File size: 12,697 Bytes
5d31c67
812b6ee
 
d79f8b5
812b6ee
5d31c67
 
812b6ee
5d31c67
 
 
 
 
 
 
 
 
 
 
 
 
d79f8b5
 
 
 
 
 
812b6ee
 
 
 
 
d79f8b5
812b6ee
d79f8b5
812b6ee
 
5d31c67
 
d79f8b5
 
 
 
 
5d31c67
 
 
 
 
 
 
 
 
 
 
 
d79f8b5
 
 
 
812b6ee
 
d79f8b5
 
 
 
 
5d31c67
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
812b6ee
 
d79f8b5
 
812b6ee
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d79f8b5
 
812b6ee
d79f8b5
 
 
 
 
 
 
 
 
 
5d31c67
 
 
 
 
 
812b6ee
5d31c67
812b6ee
d79f8b5
812b6ee
5d31c67
 
812b6ee
d79f8b5
 
812b6ee
d79f8b5
 
5d31c67
 
 
 
 
 
 
 
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
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
#!/usr/bin/env python3
"""Write reports/SUMMARY.md from a cross-validation run and reports/final/final_fit.json.

    python scripts/build_summary.py --cv-dir reports/cv_v3 --compare reports/cv_v2/cv_results.json --names v2,v3
"""
from __future__ import annotations

import argparse
import json
import os
import sys

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, ROOT)
from cellquant.labels import ALL_LABELS, CELL_LABELS, NOSTRIL_LABELS  # noqa: E402


def pct(x: float) -> str:
    return f"{100 * x:.1f}%"


def peak_thr(agg: dict) -> float:
    """Shipped min_peak_dist: the final model's threshold from rule_study.json next to cv_results.json, else PostConfig's default."""
    from cellquant.postprocess import PostConfig
    return float(agg.get("final_min_peak_dist", PostConfig().min_peak_dist))


def main(argv=None):
    ap = argparse.ArgumentParser()
    ap.add_argument("--cv-dir", default="runs/cv", help="folder holding cv_results.json of the shipped recipe")
    ap.add_argument("--compare", default="reports/cv/cv_results.json",
                    help="cv_results.json of the previous recipe for the change table ('' = none)")
    ap.add_argument("--names", default="v1,v2", help="names of the previous and the shipped recipe")
    a = ap.parse_args(argv)
    prev_name, cur_name = a.names.split(",")
    cv = json.load(open(os.path.join(ROOT, a.cv_dir, "cv_results.json")))
    prev = json.load(open(os.path.join(ROOT, a.compare)))["aggregate"] if a.compare and os.path.exists(os.path.join(ROOT, a.compare)) else None
    agg = cv["aggregate"]
    per = agg["per_image"]
    rs = os.path.join(ROOT, a.cv_dir, "rule_study.json")
    if os.path.exists(rs):
        fin = json.load(open(rs)).get("final")
        if fin:
            agg["final_min_peak_dist"] = fin["chosen"]["thr"]
    fit = json.load(open(os.path.join(ROOT, "reports", "final", "final_fit.json")))
    tot = agg["dataset_totals"]
    n_nuc = sum(tot[c] for c in CELL_LABELS)
    n_pun = sum(tot[c] for c in NOSTRIL_LABELS)
    det = agg["detection_base"]
    det8 = agg["detection_tta8"]
    L = ["# CellQuant: validation summary", "",
         f"Data: {len(per)} annotated 1024×1024 images, {n_nuc} nucleus outlines and {n_pun} punctum outlines.",
         "Protocol: leave-one-image-out cross-validation (one model per image, 3000 iterations each, held-out image never seen in training), "
         "then a final model trained on all images with the same recipe. Each held-out image was tested 23 times: 3 identical runs "
         "(determinism), no-TTA / 4× / 8× test-time augmentation, 8 rotations and flips, 6 photometric perturbations, and 6 post-processing rule variants. "
         "The only choice made on held-out data is the rule that gives a nucleus the Nostril attribute: of three candidate rules, "
         "'contains an in-nucleus punctum' (the annotation protocol) scored best and is the default; see the rule-variant table in cv_report.md. "
         "The peak-distance threshold of v3 was chosen, for every fold, on that fold's eight training images only (scripts/rule_study.py), "
         "and for the shipped model on its own nine training images; the held-out images never entered that choice.", "",
         "Recipe (v3, shipped): the v2 model (ResNet34-UNet with a full-resolution stem; targets are a 3-px inner border ring (class weight 3), "
         "a per-nucleus normalised distance-to-outline map that the watershed uses as its landscape (markers = distance > 0.5), "
         "an antibody head whose loss up-weights antibody-negative pixels by sqrt(positive / negative pixels) (4.5 on the full set), "
         "and 25 % of training crops centred on an antibody-negative nucleus) plus one post-processing rule: a nucleus whose peak "
         f"predicted distance is below {peak_thr(agg):.2f} is dropped (PostConfig.min_peak_dist). The distance head is trained to reach 1 inside "
         "every annotated nucleus and to stay at 0 everywhere else, so a low peak marks a blob the annotator would have skipped. "
         "The weights are unchanged from v2 (reports/cv_v2/); v1 (reports/cv/) used a 2-px ring, the interior map as watershed landscape "
         "and an unweighted antibody loss. The change table below compares the shipped recipe with the previous one under the same protocol.", "",
         "## Headline numbers (micro-averaged over all held-out images, no TTA)", "",
         "| Measure | Precision | Recall | F1 |", "|---|---:|---:|---:|",
         f"| Nucleus detection, IoU ≥ 0.5 | {pct(det['cells']['precision'])} | {pct(det['cells']['recall'])} | {pct(det['cells']['f1'])} |",
         f"| Nucleus detection, IoU ≥ 0.3 | {pct(det['cells_iou30']['precision'])} | {pct(det['cells_iou30']['recall'])} | {pct(det['cells_iou30']['f1'])} |",
         f"| Nucleus detection, centroid inside GT nucleus | {pct(det['cells_centroid']['precision'])} | {pct(det['cells_centroid']['recall'])} | {pct(det['cells_centroid']['f1'])} |",
         f"| Punctum detection (centroid ≤ 6 px) | {pct(det['nostrils']['precision'])} | {pct(det['nostrils']['recall'])} | {pct(det['nostrils']['f1'])} |",
         f"| Nucleus detection, IoU ≥ 0.5, 8× TTA | {pct(det8['cells']['precision'])} | {pct(det8['cells']['recall'])} | {pct(det8['cells']['f1'])} |",
         "", "Per-class F1 (a true positive needs a matched object **and** the right label):", "",
         "| Label | GT | Pred | F1 |", "|---|---:|---:|---:|"]
    for lab in ALL_LABELS:
        r = agg["micro_base"][lab]
        L.append(f"| {lab} | {r['gt_count']} | {r['pred_count']} | {pct(r['f1'])} |")
    L += ["", "## Per held-out image", "",
          "| Image | Nuclei F1 | Antibody acc | Nostril-attr acc | Puncta F1 | Nuclei F1 over 14 perturbed runs (mean ± sd) | Deterministic |",
          "|---|---:|---:|---:|---:|---|---|"]
    for k, s in per.items():
        b = s["base"]; r = s["robustness"]["cell_det_f1"]
        L.append(f"| {k} | {pct(b['cell_det_f1'])} | {pct(b['cell_attr_antibody'])} | {pct(b['cell_attr_nostril'])} | {pct(b['nos_det_f1'])} | "
                 f"{100*r['mean']:.1f} ± {100*r['std']:.1f} | {'yes' if s['deterministic'] else 'no'} |")
    L += ["", "## Final model (trained on all images) – fit on its own training images, NOT a generalisation estimate", "",
          "| Image | Nuclei F1 | Attribute acc | Puncta F1 |", "|---|---:|---:|---:|"]
    for k, v in fit.items():
        if "summary" in v:
            s = v["summary"]
            L.append(f"| {k} | {pct(s['cell_det_f1'])} | {pct(s['cell_attr_both'])} | {pct(s['nos_det_f1'])} |")
    if prev:
        pd, pd8 = prev["detection_base"], prev["detection_tta8"]
        L += ["", f"## Change from {prev_name} (same protocol, same nine held-out images; `{a.cv_dir}/comparison.md` has the full table)", "",
              f"| Measure | {prev_name} | {cur_name} | Δ (points) |", "|---|---:|---:|---:|"]
        def row(name, x, y):
            L.append(f"| {name} | {pct(x)} | {pct(y)} | {100 * (y - x):+.1f} |")
        row("Nucleus F1, IoU ≥ 0.5", pd["cells"]["f1"], det["cells"]["f1"])
        row("Nucleus precision, IoU ≥ 0.5", pd["cells"]["precision"], det["cells"]["precision"])
        row("Nucleus recall, IoU ≥ 0.5", pd["cells"]["recall"], det["cells"]["recall"])
        row("Nucleus F1, centroid rule", pd["cells_centroid"]["f1"], det["cells_centroid"]["f1"])
        row("Nucleus F1, IoU ≥ 0.5, 8× TTA", pd8["cells"]["f1"], det8["cells"]["f1"])
        row("Puncta F1", pd["nostrils"]["f1"], det["nostrils"]["f1"])
        for lab in ALL_LABELS:
            row(f"F1 [{lab}]", prev["micro_base"][lab]["f1"], agg["micro_base"][lab]["f1"])
        pr, cr = prev["rule_variants"]["rule_nos=head_cell=contains"], agg["rule_variants"]["rule_nos=head_cell=contains"]
        row("Attribute accuracy, both (each model's own matched nuclei)", pr["pooled_cell_attr_both"], cr["pooled_cell_attr_both"])
        row("Punctum type accuracy (matched puncta)", pr["pooled_nos_type_acc"], cr["pooled_nos_type_acc"])
        for k in sorted(per):
            if k in prev["per_image"]:
                row(f"Nuclei F1, {k} held out", prev["per_image"][k]["base"]["cell_det_f1"], per[k]["base"]["cell_det_f1"])
        n_pred_prev = pd["cells"]["tp"] + pd["cells"]["fp"]
        n_pred_cur = det["cells"]["tp"] + det["cells"]["fp"]
        n_gt = det["cells"]["tp"] + det["cells"]["fn"]
        nov_prev = sum(s["fp_analysis"]["n_fp_no_gt_overlap"] for s in prev["per_image"].values())
        nov_cur = sum(s["fp_analysis"]["n_fp_no_gt_overlap"] for s in per.values())
        L += ["", f"{cur_name} keeps the {prev_name} weights and drops {n_pred_prev - n_pred_cur} of the {n_pred_prev} nuclei {prev_name} predicted on the "
              f"nine held-out images ({n_gt} annotated): unmatched predictions fall from {pd['cells']['fp']} to {det['cells']['fp']} "
              f"(those overlapping no annotated nucleus from {nov_prev} to {nov_cur}) while matched ones fall from {pd['cells']['tp']} to {det['cells']['tp']}, "
              f"so precision rises by {100 * (det['cells']['precision'] - pd['cells']['precision']):.1f} points and recall drops by "
              f"{100 * (pd['cells']['recall'] - det['cells']['recall']):.1f}. Puncta are untouched. The over-count of nuclei on the annotated images "
              f"shrinks from {100 * (n_pred_prev / n_gt - 1):.0f} % to {100 * (n_pred_cur / n_gt - 1):.0f} %. The threshold was never tuned on a held-out image: "
              "each fold used the value chosen on its own training images (0.60-0.67, see rule_study.md), the shipped model the value chosen on all nine "
              f"({peak_thr(agg):.2f})."]
    sw = agg.get("antibody_threshold_sweep")
    if sw:
        best = max(sw["sweep"], key=lambda r: r["neg_f1"])
        L += ["", f"## Antibody − threshold (held-out matched nuclei: {sw['n_matched']}, of which {sw['n_negative']} negative)", "",
              f"Default threshold 0.5. Best Antibody − F1 in the sweep: {pct(best['neg_f1'])} at threshold {best['thr']:.2f} "
              f"(precision {pct(best['neg_precision'])}, recall {pct(best['neg_recall'])}); the full curve is in cv_report.md. "
              f"The default was kept because the class is dominated by a single image (F99 holds 62 of the {tot['Cell, Antibody -'] + tot['Cell, Nostril, Antibody -']} negatives)."]
    L += ["", "## What limits the scores", "",
          "* **Touching nuclei** are still the main source of IoU-0.5 misses (worst on F113, the densest image). The distance-map watershed of v2 recovers many clusters that v1 merged (v1 marker/threshold sweeps in `reports/cv/postprocess_sweep.json` had shown post-processing alone could not), but some clusters are still merged and a few nuclei are now split in two.",
          "* **Annotation completeness**: most unmatched predictions overlap an annotated nucleus (outline disagreement); the rest are dimmer, smaller objects the annotator skipped, which the peak-distance rule of v3 halves (143 → 72 on the held-out images) at the price of 21 matched nuclei. Many faint red dots were likewise not annotated, which caps external-punctum precision.",
          f"* **Antibody − is rare** ({tot['Cell, Antibody -'] + tot['Cell, Nostril, Antibody -']} of {n_nuc} nuclei, 62 in one image), and **Cell, Nostril, Antibody −** has five examples; their scores are not reliable. The class-balanced loss of v2 trades antibody-negative precision for recall.",
          "* Scores are from nine images of one experiment (35-day spinal cord, Iba1/TMEM119). New staining batches or magnifications need a check against a few annotated images.",
          "", "## Reproduce", "",
          "```bash", "python evaluate.py cv --data-dir <folder with TIFs and CVAT zips> --runs-dir runs/cv_v2 --iters 3000",
          "python scripts/rule_study.py --data-dir <folder> --runs-dir runs/cv_v2 --out runs/cv_v3/rule_study --weights weights/cellquant.pt",
          "python evaluate.py cv --data-dir <folder> --runs-dir runs/cv_v3 --post-config runs/cv_v3/rule_study_post_config.json   # fold_*.pt linked from runs/cv_v2",
          "python evaluate.py final --data-dir <folder> --weights weights/cellquant.pt",
          "python scripts/compare_cv.py reports/cv_v2/cv_results.json runs/cv_v3/cv_results.json --names v2,v3 --out reports/cv_v3/comparison.md",
          "python scripts/build_summary.py --cv-dir reports/cv_v3 --compare reports/cv_v2/cv_results.json --names v2,v3", "```", ""]
    out = os.path.join(ROOT, "reports", "SUMMARY.md")
    with open(out, "w") as f:
        f.write("\n".join(L))
    print("wrote", out)


if __name__ == "__main__":
    main()