Spaces:
Running
Running
Upload folder using huggingface_hub
Browse files- AGENTS.md +1 -0
- CLAUDE.md +1 -0
- measure_finger.py +46 -0
- script/card_to_image_ratio.py +199 -0
- web_demo/static/app.js +2 -0
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:
|