Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package python-roifile for openSUSE:Factory checked in at 2026-04-01 19:51:49 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/python-roifile (Old) and /work/SRC/openSUSE:Factory/.python-roifile.new.21863 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-roifile" Wed Apr 1 19:51:49 2026 rev:11 rq:1343969 version:2026.2.10 Changes: -------- --- /work/SRC/openSUSE:Factory/python-roifile/python-roifile.changes 2026-02-03 21:36:43.029769442 +0100 +++ /work/SRC/openSUSE:Factory/.python-roifile.new.21863/python-roifile.changes 2026-04-01 19:53:04.023207614 +0200 @@ -1,0 +2,13 @@ +Tue Mar 31 19:21:02 UTC 2026 - Dirk Müller <[email protected]> + +- update to 2026.2.10: + * Revise wrapping of integer coordinates again (breaking). + * Bump file version to 229. + * Support groups > 255 (untested). + * Support IMAGE subtype (requires imagecodecs). + * Add point_type and point_size properties for point ROIs. + * Do not return empty paths in path2coords. + * Improve documentation. + * Fix code review issues. + +------------------------------------------------------------------- Old: ---- roifile-2026.1.22.tar.gz New: ---- roifile-2026.2.10.tar.gz ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ python-roifile.spec ++++++ --- /var/tmp/diff_new_pack.4PbtXv/_old 2026-04-01 19:53:04.787239349 +0200 +++ /var/tmp/diff_new_pack.4PbtXv/_new 2026-04-01 19:53:04.795239681 +0200 @@ -18,7 +18,7 @@ %define packagename roifile Name: python-roifile -Version: 2026.1.22 +Version: 2026.2.10 Release: 0 Summary: Read and write ImageJ ROI format License: BSD-3-Clause ++++++ roifile-2026.1.22.tar.gz -> roifile-2026.2.10.tar.gz ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/roifile-2026.1.22/CHANGES.rst new/roifile-2026.2.10/CHANGES.rst --- old/roifile-2026.1.22/CHANGES.rst 2026-01-22 21:42:33.000000000 +0100 +++ new/roifile-2026.2.10/CHANGES.rst 2026-02-11 20:54:35.000000000 +0100 @@ -1,6 +1,20 @@ Revisions --------- +2026.2.10 + +- Revise wrapping of integer coordinates again (breaking). +- Bump file version to 229. +- Support groups > 255 (untested). +- Support IMAGE subtype (requires imagecodecs). +- Add point_type and point_size properties for point ROIs. +- Do not return empty paths in path2coords. +- Improve documentation. + +2026.1.29 + +- Fix code review issues. + 2026.1.22 - Fix boolean codec in ImagejRoi.properties. diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/roifile-2026.1.22/README.rst new/roifile-2026.2.10/README.rst --- old/roifile-2026.1.22/README.rst 2026-01-22 21:42:33.000000000 +0100 +++ new/roifile-2026.2.10/README.rst 2026-02-11 20:54:35.000000000 +0100 @@ -12,7 +12,7 @@ :Author: `Christoph Gohlke <https://www.cgohlke.com>`_ :License: BSD-3-Clause -:Version: 2026.1.22 +:Version: 2026.2.10 :DOI: `10.5281/zenodo.6941603 <https://doi.org/10.5281/zenodo.6941603>`_ Quickstart @@ -38,14 +38,29 @@ This revision was tested with the following requirements and dependencies (other versions may work): -- `CPython <https://www.python.org>`_ 3.11.9, 3.12.10, 3.13.11, 3.14.2 64-bit -- `NumPy <https://pypi.org/project/numpy>`_ 2.4.1 -- `Tifffile <https://pypi.org/project/tifffile/>`_ 2026.1.14 (optional) +- `CPython <https://www.python.org>`_ 3.11.9, 3.12.10, 3.13.12, 3.14.3 64-bit +- `NumPy <https://pypi.org/project/numpy>`_ 2.4.2 +- `Tifffile <https://pypi.org/project/tifffile/>`_ 2026.1.28 (optional) +- `Imagecodecs <https://pypi.org/project/imagecodecs/>`_ 2026.1.14 (optional) - `Matplotlib <https://pypi.org/project/matplotlib/>`_ 3.10.8 (optional) Revisions --------- +2026.2.10 + +- Revise wrapping of integer coordinates again (breaking). +- Bump file version to 229. +- Support groups > 255 (untested). +- Support IMAGE subtype (requires imagecodecs). +- Add point_type and point_size properties for point ROIs. +- Do not return empty paths in path2coords. +- Improve documentation. + +2026.1.29 + +- Fix code review issues. + 2026.1.22 - Fix boolean codec in ImagejRoi.properties. @@ -70,10 +85,6 @@ 2025.2.20 -- Drop support for Python 3.9. - -2024.9.15 - - … Refer to the CHANGES file for older revisions. @@ -92,17 +103,20 @@ - `ijpython_roi <https://github.com/dwaithe/ijpython_roi>`_ - `read-roi <https://github.com/hadim/read-roi/>`_ +- `sdt-python <https://github.com/schuetzgroup/sdt-python>`_ - `napari_jroitools <https://github.com/jayunruh/napari_jroitools>`_ Examples -------- -Create a new ImagejRoi instance from an array of x, y coordinates: +Create a new ImagejRoi instance from an array of x, y coordinates, +then set ROI properties: .. code-block:: python >>> roi = ImagejRoi.frompoints([[1.1, 2.2], [3.3, 4.4], [5.5, 6.6]]) >>> roi.roitype = ROI_TYPE.POINT + >>> roi.point_size = ROI_POINT_SIZE.LARGE >>> roi.options |= ROI_OPTIONS.SHOW_LABELS Export the instance to an ImageJ ROI formatted byte string or file: diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/roifile-2026.1.22/roifile/roifile.py new/roifile-2026.2.10/roifile/roifile.py --- old/roifile-2026.1.22/roifile/roifile.py 2026-01-22 21:42:33.000000000 +0100 +++ new/roifile-2026.2.10/roifile/roifile.py 2026-02-11 20:54:35.000000000 +0100 @@ -39,7 +39,7 @@ :Author: `Christoph Gohlke <https://www.cgohlke.com>`_ :License: BSD-3-Clause -:Version: 2026.1.22 +:Version: 2026.2.10 :DOI: `10.5281/zenodo.6941603 <https://doi.org/10.5281/zenodo.6941603>`_ Quickstart @@ -65,14 +65,29 @@ This revision was tested with the following requirements and dependencies (other versions may work): -- `CPython <https://www.python.org>`_ 3.11.9, 3.12.10, 3.13.11, 3.14.2 64-bit -- `NumPy <https://pypi.org/project/numpy>`_ 2.4.1 -- `Tifffile <https://pypi.org/project/tifffile/>`_ 2026.1.14 (optional) +- `CPython <https://www.python.org>`_ 3.11.9, 3.12.10, 3.13.12, 3.14.3 64-bit +- `NumPy <https://pypi.org/project/numpy>`_ 2.4.2 +- `Tifffile <https://pypi.org/project/tifffile/>`_ 2026.1.28 (optional) +- `Imagecodecs <https://pypi.org/project/imagecodecs/>`_ 2026.1.14 (optional) - `Matplotlib <https://pypi.org/project/matplotlib/>`_ 3.10.8 (optional) Revisions --------- +2026.2.10 + +- Revise wrapping of integer coordinates again (breaking). +- Bump file version to 229. +- Support groups > 255 (untested). +- Support IMAGE subtype (requires imagecodecs). +- Add point_type and point_size properties for point ROIs. +- Do not return empty paths in path2coords. +- Improve documentation. + +2026.1.29 + +- Fix code review issues. + 2026.1.22 - Fix boolean codec in ImagejRoi.properties. @@ -97,10 +112,6 @@ 2025.2.20 -- Drop support for Python 3.9. - -2024.9.15 - - … Refer to the CHANGES file for older revisions. @@ -119,15 +130,18 @@ - `ijpython_roi <https://github.com/dwaithe/ijpython_roi>`_ - `read-roi <https://github.com/hadim/read-roi/>`_ +- `sdt-python <https://github.com/schuetzgroup/sdt-python>`_ - `napari_jroitools <https://github.com/jayunruh/napari_jroitools>`_ Examples -------- -Create a new ImagejRoi instance from an array of x, y coordinates: +Create a new ImagejRoi instance from an array of x, y coordinates, +then set ROI properties: >>> roi = ImagejRoi.frompoints([[1.1, 2.2], [3.3, 4.4], [5.5, 6.6]]) >>> roi.roitype = ROI_TYPE.POINT +>>> roi.point_size = ROI_POINT_SIZE.LARGE >>> roi.options |= ROI_OPTIONS.SHOW_LABELS Export the instance to an ImageJ ROI formatted byte string or file: @@ -193,7 +207,7 @@ from __future__ import annotations -__version__ = '2026.1.22' +__version__ = '2026.2.10' __all__ = [ 'ROI_COLOR_NONE', @@ -204,6 +218,7 @@ 'ROI_TYPE', 'ImagejRoi', '__version__', + 'logger', 'roiread', 'roiwrite', ] @@ -216,7 +231,7 @@ import sys import uuid from dataclasses import dataclass -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Self import numpy @@ -239,6 +254,16 @@ For ZIP or TIFF files, return a list of ImagejRoi. + Parameters: + filename: Path to ROI, ZIP, or TIFF file. + min_int_coord: Minimum integer coordinate value for unwrapping. + Default is -5000 (ImageJ standard). + maxsize: Maximum file size to read in bytes. + + Returns: + Single ImagejRoi instance for .roi files, + or list of ImagejRoi instances for .zip and .tif files. + """ return ImagejRoi.fromfile( filename, min_int_coord=min_int_coord, maxsize=maxsize @@ -251,13 +276,20 @@ /, *, name: str | Iterable[str] | None = None, - mode: Literal['r', 'w', 'x', 'a'] | None = None, + mode: Literal['w', 'x', 'a'] | None = None, ) -> None: """Write ImagejRoi instance(s) to ROI or ZIP file. Write an ImagejRoi instance to a ROI file or write a sequence of ImagejRoi instances to a ZIP file. Existing ZIP files are opened for append. + Parameters: + filename: Path to output .roi or .zip file. + roi: Single ImagejRoi instance or iterable of instances. + name: Optional name(s) for ROI(s) in ZIP file. + mode: File mode ('w' for write, 'x' for exclusive, 'a' for append). + Defaults to 'a' for existing files, 'w' for new files. + """ filename = os.fspath(filename) @@ -280,9 +312,15 @@ with zipfile.ZipFile(filename, mode) as zf: for r in roi: if name is None: - n = r.name if r.name else r.autoname + n = r.name or r.autoname else: - n = next(name) + try: + n = next(name) + except StopIteration: + msg = "'name' iterator has fewer items than 'roi'" + raise ValueError(msg) from None + if not n: + n = r.autoname n = n if n[-4:].lower() == '.roi' else n + '.roi' with zf.open(n, 'w') as fh: fh.write(r.tobytes()) @@ -292,69 +330,153 @@ class ROI_TYPE(enum.IntEnum): """ImageJ ROI types.""" + UNKNOWN = -1 + """Undocumented or unknown ROI type.""" POLYGON = 0 + """Polygon with straight edges.""" RECT = 1 + """Rectangle.""" OVAL = 2 + """Oval/ellipse.""" LINE = 3 + """Straight line.""" FREELINE = 4 + """Freehand line.""" POLYLINE = 5 + """Polyline with straight segments.""" NOROI = 6 + """No ROI.""" FREEHAND = 7 + """Freehand polygon.""" TRACED = 8 + """Traced polygon.""" ANGLE = 9 + """Angle measurement.""" POINT = 10 + """Point or multi-point.""" + + @classmethod + def _missing_(cls, value: object) -> Self: + assert isinstance(value, int) + obj = cls(-1) + obj._value_ = value + return obj class ROI_SUBTYPE(enum.IntEnum): """ImageJ ROI subtypes.""" + UNKNOWN = -1 + """Undocumented or unknown ROI subtype.""" UNDEFINED = 0 + """No subtype specified.""" TEXT = 1 + """Text overlay.""" ARROW = 2 + """Arrow overlay.""" ELLIPSE = 3 + """Ellipse (fitted).""" IMAGE = 4 + """Embedded image overlay.""" ROTATED_RECT = 5 + """Rotated rectangle.""" + + @classmethod + def _missing_(cls, value: object) -> Self: + assert isinstance(value, int) + obj = cls(-1) + obj._value_ = value + return obj class ROI_OPTIONS(enum.IntFlag): """ImageJ ROI options.""" NONE = 0 + """No options.""" SPLINE_FIT = 1 + """Spline fit enabled.""" DOUBLE_HEADED = 2 + """Double-headed arrow.""" OUTLINE = 4 + """Outline only (no fill).""" OVERLAY_LABELS = 8 + """Show overlay labels.""" OVERLAY_NAMES = 16 + """Show overlay names.""" OVERLAY_BACKGROUNDS = 32 + """Show overlay backgrounds.""" OVERLAY_BOLD = 64 + """Bold overlay text.""" SUB_PIXEL_RESOLUTION = 128 + """Subpixel resolution coordinates.""" DRAW_OFFSET = 256 + """Draw with offset.""" ZERO_TRANSPARENT = 512 + """Zero values transparent.""" SHOW_LABELS = 1024 + """Show point labels.""" SCALE_LABELS = 2048 + """Scale labels with zoom.""" PROMPT_BEFORE_DELETING = 4096 + """Prompt before deletion.""" + SCALE_STROKE_WIDTH = 8192 + """Scale stroke width with zoom.""" + UNKNOWN_14 = 16384 + """Undocumented or unknown option (bit 14).""" + UNKNOWN_15 = 32768 + """Undocumented or unknown option (bit 15).""" class ROI_POINT_TYPE(enum.IntEnum): """ImageJ ROI point types.""" + UNKNOWN = -1 + """Undocumented or unknown point type.""" HYBRID = 0 + """Hybrid marker (cross with dot).""" CROSS = 1 - # CROSSHAIR = 1 + """Cross/crosshair marker.""" + # CROSSHAIR = 1 # alias for CROSS in ImageJ, not needed here DOT = 2 + """Dot/circle marker (filled).""" CIRCLE = 3 + """Circle marker (outline).""" + + @classmethod + def _missing_(cls, value: object) -> Self: + assert isinstance(value, int) + obj = cls(-1) + obj._value_ = value + return obj class ROI_POINT_SIZE(enum.IntEnum): """ImageJ ROI point sizes.""" + UNKNOWN = -1 + """Undocumented or unknown point size.""" TINY = 1 + """Tiny marker (1px).""" SMALL = 3 + """Small marker (3px).""" MEDIUM = 5 + """Medium marker (5px).""" LARGE = 7 + """Large marker (7px).""" EXTRA_LARGE = 11 + """Extra large marker (11px).""" XXL = 17 + """XXL marker (17px).""" XXXL = 25 + """XXXL marker (25px).""" + + @classmethod + def _missing_(cls, value: object) -> Self: + assert isinstance(value, int) + obj = cls(-1) + obj._value_ = value + return obj ROI_COLOR_NONE = b'\x00\x00\x00\x00' @@ -366,53 +488,103 @@ """Read and write ImageJ ROI format.""" byteorder: Literal['>', '<'] = '>' + """Byte order: '>' for big-endian, '<' for little-endian.""" roitype: ROI_TYPE = ROI_TYPE.POLYGON + """ROI type (polygon, rect, oval, line, point, etc).""" subtype: ROI_SUBTYPE = ROI_SUBTYPE.UNDEFINED + """Subtype for specialized ROIs (text, arrow, ellipse, image, etc).""" options: ROI_OPTIONS = ROI_OPTIONS.NONE + """Bit flags for ROI options and features.""" name: str = '' + """ROI name.""" props: str = '' - version: int = 217 + """Properties string containing key:value pairs.""" + version: int = 229 + """File format version number.""" top: int = 0 + """Bounding rectangle top coordinate.""" left: int = 0 + """Bounding rectangle left coordinate.""" bottom: int = 0 + """Bounding rectangle bottom coordinate.""" right: int = 0 + """Bounding rectangle right coordinate.""" n_coordinates: int = 0 + """Number of coordinate pairs.""" stroke_width: int = 0 + """Stroke width in pixels (also point_size for POINT ROIs version 226+).""" shape_roi_size: int = 0 + """Composite shape data size in floats.""" stroke_color: bytes = ROI_COLOR_NONE + """Stroke/outline color as ARGB bytes.""" fill_color: bytes = ROI_COLOR_NONE + """Fill color as ARGB bytes.""" arrow_style_or_aspect_ratio: int = 0 + """Arrow style, aspect ratio for ellipse, or point_type for POINT ROIs.""" arrow_head_size: int = 0 + """Arrow head size in pixels.""" rounded_rect_arc_size: int = 0 + """Arc size for rounded rectangle corners.""" position: int = 0 + """Position in stack (1-based, 0 means not set).""" c_position: int = 0 + """Channel position (1-based, 0 means not set).""" z_position: int = 0 + """Z-slice position (1-based, 0 means not set).""" t_position: int = 0 + """Time frame position (1-based, 0 means not set).""" x1: float = 0.0 + """First X coordinate for line ROIs or X for subpixel rectangles.""" y1: float = 0.0 + """First Y coordinate for line ROIs or Y for subpixel rectangles.""" x2: float = 0.0 + """Second X coordinate for line ROIs.""" y2: float = 0.0 + """Second Y coordinate for line ROIs.""" xd: float = 0.0 + """X coordinate for subpixel rectangles (double precision).""" yd: float = 0.0 + """Y coordinate for subpixel rectangles (double precision).""" widthd: float = 0.0 + """Width for subpixel rectangles (double precision).""" heightd: float = 0.0 + """Height for subpixel rectangles (double precision).""" overlay_label_color: bytes = ROI_COLOR_NONE + """Overlay label color as ARGB bytes.""" overlay_font_size: int = 0 + """Overlay label font size in points.""" group: int = 0 + """Group number for grouping related ROIs.""" image_opacity: int = 0 + """Opacity for image ROIs (0-255).""" image_size: int = 0 + """Embedded image data size in bytes.""" + image_data: bytes | None = None + """Embedded image data for IMAGE subtype ROIs.""" float_stroke_width: float = 0.0 + """Floating point stroke width for precise rendering.""" text_size: int = 0 + """Text ROI font size in points.""" text_style: int = 0 + """Text ROI font style flags (bold, italic, etc).""" text_justification: int = 0 + """Text ROI alignment (left, center, right).""" text_angle: float = 0.0 + """Text ROI rotation angle in degrees.""" text_name: str = '' + """Text ROI font name.""" text: str = '' + """Text ROI content.""" counters: NDArray[numpy.uint8] | None = None + """Counter values for each coordinate point.""" counter_positions: NDArray[numpy.uint32] | None = None + """Counter positions for each coordinate point.""" integer_coordinates: NDArray[numpy.int32] | None = None + """Integer coordinate pairs relative to bounding box.""" subpixel_coordinates: NDArray[numpy.float32] | None = None + """Floating point coordinate pairs for subpixel precision.""" multi_coordinates: NDArray[numpy.float32] | None = None + """Path data for composite shapes (MOVETO, LINETO, CLOSE operations).""" @classmethod def frompoints( @@ -426,21 +598,32 @@ c: int | None = None, z: int | None = None, t: int | None = None, - ) -> ImagejRoi: + ) -> Self: """Return ImagejRoi instance from sequence of Point coordinates. Use floating point coordinates for subpixel precision or values outside - the range -5000..60536. + the range -5000..60535. A FREEHAND ROI with options OVERLAY_BACKGROUNDS and OVERLAY_LABELS is returned. + Parameters: + points: Array-like of shape (n, 2) containing x, y coordinates. + name: ROI name. Auto-generated if None. + position: Stack position (0-based). Stored as 1-based. + index: Index for auto-generated name. + c: Channel position (0-based). Stored as 1-based. + z: Z-slice position (0-based). Stored as 1-based. + t: Time frame position (0-based). Stored as 1-based. + + Returns: + New ImagejRoi instance created from points. + """ if points is None: return cls() self = cls() - self.version = 226 self.roitype = ROI_TYPE.FREEHAND self.options = ( ROI_OPTIONS.OVERLAY_BACKGROUNDS | ROI_OPTIONS.OVERLAY_LABELS @@ -461,8 +644,14 @@ self.name = name coords = numpy.array(points, copy=True) + if coords.size == 0: + msg = 'points array is empty' + raise ValueError(msg) + if coords.ndim != 2 or coords.shape[1] != 2: + msg = f'invalid points array shape {coords.shape}, expected (n, 2)' + raise ValueError(msg) if coords.dtype.kind == 'f' or ( - numpy.any(coords > 60000) or numpy.any(coords < -5000) + numpy.any(coords > 60535) or numpy.any(coords < -5000) ): self.options |= ROI_OPTIONS.SUB_PIXEL_RESOLUTION self.subpixel_coordinates = coords.astype(numpy.float32, copy=True) @@ -498,6 +687,14 @@ For ZIP or TIFF files, return a list of ImagejRoi. + Parameters: + filename: Path to .roi, .zip, or .tif file. + min_int_coord: Minimum integer coordinate for unwrapping. + maxsize: Maximum bytes to read per entry. + + Returns: + Single ImagejRoi for .roi files, list of ImagejRoi for .zip/.tif. + """ filename = os.fspath(filename) if filename[-4:].lower() == '.tif': @@ -549,7 +746,19 @@ *, min_int_coord: int | None = None, ) -> ImagejRoi: - """Return ImagejRoi instance from bytes.""" + """Return ImagejRoi instance from bytes. + + Parameters: + data: Bytes in ImageJ ROI format. + min_int_coord: Minimum integer coordinate for unwrapping. + + Returns: + ImagejRoi instance decoded from bytes. + + """ + if len(data) < 64: + msg = f'ImageJ ROI data too short: {len(data)} < 64 bytes' + raise ValueError(msg) if data[:4] != b'Iout': msg = f'not an ImageJ ROI {data[:4]!r}' raise ValueError(msg) @@ -582,18 +791,23 @@ min_int_coord = ImagejRoi.min_int_coord(min_int_coord) + # unwrap bounding box coordinates that are clearly wrapped if self.top < min_int_coord: self.top += 65536 if self.bottom < min_int_coord: self.bottom += 65536 - if self.bottom < 0 and self.bottom < self.top: - self.bottom += 65536 - if self.left < min_int_coord: self.left += 65536 if self.right < min_int_coord: self.right += 65536 - if self.right < 0 and self.right < self.left: + + # Handle wrap-around in the ambiguous range [min_int_coord, 0] + # Values in this range could be wrapped or genuine negatives + # Unwrap if it would create a valid bounding box + # (bottom > top, right > left) + if min_int_coord <= self.bottom <= 0 and self.bottom <= self.top: + self.bottom += 65536 + if min_int_coord <= self.right <= 0 and self.right <= self.left: self.right += 65536 self.roitype = ROI_TYPE(roitype) @@ -637,15 +851,36 @@ data[header2_offset : header2_offset + 52], ) + # handle extended group for version >= 229 (groups > 255) + if self.version >= 229 and self.group == 0: + group_offset = header2_offset + 52 + if group_offset + 2 <= len(data): + self.group = struct.unpack( + self.byteorder + 'H', + data[group_offset : group_offset + 2], + )[0] + if name_offset > 0 and name_length > 0: - name = data[name_offset : name_offset + name_length * 2] - self.name = name.decode(self.utf16) + name_end = name_offset + name_length * 2 + if name_end <= len(data): + name = data[name_offset:name_end] + self.name = name.decode(self.utf16) + else: + logger().warning( + f'ImagejRoi name exceeds data size: ' + f'{name_end} > {len(data)}' + ) if roi_props_offset > 0 and roi_props_length > 0: - props = data[ - roi_props_offset : roi_props_offset + roi_props_length * 2 - ] - self.props = props.decode(self.utf16) + props_end = roi_props_offset + roi_props_length * 2 + if props_end <= len(data): + props = data[roi_props_offset:props_end] + self.props = props.decode(self.utf16) + else: + logger().warning( + f'ImagejRoi props exceeds data size: ' + f'{props_end} > {len(data)}' + ) if counters_offset > 0: counters: NDArray[numpy.uint32] = numpy.ndarray( @@ -680,6 +915,15 @@ self.byteorder + 'f', data[off : off + 4] )[0] + elif self.version >= 221 and self.subtype == ROI_SUBTYPE.IMAGE: + if 0 < self.image_size <= len(data) - 64: + self.image_data = data[64 : 64 + self.image_size] + else: + logger().warning( + 'ImagejRoi image data invalid: ' + f'size={self.image_size}, data length={len(data)}' + ) + elif self.roitype in ( ROI_TYPE.POLYGON, ROI_TYPE.FREEHAND, @@ -697,7 +941,8 @@ order='F', ).astype(numpy.int32) - select = self.integer_coordinates < min_int_coord + # unwrap negative integer_coordinates (wrapped uint16 values) + select = self.integer_coordinates < 0 self.integer_coordinates[select] += 65536 if self.subpixelresolution: @@ -728,17 +973,22 @@ /, *, name: str | None = None, - mode: Literal['r', 'w', 'x', 'a'] | None = None, + mode: Literal['w', 'x', 'a'] | None = None, ) -> None: """Write ImagejRoi to ROI or ZIP file. Existing ZIP files are opened for append. + Parameters: + filename: Path to output .roi or .zip file. + name: ROI name for ZIP file entry. + mode: File mode {'w', 'x', or 'a'}. + """ filename = os.fspath(filename) if filename[-4:].lower() == '.zip': if name is None: - name = self.name if self.name else self.autoname + name = self.name or self.autoname if name[-4:].lower() != '.roi': name += '.roi' if mode is None: @@ -756,7 +1006,12 @@ fh.write(self.tobytes()) def tobytes(self) -> bytes: - """Return ImagejRoi as bytes.""" + """Return ImagejRoi as bytes. + + Returns: + Bytes in ImageJ ROI format. + + """ result = [b'Iout'] result.append( @@ -830,6 +1085,14 @@ extradata += self.text.encode(self.utf16) extradata += struct.pack(self.byteorder + 'f', self.text_angle) + elif self.version >= 221 and self.subtype == ROI_SUBTYPE.IMAGE: + if self.image_data is not None: + extradata = self.image_data + else: + logger().warning( + 'ImagejRoi IMAGE subtype but image_data is None' + ) + elif self.roitype in ( ROI_TYPE.POLYGON, ROI_TYPE.FREEHAND, @@ -884,9 +1147,14 @@ offset += roi_props_length * 2 counters_offset = offset if self.counters is not None else 0 + # determine group byte value (use 0 if extended group will be written) + group_byte = ( + 0 if self.version >= 229 and self.group > 255 else self.group + ) + result.append( struct.pack( - self.byteorder + '4xiiiii4shBBifiii12x', + self.byteorder + '4xiiiii4shBBifiii', self.c_position, self.z_position, self.t_position, @@ -894,7 +1162,7 @@ name_length, self.overlay_label_color, self.overlay_font_size, - self.group, + group_byte, self.image_opacity, self.image_size, self.float_stroke_width, @@ -904,6 +1172,13 @@ ) ) + # write extended group for version >= 229 if group > 255 + if self.version >= 229 and self.group > 255: + result.append(struct.pack(self.byteorder + 'H', self.group)) + result.append(b'\x00' * 10) # 10 bytes padding + else: + result.append(b'\x00' * 12) # 12 bytes padding + if name_length > 0: result.append(self.name.encode(self.utf16)) if roi_props_length > 0: @@ -933,7 +1208,18 @@ show: bool = True, **kwargs: Any, ) -> None: - """Plot draft of coordinates using matplotlib.""" + """Plot draft of coordinates using matplotlib. + + Parameters: + ax: Matplotlib axes to plot on. Create new figure if None. + rois: Multiple ROIs to plot together. + title: Figure title. + bounds: Show bounding rectangle. + invert_yaxis: Invert Y axis. Auto-determined if None. + show: Call pyplot.show(). + **kwargs: Additional arguments passed to matplotlib plot functions. + + """ fig: Any roitype = self.roitype subtype = self.subtype @@ -987,45 +1273,86 @@ kwargs['linewidth'] = self.stroke_width if roitype == ROI_TYPE.POINT: if 'marker' not in kwargs: - kwargs['marker'] = 'x' + # map point type to matplotlib marker + if self.version >= 226: + match self.point_type: + case ROI_POINT_TYPE.HYBRID: + kwargs['marker'] = '+' # no exact hybrid marker + case ROI_POINT_TYPE.DOT: + kwargs['marker'] = 'o' + case ROI_POINT_TYPE.CIRCLE: + kwargs['marker'] = 'o' + kwargs['markerfacecolor'] = 'none' + case ROI_POINT_TYPE.CROSS: + kwargs['marker'] = '+' + case _: + kwargs['marker'] = 'x' + else: + kwargs['marker'] = 'x' if 'linestyle' not in kwargs: kwargs['linestyle'] = '' + # use point_size if available (version 226+) + if ( + 'markersize' not in kwargs + and 'ms' not in kwargs + and self.version >= 226 + and self.point_size > 0 + ): + kwargs['markersize'] = self.point_size * 2 - if roitype == ROI_TYPE.LINE and subtype == ROI_SUBTYPE.ARROW: - line = self.coordinates() - x, y = line[0] - dx, dy = line[1] - line[0] - if 'head_width' not in kwargs and self.arrow_head_size > 0: - kwargs['head_width'] = self.arrow_head_size - kwargs['length_includes_head'] = True - ax.arrow(x, y, dx, dy, **kwargs) - if self.options & ROI_OPTIONS.DOUBLE_HEADED: - x, y = line[1] - ax.arrow(x, y, -dx, -dy, **kwargs) - elif roitype == ROI_TYPE.RECT and subtype == ROI_SUBTYPE.TEXT: - coords = self.coordinates(multi=True)[0] - if 'fontsize' not in kwargs and self.text_size > 0: - kwargs['fontsize'] = self.text_size - text = ax.text( - coords[1][0], - coords[1][1], - self.text, - va='center_baseline', - rotation=self.text_angle, - rotation_mode='anchor', - **kwargs, - ) - scale_text(text, width=abs(coords[2, 0] - coords[0, 0])) - # ax.plot( - # coords[:, 0], - # coords[:, 1], - # linewidth=1, - # color=kwargs.get('color', 0.9), - # ls=':', - # ) - else: - for coords in self.coordinates(multi=True): - ax.plot(coords[:, 0], coords[:, 1], **kwargs) + match (roitype, subtype): + case (ROI_TYPE.LINE, ROI_SUBTYPE.ARROW): + line = self.coordinates() + x, y = line[0] + dx, dy = line[1] - line[0] + if 'head_width' not in kwargs and self.arrow_head_size > 0: + kwargs['head_width'] = self.arrow_head_size + kwargs['length_includes_head'] = True + ax.arrow(x, y, dx, dy, **kwargs) + if self.options & ROI_OPTIONS.DOUBLE_HEADED: + x, y = line[1] + ax.arrow(x, y, -dx, -dy, **kwargs) + case (ROI_TYPE.RECT, ROI_SUBTYPE.TEXT): + coordslist = self.coordinates(multi=True) + if coordslist and len(coordslist[0]) >= 3: + coords = coordslist[0] + if 'fontsize' not in kwargs and self.text_size > 0: + kwargs['fontsize'] = self.text_size + text = ax.text( + coords[1][0], + coords[1][1], + self.text, + va='center_baseline', + rotation=self.text_angle, + rotation_mode='anchor', + **kwargs, + ) + scale_text(text, width=abs(coords[2, 0] - coords[0, 0])) + # ax.plot( + # coords[:, 0], + # coords[:, 1], + # linewidth=1, + # color=kwargs.get('color', 0.9), + # ls=':', + # ) + case (ROI_TYPE.RECT, ROI_SUBTYPE.IMAGE): + if self.image is not None: + alpha = self.image_opacity / 255.0 + ax.imshow( + self.image, + extent=(self.left, self.right, self.bottom, self.top), + alpha=alpha if alpha > 0 else 1.0, + origin='upper', + interpolation='nearest', + **{ + k: v + for k, v in kwargs.items() + if k in {'cmap', 'vmin', 'vmax'} + }, + ) + case _: + for coords in self.coordinates(multi=True): + ax.plot(coords[:, 0], coords[:, 1], **kwargs) # integer limits might be bogus if ( @@ -1054,7 +1381,16 @@ *, multi: bool = False, ) -> NDArray[Any] | list[NDArray[Any]]: - """Return x, y coordinates as numpy array for display.""" + """Return x, y coordinates as numpy array for display. + + Parameters: + multi: Return list of coordinate arrays for composite shapes. + + Returns: + Array of shape (n, 2) with x, y coordinates, + or list of such arrays if multi=True. + + """ coords: Any if self.subpixel_coordinates is not None: coords = self.subpixel_coordinates.copy() @@ -1090,9 +1426,21 @@ return [coords] if multi else coords def hexcolor(self, b: bytes, /, default: str | None = None) -> str | None: - """Return color (bytes) as hex triplet or default if black.""" + """Return color (bytes) as hex triplet or default if black. + + Parameters: + b: Color as 4 ARGB bytes. + default: Value to return if color is black/none. + + Returns: + Hex color string like '#rrggbb', or default if black. + + """ if b == ROI_COLOR_NONE: return default + if len(b) != 4: + msg = f'color bytes must be length 4, got {len(b)}' + raise ValueError(msg) if self.byteorder == '>': return f'#{b[1]:02x}{b[2]:02x}{b[3]:02x}' return f'#{b[3]:02x}{b[2]:02x}{b[1]:02x}' @@ -1101,14 +1449,24 @@ def path2coords( multi_coordinates: NDArray[numpy.float32], / ) -> list[NDArray[numpy.float32]]: - """Return list of coordinate arrays from 2D geometric path.""" + """Return list of coordinate arrays from 2D geometric path. + + Parameters: + multi_coordinates: Path data with MOVETO, LINETO, CLOSE operations. + + Returns: + List of coordinate arrays, one per path segment. + + """ coordinates: list[NDArray[numpy.float32]] = [] points: list[tuple[float, float]] = [] - path: list[float] = [] - - path = multi_coordinates.tolist() + path: list[float] = multi_coordinates.tolist() n = 0 m = 0 + + if not path: + return coordinates + while n < len(path): op = int(path[n]) if op == 0: @@ -1127,6 +1485,9 @@ n += 3 elif op == 4: # CLOSE + if not points: + msg = 'CLOSE operation without any points' + raise RuntimeError(msg) points.append(points[m]) n += 1 elif op == 2 or op == 3: # noqa: PLR1714 @@ -1137,7 +1498,8 @@ msg = f'invalid PathIterator command {op!r}' raise RuntimeError(msg) - coordinates.append(numpy.array(points, dtype=numpy.float32)) + if points: + coordinates.append(numpy.array(points, dtype=numpy.float32)) return coordinates @staticmethod @@ -1147,6 +1509,12 @@ The default, -5000, is used by ImageJ. A value of -32768 means to use int16 range, 0 means uint16 range. + Parameters: + value: Minimum coordinate value (-32768 to 0), or None for default. + + Returns: + Minimum integer coordinate value (-5000 default). + """ if value is None: return -5000 @@ -1195,8 +1563,89 @@ return name @property + def point_type(self) -> ROI_POINT_TYPE: + """Point type for POINT ROIs (version 226+). + + Maps to arrow_style_or_aspect_ratio field. + Only meaningful for ROI_TYPE.POINT. + + """ + return ROI_POINT_TYPE(self.arrow_style_or_aspect_ratio) + + @point_type.setter + def point_type(self, value: int | ROI_POINT_TYPE, /) -> None: + if not isinstance(value, ROI_POINT_TYPE): + value = ROI_POINT_TYPE(value) + self.arrow_style_or_aspect_ratio = value.value + + @property + def point_size(self) -> ROI_POINT_SIZE: + """Point size for POINT ROIs (version 226+). + + Maps to stroke_width field. Only meaningful for ROI_TYPE.POINT. + + """ + return ROI_POINT_SIZE(self.stroke_width) + + @point_size.setter + def point_size(self, value: int | ROI_POINT_SIZE, /) -> None: + if not isinstance(value, ROI_POINT_SIZE): + value = ROI_POINT_SIZE(value) + self.stroke_width = value.value + + @property + def image(self) -> NDArray[Any] | None: + """Decoded image as numpy array for IMAGE subtype ROIs. + + Image is None if no image data is stored in file or decoding failed. + + """ + if self.image_data is None: + return None + try: + # TODO: is image data always TIFF? + from imagecodecs import tiff_decode + + return tiff_decode(self.image_data) + except Exception as exc: + logger().warning(f'ImagejRoi failed to decode image data: {exc}') + return None + + @image.setter + def image(self, value: NDArray[Any] | None, /) -> None: + if value is None: + self.image_data = None + self.image_size = 0 + return + + if value.ndim == 3: + if value.shape[2] not in {3, 4}: + msg = 'RGB image must have 3 or 4 channels' + raise ValueError(msg) + if value.dtype.char != 'B': + msg = f'invalid RGB dtype={value.dtype} != uint8' + raise ValueError(msg) + elif value.ndim == 2: + if value.dtype.char not in {'B', 'H', 'f'}: + msg = f'invalid grayscale dtype={value.dtype}' + raise ValueError(msg) + else: + msg = 'image array must be 2D (grayscale) or 3D (RGB/RGBA)' + raise ValueError(msg) + + self.right = self.left + value.shape[1] + self.bottom = self.top + value.shape[0] + + from imagecodecs import tiff_encode + + encoded = tiff_encode(value, description='ImageJ=1.11a name=') + assert isinstance(encoded, bytes) + self.image_data = encoded + self.image_size = len(self.image_data) + + @property def properties(self) -> dict[str, Any]: - """Return ImagejRoi.props as dictionary.""" + """Decoded props field as dictionary.""" val: Any props = {} for line in self.props.splitlines(): @@ -1219,7 +1668,6 @@ @properties.setter def properties(self, value: dict[str, Any], /) -> None: - """Set ImagejRoi.props from dictionary.""" lines = [] for item in sorted(value.items()): key, val = item @@ -1279,7 +1727,14 @@ *, offset: tuple[float, float] | None = None, ) -> None: - """Scale matplotlib text to width in data coordinates.""" + """Scale matplotlib text to width in data coordinates. + + Parameters: + text: Matplotlib text object to scale. + width: Target width in data coordinates. + offset: Optional offset tuple (x, y). + + """ from matplotlib.patheffects import AbstractPathEffect from matplotlib.transforms import Bbox @@ -1317,7 +1772,16 @@ def oval(rect: ArrayLike, /, points: int = 33) -> NDArray[numpy.float32]: - """Return coordinates of oval from rectangle corners.""" + """Return coordinates of oval inscribed in bounding rectangle. + + Parameters: + rect: Bounding rectangle as [[left, top], [right, bottom]]. + points: Number of points to generate around oval. + + Returns: + Array of shape (points, 2) with x, y coordinates. + + """ arr = numpy.asarray(rect, dtype=numpy.float32) c = numpy.linspace(0.0, 2.0 * numpy.pi, num=points, dtype=numpy.float32) c = numpy.array([numpy.cos(c), numpy.sin(c)]).T @@ -1329,7 +1793,17 @@ def indent(*args: Any, sep: str = '', end: str = '') -> str: - """Return joined string representations of objects with indented lines.""" + """Return joined string representations of objects with indented lines. + + Parameters: + *args: Objects to join and indent. + sep: Separator between objects. + end: String appended at end. + + Returns: + Indented string representation. + + """ text: str = (sep + '\n').join( arg if isinstance(arg, str) else repr(arg) for arg in args ) @@ -1344,7 +1818,15 @@ def enumstr(v: enum.Enum | None, /) -> str: - """Return IntEnum or IntFlag as str.""" + """Return IntEnum or IntFlag as str. + + Parameters: + v: Enum value to convert to string. + + Returns: + String representation of enum value. + + """ # repr() and str() of enums are type, value, and version dependent if v is None: return 'None' @@ -1362,7 +1844,12 @@ def logger() -> logging.Logger: - """Return logger for roifile module.""" + """Return logger for roifile module. + + Returns: + Logger instance for 'roifile' module. + + """ return logging.getLogger('roifile') @@ -1373,6 +1860,12 @@ python -m roifile file_or_directory + Parameters: + argv: Command line arguments. Uses sys.argv if None. + + Returns: + Exit code (0 for success). + """ from glob import glob @@ -1392,11 +1885,11 @@ else: files = argv[1:] - for fname in files: - print(fname) # noqa: T201 + for filename in files: + print(filename) # noqa: T201 try: - rois = ImagejRoi.fromfile(fname) - title = os.path.split(fname)[-1] + rois = ImagejRoi.fromfile(filename) + title = os.path.split(filename)[-1] if isinstance(rois, list): for roi in rois: print(roi, '\n') # noqa: T201 @@ -1412,7 +1905,7 @@ except ValueError as exc: if sys.flags.dev_mode: raise - print(fname, exc) # noqa: T201 + print(filename, exc) # noqa: T201 continue return 0 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/roifile-2026.1.22/setup.py new/roifile-2026.2.10/setup.py --- old/roifile-2026.1.22/setup.py 2026-01-22 21:42:33.000000000 +0100 +++ new/roifile-2026.2.10/setup.py 2026-02-11 20:54:35.000000000 +0100 @@ -102,7 +102,9 @@ entry_points={'console_scripts': ['roifile = roifile.roifile:main']}, python_requires='>=3.11', install_requires=['numpy'], - extras_require={'all': ['matplotlib', 'tifffile']}, + extras_require={ + 'all': ['matplotlib', 'tifffile', 'imagecodecs>=2026.1.14'] + }, platforms=['any'], classifiers=[ 'Development Status :: 4 - Beta', diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/roifile-2026.1.22/tests/test_roifile.py new/roifile-2026.2.10/tests/test_roifile.py --- old/roifile-2026.1.22/tests/test_roifile.py 2026-01-22 21:42:33.000000000 +0100 +++ new/roifile-2026.2.10/tests/test_roifile.py 2026-02-11 20:54:35.000000000 +0100 @@ -29,7 +29,7 @@ """Unittests for the roifile package. -:Version: 2026.1.22 +:Version: 2026.2.10 """ @@ -147,6 +147,35 @@ assert roi.right == 6 assert roi.bottom == 7 + # test ROI_POINT_SIZE + roi.roitype = ROI_TYPE.POINT + roi.point_size = ROI_POINT_SIZE.SMALL + assert roi.point_size == ROI_POINT_SIZE.SMALL + roi.point_size = ROI_POINT_SIZE.LARGE + assert roi.point_size == ROI_POINT_SIZE.LARGE + roi.point_size = 17 + assert roi.point_size == ROI_POINT_SIZE.XXL + roi.point_size = 100 + assert roi.point_size == ROI_POINT_SIZE.UNKNOWN + assert roi.point_size.value == 100 + + # test ROI_POINT_TYPE + roi.point_type = ROI_POINT_TYPE.CROSS + assert roi.point_type == ROI_POINT_TYPE.CROSS + roi.point_type = ROI_POINT_TYPE.DOT + assert roi.point_type == ROI_POINT_TYPE.DOT + roi.point_type = 3 + assert roi.point_type == ROI_POINT_TYPE.CIRCLE + roi.point_type = 100 + assert roi.point_type == ROI_POINT_TYPE.UNKNOWN + assert roi.point_type.value == 100 + + # verify round-trip with point properties + roi2 = ImagejRoi.frombytes(roi.tobytes()) + assert roi2.point_size == roi.point_size + assert roi2.point_type == roi.point_type + assert roi2 == roi + def test_frompoints_float(): """Test creating ROI from float coordinates.""" @@ -168,6 +197,97 @@ assert roi.bottom == 65535, roi.bottom [email protected]( + 'points', + [ + [[0, 0], [10, 10]], # small positive coords + [[-5000, 0], [100, 100]], # boundary at -5000 + [[0, 0], [60535, 60535]], # large positive at boundary + [[-5000, -5000], [60535, 60535]], # mixed range + [[0, 0], [32767, 32768]], # near int16 max boundary + [[0, 0], [65534, 65535]], # near uint16 max boundary + [[-5000, 60535], [60534, 65534]], # original issue #13 + ], +) +def test_coordinate_wrapping_roundtrip(points): + """Test that coordinates correctly roundtrip through int16 wrapping.""" + roi1 = ImagejRoi.frompoints(points) + data = roi1.tobytes() + roi2 = ImagejRoi.frombytes(data) + + assert roi1 == roi2 + assert numpy.array_equal( + roi1.integer_coordinates, roi2.integer_coordinates + ) + assert numpy.array_equal(roi1.coordinates(), roi2.coordinates()) + + +def test_min_int_coord_parameter(): + """Test that min_int_coord parameter is respected.""" + # test with default -5000 + roi = ImagejRoi.frompoints([[-5000, 0], [100, 100]]) + data = roi.tobytes() + roi_default = ImagejRoi.frombytes(data) + assert roi_default == roi + + # test with -32768 (full int16 range) + roi2 = ImagejRoi.frompoints([[-32768, 0], [100, 100]]) + data2 = roi2.tobytes() + roi_int16 = ImagejRoi.frombytes(data2, min_int_coord=-32768) + assert roi_int16 == roi2 + + # test with 0 (uint16 range, no negative coordinates) + roi3 = ImagejRoi.frompoints([[0, 0], [60535, 60535]]) + data3 = roi3.tobytes() + roi_uint16 = ImagejRoi.frombytes(data3, min_int_coord=0) + assert roi_uint16 == roi3 + + [email protected]('kind', ['uint8', 'uint16', 'float32', 'rgb']) +def test_image_subtype(kind): + """Test creating IMAGE subtype ROI.""" + image: numpy.ndarray + if kind == 'rgb': + image = numpy.arange(300, dtype=numpy.uint8).reshape(10, 10, 3) + else: + image = numpy.arange(100, dtype=kind).reshape(10, 10) + + roi = ImagejRoi() + roi.version = 228 + roi.name = 'Image ROI' + roi.roitype = ROI_TYPE.RECT + roi.subtype = ROI_SUBTYPE.IMAGE + roi.left = 50 + roi.top = 100 + roi.image_opacity = 128 + roi.image = image + + with open(f'{kind}.roi', 'wb') as fh: + fh.write(roi.tobytes()) + + assert roi.subtype == ROI_SUBTYPE.IMAGE + assert roi.image_size > 0 + assert roi.image_data is not None + assert roi.right == roi.left + image.shape[1] + assert roi.bottom == roi.top + image.shape[0] + + # test round-trip + roi2 = ImagejRoi.frombytes(roi.tobytes()) + assert roi2 == roi + assert roi2.subtype == ROI_SUBTYPE.IMAGE + assert roi2.image_size == roi.image_size + assert roi2.image_opacity == 128 + + # test that image can be decoded + decoded_image = roi2.image + assert decoded_image is not None + assert decoded_image.shape == image.shape + numpy.testing.assert_array_equal(decoded_image, image) + + str(roi) + str(roi2) + + def test_rotated_text(): """Test reading rotated text ROI.""" rois = roiread(DATA / 'text_rotated.roi') @@ -309,14 +429,14 @@ @pytest.mark.parametrize( - 'fname', glob.glob('*.roi', root_dir=DATA, recursive=False) + 'filename', glob.glob('*.roi', root_dir=DATA, recursive=False) ) -def test_glob_roi(fname): +def test_glob_roi(filename): """Test read all ROI files.""" - if 'defective' in fname: + if 'defective' in filename: pytest.xfail(reason='file is marked defective') - fname = DATA / fname - roi = ImagejRoi.fromfile(fname) + filename = DATA / filename + roi = ImagejRoi.fromfile(filename) assert isinstance(roi, ImagejRoi) str(roi) roi.plot(show=False) @@ -324,14 +444,14 @@ @pytest.mark.parametrize( - 'fname', glob.glob('*.zip', root_dir=DATA, recursive=False) + 'filename', glob.glob('*.zip', root_dir=DATA, recursive=False) ) -def test_glob_zip(fname): +def test_glob_zip(filename): """Test read all ZIP files.""" - if 'defective' in fname: + if 'defective' in filename: pytest.xfail(reason='file is marked defective') - fname = DATA / fname - rois = roiread(fname) + filename = DATA / filename + rois = roiread(filename) assert isinstance(rois, list) for roi in rois: str(roi)
