radiologist-core 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. radiologist/core/__init__.py +43 -0
  2. radiologist/core/callbacks/__init__.py +35 -0
  3. radiologist/core/callbacks/attribution.py +577 -0
  4. radiologist/core/callbacks/best_metric.py +90 -0
  5. radiologist/core/callbacks/onnx_export.py +98 -0
  6. radiologist/core/callbacks/wandb_summary.py +69 -0
  7. radiologist/core/configs/__init__.py +23 -0
  8. radiologist/core/configs/callbacks/attribution.yaml +8 -0
  9. radiologist/core/configs/callbacks/best_metric.yaml +5 -0
  10. radiologist/core/configs/callbacks/default.yaml +8 -0
  11. radiologist/core/configs/callbacks/lr_monitor.yaml +4 -0
  12. radiologist/core/configs/callbacks/model_checkpoint.yaml +16 -0
  13. radiologist/core/configs/callbacks/onnx_export.yaml +7 -0
  14. radiologist/core/configs/callbacks/wandb_summary.yaml +5 -0
  15. radiologist/core/configs/datamodule/default.yaml +56 -0
  16. radiologist/core/configs/debug/barebones.yaml +18 -0
  17. radiologist/core/configs/debug/base.yaml +31 -0
  18. radiologist/core/configs/debug/fast_dev_run.yaml +13 -0
  19. radiologist/core/configs/debug/limit.yaml +23 -0
  20. radiologist/core/configs/debug/overfit.yaml +20 -0
  21. radiologist/core/configs/eval.yaml +24 -0
  22. radiologist/core/configs/extras.yaml +5 -0
  23. radiologist/core/configs/hydra/default.yaml +9 -0
  24. radiologist/core/configs/loggers/wandb.yaml +17 -0
  25. radiologist/core/configs/module/loss/focal_loss.yaml +8 -0
  26. radiologist/core/configs/module/metric/fbeta_score.yaml +16 -0
  27. radiologist/core/configs/module/optimizer/adamw.yaml +8 -0
  28. radiologist/core/configs/module/resnet50.yaml +7 -0
  29. radiologist/core/configs/module/scheduler/sequential.yaml +8 -0
  30. radiologist/core/configs/paths.yaml +7 -0
  31. radiologist/core/configs/strategy/auto.yaml +2 -0
  32. radiologist/core/configs/train.yaml +28 -0
  33. radiologist/core/configs/trainer.yaml +14 -0
  34. radiologist/core/data/__init__.py +27 -0
  35. radiologist/core/data/datamodule.py +342 -0
  36. radiologist/core/data/dtypes.py +34 -0
  37. radiologist/core/data/shards.py +125 -0
  38. radiologist/core/losses.py +100 -0
  39. radiologist/core/module.py +333 -0
  40. radiologist/core/registry/__init__.py +27 -0
  41. radiologist/core/registry/export.py +190 -0
  42. radiologist/core/resume.py +91 -0
  43. radiologist/core/train.py +180 -0
  44. radiologist_core-0.1.0.dist-info/METADATA +268 -0
  45. radiologist_core-0.1.0.dist-info/RECORD +48 -0
  46. radiologist_core-0.1.0.dist-info/WHEEL +4 -0
  47. radiologist_core-0.1.0.dist-info/entry_points.txt +3 -0
  48. radiologist_core-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,43 @@
1
+ # MIT License
2
+ #
3
+ # Copyright (c) 2026 @CedrickArmel
4
+ #
5
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ # of this software and associated documentation files (the "Software"), to deal
7
+ # in the Software without restriction, including without limitation the rights
8
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ # copies of the Software, and to permit persons to whom the Software is
10
+ # furnished to do so, subject to the following conditions:
11
+ #
12
+ # The above copyright notice and this permission notice shall be included in all
13
+ # copies or substantial portions of the Software.
14
+ #
15
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ # SOFTWARE.
22
+
23
+ """Training loop, datamodule, focal loss, and callbacks for the classifier."""
24
+
25
+ from radiologist.core.callbacks import (
26
+ AttributionCallback,
27
+ BestMetricCallback,
28
+ OnnxExportCallback,
29
+ WandbDefineSummaryCallback,
30
+ )
31
+ from radiologist.core.data import WebDatasetDataModule
32
+ from radiologist.core.losses import FocalLoss
33
+ from radiologist.core.module import LModule
34
+
35
+ __all__ = [
36
+ "AttributionCallback",
37
+ "BestMetricCallback",
38
+ "FocalLoss",
39
+ "LModule",
40
+ "OnnxExportCallback",
41
+ "WandbDefineSummaryCallback",
42
+ "WebDatasetDataModule",
43
+ ]
@@ -0,0 +1,35 @@
1
+ # MIT License
2
+ #
3
+ # Copyright (c) 2026 @CedrickArmel
4
+ #
5
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ # of this software and associated documentation files (the "Software"), to deal
7
+ # in the Software without restriction, including without limitation the rights
8
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ # copies of the Software, and to permit persons to whom the Software is
10
+ # furnished to do so, subject to the following conditions:
11
+ #
12
+ # The above copyright notice and this permission notice shall be included in all
13
+ # copies or substantial portions of the Software.
14
+ #
15
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ # SOFTWARE.
22
+
23
+ """Lightning callbacks: attribution, best-metric tracking, ONNX export, W&B."""
24
+
25
+ from radiologist.core.callbacks.attribution import AttributionCallback
26
+ from radiologist.core.callbacks.best_metric import BestMetricCallback
27
+ from radiologist.core.callbacks.onnx_export import OnnxExportCallback
28
+ from radiologist.core.callbacks.wandb_summary import WandbDefineSummaryCallback
29
+
30
+ __all__ = [
31
+ "AttributionCallback",
32
+ "BestMetricCallback",
33
+ "OnnxExportCallback",
34
+ "WandbDefineSummaryCallback",
35
+ ]
@@ -0,0 +1,577 @@
1
+ # MIT License
2
+ #
3
+ # Copyright (c) 2026 @CedrickArmel
4
+ #
5
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ # of this software and associated documentation files (the "Software"), to deal
7
+ # in the Software without restriction, including without limitation the rights
8
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ # copies of the Software, and to permit persons to whom the Software is
10
+ # furnished to do so, subject to the following conditions:
11
+ #
12
+ # The above copyright notice and this permission notice shall be included in all
13
+ # copies or substantial portions of the Software.
14
+ #
15
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ # SOFTWARE.
22
+
23
+ """Score-CAM / Integrated Gradients attribution callback for Lightning."""
24
+
25
+ from __future__ import annotations
26
+
27
+ import functools
28
+ from dataclasses import dataclass
29
+ from pathlib import Path
30
+ from typing import Any, Dict, List, Mapping, Optional, Tuple, Union
31
+
32
+ import lightning as L
33
+ import torch
34
+ import torchvision.utils as vutils # type: ignore[import-untyped]
35
+
36
+ try:
37
+ from captum.attr import ( # type: ignore[import-untyped]
38
+ IntegratedGradients,
39
+ LayerGradCam,
40
+ )
41
+
42
+ _CAPTUM_AVAILABLE = True
43
+ except ImportError:
44
+ IntegratedGradients = None # type: ignore[assignment,misc]
45
+ LayerGradCam = None # type: ignore[assignment,misc]
46
+ _CAPTUM_AVAILABLE = False
47
+
48
+ try:
49
+ import wandb
50
+ except ImportError:
51
+ wandb = None # type: ignore[assignment]
52
+
53
+
54
+ @dataclass
55
+ class _Panel:
56
+ """One rendered sample panel and the metadata needed to save and log it.
57
+
58
+ Attributes:
59
+ key: dataset key identifying the source sample.
60
+ image: rendered grid ``[3, H', W']`` from ``_build_sample_image``.
61
+ caption: human-readable W&B caption (epoch / key / true / pred).
62
+ filename: deterministic PNG filename (stage, epoch, key, true, pred).
63
+ """
64
+
65
+ key: str
66
+ image: torch.Tensor
67
+ caption: str
68
+ filename: str
69
+
70
+
71
+ class AttributionCallback(L.Callback):
72
+ """Writes GradCAM and Integrated-Gradients overlays for val/test batches.
73
+
74
+ Selects one sample per true class (preferring misclassified ones), computes
75
+ attributions toward every target class in a single batched call, and logs a
76
+ stacked panel image (one row per class).
77
+
78
+ Args:
79
+ target_layer: dot-path resolved lazily against ``pl_module.net``
80
+ (e.g. ``"layer4.1.conv2"``).
81
+ every_n_val_epochs: run on validation epochs where
82
+ ``epoch % every_n_val_epochs == 0``.
83
+ every_n_batches: run on batches where ``batch_idx % every_n_batches == 0``.
84
+ every_n_test_batches: override of ``every_n_batches`` for test stage.
85
+ output_subdir: sub-directory under ``trainer.log_dir`` for PNGs.
86
+ save_to_file: whether to save panel PNGs to disk.
87
+ ig_n_steps: number of IG quadrature steps. ``None`` (default) disables IG
88
+ entirely — IG requires this many forward+backward passes per
89
+ sample×class and dominates wall time.
90
+ """
91
+
92
+ def __init__(
93
+ self,
94
+ target_layer: str,
95
+ every_n_val_epochs: int = 1,
96
+ every_n_batches: int = 1,
97
+ every_n_test_batches: Optional[int] = None,
98
+ output_subdir: str = "attributions",
99
+ save_to_file: bool = True,
100
+ ig_n_steps: Optional[int] = None,
101
+ ) -> None:
102
+ """Initialize the callback with attribution scheduling and output options."""
103
+ super().__init__()
104
+ self.target_layer = target_layer
105
+ self.every_n_val_epochs = every_n_val_epochs
106
+ self._every_n_batches = every_n_batches
107
+ self._every_n_test_batches = every_n_test_batches
108
+ self.output_subdir = output_subdir
109
+ self._save_to_file = save_to_file
110
+ self.ig_n_steps = ig_n_steps
111
+
112
+ def on_validation_batch_end(
113
+ self,
114
+ trainer: Any,
115
+ pl_module: Any,
116
+ outputs: Union[torch.Tensor, Mapping[str, Any], None],
117
+ batch: Dict[str, Any],
118
+ batch_idx: int,
119
+ dataloader_idx: int = 0,
120
+ ) -> None:
121
+ """Run attribution on eligible validation batches on the global-zero rank.
122
+
123
+ Args:
124
+ trainer: the active ``lightning.Trainer``.
125
+ pl_module: the ``LightningModule`` being validated.
126
+ outputs: the module's output for this batch, expected to be the
127
+ raw logits tensor; skipped when ``None`` or not a tensor.
128
+ batch: the validation batch dict, must contain ``"input"``,
129
+ ``"target"``, and ``"key"``.
130
+ batch_idx: index of the current batch within the epoch.
131
+ dataloader_idx: index of the dataloader, for multi-loader setups.
132
+ """
133
+ if (
134
+ not trainer.is_global_zero
135
+ or batch_idx % self._every_n_batches != 0
136
+ or trainer.current_epoch % self.every_n_val_epochs != 0
137
+ ):
138
+ return
139
+ self._run_attribution(trainer, pl_module, outputs, batch, stage="val")
140
+
141
+ def on_test_batch_end(
142
+ self,
143
+ trainer: Any,
144
+ pl_module: Any,
145
+ outputs: Union[torch.Tensor, Mapping[str, Any], None],
146
+ batch: Dict[str, Any],
147
+ batch_idx: int,
148
+ dataloader_idx: int = 0,
149
+ ) -> None:
150
+ """Run attribution on eligible test batches on the global-zero rank.
151
+
152
+ Args:
153
+ trainer: the active ``lightning.Trainer``.
154
+ pl_module: the ``LightningModule`` being tested.
155
+ outputs: the module's output for this batch, expected to be the
156
+ raw logits tensor; skipped when ``None`` or not a tensor.
157
+ batch: the test batch dict, must contain ``"input"``, ``"target"``,
158
+ and ``"key"``.
159
+ batch_idx: index of the current batch within the epoch.
160
+ dataloader_idx: index of the dataloader, for multi-loader setups.
161
+ """
162
+ if (
163
+ not trainer.is_global_zero
164
+ or batch_idx % (self._every_n_test_batches or self._every_n_batches) != 0
165
+ ):
166
+ return
167
+ self._run_attribution(trainer, pl_module, outputs, batch, stage="test")
168
+
169
+ def _run_attribution(
170
+ self,
171
+ trainer: Any,
172
+ pl_module: Any,
173
+ outputs: Union[torch.Tensor, Mapping[str, Any], None],
174
+ batch: Dict[str, Any],
175
+ stage: str,
176
+ ) -> None:
177
+ """Orchestrate: guard → select → batch → attribute → build → save → log."""
178
+ if not _CAPTUM_AVAILABLE:
179
+ return
180
+ log_dir = trainer.log_dir or trainer.default_root_dir
181
+ if log_dir is None:
182
+ return
183
+ if outputs is None or not isinstance(outputs, torch.Tensor):
184
+ return
185
+
186
+ inputs = batch["input"]
187
+ targets_true = batch["target"]
188
+ K = outputs.shape[1]
189
+ preds = outputs.argmax(dim=1)
190
+
191
+ selected = self._select_indices(targets_true, preds, K)
192
+ if not selected:
193
+ return
194
+
195
+ layer = self._resolve_layer(pl_module.net)
196
+ out_dir = self._resolve_out_dir(trainer.log_dir or trainer.default_root_dir)
197
+ inputs_rep, targets_rep = self._prepare_batched_inputs(inputs, selected, K)
198
+
199
+ was_training = pl_module.net.training
200
+ pl_module.net.train(mode=False)
201
+ try:
202
+ gc_all, ig_all = self._compute_attributions(
203
+ pl_module.net, layer, inputs_rep, targets_rep
204
+ )
205
+ finally:
206
+ pl_module.net.train(mode=was_training)
207
+
208
+ panels = self._build_panels(
209
+ inputs,
210
+ selected,
211
+ batch["key"],
212
+ targets_true,
213
+ preds,
214
+ gc_all,
215
+ ig_all,
216
+ K,
217
+ trainer.current_epoch,
218
+ stage,
219
+ )
220
+ for panel in panels:
221
+ self._save_panel(panel, out_dir)
222
+ self._log_gallery(
223
+ panels, stage, step=trainer.global_step if stage == "val" else None
224
+ )
225
+
226
+ def _compute_attributions(
227
+ self,
228
+ net: torch.nn.Module,
229
+ layer: torch.nn.Module,
230
+ inputs_rep: torch.Tensor,
231
+ targets_rep: torch.Tensor,
232
+ ) -> Tuple[torch.Tensor, Optional[torch.Tensor]]:
233
+ """Run GradCAM (and IG when enabled) over the batched inputs.
234
+
235
+ Exits ``inference_mode`` and re-enables grad so captum can backprop.
236
+ Clones both tensors before attribution — they may be inference tensors
237
+ when called from the test loop, and captum uses targets inside
238
+ ``torch.gather`` on an autograd-tracked output.
239
+
240
+ Does not toggle ``net.train`` — the caller owns that lifecycle.
241
+
242
+ Args:
243
+ net: the module under attribution.
244
+ layer: target conv layer resolved via ``_resolve_layer``.
245
+ inputs_rep: batched inputs ``[S*K, C, H, W]``.
246
+ targets_rep: per-row target classes ``[S*K]``.
247
+
248
+ Returns:
249
+ ``(gc_all, ig_all)``. ``gc_all`` is ``[S*K, 1, h, w]``;
250
+ ``ig_all`` is ``[S*K, C, H, W]`` or ``None`` when IG is disabled.
251
+ """
252
+ gc = LayerGradCam(net, layer)
253
+ ig = IntegratedGradients(net) if self.ig_n_steps is not None else None
254
+
255
+ with torch.inference_mode(False), torch.enable_grad():
256
+ inputs_attr = inputs_rep.clone()
257
+ targets_attr = targets_rep.clone()
258
+ gc_all = gc.attribute(inputs_attr, target=targets_attr) # [S*K, 1, h, w]
259
+ ig_all = (
260
+ ig.attribute(
261
+ inputs_attr, target=targets_attr, n_steps=self.ig_n_steps
262
+ ) # [S*K, C, H, W]
263
+ if ig is not None
264
+ else None
265
+ )
266
+
267
+ return gc_all, ig_all
268
+
269
+ def _resolve_layer(self, net: torch.nn.Module) -> torch.nn.Module:
270
+ """Resolve ``self.target_layer`` dot-path against ``net``."""
271
+ return functools.reduce(getattr, self.target_layer.split("."), net)
272
+
273
+ def _resolve_out_dir(self, log_dir: str) -> Path:
274
+ p = Path(log_dir) / self.output_subdir
275
+ p.mkdir(parents=True, exist_ok=True)
276
+ return p
277
+
278
+ def _select_indices(
279
+ self,
280
+ targets_true: torch.Tensor,
281
+ preds: torch.Tensor,
282
+ K: int,
283
+ ) -> List[int]:
284
+ """Return one batch index per class, preferring misclassified samples.
285
+
286
+ Args:
287
+ targets_true: ground-truth class indices, shape ``[N]``.
288
+ preds: predicted class indices (``outputs.argmax``), shape ``[N]``.
289
+ K: total number of classes (``outputs.shape[1]``).
290
+
291
+ Returns:
292
+ List of at most ``K`` indices into the batch (one per present class).
293
+ Classes absent from this batch are skipped.
294
+ """
295
+ selected: List[int] = []
296
+ for c in range(K):
297
+ in_class = (targets_true == c).nonzero(as_tuple=True)[0]
298
+ if in_class.numel() == 0:
299
+ continue
300
+ # prefer first misclassified; fall back to first correct
301
+ wrong = [idx.item() for idx in in_class if preds[idx] != c]
302
+ selected.append(int(wrong[0] if wrong else in_class[0].item()))
303
+ return selected
304
+
305
+ def _prepare_batched_inputs(
306
+ self,
307
+ inputs: torch.Tensor,
308
+ selected: List[int],
309
+ K: int,
310
+ ) -> Tuple[torch.Tensor, torch.Tensor]:
311
+ """Build the captum batch: each selected sample repeated once per class.
312
+
313
+ Args:
314
+ inputs: full batch inputs ``[N, C, H, W]``.
315
+ selected: batch indices chosen by ``_select_indices``.
316
+ K: number of target classes.
317
+
318
+ Returns:
319
+ ``(inputs_rep, targets_rep)`` where ``inputs_rep`` is
320
+ ``[S*K, C, H, W]`` (each sample repeat-interleaved K times) and
321
+ ``targets_rep`` is ``[S*K]`` cycling ``range(K)`` per sample.
322
+ """
323
+ inputs_rep = inputs[selected].repeat_interleave(K, dim=0)
324
+ targets_rep = torch.arange(K, device=inputs.device).repeat(len(selected))
325
+ return inputs_rep, targets_rep
326
+
327
+ def _denorm_input(self, x: torch.Tensor) -> torch.Tensor:
328
+ """Min-max normalise [1,C,H,W] input to [3,H,W] float32 in [0,1]."""
329
+ t = x.detach().cpu().float()[0] # [C, H, W]
330
+ if t.shape[0] == 1:
331
+ t = t.repeat(3, 1, 1)
332
+ lo, hi = t.min(), t.max()
333
+ if (hi - lo).abs() > 1e-8:
334
+ t = (t - lo) / (hi - lo)
335
+ return t
336
+
337
+ @torch.no_grad()
338
+ def _attr_to_2d(self, attr: torch.Tensor, H: int, W: int) -> torch.Tensor:
339
+ """Reduce [K,C,h,w] attribution to [K,1,H,W] normalised per-map in [0,1].
340
+
341
+ Handles both low-res GradCAM maps (upsampled) and full-res IG maps
342
+ (channel-reduced via absolute mean).
343
+ """
344
+ import torch.nn.functional as F
345
+
346
+ a = attr.detach().cpu().float()
347
+ a = a.abs().mean(dim=1, keepdim=True) if a.shape[1] > 1 else a.abs()
348
+ if a.shape[2] != H or a.shape[3] != W:
349
+ a = F.interpolate(a, size=(H, W), mode="bilinear", align_corners=False)
350
+ flat = a.view(a.shape[0], -1)
351
+ lo = flat.min(dim=1).values.view(-1, 1, 1, 1)
352
+ hi = flat.max(dim=1).values.view(-1, 1, 1, 1)
353
+ return torch.where((hi - lo).abs() > 1e-8, (a - lo) / (hi - lo), a) # [K,1,H,W]
354
+
355
+ @torch.no_grad()
356
+ def _heatmap(self, attr_2d: torch.Tensor) -> torch.Tensor:
357
+ """Apply jet colormap to [K,1,H,W] attribution, return [K,3,H,W] in [0,1]."""
358
+ import numpy as np
359
+
360
+ a = attr_2d[:, 0].numpy() # [K, H, W]
361
+ try:
362
+ from matplotlib import colormaps # type: ignore[import-untyped]
363
+
364
+ rgb = colormaps["jet"](a)[..., :3].astype("float32") # [K, H, W, 3]
365
+ except Exception:
366
+ rgb = np.stack([a, np.zeros_like(a), 1.0 - a], axis=-1).astype("float32")
367
+ return torch.from_numpy(rgb).permute(0, 3, 1, 2) # [K, 3, H, W]
368
+
369
+ def _overlay(
370
+ self, base: torch.Tensor, heat: torch.Tensor, alpha: float = 0.5
371
+ ) -> torch.Tensor:
372
+ """Alpha-blend heatmap over base image — both [K,3,H,W] or [3,H,W]."""
373
+ return (alpha * heat + (1.0 - alpha) * base).clamp(0.0, 1.0)
374
+
375
+ def _annotate(
376
+ self,
377
+ img: torch.Tensor,
378
+ text: str,
379
+ color: Tuple[int, int, int] = (255, 255, 255),
380
+ ) -> torch.Tensor:
381
+ """Burn ``text`` onto a ``[3, H, W]`` float32 tensor in [0,1].
382
+
383
+ Draws a 1-px black shadow then the text in ``color`` for legibility on
384
+ any background. Falls back to the unmodified image when PIL is absent.
385
+ """
386
+ try:
387
+ import numpy as np
388
+ from PIL import Image, ImageDraw # type: ignore[import-untyped]
389
+
390
+ arr = (img.permute(1, 2, 0).numpy() * 255).astype("uint8")
391
+ pil = Image.fromarray(arr)
392
+ draw = ImageDraw.Draw(pil)
393
+ draw.text((2, 2), text, fill=(0, 0, 0))
394
+ draw.text((1, 1), text, fill=color)
395
+ return torch.from_numpy(np.array(pil)).permute(2, 0, 1).float() / 255.0
396
+ except Exception:
397
+ return img
398
+
399
+ def _colorbar(self, H: int, W: int = 32) -> torch.Tensor:
400
+ """Vertical jet colorbar strip ``[3, H, W]`` mapping 1→0 (top→bottom).
401
+
402
+ Annotates "1.0" at the top and "0.0" at the bottom. Falls back to a
403
+ simple red→blue ramp when matplotlib is absent; skips text when PIL is absent.
404
+ """
405
+ import numpy as np
406
+
407
+ vals = np.linspace(1.0, 0.0, H, dtype="float32")
408
+ try:
409
+ from matplotlib import colormaps # type: ignore[import-untyped]
410
+
411
+ rgb = colormaps["jet"](vals)[:, :3] # [H, 3]
412
+ except Exception:
413
+ rgb = np.stack([vals, np.zeros(H, "float32"), 1.0 - vals], axis=-1)
414
+ strip = np.broadcast_to(rgb[:, None, :], (H, W, 3)).copy()
415
+ tensor = torch.from_numpy(strip).permute(2, 0, 1) # [3, H, W]
416
+ try:
417
+ from PIL import Image, ImageDraw # type: ignore[import-untyped]
418
+
419
+ arr = (tensor.permute(1, 2, 0).numpy() * 255).astype("uint8")
420
+ pil = Image.fromarray(arr)
421
+ draw = ImageDraw.Draw(pil)
422
+ for y, label in [(1, "1.0"), (H - 10, "0.0")]:
423
+ draw.text((1, y), label, fill=(0, 0, 0))
424
+ draw.text((0, y - 1), label, fill=(255, 255, 255))
425
+ tensor = torch.from_numpy(np.array(pil)).permute(2, 0, 1).float() / 255.0
426
+ except Exception:
427
+ pass
428
+ return tensor
429
+
430
+ @torch.no_grad()
431
+ def _build_sample_image(
432
+ self,
433
+ x: torch.Tensor,
434
+ gc_K: torch.Tensor,
435
+ tc: int,
436
+ pc: int,
437
+ ig_K: Optional[torch.Tensor] = None,
438
+ ) -> torch.Tensor:
439
+ """Build a single-row panel for one sample.
440
+
441
+ Layout: ``[orig | GC-ov×K | (IG-ov×K if enabled)]`` + colorbar strip.
442
+ The original is shown once (annotated with true/pred); each overlay is
443
+ annotated with its target class. Correct predictions use green text,
444
+ wrong predictions use red.
445
+
446
+ Args:
447
+ x: original input ``[1, C, H, W]``.
448
+ gc_K: GradCAM attributions for all K target classes ``[K, 1, h, w]``.
449
+ tc: ground-truth class index for this sample.
450
+ pc: predicted class index for this sample.
451
+ ig_K: optional IG attributions ``[K, C, H, W]``; omitted when IG is disabled.
452
+
453
+ Returns:
454
+ Grid ``[3, H+pad, N*(W+pad)]`` — single row, N = 1 + K (+ K when IG enabled).
455
+ """
456
+ H, W = x.shape[-2], x.shape[-1]
457
+ K = gc_K.shape[0]
458
+ orig = self._denorm_input(x) # [3, H, W]
459
+
460
+ pred_color = (100, 220, 100) if tc == pc else (220, 100, 100)
461
+ orig_ann = self._annotate(orig, f"T:{tc} P:{pc}", color=pred_color)
462
+
463
+ gc_2d = self._attr_to_2d(gc_K, H, W) # [K, 1, H, W]
464
+ gc_heat = self._heatmap(gc_2d) # [K, 3, H, W]
465
+ orig_rep = orig.unsqueeze(0).expand(K, -1, -1, -1) # [K, 3, H, W]
466
+ gc_ov = self._overlay(orig_rep, gc_heat) # [K, 3, H, W]
467
+ gc_tiles = [self._annotate(gc_ov[k], f"GC→c{k}") for k in range(K)]
468
+
469
+ tiles: List[torch.Tensor] = [orig_ann] + gc_tiles
470
+
471
+ if ig_K is not None:
472
+ ig_2d = self._attr_to_2d(ig_K, H, W)
473
+ ig_heat = self._heatmap(ig_2d)
474
+ ig_ov = self._overlay(orig_rep, ig_heat) # [K, 3, H, W]
475
+ tiles += [self._annotate(ig_ov[k], f"IG→c{k}") for k in range(K)]
476
+
477
+ grid = vutils.make_grid(torch.stack(tiles), nrow=len(tiles), padding=2)
478
+ legend = self._colorbar(grid.shape[1]) # [3, H_grid, 20]
479
+ return torch.cat([grid, legend], dim=2)
480
+
481
+ def _build_panels(
482
+ self,
483
+ inputs: torch.Tensor,
484
+ selected: List[int],
485
+ keys: List[str],
486
+ targets_true: torch.Tensor,
487
+ preds: torch.Tensor,
488
+ gc_all: torch.Tensor,
489
+ ig_all: Optional[torch.Tensor],
490
+ K: int,
491
+ epoch: int,
492
+ stage: str,
493
+ ) -> List[_Panel]:
494
+ """Assemble one ``_Panel`` per selected sample.
495
+
496
+ Slices the batched attributions back into per-sample K-blocks, renders
497
+ each panel via ``_build_sample_image``, and computes the deterministic
498
+ filename and caption from epoch / key / true / pred.
499
+
500
+ Args:
501
+ inputs: full batch inputs ``[N, C, H, W]``.
502
+ selected: batch indices from ``_select_indices``.
503
+ keys: dataset keys for all batch samples.
504
+ targets_true: ground-truth class indices ``[N]``.
505
+ preds: predicted class indices ``[N]``.
506
+ gc_all: GradCAM attributions ``[S*K, 1, h, w]``.
507
+ ig_all: IG attributions ``[S*K, C, H, W]`` or ``None``.
508
+ K: number of target classes.
509
+ epoch: current training epoch (for filenames / captions).
510
+ stage: ``"val"`` or ``"test"``.
511
+
512
+ Returns:
513
+ Panels in ``selected`` order.
514
+ """
515
+ panels: List[_Panel] = []
516
+ for s, idx in enumerate(selected):
517
+ gc_K = gc_all[s * K : (s + 1) * K]
518
+ ig_K = ig_all[s * K : (s + 1) * K] if ig_all is not None else None
519
+ key = keys[idx]
520
+ tc = int(targets_true[idx].item())
521
+ pc = int(preds[idx].item())
522
+ panels.append(
523
+ _Panel(
524
+ key=key,
525
+ image=self._build_sample_image(
526
+ inputs[idx : idx + 1], gc_K, tc, pc, ig_K
527
+ ),
528
+ caption=f"ep{epoch:03d} key={key} true={tc} pred={pc}",
529
+ filename=f"{stage}-ep{epoch:03d}-key-{key}-true{tc}-pred{pc}.png",
530
+ )
531
+ )
532
+ return panels
533
+
534
+ def _save_panel(self, panel: _Panel, out_dir: Path) -> None:
535
+ """Write one panel PNG to ``out_dir`` using its deterministic filename.
536
+
537
+ No-op when ``self._save_to_file`` is ``False``. Overwrites on rerun.
538
+ """
539
+ if self._save_to_file:
540
+ vutils.save_image(panel.image, str(out_dir / panel.filename))
541
+
542
+ def _log_gallery(
543
+ self,
544
+ panels: List[_Panel],
545
+ stage: str,
546
+ step: Optional[int] = None,
547
+ ) -> None:
548
+ """Log panels as a gallery list AND as per-sample keys in one ``wandb.log`` call.
549
+
550
+ ``attributions/{stage}`` holds a list of all images → cross-image comparison
551
+ at a given step. ``attributions/{stage}/{key}`` holds each image individually
552
+ → cross-step timeline tracking per sample.
553
+
554
+ Validation passes an explicit ``step`` to tie images to the training
555
+ global step. Test omits it — ``global_step`` is frozen at the last
556
+ training value and would trigger W&B's monotonicity warning.
557
+ """
558
+ try:
559
+ import numpy as np
560
+
561
+ images = [
562
+ wandb.Image(
563
+ (p.image.permute(1, 2, 0).numpy() * 255).astype(np.uint8),
564
+ caption=p.caption,
565
+ )
566
+ for p in panels
567
+ ]
568
+ payload: Dict[str, Any] = {f"attributions/{stage}": images}
569
+ payload.update(
570
+ {f"attributions/{stage}/{p.key}": img for p, img in zip(panels, images)}
571
+ )
572
+ if step is not None:
573
+ wandb.log(payload, step=step)
574
+ else:
575
+ wandb.log(payload)
576
+ except Exception:
577
+ pass