ring-sizer / src /session_recommendation.py
feng-x's picture
Upload folder using huggingface_hub
9f6a8e2 verified
Raw
History Blame Contribute Delete
12.1 kB
"""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