From 50315f878ce2575b0fd2454bbe02d1173cab51e3 Mon Sep 17 00:00:00 2001 From: Dusten Hubbard Date: Fri, 10 Jul 2026 11:31:21 -0500 Subject: [PATCH] feat(convert): default scaled zarr to .zarr beside the source images --- .../modules/backend/func/zarr_naming.py | 34 ++++++++++ PyReconstruct/modules/gui/main/main_window.py | 10 ++- tests/test_zarr_convert_naming.py | 62 +++++++++++++++++++ 3 files changed, 104 insertions(+), 2 deletions(-) create mode 100644 PyReconstruct/modules/backend/func/zarr_naming.py create mode 100644 tests/test_zarr_convert_naming.py diff --git a/PyReconstruct/modules/backend/func/zarr_naming.py b/PyReconstruct/modules/backend/func/zarr_naming.py new file mode 100644 index 00000000..d3412479 --- /dev/null +++ b/PyReconstruct/modules/backend/func/zarr_naming.py @@ -0,0 +1,34 @@ +"""Naming/location helpers for the "convert images to scaled zarr" action. + +Kept Qt-free so the default-path and suffix logic is unit-testable without a GUI. +The store name must end in ``zarr`` -- ``image_layer.ImageLayer.is_zarr_file`` is +``series.src_dir.endswith("zarr")`` -- so ``ensure_zarr_suffix`` enforces that +invariant regardless of what the user types or how the save dialog behaves. +""" +import os + + +def default_scaled_zarr_fp(src_dir: str, series_name: str) -> str: + """Default save path for a newly converted scaled zarr. + + Places ``.zarr`` as a **sibling** of the source image directory (one + level up), so the zarr sits next to the images rather than inside them. When + the source directory is empty/unknown, returns just the filename and lets the + save dialog fall back to its last-used folder. + """ + name = f"{series_name}.zarr" + if src_dir: + return os.path.join(os.path.dirname(os.path.normpath(src_dir)), name) + return name + + +def ensure_zarr_suffix(fp: str) -> str: + """Guarantee the store path ends in ``zarr`` (what ``is_zarr_file`` checks). + + A name already ending in ``zarr`` is left untouched -- so an override like + ``proj.zarr`` or ``proj-zarr`` is respected -- and ``.zarr`` is appended + otherwise (e.g. the user typed a bare ``proj``). + """ + if not fp: + return fp + return fp if os.path.normpath(fp).endswith("zarr") else fp + ".zarr" diff --git a/PyReconstruct/modules/gui/main/main_window.py b/PyReconstruct/modules/gui/main/main_window.py index 97df20cf..ee116b2e 100644 --- a/PyReconstruct/modules/gui/main/main_window.py +++ b/PyReconstruct/modules/gui/main/main_window.py @@ -413,14 +413,20 @@ def srcToZarr(self, create_new=True): return if create_new: + from PyReconstruct.modules.backend.func.zarr_naming import ( + default_scaled_zarr_fp, ensure_zarr_suffix, + ) zarr_fp = FileDialog.get( "save", self, "Convert Images to scaled zarr", - file_name=f"{self.series.name}_images.zarr", - filter="Zarr Directory (*.zarr)" + file_name=default_scaled_zarr_fp(self.series.src_dir, self.series.name), + filter="Zarr Directory (*zarr)" ) if not zarr_fp: return + # is_zarr_file needs the store name to end in "zarr"; enforce it here + # rather than rely on the save dialog's platform-dependent suffixing. + zarr_fp = ensure_zarr_suffix(zarr_fp) zarr_converter = Path(assets_dir) / "scripts/start_process.py" launch_prefix = script_launch_prefix() diff --git a/tests/test_zarr_convert_naming.py b/tests/test_zarr_convert_naming.py new file mode 100644 index 00000000..276e8d0f --- /dev/null +++ b/tests/test_zarr_convert_naming.py @@ -0,0 +1,62 @@ +"""Default naming/location for the "convert images to scaled zarr" action. + +Two invariants: + * the default store sits NEXT TO the source image directory (one level up), + not inside it, and is named ``.zarr`` (not the old ``_images.zarr``); + * the final store name always ends in ``zarr`` -- ``ImageLayer.is_zarr_file`` is + ``series.src_dir.endswith("zarr")``, so anything else would break detection. +""" +import os + +from PyReconstruct.modules.backend.func.zarr_naming import ( + default_scaled_zarr_fp, + ensure_zarr_suffix, +) + + +def test_default_is_sibling_of_src_dir_named_after_series(): + assert default_scaled_zarr_fp("/data/exp/images", "myseries") == \ + os.path.join("/data/exp", "myseries.zarr") + + +def test_default_is_next_to_not_within_src_dir(): + src = "/data/exp/images" + out = default_scaled_zarr_fp(src, "s") + assert os.path.dirname(out) == os.path.dirname(os.path.normpath(src)) + assert not out.startswith(os.path.normpath(src) + os.sep) # NOT inside src + + +def test_default_normalizes_trailing_separator(): + assert default_scaled_zarr_fp("/data/exp/images/", "s") == \ + os.path.join("/data/exp", "s.zarr") + + +def test_default_falls_back_to_bare_name_without_src_dir(): + assert default_scaled_zarr_fp("", "s") == "s.zarr" + assert default_scaled_zarr_fp(None, "s") == "s.zarr" + + +def test_default_uses_dot_zarr_not_underscore_images(): + out = default_scaled_zarr_fp("/a/b", "proj") + assert out.endswith("proj.zarr") + assert "_images" not in out + + +def test_ensure_suffix_leaves_zarr_names_untouched(): + assert ensure_zarr_suffix("/x/proj.zarr") == "/x/proj.zarr" + assert ensure_zarr_suffix("/x/proj-zarr") == "/x/proj-zarr" # already ends in "zarr" + + +def test_ensure_suffix_appends_when_missing(): + assert ensure_zarr_suffix("/x/proj") == "/x/proj.zarr" + assert ensure_zarr_suffix("/x/output") == "/x/output.zarr" + + +def test_ensure_suffix_always_satisfies_is_zarr_detection(): + # ImageLayer.is_zarr_file == src_dir.endswith("zarr") + for name in ("proj", "proj.zarr", "proj-zarr", "weird_name"): + assert ensure_zarr_suffix(f"/x/{name}").endswith("zarr") + + +def test_ensure_suffix_empty_is_noop(): + assert ensure_zarr_suffix("") == ""