1212from __future__ import annotations
1313
1414import numpy as np
15+ from probeflow .core .resources import asset_path
1516from probeflow .gui ._tooltips import tip as _tip
1617from probeflow .gui .typography import ui_font
18+ from PySide6 .QtCore import Qt , QSize
19+ from PySide6 .QtGui import QIcon
1720from 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
2336class 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