Spaces:
Running
Running
Upload folder using huggingface_hub
Browse files- AGENTS.md +3 -3
- CLAUDE.md +3 -3
- measure_finger.py +6 -6
- script/card_aspect_and_scale_survey.py +214 -0
- src/sam_card_detection.py +9 -0
- web_demo/templates/admin.html +5 -3
AGENTS.md
CHANGED
|
@@ -212,8 +212,8 @@ For optimal results:
|
|
| 212 |
### Failure Modes (values of `fail_reason`)
|
| 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.
|
| 216 |
-
- `card_too_small` — card detected but `longer_side_px / shorter_image_px < 0.
|
| 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
|
|
@@ -252,5 +252,5 @@ The result PNG is written alongside every JSON output. With `--debug`, the same
|
|
| 252 |
|
| 253 |
- Functions raise on malformed inputs; `measure_finger()` maps exceptions to structured `fail_reason` values in the output dict.
|
| 254 |
- Realistic width range: 1.0–3.0 cm (typical 1.4–2.4 cm). Out-of-range widths log a warning but do not fail.
|
| 255 |
-
- Credit card aspect ratio tolerance: ±15% of 1.586. `scale_confidence > 0.
|
| 256 |
- Coordinate convention: OpenCV is `(row, col) = (y, x)`; most `src/geometry.py` helpers use `(x, y)`. Contours are `Nx2` in `(x, y)` format.
|
|
|
|
| 212 |
### Failure Modes (values of `fail_reason`)
|
| 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.95` (too much perspective)
|
| 216 |
+
- `card_too_small` — card detected but `longer_side_px / shorter_image_px < 0.30` (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
|
|
|
|
| 252 |
|
| 253 |
- Functions raise on malformed inputs; `measure_finger()` maps exceptions to structured `fail_reason` values in the output dict.
|
| 254 |
- Realistic width range: 1.0–3.0 cm (typical 1.4–2.4 cm). Out-of-range widths log a warning but do not fail.
|
| 255 |
+
- Credit card aspect ratio tolerance: ±15% of 1.586. `scale_confidence > 0.95` is required (hard fail `card_not_parallel` otherwise).
|
| 256 |
- Coordinate convention: OpenCV is `(row, col) = (y, x)`; most `src/geometry.py` helpers use `(x, y)`. Contours are `Nx2` in `(x, y)` format.
|
CLAUDE.md
CHANGED
|
@@ -212,8 +212,8 @@ For optimal results:
|
|
| 212 |
### Failure Modes (values of `fail_reason`)
|
| 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.
|
| 216 |
-
- `card_too_small` — card detected but `longer_side_px / shorter_image_px < 0.
|
| 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
|
|
@@ -252,5 +252,5 @@ The result PNG is written alongside every JSON output. With `--debug`, the same
|
|
| 252 |
|
| 253 |
- Functions raise on malformed inputs; `measure_finger()` maps exceptions to structured `fail_reason` values in the output dict.
|
| 254 |
- Realistic width range: 1.0–3.0 cm (typical 1.4–2.4 cm). Out-of-range widths log a warning but do not fail.
|
| 255 |
-
- Credit card aspect ratio tolerance: ±15% of 1.586. `scale_confidence > 0.
|
| 256 |
- Coordinate convention: OpenCV is `(row, col) = (y, x)`; most `src/geometry.py` helpers use `(x, y)`. Contours are `Nx2` in `(x, y)` format.
|
|
|
|
| 212 |
### Failure Modes (values of `fail_reason`)
|
| 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.95` (too much perspective)
|
| 216 |
+
- `card_too_small` — card detected but `longer_side_px / shorter_image_px < 0.30` (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
|
|
|
|
| 252 |
|
| 253 |
- Functions raise on malformed inputs; `measure_finger()` maps exceptions to structured `fail_reason` values in the output dict.
|
| 254 |
- Realistic width range: 1.0–3.0 cm (typical 1.4–2.4 cm). Out-of-range widths log a warning but do not fail.
|
| 255 |
+
- Credit card aspect ratio tolerance: ±15% of 1.586. `scale_confidence > 0.95` is required (hard fail `card_not_parallel` otherwise).
|
| 256 |
- Coordinate convention: OpenCV is `(row, col) = (y, x)`; most `src/geometry.py` helpers use `(x, y)`. Contours are `Nx2` in `(x, y)` format.
|
measure_finger.py
CHANGED
|
@@ -65,9 +65,9 @@ def apply_calibration(raw_diameter_cm: float) -> float:
|
|
| 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.
|
| 69 |
-
#
|
| 70 |
-
MIN_CARD_LONG_SIDE_RATIO = 0.
|
| 71 |
|
| 72 |
# Type alias for finger selection
|
| 73 |
FingerIndex = Literal["auto", "index", "middle", "ring", "pinky"]
|
|
@@ -663,11 +663,11 @@ def measure_finger(
|
|
| 663 |
)
|
| 664 |
logger.info("scale: %.2f px/cm (confidence=%.2f)", px_per_cm, scale_confidence)
|
| 665 |
|
| 666 |
-
view_angle_ok = scale_confidence > 0.
|
| 667 |
card_detected = True
|
| 668 |
|
| 669 |
if not view_angle_ok:
|
| 670 |
-
logger.warning("card not parallel to camera (scale_confidence=%.2f, required>0.
|
| 671 |
scale_confidence)
|
| 672 |
_write_card_failure_viz(
|
| 673 |
result_png_path, image_canonical, hand_data, card_result=card_result
|
|
@@ -1323,7 +1323,7 @@ def measure_multi_finger(
|
|
| 1323 |
_write_card_failure_viz(result_png_path, image_canonical, hand_data)
|
| 1324 |
return {"fail_reason": "card_not_detected", "per_finger": {}, "fingers_measured": 0, "fingers_succeeded": 0}
|
| 1325 |
px_per_cm, scale_confidence = compute_scale_factor(card_result["corners"])
|
| 1326 |
-
view_angle_ok = scale_confidence > 0.
|
| 1327 |
card_detected = True
|
| 1328 |
|
| 1329 |
if not view_angle_ok:
|
|
|
|
| 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.30 flags the left tail
|
| 69 |
+
# (~25% of historical uploads cluster below this in kol_success).
|
| 70 |
+
MIN_CARD_LONG_SIDE_RATIO = 0.30
|
| 71 |
|
| 72 |
# Type alias for finger selection
|
| 73 |
FingerIndex = Literal["auto", "index", "middle", "ring", "pinky"]
|
|
|
|
| 663 |
)
|
| 664 |
logger.info("scale: %.2f px/cm (confidence=%.2f)", px_per_cm, scale_confidence)
|
| 665 |
|
| 666 |
+
view_angle_ok = scale_confidence > 0.95
|
| 667 |
card_detected = True
|
| 668 |
|
| 669 |
if not view_angle_ok:
|
| 670 |
+
logger.warning("card not parallel to camera (scale_confidence=%.2f, required>0.95)",
|
| 671 |
scale_confidence)
|
| 672 |
_write_card_failure_viz(
|
| 673 |
result_png_path, image_canonical, hand_data, card_result=card_result
|
|
|
|
| 1323 |
_write_card_failure_viz(result_png_path, image_canonical, hand_data)
|
| 1324 |
return {"fail_reason": "card_not_detected", "per_finger": {}, "fingers_measured": 0, "fingers_succeeded": 0}
|
| 1325 |
px_per_cm, scale_confidence = compute_scale_factor(card_result["corners"])
|
| 1326 |
+
view_angle_ok = scale_confidence > 0.95
|
| 1327 |
card_detected = True
|
| 1328 |
|
| 1329 |
if not view_angle_ok:
|
script/card_aspect_and_scale_survey.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Measure detected card aspect ratio and scale_confidence across an image dir.
|
| 3 |
+
|
| 4 |
+
Two metrics are captured per image (both come from the SAM-detected card's
|
| 5 |
+
4 corner points after order_corners):
|
| 6 |
+
|
| 7 |
+
aspect_ratio = max(width_px, height_px) / min(width_px, height_px)
|
| 8 |
+
Compared to the true credit-card aspect 1.586.
|
| 9 |
+
scale_confidence = 1 - |px_per_cm_w - px_per_cm_h| / max(...)
|
| 10 |
+
Consistency between the two independent scale estimates
|
| 11 |
+
you get from the W and H sides; falls below 1.0 whenever
|
| 12 |
+
the detected aspect deviates from 1.586 (perspective tilt,
|
| 13 |
+
SAM mask error on one edge, non-standard card).
|
| 14 |
+
|
| 15 |
+
Runs the same SAM hand + SAM card pipeline as the web demo. Prints per-image
|
| 16 |
+
values and distribution summaries (n, min, p05/10/25/50/75/90/95, max,
|
| 17 |
+
mean, std, coarse histograms, success/failure counts).
|
| 18 |
+
|
| 19 |
+
Usage:
|
| 20 |
+
source .venv/bin/activate
|
| 21 |
+
python3 script/card_aspect_and_scale_survey.py input/kol_success
|
| 22 |
+
"""
|
| 23 |
+
from __future__ import annotations
|
| 24 |
+
|
| 25 |
+
import argparse
|
| 26 |
+
import logging
|
| 27 |
+
import statistics as stats
|
| 28 |
+
import sys
|
| 29 |
+
import time
|
| 30 |
+
from pathlib import Path
|
| 31 |
+
from typing import List, Optional, Tuple
|
| 32 |
+
|
| 33 |
+
import cv2
|
| 34 |
+
import numpy as np
|
| 35 |
+
|
| 36 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 37 |
+
sys.path.insert(0, str(ROOT))
|
| 38 |
+
|
| 39 |
+
from src.card_detection import compute_scale_factor # noqa: E402
|
| 40 |
+
from src.finger_segmentation import segment_hand # noqa: E402
|
| 41 |
+
from src.sam_card_detection import ( # noqa: E402
|
| 42 |
+
detect_credit_card_sam_prompt,
|
| 43 |
+
suggest_card_seeds,
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
IMG_EXTS = {".jpg", ".jpeg", ".png"}
|
| 47 |
+
TRUE_ASPECT = 1.586 # ISO/IEC 7810 ID-1: 85.60 / 53.98
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _process_image(path: Path) -> Tuple[Optional[float], Optional[float], str]:
|
| 51 |
+
"""Return (aspect_ratio, scale_confidence, status) for a single image."""
|
| 52 |
+
img = cv2.imread(str(path))
|
| 53 |
+
if img is None:
|
| 54 |
+
return None, None, "load_failed"
|
| 55 |
+
|
| 56 |
+
try:
|
| 57 |
+
hand_data = segment_hand(img)
|
| 58 |
+
except Exception as e:
|
| 59 |
+
return None, None, f"hand_error:{type(e).__name__}"
|
| 60 |
+
if hand_data is None:
|
| 61 |
+
return None, None, "hand_not_detected"
|
| 62 |
+
|
| 63 |
+
canonical = hand_data.get("canonical_image", img)
|
| 64 |
+
hand_mask = hand_data.get("mask")
|
| 65 |
+
landmarks = hand_data.get("landmarks")
|
| 66 |
+
if hand_mask is None or landmarks is None or len(landmarks) <= 9:
|
| 67 |
+
return None, None, "no_landmarks"
|
| 68 |
+
|
| 69 |
+
y_limit = int(round(landmarks[9, 1]))
|
| 70 |
+
seed_info = suggest_card_seeds(hand_mask, canonical.shape[:2], y_limit)
|
| 71 |
+
seeds = seed_info["kept"]
|
| 72 |
+
if not seeds:
|
| 73 |
+
return None, None, "no_seeds"
|
| 74 |
+
|
| 75 |
+
palm_c = np.mean(landmarks[[0, 5, 9, 13, 17], :2], axis=0)
|
| 76 |
+
negatives = [(int(round(palm_c[0])), int(round(palm_c[1])))]
|
| 77 |
+
|
| 78 |
+
try:
|
| 79 |
+
card = detect_credit_card_sam_prompt(
|
| 80 |
+
canonical,
|
| 81 |
+
seed_points=seeds,
|
| 82 |
+
negative_points=negatives,
|
| 83 |
+
hand_mask=hand_mask,
|
| 84 |
+
)
|
| 85 |
+
except Exception as e:
|
| 86 |
+
return None, None, f"card_error:{type(e).__name__}"
|
| 87 |
+
if card is None:
|
| 88 |
+
return None, None, "card_not_detected"
|
| 89 |
+
|
| 90 |
+
aspect = float(card["aspect_ratio"])
|
| 91 |
+
_, scale_conf = compute_scale_factor(card["corners"])
|
| 92 |
+
return aspect, float(scale_conf), "ok"
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def _percentiles(values_sorted: List[float], p: float) -> float:
|
| 96 |
+
n = len(values_sorted)
|
| 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 |
+
|
| 106 |
+
def _describe(values: List[float], label: str, *, fmt: str = "{:.3f}",
|
| 107 |
+
hist_lo: float = 0.0, hist_hi: float = 1.0,
|
| 108 |
+
hist_bin: float = 0.05) -> None:
|
| 109 |
+
if not values:
|
| 110 |
+
print(f"\n{label}: no successful measurements.")
|
| 111 |
+
return
|
| 112 |
+
vs = sorted(values)
|
| 113 |
+
n = len(vs)
|
| 114 |
+
print(f"\n=== {label} (n={n}) ===")
|
| 115 |
+
for tag, p in [("min", 0.0), ("p05", 0.05), ("p10", 0.10), ("p25", 0.25),
|
| 116 |
+
("median", 0.50), ("p75", 0.75), ("p90", 0.90), ("p95", 0.95),
|
| 117 |
+
("max", 1.0)]:
|
| 118 |
+
v = vs[0] if p == 0 else vs[-1] if p == 1 else _percentiles(vs, p)
|
| 119 |
+
print(f" {tag:6s} = " + fmt.format(v))
|
| 120 |
+
print(f" mean = " + fmt.format(stats.mean(vs)))
|
| 121 |
+
if n > 1:
|
| 122 |
+
print(f" std = " + fmt.format(stats.stdev(vs)))
|
| 123 |
+
|
| 124 |
+
# Coarse histogram
|
| 125 |
+
edges = []
|
| 126 |
+
e = hist_lo
|
| 127 |
+
while e <= hist_hi + 1e-9:
|
| 128 |
+
edges.append(round(e, 4))
|
| 129 |
+
e += hist_bin
|
| 130 |
+
counts = [0] * (len(edges) - 1)
|
| 131 |
+
under = over = 0
|
| 132 |
+
for v in vs:
|
| 133 |
+
if v < edges[0]:
|
| 134 |
+
under += 1
|
| 135 |
+
continue
|
| 136 |
+
placed = False
|
| 137 |
+
for i in range(len(edges) - 1):
|
| 138 |
+
if edges[i] <= v < edges[i + 1]:
|
| 139 |
+
counts[i] += 1
|
| 140 |
+
placed = True
|
| 141 |
+
break
|
| 142 |
+
if not placed:
|
| 143 |
+
over += 1
|
| 144 |
+
print(" histogram:")
|
| 145 |
+
if under:
|
| 146 |
+
print(f" <{fmt.format(edges[0])} : {under}")
|
| 147 |
+
for i, c in enumerate(counts):
|
| 148 |
+
if c == 0:
|
| 149 |
+
continue
|
| 150 |
+
bar = "#" * c
|
| 151 |
+
print(f" [{fmt.format(edges[i])},{fmt.format(edges[i+1])}) : {c:2d} {bar}")
|
| 152 |
+
if over:
|
| 153 |
+
print(f" >={fmt.format(edges[-1])} : {over}")
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def main() -> int:
|
| 157 |
+
ap = argparse.ArgumentParser()
|
| 158 |
+
ap.add_argument("image_dir", type=Path)
|
| 159 |
+
ap.add_argument("--limit", type=int, default=None,
|
| 160 |
+
help="optional cap on number of images processed")
|
| 161 |
+
args = ap.parse_args()
|
| 162 |
+
|
| 163 |
+
logging.getLogger().setLevel(logging.ERROR)
|
| 164 |
+
|
| 165 |
+
if not args.image_dir.is_dir():
|
| 166 |
+
print(f"Not a directory: {args.image_dir}")
|
| 167 |
+
return 1
|
| 168 |
+
|
| 169 |
+
images = sorted(
|
| 170 |
+
p for p in args.image_dir.iterdir() if p.suffix.lower() in IMG_EXTS
|
| 171 |
+
)
|
| 172 |
+
if args.limit:
|
| 173 |
+
images = images[: args.limit]
|
| 174 |
+
if not images:
|
| 175 |
+
print(f"No images found in {args.image_dir}")
|
| 176 |
+
return 1
|
| 177 |
+
|
| 178 |
+
print(f"Processing {len(images)} images from {args.image_dir}")
|
| 179 |
+
print(f"{'#':>3} {'file':<60} {'aspect':>7} {'sc':>5} status")
|
| 180 |
+
|
| 181 |
+
aspects: List[float] = []
|
| 182 |
+
confs: List[float] = []
|
| 183 |
+
deltas: List[float] = [] # |aspect - 1.586| / 1.586
|
| 184 |
+
status_counts = {}
|
| 185 |
+
t_start = time.time()
|
| 186 |
+
for i, path in enumerate(images, 1):
|
| 187 |
+
t0 = time.time()
|
| 188 |
+
aspect, sc, status = _process_image(path)
|
| 189 |
+
dt = time.time() - t0
|
| 190 |
+
status_counts[status] = status_counts.get(status, 0) + 1
|
| 191 |
+
a_str = f"{aspect:.3f}" if aspect is not None else " - "
|
| 192 |
+
c_str = f"{sc:.3f}" if sc is not None else " - "
|
| 193 |
+
print(f"{i:>3} {path.name:<60} {a_str:>7} {c_str:>5} {status} ({dt:.1f}s)")
|
| 194 |
+
if aspect is not None:
|
| 195 |
+
aspects.append(aspect)
|
| 196 |
+
deltas.append(abs(aspect - TRUE_ASPECT) / TRUE_ASPECT)
|
| 197 |
+
if sc is not None:
|
| 198 |
+
confs.append(sc)
|
| 199 |
+
|
| 200 |
+
total_dt = time.time() - t_start
|
| 201 |
+
print(f"\nElapsed: {total_dt:.1f}s ({total_dt / max(1, len(images)):.1f}s/img)")
|
| 202 |
+
print(f"Status summary: {status_counts}")
|
| 203 |
+
|
| 204 |
+
_describe(aspects, label="aspect_ratio (true = 1.586)",
|
| 205 |
+
hist_lo=1.30, hist_hi=1.85, hist_bin=0.05)
|
| 206 |
+
_describe(deltas, label="|aspect - 1.586| / 1.586 (current gate ≤ 0.150)",
|
| 207 |
+
fmt="{:.4f}", hist_lo=0.0, hist_hi=0.20, hist_bin=0.02)
|
| 208 |
+
_describe(confs, label="scale_confidence (current gate > 0.90)",
|
| 209 |
+
hist_lo=0.80, hist_hi=1.00, hist_bin=0.02)
|
| 210 |
+
return 0
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
if __name__ == "__main__":
|
| 214 |
+
sys.exit(main())
|
src/sam_card_detection.py
CHANGED
|
@@ -34,6 +34,13 @@ logger = logging.getLogger(__name__)
|
|
| 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
|
|
@@ -91,6 +98,8 @@ def _score_card_mask(
|
|
| 91 |
overlap = float(np.logical_and(hull_bool, hand_bool).sum())
|
| 92 |
if overlap / hand_area > MAX_HAND_OVERLAP_RATIO:
|
| 93 |
return None
|
|
|
|
|
|
|
| 94 |
|
| 95 |
rect = cv2.minAreaRect(contour)
|
| 96 |
box = cv2.boxPoints(rect)
|
|
|
|
| 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 |
+
# Reject candidates whose convex hull is "fattened" by hand-shaped indentations.
|
| 38 |
+
# A real card mask is convex, so hull == mask and (hull \ mask) ∩ hand is ~0.
|
| 39 |
+
# When SAM segments a chunk of background (e.g. paper towel) bordered by the
|
| 40 |
+
# hand, the mask has a hand-shaped notch on one side; the hull closes that
|
| 41 |
+
# notch and adds hand pixels. Empirically: real-card winners measure 0.000,
|
| 42 |
+
# paper-towel false positives measure ~0.10+.
|
| 43 |
+
MAX_HULL_HAND_FILL_RATIO = 0.05
|
| 44 |
# SAM-specific upper bound on card area. Tighter than the shared
|
| 45 |
# MAX_CARD_AREA_RATIO (0.5) because SAM happily returns whole-background
|
| 46 |
# segments (ceilings, walls) as a single rectangular-ish mask when no card
|
|
|
|
| 98 |
overlap = float(np.logical_and(hull_bool, hand_bool).sum())
|
| 99 |
if overlap / hand_area > MAX_HAND_OVERLAP_RATIO:
|
| 100 |
return None
|
| 101 |
+
if mask_area > 0 and overlap / mask_area > MAX_HULL_HAND_FILL_RATIO:
|
| 102 |
+
return None
|
| 103 |
|
| 104 |
rect = cv2.minAreaRect(contour)
|
| 105 |
box = cv2.boxPoints(rect)
|
web_demo/templates/admin.html
CHANGED
|
@@ -111,6 +111,7 @@
|
|
| 111 |
<tr>
|
| 112 |
<th>KOL</th>
|
| 113 |
<th>Date</th>
|
|
|
|
| 114 |
<th>Photo</th>
|
| 115 |
<th>Index</th>
|
| 116 |
<th>Middle</th>
|
|
@@ -121,7 +122,7 @@
|
|
| 121 |
</tr>
|
| 122 |
</thead>
|
| 123 |
<tbody id="tableBody">
|
| 124 |
-
<tr><td colspan="
|
| 125 |
</tbody>
|
| 126 |
</table>
|
| 127 |
</div>
|
|
@@ -199,6 +200,7 @@
|
|
| 199 |
return `<tr data-id="${r.id}">
|
| 200 |
<td><strong>${r.kol_name || "-"}</strong></td>
|
| 201 |
<td>${fmtDate(r.created_at)}</td>
|
|
|
|
| 202 |
<td>${photoThumb}</td>
|
| 203 |
<td class="finger-cell">${fmtFinger(pf, "index")}</td>
|
| 204 |
<td class="finger-cell">${fmtFinger(pf, "middle")}</td>
|
|
@@ -216,7 +218,7 @@
|
|
| 216 |
if (currentPage < 1) currentPage = 1;
|
| 217 |
if (total === 0) {
|
| 218 |
countLabel.textContent = "0 records";
|
| 219 |
-
tbody.innerHTML = '<tr><td colspan="
|
| 220 |
} else {
|
| 221 |
const start = (currentPage - 1) * PAGE_SIZE;
|
| 222 |
const slice = allRows.slice(start, start + PAGE_SIZE);
|
|
@@ -241,7 +243,7 @@
|
|
| 241 |
currentPage = 1;
|
| 242 |
renderPage();
|
| 243 |
} catch (e) {
|
| 244 |
-
tbody.innerHTML = `<tr><td colspan="
|
| 245 |
}
|
| 246 |
};
|
| 247 |
|
|
|
|
| 111 |
<tr>
|
| 112 |
<th>KOL</th>
|
| 113 |
<th>Date</th>
|
| 114 |
+
<th>Model</th>
|
| 115 |
<th>Photo</th>
|
| 116 |
<th>Index</th>
|
| 117 |
<th>Middle</th>
|
|
|
|
| 122 |
</tr>
|
| 123 |
</thead>
|
| 124 |
<tbody id="tableBody">
|
| 125 |
+
<tr><td colspan="10" class="empty">Loading...</td></tr>
|
| 126 |
</tbody>
|
| 127 |
</table>
|
| 128 |
</div>
|
|
|
|
| 200 |
return `<tr data-id="${r.id}">
|
| 201 |
<td><strong>${r.kol_name || "-"}</strong></td>
|
| 202 |
<td>${fmtDate(r.created_at)}</td>
|
| 203 |
+
<td>${r.ring_model || "-"}</td>
|
| 204 |
<td>${photoThumb}</td>
|
| 205 |
<td class="finger-cell">${fmtFinger(pf, "index")}</td>
|
| 206 |
<td class="finger-cell">${fmtFinger(pf, "middle")}</td>
|
|
|
|
| 218 |
if (currentPage < 1) currentPage = 1;
|
| 219 |
if (total === 0) {
|
| 220 |
countLabel.textContent = "0 records";
|
| 221 |
+
tbody.innerHTML = '<tr><td colspan="10" class="empty">No measurements yet</td></tr>';
|
| 222 |
} else {
|
| 223 |
const start = (currentPage - 1) * PAGE_SIZE;
|
| 224 |
const slice = allRows.slice(start, start + PAGE_SIZE);
|
|
|
|
| 243 |
currentPage = 1;
|
| 244 |
renderPage();
|
| 245 |
} catch (e) {
|
| 246 |
+
tbody.innerHTML = `<tr><td colspan="10" class="empty">Error loading data: ${e.message}</td></tr>`;
|
| 247 |
}
|
| 248 |
};
|
| 249 |
|