feng-x commited on
Commit
9f6a8e2
·
verified ·
1 Parent(s): eecdb88

Upload folder using huggingface_hub

Browse files
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. Assume this is a continuation of an existing project.
27
- 7. Summarize your understanding of the current state and propose the next concrete step without writing code yet.
 
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. Assume this is a continuation of an existing project.
27
- 7. Summarize your understanding of the current state and propose the next concrete step without writing code yet.
 
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
- return create_output(
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
- source .venv/bin/activate
 
 
 
 
11
 
12
- python -c "
 
 
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(diameter_cm: float, ring_model: str = DEFAULT_RING_MODEL) -> Optional[Dict]:
 
 
 
 
 
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
- best_size, best_inner = min(sorted_sizes, key=lambda x: abs(x[1] - diameter_mm))
 
 
 
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=lambda x: abs(x[1] - diameter_mm),
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
- per_finger = result.get("per_finger", {})
 
 
507
  finger_widths = {}
508
  for fn in ("index", "middle", "ring"):
509
- pf = per_finger.get(fn, {})
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 result.get("overall_best_size") is not None:
518
  ai_reason = ai_explain_recommendation(
519
  finger_widths,
520
- recommended_size=result["overall_best_size"],
521
- range_min=result["overall_range_min"],
522
- range_max=result["overall_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 per_finger.values()
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": 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
- html += `<div class="finger-count">${result.fingers_succeeded}/${result.fingers_measured} fingers measured</div>`;
 
 
 
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(data.result);
278
  } else {
279
- renderSingleResult(data.result);
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 succeeded = (payload && payload.fingers_succeeded) ?? 0;
95
- const total = (payload && payload.fingers_measured) ?? 0;
96
- // Count below the cards so it reads as a footnote/summary on mobile;
97
- // the cards themselves are large enough to communicate that this is
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(`${succeeded}/${total} fingers measured`)}</div>
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">Result 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(payload, ringModel)}
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>Result Overlay</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>