feng-x commited on
Commit
8bc7d2f
·
verified ·
1 Parent(s): 347d1a8

Upload folder using huggingface_hub

Browse files
README.md CHANGED
@@ -11,17 +11,37 @@ app_port: 7860
11
 
12
  Local computer-vision CLI tool that measures **finger outer diameter** from a single image using a **credit card** as scale reference.
13
 
 
 
 
 
 
14
  ## What it does
15
  - Detects a credit card and computes `px/cm` scale.
16
  - Detects hand/finger with MediaPipe.
17
  - Measures finger width in the ring-wearing zone.
 
18
  - Supports dual edge modes:
19
  - `contour` (v0 baseline)
20
- - `sobel` (v1 refinement)
21
  - `auto` (default, Sobel with quality fallback)
22
  - `compare` (returns both method stats)
23
  - Writes JSON output and always writes a result PNG next to it.
24
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  ## Install
26
  ```bash
27
  python -m venv .venv
@@ -42,6 +62,9 @@ python measure_finger.py --input image.jpg --output output/result.json --debug
42
  # Finger selection
43
  python measure_finger.py --input image.jpg --output output/result.json --finger-index ring
44
 
 
 
 
45
  # Force method
46
  python measure_finger.py --input image.jpg --output output/result.json --edge-method contour
47
  python measure_finger.py --input image.jpg --output output/result.json --edge-method sobel
@@ -54,82 +77,51 @@ python measure_finger.py --input image.jpg --output output/result.json \
54
  --edge-method sobel --sobel-threshold 15 --sobel-kernel-size 3 --no-subpixel
55
  ```
56
 
57
- ## CLI flags (current)
58
- - `--input` (required)
59
- - `--output` (required)
60
- - `--debug` (boolean; saves intermediate debug folders)
61
- - `--save-intermediate`
62
- - `--finger-index {auto,index,middle,ring,pinky}` (default `index`)
63
- - `--confidence-threshold` (default `0.7`)
64
- - `--edge-method {auto,contour,sobel,compare}` (default `auto`)
65
- - `--sobel-threshold` (default `15.0`)
66
- - `--sobel-kernel-size {3,5,7}` (default `3`)
67
- - `--no-subpixel`
68
- - `--skip-card-detection` (testing only)
 
 
69
 
70
  ## Output JSON
71
  ```json
72
  {
73
  "finger_outer_diameter_cm": 1.78,
74
  "confidence": 0.91,
75
- "scale_px_per_cm": 203.46,
76
  "quality_flags": {
77
  "card_detected": true,
78
  "finger_detected": true,
79
  "view_angle_ok": true
80
  },
81
  "fail_reason": null,
82
- "edge_method_used": "contour_fallback",
83
- "method_comparison": {
84
- "contour": {
85
- "width_cm": 1.82,
86
- "width_px": 371.2,
87
- "std_dev_px": 3.8,
88
- "coefficient_variation": 0.01,
89
- "num_samples": 20,
90
- "method": "contour"
91
- },
92
- "sobel": {
93
- "width_cm": 1.78,
94
- "width_px": 362.0,
95
- "std_dev_px": 3.1,
96
- "coefficient_variation": 0.008,
97
- "num_samples": 140,
98
- "subpixel_used": true,
99
- "success_rate": 0.42,
100
- "edge_quality_score": 0.81,
101
- "method": "sobel"
102
- },
103
- "difference": {
104
- "absolute_cm": -0.04,
105
- "absolute_px": -9.2,
106
- "relative_pct": -2.2,
107
- "precision_improvement": 0.7
108
- },
109
- "recommendation": {
110
- "use_sobel": true,
111
- "reason": "quality_acceptable",
112
- "preferred_method": "sobel"
113
- },
114
- "quality_comparison": {
115
- "contour_cv": 0.01,
116
- "sobel_cv": 0.008,
117
- "sobel_quality_score": 0.81,
118
- "sobel_gradient_strength": 0.82,
119
- "sobel_consistency": 0.42,
120
- "sobel_smoothness": 0.91,
121
- "sobel_symmetry": 0.95
122
- }
123
- }
124
  }
125
  ```
126
 
127
  Notes:
 
128
  - `edge_method_used` and `method_comparison` are optional (present when relevant).
129
- - Result image path is auto-derived: `output/result.json` -> `output/result.png`.
130
 
131
- ## Documentation map
132
- - Requirement docs: `doc/v{i}/PRD.md`, `doc/v{i}/Plan.md`, `doc/v{i}/Progress.md`
133
- - Algorithms index: `doc/algorithms/README.md`
134
- - Scripts: `script/README.md`
135
- - Web demo: `web_demo/README.md`
 
 
 
 
 
 
11
 
12
  Local computer-vision CLI tool that measures **finger outer diameter** from a single image using a **credit card** as scale reference.
13
 
14
+ ## Live Demo
15
+ - Hugging Face Space: [https://huggingface.co/spaces/feng-x/ring-sizer](https://huggingface.co/spaces/feng-x/ring-sizer)
16
+ - Anyone can try the hosted web demo directly in the browser.
17
+ ![Ring Size CV Web Demo Screenshot](doc/assets/ring-size-cv-demo.jpg)
18
+
19
  ## What it does
20
  - Detects a credit card and computes `px/cm` scale.
21
  - Detects hand/finger with MediaPipe.
22
  - Measures finger width in the ring-wearing zone.
23
+ - **Regression calibration** corrects systematic over-measurement (MAE: 0.158 → 0.060 cm).
24
  - Supports dual edge modes:
25
  - `contour` (v0 baseline)
26
+ - `sobel` (v1 sub-pixel refinement)
27
  - `auto` (default, Sobel with quality fallback)
28
  - `compare` (returns both method stats)
29
  - Writes JSON output and always writes a result PNG next to it.
30
 
31
+ ## Accuracy
32
+
33
+ Validated on 10 subjects × 3 fingers × 2 photos = 60 measurements against caliper ground truth.
34
+
35
+ | Metric | Before Calibration | After Calibration |
36
+ |--------|-------------------|-------------------|
37
+ | MAE | 0.158 cm | **0.060 cm** |
38
+ | RMSE | 0.176 cm | **0.075 cm** |
39
+ | Max error | 0.347 cm | **0.174 cm** |
40
+
41
+ Pipeline stability: card detection CV = 0.44%, shot-to-shot repeatability = 0.028 cm.
42
+
43
+ See full report: [`doc/report/calibration_report.md`](doc/report/calibration_report.md)
44
+
45
  ## Install
46
  ```bash
47
  python -m venv .venv
 
62
  # Finger selection
63
  python measure_finger.py --input image.jpg --output output/result.json --finger-index ring
64
 
65
+ # Disable calibration (raw measurement only)
66
+ python measure_finger.py --input image.jpg --output output/result.json --no-calibration
67
+
68
  # Force method
69
  python measure_finger.py --input image.jpg --output output/result.json --edge-method contour
70
  python measure_finger.py --input image.jpg --output output/result.json --edge-method sobel
 
77
  --edge-method sobel --sobel-threshold 15 --sobel-kernel-size 3 --no-subpixel
78
  ```
79
 
80
+ ## CLI flags
81
+ | Flag | Values | Default | Description |
82
+ |------|--------|---------|-------------|
83
+ | `--input` | path | *(required)* | Input image (JPG/PNG) |
84
+ | `--output` | path | *(required)* | Output JSON path |
85
+ | `--debug` | flag | false | Save intermediate debug images |
86
+ | `--finger-index` | auto, index, middle, ring, pinky | index | Which finger to measure |
87
+ | `--confidence-threshold` | float | 0.7 | Minimum confidence threshold |
88
+ | `--edge-method` | auto, contour, sobel, compare | auto | Edge detection method |
89
+ | `--sobel-threshold` | float | 15.0 | Minimum gradient magnitude |
90
+ | `--sobel-kernel-size` | 3, 5, 7 | 3 | Sobel kernel size |
91
+ | `--no-subpixel` | flag | false | Disable sub-pixel refinement |
92
+ | `--no-calibration` | flag | false | Output raw measurement only |
93
+ | `--skip-card-detection` | flag | false | Testing only |
94
 
95
  ## Output JSON
96
  ```json
97
  {
98
  "finger_outer_diameter_cm": 1.78,
99
  "confidence": 0.91,
100
+ "scale_px_per_cm": 128.03,
101
  "quality_flags": {
102
  "card_detected": true,
103
  "finger_detected": true,
104
  "view_angle_ok": true
105
  },
106
  "fail_reason": null,
107
+ "edge_method_used": "sobel",
108
+ "raw_diameter_cm": 1.92,
109
+ "calibration_applied": true
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  }
111
  ```
112
 
113
  Notes:
114
+ - `raw_diameter_cm` is the pre-calibration measurement (present when calibration is applied).
115
  - `edge_method_used` and `method_comparison` are optional (present when relevant).
116
+ - Result image path is auto-derived: `output/result.json` `output/result.png`.
117
 
118
+ ## Documentation
119
+ | Path | Contents |
120
+ |------|----------|
121
+ | [`doc/v0/`](doc/v0/) | v0 PRD, Plan, Progress (contour baseline) |
122
+ | [`doc/v1/`](doc/v1/) | v1 PRD, Plan, Progress (Sobel edge refinement) |
123
+ | [`doc/v2/`](doc/v2/) | v2 Plan, Progress (calibration & regression) |
124
+ | [`doc/report/`](doc/report/) | Validation & calibration report |
125
+ | [`doc/algorithms/`](doc/algorithms/) | Algorithm documentation |
126
+ | [`script/`](script/) | Batch measurement & analysis scripts |
127
+ | [`web_demo/`](web_demo/) | Web demo (Flask) |
measure_finger.py CHANGED
@@ -32,6 +32,19 @@ from src.confidence import (
32
  )
33
  from src.debug_observer import draw_comprehensive_edge_overlay
34
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  # Type alias for finger selection
36
  FingerIndex = Literal["auto", "index", "middle", "ring", "pinky"]
37
 
@@ -117,6 +130,13 @@ Examples:
117
  help="Disable sub-pixel edge refinement",
118
  )
119
 
 
 
 
 
 
 
 
120
  # Testing/debugging options
121
  parser.add_argument(
122
  "--skip-card-detection",
@@ -331,6 +351,17 @@ def measure_finger(
331
  view_angle_ok = scale_confidence > 0.9
332
  card_detected = True
333
 
 
 
 
 
 
 
 
 
 
 
 
334
  # Phase 5: Finger isolation (hand already segmented in Phase 3)
335
  h_can, w_can = image_canonical.shape[:2]
336
  finger_data = isolate_finger(hand_data, finger=finger_index, image_shape=(h_can, w_can))
@@ -745,6 +776,16 @@ def main() -> int:
745
  skip_card_detection=args.skip_card_detection,
746
  )
747
 
 
 
 
 
 
 
 
 
 
 
748
  # Save output
749
  save_output(result, args.output)
750
  print(f"Results saved to: {args.output}")
@@ -755,6 +796,8 @@ def main() -> int:
755
  return 1
756
  else:
757
  print(f"Finger diameter: {result['finger_outer_diameter_cm']} cm")
 
 
758
  print(f"Confidence: {result['confidence']}")
759
  return 0
760
 
 
32
  )
33
  from src.debug_observer import draw_comprehensive_edge_overlay
34
 
35
+ # Calibration coefficients (from regression on 60 measurements)
36
+ _CALIBRATION_PATH = Path(__file__).parent / "src" / "calibration.json"
37
+
38
+ def _load_calibration() -> dict:
39
+ """Load calibration coefficients from JSON."""
40
+ with open(_CALIBRATION_PATH) as f:
41
+ return json.load(f)
42
+
43
+ def apply_calibration(raw_diameter_cm: float) -> float:
44
+ """Apply linear regression calibration: actual = slope * raw + intercept."""
45
+ cal = _load_calibration()
46
+ return cal["slope"] * raw_diameter_cm + cal["intercept"]
47
+
48
  # Type alias for finger selection
49
  FingerIndex = Literal["auto", "index", "middle", "ring", "pinky"]
50
 
 
130
  help="Disable sub-pixel edge refinement",
131
  )
132
 
133
+ # Calibration
134
+ parser.add_argument(
135
+ "--no-calibration",
136
+ action="store_true",
137
+ help="Disable regression calibration (output raw measurement only)",
138
+ )
139
+
140
  # Testing/debugging options
141
  parser.add_argument(
142
  "--skip-card-detection",
 
351
  view_angle_ok = scale_confidence > 0.9
352
  card_detected = True
353
 
354
+ # Reject if card is not parallel enough to camera
355
+ if not view_angle_ok:
356
+ print(f"Card not parallel to camera (scale_confidence={scale_confidence:.2f}, required>0.9)")
357
+ return create_output(
358
+ card_detected=True,
359
+ finger_detected=False,
360
+ scale_px_per_cm=px_per_cm,
361
+ view_angle_ok=False,
362
+ fail_reason="card_not_parallel",
363
+ )
364
+
365
  # Phase 5: Finger isolation (hand already segmented in Phase 3)
366
  h_can, w_can = image_canonical.shape[:2]
367
  finger_data = isolate_finger(hand_data, finger=finger_index, image_shape=(h_can, w_can))
 
776
  skip_card_detection=args.skip_card_detection,
777
  )
778
 
779
+ # Apply calibration (post-processing)
780
+ raw_diameter = result.get("finger_outer_diameter_cm")
781
+ if raw_diameter is not None and not args.no_calibration:
782
+ calibrated = apply_calibration(raw_diameter)
783
+ result["finger_outer_diameter_cm"] = round(calibrated, 4)
784
+ result["raw_diameter_cm"] = round(raw_diameter, 4)
785
+ result["calibration_applied"] = True
786
+ else:
787
+ result["calibration_applied"] = False
788
+
789
  # Save output
790
  save_output(result, args.output)
791
  print(f"Results saved to: {args.output}")
 
796
  return 1
797
  else:
798
  print(f"Finger diameter: {result['finger_outer_diameter_cm']} cm")
799
+ if result.get("raw_diameter_cm"):
800
+ print(f" (raw: {result['raw_diameter_cm']} cm, calibrated)")
801
  print(f"Confidence: {result['confidence']}")
802
  return 0
803
 
script/analyze_calibration.py ADDED
@@ -0,0 +1,323 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Analysis & regression script for calibration dataset.
3
+
4
+ Performs:
5
+ 1. px/cm stability analysis
6
+ 2. A vs B repeatability
7
+ 3. Ground truth sanity check (π×diameter vs circumference)
8
+ 4. Scatter plot & bias analysis
9
+ 5. Linear regression with leave-one-person-out cross-validation
10
+ """
11
+
12
+ import json
13
+ import math
14
+ import os
15
+ from pathlib import Path
16
+
17
+ import numpy as np
18
+
19
+ # Optional: matplotlib for plots (skip gracefully if missing)
20
+ try:
21
+ import matplotlib
22
+ matplotlib.use("Agg")
23
+ import matplotlib.pyplot as plt
24
+ HAS_PLT = True
25
+ except ImportError:
26
+ HAS_PLT = False
27
+ print("Warning: matplotlib not available, skipping plots")
28
+
29
+
30
+ def load_results(path: str) -> list[dict]:
31
+ with open(path, encoding="utf-8") as f:
32
+ return json.load(f)
33
+
34
+
35
+ def section(title: str):
36
+ print(f"\n{'='*60}")
37
+ print(f" {title}")
38
+ print(f"{'='*60}")
39
+
40
+
41
+ def analyze_scale_stability(results: list[dict]):
42
+ """Phase 3a: px/cm stability across all images."""
43
+ section("1. Card Detection / px/cm Stability")
44
+
45
+ scales = {}
46
+ for r in results:
47
+ img = r["image"]
48
+ if img not in scales and r["cv_scale_px_per_cm"]:
49
+ scales[img] = r["cv_scale_px_per_cm"]
50
+
51
+ vals = list(scales.values())
52
+ mean_s = np.mean(vals)
53
+ std_s = np.std(vals)
54
+ cv_pct = (std_s / mean_s) * 100
55
+
56
+ print(f" Images analyzed: {len(scales)}")
57
+ print(f" Mean px/cm: {mean_s:.2f}")
58
+ print(f" Std px/cm: {std_s:.2f}")
59
+ print(f" CV%: {cv_pct:.2f}%")
60
+ print(f" Range: {min(vals):.2f} — {max(vals):.2f}")
61
+ print(f" Max spread: {max(vals)-min(vals):.2f} px/cm ({(max(vals)-min(vals))/mean_s*100:.2f}%)")
62
+
63
+ # Per-image table
64
+ print(f"\n {'Image':<16} {'px/cm':>8} {'Δ from mean':>12}")
65
+ print(f" {'-'*38}")
66
+ for img, s in sorted(scales.items()):
67
+ print(f" {img:<16} {s:>8.2f} {s-mean_s:>+12.2f}")
68
+
69
+ return mean_s, std_s
70
+
71
+
72
+ def analyze_repeatability(results: list[dict]):
73
+ """Phase 3b: A vs B repeatability."""
74
+ section("2. A vs B Repeatability")
75
+
76
+ # Group by (person, finger)
77
+ pairs = {}
78
+ for r in results:
79
+ key = (r["person"], r["finger_en"])
80
+ if key not in pairs:
81
+ pairs[key] = {}
82
+ pairs[key][r["shot"]] = r["cv_diameter_cm"]
83
+
84
+ diffs = []
85
+ print(f" {'Person':<10} {'Finger':<8} {'Shot A':>8} {'Shot B':>8} {'Δ(B-A)':>8} {'%diff':>7}")
86
+ print(f" {'-'*53}")
87
+ for (person, finger), shots in sorted(pairs.items()):
88
+ a = shots.get("A")
89
+ b = shots.get("B")
90
+ if a and b:
91
+ d = b - a
92
+ pct = abs(d) / ((a + b) / 2) * 100
93
+ diffs.append(abs(d))
94
+ print(f" {person:<10} {finger:<8} {a:>8.3f} {b:>8.3f} {d:>+8.3f} {pct:>6.1f}%")
95
+
96
+ if diffs:
97
+ print(f"\n Mean |A-B| difference: {np.mean(diffs):.4f} cm")
98
+ print(f" Max |A-B| difference: {max(diffs):.4f} cm")
99
+ print(f" Std |A-B| difference: {np.std(diffs):.4f} cm")
100
+ print(f" 95th percentile: {np.percentile(diffs, 95):.4f} cm")
101
+
102
+ return diffs
103
+
104
+
105
+ def check_ground_truth_sanity(results: list[dict]):
106
+ """Phase 3c: Check π×diameter ≈ circumference."""
107
+ section("3. Ground Truth Sanity (π×diameter vs circumference)")
108
+
109
+ seen = set()
110
+ diffs = []
111
+ print(f" {'Person':<10} {'Finger':<6} {'Diam':>6} {'Circ':>6} {'π×D':>6} {'Δ':>7} {'%err':>6}")
112
+ print(f" {'-'*55}")
113
+
114
+ for r in results:
115
+ key = (r["person"], r["finger_cn"])
116
+ if key in seen:
117
+ continue
118
+ seen.add(key)
119
+
120
+ d = r["gt_diameter_cm"]
121
+ c = r["gt_circumference_cm"]
122
+ if d and c:
123
+ pi_d = math.pi * d
124
+ diff = c - pi_d
125
+ pct = diff / c * 100
126
+ diffs.append(diff)
127
+ print(f" {r['person']:<10} {r['finger_cn']:<6} {d:>6.2f} {c:>6.1f} {pi_d:>6.2f} {diff:>+7.2f} {pct:>+5.1f}%")
128
+
129
+ if diffs:
130
+ print(f"\n Mean (circ - π×diam): {np.mean(diffs):+.3f} cm")
131
+ print(f" This is expected: circumference > π×diameter because")
132
+ print(f" fingers are not perfect circles (slightly oval/flattened).")
133
+
134
+
135
+ def bias_analysis(results: list[dict]):
136
+ """Phase 4a: Scatter plot and bias analysis."""
137
+ section("4. Accuracy & Bias Analysis")
138
+
139
+ valid = [r for r in results if r["cv_diameter_cm"] and r["gt_diameter_cm"]]
140
+ cv = np.array([r["cv_diameter_cm"] for r in valid])
141
+ gt = np.array([r["gt_diameter_cm"] for r in valid])
142
+ errors = cv - gt
143
+ pct_errors = errors / gt * 100
144
+
145
+ print(f" N = {len(valid)} measurements")
146
+ print(f" Mean error (CV-GT): {np.mean(errors):+.4f} cm")
147
+ print(f" Median error: {np.median(errors):+.4f} cm")
148
+ print(f" Std of error: {np.std(errors):.4f} cm")
149
+ print(f" Mean % error: {np.mean(pct_errors):+.1f}%")
150
+ print(f" MAE (absolute): {np.mean(np.abs(errors)):.4f} cm")
151
+ print(f" Max error: {np.max(np.abs(errors)):.4f} cm")
152
+ print(f" RMSE: {np.sqrt(np.mean(errors**2)):.4f} cm")
153
+
154
+ # Correlation
155
+ corr = np.corrcoef(cv, gt)[0, 1]
156
+ print(f" Pearson r: {corr:.4f}")
157
+ print(f" R²: {corr**2:.4f}")
158
+
159
+ return cv, gt, errors
160
+
161
+
162
+ def linear_regression(cv: np.ndarray, gt: np.ndarray, results: list[dict]):
163
+ """Phase 4b: OLS regression + leave-one-person-out CV."""
164
+ section("5. Linear Regression Calibration")
165
+
166
+ # Fit: gt = a * cv + b
167
+ A = np.vstack([cv, np.ones(len(cv))]).T
168
+ (a, b), residuals, _, _ = np.linalg.lstsq(A, gt, rcond=None)
169
+
170
+ calibrated = a * cv + b
171
+ cal_errors = calibrated - gt
172
+
173
+ print(f" Model: actual = {a:.4f} × measured + {b:.4f}")
174
+ print(f" (i.e., slope={a:.4f}, intercept={b:.4f})")
175
+ print(f"\n After calibration:")
176
+ print(f" Mean error: {np.mean(cal_errors):+.4f} cm")
177
+ print(f" MAE: {np.mean(np.abs(cal_errors)):.4f} cm")
178
+ print(f" Max error: {np.max(np.abs(cal_errors)):.4f} cm")
179
+ print(f" RMSE: {np.sqrt(np.mean(cal_errors**2)):.4f} cm")
180
+
181
+ # Leave-one-person-out cross-validation
182
+ section("6. Leave-One-Person-Out Cross-Validation")
183
+
184
+ persons = sorted(set(r["person"] for r in results if r["cv_diameter_cm"] and r["gt_diameter_cm"]))
185
+ all_cv_errors = []
186
+ all_cv_cal_errors = []
187
+
188
+ print(f" {'Person':<10} {'N':>3} {'a':>7} {'b':>7} {'MAE_raw':>8} {'MAE_cal':>8} {'Max_cal':>8}")
189
+ print(f" {'-'*57}")
190
+
191
+ for holdout in persons:
192
+ # Train on all except holdout
193
+ train = [r for r in results
194
+ if r["cv_diameter_cm"] and r["gt_diameter_cm"] and r["person"] != holdout]
195
+ test = [r for r in results
196
+ if r["cv_diameter_cm"] and r["gt_diameter_cm"] and r["person"] == holdout]
197
+
198
+ train_cv = np.array([r["cv_diameter_cm"] for r in train])
199
+ train_gt = np.array([r["gt_diameter_cm"] for r in train])
200
+ test_cv = np.array([r["cv_diameter_cm"] for r in test])
201
+ test_gt = np.array([r["gt_diameter_cm"] for r in test])
202
+
203
+ A_train = np.vstack([train_cv, np.ones(len(train_cv))]).T
204
+ (a_fold, b_fold), _, _, _ = np.linalg.lstsq(A_train, train_gt, rcond=None)
205
+
206
+ test_cal = a_fold * test_cv + b_fold
207
+ raw_errors = np.abs(test_cv - test_gt)
208
+ cal_errors_fold = np.abs(test_cal - test_gt)
209
+
210
+ all_cv_errors.extend(raw_errors.tolist())
211
+ all_cv_cal_errors.extend(cal_errors_fold.tolist())
212
+
213
+ print(f" {holdout:<10} {len(test):>3} {a_fold:>7.4f} {b_fold:>+7.4f} "
214
+ f"{np.mean(raw_errors):>8.4f} {np.mean(cal_errors_fold):>8.4f} "
215
+ f"{np.max(cal_errors_fold):>8.4f}")
216
+
217
+ all_cv_errors = np.array(all_cv_errors)
218
+ all_cv_cal_errors = np.array(all_cv_cal_errors)
219
+
220
+ print(f"\n Cross-validated results (all holdout predictions):")
221
+ print(f" Raw MAE: {np.mean(all_cv_errors):.4f} cm")
222
+ print(f" Cal MAE: {np.mean(all_cv_cal_errors):.4f} cm")
223
+ print(f" Raw RMSE: {np.sqrt(np.mean(all_cv_errors**2)):.4f} cm")
224
+ print(f" Cal RMSE: {np.sqrt(np.mean(all_cv_cal_errors**2)):.4f} cm")
225
+ print(f" Improvement: {(1 - np.mean(all_cv_cal_errors)/np.mean(all_cv_errors))*100:.1f}% reduction in MAE")
226
+
227
+ return a, b
228
+
229
+
230
+ def generate_plots(cv: np.ndarray, gt: np.ndarray, a: float, b: float, out_dir: str):
231
+ """Generate scatter plot and residual plot."""
232
+ if not HAS_PLT:
233
+ return
234
+
235
+ section("7. Generating Plots")
236
+
237
+ fig, axes = plt.subplots(1, 3, figsize=(16, 5))
238
+
239
+ # 1. Scatter: CV vs GT with regression line
240
+ ax = axes[0]
241
+ ax.scatter(cv, gt, alpha=0.6, s=30, label="Measurements")
242
+ lim = [min(cv.min(), gt.min()) - 0.1, max(cv.max(), gt.max()) + 0.1]
243
+ ax.plot(lim, lim, "k--", alpha=0.3, label="y=x (perfect)")
244
+ x_fit = np.linspace(lim[0], lim[1], 100)
245
+ ax.plot(x_fit, a * x_fit + b, "r-", linewidth=2,
246
+ label=f"Fit: y={a:.3f}x{b:+.3f}")
247
+ ax.set_xlabel("CV Measured Diameter (cm)")
248
+ ax.set_ylabel("Actual Diameter (cm)")
249
+ ax.set_title("CV Measured vs Actual (Caliper)")
250
+ ax.legend(fontsize=8)
251
+ ax.set_aspect("equal")
252
+ ax.grid(True, alpha=0.3)
253
+
254
+ # 2. Error distribution
255
+ ax = axes[1]
256
+ errors = cv - gt
257
+ cal_errors = (a * cv + b) - gt
258
+ ax.hist(errors, bins=15, alpha=0.5, label=f"Raw (μ={np.mean(errors):+.3f})")
259
+ ax.hist(cal_errors, bins=15, alpha=0.5, label=f"Calibrated (μ={np.mean(cal_errors):+.3f})")
260
+ ax.axvline(0, color="k", linestyle="--", alpha=0.3)
261
+ ax.set_xlabel("Error (cm)")
262
+ ax.set_ylabel("Count")
263
+ ax.set_title("Error Distribution: Before vs After Calibration")
264
+ ax.legend(fontsize=8)
265
+ ax.grid(True, alpha=0.3)
266
+
267
+ # 3. Residuals vs predicted
268
+ ax = axes[2]
269
+ calibrated = a * cv + b
270
+ ax.scatter(calibrated, cal_errors, alpha=0.6, s=30)
271
+ ax.axhline(0, color="k", linestyle="--", alpha=0.3)
272
+ ax.set_xlabel("Calibrated Diameter (cm)")
273
+ ax.set_ylabel("Residual (cm)")
274
+ ax.set_title("Residuals After Calibration")
275
+ ax.grid(True, alpha=0.3)
276
+
277
+ plt.tight_layout()
278
+ plot_path = os.path.join(out_dir, "calibration_analysis.png")
279
+ plt.savefig(plot_path, dpi=150)
280
+ print(f" Saved plot: {plot_path}")
281
+ plt.close()
282
+
283
+
284
+ def main():
285
+ base_dir = Path(__file__).resolve().parent.parent
286
+ results_path = base_dir / "output" / "batch" / "batch_results.json"
287
+ out_dir = str(base_dir / "output" / "batch")
288
+
289
+ results = load_results(str(results_path))
290
+ print(f"Loaded {len(results)} results")
291
+
292
+ # Phase 3
293
+ analyze_scale_stability(results)
294
+ analyze_repeatability(results)
295
+ check_ground_truth_sanity(results)
296
+
297
+ # Phase 4
298
+ cv, gt, errors = bias_analysis(results)
299
+ a, b = linear_regression(cv, gt, results)
300
+ generate_plots(cv, gt, a, b, out_dir)
301
+
302
+ # Summary
303
+ section("SUMMARY — Calibration Coefficients")
304
+ print(f" actual_diameter = {a:.6f} × measured_diameter + ({b:.6f})")
305
+ print(f" slope (a) = {a:.6f}")
306
+ print(f" offset (b) = {b:.6f}")
307
+ print(f"\n Save these to the pipeline configuration.")
308
+
309
+ # Write coefficients to file
310
+ coeff_path = os.path.join(out_dir, "calibration_coefficients.json")
311
+ with open(coeff_path, "w") as f:
312
+ json.dump({
313
+ "slope": round(a, 6),
314
+ "intercept": round(b, 6),
315
+ "description": "actual = slope * measured + intercept",
316
+ "n_samples": len(cv),
317
+ "dataset": "input/sample (10 people × 3 fingers × 2 shots)",
318
+ }, f, indent=2)
319
+ print(f" Saved coefficients: {coeff_path}")
320
+
321
+
322
+ if __name__ == "__main__":
323
+ main()
script/batch_measure.py ADDED
@@ -0,0 +1,177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Batch measurement script for calibration dataset.
3
+
4
+ Runs measure_finger.py on all sample images × 3 fingers,
5
+ collects results, and writes to CSV + JSON.
6
+ """
7
+
8
+ import csv
9
+ import json
10
+ import os
11
+ import subprocess
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ # Finger name mapping (Chinese → CLI arg)
16
+ FINGER_MAP = {
17
+ "食指": "index",
18
+ "中指": "middle",
19
+ "无名指": "ring",
20
+ }
21
+
22
+ # People to exclude (no ground truth)
23
+ EXCLUDE = {"谢峰", "空白"}
24
+
25
+
26
+ def run_measurement(image_path: str, finger: str, output_json: str) -> dict:
27
+ """Run measure_finger.py and return parsed JSON result."""
28
+ cmd = [
29
+ sys.executable, "measure_finger.py",
30
+ "--input", image_path,
31
+ "--output", output_json,
32
+ "--finger-index", finger,
33
+ "--edge-method", "sobel",
34
+ ]
35
+ try:
36
+ proc = subprocess.run(
37
+ cmd, capture_output=True, text=True, timeout=120
38
+ )
39
+ if os.path.exists(output_json):
40
+ with open(output_json) as f:
41
+ return json.load(f)
42
+ else:
43
+ return {"fail_reason": f"no output file; stderr={proc.stderr[-200:]}"}
44
+ except subprocess.TimeoutExpired:
45
+ return {"fail_reason": "timeout"}
46
+ except Exception as e:
47
+ return {"fail_reason": str(e)}
48
+
49
+
50
+ def load_ground_truth(csv_path: str) -> list[dict]:
51
+ """Load ground truth CSV."""
52
+ rows = []
53
+ with open(csv_path, encoding="utf-8-sig") as f:
54
+ reader = csv.DictReader(f)
55
+ for row in reader:
56
+ rows.append(row)
57
+ return rows
58
+
59
+
60
+ def main():
61
+ base_dir = Path(__file__).resolve().parent.parent
62
+ os.chdir(base_dir)
63
+
64
+ jpg_dir = base_dir / "input" / "sample" / "jpg"
65
+ csv_path = base_dir / "input" / "sample" / "finger-size.csv"
66
+ out_dir = base_dir / "output" / "batch"
67
+ out_dir.mkdir(parents=True, exist_ok=True)
68
+
69
+ # Load ground truth
70
+ gt_rows = load_ground_truth(str(csv_path))
71
+ print(f"Loaded {len(gt_rows)} ground truth rows")
72
+
73
+ # Build name→rows lookup
74
+ gt_by_name = {}
75
+ for row in gt_rows:
76
+ name = row["姓名"]
77
+ if name not in gt_by_name:
78
+ gt_by_name[name] = {}
79
+ finger_cn = row["手指"]
80
+ gt_by_name[name][finger_cn] = row
81
+
82
+ # Find all person images (exclude 谢峰, 空白)
83
+ images = sorted([
84
+ f for f in jpg_dir.glob("*.jpg")
85
+ if not any(ex in f.stem for ex in EXCLUDE)
86
+ ])
87
+ print(f"Found {len(images)} images to process")
88
+
89
+ all_results = []
90
+ total = len(images) * 3 # 3 fingers per image
91
+ done = 0
92
+
93
+ for img_path in images:
94
+ stem = img_path.stem # e.g. "黄漫玉A"
95
+ person = stem[:-1] # e.g. "黄漫玉"
96
+ shot = stem[-1] # e.g. "A"
97
+
98
+ if person not in gt_by_name:
99
+ print(f" SKIP {stem}: no ground truth for {person}")
100
+ continue
101
+
102
+ for finger_cn, finger_en in FINGER_MAP.items():
103
+ done += 1
104
+ gt_row = gt_by_name[person].get(finger_cn)
105
+ gt_diameter = float(gt_row["直径(cm)"]) if gt_row else None
106
+ gt_circumference = float(gt_row["周长(cm)"]) if gt_row else None
107
+ gt_ring_size = gt_row.get("指环尺寸", "") if gt_row else ""
108
+
109
+ out_json = str(out_dir / f"{stem}_{finger_en}.json")
110
+ print(f"[{done}/{total}] {stem} / {finger_cn} ({finger_en})...", end=" ", flush=True)
111
+
112
+ result = run_measurement(str(img_path), finger_en, out_json)
113
+
114
+ cv_diameter = result.get("finger_outer_diameter_cm")
115
+ cv_confidence = result.get("confidence")
116
+ cv_scale = result.get("scale_px_per_cm")
117
+ fail = result.get("fail_reason")
118
+
119
+ if cv_diameter and gt_diameter:
120
+ error = cv_diameter - gt_diameter
121
+ pct = error / gt_diameter * 100
122
+ print(f"CV={cv_diameter:.3f} GT={gt_diameter:.3f} Δ={error:+.3f} ({pct:+.1f}%) scale={cv_scale}")
123
+ elif fail:
124
+ print(f"FAILED: {fail[:80]}")
125
+ else:
126
+ print(f"CV={cv_diameter} (no GT)")
127
+
128
+ all_results.append({
129
+ "person": person,
130
+ "shot": shot,
131
+ "finger_cn": finger_cn,
132
+ "finger_en": finger_en,
133
+ "image": img_path.name,
134
+ "gt_diameter_cm": gt_diameter,
135
+ "gt_circumference_cm": gt_circumference,
136
+ "gt_ring_size": gt_ring_size,
137
+ "cv_diameter_cm": cv_diameter,
138
+ "cv_confidence": cv_confidence,
139
+ "cv_scale_px_per_cm": cv_scale,
140
+ "fail_reason": fail,
141
+ "edge_method": result.get("edge_method_used"),
142
+ })
143
+
144
+ # Save full results JSON
145
+ results_json = str(out_dir / "batch_results.json")
146
+ with open(results_json, "w", encoding="utf-8") as f:
147
+ json.dump(all_results, f, indent=2, ensure_ascii=False)
148
+ print(f"\nSaved {len(all_results)} results to {results_json}")
149
+
150
+ # Save summary CSV
151
+ results_csv = str(out_dir / "batch_results.csv")
152
+ if all_results:
153
+ keys = all_results[0].keys()
154
+ with open(results_csv, "w", encoding="utf-8", newline="") as f:
155
+ writer = csv.DictWriter(f, fieldnames=keys)
156
+ writer.writeheader()
157
+ writer.writerows(all_results)
158
+ print(f"Saved CSV to {results_csv}")
159
+
160
+ # Quick stats
161
+ valid = [r for r in all_results if r["cv_diameter_cm"] and r["gt_diameter_cm"]]
162
+ failed = [r for r in all_results if r["fail_reason"]]
163
+ if valid:
164
+ errors = [r["cv_diameter_cm"] - r["gt_diameter_cm"] for r in valid]
165
+ mean_err = sum(errors) / len(errors)
166
+ scales = [r["cv_scale_px_per_cm"] for r in valid if r["cv_scale_px_per_cm"]]
167
+ mean_scale = sum(scales) / len(scales) if scales else 0
168
+ print(f"\n--- Quick Stats ---")
169
+ print(f"Valid measurements: {len(valid)}/{len(all_results)}")
170
+ print(f"Failed: {len(failed)}")
171
+ print(f"Mean error (CV - GT): {mean_err:+.4f} cm")
172
+ print(f"Mean scale: {mean_scale:.2f} px/cm")
173
+ print(f"Scale range: {min(scales):.2f} - {max(scales):.2f} px/cm")
174
+
175
+
176
+ if __name__ == "__main__":
177
+ main()
script/deploy_hf.sh ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Deploy current working directory to Hugging Face Spaces
3
+ set -euo pipefail
4
+
5
+ REPO_ID="Feng-X/ring-sizer"
6
+ IGNORE='[".venv/*", ".git/*", "__pycache__/*", "*.pyc", "output/*", "web_demo/uploads/*", "web_demo/results/*", "doc/*", ".claude/*", "input/*"]'
7
+
8
+ cd "$(dirname "$0")/.."
9
+
10
+ source .venv/bin/activate
11
+
12
+ python -c "
13
+ from huggingface_hub import HfApi
14
+ HfApi().upload_folder(
15
+ folder_path='.',
16
+ repo_id='${REPO_ID}',
17
+ repo_type='space',
18
+ ignore_patterns=${IGNORE},
19
+ )
20
+ print('Deployed to https://huggingface.co/spaces/${REPO_ID}')
21
+ "
src/calibration.json ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "slope": 0.792112,
3
+ "intercept": 0.250292,
4
+ "description": "calibrated_diameter = slope * raw_diameter + intercept",
5
+ "n_samples": 60,
6
+ "dataset": "10 people x 3 fingers x 2 shots, caliper ground truth",
7
+ "raw_mae_cm": 0.1578,
8
+ "calibrated_mae_cm": 0.0601,
9
+ "cv_mae_cm": 0.0601
10
+ }
web_demo/app.py CHANGED
@@ -19,7 +19,7 @@ from werkzeug.utils import secure_filename
19
  ROOT_DIR = Path(__file__).resolve().parents[1]
20
  sys.path.insert(0, str(ROOT_DIR))
21
 
22
- from measure_finger import measure_finger
23
 
24
  APP_ROOT = Path(__file__).resolve().parent
25
  UPLOAD_DIR = APP_ROOT / "uploads"
@@ -123,6 +123,13 @@ def _run_measurement(
123
  save_debug=False,
124
  )
125
 
 
 
 
 
 
 
 
126
  result_json_name = f"{run_id}__result.json"
127
  result_json_path = RESULTS_DIR / result_json_name
128
  _save_json(result_json_path, result)
 
19
  ROOT_DIR = Path(__file__).resolve().parents[1]
20
  sys.path.insert(0, str(ROOT_DIR))
21
 
22
+ from measure_finger import measure_finger, apply_calibration
23
 
24
  APP_ROOT = Path(__file__).resolve().parent
25
  UPLOAD_DIR = APP_ROOT / "uploads"
 
123
  save_debug=False,
124
  )
125
 
126
+ # Apply calibration
127
+ raw_diameter = result.get("finger_outer_diameter_cm")
128
+ if raw_diameter is not None:
129
+ result["raw_diameter_cm"] = round(raw_diameter, 4)
130
+ result["finger_outer_diameter_cm"] = round(apply_calibration(raw_diameter), 4)
131
+ result["calibration_applied"] = True
132
+
133
  result_json_name = f"{run_id}__result.json"
134
  result_json_path = RESULTS_DIR / result_json_name
135
  _save_json(result_json_path, result)
web_demo/static/app.js CHANGED
@@ -11,6 +11,8 @@ const defaultSampleUrl = window.DEFAULT_SAMPLE_URL || "";
11
  const failReasonMessageMap = {
12
  card_not_detected:
13
  "Credit card not detected. Place a full card flat beside your hand.",
 
 
14
  hand_not_detected:
15
  "Hand not detected. Include your full palm in frame and keep fingers fully visible.",
16
  finger_isolation_failed:
 
11
  const failReasonMessageMap = {
12
  card_not_detected:
13
  "Credit card not detected. Place a full card flat beside your hand.",
14
+ card_not_parallel:
15
+ "Card is not parallel to the camera. Keep your phone directly above and parallel to the card.",
16
  hand_not_detected:
17
  "Hand not detected. Include your full palm in frame and keep fingers fully visible.",
18
  finger_isolation_failed: