Skip to content

Commit ed99fd3

Browse files
Merge pull request #11 from SPMQT-Lab/fft-inverse-draw-paint
Add draw-to-create shapes + freehand paint to the Inverse FFT tab
2 parents 6fe3306 + 4608b02 commit ed99fd3

7 files changed

Lines changed: 709 additions & 171 deletions

File tree

probeflow/gui/dialogs/fft_selection.py

Lines changed: 263 additions & 60 deletions
Large diffs are not rendered by default.

probeflow/gui/dialogs/fft_viewer_reconstruct_mixin.py

Lines changed: 111 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -12,20 +12,33 @@
1212
from __future__ import annotations
1313

1414
import numpy as np
15+
from probeflow.core.resources import asset_path
1516
from probeflow.gui._tooltips import tip as _tip
1617
from probeflow.gui.typography import ui_font
18+
from PySide6.QtCore import Qt, QSize
19+
from PySide6.QtGui import QIcon
1720
from PySide6.QtWidgets import (
1821
QCheckBox, QComboBox, QFileDialog, QFrame, QGroupBox, QHBoxLayout,
19-
QLabel, QPushButton, QScrollArea, QSpinBox, QVBoxLayout, QWidget,
22+
QLabel, QPushButton, QScrollArea, QSpinBox, QToolButton, QVBoxLayout, QWidget,
2023
)
2124

25+
# Shared paint palette (mirrors the feature-finder MASK_COLORS).
26+
_PAINT_COLORS: dict[str, tuple[int, int, int]] = {
27+
"Cyan": (137, 220, 235),
28+
"Red": (243, 139, 168),
29+
"Green": (166, 227, 161),
30+
"Yellow": (249, 226, 175),
31+
"Blue": (137, 180, 250),
32+
"Magenta": (203, 166, 247),
33+
}
34+
2235

2336
class FFTViewerReconstructMixin:
2437
"""Select Fourier features, preview the inverse FFT, and apply/export."""
2538

2639
def _build_reconstruct_tab(self) -> QWidget:
27-
"""Select Fourier features (circle/ellipse), preview the inverse FFT
28-
result + residual, and apply/export."""
40+
"""Draw Fourier selections (ellipse / rectangle / paint), preview the
41+
inverse FFT result + residual, and apply/export."""
2942
scroll = QScrollArea()
3043
scroll.setWidgetResizable(True)
3144
scroll.setFrameShape(QFrame.NoFrame)
@@ -41,9 +54,9 @@ def _build_reconstruct_tab(self) -> QWidget:
4154
intro.setWordWrap(True)
4255
intro.setFont(ui_font(9))
4356
intro.setToolTip(_tip(
44-
"Make FFT filtering auditable: drop circle/ellipse selections on "
45-
"Fourier features, choose Remove or Keep, and preview the "
46-
"reconstructed image and the residual before applying. Use it to "
57+
"Make FFT filtering auditable: draw ellipse/rectangle/paint "
58+
"selections on Fourier features, choose Remove or Keep, and preview "
59+
"the reconstructed image and the residual before applying. Use it to "
4760
"confirm a periodic artefact is really gone (or to isolate one "
4861
"periodic component)."))
4962
lay.addWidget(intro)
@@ -52,31 +65,70 @@ def _build_reconstruct_tab(self) -> QWidget:
5265
sgrp = QGroupBox("Fourier selections")
5366
sg = QVBoxLayout(sgrp)
5467
sg.setSpacing(4)
55-
add_row = QHBoxLayout()
56-
add_circle = QPushButton("Add circle")
57-
add_circle.setToolTip(_tip(
58-
"Add a circular selection. Drag its centre to move it onto a "
59-
"Fourier feature and drag the square handle to resize. Its "
60-
"conjugate partner (dashed) is added automatically."))
61-
add_circle.clicked.connect(lambda: self._on_add_selection("circle"))
62-
add_ellipse = QPushButton("Add ellipse")
63-
add_ellipse.setToolTip(_tip(
64-
"Add an elliptical selection with independent width/height handles "
65-
"— for elongated or streaky Fourier features."))
66-
add_ellipse.clicked.connect(lambda: self._on_add_selection("ellipse"))
68+
# Checkable draw tools: pick one, then drag on the FFT to draw. Holding
69+
# Shift draws a regular shape (circle / square). Click an active tool
70+
# again to return to edit mode (move/resize existing selections).
71+
tool_row = QHBoxLayout()
72+
self._recon_tool_btns: dict[str, QToolButton] = {}
73+
tool_specs = [
74+
("ellipse", "Ellipse", "ellipse",
75+
"Draw an ellipse: drag a box on the FFT. Hold Shift for a circle. "
76+
"Its conjugate partner (dashed) is added automatically."),
77+
("rect", "Rectangle", "rectangle",
78+
"Draw a rectangle: drag a box on the FFT. Hold Shift for a square."),
79+
("paint", "Paint", "freehand",
80+
"Freehand brush: drag to paint an irregular Fourier region. The "
81+
"mirrored (conjugate) region is grabbed too."),
82+
]
83+
for kind, label, icon, tip in tool_specs:
84+
btn = QToolButton()
85+
btn.setText(label)
86+
btn.setCheckable(True)
87+
btn.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
88+
ipath = asset_path(f"toolbar/{icon}.png")
89+
if ipath.exists():
90+
btn.setIcon(QIcon(str(ipath)))
91+
btn.setIconSize(QSize(16, 16))
92+
btn.setToolTip(_tip(tip))
93+
btn.clicked.connect(lambda _checked=False, k=kind: self._on_tool_clicked(k))
94+
self._recon_tool_btns[kind] = btn
95+
tool_row.addWidget(btn)
96+
tool_row.addStretch(1)
97+
sg.addLayout(tool_row)
98+
99+
# Paint brush controls — only relevant while the Paint tool is active.
100+
self._recon_paint_row = QWidget()
101+
paint_lay = QHBoxLayout(self._recon_paint_row)
102+
paint_lay.setContentsMargins(0, 0, 0, 0)
103+
paint_lay.setSpacing(4)
104+
paint_lay.addWidget(QLabel("Brush:"))
105+
self._recon_brush_spin = QSpinBox()
106+
self._recon_brush_spin.setRange(1, 100)
107+
self._recon_brush_spin.setValue(8)
108+
self._recon_brush_spin.setSuffix(" px")
109+
self._recon_brush_spin.setToolTip(_tip("Paint brush radius, in FFT pixels."))
110+
self._recon_brush_spin.setMaximumWidth(80)
111+
self._recon_brush_spin.valueChanged.connect(self._on_brush_size_changed)
112+
paint_lay.addWidget(self._recon_brush_spin)
113+
paint_lay.addWidget(QLabel("Color:"))
114+
self._recon_color_combo = QComboBox()
115+
self._recon_color_combo.addItems(list(_PAINT_COLORS.keys()))
116+
self._recon_color_combo.setToolTip(_tip("Paint overlay color."))
117+
self._recon_color_combo.setMaximumWidth(110)
118+
self._recon_color_combo.currentIndexChanged.connect(self._on_paint_color_changed)
119+
paint_lay.addWidget(self._recon_color_combo)
120+
paint_lay.addStretch(1)
121+
self._recon_paint_row.setVisible(False)
122+
sg.addWidget(self._recon_paint_row)
123+
67124
del_btn = QPushButton("Delete selected")
68125
del_btn.setToolTip(_tip("Remove the currently-selected Fourier region."))
69126
del_btn.clicked.connect(self._on_delete_selection)
70127
clr_btn = QPushButton("Clear selections")
71128
clr_btn.setToolTip(_tip("Remove all Fourier selections."))
72129
clr_btn.clicked.connect(self._on_clear_selections)
73-
# 2×2 button block at a compact width instead of full-width rows.
74-
for b in (add_circle, add_ellipse, del_btn, clr_btn):
130+
for b in (del_btn, clr_btn):
75131
b.setMaximumWidth(150)
76-
add_row.addWidget(add_circle)
77-
add_row.addWidget(add_ellipse)
78-
add_row.addStretch(1)
79-
sg.addLayout(add_row)
80132
del_row = QHBoxLayout()
81133
del_row.addWidget(del_btn)
82134
del_row.addWidget(clr_btn)
@@ -228,13 +280,33 @@ def _on_selection_changed(self) -> None:
228280
self._canvas_fft.draw_idle()
229281
self._update_reconstruct_status()
230282

231-
def _on_add_selection(self, kind: str) -> None:
283+
def _on_tool_clicked(self, kind: str) -> None:
232284
ov = self._ensure_selection_overlay()
233285
if ov is None:
286+
self._recon_tool_btns[kind].setChecked(False)
234287
self._recon_status_lbl.setText("Load a scan first.")
235288
return
236-
ov.add(kind)
237-
self._on_selection_changed()
289+
# Toggle: a second click on the active tool returns to edit mode.
290+
active = None if ov.tool() == kind else kind
291+
ov.set_tool(active)
292+
for k, btn in self._recon_tool_btns.items():
293+
btn.setChecked(k == active)
294+
self._recon_paint_row.setVisible(active == "paint")
295+
if active == "paint":
296+
ov.set_brush_radius_px(self._recon_brush_spin.value())
297+
ov.set_paint_color(_PAINT_COLORS[self._recon_color_combo.currentText()])
298+
self._update_reconstruct_status()
299+
300+
def _on_brush_size_changed(self, value: int) -> None:
301+
ov = self._fft_selection_overlay
302+
if ov is not None:
303+
ov.set_brush_radius_px(int(value))
304+
305+
def _on_paint_color_changed(self, _idx: int) -> None:
306+
ov = self._fft_selection_overlay
307+
if ov is not None:
308+
ov.set_paint_color(_PAINT_COLORS[self._recon_color_combo.currentText()])
309+
self._on_selection_changed()
238310

239311
def _on_delete_selection(self) -> None:
240312
if self._fft_selection_overlay is not None:
@@ -254,11 +326,10 @@ def _compute_reconstruction(self, array):
254326
if ov is None or ov.count() == 0:
255327
return None
256328
from probeflow.processing.inverse_fft import (
257-
FourierEllipse, fourier_ellipse_mask, inverse_fft_from_mask)
258-
sels = [FourierEllipse(dx=s["dx"], dy=s["dy"], rx=s["rx"], ry=s["ry"])
259-
for s in ov.to_fft_ellipses()]
260-
mask = fourier_ellipse_mask(
261-
array.shape, sels,
329+
fourier_region_from_dict, fourier_region_mask, inverse_fft_from_mask)
330+
regions = [fourier_region_from_dict(d) for d in ov.to_regions()]
331+
mask = fourier_region_mask(
332+
array.shape, regions,
262333
conjugate=self._recon_conj_cb.isChecked(),
263334
soft_px=float(self._recon_soft_spin.value()))
264335
return inverse_fft_from_mask(array, mask, mode=self._reconstruct_mode())
@@ -284,14 +355,11 @@ def _on_reconstruct_clear(self) -> None:
284355

285356
def _reconstruct_op_params(self) -> dict:
286357
ov = self._fft_selection_overlay
287-
sels = ov.to_fft_ellipses() if ov is not None else []
358+
# to_regions() already returns JSON-safe dicts carrying the per-kind
359+
# geometry (ellipse/rect in FFT px, paint as a pixel stamp list) plus
360+
# q-space provenance.
288361
params = {
289-
"selections": [
290-
{"dx": s["dx"], "dy": s["dy"], "rx": s["rx"], "ry": s["ry"],
291-
"angle_deg": 0.0, "cx_q": s["cx_q"], "cy_q": s["cy_q"],
292-
"rx_q": s["rx_q"], "ry_q": s["ry_q"], "kind": s["kind"]}
293-
for s in sels
294-
],
362+
"selections": ov.to_regions() if ov is not None else [],
295363
"mode": self._reconstruct_mode(),
296364
"conjugate_symmetric": bool(self._recon_conj_cb.isChecked()),
297365
"soft_px": float(self._recon_soft_spin.value()),
@@ -364,8 +432,10 @@ def _update_reconstruct_status(self, res=None) -> None:
364432
mode = "remove selected" if self._reconstruct_mode() == "remove_selected" else "keep selected"
365433
src = "ROI" if self._fft_source == "active_roi" else "whole image"
366434
if n == 0:
367-
self._recon_status_lbl.setText(
368-
f"FFT source: {src}. Add a circle or ellipse to begin.")
435+
tool = ov.tool() if ov is not None else None
436+
hint = ("Drag on the FFT to draw (Shift = regular shape)."
437+
if tool else "Pick Ellipse, Rectangle or Paint to begin.")
438+
self._recon_status_lbl.setText(f"FFT source: {src}. {hint}")
369439
return
370440
conj = " + conjugates" if self._recon_conj_cb.isChecked() else ""
371441
txt = (f"FFT mask: {n} region{'s' if n != 1 else ''}{conj} · "

0 commit comments

Comments
 (0)