feng-x commited on
Commit
5ec46b5
Β·
verified Β·
1 Parent(s): a3054b6

Upload folder using huggingface_hub

Browse files
.gitignore CHANGED
@@ -5,6 +5,7 @@ __pycache__/
5
  *.so
6
  .Python
7
  .venv/
 
8
  ENV/
9
 
10
  # IDE
@@ -36,3 +37,4 @@ input/*.heic
36
  # OS
37
  .DS_Store
38
  Thumbs.db
 
 
5
  *.so
6
  .Python
7
  .venv/
8
+ .env/
9
  ENV/
10
 
11
  # IDE
 
37
  # OS
38
  .DS_Store
39
  Thumbs.db
40
+
=1.0.0 ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Collecting openai
2
+ Downloading openai-2.30.0-py3-none-any.whl (1.1 MB)
3
+ Requirement already satisfied: typing-extensions<5,>=4.11 in ./.venv/lib/python3.9/site-packages (from openai) (4.15.0)
4
+ Collecting jiter<1,>=0.10.0
5
+ Using cached jiter-0.13.0-cp39-cp39-macosx_11_0_arm64.whl (309 kB)
6
+ Requirement already satisfied: httpx<1,>=0.23.0 in ./.venv/lib/python3.9/site-packages (from openai) (0.28.1)
7
+ Requirement already satisfied: anyio<5,>=3.5.0 in ./.venv/lib/python3.9/site-packages (from openai) (4.12.1)
8
+ Collecting distro<2,>=1.7.0
9
+ Using cached distro-1.9.0-py3-none-any.whl (20 kB)
10
+ Collecting sniffio
11
+ Using cached sniffio-1.3.1-py3-none-any.whl (10 kB)
12
+ Requirement already satisfied: tqdm>4 in ./.venv/lib/python3.9/site-packages (from openai) (4.67.3)
13
+ Collecting pydantic<3,>=1.9.0
14
+ Using cached pydantic-2.12.5-py3-none-any.whl (463 kB)
15
+ Requirement already satisfied: exceptiongroup>=1.0.2 in ./.venv/lib/python3.9/site-packages (from anyio<5,>=3.5.0->openai) (1.3.1)
16
+ Requirement already satisfied: idna>=2.8 in ./.venv/lib/python3.9/site-packages (from anyio<5,>=3.5.0->openai) (3.11)
17
+ Requirement already satisfied: httpcore==1.* in ./.venv/lib/python3.9/site-packages (from httpx<1,>=0.23.0->openai) (1.0.9)
18
+ Requirement already satisfied: certifi in ./.venv/lib/python3.9/site-packages (from httpx<1,>=0.23.0->openai) (2026.1.4)
19
+ Requirement already satisfied: h11>=0.16 in ./.venv/lib/python3.9/site-packages (from httpcore==1.*->httpx<1,>=0.23.0->openai) (0.16.0)
20
+ Collecting annotated-types>=0.6.0
21
+ Using cached annotated_types-0.7.0-py3-none-any.whl (13 kB)
22
+ Collecting typing-inspection>=0.4.2
23
+ Using cached typing_inspection-0.4.2-py3-none-any.whl (14 kB)
24
+ Collecting pydantic-core==2.41.5
25
+ Using cached pydantic_core-2.41.5-cp39-cp39-macosx_11_0_arm64.whl (1.9 MB)
26
+ Installing collected packages: typing-inspection, pydantic-core, annotated-types, sniffio, pydantic, jiter, distro, openai
27
+ Successfully installed annotated-types-0.7.0 distro-1.9.0 jiter-0.13.0 openai-2.30.0 pydantic-2.12.5 pydantic-core-2.41.5 sniffio-1.3.1 typing-inspection-0.4.2
28
+ WARNING: You are using pip version 21.2.4; however, version 26.0.1 is available.
29
+ You should consider upgrading via the '/Users/fengxie/Build/ring-size-cv/.venv/bin/python3 -m pip install --upgrade pip' command.
README.md CHANGED
@@ -9,7 +9,7 @@ app_port: 7860
9
 
10
  # Ring Sizer
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)
@@ -21,6 +21,9 @@ Local computer-vision CLI tool that measures **finger outer diameter** from a si
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)
@@ -40,7 +43,20 @@ Validated on 10 subjects Γ— 3 fingers Γ— 2 photos = 60 measurements against cali
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
@@ -51,7 +67,11 @@ pip install -r requirements.txt
51
 
52
  ## Run
53
  ```bash
 
54
  python measure_finger.py --input input/test_image.jpg --output output/result.json
 
 
 
55
  ```
56
 
57
  ### Common options
@@ -83,7 +103,8 @@ python measure_finger.py --input image.jpg --output output/result.json \
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 |
@@ -106,12 +127,20 @@ python measure_finger.py --input image.jpg --output output/result.json \
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
 
@@ -121,7 +150,8 @@ Notes:
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) |
 
9
 
10
  # Ring Sizer
11
 
12
+ Local computer-vision CLI tool that measures **finger outer diameter** from a single image using a **credit card** as scale reference. Achieves **Β±0.5 mm** diameter accuracy and **0% return rate** on a 10-subject evaluation (N=20 images).
13
 
14
  ## Live Demo
15
  - Hugging Face Space: [https://huggingface.co/spaces/feng-x/ring-sizer](https://huggingface.co/spaces/feng-x/ring-sizer)
 
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
+ - **Ring size recommendation** maps calibrated diameter to China standard sizes 6–13 (best match + 2-size range).
25
+ - **Multi-finger mode** measures index, middle, and ring fingers in one pass; consensus aggregation maximizes the chance at least one finger fits.
26
+ - **Optional AI explanation** (OpenAI) generates a human-readable rationale for the recommendation (size selection is always deterministic).
27
  - Supports dual edge modes:
28
  - `contour` (v0 baseline)
29
  - `sobel` (v1 sub-pixel refinement)
 
43
 
44
  Pipeline stability: card detection CV = 0.44%, shot-to-shot repeatability = 0.028 cm.
45
 
46
+ ### Ring Size Recommendation
47
+
48
+ Evaluated on 10 subjects Γ— 2 photos = 20 images in multi-finger mode.
49
+
50
+ | Metric | Value |
51
+ |--------|-------|
52
+ | Return rate (fits no finger) | **0%** (0/20) |
53
+ | Exact size match | 55% (11/20) |
54
+ | Within Β±1 size | 100% (20/20) |
55
+ | A/B photo consistency | 90% (9/10 same size) |
56
+
57
+ See full analysis: [`doc/algorithms/08-ring-size-recommendation.md`](doc/algorithms/08-ring-size-recommendation.md)
58
+
59
+ See calibration report: [`doc/report/calibration_report.md`](doc/report/calibration_report.md)
60
 
61
  ## Install
62
  ```bash
 
67
 
68
  ## Run
69
  ```bash
70
+ # Single finger (default)
71
  python measure_finger.py --input input/test_image.jpg --output output/result.json
72
+
73
+ # Multi-finger (recommended) β€” measures index, middle, ring in one pass
74
+ python measure_finger.py --input input/test_image.jpg --output output/result.json --mode multi
75
  ```
76
 
77
  ### Common options
 
103
  | `--input` | path | *(required)* | Input image (JPG/PNG) |
104
  | `--output` | path | *(required)* | Output JSON path |
105
  | `--debug` | flag | false | Save intermediate debug images |
106
+ | `--finger-index` | auto, index, middle, ring, pinky | index | Which finger to measure (single mode) |
107
+ | `--mode` | single, multi | single | Single finger or all 3 fingers |
108
  | `--confidence-threshold` | float | 0.7 | Minimum confidence threshold |
109
  | `--edge-method` | auto, contour, sobel, compare | auto | Edge detection method |
110
  | `--sobel-threshold` | float | 15.0 | Minimum gradient magnitude |
 
127
  "fail_reason": null,
128
  "edge_method_used": "sobel",
129
  "raw_diameter_cm": 1.92,
130
+ "calibration_applied": true,
131
+ "ring_size": {
132
+ "best_match": 8,
133
+ "best_match_inner_mm": 18.6,
134
+ "range_min": 8,
135
+ "range_max": 9,
136
+ "diameter_mm": 17.80
137
+ }
138
  }
139
  ```
140
 
141
  Notes:
142
  - `raw_diameter_cm` is the pre-calibration measurement (present when calibration is applied).
143
+ - `ring_size` maps calibrated diameter to China standard sizes 6–13 (nearest match + 2-size recommended range).
144
  - `edge_method_used` and `method_comparison` are optional (present when relevant).
145
  - Result image path is auto-derived: `output/result.json` β†’ `output/result.png`.
146
 
 
150
  | [`doc/v0/`](doc/v0/) | v0 PRD, Plan, Progress (contour baseline) |
151
  | [`doc/v1/`](doc/v1/) | v1 PRD, Plan, Progress (Sobel edge refinement) |
152
  | [`doc/v2/`](doc/v2/) | v2 Plan, Progress (calibration & regression) |
153
+ | [`doc/v3/`](doc/v3/) | v3 Progress (multi-finger, quality checks, AI explanation) |
154
+ | [`doc/report/`](doc/report/) | Validation, calibration & ring size mapping reports |
155
  | [`doc/algorithms/`](doc/algorithms/) | Algorithm documentation |
156
  | [`script/`](script/) | Batch measurement & analysis scripts |
157
  | [`web_demo/`](web_demo/) | Web demo (Flask) |
measure_finger.py CHANGED
@@ -31,7 +31,13 @@ from src.confidence import (
31
  compute_overall_confidence,
32
  )
33
  from src.debug_observer import draw_comprehensive_edge_overlay
34
- from src.ring_size import recommend_ring_size
 
 
 
 
 
 
35
 
36
  # Calibration coefficients (from regression on 60 measurements)
37
  _CALIBRATION_PATH = Path(__file__).parent / "src" / "calibration.json"
@@ -131,6 +137,15 @@ Examples:
131
  help="Disable sub-pixel edge refinement",
132
  )
133
 
 
 
 
 
 
 
 
 
 
134
  # Calibration
135
  parser.add_argument(
136
  "--no-calibration",
@@ -752,6 +767,517 @@ def measure_finger(
752
  )
753
 
754
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
755
  def main() -> int:
756
  """Main entry point."""
757
  args = parse_args()
@@ -773,7 +1299,41 @@ def main() -> int:
773
  # Derive result PNG path from output JSON path
774
  result_png_path = str(Path(args.output).with_suffix(".png"))
775
 
776
- # Run measurement pipeline
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
777
  result = measure_finger(
778
  image=image,
779
  finger_index=args.finger_index,
 
31
  compute_overall_confidence,
32
  )
33
  from src.debug_observer import draw_comprehensive_edge_overlay
34
+ from src.ring_size import recommend_ring_size, aggregate_ring_sizes
35
+ from src.image_quality import (
36
+ check_card_in_frame,
37
+ check_finger_landmarks_visible,
38
+ check_finger_spacing,
39
+ check_lighting_uniformity,
40
+ )
41
 
42
  # Calibration coefficients (from regression on 60 measurements)
43
  _CALIBRATION_PATH = Path(__file__).parent / "src" / "calibration.json"
 
137
  help="Disable sub-pixel edge refinement",
138
  )
139
 
140
+ # Measurement mode
141
+ parser.add_argument(
142
+ "--mode",
143
+ type=str,
144
+ choices=["single", "multi"],
145
+ default="single",
146
+ help="Measurement mode: single (one finger) or multi (index+middle+ring at once) (default: single)",
147
+ )
148
+
149
  # Calibration
150
  parser.add_argument(
151
  "--no-calibration",
 
767
  )
768
 
769
 
770
+ MULTI_FINGERS = ["index", "middle", "ring"]
771
+
772
+
773
+ def _measure_single_finger_from_shared(
774
+ image_canonical: np.ndarray,
775
+ hand_data: Dict[str, Any],
776
+ finger_name: str,
777
+ px_per_cm: float,
778
+ card_detected: bool,
779
+ view_angle_ok: bool,
780
+ card_result: Optional[Dict[str, Any]],
781
+ scale_confidence: float,
782
+ edge_method: str = "sobel",
783
+ sobel_threshold: float = 15.0,
784
+ sobel_kernel_size: int = 3,
785
+ use_subpixel: bool = True,
786
+ ) -> Dict[str, Any]:
787
+ """Measure a single finger using pre-computed hand/card data.
788
+
789
+ Internal helper for measure_multi_finger(). Runs phases 4-8 only.
790
+ """
791
+ from src.geometry import (
792
+ calculate_angle_from_vertical,
793
+ rotate_image_precise,
794
+ rotate_axis_data,
795
+ rotate_contour,
796
+ transform_points_rotation,
797
+ )
798
+
799
+ h_can, w_can = image_canonical.shape[:2]
800
+ finger_data = isolate_finger(hand_data, finger=finger_name, image_shape=(h_can, w_can))
801
+
802
+ if finger_data is None:
803
+ return create_output(
804
+ card_detected=card_detected, finger_detected=False,
805
+ scale_px_per_cm=px_per_cm, view_angle_ok=view_angle_ok,
806
+ fail_reason="finger_isolation_failed",
807
+ )
808
+
809
+ cleaned_mask = clean_mask(finger_data["mask"])
810
+ if cleaned_mask is None:
811
+ return create_output(
812
+ card_detected=card_detected, finger_detected=False,
813
+ scale_px_per_cm=px_per_cm, view_angle_ok=view_angle_ok,
814
+ fail_reason="finger_mask_too_small",
815
+ )
816
+
817
+ contour = get_finger_contour(cleaned_mask)
818
+ if contour is None:
819
+ return create_output(
820
+ card_detected=card_detected, finger_detected=False,
821
+ scale_px_per_cm=px_per_cm, view_angle_ok=view_angle_ok,
822
+ fail_reason="contour_extraction_failed",
823
+ )
824
+
825
+ # Axis estimation
826
+ try:
827
+ axis_data = estimate_finger_axis(
828
+ mask=cleaned_mask, landmarks=finger_data.get("landmarks"),
829
+ )
830
+ except Exception:
831
+ return create_output(
832
+ card_detected=card_detected, finger_detected=True,
833
+ scale_px_per_cm=px_per_cm, view_angle_ok=view_angle_ok,
834
+ fail_reason="axis_estimation_failed",
835
+ )
836
+
837
+ # Precise rotation to vertical
838
+ img_work = image_canonical.copy()
839
+ angle_from_vertical = calculate_angle_from_vertical(axis_data["direction"])
840
+ if abs(angle_from_vertical) >= 0.0:
841
+ rot_center = (w_can / 2.0, h_can / 2.0)
842
+ img_work, rotation_matrix = rotate_image_precise(img_work, angle_from_vertical, rot_center)
843
+ axis_data = rotate_axis_data(axis_data, rotation_matrix)
844
+ contour = rotate_contour(contour, rotation_matrix)
845
+ if finger_data.get("landmarks") is not None:
846
+ finger_data["landmarks"] = transform_points_rotation(finger_data["landmarks"], rotation_matrix)
847
+ cleaned_mask = cv2.warpAffine(
848
+ cleaned_mask, rotation_matrix, (w_can, h_can),
849
+ flags=cv2.INTER_NEAREST, borderMode=cv2.BORDER_CONSTANT, borderValue=0,
850
+ )
851
+
852
+ # Ring zone
853
+ try:
854
+ landmarks = finger_data.get("landmarks")
855
+ if landmarks is not None and len(landmarks) == 4:
856
+ zone_data = localize_ring_zone_from_landmarks(
857
+ landmarks=landmarks, axis_data=axis_data, zone_type="anatomical",
858
+ )
859
+ else:
860
+ zone_data = localize_ring_zone(axis_data)
861
+ except Exception:
862
+ return create_output(
863
+ card_detected=card_detected, finger_detected=True,
864
+ scale_px_per_cm=px_per_cm, view_angle_ok=view_angle_ok,
865
+ fail_reason="zone_localization_failed",
866
+ )
867
+
868
+ # Contour measurement
869
+ try:
870
+ contour_measurement = compute_cross_section_width(
871
+ contour=contour, axis_data=axis_data, zone_data=zone_data, num_samples=20,
872
+ )
873
+ contour_width_cm = contour_measurement["median_width_px"] / px_per_cm
874
+ except Exception:
875
+ return create_output(
876
+ card_detected=card_detected, finger_detected=True,
877
+ scale_px_per_cm=px_per_cm, view_angle_ok=view_angle_ok,
878
+ fail_reason="width_measurement_failed", edge_method_used="contour",
879
+ )
880
+
881
+ # Sobel measurement
882
+ sobel_measurement = None
883
+ sobel_failed = False
884
+ if edge_method in ["sobel", "auto", "compare"]:
885
+ try:
886
+ sobel_measurement = refine_edges_sobel(
887
+ image=img_work, axis_data=axis_data, zone_data=zone_data,
888
+ scale_px_per_cm=px_per_cm, finger_landmarks=finger_data.get("landmarks"),
889
+ sobel_threshold=sobel_threshold, kernel_size=sobel_kernel_size,
890
+ use_subpixel=use_subpixel,
891
+ )
892
+ except Exception:
893
+ sobel_failed = True
894
+ if edge_method == "sobel":
895
+ return create_output(
896
+ card_detected=card_detected, finger_detected=True,
897
+ scale_px_per_cm=px_per_cm, view_angle_ok=view_angle_ok,
898
+ fail_reason="sobel_edge_refinement_failed", edge_method_used="sobel",
899
+ )
900
+
901
+ # Select method
902
+ if edge_method == "contour":
903
+ median_width_cm = contour_width_cm
904
+ edge_method_used = "contour"
905
+ final_measurement = contour_measurement
906
+ elif edge_method == "sobel" and sobel_measurement:
907
+ median_width_cm = sobel_measurement["median_width_cm"]
908
+ edge_method_used = "sobel"
909
+ final_measurement = sobel_measurement
910
+ elif edge_method == "auto":
911
+ if sobel_measurement and not sobel_failed:
912
+ should_use, _ = should_use_sobel_measurement(sobel_measurement, contour_measurement)
913
+ if should_use:
914
+ median_width_cm = sobel_measurement["median_width_cm"]
915
+ edge_method_used = "sobel"
916
+ final_measurement = sobel_measurement
917
+ else:
918
+ median_width_cm = contour_width_cm
919
+ edge_method_used = "contour_fallback"
920
+ final_measurement = contour_measurement
921
+ else:
922
+ median_width_cm = contour_width_cm
923
+ edge_method_used = "contour_fallback"
924
+ final_measurement = contour_measurement
925
+ else:
926
+ median_width_cm = contour_width_cm
927
+ edge_method_used = "contour_fallback"
928
+ final_measurement = contour_measurement
929
+
930
+ # Confidence
931
+ if card_result is not None:
932
+ card_conf = compute_card_confidence(card_result, scale_confidence)
933
+ else:
934
+ card_conf = scale_confidence
935
+ mask_area = int(np.sum(cleaned_mask > 0))
936
+ image_area = image_canonical.shape[0] * image_canonical.shape[1]
937
+ finger_conf = compute_finger_confidence(hand_data, finger_data, mask_area, image_area)
938
+ measurement_conf = compute_measurement_confidence(final_measurement, median_width_cm)
939
+ edge_quality_conf = None
940
+ if edge_method_used in ["sobel", "compare"]:
941
+ edge_quality_conf = compute_edge_quality_confidence(final_measurement.get("edge_quality"))
942
+ confidence_breakdown = compute_overall_confidence(
943
+ card_conf, finger_conf, measurement_conf,
944
+ edge_method="sobel" if edge_method_used in ["sobel", "compare"] else "contour",
945
+ edge_quality_confidence=edge_quality_conf,
946
+ )
947
+
948
+ result = create_output(
949
+ finger_diameter_cm=median_width_cm,
950
+ confidence=confidence_breakdown["overall"],
951
+ card_detected=card_detected, finger_detected=True,
952
+ scale_px_per_cm=px_per_cm, view_angle_ok=view_angle_ok,
953
+ edge_method_used=edge_method_used,
954
+ )
955
+ # Attach intermediate data for debug viz
956
+ result["_internal"] = {
957
+ "contour": contour, "axis_data": axis_data, "zone_data": zone_data,
958
+ "finger_data": finger_data, "cleaned_mask": cleaned_mask,
959
+ "sobel_measurement": sobel_measurement, "contour_measurement": contour_measurement,
960
+ "final_measurement": final_measurement, "image_work": img_work,
961
+ "rotation_matrix": rotation_matrix,
962
+ }
963
+ return result
964
+
965
+
966
+ def measure_multi_finger(
967
+ image: np.ndarray,
968
+ confidence_threshold: float = 0.7,
969
+ result_png_path: Optional[str] = None,
970
+ save_debug: bool = False,
971
+ edge_method: str = "sobel",
972
+ sobel_threshold: float = 15.0,
973
+ sobel_kernel_size: int = 3,
974
+ use_subpixel: bool = True,
975
+ skip_card_detection: bool = False,
976
+ no_calibration: bool = False,
977
+ ) -> Dict[str, Any]:
978
+ """Measure index, middle, and ring fingers from a single image.
979
+
980
+ Runs shared setup (image quality, hand detection, card detection) once,
981
+ then measures each finger independently.
982
+
983
+ Returns:
984
+ Dict with aggregated ring size recommendation + per-finger breakdown.
985
+ """
986
+ from src.finger_segmentation import FINGER_LANDMARKS
987
+
988
+ # Phase 1: Image quality
989
+ quality = assess_image_quality(image)
990
+ print(f"[multi] Image quality: blur={quality['blur_score']:.1f}, "
991
+ f"brightness={quality['brightness']:.1f}, contrast={quality['contrast']:.1f}")
992
+ if not quality["passed"]:
993
+ for issue in quality["issues"]:
994
+ print(f" Warning: {issue}")
995
+ return {"fail_reason": quality["fail_reason"], "per_finger": {}, "fingers_measured": 0, "fingers_succeeded": 0}
996
+
997
+ # Lighting uniformity check
998
+ lighting = check_lighting_uniformity(image)
999
+ if not lighting["uniform"]:
1000
+ print(f"[multi] Warning: Uneven lighting (range={lighting['brightness_range']:.1f})")
1001
+
1002
+ # Phase 2: Hand detection (use "index" for orientation β€” canonical for all)
1003
+ finger_debug_dir = None
1004
+ if save_debug and result_png_path is not None:
1005
+ finger_debug_dir = str(Path(result_png_path).parent / "finger_segmentation_debug")
1006
+
1007
+ hand_data = segment_hand(image, finger="index", debug_dir=finger_debug_dir)
1008
+ if hand_data is None:
1009
+ print("[multi] No hand detected")
1010
+ return {"fail_reason": "hand_not_detected", "per_finger": {}, "fingers_measured": 0, "fingers_succeeded": 0}
1011
+
1012
+ print(f"[multi] Hand detected: {hand_data['handedness']}, confidence={hand_data['confidence']:.2f}")
1013
+ image_canonical = hand_data.get("canonical_image", image)
1014
+
1015
+ # Phase 3: Card detection
1016
+ card_debug_dir = None
1017
+ if save_debug and result_png_path is not None:
1018
+ card_debug_dir = str(Path(result_png_path).parent / "card_detection_debug")
1019
+
1020
+ if skip_card_detection:
1021
+ card_result = None
1022
+ px_per_cm = 100.0
1023
+ scale_confidence = 0.5
1024
+ view_angle_ok = True
1025
+ card_detected = False
1026
+ else:
1027
+ card_result = detect_credit_card(image_canonical, debug_dir=card_debug_dir)
1028
+ if card_result is None:
1029
+ return {"fail_reason": "card_not_detected", "per_finger": {}, "fingers_measured": 0, "fingers_succeeded": 0}
1030
+ px_per_cm, scale_confidence = compute_scale_factor(card_result["corners"])
1031
+ view_angle_ok = scale_confidence > 0.9
1032
+ card_detected = True
1033
+
1034
+ if not view_angle_ok:
1035
+ return {"fail_reason": "card_not_parallel", "per_finger": {}, "fingers_measured": 0, "fingers_succeeded": 0}
1036
+
1037
+ # Card in-frame check
1038
+ card_frame = check_card_in_frame(card_result["corners"], image_canonical.shape)
1039
+ if not card_frame["in_frame"]:
1040
+ print(f"[multi] Warning: Card near edge (min_margin={card_frame['min_margin_px']:.0f}px)")
1041
+
1042
+ # Multi-finger quality: check spacing
1043
+ h_can, w_can = image_canonical.shape[:2]
1044
+ all_finger_landmarks = {}
1045
+ for fn in MULTI_FINGERS:
1046
+ indices = FINGER_LANDMARKS[fn]
1047
+ lm = hand_data["landmarks"][indices]
1048
+ all_finger_landmarks[fn] = lm
1049
+
1050
+ spacing = check_finger_spacing(all_finger_landmarks, image_canonical.shape)
1051
+ if not spacing["well_spaced"]:
1052
+ pair = spacing.get("closest_pair", ("", ""))
1053
+ print(f"[multi] Warning: Fingers too close ({pair[0]}, {pair[1]}, {spacing['min_spacing_px']:.0f}px)")
1054
+
1055
+ # Measure each finger
1056
+ per_finger_raw: Dict[str, Dict] = {}
1057
+ for fn in MULTI_FINGERS:
1058
+ print(f"\n[multi] === Measuring {fn} finger ===")
1059
+ result = _measure_single_finger_from_shared(
1060
+ image_canonical=image_canonical,
1061
+ hand_data=hand_data,
1062
+ finger_name=fn,
1063
+ px_per_cm=px_per_cm,
1064
+ card_detected=card_detected,
1065
+ view_angle_ok=view_angle_ok,
1066
+ card_result=card_result,
1067
+ scale_confidence=scale_confidence,
1068
+ edge_method=edge_method,
1069
+ sobel_threshold=sobel_threshold,
1070
+ sobel_kernel_size=sobel_kernel_size,
1071
+ use_subpixel=use_subpixel,
1072
+ )
1073
+
1074
+ # Apply calibration
1075
+ raw_diam = result.get("finger_outer_diameter_cm")
1076
+ if raw_diam is not None and not no_calibration:
1077
+ calibrated = apply_calibration(raw_diam)
1078
+ result["finger_outer_diameter_cm"] = round(calibrated, 4)
1079
+ result["raw_diameter_cm"] = round(raw_diam, 4)
1080
+ result["calibration_applied"] = True
1081
+ else:
1082
+ result["calibration_applied"] = False
1083
+
1084
+ # Ring size per finger
1085
+ diam = result.get("finger_outer_diameter_cm")
1086
+ if diam is not None:
1087
+ rec = recommend_ring_size(diam)
1088
+ if rec:
1089
+ result["ring_size"] = rec
1090
+
1091
+ per_finger_raw[fn] = result
1092
+
1093
+ # Aggregate
1094
+ aggregated = aggregate_ring_sizes(per_finger_raw)
1095
+
1096
+ # Build debug visualization
1097
+ if result_png_path is not None:
1098
+ _draw_multi_finger_debug(
1099
+ image_canonical=image_canonical,
1100
+ per_finger_raw=per_finger_raw,
1101
+ aggregated=aggregated,
1102
+ card_result=card_result,
1103
+ px_per_cm=px_per_cm,
1104
+ result_png_path=result_png_path,
1105
+ )
1106
+
1107
+ # Clean internal data from output
1108
+ for fn, r in per_finger_raw.items():
1109
+ r.pop("_internal", None)
1110
+
1111
+ aggregated["scale_px_per_cm"] = round(px_per_cm, 2)
1112
+ aggregated["quality_flags"] = {
1113
+ "card_detected": card_detected,
1114
+ "view_angle_ok": view_angle_ok,
1115
+ "lighting_uniform": lighting.get("uniform", True),
1116
+ "fingers_well_spaced": spacing.get("well_spaced", True),
1117
+ }
1118
+ return aggregated
1119
+
1120
+
1121
+ def _draw_multi_finger_debug(
1122
+ image_canonical: np.ndarray,
1123
+ per_finger_raw: Dict[str, Dict],
1124
+ aggregated: Dict[str, Any],
1125
+ card_result: Optional[Dict[str, Any]],
1126
+ px_per_cm: float,
1127
+ result_png_path: str,
1128
+ ) -> None:
1129
+ """Generate debug visualization for multi-finger measurement.
1130
+
1131
+ Draws per-finger Sobel edge detection overlays (edge dots, width lines,
1132
+ ROI boxes) on the canonical image, matching single-finger viz quality.
1133
+ """
1134
+ from src.visualization import draw_card_overlay, get_scaled_font_params
1135
+ from src.viz_constants import Color, FONT_FACE
1136
+
1137
+ FINGER_COLORS = {
1138
+ "index": (255, 255, 0), # Cyan (BGR)
1139
+ "middle": (0, 255, 0), # Green
1140
+ "ring": (255, 0, 255), # Magenta
1141
+ }
1142
+
1143
+ vis = image_canonical.copy()
1144
+ h, w = vis.shape[:2]
1145
+
1146
+ # Draw card
1147
+ if card_result is not None:
1148
+ vis = draw_card_overlay(vis, card_result, px_per_cm)
1149
+
1150
+ # Draw per-finger Sobel edge overlays
1151
+ for fn, result in per_finger_raw.items():
1152
+ internal = result.get("_internal")
1153
+ if internal is None:
1154
+ continue
1155
+ color = FINGER_COLORS.get(fn, Color.FINGER)
1156
+ sobel = internal.get("sobel_measurement")
1157
+ rot_mat = internal.get("rotation_matrix")
1158
+ axis_data = internal["axis_data"]
1159
+ zone_data = internal["zone_data"]
1160
+ contour = internal["contour"]
1161
+
1162
+ # Compute inverse rotation to map per-finger coords back to canonical
1163
+ inv_mat = None
1164
+ if rot_mat is not None:
1165
+ inv_mat = cv2.invertAffineTransform(rot_mat)
1166
+
1167
+ def to_canonical(pt):
1168
+ """Transform a point from per-finger rotated coords to canonical."""
1169
+ if inv_mat is None:
1170
+ return pt
1171
+ p = np.array([pt[0], pt[1], 1.0], dtype=np.float64)
1172
+ return inv_mat @ p
1173
+
1174
+ # Draw Sobel edge detection details if available
1175
+ if sobel is not None and "edge_data" in sobel and "roi_data" in sobel:
1176
+ edge_data = sobel["edge_data"]
1177
+ roi_bounds = sobel["roi_data"]["roi_bounds"]
1178
+ left_edges = edge_data["left_edges"]
1179
+ right_edges = edge_data["right_edges"]
1180
+ valid_rows = edge_data["valid_rows"]
1181
+ x_min, y_min, x_max, y_max = roi_bounds
1182
+
1183
+ # ROI box (transform corners to canonical)
1184
+ roi_corners = np.array([
1185
+ [x_min, y_min], [x_max, y_min],
1186
+ [x_max, y_max], [x_min, y_max],
1187
+ ], dtype=np.float64)
1188
+ if inv_mat is not None:
1189
+ roi_hom = np.hstack([roi_corners, np.ones((4, 1))]).T
1190
+ roi_can = (inv_mat @ roi_hom).T.astype(np.int32).reshape((-1, 1, 2))
1191
+ else:
1192
+ roi_can = roi_corners.astype(np.int32).reshape((-1, 1, 2))
1193
+ cv2.polylines(vis, [roi_can], isClosed=True, color=color, thickness=1, lineType=cv2.LINE_AA)
1194
+
1195
+ # Edge dots and width measurement lines
1196
+ valid_count = int(np.sum(valid_rows))
1197
+ line_spacing = max(1, valid_count // 20)
1198
+ count = 0
1199
+ for row_idx, valid in enumerate(valid_rows):
1200
+ if not valid:
1201
+ continue
1202
+ gy = y_min + row_idx
1203
+ lx = x_min + int(left_edges[row_idx])
1204
+ rx = x_min + int(right_edges[row_idx])
1205
+
1206
+ left_can = to_canonical(np.array([lx, gy])).astype(np.int32)
1207
+ right_can = to_canonical(np.array([rx, gy])).astype(np.int32)
1208
+
1209
+ # Edge dots (small, using finger color)
1210
+ cv2.circle(vis, tuple(left_can), 2, Color.BLUE, -1)
1211
+ cv2.circle(vis, tuple(right_can), 2, Color.MAGENTA, -1)
1212
+
1213
+ # Width measurement lines (every Nth row)
1214
+ if count % line_spacing == 0:
1215
+ cv2.line(vis, tuple(left_can), tuple(right_can),
1216
+ Color.GREEN, 1, cv2.LINE_AA)
1217
+ count += 1
1218
+
1219
+ # --- Text overlay panel ---
1220
+ params = get_scaled_font_params(h)
1221
+ lh = params["line_height"]
1222
+ fs = params["font_scale"]
1223
+ th = params["text_thickness"]
1224
+
1225
+ # Build text lines
1226
+ lines = []
1227
+ lines.append(("=== MULTI-FINGER MEASUREMENT ===", Color.TEXT_PRIMARY))
1228
+ if aggregated.get("fail_reason") is None:
1229
+ lines.append((f"Overall Best Size: {aggregated['overall_best_size']}", Color.TEXT_SUCCESS))
1230
+ lines.append((f"Recommended Range: {aggregated['overall_range_min']}-{aggregated['overall_range_max']}", Color.TEXT_PRIMARY))
1231
+ lines.append(("", None))
1232
+
1233
+ for fn in MULTI_FINGERS:
1234
+ pf = aggregated.get("per_finger", {}).get(fn, {})
1235
+ fcolor = FINGER_COLORS.get(fn, Color.TEXT_PRIMARY)
1236
+ status = pf.get("status", "failed")
1237
+ if status == "ok":
1238
+ text = f"{fn.capitalize()}: {pf['diameter_cm']:.2f}cm -> Size {pf['best_match']} ({pf['range'][0]}-{pf['range'][1]})"
1239
+ else:
1240
+ reason = pf.get("fail_reason", "unknown")
1241
+ text = f"{fn.capitalize()}: FAILED ({reason})"
1242
+ lines.append((text, fcolor))
1243
+
1244
+ lines.append(("", None))
1245
+ succeeded = aggregated.get("fingers_succeeded", 0)
1246
+ measured = aggregated.get("fingers_measured", 0)
1247
+ lines.append((f"Fingers: {succeeded}/{measured} succeeded", Color.TEXT_PRIMARY))
1248
+ lines.append(("", None))
1249
+ lines.append(("Legend:", Color.YELLOW))
1250
+ lines.append((" Blue dots = Left edges", Color.WHITE))
1251
+ lines.append((" Magenta dots = Right edges", Color.WHITE))
1252
+ lines.append((" Green lines = Width measurements", Color.WHITE))
1253
+
1254
+ # Compute text panel size
1255
+ panel_h = lh * len(lines) + 20
1256
+ panel_w = 0
1257
+ for text, _ in lines:
1258
+ if text:
1259
+ (tw, _), _ = cv2.getTextSize(text, FONT_FACE, fs, th)
1260
+ panel_w = max(panel_w, tw)
1261
+ panel_w += 40
1262
+
1263
+ # Draw semi-transparent background
1264
+ overlay = vis.copy()
1265
+ cv2.rectangle(overlay, (10, 10), (10 + panel_w, 10 + panel_h), (0, 0, 0), -1)
1266
+ cv2.addWeighted(overlay, 0.7, vis, 0.3, 0, vis)
1267
+
1268
+ # Draw text
1269
+ x = params["x_offset"]
1270
+ y = params["y_start"]
1271
+ for text, text_color in lines:
1272
+ if text:
1273
+ cv2.putText(vis, text, (x, y), FONT_FACE, fs, text_color, th, cv2.LINE_AA)
1274
+ y += lh
1275
+
1276
+ Path(result_png_path).parent.mkdir(parents=True, exist_ok=True)
1277
+ cv2.imwrite(result_png_path, vis)
1278
+ print(f"\n[multi] Debug visualization saved to: {result_png_path}")
1279
+
1280
+
1281
  def main() -> int:
1282
  """Main entry point."""
1283
  args = parse_args()
 
1299
  # Derive result PNG path from output JSON path
1300
  result_png_path = str(Path(args.output).with_suffix(".png"))
1301
 
1302
+ if args.mode == "multi":
1303
+ # Multi-finger mode
1304
+ result = measure_multi_finger(
1305
+ image=image,
1306
+ confidence_threshold=args.confidence_threshold,
1307
+ result_png_path=result_png_path,
1308
+ save_debug=args.debug,
1309
+ edge_method=args.edge_method,
1310
+ sobel_threshold=args.sobel_threshold,
1311
+ sobel_kernel_size=args.sobel_kernel_size,
1312
+ use_subpixel=not args.no_subpixel,
1313
+ skip_card_detection=args.skip_card_detection,
1314
+ no_calibration=args.no_calibration,
1315
+ )
1316
+
1317
+ save_output(result, args.output)
1318
+ print(f"Results saved to: {args.output}")
1319
+
1320
+ if result.get("fail_reason"):
1321
+ print(f"Measurement failed: {result['fail_reason']}")
1322
+ return 1
1323
+
1324
+ print(f"\n=== Multi-Finger Results ===")
1325
+ print(f"Fingers: {result.get('fingers_succeeded', 0)}/{result.get('fingers_measured', 0)} succeeded")
1326
+ for fn, pf in result.get("per_finger", {}).items():
1327
+ if pf.get("status") == "ok":
1328
+ print(f" {fn.capitalize()}: {pf['diameter_cm']:.2f}cm -> Size {pf['best_match']} (range {pf['range'][0]}-{pf['range'][1]})")
1329
+ else:
1330
+ print(f" {fn.capitalize()}: FAILED ({pf.get('fail_reason', 'unknown')})")
1331
+ if result.get("overall_best_size"):
1332
+ print(f"\nOverall Best Size: {result['overall_best_size']}")
1333
+ print(f"Recommended Range: {result['overall_range_min']}-{result['overall_range_max']}")
1334
+ return 0
1335
+
1336
+ # Single-finger mode (default, backward compatible)
1337
  result = measure_finger(
1338
  image=image,
1339
  finger_index=args.finger_index,
requirements.txt CHANGED
@@ -5,3 +5,4 @@ scipy>=1.11.0
5
  scikit-learn>=1.3.0
6
  flask>=3.0.0
7
  gunicorn>=21.2.0
 
 
5
  scikit-learn>=1.3.0
6
  flask>=3.0.0
7
  gunicorn>=21.2.0
8
+ openai>=1.0.0
script/batch_measure.py CHANGED
@@ -91,8 +91,8 @@ def main():
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:
 
91
  done = 0
92
 
93
  for img_path in images:
94
+ stem = img_path.stem # e.g. "S01A"
95
+ person = stem[:-1] # e.g. "S01"
96
  shot = stem[-1] # e.g. "A"
97
 
98
  if person not in gt_by_name:
script/eval_return_rate.py ADDED
@@ -0,0 +1,276 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Evaluate ring size recommendation effectiveness against ground truth.
3
+
4
+ Measures return rate: probability that recommended size fits NO finger.
5
+ """
6
+ import csv
7
+ import sys
8
+ import os
9
+ import json
10
+ from pathlib import Path
11
+
12
+ import cv2
13
+ import numpy as np
14
+
15
+ ROOT = Path(__file__).resolve().parents[1]
16
+ sys.path.insert(0, str(ROOT))
17
+
18
+ from measure_finger import measure_multi_finger
19
+ from src.ring_size import RING_SIZE_CHART
20
+
21
+ # --- Load ground truth ---
22
+ GT_CSV = ROOT / "input" / "sample" / "finger-size.csv"
23
+ IMG_DIR = ROOT / "input" / "sample" / "jpg"
24
+
25
+ # Name β†’ subject_id mapping (from CSV)
26
+ NAME_TO_ID = {}
27
+ # subject_id β†’ {finger: {"diameter_mm": float, "gt_size": int}}
28
+ GT = {}
29
+
30
+ FINGER_MAP = {"ι£ŸζŒ‡": "index", "δΈ­ζŒ‡": "middle", "ζ— εζŒ‡": "ring"}
31
+
32
+ with open(GT_CSV, encoding="utf-8-sig") as f:
33
+ reader = csv.DictReader(f)
34
+ for row in reader:
35
+ sid = row["subject_id"]
36
+ name = row["姓名"]
37
+ finger_cn = row["ζ‰‹ζŒ‡"]
38
+ diameter_cm = float(row["η›΄εΎ„οΌˆcmοΌ‰"])
39
+ gt_size_raw = row["ζŒ‡ηŽ―ε°Ίε―Έ"].strip()
40
+
41
+ NAME_TO_ID[name] = sid
42
+
43
+ if sid not in GT:
44
+ GT[sid] = {}
45
+
46
+ finger_en = FINGER_MAP.get(finger_cn)
47
+ if not finger_en:
48
+ continue
49
+
50
+ gt_size = int(gt_size_raw) if gt_size_raw.isdigit() else None
51
+ GT[sid][finger_en] = {
52
+ "diameter_mm": diameter_cm * 10,
53
+ "gt_size": gt_size,
54
+ }
55
+
56
+ # --- Find images per subject ---
57
+ # Images named like: ι»„ζΌ«ηŽ‰A.jpg, ι»„ζΌ«ηŽ‰B.jpg
58
+ SUBJECT_IMAGES = {} # sid β†’ [path, ...]
59
+ for img_file in sorted(IMG_DIR.glob("*.jpg")):
60
+ stem = img_file.stem # e.g. "ι»„ζΌ«ηŽ‰A"
61
+ if stem == "η©Ίη™½":
62
+ continue
63
+ # Last char is A or B
64
+ name_part = stem[:-1]
65
+ variant = stem[-1]
66
+ sid = NAME_TO_ID.get(name_part)
67
+ if sid is None:
68
+ print(f" [skip] No ground truth for {name_part}")
69
+ continue
70
+ SUBJECT_IMAGES.setdefault(sid, []).append(img_file)
71
+
72
+ print(f"Ground truth: {len(GT)} subjects, Images: {len(SUBJECT_IMAGES)} subjects")
73
+ print()
74
+
75
+ # --- Ring size inner diameters ---
76
+ SIZE_TO_INNER = RING_SIZE_CHART # {size: inner_diameter_mm}
77
+
78
+ # --- Run measurements ---
79
+ results = [] # list of dicts
80
+
81
+ for sid in sorted(GT.keys()):
82
+ images = SUBJECT_IMAGES.get(sid, [])
83
+ if not images:
84
+ print(f" [skip] {sid}: no images found")
85
+ continue
86
+
87
+ gt_fingers = GT[sid]
88
+ gt_sizes = {fn: info["gt_size"] for fn, info in gt_fingers.items() if info["gt_size"] is not None}
89
+ gt_diameters = {fn: info["diameter_mm"] for fn, info in gt_fingers.items()}
90
+
91
+ for img_path in images:
92
+ variant = img_path.stem[-1]
93
+ label = f"{sid}-{variant}"
94
+ print(f"Processing {label} ({img_path.name})...", end=" ", flush=True)
95
+
96
+ image = cv2.imread(str(img_path))
97
+ if image is None:
98
+ print("FAILED to load")
99
+ continue
100
+
101
+ try:
102
+ result = measure_multi_finger(
103
+ image=image,
104
+ edge_method="sobel",
105
+ save_debug=False,
106
+ no_calibration=False,
107
+ )
108
+ except Exception as e:
109
+ print(f"ERROR: {e}")
110
+ continue
111
+
112
+ rec_size = result.get("overall_best_size")
113
+ rec_min = result.get("overall_range_min")
114
+ rec_max = result.get("overall_range_max")
115
+ fail = result.get("fail_reason")
116
+ per_finger = result.get("per_finger", {})
117
+
118
+ # Per-finger measured diameters
119
+ measured = {}
120
+ for fn in ("index", "middle", "ring"):
121
+ pf = per_finger.get(fn, {})
122
+ if pf.get("status") == "ok" and pf.get("diameter_cm") is not None:
123
+ measured[fn] = pf["diameter_cm"] * 10 # mm
124
+ per_size = pf.get("best_match")
125
+ measured[f"{fn}_size"] = per_size
126
+
127
+ if fail or rec_size is None:
128
+ print(f"FAIL ({fail})")
129
+ results.append({
130
+ "label": label, "sid": sid, "variant": variant,
131
+ "rec_size": None, "fail": fail,
132
+ "gt_sizes": gt_sizes, "gt_diameters": gt_diameters,
133
+ "measured": measured,
134
+ "fits_any": False, "return": True,
135
+ })
136
+ continue
137
+
138
+ # Check if recommended size fits at least one finger
139
+ rec_inner = SIZE_TO_INNER.get(rec_size, 0)
140
+
141
+ fits_any = False
142
+ fit_details = {}
143
+ for fn, gt_sz in gt_sizes.items():
144
+ gt_inner = SIZE_TO_INNER.get(gt_sz, 0)
145
+ gt_diam = gt_diameters.get(fn, 0)
146
+
147
+ # "Fits" = ring can go on (rec inner >= finger diameter)
148
+ # AND not absurdly loose (rec size <= gt_size + 2)
149
+ can_go_on = rec_inner >= gt_diam
150
+ not_too_loose = rec_size <= gt_sz + 2
151
+ fits = can_go_on and not_too_loose
152
+
153
+ fit_details[fn] = {
154
+ "gt_size": gt_sz,
155
+ "gt_diam_mm": gt_diam,
156
+ "rec_inner_mm": rec_inner,
157
+ "can_go_on": can_go_on,
158
+ "not_too_loose": not_too_loose,
159
+ "fits": fits,
160
+ }
161
+ if fits:
162
+ fits_any = True
163
+
164
+ status = "OK" if fits_any else "RETURN"
165
+ print(f"rec={rec_size} ({rec_min}-{rec_max}) β†’ {status}")
166
+ for fn, fd in fit_details.items():
167
+ tag = "βœ“" if fd["fits"] else "βœ—"
168
+ print(f" {fn}: gt_size={fd['gt_size']} gt_diam={fd['gt_diam_mm']:.1f}mm "
169
+ f"rec_inner={fd['rec_inner_mm']:.1f}mm [{tag}]")
170
+
171
+ results.append({
172
+ "label": label, "sid": sid, "variant": variant,
173
+ "rec_size": rec_size, "rec_min": rec_min, "rec_max": rec_max,
174
+ "fail": None,
175
+ "gt_sizes": gt_sizes, "gt_diameters": gt_diameters,
176
+ "measured": measured, "fit_details": fit_details,
177
+ "fits_any": fits_any, "return": not fits_any,
178
+ })
179
+
180
+ # --- Summary ---
181
+ print("\n" + "=" * 70)
182
+ print("EVALUATION SUMMARY")
183
+ print("=" * 70)
184
+
185
+ total = len(results)
186
+ failed = sum(1 for r in results if r["fail"])
187
+ succeeded = total - failed
188
+ returns = sum(1 for r in results if r["return"] and not r["fail"])
189
+ fits = sum(1 for r in results if r["fits_any"])
190
+
191
+ print(f"\nTotal images: {total}")
192
+ print(f"Measurement OK: {succeeded}")
193
+ print(f"Measurement FAIL: {failed}")
194
+ print()
195
+ print(f"Of {succeeded} successful measurements:")
196
+ print(f" Fits β‰₯1 finger: {fits} ({fits/succeeded*100:.1f}%)" if succeeded else "")
197
+ print(f" Would RETURN: {returns} ({returns/succeeded*100:.1f}%)" if succeeded else "")
198
+ print()
199
+
200
+ # Detailed table
201
+ print(f"{'Label':<10} {'Rec':>4} {'Range':>7} {'GT(I)':>6} {'GT(M)':>6} {'GT(R)':>6} {'Result':<8}")
202
+ print("-" * 55)
203
+ for r in results:
204
+ if r["fail"]:
205
+ print(f"{r['label']:<10} {'FAIL':>4} {'':>7} "
206
+ f"{r['gt_sizes'].get('index',''):>6} "
207
+ f"{r['gt_sizes'].get('middle',''):>6} "
208
+ f"{r['gt_sizes'].get('ring',''):>6} "
209
+ f"{'FAIL':<8}")
210
+ continue
211
+ gt = r["gt_sizes"]
212
+ rng = f"{r['rec_min']}-{r['rec_max']}"
213
+ status = "OK" if r["fits_any"] else "RETURN"
214
+ print(f"{r['label']:<10} {r['rec_size']:>4} {rng:>7} "
215
+ f"{gt.get('index',''):>6} {gt.get('middle',''):>6} {gt.get('ring',''):>6} "
216
+ f"{status:<8}")
217
+
218
+ # Per-subject analysis (best of A/B)
219
+ print("\n\nPER-SUBJECT (best of A/B photos):")
220
+ print(f"{'Subject':<8} {'A_rec':>5} {'A_fit':>5} {'B_rec':>5} {'B_fit':>5} {'Best':>5} {'Result':<8}")
221
+ print("-" * 52)
222
+ for sid in sorted(GT.keys()):
223
+ subj_results = [r for r in results if r["sid"] == sid]
224
+ a_results = [r for r in subj_results if r["variant"] == "A"]
225
+ b_results = [r for r in subj_results if r["variant"] == "B"]
226
+
227
+ def fmt(rlist):
228
+ if not rlist:
229
+ return ("β€”", "β€”")
230
+ r = rlist[0]
231
+ if r["fail"]:
232
+ return ("FAIL", "β€”")
233
+ return (str(r["rec_size"]), "βœ“" if r["fits_any"] else "βœ—")
234
+
235
+ a_rec, a_fit = fmt(a_results)
236
+ b_rec, b_fit = fmt(b_results)
237
+ best = "OK" if any(r["fits_any"] for r in subj_results) else "RETURN"
238
+ print(f"{sid:<8} {a_rec:>5} {a_fit:>5} {b_rec:>5} {b_fit:>5} {'':>5} {best:<8}")
239
+
240
+ subj_with_any_ok = sum(
241
+ 1 for sid in GT
242
+ if any(r["fits_any"] for r in results if r["sid"] == sid)
243
+ )
244
+ subj_total = len([sid for sid in GT if any(r["sid"] == sid for r in results)])
245
+ print(f"\nSubjects with β‰₯1 fitting result: {subj_with_any_ok}/{subj_total}")
246
+ print(f"Effective return rate (per-subject): {(1 - subj_with_any_ok/subj_total)*100:.1f}%" if subj_total else "N/A")
247
+
248
+ # Size error analysis
249
+ print("\n\nSIZE ERROR ANALYSIS (recommended vs closest GT size):")
250
+ errors = []
251
+ for r in results:
252
+ if r["fail"] or r["rec_size"] is None:
253
+ continue
254
+ gt = r["gt_sizes"]
255
+ gt_vals = [v for v in gt.values() if v is not None]
256
+ if not gt_vals:
257
+ continue
258
+ # Error = rec - closest GT
259
+ closest_gt = min(gt_vals, key=lambda s: abs(s - r["rec_size"]))
260
+ err = r["rec_size"] - closest_gt
261
+ errors.append(err)
262
+
263
+ if errors:
264
+ import statistics
265
+ print(f" Mean error: {statistics.mean(errors):+.2f} sizes")
266
+ print(f" Median error: {statistics.median(errors):+.1f} sizes")
267
+ print(f" Std dev: {statistics.stdev(errors):.2f} sizes")
268
+ print(f" Range: [{min(errors):+d}, {max(errors):+d}]")
269
+
270
+ # Distribution
271
+ from collections import Counter
272
+ dist = Counter(errors)
273
+ print(f"\n Error distribution:")
274
+ for e in sorted(dist.keys()):
275
+ bar = "β–ˆ" * dist[e]
276
+ print(f" {e:+d}: {bar} ({dist[e]})")
src/ai_recommendation.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """AI-powered ring size explanation using OpenAI.
2
+
3
+ Size selection is handled by deterministic logic in ring_size.py.
4
+ This module only generates a human-friendly explanation for the recommendation.
5
+ """
6
+
7
+ import os
8
+ import logging
9
+ from typing import Dict, Optional
10
+
11
+ from src.ring_size import RING_SIZE_CHART
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ _SIZE_TABLE_TEXT = "\n".join(
16
+ f" Size {size}: inner diameter {diameter_mm:.1f} mm"
17
+ for size, diameter_mm in sorted(RING_SIZE_CHART.items())
18
+ )
19
+
20
+ _SYSTEM_PROMPT = """You are a sizing explanation assistant for Femometer Smart Ring.
21
+
22
+ You are given measured finger widths and a pre-computed ring size recommendation.
23
+ Your ONLY job is to explain WHY the recommended size is a good fit, in 1-2 concise sentences.
24
+
25
+ Do NOT suggest a different size. The size decision has already been made by the system.
26
+
27
+ Guidelines:
28
+ - Mention which finger(s) would fit best at this size
29
+ - Include specific diameter when first referencing a size, e.g. "Size 8 (18.6mm)"
30
+ - Priority context: index finger fit is slightly preferred over middle, then ring
31
+ - Keep it concise and actionable
32
+
33
+ Ring Size Chart (China Standard):
34
+ {size_table}
35
+
36
+ Respond in plain text (1-2 sentences). Do NOT use JSON or markdown.
37
+ """.format(size_table=_SIZE_TABLE_TEXT)
38
+
39
+
40
+ def ai_explain_recommendation(
41
+ finger_widths: Dict[str, Optional[float]],
42
+ recommended_size: int,
43
+ range_min: int,
44
+ range_max: int,
45
+ ) -> Optional[str]:
46
+ """Call OpenAI to explain an already-computed ring size recommendation.
47
+
48
+ Args:
49
+ finger_widths: Dict mapping finger name to diameter in cm (or None if failed).
50
+ Example: {"index": 1.93, "middle": 1.84, "ring": 1.93}
51
+ recommended_size: The deterministic best-match size from ring_size.py.
52
+ range_min: Lower bound of recommended size range.
53
+ range_max: Upper bound of recommended size range.
54
+
55
+ Returns:
56
+ A plain-text explanation string, or None if the API call fails.
57
+ """
58
+ api_key = os.environ.get("OPENAI_API_KEY")
59
+ if not api_key:
60
+ logger.warning("OPENAI_API_KEY not set, skipping AI explanation")
61
+ return None
62
+
63
+ # Build user message with measurements and pre-computed recommendation
64
+ lines = ["Measured finger outer diameters:"]
65
+ for finger, width in finger_widths.items():
66
+ if width is not None:
67
+ lines.append(f" {finger.capitalize()}: {width:.2f} cm ({width * 10:.1f} mm)")
68
+ else:
69
+ lines.append(f" {finger.capitalize()}: measurement failed")
70
+ lines.append("")
71
+ lines.append(f"Recommended size: {recommended_size} (range {range_min}–{range_max})")
72
+ lines.append("")
73
+ lines.append("Explain why this size is a good fit.")
74
+ user_msg = "\n".join(lines)
75
+
76
+ try:
77
+ import openai
78
+
79
+ client = openai.OpenAI(api_key=api_key)
80
+ response = client.chat.completions.create(
81
+ model="gpt-5.4",
82
+ messages=[
83
+ {"role": "system", "content": _SYSTEM_PROMPT},
84
+ {"role": "user", "content": user_msg},
85
+ ],
86
+ temperature=0.3,
87
+ max_completion_tokens=200,
88
+ )
89
+
90
+ content = response.choices[0].message.content.strip()
91
+ if not content:
92
+ logger.warning("AI returned empty explanation")
93
+ return None
94
+
95
+ return content
96
+
97
+ except Exception as e:
98
+ logger.error("AI explanation failed: %s", e)
99
+ return None
src/finger_segmentation.py CHANGED
@@ -162,26 +162,31 @@ def detect_hand_orientation(
162
 
163
  Canonical orientation: wrist at bottom, fingers pointing upward.
164
 
 
 
 
 
 
165
  Args:
166
  landmarks_normalized: MediaPipe hand landmarks (21x2) in normalized [0-1] coordinates
167
- finger: Which finger to use for orientation detection (default: "index")
168
 
169
  Returns:
170
  Angle in degrees to rotate image clockwise to achieve canonical orientation.
171
  Returns one of: 0, 90, 180, 270
172
  """
173
- # Get wrist (landmark 0) and specified finger tip
174
  wrist = landmarks_normalized[WRIST_LANDMARK]
175
 
176
- # Use specified finger, fallback to middle if invalid
177
- if finger in FINGER_LANDMARKS:
178
- finger_tip = landmarks_normalized[FINGER_LANDMARKS[finger][3]]
179
- else:
180
- # Fallback to middle finger for "auto" or invalid values
181
- finger_tip = landmarks_normalized[FINGER_LANDMARKS["middle"][3]]
 
182
 
183
- # Compute vector from wrist to fingertip
184
- direction = finger_tip - wrist
185
 
186
  # Compute angle from vertical upward direction
187
  # In image coordinates: y increases downward, x increases rightward
 
162
 
163
  Canonical orientation: wrist at bottom, fingers pointing upward.
164
 
165
+ Uses the centroid of index, middle, and ring fingertips for robust
166
+ orientation detection, regardless of which finger is being measured.
167
+ Individual fingers (especially index/pinky) can lean away from the
168
+ hand's central axis, causing borderline rotation errors.
169
+
170
  Args:
171
  landmarks_normalized: MediaPipe hand landmarks (21x2) in normalized [0-1] coordinates
172
+ finger: Which finger is being measured (kept for API compat, not used for orientation)
173
 
174
  Returns:
175
  Angle in degrees to rotate image clockwise to achieve canonical orientation.
176
  Returns one of: 0, 90, 180, 270
177
  """
 
178
  wrist = landmarks_normalized[WRIST_LANDMARK]
179
 
180
+ # Use centroid of index, middle, ring fingertips for robust orientation
181
+ fingertip_indices = [
182
+ FINGER_LANDMARKS["index"][3], # 8
183
+ FINGER_LANDMARKS["middle"][3], # 12
184
+ FINGER_LANDMARKS["ring"][3], # 16
185
+ ]
186
+ fingertip_center = np.mean(landmarks_normalized[fingertip_indices], axis=0)
187
 
188
+ # Compute vector from wrist to fingertip centroid
189
+ direction = fingertip_center - wrist
190
 
191
  # Compute angle from vertical upward direction
192
  # In image coordinates: y increases downward, x increases rightward
src/image_quality.py CHANGED
@@ -179,3 +179,186 @@ def assess_image_quality(image: np.ndarray) -> Dict[str, Any]:
179
  "issues": issues,
180
  "fail_reason": fail_reason,
181
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
179
  "issues": issues,
180
  "fail_reason": fail_reason,
181
  }
182
+
183
+
184
+ def check_card_in_frame(
185
+ card_corners: np.ndarray,
186
+ image_shape: tuple,
187
+ margin_pct: float = 0.02,
188
+ ) -> Dict[str, Any]:
189
+ """Check if credit card is fully within the image frame.
190
+
191
+ Args:
192
+ card_corners: 4x2 array of card corner coordinates (x, y)
193
+ image_shape: (height, width) of image
194
+ margin_pct: minimum margin from edge as fraction of image dimension
195
+
196
+ Returns:
197
+ Dict with:
198
+ - in_frame: bool
199
+ - min_margin_px: float (smallest distance from any corner to any edge)
200
+ - fail_reason: str or None
201
+ """
202
+ h, w = image_shape[:2]
203
+ margin_x = w * margin_pct
204
+ margin_y = h * margin_pct
205
+
206
+ min_margin_px = float("inf")
207
+ in_frame = True
208
+
209
+ for corner in card_corners:
210
+ x, y = float(corner[0]), float(corner[1])
211
+ dist_left = x
212
+ dist_right = w - x
213
+ dist_top = y
214
+ dist_bottom = h - y
215
+ corner_min = min(dist_left, dist_right, dist_top, dist_bottom)
216
+ min_margin_px = min(min_margin_px, corner_min)
217
+
218
+ if x < margin_x or x > w - margin_x or y < margin_y or y > h - margin_y:
219
+ in_frame = False
220
+
221
+ fail_reason = None if in_frame else "card_near_edge"
222
+
223
+ return {
224
+ "in_frame": in_frame,
225
+ "min_margin_px": min_margin_px,
226
+ "fail_reason": fail_reason,
227
+ }
228
+
229
+
230
+ def check_finger_landmarks_visible(
231
+ landmarks: np.ndarray,
232
+ image_shape: tuple,
233
+ margin_pct: float = 0.02,
234
+ ) -> Dict[str, Any]:
235
+ """Check if finger landmarks are fully visible in image.
236
+
237
+ Args:
238
+ landmarks: 4x2 array of finger landmarks [MCP, PIP, DIP, TIP]
239
+ image_shape: (height, width) of image
240
+ margin_pct: minimum margin from edge
241
+
242
+ Returns:
243
+ Dict with:
244
+ - all_visible: bool
245
+ - num_visible: int (0-4)
246
+ - fail_reason: str or None
247
+ """
248
+ h, w = image_shape[:2]
249
+ margin_x = w * margin_pct
250
+ margin_y = h * margin_pct
251
+
252
+ num_visible = 0
253
+ for lm in landmarks:
254
+ x, y = float(lm[0]), float(lm[1])
255
+ if margin_x <= x <= w - margin_x and margin_y <= y <= h - margin_y:
256
+ num_visible += 1
257
+
258
+ all_visible = num_visible == len(landmarks)
259
+ fail_reason = None if all_visible else "finger_not_fully_visible"
260
+
261
+ return {
262
+ "all_visible": all_visible,
263
+ "num_visible": num_visible,
264
+ "fail_reason": fail_reason,
265
+ }
266
+
267
+
268
+ def check_finger_spacing(
269
+ all_landmarks: Dict[str, np.ndarray],
270
+ image_shape: tuple,
271
+ min_spacing_pct: float = 0.03,
272
+ ) -> Dict[str, Any]:
273
+ """Check if fingers are spread apart enough for accurate measurement.
274
+
275
+ Args:
276
+ all_landmarks: Dict mapping finger name to 4x2 landmarks array
277
+ image_shape: (height, width) of image
278
+ min_spacing_pct: minimum spacing between adjacent fingers as fraction of image width
279
+
280
+ Returns:
281
+ Dict with:
282
+ - well_spaced: bool
283
+ - min_spacing_px: float
284
+ - closest_pair: tuple of finger names or None
285
+ - fail_reason: str or None
286
+ """
287
+ min_spacing = image_shape[1] * min_spacing_pct
288
+ finger_names = list(all_landmarks.keys())
289
+
290
+ min_spacing_px = float("inf")
291
+ closest_pair = None
292
+ well_spaced = True
293
+
294
+ for i in range(len(finger_names) - 1):
295
+ name_a = finger_names[i]
296
+ name_b = finger_names[i + 1]
297
+ pip_a = np.array(all_landmarks[name_a][1], dtype=float)
298
+ pip_b = np.array(all_landmarks[name_b][1], dtype=float)
299
+ dist = float(np.linalg.norm(pip_a - pip_b))
300
+
301
+ if dist < min_spacing_px:
302
+ min_spacing_px = dist
303
+ closest_pair = (name_a, name_b)
304
+
305
+ if min_spacing_px < min_spacing:
306
+ well_spaced = False
307
+
308
+ fail_reason = None if well_spaced else "fingers_too_close"
309
+
310
+ return {
311
+ "well_spaced": well_spaced,
312
+ "min_spacing_px": min_spacing_px,
313
+ "closest_pair": closest_pair,
314
+ "fail_reason": fail_reason,
315
+ }
316
+
317
+
318
+ def check_lighting_uniformity(
319
+ image: np.ndarray,
320
+ threshold: float = 0.4,
321
+ ) -> Dict[str, Any]:
322
+ """Check for even lighting across the image.
323
+
324
+ Args:
325
+ image: Input BGR image
326
+ threshold: maximum allowed ratio between darkest and brightest quadrant means
327
+
328
+ Returns:
329
+ Dict with:
330
+ - uniform: bool
331
+ - brightness_range: float (max_quadrant - min_quadrant)
332
+ - fail_reason: str or None
333
+ """
334
+ if len(image.shape) == 3:
335
+ gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
336
+ else:
337
+ gray = image
338
+
339
+ h, w = gray.shape[:2]
340
+ mid_y, mid_x = h // 2, w // 2
341
+
342
+ quadrants = [
343
+ gray[:mid_y, :mid_x],
344
+ gray[:mid_y, mid_x:],
345
+ gray[mid_y:, :mid_x],
346
+ gray[mid_y:, mid_x:],
347
+ ]
348
+ means = [float(np.mean(q)) for q in quadrants]
349
+
350
+ max_mean = max(means)
351
+ min_mean = min(means)
352
+ brightness_range = max_mean - min_mean
353
+
354
+ uniform = True
355
+ if max_mean > 0 and brightness_range / max_mean > threshold:
356
+ uniform = False
357
+
358
+ fail_reason = None if uniform else "image_quality_low_lighting"
359
+
360
+ return {
361
+ "uniform": uniform,
362
+ "brightness_range": brightness_range,
363
+ "fail_reason": fail_reason,
364
+ }
src/ring_size.py CHANGED
@@ -1,6 +1,6 @@
1
  """Ring size recommendation from calibrated finger width."""
2
 
3
- from typing import Dict, Optional, Tuple
4
 
5
  # China standard ring size chart: size β†’ inner diameter (mm)
6
  RING_SIZE_CHART = {
@@ -52,3 +52,95 @@ def recommend_ring_size(diameter_cm: float) -> Optional[Dict]:
52
  "range_max": range_max,
53
  "diameter_mm": round(diameter_mm, 2),
54
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """Ring size recommendation from calibrated finger width."""
2
 
3
+ from typing import Dict, List, Optional, Tuple
4
 
5
  # China standard ring size chart: size β†’ inner diameter (mm)
6
  RING_SIZE_CHART = {
 
52
  "range_max": range_max,
53
  "diameter_mm": round(diameter_mm, 2),
54
  }
55
+
56
+
57
+ def aggregate_ring_sizes(per_finger_results: Dict[str, Dict]) -> Dict:
58
+ """Aggregate ring size recommendations from multiple fingers.
59
+
60
+ Args:
61
+ per_finger_results: Dict mapping finger name to measurement result dict.
62
+ Each value must have keys:
63
+ - "finger_outer_diameter_cm": float or None
64
+ - "confidence": float
65
+ - "ring_size": dict from recommend_ring_size() or None
66
+ - "fail_reason": str or None
67
+
68
+ Returns:
69
+ Dict with:
70
+ - overall_best_size: int (consensus size if one exists in all
71
+ fingers' ranges, otherwise confidence-weighted best size)
72
+ - overall_range_min: int (min of all per-finger range_min)
73
+ - overall_range_max: int (max of all per-finger range_max)
74
+ - fingers_measured: int (total attempted)
75
+ - fingers_succeeded: int (with valid measurement)
76
+ - per_finger: dict of per-finger details
77
+ - fail_reason: str or None (only if ALL fingers failed)
78
+ """
79
+ fingers_measured = len(per_finger_results)
80
+
81
+ # Build per_finger summary
82
+ per_finger: Dict[str, Dict] = {}
83
+ for name, result in per_finger_results.items():
84
+ failed = result.get("fail_reason") is not None or result.get("ring_size") is None
85
+ rs = result.get("ring_size")
86
+ per_finger[name] = {
87
+ "diameter_cm": result.get("finger_outer_diameter_cm"),
88
+ "confidence": result.get("confidence", 0.0),
89
+ "best_match": rs["best_match"] if rs else None,
90
+ "range": [rs["range_min"], rs["range_max"]] if rs else None,
91
+ "status": "failed" if failed else "ok",
92
+ "fail_reason": result.get("fail_reason"),
93
+ }
94
+
95
+ # Filter to succeeded fingers
96
+ succeeded = {
97
+ name: info for name, info in per_finger.items() if info["status"] == "ok"
98
+ }
99
+
100
+ if not succeeded:
101
+ return {
102
+ "fail_reason": "all_fingers_failed",
103
+ "fingers_measured": fingers_measured,
104
+ "fingers_succeeded": 0,
105
+ "per_finger": per_finger,
106
+ }
107
+
108
+ # Confidence-weighted voting for best size
109
+ vote_tally: Dict[int, float] = {}
110
+ for info in succeeded.values():
111
+ size = info["best_match"]
112
+ vote_tally[size] = vote_tally.get(size, 0.0) + info["confidence"]
113
+
114
+ weighted_best_size = max(vote_tally, key=lambda s: vote_tally[s])
115
+
116
+ # Intersection-first override: if a size falls in every finger's range, prefer it
117
+ all_ranges = [set(range(info["range"][0], info["range"][1] + 1))
118
+ for info in succeeded.values()]
119
+ consensus_sizes = set.intersection(*all_ranges) if all_ranges else set()
120
+
121
+ if consensus_sizes:
122
+ # Pick the consensus size closest to the confidence-weighted winner
123
+ overall_best_size = min(consensus_sizes,
124
+ key=lambda s: abs(s - weighted_best_size))
125
+ else:
126
+ overall_best_size = weighted_best_size
127
+
128
+ # Aggregate range
129
+ overall_range_min = min(info["range"][0] for info in succeeded.values())
130
+ overall_range_max = max(info["range"][1] for info in succeeded.values())
131
+
132
+ # Ensure range covers best size
133
+ if overall_best_size < overall_range_min:
134
+ overall_range_min = overall_best_size
135
+ if overall_best_size > overall_range_max:
136
+ overall_range_max = overall_best_size
137
+
138
+ return {
139
+ "overall_best_size": overall_best_size,
140
+ "overall_range_min": overall_range_min,
141
+ "overall_range_max": overall_range_max,
142
+ "fingers_measured": fingers_measured,
143
+ "fingers_succeeded": len(succeeded),
144
+ "per_finger": per_finger,
145
+ "fail_reason": None,
146
+ }
web_demo/app.py CHANGED
@@ -13,14 +13,16 @@ from pathlib import Path
13
  from typing import Dict, Any
14
 
15
  import cv2
 
16
  from flask import Flask, jsonify, render_template, request, send_from_directory
17
  from werkzeug.utils import secure_filename
18
 
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
  from src.ring_size import recommend_ring_size
 
24
 
25
  APP_ROOT = Path(__file__).resolve().parent
26
  UPLOAD_DIR = APP_ROOT / "uploads"
@@ -37,10 +39,45 @@ def _allowed_file(filename: str) -> bool:
37
  return Path(filename).suffix.lower() in ALLOWED_EXTENSIONS
38
 
39
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  def _save_json(path: Path, data: Dict[str, Any]) -> None:
41
  path.parent.mkdir(parents=True, exist_ok=True)
42
  with path.open("w", encoding="utf-8") as f:
43
- json.dump(data, f, indent=2, ensure_ascii=False)
44
 
45
 
46
  @app.route("/")
@@ -71,6 +108,7 @@ def api_measure():
71
  return jsonify({"success": False, "error": "Unsupported file type"}), 400
72
 
73
  finger_index = request.form.get("finger_index", "index")
 
74
  run_id = uuid.uuid4().hex[:12]
75
  safe_name = secure_filename(file.filename)
76
  upload_name = f"{run_id}__{safe_name}"
@@ -82,6 +120,12 @@ def api_measure():
82
  if image is None:
83
  return jsonify({"success": False, "error": "Failed to load image"}), 400
84
 
 
 
 
 
 
 
85
  return _run_measurement(
86
  image=image,
87
  finger_index=finger_index,
@@ -92,6 +136,7 @@ def api_measure():
92
  @app.route("/api/measure-default", methods=["POST"])
93
  def api_measure_default():
94
  finger_index = request.form.get("finger_index", "index")
 
95
  if not DEFAULT_SAMPLE_PATH.exists():
96
  return jsonify({"success": False, "error": "Default sample image not found"}), 500
97
 
@@ -99,6 +144,12 @@ def api_measure_default():
99
  if image is None:
100
  return jsonify({"success": False, "error": "Failed to load default sample image"}), 500
101
 
 
 
 
 
 
 
102
  return _run_measurement(
103
  image=image,
104
  finger_index=finger_index,
@@ -135,12 +186,72 @@ def _run_measurement(
135
  if rec:
136
  result["ring_size"] = rec
137
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
  result_json_name = f"{run_id}__result.json"
139
  result_json_path = RESULTS_DIR / result_json_name
140
  _save_json(result_json_path, result)
141
 
142
  payload = {
143
  "success": result.get("fail_reason") is None,
 
144
  "result": result,
145
  "result_image_url": f"/results/{result_png_name}",
146
  "input_image_url": input_image_url,
 
13
  from typing import Dict, Any
14
 
15
  import cv2
16
+ import numpy as np
17
  from flask import Flask, jsonify, render_template, request, send_from_directory
18
  from werkzeug.utils import secure_filename
19
 
20
  ROOT_DIR = Path(__file__).resolve().parents[1]
21
  sys.path.insert(0, str(ROOT_DIR))
22
 
23
+ from measure_finger import measure_finger, measure_multi_finger, apply_calibration
24
  from src.ring_size import recommend_ring_size
25
+ from src.ai_recommendation import ai_explain_recommendation
26
 
27
  APP_ROOT = Path(__file__).resolve().parent
28
  UPLOAD_DIR = APP_ROOT / "uploads"
 
39
  return Path(filename).suffix.lower() in ALLOWED_EXTENSIONS
40
 
41
 
42
+ class _NumpyEncoder(json.JSONEncoder):
43
+ """Handle numpy types that aren't natively JSON serializable."""
44
+ def default(self, obj):
45
+ if isinstance(obj, np.bool_):
46
+ return bool(obj)
47
+ if isinstance(obj, np.integer):
48
+ return int(obj)
49
+ if isinstance(obj, np.floating):
50
+ return float(obj)
51
+ if isinstance(obj, np.ndarray):
52
+ return obj.tolist()
53
+ if isinstance(obj, np.generic):
54
+ return obj.item()
55
+ return super().default(obj)
56
+
57
+
58
+ def _numpy_safe(obj):
59
+ """Recursively convert numpy types to native Python types."""
60
+ if isinstance(obj, dict):
61
+ return {k: _numpy_safe(v) for k, v in obj.items()}
62
+ if isinstance(obj, (list, tuple)):
63
+ return [_numpy_safe(v) for v in obj]
64
+ if isinstance(obj, np.bool_):
65
+ return bool(obj)
66
+ if isinstance(obj, np.integer):
67
+ return int(obj)
68
+ if isinstance(obj, np.floating):
69
+ return float(obj)
70
+ if isinstance(obj, np.ndarray):
71
+ return obj.tolist()
72
+ if isinstance(obj, np.generic):
73
+ return obj.item()
74
+ return obj
75
+
76
+
77
  def _save_json(path: Path, data: Dict[str, Any]) -> None:
78
  path.parent.mkdir(parents=True, exist_ok=True)
79
  with path.open("w", encoding="utf-8") as f:
80
+ json.dump(data, f, indent=2, ensure_ascii=False, cls=_NumpyEncoder)
81
 
82
 
83
  @app.route("/")
 
108
  return jsonify({"success": False, "error": "Unsupported file type"}), 400
109
 
110
  finger_index = request.form.get("finger_index", "index")
111
+ mode = request.form.get("mode", "single")
112
  run_id = uuid.uuid4().hex[:12]
113
  safe_name = secure_filename(file.filename)
114
  upload_name = f"{run_id}__{safe_name}"
 
120
  if image is None:
121
  return jsonify({"success": False, "error": "Failed to load image"}), 400
122
 
123
+ if mode == "multi":
124
+ return _run_multi_measurement(
125
+ image=image,
126
+ input_image_url=f"/uploads/{upload_name}",
127
+ )
128
+
129
  return _run_measurement(
130
  image=image,
131
  finger_index=finger_index,
 
136
  @app.route("/api/measure-default", methods=["POST"])
137
  def api_measure_default():
138
  finger_index = request.form.get("finger_index", "index")
139
+ mode = request.form.get("mode", "single")
140
  if not DEFAULT_SAMPLE_PATH.exists():
141
  return jsonify({"success": False, "error": "Default sample image not found"}), 500
142
 
 
144
  if image is None:
145
  return jsonify({"success": False, "error": "Failed to load default sample image"}), 500
146
 
147
+ if mode == "multi":
148
+ return _run_multi_measurement(
149
+ image=image,
150
+ input_image_url=DEFAULT_SAMPLE_URL,
151
+ )
152
+
153
  return _run_measurement(
154
  image=image,
155
  finger_index=finger_index,
 
186
  if rec:
187
  result["ring_size"] = rec
188
 
189
+ result = _numpy_safe(result)
190
+
191
+ result_json_name = f"{run_id}__result.json"
192
+ result_json_path = RESULTS_DIR / result_json_name
193
+ _save_json(result_json_path, result)
194
+
195
+ payload = {
196
+ "success": result.get("fail_reason") is None,
197
+ "result": result,
198
+ "result_image_url": f"/results/{result_png_name}",
199
+ "input_image_url": input_image_url,
200
+ "result_json_url": f"/results/{result_json_name}",
201
+ }
202
+
203
+ return jsonify(payload)
204
+
205
+
206
+ def _run_multi_measurement(
207
+ image,
208
+ input_image_url: str,
209
+ ):
210
+ """Run multi-finger measurement pipeline."""
211
+ run_id = uuid.uuid4().hex[:12]
212
+
213
+ result_png_name = f"{run_id}__result.png"
214
+ result_png_path = RESULTS_DIR / result_png_name
215
+
216
+ result = measure_multi_finger(
217
+ image=image,
218
+ edge_method=DEMO_EDGE_METHOD,
219
+ result_png_path=str(result_png_path),
220
+ save_debug=False,
221
+ no_calibration=False,
222
+ )
223
+
224
+ result = _numpy_safe(result)
225
+
226
+ # Collect finger widths for AI recommendation
227
+ per_finger = result.get("per_finger", {})
228
+ finger_widths = {}
229
+ for fn in ("index", "middle", "ring"):
230
+ pf = per_finger.get(fn, {})
231
+ if pf.get("status") == "ok" and pf.get("diameter_cm") is not None:
232
+ finger_widths[fn] = pf["diameter_cm"]
233
+ else:
234
+ finger_widths[fn] = None
235
+
236
+ ai_reason = None
237
+ ai_explain = request.form.get("ai_explain", "0") == "1"
238
+ if ai_explain and result.get("overall_best_size") is not None:
239
+ ai_reason = ai_explain_recommendation(
240
+ finger_widths,
241
+ recommended_size=result["overall_best_size"],
242
+ range_min=result["overall_range_min"],
243
+ range_max=result["overall_range_max"],
244
+ )
245
+ if ai_reason:
246
+ result["ai_explanation"] = ai_reason
247
+
248
  result_json_name = f"{run_id}__result.json"
249
  result_json_path = RESULTS_DIR / result_json_name
250
  _save_json(result_json_path, result)
251
 
252
  payload = {
253
  "success": result.get("fail_reason") is None,
254
+ "mode": "multi",
255
  "result": result,
256
  "result_image_url": f"/results/{result_png_name}",
257
  "input_image_url": input_image_url,
web_demo/static/app.js CHANGED
@@ -7,18 +7,29 @@ const inputFrame = document.getElementById("inputFrame");
7
  const debugFrame = document.getElementById("debugFrame");
8
  const jsonOutput = document.getElementById("jsonOutput");
9
  const jsonLink = document.getElementById("jsonLink");
 
 
 
 
 
10
  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
  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:
19
  "Could not isolate the selected finger. Keep one target finger extended and separated.",
 
 
20
  finger_mask_too_small:
21
  "Finger region is too small. Move closer and use a higher-resolution photo.",
 
 
22
  contour_extraction_failed:
23
  "Finger contour extraction failed. Improve lighting and reduce background clutter.",
24
  axis_estimation_failed:
@@ -33,6 +44,20 @@ const failReasonMessageMap = {
33
  "Measured width is out of range. Retake with the phone parallel to the table.",
34
  disagreement_with_contour:
35
  "Edge methods disagree too much. Retake with cleaner edges and more even lighting.",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  };
37
 
38
  const formatFailReasonStatus = (failReason) => {
@@ -69,15 +94,74 @@ const showImage = (imgEl, frameEl, url) => {
69
 
70
  const buildMeasureSettings = () => {
71
  const fingerSelect = form.querySelector('select[name="finger_index"]');
 
 
72
  return {
73
  finger_index: fingerSelect ? fingerSelect.value : "index",
74
  edge_method: "sobel",
 
 
75
  };
76
  };
77
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  const runMeasurement = async (endpoint, formData, inputUrlFallback = "") => {
79
  setStatus("Measuring… Please wait.");
80
- jsonOutput.textContent = "{\n \"status\": \"processing\"\n}";
 
81
 
82
  try {
83
  const response = await fetch(endpoint, {
@@ -98,6 +182,10 @@ const runMeasurement = async (endpoint, formData, inputUrlFallback = "") => {
98
  showImage(inputPreview, inputFrame, data.input_image_url || inputUrlFallback);
99
  showImage(debugPreview, debugFrame, data.result_image_url);
100
 
 
 
 
 
101
  if (data.success) {
102
  setStatus("Measurement complete. Results updated.");
103
  } else {
@@ -123,6 +211,17 @@ imageInput.addEventListener("change", () => {
123
  setStatus("Image ready. Click to start measurement.");
124
  });
125
 
 
 
 
 
 
 
 
 
 
 
 
126
  form.addEventListener("submit", async (event) => {
127
  event.preventDefault();
128
 
@@ -130,6 +229,8 @@ form.addEventListener("submit", async (event) => {
130
  const formData = new FormData();
131
  formData.append("finger_index", settings.finger_index);
132
  formData.append("edge_method", settings.edge_method);
 
 
133
 
134
  const file = imageInput.files[0];
135
  if (file) {
 
7
  const debugFrame = document.getElementById("debugFrame");
8
  const jsonOutput = document.getElementById("jsonOutput");
9
  const jsonLink = document.getElementById("jsonLink");
10
+ const modeSelect = document.getElementById("modeSelect");
11
+ const fingerSelectGroup = document.getElementById("fingerSelectGroup");
12
+ const multiResultPanel = document.getElementById("multiResultPanel");
13
+ const overallSize = document.getElementById("overallSize");
14
+ const fingerBreakdown = document.getElementById("fingerBreakdown");
15
  const defaultSampleUrl = window.DEFAULT_SAMPLE_URL || "";
16
  const failReasonMessageMap = {
17
  card_not_detected:
18
  "Credit card not detected. Place a full card flat beside your hand.",
19
  card_not_parallel:
20
  "Card is not parallel to the camera. Keep your phone directly above and 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. Include your full palm in frame and keep fingers fully visible.",
25
  finger_isolation_failed:
26
  "Could not isolate the selected finger. Keep one target finger extended and separated.",
27
+ finger_not_fully_visible:
28
+ "Finger is partially out of frame. Move hand to center of photo.",
29
  finger_mask_too_small:
30
  "Finger region is too small. Move closer and use a higher-resolution photo.",
31
+ fingers_too_close:
32
+ "Fingers are too close together. Spread your fingers apart naturally.",
33
  contour_extraction_failed:
34
  "Finger contour extraction failed. Improve lighting and reduce background clutter.",
35
  axis_estimation_failed:
 
44
  "Measured width is out of range. Retake with the phone parallel to the table.",
45
  disagreement_with_contour:
46
  "Edge methods disagree too much. Retake with cleaner edges and more even lighting.",
47
+ all_fingers_failed:
48
+ "Could not measure any fingers. Ensure hand is flat with fingers spread and well-lit.",
49
+ image_too_blurry:
50
+ "Photo is blurry. Hold your phone steady or use a tripod.",
51
+ image_underexposed:
52
+ "Photo is too dark. Turn on flash or improve lighting.",
53
+ image_overexposed:
54
+ "Photo is too bright. Avoid direct sunlight or strong overhead light.",
55
+ image_low_contrast:
56
+ "Photo has low contrast. Use a different background color.",
57
+ image_resolution_too_low:
58
+ "Photo resolution is too low. Use the rear camera at full resolution.",
59
+ image_quality_low_lighting:
60
+ "Lighting is uneven. Turn on flash and shoot from directly above.",
61
  };
62
 
63
  const formatFailReasonStatus = (failReason) => {
 
94
 
95
  const buildMeasureSettings = () => {
96
  const fingerSelect = form.querySelector('select[name="finger_index"]');
97
+ const aiToggle = document.getElementById("aiExplainToggle");
98
+ const mode = modeSelect ? modeSelect.value : "single";
99
  return {
100
  finger_index: fingerSelect ? fingerSelect.value : "index",
101
  edge_method: "sobel",
102
+ mode: mode,
103
+ ai_explain: aiToggle && aiToggle.checked ? "1" : "0",
104
  };
105
  };
106
 
107
+ const renderMultiResult = (result) => {
108
+ if (!result || !result.per_finger) {
109
+ multiResultPanel.style.display = "none";
110
+ return;
111
+ }
112
+
113
+ multiResultPanel.style.display = "";
114
+
115
+ const aiExplanation = result.ai_explanation;
116
+
117
+ // Always show deterministic recommendation as hero
118
+ if (result.overall_best_size) {
119
+ overallSize.innerHTML = `
120
+ <div class="size-hero">
121
+ <span class="size-label">Recommended Size</span>
122
+ <span class="size-number">${result.overall_best_size}</span>
123
+ <span class="size-range">Range: ${result.overall_range_min} – ${result.overall_range_max}</span>
124
+ ${aiExplanation ? `<p class="ai-reason">${aiExplanation}</p>` : ""}
125
+ </div>`;
126
+ } else {
127
+ overallSize.innerHTML = `<div class="size-hero"><span class="size-label">Measurement Failed</span></div>`;
128
+ }
129
+
130
+ // Per-finger cards: size + range + width
131
+ const fingerNames = { index: "Index", middle: "Middle", ring: "Ring" };
132
+ const fingerColors = { index: "#00dddd", middle: "#00cc44", ring: "#dd44dd" };
133
+ let html = '<div class="finger-cards">';
134
+ for (const [fn, label] of Object.entries(fingerNames)) {
135
+ const pf = result.per_finger[fn];
136
+ if (!pf) continue;
137
+ const color = fingerColors[fn] || "#888";
138
+ const ok = pf.status === "ok";
139
+ html += `<div class="finger-card" style="border-top: 3px solid ${color};">
140
+ <div class="finger-name">${label}</div>`;
141
+ if (ok) {
142
+ const size = pf.best_match;
143
+ const range = pf.range;
144
+ html += `<div class="finger-size-label">Size</div>`;
145
+ html += `<div class="finger-size">${size}</div>`;
146
+ if (range) {
147
+ html += `<div class="finger-range">${range[0]} – ${range[1]}</div>`;
148
+ }
149
+ html += `<div class="finger-width">${(pf.diameter_cm * 10).toFixed(1)} mm</div>`;
150
+ } else {
151
+ html += `<div class="finger-failed">Failed</div>
152
+ <div class="finger-fail-reason">${pf.fail_reason || "unknown"}</div>`;
153
+ }
154
+ html += `</div>`;
155
+ }
156
+ html += "</div>";
157
+ html += `<div class="finger-count">${result.fingers_succeeded}/${result.fingers_measured} fingers measured</div>`;
158
+ fingerBreakdown.innerHTML = html;
159
+ };
160
+
161
  const runMeasurement = async (endpoint, formData, inputUrlFallback = "") => {
162
  setStatus("Measuring… Please wait.");
163
+ jsonOutput.textContent = '{\n "status": "processing"\n}';
164
+ multiResultPanel.style.display = "none";
165
 
166
  try {
167
  const response = await fetch(endpoint, {
 
182
  showImage(inputPreview, inputFrame, data.input_image_url || inputUrlFallback);
183
  showImage(debugPreview, debugFrame, data.result_image_url);
184
 
185
+ if (data.mode === "multi") {
186
+ renderMultiResult(data.result);
187
+ }
188
+
189
  if (data.success) {
190
  setStatus("Measurement complete. Results updated.");
191
  } else {
 
211
  setStatus("Image ready. Click to start measurement.");
212
  });
213
 
214
+ // Mode toggle: show/hide finger selector
215
+ if (modeSelect) {
216
+ const updateFingerVisibility = () => {
217
+ if (fingerSelectGroup) {
218
+ fingerSelectGroup.style.display = modeSelect.value === "multi" ? "none" : "";
219
+ }
220
+ };
221
+ modeSelect.addEventListener("change", updateFingerVisibility);
222
+ updateFingerVisibility();
223
+ }
224
+
225
  form.addEventListener("submit", async (event) => {
226
  event.preventDefault();
227
 
 
229
  const formData = new FormData();
230
  formData.append("finger_index", settings.finger_index);
231
  formData.append("edge_method", settings.edge_method);
232
+ formData.append("mode", settings.mode);
233
+ formData.append("ai_explain", settings.ai_explain);
234
 
235
  const file = imageInput.files[0];
236
  if (file) {
web_demo/static/styles.css CHANGED
@@ -286,3 +286,151 @@ pre {
286
  font-size: 2.4rem;
287
  }
288
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
286
  font-size: 2.4rem;
287
  }
288
  }
289
+
290
+ /* Multi-finger result styles */
291
+ .multi-result {
292
+ padding: 8px 0;
293
+ }
294
+
295
+ .size-hero {
296
+ text-align: center;
297
+ padding: 20px 0 16px;
298
+ border-bottom: 1px solid var(--border);
299
+ margin-bottom: 16px;
300
+ }
301
+
302
+ .size-hero .size-label {
303
+ display: block;
304
+ font-size: 0.85rem;
305
+ text-transform: uppercase;
306
+ letter-spacing: 0.08em;
307
+ color: var(--ink-soft);
308
+ margin-bottom: 4px;
309
+ }
310
+
311
+ .size-hero .size-number {
312
+ display: block;
313
+ font-size: 3.2rem;
314
+ font-weight: 700;
315
+ color: var(--accent);
316
+ line-height: 1.1;
317
+ }
318
+
319
+ .size-hero .size-range {
320
+ display: block;
321
+ font-size: 1rem;
322
+ color: var(--ink-soft);
323
+ margin-top: 4px;
324
+ }
325
+
326
+ .finger-cards {
327
+ display: grid;
328
+ grid-template-columns: repeat(3, 1fr);
329
+ gap: 12px;
330
+ margin-bottom: 12px;
331
+ }
332
+
333
+ .finger-card {
334
+ background: var(--sand);
335
+ border-radius: 8px;
336
+ padding: 12px;
337
+ text-align: center;
338
+ box-shadow: 0 1px 4px var(--shadow);
339
+ }
340
+
341
+ .finger-card .finger-name {
342
+ font-weight: 600;
343
+ font-size: 0.9rem;
344
+ margin-bottom: 6px;
345
+ text-transform: uppercase;
346
+ letter-spacing: 0.04em;
347
+ }
348
+
349
+ .finger-card .finger-width {
350
+ font-size: 0.85rem;
351
+ color: var(--ink-soft);
352
+ margin-top: 6px;
353
+ }
354
+
355
+ .finger-card .finger-size-label {
356
+ font-size: 0.75rem;
357
+ text-transform: uppercase;
358
+ letter-spacing: 0.06em;
359
+ color: var(--ink-soft);
360
+ margin-top: 6px;
361
+ }
362
+
363
+ .finger-card .finger-size {
364
+ font-size: 1.6rem;
365
+ font-weight: 700;
366
+ color: var(--accent);
367
+ line-height: 1.1;
368
+ }
369
+
370
+ .finger-card .finger-range {
371
+ font-size: 0.8rem;
372
+ color: var(--ink-soft);
373
+ margin-top: 2px;
374
+ }
375
+
376
+ .ai-reason {
377
+ font-size: 0.9rem;
378
+ color: var(--ink-soft);
379
+ margin: 12px auto 0;
380
+ max-width: 40ch;
381
+ line-height: 1.5;
382
+ font-style: italic;
383
+ }
384
+
385
+ .finger-card .finger-failed {
386
+ font-size: 1.1rem;
387
+ font-weight: 600;
388
+ color: #721c24;
389
+ margin: 8px 0 4px;
390
+ }
391
+
392
+ .finger-card .finger-fail-reason {
393
+ font-size: 0.75rem;
394
+ color: var(--ink-soft);
395
+ word-break: break-word;
396
+ }
397
+
398
+ .finger-count {
399
+ text-align: center;
400
+ font-size: 0.85rem;
401
+ color: var(--ink-soft);
402
+ }
403
+
404
+ @media (max-width: 600px) {
405
+ .finger-cards {
406
+ grid-template-columns: 1fr;
407
+ }
408
+ }
409
+
410
+ .toggle-label {
411
+ display: flex;
412
+ flex-direction: column;
413
+ gap: 6px;
414
+ font-size: 0.9rem;
415
+ color: var(--ink-soft);
416
+ }
417
+
418
+ .toggle-row {
419
+ display: flex;
420
+ align-items: center;
421
+ gap: 8px;
422
+ height: 42px;
423
+ }
424
+
425
+ .toggle-row input[type="checkbox"] {
426
+ width: 18px;
427
+ height: 18px;
428
+ accent-color: var(--accent);
429
+ cursor: pointer;
430
+ }
431
+
432
+ .toggle-hint {
433
+ font-size: 0.75rem;
434
+ color: var(--ink-soft);
435
+ opacity: 0.7;
436
+ }
web_demo/templates/index.html CHANGED
@@ -28,6 +28,13 @@
28
 
29
  <div class="controls">
30
  <label>
 
 
 
 
 
 
 
31
  <span>Finger Selection</span>
32
  <select name="finger_index">
33
  <option value="index" selected>Index (Default)</option>
@@ -43,6 +50,13 @@
43
  <option value="sobel" selected>Sobel (Locked)</option>
44
  </select>
45
  </label>
 
 
 
 
 
 
 
46
  </div>
47
 
48
  <button class="primary" type="submit">Start Measurement</button>
@@ -71,6 +85,14 @@
71
  </section>
72
 
73
  <section class="result">
 
 
 
 
 
 
 
 
74
  <div class="panel">
75
  <div class="panel-head">
76
  <h2>JSON Output</h2>
@@ -80,11 +102,15 @@
80
  </div>
81
 
82
  <div class="panel tips">
83
- <h2>Photo Tips</h2>
84
  <ul>
85
- <li>βœ“ Turn on flash</li>
86
- <li>βœ“ Keep phone parallel to table</li>
87
- <li>βœ“ Include full palm in frame</li>
 
 
 
 
88
  </ul>
89
  </div>
90
  </section>
 
28
 
29
  <div class="controls">
30
  <label>
31
+ <span>Mode</span>
32
+ <select name="mode" id="modeSelect">
33
+ <option value="multi" selected>All Fingers (Recommended)</option>
34
+ <option value="single">Single Finger</option>
35
+ </select>
36
+ </label>
37
+ <label id="fingerSelectGroup">
38
  <span>Finger Selection</span>
39
  <select name="finger_index">
40
  <option value="index" selected>Index (Default)</option>
 
50
  <option value="sobel" selected>Sobel (Locked)</option>
51
  </select>
52
  </label>
53
+ <label class="toggle-label">
54
+ <span>AI Explanation</span>
55
+ <div class="toggle-row">
56
+ <input type="checkbox" id="aiExplainToggle" />
57
+ <span class="toggle-hint">Uses OpenAI tokens</span>
58
+ </div>
59
+ </label>
60
  </div>
61
 
62
  <button class="primary" type="submit">Start Measurement</button>
 
85
  </section>
86
 
87
  <section class="result">
88
+ <div class="panel" id="multiResultPanel" style="display:none;">
89
+ <h2>Ring Size Recommendation</h2>
90
+ <div id="multiResult" class="multi-result">
91
+ <div class="overall-size" id="overallSize"></div>
92
+ <div class="finger-breakdown" id="fingerBreakdown"></div>
93
+ </div>
94
+ </div>
95
+
96
  <div class="panel">
97
  <div class="panel-head">
98
  <h2>JSON Output</h2>
 
102
  </div>
103
 
104
  <div class="panel tips">
105
+ <h2>πŸ“Έ Photo Tips</h2>
106
  <ul>
107
+ <li>βœ… Place credit card flat next to your hand</li>
108
+ <li>βœ… Shoot from directly above (bird's-eye view)</li>
109
+ <li>βœ… Turn on flash for even lighting</li>
110
+ <li>βœ… Keep all fingers and card fully within frame</li>
111
+ <li>βœ… Spread index, middle, and ring fingers apart</li>
112
+ <li>βœ… Use a plain, light-colored background</li>
113
+ <li>βœ… Hold phone steady β€” avoid blur</li>
114
  </ul>
115
  </div>
116
  </section>