Skip to content

Commit 51adba5

Browse files
authored
Merge pull request #144 from khanlab/copilot/update-zoom-montage-outputs
feat: improve zoom montage QC PNGs — better mask color, no-mask output, n4 bg option
2 parents 79f062a + b70055f commit 51adba5

4 files changed

Lines changed: 167 additions & 4 deletions

File tree

spimquant/config/snakebids.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,11 @@ parse_args:
184184
- distributed
185185
default: distributed
186186

187+
--qc_roi_zoom_bg_n4:
188+
help: "Use n4 bias-field corrected OME-Zarr as background image in the ROI zoom montage QC figures instead of the raw SPIM (only effective when --correction_method n4) (default: %(default)s)"
189+
action: store_true
190+
default: False
191+
187192
--sloppy:
188193
help: "Use low-quality parameters for speed (USE FOR TESTING ONLY)"
189194
action: store_true

spimquant/workflow/Snakefile

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -682,6 +682,25 @@ rule all_qc:
682682
)
683683
if do_seg
684684
else [],
685+
# Segmentation ROI zoom montage - no mask overlay
686+
inputs["spim"].expand(
687+
bids(
688+
root=root,
689+
datatype="qc",
690+
seg="{seg}",
691+
from_="{template}",
692+
stain="{stain}",
693+
desc="{desc}nomask",
694+
suffix="roimontage.png",
695+
**inputs["spim"].wildcards,
696+
),
697+
seg=atlas_segs,
698+
template=config["template"],
699+
stain=stains_for_seg,
700+
desc=config["seg_method"],
701+
)
702+
if do_seg
703+
else [],
685704
# Vessel overview figures
686705
inputs["spim"].expand(
687706
bids(
@@ -716,6 +735,25 @@ rule all_qc:
716735
)
717736
if do_vessels
718737
else [],
738+
# Vessel ROI zoom montage - no mask overlay
739+
inputs["spim"].expand(
740+
bids(
741+
root=root,
742+
datatype="qc",
743+
seg="{seg}",
744+
from_="{template}",
745+
stain="{stain}",
746+
desc="{desc}nomask",
747+
suffix="vesselroimontage.png",
748+
**inputs["spim"].wildcards,
749+
),
750+
seg=atlas_segs,
751+
template=config["template"],
752+
stain=stain_for_vessels,
753+
desc=config["vessel_seg_method"],
754+
)
755+
if do_vessels
756+
else [],
719757
# Z-profile QC (per stain, per seg method)
720758
inputs["spim"].expand(
721759
bids(

spimquant/workflow/rules/qc.smk

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,13 @@ Rules 2 and the ROI zoom are also generated for vessel segmentations.
2727
All outputs are written to the ``qc`` datatype directory for each subject.
2828
"""
2929

30+
# Whether to use the n4-corrected OME-Zarr as the background in zoom montage QC.
31+
# Enabled via the --qc_roi_zoom_bg_n4 CLI option (only meaningful when
32+
# correction_method=="n4").
33+
_use_n4_bg = config.get("qc_roi_zoom_bg_n4", False) and (
34+
config.get("correction_method") == "n4"
35+
)
36+
3037

3138
rule qc_intensity_histogram:
3239
"""Per-channel intensity histogram QC.
@@ -147,8 +154,26 @@ region's bounding box (in subject space) and displays the best axial slice
147154
with the field-fraction overlay. Aspect ratio is corrected from NIfTI
148155
voxel dimensions. Provides detail-level visualisation of segmentation
149156
quality within individual brain regions.
157+
158+
Two PNGs are produced: one with the mask overlay (``roimontage.png``) and
159+
one without (``desc-{desc}nomask_roimontage.png``).
150160
"""
151161
input:
162+
**(
163+
{
164+
"spim_n4": bids(
165+
root=work,
166+
datatype="seg",
167+
stain="{stain}",
168+
level=str(config["segmentation_level"]),
169+
desc="correctedn4",
170+
suffix="SPIM.ome.zarr",
171+
**inputs["spim"].wildcards,
172+
)
173+
}
174+
if _use_n4_bg
175+
else {}
176+
),
152177
spim=inputs["spim"].path,
153178
mask=bids(
154179
root=root,
@@ -185,6 +210,16 @@ quality within individual brain regions.
185210
suffix="roimontage.png",
186211
**inputs["spim"].wildcards,
187212
),
213+
png_nomask=bids(
214+
root=root,
215+
datatype="qc",
216+
seg="{seg}",
217+
from_="{template}",
218+
stain="{stain}",
219+
desc="{desc}nomask",
220+
suffix="roimontage.png",
221+
**inputs["spim"].wildcards,
222+
),
188223
threads: 4
189224
resources:
190225
mem_mb=32000,
@@ -194,6 +229,7 @@ quality within individual brain regions.
194229
n_cols=lambda wildcards: 5 if wildcards.seg == "coarse" else 10,
195230
patch_size=lambda wildcards: 2000 if wildcards.seg == "coarse" else 500,
196231
level=config["segmentation_level"],
232+
use_n4_bg=_use_n4_bg,
197233
script:
198234
"../scripts/qc_segmentation_roi_zoom.py"
199235

@@ -204,8 +240,26 @@ rule qc_vessels_roi_zoom:
204240
Identical to ``qc_segmentation_roi_zoom`` but applied to the vessel
205241
binary mask. Uses ZarrNii to load full-resolution data and
206242
ZarrNiiAtlas for atlas-based ROI cropping.
243+
244+
Two PNGs are produced: one with the mask overlay (``vesselroimontage.png``)
245+
and one without (``desc-{desc}nomask_vesselroimontage.png``).
207246
"""
208247
input:
248+
**(
249+
{
250+
"spim_n4": bids(
251+
root=work,
252+
datatype="seg",
253+
stain="{stain}",
254+
level=str(config["segmentation_level"]),
255+
desc="correctedn4",
256+
suffix="SPIM.ome.zarr",
257+
**inputs["spim"].wildcards,
258+
)
259+
}
260+
if _use_n4_bg
261+
else {}
262+
),
209263
spim=inputs["spim"].path,
210264
mask=bids(
211265
root=root,
@@ -242,6 +296,16 @@ ZarrNiiAtlas for atlas-based ROI cropping.
242296
suffix="vesselroimontage.png",
243297
**inputs["spim"].wildcards,
244298
),
299+
png_nomask=bids(
300+
root=root,
301+
datatype="qc",
302+
seg="{seg}",
303+
from_="{template}",
304+
stain="{stain}",
305+
desc="{desc}nomask",
306+
suffix="vesselroimontage.png",
307+
**inputs["spim"].wildcards,
308+
),
245309
threads: 4
246310
resources:
247311
mem_mb=32000,
@@ -251,6 +315,7 @@ ZarrNiiAtlas for atlas-based ROI cropping.
251315
n_cols=lambda wildcards: 5 if wildcards.seg == "coarse" else 10,
252316
patch_size=lambda wildcards: 2000 if wildcards.seg == "coarse" else 500,
253317
level=config["segmentation_level"],
318+
use_n4_bg=_use_n4_bg,
254319
script:
255320
"../scripts/qc_segmentation_roi_zoom.py"
256321

spimquant/workflow/scripts/qc_segmentation_roi_zoom.py

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,11 @@ def main():
4848
subject = snakemake.wildcards.subject
4949
max_rois = snakemake.params.max_rois
5050
n_cols = snakemake.params.n_cols
51+
use_n4_bg = snakemake.params.get("use_n4_bg", False)
5152

53+
spim_bg_path = snakemake.input.spim_n4 if use_n4_bg else snakemake.input.spim
5254
spim_img = ZarrNii.from_ome_zarr(
53-
snakemake.input.spim,
55+
spim_bg_path,
5456
level=snakemake.params.level,
5557
downsample_near_isotropic=True,
5658
channel_labels=[snakemake.wildcards.stain],
@@ -66,7 +68,7 @@ def main():
6668
aspect_axial = 1
6769

6870
spim_img_ds = ZarrNii.from_ome_zarr(
69-
snakemake.input.spim,
71+
spim_bg_path,
7072
level=(int(snakemake.params.level) + 5),
7173
downsample_near_isotropic=True,
7274
channel_labels=[snakemake.wildcards.stain],
@@ -103,7 +105,22 @@ def main():
103105
color="gray",
104106
)
105107
ax.axis("off")
106-
plt.savefig(snakemake.output.png, dpi=120, bbox_inches="tight")
108+
plt.savefig(snakemake.output.png, dpi=150, bbox_inches="tight")
109+
plt.close()
110+
# Also save the no-mask version (same empty figure)
111+
fig, ax = plt.subplots(figsize=(18, 12))
112+
ax.text(
113+
0.5,
114+
0.5,
115+
"No atlas ROIs found in subject",
116+
ha="center",
117+
va="center",
118+
transform=ax.transAxes,
119+
fontsize=12,
120+
color="gray",
121+
)
122+
ax.axis("off")
123+
plt.savefig(snakemake.output.png_nomask, dpi=150, bbox_inches="tight")
107124
plt.close()
108125
return
109126

@@ -126,6 +143,8 @@ def main():
126143
elif n_cols == 1:
127144
axes = axes[:, np.newaxis]
128145

146+
# Cache crops for both masked and no-mask figures
147+
cached_slices = []
129148
for i, row in enumerate(roi_rows):
130149
ax_row = i // n_cols
131150
ax_col = i % n_cols
@@ -149,11 +168,12 @@ def main():
149168
spim_sl = spim_crop.data[0, :, :].squeeze().compute()
150169
spim_sl = _apply_fixed_percentile_norm(spim_sl, glob_lo, glob_hi)
151170
mask_sl = mask_crop.data[0, :, :].squeeze().compute()
171+
cached_slices.append((label_name, spim_sl, mask_sl))
152172

153173
ax.imshow(spim_sl, cmap="gray")
154174
mask_masked = np.ma.masked_where(mask_sl < 100, mask_sl)
155175
ax.imshow(
156-
mask_masked, cmap="spring", alpha=0.6, vmin=0, vmax=100, aspect=aspect_axial
176+
mask_masked, cmap="Reds", alpha=0.6, vmin=0, vmax=100, aspect=aspect_axial
157177
)
158178
ax.set_title(label_name, fontsize=7, pad=2)
159179
ax.set_xticks([])
@@ -167,6 +187,41 @@ def main():
167187
plt.close()
168188
print(f"Saved ROI zoom montage to {snakemake.output.png}")
169189

190+
# --- No-mask version (reuses cached slices) ---
191+
n_rows_nm = int(np.ceil(n_rois / n_cols))
192+
fig_nm, axes_nm = plt.subplots(
193+
n_rows_nm,
194+
n_cols,
195+
figsize=(n_cols * 3, n_rows_nm * 3),
196+
constrained_layout=True,
197+
)
198+
fig_nm.suptitle(
199+
f"ROI Zoom Montage QC (no mask overlay)\n"
200+
f"Subject: {subject} | Stain: {stain} | Method: {desc}",
201+
fontsize=11,
202+
fontweight="bold",
203+
)
204+
if n_rows_nm == 1 and n_cols == 1:
205+
axes_nm = np.array([[axes_nm]])
206+
elif n_rows_nm == 1:
207+
axes_nm = axes_nm[np.newaxis, :]
208+
elif n_cols == 1:
209+
axes_nm = axes_nm[:, np.newaxis]
210+
211+
for i, (label_name, spim_sl, _) in enumerate(cached_slices):
212+
ax_nm = axes_nm[i // n_cols, i % n_cols]
213+
ax_nm.imshow(spim_sl, cmap="gray")
214+
ax_nm.set_title(label_name, fontsize=7, pad=2)
215+
ax_nm.set_xticks([])
216+
ax_nm.set_yticks([])
217+
218+
for i in range(n_rois, n_rows_nm * n_cols):
219+
axes_nm[i // n_cols, i % n_cols].axis("off")
220+
221+
plt.savefig(snakemake.output.png_nomask, dpi=150, bbox_inches="tight")
222+
plt.close()
223+
print(f"Saved no-mask ROI zoom montage to {snakemake.output.png_nomask}")
224+
170225

171226
if __name__ == "__main__":
172227
main()

0 commit comments

Comments
 (0)