feng-x commited on
Commit
b18951f
Β·
verified Β·
1 Parent(s): b6cb9cf

Upload folder using huggingface_hub

Browse files
measure_finger.py CHANGED
@@ -455,6 +455,34 @@ def _save_debug_visualization(path: str, image: np.ndarray) -> None:
455
  f.write(buf.tobytes())
456
 
457
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
458
  def _sam_card_detect(
459
  image_canonical: np.ndarray,
460
  hand_data: Dict[str, Any],
@@ -610,6 +638,7 @@ def measure_finger(
610
 
611
  if card_result is None:
612
  logger.warning("card not detected in image")
 
613
  return create_output(
614
  card_detected=False,
615
  fail_reason="card_not_detected",
@@ -629,6 +658,9 @@ def measure_finger(
629
  if not view_angle_ok:
630
  logger.warning("card not parallel to camera (scale_confidence=%.2f, required>0.9)",
631
  scale_confidence)
 
 
 
632
  return create_output(
633
  card_detected=True,
634
  finger_detected=False,
@@ -1255,24 +1287,16 @@ def measure_multi_finger(
1255
  else:
1256
  card_result = detect_credit_card(image_canonical, debug_dir=card_debug_dir)
1257
  if card_result is None:
1258
- # Emit a diagnostic visualization so the failure is debuggable:
1259
- # hand mask + card-prompt seeds on the canonical image. Without
1260
- # this, a card_not_detected failure on HF leaves no PNG to pull.
1261
- if result_png_path is not None:
1262
- vis = image_canonical.copy()
1263
- vis = _overlay_sam_masks(vis, hand_mask=hand_data.get("mask"))
1264
- vis = _overlay_hand_skeleton(vis, landmarks=hand_data.get("landmarks"))
1265
- vis = _overlay_card_seeds(
1266
- vis, hand_data.get("_sam_card_seed_debug")
1267
- )
1268
- _save_debug_visualization(result_png_path, vis)
1269
- logger.info("[multi] card-not-detected viz saved: %s", result_png_path)
1270
  return {"fail_reason": "card_not_detected", "per_finger": {}, "fingers_measured": 0, "fingers_succeeded": 0}
1271
  px_per_cm, scale_confidence = compute_scale_factor(card_result["corners"])
1272
  view_angle_ok = scale_confidence > 0.9
1273
  card_detected = True
1274
 
1275
  if not view_angle_ok:
 
 
 
1276
  return {"fail_reason": "card_not_parallel", "per_finger": {}, "fingers_measured": 0, "fingers_succeeded": 0}
1277
 
1278
  card_frame = check_card_in_frame(card_result["corners"], image_canonical.shape)
 
455
  f.write(buf.tobytes())
456
 
457
 
458
+ def _write_card_failure_viz(
459
+ result_png_path: Optional[str],
460
+ image_canonical: np.ndarray,
461
+ hand_data: Dict[str, Any],
462
+ card_result: Optional[Dict[str, Any]] = None,
463
+ ) -> None:
464
+ """Write a diagnostic overlay for card-phase failures.
465
+
466
+ Shows the hand mask + skeleton on the canonical image, plus either the
467
+ detected card corner quadrilateral (when the card was found but rejected,
468
+ e.g. card_not_parallel) or the card-prompt seed points (when detection
469
+ itself failed). Uses the same corner-quad rendering as the success path
470
+ (draw_card_overlay) rather than the raw SAM card mask, which can have
471
+ stray blobs outside the true card boundary.
472
+ """
473
+ if result_png_path is None:
474
+ return
475
+ vis = image_canonical.copy()
476
+ vis = _overlay_sam_masks(vis, hand_mask=hand_data.get("mask"))
477
+ vis = _overlay_hand_skeleton(vis, landmarks=hand_data.get("landmarks"))
478
+ if card_result is not None:
479
+ vis = draw_card_overlay(vis, card_result)
480
+ else:
481
+ vis = _overlay_card_seeds(vis, hand_data.get("_sam_card_seed_debug"))
482
+ _save_debug_visualization(result_png_path, vis)
483
+ logger.info("card-failure viz saved: %s", result_png_path)
484
+
485
+
486
  def _sam_card_detect(
487
  image_canonical: np.ndarray,
488
  hand_data: Dict[str, Any],
 
638
 
639
  if card_result is None:
640
  logger.warning("card not detected in image")
641
+ _write_card_failure_viz(result_png_path, image_canonical, hand_data)
642
  return create_output(
643
  card_detected=False,
644
  fail_reason="card_not_detected",
 
658
  if not view_angle_ok:
659
  logger.warning("card not parallel to camera (scale_confidence=%.2f, required>0.9)",
660
  scale_confidence)
661
+ _write_card_failure_viz(
662
+ result_png_path, image_canonical, hand_data, card_result=card_result
663
+ )
664
  return create_output(
665
  card_detected=True,
666
  finger_detected=False,
 
1287
  else:
1288
  card_result = detect_credit_card(image_canonical, debug_dir=card_debug_dir)
1289
  if card_result is None:
1290
+ _write_card_failure_viz(result_png_path, image_canonical, hand_data)
 
 
 
 
 
 
 
 
 
 
 
1291
  return {"fail_reason": "card_not_detected", "per_finger": {}, "fingers_measured": 0, "fingers_succeeded": 0}
1292
  px_per_cm, scale_confidence = compute_scale_factor(card_result["corners"])
1293
  view_angle_ok = scale_confidence > 0.9
1294
  card_detected = True
1295
 
1296
  if not view_angle_ok:
1297
+ _write_card_failure_viz(
1298
+ result_png_path, image_canonical, hand_data, card_result=card_result
1299
+ )
1300
  return {"fail_reason": "card_not_parallel", "per_finger": {}, "fingers_measured": 0, "fingers_succeeded": 0}
1301
 
1302
  card_frame = check_card_in_frame(card_result["corners"], image_canonical.shape)
src/sam_card_detection.py CHANGED
@@ -22,7 +22,6 @@ import numpy as np
22
 
23
  from .card_detection import (
24
  CARD_ASPECT_RATIO,
25
- MAX_CARD_AREA_RATIO,
26
  MIN_CARD_AREA_RATIO,
27
  get_quad_dimensions,
28
  order_corners,
@@ -35,6 +34,13 @@ logger = logging.getLogger(__name__)
35
  MIN_RECTANGULARITY = 0.90 # mask_area / minAreaRect_area; card mask is near-perfect rectangle
36
  ASPECT_RATIO_TOLERANCE = 0.15 # fractional deviation from 1.586
37
  MAX_HAND_OVERLAP_RATIO = 0.20 # reject candidates that swallow the hand (background paper, tabletop)
 
 
 
 
 
 
 
38
 
39
 
40
  def _score_card_mask(
@@ -51,7 +57,7 @@ def _score_card_mask(
51
  mask_area = float(mask.sum())
52
 
53
  area_ratio = mask_area / image_area
54
- if area_ratio < MIN_CARD_AREA_RATIO or area_ratio > MAX_CARD_AREA_RATIO:
55
  return None
56
 
57
  contours, _ = cv2.findContours(mask_u8, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
 
22
 
23
  from .card_detection import (
24
  CARD_ASPECT_RATIO,
 
25
  MIN_CARD_AREA_RATIO,
26
  get_quad_dimensions,
27
  order_corners,
 
34
  MIN_RECTANGULARITY = 0.90 # mask_area / minAreaRect_area; card mask is near-perfect rectangle
35
  ASPECT_RATIO_TOLERANCE = 0.15 # fractional deviation from 1.586
36
  MAX_HAND_OVERLAP_RATIO = 0.20 # reject candidates that swallow the hand (background paper, tabletop)
37
+ # SAM-specific upper bound on card area. Tighter than the shared
38
+ # MAX_CARD_AREA_RATIO (0.5) because SAM happily returns whole-background
39
+ # segments (ceilings, walls) as a single rectangular-ish mask when no card
40
+ # is actually present β€” a ~50% half-image mask can pass rectangularity and
41
+ # aspect ratio purely by accident. A real credit card held alongside a hand
42
+ # is ~5-15% of the frame; 25% is already 2Γ— the realistic maximum.
43
+ SAM_MAX_CARD_AREA_RATIO = 0.25
44
 
45
 
46
  def _score_card_mask(
 
57
  mask_area = float(mask.sum())
58
 
59
  area_ratio = mask_area / image_area
60
+ if area_ratio < MIN_CARD_AREA_RATIO or area_ratio > SAM_MAX_CARD_AREA_RATIO:
61
  return None
62
 
63
  contours, _ = cv2.findContours(mask_u8, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
web_demo/app.py CHANGED
@@ -104,24 +104,12 @@ def _make_base_name(kol_name: str) -> Tuple[str, str]:
104
  return base_name, run_id
105
 
106
 
107
- class _NumpyEncoder(json.JSONEncoder):
108
- """Handle numpy types that aren't natively JSON serializable."""
109
- def default(self, obj):
110
- if isinstance(obj, np.bool_):
111
- return bool(obj)
112
- if isinstance(obj, np.integer):
113
- return int(obj)
114
- if isinstance(obj, np.floating):
115
- return float(obj)
116
- if isinstance(obj, np.ndarray):
117
- return obj.tolist()
118
- if isinstance(obj, np.generic):
119
- return obj.item()
120
- return super().default(obj)
121
-
122
-
123
  def _numpy_safe(obj):
124
- """Recursively convert numpy types to native Python types."""
 
 
 
 
125
  if isinstance(obj, dict):
126
  return {k: _numpy_safe(v) for k, v in obj.items()}
127
  if isinstance(obj, (list, tuple)):
@@ -142,7 +130,21 @@ def _numpy_safe(obj):
142
  def _save_json(path: Path, data: Dict[str, Any]) -> None:
143
  path.parent.mkdir(parents=True, exist_ok=True)
144
  with path.open("w", encoding="utf-8") as f:
145
- json.dump(data, f, indent=2, ensure_ascii=False, cls=_NumpyEncoder)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
 
147
 
148
  @app.route("/")
@@ -178,13 +180,8 @@ def api_measure():
178
  if not _allowed_file(file.filename):
179
  return jsonify({"success": False, "error": "Unsupported file type"}), 400
180
 
181
- finger_index = request.form.get("finger_index", "index")
182
- mode = request.form.get("mode", "single")
183
- kol_name = request.form.get("kol_name", "").strip()
184
- ring_model = request.form.get("ring_model", DEFAULT_RING_MODEL)
185
- if ring_model not in VALID_RING_MODELS:
186
- ring_model = DEFAULT_RING_MODEL
187
- base_name, run_id = _make_base_name(kol_name)
188
  suffix = Path(secure_filename(file.filename)).suffix.lower() or ".jpg"
189
  upload_name = f"{base_name}{suffix}"
190
  upload_path = UPLOAD_DIR / upload_name
@@ -195,39 +192,23 @@ def api_measure():
195
  if image is None:
196
  return jsonify({"success": False, "error": "Failed to load image"}), 400
197
 
198
- if mode == "multi":
199
- return _run_multi_measurement(
200
- image=image,
201
- input_image_url=f"/uploads/{upload_name}",
202
- ring_model=ring_model,
203
- kol_name=kol_name,
204
- upload_path=upload_path,
205
- upload_name=upload_name,
206
- base_name=base_name,
207
- run_id=run_id,
208
- )
209
-
210
- return _run_measurement(
211
  image=image,
212
- finger_index=finger_index,
213
  input_image_url=f"/uploads/{upload_name}",
214
- ring_model=ring_model,
215
- kol_name=kol_name,
216
  upload_path=upload_path,
217
  upload_name=upload_name,
218
  base_name=base_name,
219
  run_id=run_id,
220
  )
 
 
 
221
 
222
 
223
  @app.route("/api/measure-default", methods=["POST"])
224
  def api_measure_default():
225
- finger_index = request.form.get("finger_index", "index")
226
- mode = request.form.get("mode", "single")
227
- kol_name = request.form.get("kol_name", "").strip()
228
- ring_model = request.form.get("ring_model", DEFAULT_RING_MODEL)
229
- if ring_model not in VALID_RING_MODELS:
230
- ring_model = DEFAULT_RING_MODEL
231
  if not DEFAULT_SAMPLE_PATH.exists():
232
  return jsonify({"success": False, "error": "Default sample image not found"}), 500
233
 
@@ -235,27 +216,20 @@ def api_measure_default():
235
  if image is None:
236
  return jsonify({"success": False, "error": "Failed to load default sample image"}), 500
237
 
238
- base_name, run_id = _make_base_name(kol_name or "sample")
 
239
 
240
- if mode == "multi":
241
- return _run_multi_measurement(
242
- image=image,
243
- input_image_url=DEFAULT_SAMPLE_URL,
244
- ring_model=ring_model,
245
- kol_name=kol_name,
246
- base_name=base_name,
247
- run_id=run_id,
248
- )
249
-
250
- return _run_measurement(
251
  image=image,
252
- finger_index=finger_index,
253
  input_image_url=DEFAULT_SAMPLE_URL,
254
- ring_model=ring_model,
255
- kol_name=kol_name,
256
  base_name=base_name,
257
  run_id=run_id,
258
  )
 
 
 
259
 
260
 
261
  def _run_measurement(
@@ -264,14 +238,11 @@ def _run_measurement(
264
  input_image_url: str,
265
  ring_model: str = DEFAULT_RING_MODEL,
266
  kol_name: str = "",
267
- upload_path: Path = None,
268
  upload_name: str = "",
269
  base_name: str = "",
270
  run_id: str = "",
271
  ):
272
- if not base_name:
273
- base_name, run_id = _make_base_name(kol_name)
274
-
275
  result_png_name = f"{base_name}_result.png"
276
  result_png_path = RESULTS_DIR / result_png_name
277
 
@@ -342,15 +313,12 @@ def _run_multi_measurement(
342
  input_image_url: str,
343
  ring_model: str = DEFAULT_RING_MODEL,
344
  kol_name: str = "",
345
- upload_path: Path = None,
346
  upload_name: str = "",
347
  base_name: str = "",
348
  run_id: str = "",
349
  ):
350
  """Run multi-finger measurement pipeline."""
351
- if not base_name:
352
- base_name, run_id = _make_base_name(kol_name)
353
-
354
  result_png_name = f"{base_name}_result.png"
355
  result_png_path = RESULTS_DIR / result_png_name
356
 
 
104
  return base_name, run_id
105
 
106
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  def _numpy_safe(obj):
108
+ """Recursively convert numpy types to native Python types.
109
+
110
+ Applied to measurement results before they hit either Flask's jsonify
111
+ (which doesn't know about numpy) or the JSON file writer.
112
+ """
113
  if isinstance(obj, dict):
114
  return {k: _numpy_safe(v) for k, v in obj.items()}
115
  if isinstance(obj, (list, tuple)):
 
130
  def _save_json(path: Path, data: Dict[str, Any]) -> None:
131
  path.parent.mkdir(parents=True, exist_ok=True)
132
  with path.open("w", encoding="utf-8") as f:
133
+ json.dump(data, f, indent=2, ensure_ascii=False)
134
+
135
+
136
+ def _read_form_settings() -> Dict[str, str]:
137
+ """Parse the measurement-request form fields shared by /api/measure and
138
+ /api/measure-default."""
139
+ ring_model = request.form.get("ring_model", DEFAULT_RING_MODEL)
140
+ if ring_model not in VALID_RING_MODELS:
141
+ ring_model = DEFAULT_RING_MODEL
142
+ return {
143
+ "finger_index": request.form.get("finger_index", "index"),
144
+ "mode": request.form.get("mode", "single"),
145
+ "kol_name": request.form.get("kol_name", "").strip(),
146
+ "ring_model": ring_model,
147
+ }
148
 
149
 
150
  @app.route("/")
 
180
  if not _allowed_file(file.filename):
181
  return jsonify({"success": False, "error": "Unsupported file type"}), 400
182
 
183
+ settings = _read_form_settings()
184
+ base_name, run_id = _make_base_name(settings["kol_name"])
 
 
 
 
 
185
  suffix = Path(secure_filename(file.filename)).suffix.lower() or ".jpg"
186
  upload_name = f"{base_name}{suffix}"
187
  upload_path = UPLOAD_DIR / upload_name
 
192
  if image is None:
193
  return jsonify({"success": False, "error": "Failed to load image"}), 400
194
 
195
+ common_kwargs = dict(
 
 
 
 
 
 
 
 
 
 
 
 
196
  image=image,
 
197
  input_image_url=f"/uploads/{upload_name}",
198
+ ring_model=settings["ring_model"],
199
+ kol_name=settings["kol_name"],
200
  upload_path=upload_path,
201
  upload_name=upload_name,
202
  base_name=base_name,
203
  run_id=run_id,
204
  )
205
+ if settings["mode"] == "multi":
206
+ return _run_multi_measurement(**common_kwargs)
207
+ return _run_measurement(finger_index=settings["finger_index"], **common_kwargs)
208
 
209
 
210
  @app.route("/api/measure-default", methods=["POST"])
211
  def api_measure_default():
 
 
 
 
 
 
212
  if not DEFAULT_SAMPLE_PATH.exists():
213
  return jsonify({"success": False, "error": "Default sample image not found"}), 500
214
 
 
216
  if image is None:
217
  return jsonify({"success": False, "error": "Failed to load default sample image"}), 500
218
 
219
+ settings = _read_form_settings()
220
+ base_name, run_id = _make_base_name(settings["kol_name"] or "sample")
221
 
222
+ common_kwargs = dict(
 
 
 
 
 
 
 
 
 
 
223
  image=image,
 
224
  input_image_url=DEFAULT_SAMPLE_URL,
225
+ ring_model=settings["ring_model"],
226
+ kol_name=settings["kol_name"],
227
  base_name=base_name,
228
  run_id=run_id,
229
  )
230
+ if settings["mode"] == "multi":
231
+ return _run_multi_measurement(**common_kwargs)
232
+ return _run_measurement(finger_index=settings["finger_index"], **common_kwargs)
233
 
234
 
235
  def _run_measurement(
 
238
  input_image_url: str,
239
  ring_model: str = DEFAULT_RING_MODEL,
240
  kol_name: str = "",
241
+ upload_path: Optional[Path] = None,
242
  upload_name: str = "",
243
  base_name: str = "",
244
  run_id: str = "",
245
  ):
 
 
 
246
  result_png_name = f"{base_name}_result.png"
247
  result_png_path = RESULTS_DIR / result_png_name
248
 
 
313
  input_image_url: str,
314
  ring_model: str = DEFAULT_RING_MODEL,
315
  kol_name: str = "",
316
+ upload_path: Optional[Path] = None,
317
  upload_name: str = "",
318
  base_name: str = "",
319
  run_id: str = "",
320
  ):
321
  """Run multi-finger measurement pipeline."""
 
 
 
322
  result_png_name = f"{base_name}_result.png"
323
  result_png_path = RESULTS_DIR / result_png_name
324
 
web_demo/static/app.js CHANGED
@@ -17,7 +17,7 @@ const failReasonMessageMap = {
17
  card_not_detected:
18
  "Card not detected. A card of standard credit card dimensions (85.6 Γ— 54 mm) is required as a scale reference to measure your finger width. Place the card beside your hand on a plain white background (e.g. a sheet of paper), and turn on your phone's flash.",
19
  card_not_parallel:
20
- "Card scale calibration failed. Use a standard-size card (same size as a credit/debit card) and keep your phone parallel to the card.",
21
  card_near_edge:
22
  "Card appears cropped. Place the entire card within the photo frame.",
23
  hand_not_detected:
@@ -121,6 +121,23 @@ const buildMeasureSettings = () => {
121
  };
122
  };
123
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
  const renderMultiResult = (result) => {
125
  if (!result || !result.per_finger) {
126
  overallSize.innerHTML = `<div class="size-hero"><span class="size-label">Measurement Failed</span></div>`;
@@ -158,23 +175,7 @@ const renderMultiResult = (result) => {
158
  }
159
  html += "</div>";
160
  html += `<div class="finger-count">${result.fingers_succeeded}/${result.fingers_measured} fingers measured</div>`;
161
-
162
- // Size reference table (dynamic based on selected ring model)
163
- const ringModel = ringModelSelect ? ringModelSelect.value : "gen";
164
- const sizeTable = RING_SIZE_TABLES[ringModel] || RING_SIZE_TABLES.gen;
165
- const modelLabel = RING_MODEL_LABELS[ringModel] || ringModel;
166
- let tableRows = "";
167
- for (const [size, mm] of Object.entries(sizeTable)) {
168
- tableRows += `<tr><td>${size}</td><td>${mm.toFixed(1)}</td></tr>`;
169
- }
170
- html += `
171
- <div class="size-ref-table">
172
- <h3 class="size-ref-title">Size Reference (${modelLabel})</h3>
173
- <table>
174
- <thead><tr><th>Size</th><th>Inner βŒ€ (mm)</th></tr></thead>
175
- <tbody>${tableRows}</tbody>
176
- </table>
177
- </div>`;
178
 
179
  fingerBreakdown.innerHTML = html;
180
  };
@@ -199,7 +200,7 @@ const renderSingleResult = (result) => {
199
  const fingerSelect = form.querySelector('[name="finger_index"]');
200
  const fingerName = fingerSelect ? fingerSelect.value : "finger";
201
  const capitalName = fingerName.charAt(0).toUpperCase() + fingerName.slice(1);
202
- let html = `<div class="finger-cards">
203
  <div class="finger-card" style="border-top: 3px solid #00dddd;">
204
  <div class="finger-name">${capitalName}</div>
205
  <div class="finger-size-label">Size</div>
@@ -207,26 +208,7 @@ const renderSingleResult = (result) => {
207
  <div class="finger-range">${rs.range_min} – ${rs.range_max}</div>
208
  <div class="finger-width">${diamMm} mm</div>
209
  </div>
210
- </div>`;
211
-
212
- // Size reference table
213
- const ringModel = ringModelSelect ? ringModelSelect.value : "gen";
214
- const sizeTable = RING_SIZE_TABLES[ringModel] || RING_SIZE_TABLES.gen;
215
- const refLabel = RING_MODEL_LABELS[ringModel] || ringModel;
216
- let tableRows = "";
217
- for (const [size, mm] of Object.entries(sizeTable)) {
218
- tableRows += `<tr><td>${size}</td><td>${mm.toFixed(1)}</td></tr>`;
219
- }
220
- html += `
221
- <div class="size-ref-table">
222
- <h3 class="size-ref-title">Size Reference (${refLabel})</h3>
223
- <table>
224
- <thead><tr><th>Size</th><th>Inner βŒ€ (mm)</th></tr></thead>
225
- <tbody>${tableRows}</tbody>
226
- </table>
227
- </div>`;
228
-
229
- fingerBreakdown.innerHTML = html;
230
  };
231
 
232
  const runMeasurement = async (endpoint, formData, inputUrlFallback = "") => {
@@ -248,12 +230,10 @@ const runMeasurement = async (endpoint, formData, inputUrlFallback = "") => {
248
  });
249
 
250
  if (!response.ok) {
251
- clearInterval(timerId);
252
- const error = await response.json();
253
  setStatus(error.error || "Measurement failed", { error: true });
254
  return;
255
  }
256
- clearInterval(timerId);
257
 
258
  const data = await response.json();
259
  jsonOutput.textContent = JSON.stringify(data.result, null, 2);
@@ -275,8 +255,9 @@ const runMeasurement = async (endpoint, formData, inputUrlFallback = "") => {
275
  setStatus(formatFailReasonStatus(failReason), { error: true });
276
  }
277
  } catch (error) {
278
- clearInterval(timerId);
279
  setStatus("Network error. Please retry.", { error: true });
 
 
280
  }
281
  };
282
 
 
17
  card_not_detected:
18
  "Card not detected. A card of standard credit card dimensions (85.6 Γ— 54 mm) is required as a scale reference to measure your finger width. Place the card beside your hand on a plain white background (e.g. a sheet of paper), and turn on your phone's flash.",
19
  card_not_parallel:
20
+ "Card scale calibration failed. Use a card of standard credit card dimensions (85.6 Γ— 54 mm) as the scale reference to measure your finger width, and keep your phone parallel to the card.",
21
  card_near_edge:
22
  "Card appears cropped. Place the entire card within the photo frame.",
23
  hand_not_detected:
 
121
  };
122
  };
123
 
124
+ const buildSizeRefTable = () => {
125
+ const ringModel = ringModelSelect ? ringModelSelect.value : "gen";
126
+ const sizeTable = RING_SIZE_TABLES[ringModel] || RING_SIZE_TABLES.gen;
127
+ const modelLabel = RING_MODEL_LABELS[ringModel] || ringModel;
128
+ const rows = Object.entries(sizeTable)
129
+ .map(([size, mm]) => `<tr><td>${size}</td><td>${mm.toFixed(1)}</td></tr>`)
130
+ .join("");
131
+ return `
132
+ <div class="size-ref-table">
133
+ <h3 class="size-ref-title">Size Reference (${modelLabel})</h3>
134
+ <table>
135
+ <thead><tr><th>Size</th><th>Inner βŒ€ (mm)</th></tr></thead>
136
+ <tbody>${rows}</tbody>
137
+ </table>
138
+ </div>`;
139
+ };
140
+
141
  const renderMultiResult = (result) => {
142
  if (!result || !result.per_finger) {
143
  overallSize.innerHTML = `<div class="size-hero"><span class="size-label">Measurement Failed</span></div>`;
 
175
  }
176
  html += "</div>";
177
  html += `<div class="finger-count">${result.fingers_succeeded}/${result.fingers_measured} fingers measured</div>`;
178
+ html += buildSizeRefTable();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
179
 
180
  fingerBreakdown.innerHTML = html;
181
  };
 
200
  const fingerSelect = form.querySelector('[name="finger_index"]');
201
  const fingerName = fingerSelect ? fingerSelect.value : "finger";
202
  const capitalName = fingerName.charAt(0).toUpperCase() + fingerName.slice(1);
203
+ fingerBreakdown.innerHTML = `<div class="finger-cards">
204
  <div class="finger-card" style="border-top: 3px solid #00dddd;">
205
  <div class="finger-name">${capitalName}</div>
206
  <div class="finger-size-label">Size</div>
 
208
  <div class="finger-range">${rs.range_min} – ${rs.range_max}</div>
209
  <div class="finger-width">${diamMm} mm</div>
210
  </div>
211
+ </div>` + buildSizeRefTable();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
212
  };
213
 
214
  const runMeasurement = async (endpoint, formData, inputUrlFallback = "") => {
 
230
  });
231
 
232
  if (!response.ok) {
233
+ const error = await response.json().catch(() => ({}));
 
234
  setStatus(error.error || "Measurement failed", { error: true });
235
  return;
236
  }
 
237
 
238
  const data = await response.json();
239
  jsonOutput.textContent = JSON.stringify(data.result, null, 2);
 
255
  setStatus(formatFailReasonStatus(failReason), { error: true });
256
  }
257
  } catch (error) {
 
258
  setStatus("Network error. Please retry.", { error: true });
259
+ } finally {
260
+ clearInterval(timerId);
261
  }
262
  };
263