Spaces:
Running
Running
| """Session-level median recommendations for repeated web measurements. | |
| The computer-vision pipeline continues to produce one raw result per photo. | |
| This module accumulates the successful calibrated diameters returned by those | |
| results and derives a separate recommendation from their per-finger median. | |
| It is deliberately independent of Flask and Supabase so local/offline runs use | |
| the same logic as production. | |
| """ | |
| from __future__ import annotations | |
| import hashlib | |
| import math | |
| import re | |
| import statistics | |
| import uuid | |
| from decimal import Decimal, ROUND_HALF_UP | |
| from typing import Any, Dict, Mapping, Optional, Tuple | |
| from src.ring_size import aggregate_ring_sizes, recommend_ring_size | |
| SESSION_STATE_VERSION = 1 | |
| MAX_SESSION_SHOTS = 20 | |
| MIN_STATE_DIAMETER_CM = 1.0 | |
| MAX_STATE_DIAMETER_CM = 3.0 | |
| FINGER_ORDER = ("index", "middle", "ring", "pinky") | |
| VALID_FINGERS = set(FINGER_ORDER) | |
| VALID_HANDEDNESS = {"Left", "Right", "Unknown"} | |
| _SHA256_RE = re.compile(r"^[0-9a-f]{64}$") | |
| def _size_decision_diameter_mm(median_cm: float) -> float: | |
| """Quantize a session median to the supported 0.1 mm decision precision.""" | |
| median_mm = Decimal(str(median_cm)) * Decimal("10") | |
| return float(median_mm.quantize(Decimal("0.1"), rounding=ROUND_HALF_UP)) | |
| def image_sha256(data: bytes) -> str: | |
| """Return a stable content fingerprint for duplicate-shot detection.""" | |
| return hashlib.sha256(data).hexdigest() | |
| def normalize_session_id(value: Any) -> Optional[str]: | |
| """Return a canonical UUID string, or None for absent/malformed input.""" | |
| if not isinstance(value, str) or not value.strip(): | |
| return None | |
| try: | |
| return str(uuid.UUID(value.strip())) | |
| except (ValueError, AttributeError): | |
| return None | |
| def _empty_state(session_id: str, ring_model: str) -> Dict[str, Any]: | |
| return { | |
| "version": SESSION_STATE_VERSION, | |
| "session_id": session_id, | |
| "ring_model": ring_model, | |
| "attempt_count": 0, | |
| "shots": [], | |
| } | |
| def _finite_diameter(value: Any) -> Optional[float]: | |
| if isinstance(value, bool) or not isinstance(value, (int, float)): | |
| return None | |
| diameter = float(value) | |
| if not math.isfinite(diameter): | |
| return None | |
| if diameter < MIN_STATE_DIAMETER_CM or diameter > MAX_STATE_DIAMETER_CM: | |
| return None | |
| return round(diameter, 4) | |
| def _sanitize_state( | |
| previous_state: Any, | |
| *, | |
| session_id: str, | |
| ring_model: str, | |
| ) -> Dict[str, Any]: | |
| """Validate untrusted browser-returned state and enforce a small bound.""" | |
| fresh = _empty_state(session_id, ring_model) | |
| if not isinstance(previous_state, Mapping): | |
| return fresh | |
| if previous_state.get("version") != SESSION_STATE_VERSION: | |
| return fresh | |
| if normalize_session_id(previous_state.get("session_id")) != session_id: | |
| return fresh | |
| if previous_state.get("ring_model") != ring_model: | |
| return fresh | |
| attempt_count = previous_state.get("attempt_count", 0) | |
| if isinstance(attempt_count, bool) or not isinstance(attempt_count, int): | |
| attempt_count = 0 | |
| fresh["attempt_count"] = max(0, min(attempt_count, 10_000)) | |
| raw_shots = previous_state.get("shots") | |
| if not isinstance(raw_shots, list): | |
| return fresh | |
| shots = [] | |
| for raw_shot in raw_shots[-MAX_SESSION_SHOTS:]: | |
| if not isinstance(raw_shot, Mapping): | |
| continue | |
| handedness = raw_shot.get("handedness") | |
| if handedness not in VALID_HANDEDNESS: | |
| continue | |
| digest = raw_shot.get("image_sha256") | |
| if not isinstance(digest, str) or not _SHA256_RE.fullmatch(digest): | |
| continue | |
| raw_per_finger = raw_shot.get("per_finger") | |
| if not isinstance(raw_per_finger, Mapping): | |
| continue | |
| per_finger: Dict[str, float] = {} | |
| for finger, value in raw_per_finger.items(): | |
| if finger not in VALID_FINGERS: | |
| continue | |
| diameter = _finite_diameter(value) | |
| if diameter is not None: | |
| per_finger[finger] = diameter | |
| if not per_finger: | |
| continue | |
| shots.append({ | |
| "run_id": str(raw_shot.get("run_id") or "")[:64], | |
| "image_sha256": digest, | |
| "handedness": handedness, | |
| "per_finger": per_finger, | |
| }) | |
| fresh["shots"] = shots[-MAX_SESSION_SHOTS:] | |
| return fresh | |
| def _result_handedness(result: Mapping[str, Any]) -> str: | |
| handedness = result.get("handedness") | |
| return handedness if handedness in VALID_HANDEDNESS else "Unknown" | |
| def _successful_current_samples( | |
| result: Mapping[str, Any], | |
| *, | |
| mode: str, | |
| finger_index: str, | |
| ) -> Dict[str, float]: | |
| samples: Dict[str, float] = {} | |
| if mode == "multi": | |
| per_finger = result.get("per_finger") | |
| if not isinstance(per_finger, Mapping): | |
| return samples | |
| for finger in FINGER_ORDER: | |
| item = per_finger.get(finger) | |
| if not isinstance(item, Mapping) or item.get("status") != "ok": | |
| continue | |
| diameter = _finite_diameter(item.get("diameter_cm")) | |
| if diameter is not None: | |
| samples[finger] = diameter | |
| return samples | |
| if result.get("fail_reason") is not None: | |
| return samples | |
| finger = finger_index if finger_index in VALID_FINGERS else "index" | |
| diameter = _finite_diameter(result.get("finger_outer_diameter_cm")) | |
| if diameter is not None: | |
| samples[finger] = diameter | |
| return samples | |
| def _recommend_for_hand( | |
| state: Mapping[str, Any], | |
| *, | |
| handedness: str, | |
| ring_model: str, | |
| current_result: Mapping[str, Any], | |
| mode: str, | |
| finger_index: str, | |
| current_shot_included: bool, | |
| duplicate_image: bool, | |
| ) -> Optional[Dict[str, Any]]: | |
| values: Dict[str, list] = {finger: [] for finger in FINGER_ORDER} | |
| successful_shots = 0 | |
| for shot in state.get("shots", []): | |
| if shot.get("handedness") != handedness: | |
| continue | |
| successful_shots += 1 | |
| for finger, diameter in shot.get("per_finger", {}).items(): | |
| if finger in values: | |
| values[finger].append(float(diameter)) | |
| synthetic: Dict[str, Dict[str, Any]] = {} | |
| stats: Dict[str, Dict[str, Any]] = {} | |
| for finger in FINGER_ORDER: | |
| finger_values = values[finger] | |
| if not finger_values: | |
| continue | |
| # Inputs are stored to 4 decimal places in cm, so an even-sized median | |
| # can contain one additional decimal place. Preserve that value for | |
| # auditability, but quantize the value used for discrete size lookup to | |
| # 0.1 mm. This avoids invisible hundredths of a millimetre flipping a | |
| # recommendation while the UI displays the same one-decimal diameter. | |
| median_cm = round(float(statistics.median(finger_values)), 5) | |
| decision_diameter_mm = _size_decision_diameter_mm(median_cm) | |
| spread_mm = round((max(finger_values) - min(finger_values)) * 10.0, 2) | |
| ring_size = recommend_ring_size( | |
| decision_diameter_mm / 10.0, | |
| ring_model=ring_model, | |
| prefer_smaller_on_tie=True, | |
| ) | |
| synthetic[finger] = { | |
| "finger_outer_diameter_cm": median_cm, | |
| # Session confidence is intentionally not invented. Equal weights | |
| # keep the legacy cross-finger aggregator deterministic without | |
| # reusing the non-predictive per-shot confidence score. | |
| "confidence": 1.0, | |
| "ring_size": ring_size, | |
| "fail_reason": None, | |
| } | |
| stats[finger] = { | |
| "sample_count": len(finger_values), | |
| "spread_mm": spread_mm, | |
| "decision_diameter_mm": decision_diameter_mm, | |
| } | |
| if not synthetic: | |
| return None | |
| aggregated = aggregate_ring_sizes(synthetic) | |
| per_finger = aggregated.get("per_finger", {}) | |
| for finger, finger_stats in stats.items(): | |
| if finger in per_finger: | |
| # The equal weight above is only an internal tie-breaker for the | |
| # legacy cross-finger aggregator, not a claim of 100% confidence. | |
| per_finger[finger].pop("confidence", None) | |
| per_finger[finger].update(finger_stats) | |
| # Preserve a failed current-finger card when no earlier success exists, | |
| # keeping first-shot rendering equivalent to the raw multi result. | |
| if mode == "multi": | |
| current_per_finger = current_result.get("per_finger") | |
| if isinstance(current_per_finger, Mapping): | |
| for finger in FINGER_ORDER: | |
| current_item = current_per_finger.get(finger) | |
| if finger not in per_finger and isinstance(current_item, Mapping): | |
| per_finger[finger] = dict(current_item) | |
| per_finger[finger]["sample_count"] = 0 | |
| per_finger[finger]["spread_mm"] = None | |
| per_finger[finger]["decision_diameter_mm"] = None | |
| aggregated["fingers_measured"] = len(per_finger) | |
| aggregated["fingers_succeeded"] = sum( | |
| item.get("status") == "ok" for item in per_finger.values() | |
| ) | |
| recommendation: Dict[str, Any] = { | |
| **aggregated, | |
| "basis": "session_median", | |
| "session_id": state["session_id"], | |
| "attempt_index": state["attempt_count"], | |
| "handedness": handedness, | |
| "successful_shots": successful_shots, | |
| "current_shot_included": current_shot_included, | |
| "duplicate_image": duplicate_image, | |
| } | |
| if mode != "multi": | |
| finger = finger_index if finger_index in VALID_FINGERS else "index" | |
| finger_rec = per_finger.get(finger) | |
| if finger_rec and finger_rec.get("status") == "ok": | |
| recommendation["finger_index"] = finger | |
| recommendation["finger_outer_diameter_cm"] = finger_rec["diameter_cm"] | |
| recommendation["ring_size"] = synthetic[finger]["ring_size"] | |
| return recommendation | |
| def update_session_recommendation( | |
| previous_state: Any, | |
| *, | |
| session_id: str, | |
| ring_model: str, | |
| run_id: str, | |
| image_digest: str, | |
| result: Mapping[str, Any], | |
| mode: str, | |
| finger_index: str = "index", | |
| ) -> Tuple[Dict[str, Any], Optional[Dict[str, Any]]]: | |
| """Add one attempt and return `(updated_state, recommendation)`. | |
| `result` must be the calibrated raw result for the current photo. The | |
| returned recommendation is for the current detected hand only. A total | |
| current-shot failure increments the attempt counter but returns no stale | |
| recommendation to the UI. | |
| """ | |
| canonical_id = normalize_session_id(session_id) | |
| if canonical_id is None: | |
| raise ValueError("session_id must be a valid UUID") | |
| if not _SHA256_RE.fullmatch(image_digest or ""): | |
| raise ValueError("image_digest must be a SHA-256 hex digest") | |
| state = _sanitize_state( | |
| previous_state, | |
| session_id=canonical_id, | |
| ring_model=ring_model, | |
| ) | |
| state["attempt_count"] += 1 | |
| current_samples = _successful_current_samples( | |
| result, | |
| mode=mode, | |
| finger_index=finger_index, | |
| ) | |
| handedness = _result_handedness(result) | |
| duplicate = any( | |
| shot.get("image_sha256") == image_digest for shot in state["shots"] | |
| ) | |
| included = bool(current_samples) and not duplicate | |
| if included: | |
| state["shots"].append({ | |
| "run_id": str(run_id or "")[:64], | |
| "image_sha256": image_digest, | |
| "handedness": handedness, | |
| "per_finger": current_samples, | |
| }) | |
| state["shots"] = state["shots"][-MAX_SESSION_SHOTS:] | |
| # Do not surface an old recommendation on top of a total current failure. | |
| if not current_samples: | |
| return state, None | |
| recommendation = _recommend_for_hand( | |
| state, | |
| handedness=handedness, | |
| ring_model=ring_model, | |
| current_result=result, | |
| mode=mode, | |
| finger_index=finger_index, | |
| current_shot_included=included, | |
| duplicate_image=duplicate, | |
| ) | |
| return state, recommendation | |