|
| 1 | +r"""Visualize scale-aware patch rescaling in TripletDataModule. |
| 2 | +
|
| 3 | +Loads a few cell patches from an OME-Zarr dataset using TripletDataModule |
| 4 | +with ``reference_pixel_size`` set, then plots each patch in two columns: |
| 5 | +
|
| 6 | + Left — raw patch at ``initial_yx_patch_size`` (larger physical area sampled |
| 7 | + to match the reference pixel size) |
| 8 | + Right — the same patch bilinearly downscaled to ``final_yx_patch_size`` |
| 9 | + (what the model actually receives) |
| 10 | +
|
| 11 | +Both columns use the same percentile-based grayscale contrast window so |
| 12 | +that spatial content differences are visible rather than intensity shifts. |
| 13 | +A physical-scale annotation (µm × µm) is printed below each patch. |
| 14 | +
|
| 15 | +Usage:: |
| 16 | +
|
| 17 | + python visualize_triplet_rescaling.py \\ |
| 18 | + --data-path /path/to/data.zarr \\ |
| 19 | + --tracks-path /path/to/tracks \\ |
| 20 | + --source-channel Phase3D \\ |
| 21 | + --z-range 0 5 \\ |
| 22 | + --final-yx-patch-size 224 224 \\ |
| 23 | + --reference-pixel-size 0.325 \\ |
| 24 | + --n-samples 6 \\ |
| 25 | + --output rescaling_comparison.png |
| 26 | +""" |
| 27 | + |
| 28 | +import argparse |
| 29 | +from pathlib import Path |
| 30 | + |
| 31 | +import matplotlib.pyplot as plt |
| 32 | +import numpy as np |
| 33 | +import torch |
| 34 | + |
| 35 | +from viscy_data.triplet import TripletDataModule, _read_pixel_size |
| 36 | + |
| 37 | +# ── CLI ─────────────────────────────────────────────────────────────────────── |
| 38 | + |
| 39 | + |
| 40 | +def parse_args() -> argparse.Namespace: |
| 41 | + """Parse command-line arguments.""" |
| 42 | + p = argparse.ArgumentParser( |
| 43 | + description="Visualize TripletDataModule scale-aware patch rescaling.", |
| 44 | + formatter_class=argparse.ArgumentDefaultsHelpFormatter, |
| 45 | + ) |
| 46 | + p.add_argument("--data-path", required=True, help="Path to OME-Zarr plate/position.") |
| 47 | + p.add_argument("--tracks-path", required=True, help="Path to tracks CSV directory.") |
| 48 | + p.add_argument( |
| 49 | + "--source-channel", |
| 50 | + required=True, |
| 51 | + nargs="+", |
| 52 | + help="Channel name(s) to load (e.g. Phase3D).", |
| 53 | + ) |
| 54 | + p.add_argument( |
| 55 | + "--z-range", |
| 56 | + required=True, |
| 57 | + type=int, |
| 58 | + nargs=2, |
| 59 | + metavar=("Z_START", "Z_STOP"), |
| 60 | + help="Z-slice range [start, stop).", |
| 61 | + ) |
| 62 | + p.add_argument( |
| 63 | + "--final-yx-patch-size", |
| 64 | + type=int, |
| 65 | + nargs=2, |
| 66 | + default=[224, 224], |
| 67 | + metavar=("Y", "X"), |
| 68 | + help="Target patch size fed to the model (pixels).", |
| 69 | + ) |
| 70 | + p.add_argument( |
| 71 | + "--reference-pixel-size", |
| 72 | + type=float, |
| 73 | + required=True, |
| 74 | + help="X pixel size (µm/px) of the model's training dataset.", |
| 75 | + ) |
| 76 | + p.add_argument( |
| 77 | + "--n-samples", |
| 78 | + type=int, |
| 79 | + default=6, |
| 80 | + help="Number of cell patches to visualize.", |
| 81 | + ) |
| 82 | + p.add_argument( |
| 83 | + "--z-slice", |
| 84 | + type=int, |
| 85 | + default=None, |
| 86 | + help="Z index within the patch to display. Defaults to middle slice.", |
| 87 | + ) |
| 88 | + p.add_argument( |
| 89 | + "--output", |
| 90 | + type=Path, |
| 91 | + default=Path("rescaling_comparison.png"), |
| 92 | + help="Output PNG path.", |
| 93 | + ) |
| 94 | + return p.parse_args() |
| 95 | + |
| 96 | + |
| 97 | +# ── Helpers ─────────────────────────────────────────────────────────────────── |
| 98 | + |
| 99 | + |
| 100 | +def _percentile_norm(img: np.ndarray, lo: float = 1.0, hi: float = 99.0): |
| 101 | + """Return (vmin, vmax) for percentile-based display.""" |
| 102 | + vmin, vmax = np.percentile(img, [lo, hi]) |
| 103 | + if vmax <= vmin: |
| 104 | + vmax = vmin + 1.0 |
| 105 | + return float(vmin), float(vmax) |
| 106 | + |
| 107 | + |
| 108 | +def _rescale_yx( |
| 109 | + patch: torch.Tensor, |
| 110 | + target_yx: tuple[int, int], |
| 111 | +) -> torch.Tensor: |
| 112 | + """Bilinear-rescale YX of a (C, Z, H, W) patch to target_yx.""" |
| 113 | + c, z, h, w = patch.shape |
| 114 | + flat = patch.reshape(c * z, 1, h, w).float() |
| 115 | + out = torch.nn.functional.interpolate( |
| 116 | + flat, |
| 117 | + size=target_yx, |
| 118 | + mode="bilinear", |
| 119 | + align_corners=False, |
| 120 | + antialias=True, |
| 121 | + ) |
| 122 | + return out.reshape(c, z, *target_yx) |
| 123 | + |
| 124 | + |
| 125 | +def _mid_slice(patch: torch.Tensor, z_idx: int | None) -> np.ndarray: |
| 126 | + """Return a 2-D (H, W) numpy array from (C, Z, H, W), channel 0, given z.""" |
| 127 | + z_size = patch.shape[1] |
| 128 | + z = z_idx if z_idx is not None else z_size // 2 |
| 129 | + z = max(0, min(z, z_size - 1)) |
| 130 | + return patch[0, z].numpy() |
| 131 | + |
| 132 | + |
| 133 | +# ── Main ────────────────────────────────────────────────────────────────────── |
| 134 | + |
| 135 | + |
| 136 | +def main(): |
| 137 | + """Run the visualization CLI.""" |
| 138 | + args = parse_args() |
| 139 | + |
| 140 | + final_yx = tuple(args.final_yx_patch_size) |
| 141 | + z_range = tuple(args.z_range) |
| 142 | + |
| 143 | + # Build the data module with scale-aware rescaling enabled. |
| 144 | + dm = TripletDataModule( |
| 145 | + data_path=args.data_path, |
| 146 | + tracks_path=args.tracks_path, |
| 147 | + source_channel=args.source_channel, |
| 148 | + z_range=z_range, |
| 149 | + final_yx_patch_size=final_yx, |
| 150 | + reference_pixel_size=args.reference_pixel_size, |
| 151 | + batch_size=args.n_samples, |
| 152 | + num_workers=0, |
| 153 | + ) |
| 154 | + dm.setup("predict") |
| 155 | + |
| 156 | + inference_pixel_size = _read_pixel_size(args.data_path) |
| 157 | + initial_yx = dm.initial_yx_patch_size |
| 158 | + scale = args.reference_pixel_size / inference_pixel_size |
| 159 | + |
| 160 | + print( |
| 161 | + f"Reference pixel size : {args.reference_pixel_size:.4f} µm/px\n" |
| 162 | + f"Inference pixel size : {inference_pixel_size:.4f} µm/px\n" |
| 163 | + f"Scale factor : {scale:.4f}\n" |
| 164 | + f"initial_yx_patch_size: {initial_yx}\n" |
| 165 | + f"final_yx_patch_size : {final_yx}\n" |
| 166 | + ) |
| 167 | + |
| 168 | + # Draw samples directly from the dataset (raw, before any transforms). |
| 169 | + n = min(args.n_samples, len(dm.predict_dataset)) |
| 170 | + raw_batch = dm.predict_dataset.__getitems__(list(range(n))) |
| 171 | + raw_patches = raw_batch["anchor"] # (B, C, Z, initial_Y, initial_X) |
| 172 | + |
| 173 | + # ── Plot ────────────────────────────────────────────────────────────────── |
| 174 | + fig, axes = plt.subplots( |
| 175 | + n, |
| 176 | + 2, |
| 177 | + figsize=(8, 4 * n), |
| 178 | + squeeze=False, |
| 179 | + ) |
| 180 | + |
| 181 | + for i in range(n): |
| 182 | + raw = raw_patches[i] # (C, Z, initial_Y, initial_X) |
| 183 | + rescaled = _rescale_yx(raw, final_yx) # (C, Z, final_Y, final_X) |
| 184 | + |
| 185 | + raw_2d = _mid_slice(raw, args.z_slice) |
| 186 | + rescaled_2d = _mid_slice(rescaled, args.z_slice) |
| 187 | + |
| 188 | + # Shared contrast from the raw patch so differences are spatial only. |
| 189 | + vmin, vmax = _percentile_norm(raw_2d) |
| 190 | + |
| 191 | + phys_raw_y = initial_yx[0] * inference_pixel_size |
| 192 | + phys_raw_x = initial_yx[1] * inference_pixel_size |
| 193 | + phys_final_y = final_yx[0] * args.reference_pixel_size |
| 194 | + phys_final_x = final_yx[1] * args.reference_pixel_size |
| 195 | + |
| 196 | + # Left column: raw patch |
| 197 | + ax = axes[i, 0] |
| 198 | + ax.imshow(raw_2d, cmap="gray", vmin=vmin, vmax=vmax, interpolation="nearest") |
| 199 | + ax.set_title( |
| 200 | + f"Sample {i} — raw patch\n{initial_yx[0]}×{initial_yx[1]} px ({phys_raw_y:.1f}×{phys_raw_x:.1f} µm)", |
| 201 | + fontsize=9, |
| 202 | + ) |
| 203 | + ax.axis("off") |
| 204 | + |
| 205 | + # Right column: rescaled patch |
| 206 | + ax = axes[i, 1] |
| 207 | + ax.imshow(rescaled_2d, cmap="gray", vmin=vmin, vmax=vmax, interpolation="nearest") |
| 208 | + ax.set_title( |
| 209 | + f"Sample {i} — rescaled (model input)\n" |
| 210 | + f"{final_yx[0]}×{final_yx[1]} px " |
| 211 | + f"({phys_final_y:.1f}×{phys_final_x:.1f} µm)", |
| 212 | + fontsize=9, |
| 213 | + ) |
| 214 | + ax.axis("off") |
| 215 | + |
| 216 | + axes[0, 0].annotate( |
| 217 | + "RAW (initial_yx_patch_size)", |
| 218 | + xy=(0.5, 1.12), |
| 219 | + xycoords="axes fraction", |
| 220 | + ha="center", |
| 221 | + fontsize=11, |
| 222 | + fontweight="bold", |
| 223 | + color="#e74c3c", |
| 224 | + ) |
| 225 | + axes[0, 1].annotate( |
| 226 | + "RESCALED (final_yx_patch_size)", |
| 227 | + xy=(0.5, 1.12), |
| 228 | + xycoords="axes fraction", |
| 229 | + ha="center", |
| 230 | + fontsize=11, |
| 231 | + fontweight="bold", |
| 232 | + color="#2ecc71", |
| 233 | + ) |
| 234 | + |
| 235 | + fig.suptitle( |
| 236 | + f"Scale-aware patch rescaling\n" |
| 237 | + f"reference={args.reference_pixel_size} µm/px · " |
| 238 | + f"inference={inference_pixel_size:.4f} µm/px · " |
| 239 | + f"scale={scale:.3f}", |
| 240 | + fontsize=12, |
| 241 | + fontweight="bold", |
| 242 | + y=1.01, |
| 243 | + ) |
| 244 | + |
| 245 | + fig.tight_layout() |
| 246 | + args.output.parent.mkdir(parents=True, exist_ok=True) |
| 247 | + fig.savefig(args.output, dpi=150, bbox_inches="tight") |
| 248 | + print(f"Saved: {args.output}") |
| 249 | + plt.close(fig) |
| 250 | + |
| 251 | + |
| 252 | +if __name__ == "__main__": |
| 253 | + main() |
0 commit comments