""" class_labeller.py — Class Labelling Webapp ============================================ A high-performance web app for assigning class labels to crop images. Handles 14,000+ images via: • Parallel thumbnail generation (multiprocessing) • On-disk thumbnail + metadata cache (skips re-processing) • Virtual scrolling (only visible DOM nodes rendered) • Color-grouped layout so visually similar products sit together Workflow -------- 1. Run this script pointing at your data/unknown/ (or any flat image folder) 2. Browser opens — images are sorted by dominant color into groups 3. Click / Shift-click / Ctrl-click to select images 4. Type a class name (or pick existing) → Assign 5. All assigned images are MOVED to data// 6. Remaining unassigned images stay in the source folder Usage ----- python class_labeller.py \ --source data/unknown \ --data_dir data \ --port 5001 \ --workers 8 \ --thumb_size 112 """ import argparse import hashlib import json import math import multiprocessing import os import shutil import time from functools import partial from pathlib import Path import cv2 import numpy as np from flask import Flask, Response, jsonify, request, send_file # ─────────────────────────── args ──────────────────────────────────────────── def parse_args(): p = argparse.ArgumentParser() p.add_argument("--source", default="data/unknown", help="Folder of unlabelled images") p.add_argument("--data_dir", default="data", help="Root data folder — assigned images go to data//") p.add_argument("--port", type=int, default=5001) p.add_argument("--workers", type=int, default=max(1, multiprocessing.cpu_count() - 1), help="Parallel workers for thumbnail generation") p.add_argument("--thumb_size", type=int, default=112, help="Thumbnail size in pixels (square)") p.add_argument("--cache_dir", default=".label_cache", help="Where thumbnails and metadata are cached") p.add_argument("--n_colors", type=int, default=16, help="Number of color buckets for grouping") return p.parse_args() ARGS = parse_args() IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".bmp", ".webp", ".tiff"} # ─────────────────────────── color helpers ──────────────────────────────────── # Named color buckets (hue ranges in OpenCV HSV, hue 0-179) COLOR_BUCKETS = [ ( 0, 10, "Red" ), ( 10, 20, "Orange"), ( 20, 35, "Yellow"), ( 35, 80, "Green" ), ( 80, 95, "Cyan" ), ( 95, 130, "Blue" ), (130, 150, "Purple"), (150, 170, "Pink" ), (170, 180, "Red" ), # red wraps around ] def dominant_color_hsv(img_bgr: np.ndarray) -> tuple[int, int, int]: """ Return the dominant color of an image as (H, S, V) in OpenCV ranges. Strategy: resize to 32×32, convert to HSV, take the median of each channel. Fast enough to run on thousands of images without a GPU. """ small = cv2.resize(img_bgr, (32, 32), interpolation=cv2.INTER_AREA) hsv = cv2.cvtColor(small, cv2.COLOR_BGR2HSV) h = int(np.median(hsv[:, :, 0])) s = int(np.median(hsv[:, :, 1])) v = int(np.median(hsv[:, :, 2])) return h, s, v def color_bucket(h: int, s: int, v: int) -> str: """Map (H, S, V) to a human-readable color name.""" if v < 50: return "Black" if s < 40: return "White" if v > 180 else "Gray" for lo, hi, name in COLOR_BUCKETS: if lo <= h < hi: return name return "Red" BUCKET_ORDER = [ "Red","Orange","Yellow","Green","Cyan", "Blue","Purple","Pink","White","Gray","Black" ] def sort_key(meta: dict) -> tuple: """Sort key: bucket index → hue → saturation descending → value.""" b = meta["bucket"] idx = BUCKET_ORDER.index(b) if b in BUCKET_ORDER else 99 return (idx, meta["h"], -meta["s"], -meta["v"]) # ─────────────────────────── thumbnail generation ──────────────────────────── def process_one(img_path_str: str, thumb_dir: str, size: int) -> dict | None: """ Worker function (runs in subprocess): Read one image, make a thumbnail, extract dominant color. Returns metadata dict or None on failure. """ try: img_path = Path(img_path_str) img = cv2.imread(img_path_str) if img is None: return None # Thumbnail h, w = img.shape[:2] scale = size / max(h, w) th, tw = int(h * scale), int(w * scale) thumb = cv2.resize(img, (tw, th), interpolation=cv2.INTER_AREA) # Center-pad to square canvas = np.zeros((size, size, 3), dtype=np.uint8) y0 = (size - th) // 2 x0 = (size - tw) // 2 canvas[y0:y0+th, x0:x0+tw] = thumb # Save thumbnail — use a hash of the original path as filename # so re-runs are skipped cleanly key = hashlib.md5(img_path_str.encode()).hexdigest() out_path = Path(thumb_dir) / f"{key}.jpg" if not out_path.exists(): cv2.imwrite(str(out_path), canvas, [cv2.IMWRITE_JPEG_QUALITY, 82]) dh, ds, dv = dominant_color_hsv(img) bucket = color_bucket(dh, ds, dv) return { "id": key, "path": img_path_str, "name": img_path.name, "bucket": bucket, "h": dh, "s": ds, "v": dv, "w": w, "height": h, } except Exception: return None def build_metadata(source_dir: Path, cache_dir: Path, thumb_size: int, n_workers: int) -> list[dict]: """ Generate thumbnails for every image in source_dir (parallel), cache results to disk, return sorted metadata list. Re-running only processes new images. """ thumb_dir = cache_dir / "thumbs" thumb_dir.mkdir(parents=True, exist_ok=True) meta_file = cache_dir / "metadata.json" # Load existing cache cached: dict[str, dict] = {} if meta_file.exists(): try: for m in json.loads(meta_file.read_text()): cached[m["path"]] = m except Exception: pass # Find all images in source_dir all_paths = sorted( str(p) for p in source_dir.rglob("*") if p.suffix.lower() in IMAGE_EXTS ) # Only process images not in cache new_paths = [p for p in all_paths if p not in cached] if new_paths: print(f"[cache] Processing {len(new_paths)} new images with {n_workers} workers…") worker = partial(process_one, thumb_dir=str(thumb_dir), size=thumb_size) with multiprocessing.Pool(processes=n_workers) as pool: chunk = max(1, len(new_paths) // (n_workers * 4)) results = [] done = 0 for batch in [new_paths[i:i+chunk] for i in range(0, len(new_paths), chunk)]: batch_results = pool.map(worker, batch) for r in batch_results: if r: cached[r["path"]] = r done += len(batch) pct = done / len(new_paths) * 100 print(f" {done}/{len(new_paths)} ({pct:.0f}%)", end="\r", flush=True) print() else: print(f"[cache] All {len(all_paths)} images already cached.") # Keep only images that still exist in source_dir meta = [cached[p] for p in all_paths if p in cached] # Sort by color meta.sort(key=sort_key) # Persist cache meta_file.write_text(json.dumps(meta)) print(f"[cache] {len(meta)} images ready.") return meta # ─────────────────────────── Flask app ─────────────────────────────────────── flask_app = Flask(__name__) STATE: dict = {} # populated in main() @flask_app.route("/") def index(): return HTML @flask_app.route("/api/meta") def api_meta(): """ Return lightweight metadata (no pixel data). The client paginates via ?offset=&limit= """ offset = int(request.args.get("offset", 0)) limit = int(request.args.get("limit", 500)) bucket = request.args.get("bucket", "") items = STATE["meta"] if bucket and bucket != "__all__": items = [m for m in items if m["bucket"] == bucket] total = len(items) page = items[offset : offset + limit] # strip heavy fields before sending slim = [{"id":m["id"],"name":m["name"],"bucket":m["bucket"], "h":m["h"],"s":m["s"],"v":m["v"]} for m in page] return jsonify({"total": total, "offset": offset, "items": slim}) @flask_app.route("/api/buckets") def api_buckets(): counts: dict[str, int] = {} for m in STATE["meta"]: counts[m["bucket"]] = counts.get(m["bucket"], 0) + 1 ordered = [{"name": b, "count": counts[b]} for b in BUCKET_ORDER if b in counts] return jsonify(ordered) @flask_app.route("/api/classes") def api_classes(): data_dir = Path(ARGS.data_dir) classes = sorted( d.name for d in data_dir.iterdir() if d.is_dir() and not d.name.startswith("_") and d.name != "unknown" ) if data_dir.exists() else [] return jsonify(classes) @flask_app.route("/thumb/") def thumb(img_id: str): path = Path(ARGS.cache_dir) / "thumbs" / f"{img_id}.jpg" if not path.exists(): return "", 404 return send_file(str(path), mimetype="image/jpeg", max_age=86400) # cache 24 h in browser @flask_app.route("/api/assign", methods=["POST"]) def api_assign(): """ Move selected images into data//. Body: { "ids": [...], "class_name": "cola_can" } """ body = request.json ids = set(body.get("ids", [])) class_name = body["class_name"].strip().replace(" ", "_") if not class_name: return jsonify({"error": "class_name is required"}), 400 dst_dir = Path(ARGS.data_dir) / class_name dst_dir.mkdir(parents=True, exist_ok=True) moved = 0 errors = [] id_map = {m["id"]: m for m in STATE["meta"]} for img_id in ids: meta = id_map.get(img_id) if not meta: continue src = Path(meta["path"]) if not src.exists(): continue dst = dst_dir / src.name # If name collision, append a short hash suffix if dst.exists(): stem = src.stem ext = src.suffix dst = dst_dir / f"{stem}_{img_id[:6]}{ext}" try: shutil.move(str(src), str(dst)) moved += 1 except Exception as e: errors.append(str(e)) # Remove moved images from in-memory metadata STATE["meta"] = [m for m in STATE["meta"] if m["id"] not in ids] return jsonify({"moved": moved, "errors": errors, "remaining": len(STATE["meta"])}) @flask_app.route("/api/stats") def api_stats(): return jsonify({ "total": len(STATE["meta"]), "buckets": len(set(m["bucket"] for m in STATE["meta"])), }) # ─────────────────────────── HTML / JS UI ──────────────────────────────────── HTML = r""" Class Labeller

⬡ ClassLabeller

Loading…

Class Labeller

Building thumbnail cache…

""" # ─────────────────────────── main ──────────────────────────────────────────── def main(): args = ARGS source_dir = Path(args.source) cache_dir = Path(args.cache_dir) data_dir = Path(args.data_dir) if not source_dir.exists(): print(f"[error] Source directory not found: {source_dir}") print(" Create it and add your unlabelled crops, then re-run.") return data_dir.mkdir(parents=True, exist_ok=True) print(f""" ╔══════════════════════════════════════════╗ ║ Class Labeller ║ ╠══════════════════════════════════════════╣ Source : {source_dir} Data dir : {data_dir} Cache : {cache_dir} Workers : {args.workers} Thumb : {args.thumb_size}px ╚══════════════════════════════════════════╝ """) # Build / update thumbnail cache (blocking, before server starts) meta = build_metadata(source_dir, cache_dir, args.thumb_size, args.workers) STATE["meta"] = meta import threading import webbrowser url = f"http://localhost:{args.port}" def _open(): time.sleep(1.0) webbrowser.open(url) threading.Thread(target=_open, daemon=True).start() print(f"\n[server] Starting at {url}") print(" Keyboard shortcuts:") print(" Ctrl+A → select all visible") print(" Shift+click → range select") print(" Ctrl+click → toggle single") print(" Enter → assign selected") print(" Esc → clear selection") print("\n Press Ctrl+C to stop.\n") flask_app.run(host="0.0.0.0", port=args.port, threaded=True, use_reloader=False) if __name__ == "__main__": main()