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)

Reply via email to