diff --git a/presentations/STAMP_B01_15min.pptx b/presentations/STAMP_B01_15min.pptx
new file mode 100644
index 00000000..70b4bbbb
Binary files /dev/null and b/presentations/STAMP_B01_15min.pptx differ
diff --git a/presentations/STAMP_B01_5min.pptx b/presentations/STAMP_B01_5min.pptx
new file mode 100644
index 00000000..d39ff67e
Binary files /dev/null and b/presentations/STAMP_B01_5min.pptx differ
diff --git a/presentations/STAMP_B01_project_review.pptx b/presentations/STAMP_B01_project_review.pptx
new file mode 100644
index 00000000..0cb556b7
Binary files /dev/null and b/presentations/STAMP_B01_project_review.pptx differ
diff --git a/presentations/assets/candidates.json b/presentations/assets/candidates.json
new file mode 100644
index 00000000..484902ed
--- /dev/null
+++ b/presentations/assets/candidates.json
@@ -0,0 +1,419 @@
+{
+ "high_blast": [
+ {
+ "sample_id": "SAMPLE_416_621_19_C",
+ "stem": "S_621 19 C Score 100 - 10 Fokuspunkte - entfettet - 2026-03-03 22.52.24",
+ "split": "split-4",
+ "experiment": "high_blast",
+ "blast_pct_gt": 35.0,
+ "enrichment": 0.684,
+ "tech_concern": 0,
+ "note": "GT high / pred high",
+ "overview": "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_high_blast_uni2/heatmaps/split-4/S_621 19 C Score 100 - 10 Fokuspunkte - entfettet - 2026-03-03 22.52.24/plots/overview-S_621 19 C Score 100 - 10 Fokuspunkte - entfettet - 2026-03-03 22.52.24.png"
+ },
+ {
+ "sample_id": "SAMPLE_404_1349_19_A",
+ "stem": "1349 19 A Score 78 -10 Fokuspunkte - 2026-03-07 16.38.07",
+ "split": "split-4",
+ "experiment": "high_blast",
+ "blast_pct_gt": 25.0,
+ "enrichment": 0.63,
+ "tech_concern": 1,
+ "note": "GT high / pred high",
+ "overview": "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_high_blast_uni2/heatmaps/split-4/1349 19 A Score 78 -10 Fokuspunkte - 2026-03-07 16.38.07/plots/overview-1349 19 A Score 78 -10 Fokuspunkte - 2026-03-07 16.38.07.png"
+ },
+ {
+ "sample_id": "SAMPLE_357_1131_19_B",
+ "stem": "1131 19 B Score 91 -10 Fokuspunkte - frisches Roticlear - 2026-03-06 21.08.01",
+ "split": "split-3",
+ "experiment": "high_blast",
+ "blast_pct_gt": 56.0,
+ "enrichment": 0.616,
+ "tech_concern": 0,
+ "note": "GT high / pred high",
+ "overview": "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_high_blast_uni2/heatmaps/split-3/1131 19 B Score 91 -10 Fokuspunkte - frisches Roticlear - 2026-03-06 21.08.01/plots/overview-1131 19 B Score 91 -10 Fokuspunkte - frisches Roticlear - 2026-03-06 21.08.01.png"
+ },
+ {
+ "sample_id": "SAMPLE_405_1321_19_A",
+ "stem": "1321 19 A Score 85 -10 Fokuspunkte - 2026-03-07 15.08.02",
+ "split": "split-0",
+ "experiment": "high_blast",
+ "blast_pct_gt": 77.0,
+ "enrichment": 0.55,
+ "tech_concern": 0,
+ "note": "GT high / pred high",
+ "overview": "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_high_blast_uni2/heatmaps/split-0/1321 19 A Score 85 -10 Fokuspunkte - 2026-03-07 15.08.02/plots/overview-1321 19 A Score 85 -10 Fokuspunkte - 2026-03-07 15.08.02.png"
+ },
+ {
+ "sample_id": "SAMPLE_401_960_19_B",
+ "stem": "S_960 19 B Score 95 - 5 Fokuspunkte - entfettet - 2026-03-04 21.56.58",
+ "split": "split-2",
+ "experiment": "high_blast",
+ "blast_pct_gt": 43.0,
+ "enrichment": 0.545,
+ "tech_concern": 0,
+ "note": "GT high / pred high",
+ "overview": "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_high_blast_uni2/heatmaps/split-2/S_960 19 B Score 95 - 5 Fokuspunkte - entfettet - 2026-03-04 21.56.58/plots/overview-S_960 19 B Score 95 - 5 Fokuspunkte - entfettet - 2026-03-04 21.56.58.png"
+ },
+ {
+ "sample_id": "SAMPLE_396_803_19_B",
+ "stem": "S_803 19 B Score 100 - 10 Fokuspunkte - entfettet - 2026-03-04 19.18.04",
+ "split": "split-4",
+ "experiment": "high_blast",
+ "blast_pct_gt": 81.0,
+ "enrichment": 0.519,
+ "tech_concern": 0,
+ "note": "GT high / pred high",
+ "overview": "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_high_blast_uni2/heatmaps/split-4/S_803 19 B Score 100 - 10 Fokuspunkte - entfettet - 2026-03-04 19.18.04/plots/overview-S_803 19 B Score 100 - 10 Fokuspunkte - entfettet - 2026-03-04 19.18.04.png"
+ },
+ {
+ "sample_id": "SAMPLE_416_1012_19_A",
+ "stem": "1012 19 A Score 73 - 10 Fokuspunkte- 2026-03-05 20.15.46",
+ "split": "split-2",
+ "experiment": "high_blast",
+ "blast_pct_gt": 21.0,
+ "enrichment": 0.419,
+ "tech_concern": 1,
+ "note": "GT high / pred high",
+ "overview": "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_high_blast_uni2/heatmaps/split-2/1012 19 A Score 73 - 10 Fokuspunkte- 2026-03-05 20.15.46/plots/overview-1012 19 A Score 73 - 10 Fokuspunkte- 2026-03-05 20.15.46.png"
+ },
+ {
+ "sample_id": "SAMPLE_402_1085_19_B",
+ "stem": "1085 19 B Score 89 -10 Fokuspunkte - 2026-03-06 19.40.50",
+ "split": "split-2",
+ "experiment": "high_blast",
+ "blast_pct_gt": 60.0,
+ "enrichment": 0.415,
+ "tech_concern": 0,
+ "note": "GT high / pred high",
+ "overview": "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_high_blast_uni2/heatmaps/split-2/1085 19 B Score 89 -10 Fokuspunkte - 2026-03-06 19.40.50/plots/overview-1085 19 B Score 89 -10 Fokuspunkte - 2026-03-06 19.40.50.png"
+ }
+ ],
+ "blast_percent": [
+ {
+ "sample_id": "SAMPLE_388_583_19_A",
+ "stem": "583 19 A Score 64 - 10 Fokuspunkte - entfettet - 2026-03-03 22.02.40",
+ "split": "split-0",
+ "experiment": "blast_percent",
+ "blast_pct_gt": 3.0,
+ "enrichment": 0.694,
+ "tech_concern": 2,
+ "note": "GT 3% (low) \u2014 correct",
+ "overview": "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_blast_percent_uni2/heatmaps/split-0/583 19 A Score 64 - 10 Fokuspunkte - entfettet - 2026-03-03 22.02.40/plots/overview-583 19 A Score 64 - 10 Fokuspunkte - entfettet - 2026-03-03 22.02.40.png"
+ },
+ {
+ "sample_id": "SAMPLE_398_1188_19_A",
+ "stem": "1188 19 A Score 93 -10 Fokuspunkte - 2026-03-07 10.20.25",
+ "split": "split-4",
+ "experiment": "blast_percent",
+ "blast_pct_gt": 0.0,
+ "enrichment": 0.688,
+ "tech_concern": 0,
+ "note": "GT 0% (low) \u2014 correct",
+ "overview": "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_blast_percent_uni2/heatmaps/split-4/1188 19 A Score 93 -10 Fokuspunkte - 2026-03-07 10.20.25/plots/overview-1188 19 A Score 93 -10 Fokuspunkte - 2026-03-07 10.20.25.png"
+ },
+ {
+ "sample_id": "SAMPLE_399_1294_19_C",
+ "stem": "1294 19 C Score 96 -10 Fokuspunkte - 2026-03-07 14.15.44",
+ "split": "split-3",
+ "experiment": "blast_percent",
+ "blast_pct_gt": 1.0,
+ "enrichment": 0.656,
+ "tech_concern": 1,
+ "note": "GT 1% (low) \u2014 correct",
+ "overview": "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_blast_percent_uni2/heatmaps/split-3/1294 19 C Score 96 -10 Fokuspunkte - 2026-03-07 14.15.44/plots/overview-1294 19 C Score 96 -10 Fokuspunkte - 2026-03-07 14.15.44.png"
+ },
+ {
+ "sample_id": "SAMPLE_408_1411_19_C",
+ "stem": "1411 19 C Score 88 - 10 Fokuspunkte - 2026-03-08 14.08.49",
+ "split": "split-1",
+ "experiment": "blast_percent",
+ "blast_pct_gt": 91.0,
+ "enrichment": 0.199,
+ "tech_concern": 1,
+ "note": "GT 91% -> pred 13% \u2014 FAILURE",
+ "overview": "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_blast_percent_uni2/heatmaps/split-1/1411 19 C Score 88 - 10 Fokuspunkte - 2026-03-08 14.08.49/plots/overview-1411 19 C Score 88 - 10 Fokuspunkte - 2026-03-08 14.08.49.png"
+ },
+ {
+ "sample_id": "SAMPLE_408_1411_19_A",
+ "stem": "S_1411 19 A Score 92 - 10 Fokuspunkte - 2026-03-08 14.01.06",
+ "split": "split-2",
+ "experiment": "blast_percent",
+ "blast_pct_gt": 91.0,
+ "enrichment": 0.464,
+ "tech_concern": 1,
+ "note": "GT 91% -> pred 12% \u2014 FAILURE",
+ "overview": "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_blast_percent_uni2/heatmaps/split-2/S_1411 19 A Score 92 - 10 Fokuspunkte - 2026-03-08 14.01.06/plots/overview-S_1411 19 A Score 92 - 10 Fokuspunkte - 2026-03-08 14.01.06.png"
+ },
+ {
+ "sample_id": "SAMPLE_404_1261_19_A",
+ "stem": "1261 19 A Score 75 -10 Fokuspunkte - 2026-03-07 12.27.22",
+ "split": "split-2",
+ "experiment": "blast_percent",
+ "blast_pct_gt": 90.0,
+ "enrichment": 0.499,
+ "tech_concern": 1,
+ "note": "GT 90% -> pred 12% \u2014 FAILURE",
+ "overview": "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_blast_percent_uni2/heatmaps/split-2/1261 19 A Score 75 -10 Fokuspunkte - 2026-03-07 12.27.22/plots/overview-1261 19 A Score 75 -10 Fokuspunkte - 2026-03-07 12.27.22.png"
+ }
+ ],
+ "explain_pairs": [
+ {
+ "sample_id": "SAMPLE_416_621_19_C",
+ "high_blast": {
+ "sample_id": "SAMPLE_416_621_19_C",
+ "stem": "S_621 19 C Score 100 - 10 Fokuspunkte - entfettet - 2026-03-03 22.52.24",
+ "split": "split-4",
+ "experiment": "high_blast",
+ "blast_pct_gt": 35.0,
+ "enrichment": 0.684,
+ "tech_concern": 0,
+ "note": "HIGH_BLAST attention",
+ "overview": "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_high_blast_uni2/heatmaps/split-4/S_621 19 C Score 100 - 10 Fokuspunkte - entfettet - 2026-03-03 22.52.24/plots/overview-S_621 19 C Score 100 - 10 Fokuspunkte - entfettet - 2026-03-03 22.52.24.png"
+ },
+ "blast_percent": {
+ "sample_id": "SAMPLE_416_621_19_C",
+ "stem": "S_621 19 C Score 100 - 10 Fokuspunkte - entfettet - 2026-03-03 22.52.24",
+ "split": "split-4",
+ "experiment": "blast_percent",
+ "blast_pct_gt": 35.0,
+ "enrichment": 0.286,
+ "tech_concern": 0,
+ "note": "BLAST_PERCENT attention",
+ "overview": "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_blast_percent_uni2/heatmaps/split-4/S_621 19 C Score 100 - 10 Fokuspunkte - entfettet - 2026-03-03 22.52.24/plots/overview-S_621 19 C Score 100 - 10 Fokuspunkte - entfettet - 2026-03-03 22.52.24.png"
+ }
+ },
+ {
+ "sample_id": "SAMPLE_404_1349_19_A",
+ "high_blast": {
+ "sample_id": "SAMPLE_404_1349_19_A",
+ "stem": "1349 19 A Score 78 -10 Fokuspunkte - 2026-03-07 16.38.07",
+ "split": "split-4",
+ "experiment": "high_blast",
+ "blast_pct_gt": 25.0,
+ "enrichment": 0.63,
+ "tech_concern": 1,
+ "note": "HIGH_BLAST attention",
+ "overview": "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_high_blast_uni2/heatmaps/split-4/1349 19 A Score 78 -10 Fokuspunkte - 2026-03-07 16.38.07/plots/overview-1349 19 A Score 78 -10 Fokuspunkte - 2026-03-07 16.38.07.png"
+ },
+ "blast_percent": {
+ "sample_id": "SAMPLE_404_1349_19_A",
+ "stem": "1349 19 A Score 78 -10 Fokuspunkte - 2026-03-07 16.38.07",
+ "split": "split-1",
+ "experiment": "blast_percent",
+ "blast_pct_gt": 25.0,
+ "enrichment": 0.334,
+ "tech_concern": 3,
+ "note": "BLAST_PERCENT attention",
+ "overview": "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_blast_percent_uni2/heatmaps/split-1/1349 19 A Score 78 -10 Fokuspunkte - 2026-03-07 16.38.07/plots/overview-1349 19 A Score 78 -10 Fokuspunkte - 2026-03-07 16.38.07.png"
+ }
+ },
+ {
+ "sample_id": "SAMPLE_357_1131_19_B",
+ "high_blast": {
+ "sample_id": "SAMPLE_357_1131_19_B",
+ "stem": "1131 19 B Score 91 -10 Fokuspunkte - frisches Roticlear - 2026-03-06 21.08.01",
+ "split": "split-3",
+ "experiment": "high_blast",
+ "blast_pct_gt": 56.0,
+ "enrichment": 0.616,
+ "tech_concern": 0,
+ "note": "HIGH_BLAST attention",
+ "overview": "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_high_blast_uni2/heatmaps/split-3/1131 19 B Score 91 -10 Fokuspunkte - frisches Roticlear - 2026-03-06 21.08.01/plots/overview-1131 19 B Score 91 -10 Fokuspunkte - frisches Roticlear - 2026-03-06 21.08.01.png"
+ },
+ "blast_percent": {
+ "sample_id": "SAMPLE_357_1131_19_B",
+ "stem": "1131 19 B Score 91 -10 Fokuspunkte - frisches Roticlear - 2026-03-06 21.08.01",
+ "split": "split-0",
+ "experiment": "blast_percent",
+ "blast_pct_gt": 56.0,
+ "enrichment": 0.277,
+ "tech_concern": 1,
+ "note": "BLAST_PERCENT attention",
+ "overview": "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_blast_percent_uni2/heatmaps/split-0/1131 19 B Score 91 -10 Fokuspunkte - frisches Roticlear - 2026-03-06 21.08.01/plots/overview-1131 19 B Score 91 -10 Fokuspunkte - frisches Roticlear - 2026-03-06 21.08.01.png"
+ }
+ },
+ {
+ "sample_id": "SAMPLE_405_1321_19_A",
+ "high_blast": {
+ "sample_id": "SAMPLE_405_1321_19_A",
+ "stem": "1321 19 A Score 85 -10 Fokuspunkte - 2026-03-07 15.08.02",
+ "split": "split-0",
+ "experiment": "high_blast",
+ "blast_pct_gt": 77.0,
+ "enrichment": 0.55,
+ "tech_concern": 0,
+ "note": "HIGH_BLAST attention",
+ "overview": "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_high_blast_uni2/heatmaps/split-0/1321 19 A Score 85 -10 Fokuspunkte - 2026-03-07 15.08.02/plots/overview-1321 19 A Score 85 -10 Fokuspunkte - 2026-03-07 15.08.02.png"
+ },
+ "blast_percent": {
+ "sample_id": "SAMPLE_405_1321_19_A",
+ "stem": "1321 19 A Score 85 -10 Fokuspunkte - 2026-03-07 15.08.02",
+ "split": "split-0",
+ "experiment": "blast_percent",
+ "blast_pct_gt": 77.0,
+ "enrichment": 0.299,
+ "tech_concern": 0,
+ "note": "BLAST_PERCENT attention",
+ "overview": "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_blast_percent_uni2/heatmaps/split-0/1321 19 A Score 85 -10 Fokuspunkte - 2026-03-07 15.08.02/plots/overview-1321 19 A Score 85 -10 Fokuspunkte - 2026-03-07 15.08.02.png"
+ }
+ },
+ {
+ "sample_id": "SAMPLE_401_960_19_B",
+ "high_blast": {
+ "sample_id": "SAMPLE_401_960_19_B",
+ "stem": "S_960 19 B Score 95 - 5 Fokuspunkte - entfettet - 2026-03-04 21.56.58",
+ "split": "split-2",
+ "experiment": "high_blast",
+ "blast_pct_gt": 43.0,
+ "enrichment": 0.545,
+ "tech_concern": 0,
+ "note": "HIGH_BLAST attention",
+ "overview": "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_high_blast_uni2/heatmaps/split-2/S_960 19 B Score 95 - 5 Fokuspunkte - entfettet - 2026-03-04 21.56.58/plots/overview-S_960 19 B Score 95 - 5 Fokuspunkte - entfettet - 2026-03-04 21.56.58.png"
+ },
+ "blast_percent": {
+ "sample_id": "SAMPLE_401_960_19_B",
+ "stem": "S_960 19 B Score 95 - 5 Fokuspunkte - entfettet - 2026-03-04 21.56.58",
+ "split": "split-0",
+ "experiment": "blast_percent",
+ "blast_pct_gt": 43.0,
+ "enrichment": 0.211,
+ "tech_concern": 1,
+ "note": "BLAST_PERCENT attention",
+ "overview": "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_blast_percent_uni2/heatmaps/split-0/S_960 19 B Score 95 - 5 Fokuspunkte - entfettet - 2026-03-04 21.56.58/plots/overview-S_960 19 B Score 95 - 5 Fokuspunkte - entfettet - 2026-03-04 21.56.58.png"
+ }
+ }
+ ],
+ "response": [
+ {
+ "sample_id": "SAMPLE_416_1012_19_A",
+ "stem": "1012 19 A Score 73 - 10 Fokuspunkte- 2026-03-05 20.15.46",
+ "split": "split-3",
+ "experiment": "response",
+ "gt": "no",
+ "pred": "no",
+ "p_yes": 0.02,
+ "correct": true,
+ "enrichment": 0.419,
+ "background": 0.002,
+ "overview": "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_response_uni2/heatmaps/split-3/1012 19 A Score 73 - 10 Fokuspunkte- 2026-03-05 20.15.46/plots/overview-1012 19 A Score 73 - 10 Fokuspunkte- 2026-03-05 20.15.46.png"
+ },
+ {
+ "sample_id": "SAMPLE_396_1000_19_B",
+ "stem": "1000 19 B Score 64 - 10 Fokuspunkte- 2026-03-05 19.26.44",
+ "split": "split-4",
+ "experiment": "response",
+ "gt": "no",
+ "pred": "no",
+ "p_yes": 0.001,
+ "correct": true,
+ "enrichment": 0.418,
+ "background": 0.001,
+ "overview": "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_response_uni2/heatmaps/split-4/1000 19 B Score 64 - 10 Fokuspunkte- 2026-03-05 19.26.44/plots/overview-1000 19 B Score 64 - 10 Fokuspunkte- 2026-03-05 19.26.44.png"
+ },
+ {
+ "sample_id": "SAMPLE_397_895_19_B",
+ "stem": "S_895 19 B Score 97 - 10 Fokuspunkte - entfettet - 2026-03-04 20.46.51",
+ "split": "split-2",
+ "experiment": "response",
+ "gt": "no",
+ "pred": "no",
+ "p_yes": 0.262,
+ "correct": true,
+ "enrichment": 0.392,
+ "background": 0.011,
+ "overview": "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_response_uni2/heatmaps/split-2/S_895 19 B Score 97 - 10 Fokuspunkte - entfettet - 2026-03-04 20.46.51/plots/overview-S_895 19 B Score 97 - 10 Fokuspunkte - entfettet - 2026-03-04 20.46.51.png"
+ },
+ {
+ "sample_id": "SAMPLE_396_1000_19_A",
+ "stem": "1000 19 A Score 58 - 10 Fokuspunkte- 2026-03-05 19.23.09",
+ "split": "split-0",
+ "experiment": "response",
+ "gt": "no",
+ "pred": "no",
+ "p_yes": 0.005,
+ "correct": true,
+ "enrichment": 0.377,
+ "background": 0.004,
+ "overview": "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_response_uni2/heatmaps/split-0/1000 19 A Score 58 - 10 Fokuspunkte- 2026-03-05 19.23.09/plots/overview-1000 19 A Score 58 - 10 Fokuspunkte- 2026-03-05 19.23.09.png"
+ },
+ {
+ "sample_id": "SAMPLE_416_1012_19_C",
+ "stem": "1012 19 C Score 74 - 10 Fokuspunkte- 2026-03-05 20.27.13",
+ "split": "split-0",
+ "experiment": "response",
+ "gt": "no",
+ "pred": "no",
+ "p_yes": 0.011,
+ "correct": true,
+ "enrichment": 0.359,
+ "background": 0.003,
+ "overview": "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_response_uni2/heatmaps/split-0/1012 19 C Score 74 - 10 Fokuspunkte- 2026-03-05 20.27.13/plots/overview-1012 19 C Score 74 - 10 Fokuspunkte- 2026-03-05 20.27.13.png"
+ },
+ {
+ "sample_id": "SAMPLE_396_1000_19_C",
+ "stem": "1000 19 C Score 59 - 10 Fokuspunkte- 2026-03-05 19.30.52",
+ "split": "split-2",
+ "experiment": "response",
+ "gt": "no",
+ "pred": "no",
+ "p_yes": 0.002,
+ "correct": true,
+ "enrichment": 0.244,
+ "background": 0.005,
+ "overview": "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_response_uni2/heatmaps/split-2/1000 19 C Score 59 - 10 Fokuspunkte- 2026-03-05 19.30.52/plots/overview-1000 19 C Score 59 - 10 Fokuspunkte- 2026-03-05 19.30.52.png"
+ },
+ {
+ "sample_id": "SAMPLE_391_189_19_B",
+ "stem": "189-19 B Score 31 - 10 Fokuspunkte - nicht entfettet",
+ "split": "split-2",
+ "experiment": "response",
+ "gt": "yes",
+ "pred": "yes",
+ "p_yes": 0.959,
+ "correct": true,
+ "enrichment": 0.043,
+ "background": 0.026,
+ "overview": "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_response_uni2/heatmaps/split-2/189-19 B Score 31 - 10 Fokuspunkte - nicht entfettet/plots/overview-189-19 B Score 31 - 10 Fokuspunkte - nicht entfettet.png"
+ },
+ {
+ "sample_id": "SAMPLE_379_931_19_A",
+ "stem": "931 19 A Score 30 - 5 Fokuspunkte - entfettet - 2026-03-04 21.23.41",
+ "split": "split-2",
+ "experiment": "response",
+ "gt": "yes",
+ "pred": "yes",
+ "p_yes": 0.984,
+ "correct": true,
+ "enrichment": 0.009,
+ "background": 0.0,
+ "overview": "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_response_uni2/heatmaps/split-2/931 19 A Score 30 - 5 Fokuspunkte - entfettet - 2026-03-04 21.23.41/plots/overview-931 19 A Score 30 - 5 Fokuspunkte - entfettet - 2026-03-04 21.23.41.png"
+ },
+ {
+ "sample_id": "SAMPLE_391_189_19_C",
+ "stem": "189-19 C Score 82 - 10 Fokuspunkte - nicht entfettet",
+ "split": "split-3",
+ "experiment": "response",
+ "gt": "yes",
+ "pred": "yes",
+ "p_yes": 0.85,
+ "correct": true,
+ "enrichment": 0.009,
+ "background": 0.081,
+ "overview": "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_response_uni2/heatmaps/split-3/189-19 C Score 82 - 10 Fokuspunkte - nicht entfettet/plots/overview-189-19 C Score 82 - 10 Fokuspunkte - nicht entfettet.png"
+ },
+ {
+ "sample_id": "SAMPLE_380_1020_19_A",
+ "stem": "1020 19 A Score 93 - 10 Fokuspunkte- 2026-03-05 20.40.52",
+ "split": "split-0",
+ "experiment": "response",
+ "gt": "yes",
+ "pred": "yes",
+ "p_yes": 0.875,
+ "correct": true,
+ "enrichment": 0.008,
+ "background": 0.011,
+ "overview": "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_response_uni2/heatmaps/split-0/1020 19 A Score 93 - 10 Fokuspunkte- 2026-03-05 20.40.52/plots/overview-1020 19 A Score 93 - 10 Fokuspunkte- 2026-03-05 20.40.52.png"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/presentations/assets/exp_blast_percent.png b/presentations/assets/exp_blast_percent.png
new file mode 100644
index 00000000..a39b4259
Binary files /dev/null and b/presentations/assets/exp_blast_percent.png differ
diff --git a/presentations/assets/exp_high_blast.png b/presentations/assets/exp_high_blast.png
new file mode 100644
index 00000000..481b0ac5
Binary files /dev/null and b/presentations/assets/exp_high_blast.png differ
diff --git a/presentations/assets/exp_response.png b/presentations/assets/exp_response.png
new file mode 100644
index 00000000..49ee3ee7
Binary files /dev/null and b/presentations/assets/exp_response.png differ
diff --git a/presentations/assets/fig_auprc_calibration.png b/presentations/assets/fig_auprc_calibration.png
new file mode 100644
index 00000000..0c3dcfac
Binary files /dev/null and b/presentations/assets/fig_auprc_calibration.png differ
diff --git a/presentations/assets/fig_cohort_cv.png b/presentations/assets/fig_cohort_cv.png
new file mode 100644
index 00000000..b5a98339
Binary files /dev/null and b/presentations/assets/fig_cohort_cv.png differ
diff --git a/presentations/assets/fig_confounder.png b/presentations/assets/fig_confounder.png
new file mode 100644
index 00000000..1f7c8be7
Binary files /dev/null and b/presentations/assets/fig_confounder.png differ
diff --git a/presentations/assets/fig_explain_enrichment.png b/presentations/assets/fig_explain_enrichment.png
new file mode 100644
index 00000000..36f86d0d
Binary files /dev/null and b/presentations/assets/fig_explain_enrichment.png differ
diff --git a/presentations/assets/fig_labels.png b/presentations/assets/fig_labels.png
new file mode 100644
index 00000000..f3d3e27d
Binary files /dev/null and b/presentations/assets/fig_labels.png differ
diff --git a/presentations/assets/fig_response_beyond_blast.png b/presentations/assets/fig_response_beyond_blast.png
new file mode 100644
index 00000000..293323f4
Binary files /dev/null and b/presentations/assets/fig_response_beyond_blast.png differ
diff --git a/presentations/assets/fig_sample_vs_patient.png b/presentations/assets/fig_sample_vs_patient.png
new file mode 100644
index 00000000..6b007021
Binary files /dev/null and b/presentations/assets/fig_sample_vs_patient.png differ
diff --git a/presentations/assets/fig_workflow.png b/presentations/assets/fig_workflow.png
new file mode 100644
index 00000000..4b86f0a1
Binary files /dev/null and b/presentations/assets/fig_workflow.png differ
diff --git a/presentations/assets/metrics.json b/presentations/assets/metrics.json
new file mode 100644
index 00000000..e46c958b
--- /dev/null
+++ b/presentations/assets/metrics.json
@@ -0,0 +1,93 @@
+{
+ "sample": {
+ "response": {
+ "auroc": 0.8757197323373794,
+ "lo": 0.8385320431128196,
+ "hi": 0.9093426204127778,
+ "auprc": 0.9127622482501071,
+ "n": 489,
+ "pos_rate": 0.6871165644171779
+ },
+ "high_blast": {
+ "auroc": 0.9307114150007828,
+ "lo": 0.8991408278920162,
+ "hi": 0.9586137292355886,
+ "auprc": 0.8351624231875439,
+ "n": 489,
+ "pos_rate": 0.20040899795501022
+ },
+ "blast_severity": {
+ "auroc": 0.851655924315977,
+ "lo": NaN,
+ "hi": NaN,
+ "per_class": {
+ "high": 0.9112166605772745,
+ "intermediate": 0.7777963971512357,
+ "low": 0.8659547152194211
+ },
+ "n": 489
+ },
+ "blast_percent": {
+ "pearson": 0.6505631822963103,
+ "r2": 0.02233102186784308,
+ "mae": 10.855219703141014,
+ "n": 489
+ }
+ },
+ "patient": {
+ "response": {
+ "auroc": 0.8293261749144102,
+ "lo": 0.7888875789279015,
+ "hi": 0.8666808283569561,
+ "auprc": 0.9074885825107293,
+ "n": 489,
+ "pos_rate": 0.6871165644171779
+ },
+ "high_blast": {
+ "auroc": 0.8994467352158255,
+ "lo": 0.8634412347844808,
+ "hi": 0.9315390568620953,
+ "auprc": 0.7672282640505836,
+ "n": 489,
+ "pos_rate": 0.20040899795501022
+ },
+ "blast_severity": {
+ "auroc": 0.7896417064849656,
+ "lo": NaN,
+ "hi": NaN,
+ "per_class": {
+ "high": 0.833707396001879,
+ "intermediate": 0.7116883116883117,
+ "low": 0.8235294117647058
+ },
+ "n": 489
+ },
+ "blast_percent": {
+ "pearson": 0.6475229405037475,
+ "r2": 0.04467265553761113,
+ "mae": 10.800922835851374,
+ "n": 489
+ }
+ },
+ "response_beyond_blast": {
+ "blast_response_corr": -0.7246743061484939,
+ "resp_rate_by_band": {
+ "low\n(<5%)": 1.0,
+ "intermediate\n(5\u201319%)": 0.0,
+ "high\n(\u226520%)": 0.0
+ },
+ "n_by_band": {
+ "low\n(<5%)": 336,
+ "intermediate\n(5\u201319%)": 55,
+ "high\n(\u226520%)": 98
+ },
+ "response_equals_lt5_blast": true,
+ "strata_single_class": true
+ },
+ "confounder": {
+ "prop10_low": 0.771117166212534,
+ "prop10_high": 0.8556701030927835,
+ "protocol_from_score_auroc": 0.5871250139400023,
+ "n": 464
+ }
+}
\ No newline at end of file
diff --git a/presentations/assets/patientcv_configs.json b/presentations/assets/patientcv_configs.json
new file mode 100644
index 00000000..24cd72f7
--- /dev/null
+++ b/presentations/assets/patientcv_configs.json
@@ -0,0 +1,18 @@
+{
+ "response": {
+ "config": "/mnt/nvme0n1p1/scratch/jeff/Projects/STAMP/src/stamp/stamp_aml_response_uni2_patientcv_config.yaml",
+ "crossval_dir": "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_response_uni2_patientcv/crossval"
+ },
+ "high_blast": {
+ "config": "/mnt/nvme0n1p1/scratch/jeff/Projects/STAMP/src/stamp/stamp_aml_high_blast_uni2_patientcv_config.yaml",
+ "crossval_dir": "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_high_blast_uni2_patientcv/crossval"
+ },
+ "blast_severity": {
+ "config": "/mnt/nvme0n1p1/scratch/jeff/Projects/STAMP/src/stamp/stamp_aml_blast_severity_uni2_patientcv_config.yaml",
+ "crossval_dir": "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_blast_severity_uni2_patientcv/crossval"
+ },
+ "blast_percent": {
+ "config": "/mnt/nvme0n1p1/scratch/jeff/Projects/STAMP/src/stamp/stamp_aml_blast_percent_uni2_patientcv_config.yaml",
+ "crossval_dir": "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_blast_percent_uni2_patientcv/crossval"
+ }
+}
\ No newline at end of file
diff --git a/scripts/analyze_blast_enrichment_triage.py b/scripts/analyze_blast_enrichment_triage.py
new file mode 100644
index 00000000..f65816a2
--- /dev/null
+++ b/scripts/analyze_blast_enrichment_triage.py
@@ -0,0 +1,1908 @@
+#!/usr/bin/env python3
+"""Non-diagnostic blast-enrichment triage for AML STAMP heatmap tiles.
+
+This script does not detect blasts. It combines existing STAMP heatmap tile
+scores with conservative image-quality and cell-like object visibility metrics
+to rank slides/tiles for review while pathologist feedback is pending.
+"""
+
+from __future__ import annotations
+
+import argparse
+import csv
+import html
+import json
+import math
+import re
+import shutil
+from collections import defaultdict
+from dataclasses import dataclass
+from datetime import UTC, datetime
+from pathlib import Path
+from typing import Any
+
+import cv2
+import numpy as np
+from PIL import Image, ImageDraw, ImageFont
+from scipy.stats import spearmanr
+from sklearn.metrics import average_precision_score, roc_auc_score
+
+
+ROOT = Path(__file__).resolve().parents[1]
+DATA_ROOT = Path("/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen")
+DEFAULT_OUT = ROOT / "validation_report" / "blast_enrichment_triage"
+SLIDE_TABLE = ROOT / "tables" / "stamp_slide.csv"
+CLINI_TABLE = ROOT / "tables" / "stamp_clini.csv"
+
+EXPERIMENTS = {
+ "blast_percent": {
+ "display_name": "BLAST_PERCENT regression",
+ "base_dir": DATA_ROOT / "stamp_aml_blast_percent_uni2",
+ "task": "regression",
+ },
+ "high_blast": {
+ "display_name": "HIGH_BLAST classification",
+ "base_dir": DATA_ROOT / "stamp_aml_high_blast_uni2",
+ "task": "classification",
+ },
+}
+
+TILE_RE = re.compile(r"^(top|bottom)_(\d+)-(.+)\.jpg$")
+LANCZOS = Image.Resampling.LANCZOS
+
+
+@dataclass(frozen=True)
+class SourceTile:
+ kind: str
+ rank: int
+ attention_label: str
+ attention_score: float
+ path: Path
+
+
+@dataclass(frozen=True)
+class SlideSpec:
+ experiment: str
+ split: str
+ stem: str
+ sample_id: str
+ filename: str
+ clinical: dict[str, str]
+ prediction: dict[str, str]
+ tile_dir: Path
+ top_tiles: list[SourceTile]
+ bottom_tiles: list[SourceTile]
+
+ @property
+ def slide_key(self) -> str:
+ return f"{self.experiment}|{self.split}|{self.stem}"
+
+
+@dataclass(frozen=True)
+class CandidateSummary:
+ contours: list[np.ndarray]
+ count: int
+ area_fraction: float
+ mask_fraction: float
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(
+ description="Run conservative blast-enrichment triage on STAMP heatmap tiles."
+ )
+ parser.add_argument("--out", type=Path, default=DEFAULT_OUT)
+ parser.add_argument(
+ "--experiments",
+ default="blast_percent,high_blast",
+ help="Comma-separated subset of: blast_percent,high_blast",
+ )
+ parser.add_argument("--top-k", type=int, default=8)
+ parser.add_argument("--bottom-k", type=int, default=8)
+ parser.add_argument("--zoom-px", type=int, default=512)
+ parser.add_argument("--overlay-slides", type=int, default=30)
+ parser.add_argument("--limit-slides", type=int, default=None)
+ parser.add_argument("--dry-run", action="store_true")
+ parser.add_argument("--write-all-overlays", action="store_true")
+ return parser.parse_args()
+
+
+def parse_experiments(raw: str) -> list[str]:
+ experiments = [part.strip() for part in raw.split(",") if part.strip()]
+ unknown = sorted(set(experiments) - set(EXPERIMENTS))
+ if unknown:
+ raise SystemExit(f"unknown experiment(s): {', '.join(unknown)}")
+ ordered = [key for key in EXPERIMENTS if key in experiments]
+ if not ordered:
+ raise SystemExit("no experiments selected")
+ return ordered
+
+
+def read_csv_rows(path: Path) -> list[dict[str, str]]:
+ with path.open(newline="") as fp:
+ return list(csv.DictReader(fp))
+
+
+def load_slide_table() -> dict[str, dict[str, str]]:
+ by_stem: dict[str, dict[str, str]] = {}
+ for row in read_csv_rows(SLIDE_TABLE):
+ stem = Path(row["FILENAME"]).stem
+ by_stem[stem] = row
+ return by_stem
+
+
+def load_clinical_table() -> dict[str, dict[str, str]]:
+ return {row["SAMPLE_ID"]: row for row in read_csv_rows(CLINI_TABLE)}
+
+
+def load_predictions(experiment: str) -> dict[tuple[str, str], dict[str, str]]:
+ base_dir = EXPERIMENTS[experiment]["base_dir"]
+ predictions: dict[tuple[str, str], dict[str, str]] = {}
+ for path in sorted((base_dir / "crossval").glob("split-*/patient-preds.csv")):
+ split = path.parent.name
+ for row in read_csv_rows(path):
+ predictions[(split, row["SAMPLE_ID"])] = row
+ return predictions
+
+
+def parse_tile(path: Path) -> SourceTile | None:
+ match = TILE_RE.match(path.name)
+ if not match:
+ return None
+ kind, rank_text, rest = match.groups()
+ try:
+ before_score, score_text = rest.rsplit("=", 1)
+ _, label = before_score.rsplit("-", 1)
+ score = float(score_text)
+ except ValueError:
+ return None
+ return SourceTile(
+ kind=kind,
+ rank=int(rank_text),
+ attention_label=label,
+ attention_score=score,
+ path=path,
+ )
+
+
+def collect_ranked_tiles(
+ tile_dir: Path, kind: str, count: int
+) -> tuple[list[SourceTile], list[int]]:
+ by_rank: dict[int, SourceTile] = {}
+ for path in tile_dir.glob(f"{kind}_*.jpg"):
+ tile = parse_tile(path)
+ if tile is None or tile.kind != kind:
+ continue
+ by_rank[tile.rank] = tile
+ missing = [rank for rank in range(1, count + 1) if rank not in by_rank]
+ return [by_rank[rank] for rank in range(1, count + 1) if rank in by_rank], missing
+
+
+def discover_slides_for_experiment(
+ *,
+ experiment: str,
+ top_k: int,
+ bottom_k: int,
+ limit_slides: int | None,
+ stem_to_slide: dict[str, dict[str, str]],
+ clinical_by_sample: dict[str, dict[str, str]],
+ predictions: dict[tuple[str, str], dict[str, str]],
+) -> tuple[list[SlideSpec], list[dict[str, Any]]]:
+ base_dir = EXPERIMENTS[experiment]["base_dir"]
+ heatmap_root = base_dir / "heatmaps"
+ slides: list[SlideSpec] = []
+ skipped: list[dict[str, Any]] = []
+
+ tile_dirs = sorted(
+ heatmap_root.glob("split-*/*/tiles"),
+ key=lambda path: (path.parent.parent.name, path.parent.name),
+ )
+ for tile_dir in tile_dirs:
+ split = tile_dir.parent.parent.name
+ stem = tile_dir.parent.name
+ slide_row = stem_to_slide.get(stem)
+ if slide_row is None:
+ skipped.append(
+ {
+ "experiment": experiment,
+ "split": split,
+ "stem": stem,
+ "reason": "missing slide-table mapping",
+ }
+ )
+ continue
+
+ top_tiles, missing_top = collect_ranked_tiles(tile_dir, "top", top_k)
+ bottom_tiles, missing_bottom = collect_ranked_tiles(
+ tile_dir, "bottom", bottom_k
+ )
+ if missing_top or missing_bottom:
+ skipped.append(
+ {
+ "experiment": experiment,
+ "split": split,
+ "stem": stem,
+ "sample_id": slide_row["SAMPLE_ID"],
+ "reason": "incomplete heatmap tile ranks",
+ "missing_top": missing_top,
+ "missing_bottom": missing_bottom,
+ }
+ )
+ continue
+
+ sample_id = slide_row["SAMPLE_ID"]
+ slides.append(
+ SlideSpec(
+ experiment=experiment,
+ split=split,
+ stem=stem,
+ sample_id=sample_id,
+ filename=slide_row["FILENAME"],
+ clinical=clinical_by_sample.get(sample_id, {}),
+ prediction=predictions.get((split, sample_id), {}),
+ tile_dir=tile_dir,
+ top_tiles=top_tiles,
+ bottom_tiles=bottom_tiles,
+ )
+ )
+
+ if limit_slides is not None:
+ if limit_slides < 1:
+ raise SystemExit("--limit-slides must be at least 1")
+ slides = slides[:limit_slides]
+ return slides, skipped
+
+
+def read_center_crop(path: Path, zoom_px: int) -> tuple[np.ndarray, tuple[int, int]]:
+ with Image.open(path) as image:
+ rgb = image.convert("RGB")
+ width, height = rgb.size
+ if width < zoom_px or height < zoom_px:
+ raise ValueError(f"{path} is {width}x{height}, smaller than {zoom_px}px")
+ left = (width - zoom_px) // 2
+ top = (height - zoom_px) // 2
+ crop = rgb.crop((left, top, left + zoom_px, top + zoom_px))
+ return np.asarray(crop), (width, height)
+
+
+def quality_metrics(rgb: np.ndarray) -> dict[str, float]:
+ gray = cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY)
+ hsv = cv2.cvtColor(rgb, cv2.COLOR_RGB2HSV)
+ saturation = hsv[:, :, 1]
+
+ laplacian_var = float(cv2.Laplacian(gray, cv2.CV_64F).var())
+ sobel_x = cv2.Sobel(gray, cv2.CV_64F, 1, 0, ksize=3)
+ sobel_y = cv2.Sobel(gray, cv2.CV_64F, 0, 1, ksize=3)
+ tenengrad = float(np.mean(sobel_x * sobel_x + sobel_y * sobel_y))
+
+ edges = cv2.Canny(gray, 40, 100)
+ background = (gray > 235) & (saturation < 25)
+ tissue = (gray < 242) & (saturation > 10)
+
+ return {
+ "zoom_brightness_mean": float(gray.mean()),
+ "zoom_brightness_std": float(gray.std()),
+ "zoom_saturation_mean": float(saturation.mean()),
+ "zoom_laplacian_var": laplacian_var,
+ "zoom_tenengrad": tenengrad,
+ "zoom_edge_density": float((edges > 0).mean()),
+ "zoom_background_fraction": float(background.mean()),
+ "zoom_tissue_fraction": float(tissue.mean()),
+ }
+
+
+def segment_cell_like_candidates(rgb: np.ndarray) -> CandidateSummary:
+ gray = cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY)
+ hsv = cv2.cvtColor(rgb, cv2.COLOR_RGB2HSV)
+ saturation = hsv[:, :, 1]
+
+ tissue_mask = ((gray < 242) & (saturation > 10)).astype(np.uint8) * 255
+ stain_mask = ((gray < 210) & (saturation > 25)).astype(np.uint8) * 255
+
+ blurred = cv2.GaussianBlur(gray, (5, 5), 0)
+ otsu = cv2.threshold(blurred, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)[1]
+ mask = cv2.bitwise_and(otsu, stain_mask)
+ mask = cv2.bitwise_and(mask, tissue_mask)
+
+ open_kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3))
+ close_kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
+ mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, open_kernel)
+ mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, close_kernel)
+
+ contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
+
+ accepted: list[np.ndarray] = []
+ accepted_area = 0.0
+ image_area = float(rgb.shape[0] * rgb.shape[1])
+ for contour in contours:
+ area = float(cv2.contourArea(contour))
+ if area < 60 or area > 3500:
+ continue
+ x, y, width, height = cv2.boundingRect(contour)
+ if width < 6 or height < 6 or width > 100 or height > 100:
+ continue
+ aspect = width / max(height, 1)
+ if aspect < 0.25 or aspect > 4.0:
+ continue
+ perimeter = float(cv2.arcLength(contour, True))
+ if perimeter <= 0:
+ continue
+ circularity = 4 * math.pi * area / (perimeter * perimeter)
+ if circularity < 0.15:
+ continue
+ hull = cv2.convexHull(contour)
+ hull_area = float(cv2.contourArea(hull))
+ solidity = area / max(hull_area, 1.0)
+ if solidity < 0.35:
+ continue
+
+ accepted.append(contour)
+ accepted_area += area
+
+ return CandidateSummary(
+ contours=accepted,
+ count=len(accepted),
+ area_fraction=accepted_area / max(image_area, 1.0),
+ mask_fraction=float((mask > 0).mean()),
+ )
+
+
+def positive_attention_score(experiment: str, tile: SourceTile) -> float:
+ score = min(max(tile.attention_score, 0.0), 1.0)
+ if experiment == "blast_percent":
+ return score
+ if experiment == "high_blast":
+ if tile.attention_label == "yes":
+ return score
+ if tile.attention_label == "no":
+ return 1.0 - score
+ return score
+
+
+def float_or_blank(value: Any) -> float | str:
+ if value in ("", None):
+ return ""
+ try:
+ return float(value)
+ except (TypeError, ValueError):
+ return ""
+
+
+def clinical_float(clinical: dict[str, str], key: str) -> float | str:
+ return float_or_blank(clinical.get(key, ""))
+
+
+def analyze_tile(
+ *,
+ slide: SlideSpec,
+ tile: SourceTile,
+ zoom_px: int,
+) -> dict[str, Any]:
+ zoom_rgb, (native_width, native_height) = read_center_crop(tile.path, zoom_px)
+ metrics = quality_metrics(zoom_rgb)
+ candidates = segment_cell_like_candidates(zoom_rgb)
+ blast_percent_gt = clinical_float(slide.clinical, "BLAST_PERCENT")
+
+ row: dict[str, Any] = {
+ "experiment": slide.experiment,
+ "split": slide.split,
+ "slide_key": slide.slide_key,
+ "sample_id": slide.sample_id,
+ "stem": slide.stem,
+ "filename": slide.filename,
+ "tile_kind": tile.kind,
+ "tile_rank": tile.rank,
+ "stamp_attention_label": tile.attention_label,
+ "stamp_attention_score": tile.attention_score,
+ "positive_attention_score": positive_attention_score(slide.experiment, tile),
+ "native_width": native_width,
+ "native_height": native_height,
+ "zoom_px": zoom_px,
+ "tile_path": str(tile.path),
+ "blast_percent_gt": blast_percent_gt,
+ "blast_severity_gt": slide.clinical.get("BLAST_SEVERITY", ""),
+ "high_blast_gt": slide.clinical.get("HIGH_BLAST", ""),
+ "blast_percent_pred": float_or_blank(slide.prediction.get("pred", ""))
+ if slide.experiment == "blast_percent"
+ else "",
+ "blast_percent_abs_err": float_or_blank(slide.prediction.get("loss", ""))
+ if slide.experiment == "blast_percent"
+ else "",
+ "high_blast_pred_label": slide.prediction.get("pred", "")
+ if slide.experiment == "high_blast"
+ else "",
+ "high_blast_yes_pred": float_or_blank(
+ slide.prediction.get("HIGH_BLAST_yes", "")
+ )
+ if slide.experiment == "high_blast"
+ else "",
+ "high_blast_no_pred": float_or_blank(slide.prediction.get("HIGH_BLAST_no", ""))
+ if slide.experiment == "high_blast"
+ else "",
+ "candidate_count": candidates.count,
+ "candidate_area_fraction": candidates.area_fraction,
+ "candidate_mask_fraction": candidates.mask_fraction,
+ "candidate_count_per_megapixel": candidates.count
+ / max((zoom_px * zoom_px) / 1_000_000, 1e-8),
+ }
+ row.update(metrics)
+ return row
+
+
+def mean(values: list[float]) -> float:
+ return float(sum(values) / len(values)) if values else 0.0
+
+
+def median(values: list[float]) -> float:
+ if not values:
+ return 0.0
+ return float(np.median(np.asarray(values, dtype=float)))
+
+
+def percentile(values: list[float], q: float) -> float:
+ if not values:
+ return 0.0
+ return float(np.percentile(np.asarray(values, dtype=float), q))
+
+
+def safe_ratio(numerator: float, denominator: float) -> float:
+ if denominator <= 0:
+ return 0.0
+ return numerator / denominator
+
+
+def as_float(value: Any, default: float = 0.0) -> float:
+ try:
+ if value == "":
+ return default
+ return float(value)
+ except (TypeError, ValueError):
+ return default
+
+
+def normalize_tile_rows(tile_rows: list[dict[str, Any]]) -> None:
+ by_slide: dict[str, list[dict[str, Any]]] = defaultdict(list)
+ for row in tile_rows:
+ by_slide[row["slide_key"]].append(row)
+
+ for rows in by_slide.values():
+ scores = [as_float(row["positive_attention_score"]) for row in rows]
+ min_score = min(scores) if scores else 0.0
+ max_score = max(scores) if scores else 0.0
+ score_range = max_score - min_score
+ max_log_candidate = max(
+ [math.log1p(as_float(row["candidate_count"])) for row in rows] or [0.0]
+ )
+ max_focus = max([as_float(row["zoom_laplacian_var"]) for row in rows] or [0.0])
+
+ for row in rows:
+ positive_score = as_float(row["positive_attention_score"])
+ if score_range > 1e-8:
+ positive_norm = (positive_score - min_score) / score_range
+ else:
+ positive_norm = 0.0
+
+ candidate_norm = (
+ math.log1p(as_float(row["candidate_count"])) / max_log_candidate
+ if max_log_candidate > 0
+ else 0.0
+ )
+ focus_norm = (
+ as_float(row["zoom_laplacian_var"]) / max_focus
+ if max_focus > 0
+ else 0.0
+ )
+ tissue_fraction = as_float(row["zoom_tissue_fraction"])
+ cellular_visibility = (
+ candidate_norm * math.sqrt(focus_norm) * tissue_fraction
+ )
+
+ row["positive_attention_score_slide_norm"] = positive_norm
+ row["cellular_visibility_proxy"] = cellular_visibility
+ row["blast_enrichment_proxy"] = positive_norm * cellular_visibility
+ row["attention_visibility_proxy"] = (
+ as_float(row["stamp_attention_score"]) * cellular_visibility
+ )
+
+
+def rank_blast_percent_failures(tile_rows: list[dict[str, Any]]) -> set[str]:
+ by_sample: dict[str, float] = {}
+ for row in tile_rows:
+ if row["experiment"] != "blast_percent":
+ continue
+ err = row.get("blast_percent_abs_err", "")
+ if err == "":
+ continue
+ by_sample[row["sample_id"]] = max(
+ by_sample.get(row["sample_id"], 0.0), float(err)
+ )
+ ranked = sorted(by_sample.items(), key=lambda item: item[1], reverse=True)
+ return {sample_id for sample_id, _ in ranked[:10]}
+
+
+def summarize_slides(
+ tile_rows: list[dict[str, Any]],
+) -> tuple[list[dict[str, Any]], dict[str, float]]:
+ normalize_tile_rows(tile_rows)
+ failure_top10 = rank_blast_percent_failures(tile_rows)
+
+ by_slide: dict[str, list[dict[str, Any]]] = defaultdict(list)
+ for row in tile_rows:
+ by_slide[row["slide_key"]].append(row)
+
+ prelim: list[dict[str, Any]] = []
+ for slide_key, rows in by_slide.items():
+ first = rows[0]
+ top_rows = [row for row in rows if row["tile_kind"] == "top"]
+ bottom_rows = [row for row in rows if row["tile_kind"] == "bottom"]
+
+ top_enrichment = mean(
+ [as_float(row["blast_enrichment_proxy"]) for row in top_rows]
+ )
+ bottom_enrichment = mean(
+ [as_float(row["blast_enrichment_proxy"]) for row in bottom_rows]
+ )
+ top_visibility = mean(
+ [as_float(row["cellular_visibility_proxy"]) for row in top_rows]
+ )
+ bottom_visibility = mean(
+ [as_float(row["cellular_visibility_proxy"]) for row in bottom_rows]
+ )
+ top_candidates = mean([as_float(row["candidate_count"]) for row in top_rows])
+ bottom_candidates = mean(
+ [as_float(row["candidate_count"]) for row in bottom_rows]
+ )
+ focus_mean = mean([as_float(row["zoom_laplacian_var"]) for row in rows])
+ background_mean = mean(
+ [as_float(row["zoom_background_fraction"]) for row in rows]
+ )
+ tissue_mean = mean([as_float(row["zoom_tissue_fraction"]) for row in rows])
+
+ blast_percent_gt = first["blast_percent_gt"]
+ high_blast_gt = first["high_blast_gt"]
+ blast_percent_pred = first["blast_percent_pred"]
+ blast_percent_abs_err = first["blast_percent_abs_err"]
+ high_blast_yes_pred = first["high_blast_yes_pred"]
+
+ clinical_band = "missing"
+ if blast_percent_gt != "":
+ blast_value = float(blast_percent_gt)
+ if blast_value < 5:
+ clinical_band = "low_blast_lt5"
+ elif blast_value < 20:
+ clinical_band = "intermediate_blast_5_19"
+ else:
+ clinical_band = "high_blast_ge20"
+
+ prelim.append(
+ {
+ "experiment": first["experiment"],
+ "split": first["split"],
+ "slide_key": slide_key,
+ "sample_id": first["sample_id"],
+ "stem": first["stem"],
+ "filename": first["filename"],
+ "clinical_blast_band": clinical_band,
+ "blast_percent_gt": blast_percent_gt,
+ "blast_severity_gt": first["blast_severity_gt"],
+ "high_blast_gt": high_blast_gt,
+ "blast_percent_pred": blast_percent_pred,
+ "blast_percent_abs_err": blast_percent_abs_err,
+ "high_blast_pred_label": first["high_blast_pred_label"],
+ "high_blast_yes_pred": high_blast_yes_pred,
+ "high_blast_no_pred": first["high_blast_no_pred"],
+ "is_blast_percent_top10_failure": int(
+ first["experiment"] == "blast_percent"
+ and first["sample_id"] in failure_top10
+ ),
+ "is_high_blast_confident_yes": int(
+ first["experiment"] == "high_blast"
+ and high_blast_gt == "yes"
+ and high_blast_yes_pred != ""
+ and float(high_blast_yes_pred) >= 0.9
+ ),
+ "is_high_blast_confident_no": int(
+ first["experiment"] == "high_blast"
+ and high_blast_gt == "no"
+ and high_blast_yes_pred != ""
+ and float(high_blast_yes_pred) <= 0.1
+ ),
+ "tile_count": len(rows),
+ "top_enrichment_proxy_mean": top_enrichment,
+ "bottom_enrichment_proxy_mean": bottom_enrichment,
+ "top_minus_bottom_enrichment_proxy": top_enrichment - bottom_enrichment,
+ "top_bottom_enrichment_ratio": safe_ratio(
+ top_enrichment, bottom_enrichment
+ ),
+ "top_cellular_visibility_mean": top_visibility,
+ "bottom_cellular_visibility_mean": bottom_visibility,
+ "top_candidate_count_mean": top_candidates,
+ "bottom_candidate_count_mean": bottom_candidates,
+ "all_candidate_count_mean": mean(
+ [as_float(row["candidate_count"]) for row in rows]
+ ),
+ "top_bottom_candidate_ratio": safe_ratio(
+ top_candidates, bottom_candidates
+ ),
+ "zoom_laplacian_var_mean": focus_mean,
+ "zoom_laplacian_var_median": median(
+ [as_float(row["zoom_laplacian_var"]) for row in rows]
+ ),
+ "zoom_background_fraction_mean": background_mean,
+ "zoom_tissue_fraction_mean": tissue_mean,
+ "overlay_contact_sheet": "",
+ }
+ )
+
+ thresholds = {
+ "low_focus_laplacian_p25": percentile(
+ [row["zoom_laplacian_var_mean"] for row in prelim], 25
+ ),
+ "low_candidate_count_p25": percentile(
+ [row["all_candidate_count_mean"] for row in prelim], 25
+ ),
+ "high_background_fraction_p75": percentile(
+ [row["zoom_background_fraction_mean"] for row in prelim], 75
+ ),
+ }
+
+ slide_rows: list[dict[str, Any]] = []
+ for row in prelim:
+ low_focus = (
+ row["zoom_laplacian_var_mean"] <= thresholds["low_focus_laplacian_p25"]
+ )
+ low_candidates = (
+ row["all_candidate_count_mean"] <= thresholds["low_candidate_count_p25"]
+ )
+ high_background = (
+ row["zoom_background_fraction_mean"]
+ >= thresholds["high_background_fraction_p75"]
+ )
+ ratio = row["top_bottom_candidate_ratio"]
+ candidate_shift = ratio < 0.5 or ratio > 2.0
+ concern_score = (
+ int(low_focus)
+ + int(low_candidates)
+ + int(high_background)
+ + int(candidate_shift)
+ )
+ slide_rows.append(
+ {
+ **row,
+ "flag_low_focus": int(low_focus),
+ "flag_low_candidate_density": int(low_candidates),
+ "flag_high_background": int(high_background),
+ "flag_top_bottom_candidate_shift": int(candidate_shift),
+ "technical_concern_score": concern_score,
+ }
+ )
+
+ return slide_rows, thresholds
+
+
+def summarize_group_rows(slide_rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ group_defs: list[tuple[str, str, Any]] = [
+ (
+ "clinical_blast_band",
+ "low_blast_lt5",
+ lambda row: row["clinical_blast_band"] == "low_blast_lt5",
+ ),
+ (
+ "clinical_blast_band",
+ "intermediate_blast_5_19",
+ lambda row: row["clinical_blast_band"] == "intermediate_blast_5_19",
+ ),
+ (
+ "clinical_blast_band",
+ "high_blast_ge20",
+ lambda row: row["clinical_blast_band"] == "high_blast_ge20",
+ ),
+ (
+ "model_error_control",
+ "blast_percent_top10_failures",
+ lambda row: row["is_blast_percent_top10_failure"] == 1,
+ ),
+ (
+ "confidence_control",
+ "high_blast_confident_yes",
+ lambda row: row["is_high_blast_confident_yes"] == 1,
+ ),
+ (
+ "confidence_control",
+ "high_blast_confident_no",
+ lambda row: row["is_high_blast_confident_no"] == 1,
+ ),
+ ]
+
+ rows_out: list[dict[str, Any]] = []
+ for experiment in sorted({row["experiment"] for row in slide_rows}):
+ exp_rows = [row for row in slide_rows if row["experiment"] == experiment]
+ for group_type, group_name, predicate in group_defs:
+ rows = [row for row in exp_rows if predicate(row)]
+ if not rows:
+ continue
+ rows_out.append(
+ {
+ "experiment": experiment,
+ "group_type": group_type,
+ "group_name": group_name,
+ "slide_count": len(rows),
+ "unique_sample_count": len({row["sample_id"] for row in rows}),
+ "tile_count": sum(int(row["tile_count"]) for row in rows),
+ "blast_percent_gt_mean": mean(
+ [
+ float(row["blast_percent_gt"])
+ for row in rows
+ if row["blast_percent_gt"] != ""
+ ]
+ ),
+ "top_enrichment_proxy_mean": mean(
+ [float(row["top_enrichment_proxy_mean"]) for row in rows]
+ ),
+ "bottom_enrichment_proxy_mean": mean(
+ [float(row["bottom_enrichment_proxy_mean"]) for row in rows]
+ ),
+ "top_minus_bottom_enrichment_proxy_mean": mean(
+ [
+ float(row["top_minus_bottom_enrichment_proxy"])
+ for row in rows
+ ]
+ ),
+ "top_candidate_count_mean": mean(
+ [float(row["top_candidate_count_mean"]) for row in rows]
+ ),
+ "bottom_candidate_count_mean": mean(
+ [float(row["bottom_candidate_count_mean"]) for row in rows]
+ ),
+ "zoom_laplacian_var_mean": mean(
+ [float(row["zoom_laplacian_var_mean"]) for row in rows]
+ ),
+ "background_fraction_mean": mean(
+ [float(row["zoom_background_fraction_mean"]) for row in rows]
+ ),
+ "technical_concern_score_mean": mean(
+ [float(row["technical_concern_score"]) for row in rows]
+ ),
+ "slides_with_any_concern": sum(
+ 1 for row in rows if int(row["technical_concern_score"]) > 0
+ ),
+ }
+ )
+ return rows_out
+
+
+def metric_or_blank(fn: Any, y_true: list[int], y_score: list[float]) -> float | str:
+ try:
+ if len(set(y_true)) < 2:
+ return ""
+ return float(fn(y_true, y_score))
+ except ValueError:
+ return ""
+
+
+def summarize_experiments(slide_rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ summaries: list[dict[str, Any]] = []
+ for experiment in sorted({row["experiment"] for row in slide_rows}):
+ rows = [row for row in slide_rows if row["experiment"] == experiment]
+ labeled = [row for row in rows if row["blast_percent_gt"] != ""]
+ high_blast_rows = [row for row in rows if row["high_blast_gt"] in {"yes", "no"}]
+
+ spearman_top = ""
+ spearman_delta = ""
+ spearman_top_p = ""
+ spearman_delta_p = ""
+ if len(labeled) >= 3:
+ y = [float(row["blast_percent_gt"]) for row in labeled]
+ top_scores = [float(row["top_enrichment_proxy_mean"]) for row in labeled]
+ delta_scores = [
+ float(row["top_minus_bottom_enrichment_proxy"]) for row in labeled
+ ]
+ top_result = spearmanr(y, top_scores)
+ delta_result = spearmanr(y, delta_scores)
+ spearman_top = float(top_result.statistic)
+ spearman_top_p = float(top_result.pvalue)
+ spearman_delta = float(delta_result.statistic)
+ spearman_delta_p = float(delta_result.pvalue)
+
+ y_true = [1 if row["high_blast_gt"] == "yes" else 0 for row in high_blast_rows]
+ y_top = [float(row["top_enrichment_proxy_mean"]) for row in high_blast_rows]
+ y_delta = [
+ float(row["top_minus_bottom_enrichment_proxy"]) for row in high_blast_rows
+ ]
+ failures = [row for row in rows if row["is_blast_percent_top10_failure"] == 1]
+
+ summaries.append(
+ {
+ "experiment": experiment,
+ "display_name": EXPERIMENTS[experiment]["display_name"],
+ "slide_count": len(rows),
+ "unique_sample_count": len({row["sample_id"] for row in rows}),
+ "tile_count": sum(int(row["tile_count"]) for row in rows),
+ "spearman_blast_percent_vs_top_enrichment": spearman_top,
+ "spearman_blast_percent_vs_top_enrichment_p": spearman_top_p,
+ "spearman_blast_percent_vs_delta_enrichment": spearman_delta,
+ "spearman_blast_percent_vs_delta_enrichment_p": spearman_delta_p,
+ "high_blast_auroc_top_enrichment": metric_or_blank(
+ roc_auc_score, y_true, y_top
+ ),
+ "high_blast_auprc_top_enrichment": metric_or_blank(
+ average_precision_score, y_true, y_top
+ ),
+ "high_blast_auroc_delta_enrichment": metric_or_blank(
+ roc_auc_score, y_true, y_delta
+ ),
+ "high_blast_auprc_delta_enrichment": metric_or_blank(
+ average_precision_score, y_true, y_delta
+ ),
+ "top_enrichment_proxy_mean": mean(
+ [float(row["top_enrichment_proxy_mean"]) for row in rows]
+ ),
+ "bottom_enrichment_proxy_mean": mean(
+ [float(row["bottom_enrichment_proxy_mean"]) for row in rows]
+ ),
+ "technical_concern_score_mean": mean(
+ [float(row["technical_concern_score"]) for row in rows]
+ ),
+ "blast_percent_top10_failure_count": len(failures),
+ "blast_percent_top10_failure_top_enrichment_mean": mean(
+ [float(row["top_enrichment_proxy_mean"]) for row in failures]
+ ),
+ "blast_percent_top10_failure_delta_mean": mean(
+ [
+ float(row["top_minus_bottom_enrichment_proxy"])
+ for row in failures
+ ]
+ ),
+ }
+ )
+ return summaries
+
+
+def write_csv(path: Path, rows: list[dict[str, Any]], fields: list[str]) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ with path.open("w", newline="") as fp:
+ writer = csv.DictWriter(fp, fieldnames=fields, extrasaction="ignore")
+ writer.writeheader()
+ for row in rows:
+ writer.writerow(row)
+
+
+def safe_name(value: str, max_len: int = 110) -> str:
+ cleaned = re.sub(r"[^A-Za-z0-9._-]+", "_", value).strip("_")
+ return cleaned[:max_len] or "unnamed"
+
+
+def rel(path: Path, base: Path) -> str:
+ try:
+ return path.relative_to(base).as_posix()
+ except ValueError:
+ return path.as_posix()
+
+
+def save_overlay(
+ *,
+ rgb: np.ndarray,
+ candidates: CandidateSummary,
+ row: dict[str, Any],
+ out_path: Path,
+) -> None:
+ overlay = rgb.copy()
+ cv2.drawContours(overlay, candidates.contours, -1, (0, 220, 0), 2)
+ cv2.rectangle(overlay, (0, 0), (overlay.shape[1], 34), (0, 0, 0), -1)
+ text = (
+ f"{row['tile_kind']}_{int(row['tile_rank']):02d} | "
+ f"candidates {candidates.count} | proxy "
+ f"{as_float(row.get('blast_enrichment_proxy')):.3f} | not blasts"
+ )
+ cv2.putText(
+ overlay,
+ text,
+ (8, 22),
+ cv2.FONT_HERSHEY_SIMPLEX,
+ 0.45,
+ (255, 255, 255),
+ 1,
+ cv2.LINE_AA,
+ )
+ out_path.parent.mkdir(parents=True, exist_ok=True)
+ Image.fromarray(overlay).save(out_path, quality=92)
+
+
+def load_thumb(path: Path, size: int) -> Image.Image:
+ with Image.open(path) as image:
+ thumb = image.convert("RGB")
+ thumb.thumbnail((size, size), LANCZOS)
+ canvas = Image.new("RGB", (size, size), "white")
+ x = (size - thumb.size[0]) // 2
+ y = (size - thumb.size[1]) // 2
+ canvas.paste(thumb, (x, y))
+ return canvas
+
+
+def make_overlay_contact_sheet(
+ *,
+ slide: dict[str, Any],
+ overlays: list[Path],
+ out_path: Path,
+) -> None:
+ margin = 28
+ gap = 10
+ tile_size = 168
+ cols = 4
+ rows = math.ceil(len(overlays) / cols)
+ width = margin * 2 + cols * tile_size + (cols - 1) * gap
+ height = margin * 2 + 84 + rows * (tile_size + 24)
+ canvas = Image.new("RGB", (width, height), "white")
+ draw = ImageDraw.Draw(canvas)
+ font = ImageFont.load_default()
+
+ title = (
+ f"{slide['experiment']} | {slide['sample_id']} | "
+ "non-diagnostic blast-enrichment triage"
+ )
+ draw.text((margin, margin), title, fill=(0, 0, 0), font=font)
+ draw.text(
+ (margin, margin + 22),
+ "Green contours are cell-like candidates only, not validated cells or blasts.",
+ fill=(120, 50, 20),
+ font=font,
+ )
+ draw.text(
+ (margin, margin + 44),
+ (
+ f"GT blast %: {slide['blast_percent_gt']} | "
+ f"top proxy: {as_float(slide['top_enrichment_proxy_mean']):.3f} | "
+ f"delta: {as_float(slide['top_minus_bottom_enrichment_proxy']):.3f}"
+ ),
+ fill=(60, 60, 60),
+ font=font,
+ )
+
+ start_y = margin + 74
+ for idx, path in enumerate(overlays):
+ row, col = divmod(idx, cols)
+ x = margin + col * (tile_size + gap)
+ y = start_y + row * (tile_size + 24)
+ thumb = load_thumb(path, tile_size)
+ canvas.paste(thumb, (x, y))
+ draw.rectangle(
+ [x, y, x + tile_size - 1, y + tile_size - 1],
+ outline=(180, 180, 180),
+ )
+ draw.text((x + 4, y + tile_size + 4), path.stem, fill=(60, 60, 60))
+
+ out_path.parent.mkdir(parents=True, exist_ok=True)
+ canvas.save(out_path, quality=90)
+
+
+def select_overlay_slides(
+ slide_rows: list[dict[str, Any]], overlay_slides: int, write_all: bool
+) -> list[dict[str, Any]]:
+ if write_all:
+ return slide_rows
+ if overlay_slides <= 0:
+ return []
+
+ selected: dict[str, dict[str, Any]] = {}
+
+ def add(rows: list[dict[str, Any]], limit: int) -> None:
+ for row in rows:
+ if len(selected) >= overlay_slides:
+ return
+ if len(selected) >= limit and limit < overlay_slides:
+ return
+ selected.setdefault(row["slide_key"], row)
+
+ concern_ranked = sorted(
+ slide_rows,
+ key=lambda row: (
+ int(row["technical_concern_score"]),
+ float(row["zoom_background_fraction_mean"]),
+ -float(row["zoom_laplacian_var_mean"]),
+ ),
+ reverse=True,
+ )
+ failure_ranked = sorted(
+ [row for row in slide_rows if row["is_blast_percent_top10_failure"] == 1],
+ key=lambda row: as_float(row["blast_percent_abs_err"]),
+ reverse=True,
+ )
+ confident_yes = sorted(
+ [row for row in slide_rows if row["is_high_blast_confident_yes"] == 1],
+ key=lambda row: as_float(row["high_blast_yes_pred"]),
+ reverse=True,
+ )
+ confident_no = sorted(
+ [row for row in slide_rows if row["is_high_blast_confident_no"] == 1],
+ key=lambda row: as_float(row["high_blast_yes_pred"]),
+ )
+ high_delta = sorted(
+ slide_rows,
+ key=lambda row: abs(float(row["top_minus_bottom_enrichment_proxy"])),
+ reverse=True,
+ )
+
+ quotas = [
+ (concern_ranked, min(10, overlay_slides)),
+ (failure_ranked, min(20, overlay_slides)),
+ (confident_yes, min(25, overlay_slides)),
+ (confident_no, min(30, overlay_slides)),
+ (high_delta, overlay_slides),
+ ]
+ for rows, cumulative_limit in quotas:
+ add(rows, cumulative_limit)
+ if len(selected) >= overlay_slides:
+ break
+
+ return list(selected.values())
+
+
+def write_selected_overlays(
+ *,
+ out_dir: Path,
+ slide_rows: list[dict[str, Any]],
+ tile_rows: list[dict[str, Any]],
+ zoom_px: int,
+ overlay_slides: int,
+ write_all: bool,
+) -> list[dict[str, Any]]:
+ selected_slides = select_overlay_slides(slide_rows, overlay_slides, write_all)
+ if not selected_slides:
+ return []
+
+ overlay_root = out_dir / "overlays"
+ if overlay_root.exists():
+ shutil.rmtree(overlay_root)
+
+ rows_by_slide: dict[str, list[dict[str, Any]]] = defaultdict(list)
+ for row in tile_rows:
+ rows_by_slide[row["slide_key"]].append(row)
+
+ selected_keys = {row["slide_key"] for row in selected_slides}
+ selected_slide_by_key = {row["slide_key"]: row for row in selected_slides}
+ overlay_records: list[dict[str, Any]] = []
+
+ for slide_key in selected_keys:
+ slide = selected_slide_by_key[slide_key]
+ overlay_dir = (
+ out_dir
+ / "overlays"
+ / slide["experiment"]
+ / f"{safe_name(slide['sample_id'])}__{safe_name(slide['stem'], 70)}"
+ )
+ overlay_paths: list[Path] = []
+ rows = sorted(
+ rows_by_slide[slide_key],
+ key=lambda row: (row["tile_kind"] != "top", int(row["tile_rank"])),
+ )
+ for row in rows:
+ zoom_rgb, _ = read_center_crop(Path(row["tile_path"]), zoom_px)
+ candidates = segment_cell_like_candidates(zoom_rgb)
+ overlay_path = (
+ overlay_dir
+ / "tiles"
+ / f"{row['tile_kind']}_{int(row['tile_rank']):02d}_overlay.jpg"
+ )
+ save_overlay(
+ rgb=zoom_rgb,
+ candidates=candidates,
+ row=row,
+ out_path=overlay_path,
+ )
+ overlay_paths.append(overlay_path)
+
+ contact_path = overlay_dir / "overlay_contact_sheet.jpg"
+ make_overlay_contact_sheet(
+ slide=slide,
+ overlays=overlay_paths,
+ out_path=contact_path,
+ )
+ slide["overlay_contact_sheet"] = str(contact_path)
+ overlay_records.append(
+ {
+ "slide_key": slide_key,
+ "experiment": slide["experiment"],
+ "sample_id": slide["sample_id"],
+ "contact_sheet": str(contact_path),
+ "tile_overlay_count": len(overlay_paths),
+ }
+ )
+
+ return overlay_records
+
+
+def format_number(value: Any, digits: int = 3) -> str:
+ if value == "":
+ return ""
+ try:
+ numeric = float(value)
+ except (TypeError, ValueError):
+ return str(value)
+ if math.isnan(numeric):
+ return ""
+ return f"{numeric:.{digits}f}"
+
+
+def format_p_value(value: Any) -> str:
+ if value == "":
+ return ""
+ try:
+ numeric = float(value)
+ except (TypeError, ValueError):
+ return str(value)
+ if math.isnan(numeric):
+ return ""
+ if numeric < 0.001:
+ return f"{numeric:.2e}"
+ return f"{numeric:.3f}"
+
+
+def html_table(rows: list[dict[str, Any]], fields: list[str]) -> str:
+ if not rows:
+ return "
No rows.
"
+ parts = ["", ""]
+ for field in fields:
+ parts.append(f"| {html.escape(field)} | ")
+ parts.append("
")
+ for row in rows:
+ parts.append("")
+ for field in fields:
+ value = row.get(field, "")
+ if isinstance(value, float):
+ value = format_number(value)
+ parts.append(f"| {html.escape(str(value))} | ")
+ parts.append("
")
+ parts.append("
")
+ return "\n".join(parts)
+
+
+def html_list(items: list[str]) -> str:
+ return "" + "".join(f"- {html.escape(item)}
" for item in items) + "
"
+
+
+def markdown_list(items: list[str]) -> str:
+ return "\n".join(f"- {item}" for item in items)
+
+
+def row_by_key(rows: list[dict[str, Any]], key: str, value: str) -> dict[str, Any]:
+ for row in rows:
+ if row.get(key) == value:
+ return row
+ return {}
+
+
+def group_row(
+ rows: list[dict[str, Any]], experiment: str, group_name: str
+) -> dict[str, Any]:
+ for row in rows:
+ if row.get("experiment") == experiment and row.get("group_name") == group_name:
+ return row
+ return {}
+
+
+def mean_from_rows(rows: list[dict[str, Any]], key: str) -> float:
+ values = [as_float(row.get(key, "")) for row in rows if row.get(key, "") != ""]
+ return mean(values)
+
+
+def build_report_summary(
+ *,
+ tile_rows: list[dict[str, Any]],
+ slide_rows: list[dict[str, Any]],
+ group_rows: list[dict[str, Any]],
+ experiment_rows: list[dict[str, Any]],
+ skipped: list[dict[str, Any]],
+ overlay_records: list[dict[str, Any]],
+) -> dict[str, Any]:
+ blast_percent = row_by_key(experiment_rows, "experiment", "blast_percent")
+ high_blast = row_by_key(experiment_rows, "experiment", "high_blast")
+ high_blast_high = group_row(group_rows, "high_blast", "high_blast_ge20")
+ high_blast_low = group_row(group_rows, "high_blast", "low_blast_lt5")
+ high_blast_conf_yes = group_row(
+ group_rows, "high_blast", "high_blast_confident_yes"
+ )
+ high_blast_conf_no = group_row(group_rows, "high_blast", "high_blast_confident_no")
+ blast_percent_high = group_row(group_rows, "blast_percent", "high_blast_ge20")
+ blast_percent_low = group_row(group_rows, "blast_percent", "low_blast_lt5")
+ blast_percent_failures = group_row(
+ group_rows, "blast_percent", "blast_percent_top10_failures"
+ )
+
+ top_failures = sorted(
+ [row for row in slide_rows if row["is_blast_percent_top10_failure"] == 1],
+ key=lambda row: as_float(row["blast_percent_abs_err"]),
+ reverse=True,
+ )
+
+ slide_count = len(slide_rows)
+ tile_count = len(tile_rows)
+ unique_samples = len({row["sample_id"] for row in slide_rows})
+ overlay_count = len(overlay_records)
+ failure_gt_mean = as_float(blast_percent_failures.get("blast_percent_gt_mean", ""))
+ failure_pred_mean = mean_from_rows(top_failures, "blast_percent_pred")
+ failure_err_mean = mean_from_rows(top_failures, "blast_percent_abs_err")
+
+ methods = [
+ (
+ f"Analyzed {slide_count} experiment-slide records from {unique_samples} "
+ f"unique sample IDs using {tile_count} existing heatmap-selected tile JPGs."
+ ),
+ (
+ "For each complete slide/experiment pair, the script used the existing "
+ "top_01..top_08 and bottom_01..bottom_08 heatmap tiles, without rereading "
+ "raw WSIs or rerunning preprocessing."
+ ),
+ (
+ "Each tile was center-cropped to 512 x 512 px and scored for focus, "
+ "background, tissue fraction, edge content, and conservative cell-like "
+ "candidate visibility."
+ ),
+ (
+ "The blast-enrichment proxy combines positive-class STAMP attention with "
+ "cellular visibility. It is a triage signal only, not a blast detector."
+ ),
+ ]
+
+ key_findings = [
+ (
+ f"Data coverage was strong: {slide_count} slide records and {tile_count} "
+ f"tile records were analyzed; {len(skipped)} incomplete heatmap folders "
+ "were skipped."
+ ),
+ (
+ "The HIGH_BLAST heatmap signal aligned with clinical blast burden: "
+ f"Spearman rho for clinical BLAST_PERCENT vs top enrichment was "
+ f"{format_number(high_blast.get('spearman_blast_percent_vs_top_enrichment'))} "
+ f"(p={format_p_value(high_blast.get('spearman_blast_percent_vs_top_enrichment_p'))}), "
+ f"and HIGH_BLAST AUROC using top enrichment was "
+ f"{format_number(high_blast.get('high_blast_auroc_top_enrichment'))}."
+ ),
+ (
+ "The BLAST_PERCENT regression heatmap signal did not align with clinical "
+ f"blast burden: Spearman rho was "
+ f"{format_number(blast_percent.get('spearman_blast_percent_vs_top_enrichment'))}, "
+ f"and HIGH_BLAST AUROC from that proxy was "
+ f"{format_number(blast_percent.get('high_blast_auroc_top_enrichment'))}."
+ ),
+ (
+ "In HIGH_BLAST, high-blast slides had higher top enrichment than low-blast "
+ f"slides ({format_number(high_blast_high.get('top_enrichment_proxy_mean'))} "
+ f"vs {format_number(high_blast_low.get('top_enrichment_proxy_mean'))}); "
+ "confident HIGH_BLAST positives were also much higher than confident "
+ f"negatives ({format_number(high_blast_conf_yes.get('top_enrichment_proxy_mean'))} "
+ f"vs {format_number(high_blast_conf_no.get('top_enrichment_proxy_mean'))})."
+ ),
+ (
+ "In BLAST_PERCENT, high-blast slides were not enriched above low-blast "
+ f"slides by this proxy ({format_number(blast_percent_high.get('top_enrichment_proxy_mean'))} "
+ f"vs {format_number(blast_percent_low.get('top_enrichment_proxy_mean'))}), "
+ "which points away from using the regression heatmaps as the main interim "
+ "visual triage signal."
+ ),
+ (
+ "The top 10 BLAST_PERCENT failure slides had very high clinical blast "
+ f"burden (mean GT {format_number(failure_gt_mean)}%) but low regression "
+ f"predictions (mean {format_number(failure_pred_mean)}%, mean absolute "
+ f"error {format_number(failure_err_mean)}%). Their mean top enrichment "
+ f"proxy was {format_number(blast_percent_failures.get('top_enrichment_proxy_mean'))}, "
+ "not clearly elevated compared with the overall BLAST_PERCENT run."
+ ),
+ (
+ f"The report includes {overlay_count} selected overlay contact sheets for "
+ "technical concerns, BLAST_PERCENT failures, and HIGH_BLAST confidence "
+ "controls. These overlays are intended for pathologist review and visual "
+ "sanity checking only."
+ ),
+ ]
+
+ limitations = [
+ "The analysis does not classify individual cells and does not identify blasts.",
+ "Green contours are conservative cell-like/foreground candidates, not validated cell detections.",
+ "Clinical BLAST_PERCENT is slide-level metadata; it is not a cell-level or tile-level annotation.",
+ "DeepHeme-style inference is not currently feasible because public DeepHeme assets do not provide a ready pretrained detector/classifier for these STAMP tiles.",
+ "The enrichment proxy can be biased by focus, staining, background, RBC-rich areas, and where STAMP attention lands.",
+ ]
+
+ next_steps = [
+ "Use HIGH_BLAST heatmap tiles, not BLAST_PERCENT regression heatmaps, as the primary interim source for blast-rich candidate regions.",
+ "Ask pathologists to review the selected overlay/contact-sheet examples plus the existing 12-slide pilot deck, with emphasis on whether top-attended regions are cell-rich, blast-rich, or technically misleading.",
+ "Create a small annotation set from high-blast controls, low-blast controls, and BLAST_PERCENT failure slides. Minimum useful labels: interpretable/uninterpretable region, cell-rich yes/no, blast-rich estimate, and artifact/focus notes.",
+ "After pathologist feedback, compare their top-tile blast estimates against the HIGH_BLAST enrichment proxy and clinical BLAST_PERCENT to decide whether to scale annotation.",
+ "If annotations confirm visible blast enrichment, train or calibrate a lightweight cell/region classifier on local annotations. Keep DeepHeme as a reference method unless usable pretrained weights or compatible annotations become available.",
+ "Keep technical QC gates in later analyses: low focus, high background, low candidate density, and top/bottom candidate shifts should be flagged before interpreting model attention.",
+ ]
+
+ email_subject = "Interim STAMP blast-enrichment triage findings"
+ email_body = [
+ "Dear all,",
+ "",
+ "While we wait for the pathology review of the initial cell-resolution deck, I ran a conservative interim triage analysis on the existing STAMP heatmap-selected tiles.",
+ "",
+ (
+ f"In total, this covered {slide_count} slide/experiment records "
+ f"({tile_count} tiles) from {unique_samples} unique sample IDs. "
+ "This is not a diagnostic blast detector: it combines STAMP heatmap "
+ "attention with image-quality and cell-like object visibility metrics."
+ ),
+ "",
+ "Main findings:",
+ (
+ f"- The HIGH_BLAST classifier heatmaps showed a meaningful association "
+ f"with clinical blast burden (Spearman rho "
+ f"{format_number(high_blast.get('spearman_blast_percent_vs_top_enrichment'))}; "
+ f"HIGH_BLAST AUROC {format_number(high_blast.get('high_blast_auroc_top_enrichment'))})."
+ ),
+ (
+ f"- The BLAST_PERCENT regression heatmaps did not show useful alignment "
+ f"with clinical blast burden (Spearman rho "
+ f"{format_number(blast_percent.get('spearman_blast_percent_vs_top_enrichment'))}; "
+ f"AUROC {format_number(blast_percent.get('high_blast_auroc_top_enrichment'))})."
+ ),
+ (
+ "- The worst BLAST_PERCENT failures had very high clinical blast counts "
+ f"(mean GT {format_number(failure_gt_mean)}%) but low model predictions "
+ f"(mean {format_number(failure_pred_mean)}%), suggesting the regression "
+ "model is not reliably surfacing blast-rich visual regions."
+ ),
+ "",
+ "Suggested next step: use the HIGH_BLAST heatmap tiles as the main interim source for candidate blast-rich regions, and ask pathologists to review whether the selected top-attended regions are actually cell-rich/blast-rich or technically misleading. If this is confirmed, we can build a small local annotation set and then train or calibrate a lightweight region/cell classifier.",
+ "",
+ "Best,",
+ "Jeff",
+ ]
+
+ return {
+ "methods": methods,
+ "key_findings": key_findings,
+ "limitations": limitations,
+ "next_steps": next_steps,
+ "email_subject": email_subject,
+ "email_body": email_body,
+ "top_failures": top_failures,
+ }
+
+
+def write_markdown_reports(out_dir: Path, summary: dict[str, Any]) -> None:
+ findings_parts = [
+ "# STAMP Blast-Enrichment Triage Findings",
+ "",
+ "## Methods",
+ markdown_list(summary["methods"]),
+ "",
+ "## Key Findings",
+ markdown_list(summary["key_findings"]),
+ "",
+ "## Limitations",
+ markdown_list(summary["limitations"]),
+ "",
+ "## Recommended Next Evaluations",
+ markdown_list(summary["next_steps"]),
+ "",
+ "## Top BLAST_PERCENT Failure Slides",
+ ]
+ for row in summary["top_failures"]:
+ findings_parts.append(
+ "- "
+ f"{row['sample_id']}: GT {format_number(row['blast_percent_gt'])}%, "
+ f"pred {format_number(row['blast_percent_pred'])}%, "
+ f"abs err {format_number(row['blast_percent_abs_err'])}%, "
+ f"top proxy {format_number(row['top_enrichment_proxy_mean'])}, "
+ f"technical concern {row['technical_concern_score']}"
+ )
+ findings_parts.append("")
+ findings_parts.append(
+ "Note: all contour overlays are non-diagnostic cell-like candidates, not blast calls."
+ )
+
+ email_parts = [
+ f"Subject: {summary['email_subject']}",
+ "",
+ *summary["email_body"],
+ ]
+ (out_dir / "findings_summary.md").write_text("\n".join(findings_parts))
+ (out_dir / "email_draft.md").write_text("\n".join(email_parts))
+
+
+def write_html_report(
+ *,
+ out_dir: Path,
+ tile_rows: list[dict[str, Any]],
+ slide_rows: list[dict[str, Any]],
+ group_rows: list[dict[str, Any]],
+ experiment_rows: list[dict[str, Any]],
+ thresholds: dict[str, float],
+ skipped: list[dict[str, Any]],
+ overlay_records: list[dict[str, Any]],
+ report_summary: dict[str, Any],
+) -> None:
+ ranked = sorted(
+ slide_rows,
+ key=lambda row: (
+ float(row["top_enrichment_proxy_mean"]),
+ float(row["top_minus_bottom_enrichment_proxy"]),
+ ),
+ reverse=True,
+ )
+ concern_ranked = sorted(
+ slide_rows,
+ key=lambda row: (
+ int(row["technical_concern_score"]),
+ float(row["zoom_background_fraction_mean"]),
+ -float(row["zoom_laplacian_var_mean"]),
+ ),
+ reverse=True,
+ )
+
+ experiment_fields = [
+ "experiment",
+ "slide_count",
+ "unique_sample_count",
+ "tile_count",
+ "spearman_blast_percent_vs_top_enrichment",
+ "spearman_blast_percent_vs_delta_enrichment",
+ "high_blast_auroc_top_enrichment",
+ "high_blast_auprc_top_enrichment",
+ "high_blast_auroc_delta_enrichment",
+ "high_blast_auprc_delta_enrichment",
+ "technical_concern_score_mean",
+ ]
+ group_fields = [
+ "experiment",
+ "group_type",
+ "group_name",
+ "slide_count",
+ "unique_sample_count",
+ "top_enrichment_proxy_mean",
+ "bottom_enrichment_proxy_mean",
+ "top_minus_bottom_enrichment_proxy_mean",
+ "top_candidate_count_mean",
+ "technical_concern_score_mean",
+ "slides_with_any_concern",
+ ]
+ slide_fields = [
+ "experiment",
+ "sample_id",
+ "clinical_blast_band",
+ "blast_percent_gt",
+ "blast_percent_pred",
+ "blast_percent_abs_err",
+ "high_blast_yes_pred",
+ "top_enrichment_proxy_mean",
+ "bottom_enrichment_proxy_mean",
+ "top_minus_bottom_enrichment_proxy",
+ "top_candidate_count_mean",
+ "bottom_candidate_count_mean",
+ "technical_concern_score",
+ ]
+ top_failure_fields = [
+ "sample_id",
+ "blast_percent_gt",
+ "blast_percent_pred",
+ "blast_percent_abs_err",
+ "top_enrichment_proxy_mean",
+ "technical_concern_score",
+ ]
+
+ overlay_by_key = {
+ record["slide_key"]: record["contact_sheet"] for record in overlay_records
+ }
+ parts = [
+ "",
+ "",
+ "STAMP Blast-Enrichment Triage",
+ """
+
+""",
+ "",
+ "STAMP Blast-Enrichment Triage
",
+ (
+ "Important: This is non-diagnostic "
+ "blast-enrichment triage, not validated blast detection. Green "
+ "contours are cell-like candidates only. DeepHeme remains "
+ "unavailable for faithful inference without pretrained weights or "
+ "cell-level annotations.
"
+ ),
+ "DeepHeme readiness
",
+ (
+ ""
+ "- Raw WSIs: available locally.
"
+ "- UNI2 features and STAMP heatmap tiles: available locally.
"
+ "- Cell-level blast annotations: missing.
"
+ "- DeepHeme pretrained detector/classifier: not available from "
+ "the public repo/release checked during planning.
"
+ "- Sources: "
+ "DeepHeme repo, "
+ ""
+ "v1.1 release.
"
+ "
"
+ ),
+ "Run Summary
",
+ f"Tile records: {len(tile_rows)}. Slide records: {len(slide_rows)}. "
+ f"Skipped heatmap slide folders: {len(skipped)}.
",
+ "Methods In Brief
",
+ html_list(report_summary["methods"]),
+ "Key Findings
",
+ html_list(report_summary["key_findings"]),
+ "Top BLAST_PERCENT Failure Slides
",
+ html_table(report_summary["top_failures"], top_failure_fields),
+ "Interpretation Limits
",
+ html_list(report_summary["limitations"]),
+ "Recommended Next Evaluations
",
+ html_list(report_summary["next_steps"]),
+ "Email Draft
",
+ f"Subject: {html.escape(report_summary['email_subject'])}
",
+ "" + html.escape("\n".join(report_summary["email_body"])) + "",
+ "QC Thresholds
",
+ html_table([thresholds], list(thresholds)),
+ "Experiment Summary
",
+ html_table(experiment_rows, experiment_fields),
+ "Group Summary
",
+ html_table(group_rows, group_fields),
+ "Highest Enrichment Proxy Slides
",
+ html_table(ranked[:60], slide_fields),
+ "Highest Technical Concern Slides
",
+ html_table(concern_ranked[:60], slide_fields),
+ "Selected Overlay Contact Sheets
",
+ ]
+
+ for row in [r for r in concern_ranked if r["slide_key"] in overlay_by_key]:
+ contact = Path(overlay_by_key[row["slide_key"]])
+ parts.append("")
+ parts.append(
+ f"{html.escape(row['experiment'])}: "
+ f"{html.escape(row['sample_id'])}
"
+ )
+ parts.append(
+ ""
+ f"GT blast %: {html.escape(str(row['blast_percent_gt']))}; "
+ f"top proxy: {format_number(row['top_enrichment_proxy_mean'])}; "
+ f"delta: {format_number(row['top_minus_bottom_enrichment_proxy'])}; "
+ f"technical concern: {row['technical_concern_score']}"
+ "
"
+ )
+ parts.append(
+ f""
+ f"
"
+ )
+ parts.append("")
+
+ tile_fields = [
+ "experiment",
+ "sample_id",
+ "tile_kind",
+ "tile_rank",
+ "stamp_attention_label",
+ "stamp_attention_score",
+ "positive_attention_score_slide_norm",
+ "candidate_count",
+ "cellular_visibility_proxy",
+ "blast_enrichment_proxy",
+ "zoom_laplacian_var",
+ "zoom_background_fraction",
+ ]
+ parts.append("Per-Tile Preview
")
+ parts.append(
+ "Full table is in tile_metrics.csv; "
+ "showing first 100 rows here.
"
+ )
+ parts.append(html_table(tile_rows[:100], tile_fields))
+ parts.append("")
+ (out_dir / "index.html").write_text("\n".join(parts))
+
+
+def collect_all_specs(
+ selected_experiments: list[str], top_k: int, bottom_k: int, limit_slides: int | None
+) -> tuple[list[SlideSpec], list[dict[str, Any]], dict[str, Any]]:
+ stem_to_slide = load_slide_table()
+ clinical_by_sample = load_clinical_table()
+ all_specs: list[SlideSpec] = []
+ all_skipped: list[dict[str, Any]] = []
+ dry_counts: dict[str, Any] = {}
+
+ for experiment in selected_experiments:
+ predictions = load_predictions(experiment)
+ base_dir = EXPERIMENTS[experiment]["base_dir"]
+ discovered_dirs = list((base_dir / "heatmaps").glob("split-*/*/tiles"))
+ specs, skipped = discover_slides_for_experiment(
+ experiment=experiment,
+ top_k=top_k,
+ bottom_k=bottom_k,
+ limit_slides=limit_slides,
+ stem_to_slide=stem_to_slide,
+ clinical_by_sample=clinical_by_sample,
+ predictions=predictions,
+ )
+ all_specs.extend(specs)
+ all_skipped.extend(skipped)
+ dry_counts[experiment] = {
+ "heatmap_slide_dirs": len(discovered_dirs),
+ "complete_slide_dirs": len(specs)
+ if limit_slides is None
+ else len(specs) + max(0, len(discovered_dirs) - len(skipped) - len(specs)),
+ "selected_slide_dirs": len(specs),
+ "skipped_slide_dirs": len(skipped),
+ "selected_source_tiles": len(specs) * (top_k + bottom_k),
+ "prediction_rows": len(predictions),
+ }
+
+ return all_specs, all_skipped, dry_counts
+
+
+def dry_run_report(dry_counts: dict[str, Any], skipped: list[dict[str, Any]]) -> None:
+ for experiment, counts in dry_counts.items():
+ print(f"{experiment}:")
+ for key, value in counts.items():
+ print(f" {key}: {value}")
+ if skipped:
+ print("Skipped examples:")
+ for row in skipped[:8]:
+ print(f" {row}")
+
+
+def run_analysis(args: argparse.Namespace) -> None:
+ selected_experiments = parse_experiments(args.experiments)
+ specs, skipped, dry_counts = collect_all_specs(
+ selected_experiments=selected_experiments,
+ top_k=args.top_k,
+ bottom_k=args.bottom_k,
+ limit_slides=args.limit_slides,
+ )
+ if args.dry_run:
+ dry_run_report(dry_counts, skipped)
+ return
+
+ out_dir = args.out
+ out_dir.mkdir(parents=True, exist_ok=True)
+
+ tile_rows: list[dict[str, Any]] = []
+ for idx, slide in enumerate(specs, start=1):
+ for tile in [*slide.top_tiles, *slide.bottom_tiles]:
+ tile_rows.append(analyze_tile(slide=slide, tile=tile, zoom_px=args.zoom_px))
+ if idx % 50 == 0:
+ print(f"Analyzed {idx}/{len(specs)} slide records")
+
+ slide_rows, thresholds = summarize_slides(tile_rows)
+ group_rows = summarize_group_rows(slide_rows)
+ experiment_rows = summarize_experiments(slide_rows)
+ overlay_records = write_selected_overlays(
+ out_dir=out_dir,
+ slide_rows=slide_rows,
+ tile_rows=tile_rows,
+ zoom_px=args.zoom_px,
+ overlay_slides=args.overlay_slides,
+ write_all=args.write_all_overlays,
+ )
+ report_summary = build_report_summary(
+ tile_rows=tile_rows,
+ slide_rows=slide_rows,
+ group_rows=group_rows,
+ experiment_rows=experiment_rows,
+ skipped=skipped,
+ overlay_records=overlay_records,
+ )
+
+ tile_fields = [
+ "experiment",
+ "split",
+ "slide_key",
+ "sample_id",
+ "stem",
+ "filename",
+ "tile_kind",
+ "tile_rank",
+ "stamp_attention_label",
+ "stamp_attention_score",
+ "positive_attention_score",
+ "positive_attention_score_slide_norm",
+ "cellular_visibility_proxy",
+ "blast_enrichment_proxy",
+ "attention_visibility_proxy",
+ "native_width",
+ "native_height",
+ "zoom_px",
+ "blast_percent_gt",
+ "blast_severity_gt",
+ "high_blast_gt",
+ "blast_percent_pred",
+ "blast_percent_abs_err",
+ "high_blast_pred_label",
+ "high_blast_yes_pred",
+ "high_blast_no_pred",
+ "zoom_brightness_mean",
+ "zoom_brightness_std",
+ "zoom_saturation_mean",
+ "zoom_laplacian_var",
+ "zoom_tenengrad",
+ "zoom_edge_density",
+ "zoom_background_fraction",
+ "zoom_tissue_fraction",
+ "candidate_count",
+ "candidate_area_fraction",
+ "candidate_mask_fraction",
+ "candidate_count_per_megapixel",
+ "tile_path",
+ ]
+ slide_fields = [
+ "experiment",
+ "split",
+ "slide_key",
+ "sample_id",
+ "stem",
+ "filename",
+ "clinical_blast_band",
+ "blast_percent_gt",
+ "blast_severity_gt",
+ "high_blast_gt",
+ "blast_percent_pred",
+ "blast_percent_abs_err",
+ "high_blast_pred_label",
+ "high_blast_yes_pred",
+ "high_blast_no_pred",
+ "is_blast_percent_top10_failure",
+ "is_high_blast_confident_yes",
+ "is_high_blast_confident_no",
+ "tile_count",
+ "top_enrichment_proxy_mean",
+ "bottom_enrichment_proxy_mean",
+ "top_minus_bottom_enrichment_proxy",
+ "top_bottom_enrichment_ratio",
+ "top_cellular_visibility_mean",
+ "bottom_cellular_visibility_mean",
+ "top_candidate_count_mean",
+ "bottom_candidate_count_mean",
+ "all_candidate_count_mean",
+ "top_bottom_candidate_ratio",
+ "zoom_laplacian_var_mean",
+ "zoom_laplacian_var_median",
+ "zoom_background_fraction_mean",
+ "zoom_tissue_fraction_mean",
+ "flag_low_focus",
+ "flag_low_candidate_density",
+ "flag_high_background",
+ "flag_top_bottom_candidate_shift",
+ "technical_concern_score",
+ "overlay_contact_sheet",
+ ]
+ group_fields = [
+ "experiment",
+ "group_type",
+ "group_name",
+ "slide_count",
+ "unique_sample_count",
+ "tile_count",
+ "blast_percent_gt_mean",
+ "top_enrichment_proxy_mean",
+ "bottom_enrichment_proxy_mean",
+ "top_minus_bottom_enrichment_proxy_mean",
+ "top_candidate_count_mean",
+ "bottom_candidate_count_mean",
+ "zoom_laplacian_var_mean",
+ "background_fraction_mean",
+ "technical_concern_score_mean",
+ "slides_with_any_concern",
+ ]
+ experiment_fields = [
+ "experiment",
+ "display_name",
+ "slide_count",
+ "unique_sample_count",
+ "tile_count",
+ "spearman_blast_percent_vs_top_enrichment",
+ "spearman_blast_percent_vs_top_enrichment_p",
+ "spearman_blast_percent_vs_delta_enrichment",
+ "spearman_blast_percent_vs_delta_enrichment_p",
+ "high_blast_auroc_top_enrichment",
+ "high_blast_auprc_top_enrichment",
+ "high_blast_auroc_delta_enrichment",
+ "high_blast_auprc_delta_enrichment",
+ "top_enrichment_proxy_mean",
+ "bottom_enrichment_proxy_mean",
+ "technical_concern_score_mean",
+ "blast_percent_top10_failure_count",
+ "blast_percent_top10_failure_top_enrichment_mean",
+ "blast_percent_top10_failure_delta_mean",
+ ]
+
+ write_csv(out_dir / "tile_metrics.csv", tile_rows, tile_fields)
+ write_csv(out_dir / "slide_summary.csv", slide_rows, slide_fields)
+ write_csv(out_dir / "group_summary.csv", group_rows, group_fields)
+ write_csv(out_dir / "experiment_summary.csv", experiment_rows, experiment_fields)
+ write_markdown_reports(out_dir, report_summary)
+
+ manifest = {
+ "generated_at": datetime.now(UTC).isoformat(),
+ "warning": (
+ "This is non-diagnostic blast-enrichment triage, not validated blast "
+ "detection. Cell-like candidates are not blast calls."
+ ),
+ "arguments": {
+ "experiments": selected_experiments,
+ "top_k": args.top_k,
+ "bottom_k": args.bottom_k,
+ "zoom_px": args.zoom_px,
+ "overlay_slides": args.overlay_slides,
+ "limit_slides": args.limit_slides,
+ "write_all_overlays": args.write_all_overlays,
+ },
+ "inputs": {
+ "slide_table": str(SLIDE_TABLE),
+ "clinical_table": str(CLINI_TABLE),
+ "experiment_dirs": {
+ key: str(EXPERIMENTS[key]["base_dir"]) for key in selected_experiments
+ },
+ },
+ "counts": {
+ "slide_records": len(slide_rows),
+ "unique_samples": len({row["sample_id"] for row in slide_rows}),
+ "tile_records": len(tile_rows),
+ "skipped_heatmap_slide_dirs": len(skipped),
+ "overlay_contact_sheets": len(overlay_records),
+ },
+ "dry_run_counts": dry_counts,
+ "thresholds": thresholds,
+ "outputs": {
+ "tile_metrics": str(out_dir / "tile_metrics.csv"),
+ "slide_summary": str(out_dir / "slide_summary.csv"),
+ "group_summary": str(out_dir / "group_summary.csv"),
+ "experiment_summary": str(out_dir / "experiment_summary.csv"),
+ "findings_summary": str(out_dir / "findings_summary.md"),
+ "email_draft": str(out_dir / "email_draft.md"),
+ "html": str(out_dir / "index.html"),
+ },
+ "summary": {
+ "methods": report_summary["methods"],
+ "key_findings": report_summary["key_findings"],
+ "limitations": report_summary["limitations"],
+ "next_steps": report_summary["next_steps"],
+ "email_subject": report_summary["email_subject"],
+ },
+ "skipped": skipped,
+ "overlays": overlay_records,
+ "deepheme_readiness": {
+ "raw_wsis": "available locally",
+ "uni2_features": "available locally",
+ "stamp_heatmap_tiles": "available locally",
+ "cell_level_annotations": "missing",
+ "deepheme_pretrained_detector_classifier": "not available from public repo/release checked during planning",
+ "sources": [
+ "https://github.com/GoldgofLab/DeepHeme",
+ "https://github.com/GoldgofLab/DeepHeme/releases/tag/v1.1",
+ ],
+ },
+ }
+ (out_dir / "triage_manifest.json").write_text(json.dumps(manifest, indent=2))
+ write_html_report(
+ out_dir=out_dir,
+ tile_rows=tile_rows,
+ slide_rows=slide_rows,
+ group_rows=group_rows,
+ experiment_rows=experiment_rows,
+ thresholds=thresholds,
+ skipped=skipped,
+ overlay_records=overlay_records,
+ report_summary=report_summary,
+ )
+
+ print(f"Wrote {len(slide_rows)} slide records and {len(tile_rows)} tile records")
+ print(f"Overlay contact sheets: {len(overlay_records)}")
+ print(f"Output directory: {out_dir}")
+ print(f"HTML: {out_dir / 'index.html'}")
+
+
+def main() -> None:
+ args = parse_args()
+ run_analysis(args)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/analyze_cell_resolution_qc.py b/scripts/analyze_cell_resolution_qc.py
new file mode 100644
index 00000000..7efe8448
--- /dev/null
+++ b/scripts/analyze_cell_resolution_qc.py
@@ -0,0 +1,762 @@
+#!/usr/bin/env python3
+"""Conservative QC analysis for the STAMP cell-resolution pilot.
+
+This script measures image quality and cell-like object visibility in the
+existing pilot review crops. It does not classify cells and does not call blasts.
+"""
+
+from __future__ import annotations
+
+import argparse
+import csv
+import html
+import json
+import math
+from collections import defaultdict
+from dataclasses import dataclass
+from datetime import UTC, datetime
+from pathlib import Path
+from typing import Any
+
+import cv2
+import numpy as np
+from PIL import Image, ImageDraw, ImageFont
+
+
+ROOT = Path(__file__).resolve().parents[1]
+DEFAULT_MANIFEST = ROOT / "validation_report" / "pilot_cell_res" / "pilot_manifest.json"
+DEFAULT_OUT = ROOT / "validation_report" / "pilot_cell_qc"
+
+LANCZOS = Image.Resampling.LANCZOS
+
+
+@dataclass(frozen=True)
+class CandidateSummary:
+ contours: list[np.ndarray]
+ count: int
+ area_fraction: float
+ mask_fraction: float
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(
+ description="Run conservative QC on STAMP cell-resolution pilot crops."
+ )
+ parser.add_argument("--manifest", type=Path, default=DEFAULT_MANIFEST)
+ parser.add_argument("--out", type=Path, default=DEFAULT_OUT)
+ parser.add_argument("--limit", type=int, default=None)
+ return parser.parse_args()
+
+
+def load_json(path: Path) -> Any:
+ with path.open() as fp:
+ return json.load(fp)
+
+
+def read_rgb(path: Path) -> np.ndarray:
+ with Image.open(path) as image:
+ return np.asarray(image.convert("RGB"))
+
+
+def quality_metrics(rgb: np.ndarray, prefix: str) -> dict[str, float]:
+ gray = cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY)
+ hsv = cv2.cvtColor(rgb, cv2.COLOR_RGB2HSV)
+ saturation = hsv[:, :, 1]
+
+ laplacian_var = float(cv2.Laplacian(gray, cv2.CV_64F).var())
+ sobel_x = cv2.Sobel(gray, cv2.CV_64F, 1, 0, ksize=3)
+ sobel_y = cv2.Sobel(gray, cv2.CV_64F, 0, 1, ksize=3)
+ tenengrad = float(np.mean(sobel_x * sobel_x + sobel_y * sobel_y))
+
+ edges = cv2.Canny(gray, 40, 100)
+ background = (gray > 235) & (saturation < 25)
+ tissue = (gray < 242) & (saturation > 10)
+
+ return {
+ f"{prefix}_brightness_mean": float(gray.mean()),
+ f"{prefix}_brightness_std": float(gray.std()),
+ f"{prefix}_saturation_mean": float(saturation.mean()),
+ f"{prefix}_laplacian_var": laplacian_var,
+ f"{prefix}_tenengrad": tenengrad,
+ f"{prefix}_edge_density": float((edges > 0).mean()),
+ f"{prefix}_background_fraction": float(background.mean()),
+ f"{prefix}_tissue_fraction": float(tissue.mean()),
+ }
+
+
+def segment_cell_like_candidates(rgb: np.ndarray) -> CandidateSummary:
+ """Detect stained round-ish objects in zoom crops.
+
+ The result is intentionally conservative and non-diagnostic. It should be
+ interpreted as "cell-like candidates", not as cells of a known type.
+ """
+
+ gray = cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY)
+ hsv = cv2.cvtColor(rgb, cv2.COLOR_RGB2HSV)
+ saturation = hsv[:, :, 1]
+
+ tissue_mask = ((gray < 242) & (saturation > 10)).astype(np.uint8) * 255
+ stain_mask = ((gray < 210) & (saturation > 25)).astype(np.uint8) * 255
+
+ blurred = cv2.GaussianBlur(gray, (5, 5), 0)
+ otsu = cv2.threshold(blurred, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)[1]
+ mask = cv2.bitwise_and(otsu, stain_mask)
+ mask = cv2.bitwise_and(mask, tissue_mask)
+
+ open_kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3))
+ close_kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
+ mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, open_kernel)
+ mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, close_kernel)
+
+ contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
+
+ accepted: list[np.ndarray] = []
+ accepted_area = 0.0
+ image_area = float(rgb.shape[0] * rgb.shape[1])
+ for contour in contours:
+ area = float(cv2.contourArea(contour))
+ if area < 60 or area > 3500:
+ continue
+ x, y, width, height = cv2.boundingRect(contour)
+ if width < 6 or height < 6 or width > 100 or height > 100:
+ continue
+ aspect = width / max(height, 1)
+ if aspect < 0.25 or aspect > 4.0:
+ continue
+ perimeter = float(cv2.arcLength(contour, True))
+ if perimeter <= 0:
+ continue
+ circularity = 4 * math.pi * area / (perimeter * perimeter)
+ if circularity < 0.15:
+ continue
+ hull = cv2.convexHull(contour)
+ hull_area = float(cv2.contourArea(hull))
+ solidity = area / max(hull_area, 1.0)
+ if solidity < 0.35:
+ continue
+
+ accepted.append(contour)
+ accepted_area += area
+
+ return CandidateSummary(
+ contours=accepted,
+ count=len(accepted),
+ area_fraction=accepted_area / max(image_area, 1.0),
+ mask_fraction=float((mask > 0).mean()),
+ )
+
+
+def save_overlay(
+ *,
+ rgb: np.ndarray,
+ candidates: CandidateSummary,
+ out_path: Path,
+) -> None:
+ overlay = rgb.copy()
+ cv2.drawContours(overlay, candidates.contours, -1, (0, 220, 0), 2)
+ cv2.rectangle(overlay, (0, 0), (overlay.shape[1], 28), (0, 0, 0), -1)
+ cv2.putText(
+ overlay,
+ f"cell-like candidates: {candidates.count} (not blasts)",
+ (8, 19),
+ cv2.FONT_HERSHEY_SIMPLEX,
+ 0.48,
+ (255, 255, 255),
+ 1,
+ cv2.LINE_AA,
+ )
+ out_path.parent.mkdir(parents=True, exist_ok=True)
+ Image.fromarray(overlay).save(out_path, quality=92)
+
+
+def rel(path: Path, base: Path) -> str:
+ return path.relative_to(base).as_posix()
+
+
+def mean(values: list[float]) -> float:
+ return float(sum(values) / len(values)) if values else 0.0
+
+
+def percentile(values: list[float], q: float) -> float:
+ if not values:
+ return 0.0
+ return float(np.percentile(np.asarray(values, dtype=float), q))
+
+
+def summarize_tiles(
+ tile_rows: list[dict[str, Any]], slides: list[dict[str, Any]]
+) -> tuple[list[dict[str, Any]], list[dict[str, Any]], dict[str, float]]:
+ by_sample: dict[str, list[dict[str, Any]]] = defaultdict(list)
+ for row in tile_rows:
+ by_sample[row["sample_id"]].append(row)
+
+ prelim: list[dict[str, Any]] = []
+ for slide in slides:
+ meta = slide["metadata"]
+ rows = by_sample[meta["sample_id"]]
+ top_rows = [row for row in rows if row["tile_kind"] == "top"]
+ bottom_rows = [row for row in rows if row["tile_kind"] == "bottom"]
+
+ top_candidate_density = mean(
+ [float(row["candidate_count"]) for row in top_rows]
+ )
+ bottom_candidate_density = mean(
+ [float(row["candidate_count"]) for row in bottom_rows]
+ )
+ all_candidate_density = mean([float(row["candidate_count"]) for row in rows])
+ zoom_laplacian_mean = mean([float(row["zoom_laplacian_var"]) for row in rows])
+ zoom_background_mean = mean(
+ [float(row["zoom_background_fraction"]) for row in rows]
+ )
+ zoom_tissue_mean = mean([float(row["zoom_tissue_fraction"]) for row in rows])
+ top_bottom_candidate_ratio = (
+ top_candidate_density / bottom_candidate_density
+ if bottom_candidate_density > 0
+ else 0.0
+ )
+
+ prelim.append(
+ {
+ "group": meta["group"],
+ "sample_id": meta["sample_id"],
+ "stem": meta["stem"],
+ "experiment": meta["experiment"],
+ "split": meta["split"],
+ "blast_percent_gt": meta.get("blast_percent_gt", ""),
+ "high_blast_gt": meta.get("high_blast_gt", ""),
+ "blast_percent_pred": meta.get("blast_percent_pred", ""),
+ "blast_percent_abs_err": meta.get("blast_percent_abs_err", ""),
+ "high_blast_yes": meta.get("high_blast_yes", ""),
+ "tile_count": len(rows),
+ "top_candidate_count_mean": top_candidate_density,
+ "bottom_candidate_count_mean": bottom_candidate_density,
+ "all_candidate_count_mean": all_candidate_density,
+ "top_bottom_candidate_ratio": top_bottom_candidate_ratio,
+ "zoom_laplacian_var_mean": zoom_laplacian_mean,
+ "zoom_background_fraction_mean": zoom_background_mean,
+ "zoom_tissue_fraction_mean": zoom_tissue_mean,
+ "overlay_contact_sheet": "",
+ }
+ )
+
+ thresholds = {
+ "low_focus_laplacian_p25": percentile(
+ [float(row["zoom_laplacian_var_mean"]) for row in prelim], 25
+ ),
+ "low_candidate_count_p25": percentile(
+ [float(row["all_candidate_count_mean"]) for row in prelim], 25
+ ),
+ "high_background_fraction_p75": percentile(
+ [float(row["zoom_background_fraction_mean"]) for row in prelim], 75
+ ),
+ }
+
+ slide_rows: list[dict[str, Any]] = []
+ for row in prelim:
+ low_focus = (
+ float(row["zoom_laplacian_var_mean"])
+ <= thresholds["low_focus_laplacian_p25"]
+ )
+ low_candidates = (
+ float(row["all_candidate_count_mean"])
+ <= thresholds["low_candidate_count_p25"]
+ )
+ high_background = (
+ float(row["zoom_background_fraction_mean"])
+ >= thresholds["high_background_fraction_p75"]
+ )
+ ratio = float(row["top_bottom_candidate_ratio"])
+ top_bottom_shift = ratio < 0.5 or ratio > 2.0
+ concern_score = int(low_focus) + int(low_candidates) + int(high_background)
+
+ row = {
+ **row,
+ "flag_low_focus": int(low_focus),
+ "flag_low_candidate_density": int(low_candidates),
+ "flag_high_background": int(high_background),
+ "flag_top_bottom_candidate_shift": int(top_bottom_shift),
+ "technical_concern_score": concern_score,
+ }
+ slide_rows.append(row)
+
+ group_rows = summarize_groups(slide_rows)
+ return slide_rows, group_rows, thresholds
+
+
+def summarize_groups(slide_rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ grouped: dict[str, list[dict[str, Any]]] = defaultdict(list)
+ for row in slide_rows:
+ grouped[row["group"]].append(row)
+
+ summaries: list[dict[str, Any]] = []
+ for group, rows in grouped.items():
+ summaries.append(
+ {
+ "group": group,
+ "slide_count": len(rows),
+ "candidate_count_mean": mean(
+ [float(row["all_candidate_count_mean"]) for row in rows]
+ ),
+ "top_candidate_count_mean": mean(
+ [float(row["top_candidate_count_mean"]) for row in rows]
+ ),
+ "bottom_candidate_count_mean": mean(
+ [float(row["bottom_candidate_count_mean"]) for row in rows]
+ ),
+ "zoom_laplacian_var_mean": mean(
+ [float(row["zoom_laplacian_var_mean"]) for row in rows]
+ ),
+ "background_fraction_mean": mean(
+ [float(row["zoom_background_fraction_mean"]) for row in rows]
+ ),
+ "technical_concern_score_mean": mean(
+ [float(row["technical_concern_score"]) for row in rows]
+ ),
+ "slides_with_any_concern": sum(
+ 1 for row in rows if int(row["technical_concern_score"]) > 0
+ ),
+ }
+ )
+ return sorted(summaries, key=lambda row: row["group"])
+
+
+def write_csv(path: Path, rows: list[dict[str, Any]], fields: list[str]) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ with path.open("w", newline="") as fp:
+ writer = csv.DictWriter(fp, fieldnames=fields, extrasaction="ignore")
+ writer.writeheader()
+ for row in rows:
+ writer.writerow(row)
+
+
+def load_thumb(path: Path, size: int) -> Image.Image:
+ with Image.open(path) as image:
+ thumb = image.convert("RGB")
+ thumb.thumbnail((size, size), LANCZOS)
+ canvas = Image.new("RGB", (size, size), "white")
+ x = (size - thumb.size[0]) // 2
+ y = (size - thumb.size[1]) // 2
+ canvas.paste(thumb, (x, y))
+ return canvas
+
+
+def make_overlay_contact_sheet(
+ *,
+ sample_id: str,
+ group: str,
+ overlays: list[Path],
+ out_path: Path,
+) -> None:
+ margin = 28
+ gap = 10
+ tile_size = 168
+ cols = 4
+ rows = math.ceil(len(overlays) / cols)
+ width = margin * 2 + cols * tile_size + (cols - 1) * gap
+ height = margin * 2 + 70 + rows * (tile_size + 24)
+ canvas = Image.new("RGB", (width, height), "white")
+ draw = ImageDraw.Draw(canvas)
+ font = ImageFont.load_default()
+
+ draw.text(
+ (margin, margin),
+ f"{group.upper()} | {sample_id} | cell-like candidates only",
+ fill=(0, 0, 0),
+ font=font,
+ )
+ draw.text(
+ (margin, margin + 22),
+ "Green contours are non-diagnostic object candidates, not blast calls.",
+ fill=(120, 50, 20),
+ font=font,
+ )
+
+ start_y = margin + 58
+ for idx, path in enumerate(overlays):
+ row, col = divmod(idx, cols)
+ x = margin + col * (tile_size + gap)
+ y = start_y + row * (tile_size + 24)
+ thumb = load_thumb(path, tile_size)
+ canvas.paste(thumb, (x, y))
+ draw.rectangle(
+ [x, y, x + tile_size - 1, y + tile_size - 1],
+ outline=(180, 180, 180),
+ )
+ draw.text((x + 4, y + tile_size + 4), path.stem, fill=(60, 60, 60))
+
+ out_path.parent.mkdir(parents=True, exist_ok=True)
+ canvas.save(out_path, quality=90)
+
+
+def analyze_tile(
+ *,
+ slide_meta: dict[str, Any],
+ tile: dict[str, Any],
+ out_dir: Path,
+) -> dict[str, Any]:
+ native_path = Path(tile["native_path"])
+ zoom_path = Path(tile["zoom_path"])
+ native_rgb = read_rgb(native_path)
+ zoom_rgb = read_rgb(zoom_path)
+
+ row: dict[str, Any] = {
+ "group": slide_meta["group"],
+ "sample_id": slide_meta["sample_id"],
+ "stem": slide_meta["stem"],
+ "experiment": slide_meta["experiment"],
+ "split": slide_meta["split"],
+ "blast_percent_gt": slide_meta.get("blast_percent_gt", ""),
+ "high_blast_gt": slide_meta.get("high_blast_gt", ""),
+ "blast_percent_pred": slide_meta.get("blast_percent_pred", ""),
+ "blast_percent_abs_err": slide_meta.get("blast_percent_abs_err", ""),
+ "high_blast_yes": slide_meta.get("high_blast_yes", ""),
+ "tile_kind": tile["kind"],
+ "tile_rank": int(tile["rank"]),
+ "tile_score": tile.get("score", ""),
+ "native_path": str(native_path),
+ "zoom_path": str(zoom_path),
+ "native_width": int(native_rgb.shape[1]),
+ "native_height": int(native_rgb.shape[0]),
+ "zoom_width": int(zoom_rgb.shape[1]),
+ "zoom_height": int(zoom_rgb.shape[0]),
+ }
+ row.update(quality_metrics(native_rgb, "native"))
+ row.update(quality_metrics(zoom_rgb, "zoom"))
+
+ candidates = segment_cell_like_candidates(zoom_rgb)
+ row.update(
+ {
+ "candidate_count": candidates.count,
+ "candidate_area_fraction": candidates.area_fraction,
+ "candidate_mask_fraction": candidates.mask_fraction,
+ "candidate_count_per_megapixel": candidates.count
+ / max((zoom_rgb.shape[0] * zoom_rgb.shape[1]) / 1_000_000, 1e-8),
+ }
+ )
+
+ overlay_path = (
+ out_dir
+ / slide_meta["group"]
+ / slide_meta["sample_id"]
+ / "overlays"
+ / f"{tile['kind']}_{int(tile['rank']):02d}_overlay.jpg"
+ )
+ save_overlay(rgb=zoom_rgb, candidates=candidates, out_path=overlay_path)
+ row["overlay_path"] = str(overlay_path)
+ return row
+
+
+def format_number(value: Any, digits: int = 2) -> str:
+ try:
+ return f"{float(value):.{digits}f}"
+ except (TypeError, ValueError):
+ return str(value)
+
+
+def html_table(rows: list[dict[str, Any]], fields: list[str]) -> str:
+ if not rows:
+ return "No rows.
"
+ parts = ["", ""]
+ for field in fields:
+ parts.append(f"| {html.escape(field)} | ")
+ parts.append("
")
+ for row in rows:
+ parts.append("")
+ for field in fields:
+ value = row.get(field, "")
+ if isinstance(value, float):
+ value = format_number(value, 3)
+ parts.append(f"| {html.escape(str(value))} | ")
+ parts.append("
")
+ parts.append("
")
+ return "\n".join(parts)
+
+
+def write_html_report(
+ *,
+ out_dir: Path,
+ tile_rows: list[dict[str, Any]],
+ slide_rows: list[dict[str, Any]],
+ group_rows: list[dict[str, Any]],
+ thresholds: dict[str, float],
+) -> None:
+ ranked = sorted(
+ slide_rows,
+ key=lambda row: (
+ int(row["technical_concern_score"]),
+ float(row["zoom_background_fraction_mean"]),
+ -float(row["zoom_laplacian_var_mean"]),
+ ),
+ reverse=True,
+ )
+ slide_fields = [
+ "group",
+ "sample_id",
+ "blast_percent_gt",
+ "blast_percent_pred",
+ "all_candidate_count_mean",
+ "zoom_laplacian_var_mean",
+ "zoom_background_fraction_mean",
+ "technical_concern_score",
+ "flag_low_focus",
+ "flag_low_candidate_density",
+ "flag_high_background",
+ "flag_top_bottom_candidate_shift",
+ ]
+ group_fields = [
+ "group",
+ "slide_count",
+ "candidate_count_mean",
+ "top_candidate_count_mean",
+ "bottom_candidate_count_mean",
+ "zoom_laplacian_var_mean",
+ "background_fraction_mean",
+ "technical_concern_score_mean",
+ "slides_with_any_concern",
+ ]
+
+ parts = [
+ "",
+ "",
+ "STAMP Cell-Resolution QC",
+ """
+
+""",
+ "",
+ "STAMP Cell-Resolution QC
",
+ (
+ "Important: green contours are "
+ "cell-like object candidates only. They are not validated cells, "
+ "not blast calls, and not diagnostic output.
"
+ ),
+ "DeepHeme readiness
",
+ (
+ ""
+ "- Raw WSIs: available locally.
"
+ "- Tile and UNI2 feature data: available locally.
"
+ "- Cell-level annotations: missing.
"
+ "- DeepHeme pretrained detector/classifier: not available from "
+ "the public repo/release checked during planning.
"
+ "- Conclusion: ready for QC and annotation preparation, not "
+ "ready for true DeepHeme inference.
"
+ "
"
+ ),
+ "Thresholds
",
+ html_table([thresholds], list(thresholds)),
+ "Group Summary
",
+ html_table(group_rows, group_fields),
+ "Slides Ranked By Technical Concern
",
+ html_table(ranked, slide_fields),
+ "Overlay Contact Sheets
",
+ ]
+
+ for row in ranked:
+ sample_id = row["sample_id"]
+ contact = Path(row["overlay_contact_sheet"])
+ parts.append("")
+ parts.append(f"{html.escape(row['group'])}: {html.escape(sample_id)}
")
+ parts.append(
+ ""
+ f"Candidate mean: {format_number(row['all_candidate_count_mean'])}; "
+ f"focus mean: {format_number(row['zoom_laplacian_var_mean'])}; "
+ f"background mean: {format_number(row['zoom_background_fraction_mean'])}; "
+ f"concern score: {row['technical_concern_score']}"
+ "
"
+ )
+ parts.append(
+ f""
+ f"
"
+ )
+ parts.append("")
+
+ parts.append("Per-Tile Metrics
")
+ parts.append(
+ "Full table is in tile_metrics.csv; "
+ "showing first 40 rows here.
"
+ )
+ tile_fields = [
+ "group",
+ "sample_id",
+ "tile_kind",
+ "tile_rank",
+ "candidate_count",
+ "zoom_laplacian_var",
+ "zoom_background_fraction",
+ "zoom_edge_density",
+ ]
+ parts.append(html_table(tile_rows[:40], tile_fields))
+ parts.append("")
+ (out_dir / "index.html").write_text("\n".join(parts))
+
+
+def run_analysis(
+ manifest: dict[str, Any], manifest_path: Path, out_dir: Path, limit: int | None
+) -> None:
+ slides = manifest["slides"]
+ if limit is not None:
+ if limit < 1:
+ raise SystemExit("--limit must be at least 1")
+ slides = slides[:limit]
+
+ out_dir.mkdir(parents=True, exist_ok=True)
+
+ tile_rows: list[dict[str, Any]] = []
+ overlay_paths_by_sample: dict[str, list[Path]] = defaultdict(list)
+
+ for slide in slides:
+ meta = slide["metadata"]
+ for tile in slide["tiles"]:
+ row = analyze_tile(slide_meta=meta, tile=tile, out_dir=out_dir)
+ tile_rows.append(row)
+ overlay_paths_by_sample[meta["sample_id"]].append(Path(row["overlay_path"]))
+
+ slide_rows, group_rows, thresholds = summarize_tiles(tile_rows, slides)
+ for slide in slides:
+ meta = slide["metadata"]
+ sample_id = meta["sample_id"]
+ contact_path = out_dir / meta["group"] / sample_id / "overlay_contact_sheet.jpg"
+ make_overlay_contact_sheet(
+ sample_id=sample_id,
+ group=meta["group"],
+ overlays=overlay_paths_by_sample[sample_id],
+ out_path=contact_path,
+ )
+ for row in slide_rows:
+ if row["sample_id"] == sample_id:
+ row["overlay_contact_sheet"] = str(contact_path)
+
+ tile_fields = [
+ "group",
+ "sample_id",
+ "stem",
+ "experiment",
+ "split",
+ "blast_percent_gt",
+ "high_blast_gt",
+ "blast_percent_pred",
+ "blast_percent_abs_err",
+ "high_blast_yes",
+ "tile_kind",
+ "tile_rank",
+ "tile_score",
+ "native_width",
+ "native_height",
+ "zoom_width",
+ "zoom_height",
+ "native_brightness_mean",
+ "native_brightness_std",
+ "native_saturation_mean",
+ "native_laplacian_var",
+ "native_tenengrad",
+ "native_edge_density",
+ "native_background_fraction",
+ "native_tissue_fraction",
+ "zoom_brightness_mean",
+ "zoom_brightness_std",
+ "zoom_saturation_mean",
+ "zoom_laplacian_var",
+ "zoom_tenengrad",
+ "zoom_edge_density",
+ "zoom_background_fraction",
+ "zoom_tissue_fraction",
+ "candidate_count",
+ "candidate_area_fraction",
+ "candidate_mask_fraction",
+ "candidate_count_per_megapixel",
+ "native_path",
+ "zoom_path",
+ "overlay_path",
+ ]
+ slide_fields = [
+ "group",
+ "sample_id",
+ "stem",
+ "experiment",
+ "split",
+ "blast_percent_gt",
+ "high_blast_gt",
+ "blast_percent_pred",
+ "blast_percent_abs_err",
+ "high_blast_yes",
+ "tile_count",
+ "top_candidate_count_mean",
+ "bottom_candidate_count_mean",
+ "all_candidate_count_mean",
+ "top_bottom_candidate_ratio",
+ "zoom_laplacian_var_mean",
+ "zoom_background_fraction_mean",
+ "zoom_tissue_fraction_mean",
+ "flag_low_focus",
+ "flag_low_candidate_density",
+ "flag_high_background",
+ "flag_top_bottom_candidate_shift",
+ "technical_concern_score",
+ "overlay_contact_sheet",
+ ]
+ group_fields = [
+ "group",
+ "slide_count",
+ "candidate_count_mean",
+ "top_candidate_count_mean",
+ "bottom_candidate_count_mean",
+ "zoom_laplacian_var_mean",
+ "background_fraction_mean",
+ "technical_concern_score_mean",
+ "slides_with_any_concern",
+ ]
+
+ write_csv(out_dir / "tile_metrics.csv", tile_rows, tile_fields)
+ write_csv(out_dir / "slide_summary.csv", slide_rows, slide_fields)
+ write_csv(out_dir / "group_summary.csv", group_rows, group_fields)
+
+ qc_manifest = {
+ "generated_at": datetime.now(UTC).isoformat(),
+ "input_manifest": str(manifest_path),
+ "total_slides": len(slides),
+ "total_tiles": len(tile_rows),
+ "thresholds": thresholds,
+ "outputs": {
+ "tile_metrics": str(out_dir / "tile_metrics.csv"),
+ "slide_summary": str(out_dir / "slide_summary.csv"),
+ "group_summary": str(out_dir / "group_summary.csv"),
+ "html": str(out_dir / "index.html"),
+ },
+ "warning": (
+ "Cell-like candidates are conservative image objects, not validated "
+ "cells, not blasts, and not diagnostic output."
+ ),
+ }
+ (out_dir / "qc_manifest.json").write_text(json.dumps(qc_manifest, indent=2))
+ write_html_report(
+ out_dir=out_dir,
+ tile_rows=tile_rows,
+ slide_rows=slide_rows,
+ group_rows=group_rows,
+ thresholds=thresholds,
+ )
+
+ print(f"Wrote {len(slides)} slide summaries and {len(tile_rows)} tile rows")
+ print(f"Output directory: {out_dir}")
+ print(f"HTML: {out_dir / 'index.html'}")
+ print(f"Slide summary: {out_dir / 'slide_summary.csv'}")
+
+
+def main() -> None:
+ args = parse_args()
+ manifest = load_json(args.manifest)
+ run_analysis(manifest, args.manifest, args.out, args.limit)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/build_analysis_figs.py b/scripts/build_analysis_figs.py
new file mode 100644
index 00000000..e70ffb2a
--- /dev/null
+++ b/scripts/build_analysis_figs.py
@@ -0,0 +1,476 @@
+"""Patient-level re-analysis figures + metrics for the consortium deck.
+
+Reads pooled cross-validated predictions from the ORIGINAL (sample-split) and NEW
+(patient-grouped) runs and produces:
+ metrics.json - single source of truth for the deck's numbers
+ fig_sample_vs_patient.png - AUROC drop from fixing the leakage
+ fig_auprc_calibration.png - PR + reliability for RESPONSE_CR & HIGH_BLAST
+ fig_response_beyond_blast.png - does response signal exceed blast burden?
+ fig_confounder.png - do predictions track scan protocol (batch)?
+
+All metrics are recomputed here from the raw patient-preds so old vs new are
+apples-to-apples (the shipped STAMP regression stats used a single fold).
+
+Run AFTER training: .venv/bin/python scripts/build_analysis_figs.py
+"""
+
+from __future__ import annotations
+
+import json
+import re
+from pathlib import Path
+
+import matplotlib
+
+matplotlib.use("Agg")
+import matplotlib.pyplot as plt
+import numpy as np
+import pandas as pd
+from sklearn.calibration import calibration_curve
+from sklearn.metrics import (
+ average_precision_score,
+ mean_absolute_error,
+ precision_recall_curve,
+ r2_score,
+ roc_auc_score,
+)
+
+ROOT = Path(__file__).resolve().parent.parent
+DATA = Path("/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen")
+ASSETS = ROOT / "presentations" / "assets"
+ASSETS.mkdir(parents=True, exist_ok=True)
+
+NAVY, STEEL, GREY, AMBER, RED, GREEN = (
+ "#1A4D8C",
+ "#4E7CB5",
+ "#8A94A6",
+ "#C77F1A",
+ "#B3402F",
+ "#3E7D4F",
+)
+plt.rcParams.update(
+ {
+ "font.family": "DejaVu Sans",
+ "font.size": 11,
+ "axes.edgecolor": "#444",
+ "figure.dpi": 200,
+ }
+)
+
+TASKS = ["response", "high_blast", "blast_severity", "blast_percent"]
+LABEL = {
+ "response": "RESPONSE_CR",
+ "high_blast": "HIGH_BLAST",
+ "blast_severity": "BLAST_SEVERITY",
+ "blast_percent": "BLAST_PERCENT",
+}
+POS = {"response": "yes", "high_blast": "yes"}
+KIND = {
+ "response": "clf",
+ "high_blast": "clf",
+ "blast_severity": "multiclf",
+ "blast_percent": "reg",
+}
+NICE = {
+ "response": "RESPONSE_CR",
+ "high_blast": "HIGH_BLAST",
+ "blast_severity": "BLAST_SEVERITY",
+ "blast_percent": "BLAST_PERCENT",
+}
+
+
+def pooled(exp_dir: Path) -> pd.DataFrame | None:
+ files = sorted(exp_dir.glob("crossval/split-*/patient-preds.csv"))
+ if not files:
+ return None
+ return pd.concat([pd.read_csv(f) for f in files], ignore_index=True)
+
+
+def auroc_ci(y: np.ndarray, p: np.ndarray, n_boot: int = 2000):
+ auc = roc_auc_score(y, p)
+ rng = np.random.default_rng(0)
+ idx = np.arange(len(y))
+ boots = []
+ for _ in range(n_boot):
+ s = rng.choice(idx, len(idx), replace=True)
+ if len(np.unique(y[s])) == 2:
+ boots.append(roc_auc_score(y[s], p[s]))
+ lo, hi = np.percentile(boots, [2.5, 97.5]) if boots else (np.nan, np.nan)
+ return float(auc), float(lo), float(hi)
+
+
+def compute_metrics(df: pd.DataFrame, task: str) -> dict:
+ label = LABEL[task]
+ if KIND[task] == "clf":
+ pos = POS[task]
+ y = (df[label].astype(str) == pos).to_numpy().astype(int)
+ p = df[f"{label}_{pos}"].to_numpy()
+ auc, lo, hi = auroc_ci(y, p)
+ return {
+ "auroc": auc,
+ "lo": lo,
+ "hi": hi,
+ "auprc": float(average_precision_score(y, p)),
+ "n": int(len(y)),
+ "pos_rate": float(y.mean()),
+ }
+ if KIND[task] == "multiclf":
+ classes = [
+ c.split(f"{label}_")[1] for c in df.columns if c.startswith(f"{label}_")
+ ]
+ aucs = {}
+ for c in classes:
+ y = (df[label].astype(str) == c).to_numpy().astype(int)
+ if len(np.unique(y)) == 2:
+ aucs[c] = float(roc_auc_score(y, df[f"{label}_{c}"]))
+ macro = float(np.mean(list(aucs.values())))
+ return {
+ "auroc": macro,
+ "lo": np.nan,
+ "hi": np.nan,
+ "per_class": aucs,
+ "n": int(len(df)),
+ }
+ # regression
+ y = pd.to_numeric(df[label], errors="coerce").to_numpy()
+ p = pd.to_numeric(df["pred"], errors="coerce").to_numpy()
+ m = ~np.isnan(y) & ~np.isnan(p)
+ y, p = y[m], p[m]
+ return {
+ "pearson": float(np.corrcoef(y, p)[0, 1]),
+ "r2": float(r2_score(y, p)),
+ "mae": float(mean_absolute_error(y, p)),
+ "n": int(len(y)),
+ }
+
+
+# --------------------------------------------------------------------------- #
+def fig_sample_vs_patient(metrics: dict) -> None:
+ tasks = ["response", "high_blast", "blast_severity"]
+ labels = ["RESPONSE_CR", "HIGH_BLAST", "BLAST_SEVERITY\n(macro)"]
+ x = np.arange(len(tasks))
+ w = 0.38
+ fig, ax = plt.subplots(figsize=(9.2, 5.0))
+ for off, key, color, name in [
+ (-w / 2, "sample", GREY, "sample-split (leaky)"),
+ (w / 2, "patient", NAVY, "patient-grouped (honest)"),
+ ]:
+ vals = [metrics[key][t]["auroc"] for t in tasks]
+ err = [
+ [
+ metrics[key][t]["auroc"] - (metrics[key][t].get("lo") or np.nan)
+ for t in tasks
+ ],
+ [
+ (metrics[key][t].get("hi") or np.nan) - metrics[key][t]["auroc"]
+ for t in tasks
+ ],
+ ]
+ err = np.nan_to_num(
+ np.abs(np.array(err)), nan=0.0
+ ) # 0 = no bar (e.g. macro severity)
+ ax.bar(
+ x + off,
+ vals,
+ w,
+ color=color,
+ label=name,
+ yerr=err,
+ capsize=4,
+ edgecolor="white",
+ )
+ for xi, v, ue in zip(x + off, vals, err[1]):
+ ax.text(
+ xi,
+ v + ue + 0.02,
+ f"{v:.2f}",
+ ha="center",
+ fontsize=10,
+ color=color if color != GREY else "#555",
+ )
+ ax.axhline(0.5, ls="--", color="#bbb", lw=1)
+ ax.text(len(tasks) - 0.5, 0.51, "chance", color="#999", fontsize=9, ha="right")
+ ax.set_xticks(x)
+ ax.set_xticklabels(labels)
+ ax.set_ylabel("cross-validated AUROC")
+ ax.set_ylim(0.4, 1.0)
+ ax.spines[["top", "right"]].set_visible(False)
+ ax.legend(frameon=False, loc="lower center", ncol=2, bbox_to_anchor=(0.5, -0.22))
+ r_s = metrics["sample"]["blast_percent"]["pearson"]
+ r_p = metrics["patient"]["blast_percent"]["pearson"]
+ ax.set_title(
+ f"Honest evaluation: patient-grouped vs sample-split CV\n"
+ f"(BLAST_PERCENT Pearson r {r_s:.2f} → {r_p:.2f})",
+ color=NAVY,
+ fontweight="bold",
+ fontsize=12,
+ )
+ fig.tight_layout()
+ fig.savefig(ASSETS / "fig_sample_vs_patient.png", bbox_inches="tight")
+ plt.close(fig)
+ print("wrote fig_sample_vs_patient.png")
+
+
+def fig_auprc_calibration(new_preds: dict) -> None:
+ tasks = ["response", "high_blast"]
+ fig, axes = plt.subplots(2, 2, figsize=(9.6, 8.2))
+ for r, task in enumerate(tasks):
+ label, pos = LABEL[task], POS[task]
+ df = new_preds[task]
+ y = (df[label].astype(str) == pos).to_numpy().astype(int)
+ p = df[f"{label}_{pos}"].to_numpy()
+ # PR curve
+ prec, rec, _ = precision_recall_curve(y, p)
+ ap = average_precision_score(y, p)
+ ax = axes[r, 0]
+ ax.plot(rec, prec, color=NAVY, lw=2)
+ ax.axhline(
+ y.mean(), ls="--", color=GREY, lw=1, label=f"baseline {y.mean():.2f}"
+ )
+ ax.set_xlabel("recall")
+ ax.set_ylabel("precision")
+ ax.set_title(
+ f"{NICE[task]} — PR (AP {ap:.2f})",
+ color=NAVY,
+ fontweight="bold",
+ fontsize=11,
+ )
+ ax.set_ylim(0, 1.02)
+ ax.legend(frameon=False, fontsize=9, loc="lower left")
+ ax.spines[["top", "right"]].set_visible(False)
+ # calibration
+ frac, mean_pred = calibration_curve(y, p, n_bins=8, strategy="quantile")
+ ax = axes[r, 1]
+ ax.plot([0, 1], [0, 1], ls="--", color=GREY, lw=1)
+ ax.plot(mean_pred, frac, "o-", color=NAVY, lw=2)
+ ax.set_xlabel("predicted probability")
+ ax.set_ylabel("observed frequency")
+ ax.set_title(
+ f"{NICE[task]} — calibration", color=NAVY, fontweight="bold", fontsize=11
+ )
+ ax.set_xlim(0, 1)
+ ax.set_ylim(0, 1)
+ ax.spines[["top", "right"]].set_visible(False)
+ fig.suptitle(
+ "Patient-grouped models: precision-recall & calibration",
+ fontsize=13,
+ fontweight="bold",
+ color="#222",
+ )
+ fig.tight_layout()
+ fig.savefig(ASSETS / "fig_auprc_calibration.png", bbox_inches="tight")
+ plt.close(fig)
+ print("wrote fig_auprc_calibration.png")
+
+
+def fig_response_beyond_blast(new_preds: dict, clini: pd.DataFrame) -> dict:
+ df = new_preds["response"].merge(
+ clini[["SAMPLE_ID", "BLAST_PERCENT"]], on="SAMPLE_ID", how="left"
+ )
+ df["blast"] = pd.to_numeric(df["BLAST_PERCENT"], errors="coerce")
+ df["y"] = (df["RESPONSE_CR"].astype(str) == "yes").astype(int)
+ df = df.dropna(subset=["blast"])
+ corr = float(np.corrcoef(df.blast, df.y)[0, 1])
+
+ bands = {
+ "low\n(<5%)": df.blast < 5,
+ "intermediate\n(5–19%)": (df.blast >= 5) & (df.blast < 20),
+ "high\n(≥20%)": df.blast >= 20,
+ }
+ resp_rate, ns, single = {}, {}, True
+ for name, mask in bands.items():
+ sub = df[mask]
+ resp_rate[name] = float(sub.y.mean()) if len(sub) else float("nan")
+ ns[name] = int(len(sub))
+ if 0 < sub.y.mean() < 1:
+ single = False
+ # is RESPONSE_CR exactly the <5% blast (morphologic-CR) rule?
+ equiv = bool(((df.blast < 5).astype(int) == df.y).all())
+
+ fig, (axL, axR) = plt.subplots(1, 2, figsize=(11.5, 4.6))
+ # A: blast% by response — shows the (near-)perfect separation
+ data = [df[df.y == 1].blast, df[df.y == 0].blast]
+ bp = axL.boxplot(
+ data, tick_labels=["responder", "non-responder"], patch_artist=True, widths=0.5
+ )
+ for patch, c in zip(bp["boxes"], [GREEN, GREY]):
+ patch.set_facecolor(c)
+ patch.set_alpha(0.75)
+ axL.axhline(5, ls="--", color=RED, lw=1)
+ axL.text(
+ 2.4, 6, "5% (CR threshold)", color=RED, fontsize=9, ha="right", va="bottom"
+ )
+ axL.set_ylabel("clinical blast %")
+ axL.set_title(
+ f"Blast burden by response\n(point-biserial r = {corr:+.2f})",
+ color=NAVY,
+ fontweight="bold",
+ fontsize=11,
+ )
+ axL.spines[["top", "right"]].set_visible(False)
+ # B: responder fraction by blast band — the definitional confound
+ names = list(resp_rate.keys())
+ xb = np.arange(len(names))
+ axR.bar(
+ xb,
+ [resp_rate[k] for k in names],
+ color=[GREEN, GREY, GREY],
+ edgecolor="white",
+ width=0.6,
+ )
+ for xi, k in zip(xb, names):
+ axR.text(
+ xi,
+ resp_rate[k] + 0.02,
+ f"{resp_rate[k]:.0%}\n(n={ns[k]})",
+ ha="center",
+ fontsize=9,
+ )
+ axR.set_xticks(xb)
+ axR.set_xticklabels(names, fontsize=9)
+ axR.set_ylim(0, 1.12)
+ axR.set_ylabel("fraction labelled 'responder'")
+ axR.set_title(
+ "Responder fraction by blast band", color=NAVY, fontweight="bold", fontsize=11
+ )
+ axR.spines[["top", "right"]].set_visible(False)
+ fig.tight_layout()
+ fig.savefig(ASSETS / "fig_response_beyond_blast.png", bbox_inches="tight")
+ plt.close(fig)
+ print(f"wrote fig_response_beyond_blast.png (equiv <5%: {equiv})")
+ return {
+ "blast_response_corr": corr,
+ "resp_rate_by_band": resp_rate,
+ "n_by_band": ns,
+ "response_equals_lt5_blast": equiv,
+ "strata_single_class": single,
+ }
+
+
+def _parse_protocol(fname: str) -> tuple[str, str]:
+ m = re.search(r"(\d+)\s*Fokuspunkte", fname)
+ focus = m.group(1) if m else "?"
+ defat = (
+ "nicht_entfettet"
+ if "nicht entfettet" in fname
+ else ("entfettet" if "entfettet" in fname else "?")
+ )
+ return focus, defat
+
+
+def fig_confounder(new_preds: dict, slide: pd.DataFrame) -> dict:
+ # sample-level dominant protocol
+ prot = {}
+ for sid, grp in slide.groupby("SAMPLE_ID"):
+ foci, defs = set(), set()
+ for fn in grp["FILENAME"]:
+ f, d = _parse_protocol(str(fn))
+ foci.add(f)
+ defs.add(d)
+ focus = next(iter(foci)) if len(foci) == 1 else "mixed"
+ prot[sid] = focus
+ hb = new_preds["high_blast"].copy()
+ hb["focus"] = hb["SAMPLE_ID"].map(prot)
+ hb["y"] = (hb["HIGH_BLAST"].astype(str) == "yes").astype(int)
+ hb["p"] = hb["HIGH_BLAST_yes"]
+ hb = hb[hb.focus.isin(["5", "10"])]
+ hb["is10"] = (hb.focus == "10").astype(int)
+
+ # (1) protocol vs label association: proportion 10-pt among high vs low blast
+ prop = hb.groupby("y")["is10"].mean()
+ # (2) can the model score predict protocol? AUROC(is10 ~ p)
+ score_auc = roc_auc_score(hb.is10, hb.p) if hb.is10.nunique() == 2 else np.nan
+
+ fig, (axL, axR) = plt.subplots(1, 2, figsize=(11.5, 4.6))
+ axL.bar(
+ ["low blast", "high blast"],
+ [prop.get(0, 0), prop.get(1, 0)],
+ color=[STEEL, AMBER],
+ edgecolor="white",
+ width=0.55,
+ )
+ for i, v in enumerate([prop.get(0, 0), prop.get(1, 0)]):
+ axL.text(i, v + 0.01, f"{v:.0%}", ha="center", fontsize=11)
+ axL.set_ylabel("fraction scanned at 10 Fokuspunkte")
+ axL.set_ylim(0, 1.05)
+ axL.set_title(
+ "Is scan protocol confounded with the label?",
+ color=NAVY,
+ fontweight="bold",
+ fontsize=11,
+ )
+ axL.spines[["top", "right"]].set_visible(False)
+ # score by protocol
+ data = [hb[hb.focus == "5"].p, hb[hb.focus == "10"].p]
+ bp = axR.boxplot(
+ data,
+ tick_labels=["5 Fokuspunkte", "10 Fokuspunkte"],
+ patch_artist=True,
+ widths=0.5,
+ )
+ for patch, c in zip(bp["boxes"], [STEEL, NAVY]):
+ patch.set_facecolor(c)
+ patch.set_alpha(0.75)
+ axR.set_ylabel("model HIGH_BLAST score P(yes)")
+ axR.set_title(
+ f"Does the score track scan protocol?\nAUROC(protocol ~ score) = {score_auc:.2f}",
+ color=NAVY,
+ fontweight="bold",
+ fontsize=11,
+ )
+ axR.spines[["top", "right"]].set_visible(False)
+ fig.tight_layout()
+ fig.savefig(ASSETS / "fig_confounder.png", bbox_inches="tight")
+ plt.close(fig)
+ print("wrote fig_confounder.png")
+ return {
+ "prop10_low": float(prop.get(0, 0)),
+ "prop10_high": float(prop.get(1, 0)),
+ "protocol_from_score_auroc": float(score_auc),
+ "n": int(len(hb)),
+ }
+
+
+def main() -> None:
+ clini = pd.read_csv(ROOT / "tables" / "stamp_clini.csv")
+ slide = pd.read_csv(ROOT / "tables" / "stamp_slide.csv")
+
+ metrics: dict = {"sample": {}, "patient": {}}
+ old_preds, new_preds = {}, {}
+ for t in TASKS:
+ old = pooled(DATA / f"stamp_aml_{t}_uni2")
+ new = pooled(DATA / f"stamp_aml_{t}_uni2_patientcv")
+ if old is None or new is None:
+ raise SystemExit(
+ f"missing pooled preds for {t} (old={old is not None}, new={new is not None}) "
+ "— has training finished?"
+ )
+ old_preds[t], new_preds[t] = old, new
+ metrics["sample"][t] = compute_metrics(old, t)
+ metrics["patient"][t] = compute_metrics(new, t)
+
+ fig_sample_vs_patient(metrics)
+ fig_auprc_calibration(new_preds)
+ metrics["response_beyond_blast"] = fig_response_beyond_blast(new_preds, clini)
+ metrics["confounder"] = fig_confounder(new_preds, slide)
+
+ (ASSETS / "metrics.json").write_text(
+ json.dumps(metrics, indent=2, default=lambda o: None)
+ )
+ print("wrote metrics.json")
+ # quick console summary
+ for t in TASKS:
+ s, p = metrics["sample"][t], metrics["patient"][t]
+ if KIND[t] == "reg":
+ print(
+ f" {t:14s} Pearson r sample {s['pearson']:.2f} -> patient {p['pearson']:.2f} "
+ f"| R2 {s['r2']:.2f}->{p['r2']:.2f}"
+ )
+ else:
+ print(
+ f" {t:14s} AUROC sample {s['auroc']:.3f} -> patient {p['auroc']:.3f}"
+ )
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/build_presentation_assets.py b/scripts/build_presentation_assets.py
new file mode 100644
index 00000000..70014795
--- /dev/null
+++ b/scripts/build_presentation_assets.py
@@ -0,0 +1,699 @@
+"""Generate figures + shortlist heatmap examples for the B01 project-review deck.
+
+Produces (in presentations/assets/):
+ fig_labels.png - distribution of the 4 clinical labels
+ fig_cohort_cv.png - cohort funnel + 5-fold cross-validation schematic
+ fig_workflow.png - STAMP pipeline flow chart
+ montage_.png - candidate heatmap overviews (for human selection)
+ candidates.json - shortlisted heatmap candidates with metrics + paths
+
+Selection uses the blast-enrichment triage `slide_summary.csv` as an objective
+"attention lands on tissue, not glass background" signal
+(top_enrichment_proxy_mean high, technical_concern_score / high-background flags low).
+
+Run: .venv/bin/python scripts/build_presentation_assets.py
+"""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+import matplotlib
+
+matplotlib.use("Agg")
+import matplotlib.pyplot as plt
+import numpy as np
+import pandas as pd
+from matplotlib.patches import FancyArrowPatch, FancyBboxPatch
+
+ROOT = Path(__file__).resolve().parent.parent
+VR = ROOT / "validation_report"
+DATA = Path("/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen")
+ASSETS = ROOT / "presentations" / "assets"
+ASSETS.mkdir(parents=True, exist_ok=True)
+
+NAVY = "#1A4D8C"
+STEEL = "#4E7CB5"
+GREY = "#8A94A6"
+AMBER = "#C77F1A"
+RED = "#B3402F"
+GREEN = "#3E7D4F"
+plt.rcParams.update(
+ {
+ "font.family": "DejaVu Sans",
+ "font.size": 11,
+ "axes.edgecolor": "#444444",
+ "axes.linewidth": 0.8,
+ "axes.grid": False,
+ "figure.dpi": 200,
+ }
+)
+
+
+# --------------------------------------------------------------------------- #
+# 1. Label distributions
+# --------------------------------------------------------------------------- #
+def fig_labels(clini: pd.DataFrame) -> None:
+ fig, axes = plt.subplots(1, 4, figsize=(13.3, 3.4))
+
+ def bars(ax, counts, order, title, colors):
+ vals = [int(counts.get(k, 0)) for k in order]
+ x = np.arange(len(order))
+ ax.bar(x, vals, color=colors, width=0.62, edgecolor="white")
+ ax.set_xticks(x)
+ ax.set_xticklabels(order, fontsize=10)
+ ax.set_title(title, fontsize=12, color=NAVY, fontweight="bold", pad=8)
+ ax.spines[["top", "right"]].set_visible(False)
+ ax.set_ylim(0, max(vals) * 1.18)
+ for xi, v in zip(x, vals):
+ ax.text(
+ xi, v + max(vals) * 0.02, str(v), ha="center", va="bottom", fontsize=10
+ )
+
+ bars(
+ axes[0],
+ clini["RESPONSE_CR"].value_counts(),
+ ["yes", "no"],
+ "RESPONSE_CR",
+ [GREEN, GREY],
+ )
+ bars(
+ axes[1],
+ clini["HIGH_BLAST"].value_counts(),
+ ["no", "yes"],
+ "HIGH_BLAST",
+ [GREY, AMBER],
+ )
+ bars(
+ axes[2],
+ clini["BLAST_SEVERITY"].value_counts(),
+ ["low", "intermediate", "high"],
+ "BLAST_SEVERITY",
+ [STEEL, GREY, RED],
+ )
+
+ ax = axes[3]
+ vals = pd.to_numeric(clini["BLAST_PERCENT"], errors="coerce").dropna()
+ ax.hist(vals, bins=np.arange(0, 101, 5), color=NAVY, edgecolor="white", alpha=0.9)
+ ax.set_title("BLAST_PERCENT", fontsize=12, color=NAVY, fontweight="bold", pad=8)
+ ax.set_xlabel("clinical blast %", fontsize=10)
+ ax.set_ylabel("samples", fontsize=10)
+ ax.spines[["top", "right"]].set_visible(False)
+ ax.axvline(5, color=RED, lw=1, ls="--")
+ ax.axvline(20, color=RED, lw=1, ls="--")
+
+ fig.suptitle(
+ f"Clinical label distribution (n = {len(clini)} samples)",
+ fontsize=13,
+ color="#222",
+ fontweight="bold",
+ y=1.02,
+ )
+ fig.tight_layout()
+ fig.savefig(ASSETS / "fig_labels.png", bbox_inches="tight")
+ plt.close(fig)
+ print("wrote fig_labels.png")
+
+
+# --------------------------------------------------------------------------- #
+# 2. Cohort funnel + CV schematic
+# --------------------------------------------------------------------------- #
+def fig_cohort_cv(n_pat: int, n_samp: int, n_slide: int) -> None:
+ fig, (axL, axR) = plt.subplots(
+ 1, 2, figsize=(13.3, 3.6), gridspec_kw={"width_ratios": [1, 1.25]}
+ )
+
+ # -- funnel --
+ labels = [f"{n_pat}\npatients", f"{n_samp}\nsamples", f"{n_slide}\nslide images"]
+ widths = [0.5, 0.8, 1.0]
+ colors = [NAVY, STEEL, GREY]
+ for i, (lab, w, c) in enumerate(zip(labels, widths, colors)):
+ y = 2 - i
+ axL.add_patch(
+ FancyBboxPatch(
+ (0.5 - w / 2, y - 0.36),
+ w,
+ 0.72,
+ boxstyle="round,pad=0.02,rounding_size=0.05",
+ facecolor=c,
+ edgecolor="none",
+ )
+ )
+ axL.text(
+ 0.5,
+ y,
+ lab,
+ ha="center",
+ va="center",
+ color="white",
+ fontsize=12,
+ fontweight="bold",
+ )
+ if i < 2:
+ axL.annotate(
+ "",
+ xy=(0.5, y - 0.4),
+ xytext=(0.5, y - 0.62),
+ arrowprops=dict(arrowstyle="-|>", color="#555", lw=1.5),
+ )
+ axL.set_xlim(0, 1)
+ axL.set_ylim(-0.2, 2.6)
+ axL.axis("off")
+ axL.set_title("Cohort", fontsize=12, color=NAVY, fontweight="bold")
+
+ # -- 5-fold CV schematic --
+ k = 5
+ for f in range(k):
+ y = k - 1 - f
+ for j in range(k):
+ is_test = j == f
+ axR.add_patch(
+ plt.Rectangle(
+ (j, y),
+ 0.96,
+ 0.8,
+ facecolor=(AMBER if is_test else STEEL),
+ edgecolor="white",
+ )
+ )
+ axR.text(-0.25, y + 0.4, f"fold {f + 1}", ha="right", va="center", fontsize=10)
+ axR.set_xlim(-1.6, k + 0.1)
+ axR.set_ylim(-0.6, k + 0.2)
+ axR.axis("off")
+ axR.set_title("5-fold cross-validation", fontsize=12, color=NAVY, fontweight="bold")
+ axR.add_patch(
+ plt.Rectangle((0.2, -0.55), 0.4, 0.32, facecolor=STEEL, edgecolor="white")
+ )
+ axR.text(0.72, -0.39, "train", va="center", fontsize=9)
+ axR.add_patch(
+ plt.Rectangle((2.0, -0.55), 0.4, 0.32, facecolor=AMBER, edgecolor="white")
+ )
+ axR.text(
+ 2.52,
+ -0.39,
+ "held-out test (every sample predicted once)",
+ va="center",
+ fontsize=9,
+ )
+
+ fig.tight_layout()
+ fig.savefig(ASSETS / "fig_cohort_cv.png", bbox_inches="tight")
+ plt.close(fig)
+ print("wrote fig_cohort_cv.png")
+
+
+# --------------------------------------------------------------------------- #
+# 3. Workflow chart
+# --------------------------------------------------------------------------- #
+def fig_workflow() -> None:
+ fig, ax = plt.subplots(figsize=(13.3, 3.0))
+ stages = [
+ ("Bone-marrow\nsmear WSI", NAVY),
+ ("Tiling + QC\n224×224 px\nfocus/bg filter", STEEL),
+ ("UNI2\ntile features\n(foundation model)", STEEL),
+ ("ViT-MIL\nattention\naggregation", STEEL),
+ ("Prediction\n5-fold CV\n(class / value)", NAVY),
+ ]
+ n = len(stages)
+ bw, bh, gap = 2.05, 1.5, 0.55
+ x = 0.2
+ centers = []
+ for label, color in stages:
+ ax.add_patch(
+ FancyBboxPatch(
+ (x, 0.9),
+ bw,
+ bh,
+ boxstyle="round,pad=0.03,rounding_size=0.12",
+ facecolor=color,
+ edgecolor="none",
+ )
+ )
+ ax.text(
+ x + bw / 2,
+ 0.9 + bh / 2,
+ label,
+ ha="center",
+ va="center",
+ color="white",
+ fontsize=11,
+ fontweight="bold",
+ )
+ centers.append(x + bw / 2)
+ x += bw + gap
+ for i in range(n - 1):
+ ax.add_patch(
+ FancyArrowPatch(
+ (centers[i] + bw / 2, 1.65),
+ (centers[i + 1] - bw / 2, 1.65),
+ arrowstyle="-|>",
+ mutation_scale=18,
+ color="#555",
+ lw=1.6,
+ )
+ )
+ # outputs branch
+ ax.add_patch(
+ FancyBboxPatch(
+ (centers[-1] - bw / 2, -0.55),
+ bw,
+ 1.0,
+ boxstyle="round,pad=0.03,rounding_size=0.12",
+ facecolor=GREY,
+ edgecolor="none",
+ )
+ )
+ ax.text(
+ centers[-1],
+ -0.05,
+ "Heatmaps +\ntop/bottom tiles",
+ ha="center",
+ va="center",
+ color="white",
+ fontsize=10,
+ fontweight="bold",
+ )
+ ax.add_patch(
+ FancyArrowPatch(
+ (centers[-1], 0.88),
+ (centers[-1], 0.47),
+ arrowstyle="-|>",
+ mutation_scale=16,
+ color="#555",
+ lw=1.6,
+ )
+ )
+ ax.text(
+ centers[0],
+ 2.65,
+ "Weakly supervised: one label per slide, no cell annotations",
+ ha="left",
+ fontsize=10,
+ style="italic",
+ color="#555",
+ )
+ ax.set_xlim(0, x)
+ ax.set_ylim(-0.8, 3.0)
+ ax.axis("off")
+ fig.tight_layout()
+ fig.savefig(ASSETS / "fig_workflow.png", bbox_inches="tight")
+ plt.close(fig)
+ print("wrote fig_workflow.png")
+
+
+# --------------------------------------------------------------------------- #
+# Heatmap example selection
+# --------------------------------------------------------------------------- #
+def _overview(exp_dir_name: str, split: str, stem: str) -> Path | None:
+ p = (
+ DATA
+ / exp_dir_name
+ / "heatmaps"
+ / split
+ / stem
+ / "plots"
+ / f"overview-{stem}.png"
+ )
+ return p if p.exists() else None
+
+
+def select_candidates(clini: pd.DataFrame) -> dict:
+ ss = pd.read_csv(VR / "blast_enrichment_triage" / "slide_summary.csv")
+ ss["top_enrichment_proxy_mean"] = pd.to_numeric(
+ ss["top_enrichment_proxy_mean"], errors="coerce"
+ )
+ ss["blast_percent_gt"] = pd.to_numeric(ss["blast_percent_gt"], errors="coerce")
+ ss["blast_percent_abs_err"] = pd.to_numeric(
+ ss["blast_percent_abs_err"], errors="coerce"
+ )
+
+ exp_dir = {
+ "high_blast": "stamp_aml_high_blast_uni2",
+ "blast_percent": "stamp_aml_blast_percent_uni2",
+ "response": "stamp_aml_response_uni2",
+ }
+ out: dict[str, list] = {}
+
+ def row_to_cand(r, note):
+ ov = _overview(exp_dir[r["experiment"]], r["split"], r["stem"])
+ if ov is None:
+ return None
+ return {
+ "sample_id": r["sample_id"],
+ "stem": r["stem"],
+ "split": r["split"],
+ "experiment": r["experiment"],
+ "blast_pct_gt": None
+ if pd.isna(r["blast_percent_gt"])
+ else round(float(r["blast_percent_gt"]), 1),
+ "enrichment": None
+ if pd.isna(r["top_enrichment_proxy_mean"])
+ else round(float(r["top_enrichment_proxy_mean"]), 3),
+ "tech_concern": int(r["technical_concern_score"]),
+ "note": note,
+ "overview": str(ov),
+ }
+
+ # ---- HIGH_BLAST: correct positives, attention on tissue ----
+ hb = ss[ss.experiment == "high_blast"].copy()
+ cand = hb[
+ (hb.high_blast_gt == "yes")
+ & (hb.high_blast_pred_label == "yes")
+ & (hb.flag_high_background == 0)
+ & (hb.technical_concern_score <= 1)
+ ]
+ cand = cand.sort_values("top_enrichment_proxy_mean", ascending=False).head(8)
+ out["high_blast"] = [
+ c
+ for c in (row_to_cand(r, "GT high / pred high") for _, r in cand.iterrows())
+ if c
+ ]
+
+ # ---- BLAST_PERCENT: correct across the range + a dramatic failure ----
+ bp = ss[ss.experiment == "blast_percent"].copy()
+ good = bp[(bp.blast_percent_abs_err <= 6) & (bp.flag_high_background == 0)]
+ lo = (
+ good[good.blast_percent_gt < 5]
+ .sort_values("top_enrichment_proxy_mean", ascending=False)
+ .head(3)
+ )
+ hi = (
+ good[good.blast_percent_gt >= 20]
+ .sort_values("top_enrichment_proxy_mean", ascending=False)
+ .head(3)
+ )
+ fails = (
+ bp[bp.is_blast_percent_top10_failure == 1]
+ .sort_values("blast_percent_gt", ascending=False)
+ .head(3)
+ )
+ out["blast_percent"] = [
+ c
+ for c in (
+ [
+ row_to_cand(r, f"GT {r.blast_percent_gt:.0f}% (low) — correct")
+ for _, r in lo.iterrows()
+ ]
+ + [
+ row_to_cand(r, f"GT {r.blast_percent_gt:.0f}% (high) — correct")
+ for _, r in hi.iterrows()
+ ]
+ + [
+ row_to_cand(
+ r,
+ f"GT {r.blast_percent_gt:.0f}% -> pred {r.blast_percent_pred:.0f}% — FAILURE",
+ )
+ for _, r in fails.iterrows()
+ ]
+ )
+ if c
+ ]
+
+ # ---- EXPLAINABILITY: same slide, high_blast vs blast_percent overview ----
+ pairs = []
+ for sid in [c["sample_id"] for c in out["high_blast"][:5]]:
+ hbrow = hb[hb.sample_id == sid]
+ bprow = bp[bp.sample_id == sid]
+ if len(hbrow) and len(bprow):
+ hbc = row_to_cand(hbrow.iloc[0], "HIGH_BLAST attention")
+ bpc = row_to_cand(bprow.iloc[0], "BLAST_PERCENT attention")
+ if hbc and bpc:
+ pairs.append(
+ {"sample_id": sid, "high_blast": hbc, "blast_percent": bpc}
+ )
+ out["explain_pairs"] = pairs
+
+ # ---- RESPONSE: only 23 slides rendered; rank by cellularity (join hb) + correctness ----
+ preds = []
+ for sp in range(5):
+ f = (
+ DATA
+ / exp_dir["response"]
+ / "crossval"
+ / f"split-{sp}"
+ / "patient-preds.csv"
+ )
+ if f.exists():
+ d = pd.read_csv(f)
+ d["split"] = f"split-{sp}"
+ preds.append(d)
+ preds = pd.concat(preds, ignore_index=True)
+ # sample_id -> cellularity from high_blast slide_summary
+ cell = hb.set_index("sample_id")["top_enrichment_proxy_mean"].to_dict()
+ bg = hb.set_index("sample_id")["zoom_background_fraction_mean"].to_dict()
+ resp_rows = []
+ resp_heatmap_dir = DATA / exp_dir["response"] / "heatmaps"
+ for ov in resp_heatmap_dir.glob("split-*/*/plots/overview-*.png"):
+ stem = ov.parent.parent.name
+ split = ov.parent.parent.parent.name
+ srow = ss[ss.stem == stem]
+ if not len(srow):
+ continue
+ sid = srow.iloc[0]["sample_id"]
+ prow = preds[preds.SAMPLE_ID == sid]
+ if not len(prow):
+ continue
+ pr = prow.iloc[0]
+ resp_rows.append(
+ {
+ "sample_id": sid,
+ "stem": stem,
+ "split": split,
+ "experiment": "response",
+ "gt": pr["RESPONSE_CR"],
+ "pred": pr["pred"],
+ "p_yes": round(float(pr["RESPONSE_CR_yes"]), 3),
+ "correct": bool(pr["RESPONSE_CR"] == pr["pred"]),
+ "enrichment": round(float(cell.get(sid, np.nan)), 3)
+ if sid in cell
+ else None,
+ "background": round(float(bg.get(sid, np.nan)), 3)
+ if sid in bg
+ else None,
+ "overview": str(ov),
+ }
+ )
+ rdf = pd.DataFrame(resp_rows)
+ rdf = rdf[rdf.correct].sort_values("enrichment", ascending=False)
+ out["response"] = rdf.head(10).to_dict("records")
+ return out
+
+
+def montage(task: str, cands: list, cols: int = 4) -> None:
+ if not cands:
+ print(f"no candidates for {task}")
+ return
+ n = len(cands)
+ rows = (n + cols - 1) // cols
+ fig, axes = plt.subplots(rows, cols, figsize=(cols * 3.4, rows * 3.4))
+ axes = np.atleast_1d(axes).ravel()
+ for ax, c in zip(axes, cands):
+ img = plt.imread(c["overview"])
+ ax.imshow(img)
+ cap = f"{c['sample_id']}\n{c.get('note') or ('GT ' + str(c.get('gt')) + '/pred ' + str(c.get('pred')))}"
+ extra = []
+ if c.get("enrichment") is not None:
+ extra.append(f"enr {c['enrichment']}")
+ if c.get("p_yes") is not None:
+ extra.append(f"P(yes) {c['p_yes']}")
+ if extra:
+ cap += " [" + ", ".join(extra) + "]"
+ ax.set_title(cap, fontsize=8)
+ ax.axis("off")
+ for ax in axes[n:]:
+ ax.axis("off")
+ fig.suptitle(f"{task} candidates", fontsize=13, fontweight="bold")
+ fig.tight_layout()
+ p = ASSETS / f"montage_{task}.png"
+ fig.savefig(p, bbox_inches="tight", dpi=130)
+ plt.close(fig)
+ print(f"wrote {p} ({n} candidates)")
+
+
+# --------------------------------------------------------------------------- #
+# Explainability: attention enrichment vs clinical blast band (group_summary)
+# --------------------------------------------------------------------------- #
+def fig_explain_enrichment() -> None:
+ gs = pd.read_csv(VR / "blast_enrichment_triage" / "group_summary.csv")
+ gs = gs[gs.group_type == "clinical_blast_band"]
+ order = ["low_blast_lt5", "intermediate_blast_5_19", "high_blast_ge20"]
+ xlabels = ["low\n(<5%)", "intermediate\n(5–19%)", "high\n(≥20%)"]
+
+ def series(exp):
+ d = gs[gs.experiment == exp].set_index("group_name")
+ return [float(d.loc[b, "top_enrichment_proxy_mean"]) for b in order]
+
+ hb, bp = series("high_blast"), series("blast_percent")
+ x = np.arange(len(order))
+ w = 0.38
+ fig, ax = plt.subplots(figsize=(9.2, 5.0))
+ ax.bar(
+ x - w / 2,
+ hb,
+ w,
+ label="HIGH_BLAST model (proxy AUROC 0.86)",
+ color=NAVY,
+ edgecolor="white",
+ )
+ ax.bar(
+ x + w / 2,
+ bp,
+ w,
+ label="BLAST_PERCENT model (proxy AUROC 0.42 ≈ chance)",
+ color=GREY,
+ edgecolor="white",
+ )
+ for xi, v in zip(x - w / 2, hb):
+ ax.text(xi, v + 0.008, f"{v:.2f}", ha="center", fontsize=10, color=NAVY)
+ for xi, v in zip(x + w / 2, bp):
+ ax.text(xi, v + 0.008, f"{v:.2f}", ha="center", fontsize=10, color="#555")
+ ax.set_xticks(x)
+ ax.set_xticklabels(xlabels)
+ ax.set_ylim(0, 0.52)
+ ax.set_xlabel("clinical blast burden")
+ ax.set_ylabel("top-attention enrichment\n(overlap with cell-rich regions)")
+ ax.spines[["top", "right"]].set_visible(False)
+ ax.legend(
+ frameon=False, loc="upper center", fontsize=10, bbox_to_anchor=(0.5, 1.02)
+ )
+ ax.annotate(
+ "rises with blast burden",
+ xy=(2 - w / 2, hb[2] + 0.005),
+ xytext=(0.55, 0.20),
+ fontsize=10,
+ color=NAVY,
+ fontweight="bold",
+ arrowprops=dict(arrowstyle="->", color=NAVY, lw=1.4),
+ )
+ ax.annotate(
+ "stays flat",
+ xy=(2 + w / 2, bp[2] + 0.005),
+ xytext=(2.05, 0.44),
+ fontsize=10,
+ color="#555",
+ fontweight="bold",
+ arrowprops=dict(arrowstyle="->", color="#777", lw=1.4),
+ )
+ fig.tight_layout()
+ fig.savefig(ASSETS / "fig_explain_enrichment.png", bbox_inches="tight")
+ plt.close(fig)
+ print("wrote fig_explain_enrichment.png")
+
+
+# --------------------------------------------------------------------------- #
+# Compose clean per-experiment example panels: WSI thumbnail (top) + attention
+# overlay (bottom), N examples across columns.
+# --------------------------------------------------------------------------- #
+def _folder(overview_path: str) -> Path:
+ return Path(overview_path).parent.parent # /plots/overview.png ->
+
+
+def compose_examples(task: str, picks: list[dict]) -> None:
+ n = len(picks)
+ fig, axes = plt.subplots(
+ 2, n, figsize=(4.3 * n, 5.6), gridspec_kw={"height_ratios": [1, 1]}
+ )
+ axes = np.atleast_2d(axes)
+ for i, p in enumerate(picks):
+ folder = _folder(p["overview"])
+ stem = folder.name
+ thumb = folder / "raw" / f"thumbnail-{stem}.png"
+ if p["kind"] == "classification":
+ overlay = folder / "plots" / f"overlay-{stem}-{p['cls']}.png"
+ else:
+ overlay = folder / "raw" / f"raw-overlay-{stem}.png"
+ for row, img_path, sub in (
+ (0, thumb, "WSI"),
+ (1, overlay, "attention overlay"),
+ ):
+ ax = axes[row, i]
+ if img_path.exists():
+ ax.imshow(plt.imread(img_path))
+ else:
+ ax.text(0.5, 0.5, f"missing:\n{img_path.name}", ha="center", fontsize=7)
+ ax.axis("off")
+ if row == 1:
+ ax.set_xlabel(sub, fontsize=9, color="#555")
+ axes[0, i].set_title(
+ p["caption"], fontsize=11, color=NAVY, fontweight="bold", pad=6
+ )
+ fig.tight_layout()
+ out = ASSETS / f"exp_{task}.png"
+ fig.savefig(out, bbox_inches="tight", dpi=150)
+ plt.close(fig)
+ print(f"wrote {out.name}")
+
+
+def compose_all_examples(cands: dict) -> None:
+ by_id = {}
+ for key in ("response", "high_blast", "blast_percent"):
+ for c in cands[key]:
+ by_id[(key, c["sample_id"])] = c
+
+ def get(key, sid):
+ return by_id[(key, sid)]
+
+ # Exp 1 — RESPONSE: one confident non-responder + one confident responder, tissue visible
+ resp = [
+ {
+ **get("response", "SAMPLE_416_1012_19_A"),
+ "kind": "classification",
+ "cls": "no",
+ "caption": "1012_19_A\nGT non-responder · pred non-responder",
+ },
+ {
+ **get("response", "SAMPLE_380_1020_19_A"),
+ "kind": "classification",
+ "cls": "yes",
+ "caption": "1020_19_A\nGT responder · pred responder (P=0.88)",
+ },
+ ]
+ compose_examples("response", resp)
+
+ # Exp 2 — HIGH_BLAST: three correct positives with clear blast clusters
+ hb_ids = ["SAMPLE_404_1349_19_A", "SAMPLE_401_960_19_B", "SAMPLE_402_1085_19_B"]
+ hb = [
+ {
+ **get("high_blast", sid),
+ "kind": "classification",
+ "cls": "yes",
+ "caption": f"{sid.replace('SAMPLE_', '')}\nGT high-blast · pred high",
+ }
+ for sid in hb_ids
+ ]
+ compose_examples("high_blast", hb)
+
+ # Exp 4 — BLAST_PERCENT: one correct low + two dramatic high-blast under-predictions
+ bp_specs = [
+ ("SAMPLE_388_583_19_A", "583_19_A\nGT 3% · pred ~1% (correct)"),
+ ("SAMPLE_404_1261_19_A", "1261_19_A\nGT 90% · pred 12% (FAILURE)"),
+ ("SAMPLE_408_1411_19_C", "1411_19_C\nGT 91% · pred 13% (FAILURE)"),
+ ]
+ bp = [
+ {**get("blast_percent", sid), "kind": "regression", "caption": cap}
+ for sid, cap in bp_specs
+ ]
+ compose_examples("blast_percent", bp)
+
+
+def main() -> None:
+ clini = pd.read_csv(ROOT / "tables" / "stamp_clini.csv")
+ n_pat = clini["PATIENT"].nunique()
+ n_samp = len(clini)
+ n_slide = sum(1 for _ in open(ROOT / "tables" / "stamp_slide.csv")) - 1
+
+ fig_labels(clini)
+ fig_cohort_cv(n_pat, n_samp, n_slide)
+ fig_workflow()
+
+ fig_explain_enrichment()
+
+ cands = select_candidates(clini)
+ (ASSETS / "candidates.json").write_text(json.dumps(cands, indent=2, default=str))
+ print("wrote candidates.json")
+ compose_all_examples(cands)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/build_presentations.py b/scripts/build_presentations.py
new file mode 100644
index 00000000..d213ca8f
--- /dev/null
+++ b/scripts/build_presentations.py
@@ -0,0 +1,1352 @@
+"""Build the two SFB 1709 / Project B01 presentations (15 min + 5 min) as .pptx.
+
+Single source of truth for every number and figure shown on a slide lives in the
+constants block below, so the long talk and the lightning talk can never disagree.
+
+Run: uv pip install python-pptx && .venv/bin/python scripts/build_presentations.py
+Outputs: presentations/STAMP_B01_15min.pptx, presentations/STAMP_B01_5min.pptx
+
+All figures are pulled from validation_report/. The script raises immediately if any
+referenced figure is missing so a broken deck is never produced silently.
+"""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+from pptx import Presentation
+from pptx.dml.color import RGBColor
+from pptx.enum.text import PP_ALIGN
+from pptx.util import Emu, Inches, Pt
+
+# --------------------------------------------------------------------------- #
+# Paths
+# --------------------------------------------------------------------------- #
+ROOT = Path(__file__).resolve().parent.parent
+VR = ROOT / "validation_report"
+HEATMAPS = VR / "heatmaps"
+PREP = VR / "preprocessing"
+OUT_DIR = ROOT / "presentations"
+
+S895 = (
+ HEATMAPS / "S_895 19 B Score 97 - 10 Fokuspunkte - entfettet - 2026-03-04 20.46.51"
+)
+S333 = HEATMAPS / "333 19 A Score 29 - 5 Fokuspunkte - entfettet - 2026-03-03 18.42.23"
+PREP_127 = PREP / "127 26 C Score 0 - 5 Fokuspunkte - entfettet - 2026-03-04 17.28.20"
+
+FIG = {
+ "resp_pos": S333 / "response__overview.png", # GT yes, P(yes)=0.945 -> correct
+ "high_blast_pos": S895
+ / "high_blast__overview.png", # GT yes, P(yes)=0.618 -> correct
+ "blast_pct_fail": S895 / "blast_percent__overview.png", # GT 75% -> pred 0.93%
+ "prep_before": Path(str(PREP_127) + "__before.jpg"),
+ "prep_after": Path(str(PREP_127) + "__after.jpg"),
+ "prep_rejected": Path(str(PREP_127) + "__rejected_grid.jpg"),
+ # cell-resolution (DeepHeme-style) pilot
+ "cellres_positive": VR
+ / "pilot_cell_res/positive/SAMPLE_395_486_19_A/contact_sheet.jpg",
+ "cellres_failure": VR
+ / "pilot_cell_res/failure/SAMPLE_396_803_19_B/contact_sheet.jpg",
+ # blast-enrichment triage overlay (green = conservative cell-like candidates)
+ "triage_overlay": VR / "blast_enrichment_triage/overlays/high_blast/"
+ "SAMPLE_309_665_19_C__S_665_19_C_Score_95_-_10_Fokuspunkte_-_entfettet_-_2026-03-03_23.38.50/"
+ "overlay_contact_sheet.jpg",
+}
+
+# Figures generated by scripts/build_presentation_assets.py (run that first).
+ASSET = {
+ name: OUT_DIR / "assets" / f"{name}.png"
+ for name in (
+ "fig_labels",
+ "fig_cohort_cv",
+ "fig_workflow",
+ "fig_explain_enrichment",
+ "exp_response",
+ "exp_high_blast",
+ "exp_blast_percent",
+ )
+}
+
+# --------------------------------------------------------------------------- #
+# Verified content (do not invent numbers — see plan / stats CSVs)
+# --------------------------------------------------------------------------- #
+TITLE = "Predicting VEN/AZA Response in AML from Blast Morphology with AI"
+SUBTITLE = "SFB 1709 · Project B01 • STAMP computational-pathology pipeline"
+AUTHORS = "Jiefu Zhu (PhD) • PIs: Prof. J. N. Kather, Dr. M. Janssen"
+AFFIL = "Dept. of Medical Oncology (NCT-HD) & Dept. of Hematology, Heidelberg University Hospital"
+FOOTER = "SFB 1709 · B01 · STAMP"
+
+# (task, type, n / split, AUROC or metric, note)
+RESULTS_ROWS = [
+ ("Task", "Type", "Samples", "Performance"),
+ (
+ "RESPONSE_CR (complete remission)",
+ "binary",
+ "489 (336 yes / 153 no)",
+ "AUROC 0.870 (95% CI 0.80–0.94)",
+ ),
+ ("HIGH_BLAST", "binary", "489 (98 / 391)", "AUROC 0.929 (0.88–0.98)"),
+ (
+ "BLAST_SEVERITY",
+ "3-class",
+ "489 (98 / 55 / 336)",
+ "AUROC: high 0.91 · low 0.86 · interm. 0.78",
+ ),
+ ("BLAST_PERCENT", "regression", "n≈97", "Pearson r 0.68 · R²≈0 · MAE 12%"),
+]
+
+TASKS_ROWS = [
+ ("Label", "Question", "Type"),
+ ("RESPONSE_CR", "Will the patient reach complete remission?", "binary (yes/no)"),
+ ("HIGH_BLAST", "Is this a high-blast-burden sample?", "binary (yes/no)"),
+ ("BLAST_SEVERITY", "Low / intermediate / high blast burden", "3-class"),
+ ("BLAST_PERCENT", "What % of cells are blasts?", "regression (0–100)"),
+]
+
+# Colors
+NAVY = RGBColor(0x1A, 0x4D, 0x8C)
+DARK = RGBColor(0x22, 0x22, 0x22)
+GREY = RGBColor(0x66, 0x66, 0x66)
+WHITE = RGBColor(0xFF, 0xFF, 0xFF)
+LIGHT = RGBColor(0xEE, 0xEF, 0xF7)
+
+SLIDE_W = Inches(13.333)
+SLIDE_H = Inches(7.5)
+
+
+# --------------------------------------------------------------------------- #
+# Helpers
+# --------------------------------------------------------------------------- #
+def _check_figs() -> None:
+ missing = [str(p) for p in FIG.values() if not p.exists()]
+ if missing:
+ raise FileNotFoundError("Missing figures:\n " + "\n ".join(missing))
+
+
+def _check_assets() -> None:
+ missing = [str(p) for p in ASSET.values() if not p.exists()]
+ if missing:
+ raise FileNotFoundError(
+ "Missing generated figures — run `python scripts/build_presentation_assets.py` first:\n "
+ + "\n ".join(missing)
+ )
+
+
+def _blank(prs: Presentation):
+ return prs.slides.add_slide(prs.slide_layouts[6])
+
+
+def _footer(slide) -> None:
+ box = slide.shapes.add_textbox(
+ Inches(0.4), Inches(7.05), Inches(12.5), Inches(0.35)
+ )
+ tf = box.text_frame
+ tf.text = FOOTER
+ p = tf.paragraphs[0]
+ p.font.size = Pt(9)
+ p.font.color.rgb = GREY
+
+
+def _band(slide) -> None:
+ """Thin navy accent bar under the title area."""
+ bar = slide.shapes.add_shape(1, Inches(0.0), Inches(1.28), SLIDE_W, Pt(3))
+ bar.fill.solid()
+ bar.fill.fore_color.rgb = NAVY
+ bar.line.fill.background()
+
+
+def _title(slide, text: str) -> None:
+ box = slide.shapes.add_textbox(Inches(0.5), Inches(0.35), Inches(12.3), Inches(0.9))
+ tf = box.text_frame
+ tf.word_wrap = True
+ tf.text = text
+ p = tf.paragraphs[0]
+ p.font.size = Pt(28)
+ p.font.bold = True
+ p.font.color.rgb = NAVY
+ _band(slide)
+
+
+def add_title_slide(prs) -> None:
+ slide = _blank(prs)
+ bg = slide.shapes.add_shape(1, 0, 0, SLIDE_W, SLIDE_H)
+ bg.fill.solid()
+ bg.fill.fore_color.rgb = NAVY
+ bg.line.fill.background()
+ bg.shadow.inherit = False
+
+ box = slide.shapes.add_textbox(Inches(0.8), Inches(2.1), Inches(11.7), Inches(2.0))
+ tf = box.text_frame
+ tf.word_wrap = True
+ tf.text = TITLE
+ p = tf.paragraphs[0]
+ p.font.size = Pt(40)
+ p.font.bold = True
+ p.font.color.rgb = WHITE
+
+ for txt, size in ((SUBTITLE, 20), (AUTHORS, 16), (AFFIL, 13)):
+ b = slide.shapes.add_textbox(
+ Inches(0.85),
+ Inches(4.2 if size == 20 else (5.0 if size == 16 else 5.5)),
+ Inches(11.6),
+ Inches(0.6),
+ )
+ t = b.text_frame
+ t.word_wrap = True
+ t.text = txt
+ pr = t.paragraphs[0]
+ pr.font.size = Pt(size)
+ pr.font.color.rgb = RGBColor(0xDD, 0xE6, 0xF2)
+ return slide
+
+
+def set_notes(slide, text: str):
+ """Attach speaker-notes (the spoken script) to a slide."""
+ slide.notes_slide.notes_text_frame.text = text.strip()
+ return slide
+
+
+def _subtitle(slide, text: str) -> None:
+ """Clean grey italic subtitle line just under the title band."""
+ box = slide.shapes.add_textbox(
+ Inches(0.55), Inches(1.36), Inches(12.3), Inches(0.5)
+ )
+ tf = box.text_frame
+ tf.word_wrap = True
+ tf.text = text
+ p = tf.paragraphs[0]
+ p.font.size = Pt(14)
+ p.font.italic = True
+ p.font.color.rgb = GREY
+
+
+def add_bullets(prs, title: str, bullets, *, tldr: str | None = None):
+ """bullets: list of (text, level) or str (level 0). `tldr` renders as a clean subtitle."""
+ slide = _blank(prs)
+ _title(slide, title)
+ top = Inches(1.6)
+ if tldr:
+ _subtitle(slide, tldr)
+ top = Inches(2.05)
+
+ box = slide.shapes.add_textbox(Inches(0.6), top, Inches(12.2), Inches(4.5))
+ tf = box.text_frame
+ tf.word_wrap = True
+ first = True
+ for item in bullets:
+ text, level = item if isinstance(item, tuple) else (item, 0)
+ p = tf.paragraphs[0] if first else tf.add_paragraph()
+ first = False
+ p.text = ("– " if level else "• ") + text
+ p.level = level
+ p.font.size = Pt(20 - 2 * level)
+ p.font.color.rgb = DARK if level == 0 else GREY
+ p.space_after = Pt(7)
+ _footer(slide)
+ return slide
+
+
+def add_table(prs, title: str, rows, *, note: str | None = None, col_widths=None):
+ slide = _blank(prs)
+ _title(slide, title)
+ n_rows, n_cols = len(rows), len(rows[0])
+ width = Inches(12.3)
+ height = Inches(0.55 * n_rows)
+ gfx = slide.shapes.add_table(
+ n_rows, n_cols, Inches(0.5), Inches(1.75), width, height
+ )
+ table = gfx.table
+ if col_widths:
+ for i, w in enumerate(col_widths):
+ table.columns[i].width = Inches(w)
+ for r, row in enumerate(rows):
+ for c, val in enumerate(row):
+ cell = table.cell(r, c)
+ cell.text = str(val)
+ para = cell.text_frame.paragraphs[0]
+ para.font.size = Pt(15 if r else 15)
+ if r == 0:
+ para.font.bold = True
+ para.font.color.rgb = WHITE
+ cell.fill.solid()
+ cell.fill.fore_color.rgb = NAVY
+ else:
+ para.font.color.rgb = DARK
+ cell.fill.solid()
+ cell.fill.fore_color.rgb = WHITE if r % 2 else LIGHT
+ if note:
+ nb = slide.shapes.add_textbox(
+ Inches(0.5), Inches(1.85 + 0.55 * n_rows), Inches(12.3), Inches(1.2)
+ )
+ tf = nb.text_frame
+ tf.word_wrap = True
+ tf.text = note
+ pr = tf.paragraphs[0]
+ pr.font.size = Pt(15)
+ pr.font.italic = True
+ pr.font.color.rgb = GREY
+ _footer(slide)
+ return slide
+
+
+def _add_image_fit(slide, path: Path, left, top, max_w, max_h):
+ """Insert image scaled to fit a box, preserving aspect ratio, centered."""
+ from PIL import Image
+
+ with Image.open(path) as im:
+ iw, ih = im.size
+ box_ratio = max_w / max_h
+ img_ratio = iw / ih
+ if img_ratio > box_ratio:
+ w = max_w
+ h = int(max_w / img_ratio)
+ else:
+ h = max_h
+ w = int(max_h * img_ratio)
+ off_l = left + (max_w - w) // 2
+ off_t = top + (max_h - h) // 2
+ slide.shapes.add_picture(
+ str(path), Emu(off_l), Emu(off_t), width=Emu(w), height=Emu(h)
+ )
+
+
+def add_image_slide(
+ prs, title: str, fig: Path, caption: str, *, side_text=None, subtitle=None
+):
+ slide = _blank(prs)
+ _title(slide, title)
+ img_top = Inches(1.65).emu
+ if subtitle and not side_text:
+ _subtitle(slide, subtitle)
+ img_top = Inches(2.05).emu
+ if side_text:
+ # image left, text right
+ _add_image_fit(
+ slide,
+ fig,
+ Inches(0.5).emu,
+ Inches(1.7).emu,
+ Inches(7.4).emu,
+ Inches(4.7).emu,
+ )
+ tb = slide.shapes.add_textbox(
+ Inches(8.2), Inches(1.8), Inches(4.7), Inches(4.6)
+ )
+ tf = tb.text_frame
+ tf.word_wrap = True
+ first = True
+ for item in side_text:
+ text, level = item if isinstance(item, tuple) else (item, 0)
+ p = tf.paragraphs[0] if first else tf.add_paragraph()
+ first = False
+ p.text = ("– " if level else "• ") + text
+ p.level = level
+ p.font.size = Pt(18 - 2 * level)
+ p.font.color.rgb = DARK if level == 0 else GREY
+ p.space_after = Pt(8)
+ cap_top = Inches(6.5)
+ cap_w = Inches(7.4)
+ cap_left = Inches(0.5)
+ else:
+ _add_image_fit(
+ slide,
+ fig,
+ Inches(0.5).emu,
+ img_top,
+ Inches(12.3).emu,
+ (Inches(6.55).emu - img_top),
+ )
+ cap_top = Inches(6.65)
+ cap_w = Inches(12.3)
+ cap_left = Inches(0.5)
+ cb = slide.shapes.add_textbox(cap_left, cap_top, cap_w, Inches(0.5))
+ tf = cb.text_frame
+ tf.word_wrap = True
+ tf.text = caption
+ pr = tf.paragraphs[0]
+ pr.font.size = Pt(13)
+ pr.font.italic = True
+ pr.font.color.rgb = GREY
+ _footer(slide)
+ return slide
+
+
+def add_two_image_slide(
+ prs, title: str, left_fig, left_cap, right_fig, right_cap, *, banner=None
+):
+ slide = _blank(prs)
+ _title(slide, title)
+ top = Inches(1.7).emu
+ if banner:
+ _subtitle(slide, banner)
+ top = Inches(2.15).emu
+ half = Inches(6.0).emu
+ img_h = Inches(3.9).emu
+ _add_image_fit(slide, left_fig, Inches(0.4).emu, top, half, img_h)
+ _add_image_fit(slide, right_fig, Inches(6.9).emu, top, half, img_h)
+ cap_top = Inches(6.35)
+ for left_pos, cap in ((Inches(0.4), left_cap), (Inches(6.9), right_cap)):
+ cb = slide.shapes.add_textbox(left_pos, cap_top, Inches(6.0), Inches(0.6))
+ tf = cb.text_frame
+ tf.word_wrap = True
+ tf.text = cap
+ pr = tf.paragraphs[0]
+ pr.alignment = PP_ALIGN.CENTER
+ pr.font.size = Pt(12)
+ pr.font.italic = True
+ pr.font.color.rgb = GREY
+ _footer(slide)
+ return slide
+
+
+def add_section_end(prs, title: str, bullets):
+ slide = _blank(prs)
+ bg = slide.shapes.add_shape(1, 0, 0, SLIDE_W, SLIDE_H)
+ bg.fill.solid()
+ bg.fill.fore_color.rgb = NAVY
+ bg.line.fill.background()
+ tb = slide.shapes.add_textbox(Inches(0.8), Inches(0.7), Inches(11.7), Inches(1.0))
+ tf = tb.text_frame
+ tf.text = title
+ tf.paragraphs[0].font.size = Pt(32)
+ tf.paragraphs[0].font.bold = True
+ tf.paragraphs[0].font.color.rgb = WHITE
+ box = slide.shapes.add_textbox(Inches(0.9), Inches(2.0), Inches(11.5), Inches(4.6))
+ tf = box.text_frame
+ tf.word_wrap = True
+ first = True
+ for b in bullets:
+ p = tf.paragraphs[0] if first else tf.add_paragraph()
+ first = False
+ p.text = "• " + b
+ p.font.size = Pt(20)
+ p.font.color.rgb = RGBColor(0xE6, 0xEC, 0xF5)
+ p.space_after = Pt(12)
+ return slide
+
+
+# --------------------------------------------------------------------------- #
+# 15-minute deck
+# --------------------------------------------------------------------------- #
+def build_15min() -> Presentation:
+ prs = Presentation()
+ prs.slide_width = SLIDE_W
+ prs.slide_height = SLIDE_H
+
+ add_title_slide(prs)
+
+ # 2 Clinical motivation
+ add_bullets(
+ prs,
+ "Clinical motivation",
+ [
+ "Acute myeloid leukemia (AML): aggressive blood cancer driven by immature blast cells.",
+ "Venetoclax + azacitidine (VEN/AZA) is a key therapy — but response is variable and hard to predict up front.",
+ "Blasts are the diagnostic read-out: counted and graded by hematopathologists on bone-marrow smears.",
+ ("We study paired primary samples at diagnosis and relapse.", 1),
+ "If blast morphology encodes who will respond, we can stratify patients earlier.",
+ ],
+ tldr="Goal: predict VEN/AZA response in AML directly from the appearance of blast cells.",
+ )
+
+ # 3 Project vision
+ add_bullets(
+ prs,
+ "Project B01 — the vision",
+ [
+ "Analyze cytologic variability of AML blasts as a determinant of VEN/AZA response.",
+ "Combine two modalities on the same patients:",
+ (
+ "Generative-AI / deep-learning image analysis of blast morphology (this talk).",
+ 1,
+ ),
+ ("Single-cell RNA sequencing (scRNA-seq) of the same samples.", 1),
+ "Integrate morphology + molecular profiles to predict outcome and discover novel response biomarkers.",
+ ],
+ tldr="B01: morphology (AI) + single-cell transcriptomics → response prediction & biomarker discovery.",
+ )
+
+ # 4 What is STAMP
+ add_bullets(
+ prs,
+ "What is STAMP?",
+ [
+ "End-to-end, weakly-supervised deep-learning pipeline: whole-slide image → biomarker prediction.",
+ "No pixel-level annotations needed — only one clinical label per sample.",
+ "Peer-reviewed protocol (Nature Protocols 2024); used across many tumor types and centers.",
+ "Supports 20+ pathology foundation models and classification, regression & survival in one framework.",
+ ("We use it as the image-analysis engine for B01.", 1),
+ ],
+ tldr="STAMP turns gigapixel slides into predictions with only slide-level labels.",
+ )
+
+ # 5 Pipeline
+ add_bullets(
+ prs,
+ "The STAMP pipeline (as we run it)",
+ [
+ "1. Preprocess — tile each smear WSI into 224×224 patches; QC filters drop empty/blurred tiles.",
+ "2. Feature extraction — UNI2 pathology foundation model embeds every tile.",
+ "3. Aggregation — Vision-Transformer multiple-instance learning (MIL) pools tiles → one prediction per sample.",
+ "4. Cross-validation — 5-fold; every sample is predicted while held out of training.",
+ "5. Outputs — metrics with 95% CIs + explainability heatmaps + top-tile exports.",
+ ],
+ tldr="Preprocess → UNI2 tile features → ViT-MIL → 5-fold crossval → metrics + heatmaps.",
+ )
+
+ # 6 Cohort & labels
+ add_bullets(
+ prs,
+ "Our cohort & labels",
+ [
+ "Bone-marrow smear whole-slide images from the AG Janssen AML cohort.",
+ "69 patients • 489 samples (diagnosis/relapse × smear parts A/B/C) • 511 slide images.",
+ "Tile features: UNI2 (uni2-0242c340). Model: ViT-MIL (dim 512, 2 layers, 8 heads), 5-fold crossval.",
+ "Four clinical labels per sample, defined with the hematology team:",
+ ("RESPONSE_CR, HIGH_BLAST, BLAST_SEVERITY, BLAST_PERCENT.", 1),
+ ],
+ tldr="69 patients • 489 samples • 511 BM-smear WSIs • 4 prediction tasks.",
+ )
+
+ # 7 The 4 tasks
+ add_table(prs, "The four prediction tasks", TASKS_ROWS, col_widths=[2.9, 6.4, 3.0])
+
+ # 8 Results at a glance
+ add_table(
+ prs,
+ "Results at a glance (5-fold cross-validation)",
+ RESULTS_ROWS,
+ col_widths=[4.6, 1.7, 2.8, 3.2],
+ note="Classification reported as cross-validated AUROC with 95% CI. Regression: correlation is "
+ "strong but absolute calibration is poor (see BLAST_PERCENT slide).",
+ )
+
+ # 9 RESPONSE_CR
+ add_image_slide(
+ prs,
+ "RESPONSE_CR — predicting complete remission",
+ FIG["resp_pos"],
+ "Heatmap: sample 333 19 A (held-out). GT = responder (yes); model P(yes)=0.945. "
+ "Red = tiles most relevant to the 'responder' prediction.",
+ side_text=[
+ "AUROC 0.870 (95% CI 0.80–0.94).",
+ "Best clinically actionable result: response is predictable from morphology alone.",
+ "Heatmap shows the model concentrates on cell-rich smear regions.",
+ ("Confident mistakes are flagged for re-review (label-noise check).", 1),
+ ],
+ )
+
+ # 10 HIGH_BLAST
+ add_image_slide(
+ prs,
+ "HIGH_BLAST — detecting high blast burden",
+ FIG["high_blast_pos"],
+ "Heatmap: sample S_895 19 B (held-out). GT = high blast (yes); model P(yes)=0.618 → correct.",
+ side_text=[
+ "AUROC 0.929 (95% CI 0.88–0.98) — our strongest classifier.",
+ "Attention lands on dense blast-rich areas.",
+ "This is the signal we trust most for visual triage.",
+ ],
+ )
+
+ # 11 BLAST_SEVERITY
+ add_bullets(
+ prs,
+ "BLAST_SEVERITY — low / intermediate / high",
+ [
+ "3-class severity grading.",
+ "AUROC: high 0.912 • low 0.864 • intermediate 0.783.",
+ (
+ "The 'intermediate' class is hardest — expected, it sits between two boundaries.",
+ 1,
+ ),
+ "High and low are separated reliably; intermediate is where extra annotation would help most.",
+ ],
+ tldr="Severity grading works well at the extremes; the middle class is the hard one.",
+ )
+
+ # 12 BLAST_PERCENT regression
+ add_image_slide(
+ prs,
+ "BLAST_PERCENT — a cautionary result",
+ FIG["blast_pct_fail"],
+ "Regression relevance map: sample S_895 19 B. GT blast = 75%, but predicted 0.9% (|err| 74%).",
+ side_text=[
+ "Pearson r = 0.68 (p≈10⁻¹⁴): ranks samples well.",
+ "But R²≈0 and MAE 12% — absolute values are mis-calibrated.",
+ "Fails badly on the highest-blast slides.",
+ ("Lesson: trust the ordering, not the exact % (yet).", 1),
+ ],
+ )
+
+ # 13 Explainability & triage
+ add_two_image_slide(
+ prs,
+ "What the model looks at — and what to trust",
+ FIG["high_blast_pos"],
+ "HIGH_BLAST attention — tracks clinical blast burden (proxy AUROC 0.86, ρ=0.35).",
+ FIG["blast_pct_fail"],
+ "BLAST_PERCENT relevance — does NOT track blast burden (proxy AUROC 0.42, ρ=−0.04).",
+ banner="Same slide, two heads: use HIGH_BLAST maps for visual blast triage — not the regression maps.",
+ )
+
+ # 14 Preprocessing QC
+ add_two_image_slide(
+ prs,
+ "Preprocessing QC insight: a data-quality finding",
+ FIG["prep_before"],
+ "Before — raw smear (sample 127 26 C).",
+ FIG["prep_after"],
+ "After — only 4.7% of tiles kept (green); rest dropped (red).",
+ banner="Cause: out-of-focus tiles fail the Canny edge filter — a 5- vs 10-'Fokuspunkte' scan issue. Fix = re-scan.",
+ )
+
+ # 15 Toward annotation
+ add_bullets(
+ prs,
+ "Toward hematopathologist annotation",
+ [
+ "We rank slides by how much a human label would help, in three buckets:",
+ ("Confidently correct → validate the visual cues the model relies on.", 1),
+ (
+ "Confidently wrong → surface label noise or genuinely hard morphology (highest value).",
+ 1,
+ ),
+ (
+ "Near the decision boundary → add ground truth exactly where it moves AUROC.",
+ 1,
+ ),
+ "Output is a ready-to-review deck + overlay contact sheets for the hematology team.",
+ ],
+ tldr="A targeted annotation plan turns model uncertainty into the most informative labels.",
+ )
+
+ # 16 Limitations
+ add_bullets(
+ prs,
+ "Limitations",
+ [
+ "Labels are slide-level, not cell-level — no per-blast ground truth yet.",
+ "BLAST_PERCENT regression is mis-calibrated on high-blast samples.",
+ "Focus/staining artifacts (and the 5-Fokuspunkte scans) reduce usable tissue on some slides.",
+ "Some confident errors look like label noise — needs pathologist adjudication.",
+ "Single-center cohort; external validation still to come.",
+ ],
+ )
+
+ # 17 Next steps + acknowledgments
+ add_section_end(
+ prs,
+ "Summary & next steps",
+ [
+ "Response (AUROC 0.87) and blast burden (0.93) are predictable from smear morphology alone.",
+ "Explainability heatmaps make the predictions auditable for clinicians.",
+ "Next: pathologist annotation round, regression re-calibration, re-scan low-focus slides.",
+ "Then: integrate with single-cell transcriptomics for VEN/AZA biomarker discovery.",
+ "Thanks: AG Janssen, Kather Lab, SFB 1709 consortium.",
+ ],
+ )
+
+ return prs
+
+
+# --------------------------------------------------------------------------- #
+# 5-minute deck
+# --------------------------------------------------------------------------- #
+def build_5min() -> Presentation:
+ prs = Presentation()
+ prs.slide_width = SLIDE_W
+ prs.slide_height = SLIDE_H
+
+ add_title_slide(prs)
+
+ add_bullets(
+ prs,
+ "The question",
+ [
+ "AML is treated with venetoclax + azacitidine — but who responds is hard to predict.",
+ "Blasts (leukemic cells) are graded by eye on bone-marrow smears.",
+ "Can we predict VEN/AZA response directly from blast morphology?",
+ (
+ "Project B01: AI image analysis + single-cell transcriptomics on paired diagnosis/relapse samples.",
+ 1,
+ ),
+ ],
+ tldr="Predict VEN/AZA response in AML from the appearance of blast cells.",
+ )
+
+ add_bullets(
+ prs,
+ "Our approach",
+ [
+ "STAMP weakly-supervised pipeline on bone-marrow smear WSIs (only slide-level labels).",
+ "UNI2 tile features → Vision-Transformer MIL → 5-fold cross-validation.",
+ "69 patients • 489 samples • 511 slides • 4 prediction tasks.",
+ "Outputs metrics with 95% CIs and explainability heatmaps.",
+ ],
+ tldr="Slide → UNI2 features → ViT-MIL → cross-validated prediction + heatmap.",
+ )
+
+ add_table(
+ prs,
+ "Key results (5-fold cross-validation)",
+ RESULTS_ROWS,
+ col_widths=[4.6, 1.7, 2.8, 3.2],
+ note="Response and blast burden are strongly predictable. Blast-% regression ranks well "
+ "(r=0.68) but is not yet calibrated (R²≈0, MAE 12%).",
+ )
+
+ add_image_slide(
+ prs,
+ "What the model looks at",
+ FIG["resp_pos"],
+ "RESPONSE_CR heatmap: sample 333 19 A (held-out). GT responder; P(yes)=0.945. "
+ "Red = most relevant tiles — the prediction is auditable.",
+ side_text=[
+ "Heatmaps localize the morphology driving each call.",
+ "HIGH_BLAST attention tracks clinical blast burden.",
+ ("Caveat: trust blast-% ordering, not the exact number.", 1),
+ ],
+ )
+
+ add_section_end(
+ prs,
+ "Takeaways",
+ [
+ "Response (AUROC 0.87) & high blast burden (0.93) predictable from smear morphology alone.",
+ "Predictions are explainable via heatmaps — ready for clinician review.",
+ "Next: pathologist annotation + integration with single-cell transcriptomics for biomarkers.",
+ ],
+ )
+
+ return prs
+
+
+# --------------------------------------------------------------------------- #
+# Full project-review deck (~15-18 min) WITH speaker notes (spoken script)
+# --------------------------------------------------------------------------- #
+EXP_ROADMAP_ROWS = [
+ ("#", "Experiment", "What we asked"),
+ ("1", "RESPONSE_CR", "Predict complete remission from the smear"),
+ ("2", "HIGH_BLAST", "Flag high blast burden"),
+ ("3", "BLAST_SEVERITY", "Grade blast burden (low/interm./high)"),
+ ("4", "BLAST_PERCENT", "Regress the exact % of blasts"),
+ ("5", "Explainability + triage", "Do the heatmaps point at real blasts?"),
+ ("6", "Preprocessing / data QC", "Why do some slides lose most tiles?"),
+ ("7", "Cell-resolution pilot", "Can a human even see cells in our tiles?"),
+]
+
+
+def _load_metrics():
+ f = OUT_DIR / "assets" / "metrics.json"
+ return json.loads(f.read_text()) if f.exists() else None
+
+
+def _hide(slide):
+ """Mark a slide hidden in the linear slideshow (appendix / backup)."""
+ slide._element.set("show", "0")
+ return slide
+
+
+ANALYSIS = {
+ name: OUT_DIR / "assets" / f"{name}.png"
+ for name in (
+ "fig_sample_vs_patient",
+ "fig_auprc_calibration",
+ "fig_response_beyond_blast",
+ "fig_confounder",
+ )
+}
+
+
+def build_project_review() -> Presentation:
+ prs = Presentation()
+ prs.slide_width = SLIDE_W
+ prs.slide_height = SLIDE_H
+
+ M = _load_metrics()
+ missing = [str(p) for p in ANALYSIS.values() if not p.exists()]
+ if M is None or missing:
+ raise FileNotFoundError(
+ "Run `python scripts/build_analysis_figs.py` (after training) first — "
+ "missing metrics.json and/or analysis figures:\n " + "\n ".join(missing)
+ )
+
+ P, S = M["patient"], M["sample"]
+
+ def clf(m):
+ return f"AUROC {m['auroc']:.2f} (95% CI {m['lo']:.2f}–{m['hi']:.2f})"
+
+ resp_drop = S["response"]["auroc"] - P["response"]["auroc"]
+ hb_drop = S["high_blast"]["auroc"] - P["high_blast"]["auroc"]
+ sev = P["blast_severity"]["per_class"]
+ conf = M["confounder"]
+
+ results_rows = [
+ ("Task", "Type", "n", "Patient-level result (5-fold CV)"),
+ ("RESPONSE_CR", "binary", "489", clf(P["response"])),
+ ("HIGH_BLAST", "binary", "489", clf(P["high_blast"])),
+ (
+ "BLAST_SEVERITY",
+ "3-class",
+ "489",
+ f"macro-AUROC {P['blast_severity']['auroc']:.2f}",
+ ),
+ (
+ "BLAST_PERCENT",
+ "regression",
+ "489",
+ f"Pearson r {P['blast_percent']['pearson']:.2f} · R² {P['blast_percent']['r2']:.2f} · MAE {P['blast_percent']['mae']:.0f}%",
+ ),
+ ]
+
+ # 1 Title
+ set_notes(
+ add_title_slide(prs),
+ "Good morning, and thank you for the time. I'll present our progress on Project B01 — "
+ "predicting venetoclax–azacitidine response in AML from bone-marrow smear morphology with "
+ "deep learning. I'll cover the clinical question, our data, the method, all the experiments, "
+ "the honest cross-validated results, and where I'd like your input.",
+ )
+
+ # 2 Clinical problem
+ set_notes(
+ add_bullets(
+ prs,
+ "Clinical problem",
+ [
+ "AML is an aggressive leukemia driven by immature blast cells.",
+ "Venetoclax + azacitidine (VEN/AZA) is a standard therapy, but response is heterogeneous.",
+ "Predicting response before treatment remains an unmet need.",
+ "Blasts are assessed manually on bone-marrow smears — informative but subjective and slide-level.",
+ ],
+ tldr="Question: can treatment response be predicted from blast morphology?",
+ ),
+ "AML is driven by immature cells called blasts. Venetoclax plus azacitidine is a standard "
+ "therapy, but response varies a lot between patients and is hard to predict up front. Blasts "
+ "are already read on smears, so there is rich morphological information — it is just "
+ "subjective and summarised at the slide level. Our question: is that morphology enough to "
+ "predict response?",
+ )
+
+ # 3 Project approach
+ set_notes(
+ add_bullets(
+ prs,
+ "Project B01: approach",
+ [
+ "Hypothesis: cytologic variability of blasts encodes treatment response.",
+ "Two modalities on paired diagnosis/relapse samples:",
+ ("Computational image analysis of blast morphology (this work).", 1),
+ ("Single-cell RNA sequencing of the same samples.", 1),
+ "Aim: predict response and identify candidate VEN/AZA biomarkers.",
+ ],
+ tldr="Image morphology + single-cell transcriptomics, integrated on the same patients.",
+ ),
+ "The hypothesis is that blast morphology carries response information. The project pairs two "
+ "modalities on the same diagnosis and relapse samples — image analysis, which I'll show "
+ "today, and single-cell RNA sequencing. The aim is prediction and, by linking the two, "
+ "candidate biomarkers of VEN/AZA response.",
+ )
+
+ # 4 Cohort & CV design
+ set_notes(
+ add_image_slide(
+ prs,
+ "Cohort and cross-validation design",
+ ASSET["fig_cohort_cv"],
+ "69 patients · 489 bone-marrow samples · 511 slide images. Because there are ~7 smears "
+ "per patient, folds must be grouped by patient (next slide).",
+ subtitle="Weakly supervised — one clinical label per sample, no cell-level annotation.",
+ ),
+ "The cohort is 69 patients, 489 samples across 511 slides. The crucial detail for evaluation "
+ "is that there are about seven smears per patient — up to 24 for one patient. That shapes how "
+ "we must cross-validate, which is the next slide.",
+ )
+
+ # 5 Evaluation methodology (leakage + fix)
+ set_notes(
+ add_bullets(
+ prs,
+ "Evaluation: avoiding data leakage",
+ [
+ "RESPONSE_CR is a patient-level outcome, copied onto all ~7 of a patient's smears.",
+ "If folds split by sample, a patient's other smears can sit in training while one is tested — leakage.",
+ "Fix: group folds by PATIENT (StratifiedGroupKFold) — no patient spans train and test.",
+ f"Effect is real: sample-split vs patient-grouped AUROC differs by "
+ f"{resp_drop:+.2f} (RESPONSE_CR) and {hb_drop:+.2f} (HIGH_BLAST).",
+ "All results shown here are patient-grouped.",
+ ],
+ tldr="We re-ran every task with patient-grouped CV; the honest numbers follow.",
+ ),
+ "A methodological point I want to be transparent about. Response is a patient-level outcome, "
+ "so it is the same label on all of a patient's smears. If we split folds by sample, the model "
+ "can see other smears from the same patient during training and then be tested on a held-out "
+ "smear of that same patient — that is leakage, and it inflates performance. So I re-ran all "
+ "four tasks grouping the folds by patient, which guarantees no patient is in both train and "
+ "test. Everything I show from here on is the honest, patient-grouped evaluation.",
+ )
+
+ # 6 Label distribution
+ set_notes(
+ add_image_slide(
+ prs,
+ "Clinical label distribution",
+ ASSET["fig_labels"],
+ "Classes are imbalanced (98/489 high-blast; most samples <5% blasts) — we report AUROC, "
+ "AUPRC and calibration accordingly.",
+ subtitle="Four prediction targets derived from the clinical table.",
+ ),
+ "The four targets and their distributions. Two points: the classes are imbalanced — only "
+ "about 98 of 489 samples are high-blast — and blast percentage is concentrated below 5% with "
+ "a long high tail. That tail is where the regression struggles.",
+ )
+
+ # 7 Method
+ set_notes(
+ add_image_slide(
+ prs,
+ "Method: the STAMP pipeline",
+ ASSET["fig_workflow"],
+ "Tile + QC each slide; embed tiles with the UNI2 foundation model; aggregate with an "
+ "attention Transformer (MIL); heatmaps expose the evidence.",
+ subtitle="WSI → tiling + QC → UNI2 tile features → ViT-MIL aggregation → patient-grouped CV.",
+ ),
+ "The method is the published STAMP pipeline. Slides are tiled and quality-filtered, every "
+ "tile is embedded by the UNI2 foundation model, and an attention Transformer aggregates them "
+ "into one prediction per sample. The attention gives us heatmaps, which matter for the "
+ "interpretability experiment.",
+ )
+
+ # 8 Experiments overview
+ set_notes(
+ add_table(prs, "Experiments", EXP_ROADMAP_ROWS, col_widths=[0.7, 4.3, 7.3]),
+ "Here is the full set of experiments — four predictive models, an interpretability test, a "
+ "preprocessing-quality investigation, and a cell-level feasibility pilot. I'll take them in order.",
+ )
+
+ # 9 Honest results headline
+ set_notes(
+ add_image_slide(
+ prs,
+ "Results: honest (patient-grouped) evaluation",
+ ANALYSIS["fig_sample_vs_patient"],
+ "Grey = leaky sample-split; navy = patient-grouped. The patient-grouped bars are the "
+ "numbers we stand behind.",
+ subtitle="Fixing the leakage lowers the estimates but the ranking of tasks is preserved.",
+ ),
+ "This is the headline. The grey bars are the old sample-split numbers; the navy bars are the "
+ "honest patient-grouped numbers. Performance comes down when we remove the leakage — as "
+ "expected, most for response — but the picture is stable: high blast burden is easiest, "
+ "response is predictable above chance, and blast percentage stays the weak spot. These navy "
+ "numbers are what we stand behind.",
+ )
+
+ # 10 Results table (patient-level)
+ set_notes(
+ add_table(
+ prs,
+ "Results at a glance (patient-grouped, 5-fold CV)",
+ results_rows,
+ col_widths=[3.6, 1.8, 1.2, 6.7],
+ note="Classification: AUROC with 95% CI. Regression: correlates but is not calibrated.",
+ ),
+ "The same numbers in a table. I'll now walk through each experiment with the supporting heatmaps.",
+ )
+
+ # 11 Exp1 RESPONSE
+ set_notes(
+ add_image_slide(
+ prs,
+ "Experiment 1 — Complete-remission label (RESPONSE_CR)",
+ ASSET["exp_response"],
+ "Two held-out cases (left non-responder, right responder). Top: whole-slide image; bottom: "
+ "attention on cellular smear regions. (Illustrative maps from the initial models.)",
+ subtitle=f"Patient-grouped {clf(P['response'])} — but note the caveat on the labels (slide 17).",
+ ),
+ "Experiment one is the complete-remission label. The honest patient-grouped AUROC is "
+ f"{P['response']['auroc']:.2f}. I'll show it here, but with an important caveat I'll come back "
+ "to in a few slides: in this dataset the RESPONSE_CR label turns out to be defined by the "
+ "blast count, so this number is closely tied to blast burden rather than an independent "
+ "outcome. The heatmaps, from the initial models, show attention on the cellular smear.",
+ )
+
+ # 12 Exp2 HIGH_BLAST
+ set_notes(
+ add_image_slide(
+ prs,
+ "Experiment 2 — High blast burden (HIGH_BLAST)",
+ ASSET["exp_high_blast"],
+ "Three held-out high-blast cases; attention overlaps the dense blast clusters in the WSIs above.",
+ subtitle=f"Patient-grouped {clf(P['high_blast'])} — the strongest classifier.",
+ ),
+ f"Experiment two, high blast burden — our strongest model at AUROC {P['high_blast']['auroc']:.2f}, "
+ "and it barely moves under patient-grouped CV, which makes sense because blast burden is "
+ "genuinely visible in the images. The attention lands on the dense blast clusters. This is the "
+ "signal we trust most.",
+ )
+
+ # 13 Exp3 SEVERITY
+ set_notes(
+ add_bullets(
+ prs,
+ "Experiment 3 — Blast-severity grading (3-class)",
+ [
+ "Three-class grade: low / intermediate / high.",
+ f"Patient-grouped one-vs-rest AUROC: high {sev.get('high', float('nan')):.2f} · "
+ f"low {sev.get('low', float('nan')):.2f} · intermediate {sev.get('intermediate', float('nan')):.2f}.",
+ ("Extremes separate reliably; the intermediate band is hardest.", 1),
+ "Indicates where additional expert annotation would help most.",
+ ],
+ tldr="Reliable at the extremes; the intermediate class is the main source of error.",
+ ),
+ "Experiment three grades severity into three levels. High and low are recovered well; the "
+ "intermediate band is hardest, which matches clinical experience — the middle is genuinely "
+ "ambiguous. That tells us where extra annotation would pay off.",
+ )
+
+ # 14 Exp4 BLAST_PERCENT
+ set_notes(
+ add_image_slide(
+ prs,
+ "Experiment 4 — Blast-percentage regression",
+ ASSET["exp_blast_percent"],
+ "Prediction heatmaps: the model attends to blast-rich regions but under-predicts the count "
+ "on high-blast slides (e.g. 90% → 12%).",
+ subtitle=f"Pearson r {P['blast_percent']['pearson']:.2f}, R² {P['blast_percent']['r2']:.2f}, "
+ f"MAE {P['blast_percent']['mae']:.0f}% — good ranking, poor calibration.",
+ ),
+ f"Experiment four, the exact blast percentage. Correlation is moderate — Pearson "
+ f"{P['blast_percent']['pearson']:.2f} — so ordering is roughly right, but calibration is poor: "
+ "it systematically under-predicts the highest-blast slides, as in these examples where the "
+ "truth is 90% and the model says about 12%. Use the ranking, not the absolute number.",
+ )
+
+ # 15 Exp5 Explainability
+ set_notes(
+ add_image_slide(
+ prs,
+ "Experiment 5 — Does attention localise real blasts?",
+ ASSET["fig_explain_enrichment"],
+ "Overlap of top-attended regions with cell-rich tissue by clinical blast band, over ~490 "
+ "samples. HIGH_BLAST attention rises with blast burden; BLAST_PERCENT attention does not.",
+ subtitle="HIGH_BLAST maps track clinical blasts (proxy AUROC 0.86); regression maps do not (0.42).",
+ ),
+ "Experiment five asks whether the attention is trustworthy, across all samples. For the "
+ "HIGH_BLAST model, the overlap of top-attended regions with cell-rich tissue rises with blast "
+ "burden — proxy AUROC 0.86. For the regression model it is flat, no better than chance. So we "
+ "use the HIGH_BLAST maps for visual triage, not the regression maps.",
+ )
+
+ # 16 AUPRC + calibration
+ set_notes(
+ add_image_slide(
+ prs,
+ "Precision-recall and calibration",
+ ANALYSIS["fig_auprc_calibration"],
+ "Patient-grouped RESPONSE_CR and HIGH_BLAST. PR curves address the class imbalance; "
+ "reliability curves show how well probabilities are calibrated.",
+ subtitle="Beyond AUROC: performance under imbalance, and probability calibration.",
+ ),
+ "Because AUROC can look optimistic under class imbalance, here are precision-recall curves and "
+ "calibration. HIGH_BLAST holds up well on precision-recall; response is weaker, consistent "
+ "with the AUROC. The reliability curves show where the probabilities are well-calibrated and "
+ "where they'd need adjustment before any clinical use.",
+ )
+
+ # 17 What RESPONSE_CR actually measures (important caveat)
+ set_notes(
+ add_image_slide(
+ prs,
+ "Important caveat: what RESPONSE_CR actually measures",
+ ANALYSIS["fig_response_beyond_blast"],
+ "In this table, every <5%-blast sample is labelled 'responder' and every ≥5% sample "
+ "'non-responder' — RESPONSE_CR is exactly the <5%-blast (morphologic-CR) criterion.",
+ subtitle="RESPONSE_CR ≡ blast < 5% here, so the 'response' model is re-reading blast burden, "
+ "not an independent treatment outcome.",
+ ),
+ "This is the most important caveat, and I want to be upfront about it. When I split the "
+ "response labels by blast burden, the separation is perfect: every sample under 5% blasts is "
+ "labelled responder, and every sample at or above 5% is non-responder. In other words, "
+ "RESPONSE_CR as provided is exactly the morphologic complete-remission criterion — marrow "
+ "blasts under 5%. So the 0.83 'response' number is the model re-reading blast burden on the "
+ "same slide, not predicting an independent treatment outcome. A genuine response-prediction "
+ "task needs the outcome decoupled from the same-slide blast count — for example predicting "
+ "later response from the diagnostic smear. I flag this clearly and it's my first question for "
+ "the clinical team.",
+ )
+
+ # 18 Confounder
+ set_notes(
+ add_image_slide(
+ prs,
+ "Batch-effect check: scan protocol",
+ ANALYSIS["fig_confounder"],
+ "Left: is the scan protocol (5 vs 10 focus points) associated with the label? Right: can "
+ "the model's score predict the protocol? A high AUROC here would be a red flag.",
+ subtitle=f"Score-predicts-protocol AUROC = {conf['protocol_from_score_auroc']:.2f} "
+ f"(0.5 = no batch signal).",
+ ),
+ "Kather will ask about batch effects, so I checked directly. On the left, whether the scan "
+ "protocol is associated with the label; on the right, whether the model's score can predict "
+ f"the protocol — that AUROC is {conf['protocol_from_score_auroc']:.2f}. The closer to 0.5, the "
+ "less the model is keying on scanner batch rather than biology. I'll also add stain "
+ "normalization as a safeguard.",
+ )
+
+ # 19 Preprocessing QC
+ set_notes(
+ add_two_image_slide(
+ prs,
+ "Experiment 6 — Preprocessing quality investigation",
+ FIG["prep_before"],
+ "Before — raw smear (sample 127 26 C); tissue is present.",
+ FIG["prep_after"],
+ "After — only 4.7% of tiles retained (green kept, red dropped).",
+ banner="Cause: out-of-focus tiles fail the focus filter — the 5- vs 10-Fokuspunkte scan setting. Fix: re-scan.",
+ ),
+ "Experiment six: some slides lose almost all tiles in preprocessing — this one kept under 5%. "
+ "The tissue is present; the tiles fail the focus filter, not the background filter, and it "
+ "tracks the scan setting. The pipeline is behaving correctly; the fix is upstream — re-scan "
+ "those slides at higher focus.",
+ )
+
+ # 20 Cell-resolution pilot
+ set_notes(
+ add_image_slide(
+ prs,
+ "Experiment 7 — Cell-resolution feasibility pilot",
+ FIG["cellres_positive"],
+ "Model-selected tiles + centred zooms for one high-blast case; assembled for "
+ "hematopathologist review.",
+ side_text=[
+ "Can individual cells be resolved in the tiles the model selects?",
+ "A prerequisite check before any cell-level (DeepHeme-style) model.",
+ "12-slide deck: 5 regression failures, 4 high-blast, 3 low-blast.",
+ ("Uses existing native-resolution tiles; no new training.", 1),
+ ("Status: assembled; awaiting pathologist read-out.", 1),
+ ],
+ ),
+ "The last experiment is forward-looking. Before investing in a cell-level method like "
+ "DeepHeme, we need to know whether a human can resolve individual cells in the tiles our model "
+ "selects. So I assembled a twelve-slide review deck from the native-resolution tiles the "
+ "pipeline already exports. It's ready for a hematopathologist to score, which is where I'd "
+ "value the team's help.",
+ )
+
+ # 21 Findings
+ set_notes(
+ add_bullets(
+ prs,
+ "Summary of findings",
+ [
+ f"The models reliably read blast burden from morphology: HIGH_BLAST AUROC "
+ f"{P['high_blast']['auroc']:.2f}, robust to the leakage fix.",
+ "Caveat: RESPONSE_CR ≡ blast <5% here, so it is not yet an independent treatment outcome.",
+ "Attention is interpretable: HIGH_BLAST maps localise blast-rich tissue.",
+ "Blast-% regression ranks but is not calibrated — use ordering only.",
+ "No strong scanner-batch confound (protocol-from-score AUROC 0.59); focus is a fixable bottleneck.",
+ ],
+ tldr="Robust, interpretable blast-burden reading; the 'response' label needs decoupling from blast count.",
+ ),
+ "Pulling it together: the strong, honest result is that the model reliably reads blast burden "
+ "from morphology, and that holds up under patient-grouped evaluation. The key caveat is that "
+ "the response label, as provided, is the blast-count criterion, so we can't yet call it "
+ "treatment-response prediction. The models are interpretable, the regression ranks but isn't "
+ "calibrated, there's no strong scanner-batch confound, and focus quality is fixable.",
+ )
+
+ # 22 Limitations
+ set_notes(
+ add_bullets(
+ prs,
+ "Limitations",
+ [
+ "RESPONSE_CR label ≡ blast <5% — reflects the CR criterion, not an independent outcome.",
+ "Labels are slide-level; no cell-level ground truth yet.",
+ "Blast-% regression is mis-calibrated on high-blast samples.",
+ "Some confident errors may be label noise — need pathologist adjudication.",
+ "Single-center cohort; external validation pending.",
+ ],
+ ),
+ "Limitations. Labels are slide-level. The percentage regression is mis-calibrated. The "
+ "subgroup analyses are limited by small numbers. Some confident errors may be label noise. And "
+ "this is a single center — external validation is still ahead.",
+ )
+
+ # 23 Next steps
+ set_notes(
+ add_bullets(
+ prs,
+ "Proposed next steps",
+ [
+ "Immediate: confirm RESPONSE_CR definition; obtain an outcome decoupled from same-slide blast count.",
+ "Short term: hematopathologist review of the 12-slide pilot and the confident-error list.",
+ "Short term: re-scan low-focus slides at full focus and re-run those samples.",
+ "Medium: recalibrate/reframe blast-% (ordinal); annotate the intermediate band; add stain normalization.",
+ "Long term: integrate morphology with single-cell RNA-seq for biomarkers; external validation.",
+ ],
+ tldr="Immediate priority: pin down the response label, then pathologist review + re-scan.",
+ ),
+ "Proposed next steps, and where I'd like your input. Short term: a pathologist review of the "
+ "pilot and the confident-error list, and re-scanning the low-focus slides. Medium term: "
+ "recalibrate the blast-percentage task, annotate the intermediate band, add stain "
+ "normalization, and build a small cell-level annotation set. Long term: integrate with the "
+ "single-cell data for biomarkers and validate externally. My immediate ask is the pathologist "
+ "review and the re-scan.",
+ )
+
+ # 24 Closing
+ set_notes(
+ add_bullets(
+ prs,
+ "Summary",
+ [
+ f"Honest, patient-grouped: morphology reads blast burden well (HIGH_BLAST {P['high_blast']['auroc']:.2f}).",
+ "Key caveat surfaced: RESPONSE_CR ≡ blast <5% — decouple the outcome before claiming response prediction.",
+ "Interpretable, auditable predictions; no strong scanner-batch confound.",
+ "Clear next actions: confirm labels, pathologist review, re-scan, recalibrate, annotate.",
+ "Next horizon: morphology + single-cell transcriptomics for VEN/AZA biomarkers.",
+ ],
+ tldr="Thank you — I welcome your feedback and questions.",
+ ),
+ "In summary: the honest, robust result is that morphology reads blast burden well and it holds "
+ "under patient-grouped evaluation. The most important thing I surfaced is that the response "
+ "label here is the blast-count criterion, so decoupling the outcome is the first priority. The "
+ "models are interpretable, I've checked the confounds, and the exciting direction is "
+ "integrating with the single-cell data. Thank you — I welcome your feedback and questions.",
+ )
+
+ # ---------------- Appendix (hidden backup slides for the 10-min Q&A) -----
+ _hide(
+ set_notes(
+ add_bullets(
+ prs,
+ "Appendix — backup slides",
+ [
+ "Patient- vs sample-level leakage detail",
+ "Full metrics table (sample vs patient, all tasks)",
+ "RESPONSE_CR definition checklist",
+ "Anticipated questions",
+ ],
+ tldr="Reference material for discussion.",
+ ),
+ "These are backup slides for the discussion.",
+ )
+ )
+
+ _hide(
+ set_notes(
+ add_bullets(
+ prs,
+ "Appendix — why sample-split leaks",
+ [
+ "69 patients but 489 samples: mean 7.1, max 24 smears per patient.",
+ "RESPONSE_CR is one outcome per patient, copied to every smear of that patient.",
+ "Sample-split CV puts a patient's other smears in training → memorisation, not generalisation.",
+ f"Result: AUROC inflated by {resp_drop:+.2f} (RESPONSE_CR), {hb_drop:+.2f} (HIGH_BLAST).",
+ "Patient-grouped StratifiedGroupKFold removes it; every sample still predicted once.",
+ ],
+ ),
+ "Detail on the leakage and the fix.",
+ )
+ )
+
+ full_rows = [("Task", "Sample-split", "Patient-grouped")]
+ for t, nm in [("response", "RESPONSE_CR"), ("high_blast", "HIGH_BLAST")]:
+ full_rows.append((nm, clf(S[t]), clf(P[t])))
+ full_rows.append(
+ (
+ "BLAST_SEVERITY (macro)",
+ f"AUROC {S['blast_severity']['auroc']:.2f}",
+ f"AUROC {P['blast_severity']['auroc']:.2f}",
+ )
+ )
+ full_rows.append(
+ (
+ "BLAST_PERCENT",
+ f"r {S['blast_percent']['pearson']:.2f}, R² {S['blast_percent']['r2']:.2f}",
+ f"r {P['blast_percent']['pearson']:.2f}, R² {P['blast_percent']['r2']:.2f}",
+ )
+ )
+ _hide(
+ set_notes(
+ add_table(
+ prs,
+ "Appendix — full metrics (sample vs patient)",
+ full_rows,
+ col_widths=[3.4, 4.9, 4.9],
+ ),
+ "Full comparison table for reference.",
+ )
+ )
+
+ _hide(
+ set_notes(
+ add_bullets(
+ prs,
+ "Appendix — RESPONSE_CR definition (to confirm)",
+ [
+ "Confirm: complete-remission criteria (ELN 2022?) and assessment timepoint.",
+ "Confirm: which smear (diagnosis vs relapse) each label attaches to.",
+ "Confirm: handling of patients with multiple timepoints in the outcome.",
+ "These affect interpretation and are needed before any clinical claim.",
+ ],
+ ),
+ "Checklist of label-definition questions to resolve with the clinical team.",
+ )
+ )
+
+ _hide(
+ set_notes(
+ add_bullets(
+ prs,
+ "Appendix — anticipated questions (1/2)",
+ [
+ "Leakage: addressed — all numbers are patient-grouped.",
+ "Imbalance: see PR/calibration slide; AUROC + AUPRC both reported.",
+ "Is 'response' just blast burden? Yes here — RESPONSE_CR ≡ blast <5% (CR criterion); needs decoupling.",
+ "Batch effects: protocol-from-score AUROC 0.59 (mild); stain-normalization planned.",
+ ],
+ ),
+ "Q&A crib — methodology.",
+ )
+ )
+
+ _hide(
+ set_notes(
+ add_bullets(
+ prs,
+ "Appendix — anticipated questions (2/2)",
+ [
+ "Why UNI2 / this model? Default; foundation-model & aggregator ablations planned.",
+ "Clinical utility? Proof of concept; needs calibration + external validation.",
+ "Why smears vs flow? Cheap, routine, preserves single-cell morphology (orthogonal signal).",
+ "scRNA-seq integration: correlate attention regions / image features with cell states & response.",
+ ],
+ ),
+ "Q&A crib — clinical & next steps.",
+ )
+ )
+
+ return prs
+
+
+def main() -> None:
+ _check_figs()
+ _check_assets()
+ OUT_DIR.mkdir(exist_ok=True)
+ out15 = OUT_DIR / "STAMP_B01_15min.pptx"
+ out5 = OUT_DIR / "STAMP_B01_5min.pptx"
+ out_review = OUT_DIR / "STAMP_B01_project_review.pptx"
+ build_15min().save(out15)
+ build_5min().save(out5)
+ build_project_review().save(out_review)
+ for p in (out15, out5, out_review):
+ kb = p.stat().st_size / 1024
+ print(f"wrote {p} ({kb:.0f} KB)")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/generate_blast_report.py b/scripts/generate_blast_report.py
new file mode 100644
index 00000000..8b74d4eb
--- /dev/null
+++ b/scripts/generate_blast_report.py
@@ -0,0 +1,1033 @@
+#!/usr/bin/env python3
+"""Generate a self-contained HTML report for all AML STAMP crossval experiments.
+
+Covers four experiments:
+ 1. RESPONSE_CR — binary classification (complete remission yes/no)
+ 2. BLAST_PERCENT — regression (continuous 0-91%)
+ 3. BLAST_SEVERITY — 3-class classification (low/intermediate/high)
+ 4. HIGH_BLAST — binary classification (yes/no, threshold >=20%)
+
+The report includes per-experiment: summary metrics, ROC/PR or scatter plots,
+per-fold stats tables, and a representative heatmap gallery (sampled to keep
+file size manageable).
+
+Usage:
+ python scripts/generate_blast_report.py [--max-slides N]
+
+ --max-slides: max heatmap slides per experiment per class (default: 10)
+"""
+
+import argparse
+import base64
+import random
+from datetime import datetime
+from pathlib import Path
+
+import pandas as pd
+
+# === Configuration ===
+DATA_ROOT = Path("/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen")
+SLIDE_TABLE = Path("/home/jeff/Projects/STAMP/tables/stamp_slide.csv")
+CLINI_TABLE = Path("/home/jeff/Projects/STAMP/tables/stamp_clini.csv")
+
+EXPERIMENTS = {
+ "response_cr": {
+ "name": "RESPONSE_CR",
+ "display_name": "Treatment Response (Binary)",
+ "base_dir": DATA_ROOT / "stamp_aml_response_uni2",
+ "task": "classification",
+ "ground_truth_label": "RESPONSE_CR",
+ "categories": ["yes", "no"],
+ "true_class": "yes",
+ "description": (
+ "Binary classification predicting complete remission (CR) response in AML patients. "
+ "Positive class ('yes') indicates blast percentage below 5% after treatment (complete "
+ "remission), while 'no' indicates >= 5% blasts (non-response). This is the primary "
+ "clinical endpoint for treatment efficacy."
+ ),
+ "class_balance": "yes=336, no=153",
+ "crossval_type": "5-fold StratifiedKFold",
+ },
+ "blast_percent": {
+ "name": "BLAST_PERCENT",
+ "display_name": "Blast Percentage (Regression)",
+ "base_dir": DATA_ROOT / "stamp_aml_blast_percent_uni2",
+ "task": "regression",
+ "ground_truth_label": "BLAST_PERCENT",
+ "description": (
+ "Continuous regression predicting the percentage of blast cells (0–91%) "
+ "in AML bone marrow aspirates. The model directly outputs a numeric prediction. "
+ "The distribution is heavily right-skewed (median=3%, mean=14.6%)."
+ ),
+ "class_balance": "Continuous: range 0–91%, median=3%, mean=14.6%",
+ "crossval_type": "5-fold KFold",
+ },
+ "blast_severity": {
+ "name": "BLAST_SEVERITY",
+ "display_name": "Blast Severity (3-class)",
+ "base_dir": DATA_ROOT / "stamp_aml_blast_severity_uni2",
+ "task": "classification",
+ "ground_truth_label": "BLAST_SEVERITY",
+ "categories": ["high", "intermediate", "low"],
+ "true_class": "high",
+ "description": (
+ "Three-class classification of blast severity: low (<5% blasts, n=336), "
+ "intermediate (5–19%, n=55), and high (≥20%, n=98). Thresholds based on "
+ "clinically meaningful blast count cutoffs in AML."
+ ),
+ "class_balance": "low=336, intermediate=55, high=98",
+ "crossval_type": "5-fold StratifiedKFold",
+ },
+ "high_blast": {
+ "name": "HIGH_BLAST",
+ "display_name": "High Blast (Binary)",
+ "base_dir": DATA_ROOT / "stamp_aml_high_blast_uni2",
+ "task": "classification",
+ "ground_truth_label": "HIGH_BLAST",
+ "categories": ["yes", "no"],
+ "true_class": "yes",
+ "description": (
+ "Binary classification predicting whether blast percentage is ≥20% (high blast). "
+ "Clinically relevant threshold: ≥20% blasts indicates active/refractory disease. "
+ "Positive class ('yes') = ≥20% blasts."
+ ),
+ "class_balance": "yes=98, no=391",
+ "crossval_type": "5-fold StratifiedKFold",
+ },
+}
+
+
+def embed_image(path: Path, mime: str | None = None) -> str:
+ """Return a data-URI string for embedding an image in HTML."""
+ if not path.exists():
+ return ""
+ if mime is None:
+ suffix = path.suffix.lower()
+ mime = {
+ ".png": "image/png",
+ ".jpg": "image/jpeg",
+ ".jpeg": "image/jpeg",
+ ".svg": "image/svg+xml",
+ ".gif": "image/gif",
+ }.get(suffix, "image/png")
+ data = path.read_bytes()
+ b64 = base64.b64encode(data).decode("ascii")
+ return f"data:{mime};base64,{b64}"
+
+
+def embed_svg(path: Path) -> str:
+ """Read an SVG file and return its content for inline embedding."""
+ if not path.exists():
+ return "SVG not found
"
+ return path.read_text()
+
+
+def load_clini_and_slide():
+ """Load clinical and slide tables."""
+ clini_df = pd.read_csv(CLINI_TABLE)
+ slide_df = pd.read_csv(SLIDE_TABLE)
+ fname_to_sample = dict(zip(slide_df["FILENAME"], slide_df["SAMPLE_ID"]))
+ return clini_df, slide_df, fname_to_sample
+
+
+# =============================================================================
+# Regression experiment helpers
+# =============================================================================
+
+
+def load_regression_stats(exp):
+ """Load regression statistics."""
+ stats_dir = exp["base_dir"] / "statistics"
+ name = exp["name"]
+ agg_path = stats_dir / f"{name}_regression-stats_aggregated.csv"
+ agg = pd.read_csv(agg_path, index_col=0)
+ return {
+ "r2": float(agg.loc["r2_score", "mean"]),
+ "pearson_r": float(agg.loc["pearson_r", "mean"]),
+ "pearson_p": float(agg.loc["pearson_p", "mean"]),
+ "mae": float(agg.loc["mae", "mean"]),
+ "rmse": float(agg.loc["rmse", "mean"]),
+ "count": int(float(agg.loc["count", "mean"])),
+ }
+
+
+def make_regression_section(exp):
+ """Generate the HTML section for a regression experiment."""
+ stats = load_regression_stats(exp)
+ stats_dir = exp["base_dir"] / "statistics"
+ scatter_svg_path = stats_dir / "plots" / "fold_patient-preds_scatter.svg"
+
+ html = f"""
+
+
{exp["display_name"]}
+
+
{exp["description"]}
+
+
+
+
+
Distribution
{exp["class_balance"]}
+
Crossval
{exp["crossval_type"]}
+
+
+
+
+
Performance Metrics
+
+
+
{stats["pearson_r"]:.3f}
+
Pearson r
+
+
+
{stats["mae"]:.1f}%
+
Mean Absolute Error
+
+
+
{stats["rmse"]:.1f}%
+
Root Mean Squared Error
+
+
+
+
+
+
{stats["r2"]:.3f}
+
R² Score
+
+
+
{stats["pearson_p"]:.2e}
+
Pearson p-value
+
+
+
+
Predicted vs. Actual Scatter Plot
+
+{embed_svg(scatter_svg_path)}
+
+
+
Each point represents one patient (test-set prediction from the respective crossval fold).
+The dashed line is the identity line (perfect prediction). Pearson r = {stats["pearson_r"]:.3f}
+indicates moderate-strong correlation. Note the R² = {stats["r2"]:.3f} is near zero due to the
+heavily skewed distribution (many samples near 0%, few at high %).
+"""
+ return html
+
+
+# =============================================================================
+# Classification experiment helpers
+# =============================================================================
+
+
+def load_classification_stats(exp):
+ """Load classification statistics (works for both binary and multi-class)."""
+ stats_dir = exp["base_dir"] / "statistics"
+ name = exp["name"]
+ agg = pd.read_csv(stats_dir / f"{name}_categorical-stats_aggregated.csv")
+ ind = pd.read_csv(stats_dir / f"{name}_categorical-stats_individual.csv")
+ return agg, ind
+
+
+def make_classification_section(exp):
+ """Generate the HTML section for a classification experiment."""
+ agg, ind = load_classification_stats(exp)
+ stats_dir = exp["base_dir"] / "statistics"
+ true_class = exp["true_class"]
+ name = exp["name"]
+ categories = exp["categories"]
+
+ # Parse aggregated stats
+ # agg columns: unnamed_class, roc_auc_score (mean), roc_auc_score (95%_low), roc_auc_score (95%_high),
+ # average_precision_score (mean, low, high), f1_score (mean, low, high), count
+ # Skip header row (row 0 is the sub-header)
+ class_stats = {}
+ for _, row in agg.iterrows():
+ cls = str(row.iloc[0])
+ if cls in ("", "nan") or cls.startswith("Unnamed"):
+ continue
+ class_stats[cls] = {
+ "auroc_mean": float(row.iloc[1]),
+ "auroc_low": float(row.iloc[2]),
+ "auroc_high": float(row.iloc[3]),
+ "auprc_mean": float(row.iloc[4]),
+ "auprc_low": float(row.iloc[5]),
+ "auprc_high": float(row.iloc[6]),
+ "f1_mean": float(row.iloc[7]),
+ "f1_low": float(row.iloc[8]),
+ "f1_high": float(row.iloc[9]),
+ "count": int(float(row.iloc[10])),
+ }
+
+ html = f"""
+
+
{exp["display_name"]}
+
+
{exp["description"]}
+
+
+
Task
Classification ({len(categories)}-class)
+
+
Class Balance
{exp["class_balance"]}
+
Crossval
{exp["crossval_type"]}
+
+
+
+
+
Performance Metrics
+"""
+
+ # Per-class metric cards
+ html += '
\n'
+ for cls in categories:
+ cs = class_stats.get(cls, {})
+ badge_cls = "badge-yes" if cls == true_class else "badge-no"
+ html += f"""
+
+
{cls} (n={cs.get("count", "?")})
+
+
+
{cs.get("auroc_mean", 0):.3f}
+
AUROC ({cs.get("auroc_low", 0):.3f}–{cs.get("auroc_high", 0):.3f})
+
+
+
{cs.get("auprc_mean", 0):.3f}
+
AUPRC ({cs.get("auprc_low", 0):.3f}–{cs.get("auprc_high", 0):.3f})
+
+
+
{cs.get("f1_mean", 0):.3f}
+
F1 ({cs.get("f1_low", 0):.3f}–{cs.get("f1_high", 0):.3f})
+
+
+
+"""
+ html += "
\n"
+
+ # ROC and PR curves
+ roc_path = stats_dir / f"roc-curve_{name}={true_class}.svg"
+ pr_path = stats_dir / f"pr-curve_{name}={true_class}.svg"
+
+ if roc_path.exists():
+ html += f"""
+
ROC Curve ({true_class} class, 5-fold)
+
+{embed_svg(roc_path)}
+
+"""
+
+ if pr_path.exists():
+ html += f"""
+
Precision-Recall Curve ({true_class} class, 5-fold)
+
+{embed_svg(pr_path)}
+
+"""
+
+ # Per-fold stats table
+ html += "
Per-Fold Statistics
\n"
+ html += make_classification_table(ind, categories)
+
+ return html
+
+
+def make_classification_table(ind: pd.DataFrame, categories: list[str]) -> str:
+ """Generate an HTML table from per-fold individual stats."""
+ html = "
\n"
+ html += "| Fold | Class | Count | AUROC | AUPRC | F1 | p-value | "
+ html += "
\n"
+
+ for _, row in ind.iterrows():
+ fold = row.iloc[0]
+ cls = str(row.iloc[1])
+ count = int(row.iloc[2])
+ auroc = float(row.iloc[3])
+ ap = float(row.iloc[4])
+ f1 = float(row.iloc[5])
+ pval = float(row.iloc[6])
+
+ # Color code rows by class
+ if cls == categories[0]:
+ row_style = 'style="background: #f0fff0;"'
+ elif len(categories) > 2 and cls == categories[1]:
+ row_style = 'style="background: #fff8f0;"'
+ else:
+ row_style = ""
+
+ html += (
+ f"| {fold} | {cls} | "
+ )
+ html += (
+ f"{count} | {auroc:.4f} | {ap:.4f} | {f1:.4f} | "
+ )
+ html += f"{pval:.2e} |
\n"
+
+ html += "
\n"
+ return html
+
+
+# =============================================================================
+# Heatmap gallery helpers
+# =============================================================================
+
+
+def collect_heatmap_slides(exp, clini_df, fname_to_sample, max_per_class=10):
+ """Collect heatmap slide info, sampling to keep report size manageable."""
+ heatmap_dir = exp["base_dir"] / "heatmaps"
+ crossval_dir = exp["base_dir"] / "crossval"
+ gt_label = exp["ground_truth_label"]
+ task = exp["task"]
+
+ sample_to_gt = dict(zip(clini_df["SAMPLE_ID"], clini_df[gt_label]))
+
+ # Load all predictions
+ all_preds = {}
+ for split_i in range(5):
+ pred_file = crossval_dir / f"split-{split_i}" / "patient-preds.csv"
+ if not pred_file.exists():
+ continue
+ preds = pd.read_csv(pred_file)
+ for _, row in preds.iterrows():
+ pred_info = {"split": split_i}
+ if task == "regression":
+ pred_info["pred"] = row["pred"]
+ pred_info["gt_value"] = row[gt_label]
+ else:
+ pred_info["pred"] = row["pred"]
+ pred_info["gt"] = row[gt_label]
+ # Collect per-class probabilities
+ pred_info["probs"] = {}
+ for col in preds.columns:
+ if col.startswith(f"{gt_label}_"):
+ cls = col.replace(f"{gt_label}_", "")
+ pred_info["probs"][cls] = row[col]
+ all_preds[row["SAMPLE_ID"]] = pred_info
+
+ slides = []
+ total_available = 0
+ for split_dir in sorted(heatmap_dir.glob("split-*")):
+ split_i = int(split_dir.name.split("-")[1])
+ for slide_dir in sorted(split_dir.iterdir()):
+ if not slide_dir.is_dir():
+ continue
+ stem = slide_dir.name
+ h5_name = stem + ".h5"
+ sample_id = fname_to_sample.get(h5_name, "unknown")
+ info = all_preds.get(sample_id, {})
+
+ overview_path = slide_dir / "plots" / f"overview-{stem}.png"
+ if not overview_path.exists():
+ continue
+
+ total_available += 1
+
+ # Collect top/bottom tiles
+ tiles_dir = slide_dir / "tiles"
+ top_tiles = (
+ sorted(
+ [
+ t
+ for ext in ("*.png", "*.jpg", "*.jpeg")
+ for t in tiles_dir.glob(f"top_*{ext}")
+ ]
+ )[:4]
+ if tiles_dir.exists()
+ else []
+ )
+ bottom_tiles = (
+ sorted(
+ [
+ t
+ for ext in ("*.png", "*.jpg", "*.jpeg")
+ for t in tiles_dir.glob(f"bottom_*{ext}")
+ ]
+ )[:4]
+ if tiles_dir.exists()
+ else []
+ )
+
+ slide_info = {
+ "stem": stem,
+ "sample_id": sample_id,
+ "split": split_i,
+ "overview_path": overview_path,
+ "top_tiles": top_tiles,
+ "bottom_tiles": bottom_tiles,
+ }
+
+ if task == "regression":
+ gt_val = info.get("gt_value", sample_to_gt.get(sample_id, "?"))
+ slide_info["gt_value"] = gt_val
+ slide_info["pred_value"] = info.get("pred", "?")
+ # Bin for grouping
+ try:
+ gt_num = float(gt_val)
+ if gt_num < 5:
+ slide_info["group"] = "low (<5%)"
+ elif gt_num < 20:
+ slide_info["group"] = "intermediate (5-19%)"
+ else:
+ slide_info["group"] = "high (≥20%)"
+ except (ValueError, TypeError):
+ slide_info["group"] = "unknown"
+ else:
+ slide_info["gt"] = info.get("gt", sample_to_gt.get(sample_id, "?"))
+ slide_info["pred"] = info.get("pred", "?")
+ slide_info["probs"] = info.get("probs", {})
+ slide_info["correct"] = slide_info["gt"] == slide_info["pred"]
+ slide_info["group"] = str(slide_info["gt"])
+
+ slides.append(slide_info)
+
+ # Sample slides per group to limit report size
+ if max_per_class > 0:
+ groups = {}
+ for s in slides:
+ groups.setdefault(s["group"], []).append(s)
+
+ sampled = []
+ for group_name, group_slides in sorted(groups.items()):
+ if len(group_slides) <= max_per_class:
+ sampled.extend(group_slides)
+ else:
+ # Deterministic sample: mix of correct/incorrect for classification
+ random.seed(42)
+ sampled.extend(random.sample(group_slides, max_per_class))
+
+ slides = sampled
+
+ return slides, total_available
+
+
+def make_slide_card_regression(slide: dict) -> str:
+ """Generate HTML card for a regression heatmap slide."""
+ gt_val = slide.get("gt_value", "?")
+ pred_val = slide.get("pred_value", "?")
+ try:
+ error = abs(float(pred_val) - float(gt_val))
+ error_str = f"{error:.1f}%"
+ except (ValueError, TypeError):
+ error_str = "?"
+
+ overview_uri = embed_image(slide["overview_path"])
+
+ html = f"""
+
+
+
{slide["stem"]}
+

+"""
+
+ if slide["top_tiles"]:
+ html += '
Top Attended Tiles
\n'
+ html += '
\n'
+ for tile_path in slide["top_tiles"]:
+ uri = embed_image(tile_path)
+ html += f'

\n'
+ html += "
\n"
+
+ if slide["bottom_tiles"]:
+ html += '
Least Attended Tiles
\n'
+ html += '
\n'
+ for tile_path in slide["bottom_tiles"]:
+ uri = embed_image(tile_path)
+ html += f'

\n'
+ html += "
\n"
+
+ html += "
\n"
+ return html
+
+
+def make_slide_card_classification(slide: dict) -> str:
+ """Generate HTML card for a classification heatmap slide."""
+ correct_badge = (
+ '
Correct'
+ if slide.get("correct")
+ else '
Incorrect'
+ )
+ gt = slide.get("gt", "?")
+ pred = slide.get("pred", "?")
+ probs = slide.get("probs", {})
+
+ prob_badges = " ".join(
+ f'
P({cls})={prob:.3f}'
+ for cls, prob in sorted(probs.items())
+ )
+
+ overview_uri = embed_image(slide["overview_path"])
+
+ html = f"""
+
+
+
{slide["stem"]}
+

+"""
+
+ if slide["top_tiles"]:
+ html += '
Top Attended Tiles
\n'
+ html += '
\n'
+ for tile_path in slide["top_tiles"]:
+ uri = embed_image(tile_path)
+ html += f'

\n'
+ html += "
\n"
+
+ if slide["bottom_tiles"]:
+ html += '
Least Attended Tiles
\n'
+ html += '
\n'
+ for tile_path in slide["bottom_tiles"]:
+ uri = embed_image(tile_path)
+ html += f'

\n'
+ html += "
\n"
+
+ html += "
\n"
+ return html
+
+
+def make_heatmap_gallery(exp, slides, total_available) -> str:
+ """Generate heatmap gallery section for an experiment."""
+ task = exp["task"]
+ gt_label = exp["ground_truth_label"]
+
+ html = f"""
+
Heatmap Gallery — Attention Maps
+
+Note: Showing {len(slides)} representative slides out of {total_available} available
+(all 5 crossval splits). Each heatmap uses the model from the split where that slide was in the
+test set, ensuring no data leakage. Top and bottom 8 most/least attended tiles are extracted.
+"""
+
+ if task == "regression":
+ html += """
+Warm regions indicate areas the model attends to most when predicting blast percentage.
+The attention map uses a single channel (regression) rather than per-class gradients.
+"""
+ else:
+ html += """
+Red/warm regions indicate high attention for that category prediction.
+Blue/cool regions indicate low attention.
+"""
+
+ html += "
\n"
+
+ # Group slides
+ groups = {}
+ for s in slides:
+ groups.setdefault(s["group"], []).append(s)
+
+ # Define group ordering and colors
+ if task == "regression":
+ group_order = ["low (<5%)", "intermediate (5-19%)", "high (≥20%)"]
+ group_colors = {
+ "low (<5%)": ("#28a745", "#d4edda"),
+ "intermediate (5-19%)": ("#fd7e14", "#fff3cd"),
+ "high (≥20%)": ("#dc3545", "#f8d7da"),
+ }
+ elif gt_label == "BLAST_SEVERITY":
+ group_order = ["high", "intermediate", "low"]
+ group_colors = {
+ "high": ("#dc3545", "#f8d7da"),
+ "intermediate": ("#fd7e14", "#fff3cd"),
+ "low": ("#28a745", "#d4edda"),
+ }
+ elif gt_label == "HIGH_BLAST":
+ group_order = ["yes", "no"]
+ group_colors = {
+ "yes": ("#dc3545", "#f8d7da"),
+ "no": ("#28a745", "#d4edda"),
+ }
+ elif gt_label == "RESPONSE_CR":
+ group_order = ["yes", "no"]
+ group_colors = {
+ "yes": ("#28a745", "#d4edda"),
+ "no": ("#dc3545", "#f8d7da"),
+ }
+ else:
+ group_order = sorted(groups.keys())
+ group_colors = {}
+
+ for group_name in group_order:
+ group_slides = groups.get(group_name, [])
+ if not group_slides:
+ continue
+
+ border_color, bg_color = group_colors.get(group_name, ("#333", "#f0f0f0"))
+ html += f"""
+
+"""
+ for slide in sorted(group_slides, key=lambda s: (s["sample_id"], s["stem"])):
+ if task == "regression":
+ html += make_slide_card_regression(slide)
+ else:
+ html += make_slide_card_classification(slide)
+
+ return html
+
+
+# =============================================================================
+# Main report generation
+# =============================================================================
+
+CSS = """
+
+"""
+
+
+def generate_report(max_per_class: int = 10):
+ """Generate the combined HTML report."""
+ clini_df, slide_df, fname_to_sample = load_clini_and_slide()
+
+ now = datetime.now().strftime("%Y-%m-%d %H:%M")
+ date_short = datetime.now().strftime("%Y-%m-%d")
+
+ html = f"""
+
+
+
+
+
AML Crossvalidation Report — STAMP
+{CSS}
+
+
+
+
AML Bone Marrow — STAMP Crossvalidation Report
+
+ Generated {now} — STAMP Framework, UNI2 features, ViT MIL model
+
+
+
+
+
+
Overview & Cross-Experiment Comparison
+
+
+Four prediction targets from AML bone marrow aspirates: treatment response (RESPONSE_CR) and three
+targets derived from the BLAST_PERCENT column.
+All experiments use the same dataset (489 samples, 69 biological patients), feature extractor (UNI2, 3072-dim),
+and MIL architecture (ViT, 2-layer, 8-head, 512-dim, max_epochs=32, patience=16).
+
+
+
+
+ | Experiment |
+ Task |
+ Classes |
+ Primary Metric |
+ Result |
+
+"""
+
+ # Collect summary metrics for the comparison table
+ # RESPONSE_CR
+ rc_agg, _ = load_classification_stats(EXPERIMENTS["response_cr"])
+ rc_auroc = 0.0
+ rc_auprc = 0.0
+ for _, row in rc_agg.iterrows():
+ cls = str(row.iloc[0])
+ if cls == "yes":
+ rc_auroc = float(row.iloc[1])
+ rc_auprc = float(row.iloc[4])
+ html += f"""
+
+ | RESPONSE_CR |
+ Binary Classification |
+ yes (CR) / no |
+ AUROC |
+ {rc_auroc:.3f} (AUPRC={rc_auprc:.3f}) |
+
+"""
+
+ # BLAST_PERCENT
+ bp_stats = load_regression_stats(EXPERIMENTS["blast_percent"])
+ html += f"""
+
+ | BLAST_PERCENT |
+ Regression |
+ Continuous (0–91%) |
+ Pearson r |
+ {bp_stats["pearson_r"]:.3f} (MAE={bp_stats["mae"]:.1f}%) |
+
+"""
+
+ # BLAST_SEVERITY
+ bs_agg, _ = load_classification_stats(EXPERIMENTS["blast_severity"])
+ bs_classes = {}
+ for _, row in bs_agg.iterrows():
+ cls = str(row.iloc[0])
+ if cls not in ("", "nan"):
+ bs_classes[cls] = float(row.iloc[1])
+ html += f"""
+
+ | BLAST_SEVERITY |
+ 3-class Classification |
+ high / intermediate / low |
+ AUROC (per-class) |
+ high={bs_classes.get("high", 0):.3f}, int={bs_classes.get("intermediate", 0):.3f}, low={bs_classes.get("low", 0):.3f} |
+
+"""
+
+ # HIGH_BLAST
+ hb_agg, _ = load_classification_stats(EXPERIMENTS["high_blast"])
+ hb_auroc = 0.0
+ hb_auprc = 0.0
+ for _, row in hb_agg.iterrows():
+ cls = str(row.iloc[0])
+ if cls == "yes":
+ hb_auroc = float(row.iloc[1])
+ hb_auprc = float(row.iloc[4])
+ html += f"""
+
+ | HIGH_BLAST |
+ Binary Classification |
+ yes (≥20%) / no |
+ AUROC |
+ {hb_auroc:.3f} (AUPRC={hb_auprc:.3f}) |
+
+"""
+
+ html += """
+
+
+
+Key finding: The binary HIGH_BLAST classifier achieves the best discriminative performance
+(AUROC=0.929), suggesting the model can reliably identify high-blast cases. RESPONSE_CR also shows
+strong performance (AUROC=0.870), confirming the model captures morphological features predictive of
+treatment response. The 3-class BLAST_SEVERITY model shows strong performance for high (0.912) and
+low (0.864) classes but struggles with the intermediate class (0.783), consistent with the small
+sample size (n=55) and the inherent difficulty of distinguishing 5–19% blast ranges. The regression
+model shows moderate correlation (r=0.680) but high RMSE (24.5%) driven by the skewed distribution.
+
+
+"""
+
+ # === Experiment 1: RESPONSE_CR ===
+ exp_rc = EXPERIMENTS["response_cr"]
+ html += make_classification_section(exp_rc)
+ slides_rc, total_rc = collect_heatmap_slides(
+ exp_rc, clini_df, fname_to_sample, max_per_class=max_per_class
+ )
+ html += make_heatmap_gallery(exp_rc, slides_rc, total_rc)
+ html += "
\n"
+
+ # === Experiment 2: BLAST_PERCENT ===
+ exp_bp = EXPERIMENTS["blast_percent"]
+ html += make_regression_section(exp_bp)
+ slides_bp, total_bp = collect_heatmap_slides(
+ exp_bp, clini_df, fname_to_sample, max_per_class=max_per_class
+ )
+ html += make_heatmap_gallery(exp_bp, slides_bp, total_bp)
+ html += "
\n"
+
+ # === Experiment 3: BLAST_SEVERITY ===
+ exp_bs = EXPERIMENTS["blast_severity"]
+ html += make_classification_section(exp_bs)
+ slides_bs, total_bs = collect_heatmap_slides(
+ exp_bs, clini_df, fname_to_sample, max_per_class=max_per_class
+ )
+ html += make_heatmap_gallery(exp_bs, slides_bs, total_bs)
+ html += "\n"
+
+ # === Experiment 4: HIGH_BLAST ===
+ exp_hb = EXPERIMENTS["high_blast"]
+ html += make_classification_section(exp_hb)
+ slides_hb, total_hb = collect_heatmap_slides(
+ exp_hb, clini_df, fname_to_sample, max_per_class=max_per_class
+ )
+ html += make_heatmap_gallery(exp_hb, slides_hb, total_hb)
+ html += "\n"
+
+ # === Discussion ===
+ html += f"""
+
+
Discussion
+
+
+- RESPONSE_CR captures treatment response: AUROC of 0.870 (95% CI: 0.804–0.936)
+for predicting complete remission (CR). This is the primary clinical endpoint — it directly indicates
+whether a patient achieved blast clearance (<5%) after treatment. The model's ability to predict
+this from pre/post-treatment morphology alone is clinically significant, with AUPRC of 0.915 for the
+positive (CR) class.
+
+- HIGH_BLAST is the strongest classifier: AUROC of 0.929 (95% CI: 0.877–0.981)
+for identifying ≥20% blast cases. This clinically relevant threshold separates active disease from
+remission/low-burden states, and the model achieves this with high confidence across all 5 folds.
+
+- BLAST_SEVERITY captures the ordinal structure: The 3-class model's AUROC gradient
+(high=0.912 > low=0.864 > intermediate=0.783) mirrors the biological separability — extreme cases
+(very low or very high blasts) have more distinctive morphological features than borderline cases.
+The intermediate class (5–19%) is small (n=55) and morphologically heterogeneous.
+
+- Regression faces distribution challenges: While Pearson r=0.680 indicates the model
+captures the rank ordering of blast percentages, the negative R² (−0.016) and high RMSE (24.5%)
+reflect the difficulty of precise point estimation on a heavily right-skewed distribution
+(median=3%, IQR ~0–12%). The model tends to regress toward the mean, underestimating high-blast cases
+and overestimating low-blast cases.
+
+- Biological relevance of attention patterns: Compare heatmaps across all four experiments
+for the same slides. In RESPONSE_CR, attention should highlight regions predictive of treatment outcome.
+In HIGH_BLAST, high-attention (red) regions should correspond to blast-dense areas.
+In BLAST_SEVERITY, the per-class heatmaps (high/intermediate/low) should show complementary attention patterns.
+The regression heatmaps use attention rollout rather than class-specific GradCAM.
+
+- Clinical utility: RESPONSE_CR directly addresses the clinical question of treatment
+efficacy. For blast burden assessment, the binary HIGH_BLAST classifier is most directly actionable —
+it answers "does this patient have clinically significant blast burden?"
+The BLAST_SEVERITY model adds nuance by distinguishing intermediate from extreme cases. The regression
+model provides a continuous estimate useful for trend monitoring across timepoints.
+
+- Heatmap coverage: A total of {total_rc + total_bp + total_bs + total_hb} slide heatmaps
+were generated across the four experiments (RESPONSE_CR={total_rc}, BLAST_PERCENT={total_bp},
+BLAST_SEVERITY={total_bs}, HIGH_BLAST={total_hb}), with representative samples shown in this report.
+Note: RESPONSE_CR heatmaps are limited to the 17 slides for which original WSI files were available at
+the time of generation. Full heatmap galleries are available in the output directories.
+
+- Limitations: (1) BLAST_PERCENT, BLAST_SEVERITY, and HIGH_BLAST are all derived from
+the same BLAST_PERCENT column, so those three experiments are not independent. RESPONSE_CR is a separate
+clinical endpoint. (2) The dataset has 489 samples from only 69 patients — multiple biopsies per patient
+introduce correlation that crossvalidation at the sample level does not fully address. (3) The feature
+extractor (UNI2) was pretrained on general pathology, not specifically on bone marrow morphology.
+(4) RESPONSE_CR heatmaps cover only 17 of 529 slides due to limited WSI availability.
+
+
+
+
+
+
+"""
+
+ return html
+
+
+def main():
+ parser = argparse.ArgumentParser(
+ description="Generate combined blast analysis report"
+ )
+ parser.add_argument(
+ "--max-slides",
+ type=int,
+ default=10,
+ help="Max heatmap slides per class per experiment (default: 10). Use 0 for all slides.",
+ )
+ args = parser.parse_args()
+
+ report_dir = DATA_ROOT / "aml_crossval_report"
+ report_dir.mkdir(parents=True, exist_ok=True)
+
+ print("Generating combined AML crossvalidation report (4 experiments)...")
+ print(f" Max slides per class: {args.max_slides}")
+
+ html = generate_report(max_per_class=args.max_slides)
+
+ report_path = report_dir / "aml_crossval_report.html"
+ report_path.write_text(html)
+
+ size_mb = report_path.stat().st_size / 1024 / 1024
+ print(f"\nReport generated: {report_path}")
+ print(f"Report size: {size_mb:.1f} MB")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/generate_report.py b/scripts/generate_report.py
new file mode 100644
index 00000000..8f417b7c
--- /dev/null
+++ b/scripts/generate_report.py
@@ -0,0 +1,420 @@
+#!/usr/bin/env python3
+"""Generate a self-contained HTML report combining STAMP crossval performance
+metrics with GradCAM heatmap visualizations.
+
+The report includes:
+ 1. Experiment summary (dataset, features, model, crossval setup)
+ 2. Performance metrics (ROC curve, PR curve, per-fold stats table)
+ 3. Heatmap gallery grouped by ground-truth class
+ 4. Discussion pointers
+
+Usage:
+ python scripts/generate_report.py
+"""
+
+import base64
+from datetime import datetime
+from pathlib import Path
+
+import pandas as pd
+
+# === Configuration ===
+BASE_DIR = Path("/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_response_uni2")
+STATS_DIR = BASE_DIR / "statistics"
+HEATMAP_DIR = BASE_DIR / "heatmaps"
+CROSSVAL_DIR = BASE_DIR / "crossval"
+REPORT_DIR = BASE_DIR / "report"
+SLIDE_TABLE = Path("/home/jeff/Projects/STAMP/tables/stamp_slide.csv")
+CLINI_TABLE = Path("/home/jeff/Projects/STAMP/tables/stamp_clini.csv")
+
+
+def embed_image(path: Path, mime: str | None = None) -> str:
+ """Return a data-URI string for embedding an image in HTML."""
+ if not path.exists():
+ return ""
+ if mime is None:
+ suffix = path.suffix.lower()
+ mime = {
+ ".png": "image/png",
+ ".jpg": "image/jpeg",
+ ".jpeg": "image/jpeg",
+ ".svg": "image/svg+xml",
+ ".gif": "image/gif",
+ }.get(suffix, "image/png")
+ data = path.read_bytes()
+ b64 = base64.b64encode(data).decode("ascii")
+ return f"data:{mime};base64,{b64}"
+
+
+def embed_svg(path: Path) -> str:
+ """Read an SVG file and return its content for inline embedding."""
+ if not path.exists():
+ return "SVG not found
"
+ return path.read_text()
+
+
+def load_stats():
+ """Load the per-fold and aggregated statistics CSVs."""
+ individual = pd.read_csv(STATS_DIR / "RESPONSE_CR_categorical-stats_individual.csv")
+ aggregated = pd.read_csv(STATS_DIR / "RESPONSE_CR_categorical-stats_aggregated.csv")
+ return individual, aggregated
+
+
+def collect_heatmap_slides():
+ """Collect all heatmap slide info with predictions and ground truth."""
+ slide_df = pd.read_csv(SLIDE_TABLE)
+ fname_to_sample = dict(zip(slide_df["FILENAME"], slide_df["SAMPLE_ID"]))
+
+ # Load all predictions
+ all_preds = {}
+ for split_i in range(5):
+ pred_file = CROSSVAL_DIR / f"split-{split_i}" / "patient-preds.csv"
+ if not pred_file.exists():
+ continue
+ preds = pd.read_csv(pred_file)
+ for _, row in preds.iterrows():
+ all_preds[row["SAMPLE_ID"]] = {
+ "split": split_i,
+ "pred": row["pred"],
+ "prob_yes": row["RESPONSE_CR_yes"],
+ "prob_no": row["RESPONSE_CR_no"],
+ "gt": row["RESPONSE_CR"],
+ }
+
+ slides = []
+ for split_dir in sorted(HEATMAP_DIR.glob("split-*")):
+ split_i = int(split_dir.name.split("-")[1])
+ for slide_dir in sorted(split_dir.iterdir()):
+ if not slide_dir.is_dir():
+ continue
+ stem = slide_dir.name
+ h5_name = stem + ".h5"
+ sample_id = fname_to_sample.get(h5_name, "unknown")
+ info = all_preds.get(sample_id, {})
+
+ overview_path = slide_dir / "plots" / f"overview-{stem}.png"
+ if not overview_path.exists():
+ continue # Skip slides with no overview (e.g., 1-tile failures)
+
+ # Collect top/bottom tiles
+ tiles_dir = slide_dir / "tiles"
+ top_tiles = (
+ sorted(
+ [
+ t
+ for ext in ("*.png", "*.jpg", "*.jpeg")
+ for t in tiles_dir.glob(f"top_*{ext}")
+ ]
+ )[:4]
+ if tiles_dir.exists()
+ else []
+ )
+ bottom_tiles = (
+ sorted(
+ [
+ t
+ for ext in ("*.png", "*.jpg", "*.jpeg")
+ for t in tiles_dir.glob(f"bottom_*{ext}")
+ ]
+ )[:4]
+ if tiles_dir.exists()
+ else []
+ )
+
+ slides.append(
+ {
+ "stem": stem,
+ "sample_id": sample_id,
+ "split": split_i,
+ "gt": info.get("gt", "?"),
+ "pred": info.get("pred", "?"),
+ "prob_yes": info.get("prob_yes", 0),
+ "prob_no": info.get("prob_no", 0),
+ "overview_path": overview_path,
+ "top_tiles": top_tiles,
+ "bottom_tiles": bottom_tiles,
+ "correct": info.get("gt") == info.get("pred"),
+ }
+ )
+
+ return slides
+
+
+def generate_html():
+ individual, aggregated = load_stats()
+ slides = collect_heatmap_slides()
+
+ # Group slides by ground truth
+ yes_slides = [s for s in slides if s["gt"] == "yes"]
+ no_slides = [s for s in slides if s["gt"] == "no"]
+
+ # Extract mean AUROC from aggregated CSV
+ # The columns are: class, mean, 95%_low, 95%_high (for roc_auc_score)
+ mean_auroc = float(aggregated.iloc[1, 1]) # yes class, roc_auc_score mean
+ auroc_low = float(aggregated.iloc[1, 2])
+ auroc_high = float(aggregated.iloc[1, 3])
+ mean_auprc = float(aggregated.iloc[1, 4])
+
+ html = f"""
+
+
+
+
+AML Response Crossval Report — STAMP
+
+
+
+
+AML Response Prediction — Crossvalidation Report
+Generated {datetime.now().strftime("%Y-%m-%d %H:%M")} — STAMP Framework
+
+
+
1. Experiment Summary
+
+
+
Dataset
AML Bone Marrow Aspirates
+
+
+
Target
RESPONSE_CR (blast <5%)
+
Class Balance
yes=336 / no=153
+
Feature Extractor
UNI2 (3072-dim tiles)
+
MIL Model
ViT (2-layer, 8-head, 512-dim)
+
Crossval
5-fold StratifiedKFold
+
+
+
+Task: Binary classification predicting complete remission (CR) response in AML patients from
+H&E-stained bone marrow aspirate whole-slide images. Each sample represents a bone marrow biopsy section
+(parts A, B, C) from a timepoint (year 19 or 26). The positive class ("yes") indicates blast percentage
+below 5% (complete remission), while "no" indicates ≥5% blasts.
+
+
+
+
+
2. Performance Metrics
+
+
+
+
{mean_auroc:.3f}
+
Mean AUROC (95% CI: {auroc_low:.3f}–{auroc_high:.3f})
+
+
+
{mean_auprc:.3f}
+
Mean AUPRC (yes class)
+
+
+
+
ROC Curve (5-fold)
+
+{embed_svg(STATS_DIR / "roc-curve_RESPONSE_CR=yes.svg")}
+
+
+
Precision-Recall Curve (5-fold)
+
+{embed_svg(STATS_DIR / "pr-curve_RESPONSE_CR=yes.svg")}
+
+
+
Per-Fold Statistics
+{_make_stats_table(individual)}
+
+
+
+
3. Heatmap Gallery — GradCAM Attention Maps
+
+
+Note: Heatmaps are available for {len(slides)} out of 529 slides (covering
+{len(set(s["sample_id"] for s in slides))} samples from
+{len(set(s["sample_id"].rsplit("_", 3)[0] + "_" + s["sample_id"].rsplit("_", 3)[1] for s in slides))} biological patients).
+Only slides with available WSI (.ndpi) files could be visualized. Each heatmap uses the model
+from the crossval split where that slide was in the test set, ensuring no data leakage.
+
+Red regions indicate areas where the model attends for the positive (yes/CR) prediction.
+Blue regions indicate areas supporting the negative (no/non-CR) prediction.
+The top and bottom 8 most/least attended tiles are extracted for detailed inspection.
+
+
+
+"""
+
+ for slide in sorted(yes_slides, key=lambda s: (s["sample_id"], s["stem"])):
+ html += _make_slide_card(slide)
+
+ html += f"""
+
+"""
+
+ for slide in sorted(no_slides, key=lambda s: (s["sample_id"], s["stem"])):
+ html += _make_slide_card(slide)
+
+ html += """
+
+
+
+
4. Discussion Points
+
+
+- Model performance: Mean AUROC of {auroc:.3f} suggests the model can distinguish CR responders
+from non-responders at the sample level. However, note the class imbalance (69% positive) — the AUPRC
+and per-fold F1 scores provide more informative assessment.
+
+- Biological relevance of attention: The key question for Dr. Janssen is whether high-attention
+(red) regions correspond to blast-rich areas in non-responders and blast-poor areas in responders.
+Compare the GradCAM overlays above with expert morphological assessment of the same tissue regions.
+
+- Misclassifications to examine: Slides where the model was wrong (highlighted with
+"Incorrect" badges) are particularly informative — check whether the model attends to stroma, adipose tissue,
+or other non-diagnostic regions in those cases.
+
+- Limited heatmap coverage: Only {n_wsi} out of 529 slides have WSI files available for
+heatmap generation. Additional WSIs would strengthen the interpretability analysis. The current subset
+covers 7 biological patients across both classes.
+
+- Sample vs. patient-level analysis: Multiple sections (A, B, C) from the same patient may
+show different attention patterns. Comparing within-patient consistency could reveal whether the model
+captures biologically stable features or is influenced by sectioning artifacts.
+
+
+
+
+
+
+""".format(
+ auroc=mean_auroc,
+ n_wsi=len(slides),
+ date=datetime.now().strftime("%Y-%m-%d"),
+ )
+
+ return html
+
+
+def _make_stats_table(individual: pd.DataFrame) -> str:
+ """Generate an HTML table from the per-fold stats DataFrame."""
+ # Parse the individual CSV which has columns: fold, class, count, roc_auc, ap, f1, p_value
+ html = "\n"
+ html += "| Fold | Class | Count | AUROC | AUPRC | F1 | p-value | "
+ html += "
\n"
+
+ for _, row in individual.iterrows():
+ fold = row.iloc[0]
+ cls = row.iloc[1]
+ count = int(row.iloc[2])
+ auroc = float(row.iloc[3])
+ ap = float(row.iloc[4])
+ f1 = float(row.iloc[5])
+ pval = float(row.iloc[6])
+
+ badge = "badge-yes" if cls == "yes" else "badge-no"
+ html += f'| {fold} | {cls} | '
+ html += (
+ f"{count} | {auroc:.4f} | {ap:.4f} | {f1:.4f} | "
+ )
+ html += f"{pval:.2e} |
\n"
+
+ html += "
\n"
+ return html
+
+
+def _make_slide_card(slide: dict) -> str:
+ """Generate HTML card for a single slide with heatmap."""
+ correct_badge = (
+ 'Correct'
+ if slide["correct"]
+ else 'Incorrect'
+ )
+ gt_badge = (
+ f'GT: {slide["gt"]}'
+ if slide["gt"] == "yes"
+ else f'GT: {slide["gt"]}'
+ )
+
+ overview_uri = embed_image(slide["overview_path"])
+
+ html = f"""
+
+
+
{slide["stem"]}
+

+"""
+
+ if slide["top_tiles"]:
+ html += '
Top Attended Tiles
\n'
+ html += '
\n'
+ for tile_path in slide["top_tiles"]:
+ uri = embed_image(tile_path)
+ html += f'

\n'
+ html += "
\n"
+
+ if slide["bottom_tiles"]:
+ html += '
Least Attended Tiles
\n'
+ html += '
\n'
+ for tile_path in slide["bottom_tiles"]:
+ uri = embed_image(tile_path)
+ html += f'

\n'
+ html += "
\n"
+
+ html += "
\n"
+ return html
+
+
+def main():
+ REPORT_DIR.mkdir(parents=True, exist_ok=True)
+ html = generate_html()
+ report_path = REPORT_DIR / "aml_response_report.html"
+ report_path.write_text(html)
+ print(f"Report generated: {report_path}")
+ print(f"Report size: {report_path.stat().st_size / 1024 / 1024:.1f} MB")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/pilot_cell_resolution.py b/scripts/pilot_cell_resolution.py
new file mode 100644
index 00000000..2d3c4437
--- /dev/null
+++ b/scripts/pilot_cell_resolution.py
@@ -0,0 +1,750 @@
+#!/usr/bin/env python3
+"""Build a pathologist review deck for the STAMP cell-resolution pilot.
+
+This script reuses existing STAMP heatmap tile exports. It does not recompute
+heatmaps, rerun preprocessing, or require DeepHeme assets.
+"""
+
+from __future__ import annotations
+
+import argparse
+import csv
+import html
+import json
+import re
+import shutil
+from dataclasses import dataclass
+from datetime import UTC, datetime
+from pathlib import Path
+from typing import Any
+
+from PIL import Image, ImageDraw, ImageFont
+
+
+ROOT = Path(__file__).resolve().parents[1]
+CANDIDATES_PATH = (
+ ROOT / "validation_report" / "annotation_candidates" / "candidates.json"
+)
+CLINI_PATH = ROOT / "tables" / "stamp_clini.csv"
+DEFAULT_OUT = ROOT / "validation_report" / "pilot_cell_res"
+
+EXPERIMENT_BASES = {
+ "blast_percent": Path(
+ "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_blast_percent_uni2"
+ ),
+ "high_blast": Path(
+ "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_high_blast_uni2"
+ ),
+}
+
+GROUP_ORDER = ("failure", "positive", "negative")
+GROUPS = {
+ "failure": {
+ "title": "BLAST_PERCENT failures",
+ "experiment": "blast_percent",
+ "candidate_path": ("blast_percent", "largest_errors"),
+ "sample_ids": (
+ "SAMPLE_396_803_19_B",
+ "SAMPLE_396_803_19_A",
+ "SAMPLE_408_1411_19_A",
+ "SAMPLE_404_1261_19_C",
+ "SAMPLE_408_1411_19_C",
+ ),
+ },
+ "positive": {
+ "title": "High-blast positive controls",
+ "experiment": "high_blast",
+ "candidate_path": ("high_blast", "high_confidence_yes"),
+ "sample_ids": (
+ "SAMPLE_392_585_19_C",
+ "SAMPLE_406_1309_19_C",
+ "SAMPLE_414_968_19_C",
+ "SAMPLE_395_486_19_A",
+ ),
+ },
+ "negative": {
+ "title": "Low-blast negative controls",
+ "experiment": "high_blast",
+ "candidate_path": ("high_blast", "high_confidence_no"),
+ "sample_ids": (
+ "SAMPLE_309_665_19_C",
+ "SAMPLE_379_1373_19_A",
+ "SAMPLE_368_755_19_A",
+ ),
+ },
+}
+
+TILE_RE = re.compile(r"^(top|bottom)_(\d+)-.*=([-+]?\d+(?:\.\d+)?)\.jpg$")
+LANCZOS = Image.Resampling.LANCZOS
+
+
+@dataclass(frozen=True)
+class SourceTile:
+ kind: str
+ rank: int
+ score: float | None
+ path: Path
+
+
+@dataclass(frozen=True)
+class SlideSpec:
+ group: str
+ sample_id: str
+ stem: str
+ experiment: str
+ split: str
+ candidate: dict[str, Any]
+ clinical: dict[str, str]
+ source_tile_dir: Path
+ top_tiles: list[SourceTile]
+ bottom_tiles: list[SourceTile]
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(
+ description="Build the STAMP cell-resolution pilot review deck."
+ )
+ parser.add_argument("--out", type=Path, default=DEFAULT_OUT)
+ parser.add_argument("--top-k", type=int, default=8)
+ parser.add_argument("--bottom-k", type=int, default=8)
+ parser.add_argument("--zoom-px", type=int, default=512)
+ parser.add_argument("--dry-run", action="store_true")
+ parser.add_argument("--limit", type=int, default=None)
+ parser.add_argument(
+ "--groups",
+ default=",".join(GROUP_ORDER),
+ help="Comma-separated subset of: failure,positive,negative",
+ )
+ return parser.parse_args()
+
+
+def load_json(path: Path) -> Any:
+ with path.open() as fp:
+ return json.load(fp)
+
+
+def load_clinical(path: Path) -> dict[str, dict[str, str]]:
+ with path.open(newline="") as fp:
+ reader = csv.DictReader(fp)
+ return {row["SAMPLE_ID"]: row for row in reader}
+
+
+def rows_at_path(data: dict[str, Any], keys: tuple[str, ...]) -> list[dict[str, Any]]:
+ value: Any = data
+ for key in keys:
+ value = value[key]
+ if not isinstance(value, list):
+ raise TypeError(f"candidate path {'.'.join(keys)} is not a list")
+ return value
+
+
+def parse_groups(raw: str) -> list[str]:
+ groups = [g.strip() for g in raw.split(",") if g.strip()]
+ unknown = sorted(set(groups) - set(GROUP_ORDER))
+ if unknown:
+ raise SystemExit(f"unknown group(s): {', '.join(unknown)}")
+ ordered = [g for g in GROUP_ORDER if g in groups]
+ if not ordered:
+ raise SystemExit("no groups selected")
+ return ordered
+
+
+def collect_ranked_tiles(tile_dir: Path, kind: str, count: int) -> list[SourceTile]:
+ by_rank: dict[int, SourceTile] = {}
+ for path in tile_dir.glob(f"{kind}_*.jpg"):
+ match = TILE_RE.match(path.name)
+ if not match:
+ continue
+ matched_kind, rank_text, score_text = match.groups()
+ if matched_kind != kind:
+ continue
+ rank = int(rank_text)
+ try:
+ score = float(score_text)
+ except ValueError:
+ score = None
+ by_rank[rank] = SourceTile(kind=kind, rank=rank, score=score, path=path)
+
+ missing = [rank for rank in range(1, count + 1) if rank not in by_rank]
+ if missing:
+ raise FileNotFoundError(
+ f"{tile_dir} is missing {kind} ranks: "
+ + ", ".join(f"{rank:02d}" for rank in missing)
+ )
+ return [by_rank[rank] for rank in range(1, count + 1)]
+
+
+def build_slide_specs(
+ *,
+ candidates: dict[str, Any],
+ clinical: dict[str, dict[str, str]],
+ selected_groups: list[str],
+ top_k: int,
+ bottom_k: int,
+ limit: int | None,
+) -> list[SlideSpec]:
+ slides: list[SlideSpec] = []
+ for group in selected_groups:
+ group_cfg = GROUPS[group]
+ candidate_rows = rows_at_path(candidates, group_cfg["candidate_path"])
+ rows_by_sample: dict[str, dict[str, Any]] = {}
+ for row in candidate_rows:
+ # Keep the first candidate occurrence to preserve the locked ranking
+ # from candidates.json when multiple slides share a SAMPLE_ID.
+ rows_by_sample.setdefault(row["SAMPLE_ID"], row)
+
+ for sample_id in group_cfg["sample_ids"]:
+ if sample_id not in rows_by_sample:
+ raise KeyError(
+ f"{sample_id} not found at {'.'.join(group_cfg['candidate_path'])}"
+ )
+ if sample_id not in clinical:
+ raise KeyError(f"{sample_id} not found in {CLINI_PATH}")
+
+ row = rows_by_sample[sample_id]
+ experiment = str(group_cfg["experiment"])
+ split = str(row["split"])
+ stem = str(row["stem"])
+ source_tile_dir = (
+ EXPERIMENT_BASES[experiment] / "heatmaps" / split / stem / "tiles"
+ )
+ if not source_tile_dir.exists():
+ raise FileNotFoundError(f"missing tile directory: {source_tile_dir}")
+
+ slides.append(
+ SlideSpec(
+ group=group,
+ sample_id=sample_id,
+ stem=stem,
+ experiment=experiment,
+ split=split,
+ candidate=row,
+ clinical=clinical[sample_id],
+ source_tile_dir=source_tile_dir,
+ top_tiles=collect_ranked_tiles(source_tile_dir, "top", top_k),
+ bottom_tiles=collect_ranked_tiles(
+ source_tile_dir, "bottom", bottom_k
+ ),
+ )
+ )
+
+ if limit is not None:
+ if limit < 1:
+ raise SystemExit("--limit must be at least 1")
+ slides = slides[:limit]
+ return slides
+
+
+def copy_and_zoom_tile(
+ *,
+ source: SourceTile,
+ out_dir: Path,
+ zoom_px: int,
+) -> dict[str, Any]:
+ native_dir = out_dir / "native"
+ zoom_dir = out_dir / "zoom"
+ native_dir.mkdir(parents=True, exist_ok=True)
+ zoom_dir.mkdir(parents=True, exist_ok=True)
+
+ native_name = f"{source.kind}_{source.rank:02d}_native.jpg"
+ zoom_name = f"{source.kind}_{source.rank:02d}_zoom.jpg"
+ native_path = native_dir / native_name
+ zoom_path = zoom_dir / zoom_name
+
+ shutil.copy2(source.path, native_path)
+
+ with Image.open(source.path) as img:
+ rgb = img.convert("RGB")
+ width, height = rgb.size
+ if width < zoom_px or height < zoom_px:
+ raise ValueError(
+ f"{source.path} is {width}x{height}, smaller than --zoom-px {zoom_px}"
+ )
+ left = (width - zoom_px) // 2
+ top = (height - zoom_px) // 2
+ rgb.crop((left, top, left + zoom_px, top + zoom_px)).save(zoom_path, quality=92)
+
+ return {
+ "kind": source.kind,
+ "rank": source.rank,
+ "score": source.score,
+ "source_path": str(source.path),
+ "native_path": str(native_path),
+ "zoom_path": str(zoom_path),
+ }
+
+
+def text_size(
+ draw: ImageDraw.ImageDraw, text: str, font: ImageFont.ImageFont
+) -> tuple[int, int]:
+ box = draw.textbbox((0, 0), text, font=font)
+ return box[2] - box[0], box[3] - box[1]
+
+
+def draw_wrapped(
+ draw: ImageDraw.ImageDraw,
+ xy: tuple[int, int],
+ text: str,
+ *,
+ font: ImageFont.ImageFont,
+ fill: tuple[int, int, int],
+ max_width: int,
+ line_gap: int = 4,
+) -> int:
+ words = text.split()
+ lines: list[str] = []
+ line = ""
+ for word in words:
+ candidate = word if not line else f"{line} {word}"
+ if text_size(draw, candidate, font)[0] <= max_width:
+ line = candidate
+ else:
+ if line:
+ lines.append(line)
+ line = word
+ if line:
+ lines.append(line)
+
+ x, y = xy
+ line_height = text_size(draw, "Ag", font)[1] + line_gap
+ for line in lines:
+ draw.text((x, y), line, fill=fill, font=font)
+ y += line_height
+ return y
+
+
+def load_thumb(path: Path, size: int) -> Image.Image:
+ with Image.open(path) as img:
+ rgb = img.convert("RGB")
+ rgb.thumbnail((size, size), LANCZOS)
+ canvas = Image.new("RGB", (size, size), "white")
+ x = (size - rgb.size[0]) // 2
+ y = (size - rgb.size[1]) // 2
+ canvas.paste(rgb, (x, y))
+ return canvas
+
+
+def paste_grid(
+ *,
+ canvas: Image.Image,
+ draw: ImageDraw.ImageDraw,
+ title: str,
+ paths: list[Path],
+ x: int,
+ y: int,
+ tile_size: int,
+ cols: int,
+ gap: int,
+ font: ImageFont.ImageFont,
+) -> int:
+ draw.text((x, y), title, fill=(25, 25, 25), font=font)
+ y += 24
+ for idx, path in enumerate(paths):
+ row, col = divmod(idx, cols)
+ px = x + col * (tile_size + gap)
+ py = y + row * (tile_size + 26)
+ thumb = load_thumb(path, tile_size)
+ canvas.paste(thumb, (px, py))
+ draw.rectangle(
+ [px, py, px + tile_size - 1, py + tile_size - 1],
+ outline=(180, 180, 180),
+ )
+ draw.text((px + 4, py + tile_size + 4), f"{idx + 1:02d}", fill=(60, 60, 60))
+ rows = (len(paths) + cols - 1) // cols
+ return y + rows * (tile_size + 26) + 18
+
+
+def make_contact_sheet(
+ *,
+ slide: SlideSpec,
+ tile_records: list[dict[str, Any]],
+ out_path: Path,
+) -> None:
+ margin = 28
+ gap = 12
+ tile_size = 168
+ cols = 4
+ width = margin * 2 + cols * tile_size + (cols - 1) * gap
+ header_height = 184
+ section_height = 24 + 2 * (tile_size + 26) + 18
+ height = header_height + 4 * section_height + margin
+
+ canvas = Image.new("RGB", (width, height), "white")
+ draw = ImageDraw.Draw(canvas)
+ font = ImageFont.load_default()
+
+ title = f"{slide.group.upper()} | {slide.sample_id}"
+ y = margin
+ draw.text((margin, y), title, fill=(0, 0, 0), font=font)
+ y += 22
+
+ pred_bits = []
+ if "pred" in slide.candidate:
+ pred_bits.append(f"BLAST_PERCENT pred={float(slide.candidate['pred']):.2f}")
+ if "abs_err" in slide.candidate:
+ pred_bits.append(f"abs_err={float(slide.candidate['abs_err']):.2f}")
+ if "HIGH_BLAST_yes" in slide.candidate:
+ pred_bits.append(
+ f"HIGH_BLAST_yes={float(slide.candidate['HIGH_BLAST_yes']):.4f}"
+ )
+ metadata = (
+ f"GT BLAST_PERCENT={slide.clinical.get('BLAST_PERCENT', '')}; "
+ f"GT HIGH_BLAST={slide.clinical.get('HIGH_BLAST', '')}; "
+ f"experiment={slide.experiment}; split={slide.split}; " + "; ".join(pred_bits)
+ )
+ y = draw_wrapped(
+ draw,
+ (margin, y),
+ metadata,
+ font=font,
+ fill=(30, 30, 30),
+ max_width=width - margin * 2,
+ )
+ y += 6
+ y = draw_wrapped(
+ draw,
+ (margin, y),
+ f"Slide: {slide.stem}",
+ font=font,
+ fill=(60, 60, 60),
+ max_width=width - margin * 2,
+ )
+ y += 14
+
+ top_native = [Path(r["native_path"]) for r in tile_records if r["kind"] == "top"]
+ top_zoom = [Path(r["zoom_path"]) for r in tile_records if r["kind"] == "top"]
+ bottom_native = [
+ Path(r["native_path"]) for r in tile_records if r["kind"] == "bottom"
+ ]
+ bottom_zoom = [Path(r["zoom_path"]) for r in tile_records if r["kind"] == "bottom"]
+
+ y = paste_grid(
+ canvas=canvas,
+ draw=draw,
+ title="Top-attended native tiles",
+ paths=top_native,
+ x=margin,
+ y=y,
+ tile_size=tile_size,
+ cols=cols,
+ gap=gap,
+ font=font,
+ )
+ y = paste_grid(
+ canvas=canvas,
+ draw=draw,
+ title="Top-attended centered zoom crops",
+ paths=top_zoom,
+ x=margin,
+ y=y,
+ tile_size=tile_size,
+ cols=cols,
+ gap=gap,
+ font=font,
+ )
+ y = paste_grid(
+ canvas=canvas,
+ draw=draw,
+ title="Bottom-attended native tiles",
+ paths=bottom_native,
+ x=margin,
+ y=y,
+ tile_size=tile_size,
+ cols=cols,
+ gap=gap,
+ font=font,
+ )
+ paste_grid(
+ canvas=canvas,
+ draw=draw,
+ title="Bottom-attended centered zoom crops",
+ paths=bottom_zoom,
+ x=margin,
+ y=y,
+ tile_size=tile_size,
+ cols=cols,
+ gap=gap,
+ font=font,
+ )
+
+ out_path.parent.mkdir(parents=True, exist_ok=True)
+ canvas.save(out_path, quality=90)
+
+
+def rel(path: Path, base: Path) -> str:
+ return path.relative_to(base).as_posix()
+
+
+def format_float(value: Any, digits: int = 2) -> str:
+ if value in (None, ""):
+ return ""
+ try:
+ return f"{float(value):.{digits}f}"
+ except (TypeError, ValueError):
+ return str(value)
+
+
+def slide_metadata(slide: SlideSpec) -> dict[str, str]:
+ return {
+ "group": slide.group,
+ "sample_id": slide.sample_id,
+ "stem": slide.stem,
+ "experiment": slide.experiment,
+ "split": slide.split,
+ "blast_percent_gt": format_float(slide.clinical.get("BLAST_PERCENT")),
+ "high_blast_gt": slide.clinical.get("HIGH_BLAST", ""),
+ "blast_percent_pred": format_float(slide.candidate.get("pred")),
+ "blast_percent_abs_err": format_float(slide.candidate.get("abs_err")),
+ "high_blast_yes": format_float(slide.candidate.get("HIGH_BLAST_yes"), 4),
+ }
+
+
+def write_review_template(slides: list[dict[str, Any]], out_path: Path) -> None:
+ fields = [
+ "group",
+ "sample_id",
+ "stem",
+ "experiment",
+ "split",
+ "blast_percent_gt",
+ "high_blast_gt",
+ "blast_percent_pred",
+ "blast_percent_abs_err",
+ "high_blast_yes",
+ "cells_identifiable",
+ "top_grid_blast_percent_estimate",
+ "artifact_wrong_tissue_focus_notes",
+ "reviewer",
+ "review_date",
+ ]
+ with out_path.open("w", newline="") as fp:
+ writer = csv.DictWriter(fp, fieldnames=fields)
+ writer.writeheader()
+ for slide in slides:
+ row = {field: "" for field in fields}
+ row.update(slide["metadata"])
+ writer.writerow(row)
+
+
+def write_pdf(slides: list[dict[str, Any]], out_path: Path) -> None:
+ pages: list[Image.Image] = []
+ for slide in slides:
+ image = Image.open(slide["contact_sheet_path"]).convert("RGB")
+ pages.append(image)
+
+ if not pages:
+ return
+ first, rest = pages[0], pages[1:]
+ first.save(out_path, save_all=True, append_images=rest, resolution=150)
+ for page in pages:
+ page.close()
+
+
+def write_html(slides: list[dict[str, Any]], out_dir: Path) -> None:
+ parts = [
+ "",
+ "",
+ "STAMP Cell-Resolution Pilot",
+ """
+
+""",
+ "",
+ "STAMP Cell-Resolution Pilot
",
+ (
+ "Review top/bottom attended native tiles and "
+ "centered zoom crops. Fill answers in review_answers_template.csv.
"
+ ),
+ ]
+
+ for slide in slides:
+ meta = slide["metadata"]
+ parts.append("")
+ parts.append(
+ f"{html.escape(meta['group'])}: {html.escape(meta['sample_id'])}
"
+ )
+ parts.append("")
+ parts.append(
+ "Pathologist questions: "
+ "1. Can individual cells be identified? yes / sometimes / no. "
+ "2. Estimate blast percentage in the top-attended grid. "
+ "3. Note artifacts, wrong tissue, focus problems, or uninterpretable regions."
+ "
"
+ )
+ parts.append(
+ f""
+ f"
"
+ )
+ parts.append("Zoom crops
")
+ for tile in slide["tiles"]:
+ zoom_rel = rel(Path(tile["zoom_path"]), out_dir)
+ native_rel = rel(Path(tile["native_path"]), out_dir)
+ label = f"{tile['kind']} {int(tile['rank']):02d}"
+ parts.append(
+ "
"
+ f"
"
+ f"})
"
+ f"
{html.escape(label)}"
+ "
"
+ )
+ parts.append("
")
+
+ parts.append("")
+ (out_dir / "index.html").write_text("\n".join(parts))
+
+
+def build_outputs(
+ *,
+ slides: list[SlideSpec],
+ out_dir: Path,
+ zoom_px: int,
+ top_k: int,
+ bottom_k: int,
+ selected_groups: list[str],
+) -> dict[str, Any]:
+ out_dir.mkdir(parents=True, exist_ok=True)
+ manifest_slides: list[dict[str, Any]] = []
+
+ for slide in slides:
+ slide_out = out_dir / slide.group / slide.sample_id
+ slide_out.mkdir(parents=True, exist_ok=True)
+ tile_records = [
+ copy_and_zoom_tile(source=tile, out_dir=slide_out, zoom_px=zoom_px)
+ for tile in [*slide.top_tiles, *slide.bottom_tiles]
+ ]
+ contact_sheet_path = slide_out / "contact_sheet.jpg"
+ make_contact_sheet(
+ slide=slide, tile_records=tile_records, out_path=contact_sheet_path
+ )
+
+ metadata = slide_metadata(slide)
+ manifest_slides.append(
+ {
+ "metadata": metadata,
+ "source_tile_dir": str(slide.source_tile_dir),
+ "output_dir": str(slide_out),
+ "contact_sheet_path": str(contact_sheet_path),
+ "tiles": tile_records,
+ }
+ )
+
+ manifest = {
+ "generated_at": datetime.now(UTC).isoformat(),
+ "parameters": {
+ "top_k": top_k,
+ "bottom_k": bottom_k,
+ "zoom_px": zoom_px,
+ "groups": selected_groups,
+ },
+ "inputs": {
+ "candidates": str(CANDIDATES_PATH),
+ "clinical": str(CLINI_PATH),
+ "experiments": {key: str(value) for key, value in EXPERIMENT_BASES.items()},
+ },
+ "total_slides": len(manifest_slides),
+ "total_source_tiles": sum(len(s["tiles"]) for s in manifest_slides),
+ "slides": manifest_slides,
+ }
+
+ (out_dir / "pilot_manifest.json").write_text(json.dumps(manifest, indent=2))
+ write_review_template(manifest_slides, out_dir / "review_answers_template.csv")
+ write_html(manifest_slides, out_dir)
+ write_pdf(manifest_slides, out_dir / "pilot_cell_resolution_review.pdf")
+ return manifest
+
+
+def print_summary(slides: list[SlideSpec], *, dry_run: bool) -> None:
+ total_tiles = sum(
+ len(slide.top_tiles) + len(slide.bottom_tiles) for slide in slides
+ )
+ mode = "DRY RUN" if dry_run else "WROTE"
+ print(f"{mode}: {len(slides)} slides, {total_tiles} source tiles")
+ for group in GROUP_ORDER:
+ group_slides = [slide for slide in slides if slide.group == group]
+ if not group_slides:
+ continue
+ print(f"\n{GROUPS[group]['title']} ({len(group_slides)})")
+ for slide in group_slides:
+ meta = slide_metadata(slide)
+ print(
+ " "
+ f"{slide.sample_id} | {slide.split} | "
+ f"GT_BLAST={meta['blast_percent_gt']} | "
+ f"pred={meta['blast_percent_pred'] or meta['high_blast_yes']} | "
+ f"{slide.stem}"
+ )
+
+
+def main() -> None:
+ args = parse_args()
+ if args.top_k < 1 or args.bottom_k < 1:
+ raise SystemExit("--top-k and --bottom-k must be at least 1")
+ if args.zoom_px < 1:
+ raise SystemExit("--zoom-px must be at least 1")
+
+ selected_groups = parse_groups(args.groups)
+ candidates = load_json(CANDIDATES_PATH)
+ clinical = load_clinical(CLINI_PATH)
+ slides = build_slide_specs(
+ candidates=candidates,
+ clinical=clinical,
+ selected_groups=selected_groups,
+ top_k=args.top_k,
+ bottom_k=args.bottom_k,
+ limit=args.limit,
+ )
+
+ print_summary(slides, dry_run=args.dry_run)
+ if args.dry_run:
+ return
+
+ manifest = build_outputs(
+ slides=slides,
+ out_dir=args.out,
+ zoom_px=args.zoom_px,
+ top_k=args.top_k,
+ bottom_k=args.bottom_k,
+ selected_groups=selected_groups,
+ )
+ print(f"\nOutput directory: {args.out}")
+ print(f"Manifest: {args.out / 'pilot_manifest.json'}")
+ print(f"HTML: {args.out / 'index.html'}")
+ print(f"PDF: {args.out / 'pilot_cell_resolution_review.pdf'}")
+ print(f"Review CSV: {args.out / 'review_answers_template.csv'}")
+ print(
+ f"Verified {manifest['total_slides']} slides and "
+ f"{manifest['total_source_tiles']} copied source tiles."
+ )
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/run_heatmaps_per_split.py b/scripts/run_heatmaps_per_split.py
new file mode 100644
index 00000000..75f6319e
--- /dev/null
+++ b/scripts/run_heatmaps_per_split.py
@@ -0,0 +1,200 @@
+#!/usr/bin/env python3
+"""Run STAMP heatmaps per crossval split with correct checkpoint matching.
+
+For each split, only generates heatmaps for WSI slides whose patients were
+in that split's test set (avoiding data leakage). This ensures each heatmap
+uses a model that never saw that slide during training.
+
+Usage:
+ python scripts/run_heatmaps_per_split.py --experiment
+
+ experiment_name is one of:
+ - response (RESPONSE_CR binary classification)
+ - blast_percent (BLAST_PERCENT regression)
+ - blast_severity (BLAST_SEVERITY 3-class classification)
+ - high_blast (HIGH_BLAST binary classification)
+"""
+
+import argparse
+import json
+import os
+import subprocess
+import tempfile
+from pathlib import Path
+
+import pandas as pd
+import yaml
+
+# === Shared Configuration ===
+FEATURE_DIR = Path("/mnt/nvme0n1p1/Jeff_projects/B01/features/uni2-0242c340")
+WSI_DIR = Path("/mnt/nvme0n1p1/Jeff_projects/B01/data/AG Janssen")
+SLIDE_TABLE = Path("/home/jeff/Projects/STAMP/tables/stamp_slide.csv")
+STAMP_DIR = Path("/home/jeff/Projects/STAMP")
+
+# Heatmap parameters
+DEVICE = "cuda"
+TOPK = 8
+BOTTOMK = 8
+OPACITY = 0.6
+
+# Experiment configurations
+EXPERIMENTS = {
+ "response": {
+ "base_dir": Path(
+ "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_response_uni2"
+ ),
+ },
+ "blast_percent": {
+ "base_dir": Path(
+ "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_blast_percent_uni2"
+ ),
+ },
+ "blast_severity": {
+ "base_dir": Path(
+ "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_blast_severity_uni2"
+ ),
+ },
+ "high_blast": {
+ "base_dir": Path(
+ "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_high_blast_uni2"
+ ),
+ },
+}
+
+
+def main():
+ parser = argparse.ArgumentParser(
+ description="Run STAMP heatmaps per crossval split"
+ )
+ parser.add_argument(
+ "--experiment",
+ required=True,
+ choices=list(EXPERIMENTS.keys()),
+ help="Which experiment to generate heatmaps for",
+ )
+ parser.add_argument(
+ "--splits",
+ type=int,
+ nargs="*",
+ default=None,
+ help="Only run specific splits (e.g. --splits 0 2 4). Default: all splits.",
+ )
+ args = parser.parse_args()
+
+ exp = EXPERIMENTS[args.experiment]
+ base_dir = exp["base_dir"]
+ crossval_dir = base_dir / "crossval"
+ heatmap_base_dir = base_dir / "heatmaps"
+
+ print(f"=== Experiment: {args.experiment} ===")
+ print(f" Base dir: {base_dir}")
+ print(f" Crossval dir: {crossval_dir}")
+ print(f" Heatmap dir: {heatmap_base_dir}")
+
+ # Load splits
+ with open(crossval_dir / "splits.json") as f:
+ splits_data = json.load(f)
+
+ # Load slide table to map SAMPLE_ID -> FILENAME
+ slide_df = pd.read_csv(SLIDE_TABLE)
+ sample_to_filenames = {}
+ for _, row in slide_df.iterrows():
+ sample_id = row["SAMPLE_ID"]
+ filename = row["FILENAME"]
+ sample_to_filenames.setdefault(sample_id, []).append(filename)
+
+ # Get available WSI stems (from .ndpi files)
+ available_wsis = {p.stem: p.name for p in WSI_DIR.glob("*.ndpi")}
+ print(f"Found {len(available_wsis)} available WSI files")
+
+ splits_to_run = (
+ args.splits if args.splits is not None else range(len(splits_data["splits"]))
+ )
+
+ for split_i in splits_to_run:
+ if split_i >= len(splits_data["splits"]):
+ print(f"[SKIP] Split {split_i}: does not exist")
+ continue
+
+ split = splits_data["splits"][split_i]
+ checkpoint = crossval_dir / f"split-{split_i}" / "model.ckpt"
+ if not checkpoint.exists():
+ print(f"[SKIP] Split {split_i}: no model.ckpt found")
+ continue
+
+ test_patients = set(split["test_patients"])
+
+ # Find WSIs for test patients that have actual .ndpi files
+ slide_paths = []
+ for patient in test_patients:
+ filenames = sample_to_filenames.get(patient, [])
+ for fname in filenames:
+ stem = fname.replace(".h5", "")
+ if stem in available_wsis:
+ slide_paths.append(available_wsis[stem])
+
+ if not slide_paths:
+ print(f"[SKIP] Split {split_i}: no WSI files for test patients")
+ continue
+
+ print(f"\n[SPLIT {split_i}] Generating heatmaps for {len(slide_paths)} slides:")
+ for sp in slide_paths:
+ print(f" - {sp}")
+
+ # Create per-split output directory
+ split_heatmap_dir = heatmap_base_dir / f"split-{split_i}"
+
+ # Build a temporary YAML config for this split
+ config = {
+ "heatmaps": {
+ "output_dir": str(split_heatmap_dir),
+ "feature_dir": str(FEATURE_DIR),
+ "wsi_dir": str(WSI_DIR),
+ "checkpoint_path": str(checkpoint),
+ "device": DEVICE,
+ "topk": TOPK,
+ "bottomk": BOTTOMK,
+ "opacity": OPACITY,
+ "slide_paths": slide_paths,
+ }
+ }
+
+ with tempfile.NamedTemporaryFile(
+ mode="w", suffix=".yaml", delete=False, prefix=f"heatmap_split{split_i}_"
+ ) as tmp:
+ yaml.dump(config, tmp)
+ tmp_path = tmp.name
+
+ try:
+ env = os.environ.copy()
+ env["DISABLE_ADDMM_CUDA_LT"] = "1"
+
+ result = subprocess.run(
+ ["stamp", "--config", tmp_path, "heatmaps"],
+ cwd=str(STAMP_DIR),
+ env=env,
+ capture_output=False,
+ text=True,
+ )
+
+ if result.returncode != 0:
+ print(
+ f"[ERROR] Split {split_i} failed with return code {result.returncode}"
+ )
+ else:
+ print(f"[DONE] Split {split_i} heatmaps complete")
+ finally:
+ os.unlink(tmp_path)
+
+ print(f"\n=== Heatmap generation complete for {args.experiment} ===")
+ # Summarize what was generated
+ total = 0
+ for split_dir in sorted(heatmap_base_dir.glob("split-*")):
+ slide_dirs = [d for d in split_dir.iterdir() if d.is_dir()]
+ total += len(slide_dirs)
+ print(f" {split_dir.name}: {len(slide_dirs)} slides")
+ print(f" Total: {total} slides")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/stamp/stamp_aml_blast_percent_uni2_config.yaml b/src/stamp/stamp_aml_blast_percent_uni2_config.yaml
new file mode 100644
index 00000000..cfd07666
--- /dev/null
+++ b/src/stamp/stamp_aml_blast_percent_uni2_config.yaml
@@ -0,0 +1,101 @@
+preprocessing:
+ output_dir: "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_blast_percent_uni2"
+ wsi_dir: "/path/to/raw/wsi_dir"
+ extractor: "uni2"
+ device: "cuda"
+ cache_dir: "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_blast_percent_uni2/cache"
+ max_workers: 8
+
+crossval:
+ output_dir: "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_blast_percent_uni2/crossval"
+ clini_table: "/home/jeff/Projects/STAMP/tables/stamp_clini.csv"
+ feature_dir: "/mnt/nvme0n1p1/Jeff_projects/B01/features/uni2-0242c340"
+ slide_table: "/home/jeff/Projects/STAMP/tables/stamp_slide.csv"
+ task: "regression"
+ ground_truth_label: "BLAST_PERCENT"
+ patient_label: "SAMPLE_ID"
+ filename_label: "FILENAME"
+
+training:
+ output_dir: "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_blast_percent_uni2/training"
+ clini_table: "/home/jeff/Projects/STAMP/tables/stamp_clini.csv"
+ feature_dir: "/mnt/nvme0n1p1/Jeff_projects/B01/features/uni2-0242c340"
+ slide_table: "/home/jeff/Projects/STAMP/tables/stamp_slide.csv"
+ task: "regression"
+ ground_truth_label: "BLAST_PERCENT"
+ patient_label: "SAMPLE_ID"
+ filename_label: "FILENAME"
+
+deployment:
+ output_dir: "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_blast_percent_uni2/deployment"
+ clini_table: "/home/jeff/Projects/STAMP/tables/stamp_clini.csv"
+ feature_dir: "/mnt/nvme0n1p1/Jeff_projects/B01/features/uni2-0242c340"
+ slide_table: "/home/jeff/Projects/STAMP/tables/stamp_slide.csv"
+ ground_truth_label: "BLAST_PERCENT"
+ patient_label: "SAMPLE_ID"
+ filename_label: "FILENAME"
+ checkpoint_paths:
+ - "/path/to/model.ckpt"
+
+statistics:
+ output_dir: "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_blast_percent_uni2/statistics"
+ task: "regression"
+ ground_truth_label: "BLAST_PERCENT"
+ pred_csvs:
+ - "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_blast_percent_uni2/crossval/split-0/patient-preds.csv"
+ - "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_blast_percent_uni2/crossval/split-1/patient-preds.csv"
+ - "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_blast_percent_uni2/crossval/split-2/patient-preds.csv"
+ - "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_blast_percent_uni2/crossval/split-3/patient-preds.csv"
+ - "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_blast_percent_uni2/crossval/split-4/patient-preds.csv"
+
+heatmaps:
+ output_dir: "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_blast_percent_uni2/heatmaps"
+ feature_dir: "/mnt/nvme0n1p1/Jeff_projects/B01/features/uni2-0242c340"
+ wsi_dir: "/mnt/nvme0n1p1/Jeff_projects/B01/data/AG Janssen"
+ checkpoint_path: "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_blast_percent_uni2/crossval/split-0/model.ckpt"
+ device: "cuda"
+
+patient_encoding:
+ encoder: "cobra"
+ output_dir: "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_blast_percent_uni2/patient_encoding"
+ feat_dir: "/mnt/nvme0n1p1/Jeff_projects/B01/features/uni2-0242c340"
+ slide_table: "/home/jeff/Projects/STAMP/tables/stamp_slide.csv"
+ device: "cuda"
+ patient_label: "SAMPLE_ID"
+ filename_label: "FILENAME"
+
+advanced_config:
+ seed: 42
+ max_epochs: 32
+ patience: 16
+ batch_size: 64
+ bag_size: 512
+ max_lr: 1e-4
+ div_factor: 25.0
+ model_name: "vit"
+
+ model_params:
+ vit:
+ dim_model: 512
+ dim_feedforward: 512
+ n_heads: 8
+ n_layers: 2
+ dropout: 0.25
+ use_alibi: false
+
+ trans_mil:
+ dim_hidden: 512
+
+ mlp:
+ dim_hidden: 512
+ num_layers: 2
+ dropout: 0.25
+
+ barspoon:
+ d_model: 512
+ num_encoder_heads: 8
+ num_decoder_heads: 8
+ num_encoder_layers: 2
+ num_decoder_layers: 2
+ dim_feedforward: 2048
+ positional_encoding: true
diff --git a/src/stamp/stamp_aml_blast_severity_uni2_config.yaml b/src/stamp/stamp_aml_blast_severity_uni2_config.yaml
new file mode 100644
index 00000000..cf9f0936
--- /dev/null
+++ b/src/stamp/stamp_aml_blast_severity_uni2_config.yaml
@@ -0,0 +1,104 @@
+preprocessing:
+ output_dir: "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_blast_severity_uni2"
+ wsi_dir: "/path/to/raw/wsi_dir"
+ extractor: "uni2"
+ device: "cuda"
+ cache_dir: "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_blast_severity_uni2/cache"
+ max_workers: 8
+
+crossval:
+ output_dir: "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_blast_severity_uni2/crossval"
+ clini_table: "/home/jeff/Projects/STAMP/tables/stamp_clini.csv"
+ feature_dir: "/mnt/nvme0n1p1/Jeff_projects/B01/features/uni2-0242c340"
+ slide_table: "/home/jeff/Projects/STAMP/tables/stamp_slide.csv"
+ task: "classification"
+ ground_truth_label: "BLAST_SEVERITY"
+ patient_label: "SAMPLE_ID"
+ filename_label: "FILENAME"
+ categories: ["high", "intermediate", "low"]
+
+training:
+ output_dir: "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_blast_severity_uni2/training"
+ clini_table: "/home/jeff/Projects/STAMP/tables/stamp_clini.csv"
+ feature_dir: "/mnt/nvme0n1p1/Jeff_projects/B01/features/uni2-0242c340"
+ slide_table: "/home/jeff/Projects/STAMP/tables/stamp_slide.csv"
+ task: "classification"
+ ground_truth_label: "BLAST_SEVERITY"
+ patient_label: "SAMPLE_ID"
+ filename_label: "FILENAME"
+ categories: ["high", "intermediate", "low"]
+
+deployment:
+ output_dir: "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_blast_severity_uni2/deployment"
+ clini_table: "/home/jeff/Projects/STAMP/tables/stamp_clini.csv"
+ feature_dir: "/mnt/nvme0n1p1/Jeff_projects/B01/features/uni2-0242c340"
+ slide_table: "/home/jeff/Projects/STAMP/tables/stamp_slide.csv"
+ ground_truth_label: "BLAST_SEVERITY"
+ patient_label: "SAMPLE_ID"
+ filename_label: "FILENAME"
+ checkpoint_paths:
+ - "/path/to/model.ckpt"
+
+statistics:
+ output_dir: "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_blast_severity_uni2/statistics"
+ task: "classification"
+ ground_truth_label: "BLAST_SEVERITY"
+ true_class: "high"
+ pred_csvs:
+ - "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_blast_severity_uni2/crossval/split-0/patient-preds.csv"
+ - "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_blast_severity_uni2/crossval/split-1/patient-preds.csv"
+ - "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_blast_severity_uni2/crossval/split-2/patient-preds.csv"
+ - "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_blast_severity_uni2/crossval/split-3/patient-preds.csv"
+ - "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_blast_severity_uni2/crossval/split-4/patient-preds.csv"
+
+heatmaps:
+ output_dir: "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_blast_severity_uni2/heatmaps"
+ feature_dir: "/mnt/nvme0n1p1/Jeff_projects/B01/features/uni2-0242c340"
+ wsi_dir: "/mnt/nvme0n1p1/Jeff_projects/B01/data/AG Janssen"
+ checkpoint_path: "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_blast_severity_uni2/crossval/split-0/model.ckpt"
+ device: "cuda"
+
+patient_encoding:
+ encoder: "cobra"
+ output_dir: "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_blast_severity_uni2/patient_encoding"
+ feat_dir: "/mnt/nvme0n1p1/Jeff_projects/B01/features/uni2-0242c340"
+ slide_table: "/home/jeff/Projects/STAMP/tables/stamp_slide.csv"
+ device: "cuda"
+ patient_label: "SAMPLE_ID"
+ filename_label: "FILENAME"
+
+advanced_config:
+ seed: 42
+ max_epochs: 32
+ patience: 16
+ batch_size: 64
+ bag_size: 512
+ max_lr: 1e-4
+ div_factor: 25.0
+ model_name: "vit"
+
+ model_params:
+ vit:
+ dim_model: 512
+ dim_feedforward: 512
+ n_heads: 8
+ n_layers: 2
+ dropout: 0.25
+ use_alibi: false
+
+ trans_mil:
+ dim_hidden: 512
+
+ mlp:
+ dim_hidden: 512
+ num_layers: 2
+ dropout: 0.25
+
+ barspoon:
+ d_model: 512
+ num_encoder_heads: 8
+ num_decoder_heads: 8
+ num_encoder_layers: 2
+ num_decoder_layers: 2
+ dim_feedforward: 2048
+ positional_encoding: true
diff --git a/src/stamp/stamp_aml_high_blast_uni2_config.yaml b/src/stamp/stamp_aml_high_blast_uni2_config.yaml
new file mode 100644
index 00000000..ab31db9c
--- /dev/null
+++ b/src/stamp/stamp_aml_high_blast_uni2_config.yaml
@@ -0,0 +1,104 @@
+preprocessing:
+ output_dir: "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_high_blast_uni2"
+ wsi_dir: "/path/to/raw/wsi_dir"
+ extractor: "uni2"
+ device: "cuda"
+ cache_dir: "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_high_blast_uni2/cache"
+ max_workers: 8
+
+crossval:
+ output_dir: "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_high_blast_uni2/crossval"
+ clini_table: "/home/jeff/Projects/STAMP/tables/stamp_clini.csv"
+ feature_dir: "/mnt/nvme0n1p1/Jeff_projects/B01/features/uni2-0242c340"
+ slide_table: "/home/jeff/Projects/STAMP/tables/stamp_slide.csv"
+ task: "classification"
+ ground_truth_label: "HIGH_BLAST"
+ patient_label: "SAMPLE_ID"
+ filename_label: "FILENAME"
+ categories: ["yes", "no"]
+
+training:
+ output_dir: "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_high_blast_uni2/training"
+ clini_table: "/home/jeff/Projects/STAMP/tables/stamp_clini.csv"
+ feature_dir: "/mnt/nvme0n1p1/Jeff_projects/B01/features/uni2-0242c340"
+ slide_table: "/home/jeff/Projects/STAMP/tables/stamp_slide.csv"
+ task: "classification"
+ ground_truth_label: "HIGH_BLAST"
+ patient_label: "SAMPLE_ID"
+ filename_label: "FILENAME"
+ categories: ["yes", "no"]
+
+deployment:
+ output_dir: "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_high_blast_uni2/deployment"
+ clini_table: "/home/jeff/Projects/STAMP/tables/stamp_clini.csv"
+ feature_dir: "/mnt/nvme0n1p1/Jeff_projects/B01/features/uni2-0242c340"
+ slide_table: "/home/jeff/Projects/STAMP/tables/stamp_slide.csv"
+ ground_truth_label: "HIGH_BLAST"
+ patient_label: "SAMPLE_ID"
+ filename_label: "FILENAME"
+ checkpoint_paths:
+ - "/path/to/model.ckpt"
+
+statistics:
+ output_dir: "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_high_blast_uni2/statistics"
+ task: "classification"
+ ground_truth_label: "HIGH_BLAST"
+ true_class: "yes"
+ pred_csvs:
+ - "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_high_blast_uni2/crossval/split-0/patient-preds.csv"
+ - "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_high_blast_uni2/crossval/split-1/patient-preds.csv"
+ - "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_high_blast_uni2/crossval/split-2/patient-preds.csv"
+ - "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_high_blast_uni2/crossval/split-3/patient-preds.csv"
+ - "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_high_blast_uni2/crossval/split-4/patient-preds.csv"
+
+heatmaps:
+ output_dir: "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_high_blast_uni2/heatmaps"
+ feature_dir: "/mnt/nvme0n1p1/Jeff_projects/B01/features/uni2-0242c340"
+ wsi_dir: "/mnt/nvme0n1p1/Jeff_projects/B01/data/AG Janssen"
+ checkpoint_path: "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_high_blast_uni2/crossval/split-0/model.ckpt"
+ device: "cuda"
+
+patient_encoding:
+ encoder: "cobra"
+ output_dir: "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_high_blast_uni2/patient_encoding"
+ feat_dir: "/mnt/nvme0n1p1/Jeff_projects/B01/features/uni2-0242c340"
+ slide_table: "/home/jeff/Projects/STAMP/tables/stamp_slide.csv"
+ device: "cuda"
+ patient_label: "SAMPLE_ID"
+ filename_label: "FILENAME"
+
+advanced_config:
+ seed: 42
+ max_epochs: 32
+ patience: 16
+ batch_size: 64
+ bag_size: 512
+ max_lr: 1e-4
+ div_factor: 25.0
+ model_name: "vit"
+
+ model_params:
+ vit:
+ dim_model: 512
+ dim_feedforward: 512
+ n_heads: 8
+ n_layers: 2
+ dropout: 0.25
+ use_alibi: false
+
+ trans_mil:
+ dim_hidden: 512
+
+ mlp:
+ dim_hidden: 512
+ num_layers: 2
+ dropout: 0.25
+
+ barspoon:
+ d_model: 512
+ num_encoder_heads: 8
+ num_decoder_heads: 8
+ num_encoder_layers: 2
+ num_decoder_layers: 2
+ dim_feedforward: 2048
+ positional_encoding: true
diff --git a/src/stamp/stamp_aml_response_uni2_config.yaml b/src/stamp/stamp_aml_response_uni2_config.yaml
new file mode 100644
index 00000000..82fbcf04
--- /dev/null
+++ b/src/stamp/stamp_aml_response_uni2_config.yaml
@@ -0,0 +1,114 @@
+preprocessing:
+ output_dir: "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_response_uni2"
+ wsi_dir: "/path/to/raw/wsi_dir"
+ extractor: "uni2"
+ device: "cuda"
+ cache_dir: "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_response_uni2/cache"
+ max_workers: 8
+ # Optional if slide metadata lacks resolution:
+ # default_slide_mpp: 1.0
+ # Optional: restrict preprocessing to a list of filenames
+ # wsi_list: "/path/to/slide_list.csv"
+
+crossval:
+ output_dir: "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_response_uni2/crossval"
+ clini_table: "/home/jeff/Projects/STAMP/tables/stamp_clini.csv"
+ feature_dir: "/mnt/nvme0n1p1/Jeff_projects/B01/features/uni2-0242c340"
+ slide_table: "/home/jeff/Projects/STAMP/tables/stamp_slide.csv"
+ task: "classification"
+ ground_truth_label: "RESPONSE_CR"
+ patient_label: "SAMPLE_ID"
+ filename_label: "FILENAME"
+ categories: ["yes", "no"]
+ # n_splits: 5
+
+training:
+ output_dir: "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_response_uni2/training"
+ clini_table: "/home/jeff/Projects/STAMP/tables/stamp_clini.csv"
+ feature_dir: "/mnt/nvme0n1p1/Jeff_projects/B01/features/uni2-0242c340"
+ slide_table: "/home/jeff/Projects/STAMP/tables/stamp_slide.csv"
+ task: "classification"
+ ground_truth_label: "RESPONSE_CR"
+ patient_label: "SAMPLE_ID"
+ filename_label: "FILENAME"
+ categories: ["yes", "no"]
+
+deployment:
+ output_dir: "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_response_uni2/deployment"
+ clini_table: "/home/jeff/Projects/STAMP/tables/stamp_clini.csv"
+ feature_dir: "/mnt/nvme0n1p1/Jeff_projects/B01/features/uni2-0242c340"
+ slide_table: "/home/jeff/Projects/STAMP/tables/stamp_slide.csv"
+ ground_truth_label: "RESPONSE_CR"
+ patient_label: "SAMPLE_ID"
+ filename_label: "FILENAME"
+ checkpoint_paths:
+ - "/path/to/model.ckpt"
+
+statistics:
+ output_dir: "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_response_uni2/statistics"
+ task: "classification"
+ ground_truth_label: "RESPONSE_CR"
+ true_class: "yes"
+ pred_csvs:
+ - "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_response_uni2/crossval/split-0/patient-preds.csv"
+ - "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_response_uni2/crossval/split-1/patient-preds.csv"
+ - "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_response_uni2/crossval/split-2/patient-preds.csv"
+ - "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_response_uni2/crossval/split-3/patient-preds.csv"
+ - "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_response_uni2/crossval/split-4/patient-preds.csv"
+
+heatmaps:
+ output_dir: "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_response_uni2/heatmaps"
+ feature_dir: "/mnt/nvme0n1p1/Jeff_projects/B01/features/uni2-0242c340"
+ wsi_dir: "/mnt/nvme0n1p1/Jeff_projects/B01/data/AG Janssen"
+ checkpoint_path: "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_response_uni2/crossval/split-0/model.ckpt"
+ device: "cuda"
+ # slide_paths:
+ # - slide1.svs
+
+# Optional: only use this if you want patient-level embeddings before modeling.
+# Because your current feature_dir is UNI2 tile features, use COBRA here.
+# CHIEF slide/patient encoding expects CHIEF-CTRANSPATH tile features.
+patient_encoding:
+ encoder: "cobra"
+ output_dir: "/mnt/nvme0n1p1/Jeff_projects/B01/AG Janssen/stamp_aml_response_uni2/patient_encoding"
+ feat_dir: "/mnt/nvme0n1p1/Jeff_projects/B01/features/uni2-0242c340"
+ slide_table: "/home/jeff/Projects/STAMP/tables/stamp_slide.csv"
+ device: "cuda"
+ patient_label: "SAMPLE_ID"
+ filename_label: "FILENAME"
+
+advanced_config:
+ seed: 42
+ max_epochs: 32
+ patience: 16
+ batch_size: 64
+ bag_size: 512
+ max_lr: 1e-4
+ div_factor: 25.0
+ model_name: "vit"
+
+ model_params:
+ vit:
+ dim_model: 512
+ dim_feedforward: 512
+ n_heads: 8
+ n_layers: 2
+ dropout: 0.25
+ use_alibi: false
+
+ trans_mil:
+ dim_hidden: 512
+
+ mlp:
+ dim_hidden: 512
+ num_layers: 2
+ dropout: 0.25
+
+ barspoon:
+ d_model: 512
+ num_encoder_heads: 8
+ num_decoder_heads: 8
+ num_encoder_layers: 2
+ num_decoder_layers: 2
+ dim_feedforward: 2048
+ positional_encoding: true
diff --git a/validation_report/aml_validation_followup.html b/validation_report/aml_validation_followup.html
new file mode 100644
index 00000000..b69bb9c7
--- /dev/null
+++ b/validation_report/aml_validation_followup.html
@@ -0,0 +1,700 @@
+
+
+
+
+STAMP AML Crossval — Validation Follow-up
+
+
+
+
+
+STAMP AML Crossval — Validation Follow-up
+Branch exp_aml_response_crossval · commit bc16cbe · generated 2026-04-24
+
+
+
What's in this report:
+
+ - Before / after preprocessing visualisations for 4 slides flagged as having "many tiles removed",
+ a gallery of the actual rejected tiles, and the precise reason they were dropped.
+ - Heatmaps for 6 representative slides (4 blood smears + the 2 most aggressively filtered
+ tissue slides) across all 4 AML tasks (RESPONSE_CR, BLAST_PERCENT, BLAST_SEVERITY, HIGH_BLAST),
+ with ground truth and per-class predictions side by side.
+ - Ranked annotation candidates for the haematopathologist to label next, prioritised by
+ expected information gain.
+
+
+
+1. Preprocessing — before / after, with rejected tiles
+
+
+
116 26 B Score 49 - 10 Fokuspunkte - entfettet - 2026-03-04 18.49.41
+
+
WSI dims: 57344 × 37632 px (MPP 0.2208 µm/px)
+
Tile grid: 49 × 32 = 1568 theoretical tiles
+
Tiles kept: 1206 / 1568 (76.9%)
+
Rejection reason (sampled n=300):
+ Canny low-edge 93.7% • brightness ≥ 240 0.0% • boundary/other 6.3%
+
+
+
+
Before preprocessing — raw thumbnail
+

+
+
+
After preprocessing — kept tiles outlined green; rejected tiles shaded red
+

+
+
+
+
+
Example KEPT tiles (passed brightness + Canny edge test)
+

+
+
+
Example REJECTED tiles (label = reason for rejection)
+

+
+
+
+
+
+
+
127 26 C Score 0 - 5 Fokuspunkte - entfettet - 2026-03-04 17.28.20
+
+
WSI dims: 96768 × 43008 px (MPP 0.2208 µm/px)
+
Tile grid: 83 × 37 = 3071 theoretical tiles
+
Tiles kept: 143 / 3071 (4.7%)
+
Rejection reason (sampled n=300):
+ Canny low-edge 99.7% • brightness ≥ 240 0.0% • boundary/other 0.3%
+
+
+
+
Before preprocessing — raw thumbnail
+

+
+
+
After preprocessing — kept tiles outlined green; rejected tiles shaded red
+

+
+
+
+
+
Example KEPT tiles (passed brightness + Canny edge test)
+

+
+
+
Example REJECTED tiles (label = reason for rejection)
+

+
+
+
+
+
+
+
333 19 A Score 29 - 5 Fokuspunkte - entfettet - 2026-03-03 18.42.23
+
+
WSI dims: 132608 × 59136 px (MPP 0.2208 µm/px)
+
Tile grid: 114 × 50 = 5700 theoretical tiles
+
Tiles kept: 2850 / 5700 (50.0%)
+
Rejection reason (sampled n=300):
+ Canny low-edge 92.0% • brightness ≥ 240 0.0% • boundary/other 8.0%
+
+
+
+
Before preprocessing — raw thumbnail
+

+
+
+
After preprocessing — kept tiles outlined green; rejected tiles shaded red
+

+
+
+
+
+
Example KEPT tiles (passed brightness + Canny edge test)
+

+
+
+
Example REJECTED tiles (label = reason for rejection)
+

+
+
+
+
+
+
+
931 19 A Score 30 - 5 Fokuspunkte - entfettet - 2026-03-04 21.23.41
+
+
WSI dims: 82432 × 32256 px (MPP 0.2208 µm/px)
+
Tile grid: 71 × 27 = 1917 theoretical tiles
+
Tiles kept: 762 / 1917 (39.7%)
+
Rejection reason (sampled n=300):
+ Canny low-edge 87.3% • brightness ≥ 240 0.0% • boundary/other 12.7%
+
+
+
+
Before preprocessing — raw thumbnail
+

+
+
+
After preprocessing — kept tiles outlined green; rejected tiles shaded red
+

+
+
+
+
+
Example KEPT tiles (passed brightness + Canny edge test)
+

+
+
+
Example REJECTED tiles (label = reason for rejection)
+

+
+
+
+
+
+
+
Why are so many tiles removed?
+
The STAMP preprocessing pipeline applies two background-rejection filters at tile-extraction time
+(see src/stamp/preprocessing/tiling.py):
+
+
+- Brightness filter —
brightness_cutoff = 240. Computed at the
+ supertile level on the slide thumbnail. Any region with average grayscale
+ intensity ≥ 240 is treated as glass/empty background and skipped before any
+ tiling occurs.
+- Canny edge filter —
canny_cutoff = 0.02. After tiling, each
+ 224×224 tile is run through OpenCV Canny (thresholds 40 / 100).
+ Tiles with fewer than 2 % edge pixels are deemed texture-less and dropped.
+
+
+
For the four slides above, almost every rejected tile fails the
+Canny edge test, not the brightness test. Looking at the rejected-tile
+gallery makes the cause obvious: the rejected tiles are out of focus
+(blurred, soft, or featureless purple/pink). When the smear is blurry, the
+Canny detector finds few edges → the tile is classified as low-information and
+discarded.
+
+
This correlates with the focus-acquisition setting in the file name:
+
+| Slide | Focus pts | Retention |
+| 116 26 B (10 Fokuspunkte) | 10 | 76.9 % |
+| 127 26 C (5 Fokuspunkte) | 5 | 4.7 % |
+| 333 19 A (5 Fokuspunkte) | 5 | 50.0 % |
+| 931 19 A (5 Fokuspunkte) | 5 | 39.7 % |
+
+
+
Summary: The drop in retention is not because the slide is empty —
+the tissue is there, but the sub-sampled focus stack (5 Fokuspunkte) failed to
+keep most of the smear in sharp focus. The Canny filter is doing its job:
+removing tiles that would have been useless features for the model. The
+real fix is upstream — re-scan the affected slides with the full
+10-Fokuspunkte stack.
+
+
+
+2. Heatmaps across the 4 tasks (with GT and prediction)
+For each slide we show the four task-specific heatmaps generated using the crossval split
+in which that slide was held out (so the model never saw it during training).
+Each panel pair is: left — overview with the top-K relevant tiles outlined;
+right — class map (per-tile class assignment) for classification, or a continuous
+relevance heatmap for the regression head.
+
+
+
333 19 A Score 29 - 5 Fokuspunkte - entfettet - 2026-03-03 18.42.23
+
SAMPLE_ID = SAMPLE_379_333_19_A
+
| BLAST_PERCENT | 2.0% |
| RESPONSE_CR | yes |
| BLAST_SEVERITY | low |
| HIGH_BLAST | no |
+
+
+
RESPONSE_CR (binary) (split-0)
+
GT: yes Prediction: P(yes)=0.945, P(no)=0.055 → yes
+
+
+
Overview (top-K / bottom-K patches)
+

+
+
+
Class map
+

+
+
+
+
+
+
BLAST_PERCENT (regression) (split-2)
+
GT: 2.0 Prediction: pred=3.57% (|err|=1.57%)
+
+
+
Overview (top-K / bottom-K patches)
+

+
+
+
Relevance heatmap
+

+
+
+
+
+
+
BLAST_SEVERITY (3-class) (split-0)
+
GT: low Prediction: P(low)=0.812, P(intermediate)=0.018, P(high)=0.171 → low
+
+
+
Overview (top-K / bottom-K patches)
+

+
+
+
Class map
+

+
+
+
+
+
+
HIGH_BLAST (binary) (split-3)
+
GT: no Prediction: P(yes)=0.166 → no
+
+
+
Overview (top-K / bottom-K patches)
+

+
+
+
Class map
+

+
+
+
+
+
+
+
+
+
931 19 A Score 30 - 5 Fokuspunkte - entfettet - 2026-03-04 21.23.41
+
SAMPLE_ID = SAMPLE_379_931_19_A
+
| BLAST_PERCENT | 4.0% |
| RESPONSE_CR | yes |
| BLAST_SEVERITY | low |
| HIGH_BLAST | no |
+
+
+
RESPONSE_CR (binary) (split-2)
+
GT: yes Prediction: P(yes)=0.984, P(no)=0.016 → yes
+
+
+
Overview (top-K / bottom-K patches)
+

+
+
+
Class map
+

+
+
+
+
+
+
BLAST_PERCENT (regression) (split-3)
+
GT: 4.0 Prediction: pred=3.99% (|err|=0.01%)
+
+
+
Overview (top-K / bottom-K patches)
+

+
+
+
Relevance heatmap
+

+
+
+
+
+
+
BLAST_SEVERITY (3-class) (split-4)
+
GT: low Prediction: P(low)=0.878, P(intermediate)=0.110, P(high)=0.011 → low
+
+
+
Overview (top-K / bottom-K patches)
+

+
+
+
Class map
+

+
+
+
+
+
+
HIGH_BLAST (binary) (split-2)
+
GT: no Prediction: P(yes)=0.084 → no
+
+
+
Overview (top-K / bottom-K patches)
+

+
+
+
Class map
+

+
+
+
+
+
+
+
+
+
189-19 B Score 31 - 10 Fokuspunkte - nicht entfettet
+
SAMPLE_ID = SAMPLE_391_189_19_B
+
| BLAST_PERCENT | 0.0% |
| RESPONSE_CR | yes |
| BLAST_SEVERITY | low |
| HIGH_BLAST | no |
+
+
+
RESPONSE_CR (binary) (split-2)
+
GT: yes Prediction: P(yes)=0.959, P(no)=0.041 → yes
+
+
+
Overview (top-K / bottom-K patches)
+

+
+
+
Class map
+

+
+
+
+
+
+
BLAST_PERCENT (regression) (split-3)
+
GT: 0.0 Prediction: pred=-0.65% (|err|=0.65%)
+
+
+
Overview (top-K / bottom-K patches)
+

+
+
+
Relevance heatmap
+

+
+
+
+
+
+
BLAST_SEVERITY (3-class) (split-2)
+
GT: low Prediction: P(low)=0.853, P(intermediate)=0.009, P(high)=0.138 → low
+
+
+
Overview (top-K / bottom-K patches)
+

+
+
+
Class map
+

+
+
+
+
+
+
HIGH_BLAST (binary) (split-3)
+
GT: no Prediction: P(yes)=0.429 → no
+
+
+
Overview (top-K / bottom-K patches)
+

+
+
+
Class map
+

+
+
+
+
+
+
+
+
+
189-19 C Score 82 - 10 Fokuspunkte - nicht entfettet
+
SAMPLE_ID = SAMPLE_391_189_19_C
+
| BLAST_PERCENT | 0.0% |
| RESPONSE_CR | yes |
| BLAST_SEVERITY | low |
| HIGH_BLAST | no |
+
+
+
RESPONSE_CR (binary) (split-3)
+
GT: yes Prediction: P(yes)=0.850, P(no)=0.150 → yes
+
+
+
Overview (top-K / bottom-K patches)
+

+
+
+
Class map
+

+
+
+
+
+
+
BLAST_PERCENT (regression) (split-1)
+
GT: 0.0 Prediction: pred=2.11% (|err|=2.11%)
+
+
+
Overview (top-K / bottom-K patches)
+
