Skip to content

Commit f671dec

Browse files
Phase 4b: drawing tools and canvas interaction
Add full drawing-tool support to the image canvas: - 7 tools: pan, rectangle, ellipse, polygon, freehand, line, point - tool_manager.py: ToolManager state machine + TOOLS/hints constants - ImageCanvas: set_tool(), per-tool mouse handlers, preview items (dashed orange), polygon double-click/Enter close, hover highlight, active-ROI drag-move (emits roi_move_requested), snap-to-pixel - roi_items.py: _PEN_HOVER/_BRUSH_HOVER + hover parameter on update_roi_item_style - core/roi.py: translate(roi, dx, dy) for all ROI kinds - _legacy.py: new drawing toolbar (Pan/Rect/Ellipse/Poly/Free/Line/Point), keyboard shortcuts R/E/P/F/L/T (tools), Delete (active ROI), I (invert active ROI), 1-9 (select Nth ROI), Escape (cancel draw instead of closing dialog when drawing is active), canvas context menu for ROI actions Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent a247fd9 commit f671dec

6 files changed

Lines changed: 861 additions & 94 deletions

File tree

probeflow/core/roi.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -864,6 +864,34 @@ def combine(
864864
return _shapely_to_roi(result, name=f"{mode}_{names}")
865865

866866

867+
def translate(roi: "ROI", dx: float, dy: float) -> "ROI":
868+
"""Return a copy of *roi* with all coordinates shifted by (dx, dy) pixels."""
869+
g = roi.geometry
870+
k = roi.kind
871+
if k == "rectangle":
872+
new_g = {**g, "x": g["x"] + dx, "y": g["y"] + dy}
873+
elif k == "ellipse":
874+
new_g = {**g, "cx": g["cx"] + dx, "cy": g["cy"] + dy}
875+
elif k in ("polygon", "freehand"):
876+
new_g = {"vertices": [[v[0] + dx, v[1] + dy] for v in g.get("vertices", [])]}
877+
elif k == "line":
878+
new_g = {**g, "x1": g["x1"] + dx, "y1": g["y1"] + dy,
879+
"x2": g["x2"] + dx, "y2": g["y2"] + dy}
880+
elif k == "point":
881+
new_g = {**g, "x": g["x"] + dx, "y": g["y"] + dy}
882+
elif k == "multipolygon":
883+
new_comps = []
884+
for comp in g.get("components", []):
885+
ext = [[v[0] + dx, v[1] + dy] for v in comp.get("exterior", [])]
886+
holes = [[[v[0] + dx, v[1] + dy] for v in h] for h in comp.get("holes", [])]
887+
new_comps.append({"exterior": ext, "holes": holes})
888+
new_g = {"components": new_comps}
889+
else:
890+
new_g = dict(g)
891+
return ROI(id=roi.id, name=roi.name, kind=roi.kind, geometry=new_g,
892+
coord_system=roi.coord_system, linked_file=roi.linked_file)
893+
894+
867895
# ── ROISet ────────────────────────────────────────────────────────────────────
868896

869897
@dataclass

probeflow/gui/_legacy.py

Lines changed: 219 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1490,44 +1490,38 @@ def _build(self):
14901490
toolbar.addStretch()
14911491
left_lay.addLayout(toolbar)
14921492

1493-
selection_bar = QHBoxLayout()
1494-
selection_bar.setSpacing(4)
1495-
selection_lbl = QLabel("Selection")
1496-
selection_lbl.setFont(QFont("Helvetica", 8, QFont.Bold))
1497-
selection_bar.addWidget(selection_lbl)
1498-
self._selection_group = QButtonGroup(self)
1499-
self._selection_group.setExclusive(True)
1493+
drawing_bar = QHBoxLayout()
1494+
drawing_bar.setSpacing(4)
1495+
drawing_lbl = QLabel("Draw")
1496+
drawing_lbl.setFont(QFont("Helvetica", 8, QFont.Bold))
1497+
drawing_bar.addWidget(drawing_lbl)
1498+
self._drawing_group = QButtonGroup(self)
1499+
self._drawing_group.setExclusive(True)
1500+
# Keep old name for backward-compat references
1501+
self._selection_group = self._drawing_group
15001502
for key, label, tip in (
1501-
("none", "Pointer", "Pointer / no selection tool"),
1502-
("rectangle", "Rect.", "Rectangular area selection"),
1503-
("ellipse", "Ellipse", "Elliptical area selection"),
1504-
("polygon", "Polygon", "Polygon area selection; double-click to finish"),
1505-
("line", "Line", "Line selection for display/status only"),
1503+
("pan", "✋ Pan", "Pan (drag to scroll)"),
1504+
("rectangle", "▭ Rect", "Rectangle ROI [R]"),
1505+
("ellipse", "◯ Ellipse", "Ellipse ROI [E]"),
1506+
("polygon", "⬠ Poly", "Polygon ROI — click vertices, double-click to close [P]"),
1507+
("freehand", "〰 Free", "Freehand ROI — drag to draw [F]"),
1508+
("line", "— Line", "Line ROI [L]"),
1509+
("point", "• Point", "Point ROI [T]"),
15061510
):
15071511
btn = QPushButton(label)
15081512
btn.setCheckable(True)
15091513
btn.setFixedHeight(24)
15101514
btn.setMinimumWidth(44)
15111515
btn.setFont(QFont("Helvetica", 8))
15121516
btn.setToolTip(tip)
1513-
self._selection_group.addButton(btn)
1514-
btn.setProperty("selection_tool", key)
1515-
if key == "none":
1517+
self._drawing_group.addButton(btn)
1518+
btn.setProperty("drawing_tool", key)
1519+
if key == "pan":
15161520
btn.setChecked(True)
1517-
selection_bar.addWidget(btn)
1518-
self._selection_group.buttonClicked.connect(self._on_selection_tool_clicked)
1519-
# Phase 4b: canvas drawing tools not yet implemented
1520-
for _btn in self._selection_group.buttons():
1521-
if (_btn.property("selection_tool") or "none") != "none":
1522-
_btn.setEnabled(False)
1523-
_btn.setToolTip(_btn.toolTip() + " (available in Phase 4b)")
1524-
clear_selection_btn = QPushButton("Clear")
1525-
clear_selection_btn.setFont(QFont("Helvetica", 8))
1526-
clear_selection_btn.setFixedHeight(24)
1527-
clear_selection_btn.clicked.connect(self._on_clear_roi)
1528-
selection_bar.addWidget(clear_selection_btn)
1529-
selection_bar.addStretch()
1530-
left_lay.addLayout(selection_bar)
1521+
drawing_bar.addWidget(btn)
1522+
self._drawing_group.buttonClicked.connect(self._on_drawing_tool_clicked)
1523+
drawing_bar.addStretch()
1524+
left_lay.addLayout(drawing_bar)
15311525

15321526
# Rulers scroll together with the image (placed in the same scroll
15331527
# viewport via a small grid container).
@@ -1871,6 +1865,10 @@ def _spin_row(label: str, mn: float, mx: float, init: float,
18711865
self._zoom_lbl.pixmap_resized.connect(self._on_pixmap_resized)
18721866
self._zoom_lbl.context_menu_requested.connect(self._on_image_context_menu)
18731867
self._zoom_lbl.pixel_hovered.connect(self._on_pixel_hovered)
1868+
self._zoom_lbl.roi_created.connect(self._on_canvas_roi_created)
1869+
self._zoom_lbl.roi_move_requested.connect(self._on_canvas_roi_move)
1870+
self._zoom_lbl.tool_changed.connect(self._on_canvas_tool_changed)
1871+
self._zoom_lbl.roi_context_menu_requested.connect(self._on_roi_canvas_context_menu)
18741872
self._line_profile_panel.export_csv_clicked.connect(self._on_export_line_profile_csv)
18751873

18761874
self._status_lbl = QLabel("")
@@ -1943,13 +1941,62 @@ def _spin_row(label: str, mn: float, mx: float, init: float,
19431941
# ── Navigation ─────────────────────────────────────────────────────────────
19441942
def keyPressEvent(self, event):
19451943
k = event.key()
1944+
1945+
# ── drawing tool shortcuts ────────────────────────────────────────────
1946+
_tool_keys = {
1947+
Qt.Key_R: "rectangle",
1948+
Qt.Key_E: "ellipse",
1949+
Qt.Key_P: "polygon",
1950+
Qt.Key_F: "freehand",
1951+
Qt.Key_L: "line",
1952+
Qt.Key_T: "point",
1953+
}
1954+
if k in _tool_keys and not event.modifiers():
1955+
self._set_drawing_tool(_tool_keys[k])
1956+
event.accept()
1957+
return
1958+
1959+
# ── Escape: cancel drawing, or close dialog if idle ───────────────────
1960+
if k == Qt.Key_Escape:
1961+
canvas_tool = getattr(self._zoom_lbl, "tool", lambda: "pan")()
1962+
canvas_drawing = (canvas_tool != "pan" or
1963+
self._zoom_lbl._draw_pts or
1964+
self._zoom_lbl._draw_start is not None)
1965+
if canvas_drawing:
1966+
self._zoom_lbl.cancel_drawing()
1967+
self._set_drawing_tool("pan")
1968+
event.accept()
1969+
return
1970+
self.accept()
1971+
return
1972+
1973+
# Return closes the dialog (when not drawing)
1974+
if k == Qt.Key_Return:
1975+
self.accept()
1976+
return
1977+
1978+
# ── ROI keyboard actions ──────────────────────────────────────────────
1979+
if k == Qt.Key_Delete and not event.modifiers():
1980+
self._delete_active_image_roi()
1981+
event.accept()
1982+
return
1983+
1984+
if k == Qt.Key_I and not event.modifiers():
1985+
self._invert_active_image_roi()
1986+
event.accept()
1987+
return
1988+
1989+
if Qt.Key_1 <= k <= Qt.Key_9 and not event.modifiers():
1990+
self._select_nth_image_roi(k - Qt.Key_0)
1991+
event.accept()
1992+
return
1993+
1994+
# ── arrow keys: nudge line profile or navigate ────────────────────────
19461995
if k in (Qt.Key_Left, Qt.Key_Right, Qt.Key_Up, Qt.Key_Down):
19471996
if self._nudge_line_profile(k):
19481997
event.accept()
19491998
return
1950-
if k in (Qt.Key_Escape, Qt.Key_Return):
1951-
self.accept()
1952-
elif k == Qt.Key_Left:
1999+
if k == Qt.Key_Left:
19532000
self._go_prev()
19542001
elif k == Qt.Key_Right:
19552002
self._go_next()
@@ -2311,6 +2358,8 @@ def _save_image_roi_set(self) -> None:
23112358
def _on_image_roi_set_changed(self) -> None:
23122359
self._zoom_lbl.set_roi_set(self._image_roi_set)
23132360
self._save_image_roi_set()
2361+
if hasattr(self, "_roi_dock"):
2362+
self._roi_dock.refresh(self._image_roi_set)
23142363

23152364
def _on_pixel_hovered(self, col: int, row: int, val) -> None:
23162365
if not hasattr(self, "_coord_lbl"):
@@ -2323,6 +2372,120 @@ def _on_pixel_hovered(self, col: int, row: int, val) -> None:
23232372
unit_str = f" {unit}" if unit else ""
23242373
self._coord_lbl.setText(f"({col}, {row}): {val_disp:.4g}{unit_str}")
23252374

2375+
# ── Canvas drawing-tool callbacks ─────────────────────────────────────────
2376+
2377+
def _on_canvas_roi_created(self, roi) -> None:
2378+
"""A drawing tool completed; add the new ROI and make it active."""
2379+
if self._image_roi_set is None:
2380+
return
2381+
self._image_roi_set.add(roi)
2382+
self._image_roi_set.set_active(roi.id)
2383+
self._on_image_roi_set_changed()
2384+
# Canvas already switched to pan internally; sync toolbar
2385+
self._set_drawing_tool("pan")
2386+
2387+
def _on_canvas_roi_move(self, roi_id: str, dx: int, dy: int) -> None:
2388+
"""Active ROI was drag-moved on the canvas; translate its geometry."""
2389+
if self._image_roi_set is None or (dx == 0 and dy == 0):
2390+
return
2391+
roi = self._image_roi_set.get(roi_id)
2392+
if roi is None:
2393+
return
2394+
from probeflow.core.roi import translate as _translate_roi
2395+
new_roi = _translate_roi(roi, float(dx), float(dy))
2396+
self._image_roi_set.remove(roi_id)
2397+
self._image_roi_set.add(new_roi)
2398+
self._image_roi_set.set_active(new_roi.id)
2399+
self._on_image_roi_set_changed()
2400+
2401+
def _on_canvas_tool_changed(self, kind: str) -> None:
2402+
"""Canvas emitted tool_changed (e.g. after Escape or drawing completion)."""
2403+
for btn in self._drawing_group.buttons():
2404+
btn.setChecked(btn.property("drawing_tool") == kind)
2405+
self._sync_line_profile_visibility(kind)
2406+
from probeflow.gui.tool_manager import _TOOL_HINTS
2407+
if hasattr(self, "_status_lbl"):
2408+
self._status_lbl.setText(_TOOL_HINTS.get(kind, ""))
2409+
2410+
def _on_roi_canvas_context_menu(self, roi_id: str, global_pos) -> None:
2411+
"""Right-click on a ROI in the canvas — show a small ROI action menu."""
2412+
from PySide6.QtWidgets import QMenu, QInputDialog
2413+
roi_set = self._image_roi_set
2414+
roi = roi_set.get(roi_id) if roi_set else None
2415+
if roi is None:
2416+
return
2417+
menu = QMenu(self)
2418+
act_active = menu.addAction("Set Active")
2419+
act_active.triggered.connect(lambda: self._set_active_image_roi(roi_id))
2420+
act_rename = menu.addAction("Rename…")
2421+
act_rename.triggered.connect(lambda: self._rename_image_roi(roi_id))
2422+
act_delete = menu.addAction("Delete")
2423+
act_delete.triggered.connect(lambda: self._delete_image_roi(roi_id))
2424+
act_invert = menu.addAction("Invert")
2425+
act_invert.triggered.connect(lambda: self._invert_image_roi(roi_id))
2426+
menu.exec(global_pos)
2427+
2428+
# ── ROI helper actions ────────────────────────────────────────────────────
2429+
2430+
def _set_active_image_roi(self, roi_id: str) -> None:
2431+
if self._image_roi_set is None:
2432+
return
2433+
self._image_roi_set.set_active(roi_id)
2434+
self._on_image_roi_set_changed()
2435+
2436+
def _rename_image_roi(self, roi_id: str) -> None:
2437+
from PySide6.QtWidgets import QInputDialog
2438+
roi_set = self._image_roi_set
2439+
roi = roi_set.get(roi_id) if roi_set else None
2440+
if roi is None:
2441+
return
2442+
new_name, ok = QInputDialog.getText(self, "Rename ROI", "New name:", text=roi.name)
2443+
if ok and new_name.strip():
2444+
roi.name = new_name.strip()
2445+
self._on_image_roi_set_changed()
2446+
2447+
def _delete_image_roi(self, roi_id: str) -> None:
2448+
if self._image_roi_set is None:
2449+
return
2450+
self._image_roi_set.remove(roi_id)
2451+
self._on_image_roi_set_changed()
2452+
2453+
def _delete_active_image_roi(self) -> None:
2454+
if self._image_roi_set is None:
2455+
return
2456+
active_id = self._image_roi_set.active_roi_id
2457+
if active_id is not None:
2458+
self._delete_image_roi(active_id)
2459+
2460+
def _invert_image_roi(self, roi_id: str) -> None:
2461+
roi_set = self._image_roi_set
2462+
roi = roi_set.get(roi_id) if roi_set else None
2463+
if roi is None:
2464+
return
2465+
shape = self._current_array_shape()
2466+
if shape is None:
2467+
return
2468+
from probeflow.core import roi as _roi_module
2469+
inverted = _roi_module.invert(roi, shape)
2470+
roi_set.add(inverted)
2471+
self._on_image_roi_set_changed()
2472+
2473+
def _invert_active_image_roi(self) -> None:
2474+
if self._image_roi_set is None:
2475+
return
2476+
active_id = self._image_roi_set.active_roi_id
2477+
if active_id is not None:
2478+
self._invert_image_roi(active_id)
2479+
2480+
def _select_nth_image_roi(self, n: int) -> None:
2481+
if self._image_roi_set is None:
2482+
return
2483+
rois = list(self._image_roi_set.rois)
2484+
if 1 <= n <= len(rois):
2485+
roi_id = rois[n - 1].id
2486+
self._image_roi_set.set_active(roi_id)
2487+
self._on_image_roi_set_changed()
2488+
23262489
# ── ROI operation callbacks ───────────────────────────────────────────────
23272490

23282491
def _on_roi_bg_subtract_fit(self, roi_id: str) -> None:
@@ -2437,20 +2600,36 @@ def _current_array_shape(self) -> tuple[int, int] | None:
24372600
return None if arr is None else arr.shape
24382601

24392602
def _set_selection_tool(self, kind: str) -> None:
2440-
kind = str(kind or "none")
2441-
for btn in self._selection_group.buttons():
2442-
if btn.property("selection_tool") == kind:
2443-
btn.setChecked(True)
2444-
break
2445-
self._zoom_lbl.set_selection_tool(kind)
2603+
"""Compat shim: delegates to _set_drawing_tool, mapping 'none' → 'pan'."""
2604+
self._set_drawing_tool(kind if kind and kind != "none" else "pan")
2605+
2606+
def _set_drawing_tool(self, kind: str) -> None:
2607+
"""Activate a drawing tool both on the canvas and in the toolbar."""
2608+
kind = str(kind or "pan")
2609+
from probeflow.gui.tool_manager import TOOLS
2610+
if kind not in TOOLS:
2611+
kind = "pan"
2612+
for btn in self._drawing_group.buttons():
2613+
btn.setChecked(btn.property("drawing_tool") == kind)
2614+
self._zoom_lbl.set_tool(kind)
24462615
self._sync_line_profile_visibility(kind)
2616+
from probeflow.gui.tool_manager import _TOOL_HINTS
2617+
if hasattr(self, "_status_lbl"):
2618+
self._status_lbl.setText(_TOOL_HINTS.get(kind, ""))
24472619

24482620
def _on_selection_tool_clicked(self, button) -> None:
2621+
"""Compat shim kept for any lingering external references."""
2622+
self._on_drawing_tool_clicked(button)
2623+
2624+
def _on_drawing_tool_clicked(self, button) -> None:
24492625
if self._set_zero_plane_btn.isChecked():
24502626
self._set_zero_plane_btn.setChecked(False)
2451-
kind = button.property("selection_tool") or "none"
2452-
self._zoom_lbl.set_selection_tool(kind)
2627+
kind = button.property("drawing_tool") or "pan"
2628+
self._zoom_lbl.set_tool(kind)
24532629
self._sync_line_profile_visibility(kind)
2630+
from probeflow.gui.tool_manager import _TOOL_HINTS
2631+
if hasattr(self, "_status_lbl"):
2632+
self._status_lbl.setText(_TOOL_HINTS.get(kind, ""))
24542633

24552634
def _sync_line_profile_visibility(self, kind: str | None = None) -> None:
24562635
if not hasattr(self, "_line_profile_panel"):

0 commit comments

Comments
 (0)