EyeQC / src /quality_score.py
rdharini2001's picture
EyeQC
6fe482b
Raw
History Blame Contribute Delete
2.96 kB
"""
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")