From 80b12b5f2050396d6c8a967e0e4737d5d046cdf2 Mon Sep 17 00:00:00 2001 From: You Yan Date: Fri, 20 Feb 2026 00:39:26 -0800 Subject: [PATCH 1/7] feat: Add estimate_pixel_size() method to TileFusion Co-Authored-By: Claude Opus 4.5 --- src/tilefusion/core.py | 54 +++++++++++++++++++++++++++ tests/test_core_pixel_estimation.py | 58 +++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 tests/test_core_pixel_estimation.py diff --git a/src/tilefusion/core.py b/src/tilefusion/core.py index d28bc7e..da5e4a0 100644 --- a/src/tilefusion/core.py +++ b/src/tilefusion/core.py @@ -799,6 +799,60 @@ def read_patch(idx, y_bounds, x_bounds): io_executor.shutdown(wait=True) + def estimate_pixel_size(self) -> Tuple[float, float]: + """ + Estimate pixel size from registration results. + + Compares expected shifts (from stage positions / metadata pixel size) + with measured shifts (from cross-correlation) to estimate true pixel size. + + Returns + ------- + estimated_pixel_size : float + Estimated pixel size in same units as metadata (typically um). + deviation_percent : float + Percentage deviation from metadata: (estimated/metadata - 1) * 100 + + Raises + ------ + ValueError + If no valid pairwise metrics available. + """ + if not self.pairwise_metrics: + raise ValueError("No pairwise metrics available. Run registration first.") + + ratios = [] + + for (i, j), (dy_measured, dx_measured, score) in self.pairwise_metrics.items(): + # Get stage positions + pos_i = np.array(self._tile_positions[i]) + pos_j = np.array(self._tile_positions[j]) + + # Expected shift in pixels = stage_distance / pixel_size + stage_diff = pos_j - pos_i # (dy, dx) in physical units + expected_dy = stage_diff[0] / self._pixel_size[0] + expected_dx = stage_diff[1] / self._pixel_size[1] + + # Compute ratio for non-zero shifts + if abs(dx_measured) > 5: # Horizontal shift + ratio = expected_dx / dx_measured + ratios.append(ratio) + if abs(dy_measured) > 5: # Vertical shift + ratio = expected_dy / dy_measured + ratios.append(ratio) + + if not ratios: + raise ValueError("No valid shift measurements for pixel size estimation.") + + # Use median to filter outliers + median_ratio = float(np.median(ratios)) + + # Estimated pixel size (assume isotropic) + estimated = self._pixel_size[0] * median_ratio + deviation_percent = (median_ratio - 1.0) * 100.0 + + return estimated, deviation_percent + # ------------------------------------------------------------------------- # Optimization # ------------------------------------------------------------------------- diff --git a/tests/test_core_pixel_estimation.py b/tests/test_core_pixel_estimation.py new file mode 100644 index 0000000..ef9a738 --- /dev/null +++ b/tests/test_core_pixel_estimation.py @@ -0,0 +1,58 @@ +"""Tests for pixel size estimation.""" + +import numpy as np +import pytest +from unittest.mock import MagicMock + + +class TestEstimatePixelSize: + """Tests for TileFusion.estimate_pixel_size().""" + + def _create_mock_tilefusion(self, tile_positions, pixel_size, pairwise_metrics): + """Create a mock TileFusion with required state.""" + from tilefusion.core import TileFusion + + mock = MagicMock(spec=TileFusion) + mock._tile_positions = tile_positions + mock._pixel_size = pixel_size + mock.pairwise_metrics = pairwise_metrics + + # Bind the real method + mock.estimate_pixel_size = lambda: TileFusion.estimate_pixel_size(mock) + return mock + + def test_perfect_calibration(self): + """When measured shifts match expected, deviation should be ~0%.""" + tile_positions = [(0, 0), (0, 90), (90, 0), (90, 90)] + pixel_size = (1.0, 1.0) + pairwise_metrics = { + (0, 1): (0, 90, 0.95), + (0, 2): (90, 0, 0.95), + (1, 3): (90, 0, 0.95), + (2, 3): (0, 90, 0.95), + } + + tf = self._create_mock_tilefusion(tile_positions, pixel_size, pairwise_metrics) + estimated, deviation = tf.estimate_pixel_size() + + assert abs(estimated - 1.0) < 0.01 + assert abs(deviation) < 1.0 + + def test_pixel_size_underestimated(self): + """When metadata pixel size is too small, estimated should be larger.""" + tile_positions = [(0, 0), (0, 90)] + pixel_size = (1.0, 1.0) + pairwise_metrics = {(0, 1): (0, 82, 0.95)} + + tf = self._create_mock_tilefusion(tile_positions, pixel_size, pairwise_metrics) + estimated, deviation = tf.estimate_pixel_size() + + assert 1.05 < estimated < 1.15 + assert deviation > 5.0 + + def test_no_metrics_raises(self): + """Should raise if no pairwise metrics.""" + tf = self._create_mock_tilefusion([], (1.0, 1.0), {}) + + with pytest.raises(ValueError, match="No pairwise metrics"): + tf.estimate_pixel_size() From 3d2009e7e014b8223cd762d88ab64ced39338f4c Mon Sep 17 00:00:00 2001 From: You Yan Date: Fri, 20 Feb 2026 00:40:49 -0800 Subject: [PATCH 2/7] feat: Add GUI option to use estimated pixel size Co-Authored-By: Claude Opus 4.5 --- gui/app.py | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/gui/app.py b/gui/app.py index c2ad469..a904a37 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; @@ -238,6 +237,15 @@ def patched_read_tile_region(tile_idx, y_slice, x_slice): tf.refine_tile_positions_with_cross_correlation() self.progress.emit(f"Found {len(tf.pairwise_metrics)} pairs") + # Estimate pixel size + try: + estimated_px, deviation = tf.estimate_pixel_size() + self.progress.emit( + f"Estimated pixel size: {estimated_px:.4f} µm ({deviation:+.1f}% from metadata)" + ) + except ValueError: + pass # Not enough pairs for estimation + tf.optimize_shifts( method="TWO_ROUND_ITERATIVE", rel_thresh=0.5, abs_thresh=2.0, iterative=True ) @@ -344,6 +352,7 @@ def __init__( registration_z=None, registration_t=0, registration_channel=0, + use_estimated_pixel_size=False, ): super().__init__() self.tiff_path = tiff_path @@ -356,6 +365,7 @@ def __init__( self.registration_z = registration_z self.registration_t = registration_t self.registration_channel = registration_channel + self.use_estimated_pixel_size = use_estimated_pixel_size self.output_path = None def run(self): @@ -426,6 +436,18 @@ def run(self): self.progress.emit( f"Registration complete: {len(tf.pairwise_metrics)} pairs [{reg_time:.1f}s]" ) + + # Estimate pixel size + try: + estimated_px, deviation = tf.estimate_pixel_size() + self.progress.emit( + f"Estimated pixel size: {estimated_px:.4f} µm ({deviation:+.1f}% from metadata)" + ) + if self.use_estimated_pixel_size and abs(deviation) > 1.0: + tf._pixel_size = (estimated_px, estimated_px) + self.progress.emit("Using estimated pixel size for stitching") + except ValueError as e: + self.progress.emit(f"Could not estimate pixel size: {e}") else: tf.threshold = 1.0 # Skip registration self.progress.emit("Using stage positions (no registration)") @@ -739,6 +761,7 @@ def __init__(self): # Dataset dimension state (for registration z/t selection) self.dataset_n_z = 1 self.dataset_n_t = 1 + self.estimated_pixel_size = None self.dataset_n_channels = 1 self.dataset_channel_names = [] @@ -963,6 +986,13 @@ def setup_ui(self): blend_value_layout.addStretch() settings_layout.addWidget(self.blend_value_widget) + self.use_estimated_px_checkbox = QCheckBox("Use estimated pixel size") + self.use_estimated_px_checkbox.setToolTip( + "Estimate pixel size from registration and use it for stitching" + ) + self.use_estimated_px_checkbox.setChecked(False) + settings_layout.addWidget(self.use_estimated_px_checkbox) + layout.addWidget(settings_group) # Run button @@ -1364,6 +1394,7 @@ def run_stitching(self): registration_z=registration_z, registration_t=registration_t, registration_channel=registration_channel, + use_estimated_pixel_size=self.use_estimated_px_checkbox.isChecked(), ) self.worker.progress.connect(self.log) self.worker.finished.connect(self.on_fusion_finished) From 0a930bc0a3faa0f9de53f910cb2e1bd1ac7c1e8f Mon Sep 17 00:00:00 2001 From: You Yan Date: Fri, 20 Feb 2026 01:10:00 -0800 Subject: [PATCH 3/7] fix: Address PR review issues for pixel size estimation - Fix silent failure in PreviewWorker: log error instead of pass - Add bounds check for expected shifts in ratio calculation - Add sanity check before applying estimated pixel size (within 50%) - Remove unused self.estimated_pixel_size variable Co-Authored-By: Claude Opus 4.5 --- gui/app.py | 15 ++++++++++----- src/tilefusion/core.py | 6 +++--- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/gui/app.py b/gui/app.py index a904a37..96dfe2d 100644 --- a/gui/app.py +++ b/gui/app.py @@ -243,8 +243,8 @@ def patched_read_tile_region(tile_idx, y_slice, x_slice): self.progress.emit( f"Estimated pixel size: {estimated_px:.4f} µm ({deviation:+.1f}% from metadata)" ) - except ValueError: - pass # Not enough pairs for estimation + except ValueError as e: + self.progress.emit(f"Pixel size estimation skipped: {e}") tf.optimize_shifts( method="TWO_ROUND_ITERATIVE", rel_thresh=0.5, abs_thresh=2.0, iterative=True @@ -444,8 +444,14 @@ def run(self): f"Estimated pixel size: {estimated_px:.4f} µm ({deviation:+.1f}% from metadata)" ) if self.use_estimated_pixel_size and abs(deviation) > 1.0: - tf._pixel_size = (estimated_px, estimated_px) - self.progress.emit("Using estimated pixel size for stitching") + # Sanity check: estimated should be within 50% of original + if 0.5 < estimated_px / tf._pixel_size[0] < 2.0: + tf._pixel_size = (estimated_px, estimated_px) + self.progress.emit("Using estimated pixel size for stitching") + else: + self.progress.emit( + f"Warning: Estimated pixel size {estimated_px:.4f} is unreasonable, ignoring" + ) except ValueError as e: self.progress.emit(f"Could not estimate pixel size: {e}") else: @@ -761,7 +767,6 @@ def __init__(self): # Dataset dimension state (for registration z/t selection) self.dataset_n_z = 1 self.dataset_n_t = 1 - self.estimated_pixel_size = None self.dataset_n_channels = 1 self.dataset_channel_names = [] diff --git a/src/tilefusion/core.py b/src/tilefusion/core.py index da5e4a0..c52d8c4 100644 --- a/src/tilefusion/core.py +++ b/src/tilefusion/core.py @@ -833,11 +833,11 @@ def estimate_pixel_size(self) -> Tuple[float, float]: expected_dy = stage_diff[0] / self._pixel_size[0] expected_dx = stage_diff[1] / self._pixel_size[1] - # Compute ratio for non-zero shifts - if abs(dx_measured) > 5: # Horizontal shift + # Compute ratio for non-zero shifts (both expected and measured must be significant) + if abs(dx_measured) > 5 and abs(expected_dx) > 5: # Horizontal shift ratio = expected_dx / dx_measured ratios.append(ratio) - if abs(dy_measured) > 5: # Vertical shift + if abs(dy_measured) > 5 and abs(expected_dy) > 5: # Vertical shift ratio = expected_dy / dy_measured ratios.append(ratio) From c9527b279166fb923f092a5ab64cfb3d0241c54c Mon Sep 17 00:00:00 2001 From: You Yan Date: Fri, 20 Feb 2026 01:17:15 -0800 Subject: [PATCH 4/7] style: Format view_in_napari.py with black Co-Authored-By: Claude Opus 4.5 --- 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 From d54ba6af043676328ba5f46bf78f180742c25f59 Mon Sep 17 00:00:00 2001 From: You Yan Date: Fri, 20 Feb 2026 01:20:47 -0800 Subject: [PATCH 5/7] fix: Address PR #19 review comments - Add docstring entries for registration_z and registration_t parameters - Fix type hints: z_level and time_idx now Optional[int] instead of int - Update read_zarr_tile and read_zarr_region to accept z_level and time_idx - Zarr reads now respect z-level and timepoint selection instead of hardcoding Co-Authored-By: Claude Opus 4.5 --- src/tilefusion/core.py | 23 +++++++++++++++++----- src/tilefusion/io/zarr.py | 41 +++++++++++++++++++++++++++++++-------- 2 files changed, 51 insertions(+), 13 deletions(-) diff --git a/src/tilefusion/core.py b/src/tilefusion/core.py index c52d8c4..ab81ea8 100644 --- a/src/tilefusion/core.py +++ b/src/tilefusion/core.py @@ -84,6 +84,10 @@ class TileFusion: Channel index for registration. multiscale_downsample : str Either "stride" (default) or "block_mean" to control multiscale reduction. + registration_z : int, optional + Z-level to use for registration. If None, uses middle z-level. + registration_t : int + Timepoint to use for registration. Defaults to 0. """ def __init__( @@ -461,7 +465,9 @@ def _update_profiles(self) -> None: # I/O methods (delegate to format-specific loaders) # ------------------------------------------------------------------------- - def _read_tile(self, tile_idx: int, z_level: int = None, time_idx: int = None) -> np.ndarray: + def _read_tile( + self, tile_idx: int, z_level: Optional[int] = None, time_idx: Optional[int] = None + ) -> np.ndarray: """Read a single tile from the input data (all channels).""" if z_level is None: z_level = self._registration_z # Default to registration z-level @@ -471,7 +477,7 @@ def _read_tile(self, tile_idx: int, z_level: int = None, time_idx: int = None) - if self._is_zarr_format: zarr_ts = self._metadata["tensorstore"] is_3d = self._metadata.get("is_3d", False) - tile = read_zarr_tile(zarr_ts, tile_idx, is_3d) + tile = read_zarr_tile(zarr_ts, tile_idx, is_3d, z_level=z_level, time_idx=time_idx) elif self._is_individual_tiffs_format: tile = read_individual_tiffs_tile( self._metadata["image_folder"], @@ -508,8 +514,8 @@ def _read_tile_region( tile_idx: int, y_slice: slice, x_slice: slice, - z_level: int = None, - time_idx: int = None, + z_level: Optional[int] = None, + time_idx: Optional[int] = None, ) -> np.ndarray: """Read a region of a tile from the input data.""" if z_level is None: @@ -521,7 +527,14 @@ def _read_tile_region( zarr_ts = self._metadata["tensorstore"] is_3d = self._metadata.get("is_3d", False) region = read_zarr_region( - zarr_ts, tile_idx, y_slice, x_slice, self.channel_to_use, is_3d + zarr_ts, + tile_idx, + y_slice, + x_slice, + self.channel_to_use, + is_3d, + z_level=z_level, + time_idx=time_idx, ) elif self._is_individual_tiffs_format: region = read_individual_tiffs_region( diff --git a/src/tilefusion/io/zarr.py b/src/tilefusion/io/zarr.py index dfd66ab..e702648 100644 --- a/src/tilefusion/io/zarr.py +++ b/src/tilefusion/io/zarr.py @@ -100,6 +100,8 @@ def read_zarr_tile( zarr_ts: ts.TensorStore, tile_idx: int, is_3d: bool = False, + z_level: int = None, + time_idx: int = 0, ) -> np.ndarray: """ Read all channels of a tile from Zarr format. @@ -111,7 +113,11 @@ def read_zarr_tile( tile_idx : int Index of the tile. is_3d : bool - If True, data is 3D and max projection is applied. + If True, data is 3D. + z_level : int, optional + Z-level to read. If None and is_3d, uses max projection. + time_idx : int + Timepoint to read. Defaults to 0. Returns ------- @@ -119,10 +125,15 @@ def read_zarr_tile( Tile data as float32. """ if is_3d: - arr = zarr_ts[0, tile_idx, :, :, :, :].read().result() - arr = np.max(arr, axis=1) # Max projection along Z + if z_level is not None: + # Read specific z-level + arr = zarr_ts[time_idx, tile_idx, :, z_level, :, :].read().result() + else: + # Max projection along Z (legacy behavior) + arr = zarr_ts[time_idx, tile_idx, :, :, :, :].read().result() + arr = np.max(arr, axis=1) else: - arr = zarr_ts[0, tile_idx, :, :, :].read().result() + arr = zarr_ts[time_idx, tile_idx, :, :, :].read().result() return arr.astype(np.float32) @@ -133,6 +144,8 @@ def read_zarr_region( x_slice: slice, channel_idx: int = 0, is_3d: bool = False, + z_level: int = None, + time_idx: int = 0, ) -> np.ndarray: """ Read a region of a single channel from Zarr format. @@ -149,6 +162,10 @@ def read_zarr_region( Channel index. is_3d : bool If True, data is 3D. + z_level : int, optional + Z-level to read. If None and is_3d, uses max projection. + time_idx : int + Timepoint to read. Defaults to 0. Returns ------- @@ -156,11 +173,19 @@ def read_zarr_region( Tile region as float32. """ if is_3d: - arr = zarr_ts[0, tile_idx, channel_idx, :, y_slice, x_slice].read().result() - arr = np.max(arr, axis=0) - arr = arr[np.newaxis, :, :] + if z_level is not None: + # Read specific z-level + arr = ( + zarr_ts[time_idx, tile_idx, channel_idx, z_level, y_slice, x_slice].read().result() + ) + arr = arr[np.newaxis, :, :] + else: + # Max projection along Z (legacy behavior) + arr = zarr_ts[time_idx, tile_idx, channel_idx, :, y_slice, x_slice].read().result() + arr = np.max(arr, axis=0) + arr = arr[np.newaxis, :, :] else: - arr = zarr_ts[0, tile_idx, channel_idx, y_slice, x_slice].read().result() + arr = zarr_ts[time_idx, tile_idx, channel_idx, y_slice, x_slice].read().result() arr = arr[np.newaxis, :, :] return arr.astype(np.float32) From a79403b7fa577121f458ffade1d7565fe66202bd Mon Sep 17 00:00:00 2001 From: You Yan Date: Fri, 20 Feb 2026 07:56:35 -0800 Subject: [PATCH 6/7] refactor: Simplify code in zarr.py and app.py - Extract duplicated arr[np.newaxis] to single return statement in read_zarr_region - Extract pixel size application logic to helper method _apply_estimated_pixel_size Co-Authored-By: Claude Opus 4.5 --- ...annel-selection-for-registration-design.md | 48 +++ docs/plans/2026-02-17-channel-selection.md | 146 ++++++++ ...2026-02-19-pixel-size-estimation-design.md | 36 ++ .../plans/2026-02-19-pixel-size-estimation.md | 351 ++++++++++++++++++ gui/app.py | 25 +- src/tilefusion/io/zarr.py | 6 +- view_stitched.py | 107 ++++++ 7 files changed, 706 insertions(+), 13 deletions(-) create mode 100644 docs/plans/2026-02-17-channel-selection-for-registration-design.md create mode 100644 docs/plans/2026-02-17-channel-selection.md create mode 100644 docs/plans/2026-02-19-pixel-size-estimation-design.md create mode 100644 docs/plans/2026-02-19-pixel-size-estimation.md create mode 100755 view_stitched.py diff --git a/docs/plans/2026-02-17-channel-selection-for-registration-design.md b/docs/plans/2026-02-17-channel-selection-for-registration-design.md new file mode 100644 index 0000000..516cd2b --- /dev/null +++ b/docs/plans/2026-02-17-channel-selection-for-registration-design.md @@ -0,0 +1,48 @@ +# Channel Selection for Registration + +**Date:** 2026-02-17 +**Status:** Approved + +## Summary + +Add a UI control to select which channel is used for tile registration. Currently, registration always uses channel 0. Users need to select specific channels (e.g., DAPI for nuclear staining) that provide better registration results. + +## Current State + +- `TileFusion` has a `channel_to_use` parameter (defaults to `0`) +- Used in `_read_tile_region` for reading overlap regions during registration +- GUI has z-level and timepoint selection for registration (commit ce47f22) +- No UI control to select channel + +## Design + +### UI Changes + +Add a `QComboBox` dropdown to the registration sub-options widget (`reg_zt_widget`) that displays channel names and allows selection. + +**Location:** Nested under "Enable registration refinement" checkbox, alongside z-level and timepoint controls. + +**Visibility:** Only shown when: +1. Registration is enabled, AND +2. Dataset has multiple channels (n_channels > 1) + +### State Management + +New instance variables in `StitcherGUI`: +- `self.dataset_n_channels: int` - Number of channels in loaded dataset +- `self.dataset_channel_names: List[str]` - Channel names from metadata + +### Data Flow + +1. `on_file_dropped()` loads metadata, extracts channel count and names +2. `_update_reg_zt_controls()` updates channel combo visibility and contents +3. `run_stitching()` / `run_preview()` pass `channel_to_use=reg_channel_combo.currentIndex()` to workers +4. Workers pass to `TileFusion` constructor + +### Files to Modify + +- `gui/app.py`: Add channel combo UI and wire up data flow + +## Alternatives Considered + +**SpinBox with channel index:** Simpler but less user-friendly. Users think in terms of channel names, not indices. Rejected in favor of dropdown with names. diff --git a/docs/plans/2026-02-17-channel-selection.md b/docs/plans/2026-02-17-channel-selection.md new file mode 100644 index 0000000..8f824c9 --- /dev/null +++ b/docs/plans/2026-02-17-channel-selection.md @@ -0,0 +1,146 @@ +# Channel Selection for Registration - Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add a channel dropdown to the registration settings so users can select which channel to use for tile registration. + +**Architecture:** Add QComboBox to existing `reg_zt_widget`, populate with channel names from metadata on file load, pass selection to TileFusion via `channel_to_use` parameter. + +**Tech Stack:** PyQt5 (QComboBox, QLabel), TileFusion API + +--- + +### Task 1: Add Channel Selection UI + +**Files:** +- Modify: `gui/app.py:717-735` (add state variables in `__init__`) +- Modify: `gui/app.py:909-931` (add channel combo to `reg_zt_widget`) + +**Step 1: Add state variables to `__init__`** + +After line 735 (`self.dataset_n_t = 1`), add: +```python +self.dataset_n_channels = 1 +self.dataset_channel_names = [] +``` + +**Step 2: Add channel combo to `reg_zt_widget`** + +Insert before `reg_zt_layout.addStretch()` (line 930): +```python +self.reg_channel_label = QLabel("Channel:") +reg_zt_layout.addWidget(self.reg_channel_label) +self.reg_channel_combo = QComboBox() +self.reg_channel_combo.setToolTip("Channel to use for registration") +self.reg_channel_combo.setMinimumWidth(120) +reg_zt_layout.addWidget(self.reg_channel_combo) +``` + +--- + +### Task 2: Populate Channel Combo on File Load + +**Files:** +- Modify: `gui/app.py:1005-1038` (`on_file_dropped` method) + +**Step 1: Extract channel info from metadata** + +In `on_file_dropped`, inside the `try` block after loading `tf_temp`, add: +```python +self.dataset_n_channels = tf_temp.channels +if "channel_names" in tf_temp._metadata: + self.dataset_channel_names = tf_temp._metadata["channel_names"] +else: + self.dataset_channel_names = [f"Channel {i}" for i in range(self.dataset_n_channels)] +``` + +**Step 2: Reset channel state in except block** + +In the `except Exception:` block, add: +```python +self.dataset_n_channels = 1 +self.dataset_channel_names = [] +``` + +--- + +### Task 3: Update `_update_reg_zt_controls` for Channel Visibility + +**Files:** +- Modify: `gui/app.py:1061-1084` (`_update_reg_zt_controls` method) + +**Step 1: Add channel visibility logic** + +After the timepoint visibility logic, add: +```python +# Update channel combo +has_multi_channel = self.dataset_n_channels > 1 +self.reg_channel_label.setVisible(has_multi_channel) +self.reg_channel_combo.setVisible(has_multi_channel) +if has_multi_channel: + self.reg_channel_combo.clear() + self.reg_channel_combo.addItems(self.dataset_channel_names) + self.reg_channel_combo.setCurrentIndex(0) +``` + +**Step 2: Update widget visibility condition** + +Change line ~1068-1069 from: +```python +show_zt = registration_enabled and (has_multi_z or has_multi_t) +``` +to: +```python +has_multi_channel = self.dataset_n_channels > 1 +show_zt = registration_enabled and (has_multi_z or has_multi_t or has_multi_channel) +``` + +--- + +### Task 4: Pass Channel Selection to Workers + +**Files:** +- Modify: `gui/app.py:1295-1334` (`run_stitching` method) +- Modify: `gui/app.py:1374-1405` (`run_preview` method) +- Modify: `gui/app.py:119-146` (`PreviewWorker.__init__`) +- Modify: `gui/app.py:331-353` (`FusionWorker.__init__`) + +**Step 1: Add `registration_channel` to PreviewWorker** + +In `PreviewWorker.__init__`, add parameter `registration_channel=0` and store as `self.registration_channel`. + +In `PreviewWorker.run`, pass to TileFusion: +```python +tf_full = TileFusion( + ... + channel_to_use=self.registration_channel, +) +``` + +**Step 2: Add `registration_channel` to FusionWorker** + +Same pattern as PreviewWorker. + +**Step 3: Pass selection in `run_stitching`** + +Add before creating FusionWorker: +```python +registration_channel = self.reg_channel_combo.currentIndex() if self.dataset_n_channels > 1 else 0 +``` + +Pass to FusionWorker constructor. + +**Step 4: Pass selection in `run_preview`** + +Same pattern as `run_stitching`. + +--- + +### Task 5: Test Manually + +1. Load a multi-channel dataset +2. Enable registration +3. Verify channel dropdown appears with correct names +4. Select different channel +5. Run preview/stitching +6. Verify registration uses selected channel (check logs) diff --git a/docs/plans/2026-02-19-pixel-size-estimation-design.md b/docs/plans/2026-02-19-pixel-size-estimation-design.md new file mode 100644 index 0000000..1e34799 --- /dev/null +++ b/docs/plans/2026-02-19-pixel-size-estimation-design.md @@ -0,0 +1,36 @@ +# Pixel Size Estimation from Registration + +**Date:** 2026-02-19 +**Status:** Approved + +## Summary + +Estimate the true pixel size by comparing expected vs measured tile shifts during registration. This validates/corrects the pixel size from metadata. + +## Algorithm + +1. After registration completes, for each pair with valid metrics: + - Expected shift (pixels) = `stage_distance / metadata_pixel_size` + - Measured shift (pixels) = cross-correlation result + - Ratio = `expected / measured` +2. Take median of all ratios (filters outliers) +3. Estimated pixel size = `metadata_pixel_size * median_ratio` +4. Report deviation as percentage: `(median_ratio - 1) * 100%` + +## Core Changes (TileFusion) + +- Add `estimate_pixel_size()` method in `core.py` +- Returns `(estimated_px_size, deviation_percent)` +- Called after `refine_tile_positions_with_cross_correlation()` +- Always log the result + +## GUI Changes + +- Add checkbox: "Use estimated pixel size" in Settings (under registration options) +- Show estimated value in log after registration +- If checkbox enabled, apply corrected pixel size before optimization/fusion + +## Output + +- Log: `"Estimated pixel size: 0.768 µm (2.1% deviation from metadata)"` +- Store in output zarr metadata diff --git a/docs/plans/2026-02-19-pixel-size-estimation.md b/docs/plans/2026-02-19-pixel-size-estimation.md new file mode 100644 index 0000000..3fdf12b --- /dev/null +++ b/docs/plans/2026-02-19-pixel-size-estimation.md @@ -0,0 +1,351 @@ +# Pixel Size Estimation - Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Estimate true pixel size from registration results by comparing expected vs measured tile shifts. + +**Architecture:** Add `estimate_pixel_size()` method to TileFusion that analyzes pairwise_metrics after registration. For each pair, compute ratio of expected/measured shift, take median, multiply by metadata pixel size. Add GUI checkbox to optionally use estimated value. + +**Tech Stack:** Python, NumPy, PyQt5 + +--- + +### Task 1: Add estimate_pixel_size() method to TileFusion + +**Files:** +- Modify: `src/tilefusion/core.py` +- Test: `tests/test_core_pixel_estimation.py` + +**Step 1: Write the test** + +Create `tests/test_core_pixel_estimation.py`: + +```python +"""Tests for pixel size estimation.""" + +import numpy as np +import pytest + + +class TestEstimatePixelSize: + """Tests for TileFusion.estimate_pixel_size().""" + + def test_perfect_calibration(self): + """When measured shifts match expected, ratio should be 1.0.""" + from tilefusion.core import TileFusion + + # Create minimal mock - we'll set up the state directly + # For this test, we need pairwise_metrics and tile_positions + + # Simulate 2x2 grid with 10% overlap, pixel_size=1.0 + # Tile size 100x100, so tiles at (0,0), (0,90), (90,0), (90,90) + tile_positions = [(0, 0), (0, 90), (90, 0), (90, 90)] + pixel_size = (1.0, 1.0) + + # Expected shift for horizontal neighbor: 90 pixels (stage) / 1.0 (px_size) = 90 + # If measured shift is also 90, ratio = 1.0 + # pairwise_metrics format: {(i, j): (dy, dx, score)} + pairwise_metrics = { + (0, 1): (0, 90, 0.95), # horizontal pair + (0, 2): (90, 0, 0.95), # vertical pair + (1, 3): (90, 0, 0.95), # vertical pair + (2, 3): (0, 90, 0.95), # horizontal pair + } + + estimated, deviation = _estimate_pixel_size_from_metrics( + pairwise_metrics, tile_positions, pixel_size + ) + + assert abs(estimated - 1.0) < 0.01 + assert abs(deviation) < 1.0 # Less than 1% deviation + + def test_pixel_size_too_small_in_metadata(self): + """When metadata pixel size is too small, estimated should be larger.""" + # If metadata says 1.0 but true is 1.1: + # Expected shift = 90 / 1.0 = 90 pixels + # Measured shift = 90 / 1.1 = 81.8 pixels (tiles closer in pixels) + # Ratio = 90 / 81.8 = 1.1 + # Estimated = 1.0 * 1.1 = 1.1 + + tile_positions = [(0, 0), (0, 90)] # 90 µm apart + pixel_size = (1.0, 1.0) # metadata says 1.0 + + # Measured shift is 82 pixels (as if true pixel size were ~1.1) + pairwise_metrics = { + (0, 1): (0, 82, 0.95), + } + + estimated, deviation = _estimate_pixel_size_from_metrics( + pairwise_metrics, tile_positions, pixel_size + ) + + # Expected: 90/82 * 1.0 ≈ 1.098 + assert 1.05 < estimated < 1.15 + assert deviation > 5.0 # More than 5% deviation + + +def _estimate_pixel_size_from_metrics(pairwise_metrics, tile_positions, pixel_size): + """Helper to test the core algorithm without full TileFusion.""" + from tilefusion.core import TileFusion + # This will be implemented as a static/class method or we test via TileFusion + # For now, placeholder + raise NotImplementedError("Implement estimate_pixel_size first") +``` + +**Step 2: Run test to verify it fails** + +Run: `pytest tests/test_core_pixel_estimation.py -v` +Expected: FAIL with "NotImplementedError" + +**Step 3: Implement estimate_pixel_size() in core.py** + +Add after `refine_tile_positions_with_cross_correlation` method (around line 815): + +```python +def estimate_pixel_size(self) -> Tuple[float, float]: + """ + Estimate pixel size from registration results. + + Compares expected shifts (from stage positions / metadata pixel size) + with measured shifts (from cross-correlation) to estimate true pixel size. + + Returns + ------- + estimated_pixel_size : float + Estimated pixel size in same units as metadata (typically µm). + deviation_percent : float + Percentage deviation from metadata: (estimated/metadata - 1) * 100 + + Raises + ------ + ValueError + If no valid pairwise metrics available. + """ + if not self.pairwise_metrics: + raise ValueError("No pairwise metrics available. Run registration first.") + + ratios = [] + + for (i, j), (dy_measured, dx_measured, score) in self.pairwise_metrics.items(): + # Get stage positions + pos_i = np.array(self._tile_positions[i]) + pos_j = np.array(self._tile_positions[j]) + + # Expected shift in pixels = stage_distance / pixel_size + stage_diff = pos_j - pos_i # (dy, dx) in physical units + expected_dy = stage_diff[0] / self._pixel_size[0] + expected_dx = stage_diff[1] / self._pixel_size[1] + + # Compute ratio for non-zero shifts + if abs(dx_measured) > 5: # Horizontal shift + ratio = expected_dx / dx_measured + ratios.append(ratio) + if abs(dy_measured) > 5: # Vertical shift + ratio = expected_dy / dy_measured + ratios.append(ratio) + + if not ratios: + raise ValueError("No valid shift measurements for pixel size estimation.") + + # Use median to filter outliers + median_ratio = float(np.median(ratios)) + + # Estimated pixel size (assume isotropic) + estimated = self._pixel_size[0] * median_ratio + deviation_percent = (median_ratio - 1.0) * 100.0 + + return estimated, deviation_percent +``` + +**Step 4: Update the test to use the real method** + +Update `tests/test_core_pixel_estimation.py`: + +```python +"""Tests for pixel size estimation.""" + +import numpy as np +import pytest +from unittest.mock import MagicMock + + +class TestEstimatePixelSize: + """Tests for TileFusion.estimate_pixel_size().""" + + def _create_mock_tilefusion(self, tile_positions, pixel_size, pairwise_metrics): + """Create a mock TileFusion with required state.""" + from tilefusion.core import TileFusion + + mock = MagicMock(spec=TileFusion) + mock._tile_positions = tile_positions + mock._pixel_size = pixel_size + mock.pairwise_metrics = pairwise_metrics + + # Bind the real method + mock.estimate_pixel_size = lambda: TileFusion.estimate_pixel_size(mock) + return mock + + def test_perfect_calibration(self): + """When measured shifts match expected, deviation should be ~0%.""" + tile_positions = [(0, 0), (0, 90), (90, 0), (90, 90)] + pixel_size = (1.0, 1.0) + pairwise_metrics = { + (0, 1): (0, 90, 0.95), + (0, 2): (90, 0, 0.95), + (1, 3): (90, 0, 0.95), + (2, 3): (0, 90, 0.95), + } + + tf = self._create_mock_tilefusion(tile_positions, pixel_size, pairwise_metrics) + estimated, deviation = tf.estimate_pixel_size() + + assert abs(estimated - 1.0) < 0.01 + assert abs(deviation) < 1.0 + + def test_pixel_size_underestimated(self): + """When metadata pixel size is too small, estimated should be larger.""" + tile_positions = [(0, 0), (0, 90)] + pixel_size = (1.0, 1.0) + # Measured 82 pixels instead of expected 90 + pairwise_metrics = {(0, 1): (0, 82, 0.95)} + + tf = self._create_mock_tilefusion(tile_positions, pixel_size, pairwise_metrics) + estimated, deviation = tf.estimate_pixel_size() + + assert 1.05 < estimated < 1.15 + assert deviation > 5.0 + + def test_no_metrics_raises(self): + """Should raise if no pairwise metrics.""" + tf = self._create_mock_tilefusion([], (1.0, 1.0), {}) + + with pytest.raises(ValueError, match="No pairwise metrics"): + tf.estimate_pixel_size() +``` + +**Step 5: Run tests** + +Run: `pytest tests/test_core_pixel_estimation.py -v` +Expected: PASS + +**Step 6: Commit** + +```bash +git add src/tilefusion/core.py tests/test_core_pixel_estimation.py +git commit -m "feat: Add estimate_pixel_size() method to TileFusion" +``` + +--- + +### Task 2: Add GUI checkbox and wire up pixel size estimation + +**Files:** +- Modify: `gui/app.py` + +**Step 1: Add state variable and checkbox** + +In `StitcherGUI.__init__` after the dataset dimension state (around line 735), add: +```python +self.estimated_pixel_size = None +``` + +In `setup_ui`, in the registration sub-options section (after channel combo, before `reg_zt_layout.addStretch()`): +```python +self.use_estimated_px_checkbox = QCheckBox("Use estimated pixel size") +self.use_estimated_px_checkbox.setToolTip( + "Estimate pixel size from registration and use it for stitching" +) +self.use_estimated_px_checkbox.setChecked(False) +settings_layout.addWidget(self.use_estimated_px_checkbox) +``` + +**Step 2: Call estimate_pixel_size after registration in FusionWorker** + +In `FusionWorker.run()`, after registration completes (after `tf.save_pairwise_metrics`), add: +```python +# Estimate pixel size +try: + estimated_px, deviation = tf.estimate_pixel_size() + self.progress.emit( + f"Estimated pixel size: {estimated_px:.4f} µm ({deviation:+.1f}% from metadata)" + ) + if self.use_estimated_pixel_size and abs(deviation) > 1.0: + tf._pixel_size = (estimated_px, estimated_px) + self.progress.emit(f"Using estimated pixel size for stitching") +except ValueError as e: + self.progress.emit(f"Could not estimate pixel size: {e}") +``` + +**Step 3: Add parameter to FusionWorker** + +Add `use_estimated_pixel_size=False` to `FusionWorker.__init__` and store it. + +**Step 4: Pass checkbox value in run_stitching** + +In `run_stitching()`, pass `use_estimated_pixel_size=self.use_estimated_px_checkbox.isChecked()` to FusionWorker. + +**Step 5: Commit** + +```bash +git add gui/app.py +git commit -m "feat: Add GUI option to use estimated pixel size" +``` + +--- + +### Task 3: Add pixel size estimation to PreviewWorker + +**Files:** +- Modify: `gui/app.py` + +**Step 1: Add estimation to PreviewWorker.run()** + +After registration in `PreviewWorker.run()` (after `tf.refine_tile_positions_with_cross_correlation`): +```python +# Estimate pixel size +try: + estimated_px, deviation = tf.estimate_pixel_size() + self.progress.emit( + f"Estimated pixel size: {estimated_px:.4f} µm ({deviation:+.1f}% from metadata)" + ) +except ValueError: + pass # Not enough pairs for estimation +``` + +**Step 2: Commit** + +```bash +git add gui/app.py +git commit -m "feat: Show estimated pixel size in preview" +``` + +--- + +### Task 4: Format and test + +**Step 1: Run black** +```bash +python3 -m black --line-length=100 src/tilefusion/core.py gui/app.py tests/test_core_pixel_estimation.py +``` + +**Step 2: Run all tests** +```bash +pytest tests/ -v +``` + +**Step 3: Commit if needed** +```bash +git add -A +git commit -m "style: Format with black" +``` + +--- + +### Task 5: Manual testing + +1. Run `python3 gui/app.py` +2. Load a multi-tile dataset +3. Enable registration +4. Run preview - check log shows estimated pixel size +5. Check "Use estimated pixel size" +6. Run full stitching - verify it uses estimated value diff --git a/gui/app.py b/gui/app.py index 96dfe2d..8e0894b 100644 --- a/gui/app.py +++ b/gui/app.py @@ -368,6 +368,21 @@ def __init__( self.use_estimated_pixel_size = use_estimated_pixel_size self.output_path = None + def _apply_estimated_pixel_size(self, tf, estimated_px: float, deviation: float) -> None: + """Apply estimated pixel size if enabled and within reasonable bounds.""" + if not self.use_estimated_pixel_size or abs(deviation) <= 1.0: + return + + # Sanity check: estimated should be within 50% of original + ratio = estimated_px / tf._pixel_size[0] + if 0.5 < ratio < 2.0: + tf._pixel_size = (estimated_px, estimated_px) + self.progress.emit("Using estimated pixel size for stitching") + else: + self.progress.emit( + f"Warning: Estimated pixel size {estimated_px:.4f} is unreasonable, ignoring" + ) + def run(self): try: from tilefusion import TileFusion @@ -443,15 +458,7 @@ def run(self): self.progress.emit( f"Estimated pixel size: {estimated_px:.4f} µm ({deviation:+.1f}% from metadata)" ) - if self.use_estimated_pixel_size and abs(deviation) > 1.0: - # Sanity check: estimated should be within 50% of original - if 0.5 < estimated_px / tf._pixel_size[0] < 2.0: - tf._pixel_size = (estimated_px, estimated_px) - self.progress.emit("Using estimated pixel size for stitching") - else: - self.progress.emit( - f"Warning: Estimated pixel size {estimated_px:.4f} is unreasonable, ignoring" - ) + self._apply_estimated_pixel_size(tf, estimated_px, deviation) except ValueError as e: self.progress.emit(f"Could not estimate pixel size: {e}") else: diff --git a/src/tilefusion/io/zarr.py b/src/tilefusion/io/zarr.py index e702648..0fd760a 100644 --- a/src/tilefusion/io/zarr.py +++ b/src/tilefusion/io/zarr.py @@ -178,16 +178,14 @@ def read_zarr_region( arr = ( zarr_ts[time_idx, tile_idx, channel_idx, z_level, y_slice, x_slice].read().result() ) - arr = arr[np.newaxis, :, :] else: # Max projection along Z (legacy behavior) arr = zarr_ts[time_idx, tile_idx, channel_idx, :, y_slice, x_slice].read().result() arr = np.max(arr, axis=0) - arr = arr[np.newaxis, :, :] else: arr = zarr_ts[time_idx, tile_idx, channel_idx, y_slice, x_slice].read().result() - arr = arr[np.newaxis, :, :] - return arr.astype(np.float32) + + return arr[np.newaxis, :, :].astype(np.float32) def create_zarr_store( diff --git a/view_stitched.py b/view_stitched.py new file mode 100755 index 0000000..f4b8f46 --- /dev/null +++ b/view_stitched.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +""" +View stitched microscopy data in napari with proper contrast settings. + +Usage: + python view_stitched.py [path_to_ome_zarr] + +Example: + python view_stitched.py "/media/squid/Extreme SSD/Monkey_fused/manual0.ome.zarr/t0.ome.zarr" +""" + +import sys +import numpy as np +import zarr +import napari +from pathlib import Path + + +def calculate_contrast_limits(data, percentile_low=1, percentile_high=99.5): + """Calculate good contrast limits based on percentiles.""" + # Sample data to avoid loading everything + if data.size > 10_000_000: + # Sample every Nth pixel + step = int(np.sqrt(data.size / 10_000_000)) + sample = data[::step, ::step] + else: + sample = data + + # Remove zeros for percentile calculation + nonzero = sample[sample > 0] + if len(nonzero) == 0: + return (0, 1) + + vmin = np.percentile(nonzero, percentile_low) + vmax = np.percentile(nonzero, percentile_high) + + return (float(vmin), float(vmax)) + + +def main(): + if len(sys.argv) > 1: + zarr_path = Path(sys.argv[1]) + else: + # Default to first timepoint of manual0 + zarr_path = Path("/media/squid/Extreme SSD/Monkey_fused/manual0.ome.zarr/t0.ome.zarr") + + if not zarr_path.exists(): + print(f"Error: Path not found: {zarr_path}") + print("\nUsage: python view_stitched.py [path_to_ome_zarr]") + sys.exit(1) + + print(f"Loading: {zarr_path}") + + # Open the zarr array + image_path = zarr_path / "scale0" / "image" + if not image_path.exists(): + print(f"Error: Expected image path not found: {image_path}") + sys.exit(1) + + store = zarr.DirectoryStore(str(image_path)) + z = zarr.open(store, mode="r") + + print(f"Shape: {z.shape}") + print(f"Dtype: {z.dtype}") + print(f"Chunks: {z.chunks}") + + # Load data - squeeze out singular dimensions + # Shape is (T, C, Z, Y, X) -> squeeze to (C, Y, X) if T=1 and Z=1 + data = z[0, :, 0, :, :] # All channels, first (only) time and z + + print(f"\nData loaded: {data.shape}") + print("Calculating contrast limits for each channel...") + + # Create napari viewer + viewer = napari.Viewer() + + # Channel names (update these based on your actual channels) + channel_names = ["405 nm", "488 nm", "561 nm", "638 nm", "730 nm"] + + # Add each channel with auto-contrast + for c in range(data.shape[0]): + channel_data = data[c, :, :] + + # Calculate contrast limits + contrast_limits = calculate_contrast_limits(channel_data) + + print(f" Channel {c} ({channel_names[c] if c < len(channel_names) else f'Ch{c}'}):") + print(f" Data range: [{channel_data.min()}, {channel_data.max()}]") + print(f" Contrast limits: [{contrast_limits[0]:.0f}, {contrast_limits[1]:.0f}]") + + # Add to napari + viewer.add_image( + channel_data, + name=channel_names[c] if c < len(channel_names) else f"Channel {c}", + contrast_limits=contrast_limits, + colormap="gray", + blending="additive", + ) + + print(f"\nDisplaying in napari...") + print("Tip: Use the layer controls to adjust contrast, toggle channels, or change colormaps") + + napari.run() + + +if __name__ == "__main__": + main() From 40605a850bdff4321f14b9da183464cfd8cbabc7 Mon Sep 17 00:00:00 2001 From: You Yan Date: Fri, 20 Feb 2026 14:47:33 -0800 Subject: [PATCH 7/7] chore: Remove untracked files from PR Co-Authored-By: Claude Opus 4.5 --- ...annel-selection-for-registration-design.md | 48 --- docs/plans/2026-02-17-channel-selection.md | 146 -------- ...2026-02-19-pixel-size-estimation-design.md | 36 -- .../plans/2026-02-19-pixel-size-estimation.md | 351 ------------------ view_stitched.py | 107 ------ 5 files changed, 688 deletions(-) delete mode 100644 docs/plans/2026-02-17-channel-selection-for-registration-design.md delete mode 100644 docs/plans/2026-02-17-channel-selection.md delete mode 100644 docs/plans/2026-02-19-pixel-size-estimation-design.md delete mode 100644 docs/plans/2026-02-19-pixel-size-estimation.md delete mode 100755 view_stitched.py diff --git a/docs/plans/2026-02-17-channel-selection-for-registration-design.md b/docs/plans/2026-02-17-channel-selection-for-registration-design.md deleted file mode 100644 index 516cd2b..0000000 --- a/docs/plans/2026-02-17-channel-selection-for-registration-design.md +++ /dev/null @@ -1,48 +0,0 @@ -# Channel Selection for Registration - -**Date:** 2026-02-17 -**Status:** Approved - -## Summary - -Add a UI control to select which channel is used for tile registration. Currently, registration always uses channel 0. Users need to select specific channels (e.g., DAPI for nuclear staining) that provide better registration results. - -## Current State - -- `TileFusion` has a `channel_to_use` parameter (defaults to `0`) -- Used in `_read_tile_region` for reading overlap regions during registration -- GUI has z-level and timepoint selection for registration (commit ce47f22) -- No UI control to select channel - -## Design - -### UI Changes - -Add a `QComboBox` dropdown to the registration sub-options widget (`reg_zt_widget`) that displays channel names and allows selection. - -**Location:** Nested under "Enable registration refinement" checkbox, alongside z-level and timepoint controls. - -**Visibility:** Only shown when: -1. Registration is enabled, AND -2. Dataset has multiple channels (n_channels > 1) - -### State Management - -New instance variables in `StitcherGUI`: -- `self.dataset_n_channels: int` - Number of channels in loaded dataset -- `self.dataset_channel_names: List[str]` - Channel names from metadata - -### Data Flow - -1. `on_file_dropped()` loads metadata, extracts channel count and names -2. `_update_reg_zt_controls()` updates channel combo visibility and contents -3. `run_stitching()` / `run_preview()` pass `channel_to_use=reg_channel_combo.currentIndex()` to workers -4. Workers pass to `TileFusion` constructor - -### Files to Modify - -- `gui/app.py`: Add channel combo UI and wire up data flow - -## Alternatives Considered - -**SpinBox with channel index:** Simpler but less user-friendly. Users think in terms of channel names, not indices. Rejected in favor of dropdown with names. diff --git a/docs/plans/2026-02-17-channel-selection.md b/docs/plans/2026-02-17-channel-selection.md deleted file mode 100644 index 8f824c9..0000000 --- a/docs/plans/2026-02-17-channel-selection.md +++ /dev/null @@ -1,146 +0,0 @@ -# Channel Selection for Registration - Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Add a channel dropdown to the registration settings so users can select which channel to use for tile registration. - -**Architecture:** Add QComboBox to existing `reg_zt_widget`, populate with channel names from metadata on file load, pass selection to TileFusion via `channel_to_use` parameter. - -**Tech Stack:** PyQt5 (QComboBox, QLabel), TileFusion API - ---- - -### Task 1: Add Channel Selection UI - -**Files:** -- Modify: `gui/app.py:717-735` (add state variables in `__init__`) -- Modify: `gui/app.py:909-931` (add channel combo to `reg_zt_widget`) - -**Step 1: Add state variables to `__init__`** - -After line 735 (`self.dataset_n_t = 1`), add: -```python -self.dataset_n_channels = 1 -self.dataset_channel_names = [] -``` - -**Step 2: Add channel combo to `reg_zt_widget`** - -Insert before `reg_zt_layout.addStretch()` (line 930): -```python -self.reg_channel_label = QLabel("Channel:") -reg_zt_layout.addWidget(self.reg_channel_label) -self.reg_channel_combo = QComboBox() -self.reg_channel_combo.setToolTip("Channel to use for registration") -self.reg_channel_combo.setMinimumWidth(120) -reg_zt_layout.addWidget(self.reg_channel_combo) -``` - ---- - -### Task 2: Populate Channel Combo on File Load - -**Files:** -- Modify: `gui/app.py:1005-1038` (`on_file_dropped` method) - -**Step 1: Extract channel info from metadata** - -In `on_file_dropped`, inside the `try` block after loading `tf_temp`, add: -```python -self.dataset_n_channels = tf_temp.channels -if "channel_names" in tf_temp._metadata: - self.dataset_channel_names = tf_temp._metadata["channel_names"] -else: - self.dataset_channel_names = [f"Channel {i}" for i in range(self.dataset_n_channels)] -``` - -**Step 2: Reset channel state in except block** - -In the `except Exception:` block, add: -```python -self.dataset_n_channels = 1 -self.dataset_channel_names = [] -``` - ---- - -### Task 3: Update `_update_reg_zt_controls` for Channel Visibility - -**Files:** -- Modify: `gui/app.py:1061-1084` (`_update_reg_zt_controls` method) - -**Step 1: Add channel visibility logic** - -After the timepoint visibility logic, add: -```python -# Update channel combo -has_multi_channel = self.dataset_n_channels > 1 -self.reg_channel_label.setVisible(has_multi_channel) -self.reg_channel_combo.setVisible(has_multi_channel) -if has_multi_channel: - self.reg_channel_combo.clear() - self.reg_channel_combo.addItems(self.dataset_channel_names) - self.reg_channel_combo.setCurrentIndex(0) -``` - -**Step 2: Update widget visibility condition** - -Change line ~1068-1069 from: -```python -show_zt = registration_enabled and (has_multi_z or has_multi_t) -``` -to: -```python -has_multi_channel = self.dataset_n_channels > 1 -show_zt = registration_enabled and (has_multi_z or has_multi_t or has_multi_channel) -``` - ---- - -### Task 4: Pass Channel Selection to Workers - -**Files:** -- Modify: `gui/app.py:1295-1334` (`run_stitching` method) -- Modify: `gui/app.py:1374-1405` (`run_preview` method) -- Modify: `gui/app.py:119-146` (`PreviewWorker.__init__`) -- Modify: `gui/app.py:331-353` (`FusionWorker.__init__`) - -**Step 1: Add `registration_channel` to PreviewWorker** - -In `PreviewWorker.__init__`, add parameter `registration_channel=0` and store as `self.registration_channel`. - -In `PreviewWorker.run`, pass to TileFusion: -```python -tf_full = TileFusion( - ... - channel_to_use=self.registration_channel, -) -``` - -**Step 2: Add `registration_channel` to FusionWorker** - -Same pattern as PreviewWorker. - -**Step 3: Pass selection in `run_stitching`** - -Add before creating FusionWorker: -```python -registration_channel = self.reg_channel_combo.currentIndex() if self.dataset_n_channels > 1 else 0 -``` - -Pass to FusionWorker constructor. - -**Step 4: Pass selection in `run_preview`** - -Same pattern as `run_stitching`. - ---- - -### Task 5: Test Manually - -1. Load a multi-channel dataset -2. Enable registration -3. Verify channel dropdown appears with correct names -4. Select different channel -5. Run preview/stitching -6. Verify registration uses selected channel (check logs) diff --git a/docs/plans/2026-02-19-pixel-size-estimation-design.md b/docs/plans/2026-02-19-pixel-size-estimation-design.md deleted file mode 100644 index 1e34799..0000000 --- a/docs/plans/2026-02-19-pixel-size-estimation-design.md +++ /dev/null @@ -1,36 +0,0 @@ -# Pixel Size Estimation from Registration - -**Date:** 2026-02-19 -**Status:** Approved - -## Summary - -Estimate the true pixel size by comparing expected vs measured tile shifts during registration. This validates/corrects the pixel size from metadata. - -## Algorithm - -1. After registration completes, for each pair with valid metrics: - - Expected shift (pixels) = `stage_distance / metadata_pixel_size` - - Measured shift (pixels) = cross-correlation result - - Ratio = `expected / measured` -2. Take median of all ratios (filters outliers) -3. Estimated pixel size = `metadata_pixel_size * median_ratio` -4. Report deviation as percentage: `(median_ratio - 1) * 100%` - -## Core Changes (TileFusion) - -- Add `estimate_pixel_size()` method in `core.py` -- Returns `(estimated_px_size, deviation_percent)` -- Called after `refine_tile_positions_with_cross_correlation()` -- Always log the result - -## GUI Changes - -- Add checkbox: "Use estimated pixel size" in Settings (under registration options) -- Show estimated value in log after registration -- If checkbox enabled, apply corrected pixel size before optimization/fusion - -## Output - -- Log: `"Estimated pixel size: 0.768 µm (2.1% deviation from metadata)"` -- Store in output zarr metadata diff --git a/docs/plans/2026-02-19-pixel-size-estimation.md b/docs/plans/2026-02-19-pixel-size-estimation.md deleted file mode 100644 index 3fdf12b..0000000 --- a/docs/plans/2026-02-19-pixel-size-estimation.md +++ /dev/null @@ -1,351 +0,0 @@ -# Pixel Size Estimation - Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Estimate true pixel size from registration results by comparing expected vs measured tile shifts. - -**Architecture:** Add `estimate_pixel_size()` method to TileFusion that analyzes pairwise_metrics after registration. For each pair, compute ratio of expected/measured shift, take median, multiply by metadata pixel size. Add GUI checkbox to optionally use estimated value. - -**Tech Stack:** Python, NumPy, PyQt5 - ---- - -### Task 1: Add estimate_pixel_size() method to TileFusion - -**Files:** -- Modify: `src/tilefusion/core.py` -- Test: `tests/test_core_pixel_estimation.py` - -**Step 1: Write the test** - -Create `tests/test_core_pixel_estimation.py`: - -```python -"""Tests for pixel size estimation.""" - -import numpy as np -import pytest - - -class TestEstimatePixelSize: - """Tests for TileFusion.estimate_pixel_size().""" - - def test_perfect_calibration(self): - """When measured shifts match expected, ratio should be 1.0.""" - from tilefusion.core import TileFusion - - # Create minimal mock - we'll set up the state directly - # For this test, we need pairwise_metrics and tile_positions - - # Simulate 2x2 grid with 10% overlap, pixel_size=1.0 - # Tile size 100x100, so tiles at (0,0), (0,90), (90,0), (90,90) - tile_positions = [(0, 0), (0, 90), (90, 0), (90, 90)] - pixel_size = (1.0, 1.0) - - # Expected shift for horizontal neighbor: 90 pixels (stage) / 1.0 (px_size) = 90 - # If measured shift is also 90, ratio = 1.0 - # pairwise_metrics format: {(i, j): (dy, dx, score)} - pairwise_metrics = { - (0, 1): (0, 90, 0.95), # horizontal pair - (0, 2): (90, 0, 0.95), # vertical pair - (1, 3): (90, 0, 0.95), # vertical pair - (2, 3): (0, 90, 0.95), # horizontal pair - } - - estimated, deviation = _estimate_pixel_size_from_metrics( - pairwise_metrics, tile_positions, pixel_size - ) - - assert abs(estimated - 1.0) < 0.01 - assert abs(deviation) < 1.0 # Less than 1% deviation - - def test_pixel_size_too_small_in_metadata(self): - """When metadata pixel size is too small, estimated should be larger.""" - # If metadata says 1.0 but true is 1.1: - # Expected shift = 90 / 1.0 = 90 pixels - # Measured shift = 90 / 1.1 = 81.8 pixels (tiles closer in pixels) - # Ratio = 90 / 81.8 = 1.1 - # Estimated = 1.0 * 1.1 = 1.1 - - tile_positions = [(0, 0), (0, 90)] # 90 µm apart - pixel_size = (1.0, 1.0) # metadata says 1.0 - - # Measured shift is 82 pixels (as if true pixel size were ~1.1) - pairwise_metrics = { - (0, 1): (0, 82, 0.95), - } - - estimated, deviation = _estimate_pixel_size_from_metrics( - pairwise_metrics, tile_positions, pixel_size - ) - - # Expected: 90/82 * 1.0 ≈ 1.098 - assert 1.05 < estimated < 1.15 - assert deviation > 5.0 # More than 5% deviation - - -def _estimate_pixel_size_from_metrics(pairwise_metrics, tile_positions, pixel_size): - """Helper to test the core algorithm without full TileFusion.""" - from tilefusion.core import TileFusion - # This will be implemented as a static/class method or we test via TileFusion - # For now, placeholder - raise NotImplementedError("Implement estimate_pixel_size first") -``` - -**Step 2: Run test to verify it fails** - -Run: `pytest tests/test_core_pixel_estimation.py -v` -Expected: FAIL with "NotImplementedError" - -**Step 3: Implement estimate_pixel_size() in core.py** - -Add after `refine_tile_positions_with_cross_correlation` method (around line 815): - -```python -def estimate_pixel_size(self) -> Tuple[float, float]: - """ - Estimate pixel size from registration results. - - Compares expected shifts (from stage positions / metadata pixel size) - with measured shifts (from cross-correlation) to estimate true pixel size. - - Returns - ------- - estimated_pixel_size : float - Estimated pixel size in same units as metadata (typically µm). - deviation_percent : float - Percentage deviation from metadata: (estimated/metadata - 1) * 100 - - Raises - ------ - ValueError - If no valid pairwise metrics available. - """ - if not self.pairwise_metrics: - raise ValueError("No pairwise metrics available. Run registration first.") - - ratios = [] - - for (i, j), (dy_measured, dx_measured, score) in self.pairwise_metrics.items(): - # Get stage positions - pos_i = np.array(self._tile_positions[i]) - pos_j = np.array(self._tile_positions[j]) - - # Expected shift in pixels = stage_distance / pixel_size - stage_diff = pos_j - pos_i # (dy, dx) in physical units - expected_dy = stage_diff[0] / self._pixel_size[0] - expected_dx = stage_diff[1] / self._pixel_size[1] - - # Compute ratio for non-zero shifts - if abs(dx_measured) > 5: # Horizontal shift - ratio = expected_dx / dx_measured - ratios.append(ratio) - if abs(dy_measured) > 5: # Vertical shift - ratio = expected_dy / dy_measured - ratios.append(ratio) - - if not ratios: - raise ValueError("No valid shift measurements for pixel size estimation.") - - # Use median to filter outliers - median_ratio = float(np.median(ratios)) - - # Estimated pixel size (assume isotropic) - estimated = self._pixel_size[0] * median_ratio - deviation_percent = (median_ratio - 1.0) * 100.0 - - return estimated, deviation_percent -``` - -**Step 4: Update the test to use the real method** - -Update `tests/test_core_pixel_estimation.py`: - -```python -"""Tests for pixel size estimation.""" - -import numpy as np -import pytest -from unittest.mock import MagicMock - - -class TestEstimatePixelSize: - """Tests for TileFusion.estimate_pixel_size().""" - - def _create_mock_tilefusion(self, tile_positions, pixel_size, pairwise_metrics): - """Create a mock TileFusion with required state.""" - from tilefusion.core import TileFusion - - mock = MagicMock(spec=TileFusion) - mock._tile_positions = tile_positions - mock._pixel_size = pixel_size - mock.pairwise_metrics = pairwise_metrics - - # Bind the real method - mock.estimate_pixel_size = lambda: TileFusion.estimate_pixel_size(mock) - return mock - - def test_perfect_calibration(self): - """When measured shifts match expected, deviation should be ~0%.""" - tile_positions = [(0, 0), (0, 90), (90, 0), (90, 90)] - pixel_size = (1.0, 1.0) - pairwise_metrics = { - (0, 1): (0, 90, 0.95), - (0, 2): (90, 0, 0.95), - (1, 3): (90, 0, 0.95), - (2, 3): (0, 90, 0.95), - } - - tf = self._create_mock_tilefusion(tile_positions, pixel_size, pairwise_metrics) - estimated, deviation = tf.estimate_pixel_size() - - assert abs(estimated - 1.0) < 0.01 - assert abs(deviation) < 1.0 - - def test_pixel_size_underestimated(self): - """When metadata pixel size is too small, estimated should be larger.""" - tile_positions = [(0, 0), (0, 90)] - pixel_size = (1.0, 1.0) - # Measured 82 pixels instead of expected 90 - pairwise_metrics = {(0, 1): (0, 82, 0.95)} - - tf = self._create_mock_tilefusion(tile_positions, pixel_size, pairwise_metrics) - estimated, deviation = tf.estimate_pixel_size() - - assert 1.05 < estimated < 1.15 - assert deviation > 5.0 - - def test_no_metrics_raises(self): - """Should raise if no pairwise metrics.""" - tf = self._create_mock_tilefusion([], (1.0, 1.0), {}) - - with pytest.raises(ValueError, match="No pairwise metrics"): - tf.estimate_pixel_size() -``` - -**Step 5: Run tests** - -Run: `pytest tests/test_core_pixel_estimation.py -v` -Expected: PASS - -**Step 6: Commit** - -```bash -git add src/tilefusion/core.py tests/test_core_pixel_estimation.py -git commit -m "feat: Add estimate_pixel_size() method to TileFusion" -``` - ---- - -### Task 2: Add GUI checkbox and wire up pixel size estimation - -**Files:** -- Modify: `gui/app.py` - -**Step 1: Add state variable and checkbox** - -In `StitcherGUI.__init__` after the dataset dimension state (around line 735), add: -```python -self.estimated_pixel_size = None -``` - -In `setup_ui`, in the registration sub-options section (after channel combo, before `reg_zt_layout.addStretch()`): -```python -self.use_estimated_px_checkbox = QCheckBox("Use estimated pixel size") -self.use_estimated_px_checkbox.setToolTip( - "Estimate pixel size from registration and use it for stitching" -) -self.use_estimated_px_checkbox.setChecked(False) -settings_layout.addWidget(self.use_estimated_px_checkbox) -``` - -**Step 2: Call estimate_pixel_size after registration in FusionWorker** - -In `FusionWorker.run()`, after registration completes (after `tf.save_pairwise_metrics`), add: -```python -# Estimate pixel size -try: - estimated_px, deviation = tf.estimate_pixel_size() - self.progress.emit( - f"Estimated pixel size: {estimated_px:.4f} µm ({deviation:+.1f}% from metadata)" - ) - if self.use_estimated_pixel_size and abs(deviation) > 1.0: - tf._pixel_size = (estimated_px, estimated_px) - self.progress.emit(f"Using estimated pixel size for stitching") -except ValueError as e: - self.progress.emit(f"Could not estimate pixel size: {e}") -``` - -**Step 3: Add parameter to FusionWorker** - -Add `use_estimated_pixel_size=False` to `FusionWorker.__init__` and store it. - -**Step 4: Pass checkbox value in run_stitching** - -In `run_stitching()`, pass `use_estimated_pixel_size=self.use_estimated_px_checkbox.isChecked()` to FusionWorker. - -**Step 5: Commit** - -```bash -git add gui/app.py -git commit -m "feat: Add GUI option to use estimated pixel size" -``` - ---- - -### Task 3: Add pixel size estimation to PreviewWorker - -**Files:** -- Modify: `gui/app.py` - -**Step 1: Add estimation to PreviewWorker.run()** - -After registration in `PreviewWorker.run()` (after `tf.refine_tile_positions_with_cross_correlation`): -```python -# Estimate pixel size -try: - estimated_px, deviation = tf.estimate_pixel_size() - self.progress.emit( - f"Estimated pixel size: {estimated_px:.4f} µm ({deviation:+.1f}% from metadata)" - ) -except ValueError: - pass # Not enough pairs for estimation -``` - -**Step 2: Commit** - -```bash -git add gui/app.py -git commit -m "feat: Show estimated pixel size in preview" -``` - ---- - -### Task 4: Format and test - -**Step 1: Run black** -```bash -python3 -m black --line-length=100 src/tilefusion/core.py gui/app.py tests/test_core_pixel_estimation.py -``` - -**Step 2: Run all tests** -```bash -pytest tests/ -v -``` - -**Step 3: Commit if needed** -```bash -git add -A -git commit -m "style: Format with black" -``` - ---- - -### Task 5: Manual testing - -1. Run `python3 gui/app.py` -2. Load a multi-tile dataset -3. Enable registration -4. Run preview - check log shows estimated pixel size -5. Check "Use estimated pixel size" -6. Run full stitching - verify it uses estimated value diff --git a/view_stitched.py b/view_stitched.py deleted file mode 100755 index f4b8f46..0000000 --- a/view_stitched.py +++ /dev/null @@ -1,107 +0,0 @@ -#!/usr/bin/env python3 -""" -View stitched microscopy data in napari with proper contrast settings. - -Usage: - python view_stitched.py [path_to_ome_zarr] - -Example: - python view_stitched.py "/media/squid/Extreme SSD/Monkey_fused/manual0.ome.zarr/t0.ome.zarr" -""" - -import sys -import numpy as np -import zarr -import napari -from pathlib import Path - - -def calculate_contrast_limits(data, percentile_low=1, percentile_high=99.5): - """Calculate good contrast limits based on percentiles.""" - # Sample data to avoid loading everything - if data.size > 10_000_000: - # Sample every Nth pixel - step = int(np.sqrt(data.size / 10_000_000)) - sample = data[::step, ::step] - else: - sample = data - - # Remove zeros for percentile calculation - nonzero = sample[sample > 0] - if len(nonzero) == 0: - return (0, 1) - - vmin = np.percentile(nonzero, percentile_low) - vmax = np.percentile(nonzero, percentile_high) - - return (float(vmin), float(vmax)) - - -def main(): - if len(sys.argv) > 1: - zarr_path = Path(sys.argv[1]) - else: - # Default to first timepoint of manual0 - zarr_path = Path("/media/squid/Extreme SSD/Monkey_fused/manual0.ome.zarr/t0.ome.zarr") - - if not zarr_path.exists(): - print(f"Error: Path not found: {zarr_path}") - print("\nUsage: python view_stitched.py [path_to_ome_zarr]") - sys.exit(1) - - print(f"Loading: {zarr_path}") - - # Open the zarr array - image_path = zarr_path / "scale0" / "image" - if not image_path.exists(): - print(f"Error: Expected image path not found: {image_path}") - sys.exit(1) - - store = zarr.DirectoryStore(str(image_path)) - z = zarr.open(store, mode="r") - - print(f"Shape: {z.shape}") - print(f"Dtype: {z.dtype}") - print(f"Chunks: {z.chunks}") - - # Load data - squeeze out singular dimensions - # Shape is (T, C, Z, Y, X) -> squeeze to (C, Y, X) if T=1 and Z=1 - data = z[0, :, 0, :, :] # All channels, first (only) time and z - - print(f"\nData loaded: {data.shape}") - print("Calculating contrast limits for each channel...") - - # Create napari viewer - viewer = napari.Viewer() - - # Channel names (update these based on your actual channels) - channel_names = ["405 nm", "488 nm", "561 nm", "638 nm", "730 nm"] - - # Add each channel with auto-contrast - for c in range(data.shape[0]): - channel_data = data[c, :, :] - - # Calculate contrast limits - contrast_limits = calculate_contrast_limits(channel_data) - - print(f" Channel {c} ({channel_names[c] if c < len(channel_names) else f'Ch{c}'}):") - print(f" Data range: [{channel_data.min()}, {channel_data.max()}]") - print(f" Contrast limits: [{contrast_limits[0]:.0f}, {contrast_limits[1]:.0f}]") - - # Add to napari - viewer.add_image( - channel_data, - name=channel_names[c] if c < len(channel_names) else f"Channel {c}", - contrast_limits=contrast_limits, - colormap="gray", - blending="additive", - ) - - print(f"\nDisplaying in napari...") - print("Tip: Use the layer controls to adjust contrast, toggle channels, or change colormaps") - - napari.run() - - -if __name__ == "__main__": - main()