Build a local dashboard for indexing and labeling
Wrap indexing, labeling, threshold tuning and export in one local browser page, and handle the threading traps that come with it.
Before you start
- a working index and the correction loop from earlier guides
- flask installed
Command line flags are fine until you are labeling four hundred photos. This
guide wraps everything in a single Flask page on 127.0.0.1, which turns
labeling from a chore into something you can do in ten minutes.
What the page needs to do#
Index a folder with a progress bar. Manage several concepts. Show a grid of photos to label. Sweep thresholds. Export matches. All without a build step, a framework, or a node_modules directory.
That last constraint is worth defending. This is a local tool for one user. A single Python file serving a single HTML string has no install, no version skew, and no reason to ever break. Reach for a frontend framework here and you have signed up for maintenance you will not enjoy.
Serving state to the page#
from flask import Flask, request, jsonify, Response
app = Flask(__name__)
@app.get("/api/state")
def state():
db = load_db()
return jsonify({
"images": len(IDX.paths),
"job": JOB,
"concepts": [Concept(n).summary() for n in sorted(db)],
})The page polls this. One endpoint returning everything means the UI never has to coordinate several requests to render a consistent view.
Serving thumbnails#
THUMBS = {}
def thumb(path):
if path in THUMBS:
return THUMBS[path]
im = Image.open(path).convert("RGB")
im.thumbnail((360, 360))
buf = io.BytesIO()
im.save(buf, "JPEG", quality=80)
data = buf.getvalue()
if len(THUMBS) < 5000:
THUMBS[path] = data
return data
@app.get("/img/<int:i>")
def img(i):
try:
return Response(thumb(IDX.paths[i]), mimetype="image/jpeg")
except Exception:
return Response(status=404)Indexing without freezing the page#
import threading, time
JOB = {"running": False, "done": 0, "total": 0, "msg": "idle", "stage": ""}
@app.post("/api/index")
def start_index():
if JOB["running"]:
return jsonify({"error": "already running"}), 409
folder = request.get_json()["folder"]
threading.Thread(target=index_folder, args=(folder,), daemon=True).start()
time.sleep(0.2)
return jsonify({"ok": True})The indexing function updates JOB as it goes, and the page polls
/api/state every 900ms to move the progress bar.
The folder picker trap#
A browser cannot give you an absolute folder path. That is a security boundary and there is no way around it from the page. But the server is on the same machine, so it can open the real OS dialog.
PICKER_SCRIPT = (
"import tkinter as tk\n"
"from tkinter import filedialog\n"
"r = tk.Tk()\n"
"r.withdraw()\n"
"r.attributes('-topmost', True)\n"
"p = filedialog.askdirectory(title='Choose a folder to index')\n"
"print(p or '')\n"
)
def pick_folder():
import subprocess, sys
r = subprocess.run([sys.executable, "-c", PICKER_SCRIPT],
capture_output=True, text=True, timeout=300)
path = (r.stdout or "").strip().splitlines()
return {"path": path[-1].strip()} if path and path[-1].strip() else {"cancelled": True}The click interaction that matters#
Labeling is the thing you do hundreds of times, so the interaction deserves thought.
const set = (v) => { marks[it.i] = v;
c.className = 'thumb' + (v === 'none' ? '' : ' ' + v); };
c.onclick = () => set({undefined:'good', none:'good',
good:'bad', bad:'none'}[marks[it.i]]);
c.oncontextmenu = (e) => { e.preventDefault();
set(marks[it.i] === 'bad' ? 'none' : 'bad'); };Shelling out from a web request, carefully#
If you do add a feature that runs a system command, never accept a path from the page.
def resolve_target(kind, i):
if kind == "image" and 0 <= i < len(IDX.paths):
return IDX.paths[i]
return NoneThe endpoint takes an index into data the server already has. Out of range, negative, and unknown kinds all return nothing before touching a shell.
Also know that Flask binds to 127.0.0.1 by default, so this is not reachable
from your network. If you ever change that to 0.0.0.0 to use the tool from
your phone, you have exposed a command trigger to your LAN. That may be an
acceptable trade on a home network, but make it deliberately.
One warning about Windows paths#
If you shell out to Explorer, this looks right and is wrong:
subprocess.run(["explorer", f"/select,{p}"]) # breaks on spacesWindows argument parsing mangles it the moment the path contains a space, and Explorer silently falls back to opening your Documents folder. The quoted form works:
subprocess.run(f'explorer /select,"{p}"', shell=True)Also note explorer returns a nonzero exit code even on success, so
check=True will raise on a call that worked fine.
Export that is safe to re-run#
tgt = dest / src.name
if tgt.exists() and tgt.stat().st_size == src.stat().st_size:
skipped += 1
continueChecking size before copying makes export idempotent. Bump your threshold,
export again, and only the new matches get written. Without this, every
re-export duplicates the entire set with _1 suffixes.
Where this stops being the right approach#
Flask's development server is single threaded by default and explicitly not for production. That is fine for one user on localhost and wrong for anything else. If you ever want this on a real network, put a proper WSGI server in front and add authentication, because right now anyone who can reach the port can read any indexed file.
Next: finding people, where CLIP fails completely and a different model takes over.