Conversation
There was a problem hiding this comment.
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.
| 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, | ||
| ) |
There was a problem hiding this comment.
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.
| if type(self) is View: | ||
| offset_param = "relative_offset" | ||
| else: | ||
| offset_param = "absolute_offset" |
There was a problem hiding this comment.
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_nameThis makes the code more robust and easier to extend.
| 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 | ||
|
|
There was a problem hiding this comment.
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 dataThis would make the code more maintainable.
☂️ Python Coverage
Overall Coverage
New FilesNo new covered files... Modified Files
|
Description:
View.{read,write}_cxyzto normalize any non-default axis orderingsTodos:
Make sure to delete unnecessary points or to check all before merging: