Skip to content

Commit 5b9ba0d

Browse files
Copilotakhanf
andauthored
feat: improve zoom montage QC PNGs - better mask color, no-mask output, n4 bg option
Agent-Logs-Url: https://github.com/khanlab/SPIMquant/sessions/d973790d-eb1b-4730-b51a-704815237200 Co-authored-by: akhanf <11492701+akhanf@users.noreply.github.com>
1 parent ef914a3 commit 5b9ba0d

4 files changed

Lines changed: 163 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: 61 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,6 +154,9 @@ 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:
152162
spim=inputs["spim"].path,
@@ -174,6 +184,19 @@ quality within individual brain regions.
174184
seg="{seg}",
175185
suffix="dseg.tsv",
176186
),
187+
**{
188+
"spim_n4": bids(
189+
root=work,
190+
datatype="seg",
191+
stain="{stain}",
192+
level=str(config["segmentation_level"]),
193+
desc="correctedn4",
194+
suffix="SPIM.ome.zarr",
195+
**inputs["spim"].wildcards,
196+
)
197+
}
198+
if _use_n4_bg
199+
else {},
177200
output:
178201
png=bids(
179202
root=root,
@@ -185,6 +208,16 @@ quality within individual brain regions.
185208
suffix="roimontage.png",
186209
**inputs["spim"].wildcards,
187210
),
211+
png_nomask=bids(
212+
root=root,
213+
datatype="qc",
214+
seg="{seg}",
215+
from_="{template}",
216+
stain="{stain}",
217+
desc="{desc}nomask",
218+
suffix="roimontage.png",
219+
**inputs["spim"].wildcards,
220+
),
188221
threads: 4
189222
resources:
190223
mem_mb=32000,
@@ -194,6 +227,7 @@ quality within individual brain regions.
194227
n_cols=lambda wildcards: 5 if wildcards.seg == "coarse" else 10,
195228
patch_size=lambda wildcards: 2000 if wildcards.seg == "coarse" else 500,
196229
level=config["segmentation_level"],
230+
use_n4_bg=_use_n4_bg,
197231
script:
198232
"../scripts/qc_segmentation_roi_zoom.py"
199233

@@ -204,6 +238,9 @@ rule qc_vessels_roi_zoom:
204238
Identical to ``qc_segmentation_roi_zoom`` but applied to the vessel
205239
binary mask. Uses ZarrNii to load full-resolution data and
206240
ZarrNiiAtlas for atlas-based ROI cropping.
241+
242+
Two PNGs are produced: one with the mask overlay (``vesselroimontage.png``)
243+
and one without (``desc-{desc}nomask_vesselroimontage.png``).
207244
"""
208245
input:
209246
spim=inputs["spim"].path,
@@ -231,6 +268,19 @@ ZarrNiiAtlas for atlas-based ROI cropping.
231268
seg="{seg}",
232269
suffix="dseg.tsv",
233270
),
271+
**{
272+
"spim_n4": bids(
273+
root=work,
274+
datatype="seg",
275+
stain="{stain}",
276+
level=str(config["segmentation_level"]),
277+
desc="correctedn4",
278+
suffix="SPIM.ome.zarr",
279+
**inputs["spim"].wildcards,
280+
)
281+
}
282+
if _use_n4_bg
283+
else {},
234284
output:
235285
png=bids(
236286
root=root,
@@ -242,6 +292,16 @@ ZarrNiiAtlas for atlas-based ROI cropping.
242292
suffix="vesselroimontage.png",
243293
**inputs["spim"].wildcards,
244294
),
295+
png_nomask=bids(
296+
root=root,
297+
datatype="qc",
298+
seg="{seg}",
299+
from_="{template}",
300+
stain="{stain}",
301+
desc="{desc}nomask",
302+
suffix="vesselroimontage.png",
303+
**inputs["spim"].wildcards,
304+
),
245305
threads: 4
246306
resources:
247307
mem_mb=32000,
@@ -251,6 +311,7 @@ ZarrNiiAtlas for atlas-based ROI cropping.
251311
n_cols=lambda wildcards: 5 if wildcards.seg == "coarse" else 10,
252312
patch_size=lambda wildcards: 2000 if wildcards.seg == "coarse" else 500,
253313
level=config["segmentation_level"],
314+
use_n4_bg=_use_n4_bg,
254315
script:
255316
"../scripts/qc_segmentation_roi_zoom.py"
256317

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)