| """ |
| 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 |
| ACCEPT_T = 45.0 |
| |
|
|
|
|
| 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) |
| |
| |
| 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" |
|
|
| |
| 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") |
|
|