Skip to content

Commit 37b81ff

Browse files
Soorya19Pradeepclaude
authored andcommitted
Add visualization script for TripletDataModule scale-aware rescaling
Plots n cell patches side-by-side: - Left: raw patch at initial_yx_patch_size (larger physical area) - Right: same patch bilinearly downscaled to final_yx_patch_size (model input) Both columns share the same percentile contrast window so differences are spatial. Physical scale (µm × µm) is shown in each panel title. Usage: python visualize_triplet_rescaling.py --data-path /path/to/data.zarr --tracks-path /path/to/tracks --source-channel Phase3D --z-range 0 5 --final-yx-patch-size 224 224 --reference-pixel-size 0.325 --output rescaling_comparison.png Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 8dfcbb3 commit 37b81ff

1 file changed

Lines changed: 253 additions & 0 deletions

File tree

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
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

Comments
 (0)