Skip to content

add read_cxyz and write_cxyz#1461

Draft
normanrz wants to merge 8 commits into
masterfrom
read_cxyz
Draft

add read_cxyz and write_cxyz#1461
normanrz wants to merge 8 commits into
masterfrom
read_cxyz

Conversation

@normanrz
Copy link
Copy Markdown
Member

@normanrz normanrz commented Apr 29, 2026

Description:

  • Added View.{read,write}_cxyz to normalize any non-default axis orderings

Todos:

Make sure to delete unnecessary points or to check all before merging:

  • Updated Changelog
  • Updated Documentation
  • Added / Updated Tests

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces read_cxyz and write_cxyz methods to the View and MagView classes, enabling data access in a standardized (c, x, y, z) order regardless of the underlying storage layout. Additionally, fastremap is now an optional dependency, and NDBoundingBox has been updated to handle missing spatial axes. The review feedback suggests refactoring to reduce code duplication in the new methods and argument parsing logic, and recommends using polymorphism instead of explicit type checks for better extensibility.

Comment on lines +418 to +494
def write_cxyz(
self,
data: np.ndarray,
*,
allow_resize: bool = False,
allow_unaligned: bool = False,
relative_offset: Vec3IntLike | None = None, # in mag1
absolute_offset: Vec3IntLike | None = None, # in mag1
relative_bounding_box: NDBoundingBox | None = None, # in mag1
absolute_bounding_box: NDBoundingBox | None = None, # in mag1
) -> None:
"""Write data from a (c, x, y, z) ordered array to the magnification level.

Equivalent to :meth:`write` but always accepts data in ``(c, x, y, z)`` axis
order regardless of the underlying storage axis ordering.

Args:
data (np.ndarray): 4D array in ``(c, x, y, z)`` order.
allow_resize (bool, optional): If True, allows updating the layer's
bounding box if the write extends beyond it. Defaults to False.
allow_unaligned (bool, optional): If True, allows writing data without
being aligned to the shard shape. Defaults to False.
relative_offset (Vec3IntLike | None, optional): Offset relative to view's
position in Mag(1) coordinates. Defaults to None.
absolute_offset (Vec3IntLike | None, optional): Absolute offset in Mag(1)
coordinates. Defaults to None.
relative_bounding_box (NDBoundingBox | None, optional): Bounding box
relative to view's position in Mag(1) coordinates. Defaults to None.
absolute_bounding_box (NDBoundingBox | None, optional): Absolute bounding
box in Mag(1) coordinates. Defaults to None.
"""
assert len(data.shape) == 4, (
f"write_cxyz expects a 4D (c, x, y, z) array, got shape {data.shape}"
)
self_bbox = self.normalized_bounding_box
if self_bbox.axes == ("c", "x", "y", "z"):
# Fully standard: write() accepts (c, x, y, z) natively
self.write(
data,
allow_resize=allow_resize,
allow_unaligned=allow_unaligned,
relative_offset=relative_offset,
absolute_offset=absolute_offset,
relative_bounding_box=relative_bounding_box,
absolute_bounding_box=absolute_bounding_box,
)
elif self_bbox.axes == ("x", "y", "z"):
# No channel axis: squeeze c (must be size 1), write 3D data
data = View._reorder_cxyz_to_storage(data, self_bbox.axes)
self.write(
data,
allow_resize=allow_resize,
allow_unaligned=allow_unaligned,
relative_offset=relative_offset,
absolute_offset=absolute_offset,
relative_bounding_box=relative_bounding_box,
absolute_bounding_box=absolute_bounding_box,
)
else:
# Non-standard axis order: resolve bbox explicitly, then reorder
assert (relative_bounding_box is not None) or (
absolute_bounding_box is not None
), (
"write_cxyz with non-standard axis ordering requires an explicit "
"relative_bounding_box or absolute_bounding_box"
)
mag1_bbox = self._get_mag1_bbox(
rel_mag1_bbox=relative_bounding_box,
abs_mag1_bbox=absolute_bounding_box,
)
data = View._reorder_cxyz_to_storage(data, mag1_bbox.axes)
self.write(
data,
allow_resize=allow_resize,
allow_unaligned=allow_unaligned,
absolute_bounding_box=mag1_bbox,
)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This write_cxyz method is almost an exact copy of View.write_cxyz. This code duplication can make future maintenance more difficult, as changes would need to be applied in two places.

Since MagView inherits from View, you could refactor this to call super().write_cxyz. The main challenge is passing the allow_resize parameter, as View.write_cxyz doesn't know about it.

A potential solution is to make View.write_cxyz more extensible by having it accept arbitrary keyword arguments (**kwargs) and pass them to self.write. Because self.write will resolve to MagView.write when called on a MagView instance, the allow_resize argument would be correctly handled.

This would allow you to simplify MagView.write_cxyz to something like this:

def write_cxyz(self, ..., allow_resize: bool = False, ...) -> None:
    super().write_cxyz(
        ...,
        allow_resize=allow_resize,
    )

This approach would remove the code duplication and improve maintainability.

Comment on lines +848 to +851
if type(self) is View:
offset_param = "relative_offset"
else:
offset_param = "absolute_offset"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using type(self) is View is generally considered an anti-pattern as it breaks the principles of polymorphism and can make the code brittle. If a new subclass of View is introduced in the future, this check would need to be updated.

A better approach would be to define a class attribute in View that can be overridden by subclasses. For example:

In View:

class View:
    _default_offset_param_name = "relative_offset"
    # ...

In MagView:

class MagView(View):
    _default_offset_param_name = "absolute_offset"
    # ...

Then you can replace the if/else block with:

offset_param = self._default_offset_param_name

This makes the code more robust and easier to extend.

Comment on lines +794 to 922
def read_cxyz(
self,
size: Vec3IntLike
| None = None, # usually in mag1, in current mag if offset is given
*,
relative_offset: Vec3IntLike | None = None, # in mag1
absolute_offset: Vec3IntLike | None = None, # in mag1
relative_bounding_box: NDBoundingBox | None = None, # in mag1
absolute_bounding_box: NDBoundingBox | None = None, # in mag1
) -> np.ndarray:
"""Read data from the view and return it in ``(c, x, y, z)`` axis order.

Equivalent to :meth:`read` but always returns a 4-D array ordered as
``(channels, x, y, z)`` regardless of the underlying storage axis ordering.
If the array does not have a channel axis, one of size 1 is added.

Args:
size (Vec3IntLike | None, optional): Size of region to read in Mag(1)
coordinates. Defaults to None.
relative_offset (Vec3IntLike | None, optional): Offset relative to the
view's position in Mag(1) coordinates. Defaults to None.
absolute_offset (Vec3IntLike | None, optional): Absolute offset in Mag(1)
coordinates. Defaults to None.
relative_bounding_box (NDBoundingBox | None, optional): Bounding box
relative to the view's position in Mag(1) coordinates. Defaults to None.
absolute_bounding_box (NDBoundingBox | None, optional): Absolute bounding
box in Mag(1) coordinates. Defaults to None.

Returns:
np.ndarray: Data as a 4-D numpy array with shape ``(c, x, y, z)``.
Areas outside the dataset are zero-padded.

Examples:
```python
# Read entire view's data in cxyz order
view = layer.get_mag("1").get_view(size=(100, 100, 10))
data = view.read_cxyz() # Always returns (c, x, y, z) array

# Read with relative offset and size
data = view.read_cxyz(relative_offset=(10, 10, 0), size=(50, 50, 10))
```
"""
current_mag_size: Vec3IntLike | None
mag1_size: Vec3IntLike | None
if absolute_bounding_box is None and relative_bounding_box is None:
if size is None:
assert relative_offset is None and absolute_offset is None, (
"You must supply size, when reading with an offset."
)
absolute_bounding_box = self.bounding_box
current_mag_size = None
mag1_size = None
else:
if relative_offset is None and absolute_offset is None:
if type(self) is View:
offset_param = "relative_offset"
else:
offset_param = "absolute_offset"
warnings.warn(
"[DEPRECATION] Using view.read_cxyz(size=my_vec) only with a size is deprecated. "
+ f"Please use view.read_cxyz({offset_param}=(0, 0, 0), size=size_vec * view.mag.to_vec3_int()) instead.",
DeprecationWarning,
)
current_mag_size = size
mag1_size = None
else:
current_mag_size = None
mag1_size = size

if all(
i is None
for i in [
absolute_offset,
relative_offset,
absolute_bounding_box,
]
):
relative_offset = Vec3Int.zeros()
else:
assert size is None, (
"Cannot supply a size when using bounding_box in view.read_cxyz()"
)
current_mag_size = None
mag1_size = None

mag1_bbox = self._get_mag1_bbox(
rel_mag1_offset=relative_offset,
abs_mag1_offset=absolute_offset,
rel_mag1_bbox=relative_bounding_box,
abs_mag1_bbox=absolute_bounding_box,
current_mag_size=current_mag_size,
mag1_size=mag1_size,
)
assert not mag1_bbox.is_empty(), (
f"The size ({mag1_bbox.size} in mag1) contains a zero. "
+ "All dimensions must be strictly larger than '0'."
)
assert mag1_bbox.topleft.is_positive(), (
f"The offset ({mag1_bbox.topleft} in mag1) must not contain negative dimensions."
)

data = self._read_without_checks(mag1_bbox.in_mag(self._mag))
# reorder to (c, x, y, z), inserting a size-1 axis for each missing cxyz axis
storage_axes = mag1_bbox.axes
source_positions = []
next_appended = len(storage_axes)
for axis in ("c", "x", "y", "z"):
if axis in storage_axes:
source_positions.append(storage_axes.index(axis))
else:
data = np.expand_dims(data, axis=-1)
source_positions.append(next_appended)
next_appended += 1
data = np.moveaxis(data, source_positions, [0, 1, 2, 3])
# squeeze any extra non-cxyz axes that were moved to positions 4+;
# they must all be size 1 or the data can't be collapsed to (c, x, y, z)
extra_axes = [
a for a in storage_axes if a not in ("c", "x", "y", "z")
]
for i, axis in enumerate(extra_axes):
size = data.shape[4 + i]
if size != 1:
raise ValueError(
f"Cannot read into (c, x, y, z) format: extra axis '{axis}' "
f"has size {size} > 1. Use read() and handle the extra axis manually."
)
data = data.squeeze(axis=tuple(range(4, len(data.shape))))
return data

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The argument parsing logic at the beginning of this method (lines 836-893) is identical to the logic in the read() method. This duplication can lead to maintenance issues, where a bug fix or change in one place might be missed in the other.

To improve this, you could extract the common argument parsing logic into a private helper method, for example _resolve_read_bbox(...). Both read() and read_cxyz() could then call this helper to get the mag1_bbox, keeping the code DRY.

Example:

def _resolve_read_bbox(self, size, relative_offset, absolute_offset, relative_bounding_box, absolute_bounding_box) -> NormalizedBoundingBox:
    # ... duplicated argument parsing logic ...
    mag1_bbox = self._get_mag1_bbox(...)
    # ... assertions on mag1_bbox ...
    return mag1_bbox

def read(self, ...):
    mag1_bbox = self._resolve_read_bbox(...)
    return self._read_without_checks(mag1_bbox.in_mag(self._mag))

def read_cxyz(self, ...):
    mag1_bbox = self._resolve_read_bbox(...)
    data = self._read_without_checks(mag1_bbox.in_mag(self._mag))
    # ... reordering logic ...
    return data

This would make the code more maintainable.

@normanrz normanrz self-assigned this May 13, 2026
@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 13, 2026

☂️ Python Coverage

current status: ✅

Overall Coverage

Lines Covered Coverage Threshold Status
11510 9724 84% 80% 🟢

New Files

No new covered files...

Modified Files

File Coverage Status
webknossos/webknossos/cli/export_as_tiff.py 50% 🟢
webknossos/webknossos/client/_download_dataset.py 94% 🟢
webknossos/webknossos/dataset/_utils/pims_czi_reader.py 83% 🟢
webknossos/webknossos/dataset/layer/_downsampling_utils.py 74% 🟢
webknossos/webknossos/dataset/layer/_upsampling_utils.py 93% 🟢
webknossos/webknossos/dataset/layer/view/mag_view.py 90% 🟢
webknossos/webknossos/dataset/layer/view/view.py 84% 🟢
webknossos/webknossos/geometry/nd_bounding_box.py 89% 🟢
TOTAL 82% 🟢

updated for commit: 3dba419 by action🐍

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant