feng-x commited on
Commit
dbfd887
·
verified ·
1 Parent(s): 0142f2c

Upload folder using huggingface_hub

Browse files
measure_finger.py CHANGED
@@ -26,7 +26,12 @@ from src.sam_card_detection import (
26
  )
27
  from src.finger_segmentation import segment_hand, isolate_finger, clean_mask, get_finger_contour
28
  from src.geometry import estimate_finger_axis, localize_ring_zone, localize_ring_zone_from_landmarks, compute_cross_section_width
29
- from src.edge_refinement import refine_edges_sobel, should_use_sobel_measurement, compare_edge_methods
 
 
 
 
 
30
  from src.confidence import (
31
  compute_card_confidence,
32
  compute_finger_confidence,
@@ -808,6 +813,22 @@ def measure_finger(
808
  f"std={sobel_measurement['std_width_px']:.2f}px, "
809
  f"quality={sobel_measurement['edge_quality']['overall_score']:.3f})")
810
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
811
  except Exception as e:
812
  print(f"Edge refinement failed: {e}")
813
  sobel_failed = True
@@ -1162,6 +1183,17 @@ def _measure_single_finger_from_shared(
1162
  mask_mode=mask_mode,
1163
  finger_name=finger_name,
1164
  )
 
 
 
 
 
 
 
 
 
 
 
1165
  except Exception:
1166
  sobel_failed = True
1167
  if edge_method in ("sobel", "mask"):
 
26
  )
27
  from src.finger_segmentation import segment_hand, isolate_finger, clean_mask, get_finger_contour
28
  from src.geometry import estimate_finger_axis, localize_ring_zone, localize_ring_zone_from_landmarks, compute_cross_section_width
29
+ from src.edge_refinement import (
30
+ refine_edges_sobel,
31
+ should_use_sobel_measurement,
32
+ compare_edge_methods,
33
+ MIN_MASK_SAMPLES,
34
+ )
35
  from src.confidence import (
36
  compute_card_confidence,
37
  compute_finger_confidence,
 
813
  f"std={sobel_measurement['std_width_px']:.2f}px, "
814
  f"quality={sobel_measurement['edge_quality']['overall_score']:.3f})")
815
 
816
+ # Hard-fail mask mode when there aren't enough valid rows to
817
+ # trust the median. Without this gate a single surviving row
818
+ # still produces a "valid" measurement.
819
+ if edge_method == "mask":
820
+ num_samples = int(sobel_measurement.get("num_samples", 0))
821
+ if num_samples < MIN_MASK_SAMPLES:
822
+ print(f"Insufficient valid edge samples: {num_samples} < {MIN_MASK_SAMPLES}")
823
+ return create_output(
824
+ card_detected=card_detected,
825
+ finger_detected=True,
826
+ scale_px_per_cm=px_per_cm,
827
+ view_angle_ok=view_angle_ok,
828
+ fail_reason=f"insufficient_edge_samples_{num_samples}",
829
+ edge_method_used="mask",
830
+ )
831
+
832
  except Exception as e:
833
  print(f"Edge refinement failed: {e}")
834
  sobel_failed = True
 
1183
  mask_mode=mask_mode,
1184
  finger_name=finger_name,
1185
  )
1186
+ # Hard-fail mask mode when too few valid rows survived all the
1187
+ # invalidation gates.
1188
+ if edge_method == "mask":
1189
+ num_samples = int(sobel_measurement.get("num_samples", 0))
1190
+ if num_samples < MIN_MASK_SAMPLES:
1191
+ return create_output(
1192
+ card_detected=card_detected, finger_detected=True,
1193
+ scale_px_per_cm=px_per_cm, view_angle_ok=view_angle_ok,
1194
+ fail_reason=f"insufficient_edge_samples_{num_samples}",
1195
+ edge_method_used="mask",
1196
+ )
1197
  except Exception:
1198
  sobel_failed = True
1199
  if edge_method in ("sobel", "mask"):
src/edge_refinement.py CHANGED
@@ -52,6 +52,13 @@ from src.edge_refinement_constants import (
52
  # Configure logging
53
  logger = logging.getLogger(__name__)
54
 
 
 
 
 
 
 
 
55
 
56
  # =============================================================================
57
  # Helper Functions (extracted from nested scope)
@@ -1053,7 +1060,6 @@ def should_use_sobel_measurement(
1053
  # otherwise reasonable width usually indicates the per-finger mask
1054
  # bled into an adjacent finger and width validation killed most
1055
  # rows — contour is safer in that situation.
1056
- MIN_MASK_SAMPLES = 20 # parity with the contour path's 20 samples
1057
  num_samples = int(sobel_result.get("num_samples", 0))
1058
  if num_samples < MIN_MASK_SAMPLES:
1059
  return False, f"mask_samples_low_{num_samples}"
 
52
  # Configure logging
53
  logger = logging.getLogger(__name__)
54
 
55
+ # Minimum valid cross-sections required for a trustworthy mask-based width
56
+ # measurement. Below this the median is too sensitive to individual row
57
+ # errors (webbing leakage, axis drift, finger tilt), so callers should fail
58
+ # the measurement rather than report a potentially-wrong number. 20 keeps
59
+ # parity with the contour path's 20-sample target.
60
+ MIN_MASK_SAMPLES = 20
61
+
62
 
63
  # =============================================================================
64
  # Helper Functions (extracted from nested scope)
 
1060
  # otherwise reasonable width usually indicates the per-finger mask
1061
  # bled into an adjacent finger and width validation killed most
1062
  # rows — contour is safer in that situation.
 
1063
  num_samples = int(sobel_result.get("num_samples", 0))
1064
  if num_samples < MIN_MASK_SAMPLES:
1065
  return False, f"mask_samples_low_{num_samples}"
web_demo/app.py CHANGED
@@ -9,13 +9,15 @@ from __future__ import annotations
9
  import csv
10
  import io
11
  import json
 
12
  import os
13
  import re
14
  import sys
15
  import uuid
 
16
  from datetime import datetime
17
  from pathlib import Path
18
- from typing import Dict, Any, Tuple
19
 
20
  import cv2
21
  import numpy as np
@@ -42,6 +44,46 @@ DEMO_HAND_MASK_METHOD = "sam"
42
 
43
  app = Flask(__name__)
44
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
 
46
  def _allowed_file(filename: str) -> bool:
47
  return Path(filename).suffix.lower() in ALLOWED_EXTENSIONS
@@ -259,41 +301,38 @@ def _run_measurement(
259
  result_json_path = RESULTS_DIR / result_json_name
260
  _save_json(result_json_path, result)
261
 
262
- # --- Persist to Supabase ---
263
- photo_public_url = None
264
- result_public_url = None
265
- if upload_path and upload_path.exists():
266
- photo_public_url = upload_file(str(upload_path), f"photos/{upload_name}")
267
- if result_png_path.exists():
268
- result_public_url = upload_file(str(result_png_path), f"results/{result_png_name}")
269
-
270
- ring_size = result.get("ring_size", {})
271
- measurement_id = save_measurement({
272
- "run_id": run_id,
273
- "kol_name": kol_name,
274
- "mode": "single",
275
- "ring_model": ring_model,
276
- "finger_index": finger_index,
277
- "diameter_cm": result.get("finger_outer_diameter_cm"),
278
- "confidence": result.get("confidence"),
279
- "overall_best_size": ring_size.get("best_match"),
280
- "overall_range_min": ring_size.get("range_min"),
281
- "overall_range_max": ring_size.get("range_max"),
282
- "photo_url": photo_public_url,
283
- "result_url": result_public_url,
284
- "result_json": result,
285
- "fail_reason": result.get("fail_reason"),
286
- })
287
-
288
  payload = {
289
  "success": result.get("fail_reason") is None,
290
  "result": result,
291
  "result_image_url": f"/results/{result_png_name}",
292
  "input_image_url": input_image_url,
293
  "result_json_url": f"/results/{result_json_name}",
294
- "measurement_id": measurement_id,
295
  }
296
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
297
  return jsonify(payload)
298
 
299
 
@@ -354,14 +393,6 @@ def _run_multi_measurement(
354
  result_json_path = RESULTS_DIR / result_json_name
355
  _save_json(result_json_path, result)
356
 
357
- # --- Persist to Supabase ---
358
- photo_public_url = None
359
- result_public_url = None
360
- if upload_path and upload_path.exists():
361
- photo_public_url = upload_file(str(upload_path), f"photos/{upload_name}")
362
- if result_png_path.exists():
363
- result_public_url = upload_file(str(result_png_path), f"results/{result_png_name}")
364
-
365
  # Compute overall confidence from per-finger data
366
  confidences = [
367
  pf.get("confidence") for pf in per_finger.values()
@@ -369,22 +400,6 @@ def _run_multi_measurement(
369
  ]
370
  overall_confidence = min(confidences) if confidences else None
371
 
372
- measurement_id = save_measurement({
373
- "run_id": run_id,
374
- "kol_name": kol_name,
375
- "mode": "multi",
376
- "ring_model": ring_model,
377
- "overall_best_size": result.get("overall_best_size"),
378
- "overall_range_min": result.get("overall_range_min"),
379
- "overall_range_max": result.get("overall_range_max"),
380
- "per_finger": per_finger,
381
- "confidence": overall_confidence,
382
- "photo_url": photo_public_url,
383
- "result_url": result_public_url,
384
- "result_json": result,
385
- "fail_reason": result.get("fail_reason"),
386
- })
387
-
388
  payload = {
389
  "success": result.get("fail_reason") is None,
390
  "mode": "multi",
@@ -392,9 +407,29 @@ def _run_multi_measurement(
392
  "result_image_url": f"/results/{result_png_name}",
393
  "input_image_url": input_image_url,
394
  "result_json_url": f"/results/{result_json_name}",
395
- "measurement_id": measurement_id,
396
  }
397
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
398
  return jsonify(payload)
399
 
400
 
 
9
  import csv
10
  import io
11
  import json
12
+ import logging
13
  import os
14
  import re
15
  import sys
16
  import uuid
17
+ from concurrent.futures import ThreadPoolExecutor
18
  from datetime import datetime
19
  from pathlib import Path
20
+ from typing import Any, Dict, Optional, Tuple
21
 
22
  import cv2
23
  import numpy as np
 
44
 
45
  app = Flask(__name__)
46
 
47
+ logger = logging.getLogger(__name__)
48
+
49
+ # Persist to Supabase off the request thread: two storage uploads + a row
50
+ # insert add multi-second latency that the user would otherwise stare at
51
+ # after the measurement is already done. max_workers=2 is plenty — each
52
+ # request queues one task, and we don't need ordering.
53
+ _persist_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="supa-persist")
54
+
55
+
56
+ def _persist_measurement_async(
57
+ *,
58
+ upload_path: Optional[Path],
59
+ upload_name: str,
60
+ result_png_path: Path,
61
+ result_png_name: str,
62
+ record: Dict[str, Any],
63
+ ) -> None:
64
+ """Upload the photo + result PNG to Supabase Storage and insert the
65
+ measurement row. Designed to run in a worker thread — takes no request
66
+ state and logs (rather than raises) on failure so a broken Supabase
67
+ connection never poisons the measurement the user just saw.
68
+ """
69
+ def _task() -> None:
70
+ try:
71
+ photo_url = None
72
+ result_url = None
73
+ if upload_path and upload_path.exists():
74
+ photo_url = upload_file(str(upload_path), f"photos/{upload_name}")
75
+ if result_png_path.exists():
76
+ result_url = upload_file(str(result_png_path), f"results/{result_png_name}")
77
+ record_with_urls = dict(record)
78
+ record_with_urls["photo_url"] = photo_url
79
+ record_with_urls["result_url"] = result_url
80
+ save_measurement(record_with_urls)
81
+ except Exception as exc: # noqa: BLE001
82
+ logger.exception("Supabase persist failed for run %s: %s",
83
+ record.get("run_id"), exc)
84
+
85
+ _persist_executor.submit(_task)
86
+
87
 
88
  def _allowed_file(filename: str) -> bool:
89
  return Path(filename).suffix.lower() in ALLOWED_EXTENSIONS
 
301
  result_json_path = RESULTS_DIR / result_json_name
302
  _save_json(result_json_path, result)
303
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
304
  payload = {
305
  "success": result.get("fail_reason") is None,
306
  "result": result,
307
  "result_image_url": f"/results/{result_png_name}",
308
  "input_image_url": input_image_url,
309
  "result_json_url": f"/results/{result_json_name}",
 
310
  }
311
 
312
+ # Persist to Supabase in the background — do not block the response on
313
+ # storage uploads or DB inserts.
314
+ ring_size = result.get("ring_size", {})
315
+ _persist_measurement_async(
316
+ upload_path=upload_path,
317
+ upload_name=upload_name,
318
+ result_png_path=result_png_path,
319
+ result_png_name=result_png_name,
320
+ record={
321
+ "run_id": run_id,
322
+ "kol_name": kol_name,
323
+ "mode": "single",
324
+ "ring_model": ring_model,
325
+ "finger_index": finger_index,
326
+ "diameter_cm": result.get("finger_outer_diameter_cm"),
327
+ "confidence": result.get("confidence"),
328
+ "overall_best_size": ring_size.get("best_match"),
329
+ "overall_range_min": ring_size.get("range_min"),
330
+ "overall_range_max": ring_size.get("range_max"),
331
+ "result_json": result,
332
+ "fail_reason": result.get("fail_reason"),
333
+ },
334
+ )
335
+
336
  return jsonify(payload)
337
 
338
 
 
393
  result_json_path = RESULTS_DIR / result_json_name
394
  _save_json(result_json_path, result)
395
 
 
 
 
 
 
 
 
 
396
  # Compute overall confidence from per-finger data
397
  confidences = [
398
  pf.get("confidence") for pf in per_finger.values()
 
400
  ]
401
  overall_confidence = min(confidences) if confidences else None
402
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
403
  payload = {
404
  "success": result.get("fail_reason") is None,
405
  "mode": "multi",
 
407
  "result_image_url": f"/results/{result_png_name}",
408
  "input_image_url": input_image_url,
409
  "result_json_url": f"/results/{result_json_name}",
 
410
  }
411
 
412
+ # Persist to Supabase in the background.
413
+ _persist_measurement_async(
414
+ upload_path=upload_path,
415
+ upload_name=upload_name,
416
+ result_png_path=result_png_path,
417
+ result_png_name=result_png_name,
418
+ record={
419
+ "run_id": run_id,
420
+ "kol_name": kol_name,
421
+ "mode": "multi",
422
+ "ring_model": ring_model,
423
+ "overall_best_size": result.get("overall_best_size"),
424
+ "overall_range_min": result.get("overall_range_min"),
425
+ "overall_range_max": result.get("overall_range_max"),
426
+ "per_finger": per_finger,
427
+ "confidence": overall_confidence,
428
+ "result_json": result,
429
+ "fail_reason": result.get("fail_reason"),
430
+ },
431
+ )
432
+
433
  return jsonify(payload)
434
 
435
 
web_demo/templates/index.html CHANGED
@@ -26,7 +26,7 @@
26
  <span class="file-hint">JPG / PNG supported · 1080p or higher recommended</span>
27
  </label>
28
 
29
- <ul class="capture-tips">
30
  <li><strong>Turn on your phone's flash</strong>, it helps sharpen the finger edges.</li>
31
  <li><strong>Use plain white background</strong>, a sheet of paper works great.</li>
32
  <li><strong>Spread your fingers naturally</strong>, place the card beside your hand.</li>
 
26
  <span class="file-hint">JPG / PNG supported · 1080p or higher recommended</span>
27
  </label>
28
 
29
+ <ul class="capture-tips" hidden>
30
  <li><strong>Turn on your phone's flash</strong>, it helps sharpen the finger edges.</li>
31
  <li><strong>Use plain white background</strong>, a sheet of paper works great.</li>
32
  <li><strong>Spread your fingers naturally</strong>, place the card beside your hand.</li>