Skip to content

Conversation

@pieleric
Copy link
Member

@pieleric pieleric commented Dec 22, 2025

After extending the SPARC acquisition to support rotated RoA, this extends the GUI to allow rotating the RoA from the GUI.
In practice, it changes mainly two parts:

  • Rewrite RepetitionSelectOverlay to use the (FAST-EM) RectangleEditingMixin overlay, which supports rotation. Used in the acquisition tab.
  • Adjust PixelSelectOverlay & PixelDataMixin to support rotation in the analysis tab. It's easier than it sounds, because instead of hard-coding the computation of the pixel position, we just use the (now available) method on the stream, which does just the right thing.

Copilot AI review requested due to automatic review settings December 22, 2025 14:29
@pieleric pieleric changed the title [feat] SPARC GUI: support rotated RoA [TRCL-664][feat] SPARC GUI: support rotated RoA Dec 22, 2025
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR adds support for rotated Region of Acquisition (RoA) in the SPARC GUI, enabling vector-scanned e-beam to acquire data at arbitrary rotation angles. The implementation provides rotation controls when the scanner supports the scanPath attribute.

Key changes:

  • New utility functions rotate_rect() and separate_rect_rotation() for rectangle rotation transformations
  • Enhanced RepetitionSelectOverlay and RectangleEditingMixin with rotation support and improved editing controls
  • Updated pixel/spectrum overlays to handle rotated spectral data using stream coordinate transformations

Reviewed changes

Copilot reviewed 18 out of 18 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
src/odemis/util/init.py Added rotate_rect() and separate_rect_rotation() utility functions for rectangle rotation transformations
src/odemis/util/test/util_test.py Added comprehensive test cases for rotation functions covering various angles and edge cases
src/odemis/gui/model/tab_gui_data.py Added roa_rotation VA to enable rotation control when scanner supports vector scanning
src/odemis/gui/cont/tabs/sparc_acquisition_tab.py Updated spot position initialization to use global RoA instead of stream-specific ROI
src/odemis/gui/cont/acquisition/sparc_acq.py Added validation to prevent acquisition when rotated RoA exceeds FoV boundaries
src/odemis/gui/cont/stream_bar.py Added rotation synchronization between tab data model and streams with ROI
src/odemis/gui/cont/tabs/analysis_tab.py Refactored to use set_stream() instead of set_data_properties() for pixel overlays
src/odemis/gui/comp/overlay/repetition_select.py Major refactor to support rotation: added physical coordinates tracking, rotation handling, edit knobs, and side labels
src/odemis/gui/comp/overlay/rectangle.py Updated to use renamed start_creation() method and removed unused center parameter
src/odemis/gui/comp/overlay/pixel_select.py Updated to use stream-based coordinate transformations and handle rotated pixels
src/odemis/gui/comp/overlay/spectrum_line_select.py Updated to use cairo_polygon() for drawing rotated pixel rectangles
src/odemis/gui/comp/overlay/world_select.py Added TODO comment about refactoring overlay class hierarchy
src/odemis/gui/comp/overlay/base.py Enhanced RectangleEditingMixin with rotation support, normalized rectangle handling, PixelDataMixin refactored to use stream coordinates, added cairo_polygon() helper
src/odemis/gui/comp/overlay/view_select.py Removed unused ViewSelectOverlay class
src/odemis/gui/comp/miccanvas.py Updated RepetitionSelectOverlay initialization to pass rotation parameter
src/odemis/gui/test/comp_overlay_test.py Updated tests to use corners instead of rectangles, added rotation tests, refactored spectrum stream creation
src/odemis/acq/stream/_base.py Enhanced documentation for getPixelCoordinates() and getPhysicalCoordinates()
plugins/secom_cl.py Set roa_rotation = None to indicate no rotation support for SECOM-CL acquisitions
Comments suppressed due to low confidence (2)

src/odemis/gui/cont/stream_bar.py:981

  • The rotation subscription added in _connectROI at line 963 is not properly unsubscribed in _disconnectROI. This could lead to a memory leak or unexpected behavior when streams are removed. The roa_rotation.unsubscribe(self._on_roa_rotation) call should be added in the _disconnectROI method, similar to how the ROA subscription is handled.
    def _disconnectROI(self, stream):
        """
        Remove ROI subscriptions for the stream.
        It's fine to call for a stream which is not connected to ROI.
        stream (Stream): the stream being removed
        """
        if stream in self._roi_listeners:
            logging.debug("Removing %s from ROI subscriptions", stream)
            # Removing the callable from the roi_listeners should be sufficient,
            # as the callable should be unreferenced and free'd, which should drop
            # it from the subscriber... but let's make everything explicit.
            stream.roi.unsubscribe(self._roi_listeners[stream])
            del self._roi_listeners[stream]

src/odemis/gui/comp/overlay/rectangle.py:81

class RectangleOverlay(EditableShape, RectangleEditingMixin, WorldOverlay):

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@coderabbitai
Copy link

coderabbitai bot commented Dec 22, 2025

📝 Walkthrough

Walkthrough

Adds vector-rotation support for ROA across the GUI, streams, overlays, acquisition controller, and utilities. A new optional public attribute roa_rotation (FloatContinuous) was added to SparcAcquisitionGUIData and set to None in the secom_cl plugin when rotation is unsupported. StreamBar publishes and forwards ROA rotation to streams; streams expose rotation via set_stream. Overlays (RepetitionSelectOverlay, PixelSelectOverlay, SpectrumLineSelectOverlay) accept/use rotation and stream metadata. RectangleEditingMixin and PixelDataMixin are rotation-aware (creation, mapping, snapping). SparcAcquiController now validates rotated ROA corners before enabling acquisition. util.rotate_rect and util.separate_rect_rotation and tests were added.

Sequence Diagram(s)

sequenceDiagram
  actor User
  participant GUI as SparcAcquisitionGUIData
  participant StreamBar as StreamBarController
  participant Stream as Stream
  participant Overlay as Repetition/Pixel/Spectrum Overlay
  participant Acq as SparcAcquiController
  participant Util as util.rotate_rect / separate_rect_rotation

  User->>GUI: set RoA and/or roa_rotation
  GUI->>StreamBar: publish roa and roa_rotation
  StreamBar->>Stream: init stream rotation & subscribe to updates
  Stream->>Overlay: provide metadata via set_stream (includes rotation)
  Overlay->>Util: compute rotated physical corners (rotate_rect)
  Overlay->>Acq: emit on_roa / on_rotation events
  Acq->>Util: validate rotated corners within [0,1] (separate_rect_rotation)
  Acq-->>User: enable/disable Acquire UI and show warnings
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 54.24% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title '[TRCL-664][feat] SPARC GUI: support rotated RoA' clearly summarizes the main objective of the PR: adding rotated RoA support to the SPARC GUI.
Description check ✅ Passed The description is related to the changeset, explaining that it extends SPARC GUI to support rotating the RoA, with details on the two main parts modified.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Tip

🧪 Unit Test Generation v2 is now available!

We have significantly improved our unit test generation capabilities.

To enable: Add this to your .coderabbit.yaml configuration:

reviews:
  finishing_touches:
    unit_tests:
      enabled: true

Try it out by using the @coderabbitai generate unit tests command on your code files or under ✨ Finishing Touches on the walkthrough!

Have feedback? Share your thoughts on our Discord thread!


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/odemis/gui/cont/stream_bar.py (1)

953-968: Guard roa_rotation access and avoid repeated subscriptions

The rotation wiring in _connectROI is conceptually right, but the implementation is brittle:

  • if self._tab_data_model.roa_rotation: will raise AttributeError if a tab defines .roa but not .roa_rotation.
  • Calling self._tab_data_model.roa_rotation.subscribe(self._on_roa_rotation) on every _connectROI risks duplicate subscriptions and unnecessary callbacks.

Consider:

  • Resolving the rotation VA once (e.g. in __init__) via getattr(tab_data, "roa_rotation", None).
  • Subscribing _on_roa_rotation only once when that VA exists, and only using it in _connectROI to seed stream.rotation.value.

Example adjustment:

Proposed safer rotation hookup
@@ class StreamBarController(object):
-        self._tab_data_model = tab_data
+        self._tab_data_model = tab_data
+        # Optional global ROA rotation VA
+        self._roa_rotation_va = getattr(tab_data, "roa_rotation", None)
@@     def _connectROI(self, stream):
-        stream.roi.value = self._tab_data_model.roa.value
-        self._tab_data_model.roa.subscribe(self._onROA)
-
-        if self._tab_data_model.roa_rotation:
-            stream.rotation.value = self._tab_data_model.roa_rotation.value
-            self._tab_data_model.roa_rotation.subscribe(self._on_roa_rotation)
+        stream.roi.value = self._tab_data_model.roa.value
+        self._tab_data_model.roa.subscribe(self._onROA)
+
+        if self._roa_rotation_va is not None:
+            stream.rotation.value = self._roa_rotation_va.value
+            # Subscribe once (idempotent if VA de-duplicates callbacks)
+            self._roa_rotation_va.subscribe(self._on_roa_rotation)
src/odemis/acq/stream/_base.py (1)

1259-1267: Clarify coordinate docstrings to match actual behavior

The new docstrings are slightly misleading relative to the current implementations:

  • getPixelCoordinates(...) returns integer indices when check_bbox=True (after floor + bounds check), and floating coordinates only when check_bbox=False. The text currently describes float-in-pixel semantics unconditionally.
  • getPhysicalCoordinates(...) accepts floats (and works fine with sub‑pixel values like i+0.5), but the wording could better stress that pixel coordinates are in image‑index units (0 at top‑left pixel border, 1 per pixel step), not normalized 0–1 over the whole image.

Consider tightening the docstrings along these lines:

Suggested docstring tweak
-        :returns: the position in pixel coordinates, as float, going from +(0, 0) for the top-left of
-        the pixel to +(1.0, 1.0) for the bottom-right of the pixel. So you can use int() to get the pixel index.
-        It returns None if it's outside of the image and check_boundary is True
+        :returns: the position in pixel coordinates.
+          * If ``check_bbox`` is True (default), returns integer pixel indices ``(ix, iy)`` or
+            ``None`` when outside the image.
+          * If ``check_bbox`` is False, returns float pixel coordinates, where each unit
+            corresponds to one pixel (fractional part describes the position inside a pixel).

This keeps callers from assuming float returns in all cases.

Also applies to: 1297-1303

🧹 Nitpick comments (8)
src/odemis/gui/comp/overlay/world_select.py (1)

39-40: Consider tracking these refactoring TODOs in your issue tracker.

The TODO comments document substantial architectural changes that would benefit from formal tracking and prioritization. This ensures they don't get lost and can be planned appropriately.

src/odemis/gui/comp/overlay/base.py (1)

1965-2051: Respect non-square pixels in PixelDataMixin.pixel_to_rect

pixel_to_rect currently uses the X pixel size for both width and height:

b_pixel_size = (self._phys_pxs[0] * scale, self._phys_pxs[0] * scale)

This assumes square pixels and slightly distorts the highlighted area when X/Y pixel sizes differ. Since you now centralized this helper, it’s a good moment to make it fully general:

Suggested fix for asymmetric pixel sizes
-        b_pixel_size = (self._phys_pxs[0] * scale, self._phys_pxs[0] * scale)
+        b_pixel_size = (self._phys_pxs[0] * scale, self._phys_pxs[1] * scale)

That keeps behavior identical for square pixels while producing correct geometry for anisotropic pixels.

src/odemis/gui/comp/overlay/rectangle.py (1)

89-157: Constructor API change is consistent but might want optional can_rotate exposure

The new __init__(self, cnvs, colour=..., show_selection_points=True) correctly matches RectangleEditingMixin.__init__(self, colour) and keeps rotation enabled by default. If you foresee non-rotatable rectangles in the future, consider threading a can_rotate parameter through this constructor (defaulting to True) so callers don’t need to reach into the mixin directly.

src/odemis/util/test/util_test.py (2)

587-591: Consider adding strict=True to zip() for safety.

The static analyzer flags zip() without explicit strict= parameter. Since expected and actual lengths are asserted equal on line 588, adding strict=True would make the intent clearer and catch any future bugs if the assertion is accidentally removed.

🔎 Proposed fix
     def _assert_corners_equal(self, expected: List[Tuple], actual:List[Tuple]):
         assert len(expected) == len(actual)
-        for e, a in zip(expected, actual):
+        for e, a in zip(expected, actual, strict=True):
             self.assertAlmostEqual(e[0], a[0])
             self.assertAlmostEqual(e[1], a[1])

701-705: Unused variable res_angle is intentional but could be clearer.

The static analyzer flags res_angle as unused. Since this test only verifies the rectangle result for undefined ROI, consider using _ to indicate the variable is intentionally ignored.

🔎 Proposed fix
     def test_undefined_roi(self):
         corners = [(2, 3)] * 4
-        res_rect, res_angle = util.separate_rect_rotation(corners)
+        res_rect, _ = util.separate_rect_rotation(corners)
         self.assertEqual(res_rect, UNDEFINED_ROI)
src/odemis/gui/comp/overlay/repetition_select.py (3)

166-199: Corner ordering differs from WorldSelectOverlay.get_physical_sel.

The get_physical_sel method returns corners in a reordered sequence (4, 3, 2, 1) to account for Y-axis inversion between view and physical coordinates. This differs from the base WorldSelectOverlay.get_physical_sel which returns a simple (l, t, r, b) rect. Ensure callers are aware of this change in return type (4 corners vs. 4-tuple rect).

The docstring could be enhanced to clarify the corner ordering.

🔎 Suggested docstring enhancement
     def get_physical_sel(self) -> Optional[List[Tuple[float, float]]]:
         """ Return the selected rectangle in physical coordinates
-        :return: Physical position in m of the 4 corners, or None if no selection
+        :return: Physical position in m of the 4 corners (ordered for rotate_rect compatibility:
+          bottom-left, bottom-right, top-right, top-left), or None if no selection
         """

280-310: Rotation conversion logic is correct but could use a comment.

The rotation conversion 2 * math.pi - rotation correctly handles the Y-axis inversion between physical coordinates (Y up) and view coordinates (Y down). A brief inline comment explaining this would improve maintainability.

🔎 Suggested comment
     def on_roa(self, roa: Optional[Tuple[float, float, float, float]]):
         """ Update the ROA overlay with the new roa VA data

         roi (tuple of 4 floats): left, top, right, bottom position relative to the SEM image

         """
-        phys_rot = 2 * math.pi - self.rotation  # Y inverted => clockwise rotation becomes counter-clockwise
+        # Convert from view rotation to physical rotation:
+        # Y is inverted between coordinate systems, so counter-clockwise in view
+        # becomes clockwise in physical coordinates (and vice versa)
+        phys_rot = 2 * math.pi - self.rotation

338-373: Large TODO block indicates incomplete rotation clipping implementation.

The TODO comments (lines 343-356) describe a significant missing feature: clipping the rotated rectangle to the scanner FOV. Currently, this is handled by the GUI controller disabling the "acquire" button when the ROA is out of bounds, but this is noted as a temporary workaround.

Consider tracking this as a follow-up issue to ensure it's addressed.

Would you like me to open an issue to track the implementation of proper rotation-aware ROA clipping to the scanner FOV?

📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 8ad878c and e3d37be.

📒 Files selected for processing (18)
  • plugins/secom_cl.py
  • src/odemis/acq/stream/_base.py
  • src/odemis/gui/comp/miccanvas.py
  • src/odemis/gui/comp/overlay/base.py
  • src/odemis/gui/comp/overlay/pixel_select.py
  • src/odemis/gui/comp/overlay/rectangle.py
  • src/odemis/gui/comp/overlay/repetition_select.py
  • src/odemis/gui/comp/overlay/spectrum_line_select.py
  • src/odemis/gui/comp/overlay/view_select.py
  • src/odemis/gui/comp/overlay/world_select.py
  • src/odemis/gui/cont/acquisition/sparc_acq.py
  • src/odemis/gui/cont/stream_bar.py
  • src/odemis/gui/cont/tabs/analysis_tab.py
  • src/odemis/gui/cont/tabs/sparc_acquisition_tab.py
  • src/odemis/gui/model/tab_gui_data.py
  • src/odemis/gui/test/comp_overlay_test.py
  • src/odemis/util/__init__.py
  • src/odemis/util/test/util_test.py
💤 Files with no reviewable changes (1)
  • src/odemis/gui/comp/overlay/view_select.py
🧰 Additional context used
🧬 Code graph analysis (11)
src/odemis/gui/comp/overlay/spectrum_line_select.py (1)
src/odemis/gui/comp/overlay/base.py (2)
  • cairo_polygon (61-73)
  • pixel_to_rect (2029-2050)
src/odemis/gui/cont/tabs/analysis_tab.py (3)
src/odemis/gui/comp/overlay/base.py (1)
  • set_stream (1985-1992)
src/odemis/gui/comp/overlay/pixel_select.py (1)
  • connect_selection (51-62)
src/odemis/gui/comp/overlay/spectrum_line_select.py (1)
  • connect_selection (186-212)
src/odemis/gui/comp/overlay/repetition_select.py (3)
src/odemis/gui/comp/overlay/base.py (18)
  • Vec (295-342)
  • cairo_polygon (61-73)
  • text (99-100)
  • text (103-105)
  • pos (117-118)
  • pos (121-123)
  • align (135-136)
  • align (139-141)
  • clear_selection (773-784)
  • clear_selection (1238-1253)
  • clear_selection (1729-1734)
  • x (322-323)
  • y (326-327)
  • rotate (329-342)
  • draw (159-292)
  • draw (2060-2061)
  • draw (2078-2079)
  • draw (2102-2139)
src/odemis/gui/comp/overlay/world_select.py (5)
  • clear_selection (70-74)
  • _phys_to_view (86-94)
  • get_physical_sel (96-107)
  • set_physical_sel (109-121)
  • draw (123-185)
src/odemis/util/__init__.py (2)
  • rotate_rect (430-466)
  • separate_rect_rotation (469-514)
src/odemis/gui/cont/acquisition/sparc_acq.py (2)
src/odemis/gui/cont/stream_bar.py (1)
  • _onROA (1014-1036)
src/odemis/util/__init__.py (1)
  • rotate_rect (430-466)
src/odemis/gui/model/tab_gui_data.py (2)
src/odemis/model/_components.py (2)
  • model (570-571)
  • hasVA (63-69)
src/odemis/model/_vattributes.py (1)
  • FloatContinuous (1072-1082)
src/odemis/gui/comp/overlay/rectangle.py (2)
src/odemis/gui/comp/overlay/shapes.py (1)
  • EditableShape (47-170)
src/odemis/gui/comp/overlay/base.py (2)
  • RectangleEditingMixin (1125-1662)
  • start_creation (1207-1214)
src/odemis/gui/comp/miccanvas.py (1)
src/odemis/gui/comp/overlay/repetition_select.py (1)
  • RepetitionSelectOverlay (42-631)
src/odemis/gui/comp/overlay/pixel_select.py (1)
src/odemis/gui/comp/overlay/base.py (4)
  • DragMixin (564-659)
  • cairo_polygon (61-73)
  • pixel_to_rect (2029-2050)
  • dragging (644-646)
src/odemis/gui/comp/overlay/base.py (2)
src/odemis/acq/stream/_base.py (4)
  • Stream (91-1370)
  • _find_metadata (988-1028)
  • getPixelCoordinates (1259-1295)
  • getPhysicalCoordinates (1297-1322)
src/odemis/util/__init__.py (2)
  • normalize_rect (387-403)
  • rotate_rect (430-466)
src/odemis/gui/test/comp_overlay_test.py (3)
src/odemis/util/comp.py (1)
  • get_fov_rect (98-113)
src/odemis/util/testing.py (1)
  • assert_tuple_almost_equal (221-236)
src/odemis/util/__init__.py (1)
  • rotate_rect (430-466)
src/odemis/util/test/util_test.py (2)
src/odemis/util/__init__.py (5)
  • to_str_escape (178-194)
  • BackgroundWorker (824-892)
  • rotate_rect (430-466)
  • separate_rect_rotation (469-514)
  • rot_almost_equal (128-144)
src/odemis/util/testing.py (1)
  • assert_tuple_almost_equal (221-236)
🪛 Ruff (0.14.8)
src/odemis/gui/comp/overlay/base.py

1568-1568: Avoid specifying long messages outside the exception class

(TRY003)

src/odemis/gui/test/comp_overlay_test.py

638-638: zip() without an explicit strict= parameter

Add explicit value for parameter strict=

(B905)


721-721: zip() without an explicit strict= parameter

Add explicit value for parameter strict=

(B905)


734-734: zip() without an explicit strict= parameter

Add explicit value for parameter strict=

(B905)


747-747: zip() without an explicit strict= parameter

Add explicit value for parameter strict=

(B905)

src/odemis/util/test/util_test.py

589-589: zip() without an explicit strict= parameter

Add explicit value for parameter strict=

(B905)


661-661: zip() without an explicit strict= parameter

Add explicit value for parameter strict=

(B905)


678-678: zip() without an explicit strict= parameter

Add explicit value for parameter strict=

(B905)


698-698: zip() without an explicit strict= parameter

Add explicit value for parameter strict=

(B905)


703-703: Unpacked variable res_angle is never used

Prefix it with an underscore or any other dummy variable pattern

(RUF059)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (4)
  • GitHub Check: Agent
  • GitHub Check: CodeQL analysis (python)
  • GitHub Check: build (ubuntu-24.04)
  • GitHub Check: build (ubuntu-22.04)
🔇 Additional comments (28)
plugins/secom_cl.py (1)

888-896: Explicitly disabling ROA rotation for SECOM CL is appropriate

Setting dlg._dmodel.roa_rotation = None clearly signals that this dialog does not support rotation and keeps DblMicroscopeCanvas.setView happy when it passes tab_data.roa_rotation into RepetitionSelectOverlay. No further changes needed here.

src/odemis/gui/comp/overlay/pixel_select.py (1)

25-35: Polygon-based pixel rendering integrates cleanly with PixelDataMixin

Using cairo_polygon(ctx, self.pixel_to_rect(...)) for both selected and hover pixels, with distinct select_color vs colour, keeps the drawing logic simple and correctly leverages the rotation-aware PixelDataMixin.pixel_to_rect. Skipping hover fill while self.dragging avoids visual flicker during selection updates. This refactor looks solid.

Also applies to: 48-50, 162-180

src/odemis/gui/model/tab_gui_data.py (1)

568-603: Conditional roa_rotation VA design matches hardware capability

Exposing roa_rotation as a FloatContinuous only when main.ebeam supports scanPath, and otherwise setting it to None, is a reasonable way to gate rotation support on hardware capability. As long as callers treat None as “no rotation possible” (as in miccanvas and the SECOM CL plugin), this is a good fit.

Please double‑check that all other consumers of SparcAcquisitionGUIData now null‑check roa_rotation before subscribing or writing to it.

src/odemis/gui/comp/overlay/base.py (1)

61-75: Geometry / rotation infrastructure looks good; mind API consumers

The new cairo_polygon helper, extended Vec arithmetic, and rotation-aware RectangleEditingMixin (with can_rotate and rotation knob handling) form a coherent base for rotated ROIs. Internals (_calc_center, _calc_rotation, rotation knob placement) are consistent, and the selection mode handling (CREATE/EDIT/DRAG/ROTATION) is clear.

However, RectangleEditingMixin’s public-ish API changed (start_selection/update_selectionstart_creation/update_creation, new can_rotate parameter). Any subclasses or external code that previously called the old methods or relied on always-rotatable rectangles must be updated accordingly.

Please grep for RectangleEditingMixin subclasses and ensure:

  • They no longer call start_selection / update_selection directly.
  • They pass an appropriate can_rotate flag (e.g. can_rotate=rotation is not None) when calling __init__.

Also applies to: 295-343, 1125-1200, 1398-1460, 1554-1569

src/odemis/gui/comp/overlay/rectangle.py (1)

341-369: Use of start_creation() aligns correctly with new mixin API

Switching from start_selection() to start_creation() matches the updated RectangleEditingMixin interface and keeps the “only create when no selection exists” behavior intact. No functional issues spotted.

src/odemis/gui/cont/tabs/sparc_acquisition_tab.py (1)

381-394: Spot initialization from ROA center looks correct

Using tab_data_model.roa.value (with UNDEFINED_ROI fallback) to place the initial spot at the ROA center, or FoV center when undefined, is consistent with the ROA-driven workflow and keeps coordinates normalized. No issues found.

src/odemis/gui/comp/overlay/spectrum_line_select.py (2)

25-33: Imports for wx and cairo_polygon are appropriate

Bringing in wx (for cursor control) and cairo_polygon together with PixelDataMixin/Vec matches their usage later in the file and avoids NameErrors.


279-295: Rotation-aware pixel rendering properly implemented with correct initialization

The change to use pixel_to_rect(point, scale) + cairo_polygon(ctx, corners) correctly renders rotation-aware per-pixel footprints in buffer space. The code properly depends on set_stream() initialization: analysis_tab.py calls ol.set_stream(spec_stream) before draw operations, and test code follows the same pattern, ensuring _data_resolution, _phys_pxs, and rotation attributes are populated.

src/odemis/gui/cont/tabs/analysis_tab.py (1)

436-454: Stream binding for pixel/line overlays is wired correctly

Calling ol.set_stream(spec_stream) before connect_selection(...) for both pixel_overlay and line_overlay is the right way to feed resolution/physical/rotation metadata into the overlays. The existing “first spectrum stream only” limitation is unchanged and documented in the comment.

src/odemis/gui/cont/stream_bar.py (1)

1038-1049: ROA rotation propagation to ROI streams is logically sound

_on_roa_rotation correctly broadcasts the new angle to all streams that participate in ROI synchronisation (those tracked in _roi_listeners) and doesn’t attempt to write back to the global ROA, avoiding feedback loops. This assumes all such streams expose a writable .rotation VA, which matches the new rotation-capable settings streams.

src/odemis/util/test/util_test.py (2)

593-627: LGTM!

The RotateRectTestCase test class is well-structured with good coverage of rotation scenarios: zero angle, 90° rotation about center, custom center rotation, and negative angles. The helper method _rotate_point correctly implements the rotation formula for verification.


630-679: LGTM!

The SeparateRectRotationTestCase provides comprehensive round-trip testing between rotate_rect and separate_rect_rotation. The tests verify that the functions are inverses of each other, which is the correct approach for validating coordinate transformation utilities.

src/odemis/gui/cont/acquisition/sparc_acq.py (3)

39-39: LGTM!

The util module import is correctly added to support the rotate_rect function usage for rotation bounds checking.


141-161: LGTM!

The ROA source is correctly updated to use tab_data.roa directly, and the conditional subscription to roa_rotation properly triggers ROA updates when rotation changes. The comment clearly explains the purpose of the rotation subscription.


302-342: Verify rotation bounds check handles edge cases correctly.

The rotation bounds check at lines 321-328 rotates the ROA and verifies all corners are within [0, 1]. However, roa_rotation is checked for truthiness, which would be False if the rotation value is 0. This seems intentional (skip check when no rotation), but verify this is the desired behavior.

Also, the rotation is applied directly to the ROA coordinates. Ensure that the rotation center used by rotate_rect (defaulting to rectangle center) aligns with how the acquisition system interprets the rotation.

src/odemis/util/__init__.py (2)

430-466: LGTM!

The rotate_rect function correctly implements 2D rotation around an optional center point. The math is correct: translate to origin, apply rotation matrix, translate back. The docstring clearly documents the corner ordering (clockwise from minx, miny).


469-514: LGTM!

The separate_rect_rotation function correctly inverts rotate_rect by computing the rotation angle from corner vectors and then un-rotating to get the axis-aligned rectangle. The edge case handling for degenerate rectangles (corners 0 & 1 identical) is well-documented and falls back to using corners 0 & 3 with a 90° offset.

src/odemis/gui/test/comp_overlay_test.py (4)

622-639: LGTM!

The test correctly creates a rotation model and passes it to RepetitionSelectOverlay. The ROI comparison is properly updated to use rotated corners with util.rotate_rect and assert_tuple_almost_equal for floating-point comparison.


686-748: LGTM!

The test_roa_select_overlay_va test is well-updated to verify rotation functionality. It properly tests:

  1. Undefined ROA returns None
  2. Full FoV with no rotation
  3. Half FoV with no rotation
  4. Half FoV with 0.1 rad rotation

The corner comparisons correctly use util.rotate_rect to compute expected rotated positions.


783-795: LGTM!

The _create_spectrum_stream helper method is a good refactoring that reduces code duplication across spectrum-related overlay tests. The metadata setup (pixel size, position, rotation, wavelength list) provides a realistic test fixture.


816-816: LGTM!

The overlay tests are correctly updated to use set_stream() instead of the previous property-based approach, aligning with the new streaming data API introduced in this PR.

src/odemis/gui/comp/overlay/repetition_select.py (7)

42-66: LGTM!

The class inheritance change from WorldSelectOverlay to WorldOverlay and RectangleEditingMixin is a clean refactoring. The can_rotate parameter is correctly derived from whether a rotation VA is provided, and both parent classes are properly initialized.


79-107: LGTM!

The physical corner tracking fields (p_point1-4) and side length labels are well-initialized. The label configuration with white text on black background provides good visibility for the dimension display.


142-165: Verify coordinate conversion consistency.

The _view_to_phys and _phys_to_view methods convert between view and physical coordinates. Ensure that these are called symmetrically after any coordinate modifications to keep v_point* and p_point* in sync. Missing synchronization could lead to inconsistent state.


402-458: LGTM!

The _draw_points method correctly handles the rotated rectangle by computing point positions via interpolation between the corner vertices. The performance optimization to fall back to a solid fill for very dense grids (>50K points or <4px spacing) is a good approach.


460-500: LGTM!

The _draw_grid method correctly draws grid lines by interpolating between adjacent corners, which naturally handles rotation. The fallback to solid fill for dense grids (<3px spacing) maintains visual clarity.


557-596: LGTM!

The _draw_side_labels method correctly computes side lengths using physical coordinates and positions labels with appropriate rotation to align with each side. The approach of finding corners by x+y sum/difference is a clever way to identify the bottom-left and bottom-right corners regardless of rotation.


597-631: LGTM!

The draw method correctly validates that all four points are defined before rendering, handles the degenerate case where the rectangle hasn't been fully defined yet, and appropriately skips repetition drawing during edit/create modes to improve interactivity.

@pieleric pieleric requested review from nandishjpatel and removed request for tmoerkerken January 5, 2026 12:57
@pieleric pieleric force-pushed the feat-sparc-gui-support-rotated-roa branch from e3d37be to 75c27fa Compare January 13, 2026 16:06
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In @src/odemis/acq/stream/_base.py:
- Around line 1264-1266: The docstring mistakenly mentions parameter name
check_boundary; update the docstring to refer to the actual parameter check_bbox
(the parameter defined as check_bbox) and adjust the sentence to state that the
function returns None when the point is outside the image and check_bbox is
True; ensure any other occurrences in the same docstring use check_bbox
consistently so the documentation matches the function signature (refer to the
parameter check_bbox in the current function's docstring).
🧹 Nitpick comments (1)
src/odemis/gui/test/comp_overlay_test.py (1)

622-639: Consider adding strict=True to zip() for corner comparison.

The comparison loops at lines 638-639 (and similar ones at 721-722, 734-735, 747-748) iterate over two lists of corners that should always have exactly 4 elements. Adding strict=True would catch any unexpected mismatch during testing.

🔧 Suggested fix
-        for o, b in zip(corners, corners_back):
-            testing.assert_tuple_almost_equal(o, b)
+        for o, b in zip(corners, corners_back, strict=True):
+            testing.assert_tuple_almost_equal(o, b)
📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between e3d37be and 75c27fa.

📒 Files selected for processing (15)
  • plugins/secom_cl.py
  • src/odemis/acq/stream/_base.py
  • src/odemis/gui/comp/miccanvas.py
  • src/odemis/gui/comp/overlay/base.py
  • src/odemis/gui/comp/overlay/pixel_select.py
  • src/odemis/gui/comp/overlay/rectangle.py
  • src/odemis/gui/comp/overlay/repetition_select.py
  • src/odemis/gui/comp/overlay/spectrum_line_select.py
  • src/odemis/gui/comp/overlay/world_select.py
  • src/odemis/gui/cont/acquisition/sparc_acq.py
  • src/odemis/gui/cont/stream_bar.py
  • src/odemis/gui/cont/tabs/analysis_tab.py
  • src/odemis/gui/cont/tabs/sparc_acquisition_tab.py
  • src/odemis/gui/model/tab_gui_data.py
  • src/odemis/gui/test/comp_overlay_test.py
🚧 Files skipped from review as they are similar to previous changes (10)
  • src/odemis/gui/cont/tabs/sparc_acquisition_tab.py
  • src/odemis/gui/comp/overlay/spectrum_line_select.py
  • src/odemis/gui/cont/stream_bar.py
  • plugins/secom_cl.py
  • src/odemis/gui/cont/tabs/analysis_tab.py
  • src/odemis/gui/comp/overlay/pixel_select.py
  • src/odemis/gui/comp/overlay/world_select.py
  • src/odemis/gui/cont/acquisition/sparc_acq.py
  • src/odemis/gui/comp/miccanvas.py
  • src/odemis/gui/comp/overlay/rectangle.py
🧰 Additional context used
🧠 Learnings (2)
📚 Learning: 2026-01-12T12:37:35.155Z
Learnt from: K4rishma
Repo: delmic/odemis PR: 3245
File: src/odemis/acq/align/z_localization.py:29-40
Timestamp: 2026-01-12T12:37:35.155Z
Learning: Maintain the debugging pattern of importing odemis.gui.conf and exporting TIFF files within acquisition/localization loops across all odemis Python sources. Do not remove or restructure this behavior in refactors if it serves debugging consistency; document the debugging purpose with comments and ensure the behavior remains consistent across modules (e.g., acquisition/localization loops such as src/odemis/acq/align/z_localization.py).

Applied to files:

  • src/odemis/gui/comp/overlay/repetition_select.py
  • src/odemis/gui/test/comp_overlay_test.py
  • src/odemis/gui/model/tab_gui_data.py
  • src/odemis/acq/stream/_base.py
  • src/odemis/gui/comp/overlay/base.py
📚 Learning: 2025-10-20T10:00:09.185Z
Learnt from: K4rishma
Repo: delmic/odemis PR: 3245
File: src/odemis/gui/main_xrc.py:11982-11982
Timestamp: 2025-10-20T10:00:09.185Z
Learning: In `src/odemis/gui/main_xrc.py`, extra spaces in wxStaticText labels (such as "POI size     " and "Use Current Z ") are intentionally added for GUI alignment purposes and should not be flagged as formatting issues.

Applied to files:

  • src/odemis/gui/comp/overlay/base.py
🧬 Code graph analysis (2)
src/odemis/gui/model/tab_gui_data.py (2)
src/odemis/model/_components.py (2)
  • model (570-571)
  • hasVA (63-69)
src/odemis/model/_vattributes.py (1)
  • FloatContinuous (1072-1082)
src/odemis/gui/comp/overlay/base.py (3)
src/odemis/acq/stream/_base.py (4)
  • Stream (91-1370)
  • _find_metadata (988-1028)
  • getPixelCoordinates (1259-1295)
  • getPhysicalCoordinates (1297-1322)
src/odemis/gui/comp/overlay/shapes.py (1)
  • move_to (112-114)
src/odemis/util/__init__.py (2)
  • normalize_rect (387-403)
  • rotate_rect (430-466)
🪛 Ruff (0.14.10)
src/odemis/gui/test/comp_overlay_test.py

638-638: zip() without an explicit strict= parameter

Add explicit value for parameter strict=

(B905)


721-721: zip() without an explicit strict= parameter

Add explicit value for parameter strict=

(B905)


734-734: zip() without an explicit strict= parameter

Add explicit value for parameter strict=

(B905)


747-747: zip() without an explicit strict= parameter

Add explicit value for parameter strict=

(B905)

src/odemis/gui/comp/overlay/base.py

1577-1577: Avoid specifying long messages outside the exception class

(TRY003)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: build (ubuntu-24.04)
  • GitHub Check: build (ubuntu-22.04)
🔇 Additional comments (22)
src/odemis/acq/stream/_base.py (1)

1300-1301: LGTM!

The updated docstring for pixel_pos accurately describes the coordinate system and aligns with the return value documentation of getPixelCoordinates.

src/odemis/gui/model/tab_gui_data.py (1)

598-602: LGTM! Clean conditional initialization of rotation capability.

The pattern of checking for scanPath VA to determine rotation support is appropriate, and setting roa_rotation to None when not supported provides a clear signal to consumers. The range (0, 2*pi) is correct for rotation angles.

src/odemis/gui/test/comp_overlay_test.py (3)

737-749: Good rotation test coverage.

The test properly validates that rotation changes propagate through the overlay, computing expected corners with util.rotate_rect and comparing against the actual selection. This ensures the rotation integration works end-to-end.


783-795: Well-structured test helper.

The _create_spectrum_stream helper provides a clean, reusable way to create test streams with proper metadata. The rotation metadata at line 791 enables testing the rotation-aware pixel coordinate transformations.


816-816: API improvement: set_stream is cleaner than individual property setters.

Using set_stream() provides all required metadata (resolution, pixel size, rotation) from a single source of truth, reducing the risk of inconsistent state.

src/odemis/gui/comp/overlay/repetition_select.py (7)

42-107: Good architecture: Mixin-based approach for rotation support.

The refactor to inherit from WorldOverlay and RectangleEditingMixin is a clean design that reuses the rotation-aware rectangle editing logic from FAST-EM. The dual tracking of view coordinates (v_point*) and physical coordinates (p_point*) is necessary for proper world-space representation.


142-164: Coordinate synchronization methods are correctly implemented.

The bidirectional conversion between view and physical coordinates properly handles the optional point state and uses the canvas's coordinate transformation methods.


166-199: Selection accessors handle coordinate system differences correctly.

The point reordering in get_physical_sel() (returning [p_point4, p_point3, p_point2, p_point1]) compensates for Y-axis inversion between view and physical coordinates. The guard in set_physical_sel() preventing updates during active selection prevents race conditions.


280-298: ROA callback correctly handles rotation and coordinate systems.

The rotation inversion (2 * math.pi - self.rotation) properly accounts for the Y-axis direction difference between physical (Y-up) and view (Y-down) coordinates. The conditional handling of scanner-relative vs. absolute coordinates is well-structured.


339-374: ROA update logic is sound, with documented limitation for rotated clipping.

The unsubscribe/subscribe pattern at lines 366-370 correctly prevents feedback loops when updating the ROA VA. The TODO comments (lines 343-356) acknowledge the complexity of clipping rotated rectangles to the scanner FOV - this is a reasonable scope limitation for this PR.


402-500: Well-optimized rotation-aware repetition rendering.

The drawing methods handle rotated rectangles correctly by interpolating positions between corners rather than using axis-aligned calculations. The fallback to solid fill for dense grids (>50K points or <3px spacing) is a good performance optimization that prevents UI lag.


557-595: Side labels provide useful dimension feedback.

The label positioning logic correctly identifies the "bottom" and "right" sides of a rotated rectangle using coordinate sum/difference comparisons. Computing lengths from physical coordinates ensures accurate measurements regardless of view scale.

src/odemis/gui/comp/overlay/base.py (10)

61-73: Useful utility for drawing polygons.

The cairo_polygon helper cleanly encapsulates the common pattern of drawing a closed polygon path. The early return for empty points prevents errors.


312-319: Vec division operator enables cleaner center calculations.

The __truediv__ method allows expressions like (self.v_point1 + self.v_point3) / 2 for computing rectangle centers, improving code readability.


1156-1199: Good design: can_rotate flag separates user interaction from programmatic control.

The can_rotate parameter allows disabling user rotation while still permitting programmatic rotation. This is useful for cases where rotation should be controlled externally (e.g., linked to a VA).


1207-1214: Method rename improves clarity.

Renaming start_selection to start_creation better distinguishes rectangle creation from the "SEL_MODE_*" states used during editing. Resetting rotation to 0 for new rectangles ensures consistent initial state.


1534-1561: Rectangle normalization correctly handles rotated rectangles.

The approach of unrotating, normalizing, and re-rotating ensures points are always in consistent clockwise order regardless of how the user dragged corners. This is essential for correct hover detection and edge editing.


1324-1382: Rotation editing implementation is robust.

The rotation methods correctly:

  • Compute rotation by comparing angles relative to center
  • Rotate all 4 points together about the center
  • Support Ctrl-snapping to 45° increments for precise alignment
  • Handle edge cases (no points defined)

The Ctrl-snapping feature (lines 1350-1361) is a nice UX improvement for aligning rectangles.


1449-1532: Edge calculation correctly gates rotation hover on can_rotate.

The conditional addition of HOVER_ROTATION edges (lines 1459-1468) ensures the rotation knob only appears when rotation is enabled. The midpoint and vertex bounding boxes provide proper hit-testing areas for edge and corner editing.


2048-2069: pixel_to_rect correctly produces rotation-aware pixel boundaries.

The method computes pixel corners by building an axis-aligned rectangle and then rotating it. The negated rotation (-self._rotation) correctly compensates for the Y-axis direction difference in physical coordinates.


1576-1578: Minor: Inline exception message is acceptable here.

The static analysis hint about TRY003 (exception messages) is a false positive for this case - the message is short and provides necessary context for debugging. No change needed.


2004-2012: set_stream provides a cleaner API than individual property setters and follows the same pattern as Stream's public methods.

Extracting metadata from the Stream object ensures consistency. The _find_metadata call mirrors the approach used in Stream's public getPixelCoordinates() method, making this a standard internal pattern rather than problematic coupling.

@pieleric pieleric force-pushed the feat-sparc-gui-support-rotated-roa branch from 75c27fa to c238c6d Compare January 14, 2026 09:16
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/odemis/gui/cont/stream_bar.py (1)

953-968: Add rotation VA guard and prevent duplicate subscriptions to roa_rotation.

The code subscribes to roa_rotation inside _connectROI(), which gets called once per ROI-capable stream. This causes the same callback _on_roa_rotation() to be registered multiple times, multiplying its execution on each rotation change.

Additionally, line 962 and line 1048 directly access stream.rotation.value without checking if the stream has a rotation VA. The codebase shows the correct pattern elsewhere (e.g., src/odemis/acq/stream/_sync.py:127-128 uses if model.hasVA(s, "rotation")). Finally, the _on_roa_rotation() method lacks a return type hint (-> None) per the Python 3.10+ guideline.

Proposed fix (subscribe once; guard rotation VA; add return type)
 def _connectROI(self, stream):
     """
     Connect the .roi of the (repetition) stream to the global ROA
     """
     # First, start with the same ROI as the global ROA
     stream.roi.value = self._tab_data_model.roa.value
     self._tab_data_model.roa.subscribe(self._onROA)

     rotation_va = getattr(self._tab_data_model, "roa_rotation", None)
     if rotation_va is not None and model.hasVA(stream, "rotation"):
         try:
             stream.rotation.value = rotation_va.value
         except Exception:
             logging.exception("Failed to initialize rotation for %s", stream.name.value)
         # Subscribe only once per controller, not once per stream
         if not getattr(self, "_roa_rotation_subscribed", False):
             rotation_va.subscribe(self._on_roa_rotation)
             self._roa_rotation_subscribed = True

     listener = functools.partial(self._onStreamROI, stream)
     stream.roi.subscribe(listener)
     self._roi_listeners[stream] = listener

-def _on_roa_rotation(self, angle: float):
+def _on_roa_rotation(self, angle: float) -> None:
     """
     Called when the ROA rotation is changed.
     Copies the angle to all the (settings) streams with ROI.
     :param angle: rotation angle in radians (0 -> 2pi)
     """
-    for s in self._tab_data_model.streams.value:
-        if s not in self._roi_listeners:
-            continue
-        # The streams always accept the rotation as-is, so no need to read it back
-        s.rotation.value = angle
+    for s in self._roi_listeners.keys():
+        if not model.hasVA(s, "rotation"):
+            continue
+        try:
+            # The streams always accept the rotation as-is, so no need to read it back
+            s.rotation.value = angle
+        except Exception:
+            logging.exception("Failed to apply rotation for %s", s.name.value)

Also applies to: lines 1038–1049

🤖 Fix all issues with AI agents
In `@src/odemis/gui/comp/overlay/base.py`:
- Around line 2061-2062: The code sets b_pixel_size using the X component twice;
update the assignment for b_pixel_size to use both components of self._phys_pxs
so width uses self._phys_pxs[0] and height uses self._phys_pxs[1] (i.e., replace
the second self._phys_pxs[0] with self._phys_pxs[1]) so non-square pixel sizes
are handled correctly where b_pixel_size is computed.
♻️ Duplicate comments (1)
src/odemis/gui/comp/miccanvas.py (1)

246-251: Guard tab_data.roa_rotation to avoid AttributeError across tabs.
This is the same crash risk noted previously: some tab_data models may have TOOL_ROA but no roa_rotation attribute, and this direct access will blow up at runtime.

Proposed fix
         if guimodel.TOOL_ROA in tools_possible:
             # Get the region of interest and link it to the ROA overlay
             self._roa = tab_data.roa
-            self.roa_overlay = RepetitionSelectOverlay(self, self._roa, tab_data.fovComp,
-                                                       tab_data.roa_rotation)
+            rotation = getattr(tab_data, "roa_rotation", None)
+            self.roa_overlay = RepetitionSelectOverlay(self, self._roa, tab_data.fovComp, rotation)
             self.add_world_overlay(self.roa_overlay)
🧹 Nitpick comments (5)
src/odemis/gui/test/comp_overlay_test.py (1)

638-639: Consider adding strict=True to zip() calls for defensive testing.

Static analysis flags these zip() calls without explicit strict= parameter. While the lengths are inherently matched (both are 4 corners from rectangles), adding strict=True would provide better error detection if the underlying functions ever change:

-for o, b in zip(corners, corners_back):
+for o, b in zip(corners, corners_back, strict=True):

This is optional for test code since the lengths are guaranteed to match.

Also applies to: 721-722, 734-735, 747-748

src/odemis/gui/comp/overlay/repetition_select.py (1)

390-391: Consider using a more intuitive cursor for rotation.

wx.CURSOR_MAGNIFIER is typically associated with zooming. A cursor like wx.CURSOR_HAND might be more intuitive for rotation, though this is a minor UX detail.

src/odemis/gui/comp/overlay/base.py (3)

1533-1547: Minor docstring inconsistency.

The docstring mentions :param point1:, :param point3:, and :param rotation: (lines 1538-1540) but these aren't method parameters - they're internal instance attributes. Consider simplifying:

📝 Suggested docstring fix
     def _normalize_rectangle(self) -> None:
         """
         Given 4 points of a rectangle, return them in the order point1, point2, point3, point4
         where point1 is the top-left point (min, min) and the other points are in clockwise order.
-
-        :param point1: Vec
-        :param point3: Vec
-        :param rotation: The rotation of the rectangle in radians
-        :return: point1, point2, point3, point4 in clockwise order
+        
+        Updates v_point1-4 to be in clockwise order.
         1 -------------- 2
         |                |
         |                |

1792-1800: Optional: Duplicated snap-to-45° logic.

The Ctrl-snap logic here is identical to RectangleEditingMixin.update_rotation (lines 1350-1361). Consider extracting to a shared helper function if this pattern is used elsewhere.


2003-2010: Consider making _find_metadata public or providing a public wrapper.

Line 2007 accesses stream._find_metadata(), a private method. While this is the current API for retrieving merged metadata from a stream, there is no public alternative available. This pattern is used elsewhere in the codebase (e.g., plugins, other GUI components), suggesting the method should be made public (renamed to find_metadata) or wrapped in a public accessor if privacy is intentional.

📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 75c27fa and c238c6d.

📒 Files selected for processing (15)
  • plugins/secom_cl.py
  • src/odemis/acq/stream/_base.py
  • src/odemis/gui/comp/miccanvas.py
  • src/odemis/gui/comp/overlay/base.py
  • src/odemis/gui/comp/overlay/pixel_select.py
  • src/odemis/gui/comp/overlay/rectangle.py
  • src/odemis/gui/comp/overlay/repetition_select.py
  • src/odemis/gui/comp/overlay/spectrum_line_select.py
  • src/odemis/gui/comp/overlay/world_select.py
  • src/odemis/gui/cont/acquisition/sparc_acq.py
  • src/odemis/gui/cont/stream_bar.py
  • src/odemis/gui/cont/tabs/analysis_tab.py
  • src/odemis/gui/cont/tabs/sparc_acquisition_tab.py
  • src/odemis/gui/model/tab_gui_data.py
  • src/odemis/gui/test/comp_overlay_test.py
✅ Files skipped from review due to trivial changes (1)
  • src/odemis/gui/comp/overlay/world_select.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/odemis/acq/stream/_base.py
  • src/odemis/gui/model/tab_gui_data.py
🧰 Additional context used
📓 Path-based instructions (1)
**/*.py

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

**/*.py: Always use type hints for function parameters and return types in Python code
Include docstrings for all functions and classes, following the reStructuredText style guide (without type information)
Ensure code is valid for Python 3.10 and above
Clean up code at the end of a task using autopep8 with the command: autopep8 --in-place --select W291,W292,W293,W391

Files:

  • src/odemis/gui/comp/overlay/repetition_select.py
  • src/odemis/gui/cont/stream_bar.py
  • plugins/secom_cl.py
  • src/odemis/gui/cont/tabs/analysis_tab.py
  • src/odemis/gui/comp/miccanvas.py
  • src/odemis/gui/comp/overlay/rectangle.py
  • src/odemis/gui/test/comp_overlay_test.py
  • src/odemis/gui/cont/acquisition/sparc_acq.py
  • src/odemis/gui/comp/overlay/pixel_select.py
  • src/odemis/gui/cont/tabs/sparc_acquisition_tab.py
  • src/odemis/gui/comp/overlay/spectrum_line_select.py
  • src/odemis/gui/comp/overlay/base.py
🧠 Learnings (2)
📚 Learning: 2026-01-12T12:37:35.155Z
Learnt from: K4rishma
Repo: delmic/odemis PR: 3245
File: src/odemis/acq/align/z_localization.py:29-40
Timestamp: 2026-01-12T12:37:35.155Z
Learning: Maintain the debugging pattern of importing odemis.gui.conf and exporting TIFF files within acquisition/localization loops across all odemis Python sources. Do not remove or restructure this behavior in refactors if it serves debugging consistency; document the debugging purpose with comments and ensure the behavior remains consistent across modules (e.g., acquisition/localization loops such as src/odemis/acq/align/z_localization.py).

Applied to files:

  • src/odemis/gui/comp/overlay/repetition_select.py
  • src/odemis/gui/cont/stream_bar.py
  • src/odemis/gui/cont/tabs/analysis_tab.py
  • src/odemis/gui/comp/miccanvas.py
  • src/odemis/gui/comp/overlay/rectangle.py
  • src/odemis/gui/test/comp_overlay_test.py
  • src/odemis/gui/cont/acquisition/sparc_acq.py
  • src/odemis/gui/comp/overlay/pixel_select.py
  • src/odemis/gui/cont/tabs/sparc_acquisition_tab.py
  • src/odemis/gui/comp/overlay/spectrum_line_select.py
  • src/odemis/gui/comp/overlay/base.py
📚 Learning: 2025-10-20T10:00:09.185Z
Learnt from: K4rishma
Repo: delmic/odemis PR: 3245
File: src/odemis/gui/main_xrc.py:11982-11982
Timestamp: 2025-10-20T10:00:09.185Z
Learning: In `src/odemis/gui/main_xrc.py`, extra spaces in wxStaticText labels (such as "POI size     " and "Use Current Z ") are intentionally added for GUI alignment purposes and should not be flagged as formatting issues.

Applied to files:

  • src/odemis/gui/cont/acquisition/sparc_acq.py
  • src/odemis/gui/comp/overlay/base.py
🧬 Code graph analysis (8)
src/odemis/gui/comp/overlay/repetition_select.py (1)
src/odemis/util/__init__.py (2)
  • rotate_rect (430-466)
  • separate_rect_rotation (469-514)
src/odemis/gui/cont/stream_bar.py (1)
src/odemis/acq/stream/_sync.py (2)
  • streams (227-228)
  • streams (2589-2590)
src/odemis/gui/comp/miccanvas.py (1)
src/odemis/gui/comp/overlay/repetition_select.py (1)
  • RepetitionSelectOverlay (42-636)
src/odemis/gui/test/comp_overlay_test.py (3)
src/odemis/util/comp.py (2)
  • compute_scanner_fov (33-57)
  • get_fov_rect (98-113)
src/odemis/util/testing.py (1)
  • assert_array_not_equal (204-218)
src/odemis/util/__init__.py (1)
  • rotate_rect (430-466)
src/odemis/gui/cont/acquisition/sparc_acq.py (2)
src/odemis/gui/cont/stream_bar.py (1)
  • _onROA (1014-1036)
src/odemis/util/__init__.py (1)
  • rotate_rect (430-466)
src/odemis/gui/comp/overlay/pixel_select.py (1)
src/odemis/gui/comp/overlay/base.py (4)
  • PixelDataMixin (1983-2068)
  • cairo_polygon (61-73)
  • pixel_to_rect (2047-2068)
  • is_over_pixel_data (2019-2028)
src/odemis/gui/comp/overlay/spectrum_line_select.py (1)
src/odemis/gui/comp/overlay/base.py (3)
  • Vec (295-342)
  • cairo_polygon (61-73)
  • pixel_to_rect (2047-2068)
src/odemis/gui/comp/overlay/base.py (3)
src/odemis/acq/stream/_base.py (2)
  • getPixelCoordinates (1259-1295)
  • getPhysicalCoordinates (1297-1322)
src/odemis/gui/comp/overlay/rectangle.py (1)
  • move_to (222-232)
src/odemis/util/__init__.py (2)
  • normalize_rect (387-403)
  • rotate_rect (430-466)
🪛 Ruff (0.14.11)
src/odemis/gui/test/comp_overlay_test.py

638-638: zip() without an explicit strict= parameter

Add explicit value for parameter strict=

(B905)


721-721: zip() without an explicit strict= parameter

Add explicit value for parameter strict=

(B905)


734-734: zip() without an explicit strict= parameter

Add explicit value for parameter strict=

(B905)


747-747: zip() without an explicit strict= parameter

Add explicit value for parameter strict=

(B905)

src/odemis/gui/comp/overlay/base.py

1576-1576: Avoid specifying long messages outside the exception class

(TRY003)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: build (ubuntu-24.04)
  • GitHub Check: build (ubuntu-22.04)
🔇 Additional comments (30)
plugins/secom_cl.py (1)

894-897: Good explicit “no rotation supported” signal for this dialog model.
Setting dlg._dmodel.roa_rotation = None cleanly opts this plugin out of the new rotation feature and should keep overlay behavior consistent.

src/odemis/gui/cont/tabs/sparc_acquisition_tab.py (1)

381-393: Nice alignment: spot initialization now uses the tab’s global ROA.
Using self.tab_data_model.roa.value keeps spot placement consistent with the GUI’s ROA source (including cases where ROA is deliberately decoupled from a specific stream).

src/odemis/gui/comp/overlay/spectrum_line_select.py (1)

25-35: LGTM: rotation-aware pixel rendering via pixel_to_rect() + cairo_polygon().
This is a clean swap from axis-aligned rectangles to a shared polygon trace that can represent rotated pixels.

Also applies to: 287-295

src/odemis/gui/cont/tabs/analysis_tab.py (1)

442-453: LGTM! Clean migration to stream-based data binding.

The switch from explicit data property binding to set_stream() is consistent with the PixelDataMixin changes that now extract resolution, pixel size, and rotation from the stream's metadata. This simplifies the overlay binding and naturally supports rotation through MD_ROTATION in the stream metadata.

src/odemis/gui/comp/overlay/pixel_select.py (1)

160-178: LGTM! Clean refactor to polygon-based rendering with rotation support.

The draw method now correctly uses pixel_to_rect() (which returns rotation-aware corners from PixelDataMixin) and cairo_polygon() for tracing. The pattern of tracing all polygons before a single fill() call is efficient for batch rendering.

src/odemis/gui/cont/acquisition/sparc_acq.py (3)

158-161: LGTM! Proper subscription to rotation changes.

The conditional subscription to roa_rotation correctly triggers RoA validity checks when the rotation changes. The comment clearly explains why rotation affects acquisition enablement (RoA may go out of FoV) but not acquisition time.


321-328: Verify intent: rotation value of 0.0 is falsy.

The condition elif self._tab_data_model.roa_rotation: checks both that the VA exists and that its value is truthy. Since 0.0 is falsy in Python, this branch is skipped when rotation is exactly zero. This is likely intentional (no rotation means no bounds expansion), but consider making it explicit for clarity:

elif self._tab_data_model.roa_rotation is not None and self._tab_data_model.roa_rotation.value != 0:

Alternatively, if the check should always run when roa_rotation is present (even at 0), access .value explicitly:

elif self._tab_data_model.roa_rotation is not None:

302-342: LGTM! Clean refactoring with centralized acquisition enablement.

The can_acquire flag effectively consolidates the acquisition button enablement logic. The if-elif chain correctly prioritizes error conditions, and the estimated time computation only happens when no blocking conditions exist.

src/odemis/gui/test/comp_overlay_test.py (3)

783-795: LGTM! Well-structured test helper for spectrum streams.

The _create_spectrum_stream() helper creates a properly configured StaticSpectrumStream with relevant metadata including MD_ROTATION, MD_PIXEL_SIZE, and MD_POS. This enables proper testing of the rotation-aware overlay behavior.


737-749: LGTM! Good test coverage for rotation behavior.

This test case properly verifies that the RepetitionSelectOverlay correctly applies rotation when computing the physical selection. The test sets a rotation value, computes the expected rotated corners, and validates the overlay returns matching coordinates.


816-816: LGTM! Tests correctly use the new set_stream() API.

The test properly uses set_stream() with the spectrum stream helper, validating the new streaming-based data binding API for overlays.

src/odemis/gui/comp/overlay/rectangle.py (5)

89-91: LGTM! Simplified constructor signature.

Removing the center parameter from the constructor simplifies the API. The mixin initialization now takes only the colour parameter, which is cleaner.


108-142: LGTM! Improved label visibility.

Setting flip=False ensures labels maintain consistent orientation, and the black background (0, 0, 0) improves readability against varied canvas backgrounds. These are good UI improvements for rectangle overlays.


422-466: Improved side label positioning with rotation awareness.

The rewritten draw_side_labels method uses math.dist for proper distance calculation and perpendicular label shifts for better positioning. The corner detection algorithm is robust for rotated rectangles.

One consideration: for significant rotations, the "bottom" and "right" edges are determined by physical coordinate ordering, which may differ from visual orientation. This is acceptable since the labels still correctly display the side dimensions.


468-471: LGTM! Cleaner rotation label formatting.

Using round() produces integer-like display values (e.g., "45°" instead of "45.0°"), improving label readability.


354-358: The start_creation() method is properly defined and correctly available. It exists in RectangleEditingMixin (base.py, line 1207) and is semantically appropriate for initializing a new rectangle with point-based coordinates and rotation settings. The method call at line 358 is valid.

src/odemis/gui/comp/overlay/repetition_select.py (6)

25-42: LGTM!

The imports and class inheritance changes are well-structured. The switch from WorldSelectOverlay to WorldOverlay + RectangleEditingMixin is a clean approach for adding rotation support while reusing the existing mixin's rectangle editing functionality.


53-108: LGTM!

The constructor correctly initializes both base classes (WorldOverlay and RectangleEditingMixin) with proper parameter passing. The physical coordinate points (p_point1-p_point4) are well-placed for maintaining synchronization between view and physical coordinate systems. The side labels initialization for displaying rectangle dimensions is clean.


167-200: LGTM!

The physical selection getter/setter methods handle the Y-axis inversion between view and physical coordinates correctly. The corner reordering in get_physical_sel() (lines 171-174) is necessary because physical Y increases upward while view Y increases downward. The set_physical_sel() properly normalizes the rectangle and recalculates edges after setting.


229-238: Acknowledged: TODO for rotation-aware ROI clipping.

The comment correctly documents the limitation where rotated ROAs near 90°/270° might not be properly clipped to the scanner FoV. The workaround via the GUI controller's bounds check (mentioned in line 236-238) is reasonable for now.


563-576: Good defensive check for degenerate rectangles.

The check at line 565-566 for len(all_points) < 4 handles the edge case where physical points might overlap. However, note that the set comparison relies on Vec being hashable and having proper __eq__. Since Vec extends tuple, this should work correctly.


602-636: LGTM!

The draw() method is well-structured with appropriate early returns for undefined/degenerate rectangles. The performance optimization to skip repetition drawing during edit/create modes (line 625) is a good UX improvement. The conditional rendering of edit knobs and labels based on active.value is correct.

src/odemis/gui/comp/overlay/base.py (8)

61-74: LGTM!

Clean utility function with proper type hints and docstring. The early return for empty points list is a good defensive check.


318-319: LGTM!

The __truediv__ method completes the arithmetic operators for Vec. Division by zero will raise a standard ZeroDivisionError, which is appropriate behavior.


1156-1162: LGTM!

The docstring clearly explains the can_rotate parameter semantics - users can't rotate when False, but programmatic rotation is still allowed. This is a good design decision for flexibility.


1207-1254: LGTM!

The selection lifecycle methods are well-implemented. Setting rotation = 0 in start_creation() ensures new rectangles start axis-aligned. The _normalize_rectangle() call in stop_selection() maintains consistent point ordering after editing.


1350-1361: Nice UX feature: Ctrl-snap to 45° increments.

The snap-to-45-degrees functionality when Ctrl is held provides precise rotation control. The implementation correctly recalculates all points after snapping.


1562-1580: LGTM!

The cursor direction calculation correctly determines whether the edge is more horizontal (NS cursor) or vertical (EW cursor) based on the delta comparison. The ValueError for invalid indices is appropriate defensive coding.


1613-1670: LGTM!

The event handlers comprehensively cover all interaction modes (create, drag, rotate, edit). The warning for unhandled hover types (line 1635) aids debugging. The comment explaining the left_dragging check for double-click edge cases is helpful.


1458-1467: LGTM!

The conditional addition of HOVER_ROTATION edges based on can_rotate correctly prevents rotation interaction when disabled while still allowing the rotation point to be calculated for other purposes.

✏️ Tip: You can disable this entire section by setting review_details to false in your review settings.

@pieleric pieleric force-pushed the feat-sparc-gui-support-rotated-roa branch 2 times, most recently from 4372fdd to 02ac003 Compare January 19, 2026 18:14
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@src/odemis/gui/comp/overlay/base.py`:
- Around line 1338-1344: The rotation delta calculation can jump by ±2π when
angles cross the 0/2π boundary; in the block that computes prev_rot =
self._get_point_angle(self.v_point1), knob_pos =
Vec(self.cnvs.clip_to_viewport(self.drag_v_end_pos)), new_rot =
self._get_point_angle(knob_pos) and diff_angle = new_rot - prev_rot before
calling self.v_point1.rotate(diff_angle, self.v_center), normalize diff_angle
into the principal range (e.g. (-π, π]) to remove 2π jumps (use math.pi and
modulo/adjustment) and then pass the normalized diff_angle to rotate so
rotations are continuous across the angle wrap boundary.
- Around line 2000-2007: Add a docstring to set_stream describing that it sets
self._stream and extracts data properties and that the Stream must contain raw
data (accessing stream.raw[0] will fail if empty); then validate the incoming
Stream by checking that stream.raw is non-empty (e.g., if not stream.raw: raise
ValueError("Stream.raw must contain at least one raw element")) before accessing
stream.raw[0], and only then assign self._stream and compute
self._data_resolution, self._phys_pxs and self._rotation; reference symbols:
set_stream, Stream, self._stream, self._data_resolution, self._phys_pxs,
self._rotation and downstream callers like is_over_pixel_data /
view_to_data_pixel so reviewers know this precondition is required.

In `@src/odemis/gui/cont/stream_bar.py`:
- Around line 1038-1049: The handler _on_roa_rotation is missing a return type
annotation; update its signature (def _on_roa_rotation(self, angle: float)) to
include the explicit return type -> None so it follows project standards,
leaving the body and docstring unchanged and ensuring any callers remain
compatible.
♻️ Duplicate comments (2)
src/odemis/util/__init__.py (1)

48-49: Duplicated UNDEFINED_ROI constant.

This constant is also defined in odemis.acq.stream._base.py. Consider importing from the canonical source to avoid maintenance drift. The comment acknowledges this is from odemis.acq.stream, but a direct import would be cleaner.

src/odemis/gui/comp/overlay/base.py (1)

2056-2062: Use per-axis pixel size to support non-square pixels.
Line 2058 uses the X pixel size for both width and height.

🐛 Proposed fix
-        b_pixel_size = (self._phys_pxs[0] * scale, self._phys_pxs[0] * scale)
+        b_pixel_size = (self._phys_pxs[0] * scale, self._phys_pxs[1] * scale)
🧹 Nitpick comments (6)
src/odemis/util/test/util_test.py (3)

576-591: Helper method _rotate_point is missing a return type hint.

As per coding guidelines, all functions should have type hints for parameters and return types. The _assert_corners_equal method also uses assert instead of self.assert* methods, which won't report proper test failures if assertions are disabled.

🔧 Suggested improvements
-    def _rotate_point(self, pt: Tuple, center: Tuple, angle: float):
+    def _rotate_point(self, pt: Tuple[float, float], center: Tuple[float, float], angle: float) -> Tuple[float, float]:
         x, y = pt
         cx, cy = center
         x -= cx
         y -= cy
         cos_a = math.cos(angle)
         sin_a = math.sin(angle)
         x_new = x * cos_a - y * sin_a
         y_new = x * sin_a + y * cos_a
         return (x_new + cx, y_new + cy)

-    def _assert_corners_equal(self, expected: List[Tuple], actual:List[Tuple]):
-        assert len(expected) == len(actual)
-        for e, a in zip(expected, actual):
+    def _assert_corners_equal(self, expected: List[Tuple[float, float]], actual: List[Tuple[float, float]]) -> None:
+        self.assertEqual(len(expected), len(actual))
+        for e, a in zip(expected, actual, strict=True):
             self.assertAlmostEqual(e[0], a[0])
             self.assertAlmostEqual(e[1], a[1])

661-662: Add strict=True to zip() for safer iteration.

When iterating over two lists that should have equal lengths, using strict=True ensures a ValueError is raised if the lengths differ, catching bugs early.

🔧 Suggested fix
-        for v_expected, v_actual in zip(corners, recon):
+        for v_expected, v_actual in zip(corners, recon, strict=True):
             testing.assert_tuple_almost_equal(v_expected, v_actual)

The same applies to lines 678 and 698.


701-704: Unused variable res_angle should be prefixed with underscore.

The static analysis correctly identifies that res_angle is never used. Prefix it with an underscore to indicate it's intentionally ignored.

🔧 Suggested fix
     def test_undefined_roi(self):
         corners = [(2, 3)] * 4
-        res_rect, res_angle = util.separate_rect_rotation(corners)
+        res_rect, _res_angle = util.separate_rect_rotation(corners)
         self.assertEqual(res_rect, UNDEFINED_ROI)
src/odemis/gui/test/comp_overlay_test.py (1)

638-639: Consider adding strict=True to zip() for test robustness.

Adding strict=True would catch any unexpected length mismatches between corners and corners_back, making tests fail faster with clearer error messages.

🔧 Suggested fix
-        for o, b in zip(corners, corners_back):
+        for o, b in zip(corners, corners_back, strict=True):
             testing.assert_tuple_almost_equal(o, b)

The same applies to similar zip() calls at lines 721, 734, and 747.

src/odemis/gui/comp/overlay/repetition_select.py (1)

501-601: Code duplication with RectangleOverlay noted for future refactoring.

The _draw_edit_knobs (line 502) and _draw_side_labels (line 556) methods contain similar logic to RectangleOverlay.draw_edges and RectangleOverlay.draw_side_labels. The TODO comments acknowledge this. Consider extracting shared drawing utilities in a future refactor.

src/odemis/gui/comp/overlay/base.py (1)

312-319: Add explicit return type hints for Vec arithmetic overloads.
This keeps the typing contract consistent with the new parameter hints.

♻️ Proposed update
-    def __mul__(self, c: float):
+    def __mul__(self, c: float) -> "Vec":
         return Vec(x * c for x in self)

-    def __rmul__(self, c: float):
+    def __rmul__(self, c: float) -> "Vec":
         return Vec(c * x for x in self)

-    def __truediv__(self, c):
+    def __truediv__(self, c: float) -> "Vec":
         return Vec(x / c for x in self)

As per coding guidelines, please add return type hints consistently.

@pieleric pieleric force-pushed the feat-sparc-gui-support-rotated-roa branch from 02ac003 to 67e13b3 Compare January 19, 2026 18:17
@pieleric pieleric requested a review from tepals January 19, 2026 18:18
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🤖 Fix all issues with AI agents
In `@src/odemis/gui/comp/overlay/base.py`:
- Around line 312-319: Update the numeric operator methods to include full type
annotations: annotate __truediv__'s parameter as float (def __truediv__(self, c:
float)) and add explicit return type hints -> Vec to __mul__, __rmul__, and
__truediv__. Keep the existing generator expressions but ensure the signatures
are: def __mul__(self, c: float) -> Vec, def __rmul__(self, c: float) -> Vec,
def __truediv__(self, c: float) -> Vec so callers and static type checkers can
infer Vec results.

In `@src/odemis/gui/comp/overlay/rectangle.py`:
- Around line 89-92: The RectangleOverlay.__init__ currently lacks type hints
and a docstring; update its signature to add proper type hints for parameters
(e.g., cnvs: CanvasType, colour: str = gui.SELECTION_COLOUR,
show_selection_points: bool = True) and add a concise reST-style __init__
docstring describing the parameters and purpose (one-line summary + :param: /
:type: entries) while preserving the existing calls to
EditableShape.__init__(self, cnvs) and RectangleEditingMixin.__init__(self,
colour); ensure the docstring sits immediately under the def and follows the
project’s reST formatting conventions.

In `@src/odemis/gui/comp/overlay/repetition_select.py`:
- Around line 53-69: Update the __init__ signature to use typed parameters and
an explicit return type (-> None); e.g. annotate cnvs, roa, scanner, rotation
and colour with the appropriate types used in the module (e.g. Canvas type for
cnvs, Optional[...] for roa/scanner/rotation, and the colour type matching
gui.SELECTION_COLOUR) and add "-> None" to the signature of def __init__(...),
and add any necessary typing imports (Optional, typing aliases) at the top of
the file so WorldOverlay.__init__ and RectangleEditingMixin.__init__ calls
remain unchanged.

In `@src/odemis/util/test/util_test.py`:
- Around line 701-703: In test_single_point replace the unused res_angle
assignment with a throwaway variable to satisfy linter: call
util.separate_rect_rotation(corners) and assign its second return to _ (e.g.,
res_rect, _ ) instead of res_angle; update the assignment in the
test_single_point function that invokes util.separate_rect_rotation so only
res_rect is used.
- Around line 587-592: The zip calls must be made strict to catch length
mismatches: update the loop in _assert_corners_equal (where it iterates for e, a
in zip(expected, actual)) to use zip(..., strict=True), and likewise update the
three other occurrences that iterate with for v_expected, v_actual in
zip(corners, recon) to use zip(corners, recon, strict=True); ensure all four zip
calls include strict=True so mismatched lengths raise immediately.
♻️ Duplicate comments (6)
src/odemis/util/__init__.py (1)

48-48: Avoid duplicating UNDEFINED_ROI constant.

Defining it here risks drift from the canonical value in odemis.acq.stream. Prefer importing the shared constant instead.

♻️ Proposed change
-from . import weak
-
-UNDEFINED_ROI = (0,0,0,0)  # From odemis.acq.stream
+from . import weak
+from odemis.acq.stream import UNDEFINED_ROI
src/odemis/acq/stream/_base.py (1)

1264-1266: Fix docstring parameter name mismatch.

Line 1266 mentions check_boundary, but the parameter is check_bbox.

🛠️ Proposed fix
-        It returns None if it's outside of the image and check_boundary is True
+        It returns None if it's outside of the image and check_bbox is True
src/odemis/gui/cont/stream_bar.py (1)

1038-1049: Add return type annotation to _on_roa_rotation.

The handler lacks an explicit return type.
As per coding guidelines, add -> None.

🛠️ Proposed fix
-    def _on_roa_rotation(self, angle: float):
+    def _on_roa_rotation(self, angle: float) -> None:
src/odemis/gui/comp/overlay/base.py (2)

1338-1344: Normalize rotation delta to avoid ±2π jumps.
Crossing the 0/2π boundary can rotate the rectangle the long way around. Normalize diff_angle to the shortest signed angle.

🐛 Suggested fix
-        diff_angle = new_rot - prev_rot
+        # Normalize to the shortest signed angle (-pi, pi]
+        diff_angle = (new_rot - prev_rot + math.pi) % (2 * math.pi) - math.pi

2000-2007: Guard empty streams and add a docstring.
stream.raw[0] will raise IndexError if the stream has no data. Also, this method needs a docstring per the Python guidelines.

✅ Suggested update
-    def set_stream(self, stream: Stream) -> None:
-        self._stream = stream
-        raw = stream.raw[0]
+    def set_stream(self, stream: Stream) -> None:
+        """Set the active stream and derive data properties."""
+        if not stream.raw:
+            raise ValueError("Stream.raw must contain at least one element")
+        self._stream = stream
+        raw = stream.raw[0]
         self._data_resolution = Vec(raw.shape[-1:-3:-1])  # CTZYX -> XY
         md = stream._find_metadata(raw.metadata)
         pxs = md.get(model.MD_PIXEL_SIZE, (1e-6, 1e-6))[0:2]
         self._phys_pxs = pxs
         self._rotation = md.get(model.MD_ROTATION, 0.0)
src/odemis/gui/comp/miccanvas.py (1)

246-251: Guard tab_data.roa_rotation to avoid AttributeError.
Some GUIData variants expose TOOL_ROA without roa_rotation. Use a safe fallback when constructing the overlay.

✅ Suggested fix
-            self.roa_overlay = RepetitionSelectOverlay(self, self._roa, tab_data.fovComp,
-                                                       tab_data.roa_rotation)
+            rotation = getattr(tab_data, "roa_rotation", None)
+            self.roa_overlay = RepetitionSelectOverlay(self, self._roa, tab_data.fovComp, rotation)
🧹 Nitpick comments (3)
src/odemis/util/test/util_test.py (1)

575-588: Add return type annotations and brief docstrings for new helpers/tests.

The new helper methods and test case classes don’t include return type hints or class docstrings.
As per coding guidelines, please add them for the new additions.

🛠️ Suggested edits (example)
-class RotateRectTestCase(unittest.TestCase):
-    def _rotate_point(self, pt: Tuple, center: Tuple, angle: float):
+class RotateRectTestCase(unittest.TestCase):
+    """Tests for rotate_rect()."""
+    def _rotate_point(
+            self,
+            pt: Tuple[float, float],
+            center: Tuple[float, float],
+            angle: float,
+    ) -> Tuple[float, float]:
+        """Rotate a point around a center by a given angle."""
         x, y = pt
         cx, cy = center
         x -= cx
         y -= cy
         cos_a = math.cos(angle)
         sin_a = math.sin(angle)
         x_new = x * cos_a - y * sin_a
         y_new = x * sin_a + y * cos_a
         return (x_new + cx, y_new + cy)

-    def _assert_corners_equal(self, expected: List[Tuple], actual:List[Tuple]):
+    def _assert_corners_equal(
+            self,
+            expected: List[Tuple[float, float]],
+            actual: List[Tuple[float, float]],
+    ) -> None:
+        """Assert that two corner lists are equal within tolerance."""
         assert len(expected) == len(actual)
         for e, a in zip(expected, actual):
             self.assertAlmostEqual(e[0], a[0])
             self.assertAlmostEqual(e[1], a[1])

-class SeparateRectRotationTestCase(unittest.TestCase):
+class SeparateRectRotationTestCase(unittest.TestCase):
+    """Tests for separate_rect_rotation()."""

Also applies to: 630-631

src/odemis/gui/test/comp_overlay_test.py (2)

638-639: Prefer zip(..., strict=True) to avoid silent truncation.
This ensures the test fails if the corner list lengths ever diverge.

♻️ Proposed change
-for o, b in zip(corners, corners_back):
+for o, b in zip(corners, corners_back, strict=True):
     testing.assert_tuple_almost_equal(o, b)

721-723: Use zip(..., strict=True) for all corner comparisons.
Avoids hiding mismatched corner counts during rotation tests.

♻️ Proposed change
-for o, b in zip(ebeam_corners, roi_back):
+for o, b in zip(ebeam_corners, roi_back, strict=True):
     assert_tuple_almost_equal(o, b, msg="ebeam FoV (%s) != ROI (%s)" % (ebeam_corners, roi_back))

-        for o, b in zip(ebeam_corners, roi_back):
+        for o, b in zip(ebeam_corners, roi_back, strict=True):
             assert_tuple_almost_equal(o, b, msg="ebeam FoV (%s) != ROI (%s)" % (ebeam_corners, roi_back))

-        for o, b in zip(ebeam_corners, roi_back):
+        for o, b in zip(ebeam_corners, roi_back, strict=True):
             assert_tuple_almost_equal(o, b, msg="ebeam FoV (%s) != ROI (%s)" % (ebeam_corners, roi_back))

Also applies to: 734-735, 747-748

Copy link
Contributor

@tepals tepals left a comment

Choose a reason for hiding this comment

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

one small comment, rest looks good

@pieleric pieleric force-pushed the feat-sparc-gui-support-rotated-roa branch from 67e13b3 to 9d3f6c8 Compare January 30, 2026 11:14
Introduce a function to compute the positions of the 4 corners of a
rectangle after rotation, and its inverse function.

These two functions will be used by the GUI to support the RoI rotation.
If the e-beam scanner supports vector scanning, it's now possible to
acquire with a rotation. So add a "rotation knob" next to the RoA
selector to allow rotating it.

In order to do that, we drop the original RepetiionSelectOverlay, and
provide a new one based on the FASTEM RectangleEditingMixin.
Update code for displaying FASTEM rectangle to match code for the SPARC,
to display the size of the sides and the angle during rotation.
Now that SPARC acquisition supports rotation, the pixel selection
overlay and spectrum line selection overlay must support rotation too.
@pieleric pieleric force-pushed the feat-sparc-gui-support-rotated-roa branch from 9d3f6c8 to 5d352c5 Compare January 30, 2026 11:28
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@src/odemis/gui/comp/overlay/world_select.py`:
- Around line 39-40: The TODO comment referencing merging SelectionMixin,
WorldSelectOverlay, LineSelectOverlay and changing FastEMROCOverlay to inherit
from RepetitionSelectOverlay must be tracked in your issue tracker: create one
or more tickets (e.g., "merge-selection-mixins" and
"change-fastemroc-inheritance") and update the TODO line in
src/odemis/gui/comp/overlay/world_select.py to include the issue IDs or brief
ownership and ETA; specifically modify the comment mentioning SelectionMixin,
WorldSelectOverlay, LineSelectOverlay, FastEMROCOverlay, RepetitionSelectOverlay
and EDIT_MODE_POINT to append the created ticket references (or a single
umbrella ticket) so the work is traceable.
🧹 Nitpick comments (1)
src/odemis/gui/comp/overlay/rectangle.py (1)

429-437: Corner-finding logic may behave unexpectedly for certain rotations.

The algorithm finds corners by coordinate extremes, which works well for axis-aligned or slightly rotated rectangles. However, for rotations near 45°, the "bottom-left" and "bottom-right" definitions based on (x, y) and (-x, y) comparisons may not produce intuitive results.

This is likely acceptable for the side-length labeling use case, but consider adding a comment explaining the intent.

📝 Suggested documentation
         # Find the 3 corners we need to compute the side lengths and angles
+        # This heuristic finds corners by coordinate extremes in physical space.
+        # For rotated rectangles, the "bottom-left" corner is the one with minimum (x, y).
         all_points = set(points.keys())
         if len(all_points) < 4:
             return  # Cannot compute side lengths if points are not unique

Comment on lines +39 to +40
# TODO: merge SelectionMixin and WorldSelectOverlay and LineSelectOverlay into one class (with just EDIT_MODE_POINT)
# Change FastEMROCOverlay to inherit from RepetitionSelectOverlay.
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Please track these TODOs with an issue/ticket.

Untracked TODOs tend to be forgotten; consider adding a reference (issue ID) or brief ownership/context.

🤖 Prompt for AI Agents
In `@src/odemis/gui/comp/overlay/world_select.py` around lines 39 - 40, The TODO
comment referencing merging SelectionMixin, WorldSelectOverlay,
LineSelectOverlay and changing FastEMROCOverlay to inherit from
RepetitionSelectOverlay must be tracked in your issue tracker: create one or
more tickets (e.g., "merge-selection-mixins" and "change-fastemroc-inheritance")
and update the TODO line in src/odemis/gui/comp/overlay/world_select.py to
include the issue IDs or brief ownership and ETA; specifically modify the
comment mentioning SelectionMixin, WorldSelectOverlay, LineSelectOverlay,
FastEMROCOverlay, RepetitionSelectOverlay and EDIT_MODE_POINT to append the
created ticket references (or a single umbrella ticket) so the work is
traceable.

@pieleric pieleric merged commit 1a663aa into delmic:master Jan 30, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants