Spaces:
Running
Running
Upload folder using huggingface_hub
Browse files- README.md +8 -3
- measure_finger.py +17 -4
- src/ai_recommendation.py +24 -10
- src/ring_size.py +41 -17
- web_demo/app.py +17 -2
- web_demo/static/app.js +25 -16
- web_demo/templates/index.html +13 -15
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
|
| 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
|
| 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
|
| 12 |
|
| 13 |
logger = logging.getLogger(__name__)
|
| 14 |
|
| 15 |
-
|
| 16 |
-
f" Size {size}: inner diameter {diameter_mm:.1f} mm"
|
| 17 |
-
for size, diameter_mm in sorted(RING_SIZE_CHART.items())
|
| 18 |
-
)
|
| 19 |
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 (
|
| 34 |
{size_table}
|
| 35 |
|
| 36 |
Respond in plain text (1-2 sentences). Do NOT use JSON or markdown.
|
| 37 |
-
"""
|
| 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}
|
| 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":
|
| 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 |
-
#
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
}
|
| 16 |
|
| 17 |
-
|
| 18 |
-
|
| 19 |
|
|
|
|
|
|
|
| 20 |
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 38 |
|
| 39 |
# Find second nearest size
|
| 40 |
second_size, second_inner = min(
|
| 41 |
-
(s for s in
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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"
|
| 89 |
<h2>Ring Size Recommendation</h2>
|
| 90 |
<div id="multiResult" class="multi-result">
|
| 91 |
-
<div class="overall-size" id="overallSize">
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|