470 lines
19 KiB
HTML
470 lines
19 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>Product Detection Pipeline</title>
|
|
<style>
|
|
@import url('https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Syne:wght@700;800&display=swap');
|
|
|
|
:root {
|
|
--bg: #0d0f12;
|
|
--surface: #161a20;
|
|
--border: #252a33;
|
|
--text: #dde3ee;
|
|
--muted: #5a6070;
|
|
--accent: #00e5a0;
|
|
|
|
--c-gray: #353b47;
|
|
--c-purple: #3d3489;
|
|
--c-teal: #0f6e56;
|
|
--c-coral: #993c1d;
|
|
--c-amber: #7a4f0a;
|
|
|
|
--t-gray: #b4b2a9;
|
|
--t-purple: #afa9ec;
|
|
--t-teal: #5dcaa5;
|
|
--t-coral: #f0997b;
|
|
--t-amber: #fac775;
|
|
}
|
|
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
|
|
body {
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
font-family: 'DM Mono', monospace;
|
|
min-height: 100vh;
|
|
padding: 32px 24px 48px;
|
|
}
|
|
|
|
h1 {
|
|
font-family: 'Syne', sans-serif;
|
|
font-size: 22px;
|
|
font-weight: 800;
|
|
color: var(--accent);
|
|
margin-bottom: 6px;
|
|
}
|
|
.subtitle {
|
|
font-size: 12px;
|
|
color: var(--muted);
|
|
margin-bottom: 32px;
|
|
}
|
|
|
|
/* legend */
|
|
.legend {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 16px;
|
|
margin-bottom: 28px;
|
|
font-size: 11px;
|
|
color: var(--muted);
|
|
}
|
|
.legend span { display: flex; align-items: center; gap: 6px; }
|
|
.dot { width: 10px; height: 10px; border-radius: 3px; flex-shrink: 0; }
|
|
|
|
/* pipeline wrapper */
|
|
.pipe { display: flex; flex-direction: column; align-items: center; gap: 0; }
|
|
|
|
/* node */
|
|
.node {
|
|
border-radius: 8px;
|
|
padding: 10px 20px;
|
|
text-align: center;
|
|
cursor: pointer;
|
|
transition: filter .15s, transform .15s;
|
|
border: 1px solid rgba(255,255,255,.1);
|
|
}
|
|
.node:hover { filter: brightness(1.2); transform: translateY(-2px); }
|
|
.node .label { font-size: 13px; font-weight: 500; }
|
|
.node .sub { font-size: 11px; opacity: .7; margin-top: 3px; }
|
|
|
|
.n-gray { background: var(--c-gray); color: var(--t-gray); }
|
|
.n-teal { background: var(--c-teal); color: var(--t-teal); }
|
|
.n-purple { background: var(--c-purple); color: var(--t-purple); }
|
|
.n-coral { background: var(--c-coral); color: var(--t-coral); }
|
|
.n-amber { background: var(--c-amber); color: var(--t-amber); }
|
|
|
|
/* arrow */
|
|
.arrow {
|
|
width: 1px;
|
|
background: var(--border);
|
|
position: relative;
|
|
flex-shrink: 0;
|
|
}
|
|
.arrow::after {
|
|
content: '';
|
|
position: absolute;
|
|
bottom: -1px;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
border-left: 5px solid transparent;
|
|
border-right: 5px solid transparent;
|
|
border-top: 7px solid var(--muted);
|
|
}
|
|
.arrow.h20 { height: 20px; }
|
|
.arrow.h28 { height: 28px; }
|
|
|
|
/* split row */
|
|
.split-row {
|
|
display: grid;
|
|
width: 100%;
|
|
gap: 16px;
|
|
align-items: start;
|
|
}
|
|
.split-row.two { grid-template-columns: 1fr 1fr; }
|
|
.split-row.three { grid-template-columns: 1fr 1fr 1fr; }
|
|
|
|
/* split connector line */
|
|
.split-line {
|
|
position: relative;
|
|
width: 100%;
|
|
height: 28px;
|
|
flex-shrink: 0;
|
|
}
|
|
.split-line svg { width: 100%; height: 100%; overflow: visible; }
|
|
|
|
/* merge connector */
|
|
.merge-line {
|
|
position: relative;
|
|
width: 100%;
|
|
height: 28px;
|
|
flex-shrink: 0;
|
|
}
|
|
.merge-line svg { width: 100%; height: 100%; overflow: visible; }
|
|
|
|
/* column: node + arrow stacked */
|
|
.col { display: flex; flex-direction: column; align-items: center; }
|
|
|
|
/* loop banner */
|
|
.loop-banner {
|
|
margin-top: 32px;
|
|
width: 100%;
|
|
border: 1px dashed var(--border);
|
|
border-radius: 8px;
|
|
padding: 12px 20px;
|
|
font-size: 12px;
|
|
color: var(--muted);
|
|
text-align: center;
|
|
line-height: 1.7;
|
|
}
|
|
.loop-banner strong { color: var(--accent); }
|
|
|
|
/* tooltip popup */
|
|
.tip {
|
|
display: none;
|
|
position: fixed;
|
|
top: 50%;
|
|
left: 50%;
|
|
transform: translate(-50%, -50%);
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: 10px;
|
|
padding: 20px 24px;
|
|
max-width: 380px;
|
|
width: 90vw;
|
|
z-index: 100;
|
|
font-size: 13px;
|
|
line-height: 1.7;
|
|
color: var(--text);
|
|
box-shadow: 0 24px 60px rgba(0,0,0,.6);
|
|
}
|
|
.tip.open { display: block; }
|
|
.tip h3 { font-family:'Syne',sans-serif; font-size:15px; margin-bottom:8px; color: var(--accent); }
|
|
.tip .cmd { background: var(--bg); border-radius: 6px; padding: 8px 12px; font-size: 11px; color: var(--muted); margin-top: 10px; white-space: pre; line-height: 1.6; }
|
|
.tip-close { position:absolute; top:12px; right:14px; background:none; border:none; color:var(--muted); cursor:pointer; font-size:18px; }
|
|
.overlay { display:none; position:fixed; inset:0; z-index:99; }
|
|
.overlay.open { display:block; }
|
|
|
|
/* section label */
|
|
.section-tag {
|
|
font-size: 10px;
|
|
letter-spacing: 1.2px;
|
|
text-transform: uppercase;
|
|
color: var(--muted);
|
|
margin-bottom: 8px;
|
|
align-self: flex-start;
|
|
width: 100%;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<h1>Product Detection Pipeline</h1>
|
|
<p class="subtitle">Click any block to see details and the command that runs it.</p>
|
|
|
|
<div class="legend">
|
|
<span><span class="dot" style="background:var(--c-gray)"></span> Data</span>
|
|
<span><span class="dot" style="background:var(--c-teal)"></span> Models</span>
|
|
<span><span class="dot" style="background:var(--c-purple)"></span> Scripts</span>
|
|
<span><span class="dot" style="background:var(--c-coral)"></span> Deploy</span>
|
|
<span><span class="dot" style="background:var(--c-amber)"></span> Manual step</span>
|
|
</div>
|
|
|
|
<div class="pipe" id="pipe">
|
|
|
|
<!-- source -->
|
|
<div class="node n-gray" style="width:220px" onclick="show('src')">
|
|
<div class="label">Shelf images</div>
|
|
<div class="sub">jpg / png / mp4 / webcam</div>
|
|
</div>
|
|
<div class="arrow h28"></div>
|
|
|
|
<!-- detector -->
|
|
<div class="node n-teal" style="width:280px" onclick="show('det')">
|
|
<div class="label">YOLOv8n detector</div>
|
|
<div class="sub">Detect & crop every product</div>
|
|
</div>
|
|
|
|
<!-- fork label -->
|
|
<div class="split-line">
|
|
<svg viewBox="0 0 600 28" preserveAspectRatio="none">
|
|
<line x1="300" y1="0" x2="300" y2="28" stroke="#252a33" stroke-width="1"/>
|
|
<line x1="150" y1="28" x2="450" y2="28" stroke="#252a33" stroke-width="1"/>
|
|
<line x1="150" y1="28" x2="150" y2="28" stroke="#252a33" stroke-width="1"/>
|
|
<line x1="450" y1="28" x2="450" y2="28" stroke="#252a33" stroke-width="1"/>
|
|
<polygon points="146,24 154,24 150,29" fill="#5a6070"/>
|
|
<polygon points="446,24 454,24 450,29" fill="#5a6070"/>
|
|
<text x="140" y="18" text-anchor="middle" fill="#5a6070" font-family="DM Mono,monospace" font-size="10">Path A — first run</text>
|
|
<text x="462" y="18" text-anchor="middle" fill="#5a6070" font-family="DM Mono,monospace" font-size="10">Path B — iterative</text>
|
|
</svg>
|
|
</div>
|
|
|
|
<!-- path A / B -->
|
|
<div class="split-row two" style="max-width:640px; width:100%">
|
|
|
|
<!-- PATH A -->
|
|
<div class="col">
|
|
<div class="node n-purple" style="width:100%" onclick="show('gen')">
|
|
<div class="label">1_generate_crop_dataset.py</div>
|
|
<div class="sub">Crop only, no classifier</div>
|
|
</div>
|
|
<div class="arrow h20"></div>
|
|
<div class="node n-amber" style="width:100%" onclick="show('manual')">
|
|
<div class="label">Manual labelling</div>
|
|
<div class="sub">Move crops to class folders</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- PATH B -->
|
|
<div class="col">
|
|
<div class="node n-purple" style="width:100%" onclick="show('auto')">
|
|
<div class="label">auto_label.py</div>
|
|
<div class="sub">Detect + auto-classify</div>
|
|
</div>
|
|
<div class="arrow h20"></div>
|
|
<div class="node n-coral" style="width:100%" onclick="show('review')">
|
|
<div class="label">Browser review UI</div>
|
|
<div class="sub">Confirm / fix / reject</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- merge -->
|
|
<div class="merge-line">
|
|
<svg viewBox="0 0 600 28" preserveAspectRatio="none">
|
|
<line x1="150" y1="0" x2="150" y2="14" stroke="#252a33" stroke-width="1"/>
|
|
<line x1="450" y1="0" x2="450" y2="14" stroke="#252a33" stroke-width="1"/>
|
|
<line x1="150" y1="14" x2="450" y2="14" stroke="#252a33" stroke-width="1"/>
|
|
<line x1="300" y1="14" x2="300" y2="28" stroke="#252a33" stroke-width="1"/>
|
|
<polygon points="296,24 304,24 300,29" fill="#5a6070"/>
|
|
</svg>
|
|
</div>
|
|
|
|
<!-- data/ folder -->
|
|
<div class="node n-gray" style="width:240px" onclick="show('data')">
|
|
<div class="label">data/</div>
|
|
<div class="sub">Labelled crops by class folder</div>
|
|
</div>
|
|
<div class="arrow h28"></div>
|
|
|
|
<!-- splitter -->
|
|
<div class="node n-purple" style="width:280px" onclick="show('split')">
|
|
<div class="label">1b_split_dataset.py</div>
|
|
<div class="sub">Stratified train / val / test</div>
|
|
</div>
|
|
<div class="arrow h20"></div>
|
|
|
|
<!-- crops_dataset -->
|
|
<div class="node n-gray" style="width:240px" onclick="show('crops')">
|
|
<div class="label">crops_dataset/</div>
|
|
<div class="sub">train / val / test</div>
|
|
</div>
|
|
|
|
<!-- fork to local / kaggle -->
|
|
<div class="split-line">
|
|
<svg viewBox="0 0 600 28" preserveAspectRatio="none">
|
|
<line x1="300" y1="0" x2="300" y2="28" stroke="#252a33" stroke-width="1"/>
|
|
<line x1="150" y1="28" x2="450" y2="28" stroke="#252a33" stroke-width="1"/>
|
|
<polygon points="146,24 154,24 150,29" fill="#5a6070"/>
|
|
<polygon points="446,24 454,24 450,29" fill="#5a6070"/>
|
|
<text x="140" y="18" text-anchor="middle" fill="#5a6070" font-family="DM Mono,monospace" font-size="10">Local GPU / CPU</text>
|
|
<text x="462" y="18" text-anchor="middle" fill="#5a6070" font-family="DM Mono,monospace" font-size="10">Free cloud GPU</text>
|
|
</svg>
|
|
</div>
|
|
|
|
<div class="split-row two" style="max-width:640px; width:100%">
|
|
<div class="col">
|
|
<div class="node n-purple" style="width:100%" onclick="show('train')">
|
|
<div class="label">2_train_classifier.py</div>
|
|
<div class="sub">EfficientNet-B0 local</div>
|
|
</div>
|
|
</div>
|
|
<div class="col">
|
|
<div class="node n-purple" style="width:100%" onclick="show('kaggle')">
|
|
<div class="label">Kaggle notebook</div>
|
|
<div class="sub">T4 GPU — free tier</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- merge to weights -->
|
|
<div class="merge-line">
|
|
<svg viewBox="0 0 600 28" preserveAspectRatio="none">
|
|
<line x1="150" y1="0" x2="150" y2="14" stroke="#252a33" stroke-width="1"/>
|
|
<line x1="450" y1="0" x2="450" y2="14" stroke="#252a33" stroke-width="1"/>
|
|
<line x1="150" y1="14" x2="450" y2="14" stroke="#252a33" stroke-width="1"/>
|
|
<line x1="300" y1="14" x2="300" y2="28" stroke="#252a33" stroke-width="1"/>
|
|
<polygon points="296,24 304,24 300,29" fill="#5a6070"/>
|
|
</svg>
|
|
</div>
|
|
|
|
<!-- classifier weights -->
|
|
<div class="node n-teal" style="width:280px" onclick="show('weights')">
|
|
<div class="label">Classifier weights</div>
|
|
<div class="sub">best.pt + class_names.json</div>
|
|
</div>
|
|
|
|
<!-- fork to inference / api -->
|
|
<div class="split-line">
|
|
<svg viewBox="0 0 600 28" preserveAspectRatio="none">
|
|
<line x1="300" y1="0" x2="300" y2="28" stroke="#252a33" stroke-width="1"/>
|
|
<line x1="150" y1="28" x2="450" y2="28" stroke="#252a33" stroke-width="1"/>
|
|
<polygon points="146,24 154,24 150,29" fill="#5a6070"/>
|
|
<polygon points="446,24 454,24 450,29" fill="#5a6070"/>
|
|
</svg>
|
|
</div>
|
|
|
|
<div class="split-row two" style="max-width:640px; width:100%">
|
|
<div class="col">
|
|
<div class="node n-coral" style="width:100%" onclick="show('inf')">
|
|
<div class="label">3_inference.py</div>
|
|
<div class="sub">Images · video · webcam</div>
|
|
</div>
|
|
</div>
|
|
<div class="col">
|
|
<div class="node n-coral" style="width:100%" onclick="show('api')">
|
|
<div class="label">api_server.py</div>
|
|
<div class="sub">REST + polling API</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- loop -->
|
|
<div class="loop-banner">
|
|
↻ <strong>Iterative loop</strong> — shoot new shelf photos → run <strong>auto_label.py</strong> → review → retrain → better auto-labelling next time
|
|
</div>
|
|
|
|
</div><!-- /pipe -->
|
|
|
|
<!-- overlay -->
|
|
<div class="overlay" id="overlay" onclick="close_tip()"></div>
|
|
|
|
<!-- tooltip -->
|
|
<div class="tip" id="tip">
|
|
<button class="tip-close" onclick="close_tip()">✕</button>
|
|
<h3 id="tip-title"></h3>
|
|
<div id="tip-body"></div>
|
|
</div>
|
|
|
|
<script>
|
|
const INFO = {
|
|
src: {
|
|
title: "Shelf images",
|
|
body: `Any image file, folder of images, video, or webcam stream.<br>Supported: jpg, jpeg, png, bmp, webp, tiff, mp4, avi, mov.<br>Pass a single file or an entire folder — the scripts handle both.`,
|
|
cmd: null
|
|
},
|
|
det: {
|
|
title: "YOLOv8n detector",
|
|
body: `Your fine-tuned YOLOv8n model runs on each frame and outputs bounding boxes for every product detected. Each box is cropped out (with configurable padding) and passed to the next step.`,
|
|
cmd: `# Used internally by all pipeline scripts\n# Fine-tuned weights live at: detector/best.pt`
|
|
},
|
|
gen: {
|
|
title: "1_generate_crop_dataset.py",
|
|
body: `<b>Path A — first time only.</b><br>Runs YOLOv8n on your images and saves every crop into <code>data/unknown/</code>. No classifier is used here. You then manually move crops into named class folders before splitting.`,
|
|
cmd: `python 1_generate_crop_dataset.py \\\n --source shelf_images/ \\\n --weights detector/best.pt \\\n --output_dir data \\\n --conf 0.25 \\\n --padding 10`
|
|
},
|
|
manual: {
|
|
title: "Manual labelling",
|
|
body: `After script 1, all crops land in <code>data/unknown/</code>. You move them into named subfolders:<br><br><code>data/cola_can/ crop001.jpg<br>data/pepsi_can/ crop002.jpg</code><br><br>Use any file manager or Label Studio. Only needed the first time.`,
|
|
cmd: null
|
|
},
|
|
auto: {
|
|
title: "auto_label.py",
|
|
body: `<b>Path B — iterative (recommended).</b><br>Runs YOLOv8n + the classifier together, proposes a label for every crop, and opens a browser review UI. Much faster than manual labelling after the first model is trained.`,
|
|
cmd: `python auto_label.py \\\n --source shelf_images/ \\\n --detector_weights detector/best.pt \\\n --classifier_weights runs/classify/best.pt \\\n --output_dir data \\\n --cls_conf 0.50 \\\n --port 5000`
|
|
},
|
|
review: {
|
|
title: "Browser review UI",
|
|
body: `Opens at <code>http://localhost:5000</code>. For each crop you see:<br>• The top-3 class predictions with confidence<br>• One-click confirm, reject, or reclassify<br>• Ctrl+click for multi-select bulk actions<br>• Confidence slider to hide uncertain crops<br><br><b>Keyboard:</b> C = confirm · R = reject · ← → = navigate`,
|
|
cmd: null
|
|
},
|
|
data: {
|
|
title: "data/ folder",
|
|
body: `The labelled dataset root. Each subfolder is one class.<br><br><code>data/<br> cola_can/<br> pepsi_can/<br> lays_chips/<br> _rejected_/ ← from review UI<br> _unreviewed_/</code><br><br>Every <code>auto_label.py</code> run on new images <b>adds</b> to this folder — it never overwrites existing crops.`,
|
|
cmd: null
|
|
},
|
|
split: {
|
|
title: "1b_split_dataset.py",
|
|
body: `Reads <code>data/</code> and produces a stratified train / val / test split — every class gets the same ratio across all splits, so rare classes are never accidentally lost from val or test.<br><br>Automatically skips <code>_rejected_/</code>, <code>_unreviewed_/</code>, <code>unknown/</code>, and any folder starting with <code>_</code>.`,
|
|
cmd: `python 1b_split_dataset.py \\\n --data_dir data \\\n --output_dir crops_dataset \\\n --val_split 0.15 \\\n --test_split 0.05`
|
|
},
|
|
crops: {
|
|
title: "crops_dataset/",
|
|
body: `The split dataset, ready for training.<br><br><code>crops_dataset/<br> train/ cola_can/ pepsi_can/ …<br> val/ cola_can/ pepsi_can/ …<br> test/ cola_can/ pepsi_can/ …</code><br><br>Aim for ≥ 50 train images and ≥ 10 val images per class before training.`,
|
|
cmd: null
|
|
},
|
|
train: {
|
|
title: "2_train_classifier.py",
|
|
body: `Trains EfficientNet-B0 (ImageNet pre-trained) on <code>crops_dataset/</code>.<br>• <b>WeightedRandomSampler</b> handles class imbalance automatically<br>• <b>AMP</b> (--amp) for faster GPU training<br>• <b>Early stopping</b> with configurable patience<br>• Saves best.pt, last.pt, class_names.json, training curves`,
|
|
cmd: `python 2_train_classifier.py \\\n --data_dir crops_dataset \\\n --model efficientnet_b0 \\\n --epochs 50 \\\n --batch_size 64 \\\n --amp`
|
|
},
|
|
kaggle: {
|
|
title: "Kaggle notebook",
|
|
body: `<code>train_classifier_kaggle.ipynb</code> — same training logic as the script but runs on Kaggle's free T4 GPU.<br><br>Extra cells: dataset diagnostics, live training curves, per-class accuracy bar chart, confusion matrix.<br><br>Download <code>best.pt</code> + <code>class_names.json</code> and place them in <code>runs/classify/</code>.`,
|
|
cmd: `# On Kaggle:\n# 1. Upload crops_dataset/ as a Kaggle Dataset\n# 2. Attach it to the notebook\n# 3. Set DATA_DIR in Cell 2\n# 4. Runtime → GPU T4 → Run All`
|
|
},
|
|
weights: {
|
|
title: "Classifier weights",
|
|
body: `Two files are all you need for inference and the API:<br><br><code>runs/classify/best.pt</code> — model weights<br><code>runs/classify/class_names.json</code> — index→class mapping<br><br>The dynamic batch sizer probes available VRAM at startup and picks the optimal batch size automatically.`,
|
|
cmd: null
|
|
},
|
|
inf: {
|
|
title: "3_inference.py",
|
|
body: `Full detect → classify pipeline on any source. Draws colour-coded bounding boxes with class name + confidence. Batch size is auto-calibrated to VRAM — handles 260+ products per frame safely.`,
|
|
cmd: `python 3_inference.py \\\n --detector_weights detector/best.pt \\\n --classifier_weights runs/classify/best.pt \\\n --source shelf_images/ \\\n --det_conf 0.30 \\\n --cls_conf 0.65 \\\n --show`
|
|
},
|
|
api: {
|
|
title: "api_server.py",
|
|
body: `FastAPI server with a polling pattern:<br>1. <code>POST /jobs</code> — submit base64 image + class filter → get <code>job_id</code><br>2. <code>GET /jobs/{job_id}</code> — poll every 3s until <code>status: "done"</code><br>3. Response includes annotated image (base64), counts per class, and per-detection bboxes.`,
|
|
cmd: `uvicorn api_server:app \\\n --host 0.0.0.0 --port 8000 --workers 1\n\n# Env vars:\nDETECTOR_WEIGHTS=detector/best.pt\nCLASSIFIER_WEIGHTS=runs/classify/best.pt`
|
|
}
|
|
};
|
|
|
|
function show(key) {
|
|
const d = INFO[key];
|
|
document.getElementById('tip-title').textContent = d.title;
|
|
document.getElementById('tip-body').innerHTML =
|
|
d.body + (d.cmd ? `<div class="cmd">${d.cmd}</div>` : '');
|
|
document.getElementById('tip').classList.add('open');
|
|
document.getElementById('overlay').classList.add('open');
|
|
}
|
|
function close_tip() {
|
|
document.getElementById('tip').classList.remove('open');
|
|
document.getElementById('overlay').classList.remove('open');
|
|
}
|
|
document.addEventListener('keydown', e => { if (e.key === 'Escape') close_tip(); });
|
|
</script>
|
|
</body>
|
|
</html>
|