""" Composite quality score and PASS / ACCEPTABLE / FAIL verdict. Two ideas are combined, both clinically motivated: 1. Weighted aggregate. A weighted mean of the per-axis 0-1 scores gives the overall picture (weights in qc_metrics.METRIC_WEIGHTS). 2. Weakest-link gate. Clinically, one catastrophic axis makes an image ungradable even if everything else is perfect (e.g. a perfectly exposed but totally out-of-focus photo is useless). So the final composite is pulled toward the worst axis via a soft-min term. This mirrors the 3-tier Good / Usable / Reject grading used in retinal-QC datasets (e.g. EyeQ), which we surface as PASS / ACCEPTABLE / FAIL. """ from __future__ import annotations import numpy as np from .qc_metrics import METRIC_WEIGHTS PASS_T = 70.0 # composite >= 70 -> PASS (Good) ACCEPT_T = 45.0 # 45-70 -> ACCEPTABLE (Usable) # < 45 -> FAIL (Reject) def _softmin(scores, tau=0.15): """Differentiable-ish soft minimum in [0,1]; emphasises the worst axis.""" s = np.asarray(scores, float) w = np.exp(-s / tau) return float((s * w).sum() / (w.sum() + 1e-9)) def composite_score(metrics: list[dict]) -> dict: """Combine per-metric scores into a 0-100 composite and a verdict.""" weighted = 0.0 wsum = 0.0 scores = [] for m in metrics: w = METRIC_WEIGHTS.get(m["name"], 0.02) weighted += w * m["score"] wsum += w scores.append(m["score"]) weighted /= (wsum + 1e-9) worst = _softmin(scores) # Blend: 65% weighted mean, 35% weakest-link. The weakest-link term means a # single catastrophic axis cannot be hidden by strong performance elsewhere. composite01 = 0.65 * weighted + 0.35 * worst composite = float(np.clip(composite01 * 100, 0, 100)) if composite >= PASS_T: verdict, band = "PASS", "Good" elif composite >= ACCEPT_T: verdict, band = "ACCEPTABLE", "Usable" else: verdict, band = "FAIL", "Reject" # Identify the axes dragging the score down (for the clinician summary) failing = sorted([m for m in metrics if m["score"] < 0.40], key=lambda m: m["score"]) borderline = sorted([m for m in metrics if 0.40 <= m["score"] < 0.66], key=lambda m: m["score"]) return dict( composite=composite, verdict=verdict, band=band, weighted_mean=weighted * 100, weakest_link=worst * 100, failing=[m["name"] for m in failing], borderline=[m["name"] for m in borderline], primary_reason=(failing[0]["reason"] if failing else (borderline[0]["reason"] if borderline else "All quality axes within acceptable range")), ) def verdict_color(verdict: str) -> str: return {"PASS": "#1f9d61", "ACCEPTABLE": "#d69e2e", "FAIL": "#e05252"}.get( verdict, "#64748b")