feng-x commited on
Commit
c87df44
·
verified ·
1 Parent(s): 0fa84da

Upload folder using huggingface_hub

Browse files
=2.0.0 ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ WARNING: You are using pip version 21.2.4; however, version 26.0.1 is available.
2
+ You should consider upgrading via the '/Users/fengxie/Build/ring-size-cv/.venv/bin/python3 -m pip install --upgrade pip' command.
requirements.txt CHANGED
@@ -6,3 +6,4 @@ scikit-learn>=1.3.0
6
  flask>=3.0.0
7
  gunicorn>=21.2.0
8
  openai>=1.0.0
 
 
6
  flask>=3.0.0
7
  gunicorn>=21.2.0
8
  openai>=1.0.0
9
+ supabase>=2.0.0
web_demo/app.py CHANGED
@@ -6,6 +6,8 @@ Upload an image, run measurement, and return JSON + debug overlay.
6
 
7
  from __future__ import annotations
8
 
 
 
9
  import json
10
  import sys
11
  import uuid
@@ -14,7 +16,7 @@ from typing import Dict, Any
14
 
15
  import cv2
16
  import numpy as np
17
- from flask import Flask, jsonify, render_template, request, send_from_directory
18
  from werkzeug.utils import secure_filename
19
 
20
  ROOT_DIR = Path(__file__).resolve().parents[1]
@@ -23,6 +25,7 @@ sys.path.insert(0, str(ROOT_DIR))
23
  from measure_finger import measure_finger, measure_multi_finger, apply_calibration
24
  from src.ring_size import recommend_ring_size, RING_MODELS, VALID_RING_MODELS, DEFAULT_RING_MODEL
25
  from src.ai_recommendation import ai_explain_recommendation
 
26
 
27
  APP_ROOT = Path(__file__).resolve().parent
28
  UPLOAD_DIR = APP_ROOT / "uploads"
@@ -109,6 +112,7 @@ def api_measure():
109
 
110
  finger_index = request.form.get("finger_index", "index")
111
  mode = request.form.get("mode", "single")
 
112
  ring_model = request.form.get("ring_model", DEFAULT_RING_MODEL)
113
  if ring_model not in VALID_RING_MODELS:
114
  ring_model = DEFAULT_RING_MODEL
@@ -128,6 +132,9 @@ def api_measure():
128
  image=image,
129
  input_image_url=f"/uploads/{upload_name}",
130
  ring_model=ring_model,
 
 
 
131
  )
132
 
133
  return _run_measurement(
@@ -135,6 +142,9 @@ def api_measure():
135
  finger_index=finger_index,
136
  input_image_url=f"/uploads/{upload_name}",
137
  ring_model=ring_model,
 
 
 
138
  )
139
 
140
 
@@ -142,6 +152,7 @@ def api_measure():
142
  def api_measure_default():
143
  finger_index = request.form.get("finger_index", "index")
144
  mode = request.form.get("mode", "single")
 
145
  ring_model = request.form.get("ring_model", DEFAULT_RING_MODEL)
146
  if ring_model not in VALID_RING_MODELS:
147
  ring_model = DEFAULT_RING_MODEL
@@ -157,6 +168,7 @@ def api_measure_default():
157
  image=image,
158
  input_image_url=DEFAULT_SAMPLE_URL,
159
  ring_model=ring_model,
 
160
  )
161
 
162
  return _run_measurement(
@@ -164,6 +176,7 @@ def api_measure_default():
164
  finger_index=finger_index,
165
  input_image_url=DEFAULT_SAMPLE_URL,
166
  ring_model=ring_model,
 
167
  )
168
 
169
 
@@ -172,6 +185,9 @@ def _run_measurement(
172
  finger_index: str,
173
  input_image_url: str,
174
  ring_model: str = DEFAULT_RING_MODEL,
 
 
 
175
  ):
176
  run_id = uuid.uuid4().hex[:12]
177
 
@@ -204,12 +220,39 @@ def _run_measurement(
204
  result_json_path = RESULTS_DIR / result_json_name
205
  _save_json(result_json_path, result)
206
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
207
  payload = {
208
  "success": result.get("fail_reason") is None,
209
  "result": result,
210
  "result_image_url": f"/results/{result_png_name}",
211
  "input_image_url": input_image_url,
212
  "result_json_url": f"/results/{result_json_name}",
 
213
  }
214
 
215
  return jsonify(payload)
@@ -219,6 +262,9 @@ def _run_multi_measurement(
219
  image,
220
  input_image_url: str,
221
  ring_model: str = DEFAULT_RING_MODEL,
 
 
 
222
  ):
223
  """Run multi-finger measurement pipeline."""
224
  run_id = uuid.uuid4().hex[:12]
@@ -264,6 +310,37 @@ def _run_multi_measurement(
264
  result_json_path = RESULTS_DIR / result_json_name
265
  _save_json(result_json_path, result)
266
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
267
  payload = {
268
  "success": result.get("fail_reason") is None,
269
  "mode": "multi",
@@ -271,10 +348,86 @@ def _run_multi_measurement(
271
  "result_image_url": f"/results/{result_png_name}",
272
  "input_image_url": input_image_url,
273
  "result_json_url": f"/results/{result_json_name}",
 
274
  }
275
 
276
  return jsonify(payload)
277
 
278
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
279
  if __name__ == "__main__":
280
  app.run(host="0.0.0.0", port=8000, debug=True)
 
6
 
7
  from __future__ import annotations
8
 
9
+ import csv
10
+ import io
11
  import json
12
  import sys
13
  import uuid
 
16
 
17
  import cv2
18
  import numpy as np
19
+ from flask import Flask, Response, jsonify, render_template, request, send_from_directory
20
  from werkzeug.utils import secure_filename
21
 
22
  ROOT_DIR = Path(__file__).resolve().parents[1]
 
25
  from measure_finger import measure_finger, measure_multi_finger, apply_calibration
26
  from src.ring_size import recommend_ring_size, RING_MODELS, VALID_RING_MODELS, DEFAULT_RING_MODEL
27
  from src.ai_recommendation import ai_explain_recommendation
28
+ from web_demo.supabase_client import upload_file, save_measurement, list_measurements, update_ground_truth
29
 
30
  APP_ROOT = Path(__file__).resolve().parent
31
  UPLOAD_DIR = APP_ROOT / "uploads"
 
112
 
113
  finger_index = request.form.get("finger_index", "index")
114
  mode = request.form.get("mode", "single")
115
+ kol_name = request.form.get("kol_name", "").strip()
116
  ring_model = request.form.get("ring_model", DEFAULT_RING_MODEL)
117
  if ring_model not in VALID_RING_MODELS:
118
  ring_model = DEFAULT_RING_MODEL
 
132
  image=image,
133
  input_image_url=f"/uploads/{upload_name}",
134
  ring_model=ring_model,
135
+ kol_name=kol_name,
136
+ upload_path=upload_path,
137
+ upload_name=upload_name,
138
  )
139
 
140
  return _run_measurement(
 
142
  finger_index=finger_index,
143
  input_image_url=f"/uploads/{upload_name}",
144
  ring_model=ring_model,
145
+ kol_name=kol_name,
146
+ upload_path=upload_path,
147
+ upload_name=upload_name,
148
  )
149
 
150
 
 
152
  def api_measure_default():
153
  finger_index = request.form.get("finger_index", "index")
154
  mode = request.form.get("mode", "single")
155
+ kol_name = request.form.get("kol_name", "").strip()
156
  ring_model = request.form.get("ring_model", DEFAULT_RING_MODEL)
157
  if ring_model not in VALID_RING_MODELS:
158
  ring_model = DEFAULT_RING_MODEL
 
168
  image=image,
169
  input_image_url=DEFAULT_SAMPLE_URL,
170
  ring_model=ring_model,
171
+ kol_name=kol_name,
172
  )
173
 
174
  return _run_measurement(
 
176
  finger_index=finger_index,
177
  input_image_url=DEFAULT_SAMPLE_URL,
178
  ring_model=ring_model,
179
+ kol_name=kol_name,
180
  )
181
 
182
 
 
185
  finger_index: str,
186
  input_image_url: str,
187
  ring_model: str = DEFAULT_RING_MODEL,
188
+ kol_name: str = "",
189
+ upload_path: Path = None,
190
+ upload_name: str = "",
191
  ):
192
  run_id = uuid.uuid4().hex[:12]
193
 
 
220
  result_json_path = RESULTS_DIR / result_json_name
221
  _save_json(result_json_path, result)
222
 
223
+ # --- Persist to Supabase ---
224
+ photo_public_url = None
225
+ result_public_url = None
226
+ if upload_path and upload_path.exists():
227
+ photo_public_url = upload_file(str(upload_path), f"photos/{upload_name}")
228
+ if result_png_path.exists():
229
+ result_public_url = upload_file(str(result_png_path), f"results/{result_png_name}")
230
+
231
+ ring_size = result.get("ring_size", {})
232
+ measurement_id = save_measurement({
233
+ "run_id": run_id,
234
+ "kol_name": kol_name,
235
+ "mode": "single",
236
+ "ring_model": ring_model,
237
+ "finger_index": finger_index,
238
+ "diameter_cm": result.get("finger_outer_diameter_cm"),
239
+ "confidence": result.get("confidence"),
240
+ "overall_best_size": ring_size.get("best_match"),
241
+ "overall_range_min": ring_size.get("range_min"),
242
+ "overall_range_max": ring_size.get("range_max"),
243
+ "photo_url": photo_public_url,
244
+ "result_url": result_public_url,
245
+ "result_json": result,
246
+ "fail_reason": result.get("fail_reason"),
247
+ })
248
+
249
  payload = {
250
  "success": result.get("fail_reason") is None,
251
  "result": result,
252
  "result_image_url": f"/results/{result_png_name}",
253
  "input_image_url": input_image_url,
254
  "result_json_url": f"/results/{result_json_name}",
255
+ "measurement_id": measurement_id,
256
  }
257
 
258
  return jsonify(payload)
 
262
  image,
263
  input_image_url: str,
264
  ring_model: str = DEFAULT_RING_MODEL,
265
+ kol_name: str = "",
266
+ upload_path: Path = None,
267
+ upload_name: str = "",
268
  ):
269
  """Run multi-finger measurement pipeline."""
270
  run_id = uuid.uuid4().hex[:12]
 
310
  result_json_path = RESULTS_DIR / result_json_name
311
  _save_json(result_json_path, result)
312
 
313
+ # --- Persist to Supabase ---
314
+ photo_public_url = None
315
+ result_public_url = None
316
+ if upload_path and upload_path.exists():
317
+ photo_public_url = upload_file(str(upload_path), f"photos/{upload_name}")
318
+ if result_png_path.exists():
319
+ result_public_url = upload_file(str(result_png_path), f"results/{result_png_name}")
320
+
321
+ # Compute overall confidence from per-finger data
322
+ confidences = [
323
+ pf.get("confidence") for pf in per_finger.values()
324
+ if pf.get("status") == "ok" and pf.get("confidence") is not None
325
+ ]
326
+ overall_confidence = min(confidences) if confidences else None
327
+
328
+ measurement_id = save_measurement({
329
+ "run_id": run_id,
330
+ "kol_name": kol_name,
331
+ "mode": "multi",
332
+ "ring_model": ring_model,
333
+ "overall_best_size": result.get("overall_best_size"),
334
+ "overall_range_min": result.get("overall_range_min"),
335
+ "overall_range_max": result.get("overall_range_max"),
336
+ "per_finger": per_finger,
337
+ "confidence": overall_confidence,
338
+ "photo_url": photo_public_url,
339
+ "result_url": result_public_url,
340
+ "result_json": result,
341
+ "fail_reason": result.get("fail_reason"),
342
+ })
343
+
344
  payload = {
345
  "success": result.get("fail_reason") is None,
346
  "mode": "multi",
 
348
  "result_image_url": f"/results/{result_png_name}",
349
  "input_image_url": input_image_url,
350
  "result_json_url": f"/results/{result_json_name}",
351
+ "measurement_id": measurement_id,
352
  }
353
 
354
  return jsonify(payload)
355
 
356
 
357
+ # ---------------------------------------------------------------------------
358
+ # Admin routes
359
+ # ---------------------------------------------------------------------------
360
+
361
+ @app.route("/admin")
362
+ def admin_page():
363
+ return render_template("admin.html")
364
+
365
+
366
+ @app.route("/api/admin/measurements")
367
+ def api_admin_measurements():
368
+ rows = list_measurements(limit=500)
369
+ return jsonify(rows)
370
+
371
+
372
+ @app.route("/api/admin/measurements/<measurement_id>/ground-truth", methods=["POST"])
373
+ def api_admin_update_gt(measurement_id: str):
374
+ data = request.get_json(silent=True) or {}
375
+ ok = update_ground_truth(measurement_id, data)
376
+ if ok:
377
+ return jsonify({"success": True})
378
+ return jsonify({"success": False, "error": "Update failed"}), 400
379
+
380
+
381
+ @app.route("/api/admin/export-csv")
382
+ def api_admin_export_csv():
383
+ rows = list_measurements(limit=5000)
384
+ output = io.StringIO()
385
+ fieldnames = [
386
+ "kol_name", "created_at", "mode", "ring_model",
387
+ "overall_best_size", "overall_range_min", "overall_range_max",
388
+ "index_size", "index_diameter", "index_confidence",
389
+ "middle_size", "middle_diameter", "middle_confidence",
390
+ "ring_size", "ring_diameter", "ring_confidence",
391
+ "confidence", "fail_reason",
392
+ "gt_index_size", "gt_middle_size", "gt_ring_size", "ring_fit", "gt_notes",
393
+ "photo_url", "result_url", "id",
394
+ ]
395
+ writer = csv.DictWriter(output, fieldnames=fieldnames, extrasaction="ignore")
396
+ writer.writeheader()
397
+ for row in rows:
398
+ flat = {
399
+ "kol_name": row.get("kol_name", ""),
400
+ "created_at": row.get("created_at", ""),
401
+ "mode": row.get("mode", ""),
402
+ "ring_model": row.get("ring_model", ""),
403
+ "overall_best_size": row.get("overall_best_size", ""),
404
+ "overall_range_min": row.get("overall_range_min", ""),
405
+ "overall_range_max": row.get("overall_range_max", ""),
406
+ "confidence": row.get("confidence", ""),
407
+ "fail_reason": row.get("fail_reason", ""),
408
+ "gt_index_size": row.get("gt_index_size", ""),
409
+ "gt_middle_size": row.get("gt_middle_size", ""),
410
+ "gt_ring_size": row.get("gt_ring_size", ""),
411
+ "ring_fit": row.get("ring_fit", ""),
412
+ "gt_notes": row.get("gt_notes", ""),
413
+ "photo_url": row.get("photo_url", ""),
414
+ "result_url": row.get("result_url", ""),
415
+ "id": row.get("id", ""),
416
+ }
417
+ pf = row.get("per_finger") or {}
418
+ for finger in ("index", "middle", "ring"):
419
+ fd = pf.get(finger, {})
420
+ flat[f"{finger}_size"] = fd.get("best_match", "")
421
+ flat[f"{finger}_diameter"] = fd.get("diameter_cm", "")
422
+ flat[f"{finger}_confidence"] = fd.get("confidence", "")
423
+ writer.writerow(flat)
424
+
425
+ return Response(
426
+ output.getvalue(),
427
+ mimetype="text/csv",
428
+ headers={"Content-Disposition": "attachment; filename=kol_measurements.csv"},
429
+ )
430
+
431
+
432
  if __name__ == "__main__":
433
  app.run(host="0.0.0.0", port=8000, debug=True)
web_demo/static/app.js CHANGED
@@ -100,6 +100,8 @@ const RING_SIZE_TABLES = {
100
  };
101
  const RING_MODEL_LABELS = { gen: "Gen1/Gen2", air: "Air" };
102
 
 
 
103
  const buildMeasureSettings = () => {
104
  const fingerSelect = form.querySelector('select[name="finger_index"]');
105
  const aiToggle = document.getElementById("aiExplainToggle");
@@ -111,6 +113,7 @@ const buildMeasureSettings = () => {
111
  mode: mode,
112
  ring_model: ringModel,
113
  ai_explain: aiToggle && aiToggle.checked ? "1" : "0",
 
114
  };
115
  };
116
 
@@ -312,6 +315,7 @@ form.addEventListener("submit", async (event) => {
312
  formData.append("mode", settings.mode);
313
  formData.append("ring_model", settings.ring_model);
314
  formData.append("ai_explain", settings.ai_explain);
 
315
 
316
  const file = imageInput.files[0];
317
  if (file) {
 
100
  };
101
  const RING_MODEL_LABELS = { gen: "Gen1/Gen2", air: "Air" };
102
 
103
+ const kolNameInput = document.getElementById("kolNameInput");
104
+
105
  const buildMeasureSettings = () => {
106
  const fingerSelect = form.querySelector('select[name="finger_index"]');
107
  const aiToggle = document.getElementById("aiExplainToggle");
 
113
  mode: mode,
114
  ring_model: ringModel,
115
  ai_explain: aiToggle && aiToggle.checked ? "1" : "0",
116
+ kol_name: kolNameInput ? kolNameInput.value.trim() : "",
117
  };
118
  };
119
 
 
315
  formData.append("mode", settings.mode);
316
  formData.append("ring_model", settings.ring_model);
317
  formData.append("ai_explain", settings.ai_explain);
318
+ formData.append("kol_name", settings.kol_name);
319
 
320
  const file = imageInput.files[0];
321
  if (file) {
web_demo/supabase_client.py ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Supabase persistence layer for ring-size-cv web demo.
2
+
3
+ Graceful degradation: if SUPABASE_URL or SUPABASE_SERVICE_KEY env vars
4
+ are missing, all functions return None/empty and the app works without
5
+ persistence.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import os
12
+ from pathlib import Path
13
+ from typing import Any, Dict, List, Optional
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ _client = None
18
+ _initialized = False
19
+
20
+
21
+ def _get_client():
22
+ """Lazy-init Supabase client. Returns None if env vars missing."""
23
+ global _client, _initialized
24
+ if _initialized:
25
+ return _client
26
+ _initialized = True
27
+
28
+ url = os.environ.get("SUPABASE_URL", "").strip()
29
+ key = os.environ.get("SUPABASE_SERVICE_KEY", "").strip()
30
+ if not url or not key:
31
+ logger.warning("SUPABASE_URL or SUPABASE_SERVICE_KEY not set — persistence disabled")
32
+ return None
33
+
34
+ try:
35
+ from supabase import create_client
36
+ _client = create_client(url, key)
37
+ logger.info("Supabase client initialized (%s)", url)
38
+ except Exception as e:
39
+ logger.error("Failed to initialize Supabase client: %s", e)
40
+ _client = None
41
+ return _client
42
+
43
+
44
+ BUCKET = "ring-measurements"
45
+
46
+
47
+ def upload_file(local_path: str, storage_path: str) -> Optional[str]:
48
+ """Upload a file to Supabase Storage. Returns public URL or None."""
49
+ client = _get_client()
50
+ if client is None:
51
+ return None
52
+ try:
53
+ with open(local_path, "rb") as f:
54
+ data = f.read()
55
+ # Determine content type
56
+ suffix = Path(local_path).suffix.lower()
57
+ content_type = {
58
+ ".jpg": "image/jpeg",
59
+ ".jpeg": "image/jpeg",
60
+ ".png": "image/png",
61
+ }.get(suffix, "application/octet-stream")
62
+
63
+ client.storage.from_(BUCKET).upload(
64
+ storage_path,
65
+ data,
66
+ file_options={"content-type": content_type},
67
+ )
68
+ public_url = client.storage.from_(BUCKET).get_public_url(storage_path)
69
+ return public_url
70
+ except Exception as e:
71
+ logger.error("Failed to upload %s: %s", storage_path, e)
72
+ return None
73
+
74
+
75
+ def save_measurement(record: Dict[str, Any]) -> Optional[str]:
76
+ """Insert a measurement record. Returns row UUID or None."""
77
+ client = _get_client()
78
+ if client is None:
79
+ return None
80
+ try:
81
+ resp = client.table("measurements").insert(record).execute()
82
+ if resp.data and len(resp.data) > 0:
83
+ return resp.data[0].get("id")
84
+ return None
85
+ except Exception as e:
86
+ logger.error("Failed to save measurement: %s", e)
87
+ return None
88
+
89
+
90
+ def list_measurements(limit: int = 200, offset: int = 0) -> List[Dict[str, Any]]:
91
+ """Fetch measurements for admin page, newest first."""
92
+ client = _get_client()
93
+ if client is None:
94
+ return []
95
+ try:
96
+ resp = (
97
+ client.table("measurements")
98
+ .select("*")
99
+ .order("created_at", desc=True)
100
+ .range(offset, offset + limit - 1)
101
+ .execute()
102
+ )
103
+ return resp.data or []
104
+ except Exception as e:
105
+ logger.error("Failed to list measurements: %s", e)
106
+ return []
107
+
108
+
109
+ GT_ALLOWED_FIELDS = {"gt_index_size", "gt_middle_size", "gt_ring_size", "ring_fit", "gt_notes"}
110
+
111
+
112
+ def update_ground_truth(measurement_id: str, updates: Dict[str, Any]) -> bool:
113
+ """Update only ground-truth columns for a measurement."""
114
+ client = _get_client()
115
+ if client is None:
116
+ return False
117
+ # Whitelist filter
118
+ safe = {k: v for k, v in updates.items() if k in GT_ALLOWED_FIELDS}
119
+ if not safe:
120
+ return False
121
+ try:
122
+ client.table("measurements").update(safe).eq("id", measurement_id).execute()
123
+ return True
124
+ except Exception as e:
125
+ logger.error("Failed to update GT for %s: %s", measurement_id, e)
126
+ return False
web_demo/templates/admin.html ADDED
@@ -0,0 +1,215 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Ring Sizer — Admin</title>
7
+ <style>
8
+ :root {
9
+ --bg-1: #f5f1e7;
10
+ --ink: #2b1f1f;
11
+ --ink-soft: #4b3d3d;
12
+ --accent: #bf3a2b;
13
+ --sand: #f9f4ec;
14
+ --shadow: rgba(34, 26, 26, 0.12);
15
+ --border: rgba(45, 33, 33, 0.18);
16
+ }
17
+ * { box-sizing: border-box; }
18
+ body {
19
+ margin: 0; padding: 24px;
20
+ color: var(--ink);
21
+ background: var(--bg-1);
22
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
23
+ font-size: 14px;
24
+ }
25
+ h1 { font-size: 1.5rem; margin: 0 0 8px; }
26
+ .toolbar {
27
+ display: flex; gap: 12px; align-items: center;
28
+ margin-bottom: 16px; flex-wrap: wrap;
29
+ }
30
+ .toolbar a, .toolbar button {
31
+ padding: 6px 14px; border-radius: 6px; text-decoration: none;
32
+ font-size: 13px; cursor: pointer; border: 1px solid var(--border);
33
+ background: #fff; color: var(--ink);
34
+ }
35
+ .toolbar a:hover, .toolbar button:hover { background: var(--sand); }
36
+ .toolbar .count { color: var(--ink-soft); font-size: 13px; }
37
+ table {
38
+ width: 100%; border-collapse: collapse;
39
+ background: #fff; border-radius: 8px;
40
+ box-shadow: 0 1px 4px var(--shadow);
41
+ overflow: hidden;
42
+ }
43
+ th, td {
44
+ padding: 8px 10px; text-align: left;
45
+ border-bottom: 1px solid var(--border);
46
+ white-space: nowrap; font-size: 13px;
47
+ }
48
+ th { background: var(--sand); font-weight: 600; position: sticky; top: 0; z-index: 1; }
49
+ tr:hover td { background: #faf7f2; }
50
+ .thumb { width: 48px; height: 48px; object-fit: cover; border-radius: 4px; cursor: pointer; }
51
+ .fail { color: var(--accent); font-weight: 500; }
52
+ .gt-input {
53
+ width: 48px; padding: 2px 4px; border: 1px solid var(--border);
54
+ border-radius: 4px; font-size: 12px; text-align: center;
55
+ }
56
+ .gt-select {
57
+ padding: 2px 4px; border: 1px solid var(--border);
58
+ border-radius: 4px; font-size: 12px;
59
+ }
60
+ .gt-notes-input {
61
+ width: 100px; padding: 2px 4px; border: 1px solid var(--border);
62
+ border-radius: 4px; font-size: 12px;
63
+ }
64
+ .save-btn {
65
+ padding: 2px 8px; font-size: 11px; cursor: pointer;
66
+ border: 1px solid var(--accent); border-radius: 4px;
67
+ background: #fff; color: var(--accent);
68
+ }
69
+ .save-btn:hover { background: var(--accent); color: #fff; }
70
+ .save-btn.saved { border-color: #2a7; color: #2a7; }
71
+ .finger-cell { font-size: 12px; }
72
+ .finger-cell .size { font-weight: 600; }
73
+ .finger-cell .detail { color: var(--ink-soft); }
74
+ .empty { text-align: center; padding: 48px; color: var(--ink-soft); }
75
+ .scroll-wrap { overflow-x: auto; }
76
+ </style>
77
+ </head>
78
+ <body>
79
+ <h1>KOL Measurement Records</h1>
80
+ <div class="toolbar">
81
+ <a href="/">Back to Demo</a>
82
+ <a href="/api/admin/export-csv" download>Export CSV</a>
83
+ <button id="refreshBtn">Refresh</button>
84
+ <span class="count" id="countLabel">Loading...</span>
85
+ </div>
86
+
87
+ <div class="scroll-wrap">
88
+ <table>
89
+ <thead>
90
+ <tr>
91
+ <th>KOL</th>
92
+ <th>Date</th>
93
+ <th>Photo</th>
94
+ <th>Mode</th>
95
+ <th>Size</th>
96
+ <th>Range</th>
97
+ <th>Index</th>
98
+ <th>Middle</th>
99
+ <th>Ring</th>
100
+ <th>Conf</th>
101
+ <th>Fail</th>
102
+ <th>GT Idx</th>
103
+ <th>GT Mid</th>
104
+ <th>GT Ring</th>
105
+ <th>Ring Fit</th>
106
+ <th>GT Notes</th>
107
+ <th></th>
108
+ </tr>
109
+ </thead>
110
+ <tbody id="tableBody">
111
+ <tr><td colspan="17" class="empty">Loading...</td></tr>
112
+ </tbody>
113
+ </table>
114
+ </div>
115
+
116
+ <script>
117
+ const tbody = document.getElementById("tableBody");
118
+ const countLabel = document.getElementById("countLabel");
119
+
120
+ const fmtDate = (iso) => {
121
+ if (!iso) return "";
122
+ const d = new Date(iso);
123
+ return d.toLocaleDateString("en-CA") + " " + d.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
124
+ };
125
+
126
+ const fmtFinger = (pf, name) => {
127
+ if (!pf || !pf[name]) return '<span class="detail">-</span>';
128
+ const f = pf[name];
129
+ if (f.status !== "ok") return `<span class="fail">${f.fail_reason || "fail"}</span>`;
130
+ const size = f.best_match != null ? f.best_match : "-";
131
+ const diam = f.diameter_cm != null ? (f.diameter_cm * 10).toFixed(1) + "mm" : "";
132
+ const conf = f.confidence != null ? (f.confidence * 100).toFixed(0) + "%" : "";
133
+ return `<span class="size">${size}</span> <span class="detail">${diam} ${conf}</span>`;
134
+ };
135
+
136
+ const loadData = async () => {
137
+ try {
138
+ const resp = await fetch("/api/admin/measurements");
139
+ const rows = await resp.json();
140
+ countLabel.textContent = `${rows.length} records`;
141
+ if (rows.length === 0) {
142
+ tbody.innerHTML = '<tr><td colspan="17" class="empty">No measurements yet</td></tr>';
143
+ return;
144
+ }
145
+ tbody.innerHTML = rows.map((r) => {
146
+ const pf = r.per_finger || {};
147
+ const photoThumb = r.photo_url
148
+ ? `<img class="thumb" src="${r.photo_url}" onclick="window.open('${r.photo_url}')" />`
149
+ : "-";
150
+ return `<tr data-id="${r.id}">
151
+ <td><strong>${r.kol_name || "-"}</strong></td>
152
+ <td>${fmtDate(r.created_at)}</td>
153
+ <td>${photoThumb}</td>
154
+ <td>${r.mode || "-"}</td>
155
+ <td><strong>${r.overall_best_size != null ? r.overall_best_size : "-"}</strong></td>
156
+ <td>${r.overall_range_min != null ? r.overall_range_min + "–" + r.overall_range_max : "-"}</td>
157
+ <td class="finger-cell">${fmtFinger(pf, "index")}</td>
158
+ <td class="finger-cell">${fmtFinger(pf, "middle")}</td>
159
+ <td class="finger-cell">${fmtFinger(pf, "ring")}</td>
160
+ <td>${r.confidence != null ? (r.confidence * 100).toFixed(0) + "%" : "-"}</td>
161
+ <td>${r.fail_reason ? '<span class="fail">' + r.fail_reason + "</span>" : ""}</td>
162
+ <td><input class="gt-input" data-field="gt_index_size" type="number" min="4" max="15" value="${r.gt_index_size ?? ""}" /></td>
163
+ <td><input class="gt-input" data-field="gt_middle_size" type="number" min="4" max="15" value="${r.gt_middle_size ?? ""}" /></td>
164
+ <td><input class="gt-input" data-field="gt_ring_size" type="number" min="4" max="15" value="${r.gt_ring_size ?? ""}" /></td>
165
+ <td><select class="gt-select" data-field="ring_fit">
166
+ <option value="">-</option>
167
+ <option value="fits_well" ${r.ring_fit === "fits_well" ? "selected" : ""}>Fits well</option>
168
+ <option value="too_tight" ${r.ring_fit === "too_tight" ? "selected" : ""}>Too tight</option>
169
+ <option value="too_loose" ${r.ring_fit === "too_loose" ? "selected" : ""}>Too loose</option>
170
+ </select></td>
171
+ <td><input class="gt-notes-input" data-field="gt_notes" type="text" value="${r.gt_notes || ""}" placeholder="notes" /></td>
172
+ <td><button class="save-btn" onclick="saveGT(this)">Save</button></td>
173
+ </tr>`;
174
+ }).join("");
175
+ } catch (e) {
176
+ tbody.innerHTML = `<tr><td colspan="17" class="empty">Error loading data: ${e.message}</td></tr>`;
177
+ }
178
+ };
179
+
180
+ window.saveGT = async (btn) => {
181
+ const tr = btn.closest("tr");
182
+ const id = tr.dataset.id;
183
+ const data = {};
184
+ tr.querySelectorAll("[data-field]").forEach((el) => {
185
+ const field = el.dataset.field;
186
+ if (el.type === "number") {
187
+ data[field] = el.value ? parseInt(el.value, 10) : null;
188
+ } else {
189
+ data[field] = el.value || null;
190
+ }
191
+ });
192
+ try {
193
+ const resp = await fetch(`/api/admin/measurements/${id}/ground-truth`, {
194
+ method: "POST",
195
+ headers: { "Content-Type": "application/json" },
196
+ body: JSON.stringify(data),
197
+ });
198
+ const result = await resp.json();
199
+ if (result.success) {
200
+ btn.textContent = "Saved";
201
+ btn.classList.add("saved");
202
+ setTimeout(() => { btn.textContent = "Save"; btn.classList.remove("saved"); }, 2000);
203
+ } else {
204
+ alert("Save failed: " + (result.error || "unknown"));
205
+ }
206
+ } catch (e) {
207
+ alert("Network error: " + e.message);
208
+ }
209
+ };
210
+
211
+ document.getElementById("refreshBtn").addEventListener("click", loadData);
212
+ loadData();
213
+ </script>
214
+ </body>
215
+ </html>
web_demo/templates/index.html CHANGED
@@ -27,6 +27,10 @@
27
  </label>
28
 
29
  <div class="controls">
 
 
 
 
30
  <label>
31
  <span>Mode</span>
32
  <select name="mode" id="modeSelect">
 
27
  </label>
28
 
29
  <div class="controls">
30
+ <label>
31
+ <span>Your Name / ID</span>
32
+ <input type="text" name="kol_name" id="kolNameInput" placeholder="e.g. KOL-001 or your name" />
33
+ </label>
34
  <label>
35
  <span>Mode</span>
36
  <select name="mode" id="modeSelect">