feat: Add z-level, timepoint, and channel selection for registration#19
feat: Add z-level, timepoint, and channel selection for registration#19hongquanli merged 3 commits intoCephla-Lab:mainfrom
Conversation
Add the ability to select which z-level and timepoint to use for registration in multi-z, multi-timepoint acquisitions. Previously, registration always used the middle z-level and first timepoint. Changes: - Add registration_z and registration_t parameters to TileFusion.__init__ - Validate parameters after metadata loading when n_z/n_t are known - Update _read_tile and _read_tile_region to use registration z/t defaults - Add dataset_n_z and dataset_n_t state variables to GUI - Add Z-level and Timepoint spinboxes (shown when registration enabled AND dataset has multi-z/t) - Load dataset dimensions on file drop and update control visibility - Pass registration z/t values to FusionWorker and PreviewWorker Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add a dropdown to select which channel is used for tile registration. The channel selector appears alongside z-level and timepoint controls when registration is enabled and the dataset has multiple channels. - Add dataset_n_channels and dataset_channel_names state variables - Add channel combo to registration options widget - Populate channel names from TileFusion metadata on file load - Pass selected channel to PreviewWorker and FusionWorker Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR adds user-configurable selection of the z-plane, timepoint, and channel used during registration refinement, exposing the controls in the GUI settings when registration is enabled and the dataset supports multiple z/t/channels.
Changes:
- Extend
TileFusionto acceptregistration_zandregistration_t, and use them as defaults for tile reads used during registration/preview. - Extend GUI workers to pass
registration_z,registration_t, and registration channel (viachannel_to_use) intoTileFusion. - Add GUI controls (spinboxes/combobox) to choose z-level, timepoint, and channel for registration.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
src/tilefusion/core.py |
Adds registration z/t parameters, validates them, and changes default tile read behavior to use selected z/t. |
gui/app.py |
Adds GUI state + controls for z/t/channel selection and forwards these values into background workers/TileFusion. |
Comments suppressed due to low confidence (4)
src/tilefusion/core.py:469
_read_tileannotatestime_idxasintbut defaults it toNone. This is an invalid type combination (and same forz_level). Update the annotations toOptional[int](or default to anint) to keep typing consistent.
def _read_tile(self, tile_idx: int, z_level: int = None, time_idx: 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
if time_idx is None:
time_idx = self._registration_t # Default to registration timepoint
src/tilefusion/core.py:115
- The new
registration_z/registration_tparameters are public API, but the class docstring’s parameter list wasn’t updated to document them. Please add entries describing what they control and how defaults are chosen (middle z / t=0).
registration_z: Optional[int] = None,
registration_t: int = 0,
):
src/tilefusion/core.py:475
- In the Zarr branch,
_read_tileignorestime_idxandz_levelentirely (it always readszarr_ts[0, tile_idx, ...]and max-projects Z). That means the new registration timepoint/z-level selection won’t actually affect registration (or preview) for Zarr datasets. Update the Zarr read path to index the requested timepoint and to use the requested z-plane (or clearly document that Zarr registration is always time=0 with Z max-projection).
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)
elif self._is_individual_tiffs_format:
src/tilefusion/core.py:525
_read_tile_regionalso ignorestime_idx/z_levelfor Zarr inputs (viaread_zarr_regionwhich is hardcoded to time 0 and max-projects Z). This prevents the new registration timepoint/z-level controls from working for Zarr-backed datasets.
if self._is_zarr_format:
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
)
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| # Z-stack and time series properties | ||
| self.n_z = self._metadata.get("n_z", 1) | ||
| self.n_t = self._metadata.get("n_t", 1) | ||
| self.dz_um = self._metadata.get("dz_um", 1.0) | ||
| self._time_folders = self._metadata.get("time_folders", None) | ||
| self._middle_z = self.n_z // 2 # Use middle z-level for registration | ||
|
|
||
| # Registration z/t selection (validate after n_z/n_t are known) | ||
| if registration_z is None: | ||
| self._registration_z = self._middle_z | ||
| else: | ||
| if registration_z < 0 or registration_z >= self.n_z: | ||
| raise ValueError(f"registration_z={registration_z} out of range [0, {self.n_z})") | ||
| self._registration_z = registration_z | ||
|
|
||
| if registration_t < 0 or registration_t >= self.n_t: | ||
| raise ValueError(f"registration_t={registration_t} out of range [0, {self.n_t})") | ||
| self._registration_t = registration_t |
There was a problem hiding this comment.
For Zarr inputs, registration_t/registration_z can’t work as implemented: self.n_t/self.n_z come from metadata keys n_t/n_z, but load_zarr_metadata only provides time_dim (and no n_z), so n_t/n_z stay at 1. This makes the validation reject any non-zero registration_t and prevents multi-time / multi-z datasets from ever exposing these controls. Consider mapping time_dim -> n_t and exposing z_dim -> n_z for Zarr metadata.
| try: | ||
| from tilefusion import TileFusion | ||
|
|
||
| tf_temp = TileFusion(file_path) | ||
| self.dataset_n_z = tf_temp.n_z | ||
| self.dataset_n_t = tf_temp.n_t | ||
| 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) | ||
| ] | ||
| tf_temp.close() | ||
| if self.dataset_n_z > 1 or self.dataset_n_t > 1: | ||
| self.log(f"Dataset: {self.dataset_n_z} z-levels, {self.dataset_n_t} timepoints") | ||
| self._update_reg_zt_controls() | ||
| except Exception: |
There was a problem hiding this comment.
on_file_dropped creates a temporary TileFusion instance to probe dataset dimensions, but tf_temp.close() is only called on the success path. If anything raises after construction (e.g., during metadata access), file handles may leak. Use a context manager (with TileFusion(...) as tf_temp:) or a try/finally to guarantee cleanup.
| # Update z spinbox | ||
| self.reg_z_label.setVisible(has_multi_z) | ||
| self.reg_z_spin.setVisible(has_multi_z) | ||
| if has_multi_z: | ||
| self.reg_z_spin.setRange(0, self.dataset_n_z - 1) | ||
| self.reg_z_spin.setValue(self.dataset_n_z // 2) # Default to middle | ||
|
|
||
| # Update t spinbox | ||
| self.reg_t_label.setVisible(has_multi_t) | ||
| self.reg_t_spin.setVisible(has_multi_t) | ||
| if has_multi_t: | ||
| self.reg_t_spin.setRange(0, self.dataset_n_t - 1) | ||
| self.reg_t_spin.setValue(0) # Default to first timepoint | ||
|
|
||
| # Update channel combo | ||
| 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) |
There was a problem hiding this comment.
_update_reg_zt_controls() resets the z/time/channel selections every time it runs (setValue(...) / setCurrentIndex(0)). Since it’s called when registration is toggled and when files are dropped, a user’s selection can be lost unexpectedly. Preserve the current values when still in range, and only apply defaults when the dataset changes or on first show.
| # Update z spinbox | |
| self.reg_z_label.setVisible(has_multi_z) | |
| self.reg_z_spin.setVisible(has_multi_z) | |
| if has_multi_z: | |
| self.reg_z_spin.setRange(0, self.dataset_n_z - 1) | |
| self.reg_z_spin.setValue(self.dataset_n_z // 2) # Default to middle | |
| # Update t spinbox | |
| self.reg_t_label.setVisible(has_multi_t) | |
| self.reg_t_spin.setVisible(has_multi_t) | |
| if has_multi_t: | |
| self.reg_t_spin.setRange(0, self.dataset_n_t - 1) | |
| self.reg_t_spin.setValue(0) # Default to first timepoint | |
| # Update channel combo | |
| 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) | |
| # Preserve previous selections where possible | |
| prev_z = self.reg_z_spin.value() if self.reg_z_spin.isVisible() else None | |
| prev_t = self.reg_t_spin.value() if self.reg_t_spin.isVisible() else None | |
| prev_channel_index = ( | |
| self.reg_channel_combo.currentIndex() | |
| if self.reg_channel_combo.count() > 0 | |
| else None | |
| ) | |
| existing_channels = [ | |
| self.reg_channel_combo.itemText(i) | |
| for i in range(self.reg_channel_combo.count()) | |
| ] | |
| # Update z spinbox | |
| self.reg_z_label.setVisible(has_multi_z) | |
| self.reg_z_spin.setVisible(has_multi_z) | |
| if has_multi_z: | |
| self.reg_z_spin.setRange(0, self.dataset_n_z - 1) | |
| max_z = self.dataset_n_z - 1 | |
| if prev_z is not None and 0 <= prev_z <= max_z: | |
| self.reg_z_spin.setValue(prev_z) | |
| else: | |
| # Default to middle slice when no previous valid selection | |
| self.reg_z_spin.setValue(self.dataset_n_z // 2) | |
| # Update t spinbox | |
| self.reg_t_label.setVisible(has_multi_t) | |
| self.reg_t_spin.setVisible(has_multi_t) | |
| if has_multi_t: | |
| self.reg_t_spin.setRange(0, self.dataset_n_t - 1) | |
| max_t = self.dataset_n_t - 1 | |
| if prev_t is not None and 0 <= prev_t <= max_t: | |
| self.reg_t_spin.setValue(prev_t) | |
| else: | |
| # Default to first timepoint when no previous valid selection | |
| self.reg_t_spin.setValue(0) | |
| # Update channel combo | |
| self.reg_channel_label.setVisible(has_multi_channel) | |
| self.reg_channel_combo.setVisible(has_multi_channel) | |
| if has_multi_channel: | |
| # Only rebuild items if the dataset channels changed | |
| if existing_channels != self.dataset_channel_names: | |
| self.reg_channel_combo.clear() | |
| self.reg_channel_combo.addItems(self.dataset_channel_names) | |
| # On dataset change, default to first channel | |
| self.reg_channel_combo.setCurrentIndex(0) | |
| else: | |
| # Preserve previous channel selection if still valid | |
| max_channel_index = self.reg_channel_combo.count() - 1 | |
| if ( | |
| prev_channel_index is not None | |
| and 0 <= prev_channel_index <= max_channel_index | |
| ): | |
| self.reg_channel_combo.setCurrentIndex(prev_channel_index) | |
| else: | |
| self.reg_channel_combo.setCurrentIndex(0) |
- 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 <noreply@anthropic.com>
Summary
These controls appear in the Settings section when "Enable registration refinement" is checked and the dataset has multiple z-levels, timepoints, or channels respectively.
Test plan
🤖 Generated with Claude Code