Spaces:
Running
Running
Upload folder using huggingface_hub
Browse files- AGENTS.md +32 -2
- CLAUDE.md +32 -2
- measure_finger.py +16 -2
- script/deploy_hf.sh +10 -3
- script/validate_knuckle.py +237 -0
- src/ring_size.py +26 -3
- src/session_recommendation.py +338 -0
- tests/test_cli_output.py +24 -0
- tests/test_measurement_session.js +48 -0
- tests/test_session_evidence.js +38 -0
- tests/test_session_recommendation.py +160 -0
- tests/test_web_session_api.py +120 -0
- web_demo/app.py +147 -8
- web_demo/static/app.js +31 -7
- web_demo/static/mobile/steps/confirm.js +11 -0
- web_demo/static/mobile/steps/result.js +8 -8
- web_demo/static/shared/measure-api.js +8 -0
- web_demo/static/shared/measurement-session.js +94 -0
- web_demo/static/shared/session-evidence.js +31 -0
- web_demo/templates/index.html +3 -1
- web_demo/templates/mobile.html +2 -0
AGENTS.md
CHANGED
|
@@ -23,8 +23,9 @@ For tasks of **reboot** from a new codex session:
|
|
| 23 |
3. Read doc/v4/PRD.md, doc/v4/Plan.md, doc/v4/Progress.md for SAM 2.1 integration (card + hand)
|
| 24 |
4. Read doc/v5/PRD.md, doc/v5/Plan.md, doc/v5/Progress.md for the in-browser capture coach (distance + level gates)
|
| 25 |
5. Read doc/v6/PRD.md, doc/v6/Plan.md, doc/v6/Progress.md for the mobile-native demo surface (`/m`)
|
| 26 |
-
6.
|
| 27 |
-
7.
|
|
|
|
| 28 |
|
| 29 |
## Project Overview
|
| 30 |
|
|
@@ -242,6 +243,35 @@ in `web_demo/app.py`), never the full address, to keep PII out of bucket
|
|
| 242 |
paths. Admin grouping/display and CSV export prefer `kol_email` and fall
|
| 243 |
back to `kol_name` for legacy rows.
|
| 244 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 245 |
### v8 post-shipment feedback (`/feedback` + `feedback` table)
|
| 246 |
|
| 247 |
A **separate** flow from the post-result rating above — different
|
|
|
|
| 23 |
3. Read doc/v4/PRD.md, doc/v4/Plan.md, doc/v4/Progress.md for SAM 2.1 integration (card + hand)
|
| 24 |
4. Read doc/v5/PRD.md, doc/v5/Plan.md, doc/v5/Progress.md for the in-browser capture coach (distance + level gates)
|
| 25 |
5. Read doc/v6/PRD.md, doc/v6/Plan.md, doc/v6/Progress.md for the mobile-native demo surface (`/m`)
|
| 26 |
+
6. Read doc/v10/PRD.md, doc/v10/Plan.md, doc/v10/Progress.md for multi-shot session median recommendations
|
| 27 |
+
7. Assume this is a continuation of an existing project.
|
| 28 |
+
8. Summarize your understanding of the current state and propose the next concrete step without writing code yet.
|
| 29 |
|
| 30 |
## Project Overview
|
| 31 |
|
|
|
|
| 243 |
paths. Admin grouping/display and CSV export prefer `kol_email` and fall
|
| 244 |
back to `kol_name` for legacy rows.
|
| 245 |
|
| 246 |
+
### v10 multi-shot session median
|
| 247 |
+
|
| 248 |
+
Repeat captures in the same browser session are linked by a client-generated
|
| 249 |
+
UUID. The browser carries the previous server-returned session state; Python
|
| 250 |
+
adds the current successful calibrated per-finger diameters and computes each
|
| 251 |
+
median. The precise median is retained, while a half-up 0.1 mm value is used
|
| 252 |
+
for `recommend_ring_size()`; exact midpoint ties prefer the smaller size. Raw
|
| 253 |
+
shot fields remain unchanged. The visible result cards prefer the separate
|
| 254 |
+
`session_recommendation` object. A single footer below the cards reads
|
| 255 |
+
`Based on N measurements. (Please measure at least 3 times for best reliability)`
|
| 256 |
+
|
| 257 |
+
Aggregation is partitioned by `handedness + finger + ring_model`; failed
|
| 258 |
+
fingers are excluded and exact duplicate images are identified by SHA-256.
|
| 259 |
+
Supabase is only an asynchronous audit trail and is never queried on the live
|
| 260 |
+
recommendation path. Sessions expire after 30 minutes or when email/model/mode
|
| 261 |
+
context changes. See `doc/v10/`.
|
| 262 |
+
|
| 263 |
+
Additive columns on `measurements`:
|
| 264 |
+
|
| 265 |
+
| Column | Notes |
|
| 266 |
+
|---|---|
|
| 267 |
+
| `session_id` | Browser-session UUID; nullable for legacy/non-session clients. |
|
| 268 |
+
| `session_attempt_index` | Monotonic request count within the carried state. |
|
| 269 |
+
| `image_sha256` | Exact duplicate detection and audit. |
|
| 270 |
+
| `session_recommendation` | JSONB snapshot of precise medians, 0.1 mm decision diameters, sizes, counts, and spreads. |
|
| 271 |
+
|
| 272 |
+
Existing `diameter_cm`, `per_finger`, and `overall_*` fields continue to mean
|
| 273 |
+
the current raw shot, not the session median.
|
| 274 |
+
|
| 275 |
### v8 post-shipment feedback (`/feedback` + `feedback` table)
|
| 276 |
|
| 277 |
A **separate** flow from the post-result rating above — different
|
CLAUDE.md
CHANGED
|
@@ -23,8 +23,9 @@ For tasks of **reboot** from a new codex session:
|
|
| 23 |
3. Read doc/v4/PRD.md, doc/v4/Plan.md, doc/v4/Progress.md for SAM 2.1 integration (card + hand)
|
| 24 |
4. Read doc/v5/PRD.md, doc/v5/Plan.md, doc/v5/Progress.md for the in-browser capture coach (distance + level gates)
|
| 25 |
5. Read doc/v6/PRD.md, doc/v6/Plan.md, doc/v6/Progress.md for the mobile-native demo surface (`/m`)
|
| 26 |
-
6.
|
| 27 |
-
7.
|
|
|
|
| 28 |
|
| 29 |
## Project Overview
|
| 30 |
|
|
@@ -242,6 +243,35 @@ in `web_demo/app.py`), never the full address, to keep PII out of bucket
|
|
| 242 |
paths. Admin grouping/display and CSV export prefer `kol_email` and fall
|
| 243 |
back to `kol_name` for legacy rows.
|
| 244 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 245 |
### v8 post-shipment feedback (`/feedback` + `feedback` table)
|
| 246 |
|
| 247 |
A **separate** flow from the post-result rating above — different
|
|
|
|
| 23 |
3. Read doc/v4/PRD.md, doc/v4/Plan.md, doc/v4/Progress.md for SAM 2.1 integration (card + hand)
|
| 24 |
4. Read doc/v5/PRD.md, doc/v5/Plan.md, doc/v5/Progress.md for the in-browser capture coach (distance + level gates)
|
| 25 |
5. Read doc/v6/PRD.md, doc/v6/Plan.md, doc/v6/Progress.md for the mobile-native demo surface (`/m`)
|
| 26 |
+
6. Read doc/v10/PRD.md, doc/v10/Plan.md, doc/v10/Progress.md for multi-shot session median recommendations
|
| 27 |
+
7. Assume this is a continuation of an existing project.
|
| 28 |
+
8. Summarize your understanding of the current state and propose the next concrete step without writing code yet.
|
| 29 |
|
| 30 |
## Project Overview
|
| 31 |
|
|
|
|
| 243 |
paths. Admin grouping/display and CSV export prefer `kol_email` and fall
|
| 244 |
back to `kol_name` for legacy rows.
|
| 245 |
|
| 246 |
+
### v10 multi-shot session median
|
| 247 |
+
|
| 248 |
+
Repeat captures in the same browser session are linked by a client-generated
|
| 249 |
+
UUID. The browser carries the previous server-returned session state; Python
|
| 250 |
+
adds the current successful calibrated per-finger diameters and computes each
|
| 251 |
+
median. The precise median is retained, while a half-up 0.1 mm value is used
|
| 252 |
+
for `recommend_ring_size()`; exact midpoint ties prefer the smaller size. Raw
|
| 253 |
+
shot fields remain unchanged. The visible result cards prefer the separate
|
| 254 |
+
`session_recommendation` object. A single footer below the cards reads
|
| 255 |
+
`Based on N measurements. (Please measure at least 3 times for best reliability)`
|
| 256 |
+
|
| 257 |
+
Aggregation is partitioned by `handedness + finger + ring_model`; failed
|
| 258 |
+
fingers are excluded and exact duplicate images are identified by SHA-256.
|
| 259 |
+
Supabase is only an asynchronous audit trail and is never queried on the live
|
| 260 |
+
recommendation path. Sessions expire after 30 minutes or when email/model/mode
|
| 261 |
+
context changes. See `doc/v10/`.
|
| 262 |
+
|
| 263 |
+
Additive columns on `measurements`:
|
| 264 |
+
|
| 265 |
+
| Column | Notes |
|
| 266 |
+
|---|---|
|
| 267 |
+
| `session_id` | Browser-session UUID; nullable for legacy/non-session clients. |
|
| 268 |
+
| `session_attempt_index` | Monotonic request count within the carried state. |
|
| 269 |
+
| `image_sha256` | Exact duplicate detection and audit. |
|
| 270 |
+
| `session_recommendation` | JSONB snapshot of precise medians, 0.1 mm decision diameters, sizes, counts, and spreads. |
|
| 271 |
+
|
| 272 |
+
Existing `diameter_cm`, `per_finger`, and `overall_*` fields continue to mean
|
| 273 |
+
the current raw shot, not the session median.
|
| 274 |
+
|
| 275 |
### v8 post-shipment feedback (`/feedback` + `feedback` table)
|
| 276 |
|
| 277 |
A **separate** flow from the post-result rating above — different
|
measure_finger.py
CHANGED
|
@@ -289,8 +289,16 @@ def save_output(output: Dict[str, Any], output_path: str) -> None:
|
|
| 289 |
# Ensure output directory exists
|
| 290 |
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
| 291 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 292 |
with open(output_path, "w") as f:
|
| 293 |
-
json.dump(output, f, indent=2)
|
| 294 |
|
| 295 |
|
| 296 |
# Debug visualisations are for human inspection, so there's no reason to
|
|
@@ -1083,7 +1091,7 @@ def measure_finger(
|
|
| 1083 |
|
| 1084 |
log_phase_summary(timings)
|
| 1085 |
|
| 1086 |
-
|
| 1087 |
finger_diameter_cm=median_width_cm,
|
| 1088 |
confidence=confidence_breakdown['overall'],
|
| 1089 |
card_detected=card_detected,
|
|
@@ -1094,6 +1102,11 @@ def measure_finger(
|
|
| 1094 |
edge_method_used=edge_method_used,
|
| 1095 |
card_metrics=card_metrics,
|
| 1096 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1097 |
|
| 1098 |
|
| 1099 |
MULTI_FINGERS = ["index", "middle", "ring"]
|
|
@@ -1463,6 +1476,7 @@ def measure_multi_finger(
|
|
| 1463 |
r.pop("_internal", None)
|
| 1464 |
|
| 1465 |
aggregated["scale_px_per_cm"] = round(px_per_cm, 2)
|
|
|
|
| 1466 |
aggregated["quality_flags"] = {
|
| 1467 |
"card_detected": card_detected,
|
| 1468 |
"view_angle_ok": view_angle_ok,
|
|
|
|
| 289 |
# Ensure output directory exists
|
| 290 |
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
| 291 |
|
| 292 |
+
def _numpy_json_default(value: Any) -> Any:
|
| 293 |
+
"""Normalize NumPy values emitted by multi-finger geometry helpers."""
|
| 294 |
+
if isinstance(value, np.ndarray):
|
| 295 |
+
return value.tolist()
|
| 296 |
+
if isinstance(value, np.generic):
|
| 297 |
+
return value.item()
|
| 298 |
+
raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable")
|
| 299 |
+
|
| 300 |
with open(output_path, "w") as f:
|
| 301 |
+
json.dump(output, f, indent=2, default=_numpy_json_default)
|
| 302 |
|
| 303 |
|
| 304 |
# Debug visualisations are for human inspection, so there's no reason to
|
|
|
|
| 1091 |
|
| 1092 |
log_phase_summary(timings)
|
| 1093 |
|
| 1094 |
+
output = create_output(
|
| 1095 |
finger_diameter_cm=median_width_cm,
|
| 1096 |
confidence=confidence_breakdown['overall'],
|
| 1097 |
card_detected=card_detected,
|
|
|
|
| 1102 |
edge_method_used=edge_method_used,
|
| 1103 |
card_metrics=card_metrics,
|
| 1104 |
)
|
| 1105 |
+
# Additive metadata used by the web session aggregator. Keeping it in the
|
| 1106 |
+
# core result also makes CLI/debug JSON explicit about which physical hand
|
| 1107 |
+
# was measured.
|
| 1108 |
+
output["handedness"] = hand_data.get("handedness", "Unknown")
|
| 1109 |
+
return output
|
| 1110 |
|
| 1111 |
|
| 1112 |
MULTI_FINGERS = ["index", "middle", "ring"]
|
|
|
|
| 1476 |
r.pop("_internal", None)
|
| 1477 |
|
| 1478 |
aggregated["scale_px_per_cm"] = round(px_per_cm, 2)
|
| 1479 |
+
aggregated["handedness"] = hand_data.get("handedness", "Unknown")
|
| 1480 |
aggregated["quality_flags"] = {
|
| 1481 |
"card_detected": card_detected,
|
| 1482 |
"view_angle_ok": view_angle_ok,
|
script/deploy_hf.sh
CHANGED
|
@@ -5,11 +5,18 @@ set -euo pipefail
|
|
| 5 |
REPO_ID="Feng-X/ring-sizer"
|
| 6 |
IGNORE='[".venv/*", ".git/*", "__pycache__/*", "*.pyc", "output/*", "web_demo/uploads/*", "web_demo/results/*", "doc/*", ".claude/*", "input/*"]'
|
| 7 |
|
| 8 |
-
cd "$(dirname "$0")/.."
|
|
|
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
-
|
|
|
|
|
|
|
| 13 |
from huggingface_hub import HfApi
|
| 14 |
HfApi().upload_folder(
|
| 15 |
folder_path='.',
|
|
|
|
| 5 |
REPO_ID="Feng-X/ring-sizer"
|
| 6 |
IGNORE='[".venv/*", ".git/*", "__pycache__/*", "*.pyc", "output/*", "web_demo/uploads/*", "web_demo/results/*", "doc/*", ".claude/*", "input/*"]'
|
| 7 |
|
| 8 |
+
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
| 9 |
+
PYTHON_BIN="${REPO_ROOT}/.venv/bin/python"
|
| 10 |
|
| 11 |
+
if [[ ! -x "${PYTHON_BIN}" ]]; then
|
| 12 |
+
echo "Deployment environment not found: ${PYTHON_BIN}" >&2
|
| 13 |
+
echo "Create it with: python3 -m venv .venv && .venv/bin/pip install -r requirements.txt" >&2
|
| 14 |
+
exit 1
|
| 15 |
+
fi
|
| 16 |
|
| 17 |
+
cd "${REPO_ROOT}"
|
| 18 |
+
|
| 19 |
+
"${PYTHON_BIN}" -c "
|
| 20 |
from huggingface_hub import HfApi
|
| 21 |
HfApi().upload_folder(
|
| 22 |
folder_path='.',
|
script/validate_knuckle.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Seat-vs-PIP-knuckle width validation over a folder of hand photos.
|
| 2 |
+
|
| 3 |
+
For each image: run the real pipeline up through canonical rotation, then
|
| 4 |
+
measure two bands on the chosen finger --
|
| 5 |
+
* SEAT : current anatomical band [PIP - seg , PIP] (median)
|
| 6 |
+
* KNUCKLE : PIP-centered band PIP +/- K_SEG*seg (median + p90)
|
| 7 |
+
-- map each to a ring size, and write
|
| 8 |
+
* an annotated overlay PNG so the seat vs knuckle lines can be eyeballed
|
| 9 |
+
* a row in results.csv
|
| 10 |
+
|
| 11 |
+
No ground truth is required: this is a visual + tabular comparison to confirm
|
| 12 |
+
the knuckle band detects the wider PIP joint on knuckle-dominant fingers.
|
| 13 |
+
|
| 14 |
+
Usage:
|
| 15 |
+
python script/validate_knuckle.py [input_dir] [--finger index] [--limit N]
|
| 16 |
+
"""
|
| 17 |
+
import sys
|
| 18 |
+
import csv
|
| 19 |
+
import argparse
|
| 20 |
+
from pathlib import Path
|
| 21 |
+
|
| 22 |
+
import numpy as np
|
| 23 |
+
import cv2
|
| 24 |
+
|
| 25 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
| 26 |
+
|
| 27 |
+
from src.finger_segmentation import segment_hand, isolate_finger
|
| 28 |
+
from src.geometry import (
|
| 29 |
+
estimate_finger_axis, localize_ring_zone_from_landmarks,
|
| 30 |
+
calculate_angle_from_vertical, rotate_image_precise, rotate_axis_data,
|
| 31 |
+
transform_points_rotation,
|
| 32 |
+
)
|
| 33 |
+
from src.edge_refinement import refine_edges_sobel
|
| 34 |
+
from src.card_detection import compute_scale_factor
|
| 35 |
+
from src.ring_size import recommend_ring_size
|
| 36 |
+
from measure_finger import _sam_card_detect
|
| 37 |
+
|
| 38 |
+
# --- locked method parameters (per agreed recommendation) ---
|
| 39 |
+
K_SEG = 0.4 # knuckle band half-height in units of |DIP-PIP|
|
| 40 |
+
SLOPE, INTERCEPT = 0.809353, 0.251506 # src/calibration.json (mask/classic)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _cal_mm(raw_px, px_per_cm):
|
| 44 |
+
raw_cm = raw_px / px_per_cm
|
| 45 |
+
return (SLOPE * raw_cm + INTERCEPT) * 10.0
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _size(cal_mm):
|
| 49 |
+
rs = recommend_ring_size(cal_mm / 10.0)
|
| 50 |
+
return rs["best_match"] if rs else None
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _band_widths_px(image, axis_data, zone, px_per_cm, landmarks, mask):
|
| 54 |
+
"""Return (median_px, p90_px, n) of per-row mask widths over a band."""
|
| 55 |
+
m = refine_edges_sobel(
|
| 56 |
+
image=image, axis_data=axis_data, zone_data=zone,
|
| 57 |
+
scale_px_per_cm=px_per_cm, finger_landmarks=landmarks,
|
| 58 |
+
finger_mask=mask, mask_mode="mask_only",
|
| 59 |
+
)
|
| 60 |
+
ed = m["edge_data"]
|
| 61 |
+
vr = ed["valid_rows"]
|
| 62 |
+
w = (ed["right_edges"][vr] - ed["left_edges"][vr])
|
| 63 |
+
if w.size == 0:
|
| 64 |
+
return None, None, 0
|
| 65 |
+
# same MAD trim the production median uses, so seat matches the real number
|
| 66 |
+
med = np.median(w)
|
| 67 |
+
mad = np.median(np.abs(w - med))
|
| 68 |
+
keep = np.abs(w - med) <= 3 * mad if mad > 0 else np.ones_like(w, bool)
|
| 69 |
+
w = w[keep]
|
| 70 |
+
return float(np.median(w)), float(np.percentile(w, 90)), int(w.size)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _draw(img, landmarks, seat_c, seat_w, knk_c, knk_w, texts):
|
| 74 |
+
"""Draw both width lines + landmarks on a copy, crop tight, return it."""
|
| 75 |
+
vis = img.copy()
|
| 76 |
+
t = max(2, img.shape[1] // 500)
|
| 77 |
+
pts = [seat_c, knk_c] + [tuple(p) for p in landmarks]
|
| 78 |
+
|
| 79 |
+
def hline(c, half, color):
|
| 80 |
+
cx, cy = float(c[0]), float(c[1])
|
| 81 |
+
a = (int(cx - half), int(cy)); b = (int(cx + half), int(cy))
|
| 82 |
+
cv2.line(vis, a, b, color, t)
|
| 83 |
+
cv2.circle(vis, a, t + 1, color, -1)
|
| 84 |
+
cv2.circle(vis, b, t + 1, color, -1)
|
| 85 |
+
pts.extend([a, b])
|
| 86 |
+
|
| 87 |
+
hline(seat_c, seat_w / 2, (0, 200, 0)) # green = seat
|
| 88 |
+
hline(knk_c, knk_w / 2, (0, 80, 255)) # orange = knuckle
|
| 89 |
+
for name, p in zip(["MCP", "PIP", "DIP", "TIP"], landmarks):
|
| 90 |
+
cv2.circle(vis, (int(p[0]), int(p[1])), t + 2, (255, 255, 0), -1)
|
| 91 |
+
|
| 92 |
+
xs = [p[0] for p in pts]; ys = [p[1] for p in pts]
|
| 93 |
+
pad = int(img.shape[1] * 0.12)
|
| 94 |
+
x0 = max(0, int(min(xs)) - pad); x1 = min(img.shape[1], int(max(xs)) + pad)
|
| 95 |
+
y0 = max(0, int(min(ys)) - pad); y1 = min(img.shape[0], int(max(ys)) + pad)
|
| 96 |
+
crop = vis[y0:y1, x0:x1].copy()
|
| 97 |
+
|
| 98 |
+
fs = crop.shape[1] / 700.0
|
| 99 |
+
for i, txt in enumerate(texts):
|
| 100 |
+
y = int(30 * fs) + int(i * 34 * fs)
|
| 101 |
+
cv2.putText(crop, txt, (int(10 * fs), y), cv2.FONT_HERSHEY_SIMPLEX,
|
| 102 |
+
fs, (0, 0, 0), max(3, int(4 * fs)), cv2.LINE_AA)
|
| 103 |
+
cv2.putText(crop, txt, (int(10 * fs), y), cv2.FONT_HERSHEY_SIMPLEX,
|
| 104 |
+
fs, (255, 255, 255), max(1, int(2 * fs)), cv2.LINE_AA)
|
| 105 |
+
return crop
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def process(path, finger, out_dir):
|
| 109 |
+
image = cv2.imread(str(path))
|
| 110 |
+
if image is None:
|
| 111 |
+
return {"name": path.stem, "fail_reason": "imread_failed"}
|
| 112 |
+
|
| 113 |
+
hand = segment_hand(image, finger=finger)
|
| 114 |
+
if hand is None:
|
| 115 |
+
return {"name": path.stem, "fail_reason": "hand_not_detected"}
|
| 116 |
+
img_can = hand.get("canonical_image", image)
|
| 117 |
+
raw_mask = hand.get("mask")
|
| 118 |
+
|
| 119 |
+
card = _sam_card_detect(img_can, hand, False, None)
|
| 120 |
+
if card is None:
|
| 121 |
+
return {"name": path.stem, "fail_reason": "card_not_detected"}
|
| 122 |
+
px_per_cm, _ = compute_scale_factor(card["corners"])
|
| 123 |
+
|
| 124 |
+
h, w = img_can.shape[:2]
|
| 125 |
+
fd = isolate_finger(hand, finger=finger, image_shape=(h, w))
|
| 126 |
+
if fd is None or fd.get("landmarks") is None:
|
| 127 |
+
return {"name": path.stem, "fail_reason": "finger_isolation_failed"}
|
| 128 |
+
|
| 129 |
+
axis = estimate_finger_axis(fd["landmarks"])
|
| 130 |
+
angle = calculate_angle_from_vertical(axis["direction"])
|
| 131 |
+
R = None
|
| 132 |
+
if abs(angle) >= 0.0:
|
| 133 |
+
img_can, R = rotate_image_precise(img_can, angle, (w / 2.0, h / 2.0))
|
| 134 |
+
axis = rotate_axis_data(axis, R)
|
| 135 |
+
lm = transform_points_rotation(fd["landmarks"], R)
|
| 136 |
+
raw_mask = cv2.warpAffine(raw_mask, R, (w, h), flags=cv2.INTER_NEAREST)
|
| 137 |
+
else:
|
| 138 |
+
lm = fd["landmarks"]
|
| 139 |
+
|
| 140 |
+
mcp, pip, dip, tip = lm
|
| 141 |
+
seg = float(np.linalg.norm(dip - pip))
|
| 142 |
+
direction = (pip - mcp) / np.linalg.norm(pip - mcp)
|
| 143 |
+
|
| 144 |
+
seat = localize_ring_zone_from_landmarks(lm, axis, zone_type="anatomical")
|
| 145 |
+
half = K_SEG * seg
|
| 146 |
+
knuckle = {
|
| 147 |
+
"start_point": (pip - direction * half).astype(np.float32),
|
| 148 |
+
"end_point": (pip + direction * half).astype(np.float32),
|
| 149 |
+
"center_point": pip.astype(np.float32),
|
| 150 |
+
"length": float(2 * half),
|
| 151 |
+
"localization_method": "pip_knuckle",
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
seat_med, _, seat_n = _band_widths_px(img_can, axis, seat, px_per_cm, lm, raw_mask)
|
| 155 |
+
knk_med, knk_p90, knk_n = _band_widths_px(img_can, axis, knuckle, px_per_cm, lm, raw_mask)
|
| 156 |
+
if seat_med is None or knk_med is None:
|
| 157 |
+
return {"name": path.stem, "fail_reason": "no_valid_rows"}
|
| 158 |
+
|
| 159 |
+
seat_cal = _cal_mm(seat_med, px_per_cm)
|
| 160 |
+
knk_cal = _cal_mm(knk_med, px_per_cm)
|
| 161 |
+
knk_p90_cal = _cal_mm(knk_p90, px_per_cm)
|
| 162 |
+
row = {
|
| 163 |
+
"name": path.stem,
|
| 164 |
+
"finger": finger,
|
| 165 |
+
"scale_px_per_cm": round(px_per_cm, 1),
|
| 166 |
+
"seat_cal_mm": round(seat_cal, 2),
|
| 167 |
+
"seat_size": _size(seat_cal),
|
| 168 |
+
"knuckle_med_cal_mm": round(knk_cal, 2),
|
| 169 |
+
"knuckle_size": _size(knk_cal),
|
| 170 |
+
"knuckle_p90_cal_mm": round(knk_p90_cal, 2),
|
| 171 |
+
"knuckle_p90_size": _size(knk_p90_cal),
|
| 172 |
+
"gap_mm": round(knk_cal - seat_cal, 2),
|
| 173 |
+
"size_up": int((_size(knk_cal) or 0) > (_size(seat_cal) or 0)),
|
| 174 |
+
"fail_reason": "",
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
texts = [
|
| 178 |
+
f"seat {seat_cal:.1f}mm size {row['seat_size']}",
|
| 179 |
+
f"knuckle {knk_cal:.1f}mm size {row['knuckle_size']} (gap {row['gap_mm']:+.1f}mm)",
|
| 180 |
+
]
|
| 181 |
+
crop = _draw(img_can, lm, seat["center_point"], seat_med,
|
| 182 |
+
pip, knk_med, texts)
|
| 183 |
+
cv2.imwrite(str(out_dir / f"{path.stem}__cmp.png"), crop)
|
| 184 |
+
return row
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def main():
|
| 188 |
+
ap = argparse.ArgumentParser()
|
| 189 |
+
ap.add_argument("input_dir", nargs="?", default="input/kol_high_quality")
|
| 190 |
+
ap.add_argument("--finger", default="index")
|
| 191 |
+
ap.add_argument("--limit", type=int, default=0)
|
| 192 |
+
args = ap.parse_args()
|
| 193 |
+
|
| 194 |
+
in_dir = Path(args.input_dir)
|
| 195 |
+
out_dir = Path("output/knuckle_validation")
|
| 196 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 197 |
+
|
| 198 |
+
exts = {".jpg", ".jpeg", ".png"}
|
| 199 |
+
images = sorted(p for p in in_dir.iterdir() if p.suffix.lower() in exts)
|
| 200 |
+
if args.limit:
|
| 201 |
+
images = images[:args.limit]
|
| 202 |
+
|
| 203 |
+
fields = ["name", "finger", "scale_px_per_cm", "seat_cal_mm", "seat_size",
|
| 204 |
+
"knuckle_med_cal_mm", "knuckle_size", "knuckle_p90_cal_mm",
|
| 205 |
+
"knuckle_p90_size", "gap_mm", "size_up", "fail_reason"]
|
| 206 |
+
rows = []
|
| 207 |
+
for i, p in enumerate(images, 1):
|
| 208 |
+
try:
|
| 209 |
+
r = process(p, args.finger, out_dir)
|
| 210 |
+
except Exception as e:
|
| 211 |
+
r = {"name": p.stem, "fail_reason": f"error:{type(e).__name__}:{e}"}
|
| 212 |
+
rows.append({k: r.get(k, "") for k in fields})
|
| 213 |
+
print(f"[{i}/{len(images)}] {p.stem}: "
|
| 214 |
+
+ (r["fail_reason"] if r.get("fail_reason")
|
| 215 |
+
else f"seat={r['seat_size']} knuckle={r['knuckle_size']} gap={r['gap_mm']:+.1f}mm"),
|
| 216 |
+
flush=True)
|
| 217 |
+
|
| 218 |
+
csv_path = out_dir / "results.csv"
|
| 219 |
+
with open(csv_path, "w", newline="") as f:
|
| 220 |
+
wcsv = csv.DictWriter(f, fieldnames=fields)
|
| 221 |
+
wcsv.writeheader()
|
| 222 |
+
wcsv.writerows(rows)
|
| 223 |
+
|
| 224 |
+
ok = [r for r in rows if not r["fail_reason"]]
|
| 225 |
+
up = [r for r in ok if r["size_up"]]
|
| 226 |
+
print("\n==== summary ====")
|
| 227 |
+
print(f"processed {len(rows)}, ok {len(ok)}, failed {len(rows) - len(ok)}")
|
| 228 |
+
if ok:
|
| 229 |
+
gaps = [float(r["gap_mm"]) for r in ok]
|
| 230 |
+
print(f"knuckle wider in {sum(g > 0 for g in gaps)}/{len(ok)} images; "
|
| 231 |
+
f"mean gap {np.mean(gaps):+.2f}mm, max {max(gaps):+.2f}mm")
|
| 232 |
+
print(f"knuckle bumps the recommended size up in {len(up)}/{len(ok)} images")
|
| 233 |
+
print(f"overlays + results.csv -> {out_dir}")
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
if __name__ == "__main__":
|
| 237 |
+
main()
|
src/ring_size.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 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}
|
|
@@ -44,9 +45,18 @@ def _get_sorted_sizes(ring_model: str) -> List[Tuple[int, float]]:
|
|
| 44 |
return sorted(chart.items(), key=lambda x: x[1])
|
| 45 |
|
| 46 |
|
| 47 |
-
def recommend_ring_size(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
"""Recommend ring size from calibrated finger outer diameter.
|
| 49 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
Returns dict with:
|
| 51 |
- best_match: nearest ring size (int)
|
| 52 |
- best_match_inner_mm: inner diameter of best match
|
|
@@ -61,14 +71,27 @@ def recommend_ring_size(diameter_cm: float, ring_model: str = DEFAULT_RING_MODEL
|
|
| 61 |
return None
|
| 62 |
|
| 63 |
sorted_sizes = _get_sorted_sizes(ring_model)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
|
| 65 |
# Find nearest size
|
| 66 |
-
|
|
|
|
|
|
|
|
|
|
| 67 |
|
| 68 |
# Find second nearest size
|
| 69 |
second_size, second_inner = min(
|
| 70 |
(s for s in sorted_sizes if s[0] != best_size),
|
| 71 |
-
key=
|
| 72 |
)
|
| 73 |
|
| 74 |
range_min = min(best_size, second_size)
|
|
|
|
| 1 |
"""Ring size recommendation from calibrated finger width."""
|
| 2 |
|
| 3 |
+
from decimal import Decimal
|
| 4 |
from typing import Dict, List, Literal, Optional, Tuple
|
| 5 |
|
| 6 |
# Ring model definitions: model name → {size: inner_diameter_mm}
|
|
|
|
| 45 |
return sorted(chart.items(), key=lambda x: x[1])
|
| 46 |
|
| 47 |
|
| 48 |
+
def recommend_ring_size(
|
| 49 |
+
diameter_cm: float,
|
| 50 |
+
ring_model: str = DEFAULT_RING_MODEL,
|
| 51 |
+
*,
|
| 52 |
+
prefer_smaller_on_tie: bool = False,
|
| 53 |
+
) -> Optional[Dict]:
|
| 54 |
"""Recommend ring size from calibrated finger outer diameter.
|
| 55 |
|
| 56 |
+
When ``prefer_smaller_on_tie`` is true, exact midpoint ties are compared
|
| 57 |
+
with decimal arithmetic and resolve to the smaller size. The default keeps
|
| 58 |
+
the legacy per-photo lookup behavior.
|
| 59 |
+
|
| 60 |
Returns dict with:
|
| 61 |
- best_match: nearest ring size (int)
|
| 62 |
- best_match_inner_mm: inner diameter of best match
|
|
|
|
| 71 |
return None
|
| 72 |
|
| 73 |
sorted_sizes = _get_sorted_sizes(ring_model)
|
| 74 |
+
if prefer_smaller_on_tie:
|
| 75 |
+
decision_mm = Decimal(str(diameter_cm)) * Decimal("10")
|
| 76 |
+
|
| 77 |
+
def distance_key(size_and_diameter: Tuple[int, float]):
|
| 78 |
+
size, inner_mm = size_and_diameter
|
| 79 |
+
return abs(Decimal(str(inner_mm)) - decision_mm), size
|
| 80 |
+
else:
|
| 81 |
+
def distance_key(size_and_diameter: Tuple[int, float]):
|
| 82 |
+
_, inner_mm = size_and_diameter
|
| 83 |
+
return abs(inner_mm - diameter_mm)
|
| 84 |
|
| 85 |
# Find nearest size
|
| 86 |
+
# Session recommendations opt into a smaller-size exact-tie policy.
|
| 87 |
+
# Decimal distances keep binary-float noise from turning a mathematical
|
| 88 |
+
# tie into an upper-size result. Other callers retain the legacy lookup.
|
| 89 |
+
best_size, best_inner = min(sorted_sizes, key=distance_key)
|
| 90 |
|
| 91 |
# Find second nearest size
|
| 92 |
second_size, second_inner = min(
|
| 93 |
(s for s in sorted_sizes if s[0] != best_size),
|
| 94 |
+
key=distance_key,
|
| 95 |
)
|
| 96 |
|
| 97 |
range_min = min(best_size, second_size)
|
src/session_recommendation.py
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Session-level median recommendations for repeated web measurements.
|
| 2 |
+
|
| 3 |
+
The computer-vision pipeline continues to produce one raw result per photo.
|
| 4 |
+
This module accumulates the successful calibrated diameters returned by those
|
| 5 |
+
results and derives a separate recommendation from their per-finger median.
|
| 6 |
+
It is deliberately independent of Flask and Supabase so local/offline runs use
|
| 7 |
+
the same logic as production.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import hashlib
|
| 13 |
+
import math
|
| 14 |
+
import re
|
| 15 |
+
import statistics
|
| 16 |
+
import uuid
|
| 17 |
+
from decimal import Decimal, ROUND_HALF_UP
|
| 18 |
+
from typing import Any, Dict, Mapping, Optional, Tuple
|
| 19 |
+
|
| 20 |
+
from src.ring_size import aggregate_ring_sizes, recommend_ring_size
|
| 21 |
+
|
| 22 |
+
SESSION_STATE_VERSION = 1
|
| 23 |
+
MAX_SESSION_SHOTS = 20
|
| 24 |
+
MIN_STATE_DIAMETER_CM = 1.0
|
| 25 |
+
MAX_STATE_DIAMETER_CM = 3.0
|
| 26 |
+
|
| 27 |
+
FINGER_ORDER = ("index", "middle", "ring", "pinky")
|
| 28 |
+
VALID_FINGERS = set(FINGER_ORDER)
|
| 29 |
+
VALID_HANDEDNESS = {"Left", "Right", "Unknown"}
|
| 30 |
+
_SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _size_decision_diameter_mm(median_cm: float) -> float:
|
| 34 |
+
"""Quantize a session median to the supported 0.1 mm decision precision."""
|
| 35 |
+
median_mm = Decimal(str(median_cm)) * Decimal("10")
|
| 36 |
+
return float(median_mm.quantize(Decimal("0.1"), rounding=ROUND_HALF_UP))
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def image_sha256(data: bytes) -> str:
|
| 40 |
+
"""Return a stable content fingerprint for duplicate-shot detection."""
|
| 41 |
+
return hashlib.sha256(data).hexdigest()
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def normalize_session_id(value: Any) -> Optional[str]:
|
| 45 |
+
"""Return a canonical UUID string, or None for absent/malformed input."""
|
| 46 |
+
if not isinstance(value, str) or not value.strip():
|
| 47 |
+
return None
|
| 48 |
+
try:
|
| 49 |
+
return str(uuid.UUID(value.strip()))
|
| 50 |
+
except (ValueError, AttributeError):
|
| 51 |
+
return None
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _empty_state(session_id: str, ring_model: str) -> Dict[str, Any]:
|
| 55 |
+
return {
|
| 56 |
+
"version": SESSION_STATE_VERSION,
|
| 57 |
+
"session_id": session_id,
|
| 58 |
+
"ring_model": ring_model,
|
| 59 |
+
"attempt_count": 0,
|
| 60 |
+
"shots": [],
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _finite_diameter(value: Any) -> Optional[float]:
|
| 65 |
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
| 66 |
+
return None
|
| 67 |
+
diameter = float(value)
|
| 68 |
+
if not math.isfinite(diameter):
|
| 69 |
+
return None
|
| 70 |
+
if diameter < MIN_STATE_DIAMETER_CM or diameter > MAX_STATE_DIAMETER_CM:
|
| 71 |
+
return None
|
| 72 |
+
return round(diameter, 4)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _sanitize_state(
|
| 76 |
+
previous_state: Any,
|
| 77 |
+
*,
|
| 78 |
+
session_id: str,
|
| 79 |
+
ring_model: str,
|
| 80 |
+
) -> Dict[str, Any]:
|
| 81 |
+
"""Validate untrusted browser-returned state and enforce a small bound."""
|
| 82 |
+
fresh = _empty_state(session_id, ring_model)
|
| 83 |
+
if not isinstance(previous_state, Mapping):
|
| 84 |
+
return fresh
|
| 85 |
+
if previous_state.get("version") != SESSION_STATE_VERSION:
|
| 86 |
+
return fresh
|
| 87 |
+
if normalize_session_id(previous_state.get("session_id")) != session_id:
|
| 88 |
+
return fresh
|
| 89 |
+
if previous_state.get("ring_model") != ring_model:
|
| 90 |
+
return fresh
|
| 91 |
+
|
| 92 |
+
attempt_count = previous_state.get("attempt_count", 0)
|
| 93 |
+
if isinstance(attempt_count, bool) or not isinstance(attempt_count, int):
|
| 94 |
+
attempt_count = 0
|
| 95 |
+
fresh["attempt_count"] = max(0, min(attempt_count, 10_000))
|
| 96 |
+
|
| 97 |
+
raw_shots = previous_state.get("shots")
|
| 98 |
+
if not isinstance(raw_shots, list):
|
| 99 |
+
return fresh
|
| 100 |
+
|
| 101 |
+
shots = []
|
| 102 |
+
for raw_shot in raw_shots[-MAX_SESSION_SHOTS:]:
|
| 103 |
+
if not isinstance(raw_shot, Mapping):
|
| 104 |
+
continue
|
| 105 |
+
handedness = raw_shot.get("handedness")
|
| 106 |
+
if handedness not in VALID_HANDEDNESS:
|
| 107 |
+
continue
|
| 108 |
+
digest = raw_shot.get("image_sha256")
|
| 109 |
+
if not isinstance(digest, str) or not _SHA256_RE.fullmatch(digest):
|
| 110 |
+
continue
|
| 111 |
+
raw_per_finger = raw_shot.get("per_finger")
|
| 112 |
+
if not isinstance(raw_per_finger, Mapping):
|
| 113 |
+
continue
|
| 114 |
+
per_finger: Dict[str, float] = {}
|
| 115 |
+
for finger, value in raw_per_finger.items():
|
| 116 |
+
if finger not in VALID_FINGERS:
|
| 117 |
+
continue
|
| 118 |
+
diameter = _finite_diameter(value)
|
| 119 |
+
if diameter is not None:
|
| 120 |
+
per_finger[finger] = diameter
|
| 121 |
+
if not per_finger:
|
| 122 |
+
continue
|
| 123 |
+
shots.append({
|
| 124 |
+
"run_id": str(raw_shot.get("run_id") or "")[:64],
|
| 125 |
+
"image_sha256": digest,
|
| 126 |
+
"handedness": handedness,
|
| 127 |
+
"per_finger": per_finger,
|
| 128 |
+
})
|
| 129 |
+
|
| 130 |
+
fresh["shots"] = shots[-MAX_SESSION_SHOTS:]
|
| 131 |
+
return fresh
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def _result_handedness(result: Mapping[str, Any]) -> str:
|
| 135 |
+
handedness = result.get("handedness")
|
| 136 |
+
return handedness if handedness in VALID_HANDEDNESS else "Unknown"
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def _successful_current_samples(
|
| 140 |
+
result: Mapping[str, Any],
|
| 141 |
+
*,
|
| 142 |
+
mode: str,
|
| 143 |
+
finger_index: str,
|
| 144 |
+
) -> Dict[str, float]:
|
| 145 |
+
samples: Dict[str, float] = {}
|
| 146 |
+
if mode == "multi":
|
| 147 |
+
per_finger = result.get("per_finger")
|
| 148 |
+
if not isinstance(per_finger, Mapping):
|
| 149 |
+
return samples
|
| 150 |
+
for finger in FINGER_ORDER:
|
| 151 |
+
item = per_finger.get(finger)
|
| 152 |
+
if not isinstance(item, Mapping) or item.get("status") != "ok":
|
| 153 |
+
continue
|
| 154 |
+
diameter = _finite_diameter(item.get("diameter_cm"))
|
| 155 |
+
if diameter is not None:
|
| 156 |
+
samples[finger] = diameter
|
| 157 |
+
return samples
|
| 158 |
+
|
| 159 |
+
if result.get("fail_reason") is not None:
|
| 160 |
+
return samples
|
| 161 |
+
finger = finger_index if finger_index in VALID_FINGERS else "index"
|
| 162 |
+
diameter = _finite_diameter(result.get("finger_outer_diameter_cm"))
|
| 163 |
+
if diameter is not None:
|
| 164 |
+
samples[finger] = diameter
|
| 165 |
+
return samples
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def _recommend_for_hand(
|
| 169 |
+
state: Mapping[str, Any],
|
| 170 |
+
*,
|
| 171 |
+
handedness: str,
|
| 172 |
+
ring_model: str,
|
| 173 |
+
current_result: Mapping[str, Any],
|
| 174 |
+
mode: str,
|
| 175 |
+
finger_index: str,
|
| 176 |
+
current_shot_included: bool,
|
| 177 |
+
duplicate_image: bool,
|
| 178 |
+
) -> Optional[Dict[str, Any]]:
|
| 179 |
+
values: Dict[str, list] = {finger: [] for finger in FINGER_ORDER}
|
| 180 |
+
successful_shots = 0
|
| 181 |
+
for shot in state.get("shots", []):
|
| 182 |
+
if shot.get("handedness") != handedness:
|
| 183 |
+
continue
|
| 184 |
+
successful_shots += 1
|
| 185 |
+
for finger, diameter in shot.get("per_finger", {}).items():
|
| 186 |
+
if finger in values:
|
| 187 |
+
values[finger].append(float(diameter))
|
| 188 |
+
|
| 189 |
+
synthetic: Dict[str, Dict[str, Any]] = {}
|
| 190 |
+
stats: Dict[str, Dict[str, Any]] = {}
|
| 191 |
+
for finger in FINGER_ORDER:
|
| 192 |
+
finger_values = values[finger]
|
| 193 |
+
if not finger_values:
|
| 194 |
+
continue
|
| 195 |
+
# Inputs are stored to 4 decimal places in cm, so an even-sized median
|
| 196 |
+
# can contain one additional decimal place. Preserve that value for
|
| 197 |
+
# auditability, but quantize the value used for discrete size lookup to
|
| 198 |
+
# 0.1 mm. This avoids invisible hundredths of a millimetre flipping a
|
| 199 |
+
# recommendation while the UI displays the same one-decimal diameter.
|
| 200 |
+
median_cm = round(float(statistics.median(finger_values)), 5)
|
| 201 |
+
decision_diameter_mm = _size_decision_diameter_mm(median_cm)
|
| 202 |
+
spread_mm = round((max(finger_values) - min(finger_values)) * 10.0, 2)
|
| 203 |
+
ring_size = recommend_ring_size(
|
| 204 |
+
decision_diameter_mm / 10.0,
|
| 205 |
+
ring_model=ring_model,
|
| 206 |
+
prefer_smaller_on_tie=True,
|
| 207 |
+
)
|
| 208 |
+
synthetic[finger] = {
|
| 209 |
+
"finger_outer_diameter_cm": median_cm,
|
| 210 |
+
# Session confidence is intentionally not invented. Equal weights
|
| 211 |
+
# keep the legacy cross-finger aggregator deterministic without
|
| 212 |
+
# reusing the non-predictive per-shot confidence score.
|
| 213 |
+
"confidence": 1.0,
|
| 214 |
+
"ring_size": ring_size,
|
| 215 |
+
"fail_reason": None,
|
| 216 |
+
}
|
| 217 |
+
stats[finger] = {
|
| 218 |
+
"sample_count": len(finger_values),
|
| 219 |
+
"spread_mm": spread_mm,
|
| 220 |
+
"decision_diameter_mm": decision_diameter_mm,
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
if not synthetic:
|
| 224 |
+
return None
|
| 225 |
+
|
| 226 |
+
aggregated = aggregate_ring_sizes(synthetic)
|
| 227 |
+
per_finger = aggregated.get("per_finger", {})
|
| 228 |
+
for finger, finger_stats in stats.items():
|
| 229 |
+
if finger in per_finger:
|
| 230 |
+
# The equal weight above is only an internal tie-breaker for the
|
| 231 |
+
# legacy cross-finger aggregator, not a claim of 100% confidence.
|
| 232 |
+
per_finger[finger].pop("confidence", None)
|
| 233 |
+
per_finger[finger].update(finger_stats)
|
| 234 |
+
|
| 235 |
+
# Preserve a failed current-finger card when no earlier success exists,
|
| 236 |
+
# keeping first-shot rendering equivalent to the raw multi result.
|
| 237 |
+
if mode == "multi":
|
| 238 |
+
current_per_finger = current_result.get("per_finger")
|
| 239 |
+
if isinstance(current_per_finger, Mapping):
|
| 240 |
+
for finger in FINGER_ORDER:
|
| 241 |
+
current_item = current_per_finger.get(finger)
|
| 242 |
+
if finger not in per_finger and isinstance(current_item, Mapping):
|
| 243 |
+
per_finger[finger] = dict(current_item)
|
| 244 |
+
per_finger[finger]["sample_count"] = 0
|
| 245 |
+
per_finger[finger]["spread_mm"] = None
|
| 246 |
+
per_finger[finger]["decision_diameter_mm"] = None
|
| 247 |
+
|
| 248 |
+
aggregated["fingers_measured"] = len(per_finger)
|
| 249 |
+
aggregated["fingers_succeeded"] = sum(
|
| 250 |
+
item.get("status") == "ok" for item in per_finger.values()
|
| 251 |
+
)
|
| 252 |
+
|
| 253 |
+
recommendation: Dict[str, Any] = {
|
| 254 |
+
**aggregated,
|
| 255 |
+
"basis": "session_median",
|
| 256 |
+
"session_id": state["session_id"],
|
| 257 |
+
"attempt_index": state["attempt_count"],
|
| 258 |
+
"handedness": handedness,
|
| 259 |
+
"successful_shots": successful_shots,
|
| 260 |
+
"current_shot_included": current_shot_included,
|
| 261 |
+
"duplicate_image": duplicate_image,
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
if mode != "multi":
|
| 265 |
+
finger = finger_index if finger_index in VALID_FINGERS else "index"
|
| 266 |
+
finger_rec = per_finger.get(finger)
|
| 267 |
+
if finger_rec and finger_rec.get("status") == "ok":
|
| 268 |
+
recommendation["finger_index"] = finger
|
| 269 |
+
recommendation["finger_outer_diameter_cm"] = finger_rec["diameter_cm"]
|
| 270 |
+
recommendation["ring_size"] = synthetic[finger]["ring_size"]
|
| 271 |
+
return recommendation
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
def update_session_recommendation(
|
| 275 |
+
previous_state: Any,
|
| 276 |
+
*,
|
| 277 |
+
session_id: str,
|
| 278 |
+
ring_model: str,
|
| 279 |
+
run_id: str,
|
| 280 |
+
image_digest: str,
|
| 281 |
+
result: Mapping[str, Any],
|
| 282 |
+
mode: str,
|
| 283 |
+
finger_index: str = "index",
|
| 284 |
+
) -> Tuple[Dict[str, Any], Optional[Dict[str, Any]]]:
|
| 285 |
+
"""Add one attempt and return `(updated_state, recommendation)`.
|
| 286 |
+
|
| 287 |
+
`result` must be the calibrated raw result for the current photo. The
|
| 288 |
+
returned recommendation is for the current detected hand only. A total
|
| 289 |
+
current-shot failure increments the attempt counter but returns no stale
|
| 290 |
+
recommendation to the UI.
|
| 291 |
+
"""
|
| 292 |
+
canonical_id = normalize_session_id(session_id)
|
| 293 |
+
if canonical_id is None:
|
| 294 |
+
raise ValueError("session_id must be a valid UUID")
|
| 295 |
+
if not _SHA256_RE.fullmatch(image_digest or ""):
|
| 296 |
+
raise ValueError("image_digest must be a SHA-256 hex digest")
|
| 297 |
+
|
| 298 |
+
state = _sanitize_state(
|
| 299 |
+
previous_state,
|
| 300 |
+
session_id=canonical_id,
|
| 301 |
+
ring_model=ring_model,
|
| 302 |
+
)
|
| 303 |
+
state["attempt_count"] += 1
|
| 304 |
+
|
| 305 |
+
current_samples = _successful_current_samples(
|
| 306 |
+
result,
|
| 307 |
+
mode=mode,
|
| 308 |
+
finger_index=finger_index,
|
| 309 |
+
)
|
| 310 |
+
handedness = _result_handedness(result)
|
| 311 |
+
duplicate = any(
|
| 312 |
+
shot.get("image_sha256") == image_digest for shot in state["shots"]
|
| 313 |
+
)
|
| 314 |
+
included = bool(current_samples) and not duplicate
|
| 315 |
+
if included:
|
| 316 |
+
state["shots"].append({
|
| 317 |
+
"run_id": str(run_id or "")[:64],
|
| 318 |
+
"image_sha256": image_digest,
|
| 319 |
+
"handedness": handedness,
|
| 320 |
+
"per_finger": current_samples,
|
| 321 |
+
})
|
| 322 |
+
state["shots"] = state["shots"][-MAX_SESSION_SHOTS:]
|
| 323 |
+
|
| 324 |
+
# Do not surface an old recommendation on top of a total current failure.
|
| 325 |
+
if not current_samples:
|
| 326 |
+
return state, None
|
| 327 |
+
|
| 328 |
+
recommendation = _recommend_for_hand(
|
| 329 |
+
state,
|
| 330 |
+
handedness=handedness,
|
| 331 |
+
ring_model=ring_model,
|
| 332 |
+
current_result=result,
|
| 333 |
+
mode=mode,
|
| 334 |
+
finger_index=finger_index,
|
| 335 |
+
current_shot_included=included,
|
| 336 |
+
duplicate_image=duplicate,
|
| 337 |
+
)
|
| 338 |
+
return state, recommendation
|
tests/test_cli_output.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import tempfile
|
| 3 |
+
import unittest
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
import numpy as np
|
| 7 |
+
|
| 8 |
+
from measure_finger import save_output
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class CliOutputTests(unittest.TestCase):
|
| 12 |
+
def test_save_output_normalizes_numpy_scalars_and_arrays(self):
|
| 13 |
+
with tempfile.TemporaryDirectory() as tmp:
|
| 14 |
+
path = Path(tmp) / "result.json"
|
| 15 |
+
save_output(
|
| 16 |
+
{"scalar": np.float32(1.25), "array": np.array([1, 2], dtype=np.int32)},
|
| 17 |
+
str(path),
|
| 18 |
+
)
|
| 19 |
+
self.assertEqual(json.loads(path.read_text()), {"scalar": 1.25, "array": [1, 2]})
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
if __name__ == "__main__":
|
| 23 |
+
unittest.main()
|
| 24 |
+
|
tests/test_measurement_session.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const assert = require("node:assert/strict");
|
| 2 |
+
const fs = require("node:fs");
|
| 3 |
+
const test = require("node:test");
|
| 4 |
+
const vm = require("node:vm");
|
| 5 |
+
|
| 6 |
+
const source = fs.readFileSync(
|
| 7 |
+
"web_demo/static/shared/measurement-session.js",
|
| 8 |
+
"utf8",
|
| 9 |
+
);
|
| 10 |
+
|
| 11 |
+
function loadHelper() {
|
| 12 |
+
const data = new Map();
|
| 13 |
+
let idCounter = 0;
|
| 14 |
+
const window = {
|
| 15 |
+
crypto: { randomUUID: () => `00000000-0000-4000-8000-${String(++idCounter).padStart(12, "0")}` },
|
| 16 |
+
sessionStorage: {
|
| 17 |
+
getItem: (key) => data.has(key) ? data.get(key) : null,
|
| 18 |
+
setItem: (key, value) => data.set(key, value),
|
| 19 |
+
},
|
| 20 |
+
};
|
| 21 |
+
vm.runInNewContext(source, { window, Date, JSON, Math, Number, String });
|
| 22 |
+
return window.MeasurementSession;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
test("preserves returned state for an unchanged context", () => {
|
| 26 |
+
const helper = loadHelper();
|
| 27 |
+
const context = {
|
| 28 |
+
kol_email: "User@Example.com ", ring_model: "gen", mode: "multi",
|
| 29 |
+
finger_index: "index", source: "photo",
|
| 30 |
+
};
|
| 31 |
+
const first = helper.prepare(context);
|
| 32 |
+
const state = { session_id: first.session_id, attempt_count: 1, shots: [] };
|
| 33 |
+
helper.accept(context, { session_state: state });
|
| 34 |
+
assert.deepEqual(helper.prepare(context).session_state, state);
|
| 35 |
+
});
|
| 36 |
+
|
| 37 |
+
test("rotates the session when measurement context changes", () => {
|
| 38 |
+
const helper = loadHelper();
|
| 39 |
+
const base = {
|
| 40 |
+
kol_email: "user@example.com", ring_model: "gen", mode: "multi",
|
| 41 |
+
finger_index: "index", source: "photo",
|
| 42 |
+
};
|
| 43 |
+
const first = helper.prepare(base);
|
| 44 |
+
const second = helper.prepare({ ...base, ring_model: "air" });
|
| 45 |
+
assert.notEqual(second.session_id, first.session_id);
|
| 46 |
+
assert.equal(second.session_state, null);
|
| 47 |
+
});
|
| 48 |
+
|
tests/test_session_evidence.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const assert = require("node:assert/strict");
|
| 2 |
+
const fs = require("node:fs");
|
| 3 |
+
const test = require("node:test");
|
| 4 |
+
const vm = require("node:vm");
|
| 5 |
+
|
| 6 |
+
const source = fs.readFileSync(
|
| 7 |
+
"web_demo/static/shared/session-evidence.js",
|
| 8 |
+
"utf8",
|
| 9 |
+
);
|
| 10 |
+
|
| 11 |
+
function loadHelper() {
|
| 12 |
+
const window = {};
|
| 13 |
+
vm.runInNewContext(source, { window, Number, Object });
|
| 14 |
+
return window.SessionEvidence;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
test("formats singular and recommended measurement guidance", () => {
|
| 18 |
+
const helper = loadHelper();
|
| 19 |
+
assert.equal(
|
| 20 |
+
helper.text({ successful_shots: 1 }),
|
| 21 |
+
"Based on 1 measurement. (Please measure at least 3 times for best reliability)",
|
| 22 |
+
);
|
| 23 |
+
});
|
| 24 |
+
|
| 25 |
+
test("uses the session shot count and keeps guidance after three", () => {
|
| 26 |
+
const helper = loadHelper();
|
| 27 |
+
assert.equal(
|
| 28 |
+
helper.text({ successful_shots: 3 }),
|
| 29 |
+
"Based on 3 measurements. (Please measure at least 3 times for best reliability)",
|
| 30 |
+
);
|
| 31 |
+
});
|
| 32 |
+
|
| 33 |
+
test("falls back to per-finger counts and hides empty evidence", () => {
|
| 34 |
+
const helper = loadHelper();
|
| 35 |
+
assert.equal(helper.text({ per_finger: { index: { sample_count: 2 } } }),
|
| 36 |
+
"Based on 2 measurements. (Please measure at least 3 times for best reliability)");
|
| 37 |
+
assert.equal(helper.text({ per_finger: {} }), "");
|
| 38 |
+
});
|
tests/test_session_recommendation.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import unittest
|
| 2 |
+
import uuid
|
| 3 |
+
|
| 4 |
+
from src.session_recommendation import image_sha256, update_session_recommendation
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def multi_result(*, handedness="Right", index=1.80, middle=1.90, ring=1.70):
|
| 8 |
+
values = {"index": index, "middle": middle, "ring": ring}
|
| 9 |
+
per_finger = {}
|
| 10 |
+
for finger, value in values.items():
|
| 11 |
+
if value is None:
|
| 12 |
+
per_finger[finger] = {
|
| 13 |
+
"status": "failed",
|
| 14 |
+
"diameter_cm": None,
|
| 15 |
+
"best_match": None,
|
| 16 |
+
"range": None,
|
| 17 |
+
"fail_reason": "finger_isolation_failed",
|
| 18 |
+
}
|
| 19 |
+
else:
|
| 20 |
+
per_finger[finger] = {
|
| 21 |
+
"status": "ok",
|
| 22 |
+
"diameter_cm": value,
|
| 23 |
+
"best_match": 8,
|
| 24 |
+
"range": [7, 8],
|
| 25 |
+
"fail_reason": None,
|
| 26 |
+
}
|
| 27 |
+
return {
|
| 28 |
+
"fail_reason": None,
|
| 29 |
+
"handedness": handedness,
|
| 30 |
+
"per_finger": per_finger,
|
| 31 |
+
"fingers_measured": 3,
|
| 32 |
+
"fingers_succeeded": sum(v is not None for v in values.values()),
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class SessionRecommendationTests(unittest.TestCase):
|
| 37 |
+
def setUp(self):
|
| 38 |
+
self.session_id = str(uuid.uuid4())
|
| 39 |
+
|
| 40 |
+
def update(self, state, result, image, **kwargs):
|
| 41 |
+
return update_session_recommendation(
|
| 42 |
+
state,
|
| 43 |
+
session_id=self.session_id,
|
| 44 |
+
ring_model=kwargs.get("ring_model", "gen"),
|
| 45 |
+
run_id=kwargs.get("run_id", image),
|
| 46 |
+
image_digest=image_sha256(image.encode()),
|
| 47 |
+
result=result,
|
| 48 |
+
mode=kwargs.get("mode", "multi"),
|
| 49 |
+
finger_index=kwargs.get("finger_index", "index"),
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
def test_first_shot_matches_its_diameter(self):
|
| 53 |
+
state, rec = self.update(None, multi_result(index=1.80), "one")
|
| 54 |
+
self.assertEqual(state["attempt_count"], 1)
|
| 55 |
+
self.assertEqual(rec["per_finger"]["index"]["diameter_cm"], 1.80)
|
| 56 |
+
self.assertEqual(rec["per_finger"]["index"]["sample_count"], 1)
|
| 57 |
+
|
| 58 |
+
def test_even_and_odd_medians_are_computed_before_size_lookup(self):
|
| 59 |
+
state, _ = self.update(None, multi_result(index=1.70), "one")
|
| 60 |
+
state, rec = self.update(state, multi_result(index=1.90), "two")
|
| 61 |
+
self.assertEqual(rec["per_finger"]["index"]["diameter_cm"], 1.80)
|
| 62 |
+
self.assertEqual(rec["per_finger"]["index"]["sample_count"], 2)
|
| 63 |
+
state, rec = self.update(state, multi_result(index=1.80), "three")
|
| 64 |
+
self.assertEqual(rec["per_finger"]["index"]["diameter_cm"], 1.80)
|
| 65 |
+
self.assertEqual(rec["per_finger"]["index"]["sample_count"], 3)
|
| 66 |
+
|
| 67 |
+
def test_size_lookup_rounds_median_to_tenth_mm_and_prefers_smaller_tie(self):
|
| 68 |
+
state, _ = self.update(
|
| 69 |
+
None,
|
| 70 |
+
multi_result(index=1.7784),
|
| 71 |
+
"one",
|
| 72 |
+
ring_model="air",
|
| 73 |
+
)
|
| 74 |
+
_, rec = self.update(
|
| 75 |
+
state,
|
| 76 |
+
multi_result(index=1.7837),
|
| 77 |
+
"two",
|
| 78 |
+
ring_model="air",
|
| 79 |
+
)
|
| 80 |
+
index = rec["per_finger"]["index"]
|
| 81 |
+
self.assertEqual(index["diameter_cm"], 1.78105)
|
| 82 |
+
self.assertEqual(index["decision_diameter_mm"], 17.8)
|
| 83 |
+
self.assertEqual(index["best_match"], 7)
|
| 84 |
+
|
| 85 |
+
def test_nearby_medians_on_both_sides_share_the_same_air_decision(self):
|
| 86 |
+
_, above = self.update(
|
| 87 |
+
None,
|
| 88 |
+
multi_result(index=1.7807),
|
| 89 |
+
"above",
|
| 90 |
+
ring_model="air",
|
| 91 |
+
)
|
| 92 |
+
self.session_id = str(uuid.uuid4())
|
| 93 |
+
_, below = self.update(
|
| 94 |
+
None,
|
| 95 |
+
multi_result(index=1.7795),
|
| 96 |
+
"below",
|
| 97 |
+
ring_model="air",
|
| 98 |
+
)
|
| 99 |
+
for rec in (above, below):
|
| 100 |
+
index = rec["per_finger"]["index"]
|
| 101 |
+
self.assertEqual(index["decision_diameter_mm"], 17.8)
|
| 102 |
+
self.assertEqual(index["best_match"], 7)
|
| 103 |
+
|
| 104 |
+
def test_partial_failure_only_skips_that_finger(self):
|
| 105 |
+
state, rec = self.update(None, multi_result(index=1.80, middle=None), "one")
|
| 106 |
+
self.assertEqual(rec["per_finger"]["index"]["sample_count"], 1)
|
| 107 |
+
self.assertEqual(rec["per_finger"]["middle"]["status"], "failed")
|
| 108 |
+
self.assertEqual(rec["per_finger"]["middle"]["sample_count"], 0)
|
| 109 |
+
|
| 110 |
+
def test_duplicate_image_is_not_counted_twice(self):
|
| 111 |
+
state, _ = self.update(None, multi_result(index=1.80), "same")
|
| 112 |
+
state, rec = self.update(state, multi_result(index=1.80), "same")
|
| 113 |
+
self.assertEqual(state["attempt_count"], 2)
|
| 114 |
+
self.assertEqual(len(state["shots"]), 1)
|
| 115 |
+
self.assertTrue(rec["duplicate_image"])
|
| 116 |
+
self.assertEqual(rec["per_finger"]["index"]["sample_count"], 1)
|
| 117 |
+
|
| 118 |
+
def test_hands_are_partitioned(self):
|
| 119 |
+
state, _ = self.update(None, multi_result(handedness="Right", index=1.80), "right")
|
| 120 |
+
state, rec = self.update(state, multi_result(handedness="Left", index=2.00), "left")
|
| 121 |
+
self.assertEqual(rec["handedness"], "Left")
|
| 122 |
+
self.assertEqual(rec["per_finger"]["index"]["diameter_cm"], 2.00)
|
| 123 |
+
self.assertEqual(rec["per_finger"]["index"]["sample_count"], 1)
|
| 124 |
+
|
| 125 |
+
def test_total_failure_increments_attempt_without_stale_recommendation(self):
|
| 126 |
+
state, _ = self.update(None, multi_result(index=1.80), "one")
|
| 127 |
+
failed = {"fail_reason": "card_not_detected", "per_finger": {}}
|
| 128 |
+
state, rec = self.update(state, failed, "two")
|
| 129 |
+
self.assertEqual(state["attempt_count"], 2)
|
| 130 |
+
self.assertEqual(len(state["shots"]), 1)
|
| 131 |
+
self.assertIsNone(rec)
|
| 132 |
+
|
| 133 |
+
def test_single_mode_uses_same_aggregator(self):
|
| 134 |
+
first = {
|
| 135 |
+
"fail_reason": None,
|
| 136 |
+
"handedness": "Right",
|
| 137 |
+
"finger_outer_diameter_cm": 1.70,
|
| 138 |
+
}
|
| 139 |
+
second = {**first, "finger_outer_diameter_cm": 1.90}
|
| 140 |
+
state, _ = self.update(None, first, "one", mode="single")
|
| 141 |
+
_, rec = self.update(state, second, "two", mode="single")
|
| 142 |
+
self.assertEqual(rec["finger_outer_diameter_cm"], 1.80)
|
| 143 |
+
self.assertEqual(rec["per_finger"]["index"]["sample_count"], 2)
|
| 144 |
+
|
| 145 |
+
def test_mismatched_or_malformed_state_starts_fresh(self):
|
| 146 |
+
malformed = {
|
| 147 |
+
"version": 1,
|
| 148 |
+
"session_id": str(uuid.uuid4()),
|
| 149 |
+
"ring_model": "gen",
|
| 150 |
+
"attempt_count": 999,
|
| 151 |
+
"shots": [{"diameter_cm": "hacked"}],
|
| 152 |
+
}
|
| 153 |
+
state, rec = self.update(malformed, multi_result(index=1.80), "one")
|
| 154 |
+
self.assertEqual(state["attempt_count"], 1)
|
| 155 |
+
self.assertEqual(len(state["shots"]), 1)
|
| 156 |
+
self.assertEqual(rec["per_finger"]["index"]["sample_count"], 1)
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
if __name__ == "__main__":
|
| 160 |
+
unittest.main()
|
tests/test_web_session_api.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import unittest
|
| 3 |
+
import uuid
|
| 4 |
+
from unittest.mock import patch
|
| 5 |
+
|
| 6 |
+
from web_demo.app import app
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def raw_multi(index):
|
| 10 |
+
return {
|
| 11 |
+
"fail_reason": None,
|
| 12 |
+
"handedness": "Right",
|
| 13 |
+
"overall_best_size": 8,
|
| 14 |
+
"overall_range_min": 7,
|
| 15 |
+
"overall_range_max": 9,
|
| 16 |
+
"fingers_measured": 3,
|
| 17 |
+
"fingers_succeeded": 3,
|
| 18 |
+
"per_finger": {
|
| 19 |
+
"index": {
|
| 20 |
+
"status": "ok", "diameter_cm": index, "confidence": 0.61,
|
| 21 |
+
"best_match": 8, "range": [7, 8], "fail_reason": None,
|
| 22 |
+
},
|
| 23 |
+
"middle": {
|
| 24 |
+
"status": "ok", "diameter_cm": 1.90, "confidence": 0.62,
|
| 25 |
+
"best_match": 9, "range": [8, 9], "fail_reason": None,
|
| 26 |
+
},
|
| 27 |
+
"ring": {
|
| 28 |
+
"status": "ok", "diameter_cm": 1.70, "confidence": 0.60,
|
| 29 |
+
"best_match": 7, "range": [6, 7], "fail_reason": None,
|
| 30 |
+
},
|
| 31 |
+
},
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class WebSessionApiTests(unittest.TestCase):
|
| 36 |
+
def setUp(self):
|
| 37 |
+
app.config.update(TESTING=True)
|
| 38 |
+
self.client = app.test_client()
|
| 39 |
+
self.session_id = str(uuid.uuid4())
|
| 40 |
+
|
| 41 |
+
def post_default(self, state=None):
|
| 42 |
+
data = {
|
| 43 |
+
"kol_email": "user@example.com",
|
| 44 |
+
"ring_model": "gen",
|
| 45 |
+
"mode": "multi",
|
| 46 |
+
"session_id": self.session_id,
|
| 47 |
+
}
|
| 48 |
+
if state is not None:
|
| 49 |
+
data["session_state"] = json.dumps(state)
|
| 50 |
+
return self.client.post("/api/measure-default", data=data)
|
| 51 |
+
|
| 52 |
+
@patch("web_demo.app._save_json")
|
| 53 |
+
@patch("web_demo.app._persist_measurement_async")
|
| 54 |
+
@patch("web_demo.app.image_sha256", side_effect=["a" * 64, "b" * 64])
|
| 55 |
+
@patch("web_demo.app.measure_multi_finger")
|
| 56 |
+
def test_two_shots_return_median_and_persist_raw_plus_snapshot(
|
| 57 |
+
self, measure_mock, _hash_mock, persist_mock, _save_mock
|
| 58 |
+
):
|
| 59 |
+
measure_mock.side_effect = [raw_multi(1.70), raw_multi(1.90)]
|
| 60 |
+
|
| 61 |
+
first_response = self.post_default()
|
| 62 |
+
self.assertEqual(first_response.status_code, 200)
|
| 63 |
+
first = first_response.get_json()
|
| 64 |
+
self.assertEqual(
|
| 65 |
+
first["session_recommendation"]["per_finger"]["index"]["diameter_cm"],
|
| 66 |
+
1.70,
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
second_response = self.post_default(first["session_state"])
|
| 70 |
+
self.assertEqual(second_response.status_code, 200)
|
| 71 |
+
second = second_response.get_json()
|
| 72 |
+
self.assertEqual(second["result"]["per_finger"]["index"]["diameter_cm"], 1.90)
|
| 73 |
+
self.assertEqual(
|
| 74 |
+
second["session_recommendation"]["per_finger"]["index"]["diameter_cm"],
|
| 75 |
+
1.80,
|
| 76 |
+
)
|
| 77 |
+
self.assertEqual(
|
| 78 |
+
second["session_recommendation"]["per_finger"]["index"]["sample_count"],
|
| 79 |
+
2,
|
| 80 |
+
)
|
| 81 |
+
self.assertEqual(
|
| 82 |
+
second["session_recommendation"]["per_finger"]["index"][
|
| 83 |
+
"decision_diameter_mm"
|
| 84 |
+
],
|
| 85 |
+
18.0,
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
second_record = persist_mock.call_args_list[1].kwargs["record"]
|
| 89 |
+
self.assertEqual(second_record["per_finger"]["index"]["diameter_cm"], 1.90)
|
| 90 |
+
self.assertEqual(
|
| 91 |
+
second_record["session_recommendation"]["per_finger"]["index"]["diameter_cm"],
|
| 92 |
+
1.80,
|
| 93 |
+
)
|
| 94 |
+
self.assertEqual(
|
| 95 |
+
second_record["session_recommendation"]["per_finger"]["index"][
|
| 96 |
+
"decision_diameter_mm"
|
| 97 |
+
],
|
| 98 |
+
18.0,
|
| 99 |
+
)
|
| 100 |
+
self.assertEqual(second_record["session_attempt_index"], 2)
|
| 101 |
+
|
| 102 |
+
@patch("web_demo.app._save_json")
|
| 103 |
+
@patch("web_demo.app._persist_measurement_async")
|
| 104 |
+
@patch("web_demo.app.measure_multi_finger", return_value=raw_multi(1.80))
|
| 105 |
+
def test_old_client_without_session_fields_keeps_single_shot_contract(
|
| 106 |
+
self, _measure_mock, _persist_mock, _save_mock
|
| 107 |
+
):
|
| 108 |
+
response = self.client.post(
|
| 109 |
+
"/api/measure-default",
|
| 110 |
+
data={"kol_email": "user@example.com", "ring_model": "gen", "mode": "multi"},
|
| 111 |
+
)
|
| 112 |
+
self.assertEqual(response.status_code, 200)
|
| 113 |
+
body = response.get_json()
|
| 114 |
+
self.assertIsNone(body["session_state"])
|
| 115 |
+
self.assertIsNone(body["session_recommendation"])
|
| 116 |
+
self.assertEqual(body["result"]["per_finger"]["index"]["diameter_cm"], 1.80)
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
if __name__ == "__main__":
|
| 120 |
+
unittest.main()
|
web_demo/app.py
CHANGED
|
@@ -33,6 +33,11 @@ from measure_finger import measure_finger, measure_multi_finger, apply_calibrati
|
|
| 33 |
from src.logging_config import configure_logging
|
| 34 |
from src.ring_size import recommend_ring_size, RING_MODELS, VALID_RING_MODELS, DEFAULT_RING_MODEL
|
| 35 |
from src.ai_recommendation import ai_explain_recommendation
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
from web_demo.supabase_client import (
|
| 37 |
upload_file,
|
| 38 |
upload_bytes,
|
|
@@ -90,6 +95,7 @@ _persist_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="supa-p
|
|
| 90 |
PHOTO_MAX_SIDE = 2048
|
| 91 |
RESULT_MAX_SIDE = 1024
|
| 92 |
STORE_JPEG_QUALITY = 80
|
|
|
|
| 93 |
|
| 94 |
|
| 95 |
def _shrink_jpeg(image_path: Path, max_side: int) -> Optional[bytes]:
|
|
@@ -248,6 +254,25 @@ def _read_form_settings() -> Dict[str, Any]:
|
|
| 248 |
except json.JSONDecodeError:
|
| 249 |
logger.warning("dropping malformed gate_telemetry payload")
|
| 250 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 251 |
return {
|
| 252 |
"finger_index": request.form.get("finger_index", "index"),
|
| 253 |
"mode": request.form.get("mode", "single"),
|
|
@@ -258,9 +283,44 @@ def _read_form_settings() -> Dict[str, Any]:
|
|
| 258 |
"ring_model": ring_model,
|
| 259 |
"capture_method": capture_method,
|
| 260 |
"gate_telemetry": gate_telemetry,
|
|
|
|
|
|
|
| 261 |
}
|
| 262 |
|
| 263 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
# Coarse UA sniff — only used to decide which surface (`index.html` vs
|
| 265 |
# `mobile.html`) to render at `/`. We deliberately use a simple regex
|
| 266 |
# rather than a UA-parsing library: false negatives just mean a phone
|
|
@@ -338,6 +398,7 @@ def api_measure():
|
|
| 338 |
upload_path = UPLOAD_DIR / upload_name
|
| 339 |
upload_path.parent.mkdir(parents=True, exist_ok=True)
|
| 340 |
file.save(upload_path)
|
|
|
|
| 341 |
|
| 342 |
image = cv2.imread(str(upload_path))
|
| 343 |
if image is None:
|
|
@@ -354,6 +415,9 @@ def api_measure():
|
|
| 354 |
upload_name=upload_name,
|
| 355 |
base_name=base_name,
|
| 356 |
run_id=run_id,
|
|
|
|
|
|
|
|
|
|
| 357 |
)
|
| 358 |
if settings["mode"] == "multi":
|
| 359 |
return _run_multi_measurement(**common_kwargs)
|
|
@@ -371,6 +435,7 @@ def api_measure_default():
|
|
| 371 |
|
| 372 |
settings = _read_form_settings()
|
| 373 |
base_name, run_id = _make_base_name(settings["kol_email"] or "sample")
|
|
|
|
| 374 |
|
| 375 |
common_kwargs = dict(
|
| 376 |
image=image,
|
|
@@ -381,6 +446,9 @@ def api_measure_default():
|
|
| 381 |
gate_telemetry=settings["gate_telemetry"],
|
| 382 |
base_name=base_name,
|
| 383 |
run_id=run_id,
|
|
|
|
|
|
|
|
|
|
| 384 |
)
|
| 385 |
if settings["mode"] == "multi":
|
| 386 |
return _run_multi_measurement(**common_kwargs)
|
|
@@ -399,6 +467,9 @@ def _run_measurement(
|
|
| 399 |
upload_name: str = "",
|
| 400 |
base_name: str = "",
|
| 401 |
run_id: str = "",
|
|
|
|
|
|
|
|
|
|
| 402 |
):
|
| 403 |
result_png_name = f"{base_name}_result.png"
|
| 404 |
result_png_path = RESULTS_DIR / result_png_name
|
|
@@ -429,6 +500,17 @@ def _run_measurement(
|
|
| 429 |
result["gate_telemetry"] = gate_telemetry
|
| 430 |
result = _numpy_safe(result)
|
| 431 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 432 |
result_json_name = f"{base_name}_result.json"
|
| 433 |
result_json_path = RESULTS_DIR / result_json_name
|
| 434 |
_save_json(result_json_path, result)
|
|
@@ -440,6 +522,8 @@ def _run_measurement(
|
|
| 440 |
"input_image_url": input_image_url,
|
| 441 |
"result_json_url": f"/results/{result_json_name}",
|
| 442 |
"run_id": run_id,
|
|
|
|
|
|
|
| 443 |
}
|
| 444 |
|
| 445 |
# Persist to Supabase in the background — do not block the response on
|
|
@@ -465,6 +549,13 @@ def _run_measurement(
|
|
| 465 |
"overall_range_max": ring_size.get("range_max"),
|
| 466 |
"result_json": result,
|
| 467 |
"fail_reason": result.get("fail_reason"),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 468 |
},
|
| 469 |
)
|
| 470 |
|
|
@@ -482,6 +573,9 @@ def _run_multi_measurement(
|
|
| 482 |
upload_name: str = "",
|
| 483 |
base_name: str = "",
|
| 484 |
run_id: str = "",
|
|
|
|
|
|
|
|
|
|
| 485 |
):
|
| 486 |
"""Run multi-finger measurement pipeline."""
|
| 487 |
result_png_name = f"{base_name}_result.png"
|
|
@@ -502,11 +596,23 @@ def _run_multi_measurement(
|
|
| 502 |
result["gate_telemetry"] = gate_telemetry
|
| 503 |
result = _numpy_safe(result)
|
| 504 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 505 |
# Collect finger widths for AI recommendation
|
| 506 |
-
|
|
|
|
|
|
|
| 507 |
finger_widths = {}
|
| 508 |
for fn in ("index", "middle", "ring"):
|
| 509 |
-
pf =
|
| 510 |
if pf.get("status") == "ok" and pf.get("diameter_cm") is not None:
|
| 511 |
finger_widths[fn] = pf["diameter_cm"]
|
| 512 |
else:
|
|
@@ -514,12 +620,12 @@ def _run_multi_measurement(
|
|
| 514 |
|
| 515 |
ai_reason = None
|
| 516 |
ai_explain = request.form.get("ai_explain", "0") == "1"
|
| 517 |
-
if ai_explain and
|
| 518 |
ai_reason = ai_explain_recommendation(
|
| 519 |
finger_widths,
|
| 520 |
-
recommended_size=
|
| 521 |
-
range_min=
|
| 522 |
-
range_max=
|
| 523 |
ring_model=ring_model,
|
| 524 |
)
|
| 525 |
if ai_reason:
|
|
@@ -531,7 +637,7 @@ def _run_multi_measurement(
|
|
| 531 |
|
| 532 |
# Compute overall confidence from per-finger data
|
| 533 |
confidences = [
|
| 534 |
-
pf.get("confidence") for pf in
|
| 535 |
if pf.get("status") == "ok" and pf.get("confidence") is not None
|
| 536 |
]
|
| 537 |
overall_confidence = min(confidences) if confidences else None
|
|
@@ -544,6 +650,8 @@ def _run_multi_measurement(
|
|
| 544 |
"input_image_url": input_image_url,
|
| 545 |
"result_json_url": f"/results/{result_json_name}",
|
| 546 |
"run_id": run_id,
|
|
|
|
|
|
|
| 547 |
}
|
| 548 |
|
| 549 |
# Persist to Supabase in the background.
|
|
@@ -561,10 +669,17 @@ def _run_multi_measurement(
|
|
| 561 |
"overall_best_size": result.get("overall_best_size"),
|
| 562 |
"overall_range_min": result.get("overall_range_min"),
|
| 563 |
"overall_range_max": result.get("overall_range_max"),
|
| 564 |
-
"per_finger":
|
| 565 |
"confidence": overall_confidence,
|
| 566 |
"result_json": result,
|
| 567 |
"fail_reason": result.get("fail_reason"),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 568 |
},
|
| 569 |
)
|
| 570 |
|
|
@@ -1071,10 +1186,20 @@ def api_admin_export_csv():
|
|
| 1071 |
output = io.StringIO()
|
| 1072 |
fieldnames = [
|
| 1073 |
"kol_email", "kol_name", "created_at", "mode", "ring_model",
|
|
|
|
| 1074 |
"overall_best_size", "overall_range_min", "overall_range_max",
|
| 1075 |
"index_size", "index_diameter", "index_confidence",
|
| 1076 |
"middle_size", "middle_diameter", "middle_confidence",
|
| 1077 |
"ring_size", "ring_diameter", "ring_confidence",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1078 |
"confidence", "fail_reason", "ai_explanation",
|
| 1079 |
"ring_fit", "gt_best_finger", "gt_index_size", "gt_middle_size", "gt_ring_size", "gt_notes",
|
| 1080 |
"feedback_rating", "feedback_message",
|
|
@@ -1089,6 +1214,8 @@ def api_admin_export_csv():
|
|
| 1089 |
"created_at": row.get("created_at", ""),
|
| 1090 |
"mode": row.get("mode", ""),
|
| 1091 |
"ring_model": row.get("ring_model", ""),
|
|
|
|
|
|
|
| 1092 |
"overall_best_size": row.get("overall_best_size", ""),
|
| 1093 |
"overall_range_min": row.get("overall_range_min", ""),
|
| 1094 |
"overall_range_max": row.get("overall_range_max", ""),
|
|
@@ -1108,11 +1235,23 @@ def api_admin_export_csv():
|
|
| 1108 |
"id": row.get("id", ""),
|
| 1109 |
}
|
| 1110 |
pf = row.get("per_finger") or {}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1111 |
for finger in ("index", "middle", "ring"):
|
| 1112 |
fd = pf.get(finger, {})
|
| 1113 |
flat[f"{finger}_size"] = fd.get("best_match", "")
|
| 1114 |
flat[f"{finger}_diameter"] = fd.get("diameter_cm", "")
|
| 1115 |
flat[f"{finger}_confidence"] = fd.get("confidence", "")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1116 |
writer.writerow(flat)
|
| 1117 |
|
| 1118 |
return Response(
|
|
|
|
| 33 |
from src.logging_config import configure_logging
|
| 34 |
from src.ring_size import recommend_ring_size, RING_MODELS, VALID_RING_MODELS, DEFAULT_RING_MODEL
|
| 35 |
from src.ai_recommendation import ai_explain_recommendation
|
| 36 |
+
from src.session_recommendation import (
|
| 37 |
+
image_sha256,
|
| 38 |
+
normalize_session_id,
|
| 39 |
+
update_session_recommendation,
|
| 40 |
+
)
|
| 41 |
from web_demo.supabase_client import (
|
| 42 |
upload_file,
|
| 43 |
upload_bytes,
|
|
|
|
| 95 |
PHOTO_MAX_SIDE = 2048
|
| 96 |
RESULT_MAX_SIDE = 1024
|
| 97 |
STORE_JPEG_QUALITY = 80
|
| 98 |
+
SESSION_STATE_MAX_BYTES = 50_000
|
| 99 |
|
| 100 |
|
| 101 |
def _shrink_jpeg(image_path: Path, max_side: int) -> Optional[bytes]:
|
|
|
|
| 254 |
except json.JSONDecodeError:
|
| 255 |
logger.warning("dropping malformed gate_telemetry payload")
|
| 256 |
|
| 257 |
+
# Session state is returned by this server and carried by the browser to
|
| 258 |
+
# the next measurement. It is still untrusted input: cap its size here and
|
| 259 |
+
# let src/session_recommendation.py validate every nested field.
|
| 260 |
+
session_state: Optional[Dict[str, Any]] = None
|
| 261 |
+
raw_session_state = request.form.get("session_state")
|
| 262 |
+
if raw_session_state:
|
| 263 |
+
if len(raw_session_state) > SESSION_STATE_MAX_BYTES:
|
| 264 |
+
logger.warning(
|
| 265 |
+
"dropping oversized session_state payload (%d bytes)",
|
| 266 |
+
len(raw_session_state),
|
| 267 |
+
)
|
| 268 |
+
else:
|
| 269 |
+
try:
|
| 270 |
+
parsed_state = json.loads(raw_session_state)
|
| 271 |
+
if isinstance(parsed_state, dict):
|
| 272 |
+
session_state = parsed_state
|
| 273 |
+
except json.JSONDecodeError:
|
| 274 |
+
logger.warning("dropping malformed session_state payload")
|
| 275 |
+
|
| 276 |
return {
|
| 277 |
"finger_index": request.form.get("finger_index", "index"),
|
| 278 |
"mode": request.form.get("mode", "single"),
|
|
|
|
| 283 |
"ring_model": ring_model,
|
| 284 |
"capture_method": capture_method,
|
| 285 |
"gate_telemetry": gate_telemetry,
|
| 286 |
+
"session_id": normalize_session_id(request.form.get("session_id")),
|
| 287 |
+
"session_state": session_state,
|
| 288 |
}
|
| 289 |
|
| 290 |
|
| 291 |
+
def _apply_session_recommendation(
|
| 292 |
+
result: Dict[str, Any],
|
| 293 |
+
*,
|
| 294 |
+
session_id: Optional[str],
|
| 295 |
+
previous_state: Optional[Dict[str, Any]],
|
| 296 |
+
ring_model: str,
|
| 297 |
+
run_id: str,
|
| 298 |
+
image_digest: str,
|
| 299 |
+
mode: str,
|
| 300 |
+
finger_index: str = "index",
|
| 301 |
+
) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
|
| 302 |
+
"""Attach the median recommendation without changing raw shot fields."""
|
| 303 |
+
if session_id is None:
|
| 304 |
+
return None, None
|
| 305 |
+
try:
|
| 306 |
+
state, recommendation = update_session_recommendation(
|
| 307 |
+
previous_state,
|
| 308 |
+
session_id=session_id,
|
| 309 |
+
ring_model=ring_model,
|
| 310 |
+
run_id=run_id,
|
| 311 |
+
image_digest=image_digest,
|
| 312 |
+
result=result,
|
| 313 |
+
mode=mode,
|
| 314 |
+
finger_index=finger_index,
|
| 315 |
+
)
|
| 316 |
+
except (TypeError, ValueError) as exc:
|
| 317 |
+
logger.warning("session aggregation skipped for run %s: %s", run_id, exc)
|
| 318 |
+
return None, None
|
| 319 |
+
if recommendation is not None:
|
| 320 |
+
result["session_recommendation"] = recommendation
|
| 321 |
+
return state, recommendation
|
| 322 |
+
|
| 323 |
+
|
| 324 |
# Coarse UA sniff — only used to decide which surface (`index.html` vs
|
| 325 |
# `mobile.html`) to render at `/`. We deliberately use a simple regex
|
| 326 |
# rather than a UA-parsing library: false negatives just mean a phone
|
|
|
|
| 398 |
upload_path = UPLOAD_DIR / upload_name
|
| 399 |
upload_path.parent.mkdir(parents=True, exist_ok=True)
|
| 400 |
file.save(upload_path)
|
| 401 |
+
image_digest = image_sha256(upload_path.read_bytes())
|
| 402 |
|
| 403 |
image = cv2.imread(str(upload_path))
|
| 404 |
if image is None:
|
|
|
|
| 415 |
upload_name=upload_name,
|
| 416 |
base_name=base_name,
|
| 417 |
run_id=run_id,
|
| 418 |
+
image_digest=image_digest,
|
| 419 |
+
session_id=settings["session_id"],
|
| 420 |
+
session_state=settings["session_state"],
|
| 421 |
)
|
| 422 |
if settings["mode"] == "multi":
|
| 423 |
return _run_multi_measurement(**common_kwargs)
|
|
|
|
| 435 |
|
| 436 |
settings = _read_form_settings()
|
| 437 |
base_name, run_id = _make_base_name(settings["kol_email"] or "sample")
|
| 438 |
+
image_digest = image_sha256(DEFAULT_SAMPLE_PATH.read_bytes())
|
| 439 |
|
| 440 |
common_kwargs = dict(
|
| 441 |
image=image,
|
|
|
|
| 446 |
gate_telemetry=settings["gate_telemetry"],
|
| 447 |
base_name=base_name,
|
| 448 |
run_id=run_id,
|
| 449 |
+
image_digest=image_digest,
|
| 450 |
+
session_id=settings["session_id"],
|
| 451 |
+
session_state=settings["session_state"],
|
| 452 |
)
|
| 453 |
if settings["mode"] == "multi":
|
| 454 |
return _run_multi_measurement(**common_kwargs)
|
|
|
|
| 467 |
upload_name: str = "",
|
| 468 |
base_name: str = "",
|
| 469 |
run_id: str = "",
|
| 470 |
+
image_digest: str = "",
|
| 471 |
+
session_id: Optional[str] = None,
|
| 472 |
+
session_state: Optional[Dict[str, Any]] = None,
|
| 473 |
):
|
| 474 |
result_png_name = f"{base_name}_result.png"
|
| 475 |
result_png_path = RESULTS_DIR / result_png_name
|
|
|
|
| 500 |
result["gate_telemetry"] = gate_telemetry
|
| 501 |
result = _numpy_safe(result)
|
| 502 |
|
| 503 |
+
updated_session_state, session_recommendation = _apply_session_recommendation(
|
| 504 |
+
result,
|
| 505 |
+
session_id=session_id,
|
| 506 |
+
previous_state=session_state,
|
| 507 |
+
ring_model=ring_model,
|
| 508 |
+
run_id=run_id,
|
| 509 |
+
image_digest=image_digest,
|
| 510 |
+
mode="single",
|
| 511 |
+
finger_index=finger_index,
|
| 512 |
+
)
|
| 513 |
+
|
| 514 |
result_json_name = f"{base_name}_result.json"
|
| 515 |
result_json_path = RESULTS_DIR / result_json_name
|
| 516 |
_save_json(result_json_path, result)
|
|
|
|
| 522 |
"input_image_url": input_image_url,
|
| 523 |
"result_json_url": f"/results/{result_json_name}",
|
| 524 |
"run_id": run_id,
|
| 525 |
+
"session_state": updated_session_state,
|
| 526 |
+
"session_recommendation": session_recommendation,
|
| 527 |
}
|
| 528 |
|
| 529 |
# Persist to Supabase in the background — do not block the response on
|
|
|
|
| 549 |
"overall_range_max": ring_size.get("range_max"),
|
| 550 |
"result_json": result,
|
| 551 |
"fail_reason": result.get("fail_reason"),
|
| 552 |
+
"session_id": session_id,
|
| 553 |
+
"session_attempt_index": (
|
| 554 |
+
updated_session_state.get("attempt_count")
|
| 555 |
+
if updated_session_state else None
|
| 556 |
+
),
|
| 557 |
+
"image_sha256": image_digest,
|
| 558 |
+
"session_recommendation": session_recommendation,
|
| 559 |
},
|
| 560 |
)
|
| 561 |
|
|
|
|
| 573 |
upload_name: str = "",
|
| 574 |
base_name: str = "",
|
| 575 |
run_id: str = "",
|
| 576 |
+
image_digest: str = "",
|
| 577 |
+
session_id: Optional[str] = None,
|
| 578 |
+
session_state: Optional[Dict[str, Any]] = None,
|
| 579 |
):
|
| 580 |
"""Run multi-finger measurement pipeline."""
|
| 581 |
result_png_name = f"{base_name}_result.png"
|
|
|
|
| 596 |
result["gate_telemetry"] = gate_telemetry
|
| 597 |
result = _numpy_safe(result)
|
| 598 |
|
| 599 |
+
updated_session_state, session_recommendation = _apply_session_recommendation(
|
| 600 |
+
result,
|
| 601 |
+
session_id=session_id,
|
| 602 |
+
previous_state=session_state,
|
| 603 |
+
ring_model=ring_model,
|
| 604 |
+
run_id=run_id,
|
| 605 |
+
image_digest=image_digest,
|
| 606 |
+
mode="multi",
|
| 607 |
+
)
|
| 608 |
+
|
| 609 |
# Collect finger widths for AI recommendation
|
| 610 |
+
recommendation_result = session_recommendation or result
|
| 611 |
+
recommendation_per_finger = recommendation_result.get("per_finger", {})
|
| 612 |
+
raw_per_finger = result.get("per_finger", {})
|
| 613 |
finger_widths = {}
|
| 614 |
for fn in ("index", "middle", "ring"):
|
| 615 |
+
pf = recommendation_per_finger.get(fn, {})
|
| 616 |
if pf.get("status") == "ok" and pf.get("diameter_cm") is not None:
|
| 617 |
finger_widths[fn] = pf["diameter_cm"]
|
| 618 |
else:
|
|
|
|
| 620 |
|
| 621 |
ai_reason = None
|
| 622 |
ai_explain = request.form.get("ai_explain", "0") == "1"
|
| 623 |
+
if ai_explain and recommendation_result.get("overall_best_size") is not None:
|
| 624 |
ai_reason = ai_explain_recommendation(
|
| 625 |
finger_widths,
|
| 626 |
+
recommended_size=recommendation_result["overall_best_size"],
|
| 627 |
+
range_min=recommendation_result["overall_range_min"],
|
| 628 |
+
range_max=recommendation_result["overall_range_max"],
|
| 629 |
ring_model=ring_model,
|
| 630 |
)
|
| 631 |
if ai_reason:
|
|
|
|
| 637 |
|
| 638 |
# Compute overall confidence from per-finger data
|
| 639 |
confidences = [
|
| 640 |
+
pf.get("confidence") for pf in raw_per_finger.values()
|
| 641 |
if pf.get("status") == "ok" and pf.get("confidence") is not None
|
| 642 |
]
|
| 643 |
overall_confidence = min(confidences) if confidences else None
|
|
|
|
| 650 |
"input_image_url": input_image_url,
|
| 651 |
"result_json_url": f"/results/{result_json_name}",
|
| 652 |
"run_id": run_id,
|
| 653 |
+
"session_state": updated_session_state,
|
| 654 |
+
"session_recommendation": session_recommendation,
|
| 655 |
}
|
| 656 |
|
| 657 |
# Persist to Supabase in the background.
|
|
|
|
| 669 |
"overall_best_size": result.get("overall_best_size"),
|
| 670 |
"overall_range_min": result.get("overall_range_min"),
|
| 671 |
"overall_range_max": result.get("overall_range_max"),
|
| 672 |
+
"per_finger": raw_per_finger,
|
| 673 |
"confidence": overall_confidence,
|
| 674 |
"result_json": result,
|
| 675 |
"fail_reason": result.get("fail_reason"),
|
| 676 |
+
"session_id": session_id,
|
| 677 |
+
"session_attempt_index": (
|
| 678 |
+
updated_session_state.get("attempt_count")
|
| 679 |
+
if updated_session_state else None
|
| 680 |
+
),
|
| 681 |
+
"image_sha256": image_digest,
|
| 682 |
+
"session_recommendation": session_recommendation,
|
| 683 |
},
|
| 684 |
)
|
| 685 |
|
|
|
|
| 1186 |
output = io.StringIO()
|
| 1187 |
fieldnames = [
|
| 1188 |
"kol_email", "kol_name", "created_at", "mode", "ring_model",
|
| 1189 |
+
"session_id", "session_attempt_index", "session_hand", "session_successful_shots",
|
| 1190 |
"overall_best_size", "overall_range_min", "overall_range_max",
|
| 1191 |
"index_size", "index_diameter", "index_confidence",
|
| 1192 |
"middle_size", "middle_diameter", "middle_confidence",
|
| 1193 |
"ring_size", "ring_diameter", "ring_confidence",
|
| 1194 |
+
"session_index_size", "session_index_diameter",
|
| 1195 |
+
"session_index_decision_mm", "session_index_samples",
|
| 1196 |
+
"session_index_spread_mm",
|
| 1197 |
+
"session_middle_size", "session_middle_diameter",
|
| 1198 |
+
"session_middle_decision_mm", "session_middle_samples",
|
| 1199 |
+
"session_middle_spread_mm",
|
| 1200 |
+
"session_ring_size", "session_ring_diameter",
|
| 1201 |
+
"session_ring_decision_mm", "session_ring_samples",
|
| 1202 |
+
"session_ring_spread_mm",
|
| 1203 |
"confidence", "fail_reason", "ai_explanation",
|
| 1204 |
"ring_fit", "gt_best_finger", "gt_index_size", "gt_middle_size", "gt_ring_size", "gt_notes",
|
| 1205 |
"feedback_rating", "feedback_message",
|
|
|
|
| 1214 |
"created_at": row.get("created_at", ""),
|
| 1215 |
"mode": row.get("mode", ""),
|
| 1216 |
"ring_model": row.get("ring_model", ""),
|
| 1217 |
+
"session_id": row.get("session_id", ""),
|
| 1218 |
+
"session_attempt_index": row.get("session_attempt_index", ""),
|
| 1219 |
"overall_best_size": row.get("overall_best_size", ""),
|
| 1220 |
"overall_range_min": row.get("overall_range_min", ""),
|
| 1221 |
"overall_range_max": row.get("overall_range_max", ""),
|
|
|
|
| 1235 |
"id": row.get("id", ""),
|
| 1236 |
}
|
| 1237 |
pf = row.get("per_finger") or {}
|
| 1238 |
+
session_rec = row.get("session_recommendation") or {}
|
| 1239 |
+
session_pf = session_rec.get("per_finger") or {}
|
| 1240 |
+
flat["session_hand"] = session_rec.get("handedness", "")
|
| 1241 |
+
flat["session_successful_shots"] = session_rec.get("successful_shots", "")
|
| 1242 |
for finger in ("index", "middle", "ring"):
|
| 1243 |
fd = pf.get(finger, {})
|
| 1244 |
flat[f"{finger}_size"] = fd.get("best_match", "")
|
| 1245 |
flat[f"{finger}_diameter"] = fd.get("diameter_cm", "")
|
| 1246 |
flat[f"{finger}_confidence"] = fd.get("confidence", "")
|
| 1247 |
+
session_fd = session_pf.get(finger, {})
|
| 1248 |
+
flat[f"session_{finger}_size"] = session_fd.get("best_match", "")
|
| 1249 |
+
flat[f"session_{finger}_diameter"] = session_fd.get("diameter_cm", "")
|
| 1250 |
+
flat[f"session_{finger}_decision_mm"] = session_fd.get(
|
| 1251 |
+
"decision_diameter_mm", ""
|
| 1252 |
+
)
|
| 1253 |
+
flat[f"session_{finger}_samples"] = session_fd.get("sample_count", "")
|
| 1254 |
+
flat[f"session_{finger}_spread_mm"] = session_fd.get("spread_mm", "")
|
| 1255 |
writer.writerow(flat)
|
| 1256 |
|
| 1257 |
return Response(
|
web_demo/static/app.js
CHANGED
|
@@ -189,7 +189,10 @@ const renderMultiResult = (result) => {
|
|
| 189 |
html += `</div>`;
|
| 190 |
}
|
| 191 |
html += "</div>";
|
| 192 |
-
|
|
|
|
|
|
|
|
|
|
| 193 |
html += buildSizeRefTable();
|
| 194 |
|
| 195 |
fingerBreakdown.innerHTML = html;
|
|
@@ -215,6 +218,10 @@ const renderSingleResult = (result) => {
|
|
| 215 |
const fingerSelect = form.querySelector('[name="finger_index"]');
|
| 216 |
const fingerName = fingerSelect ? fingerSelect.value : "finger";
|
| 217 |
const capitalName = fingerName.charAt(0).toUpperCase() + fingerName.slice(1);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
fingerBreakdown.innerHTML = `<div class="finger-cards">
|
| 219 |
<div class="finger-card" style="border-top: 3px solid #00dddd;">
|
| 220 |
<div class="finger-name">${capitalName}</div>
|
|
@@ -223,10 +230,10 @@ const renderSingleResult = (result) => {
|
|
| 223 |
<div class="finger-range">${rs.range_min} – ${rs.range_max}</div>
|
| 224 |
<div class="finger-width">${diamMm} mm</div>
|
| 225 |
</div>
|
| 226 |
-
</div>` + buildSizeRefTable();
|
| 227 |
};
|
| 228 |
|
| 229 |
-
const runMeasurement = async (endpoint, formData, inputUrlFallback = "") => {
|
| 230 |
const startedAt = Date.now();
|
| 231 |
const renderElapsed = () => {
|
| 232 |
const secs = Math.floor((Date.now() - startedAt) / 1000);
|
|
@@ -258,6 +265,9 @@ const runMeasurement = async (endpoint, formData, inputUrlFallback = "") => {
|
|
| 258 |
}
|
| 259 |
|
| 260 |
const data = await response.json();
|
|
|
|
|
|
|
|
|
|
| 261 |
lastRunId = data.run_id || "";
|
| 262 |
if (feedbackSection) {
|
| 263 |
if (data.success && lastRunId) {
|
|
@@ -273,10 +283,11 @@ const runMeasurement = async (endpoint, formData, inputUrlFallback = "") => {
|
|
| 273 |
showImage(inputPreview, inputFrame, data.input_image_url || inputUrlFallback);
|
| 274 |
showImage(debugPreview, debugFrame, data.result_image_url);
|
| 275 |
|
|
|
|
| 276 |
if (data.mode === "multi") {
|
| 277 |
-
renderMultiResult(
|
| 278 |
} else {
|
| 279 |
-
renderSingleResult(
|
| 280 |
}
|
| 281 |
|
| 282 |
if (data.success) {
|
|
@@ -335,13 +346,26 @@ form.addEventListener("submit", async (event) => {
|
|
| 335 |
formData.append("kol_email", settings.kol_email);
|
| 336 |
|
| 337 |
const file = imageInput.files[0];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 338 |
if (file) {
|
| 339 |
formData.append("image", file);
|
| 340 |
-
await runMeasurement("/api/measure", formData);
|
| 341 |
return;
|
| 342 |
}
|
| 343 |
|
| 344 |
-
await runMeasurement("/api/measure-default", formData, defaultSampleUrl);
|
| 345 |
});
|
| 346 |
|
| 347 |
if (defaultSampleUrl) {
|
|
|
|
| 189 |
html += `</div>`;
|
| 190 |
}
|
| 191 |
html += "</div>";
|
| 192 |
+
const evidenceText = window.SessionEvidence.text(result);
|
| 193 |
+
if (evidenceText) {
|
| 194 |
+
html += `<div class="finger-count">${evidenceText}</div>`;
|
| 195 |
+
}
|
| 196 |
html += buildSizeRefTable();
|
| 197 |
|
| 198 |
fingerBreakdown.innerHTML = html;
|
|
|
|
| 218 |
const fingerSelect = form.querySelector('[name="finger_index"]');
|
| 219 |
const fingerName = fingerSelect ? fingerSelect.value : "finger";
|
| 220 |
const capitalName = fingerName.charAt(0).toUpperCase() + fingerName.slice(1);
|
| 221 |
+
const evidenceText = window.SessionEvidence.text(result);
|
| 222 |
+
const evidence = evidenceText
|
| 223 |
+
? `<div class="finger-count">${evidenceText}</div>`
|
| 224 |
+
: "";
|
| 225 |
fingerBreakdown.innerHTML = `<div class="finger-cards">
|
| 226 |
<div class="finger-card" style="border-top: 3px solid #00dddd;">
|
| 227 |
<div class="finger-name">${capitalName}</div>
|
|
|
|
| 230 |
<div class="finger-range">${rs.range_min} – ${rs.range_max}</div>
|
| 231 |
<div class="finger-width">${diamMm} mm</div>
|
| 232 |
</div>
|
| 233 |
+
</div>${evidence}` + buildSizeRefTable();
|
| 234 |
};
|
| 235 |
|
| 236 |
+
const runMeasurement = async (endpoint, formData, inputUrlFallback = "", sessionContext = null) => {
|
| 237 |
const startedAt = Date.now();
|
| 238 |
const renderElapsed = () => {
|
| 239 |
const secs = Math.floor((Date.now() - startedAt) / 1000);
|
|
|
|
| 265 |
}
|
| 266 |
|
| 267 |
const data = await response.json();
|
| 268 |
+
if (sessionContext && window.MeasurementSession) {
|
| 269 |
+
window.MeasurementSession.accept(sessionContext, data);
|
| 270 |
+
}
|
| 271 |
lastRunId = data.run_id || "";
|
| 272 |
if (feedbackSection) {
|
| 273 |
if (data.success && lastRunId) {
|
|
|
|
| 283 |
showImage(inputPreview, inputFrame, data.input_image_url || inputUrlFallback);
|
| 284 |
showImage(debugPreview, debugFrame, data.result_image_url);
|
| 285 |
|
| 286 |
+
const recommendation = data.session_recommendation || data.result;
|
| 287 |
if (data.mode === "multi") {
|
| 288 |
+
renderMultiResult(recommendation);
|
| 289 |
} else {
|
| 290 |
+
renderSingleResult(recommendation);
|
| 291 |
}
|
| 292 |
|
| 293 |
if (data.success) {
|
|
|
|
| 346 |
formData.append("kol_email", settings.kol_email);
|
| 347 |
|
| 348 |
const file = imageInput.files[0];
|
| 349 |
+
const sessionContext = {
|
| 350 |
+
kol_email: settings.kol_email,
|
| 351 |
+
ring_model: settings.ring_model,
|
| 352 |
+
mode: settings.mode,
|
| 353 |
+
finger_index: settings.finger_index,
|
| 354 |
+
source: file ? "photo" : "sample",
|
| 355 |
+
};
|
| 356 |
+
const measurementSession = window.MeasurementSession.prepare(sessionContext);
|
| 357 |
+
formData.append("session_id", measurementSession.session_id);
|
| 358 |
+
if (measurementSession.session_state) {
|
| 359 |
+
formData.append("session_state", JSON.stringify(measurementSession.session_state));
|
| 360 |
+
}
|
| 361 |
+
|
| 362 |
if (file) {
|
| 363 |
formData.append("image", file);
|
| 364 |
+
await runMeasurement("/api/measure", formData, "", sessionContext);
|
| 365 |
return;
|
| 366 |
}
|
| 367 |
|
| 368 |
+
await runMeasurement("/api/measure-default", formData, defaultSampleUrl, sessionContext);
|
| 369 |
});
|
| 370 |
|
| 371 |
if (defaultSampleUrl) {
|
web_demo/static/mobile/steps/confirm.js
CHANGED
|
@@ -92,14 +92,25 @@ export default {
|
|
| 92 |
elapsedTimer = setInterval(renderTimer, 1000);
|
| 93 |
|
| 94 |
try {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
const result = await postMeasure({
|
| 96 |
blob: session.imageBlob,
|
| 97 |
kol_email: session.kolEmail,
|
| 98 |
ring_model: session.ringModel,
|
| 99 |
gate_telemetry: session.gateTelemetry,
|
| 100 |
capture_method: session.imageSource === "camera" ? "camera" : "upload",
|
|
|
|
|
|
|
| 101 |
});
|
| 102 |
clearTimerFn();
|
|
|
|
| 103 |
session.result = result;
|
| 104 |
nav.next();
|
| 105 |
} catch (err) {
|
|
|
|
| 92 |
elapsedTimer = setInterval(renderTimer, 1000);
|
| 93 |
|
| 94 |
try {
|
| 95 |
+
const sessionContext = {
|
| 96 |
+
kol_email: session.kolEmail,
|
| 97 |
+
ring_model: session.ringModel,
|
| 98 |
+
mode: "multi",
|
| 99 |
+
finger_index: "index",
|
| 100 |
+
source: "photo",
|
| 101 |
+
};
|
| 102 |
+
const measurementSession = window.MeasurementSession.prepare(sessionContext);
|
| 103 |
const result = await postMeasure({
|
| 104 |
blob: session.imageBlob,
|
| 105 |
kol_email: session.kolEmail,
|
| 106 |
ring_model: session.ringModel,
|
| 107 |
gate_telemetry: session.gateTelemetry,
|
| 108 |
capture_method: session.imageSource === "camera" ? "camera" : "upload",
|
| 109 |
+
session_id: measurementSession.session_id,
|
| 110 |
+
session_state: measurementSession.session_state,
|
| 111 |
});
|
| 112 |
clearTimerFn();
|
| 113 |
+
window.MeasurementSession.accept(sessionContext, result);
|
| 114 |
session.result = result;
|
| 115 |
nav.next();
|
| 116 |
} catch (err) {
|
web_demo/static/mobile/steps/result.js
CHANGED
|
@@ -91,14 +91,13 @@ function renderRecommendation(payload, ringModel) {
|
|
| 91 |
if (!pf) continue;
|
| 92 |
cards += fingerCardHtml(fn, pf);
|
| 93 |
}
|
| 94 |
-
const
|
| 95 |
-
|
| 96 |
-
//
|
| 97 |
-
//
|
| 98 |
-
// a per-finger breakdown without a leading label.
|
| 99 |
return `
|
| 100 |
${cards ? `<div class="finger-cards">${cards}</div>` : ""}
|
| 101 |
-
<div class="finger-count">${escape(
|
| 102 |
${buildSizeRefTable(ringModel)}
|
| 103 |
`;
|
| 104 |
}
|
|
@@ -112,6 +111,7 @@ export default {
|
|
| 112 |
return;
|
| 113 |
}
|
| 114 |
const payload = data.result || data;
|
|
|
|
| 115 |
const overlayUrl = data.result_image_url;
|
| 116 |
const ringModel = session.ringModel || "gen";
|
| 117 |
|
|
@@ -174,13 +174,13 @@ export default {
|
|
| 174 |
<div class="step-body">
|
| 175 |
${statusBanner}
|
| 176 |
<div class="panel">
|
| 177 |
-
<h2 class="panel-title">
|
| 178 |
<div class="image-frame show">${overlayInner}</div>
|
| 179 |
</div>
|
| 180 |
|
| 181 |
<div class="panel">
|
| 182 |
<h2 class="panel-title">Ring Size Recommendation</h2>
|
| 183 |
-
${renderRecommendation(
|
| 184 |
</div>
|
| 185 |
|
| 186 |
${feedbackPanel}
|
|
|
|
| 91 |
if (!pf) continue;
|
| 92 |
cards += fingerCardHtml(fn, pf);
|
| 93 |
}
|
| 94 |
+
const evidenceText = window.SessionEvidence.text(payload);
|
| 95 |
+
// Session evidence replaces the old "N/N fingers measured" footer. Failed
|
| 96 |
+
// fingers remain explicit in their own cards, while this line explains the
|
| 97 |
+
// strength of the recommendation and the target number of repeat captures.
|
|
|
|
| 98 |
return `
|
| 99 |
${cards ? `<div class="finger-cards">${cards}</div>` : ""}
|
| 100 |
+
${evidenceText ? `<div class="finger-count">${escape(evidenceText)}</div>` : ""}
|
| 101 |
${buildSizeRefTable(ringModel)}
|
| 102 |
`;
|
| 103 |
}
|
|
|
|
| 111 |
return;
|
| 112 |
}
|
| 113 |
const payload = data.result || data;
|
| 114 |
+
const recommendation = data.session_recommendation || payload;
|
| 115 |
const overlayUrl = data.result_image_url;
|
| 116 |
const ringModel = session.ringModel || "gen";
|
| 117 |
|
|
|
|
| 174 |
<div class="step-body">
|
| 175 |
${statusBanner}
|
| 176 |
<div class="panel">
|
| 177 |
+
<h2 class="panel-title">Current Photo Overlay</h2>
|
| 178 |
<div class="image-frame show">${overlayInner}</div>
|
| 179 |
</div>
|
| 180 |
|
| 181 |
<div class="panel">
|
| 182 |
<h2 class="panel-title">Ring Size Recommendation</h2>
|
| 183 |
+
${renderRecommendation(recommendation, ringModel)}
|
| 184 |
</div>
|
| 185 |
|
| 186 |
${feedbackPanel}
|
web_demo/static/shared/measure-api.js
CHANGED
|
@@ -22,6 +22,8 @@ export async function postMeasure({
|
|
| 22 |
blob,
|
| 23 |
kol_email,
|
| 24 |
gate_telemetry = null,
|
|
|
|
|
|
|
| 25 |
...overrides
|
| 26 |
} = {}) {
|
| 27 |
if (!blob) throw new Error("postMeasure: blob is required");
|
|
@@ -37,6 +39,12 @@ export async function postMeasure({
|
|
| 37 |
formData.append("ring_model", settings.ring_model);
|
| 38 |
formData.append("ai_explain", settings.ai_explain);
|
| 39 |
formData.append("capture_method", settings.capture_method);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
if (gate_telemetry) {
|
| 41 |
formData.append("gate_telemetry", JSON.stringify(gate_telemetry));
|
| 42 |
}
|
|
|
|
| 22 |
blob,
|
| 23 |
kol_email,
|
| 24 |
gate_telemetry = null,
|
| 25 |
+
session_id = null,
|
| 26 |
+
session_state = null,
|
| 27 |
...overrides
|
| 28 |
} = {}) {
|
| 29 |
if (!blob) throw new Error("postMeasure: blob is required");
|
|
|
|
| 39 |
formData.append("ring_model", settings.ring_model);
|
| 40 |
formData.append("ai_explain", settings.ai_explain);
|
| 41 |
formData.append("capture_method", settings.capture_method);
|
| 42 |
+
if (session_id) {
|
| 43 |
+
formData.append("session_id", session_id);
|
| 44 |
+
}
|
| 45 |
+
if (session_state) {
|
| 46 |
+
formData.append("session_state", JSON.stringify(session_state));
|
| 47 |
+
}
|
| 48 |
if (gate_telemetry) {
|
| 49 |
formData.append("gate_telemetry", JSON.stringify(gate_telemetry));
|
| 50 |
}
|
web_demo/static/shared/measurement-session.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Browser transport for v10 multi-shot measurement sessions.
|
| 2 |
+
//
|
| 3 |
+
// Python owns all measurement aggregation and ring-size decisions. This
|
| 4 |
+
// helper only keeps the last server-returned state in sessionStorage and sends
|
| 5 |
+
// it back with the next photo. State is per-tab, survives a refresh, and is
|
| 6 |
+
// rotated when the measurement context changes or sits idle for 30 minutes.
|
| 7 |
+
|
| 8 |
+
(function attachMeasurementSession(global) {
|
| 9 |
+
"use strict";
|
| 10 |
+
|
| 11 |
+
const STORAGE_KEY = "ring-sizer-measurement-session-v1";
|
| 12 |
+
const IDLE_TIMEOUT_MS = 30 * 60 * 1000;
|
| 13 |
+
let memoryRecord = null;
|
| 14 |
+
|
| 15 |
+
function newId() {
|
| 16 |
+
if (global.crypto && typeof global.crypto.randomUUID === "function") {
|
| 17 |
+
return global.crypto.randomUUID();
|
| 18 |
+
}
|
| 19 |
+
// RFC 4122 v4 fallback for older embedded browsers.
|
| 20 |
+
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
| 21 |
+
const r = Math.floor(Math.random() * 16);
|
| 22 |
+
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
| 23 |
+
return v.toString(16);
|
| 24 |
+
});
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
function contextKey(context) {
|
| 28 |
+
const normalized = {
|
| 29 |
+
kol_email: String(context && context.kol_email || "").trim().toLowerCase(),
|
| 30 |
+
ring_model: String(context && context.ring_model || "gen"),
|
| 31 |
+
mode: String(context && context.mode || "multi"),
|
| 32 |
+
finger_index: String(context && context.finger_index || "index"),
|
| 33 |
+
source: String(context && context.source || "photo"),
|
| 34 |
+
};
|
| 35 |
+
return JSON.stringify(normalized);
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
function load() {
|
| 39 |
+
try {
|
| 40 |
+
const raw = global.sessionStorage.getItem(STORAGE_KEY);
|
| 41 |
+
return raw ? JSON.parse(raw) : null;
|
| 42 |
+
} catch (_err) {
|
| 43 |
+
return memoryRecord;
|
| 44 |
+
}
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
function save(record) {
|
| 48 |
+
memoryRecord = record;
|
| 49 |
+
try {
|
| 50 |
+
global.sessionStorage.setItem(STORAGE_KEY, JSON.stringify(record));
|
| 51 |
+
} catch (_err) {
|
| 52 |
+
// Private browsing / embedded webviews may reject storage. The in-memory
|
| 53 |
+
// record still preserves the session for this page lifetime.
|
| 54 |
+
}
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
function prepare(context) {
|
| 58 |
+
const key = contextKey(context);
|
| 59 |
+
const now = Date.now();
|
| 60 |
+
let record = load();
|
| 61 |
+
const expired = !record || !Number.isFinite(record.updated_at)
|
| 62 |
+
|| now - record.updated_at > IDLE_TIMEOUT_MS;
|
| 63 |
+
if (expired || record.context_key !== key || !record.session_id) {
|
| 64 |
+
record = {
|
| 65 |
+
context_key: key,
|
| 66 |
+
session_id: newId(),
|
| 67 |
+
session_state: null,
|
| 68 |
+
updated_at: now,
|
| 69 |
+
};
|
| 70 |
+
save(record);
|
| 71 |
+
}
|
| 72 |
+
return {
|
| 73 |
+
session_id: record.session_id,
|
| 74 |
+
session_state: record.session_state,
|
| 75 |
+
};
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
function accept(context, response) {
|
| 79 |
+
const prepared = prepare(context);
|
| 80 |
+
const returnedState = response && response.session_state;
|
| 81 |
+
if (!returnedState || returnedState.session_id !== prepared.session_id) {
|
| 82 |
+
return;
|
| 83 |
+
}
|
| 84 |
+
save({
|
| 85 |
+
context_key: contextKey(context),
|
| 86 |
+
session_id: prepared.session_id,
|
| 87 |
+
session_state: returnedState,
|
| 88 |
+
updated_at: Date.now(),
|
| 89 |
+
});
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
global.MeasurementSession = { prepare, accept };
|
| 93 |
+
})(window);
|
| 94 |
+
|
web_demo/static/shared/session-evidence.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Shared result-footer copy for multi-shot recommendations.
|
| 2 |
+
// Kept separate from the renderers so desktop and mobile always make the
|
| 3 |
+
// same claim about how many measurements support the recommendation.
|
| 4 |
+
|
| 5 |
+
(function attachSessionEvidence(global) {
|
| 6 |
+
"use strict";
|
| 7 |
+
|
| 8 |
+
function measurementCount(payload) {
|
| 9 |
+
const successfulShots = Number(payload && payload.successful_shots);
|
| 10 |
+
if (Number.isInteger(successfulShots) && successfulShots > 0) {
|
| 11 |
+
return successfulShots;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
const perFinger = payload && payload.per_finger;
|
| 15 |
+
if (!perFinger || typeof perFinger !== "object") return 0;
|
| 16 |
+
const counts = Object.values(perFinger)
|
| 17 |
+
.map((finger) => Number(finger && finger.sample_count))
|
| 18 |
+
.filter((count) => Number.isInteger(count) && count > 0);
|
| 19 |
+
return counts.length ? Math.max(...counts) : 0;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
function text(payload) {
|
| 23 |
+
const count = measurementCount(payload);
|
| 24 |
+
if (!count) return "";
|
| 25 |
+
const noun = count === 1 ? "measurement" : "measurements";
|
| 26 |
+
return `Based on ${count} ${noun}. (Please measure at least 3 times for best reliability)`;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
global.SessionEvidence = { measurementCount, text };
|
| 30 |
+
})(window);
|
| 31 |
+
|
web_demo/templates/index.html
CHANGED
|
@@ -102,7 +102,7 @@
|
|
| 102 |
</div>
|
| 103 |
|
| 104 |
<div class="panel">
|
| 105 |
-
<h2>
|
| 106 |
<div class="image-frame" id="debugFrame">
|
| 107 |
<img id="debugPreview" alt="" />
|
| 108 |
<p class="placeholder">Waiting for result</p>
|
|
@@ -165,6 +165,8 @@
|
|
| 165 |
</footer>
|
| 166 |
|
| 167 |
<script>window.DEFAULT_SAMPLE_URL = "{{ default_sample_url }}";</script>
|
|
|
|
|
|
|
| 168 |
<script src="/static/app.js"></script>
|
| 169 |
</body>
|
| 170 |
</html>
|
|
|
|
| 102 |
</div>
|
| 103 |
|
| 104 |
<div class="panel">
|
| 105 |
+
<h2>Current Photo Overlay</h2>
|
| 106 |
<div class="image-frame" id="debugFrame">
|
| 107 |
<img id="debugPreview" alt="" />
|
| 108 |
<p class="placeholder">Waiting for result</p>
|
|
|
|
| 165 |
</footer>
|
| 166 |
|
| 167 |
<script>window.DEFAULT_SAMPLE_URL = "{{ default_sample_url }}";</script>
|
| 168 |
+
<script src="/static/shared/measurement-session.js"></script>
|
| 169 |
+
<script src="/static/shared/session-evidence.js"></script>
|
| 170 |
<script src="/static/app.js"></script>
|
| 171 |
</body>
|
| 172 |
</html>
|
web_demo/templates/mobile.html
CHANGED
|
@@ -30,6 +30,8 @@
|
|
| 30 |
<script src="/static/preview/thresholds.js"></script>
|
| 31 |
<script src="/static/preview/preview.js"></script>
|
| 32 |
<script src="/static/preview/orientation.js"></script>
|
|
|
|
|
|
|
| 33 |
<script type="module" src="/static/preview/hands.js"></script>
|
| 34 |
|
| 35 |
<script type="module" src="/static/mobile/mobile.js"></script>
|
|
|
|
| 30 |
<script src="/static/preview/thresholds.js"></script>
|
| 31 |
<script src="/static/preview/preview.js"></script>
|
| 32 |
<script src="/static/preview/orientation.js"></script>
|
| 33 |
+
<script src="/static/shared/measurement-session.js"></script>
|
| 34 |
+
<script src="/static/shared/session-evidence.js"></script>
|
| 35 |
<script type="module" src="/static/preview/hands.js"></script>
|
| 36 |
|
| 37 |
<script type="module" src="/static/mobile/mobile.js"></script>
|