isoview 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.
isoview/pipeline.py ADDED
@@ -0,0 +1,913 @@
1
+ """Main processing pipeline for isoview microscopy data."""
2
+
3
+ from pathlib import Path
4
+ from typing import Optional
5
+ import shutil
6
+ import time
7
+
8
+ import numpy as np
9
+
10
+ from .config import ProcessingConfig
11
+ from .corrections import correct_dead_pixels, estimate_background, percentile_interp
12
+ from .io import (
13
+ find_stack_file,
14
+ infer_file_structure,
15
+ read_volume,
16
+ read_all_xml_metadata,
17
+ read_background_values,
18
+ write_volume,
19
+ write_to_consolidated_zarr,
20
+ setup_consolidated_zarr,
21
+ add_label_to_registry,
22
+ )
23
+ from .segmentation import create_coordinate_masks, fuse_masks, segment_foreground
24
+ from .transforms import apply_mask, crop_volume, flip_volume, rotate_volume
25
+
26
+
27
+ class IsoviewProcessor:
28
+ """Process multi-view light sheet microscopy data."""
29
+
30
+ def __init__(self, config: ProcessingConfig):
31
+ """Initialize processor with configuration."""
32
+ self.config = config
33
+ self.structure = infer_file_structure(
34
+ config.input_dir, config.specimen, config.channels
35
+ )
36
+
37
+ # Load metadata from all XML files
38
+ self.metadata, self.per_camera_metadata = read_all_xml_metadata(config.input_dir, config.specimen)
39
+
40
+ self.background_values = read_background_values(config.input_dir, percentile=3.0)
41
+
42
+ # create output directories
43
+ self.config.output_dir.mkdir(parents=True, exist_ok=True)
44
+ self.config.projection_dir.mkdir(parents=True, exist_ok=True)
45
+
46
+ def process_timepoint(self, timepoint: int) -> None:
47
+ """Process single timepoint across all cameras and channels.
48
+
49
+ Args:
50
+ timepoint: timepoint index to process
51
+ """
52
+ # create output subdirectory
53
+ output_subdir = self.config.output_dir / f"TM{timepoint:06d}"
54
+ output_subdir.mkdir(exist_ok=True)
55
+
56
+ # determine channel processing groups
57
+ if self.config.reference_channels is not None:
58
+ ref_groups = self.config.reference_channels
59
+ dep_groups = self.config.dependent_channels or [[] for _ in ref_groups]
60
+ else:
61
+ # each channel is independent
62
+ ref_groups = [[ch] for ch in self.config.channels]
63
+ dep_groups = [[] for _ in self.config.channels]
64
+
65
+ # process each camera
66
+ for camera in self.config.cameras:
67
+ self._process_camera(timepoint, camera, output_subdir, ref_groups, dep_groups)
68
+
69
+ def _process_camera(
70
+ self,
71
+ timepoint: int,
72
+ camera: int,
73
+ output_dir: Path,
74
+ ref_groups: list[list[int]],
75
+ dep_groups: list[list[int]],
76
+ ) -> None:
77
+ """Process single camera for given timepoint."""
78
+
79
+ # process each reference group
80
+ for refs, deps in zip(ref_groups, dep_groups):
81
+ self._process_channel_group(
82
+ timepoint, camera, output_dir, refs, deps
83
+ )
84
+
85
+ def _process_channel_group(
86
+ self,
87
+ timepoint: int,
88
+ camera: int,
89
+ output_dir: Path,
90
+ ref_channels: list[int],
91
+ dep_channels: list[int],
92
+ ) -> None:
93
+ """Process group of reference and dependent channels."""
94
+ # load and process reference channels
95
+ ref_volumes = []
96
+ ref_masks = []
97
+
98
+ for channel in ref_channels:
99
+ volume = self._load_and_correct(timepoint, camera, channel)
100
+
101
+ if self.config.segment:
102
+ # generate mask
103
+ mask = segment_foreground(
104
+ volume,
105
+ self.config.gauss_sigma,
106
+ self.config.gauss_kernel,
107
+ self.config.segment_threshold,
108
+ self.config.mask_percentile,
109
+ self.config.scaling,
110
+ self.config.splitting,
111
+ self.config.subsample_factor,
112
+ verbose=True,
113
+ )
114
+ ref_masks.append(mask)
115
+
116
+ # apply mask
117
+ volume = apply_mask(volume, mask)
118
+
119
+ ref_volumes.append(volume)
120
+
121
+ # save reference volume and get path for consolidated mode
122
+ base_zarr_path = self._save_volume(timepoint, camera, channel, volume, output_dir)
123
+
124
+ # fuse masks if multiple references
125
+ if len(ref_masks) > 1:
126
+ fused_mask = fuse_masks(ref_masks)
127
+ elif ref_masks:
128
+ fused_mask = ref_masks[0]
129
+ else:
130
+ fused_mask = None
131
+
132
+ # save mask and coordinate masks
133
+ if fused_mask is not None and self.config.segment:
134
+ # Determine camera_subpath for consolidated mode
135
+ camera_subpath = None
136
+ if self.config.zarr_consolidate and self.config.output_format == "zarr":
137
+ camera_subpath = f"camera_{camera}"
138
+ self._save_mask(timepoint, camera, ref_channels[0], fused_mask, output_dir, base_zarr_path, camera_subpath)
139
+
140
+ # process dependent channels
141
+ for channel in dep_channels:
142
+ volume = self._load_and_correct(timepoint, camera, channel)
143
+
144
+ if fused_mask is not None:
145
+ volume = apply_mask(volume, fused_mask)
146
+
147
+ self._save_volume(timepoint, camera, channel, volume, output_dir)
148
+
149
+ def _load_and_correct(
150
+ self,
151
+ timepoint: int,
152
+ camera: int,
153
+ channel: int,
154
+ ) -> np.ndarray:
155
+ """Load volume and apply corrections."""
156
+ # find stack file
157
+ stack_path = find_stack_file(
158
+ self.config.input_dir,
159
+ self.config.specimen,
160
+ timepoint,
161
+ camera,
162
+ channel,
163
+ self.structure,
164
+ )
165
+
166
+ volume = read_volume(stack_path)
167
+
168
+ crop_top = self.config.crop_top[camera] if self.config.crop_top else 0
169
+ crop_left = self.config.crop_left[camera] if self.config.crop_left else 0
170
+ crop_height = self.config.crop_height[camera] if self.config.crop_height else None
171
+ crop_width = self.config.crop_width[camera] if self.config.crop_width else None
172
+ crop_front = self.config.crop_front[camera] if self.config.crop_front else 0
173
+ crop_depth = self.config.crop_depth[camera] if self.config.crop_depth else None
174
+
175
+ if crop_top or crop_left or crop_front or crop_height or crop_width or crop_depth:
176
+ volume = crop_volume(
177
+ volume,
178
+ top=crop_top,
179
+ left=crop_left,
180
+ height=crop_height,
181
+ width=crop_width,
182
+ front=crop_front,
183
+ depth=crop_depth,
184
+ )
185
+
186
+ # apply rotation
187
+ if self.config.rotation != 0:
188
+ volume = rotate_volume(volume, self.config.rotation)
189
+
190
+ # apply flips to second camera in each pair
191
+ if self.config.is_second_in_pair(camera):
192
+ if self.config.flip_horizontal or self.config.flip_vertical:
193
+ volume = flip_volume(
194
+ volume,
195
+ self.config.flip_horizontal,
196
+ self.config.flip_vertical,
197
+ )
198
+
199
+ if self.config.median_kernel is not None:
200
+ cam_idx = self.config.cameras.index(camera) if camera in self.config.cameras else 0
201
+ if cam_idx < len(self.background_values):
202
+ bg = self.background_values[cam_idx]
203
+ else:
204
+ bg = estimate_background(volume, percentile=3.0, subsample=self.config.subsample_factor)
205
+
206
+ volume = correct_dead_pixels(
207
+ volume,
208
+ bg,
209
+ self.config.median_kernel,
210
+ )
211
+
212
+ return volume
213
+
214
+ def _save_volume(
215
+ self,
216
+ timepoint: int,
217
+ camera: int,
218
+ channel: int,
219
+ volume: np.ndarray,
220
+ output_dir: Path,
221
+ ) -> Path:
222
+ """Save processed volume and projection.
223
+
224
+ Returns:
225
+ Path to the main output file (for consolidated mode)
226
+ """
227
+ # Determine output path based on consolidate mode
228
+ if self.config.zarr_consolidate and self.config.output_format == "zarr":
229
+ # Multi-camera consolidated structure: header.zarr/camera_N/
230
+ data_header = self.metadata.get("data_header", "data")
231
+ base_filename = f"{data_header}_TM{timepoint:06d}_SPM{self.config.specimen:02d}.zarr"
232
+ main_path = output_dir / base_filename
233
+ camera_subpath = f"camera_{camera}"
234
+ else:
235
+ # Traditional single-file structure
236
+ filename = (
237
+ f"SPM{self.config.specimen:02d}_TM{timepoint:06d}_"
238
+ f"CM{camera:02d}_CHN{channel:02d}{self.config.extension}"
239
+ )
240
+ main_path = output_dir / filename
241
+ camera_subpath = None
242
+
243
+ # Get camera-specific metadata if available
244
+ camera_metadata = self.per_camera_metadata.get(camera, {}).copy()
245
+
246
+ # Add min intensity to camera metadata
247
+ camera_metadata["min_intensity"] = float(np.min(volume))
248
+
249
+ write_volume(
250
+ volume,
251
+ main_path,
252
+ self.config.pixel_spacing,
253
+ compression=self.config.zarr_compression,
254
+ compression_level=self.config.zarr_compression_level,
255
+ chunks=self.config.zarr_chunks,
256
+ shards=self.config.zarr_shards,
257
+ metadata=self.metadata,
258
+ camera_subpath=camera_subpath,
259
+ camera_metadata=camera_metadata,
260
+ )
261
+
262
+ # Setup consolidated structure if needed
263
+ if self.config.zarr_consolidate and self.config.output_format == "zarr":
264
+ setup_consolidated_zarr(main_path, self.metadata, camera_subpath)
265
+
266
+ # save xy projection
267
+ proj = np.max(volume, axis=0)
268
+
269
+ if self.config.zarr_consolidate and self.config.output_format == "zarr":
270
+ # Write projection into the consolidated file
271
+ write_to_consolidated_zarr(
272
+ main_path,
273
+ "projections/xy",
274
+ proj[np.newaxis, :, :],
275
+ pixel_spacing=self.config.pixel_spacing,
276
+ compression=self.config.zarr_compression,
277
+ compression_level=self.config.zarr_compression_level,
278
+ chunks=self.config.zarr_chunks,
279
+ shards=self.config.zarr_shards,
280
+ camera_subpath=camera_subpath,
281
+ )
282
+ else:
283
+ # Write as separate file
284
+ proj_filename = (
285
+ f"SPM{self.config.specimen:02d}_TM{timepoint:06d}_"
286
+ f"CM{camera:02d}_CHN{channel:02d}.xyProjection{self.config.extension}"
287
+ )
288
+ write_volume(
289
+ proj[np.newaxis, :, :],
290
+ self.config.projection_dir / proj_filename,
291
+ self.config.pixel_spacing,
292
+ compression=self.config.zarr_compression,
293
+ compression_level=self.config.zarr_compression_level,
294
+ chunks=self.config.zarr_chunks,
295
+ shards=self.config.zarr_shards,
296
+ metadata=self.metadata,
297
+ )
298
+
299
+ return main_path
300
+
301
+ def _save_mask(
302
+ self,
303
+ timepoint: int,
304
+ camera: int,
305
+ channel: int,
306
+ mask: np.ndarray,
307
+ output_dir: Path,
308
+ base_zarr_path: Optional[Path] = None,
309
+ camera_subpath: Optional[str] = None,
310
+ ) -> None:
311
+ """Save segmentation mask and coordinate masks.
312
+
313
+ Args:
314
+ timepoint: Timepoint index
315
+ camera: Camera index
316
+ channel: Channel index
317
+ mask: Segmentation mask array
318
+ output_dir: Output directory
319
+ base_zarr_path: Path to consolidated Zarr file (if consolidate mode)
320
+ camera_subpath: Optional camera subgroup path for multi-camera structure
321
+ """
322
+ prefix = f"SPM{self.config.specimen:02d}_TM{timepoint:06d}_CM{camera:02d}_CHN{channel:02d}"
323
+
324
+ # Generate coordinate masks
325
+ xz_mask, xy_mask = create_coordinate_masks(mask, self.config.splitting)
326
+
327
+ if self.config.zarr_consolidate and self.config.output_format == "zarr" and base_zarr_path:
328
+ # Write masks into consolidated file
329
+ # Binary segmentation mask
330
+ write_to_consolidated_zarr(
331
+ base_zarr_path,
332
+ "labels/segmentation",
333
+ mask,
334
+ compression=self.config.zarr_compression,
335
+ compression_level=self.config.zarr_compression_level,
336
+ chunks=self.config.zarr_chunks,
337
+ shards=self.config.zarr_shards,
338
+ label_metadata={
339
+ "version": "0.5",
340
+ "colors": [
341
+ {"label-value": 0, "rgba": [0, 0, 0, 0]}, # background: transparent
342
+ {"label-value": 1, "rgba": [0, 255, 0, 128]}, # foreground: semi-transparent green
343
+ ],
344
+ "source": {"image": "../../0"},
345
+ },
346
+ camera_subpath=camera_subpath,
347
+ )
348
+ add_label_to_registry(base_zarr_path, "segmentation", camera_subpath)
349
+
350
+ # XZ coordinate mask
351
+ write_to_consolidated_zarr(
352
+ base_zarr_path,
353
+ "labels/xz_coords",
354
+ xz_mask[np.newaxis, :, :],
355
+ compression=self.config.zarr_compression,
356
+ compression_level=self.config.zarr_compression_level,
357
+ chunks=self.config.zarr_chunks,
358
+ shards=self.config.zarr_shards,
359
+ label_metadata={
360
+ "version": "0.5",
361
+ "source": {"image": "../../0"},
362
+ },
363
+ camera_subpath=camera_subpath,
364
+ )
365
+ add_label_to_registry(base_zarr_path, "xz_coords", camera_subpath)
366
+
367
+ # XY coordinate mask
368
+ write_to_consolidated_zarr(
369
+ base_zarr_path,
370
+ "labels/xy_coords",
371
+ xy_mask[np.newaxis, :, :],
372
+ compression=self.config.zarr_compression,
373
+ compression_level=self.config.zarr_compression_level,
374
+ chunks=self.config.zarr_chunks,
375
+ shards=self.config.zarr_shards,
376
+ label_metadata={
377
+ "version": "0.5",
378
+ "source": {"image": "../../0"},
379
+ },
380
+ camera_subpath=camera_subpath,
381
+ )
382
+ add_label_to_registry(base_zarr_path, "xy_coords", camera_subpath)
383
+
384
+ else:
385
+ # Write as separate files
386
+ mask_file = output_dir / f"{prefix}.segmentationMask{self.config.extension}"
387
+ write_volume(
388
+ mask,
389
+ mask_file,
390
+ compression=self.config.zarr_compression,
391
+ compression_level=self.config.zarr_compression_level,
392
+ chunks=self.config.zarr_chunks,
393
+ shards=self.config.zarr_shards,
394
+ metadata=self.metadata,
395
+ )
396
+
397
+ xz_file = output_dir / f"{prefix}.xzMask{self.config.extension}"
398
+ write_volume(
399
+ xz_mask[np.newaxis, :, :],
400
+ xz_file,
401
+ compression=self.config.zarr_compression,
402
+ compression_level=self.config.zarr_compression_level,
403
+ chunks=self.config.zarr_chunks,
404
+ shards=self.config.zarr_shards,
405
+ metadata=self.metadata,
406
+ )
407
+
408
+ xy_file = output_dir / f"{prefix}.xyMask{self.config.extension}"
409
+ write_volume(
410
+ xy_mask[np.newaxis, :, :],
411
+ xy_file,
412
+ compression=self.config.zarr_compression,
413
+ compression_level=self.config.zarr_compression_level,
414
+ chunks=self.config.zarr_chunks,
415
+ shards=self.config.zarr_shards,
416
+ metadata=self.metadata,
417
+ )
418
+
419
+ def process_all(self) -> None:
420
+ """Process all timepoints."""
421
+ for timepoint in self.config.timepoints:
422
+ self.process_timepoint(timepoint)
423
+
424
+ def process_single_timepoint(self, timepoint: int) -> None:
425
+ """Process single timepoint.
426
+
427
+ Args:
428
+ timepoint: timepoint index to process
429
+ """
430
+ # create output subdirectory
431
+ output_subdir = self.config.output_dir / f"TM{timepoint:06d}"
432
+ output_subdir.mkdir(exist_ok=True, parents=True)
433
+
434
+ # load global mask if segment_mode == 3
435
+ global_mask = None
436
+ if self.config.segment_mode == 3:
437
+ # search for global mask in any supported format
438
+ global_mask_path = None
439
+ for ext in [".klb", ".zarr", ".tif", ".tiff"]:
440
+ candidate = self.config.global_mask_dir / f"Global.segmentationMask{ext}"
441
+ if candidate.exists():
442
+ global_mask_path = candidate
443
+ break
444
+ if global_mask_path is not None:
445
+ global_mask = read_volume(global_mask_path)
446
+ else:
447
+ raise FileNotFoundError(
448
+ f"global mask not found in {self.config.global_mask_dir} "
449
+ "(tried .klb, .zarr, .tif, .tiff)"
450
+ )
451
+
452
+ if self.config.reference_channels is not None:
453
+ ref_groups = self.config.reference_channels
454
+ dep_groups = self.config.dependent_channels or [[] for _ in ref_groups]
455
+ else:
456
+ # each channel is independent reference
457
+ ref_groups = [[ch] for ch in self.config.channels]
458
+ dep_groups = [[] for _ in self.config.channels]
459
+
460
+ # process each camera
461
+ for cam_idx, camera in enumerate(self.config.cameras):
462
+ self._process_single_camera(
463
+ timepoint, camera, cam_idx, output_subdir,
464
+ ref_groups, dep_groups, global_mask
465
+ )
466
+
467
+ def _process_single_camera(
468
+ self,
469
+ timepoint: int,
470
+ camera: int,
471
+ cam_idx: int,
472
+ output_dir: Path,
473
+ ref_groups: list[list[int]],
474
+ dep_groups: list[list[int]],
475
+ global_mask: Optional[np.ndarray],
476
+ ) -> None:
477
+ """Process single camera."""
478
+ # get the valid channel for this camera from the mapping
479
+ valid_channel = self.config.camera_channel_map.get(camera)
480
+
481
+ # process each reference group, but only if it matches the camera's channel
482
+ for r_idx, (ref_channels, dep_channels) in enumerate(zip(ref_groups, dep_groups)):
483
+ # filter ref_channels to only include the valid channel for this camera
484
+ filtered_refs = [ch for ch in ref_channels if ch == valid_channel]
485
+ filtered_deps = [ch for ch in dep_channels if ch == valid_channel]
486
+
487
+ if not filtered_refs:
488
+ # no valid channels for this camera in this group
489
+ continue
490
+
491
+ self._process_single_channel_group(
492
+ timepoint, camera, cam_idx, output_dir,
493
+ filtered_refs, filtered_deps, global_mask
494
+ )
495
+
496
+ def _process_single_channel_group(
497
+ self,
498
+ timepoint: int,
499
+ camera: int,
500
+ cam_idx: int,
501
+ output_dir: Path,
502
+ ref_channels: list[int],
503
+ dep_channels: list[int],
504
+ global_mask: Optional[np.ndarray],
505
+ ) -> None:
506
+ """Process channel group."""
507
+ ref_stacks = []
508
+ ref_masks = []
509
+
510
+ for channel in ref_channels:
511
+ stack = self._load_and_preprocess(timepoint, camera, cam_idx, channel)
512
+
513
+ subsampled = stack.ravel()[::self.config.subsample_factor]
514
+ subsampled = subsampled[subsampled > 0]
515
+ min_intensity_stack = percentile_interp(subsampled, self.config.background_percentile)
516
+
517
+ # generate segmentation mask if needed
518
+ if self.config.segment_mode in (1, 2):
519
+ mask = segment_foreground(
520
+ stack,
521
+ kernel_sigma=self.config.gauss_sigma,
522
+ kernel_size=self.config.gauss_kernel,
523
+ threshold=self.config.segment_threshold,
524
+ mask_percentile=self.config.mask_percentile,
525
+ scaling=self.config.scaling,
526
+ splitting=self.config.splitting,
527
+ subsample_factor=self.config.subsample_factor,
528
+ verbose=True,
529
+ )
530
+ ref_masks.append(mask)
531
+
532
+ # save min intensity for both mask and stack
533
+ min_intensity_mask = percentile_interp(
534
+ mask.ravel()[::self.config.subsample_factor],
535
+ self.config.mask_percentile
536
+ )
537
+ min_intensity = np.array([min_intensity_mask, min_intensity_stack])
538
+ else:
539
+ min_intensity = min_intensity_stack
540
+
541
+ # save min intensity
542
+ self._save_min_intensity(output_dir, timepoint, camera, channel, min_intensity)
543
+
544
+ ref_stacks.append(stack)
545
+
546
+ # fuse masks if multiple references
547
+ if len(ref_masks) > 1 and self.config.segment_mode in (1, 2):
548
+ fused_mask = fuse_masks(ref_masks)
549
+ elif ref_masks:
550
+ fused_mask = ref_masks[0]
551
+ else:
552
+ fused_mask = None
553
+
554
+ # build master channel list (refs + deps)
555
+ master_list = list(ref_channels) + list(dep_channels)
556
+
557
+ # save segmentation mask
558
+ if self.config.segment_mode in (1, 2) and fused_mask is not None:
559
+ self._save_segmentation_mask_file(
560
+ output_dir, timepoint, camera, master_list[0], fused_mask
561
+ )
562
+ # copy to other channels
563
+ if len(master_list) > 1 and self.config.segment_mode == 1:
564
+ for other_ch in master_list[1:]:
565
+ self._copy_mask_file(output_dir, timepoint, camera, master_list[0], other_ch)
566
+
567
+ # skip volume saving if segment_mode == 2 (masks only)
568
+ if self.config.segment_mode == 2:
569
+ return
570
+
571
+ # apply mask and save reference stacks
572
+ for stack, channel in zip(ref_stacks, ref_channels):
573
+ # apply mask
574
+ if self.config.segment_mode == 1 and fused_mask is not None:
575
+ stack = apply_mask(stack, fused_mask)
576
+ elif self.config.segment_mode == 3 and global_mask is not None:
577
+ stack = apply_mask(stack, global_mask)
578
+
579
+ # save volume
580
+ self._save_volume_file(output_dir, timepoint, camera, channel, stack)
581
+
582
+ # save xy projection
583
+ projection = np.max(stack, axis=0)
584
+ self._save_projection_file(timepoint, camera, channel, projection)
585
+
586
+ # generate and save coordinate masks
587
+ if self.config.segment_mode == 1 and fused_mask is not None:
588
+ xz_mask, xy_mask = create_coordinate_masks(fused_mask, self.config.splitting)
589
+ self._save_coordinate_masks(output_dir, timepoint, camera, master_list[0], xz_mask, xy_mask)
590
+ # copy to other channels
591
+ if len(master_list) > 1:
592
+ for other_ch in master_list[1:]:
593
+ self._copy_coord_masks(output_dir, timepoint, camera, master_list[0], other_ch)
594
+
595
+ for channel in dep_channels:
596
+ stack = self._load_and_preprocess(timepoint, camera, cam_idx, channel)
597
+
598
+ subsampled = stack.ravel()[::self.config.subsample_factor]
599
+ subsampled = subsampled[subsampled > 0]
600
+ min_intensity = percentile_interp(subsampled, self.config.background_percentile)
601
+ self._save_min_intensity(output_dir, timepoint, camera, channel, min_intensity)
602
+
603
+ # apply mask
604
+ if self.config.segment_mode == 1 and fused_mask is not None:
605
+ stack = apply_mask(stack, fused_mask)
606
+ elif self.config.segment_mode == 3 and global_mask is not None:
607
+ stack = apply_mask(stack, global_mask)
608
+
609
+ # save volume
610
+ self._save_volume_file(output_dir, timepoint, camera, channel, stack)
611
+
612
+ # save xy projection
613
+ projection = np.max(stack, axis=0)
614
+ self._save_projection_file(timepoint, camera, channel, projection)
615
+
616
+ def _load_and_preprocess(
617
+ self,
618
+ timepoint: int,
619
+ camera: int,
620
+ cam_idx: int,
621
+ channel: int,
622
+ ) -> np.ndarray:
623
+ """Load stack and apply preprocessing (crop, rotate, flip, dead pixel correction)."""
624
+ # find and read stack
625
+ stack_path = find_stack_file(
626
+ self.config.input_dir,
627
+ self.config.specimen,
628
+ timepoint,
629
+ camera,
630
+ channel,
631
+ self.structure,
632
+ )
633
+ stack = read_volume(stack_path)
634
+
635
+ # apply cropping (uses cam_idx to index into per-camera arrays, supports partial params)
636
+ crop_top = self.config.crop_top[cam_idx] if self.config.crop_top else 0
637
+ crop_left = self.config.crop_left[cam_idx] if self.config.crop_left else 0
638
+ crop_height = self.config.crop_height[cam_idx] if self.config.crop_height else None
639
+ crop_width = self.config.crop_width[cam_idx] if self.config.crop_width else None
640
+ crop_front = self.config.crop_front[cam_idx] if self.config.crop_front else 0
641
+ crop_depth = self.config.crop_depth[cam_idx] if self.config.crop_depth else None
642
+
643
+ if crop_top or crop_left or crop_front or crop_height or crop_width or crop_depth:
644
+ stack = crop_volume(
645
+ stack,
646
+ top=crop_top,
647
+ left=crop_left,
648
+ height=crop_height,
649
+ width=crop_width,
650
+ front=crop_front,
651
+ depth=crop_depth,
652
+ )
653
+
654
+ # apply rotation
655
+ if self.config.rotation != 0:
656
+ stack = rotate_volume(stack, self.config.rotation)
657
+
658
+ # apply flips to second camera in each pair
659
+ if self.config.is_second_in_pair(camera):
660
+ if self.config.flip_horizontal or self.config.flip_vertical:
661
+ stack = flip_volume(stack, self.config.flip_horizontal, self.config.flip_vertical)
662
+
663
+ if self.config.median_kernel is not None:
664
+ if cam_idx < len(self.background_values):
665
+ bg = self.background_values[cam_idx]
666
+ else:
667
+ bg = estimate_background(stack, percentile=3.0, subsample=self.config.subsample_factor)
668
+ stack = correct_dead_pixels(stack, bg, self.config.median_kernel)
669
+
670
+ return stack
671
+
672
+ def _save_min_intensity(
673
+ self,
674
+ output_dir: Path,
675
+ timepoint: int,
676
+ camera: int,
677
+ channel: int,
678
+ min_intensity,
679
+ ) -> None:
680
+ """Save min intensity to npz file."""
681
+ filename = f"SPM{self.config.specimen:02d}_TM{timepoint:06d}_CM{camera:02d}_CHN{channel:02d}.minIntensity.npz"
682
+ np.savez(output_dir / filename, min_intensity=min_intensity)
683
+
684
+ def _save_segmentation_mask_file(
685
+ self,
686
+ output_dir: Path,
687
+ timepoint: int,
688
+ camera: int,
689
+ channel: int,
690
+ mask: np.ndarray,
691
+ ) -> None:
692
+ """Save segmentation mask."""
693
+ prefix = f"SPM{self.config.specimen:02d}_TM{timepoint:06d}_CM{camera:02d}_CHN{channel:02d}"
694
+ write_volume(
695
+ mask,
696
+ output_dir / f"{prefix}.segmentationMask{self.config.extension}",
697
+ compression=self.config.zarr_compression,
698
+ compression_level=self.config.zarr_compression_level,
699
+ )
700
+
701
+ def _copy_mask_file(
702
+ self,
703
+ output_dir: Path,
704
+ timepoint: int,
705
+ camera: int,
706
+ src_channel: int,
707
+ dst_channel: int,
708
+ ) -> None:
709
+ """Copy mask file to another channel."""
710
+ prefix_src = f"SPM{self.config.specimen:02d}_TM{timepoint:06d}_CM{camera:02d}_CHN{src_channel:02d}"
711
+ prefix_dst = f"SPM{self.config.specimen:02d}_TM{timepoint:06d}_CM{camera:02d}_CHN{dst_channel:02d}"
712
+ src = output_dir / f"{prefix_src}.segmentationMask{self.config.extension}"
713
+ dst = output_dir / f"{prefix_dst}.segmentationMask{self.config.extension}"
714
+ if src.exists():
715
+ if src.is_dir():
716
+ shutil.copytree(src, dst, dirs_exist_ok=True)
717
+ else:
718
+ shutil.copy2(src, dst)
719
+
720
+ def _save_coordinate_masks(
721
+ self,
722
+ output_dir: Path,
723
+ timepoint: int,
724
+ camera: int,
725
+ channel: int,
726
+ xz_mask: np.ndarray,
727
+ xy_mask: np.ndarray,
728
+ ) -> None:
729
+ """Save xz and xy coordinate masks."""
730
+ prefix = f"SPM{self.config.specimen:02d}_TM{timepoint:06d}_CM{camera:02d}_CHN{channel:02d}"
731
+
732
+ write_volume(
733
+ xz_mask[np.newaxis, :, :],
734
+ output_dir / f"{prefix}.xzMask{self.config.extension}",
735
+ compression=self.config.zarr_compression,
736
+ compression_level=self.config.zarr_compression_level,
737
+ )
738
+
739
+ write_volume(
740
+ xy_mask[np.newaxis, :, :],
741
+ output_dir / f"{prefix}.xyMask{self.config.extension}",
742
+ compression=self.config.zarr_compression,
743
+ compression_level=self.config.zarr_compression_level,
744
+ )
745
+
746
+ def _copy_coord_masks(
747
+ self,
748
+ output_dir: Path,
749
+ timepoint: int,
750
+ camera: int,
751
+ src_channel: int,
752
+ dst_channel: int,
753
+ ) -> None:
754
+ """Copy coordinate mask files to another channel."""
755
+ prefix_src = f"SPM{self.config.specimen:02d}_TM{timepoint:06d}_CM{camera:02d}_CHN{src_channel:02d}"
756
+ prefix_dst = f"SPM{self.config.specimen:02d}_TM{timepoint:06d}_CM{camera:02d}_CHN{dst_channel:02d}"
757
+
758
+ for mask_type in ["xzMask", "xyMask"]:
759
+ src = output_dir / f"{prefix_src}.{mask_type}{self.config.extension}"
760
+ dst = output_dir / f"{prefix_dst}.{mask_type}{self.config.extension}"
761
+ if src.exists():
762
+ if src.is_dir():
763
+ shutil.copytree(src, dst, dirs_exist_ok=True)
764
+ else:
765
+ shutil.copy2(src, dst)
766
+
767
+ def _save_volume_file(
768
+ self,
769
+ output_dir: Path,
770
+ timepoint: int,
771
+ camera: int,
772
+ channel: int,
773
+ volume: np.ndarray,
774
+ ) -> None:
775
+ """Save processed volume."""
776
+ prefix = f"SPM{self.config.specimen:02d}_TM{timepoint:06d}_CM{camera:02d}_CHN{channel:02d}"
777
+ write_volume(
778
+ volume,
779
+ output_dir / f"{prefix}{self.config.extension}",
780
+ pixel_spacing=self.config.pixel_spacing,
781
+ compression=self.config.zarr_compression,
782
+ compression_level=self.config.zarr_compression_level,
783
+ chunks=self.config.zarr_chunks,
784
+ shards=self.config.zarr_shards,
785
+ metadata=self.metadata,
786
+ )
787
+
788
+ def _save_projection_file(
789
+ self,
790
+ timepoint: int,
791
+ camera: int,
792
+ channel: int,
793
+ projection: np.ndarray,
794
+ ) -> None:
795
+ """Save xy projection."""
796
+ self.config.projection_dir.mkdir(parents=True, exist_ok=True)
797
+ prefix = f"SPM{self.config.specimen:02d}_TM{timepoint:06d}_CM{camera:02d}_CHN{channel:02d}"
798
+ write_volume(
799
+ projection[np.newaxis, :, :],
800
+ self.config.projection_dir / f"{prefix}.xyProjection{self.config.extension}",
801
+ pixel_spacing=self.config.pixel_spacing,
802
+ compression=self.config.zarr_compression,
803
+ compression_level=self.config.zarr_compression_level,
804
+ )
805
+
806
+ def run(self) -> None:
807
+ """Process all timepoints."""
808
+ n_timepoints = len(self.config.timepoints)
809
+ print(f"processing {n_timepoints} timepoints: {self.config.timepoints[0]} to {self.config.timepoints[-1]}")
810
+ print(f"cameras: {self.config.cameras}, channels: {self.config.channels}")
811
+ print(f"segment_mode: {self.config.segment_mode}, output: {self.config.output_format}")
812
+ print("-" * 60)
813
+
814
+ total_start = time.time()
815
+ timepoint_times = []
816
+
817
+ for i, timepoint in enumerate(self.config.timepoints):
818
+ tp_start = time.time()
819
+ print(f"[{i+1}/{n_timepoints}] timepoint {timepoint:04d}\n", end="", flush=True)
820
+
821
+ self.process_single_timepoint(timepoint)
822
+
823
+ tp_elapsed = time.time() - tp_start
824
+ timepoint_times.append(tp_elapsed)
825
+ print(f" - {tp_elapsed:.1f}s")
826
+
827
+ total_elapsed = time.time() - total_start
828
+ avg_time = total_elapsed / n_timepoints if n_timepoints > 0 else 0
829
+
830
+ print("-" * 60)
831
+ print(f"completed {n_timepoints} timepoints in {total_elapsed:.1f}s ({avg_time:.2f}s/timepoint)")
832
+
833
+ # generate global mask if segment_mode == 2
834
+ if self.config.segment_mode == 2:
835
+ self.generate_global_mask()
836
+
837
+ def generate_global_mask(self) -> None:
838
+ """Generate global segmentation mask from individual timepoint masks."""
839
+ print("generating global segmentation mask")
840
+ self.config.global_mask_dir.mkdir(parents=True, exist_ok=True)
841
+
842
+ global_mask = None
843
+ mask_count = 0
844
+
845
+ for timepoint in self.config.timepoints:
846
+ mask_dir = self.config.output_dir / f"TM{timepoint:06d}"
847
+
848
+ for camera in self.config.cameras:
849
+ # determine which channels have masks
850
+ if self.config.reference_channels is not None:
851
+ channels_to_check = [ref[0] for ref in self.config.reference_channels]
852
+ else:
853
+ channels_to_check = self.config.channels
854
+
855
+ for channel in channels_to_check:
856
+ prefix = f"SPM{self.config.specimen:02d}_TM{timepoint:06d}_CM{camera:02d}_CHN{channel:02d}"
857
+ mask_path = mask_dir / f"{prefix}.segmentationMask{self.config.extension}"
858
+
859
+ if mask_path.exists():
860
+ print(f"reading mask: {mask_path.name}")
861
+ mask = read_volume(mask_path)
862
+
863
+ if global_mask is None:
864
+ global_mask = mask == 1
865
+ else:
866
+ global_mask = global_mask | (mask == 1)
867
+
868
+ mask_count += 1
869
+
870
+ if global_mask is None:
871
+ print("no masks found")
872
+ return
873
+
874
+ print(f"total masks read: {mask_count}")
875
+
876
+ # save global mask
877
+ global_mask_uint16 = global_mask.astype(np.uint16)
878
+ write_volume(
879
+ global_mask_uint16,
880
+ self.config.global_mask_dir / f"Global.segmentationMask{self.config.extension}",
881
+ compression=self.config.zarr_compression,
882
+ compression_level=self.config.zarr_compression_level,
883
+ )
884
+
885
+ # generate and save coordinate masks
886
+ xz_mask, xy_mask = create_coordinate_masks(global_mask_uint16, self.config.splitting)
887
+
888
+ write_volume(
889
+ xz_mask[np.newaxis, :, :],
890
+ self.config.global_mask_dir / f"Global.xzMask{self.config.extension}",
891
+ compression=self.config.zarr_compression,
892
+ compression_level=self.config.zarr_compression_level,
893
+ )
894
+
895
+ write_volume(
896
+ xy_mask[np.newaxis, :, :],
897
+ self.config.global_mask_dir / f"Global.xyMask{self.config.extension}",
898
+ compression=self.config.zarr_compression,
899
+ compression_level=self.config.zarr_compression_level,
900
+ )
901
+
902
+ print("global mask saved")
903
+
904
+
905
+ def process_timepoint(config: ProcessingConfig) -> None:
906
+ """Process all timepoints in config (matches MATLAB processTimepoint)."""
907
+ processor = IsoviewProcessor(config)
908
+ processor.run()
909
+
910
+
911
+ def process_dataset(config: ProcessingConfig) -> None:
912
+ processor = IsoviewProcessor(config)
913
+ processor.process_all()