feng-x commited on
Commit
405e42a
·
verified ·
1 Parent(s): c87df44

Upload folder using huggingface_hub

Browse files
web_demo/app.py CHANGED
@@ -9,6 +9,7 @@ from __future__ import annotations
9
  import csv
10
  import io
11
  import json
 
12
  import sys
13
  import uuid
14
  from pathlib import Path
@@ -25,7 +26,7 @@ sys.path.insert(0, str(ROOT_DIR))
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"
@@ -358,19 +359,31 @@ def _run_multi_measurement(
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:
@@ -378,8 +391,20 @@ def api_admin_update_gt(measurement_id: str):
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 = [
@@ -388,8 +413,8 @@ def api_admin_export_csv():
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")
@@ -405,10 +430,12 @@ def api_admin_export_csv():
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", ""),
 
9
  import csv
10
  import io
11
  import json
12
+ import os
13
  import sys
14
  import uuid
15
  from pathlib import Path
 
26
  from measure_finger import measure_finger, measure_multi_finger, apply_calibration
27
  from src.ring_size import recommend_ring_size, RING_MODELS, VALID_RING_MODELS, DEFAULT_RING_MODEL
28
  from src.ai_recommendation import ai_explain_recommendation
29
+ from web_demo.supabase_client import upload_file, save_measurement, list_measurements, update_ground_truth, delete_measurement
30
 
31
  APP_ROOT = Path(__file__).resolve().parent
32
  UPLOAD_DIR = APP_ROOT / "uploads"
 
359
  # Admin routes
360
  # ---------------------------------------------------------------------------
361
 
362
+ ADMIN_TOKEN = os.environ.get("ADMIN_TOKEN", "ringsizer2026")
363
+
364
+
365
  @app.route("/admin")
366
  def admin_page():
367
  return render_template("admin.html")
368
 
369
 
370
+ def _check_admin_token():
371
+ token = request.args.get("token") or request.headers.get("X-Admin-Token")
372
+ return token == ADMIN_TOKEN
373
+
374
+
375
  @app.route("/api/admin/measurements")
376
  def api_admin_measurements():
377
+ if not _check_admin_token():
378
+ return jsonify({"error": "Unauthorized"}), 401
379
  rows = list_measurements(limit=500)
380
  return jsonify(rows)
381
 
382
 
383
  @app.route("/api/admin/measurements/<measurement_id>/ground-truth", methods=["POST"])
384
  def api_admin_update_gt(measurement_id: str):
385
+ if not _check_admin_token():
386
+ return jsonify({"error": "Unauthorized"}), 401
387
  data = request.get_json(silent=True) or {}
388
  ok = update_ground_truth(measurement_id, data)
389
  if ok:
 
391
  return jsonify({"success": False, "error": "Update failed"}), 400
392
 
393
 
394
+ @app.route("/api/admin/measurements/<measurement_id>", methods=["DELETE"])
395
+ def api_admin_delete(measurement_id: str):
396
+ if not _check_admin_token():
397
+ return jsonify({"error": "Unauthorized"}), 401
398
+ ok = delete_measurement(measurement_id)
399
+ if ok:
400
+ return jsonify({"success": True})
401
+ return jsonify({"success": False, "error": "Delete failed"}), 400
402
+
403
+
404
  @app.route("/api/admin/export-csv")
405
  def api_admin_export_csv():
406
+ if not _check_admin_token():
407
+ return jsonify({"error": "Unauthorized"}), 401
408
  rows = list_measurements(limit=5000)
409
  output = io.StringIO()
410
  fieldnames = [
 
413
  "index_size", "index_diameter", "index_confidence",
414
  "middle_size", "middle_diameter", "middle_confidence",
415
  "ring_size", "ring_diameter", "ring_confidence",
416
+ "confidence", "fail_reason", "ai_explanation",
417
+ "ring_fit", "gt_best_finger", "gt_index_size", "gt_middle_size", "gt_ring_size", "gt_notes",
418
  "photo_url", "result_url", "id",
419
  ]
420
  writer = csv.DictWriter(output, fieldnames=fieldnames, extrasaction="ignore")
 
430
  "overall_range_max": row.get("overall_range_max", ""),
431
  "confidence": row.get("confidence", ""),
432
  "fail_reason": row.get("fail_reason", ""),
433
+ "ai_explanation": (row.get("result_json") or {}).get("ai_explanation", ""),
434
+ "ring_fit": row.get("ring_fit", ""),
435
+ "gt_best_finger": row.get("gt_best_finger", ""),
436
  "gt_index_size": row.get("gt_index_size", ""),
437
  "gt_middle_size": row.get("gt_middle_size", ""),
438
  "gt_ring_size": row.get("gt_ring_size", ""),
 
439
  "gt_notes": row.get("gt_notes", ""),
440
  "photo_url": row.get("photo_url", ""),
441
  "result_url": row.get("result_url", ""),
web_demo/static/app.js CHANGED
@@ -309,6 +309,11 @@ form.addEventListener("submit", async (event) => {
309
  event.preventDefault();
310
 
311
  const settings = buildMeasureSettings();
 
 
 
 
 
312
  const formData = new FormData();
313
  formData.append("finger_index", settings.finger_index);
314
  formData.append("edge_method", settings.edge_method);
 
309
  event.preventDefault();
310
 
311
  const settings = buildMeasureSettings();
312
+ if (!settings.kol_name) {
313
+ setStatus("Please enter your Name / ID before measuring.");
314
+ kolNameInput.focus();
315
+ return;
316
+ }
317
  const formData = new FormData();
318
  formData.append("finger_index", settings.finger_index);
319
  formData.append("edge_method", settings.edge_method);
web_demo/static/examples/default_sample.jpg CHANGED

Git LFS Details

  • SHA256: 1262e998f9e465492be2cb595ad04a0450c7bea5e37a33eeb28ff7a056c50261
  • Pointer size: 132 Bytes
  • Size of remote file: 1.62 MB

Git LFS Details

  • SHA256: e81309483f80a0ea66a71752f446b7cd00310f43d52610fe4ff40a8a595dbf6e
  • Pointer size: 132 Bytes
  • Size of remote file: 1.96 MB
web_demo/static/styles.css CHANGED
@@ -148,13 +148,15 @@ body {
148
  color: var(--ink-soft);
149
  }
150
 
151
- select {
 
152
  border: 1px solid var(--border);
153
  border-radius: 12px;
154
  padding: 10px 12px;
155
  font-size: 0.95rem;
156
  background: white;
157
  color: var(--ink);
 
158
  }
159
 
160
  .primary {
 
148
  color: var(--ink-soft);
149
  }
150
 
151
+ select,
152
+ .controls input[type="text"] {
153
  border: 1px solid var(--border);
154
  border-radius: 12px;
155
  padding: 10px 12px;
156
  font-size: 0.95rem;
157
  background: white;
158
  color: var(--ink);
159
+ width: 100%;
160
  }
161
 
162
  .primary {
web_demo/supabase_client.py CHANGED
@@ -60,15 +60,17 @@ def upload_file(local_path: str, storage_path: str) -> Optional[str]:
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
 
@@ -106,7 +108,20 @@ def list_measurements(limit: int = 200, offset: int = 0) -> List[Dict[str, Any]]
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:
 
60
  ".png": "image/png",
61
  }.get(suffix, "application/octet-stream")
62
 
63
+ resp = client.storage.from_(BUCKET).upload(
64
  storage_path,
65
  data,
66
  file_options={"content-type": content_type},
67
  )
68
+ logger.info("Storage upload %s: %s bytes, response=%s", storage_path, len(data), resp)
69
  public_url = client.storage.from_(BUCKET).get_public_url(storage_path)
70
  return public_url
71
  except Exception as e:
72
+ logger.error("Failed to upload %s (%s bytes): %s", storage_path,
73
+ os.path.getsize(local_path) if os.path.exists(local_path) else "missing", e)
74
  return None
75
 
76
 
 
108
  return []
109
 
110
 
111
+ def delete_measurement(measurement_id: str) -> bool:
112
+ """Delete a measurement record by ID."""
113
+ client = _get_client()
114
+ if client is None:
115
+ return False
116
+ try:
117
+ client.table("measurements").delete().eq("id", measurement_id).execute()
118
+ return True
119
+ except Exception as e:
120
+ logger.error("Failed to delete %s: %s", measurement_id, e)
121
+ return False
122
+
123
+
124
+ GT_ALLOWED_FIELDS = {"gt_index_size", "gt_middle_size", "gt_ring_size", "ring_fit", "gt_best_finger", "gt_notes"}
125
 
126
 
127
  def update_ground_truth(measurement_id: str, updates: Dict[str, Any]) -> bool:
web_demo/templates/admin.html CHANGED
@@ -23,6 +23,24 @@
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;
@@ -63,11 +81,18 @@
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); }
@@ -76,47 +101,102 @@
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);
@@ -135,7 +215,14 @@
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) {
@@ -153,23 +240,30 @@
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) {
@@ -190,7 +284,7 @@
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),
@@ -208,8 +302,28 @@
208
  }
209
  };
210
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
211
  document.getElementById("refreshBtn").addEventListener("click", loadData);
212
- loadData();
213
  </script>
214
  </body>
215
  </html>
 
23
  font-size: 14px;
24
  }
25
  h1 { font-size: 1.5rem; margin: 0 0 8px; }
26
+ /* --- Login gate --- */
27
+ .login-gate {
28
+ max-width: 320px; margin: 120px auto; text-align: center;
29
+ }
30
+ .login-gate h1 { margin-bottom: 16px; }
31
+ .login-gate input {
32
+ width: 100%; padding: 10px 12px; border: 1px solid var(--border);
33
+ border-radius: 8px; font-size: 14px; margin-bottom: 12px;
34
+ }
35
+ .login-gate button {
36
+ width: 100%; padding: 10px; border: none; border-radius: 8px;
37
+ background: var(--accent); color: #fff; font-size: 14px; cursor: pointer;
38
+ }
39
+ .login-gate button:hover { opacity: 0.9; }
40
+ .login-gate .error { color: var(--accent); font-size: 13px; margin-top: 8px; }
41
+ .login-gate .back { display: inline-block; margin-top: 16px; color: var(--ink-soft); font-size: 13px; text-decoration: none; }
42
+ /* --- Admin content --- */
43
+ .admin-content { display: none; }
44
  .toolbar {
45
  display: flex; gap: 12px; align-items: center;
46
  margin-bottom: 16px; flex-wrap: wrap;
 
81
  }
82
  .save-btn {
83
  padding: 2px 8px; font-size: 11px; cursor: pointer;
84
+ border: 1px solid #2a7; border-radius: 4px;
85
+ background: #fff; color: #2a7;
86
  }
87
+ .save-btn:hover { background: #2a7; color: #fff; }
88
  .save-btn.saved { border-color: #2a7; color: #2a7; }
89
+ .del-btn {
90
+ padding: 2px 8px; font-size: 11px; cursor: pointer;
91
+ border: 1px solid var(--accent); border-radius: 4px;
92
+ background: #fff; color: var(--accent); margin-left: 4px;
93
+ }
94
+ .del-btn:hover { background: var(--accent); color: #fff; }
95
+ .ai-peek { color: var(--accent); cursor: pointer; text-decoration: underline; font-size: 12px; }
96
  .finger-cell { font-size: 12px; }
97
  .finger-cell .size { font-weight: 600; }
98
  .finger-cell .detail { color: var(--ink-soft); }
 
101
  </style>
102
  </head>
103
  <body>
104
+
105
+ <!-- Login gate -->
106
+ <div class="login-gate" id="loginGate">
107
+ <h1>Admin Access</h1>
108
+ <form id="loginForm">
109
+ <input type="password" id="tokenInput" placeholder="Enter passcode" autofocus />
110
+ <button type="submit">Unlock</button>
111
+ </form>
112
+ <div class="error" id="loginError"></div>
113
+ <a class="back" href="/">Back to Demo</a>
114
  </div>
115
 
116
+ <!-- Admin content (hidden until authenticated) -->
117
+ <div class="admin-content" id="adminContent">
118
+ <h1>KOL Measurement Records</h1>
119
+ <div class="toolbar">
120
+ <a href="/">Back to Demo</a>
121
+ <a id="exportCsvLink" href="#" download>Export CSV</a>
122
+ <button id="refreshBtn">Refresh</button>
123
+ <span class="count" id="countLabel">Loading...</span>
124
+ </div>
125
+
126
+ <div class="scroll-wrap">
127
+ <table>
128
+ <thead>
129
+ <tr>
130
+ <th>KOL</th>
131
+ <th>Date</th>
132
+ <th>Photo</th>
133
+ <th>Mode</th>
134
+ <th>Size</th>
135
+ <th>Range</th>
136
+ <th>Index</th>
137
+ <th>Middle</th>
138
+ <th>Ring</th>
139
+ <th>Conf</th>
140
+ <th>Fail</th>
141
+ <th>AI Explain</th>
142
+ <th>Ring Fit</th>
143
+ <th>Best Finger</th>
144
+ <th>GT Idx</th>
145
+ <th>GT Mid</th>
146
+ <th>GT Ring</th>
147
+ <th>GT Notes</th>
148
+ <th></th>
149
+ </tr>
150
+ </thead>
151
+ <tbody id="tableBody">
152
+ <tr><td colspan="19" class="empty">Loading...</td></tr>
153
+ </tbody>
154
+ </table>
155
+ </div>
156
  </div>
157
 
158
  <script>
159
+ const loginGate = document.getElementById("loginGate");
160
+ const adminContent = document.getElementById("adminContent");
161
+ const loginForm = document.getElementById("loginForm");
162
+ const tokenInput = document.getElementById("tokenInput");
163
+ const loginError = document.getElementById("loginError");
164
  const tbody = document.getElementById("tableBody");
165
  const countLabel = document.getElementById("countLabel");
166
 
167
+ let adminToken = sessionStorage.getItem("admin_token") || "";
168
+
169
+ const unlock = async (token) => {
170
+ const resp = await fetch(`/api/admin/measurements?token=${encodeURIComponent(token)}`);
171
+ if (resp.status === 401) return false;
172
+ adminToken = token;
173
+ sessionStorage.setItem("admin_token", token);
174
+ loginGate.style.display = "none";
175
+ adminContent.style.display = "block";
176
+ document.getElementById("exportCsvLink").href = `/api/admin/export-csv?token=${encodeURIComponent(adminToken)}`;
177
+ loadData();
178
+ return true;
179
+ };
180
+
181
+ // Auto-unlock if token saved in session
182
+ if (adminToken) {
183
+ unlock(adminToken).then((ok) => {
184
+ if (!ok) { adminToken = ""; sessionStorage.removeItem("admin_token"); }
185
+ });
186
+ }
187
+
188
+ loginForm.addEventListener("submit", async (e) => {
189
+ e.preventDefault();
190
+ const token = tokenInput.value.trim();
191
+ if (!token) return;
192
+ const ok = await unlock(token);
193
+ if (!ok) {
194
+ loginError.textContent = "Incorrect passcode";
195
+ tokenInput.value = "";
196
+ tokenInput.focus();
197
+ }
198
+ });
199
+
200
  const fmtDate = (iso) => {
201
  if (!iso) return "";
202
  const d = new Date(iso);
 
215
 
216
  const loadData = async () => {
217
  try {
218
+ const resp = await fetch(`/api/admin/measurements?token=${encodeURIComponent(adminToken)}`);
219
+ if (resp.status === 401) {
220
+ sessionStorage.removeItem("admin_token");
221
+ loginGate.style.display = "";
222
+ adminContent.style.display = "none";
223
+ loginError.textContent = "Session expired. Please log in again.";
224
+ return;
225
+ }
226
  const rows = await resp.json();
227
  countLabel.textContent = `${rows.length} records`;
228
  if (rows.length === 0) {
 
240
  <td>${photoThumb}</td>
241
  <td>${r.mode || "-"}</td>
242
  <td><strong>${r.overall_best_size != null ? r.overall_best_size : "-"}</strong></td>
243
+ <td>${r.overall_range_min != null ? r.overall_range_min + "\u2013" + r.overall_range_max : "-"}</td>
244
  <td class="finger-cell">${fmtFinger(pf, "index")}</td>
245
  <td class="finger-cell">${fmtFinger(pf, "middle")}</td>
246
  <td class="finger-cell">${fmtFinger(pf, "ring")}</td>
247
  <td>${r.confidence != null ? (r.confidence * 100).toFixed(0) + "%" : "-"}</td>
248
  <td>${r.fail_reason ? '<span class="fail">' + r.fail_reason + "</span>" : ""}</td>
249
+ <td>${(r.result_json && r.result_json.ai_explanation) ? '<span class="ai-peek" title="Click to view" onclick="alert(this.dataset.full)" data-full="' + r.result_json.ai_explanation.replace(/"/g, '&quot;') + '">View</span>' : "-"}</td>
 
 
250
  <td><select class="gt-select" data-field="ring_fit">
251
  <option value="">-</option>
252
  <option value="fits_well" ${r.ring_fit === "fits_well" ? "selected" : ""}>Fits well</option>
253
  <option value="too_tight" ${r.ring_fit === "too_tight" ? "selected" : ""}>Too tight</option>
254
  <option value="too_loose" ${r.ring_fit === "too_loose" ? "selected" : ""}>Too loose</option>
255
  </select></td>
256
+ <td><select class="gt-select" data-field="gt_best_finger">
257
+ <option value="">-</option>
258
+ <option value="index" ${r.gt_best_finger === "index" ? "selected" : ""}>Index</option>
259
+ <option value="middle" ${r.gt_best_finger === "middle" ? "selected" : ""}>Middle</option>
260
+ <option value="ring" ${r.gt_best_finger === "ring" ? "selected" : ""}>Ring</option>
261
+ </select></td>
262
+ <td><input class="gt-input" data-field="gt_index_size" type="number" min="4" max="15" value="${r.gt_index_size ?? ""}" /></td>
263
+ <td><input class="gt-input" data-field="gt_middle_size" type="number" min="4" max="15" value="${r.gt_middle_size ?? ""}" /></td>
264
+ <td><input class="gt-input" data-field="gt_ring_size" type="number" min="4" max="15" value="${r.gt_ring_size ?? ""}" /></td>
265
  <td><input class="gt-notes-input" data-field="gt_notes" type="text" value="${r.gt_notes || ""}" placeholder="notes" /></td>
266
+ <td><button class="save-btn" onclick="saveGT(this)">Save</button> <button class="del-btn" onclick="deleteRow(this)">Delete</button></td>
267
  </tr>`;
268
  }).join("");
269
  } catch (e) {
 
284
  }
285
  });
286
  try {
287
+ const resp = await fetch(`/api/admin/measurements/${id}/ground-truth?token=${encodeURIComponent(adminToken)}`, {
288
  method: "POST",
289
  headers: { "Content-Type": "application/json" },
290
  body: JSON.stringify(data),
 
302
  }
303
  };
304
 
305
+ window.deleteRow = async (btn) => {
306
+ if (!confirm("Delete this measurement record?")) return;
307
+ const tr = btn.closest("tr");
308
+ const id = tr.dataset.id;
309
+ try {
310
+ const resp = await fetch(`/api/admin/measurements/${id}?token=${encodeURIComponent(adminToken)}`, {
311
+ method: "DELETE",
312
+ });
313
+ const result = await resp.json();
314
+ if (result.success) {
315
+ tr.remove();
316
+ const rows = tbody.querySelectorAll("tr");
317
+ countLabel.textContent = `${rows.length} records`;
318
+ } else {
319
+ alert("Delete failed: " + (result.error || "unknown"));
320
+ }
321
+ } catch (e) {
322
+ alert("Network error: " + e.message);
323
+ }
324
+ };
325
+
326
  document.getElementById("refreshBtn").addEventListener("click", loadData);
 
327
  </script>
328
  </body>
329
  </html>
web_demo/templates/index.html CHANGED
@@ -12,10 +12,10 @@
12
 
13
  <header class="hero">
14
  <div class="hero-copy">
15
- <p class="hero-kicker">Ring Size CV · Web Demo</p>
16
  <h1>Upload a photo to quickly measure ring size</h1>
17
  <p class="hero-sub">
18
- Runs locally with no cloud upload. Results include JSON output and a visual overlay.
19
  </p>
20
  </div>
21
  <div class="hero-card">
@@ -28,8 +28,15 @@
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>
@@ -48,23 +55,10 @@
48
  <option value="auto">Auto</option>
49
  </select>
50
  </label>
51
- <label>
52
- <span>Ring Model</span>
53
- <select name="ring_model" id="ringModelSelect">
54
- <option value="gen" selected>Gen1/Gen2</option>
55
- <option value="air">Air</option>
56
- </select>
57
- </label>
58
- <label>
59
- <span>Edge Method</span>
60
- <select name="edge_method" disabled aria-disabled="true">
61
- <option value="sobel" selected>Sobel (Locked)</option>
62
- </select>
63
- </label>
64
  <label class="toggle-label">
65
  <span>AI Explanation</span>
66
  <div class="toggle-row">
67
- <input type="checkbox" id="aiExplainToggle" />
68
  <span class="toggle-hint">Uses OpenAI tokens</span>
69
  </div>
70
  </label>
@@ -118,6 +112,10 @@
118
  </section>
119
  </main>
120
 
 
 
 
 
121
  <script>window.DEFAULT_SAMPLE_URL = "{{ default_sample_url }}";</script>
122
  <script src="/static/app.js"></script>
123
  </body>
 
12
 
13
  <header class="hero">
14
  <div class="hero-copy">
15
+ <p class="hero-kicker">Femometer Smart Ring Sizer</p>
16
  <h1>Upload a photo to quickly measure ring size</h1>
17
  <p class="hero-sub">
18
+ AI-powered measurement using your hand photo and a standard-size card for scale.
19
  </p>
20
  </div>
21
  <div class="hero-card">
 
28
 
29
  <div class="controls">
30
  <label>
31
+ <span>Name / ID</span>
32
+ <input type="text" name="kol_name" id="kolNameInput" placeholder="e.g. Your Name" />
33
+ </label>
34
+ <label>
35
+ <span>Ring Model</span>
36
+ <select name="ring_model" id="ringModelSelect">
37
+ <option value="gen" selected>Gen1/Gen2</option>
38
+ <option value="air">Air</option>
39
+ </select>
40
  </label>
41
  <label>
42
  <span>Mode</span>
 
55
  <option value="auto">Auto</option>
56
  </select>
57
  </label>
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  <label class="toggle-label">
59
  <span>AI Explanation</span>
60
  <div class="toggle-row">
61
+ <input type="checkbox" id="aiExplainToggle" checked />
62
  <span class="toggle-hint">Uses OpenAI tokens</span>
63
  </div>
64
  </label>
 
112
  </section>
113
  </main>
114
 
115
+ <footer style="text-align:center;padding:24px 0 12px;opacity:0.4;font-size:13px;">
116
+ <a href="/admin" style="color:var(--ink-soft);text-decoration:none;">Admin</a>
117
+ </footer>
118
+
119
  <script>window.DEFAULT_SAMPLE_URL = "{{ default_sample_url }}";</script>
120
  <script src="/static/app.js"></script>
121
  </body>