feng-x commited on
Commit
d6218c1
·
verified ·
1 Parent(s): ad841de

Upload folder using huggingface_hub

Browse files
README.md CHANGED
@@ -21,7 +21,7 @@ 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
- - **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:
@@ -72,6 +72,9 @@ python measure_finger.py --input input/test_image.jpg --output output/result.jso
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
@@ -105,6 +108,7 @@ python measure_finger.py --input image.jpg --output output/result.json \
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 |
@@ -133,14 +137,15 @@ python measure_finger.py --input image.jpg --output output/result.json \
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
 
 
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 sizes 6–13 (best match + 2-size range). Supports multiple ring models: **Gen1/Gen2** and **Air**, each with its own size chart.
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:
 
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
+ # Specify ring model (gen or air)
77
+ python measure_finger.py --input input/test_image.jpg --output output/result.json --mode multi --ring-model air
78
  ```
79
 
80
  ### Common options
 
108
  | `--debug` | flag | false | Save intermediate debug images |
109
  | `--finger-index` | auto, index, middle, ring, pinky | index | Which finger to measure (single mode) |
110
  | `--mode` | single, multi | single | Single finger or all 3 fingers |
111
+ | `--ring-model` | gen, air | gen | Ring model for size lookup (Gen1/Gen2 or Air) |
112
  | `--confidence-threshold` | float | 0.7 | Minimum confidence threshold |
113
  | `--edge-method` | auto, contour, sobel, compare | auto | Edge detection method |
114
  | `--sobel-threshold` | float | 15.0 | Minimum gradient magnitude |
 
137
  "best_match_inner_mm": 18.6,
138
  "range_min": 8,
139
  "range_max": 9,
140
+ "diameter_mm": 17.80,
141
+ "ring_model": "gen"
142
  }
143
  }
144
  ```
145
 
146
  Notes:
147
  - `raw_diameter_cm` is the pre-calibration measurement (present when calibration is applied).
148
+ - `ring_size` maps calibrated diameter to sizes 6–13 for the selected ring model (nearest match + 2-size recommended range). `ring_model` indicates which chart was used (`gen` or `air`).
149
  - `edge_method_used` and `method_comparison` are optional (present when relevant).
150
  - Result image path is auto-derived: `output/result.json` → `output/result.png`.
151
 
measure_finger.py CHANGED
@@ -31,7 +31,7 @@ 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, aggregate_ring_sizes
35
  from src.image_quality import (
36
  check_card_in_frame,
37
  check_finger_landmarks_visible,
@@ -146,6 +146,15 @@ Examples:
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",
@@ -273,6 +282,7 @@ def measure_finger(
273
  sobel_kernel_size: int = 3,
274
  use_subpixel: bool = True,
275
  skip_card_detection: bool = False,
 
276
  ) -> Dict[str, Any]:
277
  """
278
  Main measurement pipeline.
@@ -720,7 +730,7 @@ def measure_finger(
720
  width_data["raw_width_cm"] = raw_cm
721
 
722
  # Add ring size for overlay
723
- rec = recommend_ring_size(cal_cm)
724
  if rec:
725
  width_data["ring_size_rec"] = rec
726
 
@@ -974,6 +984,7 @@ def measure_multi_finger(
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
 
@@ -1084,7 +1095,7 @@ def measure_multi_finger(
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
 
@@ -1255,6 +1266,7 @@ def main() -> int:
1255
  use_subpixel=not args.no_subpixel,
1256
  skip_card_detection=args.skip_card_detection,
1257
  no_calibration=args.no_calibration,
 
1258
  )
1259
 
1260
  save_output(result, args.output)
@@ -1289,6 +1301,7 @@ def main() -> int:
1289
  sobel_kernel_size=args.sobel_kernel_size,
1290
  use_subpixel=not args.no_subpixel,
1291
  skip_card_detection=args.skip_card_detection,
 
1292
  )
1293
 
1294
  # Apply calibration (post-processing)
@@ -1304,7 +1317,7 @@ def main() -> int:
1304
  # Ring size recommendation (from calibrated diameter)
1305
  diameter = result.get("finger_outer_diameter_cm")
1306
  if diameter is not None:
1307
- rec = recommend_ring_size(diameter)
1308
  if rec:
1309
  result["ring_size"] = rec
1310
 
 
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, VALID_RING_MODELS, DEFAULT_RING_MODEL
35
  from src.image_quality import (
36
  check_card_in_frame,
37
  check_finger_landmarks_visible,
 
146
  help="Measurement mode: single (one finger) or multi (index+middle+ring at once) (default: single)",
147
  )
148
 
149
+ # Ring model
150
+ parser.add_argument(
151
+ "--ring-model",
152
+ type=str,
153
+ choices=VALID_RING_MODELS,
154
+ default=DEFAULT_RING_MODEL,
155
+ help=f"Ring model for size recommendation (default: {DEFAULT_RING_MODEL})",
156
+ )
157
+
158
  # Calibration
159
  parser.add_argument(
160
  "--no-calibration",
 
282
  sobel_kernel_size: int = 3,
283
  use_subpixel: bool = True,
284
  skip_card_detection: bool = False,
285
+ ring_model: str = DEFAULT_RING_MODEL,
286
  ) -> Dict[str, Any]:
287
  """
288
  Main measurement pipeline.
 
730
  width_data["raw_width_cm"] = raw_cm
731
 
732
  # Add ring size for overlay
733
+ rec = recommend_ring_size(cal_cm, ring_model=ring_model)
734
  if rec:
735
  width_data["ring_size_rec"] = rec
736
 
 
984
  use_subpixel: bool = True,
985
  skip_card_detection: bool = False,
986
  no_calibration: bool = False,
987
+ ring_model: str = DEFAULT_RING_MODEL,
988
  ) -> Dict[str, Any]:
989
  """Measure index, middle, and ring fingers from a single image.
990
 
 
1095
  # Ring size per finger
1096
  diam = result.get("finger_outer_diameter_cm")
1097
  if diam is not None:
1098
+ rec = recommend_ring_size(diam, ring_model=ring_model)
1099
  if rec:
1100
  result["ring_size"] = rec
1101
 
 
1266
  use_subpixel=not args.no_subpixel,
1267
  skip_card_detection=args.skip_card_detection,
1268
  no_calibration=args.no_calibration,
1269
+ ring_model=args.ring_model,
1270
  )
1271
 
1272
  save_output(result, args.output)
 
1301
  sobel_kernel_size=args.sobel_kernel_size,
1302
  use_subpixel=not args.no_subpixel,
1303
  skip_card_detection=args.skip_card_detection,
1304
+ ring_model=args.ring_model,
1305
  )
1306
 
1307
  # Apply calibration (post-processing)
 
1317
  # Ring size recommendation (from calibrated diameter)
1318
  diameter = result.get("finger_outer_diameter_cm")
1319
  if diameter is not None:
1320
+ rec = recommend_ring_size(diameter, ring_model=args.ring_model)
1321
  if rec:
1322
  result["ring_size"] = rec
1323
 
src/ai_recommendation.py CHANGED
@@ -8,16 +8,22 @@ 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.
@@ -30,11 +36,11 @@ Guidelines:
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(
@@ -42,6 +48,7 @@ def ai_explain_recommendation(
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
 
@@ -51,6 +58,7 @@ def ai_explain_recommendation(
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.
@@ -60,6 +68,12 @@ def ai_explain_recommendation(
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():
@@ -68,7 +82,7 @@ def ai_explain_recommendation(
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)
@@ -80,7 +94,7 @@ def ai_explain_recommendation(
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,
 
8
  import logging
9
  from typing import Dict, Optional
10
 
11
+ from src.ring_size import RING_MODELS, DEFAULT_RING_MODEL
12
 
13
  logger = logging.getLogger(__name__)
14
 
15
+ _MODEL_LABELS = {"gen": "Gen1/Gen2", "air": "Air"}
 
 
 
16
 
17
+
18
+ def _build_size_table_text(ring_model: str = DEFAULT_RING_MODEL) -> str:
19
+ chart = RING_MODELS.get(ring_model, RING_MODELS[DEFAULT_RING_MODEL])
20
+ return "\n".join(
21
+ f" Size {size}: inner diameter {diameter_mm:.1f} mm"
22
+ for size, diameter_mm in sorted(chart.items())
23
+ )
24
+
25
+
26
+ _SYSTEM_PROMPT_TEMPLATE = """You are a sizing explanation assistant for Femometer Smart Ring ({model_label}).
27
 
28
  You are given measured finger widths and a pre-computed ring size recommendation.
29
  Your ONLY job is to explain WHY the recommended size is a good fit, in 1-2 concise sentences.
 
36
  - Priority context: index finger fit is slightly preferred over middle, then ring
37
  - Keep it concise and actionable
38
 
39
+ Ring Size Chart ({model_label}):
40
  {size_table}
41
 
42
  Respond in plain text (1-2 sentences). Do NOT use JSON or markdown.
43
+ """
44
 
45
 
46
  def ai_explain_recommendation(
 
48
  recommended_size: int,
49
  range_min: int,
50
  range_max: int,
51
+ ring_model: str = DEFAULT_RING_MODEL,
52
  ) -> Optional[str]:
53
  """Call OpenAI to explain an already-computed ring size recommendation.
54
 
 
58
  recommended_size: The deterministic best-match size from ring_size.py.
59
  range_min: Lower bound of recommended size range.
60
  range_max: Upper bound of recommended size range.
61
+ ring_model: Which ring model chart to reference.
62
 
63
  Returns:
64
  A plain-text explanation string, or None if the API call fails.
 
68
  logger.warning("OPENAI_API_KEY not set, skipping AI explanation")
69
  return None
70
 
71
+ model_label = _MODEL_LABELS.get(ring_model, ring_model)
72
+ system_prompt = _SYSTEM_PROMPT_TEMPLATE.format(
73
+ model_label=model_label,
74
+ size_table=_build_size_table_text(ring_model),
75
+ )
76
+
77
  # Build user message with measurements and pre-computed recommendation
78
  lines = ["Measured finger outer diameters:"]
79
  for finger, width in finger_widths.items():
 
82
  else:
83
  lines.append(f" {finger.capitalize()}: measurement failed")
84
  lines.append("")
85
+ lines.append(f"Recommended size: {recommended_size} (range {range_min}\u2013{range_max})")
86
  lines.append("")
87
  lines.append("Explain why this size is a good fit.")
88
  user_msg = "\n".join(lines)
 
94
  response = client.chat.completions.create(
95
  model="gpt-5.4",
96
  messages=[
97
+ {"role": "system", "content": system_prompt},
98
  {"role": "user", "content": user_msg},
99
  ],
100
  temperature=0.3,
src/ring_size.py CHANGED
@@ -1,24 +1,44 @@
1
  """Ring size recommendation from calibrated finger width."""
2
 
3
- from typing import Dict, List, Optional, Tuple
4
-
5
- # China standard ring size chart: sizeinner diameter (mm)
6
- RING_SIZE_CHART = {
7
- 6: 16.9,
8
- 7: 17.7,
9
- 8: 18.6,
10
- 9: 19.4,
11
- 10: 20.3,
12
- 11: 21.1,
13
- 12: 21.9,
14
- 13: 22.7,
 
 
 
 
 
 
 
 
 
 
 
 
15
  }
16
 
17
- # Sorted sizes for lookup
18
- _SORTED_SIZES = sorted(RING_SIZE_CHART.items(), key=lambda x: x[1])
19
 
 
 
20
 
21
- def recommend_ring_size(diameter_cm: float) -> Optional[Dict]:
 
 
 
 
 
 
22
  """Recommend ring size from calibrated finger outer diameter.
23
 
24
  Returns dict with:
@@ -26,6 +46,7 @@ def recommend_ring_size(diameter_cm: float) -> Optional[Dict]:
26
  - best_match_inner_mm: inner diameter of best match
27
  - range_min / range_max: recommended 2-size range
28
  - diameter_mm: input converted to mm
 
29
  Returns None if diameter is out of reasonable range.
30
  """
31
  diameter_mm = diameter_cm * 10.0
@@ -33,12 +54,14 @@ def recommend_ring_size(diameter_cm: float) -> Optional[Dict]:
33
  if diameter_mm < 14.0 or diameter_mm > 26.0:
34
  return None
35
 
 
 
36
  # Find nearest size
37
- best_size, best_inner = min(_SORTED_SIZES, key=lambda x: abs(x[1] - diameter_mm))
38
 
39
  # Find second nearest size
40
  second_size, second_inner = min(
41
- (s for s in _SORTED_SIZES if s[0] != best_size),
42
  key=lambda x: abs(x[1] - diameter_mm),
43
  )
44
 
@@ -51,6 +74,7 @@ def recommend_ring_size(diameter_cm: float) -> Optional[Dict]:
51
  "range_min": range_min,
52
  "range_max": range_max,
53
  "diameter_mm": round(diameter_mm, 2),
 
54
  }
55
 
56
 
 
1
  """Ring size recommendation from calibrated finger width."""
2
 
3
+ from typing import Dict, List, Literal, Optional, Tuple
4
+
5
+ # Ring model definitions: model name {size: inner_diameter_mm}
6
+ RING_MODELS: Dict[str, Dict[int, float]] = {
7
+ "gen": {
8
+ 6: 16.9,
9
+ 7: 17.7,
10
+ 8: 18.6,
11
+ 9: 19.4,
12
+ 10: 20.3,
13
+ 11: 21.1,
14
+ 12: 21.9,
15
+ 13: 22.7,
16
+ },
17
+ "air": {
18
+ 6: 16.6,
19
+ 7: 17.4,
20
+ 8: 18.2,
21
+ 9: 19.0,
22
+ 10: 19.9,
23
+ 11: 20.7,
24
+ 12: 21.5,
25
+ 13: 22.3,
26
+ },
27
  }
28
 
29
+ VALID_RING_MODELS = list(RING_MODELS.keys())
30
+ DEFAULT_RING_MODEL = "gen"
31
 
32
+ # Backwards-compatible alias
33
+ RING_SIZE_CHART = RING_MODELS[DEFAULT_RING_MODEL]
34
 
35
+
36
+ def _get_sorted_sizes(ring_model: str) -> List[Tuple[int, float]]:
37
+ chart = RING_MODELS.get(ring_model, RING_MODELS[DEFAULT_RING_MODEL])
38
+ return sorted(chart.items(), key=lambda x: x[1])
39
+
40
+
41
+ def recommend_ring_size(diameter_cm: float, ring_model: str = DEFAULT_RING_MODEL) -> Optional[Dict]:
42
  """Recommend ring size from calibrated finger outer diameter.
43
 
44
  Returns dict with:
 
46
  - best_match_inner_mm: inner diameter of best match
47
  - range_min / range_max: recommended 2-size range
48
  - diameter_mm: input converted to mm
49
+ - ring_model: which model chart was used
50
  Returns None if diameter is out of reasonable range.
51
  """
52
  diameter_mm = diameter_cm * 10.0
 
54
  if diameter_mm < 14.0 or diameter_mm > 26.0:
55
  return None
56
 
57
+ sorted_sizes = _get_sorted_sizes(ring_model)
58
+
59
  # Find nearest size
60
+ best_size, best_inner = min(sorted_sizes, key=lambda x: abs(x[1] - diameter_mm))
61
 
62
  # Find second nearest size
63
  second_size, second_inner = min(
64
+ (s for s in sorted_sizes if s[0] != best_size),
65
  key=lambda x: abs(x[1] - diameter_mm),
66
  )
67
 
 
74
  "range_min": range_min,
75
  "range_max": range_max,
76
  "diameter_mm": round(diameter_mm, 2),
77
+ "ring_model": ring_model,
78
  }
79
 
80
 
web_demo/app.py CHANGED
@@ -21,7 +21,7 @@ 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
@@ -109,6 +109,9 @@ def api_measure():
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}"
@@ -124,12 +127,14 @@ def api_measure():
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,
132
  input_image_url=f"/uploads/{upload_name}",
 
133
  )
134
 
135
 
@@ -137,6 +142,9 @@ def api_measure():
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
 
@@ -148,12 +156,14 @@ def api_measure_default():
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,
156
  input_image_url=DEFAULT_SAMPLE_URL,
 
157
  )
158
 
159
 
@@ -161,6 +171,7 @@ def _run_measurement(
161
  image,
162
  finger_index: str,
163
  input_image_url: str,
 
164
  ):
165
  run_id = uuid.uuid4().hex[:12]
166
 
@@ -173,6 +184,7 @@ def _run_measurement(
173
  edge_method=DEMO_EDGE_METHOD,
174
  result_png_path=str(result_png_path),
175
  save_debug=False,
 
176
  )
177
 
178
  # Apply calibration
@@ -182,7 +194,7 @@ def _run_measurement(
182
  calibrated = round(apply_calibration(raw_diameter), 4)
183
  result["finger_outer_diameter_cm"] = calibrated
184
  result["calibration_applied"] = True
185
- rec = recommend_ring_size(calibrated)
186
  if rec:
187
  result["ring_size"] = rec
188
 
@@ -206,6 +218,7 @@ def _run_measurement(
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]
@@ -219,6 +232,7 @@ def _run_multi_measurement(
219
  result_png_path=str(result_png_path),
220
  save_debug=False,
221
  no_calibration=False,
 
222
  )
223
 
224
  result = _numpy_safe(result)
@@ -241,6 +255,7 @@ def _run_multi_measurement(
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
 
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, RING_MODELS, VALID_RING_MODELS, DEFAULT_RING_MODEL
25
  from src.ai_recommendation import ai_explain_recommendation
26
 
27
  APP_ROOT = Path(__file__).resolve().parent
 
109
 
110
  finger_index = request.form.get("finger_index", "index")
111
  mode = request.form.get("mode", "single")
112
+ ring_model = request.form.get("ring_model", DEFAULT_RING_MODEL)
113
+ if ring_model not in VALID_RING_MODELS:
114
+ ring_model = DEFAULT_RING_MODEL
115
  run_id = uuid.uuid4().hex[:12]
116
  safe_name = secure_filename(file.filename)
117
  upload_name = f"{run_id}__{safe_name}"
 
127
  return _run_multi_measurement(
128
  image=image,
129
  input_image_url=f"/uploads/{upload_name}",
130
+ ring_model=ring_model,
131
  )
132
 
133
  return _run_measurement(
134
  image=image,
135
  finger_index=finger_index,
136
  input_image_url=f"/uploads/{upload_name}",
137
+ ring_model=ring_model,
138
  )
139
 
140
 
 
142
  def api_measure_default():
143
  finger_index = request.form.get("finger_index", "index")
144
  mode = request.form.get("mode", "single")
145
+ ring_model = request.form.get("ring_model", DEFAULT_RING_MODEL)
146
+ if ring_model not in VALID_RING_MODELS:
147
+ ring_model = DEFAULT_RING_MODEL
148
  if not DEFAULT_SAMPLE_PATH.exists():
149
  return jsonify({"success": False, "error": "Default sample image not found"}), 500
150
 
 
156
  return _run_multi_measurement(
157
  image=image,
158
  input_image_url=DEFAULT_SAMPLE_URL,
159
+ ring_model=ring_model,
160
  )
161
 
162
  return _run_measurement(
163
  image=image,
164
  finger_index=finger_index,
165
  input_image_url=DEFAULT_SAMPLE_URL,
166
+ ring_model=ring_model,
167
  )
168
 
169
 
 
171
  image,
172
  finger_index: str,
173
  input_image_url: str,
174
+ ring_model: str = DEFAULT_RING_MODEL,
175
  ):
176
  run_id = uuid.uuid4().hex[:12]
177
 
 
184
  edge_method=DEMO_EDGE_METHOD,
185
  result_png_path=str(result_png_path),
186
  save_debug=False,
187
+ ring_model=ring_model,
188
  )
189
 
190
  # Apply calibration
 
194
  calibrated = round(apply_calibration(raw_diameter), 4)
195
  result["finger_outer_diameter_cm"] = calibrated
196
  result["calibration_applied"] = True
197
+ rec = recommend_ring_size(calibrated, ring_model=ring_model)
198
  if rec:
199
  result["ring_size"] = rec
200
 
 
218
  def _run_multi_measurement(
219
  image,
220
  input_image_url: str,
221
+ ring_model: str = DEFAULT_RING_MODEL,
222
  ):
223
  """Run multi-finger measurement pipeline."""
224
  run_id = uuid.uuid4().hex[:12]
 
232
  result_png_path=str(result_png_path),
233
  save_debug=False,
234
  no_calibration=False,
235
+ ring_model=ring_model,
236
  )
237
 
238
  result = _numpy_safe(result)
 
255
  recommended_size=result["overall_best_size"],
256
  range_min=result["overall_range_min"],
257
  range_max=result["overall_range_max"],
258
+ ring_model=ring_model,
259
  )
260
  if ai_reason:
261
  result["ai_explanation"] = ai_reason
web_demo/static/app.js CHANGED
@@ -92,26 +92,35 @@ const showImage = (imgEl, frameEl, url) => {
92
  frameEl.querySelector(".placeholder").style.display = "none";
93
  };
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
@@ -156,22 +165,20 @@ const renderMultiResult = (result) => {
156
  html += "</div>";
157
  html += `<div class="finger-count">${result.fingers_succeeded}/${result.fingers_measured} fingers measured</div>`;
158
 
159
- // Size reference table
 
 
 
 
 
 
 
160
  html += `
161
  <div class="size-ref-table">
162
- <h3 class="size-ref-title">Size Reference</h3>
163
  <table>
164
  <thead><tr><th>Size</th><th>Inner ⌀ (mm)</th></tr></thead>
165
- <tbody>
166
- <tr><td>6</td><td>16.9</td></tr>
167
- <tr><td>7</td><td>17.7</td></tr>
168
- <tr><td>8</td><td>18.6</td></tr>
169
- <tr><td>9</td><td>19.4</td></tr>
170
- <tr><td>10</td><td>20.3</td></tr>
171
- <tr><td>11</td><td>21.1</td></tr>
172
- <tr><td>12</td><td>21.9</td></tr>
173
- <tr><td>13</td><td>22.7</td></tr>
174
- </tbody>
175
  </table>
176
  </div>`;
177
 
@@ -181,7 +188,8 @@ const renderMultiResult = (result) => {
181
  const runMeasurement = async (endpoint, formData, inputUrlFallback = "") => {
182
  setStatus("Measuring… Please wait.");
183
  jsonOutput.textContent = '{\n "status": "processing"\n}';
184
- multiResultPanel.style.display = "none";
 
185
 
186
  try {
187
  const response = await fetch(endpoint, {
@@ -250,6 +258,7 @@ form.addEventListener("submit", async (event) => {
250
  formData.append("finger_index", settings.finger_index);
251
  formData.append("edge_method", settings.edge_method);
252
  formData.append("mode", settings.mode);
 
253
  formData.append("ai_explain", settings.ai_explain);
254
 
255
  const file = imageInput.files[0];
 
92
  frameEl.querySelector(".placeholder").style.display = "none";
93
  };
94
 
95
+ const ringModelSelect = document.getElementById("ringModelSelect");
96
+
97
+ const RING_SIZE_TABLES = {
98
+ gen: {6: 16.9, 7: 17.7, 8: 18.6, 9: 19.4, 10: 20.3, 11: 21.1, 12: 21.9, 13: 22.7},
99
+ air: {6: 16.6, 7: 17.4, 8: 18.2, 9: 19.0, 10: 19.9, 11: 20.7, 12: 21.5, 13: 22.3},
100
+ };
101
+ const RING_MODEL_LABELS = { gen: "Gen1/Gen2", air: "Air" };
102
+
103
  const buildMeasureSettings = () => {
104
  const fingerSelect = form.querySelector('select[name="finger_index"]');
105
  const aiToggle = document.getElementById("aiExplainToggle");
106
  const mode = modeSelect ? modeSelect.value : "single";
107
+ const ringModel = ringModelSelect ? ringModelSelect.value : "gen";
108
  return {
109
  finger_index: fingerSelect ? fingerSelect.value : "index",
110
  edge_method: "sobel",
111
  mode: mode,
112
+ ring_model: ringModel,
113
  ai_explain: aiToggle && aiToggle.checked ? "1" : "0",
114
  };
115
  };
116
 
117
  const renderMultiResult = (result) => {
118
  if (!result || !result.per_finger) {
119
+ overallSize.innerHTML = `<div class="size-hero"><span class="size-label">Measurement Failed</span></div>`;
120
+ fingerBreakdown.innerHTML = "";
121
  return;
122
  }
123
 
 
 
124
  const aiExplanation = result.ai_explanation;
125
 
126
  // Always show deterministic recommendation as hero
 
165
  html += "</div>";
166
  html += `<div class="finger-count">${result.fingers_succeeded}/${result.fingers_measured} fingers measured</div>`;
167
 
168
+ // Size reference table (dynamic based on selected ring model)
169
+ const ringModel = ringModelSelect ? ringModelSelect.value : "gen";
170
+ const sizeTable = RING_SIZE_TABLES[ringModel] || RING_SIZE_TABLES.gen;
171
+ const modelLabel = RING_MODEL_LABELS[ringModel] || ringModel;
172
+ let tableRows = "";
173
+ for (const [size, mm] of Object.entries(sizeTable)) {
174
+ tableRows += `<tr><td>${size}</td><td>${mm.toFixed(1)}</td></tr>`;
175
+ }
176
  html += `
177
  <div class="size-ref-table">
178
+ <h3 class="size-ref-title">Size Reference (${modelLabel})</h3>
179
  <table>
180
  <thead><tr><th>Size</th><th>Inner ⌀ (mm)</th></tr></thead>
181
+ <tbody>${tableRows}</tbody>
 
 
 
 
 
 
 
 
 
182
  </table>
183
  </div>`;
184
 
 
188
  const runMeasurement = async (endpoint, formData, inputUrlFallback = "") => {
189
  setStatus("Measuring… Please wait.");
190
  jsonOutput.textContent = '{\n "status": "processing"\n}';
191
+ overallSize.innerHTML = `<div class="size-hero"><span class="size-label">Measuring…</span></div>`;
192
+ fingerBreakdown.innerHTML = "";
193
 
194
  try {
195
  const response = await fetch(endpoint, {
 
258
  formData.append("finger_index", settings.finger_index);
259
  formData.append("edge_method", settings.edge_method);
260
  formData.append("mode", settings.mode);
261
+ formData.append("ring_model", settings.ring_model);
262
  formData.append("ai_explain", settings.ai_explain);
263
 
264
  const file = imageInput.files[0];
web_demo/templates/index.html CHANGED
@@ -44,6 +44,13 @@
44
  <option value="auto">Auto</option>
45
  </select>
46
  </label>
 
 
 
 
 
 
 
47
  <label>
48
  <span>Edge Method</span>
49
  <select name="edge_method" disabled aria-disabled="true">
@@ -85,10 +92,14 @@
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>
@@ -100,19 +111,6 @@
100
  </div>
101
  <pre id="jsonOutput">{}</pre>
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>
117
  </main>
118
 
 
44
  <option value="auto">Auto</option>
45
  </select>
46
  </label>
47
+ <label>
48
+ <span>Ring Model</span>
49
+ <select name="ring_model" id="ringModelSelect">
50
+ <option value="gen" selected>Gen1/Gen2</option>
51
+ <option value="air">Air</option>
52
+ </select>
53
+ </label>
54
  <label>
55
  <span>Edge Method</span>
56
  <select name="edge_method" disabled aria-disabled="true">
 
92
  </section>
93
 
94
  <section class="result">
95
+ <div class="panel" id="multiResultPanel">
96
  <h2>Ring Size Recommendation</h2>
97
  <div id="multiResult" class="multi-result">
98
+ <div class="overall-size" id="overallSize">
99
+ <div class="size-hero">
100
+ <span class="size-label">Waiting for measurement</span>
101
+ </div>
102
+ </div>
103
  <div class="finger-breakdown" id="fingerBreakdown"></div>
104
  </div>
105
  </div>
 
111
  </div>
112
  <pre id="jsonOutput">{}</pre>
113
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  </section>
115
  </main>
116