feng-x commited on
Commit
53fb68e
·
verified ·
1 Parent(s): 8e8d804

Upload folder using huggingface_hub

Browse files
AGENTS.md CHANGED
@@ -213,6 +213,7 @@ For optimal results:
213
  - `hand_not_detected` — MediaPipe did not locate a hand
214
  - `card_not_detected` — classical or SAM card detector returned nothing
215
  - `card_not_parallel` — card detected but `scale_confidence ≤ 0.9` (too much perspective)
 
216
  - `finger_isolation_failed`, `finger_mask_too_small`, `contour_extraction_failed` — finger segmentation stages
217
  - `axis_estimation_failed` — landmarks missing or failed quality checks (NaN, collapsed, non-monotonic, below min length)
218
  - `zone_localization_failed` — ring zone could not be derived
 
213
  - `hand_not_detected` — MediaPipe did not locate a hand
214
  - `card_not_detected` — classical or SAM card detector returned nothing
215
  - `card_not_parallel` — card detected but `scale_confidence ≤ 0.9` (too much perspective)
216
+ - `card_too_small` — card detected but `longer_side_px / shorter_image_px < 0.25` (camera held too far from the table; see `doc/report/framing_ratio_survey.md`)
217
  - `finger_isolation_failed`, `finger_mask_too_small`, `contour_extraction_failed` — finger segmentation stages
218
  - `axis_estimation_failed` — landmarks missing or failed quality checks (NaN, collapsed, non-monotonic, below min length)
219
  - `zone_localization_failed` — ring zone could not be derived
CLAUDE.md CHANGED
@@ -213,6 +213,7 @@ For optimal results:
213
  - `hand_not_detected` — MediaPipe did not locate a hand
214
  - `card_not_detected` — classical or SAM card detector returned nothing
215
  - `card_not_parallel` — card detected but `scale_confidence ≤ 0.9` (too much perspective)
 
216
  - `finger_isolation_failed`, `finger_mask_too_small`, `contour_extraction_failed` — finger segmentation stages
217
  - `axis_estimation_failed` — landmarks missing or failed quality checks (NaN, collapsed, non-monotonic, below min length)
218
  - `zone_localization_failed` — ring zone could not be derived
 
213
  - `hand_not_detected` — MediaPipe did not locate a hand
214
  - `card_not_detected` — classical or SAM card detector returned nothing
215
  - `card_not_parallel` — card detected but `scale_confidence ≤ 0.9` (too much perspective)
216
+ - `card_too_small` — card detected but `longer_side_px / shorter_image_px < 0.25` (camera held too far from the table; see `doc/report/framing_ratio_survey.md`)
217
  - `finger_isolation_failed`, `finger_mask_too_small`, `contour_extraction_failed` — finger segmentation stages
218
  - `axis_estimation_failed` — landmarks missing or failed quality checks (NaN, collapsed, non-monotonic, below min length)
219
  - `zone_localization_failed` — ring zone could not be derived
measure_finger.py CHANGED
@@ -61,6 +61,14 @@ def apply_calibration(raw_diameter_cm: float) -> float:
61
  cal = _load_calibration()
62
  return cal["slope"] * raw_diameter_cm + cal["intercept"]
63
 
 
 
 
 
 
 
 
 
64
  # Type alias for finger selection
65
  FingerIndex = Literal["auto", "index", "middle", "ring", "pinky"]
66
 
@@ -672,6 +680,28 @@ def measure_finger(
672
  fail_reason="card_not_parallel",
673
  )
674
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
675
  # Phase 5: Finger isolation (hand already segmented in Phase 3).
676
  h_can, w_can = image_canonical.shape[:2]
677
  # Keep a reference to the raw SAM hand mask (pre-isolation polygon clip).
@@ -1302,6 +1332,22 @@ def measure_multi_finger(
1302
  )
1303
  return {"fail_reason": "card_not_parallel", "per_finger": {}, "fingers_measured": 0, "fingers_succeeded": 0}
1304
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1305
  card_frame = check_card_in_frame(card_result["corners"], image_canonical.shape)
1306
  if not card_frame["in_frame"]:
1307
  logger.warning("[multi] card near edge (min_margin=%.0fpx)",
 
61
  cal = _load_calibration()
62
  return cal["slope"] * raw_diameter_cm + cal["intercept"]
63
 
64
+ # Minimum ratio of (longer card side) / (shorter image side). Below this
65
+ # the camera is too far from the table: the card and finger occupy so few
66
+ # pixels that edge refinement noise and calibration extrapolation both
67
+ # start to dominate. See doc/report/framing_ratio_survey.md — calibration
68
+ # was fit at ~0.48, production median is ~0.33; 0.25 flags the egregious
69
+ # tail (~4% of historical uploads).
70
+ MIN_CARD_LONG_SIDE_RATIO = 0.25
71
+
72
  # Type alias for finger selection
73
  FingerIndex = Literal["auto", "index", "middle", "ring", "pinky"]
74
 
 
680
  fail_reason="card_not_parallel",
681
  )
682
 
683
+ longer_card_px = max(
684
+ float(card_result["width_px"]), float(card_result["height_px"])
685
+ )
686
+ shorter_image_px = float(min(image_canonical.shape[:2]))
687
+ card_long_side_ratio = longer_card_px / shorter_image_px
688
+ logger.info("card framing: long_side/short_image=%.3f", card_long_side_ratio)
689
+ if card_long_side_ratio < MIN_CARD_LONG_SIDE_RATIO:
690
+ logger.warning(
691
+ "card too small in frame (ratio=%.3f, required>=%.2f)",
692
+ card_long_side_ratio, MIN_CARD_LONG_SIDE_RATIO,
693
+ )
694
+ _write_card_failure_viz(
695
+ result_png_path, image_canonical, hand_data, card_result=card_result
696
+ )
697
+ return create_output(
698
+ card_detected=True,
699
+ finger_detected=False,
700
+ scale_px_per_cm=px_per_cm,
701
+ view_angle_ok=view_angle_ok,
702
+ fail_reason="card_too_small",
703
+ )
704
+
705
  # Phase 5: Finger isolation (hand already segmented in Phase 3).
706
  h_can, w_can = image_canonical.shape[:2]
707
  # Keep a reference to the raw SAM hand mask (pre-isolation polygon clip).
 
1332
  )
1333
  return {"fail_reason": "card_not_parallel", "per_finger": {}, "fingers_measured": 0, "fingers_succeeded": 0}
1334
 
1335
+ longer_card_px = max(
1336
+ float(card_result["width_px"]), float(card_result["height_px"])
1337
+ )
1338
+ shorter_image_px = float(min(image_canonical.shape[:2]))
1339
+ card_long_side_ratio = longer_card_px / shorter_image_px
1340
+ logger.info("[multi] card framing: long_side/short_image=%.3f", card_long_side_ratio)
1341
+ if card_long_side_ratio < MIN_CARD_LONG_SIDE_RATIO:
1342
+ logger.warning(
1343
+ "[multi] card too small in frame (ratio=%.3f, required>=%.2f)",
1344
+ card_long_side_ratio, MIN_CARD_LONG_SIDE_RATIO,
1345
+ )
1346
+ _write_card_failure_viz(
1347
+ result_png_path, image_canonical, hand_data, card_result=card_result
1348
+ )
1349
+ return {"fail_reason": "card_too_small", "per_finger": {}, "fingers_measured": 0, "fingers_succeeded": 0}
1350
+
1351
  card_frame = check_card_in_frame(card_result["corners"], image_canonical.shape)
1352
  if not card_frame["in_frame"]:
1353
  logger.warning("[multi] card near edge (min_margin=%.0fpx)",
script/card_to_image_ratio.py ADDED
@@ -0,0 +1,199 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Measure "longer card side / shorter image side" across an image dir.
3
+
4
+ This metric approximates how large the reference card is in the frame: a
5
+ low value means the camera was held far from the table (small card, thin
6
+ edges, poorer calibration fit); a high value means close framing. Used to
7
+ recommend a minimum framing ratio before the web demo accepts an upload.
8
+
9
+ Runs the same SAM hand + SAM card pipeline as the web demo. Prints per-image
10
+ values and a distribution summary (n, min, p10/25/50/75/90, max, mean, std,
11
+ a coarse histogram, and success/failure counts).
12
+
13
+ Usage:
14
+ source .venv/bin/activate
15
+ python3 script/card_to_image_ratio.py input/calibration_dataset/jpg
16
+ python3 script/card_to_image_ratio.py input/kol_success
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ import logging
22
+ import statistics as stats
23
+ import sys
24
+ import time
25
+ from pathlib import Path
26
+ from typing import List, Optional, Tuple
27
+
28
+ import cv2
29
+ import numpy as np
30
+
31
+ ROOT = Path(__file__).resolve().parents[1]
32
+ sys.path.insert(0, str(ROOT))
33
+
34
+ from src.finger_segmentation import segment_hand # noqa: E402
35
+ from src.sam_card_detection import ( # noqa: E402
36
+ detect_credit_card_sam_prompt,
37
+ suggest_card_seeds,
38
+ )
39
+
40
+ IMG_EXTS = {".jpg", ".jpeg", ".png"}
41
+
42
+
43
+ def _process_image(path: Path) -> Tuple[Optional[float], str]:
44
+ """Return (ratio_or_None, status_tag) for a single image."""
45
+ img = cv2.imread(str(path))
46
+ if img is None:
47
+ return None, "load_failed"
48
+
49
+ try:
50
+ hand_data = segment_hand(img)
51
+ except Exception as e:
52
+ return None, f"hand_error:{type(e).__name__}"
53
+ if hand_data is None:
54
+ return None, "hand_not_detected"
55
+
56
+ canonical = hand_data.get("canonical_image", img)
57
+ hand_mask = hand_data.get("mask")
58
+ landmarks = hand_data.get("landmarks")
59
+ if hand_mask is None or landmarks is None or len(landmarks) <= 9:
60
+ return None, "no_landmarks"
61
+
62
+ y_limit = int(round(landmarks[9, 1]))
63
+ seed_info = suggest_card_seeds(hand_mask, canonical.shape[:2], y_limit)
64
+ seeds = seed_info["kept"]
65
+ if not seeds:
66
+ return None, "no_seeds"
67
+
68
+ palm_c = np.mean(landmarks[[0, 5, 9, 13, 17], :2], axis=0)
69
+ negatives = [(int(round(palm_c[0])), int(round(palm_c[1])))]
70
+
71
+ try:
72
+ card = detect_credit_card_sam_prompt(
73
+ canonical,
74
+ seed_points=seeds,
75
+ negative_points=negatives,
76
+ hand_mask=hand_mask,
77
+ )
78
+ except Exception as e:
79
+ return None, f"card_error:{type(e).__name__}"
80
+ if card is None:
81
+ return None, "card_not_detected"
82
+
83
+ longer = max(float(card["width_px"]), float(card["height_px"]))
84
+ shorter_image = float(min(canonical.shape[0], canonical.shape[1]))
85
+ return longer / shorter_image, "ok"
86
+
87
+
88
+ def _describe(values: List[float], label: str) -> None:
89
+ if not values:
90
+ print(f"\n{label}: no successful measurements.")
91
+ return
92
+
93
+ values_sorted = sorted(values)
94
+ n = len(values_sorted)
95
+
96
+ def pct(p: float) -> float:
97
+ if n == 1:
98
+ return values_sorted[0]
99
+ k = (n - 1) * p
100
+ lo = int(k)
101
+ hi = min(lo + 1, n - 1)
102
+ frac = k - lo
103
+ return values_sorted[lo] * (1 - frac) + values_sorted[hi] * frac
104
+
105
+ print(f"\n=== {label} (n={n}) ===")
106
+ print(f" min = {min(values_sorted):.3f}")
107
+ print(f" p10 = {pct(0.10):.3f}")
108
+ print(f" p25 = {pct(0.25):.3f}")
109
+ print(f" median = {pct(0.50):.3f}")
110
+ print(f" p75 = {pct(0.75):.3f}")
111
+ print(f" p90 = {pct(0.90):.3f}")
112
+ print(f" max = {max(values_sorted):.3f}")
113
+ print(f" mean = {stats.mean(values_sorted):.3f}")
114
+ if n > 1:
115
+ print(f" std = {stats.stdev(values_sorted):.3f}")
116
+
117
+ # Coarse histogram, 0.05-wide bins across the realistic range.
118
+ lo_edge = 0.10
119
+ hi_edge = max(0.65, max(values_sorted) + 0.05)
120
+ bin_w = 0.05
121
+ edges = []
122
+ e = lo_edge
123
+ while e <= hi_edge + 1e-9:
124
+ edges.append(round(e, 2))
125
+ e += bin_w
126
+ counts = [0] * (len(edges) - 1)
127
+ under = over = 0
128
+ for v in values_sorted:
129
+ if v < edges[0]:
130
+ under += 1
131
+ continue
132
+ placed = False
133
+ for i in range(len(edges) - 1):
134
+ if edges[i] <= v < edges[i + 1]:
135
+ counts[i] += 1
136
+ placed = True
137
+ break
138
+ if not placed:
139
+ over += 1
140
+ print(" histogram:")
141
+ if under:
142
+ print(f" <{edges[0]:.2f} : {under}")
143
+ for i, c in enumerate(counts):
144
+ if c == 0:
145
+ continue
146
+ bar = "#" * c
147
+ print(f" [{edges[i]:.2f},{edges[i+1]:.2f}) : {c:2d} {bar}")
148
+ if over:
149
+ print(f" >={edges[-1]:.2f} : {over}")
150
+
151
+
152
+ def main() -> int:
153
+ ap = argparse.ArgumentParser()
154
+ ap.add_argument("image_dir", type=Path)
155
+ ap.add_argument("--limit", type=int, default=None,
156
+ help="optional cap on number of images processed")
157
+ args = ap.parse_args()
158
+
159
+ logging.getLogger().setLevel(logging.ERROR) # silence pipeline chatter
160
+
161
+ if not args.image_dir.is_dir():
162
+ print(f"Not a directory: {args.image_dir}")
163
+ return 1
164
+
165
+ images = sorted(
166
+ p for p in args.image_dir.iterdir()
167
+ if p.suffix.lower() in IMG_EXTS
168
+ )
169
+ if args.limit:
170
+ images = images[: args.limit]
171
+ if not images:
172
+ print(f"No images found in {args.image_dir}")
173
+ return 1
174
+
175
+ print(f"Processing {len(images)} images from {args.image_dir}")
176
+ print(f"{'#':>3} {'file':<60} {'ratio':>6} status")
177
+
178
+ ratios: List[float] = []
179
+ status_counts = {}
180
+ t_start = time.time()
181
+ for i, path in enumerate(images, 1):
182
+ t0 = time.time()
183
+ ratio, status = _process_image(path)
184
+ dt = time.time() - t0
185
+ status_counts[status] = status_counts.get(status, 0) + 1
186
+ ratio_str = f"{ratio:.3f}" if ratio is not None else " - "
187
+ print(f"{i:>3} {path.name:<60} {ratio_str:>6} {status} ({dt:.1f}s)")
188
+ if ratio is not None:
189
+ ratios.append(ratio)
190
+
191
+ total_dt = time.time() - t_start
192
+ print(f"\nElapsed: {total_dt:.1f}s ({total_dt / max(1, len(images)):.1f}s/img)")
193
+ print(f"Status summary: {status_counts}")
194
+ _describe(ratios, label=f"ratio = longer_card_px / shorter_image_px ({args.image_dir})")
195
+ return 0
196
+
197
+
198
+ if __name__ == "__main__":
199
+ sys.exit(main())
web_demo/static/app.js CHANGED
@@ -20,6 +20,8 @@ const failReasonMessageMap = {
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:
24
  "Hand not detected. Place your hand flat on a plain white background (e.g. a sheet of paper), and spread your fingers naturally.",
25
  finger_isolation_failed:
 
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
+ card_too_small:
24
+ "Card looks too small in the photo. Move your phone closer to the table so the card takes up a larger portion of the frame, then retake.",
25
  hand_not_detected:
26
  "Hand not detected. Place your hand flat on a plain white background (e.g. a sheet of paper), and spread your fingers naturally.",
27
  finger_isolation_failed: