From ac9d8962879d8b2f07d827a28902d85b777a6ff9 Mon Sep 17 00:00:00 2001 From: You Yan Date: Tue, 7 Apr 2026 10:28:41 -0700 Subject: [PATCH 1/7] feat: Add batch processing for multiple folders in GUI Drop multiple folders/files onto the drop area to process them sequentially with shared parameters. Each item is validated on drop (metadata loaded via TileFusion), with invalid items warned and skipped. Progress bar shows determinate progress (N/total) and log messages are prefixed with [1/5 folder_name]. Preview, calculate-flatfield, reg z/t controls, and Napari are grayed out in batch mode. Per-item failures are logged and processing continues with remaining items. Co-Authored-By: Claude Opus 4.6 (1M context) --- gui/app.py | 343 +++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 334 insertions(+), 9 deletions(-) diff --git a/gui/app.py b/gui/app.py index c2ad469..51fe03c 100644 --- a/gui/app.py +++ b/gui/app.py @@ -493,13 +493,182 @@ def run(self): self.error.emit(f"Error: {str(e)}\n{traceback.format_exc()}") +class BatchFusionWorker(QThread): + """Worker thread for batch processing multiple folders/files.""" + + progress = pyqtSignal(str) + item_started = pyqtSignal(int, int, str) # (current_index, total, folder_name) + item_finished = pyqtSignal(int, int) # (current_index, total) for progress bar + finished = pyqtSignal(int, int, float) # (succeeded, failed, total_time) + error = pyqtSignal(str) + + def __init__( + self, + paths, + do_registration, + blend_pixels, + downsample_factor, + fusion_mode="blended", + flatfield=None, + darkfield=None, + ): + super().__init__() + self.paths = paths + self.do_registration = do_registration + self.blend_pixels = blend_pixels + self.downsample_factor = downsample_factor + self.fusion_mode = fusion_mode + self.flatfield = flatfield + self.darkfield = darkfield + + def _log(self, index, total, name, message): + self.progress.emit(f"[{index + 1}/{total} {name}] {message}") + + def run(self): + import time + + total = len(self.paths) + succeeded = 0 + failed = 0 + batch_start = time.time() + + for idx, tiff_path in enumerate(self.paths): + name = Path(tiff_path).name + self.item_started.emit(idx, total, name) + + try: + self._process_one(idx, total, tiff_path) + succeeded += 1 + except Exception as e: + import traceback + + failed += 1 + self._log(idx, total, name, f"FAILED: {e}") + self.progress.emit(traceback.format_exc()) + + self.item_finished.emit(idx, total) + + total_time = time.time() - batch_start + self.finished.emit(succeeded, failed, total_time) + + def _process_one(self, idx, total, tiff_path): + import gc + import json + import shutil + import time + + import numpy as np + from tilefusion import TileFusion + + name = Path(tiff_path).name + + def log(msg): + self._log(idx, total, name, msg) + + log("Loading...") + + output_path = Path(tiff_path).parent / f"{Path(tiff_path).stem}_fused.ome.zarr" + output_folder = Path(tiff_path).parent / f"{Path(tiff_path).stem}_fused" + + if output_path.exists(): + shutil.rmtree(output_path) + if output_folder.exists(): + shutil.rmtree(output_folder) + + metrics_path = Path(tiff_path).parent / "metrics.json" + if metrics_path.exists(): + metrics_path.unlink() + for m in Path(tiff_path).parent.glob("metrics_*.json"): + m.unlink() + + step_start = time.time() + tf = TileFusion( + tiff_path, + output_path=output_path, + blend_pixels=self.blend_pixels, + downsample_factors=(self.downsample_factor, self.downsample_factor), + flatfield=self.flatfield, + darkfield=self.darkfield, + ) + load_time = time.time() - step_start + log(f"Loaded {tf.n_tiles} tiles ({tf.Y}x{tf.X}) [{load_time:.1f}s]") + + # Multi-region dataset + if len(tf._unique_regions) > 1: + log(f"Multi-region dataset: {tf._unique_regions}") + tf.stitch_all_regions() + return + + # Registration + step_start = time.time() + if self.do_registration: + log("Computing registration...") + tf.refine_tile_positions_with_cross_correlation() + tf.save_pairwise_metrics(metrics_path) + reg_time = time.time() - step_start + log(f"Registration: {len(tf.pairwise_metrics)} pairs [{reg_time:.1f}s]") + else: + tf.threshold = 1.0 + log("Using stage positions (no registration)") + + # Optimize + step_start = time.time() + log("Optimizing positions...") + tf.optimize_shifts( + method="TWO_ROUND_ITERATIVE", rel_thresh=0.5, abs_thresh=2.0, iterative=True + ) + gc.collect() + + tf._tile_positions = [ + tuple(np.array(pos) + off * np.array(tf.pixel_size)) + for pos, off in zip(tf._tile_positions, tf.global_offsets) + ] + opt_time = time.time() - step_start + log(f"Positions optimized [{opt_time:.1f}s]") + + # Fused space + step_start = time.time() + tf._compute_fused_image_space() + tf._pad_to_chunk_multiple() + log(f"Output size: {tf.padded_shape[0]} x {tf.padded_shape[1]}") + + scale0 = output_path / "scale0" / "image" + scale0.parent.mkdir(parents=True, exist_ok=True) + tf._create_fused_tensorstore(output_path=scale0) + + mode_label = "direct placement" if self.fusion_mode == "direct" else "blended" + log(f"Fusing tiles ({mode_label})...") + tf._fuse_tiles(mode=self.fusion_mode) + fuse_time = time.time() - step_start + log(f"Tiles fused [{fuse_time:.1f}s]") + + # Metadata + ngff = { + "attributes": {"_ARRAY_DIMENSIONS": ["t", "c", "y", "x"]}, + "zarr_format": 3, + "node_type": "group", + } + with open(output_path / "scale0" / "zarr.json", "w") as f: + json.dump(ngff, f, indent=2) + + # Multiscales + step_start = time.time() + log("Building multiscale pyramid...") + tf._create_multiscales(output_path, factors=tf.multiscale_factors) + tf._generate_ngff_zarr3_json(output_path, resolution_multiples=tf.resolution_multiples) + pyramid_time = time.time() - step_start + log(f"Done [{pyramid_time:.1f}s]") + + class DropArea(QFrame): - """Drag and drop area for files or folders.""" + """Drag and drop area for files or folders. Supports single and multi-drop.""" fileDropped = pyqtSignal(str) + filesDropped = pyqtSignal(list) # list of validated path strings _default_style = "border: 2px dashed #888; border-radius: 8px; background: #fafafa;" _hover_style = "border: 2px dashed #0071e3; border-radius: 8px; background: #e8f4ff;" _active_style = "border: 2px solid #34c759; border-radius: 8px; background: #f0fff4;" + _warn_style = "border: 2px solid #ff9500; border-radius: 8px; background: #fff8f0;" def __init__(self): super().__init__() @@ -523,6 +692,7 @@ def __init__(self): layout.addWidget(self.label) self.file_path = None + self.file_paths = [] # All validated paths for batch mode def dragEnterEvent(self, event: QDragEnterEvent): if event.mimeData().hasUrls(): @@ -535,18 +705,36 @@ def dragLeaveEvent(self, event): else: self.setStyleSheet(self._default_style) + def _is_valid_path(self, file_path): + """Check if a path is a valid folder or TIFF file.""" + path = Path(file_path) + return path.is_dir() or file_path.endswith((".tif", ".tiff")) + def dropEvent(self, event: QDropEvent): urls = event.mimeData().urls() - if urls: - file_path = urls[0].toLocalFile() - path = Path(file_path) - if path.is_dir() or file_path.endswith((".tif", ".tiff")): - self.setFile(file_path) - self.fileDropped.emit(file_path) + if not urls: + self.setStyleSheet(self._default_style) + return + + valid_paths = [] + invalid_names = [] + for url in urls: + file_path = url.toLocalFile() + if self._is_valid_path(file_path): + valid_paths.append(file_path) else: - self.setStyleSheet(self._default_style) - else: + invalid_names.append(Path(file_path).name) + + if not valid_paths: self.setStyleSheet(self._default_style) + return + + if len(valid_paths) == 1: + self.setFile(valid_paths[0]) + self.fileDropped.emit(valid_paths[0]) + else: + self.setFiles(valid_paths, invalid_names) + self.filesDropped.emit(valid_paths) def mousePressEvent(self, event): from PyQt5.QtWidgets import QMenu @@ -572,6 +760,7 @@ def mousePressEvent(self, event): def setFile(self, file_path): self.file_path = file_path + self.file_paths = [file_path] path = Path(file_path) self.setStyleSheet(self._active_style) self.icon_label.setText("āœ…") @@ -580,6 +769,20 @@ def setFile(self, file_path): else: self.label.setText(path.name) + def setFiles(self, paths, invalid_names=None): + """Set multiple validated paths (batch mode).""" + self.file_paths = list(paths) + self.file_path = paths[0] if paths else None + names = [Path(p).name for p in paths] + label_lines = f"šŸ“¦ {len(paths)} items selected:\n" + "\n".join(f" {n}" for n in names) + if invalid_names: + label_lines += f"\n⚠ Skipped: {', '.join(invalid_names)}" + self.setStyleSheet(self._warn_style) + else: + self.setStyleSheet(self._active_style) + self.icon_label.setText("āœ…") + self.label.setText(label_lines) + class FlatfieldDropArea(QFrame): """Small drag and drop area for flatfield .npy files.""" @@ -731,6 +934,10 @@ def __init__(self): self.regions = [] # List of region names for multi-region outputs self.is_multi_region = False + # Batch processing state + self.batch_paths = [] # Validated paths for batch mode + self.is_batch_mode = False + # Flatfield correction state self.flatfield = None # Shape (C, Y, X) or None self.darkfield = None # Shape (C, Y, X) or None @@ -754,6 +961,7 @@ def setup_ui(self): # Input drop area (no wrapper group to avoid double border) self.drop_area = DropArea() self.drop_area.fileDropped.connect(self.on_file_dropped) + self.drop_area.filesDropped.connect(self.on_files_dropped) layout.addWidget(self.drop_area) # Preview section @@ -1017,7 +1225,29 @@ def setup_ui(self): layout.addStretch() + def _set_batch_mode(self, enabled): + """Enable/disable batch mode — grays out features that don't apply to batch.""" + self.is_batch_mode = enabled + self.preview_button.setEnabled(not enabled) + self.calc_flatfield_button.setEnabled(not enabled and self.drop_area.file_path is not None) + self.reg_zt_widget.setEnabled(not enabled) + self.napari_button.setEnabled(False) + if enabled: + self.preview_button.setToolTip("Preview is not available in batch mode") + self.calc_flatfield_button.setToolTip( + "Calculate flatfield from a single dataset first, then load it for batch" + ) + self.napari_button.setToolTip("Open individual outputs in Napari after batch completes") + else: + self.preview_button.setToolTip("") + self.calc_flatfield_button.setToolTip("") + self.napari_button.setToolTip("") + def on_file_dropped(self, file_path): + """Handle single file/folder drop — exits batch mode.""" + self._set_batch_mode(False) + self.batch_paths = [] + path = Path(file_path) if path.is_dir(): self.log(f"Selected SQUID folder: {file_path}") @@ -1078,6 +1308,53 @@ def on_file_dropped(self, file_path): else: self.flatfield_checkbox.setChecked(False) + def on_files_dropped(self, paths): + """Handle multi-drop — validate each path and enter batch mode.""" + from tilefusion import TileFusion + + self.log_text.clear() + self.log(f"Validating {len(paths)} dropped items...") + + valid_paths = [] + invalid_names = [] + for p in paths: + name = Path(p).name + try: + tf_temp = TileFusion(p) + tf_temp.close() + valid_paths.append(p) + self.log(f" āœ“ {name}") + except Exception as e: + invalid_names.append(name) + self.log(f" āœ— {name}: {e}") + + if not valid_paths: + self.log("No valid datasets found.") + self.run_button.setEnabled(False) + return + + # Update DropArea display with validation results + self.drop_area.setFiles(valid_paths, invalid_names) + self.batch_paths = valid_paths + self._set_batch_mode(True) + self.run_button.setEnabled(True) + + # Clear single-dataset state + self.flatfield_status.setText("No flatfield") + self.flatfield_status.setStyleSheet("color: #86868b; font-size: 11px;") + self.dataset_n_z = 1 + self.dataset_n_t = 1 + self.dataset_n_channels = 1 + self.dataset_channel_names = [] + + if invalid_names: + self.log( + f"\n{len(valid_paths)} of {len(paths)} valid. " + f"Skipped: {', '.join(invalid_names)}" + ) + else: + self.log(f"\nAll {len(valid_paths)} items valid. Ready to run batch.") + def on_registration_toggled(self, checked): self.downsample_widget.setVisible(checked) self._update_reg_zt_controls() @@ -1346,6 +1623,12 @@ def run_stitching(self): flatfield = self.flatfield if self.flatfield_checkbox.isChecked() else None darkfield = self.darkfield if self.flatfield_checkbox.isChecked() else None + if self.is_batch_mode and len(self.batch_paths) > 1: + self._run_batch(blend_pixels, fusion_mode, flatfield, darkfield) + else: + self._run_single(blend_pixels, fusion_mode, flatfield, darkfield) + + def _run_single(self, blend_pixels, fusion_mode, flatfield, darkfield): # Get registration z/t values (None means use default middle z) registration_z = self.reg_z_spin.value() if self.dataset_n_z > 1 else None registration_t = self.reg_t_spin.value() if self.dataset_n_t > 1 else 0 @@ -1370,6 +1653,48 @@ def run_stitching(self): self.worker.error.connect(self.on_fusion_error) self.worker.start() + def _run_batch(self, blend_pixels, fusion_mode, flatfield, darkfield): + total = len(self.batch_paths) + self.progress_bar.setRange(0, total) + self.progress_bar.setValue(0) + self.log(f"Starting batch processing: {total} items\n") + + self.worker = BatchFusionWorker( + self.batch_paths, + self.registration_checkbox.isChecked(), + blend_pixels, + self.downsample_spin.value(), + fusion_mode, + flatfield=flatfield, + darkfield=darkfield, + ) + self.worker.progress.connect(self.log) + self.worker.item_started.connect(self._on_batch_item_started) + self.worker.item_finished.connect(self._on_batch_item_finished) + self.worker.finished.connect(self._on_batch_finished) + self.worker.start() + + def _on_batch_item_started(self, index, total, name): + self.log(f"\n{'='*40}") + self.log(f"Processing {index + 1}/{total}: {name}") + self.log(f"{'='*40}") + + def _on_batch_item_finished(self, index, total): + self.progress_bar.setValue(index + 1) + + def _on_batch_finished(self, succeeded, failed, total_time): + self.progress_bar.setVisible(False) + self.progress_bar.setRange(0, 0) # Reset to indeterminate for next run + self.run_button.setEnabled(True) + + minutes = int(total_time // 60) + seconds = total_time % 60 + time_str = f"{minutes}m {seconds:.1f}s" if minutes > 0 else f"{seconds:.1f}s" + + self.log(f"\n{'='*40}") + self.log(f"Batch complete! {succeeded} succeeded, {failed} failed. Total time: {time_str}") + self.log(f"{'='*40}") + def on_fusion_finished(self, output_path, elapsed_time): self.output_path = output_path self.progress_bar.setVisible(False) From 8cc02ef687c3d143c1d815f296f8722f0fbb658c Mon Sep 17 00:00:00 2001 From: You Yan Date: Tue, 7 Apr 2026 12:34:14 -0700 Subject: [PATCH 2/7] refactor: Extract shared fusion pipeline, remove redundant state - Extract _run_fusion_pipeline() used by both FusionWorker and BatchFusionWorker, eliminating ~100 lines of duplicated pipeline logic. This also fixes batch mode silently dropping registration z/t/channel parameters. - Make DropArea.file_path a property derived from file_paths list. - Make StitcherGUI.is_batch_mode a property derived from batch_paths. - Remove incorrect flatfield status reset in on_files_dropped (batch mode preserves any previously loaded flatfield for shared use). Co-Authored-By: Claude Opus 4.6 (1M context) --- gui/app.py | 403 ++++++++++++++++++++++------------------------------- 1 file changed, 163 insertions(+), 240 deletions(-) diff --git a/gui/app.py b/gui/app.py index 51fe03c..d430a63 100644 --- a/gui/app.py +++ b/gui/app.py @@ -325,6 +325,127 @@ def get_color(row, col): self.error.emit(f"Error: {str(e)}\n{traceback.format_exc()}") +def _run_fusion_pipeline( + tiff_path, + do_registration, + blend_pixels, + downsample_factor, + fusion_mode, + flatfield=None, + darkfield=None, + registration_z=None, + registration_t=0, + registration_channel=0, + log_fn=None, +): + """Shared stitching pipeline used by both single and batch workers. + + Returns the output path string. Raises on failure. + """ + import gc + import json + import shutil + import time + + import numpy as np + from tilefusion import TileFusion + + def log(msg): + if log_fn: + log_fn(msg) + + p = Path(tiff_path) + output_path = p.parent / f"{p.stem}_fused.ome.zarr" + output_folder = p.parent / f"{p.stem}_fused" + + if output_path.exists(): + shutil.rmtree(output_path) + if output_folder.exists(): + shutil.rmtree(output_folder) + + metrics_path = p.parent / "metrics.json" + if metrics_path.exists(): + metrics_path.unlink() + for m in p.parent.glob("metrics_*.json"): + m.unlink() + + step_start = time.time() + tf = TileFusion( + tiff_path, + output_path=output_path, + blend_pixels=blend_pixels, + downsample_factors=(downsample_factor, downsample_factor), + flatfield=flatfield, + darkfield=darkfield, + registration_z=registration_z, + registration_t=registration_t, + channel_to_use=registration_channel, + ) + load_time = time.time() - step_start + log(f"Loaded {tf.n_tiles} tiles ({tf.Y}x{tf.X}) [{load_time:.1f}s]") + + if len(tf._unique_regions) > 1: + log(f"Multi-region dataset: {tf._unique_regions}") + tf.stitch_all_regions() + return str(output_folder) + + step_start = time.time() + if do_registration: + log("Computing registration...") + tf.refine_tile_positions_with_cross_correlation() + tf.save_pairwise_metrics(metrics_path) + reg_time = time.time() - step_start + log(f"Registration complete: {len(tf.pairwise_metrics)} pairs [{reg_time:.1f}s]") + else: + tf.threshold = 1.0 + log("Using stage positions (no registration)") + + step_start = time.time() + log("Optimizing positions...") + tf.optimize_shifts(method="TWO_ROUND_ITERATIVE", rel_thresh=0.5, abs_thresh=2.0, iterative=True) + gc.collect() + + tf._tile_positions = [ + tuple(np.array(pos) + off * np.array(tf.pixel_size)) + for pos, off in zip(tf._tile_positions, tf.global_offsets) + ] + opt_time = time.time() - step_start + log(f"Positions optimized [{opt_time:.1f}s]") + + step_start = time.time() + log("Computing fused image space...") + tf._compute_fused_image_space() + tf._pad_to_chunk_multiple() + log(f"Output size: {tf.padded_shape[0]} x {tf.padded_shape[1]}") + + scale0 = output_path / "scale0" / "image" + scale0.parent.mkdir(parents=True, exist_ok=True) + tf._create_fused_tensorstore(output_path=scale0) + + mode_label = "direct placement" if fusion_mode == "direct" else "blended" + log(f"Fusing tiles ({mode_label})...") + tf._fuse_tiles(mode=fusion_mode) + fuse_time = time.time() - step_start + log(f"Tiles fused [{fuse_time:.1f}s]") + + ngff = { + "attributes": {"_ARRAY_DIMENSIONS": ["t", "c", "y", "x"]}, + "zarr_format": 3, + "node_type": "group", + } + with open(output_path / "scale0" / "zarr.json", "w") as f: + json.dump(ngff, f, indent=2) + + step_start = time.time() + log("Building multiscale pyramid...") + tf._create_multiscales(output_path, factors=tf.multiscale_factors) + tf._generate_ngff_zarr3_json(output_path, resolution_multiples=tf.resolution_multiples) + pyramid_time = time.time() - step_start + log(f"Pyramid built [{pyramid_time:.1f}s]") + + return str(output_path) + + class FusionWorker(QThread): """Worker thread for running tile fusion.""" @@ -360,132 +481,27 @@ def __init__( def run(self): try: - from tilefusion import TileFusion - import shutil import time - import json - import gc start_time = time.time() - self.progress.emit(f"Loading {self.tiff_path}...") - output_path = ( - Path(self.tiff_path).parent / f"{Path(self.tiff_path).stem}_fused.ome.zarr" - ) - # Multi-region output folder - output_folder = Path(self.tiff_path).parent / f"{Path(self.tiff_path).stem}_fused" - - # Remove existing outputs if present - if output_path.exists(): - shutil.rmtree(output_path) - if output_folder.exists(): - shutil.rmtree(output_folder) - - # Also remove metrics if not doing registration - metrics_path = Path(self.tiff_path).parent / "metrics.json" - if metrics_path.exists(): - metrics_path.unlink() - # Remove multi-region metrics - for m in Path(self.tiff_path).parent.glob("metrics_*.json"): - m.unlink() - - step_start = time.time() - tf = TileFusion( + self.output_path = _run_fusion_pipeline( self.tiff_path, - output_path=output_path, - blend_pixels=self.blend_pixels, - downsample_factors=(self.downsample_factor, self.downsample_factor), + self.do_registration, + self.blend_pixels, + self.downsample_factor, + self.fusion_mode, flatfield=self.flatfield, darkfield=self.darkfield, registration_z=self.registration_z, registration_t=self.registration_t, - channel_to_use=self.registration_channel, + registration_channel=self.registration_channel, + log_fn=self.progress.emit, ) - load_time = time.time() - step_start - self.progress.emit(f"Loaded {tf.n_tiles} tiles ({tf.Y}x{tf.X} each) [{load_time:.1f}s]") - - # Check for multi-region dataset - if len(tf._unique_regions) > 1: - self.progress.emit(f"Multi-region dataset: {tf._unique_regions}") - tf.stitch_all_regions() - # Output folder for multi-region - output_folder = Path(self.tiff_path).parent / f"{Path(self.tiff_path).stem}_fused" - elapsed_time = time.time() - start_time - self.output_path = str(output_folder) - self.finished.emit(str(output_folder), elapsed_time) - return - - # Registration step - step_start = time.time() - if self.do_registration: - self.progress.emit("Computing registration...") - tf.refine_tile_positions_with_cross_correlation() - tf.save_pairwise_metrics(metrics_path) - reg_time = time.time() - step_start - self.progress.emit( - f"Registration complete: {len(tf.pairwise_metrics)} pairs [{reg_time:.1f}s]" - ) - else: - tf.threshold = 1.0 # Skip registration - self.progress.emit("Using stage positions (no registration)") - - # Optimize shifts - step_start = time.time() - self.progress.emit("Optimizing positions...") - tf.optimize_shifts( - method="TWO_ROUND_ITERATIVE", rel_thresh=0.5, abs_thresh=2.0, iterative=True - ) - gc.collect() - - import numpy as np - - tf._tile_positions = [ - tuple(np.array(pos) + off * np.array(tf.pixel_size)) - for pos, off in zip(tf._tile_positions, tf.global_offsets) - ] - opt_time = time.time() - step_start - self.progress.emit(f"Positions optimized [{opt_time:.1f}s]") - - # Compute fused space - step_start = time.time() - self.progress.emit("Computing fused image space...") - tf._compute_fused_image_space() - tf._pad_to_chunk_multiple() - self.progress.emit(f"Output size: {tf.padded_shape[0]} x {tf.padded_shape[1]}") - - # Create output store - scale0 = output_path / "scale0" / "image" - scale0.parent.mkdir(parents=True, exist_ok=True) - tf._create_fused_tensorstore(output_path=scale0) - - # Fuse tiles - mode_label = "direct placement" if self.fusion_mode == "direct" else "blended" - self.progress.emit(f"Fusing tiles ({mode_label})...") - tf._fuse_tiles(mode=self.fusion_mode) - fuse_time = time.time() - step_start - self.progress.emit(f"Tiles fused [{fuse_time:.1f}s]") - - # Write metadata - ngff = { - "attributes": {"_ARRAY_DIMENSIONS": ["t", "c", "y", "x"]}, - "zarr_format": 3, - "node_type": "group", - } - with open(output_path / "scale0" / "zarr.json", "w") as f: - json.dump(ngff, f, indent=2) - - # Build multiscales - step_start = time.time() - self.progress.emit("Building multiscale pyramid...") - tf._create_multiscales(output_path, factors=tf.multiscale_factors) - tf._generate_ngff_zarr3_json(output_path, resolution_multiples=tf.resolution_multiples) - pyramid_time = time.time() - step_start - self.progress.emit(f"Pyramid built [{pyramid_time:.1f}s]") elapsed_time = time.time() - start_time - self.output_path = str(output_path) - self.finished.emit(str(output_path), elapsed_time) + self.finished.emit(self.output_path, elapsed_time) except Exception as e: import traceback @@ -537,7 +553,20 @@ def run(self): self.item_started.emit(idx, total, name) try: - self._process_one(idx, total, tiff_path) + + def log_fn(msg, _idx=idx, _total=total, _name=name): + self._log(_idx, _total, _name, msg) + + _run_fusion_pipeline( + tiff_path, + self.do_registration, + self.blend_pixels, + self.downsample_factor, + self.fusion_mode, + flatfield=self.flatfield, + darkfield=self.darkfield, + log_fn=log_fn, + ) succeeded += 1 except Exception as e: import traceback @@ -551,114 +580,6 @@ def run(self): total_time = time.time() - batch_start self.finished.emit(succeeded, failed, total_time) - def _process_one(self, idx, total, tiff_path): - import gc - import json - import shutil - import time - - import numpy as np - from tilefusion import TileFusion - - name = Path(tiff_path).name - - def log(msg): - self._log(idx, total, name, msg) - - log("Loading...") - - output_path = Path(tiff_path).parent / f"{Path(tiff_path).stem}_fused.ome.zarr" - output_folder = Path(tiff_path).parent / f"{Path(tiff_path).stem}_fused" - - if output_path.exists(): - shutil.rmtree(output_path) - if output_folder.exists(): - shutil.rmtree(output_folder) - - metrics_path = Path(tiff_path).parent / "metrics.json" - if metrics_path.exists(): - metrics_path.unlink() - for m in Path(tiff_path).parent.glob("metrics_*.json"): - m.unlink() - - step_start = time.time() - tf = TileFusion( - tiff_path, - output_path=output_path, - blend_pixels=self.blend_pixels, - downsample_factors=(self.downsample_factor, self.downsample_factor), - flatfield=self.flatfield, - darkfield=self.darkfield, - ) - load_time = time.time() - step_start - log(f"Loaded {tf.n_tiles} tiles ({tf.Y}x{tf.X}) [{load_time:.1f}s]") - - # Multi-region dataset - if len(tf._unique_regions) > 1: - log(f"Multi-region dataset: {tf._unique_regions}") - tf.stitch_all_regions() - return - - # Registration - step_start = time.time() - if self.do_registration: - log("Computing registration...") - tf.refine_tile_positions_with_cross_correlation() - tf.save_pairwise_metrics(metrics_path) - reg_time = time.time() - step_start - log(f"Registration: {len(tf.pairwise_metrics)} pairs [{reg_time:.1f}s]") - else: - tf.threshold = 1.0 - log("Using stage positions (no registration)") - - # Optimize - step_start = time.time() - log("Optimizing positions...") - tf.optimize_shifts( - method="TWO_ROUND_ITERATIVE", rel_thresh=0.5, abs_thresh=2.0, iterative=True - ) - gc.collect() - - tf._tile_positions = [ - tuple(np.array(pos) + off * np.array(tf.pixel_size)) - for pos, off in zip(tf._tile_positions, tf.global_offsets) - ] - opt_time = time.time() - step_start - log(f"Positions optimized [{opt_time:.1f}s]") - - # Fused space - step_start = time.time() - tf._compute_fused_image_space() - tf._pad_to_chunk_multiple() - log(f"Output size: {tf.padded_shape[0]} x {tf.padded_shape[1]}") - - scale0 = output_path / "scale0" / "image" - scale0.parent.mkdir(parents=True, exist_ok=True) - tf._create_fused_tensorstore(output_path=scale0) - - mode_label = "direct placement" if self.fusion_mode == "direct" else "blended" - log(f"Fusing tiles ({mode_label})...") - tf._fuse_tiles(mode=self.fusion_mode) - fuse_time = time.time() - step_start - log(f"Tiles fused [{fuse_time:.1f}s]") - - # Metadata - ngff = { - "attributes": {"_ARRAY_DIMENSIONS": ["t", "c", "y", "x"]}, - "zarr_format": 3, - "node_type": "group", - } - with open(output_path / "scale0" / "zarr.json", "w") as f: - json.dump(ngff, f, indent=2) - - # Multiscales - step_start = time.time() - log("Building multiscale pyramid...") - tf._create_multiscales(output_path, factors=tf.multiscale_factors) - tf._generate_ngff_zarr3_json(output_path, resolution_multiples=tf.resolution_multiples) - pyramid_time = time.time() - step_start - log(f"Done [{pyramid_time:.1f}s]") - class DropArea(QFrame): """Drag and drop area for files or folders. Supports single and multi-drop.""" @@ -691,8 +612,11 @@ def __init__(self): self.label.setStyleSheet("border: none; background: transparent;") layout.addWidget(self.label) - self.file_path = None - self.file_paths = [] # All validated paths for batch mode + self.file_paths = [] + + @property + def file_path(self): + return self.file_paths[0] if self.file_paths else None def dragEnterEvent(self, event: QDragEnterEvent): if event.mimeData().hasUrls(): @@ -759,7 +683,6 @@ def mousePressEvent(self, event): self.fileDropped.emit(folder_path) def setFile(self, file_path): - self.file_path = file_path self.file_paths = [file_path] path = Path(file_path) self.setStyleSheet(self._active_style) @@ -935,8 +858,7 @@ def __init__(self): self.is_multi_region = False # Batch processing state - self.batch_paths = [] # Validated paths for batch mode - self.is_batch_mode = False + self.batch_paths = [] # Flatfield correction state self.flatfield = None # Shape (C, Y, X) or None @@ -1225,14 +1147,18 @@ def setup_ui(self): layout.addStretch() - def _set_batch_mode(self, enabled): - """Enable/disable batch mode — grays out features that don't apply to batch.""" - self.is_batch_mode = enabled - self.preview_button.setEnabled(not enabled) - self.calc_flatfield_button.setEnabled(not enabled and self.drop_area.file_path is not None) - self.reg_zt_widget.setEnabled(not enabled) + @property + def is_batch_mode(self): + return len(self.batch_paths) > 1 + + def _update_batch_mode_ui(self): + """Update UI to reflect batch vs single mode.""" + batch = self.is_batch_mode + self.preview_button.setEnabled(not batch) + self.calc_flatfield_button.setEnabled(not batch and self.drop_area.file_path is not None) + self.reg_zt_widget.setEnabled(not batch) self.napari_button.setEnabled(False) - if enabled: + if batch: self.preview_button.setToolTip("Preview is not available in batch mode") self.calc_flatfield_button.setToolTip( "Calculate flatfield from a single dataset first, then load it for batch" @@ -1245,8 +1171,8 @@ def _set_batch_mode(self, enabled): def on_file_dropped(self, file_path): """Handle single file/folder drop — exits batch mode.""" - self._set_batch_mode(False) self.batch_paths = [] + self._update_batch_mode_ui() path = Path(file_path) if path.is_dir(): @@ -1336,12 +1262,9 @@ def on_files_dropped(self, paths): # Update DropArea display with validation results self.drop_area.setFiles(valid_paths, invalid_names) self.batch_paths = valid_paths - self._set_batch_mode(True) + self._update_batch_mode_ui() self.run_button.setEnabled(True) - # Clear single-dataset state - self.flatfield_status.setText("No flatfield") - self.flatfield_status.setStyleSheet("color: #86868b; font-size: 11px;") self.dataset_n_z = 1 self.dataset_n_t = 1 self.dataset_n_channels = 1 @@ -1623,7 +1546,7 @@ def run_stitching(self): flatfield = self.flatfield if self.flatfield_checkbox.isChecked() else None darkfield = self.darkfield if self.flatfield_checkbox.isChecked() else None - if self.is_batch_mode and len(self.batch_paths) > 1: + if self.is_batch_mode: self._run_batch(blend_pixels, fusion_mode, flatfield, darkfield) else: self._run_single(blend_pixels, fusion_mode, flatfield, darkfield) From 61aec2a972afd8c3ff86bd82407f185bca98d5c3 Mon Sep 17 00:00:00 2001 From: You Yan Date: Tue, 7 Apr 2026 12:38:34 -0700 Subject: [PATCH 3/7] =?UTF-8?q?fix:=20Address=20PR=20review=20=E2=80=94=20?= =?UTF-8?q?crash=20bug,=20error=20handling,=20UI=20re-enable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CRITICAL: Remove assignment to read-only file_path property in setFiles() which caused AttributeError on every batch drop. - Wrap BatchFusionWorker.run() in top-level try/except that emits error signal; connect it in _run_batch so UI doesn't freeze on unexpected thread death. - Add MemoryError early exit in batch loop instead of continuing futile processing. - Re-enable preview, calc-flatfield, and reg z/t controls in _on_batch_finished so UI isn't stuck after batch completes. - Use context manager for TileFusion validation in on_files_dropped to prevent file handle leaks. - Fix misleading signal comments (folder_name -> item_name, validated -> type-checked). Co-Authored-By: Claude Opus 4.6 (1M context) --- gui/app.py | 30 +++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/gui/app.py b/gui/app.py index d430a63..82b5a62 100644 --- a/gui/app.py +++ b/gui/app.py @@ -513,7 +513,7 @@ class BatchFusionWorker(QThread): """Worker thread for batch processing multiple folders/files.""" progress = pyqtSignal(str) - item_started = pyqtSignal(int, int, str) # (current_index, total, folder_name) + item_started = pyqtSignal(int, int, str) # (current_index, total, item_name) item_finished = pyqtSignal(int, int) # (current_index, total) for progress bar finished = pyqtSignal(int, int, float) # (succeeded, failed, total_time) error = pyqtSignal(str) @@ -541,6 +541,14 @@ def _log(self, index, total, name, message): self.progress.emit(f"[{index + 1}/{total} {name}] {message}") def run(self): + try: + self._run_batch() + except Exception as e: + import traceback + + self.error.emit(f"Batch processing failed: {e}\n{traceback.format_exc()}") + + def _run_batch(self): import time total = len(self.paths) @@ -568,12 +576,17 @@ def log_fn(msg, _idx=idx, _total=total, _name=name): log_fn=log_fn, ) succeeded += 1 + except MemoryError: + failed += 1 + self._log(idx, total, name, "FAILED: Out of memory. Stopping batch.") + self.item_finished.emit(idx, total) + break except Exception as e: import traceback failed += 1 self._log(idx, total, name, f"FAILED: {e}") - self.progress.emit(traceback.format_exc()) + self._log(idx, total, name, traceback.format_exc()) self.item_finished.emit(idx, total) @@ -585,7 +598,7 @@ class DropArea(QFrame): """Drag and drop area for files or folders. Supports single and multi-drop.""" fileDropped = pyqtSignal(str) - filesDropped = pyqtSignal(list) # list of validated path strings + filesDropped = pyqtSignal(list) # list of path strings (directories or .tif/.tiff files) _default_style = "border: 2px dashed #888; border-radius: 8px; background: #fafafa;" _hover_style = "border: 2px dashed #0071e3; border-radius: 8px; background: #e8f4ff;" _active_style = "border: 2px solid #34c759; border-radius: 8px; background: #f0fff4;" @@ -693,9 +706,8 @@ def setFile(self, file_path): self.label.setText(path.name) def setFiles(self, paths, invalid_names=None): - """Set multiple validated paths (batch mode).""" + """Set multiple paths and update the display for batch mode.""" self.file_paths = list(paths) - self.file_path = paths[0] if paths else None names = [Path(p).name for p in paths] label_lines = f"šŸ“¦ {len(paths)} items selected:\n" + "\n".join(f" {n}" for n in names) if invalid_names: @@ -1246,8 +1258,8 @@ def on_files_dropped(self, paths): for p in paths: name = Path(p).name try: - tf_temp = TileFusion(p) - tf_temp.close() + with TileFusion(p): + pass valid_paths.append(p) self.log(f" āœ“ {name}") except Exception as e: @@ -1592,6 +1604,7 @@ def _run_batch(self, blend_pixels, fusion_mode, flatfield, darkfield): darkfield=darkfield, ) self.worker.progress.connect(self.log) + self.worker.error.connect(self.on_fusion_error) self.worker.item_started.connect(self._on_batch_item_started) self.worker.item_finished.connect(self._on_batch_item_finished) self.worker.finished.connect(self._on_batch_finished) @@ -1609,6 +1622,9 @@ def _on_batch_finished(self, succeeded, failed, total_time): self.progress_bar.setVisible(False) self.progress_bar.setRange(0, 0) # Reset to indeterminate for next run self.run_button.setEnabled(True) + self.preview_button.setEnabled(True) + self.calc_flatfield_button.setEnabled(True) + self.reg_zt_widget.setEnabled(True) minutes = int(total_time // 60) seconds = total_time % 60 From 4ffa656fb2b79f382e5650a0d927ac6cad316627 Mon Sep 17 00:00:00 2001 From: You Yan Date: Tue, 7 Apr 2026 21:25:35 -0700 Subject: [PATCH 4/7] fix: Address Copilot review comments - Clear batch_paths in _on_batch_finished and call _update_batch_mode_ui() to properly exit batch mode and restore controls to correct state. - Emit finished signal from BatchFusionWorker error handler so UI always resets (progress bar, disabled buttons) even on unexpected thread failure. - Redirect single-valid-item from multi-drop to on_file_dropped so dimension detection and flatfield auto-load still work. - Add tooltip for reg_zt_widget in batch mode for consistency with other disabled controls. Co-Authored-By: Claude Opus 4.6 (1M context) --- gui/app.py | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/gui/app.py b/gui/app.py index 82b5a62..7c72592 100644 --- a/gui/app.py +++ b/gui/app.py @@ -547,6 +547,7 @@ def run(self): import traceback self.error.emit(f"Batch processing failed: {e}\n{traceback.format_exc()}") + self.finished.emit(0, len(self.paths), 0.0) def _run_batch(self): import time @@ -1175,10 +1176,12 @@ def _update_batch_mode_ui(self): self.calc_flatfield_button.setToolTip( "Calculate flatfield from a single dataset first, then load it for batch" ) + self.reg_zt_widget.setToolTip("Registration z/t/channel uses defaults in batch mode") self.napari_button.setToolTip("Open individual outputs in Napari after batch completes") else: self.preview_button.setToolTip("") self.calc_flatfield_button.setToolTip("") + self.reg_zt_widget.setToolTip("") self.napari_button.setToolTip("") def on_file_dropped(self, file_path): @@ -1271,7 +1274,20 @@ def on_files_dropped(self, paths): self.run_button.setEnabled(False) return - # Update DropArea display with validation results + if invalid_names: + self.log( + f"\n{len(valid_paths)} of {len(paths)} valid. " + f"Skipped: {', '.join(invalid_names)}" + ) + + # Single valid item — fall back to normal single-item flow + if len(valid_paths) == 1: + self.log(f"\nOnly 1 valid item — using single mode.") + self.drop_area.setFile(valid_paths[0]) + self.on_file_dropped(valid_paths[0]) + return + + # Multiple valid items — enter batch mode self.drop_area.setFiles(valid_paths, invalid_names) self.batch_paths = valid_paths self._update_batch_mode_ui() @@ -1282,12 +1298,7 @@ def on_files_dropped(self, paths): self.dataset_n_channels = 1 self.dataset_channel_names = [] - if invalid_names: - self.log( - f"\n{len(valid_paths)} of {len(paths)} valid. " - f"Skipped: {', '.join(invalid_names)}" - ) - else: + if not invalid_names: self.log(f"\nAll {len(valid_paths)} items valid. Ready to run batch.") def on_registration_toggled(self, checked): @@ -1621,10 +1632,9 @@ def _on_batch_item_finished(self, index, total): def _on_batch_finished(self, succeeded, failed, total_time): self.progress_bar.setVisible(False) self.progress_bar.setRange(0, 0) # Reset to indeterminate for next run + self.batch_paths = [] self.run_button.setEnabled(True) - self.preview_button.setEnabled(True) - self.calc_flatfield_button.setEnabled(True) - self.reg_zt_widget.setEnabled(True) + self._update_batch_mode_ui() minutes = int(total_time // 60) seconds = total_time % 60 From 6ab02b68232b095e13c5f0b1281b268a4fdfe2b9 Mon Sep 17 00:00:00 2001 From: You Yan Date: Tue, 7 Apr 2026 21:43:31 -0700 Subject: [PATCH 5/7] fix: Keep Napari button enabled, open empty viewer without output - Remove unconditional napari_button.setEnabled(False) from _update_batch_mode_ui so button stays available in batch mode. - Re-enable napari button in _on_batch_finished. - When no output_path is set, open an empty Napari viewer instead of silently returning. Co-Authored-By: Claude Opus 4.6 (1M context) --- gui/app.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/gui/app.py b/gui/app.py index 7c72592..12d7b87 100644 --- a/gui/app.py +++ b/gui/app.py @@ -1170,14 +1170,12 @@ def _update_batch_mode_ui(self): self.preview_button.setEnabled(not batch) self.calc_flatfield_button.setEnabled(not batch and self.drop_area.file_path is not None) self.reg_zt_widget.setEnabled(not batch) - self.napari_button.setEnabled(False) if batch: self.preview_button.setToolTip("Preview is not available in batch mode") self.calc_flatfield_button.setToolTip( "Calculate flatfield from a single dataset first, then load it for batch" ) self.reg_zt_widget.setToolTip("Registration z/t/channel uses defaults in batch mode") - self.napari_button.setToolTip("Open individual outputs in Napari after batch completes") else: self.preview_button.setToolTip("") self.calc_flatfield_button.setToolTip("") @@ -1634,6 +1632,7 @@ def _on_batch_finished(self, succeeded, failed, total_time): self.progress_bar.setRange(0, 0) # Reset to indeterminate for next run self.batch_paths = [] self.run_button.setEnabled(True) + self.napari_button.setEnabled(True) self._update_batch_mode_ui() minutes = int(total_time // 60) @@ -1760,6 +1759,13 @@ def _on_region_slider_changed(self, value): def open_in_napari(self): if not self.output_path: + try: + import napari + + napari.Viewer() + napari.run() + except Exception as e: + self.log(f"Error opening Napari: {e}") return # Determine the actual zarr path to open From 2f06e750833b4c747c6ce343b1db19e85630cde6 Mon Sep 17 00:00:00 2001 From: You Yan Date: Tue, 7 Apr 2026 22:03:12 -0700 Subject: [PATCH 6/7] style: Fix black formatting for CI (Python 3.11 target) Co-Authored-By: Claude Opus 4.6 (1M context) --- gui/app.py | 1 - 1 file changed, 1 deletion(-) diff --git a/gui/app.py b/gui/app.py index 12d7b87..494b20c 100644 --- a/gui/app.py +++ b/gui/app.py @@ -36,7 +36,6 @@ from PyQt5.QtCore import Qt, QThread, pyqtSignal from PyQt5.QtGui import QDragEnterEvent, QDropEvent - STYLE_SHEET = """ QGroupBox { font-weight: bold; From c6b94dc4b2fb88af20cf9b90d66d640439a08570 Mon Sep 17 00:00:00 2001 From: You Yan Date: Tue, 7 Apr 2026 22:04:00 -0700 Subject: [PATCH 7/7] style: Fix pre-existing black formatting in view_in_napari.py Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/view_in_napari.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/view_in_napari.py b/scripts/view_in_napari.py index c38628c..a4697d8 100644 --- a/scripts/view_in_napari.py +++ b/scripts/view_in_napari.py @@ -3,6 +3,7 @@ Simple script to view fused OME-Zarr in napari. Works around napari-ome-zarr plugin issues with Zarr v3. """ + import sys from pathlib import Path