diff --git a/docs/source/runtime/ingestion-and-canonical-store.rst b/docs/source/runtime/ingestion-and-canonical-store.rst index 11586b2..c144090 100644 --- a/docs/source/runtime/ingestion-and-canonical-store.rst +++ b/docs/source/runtime/ingestion-and-canonical-store.rst @@ -51,6 +51,27 @@ to convert the source into canonical ``*.ome.zarr``. If stale legacy ClearEx groups such as ``data`` or ``results`` exist inside the source ``.n5`` tree, they are ignored for source selection. +Materialization Execution Model +------------------------------- + +Pressing ``Next`` in the setup flow starts +``materialize_experiment_data_store(...)`` and enters the Dask-backed ingestion +path. Setup metadata selection itself does not materialize TIFF payloads. + +Current execution behavior is format-dependent: + +- TIFF/OME-TIFF, HDF5, ``.npy``, and ``.npz`` sources are opened as lazy Dask + arrays and written in bounded parallel batches, but the write graph currently + executes through Dask's local threaded scheduler. +- Generic Zarr and canonical OME-Zarr sources can use the active distributed + Dask ``Client`` for canonical writes. +- Navigate BDV ``.n5`` sources remain Dask-parallel through TensorStore-backed + reads and the active client path. + +This distinction is intentional in the current implementation because some +file-backed graphs, especially TIFF/HDF-backed graphs with locks or +non-serializable handles, do not reliably serialize to distributed workers. + Canonical Store Path Policy --------------------------- diff --git a/src/clearex/flatfield/pipeline.py b/src/clearex/flatfield/pipeline.py index a8281df..69ac44f 100644 --- a/src/clearex/flatfield/pipeline.py +++ b/src/clearex/flatfield/pipeline.py @@ -2401,11 +2401,15 @@ def _fit_profile_tiled( flatfield_sum[ y_read_start:y_read_stop, x_read_start:x_read_stop, - ] += flatfield_tile.astype(np.float64) * blend_weights_64 + ] += ( + flatfield_tile.astype(np.float64) * blend_weights_64 + ) darkfield_sum[ y_read_start:y_read_stop, x_read_start:x_read_stop, - ] += darkfield_tile.astype(np.float64) * blend_weights_64 + ] += ( + darkfield_tile.astype(np.float64) * blend_weights_64 + ) weight_sum[ y_read_start:y_read_stop, x_read_start:x_read_stop, diff --git a/src/clearex/gui/README.md b/src/clearex/gui/README.md index f03332e..75bbdc0 100644 --- a/src/clearex/gui/README.md +++ b/src/clearex/gui/README.md @@ -10,10 +10,19 @@ This folder owns the PyQt6 UX in `app.py`. - `Create Experiment List` for recursive folder scans or saved-list reloads, - drag and drop of experiments, folders, or `.clearex-experiment-list.json` files - Add/remove experiment entries from the list and persist the list for reuse + - the setup experiment list supports `Delete` / `Backspace` to remove the + current selection without using the `Remove Selected` button - Auto-load metadata when the current list selection changes - for Navigate BDV ``file_type: N5``, source metadata must come from the Navigate experiment context plus TensorStore-backed BDV source summary, not from sending the raw ``.n5`` path through the generic Zarr reader + - Setup metadata/source-resolution contexts are cached per experiment path to + avoid repeated recursive TIFF candidate scans and source re-opens when + reselecting experiments in the same session; cache entries are invalidated + on explicit reload, source-directory override changes, and list removal + - Navigate TIFF metadata verification should use `tifffile.TiffFile(...)` + header parsing instead of constructing a Dask-backed source array during + setup-screen metadata refresh - Configure Dask backend and OME-Zarr save options - Configure `Spatial Calibration` for the currently selected experiment: - map world `z/y/x` to Navigate stage `X/Y/Z/F` or `none`, diff --git a/src/clearex/gui/app.py b/src/clearex/gui/app.py index 66a2f70..30f524d 100644 --- a/src/clearex/gui/app.py +++ b/src/clearex/gui/app.py @@ -146,6 +146,7 @@ QIcon, QImage, QImageReader, + QKeyEvent, QPixmap, ) from PyQt6.QtWidgets import ( @@ -2070,6 +2071,54 @@ def _apply_experiment_overrides( return out +def _path_signature(path: Path) -> Optional[tuple[int, int]]: + """Return a lightweight `(mtime_ns, size)` signature for cache validation. + + Parameters + ---------- + path : pathlib.Path + Path to stat. + + Returns + ------- + tuple[int, int], optional + Signature tuple when stat succeeds, otherwise ``None``. + """ + try: + stat_result = path.stat() + except OSError: + return None + return int(stat_result.st_mtime_ns), int(stat_result.st_size) + + +@dataclass(frozen=True) +class ExperimentMetadataCacheEntry: + """Cache one resolved setup metadata context for an experiment path. + + Parameters + ---------- + experiment : NavigateExperiment + Parsed experiment metadata. + source_data_path : pathlib.Path + Resolved acquisition source path. + image_info : ImageInfo + Source metadata summary loaded for the setup panel. + experiment_signature : tuple[int, int], optional + Cached signature of the experiment descriptor file. + source_signature : tuple[int, int], optional + Cached signature of the resolved source path. + override_directory : pathlib.Path, optional + Source-directory override active at cache creation time. + """ + + experiment: NavigateExperiment + source_data_path: Path + image_info: ImageInfo + experiment_signature: Optional[tuple[int, int]] + source_signature: Optional[tuple[int, int]] + override_directory: Optional[Path] + + @dataclass(frozen=True) class ExperimentStorePreparationRequest: """Describe one experiment store that may need canonical materialization. @@ -4876,6 +4925,9 @@ def __init__(self, initial: WorkflowConfig) -> None: self._experiment_list_file_path: Optional[Path] = None self._experiment_list_dirty = False self._source_data_directory_overrides: Dict[Path, Path] = {} + self._experiment_metadata_cache: Dict[ + Path, ExperimentMetadataCacheEntry + ] = {} self._spatial_calibration_drafts: Dict[Path, SpatialCalibrationConfig] = {} self._current_spatial_calibration: SpatialCalibrationConfig = ( initial.spatial_calibration @@ -5774,6 +5826,128 @@ def _experiment_list_paths(self) -> list[Path]: experiment_paths.append(path) return experiment_paths + def _prune_experiment_metadata_cache(self, valid_paths: Sequence[Path]) -> None: + """Drop cached metadata entries no longer present in the list. + + Parameters + ---------- + valid_paths : sequence[pathlib.Path] + Experiment paths that should remain cache-eligible. + + Returns + ------- + None + Cache is pruned in-place. + """ + keep = {Path(path).expanduser().resolve() for path in valid_paths} + for cached_path in list(self._experiment_metadata_cache): + if cached_path not in keep: + self._experiment_metadata_cache.pop(cached_path, None) + + def _invalidate_experiment_metadata_cache_entry( + self, + experiment_path: Path, + ) -> None: + """Invalidate one cached experiment metadata entry. + + Parameters + ---------- + experiment_path : pathlib.Path + Experiment path whose cache entry should be removed. + + Returns + ------- + None + Cache is updated in-place. + """ + resolved_path = Path(experiment_path).expanduser().resolve() + self._experiment_metadata_cache.pop(resolved_path, None) + + def _cached_experiment_metadata_context( + self, + experiment_path: Path, + ) -> Optional[tuple[Path, NavigateExperiment, Path, ImageInfo]]: + """Return a valid cached metadata context when available. + + Parameters + ---------- + experiment_path : pathlib.Path + Experiment descriptor path to resolve. + + Returns + ------- + tuple[pathlib.Path, NavigateExperiment, pathlib.Path, ImageInfo], optional + Cached context tuple when signatures and overrides still match. + """ + resolved_experiment_path = Path(experiment_path).expanduser().resolve() + entry = self._experiment_metadata_cache.get(resolved_experiment_path) + if entry is None: + return None + + current_override = self._source_data_directory_overrides.get( + resolved_experiment_path + ) + if current_override != entry.override_directory: + self._experiment_metadata_cache.pop(resolved_experiment_path, None) + return None + + if _path_signature(resolved_experiment_path) != entry.experiment_signature: + self._experiment_metadata_cache.pop(resolved_experiment_path, None) + return None + + resolved_source_path = Path(entry.source_data_path).expanduser().resolve() + if _path_signature(resolved_source_path) != entry.source_signature: + self._experiment_metadata_cache.pop(resolved_experiment_path, None) + return None + + return ( + resolved_experiment_path, + entry.experiment, + resolved_source_path, + entry.image_info, + ) + + def _store_experiment_metadata_context( + self, + *, + experiment_path: Path, + experiment: NavigateExperiment, + source_data_path: Path, + info: ImageInfo, + ) -> None: + """Store one resolved metadata context in the in-memory cache. + + Parameters + ---------- + experiment_path : pathlib.Path + Experiment descriptor path. + experiment : NavigateExperiment + Parsed experiment metadata. + source_data_path : pathlib.Path + Resolved source path for the experiment. + info : ImageInfo + Source metadata summary. + + Returns + ------- + None + Cache is updated in-place. + """ + resolved_experiment_path = Path(experiment_path).expanduser().resolve() + resolved_source_path = Path(source_data_path).expanduser().resolve() + self._experiment_metadata_cache[resolved_experiment_path] = ( + ExperimentMetadataCacheEntry( + experiment=experiment, + source_data_path=resolved_source_path, + image_info=info, + experiment_signature=_path_signature(resolved_experiment_path), + source_signature=_path_signature(resolved_source_path), + override_directory=self._source_data_directory_overrides.get( + resolved_experiment_path + ), + ) + ) + def _current_selected_experiment_path(self) -> Optional[Path]: """Return the current experiment selection path. @@ -5810,6 +5984,7 @@ def _set_experiment_list_paths( Widget state is updated in-place. """ normalized_paths = _deduplicate_resolved_paths(experiment_paths) + self._prune_experiment_metadata_cache(normalized_paths) prior_block_state = self._experiment_list.blockSignals(True) self._experiment_list.clear() for experiment_path in normalized_paths: @@ -6003,7 +6178,7 @@ def _clear_loaded_experiment_context(self) -> None: self._loaded_target_store_path = None def eventFilter(self, watched: QObject, event: QEvent) -> bool: - """Handle drag/drop events routed through the experiment list. + """Handle list keyboard removal and drag/drop routed through the list. Parameters ---------- @@ -6019,6 +6194,15 @@ def eventFilter(self, watched: QObject, event: QEvent) -> bool: event filter result. """ if watched in {self._experiment_list, self._experiment_list.viewport()}: + if ( + watched is self._experiment_list + and isinstance(event, QKeyEvent) + and event.type() == QEvent.Type.KeyPress + and event.key() in {Qt.Key.Key_Delete, Qt.Key.Key_Backspace} + ): + self._on_remove_selected_experiments() + event.accept() + return True if isinstance(event, QDragEnterEvent): if self._can_accept_experiment_drop(event.mimeData()): event.acceptProposedAction() @@ -6342,6 +6526,7 @@ def _on_remove_selected_experiments(self) -> None: for removed_path in selected_paths: self._source_data_directory_overrides.pop(removed_path, None) + self._invalidate_experiment_metadata_cache_entry(removed_path) if ( self._experiment_list_file_path is not None and remaining_paths != self._experiment_list_paths() @@ -6543,9 +6728,25 @@ def _load_metadata_for_experiment_path( return try: - loaded_path, experiment, source_data_path, info = ( - self._load_experiment_context(path=resolved_experiment_path) + cached_context = ( + None + if force_reload + else self._cached_experiment_metadata_context( + resolved_experiment_path + ) ) + if cached_context is None: + loaded_path, experiment, source_data_path, info = ( + self._load_experiment_context(path=resolved_experiment_path) + ) + self._store_experiment_metadata_context( + experiment_path=loaded_path, + experiment=experiment, + source_data_path=source_data_path, + info=info, + ) + else: + loaded_path, experiment, source_data_path, info = cached_context except Exception as exc: logging.getLogger(__name__).exception( "Failed to load experiment metadata from %s.", @@ -6649,6 +6850,7 @@ def _prompt_for_source_data_directory( self._source_data_directory_overrides[ experiment.path.expanduser().resolve() ] = override_directory + self._invalidate_experiment_metadata_cache_entry(experiment.path) return source_data_path def _load_experiment_context( @@ -6715,11 +6917,17 @@ def _resolve_store_preparation_request( experiment = self._loaded_experiment source_data_path = self._loaded_source_data_path else: - _loaded_path, experiment, source_data_path = ( - self._resolve_experiment_source_context( - path=resolved_experiment_path - ) + cached_context = self._cached_experiment_metadata_context( + resolved_experiment_path ) + if cached_context is not None: + _loaded_path, experiment, source_data_path, _info = cached_context + else: + _loaded_path, experiment, source_data_path = ( + self._resolve_experiment_source_context( + path=resolved_experiment_path + ) + ) target_store = resolve_data_store_path(experiment, source_data_path) return ExperimentStorePreparationRequest( experiment_path=resolved_experiment_path, @@ -10045,9 +10253,7 @@ def _build_registration_parameter_rows(self, form: QFormLayout) -> None: ) form.addRow(fusion_section) - perf_section, perf_form = self._build_parameter_section_card( - "Performance" - ) + perf_section, perf_form = self._build_parameter_section_card("Performance") self._registration_max_pairwise_voxels_spin = QSpinBox() self._registration_max_pairwise_voxels_spin.setRange(0, 100_000_000) self._registration_max_pairwise_voxels_spin.setSingleStep(50_000) @@ -10959,7 +11165,9 @@ def _read_component_combo_value(combo: QComboBox) -> str: selected_data = combo.currentData() return str(selected_data).strip() if selected_data is not None else "" - def _available_channels_for_component(component_value: str) -> tuple[int, ...]: + def _available_channels_for_component( + component_value: str, + ) -> tuple[int, ...]: component = str(component_value).strip() or "data" resolved_component = resolve_analysis_input_component(component) if store_root is None: diff --git a/src/clearex/io/experiment.py b/src/clearex/io/experiment.py index 92098eb..022ae6d 100644 --- a/src/clearex/io/experiment.py +++ b/src/clearex/io/experiment.py @@ -90,7 +90,7 @@ source_cache_component, update_store_metadata, ) -from clearex.io.read import ImageInfo +from clearex.io.read import ImageInfo, open_tiff_as_dask from clearex.workflow import ( SpatialCalibrationConfig, spatial_calibration_to_dict, @@ -1110,8 +1110,7 @@ def _open_source_as_dask( axes = _normalize_axes_descriptor( getattr(series, "axes", None), ndim=len(tuple(series.shape)) ) - tiff_store = tifffile.imread(str(source_path), aszarr=True) - source_array = da.from_zarr(tiff_store) + source_array = open_tiff_as_dask(source_path) return source_array, axes, meta if suffix in {".h5", ".hdf5", ".hdf"}: @@ -1755,6 +1754,57 @@ def load_navigate_experiment_source_image_info( source layout. """ resolved_source = Path(source_path).expanduser().resolve() + + if resolved_source.suffix.lower() in {".tif", ".tiff"}: + with tifffile.TiffFile(str(resolved_source)) as tif: + series = tif.series[0] + axes = getattr(series, "axes", None) + metadata: dict[str, Any] = { + "tiff_page_count": int(len(tif.pages)), + "tiff_tag_names": tuple( + str(tag.name) for tag in tif.pages[0].tags.values() + ), + } + image_description = tif.pages[0].description + if isinstance(image_description, str) and image_description.strip(): + metadata["ImageDescription"] = image_description + try: + description_payload = json.loads(image_description) + except Exception: + description_payload = None + if isinstance(description_payload, dict): + metadata.update(description_payload) + spacing = description_payload.get("spacing") + unit = str(description_payload.get("unit", "")).strip().lower() + if spacing is not None and unit in {"um", "micron", "microns"}: + try: + spacing_um = float(spacing) + except (TypeError, ValueError): + spacing_um = None + xy_um = ( + float(experiment.xy_pixel_size_um) + if experiment.xy_pixel_size_um is not None + and experiment.xy_pixel_size_um > 0 + else None + ) + if ( + spacing_um is not None + and spacing_um > 0 + and xy_um is not None + ): + metadata["voxel_size_um_zyx"] = ( + spacing_um, + xy_um, + xy_um, + ) + return ImageInfo( + path=resolved_source, + shape=tuple(int(v) for v in series.shape), + dtype=np.dtype(series.dtype), + axes=str(axes) if axes else None, + metadata=metadata, + ) + if ( _normalize_file_type(experiment.file_type) == "N5" and resolved_source.suffix.lower() == ".n5" diff --git a/src/clearex/io/provenance.py b/src/clearex/io/provenance.py index db8673c..ee4b1f5 100644 --- a/src/clearex/io/provenance.py +++ b/src/clearex/io/provenance.py @@ -428,7 +428,14 @@ def _default_outputs(workflow: WorkflowConfig) -> Dict[str, Any]: key = _normalize_analysis_name(analysis_name) component = ( public_analysis_root(key) - if key in {"flatfield", "deconvolution", "shear_transform", "usegment3d", "registration"} + if key + in { + "flatfield", + "deconvolution", + "shear_transform", + "usegment3d", + "registration", + } else analysis_auxiliary_root(key) ) outputs[key] = { diff --git a/src/clearex/io/read.py b/src/clearex/io/read.py index f459504..8f5a378 100644 --- a/src/clearex/io/read.py +++ b/src/clearex/io/read.py @@ -49,6 +49,72 @@ logger.addHandler(hdlr=logging.NullHandler()) +def open_tiff_as_dask( + path: Path, + *, + chunks: Optional[Union[int, Tuple[int, ...]]] = None, + **kwargs: Any, +) -> da.Array: + """Open one TIFF path as a lazy Dask array with zarr3-safe fallback. + + Parameters + ---------- + path : Path + TIFF source path. + chunks : int or tuple[int, ...], optional + Optional Dask chunking override. + **kwargs : dict + Additional keyword arguments forwarded to tifffile readers. + + Returns + ------- + dask.array.Array + Lazy array view over the TIFF payload. + + Notes + ----- + Under ``zarr>=3``, ``da.from_zarr`` may reject tifffile's ``ZarrTiffStore`` + objects. In that case we fall back to a memory-mapped TIFF view to preserve + lazy chunked reads without requiring zarr v2 behavior. + """ + tiff_store = tifffile.imread(str(path), aszarr=True, **kwargs) + try: + return ( + da.from_zarr(tiff_store, chunks=chunks) + if chunks is not None + else da.from_zarr(tiff_store) + ) + except TypeError as exc: + if "Unsupported type for store_like" not in str(exc): + raise + close = getattr(tiff_store, "close", None) + if callable(close): + try: + close() + except Exception: + pass + logger.info( + "Falling back to tifffile.memmap for %s because ZarrTiffStore is " + "not supported by da.from_zarr under zarr3.", + path.name, + ) + + try: + mmap = tifffile.memmap(str(path), **kwargs) + return da.from_array( + mmap, + chunks=chunks if chunks is not None else "auto", + asarray=False, + ) + except Exception: + logger.info( + "tifffile.memmap unavailable for %s; using tifffile.imread fallback.", + path.name, + ) + arr = tifffile.imread(str(path), **kwargs) + return da.from_array(arr, chunks=chunks if chunks is not None else "auto") + + @dataclass class ImageInfo: """Container for image metadata. @@ -360,8 +426,7 @@ def open( if prefer_dask: # Option A: use tifffile's OME-as-zarr path if possible # This keeps it lazy and chunked without loading into RAM - store = tifffile.imread(str(path), aszarr=True) - darr = da.from_zarr(store, chunks=chunks) if chunks else da.from_zarr(store) + darr = open_tiff_as_dask(path, chunks=chunks, **kwargs) info = ImageInfo( path=path, shape=tuple(darr.shape), @@ -620,7 +685,9 @@ def open( meta["ome_selected"] = True if prefer_dask: darr = ( - da.from_zarr(array, chunks=chunks) if chunks else da.from_zarr(array) + da.from_zarr(array, chunks=chunks) + if chunks + else da.from_zarr(array) ) logger.info(f"Loaded public OME-Zarr array from {path.name}.") info = ImageInfo( diff --git a/src/clearex/workflow.py b/src/clearex/workflow.py index 8b90760..00bb5d0 100644 --- a/src/clearex/workflow.py +++ b/src/clearex/workflow.py @@ -1806,9 +1806,7 @@ def _normalize_registration_parameters( ants_sampling_rate = float(normalized.get("ants_sampling_rate", 0.20)) if ants_sampling_rate <= 0.0 or ants_sampling_rate > 1.0: - raise ValueError( - "registration ants_sampling_rate must be in the range (0, 1]." - ) + raise ValueError("registration ants_sampling_rate must be in the range (0, 1].") normalized["ants_sampling_rate"] = ants_sampling_rate normalized["use_phase_correlation"] = bool( diff --git a/tests/gui/test_gui_execution.py b/tests/gui/test_gui_execution.py index e8fd1d8..b462512 100644 --- a/tests/gui/test_gui_execution.py +++ b/tests/gui/test_gui_execution.py @@ -950,6 +950,35 @@ def test_setup_dialog_resolves_spatial_calibration_drafts_per_experiment() -> No dialog.close() +def test_setup_dialog_delete_key_removes_selected_experiment() -> None: + if not app_module.HAS_PYQT6: + return + + app = app_module.QApplication.instance() + if app is None: + app = app_module.QApplication([]) + + dialog = app_module.ClearExSetupDialog(initial=app_module.WorkflowConfig()) + first = Path("/tmp/cell_001/experiment.yml").resolve() + second = Path("/tmp/cell_002/experiment.yml").resolve() + dialog._set_experiment_list_paths((first, second), current_path=first) + dialog._experiment_list.setCurrentRow(0) + + event = app_module.QKeyEvent( + app_module.QEvent.Type.KeyPress, + app_module.Qt.Key.Key_Delete, + app_module.Qt.KeyboardModifier.NoModifier, + ) + handled = dialog.eventFilter(dialog._experiment_list, event) + + assert handled is True + assert dialog._experiment_list.count() == 1 + remaining = dialog._experiment_path_from_item(dialog._experiment_list.item(0)) + assert remaining == second + + dialog.close() + + def test_setup_dialog_prefills_spatial_calibration_from_existing_store( tmp_path: Path, ) -> None: diff --git a/tests/io/test_experiment.py b/tests/io/test_experiment.py index 83464f8..ce49946 100644 --- a/tests/io/test_experiment.py +++ b/tests/io/test_experiment.py @@ -39,6 +39,7 @@ # Local Imports import clearex.io.experiment as experiment_module +import clearex.io.read as read_module from clearex.io.experiment import ( default_analysis_store_path, find_experiment_data_candidates, @@ -1455,6 +1456,48 @@ def open(self, *args, **kwargs): assert info.metadata["channels"] == 2 +def test_load_navigate_experiment_source_image_info_reads_tiff_header_only( + tmp_path: Path, +) -> None: + experiment_path = tmp_path / "experiment.yml" + _write_minimal_experiment( + experiment_path, + save_directory=tmp_path, + file_type="TIFF", + is_multiposition=False, + ) + experiment = load_navigate_experiment(experiment_path) + + source_path = tmp_path / "CH00_000000.tiff" + expected = np.arange(2 * 3 * 4, dtype=np.uint16).reshape((2, 3, 4)) + tifffile.imwrite( + str(source_path), + expected, + metadata={"axes": "ZYX", "spacing": 0.2, "unit": "um"}, + ) + + class _FailingOpener: + def open(self, *args, **kwargs): + raise AssertionError("ImageOpener.open should not be used for TIFF.") + + info = load_navigate_experiment_source_image_info( + experiment=experiment, + source_path=source_path, + opener=_FailingOpener(), + ) + + assert info.path == source_path.resolve() + assert info.shape == expected.shape + assert info.dtype == np.dtype(np.uint16) + assert info.axes == "ZYX" + assert info.metadata is not None + assert int(info.metadata["tiff_page_count"]) >= 1 + assert "ImageDescription" in info.metadata + assert info.metadata["axes"] == "ZYX" + assert info.metadata["spacing"] == 0.2 + assert info.metadata["unit"] == "um" + + def test_open_source_as_dask_rejects_standalone_n5_source(tmp_path: Path) -> None: source_path = tmp_path / "standalone.n5" _write_real_n5_dataset( @@ -1471,6 +1514,31 @@ def test_open_source_as_dask_rejects_standalone_n5_source(tmp_path: Path) -> Non ) +def test_open_source_as_dask_tiff_falls_back_when_store_is_unsupported( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + source_path = tmp_path / "source.tiff" + expected = np.arange(2 * 3 * 4, dtype=np.uint16).reshape((2, 3, 4)) + tifffile.imwrite(str(source_path), expected, metadata={"axes": "ZYX"}) + + def _raise_unsupported_store(*_args, **_kwargs): + raise TypeError("Unsupported type for store_like: 'ZarrTiffStore'") + + monkeypatch.setattr(read_module.da, "from_zarr", _raise_unsupported_store) + + with ExitStack() as exit_stack: + source_array, source_axes, source_meta = experiment_module._open_source_as_dask( + source_path, + exit_stack=exit_stack, + ) + + assert isinstance(source_array, da.Array) + assert tuple(source_array.shape) == expected.shape + assert np.array_equal(source_array.compute(), expected) + assert tuple(source_axes or ()) == ("z", "y", "x") + assert source_meta["source_path"] == str(source_path) + + def test_materialize_experiment_data_store_stacks_bdv_ome_zarr_setups( tmp_path: Path, ): diff --git a/tests/io/test_read.py b/tests/io/test_read.py index d9b39e0..924a2b4 100644 --- a/tests/io/test_read.py +++ b/tests/io/test_read.py @@ -246,7 +246,6 @@ def open( info = ImageInfo(path=path, shape=arr.shape, dtype=arr.dtype) return arr, info - reader = EmptySuffixReader() assert EmptySuffixReader.claims(Path("any.file")) is False def test_open_method_signature(self): @@ -488,6 +487,29 @@ def test_open_tiff_with_custom_chunks(self, tiff_reader, temp_tiff_3d): # Check that chunking was applied assert arr.chunks is not None + def test_open_tiff_as_dask_falls_back_when_store_is_unsupported( + self, monkeypatch, tiff_reader, temp_tiff_3d + ): + """TIFF Dask loads should still work when da.from_zarr rejects ZarrTiffStore.""" + tiff_path, expected_arr = temp_tiff_3d + calls = {"count": 0} + + def _raise_unsupported_store(*_args, **_kwargs): + calls["count"] += 1 + raise TypeError("Unsupported type for store_like: 'ZarrTiffStore'") + + monkeypatch.setattr(da, "from_zarr", _raise_unsupported_store) + + arr, info = tiff_reader.open( + tiff_path, prefer_dask=True, chunks=(2, 64, 64) + ) + + assert calls["count"] >= 1 + assert isinstance(arr, da.Array) + assert arr.shape == expected_arr.shape + assert arr.dtype == expected_arr.dtype + assert np.array_equal(arr.compute(), expected_arr) + def test_open_tiff_with_single_chunk_size(self, tiff_reader, temp_tiff_3d): """Test opening a TIFF file with a single chunk size for all dimensions.""" tiff_path, expected_arr = temp_tiff_3d @@ -1620,7 +1642,7 @@ def test_hdf5_empty_file_raises_error(self, hdf5_reader, tmp_path): hdf5_path = tmp_path / "empty.h5" # Create an empty file with no datasets - with h5py.File(str(hdf5_path), "w") as f: + with h5py.File(str(hdf5_path), "w"): pass with pytest.raises(ValueError, match="No datasets found"):