Spaces:
Running
Running
Upload folder using huggingface_hub
Browse files- README.md +54 -62
- measure_finger.py +43 -0
- script/analyze_calibration.py +323 -0
- script/batch_measure.py +177 -0
- script/deploy_hf.sh +21 -0
- src/calibration.json +10 -0
- web_demo/app.py +8 -1
- web_demo/static/app.js +2 -0
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
|
| 58 |
-
|
| 59 |
-
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
|
|
|
|
|
|
| 69 |
|
| 70 |
## Output JSON
|
| 71 |
```json
|
| 72 |
{
|
| 73 |
"finger_outer_diameter_cm": 1.78,
|
| 74 |
"confidence": 0.91,
|
| 75 |
-
"scale_px_per_cm":
|
| 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": "
|
| 83 |
-
"
|
| 84 |
-
|
| 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`
|
| 130 |
|
| 131 |
-
## Documentation
|
| 132 |
-
|
| 133 |
-
-
|
| 134 |
-
|
| 135 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+

|
| 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:
|