File size: 2,958 Bytes
6fe482b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
"""
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")