From 77730d297d4f9894dacdd463d5fe09d2b94d9150 Mon Sep 17 00:00:00 2001 From: martin-s-a Date: Sun, 20 Sep 2026 18:21:55 +0200 Subject: [PATCH 1/2] Answer what shape a file is, without reading it Sizing the destination of a batch read means knowing the shape of one image or volume, and rexlib now answers that without the caller opening anything. The manager is defaulted the way it is for read and write, so a caller asks about a path and nothing else, and the extents come back as a tuple to agree with every other shape this package reports. The overloads over a reader provider are left until the provider is bound. Co-Authored-By: Claude Opus 5 --- BACKLOG.md | 10 +-- external/rexlib | 2 +- python/rexlib/em/image/__init__.py | 9 ++- python/rexlib/em/image/_functions.py | 44 +++++++++++++ src/em/image/image_read_format_manager.cpp | 40 ++++++++++++ tests/em/image/test_queries.py | 76 ++++++++++++++++++++++ 6 files changed, 174 insertions(+), 7 deletions(-) create mode 100644 tests/em/image/test_queries.py diff --git a/BACKLOG.md b/BACKLOG.md index 9a95edda..8a7ea396 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -74,11 +74,11 @@ ImageReadFormatManager ← bound It splits in two, by risk rather than by size. -**3a — geometry.** Bind `query_extents` and `query_core_extents`, each with -its two overloads, over a format manager and over a reader provider. Read-only -surface, no threads, no GIL, and useful for sizing any read rather than only a -batch. Note the provider overloads take a non-const reference where the format -manager ones take a const one. +**3a — geometry.** Bind `query_extents` and `query_core_extents` over a format +manager. Read-only surface, no threads, no GIL, and useful for sizing any read +rather than only a batch. Their overloads over a reader provider wait for 3b, +which is what binds the provider; those take a non-const reference where the +format manager ones take a const one. `query_core_extents` returns the shape of a single image or volume, which is what a batch destination carries beside its leading extent, so no arithmetic diff --git a/external/rexlib b/external/rexlib index 741b2628..43bbffec 160000 --- a/external/rexlib +++ b/external/rexlib @@ -1 +1 @@ -Subproject commit 741b262898845869984981e09947a7aa01991fdf +Subproject commit 43bbffecbfc7b04daeb032d99391d58453d1b499 diff --git a/python/rexlib/em/image/__init__.py b/python/rexlib/em/image/__init__.py index 41da803f..0d8ece47 100644 --- a/python/rexlib/em/image/__init__.py +++ b/python/rexlib/em/image/__init__.py @@ -27,7 +27,12 @@ get_image_read_format_manager as get_image_read_format_manager, get_image_write_format_manager as get_image_write_format_manager, ) -from ._functions import read as read, write as write +from ._functions import ( + query_core_extents as query_core_extents, + query_extents as query_extents, + read as read, + write as write, +) __all__ = [ "ImageLocation", @@ -35,6 +40,8 @@ "ImageWriteFormatManager", "get_image_read_format_manager", "get_image_write_format_manager", + "query_core_extents", + "query_extents", "read", "write", ] diff --git a/python/rexlib/em/image/_functions.py b/python/rexlib/em/image/_functions.py index 2fca7d3f..0ba17fc9 100644 --- a/python/rexlib/em/image/_functions.py +++ b/python/rexlib/em/image/_functions.py @@ -38,6 +38,50 @@ def _resolve_write_manager( manager = get_image_write_format_manager(get_default_catalog()) return manager +def query_extents( + path: str, + manager: ImageReadFormatManager | None = None +) -> tuple[int, ...]: + """ + Get the extents of a whole image file. + + Slowest axis first, so a stack reports the axis it stacks along + before the shape of one of its images. + + Args: + path: The file to ask about. + manager: The formats to recognize the file with. Defaults to the + ones the default catalog holds. + + Returns: + tuple: The extents of the file. + """ + return _raw.query_extents(_resolve_read_manager(manager), path) + +def query_core_extents( + path: str, + manager: ImageReadFormatManager | None = None +) -> tuple[int, ...]: + """ + Get the extents of one image or volume of a file. + + The axes a file stacks along are left out, so this is the shape a + batch destination carries beside its leading extent: + + descriptor = rexlib.make_contiguous_array_descriptor( + (len(locations), *query_core_extents(path)), data_type + ) + + Args: + path: The file to ask about. + manager: The formats to recognize the file with. Defaults to the + ones the default catalog holds. + + Returns: + tuple: The extents of one image or volume. + """ + return _raw.query_core_extents(_resolve_read_manager(manager), path) + def read( path: str | ImageLocation, manager: ImageReadFormatManager | None = None, diff --git a/src/em/image/image_read_format_manager.cpp b/src/em/image/image_read_format_manager.cpp index 978f19e7..63495102 100644 --- a/src/em/image/image_read_format_manager.cpp +++ b/src/em/image/image_read_format_manager.cpp @@ -4,9 +4,41 @@ #include +#include +#include +#include + namespace rexlib { +namespace py = pybind11; + +static py::tuple to_shape(const std::vector &extents) +{ + py::tuple shape(extents.size()); + for (std::size_t i = 0; i < extents.size(); ++i) + { + shape[i] = extents[i]; + } + return shape; +} + +static py::tuple query_extents( + const em::image_read_format_manager &formats, + const std::string &path +) +{ + return to_shape(em::query_extents(formats, path)); +} + +static py::tuple query_core_extents( + const em::image_read_format_manager &formats, + const std::string &path +) +{ + return to_shape(em::query_core_extents(formats, path)); +} + static std::shared_ptr get_image_read_format_manager(service_catalog &catalog) { @@ -22,6 +54,14 @@ declare_image_read_format_manager(pybind11::module_ &m) void define_image_read_format_manager(pybind11::module_ &m) { m.def("get_image_read_format_manager", &get_image_read_format_manager); + m.def( + "query_extents", &query_extents, + py::arg("formats"), py::arg("path") + ); + m.def( + "query_core_extents", &query_core_extents, + py::arg("formats"), py::arg("path") + ); } } // namespace rexlib diff --git a/tests/em/image/test_queries.py b/tests/em/image/test_queries.py new file mode 100644 index 00000000..06c14f18 --- /dev/null +++ b/tests/em/image/test_queries.py @@ -0,0 +1,76 @@ +# SPDX-License-Identifier: GPL-3.0-only + +import pytest + +import rexlib + +image = rexlib.em.image + +def test_reports_the_extents_it_was_written_with(__written_file): + assert image.query_extents(__written_file) == (4, 6) + +def test_extents_are_a_tuple(__written_file): + assert isinstance(image.query_extents(__written_file), tuple) + +def test_core_extents_are_a_tuple(__written_file): + assert isinstance(image.query_core_extents(__written_file), tuple) + +def test_core_extents_are_the_trailing_extents(__written_file): + # The axes a file stacks along are left out, so whatever the rank, the + # core is the tail of the whole. + extents = image.query_extents(__written_file) + core = image.query_core_extents(__written_file) + assert len(core) <= len(extents) + assert extents[len(extents) - len(core):] == core + +def test_what_it_reports_sizes_a_destination(__written_file, __setup_context): + descriptor = rexlib.make_contiguous_array_descriptor( + (3, *image.query_core_extents(__written_file)), + rexlib.NumericalType.float32 + ) + destination = rexlib.zeros( + descriptor, rexlib.hardware.MemoryResourceAffinity.host, __setup_context + ) + assert destination.shape[1:] == image.query_core_extents(__written_file) + +def test_accepts_an_explicit_manager(__written_file): + catalog = rexlib.ServiceCatalog() + manager = image.get_image_read_format_manager(catalog) + assert image.query_extents(__written_file, manager) == (4, 6) + +@pytest.mark.parametrize( + "query", + [ + pytest.param('query_extents', id="Whole file"), + pytest.param('query_core_extents', id="One image"), + ] +) +def test_raises_on_a_file_no_format_recognizes(query): + with pytest.raises(RuntimeError): + getattr(image, query)('/path/to/no/such/file.mrc') + +@pytest.fixture +def __written_file(tmp_path, __setup_context): + path = str(tmp_path / 'stack.mrc') + descriptor = rexlib.make_contiguous_array_descriptor( + (4, 6), rexlib.NumericalType.float32 + ) + image.write( + rexlib.zeros( + descriptor, + rexlib.hardware.MemoryResourceAffinity.host, + __setup_context + ), + path + ) + return path + +@pytest.fixture +def __setup_context(): + catalog = rexlib.ServiceCatalog() + manager = rexlib.hardware.get_device_manager(catalog) + session = manager.create_device_session(rexlib.hardware.DeviceIndex('cpu', 0)) + device_context = rexlib.hardware.DeviceContext(session) + program_manager = rexlib.dispatch.get_program_manager(catalog) + dispatcher = rexlib.dispatch.make_eager_dispatcher(program_manager) + return rexlib.dispatch.ExecutionContext(device_context, dispatcher) From 9d3a03c51382c87cb655027fd88f640e40dae025 Mon Sep 17 00:00:00 2001 From: martin-s-a Date: Sun, 20 Sep 2026 18:37:51 +0200 Subject: [PATCH 2/2] Resolve the query outside the block that expects it to throw Co-Authored-By: Claude Opus 5 --- tests/em/image/test_queries.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/em/image/test_queries.py b/tests/em/image/test_queries.py index 06c14f18..22fde9f8 100644 --- a/tests/em/image/test_queries.py +++ b/tests/em/image/test_queries.py @@ -41,13 +41,13 @@ def test_accepts_an_explicit_manager(__written_file): @pytest.mark.parametrize( "query", [ - pytest.param('query_extents', id="Whole file"), - pytest.param('query_core_extents', id="One image"), + pytest.param(image.query_extents, id="Whole file"), + pytest.param(image.query_core_extents, id="One image"), ] ) def test_raises_on_a_file_no_format_recognizes(query): with pytest.raises(RuntimeError): - getattr(image, query)('/path/to/no/such/file.mrc') + query('/path/to/no/such/file.mrc') @pytest.fixture def __written_file(tmp_path, __setup_context):