setiastrosuitepro 1.6.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of setiastrosuitepro might be problematic. Click here for more details.
- setiastro/__init__.py +2 -0
- setiastro/saspro/__init__.py +20 -0
- setiastro/saspro/__main__.py +784 -0
- setiastro/saspro/_generated/__init__.py +7 -0
- setiastro/saspro/_generated/build_info.py +2 -0
- setiastro/saspro/abe.py +1295 -0
- setiastro/saspro/abe_preset.py +196 -0
- setiastro/saspro/aberration_ai.py +694 -0
- setiastro/saspro/aberration_ai_preset.py +224 -0
- setiastro/saspro/accel_installer.py +218 -0
- setiastro/saspro/accel_workers.py +30 -0
- setiastro/saspro/add_stars.py +621 -0
- setiastro/saspro/astrobin_exporter.py +1007 -0
- setiastro/saspro/astrospike.py +153 -0
- setiastro/saspro/astrospike_python.py +1839 -0
- setiastro/saspro/autostretch.py +196 -0
- setiastro/saspro/backgroundneutral.py +560 -0
- setiastro/saspro/batch_convert.py +325 -0
- setiastro/saspro/batch_renamer.py +519 -0
- setiastro/saspro/blemish_blaster.py +488 -0
- setiastro/saspro/blink_comparator_pro.py +2923 -0
- setiastro/saspro/bundles.py +61 -0
- setiastro/saspro/bundles_dock.py +114 -0
- setiastro/saspro/cheat_sheet.py +168 -0
- setiastro/saspro/clahe.py +342 -0
- setiastro/saspro/comet_stacking.py +1377 -0
- setiastro/saspro/config.py +38 -0
- setiastro/saspro/config_bootstrap.py +40 -0
- setiastro/saspro/config_manager.py +316 -0
- setiastro/saspro/continuum_subtract.py +1617 -0
- setiastro/saspro/convo.py +1397 -0
- setiastro/saspro/convo_preset.py +414 -0
- setiastro/saspro/copyastro.py +187 -0
- setiastro/saspro/cosmicclarity.py +1564 -0
- setiastro/saspro/cosmicclarity_preset.py +407 -0
- setiastro/saspro/crop_dialog_pro.py +948 -0
- setiastro/saspro/crop_preset.py +189 -0
- setiastro/saspro/curve_editor_pro.py +2544 -0
- setiastro/saspro/curves_preset.py +375 -0
- setiastro/saspro/debayer.py +670 -0
- setiastro/saspro/debug_utils.py +29 -0
- setiastro/saspro/dnd_mime.py +35 -0
- setiastro/saspro/doc_manager.py +2634 -0
- setiastro/saspro/exoplanet_detector.py +2166 -0
- setiastro/saspro/file_utils.py +284 -0
- setiastro/saspro/fitsmodifier.py +744 -0
- setiastro/saspro/free_torch_memory.py +48 -0
- setiastro/saspro/frequency_separation.py +1343 -0
- setiastro/saspro/function_bundle.py +1594 -0
- setiastro/saspro/ghs_dialog_pro.py +660 -0
- setiastro/saspro/ghs_preset.py +284 -0
- setiastro/saspro/graxpert.py +634 -0
- setiastro/saspro/graxpert_preset.py +287 -0
- setiastro/saspro/gui/__init__.py +0 -0
- setiastro/saspro/gui/main_window.py +8494 -0
- setiastro/saspro/gui/mixins/__init__.py +33 -0
- setiastro/saspro/gui/mixins/dock_mixin.py +263 -0
- setiastro/saspro/gui/mixins/file_mixin.py +445 -0
- setiastro/saspro/gui/mixins/geometry_mixin.py +403 -0
- setiastro/saspro/gui/mixins/header_mixin.py +441 -0
- setiastro/saspro/gui/mixins/mask_mixin.py +421 -0
- setiastro/saspro/gui/mixins/menu_mixin.py +361 -0
- setiastro/saspro/gui/mixins/theme_mixin.py +367 -0
- setiastro/saspro/gui/mixins/toolbar_mixin.py +1324 -0
- setiastro/saspro/gui/mixins/update_mixin.py +309 -0
- setiastro/saspro/gui/mixins/view_mixin.py +435 -0
- setiastro/saspro/halobgon.py +462 -0
- setiastro/saspro/header_viewer.py +445 -0
- setiastro/saspro/headless_utils.py +88 -0
- setiastro/saspro/histogram.py +753 -0
- setiastro/saspro/history_explorer.py +939 -0
- setiastro/saspro/image_combine.py +414 -0
- setiastro/saspro/image_peeker_pro.py +1596 -0
- setiastro/saspro/imageops/__init__.py +37 -0
- setiastro/saspro/imageops/mdi_snap.py +292 -0
- setiastro/saspro/imageops/scnr.py +36 -0
- setiastro/saspro/imageops/starbasedwhitebalance.py +210 -0
- setiastro/saspro/imageops/stretch.py +244 -0
- setiastro/saspro/isophote.py +1179 -0
- setiastro/saspro/layers.py +208 -0
- setiastro/saspro/layers_dock.py +714 -0
- setiastro/saspro/lazy_imports.py +193 -0
- setiastro/saspro/legacy/__init__.py +2 -0
- setiastro/saspro/legacy/image_manager.py +2226 -0
- setiastro/saspro/legacy/numba_utils.py +3659 -0
- setiastro/saspro/legacy/xisf.py +1071 -0
- setiastro/saspro/linear_fit.py +534 -0
- setiastro/saspro/live_stacking.py +1830 -0
- setiastro/saspro/log_bus.py +5 -0
- setiastro/saspro/logging_config.py +460 -0
- setiastro/saspro/luminancerecombine.py +309 -0
- setiastro/saspro/main_helpers.py +201 -0
- setiastro/saspro/mask_creation.py +928 -0
- setiastro/saspro/masks_core.py +56 -0
- setiastro/saspro/mdi_widgets.py +353 -0
- setiastro/saspro/memory_utils.py +666 -0
- setiastro/saspro/metadata_patcher.py +75 -0
- setiastro/saspro/mfdeconv.py +3826 -0
- setiastro/saspro/mfdeconv_earlystop.py +71 -0
- setiastro/saspro/mfdeconvcudnn.py +3263 -0
- setiastro/saspro/mfdeconvsport.py +2382 -0
- setiastro/saspro/minorbodycatalog.py +567 -0
- setiastro/saspro/morphology.py +382 -0
- setiastro/saspro/multiscale_decomp.py +1290 -0
- setiastro/saspro/nbtorgb_stars.py +531 -0
- setiastro/saspro/numba_utils.py +3044 -0
- setiastro/saspro/numba_warmup.py +141 -0
- setiastro/saspro/ops/__init__.py +9 -0
- setiastro/saspro/ops/command_help_dialog.py +623 -0
- setiastro/saspro/ops/command_runner.py +217 -0
- setiastro/saspro/ops/commands.py +1594 -0
- setiastro/saspro/ops/script_editor.py +1102 -0
- setiastro/saspro/ops/scripts.py +1413 -0
- setiastro/saspro/ops/settings.py +560 -0
- setiastro/saspro/parallel_utils.py +554 -0
- setiastro/saspro/pedestal.py +121 -0
- setiastro/saspro/perfect_palette_picker.py +1053 -0
- setiastro/saspro/pipeline.py +110 -0
- setiastro/saspro/pixelmath.py +1600 -0
- setiastro/saspro/plate_solver.py +2435 -0
- setiastro/saspro/project_io.py +797 -0
- setiastro/saspro/psf_utils.py +136 -0
- setiastro/saspro/psf_viewer.py +549 -0
- setiastro/saspro/pyi_rthook_astroquery.py +95 -0
- setiastro/saspro/remove_green.py +314 -0
- setiastro/saspro/remove_stars.py +1625 -0
- setiastro/saspro/remove_stars_preset.py +404 -0
- setiastro/saspro/resources.py +472 -0
- setiastro/saspro/rgb_combination.py +207 -0
- setiastro/saspro/rgb_extract.py +19 -0
- setiastro/saspro/rgbalign.py +723 -0
- setiastro/saspro/runtime_imports.py +7 -0
- setiastro/saspro/runtime_torch.py +754 -0
- setiastro/saspro/save_options.py +72 -0
- setiastro/saspro/selective_color.py +1552 -0
- setiastro/saspro/sfcc.py +1425 -0
- setiastro/saspro/shortcuts.py +2807 -0
- setiastro/saspro/signature_insert.py +1099 -0
- setiastro/saspro/stacking_suite.py +17712 -0
- setiastro/saspro/star_alignment.py +7420 -0
- setiastro/saspro/star_alignment_preset.py +329 -0
- setiastro/saspro/star_metrics.py +49 -0
- setiastro/saspro/star_spikes.py +681 -0
- setiastro/saspro/star_stretch.py +470 -0
- setiastro/saspro/stat_stretch.py +502 -0
- setiastro/saspro/status_log_dock.py +78 -0
- setiastro/saspro/subwindow.py +3267 -0
- setiastro/saspro/supernovaasteroidhunter.py +1712 -0
- setiastro/saspro/swap_manager.py +99 -0
- setiastro/saspro/torch_backend.py +89 -0
- setiastro/saspro/torch_rejection.py +434 -0
- setiastro/saspro/view_bundle.py +1555 -0
- setiastro/saspro/wavescale_hdr.py +624 -0
- setiastro/saspro/wavescale_hdr_preset.py +100 -0
- setiastro/saspro/wavescalede.py +657 -0
- setiastro/saspro/wavescalede_preset.py +228 -0
- setiastro/saspro/wcs_update.py +374 -0
- setiastro/saspro/whitebalance.py +456 -0
- setiastro/saspro/widgets/__init__.py +48 -0
- setiastro/saspro/widgets/common_utilities.py +305 -0
- setiastro/saspro/widgets/graphics_views.py +122 -0
- setiastro/saspro/widgets/image_utils.py +518 -0
- setiastro/saspro/widgets/preview_dialogs.py +280 -0
- setiastro/saspro/widgets/spinboxes.py +275 -0
- setiastro/saspro/widgets/themed_buttons.py +13 -0
- setiastro/saspro/widgets/wavelet_utils.py +299 -0
- setiastro/saspro/window_shelf.py +185 -0
- setiastro/saspro/xisf.py +1123 -0
- setiastrosuitepro-1.6.0.dist-info/METADATA +266 -0
- setiastrosuitepro-1.6.0.dist-info/RECORD +174 -0
- setiastrosuitepro-1.6.0.dist-info/WHEEL +4 -0
- setiastrosuitepro-1.6.0.dist-info/entry_points.txt +6 -0
- setiastrosuitepro-1.6.0.dist-info/licenses/LICENSE +674 -0
- setiastrosuitepro-1.6.0.dist-info/licenses/license.txt +2580 -0
|
@@ -0,0 +1,928 @@
|
|
|
1
|
+
# pro/mask_creation.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import uuid
|
|
4
|
+
import numpy as np
|
|
5
|
+
import math
|
|
6
|
+
|
|
7
|
+
# Optional deps
|
|
8
|
+
try:
|
|
9
|
+
import cv2
|
|
10
|
+
except Exception:
|
|
11
|
+
cv2 = None
|
|
12
|
+
try:
|
|
13
|
+
import sep
|
|
14
|
+
except Exception:
|
|
15
|
+
sep = None
|
|
16
|
+
|
|
17
|
+
from PyQt6.QtCore import Qt, QPointF, QRectF, QTimer, QEvent
|
|
18
|
+
from PyQt6.QtGui import (
|
|
19
|
+
QImage, QPixmap, QPainter, QColor, QPen, QBrush,
|
|
20
|
+
QPainterPath, QWheelEvent, QPolygonF
|
|
21
|
+
)
|
|
22
|
+
from PyQt6.QtWidgets import (
|
|
23
|
+
QInputDialog, QMessageBox, QFileDialog, # QFileDialog only used if you later add “export”
|
|
24
|
+
QDialog, QDialogButtonBox,
|
|
25
|
+
QVBoxLayout, QHBoxLayout, QGridLayout,
|
|
26
|
+
QLabel, QPushButton, QComboBox, QSlider, QCheckBox, QButtonGroup, QGroupBox,
|
|
27
|
+
QScrollArea, QSizePolicy,
|
|
28
|
+
QGraphicsView, QGraphicsScene, QGraphicsItem,
|
|
29
|
+
QGraphicsPixmapItem, QGraphicsPathItem, QGraphicsPolygonItem,
|
|
30
|
+
QGraphicsEllipseItem, QGraphicsRectItem, QMdiSubWindow, QLabel
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
from .masks_core import MaskLayer
|
|
34
|
+
from setiastro.saspro.widgets.themed_buttons import themed_toolbtn
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ---------- small utils ----------
|
|
38
|
+
|
|
39
|
+
def _to_qpixmap01(img01: np.ndarray) -> QPixmap:
|
|
40
|
+
a = np.clip(img01, 0.0, 1.0)
|
|
41
|
+
if a.ndim == 2:
|
|
42
|
+
buf = (a * 255).astype(np.uint8)
|
|
43
|
+
h, w = buf.shape
|
|
44
|
+
qimg = QImage(buf.data, w, h, w, QImage.Format.Format_Grayscale8)
|
|
45
|
+
else:
|
|
46
|
+
buf = (a * 255).astype(np.uint8)
|
|
47
|
+
h, w, _ = buf.shape
|
|
48
|
+
qimg = QImage(buf.data, w, h, buf.strides[0], QImage.Format.Format_RGB888)
|
|
49
|
+
return QPixmap.fromImage(qimg)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _find_main_window(w):
|
|
53
|
+
p = w
|
|
54
|
+
from PyQt6.QtWidgets import QMainWindow
|
|
55
|
+
while p is not None and not isinstance(p, QMainWindow):
|
|
56
|
+
p = p.parent()
|
|
57
|
+
return p
|
|
58
|
+
|
|
59
|
+
def _push_numpy_as_new_document(owner_widget, arr01: np.ndarray, default_name: str = "Mask") -> bool:
|
|
60
|
+
mw = _find_main_window(owner_widget)
|
|
61
|
+
if mw is None or not hasattr(mw, "docman"):
|
|
62
|
+
QMessageBox.warning(owner_widget, "Cannot Create Document", "Main window / DocManager not found.")
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
# Ask for the document name
|
|
66
|
+
name, ok = QInputDialog.getText(owner_widget, "New Document Name", "Name:", text=default_name)
|
|
67
|
+
if not ok:
|
|
68
|
+
return False
|
|
69
|
+
|
|
70
|
+
# Ensure float32 in [0..1]
|
|
71
|
+
img = np.clip(arr01.astype(np.float32, copy=False), 0.0, 1.0)
|
|
72
|
+
|
|
73
|
+
# This sets metadata['display_name'] via DocManager and emits documentAdded
|
|
74
|
+
doc = mw.docman.open_array(img, title=name)
|
|
75
|
+
|
|
76
|
+
# Nothing else required: AstroSuiteProMainWindow._open_subwindow_for_added_doc
|
|
77
|
+
# will create/show the subwindow.
|
|
78
|
+
if hasattr(mw, "_log"):
|
|
79
|
+
mw._log(f"Created new document from mask: {doc.display_name()}")
|
|
80
|
+
return True
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ---------- Interactive ellipse handles ----------
|
|
84
|
+
class HandleItem(QGraphicsRectItem):
|
|
85
|
+
SIZE = 8
|
|
86
|
+
def __init__(self, role: str, parent_ellipse: QGraphicsEllipseItem):
|
|
87
|
+
super().__init__(-self.SIZE/2, -self.SIZE/2, self.SIZE, self.SIZE, parent_ellipse)
|
|
88
|
+
self.role = role
|
|
89
|
+
self.parent_ellipse = parent_ellipse
|
|
90
|
+
self.setBrush(QColor(255, 0, 0))
|
|
91
|
+
self.setAcceptedMouseButtons(Qt.MouseButton.LeftButton)
|
|
92
|
+
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsMovable, False)
|
|
93
|
+
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges, False)
|
|
94
|
+
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIgnoresTransformations, True)
|
|
95
|
+
|
|
96
|
+
cursors = {
|
|
97
|
+
'top': Qt.CursorShape.SizeVerCursor,
|
|
98
|
+
'bottom': Qt.CursorShape.SizeVerCursor,
|
|
99
|
+
'left': Qt.CursorShape.SizeHorCursor,
|
|
100
|
+
'right': Qt.CursorShape.SizeHorCursor,
|
|
101
|
+
'rotate': Qt.CursorShape.OpenHandCursor,
|
|
102
|
+
}
|
|
103
|
+
self.setCursor(cursors[role])
|
|
104
|
+
|
|
105
|
+
self._lastScenePos = None
|
|
106
|
+
# extra state for rotation
|
|
107
|
+
self._centerScene = None
|
|
108
|
+
self._startAngle = None
|
|
109
|
+
self._startRotation = None
|
|
110
|
+
|
|
111
|
+
def mousePressEvent(self, ev):
|
|
112
|
+
if self.role == 'rotate':
|
|
113
|
+
# Store center of ellipse in scene coords
|
|
114
|
+
rect = self.parent_ellipse.rect()
|
|
115
|
+
center_item = rect.center()
|
|
116
|
+
self._centerScene = self.parent_ellipse.mapToScene(center_item)
|
|
117
|
+
|
|
118
|
+
# Starting angle from center → mouse
|
|
119
|
+
p = ev.scenePos()
|
|
120
|
+
dx = p.x() - self._centerScene.x()
|
|
121
|
+
dy = p.y() - self._centerScene.y()
|
|
122
|
+
self._startAngle = math.degrees(math.atan2(dy, dx))
|
|
123
|
+
|
|
124
|
+
# Store current item rotation
|
|
125
|
+
self._startRotation = self.parent_ellipse.rotation()
|
|
126
|
+
|
|
127
|
+
# Optional: change cursor to "grabbing"
|
|
128
|
+
self.setCursor(Qt.CursorShape.ClosedHandCursor)
|
|
129
|
+
ev.accept()
|
|
130
|
+
return
|
|
131
|
+
|
|
132
|
+
# Non-rotate handles use the old dx/dy code path
|
|
133
|
+
self._lastScenePos = ev.scenePos()
|
|
134
|
+
ev.accept()
|
|
135
|
+
|
|
136
|
+
def mouseMoveEvent(self, ev):
|
|
137
|
+
if self.role == 'rotate':
|
|
138
|
+
if self._centerScene is None or self._startAngle is None or self._startRotation is None:
|
|
139
|
+
ev.accept()
|
|
140
|
+
return
|
|
141
|
+
|
|
142
|
+
p = ev.scenePos()
|
|
143
|
+
dx = p.x() - self._centerScene.x()
|
|
144
|
+
dy = p.y() - self._centerScene.y()
|
|
145
|
+
|
|
146
|
+
# Current angle from center → mouse
|
|
147
|
+
current_angle = math.degrees(math.atan2(dy, dx))
|
|
148
|
+
|
|
149
|
+
# Delta relative to the original grab angle
|
|
150
|
+
delta = current_angle - self._startAngle
|
|
151
|
+
|
|
152
|
+
# Set absolute rotation: starting rotation + delta
|
|
153
|
+
self.parent_ellipse.setRotation(self._startRotation + delta)
|
|
154
|
+
ev.accept()
|
|
155
|
+
return
|
|
156
|
+
|
|
157
|
+
# Resize handles: same as before
|
|
158
|
+
if self._lastScenePos is None:
|
|
159
|
+
self._lastScenePos = ev.scenePos()
|
|
160
|
+
dx = ev.scenePos().x() - self._lastScenePos.x()
|
|
161
|
+
dy = ev.scenePos().y() - self._lastScenePos.y()
|
|
162
|
+
self.parent_ellipse.interactiveResize(self.role, dx, dy)
|
|
163
|
+
self._lastScenePos = ev.scenePos()
|
|
164
|
+
ev.accept()
|
|
165
|
+
|
|
166
|
+
def mouseReleaseEvent(self, ev):
|
|
167
|
+
# Reset rotation state and cursor
|
|
168
|
+
if self.role == 'rotate':
|
|
169
|
+
self._centerScene = None
|
|
170
|
+
self._startAngle = None
|
|
171
|
+
self._startRotation = None
|
|
172
|
+
self.setCursor(Qt.CursorShape.OpenHandCursor)
|
|
173
|
+
|
|
174
|
+
self._lastScenePos = None
|
|
175
|
+
ev.accept()
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class InteractiveEllipseItem(QGraphicsEllipseItem):
|
|
180
|
+
def __init__(self, rect: QRectF):
|
|
181
|
+
super().__init__(rect)
|
|
182
|
+
self._resizing = False
|
|
183
|
+
self.setTransformOriginPoint(self.rect().center())
|
|
184
|
+
self.setFlags(
|
|
185
|
+
QGraphicsItem.GraphicsItemFlag.ItemIsMovable |
|
|
186
|
+
QGraphicsItem.GraphicsItemFlag.ItemIsSelectable |
|
|
187
|
+
QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
# Cosmetic pen: stays the same thickness on screen regardless of zoom
|
|
191
|
+
pen = QPen(QColor(0, 255, 0), 2)
|
|
192
|
+
pen.setCosmetic(True)
|
|
193
|
+
self.setPen(pen)
|
|
194
|
+
|
|
195
|
+
self.handles = {r: HandleItem(r, self) for r in ('top','bottom','left','right','rotate')}
|
|
196
|
+
self.updateHandles()
|
|
197
|
+
|
|
198
|
+
def updateHandles(self):
|
|
199
|
+
r = self.rect()
|
|
200
|
+
cx, cy = r.center().x(), r.center().y()
|
|
201
|
+
for h in self.handles.values():
|
|
202
|
+
h.setFlag(QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges, False)
|
|
203
|
+
positions = {
|
|
204
|
+
'top': QPointF(cx, r.top()),
|
|
205
|
+
'bottom': QPointF(cx, r.bottom()),
|
|
206
|
+
'left': QPointF(r.left(), cy),
|
|
207
|
+
'right': QPointF(r.right(), cy),
|
|
208
|
+
'rotate': QPointF(cx, r.top()-20)
|
|
209
|
+
}
|
|
210
|
+
for role, h in self.handles.items():
|
|
211
|
+
h.setPos(self.mapFromScene(self.mapToScene(positions[role])))
|
|
212
|
+
for h in self.handles.values():
|
|
213
|
+
h.setFlag(QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges, True)
|
|
214
|
+
|
|
215
|
+
def itemChange(self, change, value):
|
|
216
|
+
if change in (QGraphicsItem.GraphicsItemChange.ItemPositionChange,
|
|
217
|
+
QGraphicsItem.GraphicsItemChange.ItemTransformChange,
|
|
218
|
+
QGraphicsItem.GraphicsItemChange.ItemSelectedHasChanged):
|
|
219
|
+
QTimer.singleShot(0, self.updateHandles)
|
|
220
|
+
return super().itemChange(change, value)
|
|
221
|
+
|
|
222
|
+
def interactiveResize(self, role: str, dx: float, dy: float):
|
|
223
|
+
if self._resizing:
|
|
224
|
+
return
|
|
225
|
+
r = QRectF(self.rect())
|
|
226
|
+
if role == 'top':
|
|
227
|
+
r.setTop(r.top() + dy)
|
|
228
|
+
elif role == 'bottom':
|
|
229
|
+
r.setBottom(r.bottom() + dy)
|
|
230
|
+
elif role == 'left':
|
|
231
|
+
r.setLeft(r.left() + dx)
|
|
232
|
+
elif role == 'right':
|
|
233
|
+
r.setRight(r.right() + dx)
|
|
234
|
+
elif role == 'rotate':
|
|
235
|
+
# rotation is handled in HandleItem.mouseMoveEvent now
|
|
236
|
+
return
|
|
237
|
+
self._resizing = True
|
|
238
|
+
self.prepareGeometryChange()
|
|
239
|
+
self.setRect(r)
|
|
240
|
+
self.updateHandles()
|
|
241
|
+
self._resizing = False
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# ---------- Canvas ----------
|
|
245
|
+
|
|
246
|
+
class MaskCanvas(QGraphicsView):
|
|
247
|
+
def __init__(self, image01: np.ndarray, parent=None):
|
|
248
|
+
super().__init__(parent)
|
|
249
|
+
self.setRenderHint(QPainter.RenderHint.Antialiasing)
|
|
250
|
+
|
|
251
|
+
# scene + background image
|
|
252
|
+
self.scene = QGraphicsScene(self)
|
|
253
|
+
self.setScene(self.scene)
|
|
254
|
+
self.bg_item = QGraphicsPixmapItem(_to_qpixmap01(image01))
|
|
255
|
+
self.scene.addItem(self.bg_item)
|
|
256
|
+
|
|
257
|
+
# --- NEW: basic zoom state ---
|
|
258
|
+
self._zoom = 1.0
|
|
259
|
+
self._min_zoom = 0.05
|
|
260
|
+
self._max_zoom = 8.0
|
|
261
|
+
self.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
|
|
262
|
+
self.setResizeAnchor(QGraphicsView.ViewportAnchor.AnchorViewCenter)
|
|
263
|
+
# Make sure the scene rect matches the image so fit works perfectly
|
|
264
|
+
self.setSceneRect(self.bg_item.boundingRect())
|
|
265
|
+
|
|
266
|
+
self.mode = 'polygon'
|
|
267
|
+
self.temp_path: QGraphicsPathItem | None = None
|
|
268
|
+
self.poly_points: list[QPointF] = []
|
|
269
|
+
self.temp_ellipse: QGraphicsEllipseItem | None = None
|
|
270
|
+
self.ellipse_origin: QPointF | None = None
|
|
271
|
+
self.shapes: list[QGraphicsItem] = []
|
|
272
|
+
|
|
273
|
+
# ------------------- NEW: Zoom API -------------------
|
|
274
|
+
def set_zoom(self, z: float):
|
|
275
|
+
"""Absolute zoom setter (resets transform, then scales)."""
|
|
276
|
+
self._zoom = max(self._min_zoom, min(float(z), self._max_zoom))
|
|
277
|
+
self.resetTransform()
|
|
278
|
+
self.scale(self._zoom, self._zoom)
|
|
279
|
+
|
|
280
|
+
def zoom_in(self):
|
|
281
|
+
self.set_zoom(self._zoom * 1.25)
|
|
282
|
+
|
|
283
|
+
def zoom_out(self):
|
|
284
|
+
self.set_zoom(self._zoom / 1.25)
|
|
285
|
+
|
|
286
|
+
def fit_to_view(self):
|
|
287
|
+
"""Fit the background image into the viewport (Keeps aspect)."""
|
|
288
|
+
pm = self.bg_item.pixmap()
|
|
289
|
+
if pm.isNull():
|
|
290
|
+
return
|
|
291
|
+
vw = max(1, self.viewport().width())
|
|
292
|
+
vh = max(1, self.viewport().height())
|
|
293
|
+
iw = pm.width()
|
|
294
|
+
ih = pm.height()
|
|
295
|
+
if iw == 0 or ih == 0:
|
|
296
|
+
return
|
|
297
|
+
s = min(vw / iw, vh / ih)
|
|
298
|
+
self.set_zoom(s)
|
|
299
|
+
|
|
300
|
+
def wheelEvent(self, ev):
|
|
301
|
+
"""Ctrl + wheel → zoom; otherwise default scroll behavior."""
|
|
302
|
+
if ev.modifiers() & Qt.KeyboardModifier.ControlModifier:
|
|
303
|
+
self.set_zoom(self._zoom * (1.25 if ev.angleDelta().y() > 0 else 0.8))
|
|
304
|
+
ev.accept()
|
|
305
|
+
return
|
|
306
|
+
super().wheelEvent(ev)
|
|
307
|
+
# ----------------- END: Zoom API ---------------------
|
|
308
|
+
|
|
309
|
+
def set_mode(self, mode: str):
|
|
310
|
+
assert mode in ('polygon', 'ellipse', 'select')
|
|
311
|
+
self.mode = mode
|
|
312
|
+
|
|
313
|
+
def clear_shapes(self):
|
|
314
|
+
for it in list(self.shapes):
|
|
315
|
+
self.scene.removeItem(it)
|
|
316
|
+
self.shapes.clear()
|
|
317
|
+
|
|
318
|
+
def select_entire_image(self):
|
|
319
|
+
self.clear_shapes()
|
|
320
|
+
rect = self.bg_item.boundingRect()
|
|
321
|
+
poly = QGraphicsPolygonItem(QPolygonF([rect.topLeft(), rect.topRight(),
|
|
322
|
+
rect.bottomRight(), rect.bottomLeft()]))
|
|
323
|
+
poly.setBrush(QColor(0, 255, 0, 50))
|
|
324
|
+
pen = QPen(QColor(0, 255, 0), 2)
|
|
325
|
+
pen.setCosmetic(True)
|
|
326
|
+
poly.setPen(pen)
|
|
327
|
+
poly.setFlags(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable |
|
|
328
|
+
QGraphicsItem.GraphicsItemFlag.ItemIsMovable)
|
|
329
|
+
self.scene.addItem(poly); self.shapes.append(poly)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def mousePressEvent(self, ev):
|
|
333
|
+
pt = self.mapToScene(ev.pos())
|
|
334
|
+
if self.mode == 'ellipse' and ev.button() == Qt.MouseButton.LeftButton:
|
|
335
|
+
for it in self.items(ev.pos()):
|
|
336
|
+
if isinstance(it, (InteractiveEllipseItem, HandleItem)):
|
|
337
|
+
return super().mousePressEvent(ev)
|
|
338
|
+
|
|
339
|
+
if self.mode == 'polygon' and ev.button() == Qt.MouseButton.LeftButton:
|
|
340
|
+
self.poly_points = [pt]
|
|
341
|
+
path = QPainterPath(pt)
|
|
342
|
+
self.temp_path = QGraphicsPathItem(path)
|
|
343
|
+
pen = QPen(QColor(255, 0, 0), 2, Qt.PenStyle.DashLine)
|
|
344
|
+
pen.setCosmetic(True)
|
|
345
|
+
self.temp_path.setPen(pen)
|
|
346
|
+
self.scene.addItem(self.temp_path)
|
|
347
|
+
return
|
|
348
|
+
|
|
349
|
+
if self.mode == 'ellipse' and ev.button() == Qt.MouseButton.LeftButton:
|
|
350
|
+
self.ellipse_origin = pt
|
|
351
|
+
self.temp_ellipse = QGraphicsEllipseItem(QRectF(pt, pt))
|
|
352
|
+
pen = QPen(QColor(0, 255, 0), 2, Qt.PenStyle.DashLine)
|
|
353
|
+
pen.setCosmetic(True)
|
|
354
|
+
self.temp_ellipse.setPen(pen)
|
|
355
|
+
self.scene.addItem(self.temp_ellipse)
|
|
356
|
+
return
|
|
357
|
+
|
|
358
|
+
super().mousePressEvent(ev)
|
|
359
|
+
|
|
360
|
+
def mouseMoveEvent(self, ev):
|
|
361
|
+
pt = self.mapToScene(ev.pos())
|
|
362
|
+
if self.mode == 'ellipse' and self.temp_ellipse is not None:
|
|
363
|
+
self.temp_ellipse.setRect(QRectF(self.ellipse_origin, pt).normalized())
|
|
364
|
+
elif self.mode == 'polygon' and self.temp_path:
|
|
365
|
+
self.poly_points.append(pt)
|
|
366
|
+
p = QPainterPath(self.poly_points[0])
|
|
367
|
+
for q in self.poly_points[1:]:
|
|
368
|
+
p.lineTo(q)
|
|
369
|
+
self.temp_path.setPath(p)
|
|
370
|
+
else:
|
|
371
|
+
super().mouseMoveEvent(ev)
|
|
372
|
+
|
|
373
|
+
def mouseReleaseEvent(self, ev):
|
|
374
|
+
if self.mode == 'ellipse' and self.temp_ellipse is not None:
|
|
375
|
+
final_rect = self.temp_ellipse.rect().normalized()
|
|
376
|
+
self.scene.removeItem(self.temp_ellipse); self.temp_ellipse = None
|
|
377
|
+
if final_rect.width() > 4 and final_rect.height() > 4:
|
|
378
|
+
local_rect = QRectF(0, 0, final_rect.width(), final_rect.height())
|
|
379
|
+
ell = InteractiveEllipseItem(local_rect)
|
|
380
|
+
ell.setBrush(QBrush(Qt.BrushStyle.NoBrush))
|
|
381
|
+
ell.setZValue(1)
|
|
382
|
+
ell.setPos(final_rect.topLeft())
|
|
383
|
+
self.scene.addItem(ell); self.shapes.append(ell)
|
|
384
|
+
return
|
|
385
|
+
|
|
386
|
+
if self.mode == 'polygon' and self.temp_path:
|
|
387
|
+
poly = QGraphicsPolygonItem(QPolygonF(self.poly_points))
|
|
388
|
+
poly.setBrush(QColor(0, 255, 0, 50))
|
|
389
|
+
pen = QPen(QColor(0, 255, 0), 2)
|
|
390
|
+
pen.setCosmetic(True)
|
|
391
|
+
poly.setPen(pen)
|
|
392
|
+
poly.setFlags(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable |
|
|
393
|
+
QGraphicsItem.GraphicsItemFlag.ItemIsMovable)
|
|
394
|
+
self.scene.removeItem(self.temp_path); self.temp_path = None
|
|
395
|
+
self.scene.addItem(poly); self.shapes.append(poly)
|
|
396
|
+
return
|
|
397
|
+
|
|
398
|
+
super().mouseReleaseEvent(ev)
|
|
399
|
+
|
|
400
|
+
def create_mask(self) -> np.ndarray:
|
|
401
|
+
if cv2 is None:
|
|
402
|
+
raise RuntimeError("OpenCV (cv2) is required for mask creation.")
|
|
403
|
+
h = self.bg_item.pixmap().height()
|
|
404
|
+
w = self.bg_item.pixmap().width()
|
|
405
|
+
mask = np.zeros((h, w), dtype=np.uint8)
|
|
406
|
+
|
|
407
|
+
for s in self.shapes:
|
|
408
|
+
if isinstance(s, QGraphicsPolygonItem):
|
|
409
|
+
pts = s.polygon()
|
|
410
|
+
arr = np.array([[p.x(), p.y()] for p in pts], np.int32)
|
|
411
|
+
cv2.fillPoly(mask, [arr], 1)
|
|
412
|
+
elif isinstance(s, InteractiveEllipseItem):
|
|
413
|
+
r = s.rect()
|
|
414
|
+
scenep = s.mapToScene(r.center())
|
|
415
|
+
cx, cy = int(scenep.x()), int(scenep.y())
|
|
416
|
+
rx = int(max(1, r.width() / 2))
|
|
417
|
+
ry = int(max(1, r.height() / 2))
|
|
418
|
+
angle = float(s.rotation())
|
|
419
|
+
cv2.ellipse(mask, (cx, cy), (rx, ry), angle, 0, 360, 1, -1)
|
|
420
|
+
|
|
421
|
+
return (mask > 0).astype(np.float32)
|
|
422
|
+
|
|
423
|
+
# Fit once on first show (nice UX)
|
|
424
|
+
def showEvent(self, ev):
|
|
425
|
+
super().showEvent(ev)
|
|
426
|
+
QTimer.singleShot(0, self.fit_to_view)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
# ---------- Live preview ----------
|
|
431
|
+
|
|
432
|
+
class LivePreviewDialog(QDialog):
|
|
433
|
+
def __init__(self, original_image01: np.ndarray, parent=None):
|
|
434
|
+
super().__init__(parent)
|
|
435
|
+
self.setWindowTitle("Live Mask Preview")
|
|
436
|
+
self.label = QLabel(alignment=Qt.AlignmentFlag.AlignCenter)
|
|
437
|
+
lay = QVBoxLayout(self); lay.addWidget(self.label)
|
|
438
|
+
self.resize(300, 300)
|
|
439
|
+
self.base_pixmap = _to_qpixmap01(original_image01)
|
|
440
|
+
self.max_alpha = 150
|
|
441
|
+
|
|
442
|
+
def update_mask(self, mask01: np.ndarray):
|
|
443
|
+
h, w = mask01.shape
|
|
444
|
+
alpha = (np.clip(mask01, 0, 1) * self.max_alpha).astype(np.uint8)
|
|
445
|
+
rgba = np.zeros((h, w, 4), dtype=np.uint8)
|
|
446
|
+
rgba[..., 0] = 255 # red
|
|
447
|
+
rgba[..., 3] = alpha
|
|
448
|
+
overlay_qimg = QImage(rgba.data, w, h, 4*w, QImage.Format.Format_RGBA8888)
|
|
449
|
+
overlay = QPixmap.fromImage(overlay_qimg)
|
|
450
|
+
canvas = QPixmap(self.base_pixmap)
|
|
451
|
+
p = QPainter(canvas); p.drawPixmap(0, 0, overlay); p.end()
|
|
452
|
+
self.label.setPixmap(canvas.scaled(self.label.size(),
|
|
453
|
+
Qt.AspectRatioMode.KeepAspectRatio,
|
|
454
|
+
Qt.TransformationMode.SmoothTransformation))
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
# ---------- Preview (push-as-doc) ----------
|
|
458
|
+
|
|
459
|
+
class MaskPreviewDialog(QDialog):
|
|
460
|
+
"""Scrollable preview + 'Push as New Document…'."""
|
|
461
|
+
def __init__(self, mask01: np.ndarray, parent=None):
|
|
462
|
+
super().__init__(parent)
|
|
463
|
+
self.setWindowTitle("Mask Preview")
|
|
464
|
+
self.mask = np.clip(mask01, 0, 1).astype(np.float32)
|
|
465
|
+
|
|
466
|
+
self.scroll = QScrollArea(self); self.scroll.setWidgetResizable(False)
|
|
467
|
+
self.scroll.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
468
|
+
self.label = QLabel(alignment=Qt.AlignmentFlag.AlignCenter)
|
|
469
|
+
self.label.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Ignored)
|
|
470
|
+
self.pixmap = self._to_pixmap(self.mask); self.label.setPixmap(self.pixmap)
|
|
471
|
+
self.scroll.setWidget(self.label)
|
|
472
|
+
|
|
473
|
+
btns = QHBoxLayout()
|
|
474
|
+
b_in = themed_toolbtn("zoom-in", "Zoom In")
|
|
475
|
+
b_out = themed_toolbtn("zoom-out", "Zoom Out")
|
|
476
|
+
b_fit = themed_toolbtn("zoom-fit-best", "Fit to Preview")
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
b_push = QPushButton("Push as New Document…")
|
|
480
|
+
b_in.clicked.connect(lambda: self._zoom(1.2))
|
|
481
|
+
b_out.clicked.connect(lambda: self._zoom(1/1.2))
|
|
482
|
+
b_fit.clicked.connect(self._fit)
|
|
483
|
+
b_push.clicked.connect(self.push_as_new_document)
|
|
484
|
+
for b in (b_in, b_out, b_fit, b_push):
|
|
485
|
+
btns.addWidget(b)
|
|
486
|
+
|
|
487
|
+
lay = QVBoxLayout(self); lay.addWidget(self.scroll); lay.addLayout(btns)
|
|
488
|
+
self.scale = 1.0; self.setMinimumSize(600, 400)
|
|
489
|
+
|
|
490
|
+
def _to_pixmap(self, mask01: np.ndarray) -> QPixmap:
|
|
491
|
+
m8 = (np.clip(mask01, 0, 1) * 255).astype(np.uint8)
|
|
492
|
+
h, w = m8.shape
|
|
493
|
+
qimg = QImage(m8.data, w, h, w, QImage.Format.Format_Grayscale8)
|
|
494
|
+
return QPixmap.fromImage(qimg)
|
|
495
|
+
|
|
496
|
+
def _zoom(self, factor: float):
|
|
497
|
+
self.scale *= factor
|
|
498
|
+
scaled = self.pixmap.scaled(self.pixmap.size() * self.scale,
|
|
499
|
+
Qt.AspectRatioMode.KeepAspectRatio,
|
|
500
|
+
Qt.TransformationMode.SmoothTransformation)
|
|
501
|
+
self.label.setPixmap(scaled); self.label.resize(scaled.size())
|
|
502
|
+
|
|
503
|
+
def _fit(self):
|
|
504
|
+
vp = self.scroll.viewport().size()
|
|
505
|
+
if self.pixmap.width() and self.pixmap.height():
|
|
506
|
+
s = min(vp.width()/self.pixmap.width(), vp.height()/self.pixmap.height())
|
|
507
|
+
self.scale = max(0.05, s)
|
|
508
|
+
self._zoom(1.0)
|
|
509
|
+
|
|
510
|
+
def push_as_new_document(self):
|
|
511
|
+
if self.mask is None:
|
|
512
|
+
QMessageBox.warning(self, "No Mask", "No mask to push.")
|
|
513
|
+
return
|
|
514
|
+
|
|
515
|
+
# Walk up to the main window to reach DocManager
|
|
516
|
+
host = self.parent()
|
|
517
|
+
while host is not None and not hasattr(host, "docman"):
|
|
518
|
+
host = host.parent()
|
|
519
|
+
if host is None or not hasattr(host, "docman"):
|
|
520
|
+
QMessageBox.warning(self, "No DocManager", "Could not find the document manager.")
|
|
521
|
+
return
|
|
522
|
+
|
|
523
|
+
# Ask for a friendly name
|
|
524
|
+
name, ok = QInputDialog.getText(self, "New Document Name", "Name:", text="Mask")
|
|
525
|
+
if not ok:
|
|
526
|
+
return
|
|
527
|
+
|
|
528
|
+
# Ensure float32; a mask is mono by definition
|
|
529
|
+
img = self.mask.astype(np.float32, copy=False)
|
|
530
|
+
meta = {
|
|
531
|
+
"bit_depth": "32-bit floating point",
|
|
532
|
+
"is_mono": True,
|
|
533
|
+
"original_format": "fits",
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
# Create the doc → this emits documentAdded, which the main window now handles via _spawn_subwindow_for
|
|
537
|
+
new_doc = host.docman.create_document(img, metadata=meta, name=(name or "Mask"))
|
|
538
|
+
|
|
539
|
+
# Focus it
|
|
540
|
+
try:
|
|
541
|
+
sw = host._find_subwindow_for_doc(new_doc)
|
|
542
|
+
if sw:
|
|
543
|
+
host.mdi.setActiveSubWindow(sw)
|
|
544
|
+
except Exception:
|
|
545
|
+
pass
|
|
546
|
+
|
|
547
|
+
self.accept()
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
# ---------- Mask dialog ----------
|
|
552
|
+
|
|
553
|
+
class MaskCreationDialog(QDialog):
|
|
554
|
+
"""Mask creation UI for SASpro documents (returns a np mask on OK)."""
|
|
555
|
+
def __init__(self, image01: np.ndarray, parent=None, auto_push_on_ok: bool = True):
|
|
556
|
+
super().__init__(parent)
|
|
557
|
+
self.setWindowTitle("Mask Creation")
|
|
558
|
+
self.image = np.asarray(image01, dtype=np.float32).copy()
|
|
559
|
+
self.mask: np.ndarray | None = None
|
|
560
|
+
self.live_preview = LivePreviewDialog(self.image, parent=self)
|
|
561
|
+
|
|
562
|
+
self.mask_type = "Binary"
|
|
563
|
+
self.blur_amount = 0
|
|
564
|
+
|
|
565
|
+
# <- this was missing
|
|
566
|
+
self.auto_push_on_ok = auto_push_on_ok
|
|
567
|
+
|
|
568
|
+
self._build_ui()
|
|
569
|
+
|
|
570
|
+
def _build_ui(self):
|
|
571
|
+
layout = QVBoxLayout(self)
|
|
572
|
+
|
|
573
|
+
# Mode toolbar
|
|
574
|
+
mode_bar = QHBoxLayout()
|
|
575
|
+
self.free_btn = QPushButton("Freehand"); self.free_btn.setCheckable(True)
|
|
576
|
+
self.ellipse_btn = QPushButton("Ellipse"); self.ellipse_btn.setCheckable(True)
|
|
577
|
+
self.select_btn = QPushButton("Select Entire Image"); self.select_btn.setCheckable(True)
|
|
578
|
+
group = QButtonGroup(self); group.setExclusive(True)
|
|
579
|
+
for b in (self.free_btn, self.ellipse_btn, self.select_btn):
|
|
580
|
+
b.setAutoExclusive(True); group.addButton(b)
|
|
581
|
+
b.setStyleSheet("""
|
|
582
|
+
QPushButton { padding:6px; border:1px solid #888; border-radius:4px; background:transparent; }
|
|
583
|
+
QPushButton:checked { background-color:#0078d4; color:white; border-color:#005a9e; }
|
|
584
|
+
""")
|
|
585
|
+
for btn, mode in ((self.free_btn,'polygon'), (self.ellipse_btn,'ellipse'), (self.select_btn,'select')):
|
|
586
|
+
btn.clicked.connect(lambda _=False, m=mode: self._set_mode(m))
|
|
587
|
+
mode_bar.addWidget(btn)
|
|
588
|
+
self.free_btn.setChecked(True)
|
|
589
|
+
layout.addLayout(mode_bar)
|
|
590
|
+
|
|
591
|
+
zoom_bar = QHBoxLayout()
|
|
592
|
+
z_out = themed_toolbtn("zoom-out", "Zoom Out")
|
|
593
|
+
z_in = themed_toolbtn("zoom-in", "Zoom In")
|
|
594
|
+
z_fit = themed_toolbtn("zoom-fit-best", "Fit to Preview")
|
|
595
|
+
z_out.clicked.connect(lambda: self._zoom_canvas(1/1.25))
|
|
596
|
+
z_in.clicked.connect(lambda: self._zoom_canvas(1.25))
|
|
597
|
+
z_fit.clicked.connect(self._fit_canvas)
|
|
598
|
+
zoom_bar.addWidget(z_out); zoom_bar.addWidget(z_in); zoom_bar.addWidget(z_fit)
|
|
599
|
+
layout.addLayout(zoom_bar)
|
|
600
|
+
|
|
601
|
+
# Canvas
|
|
602
|
+
self.canvas = MaskCanvas(self.image)
|
|
603
|
+
layout.addWidget(self.canvas, 1)
|
|
604
|
+
|
|
605
|
+
# Mask type & blur
|
|
606
|
+
controls = QHBoxLayout()
|
|
607
|
+
controls.addWidget(QLabel("Mask Type:"))
|
|
608
|
+
self.type_dd = QComboBox()
|
|
609
|
+
self.type_dd.addItems([
|
|
610
|
+
"Binary","Range Selection","Lightness","Chrominance","Star Mask",
|
|
611
|
+
"Color: Red","Color: Orange","Color: Yellow",
|
|
612
|
+
"Color: Green","Color: Cyan","Color: Blue","Color: Magenta"
|
|
613
|
+
])
|
|
614
|
+
self.type_dd.currentTextChanged.connect(lambda t: setattr(self, 'mask_type', t))
|
|
615
|
+
controls.addWidget(self.type_dd)
|
|
616
|
+
|
|
617
|
+
controls.addWidget(QLabel("Edge Blur (px):"))
|
|
618
|
+
self.blur_slider = QSlider(Qt.Orientation.Horizontal); self.blur_slider.setRange(0, 300)
|
|
619
|
+
self.blur_slider.valueChanged.connect(lambda v: setattr(self, 'blur_amount', int(v)))
|
|
620
|
+
controls.addWidget(self.blur_slider)
|
|
621
|
+
self.blur_lbl = QLabel("0")
|
|
622
|
+
self.blur_slider.valueChanged.connect(lambda v: self.blur_lbl.setText(str(v)))
|
|
623
|
+
controls.addWidget(self.blur_lbl)
|
|
624
|
+
layout.addLayout(controls)
|
|
625
|
+
|
|
626
|
+
# Range Selection
|
|
627
|
+
self.range_box = QGroupBox("Range Selection"); g = QGridLayout(self.range_box)
|
|
628
|
+
def add_slider(row: int, name: str, maxv: int):
|
|
629
|
+
g.addWidget(QLabel(name + ":"), row, 0)
|
|
630
|
+
s = QSlider(Qt.Orientation.Horizontal); s.setRange(0, maxv)
|
|
631
|
+
s.setValue(maxv if name == "Upper" else 0)
|
|
632
|
+
lbl = QLabel(f"{(s.value()/maxv):.2f}")
|
|
633
|
+
s.valueChanged.connect(lambda v, l=lbl, s=s: l.setText(f"{v/s.maximum():.2f}"))
|
|
634
|
+
s.valueChanged.connect(self._update_live_preview)
|
|
635
|
+
g.addWidget(s, row, 1); g.addWidget(lbl, row, 2)
|
|
636
|
+
return s, lbl
|
|
637
|
+
self.lower_sl, _ = add_slider(0, "Lower", 100)
|
|
638
|
+
self.upper_sl, _ = add_slider(1, "Upper", 100)
|
|
639
|
+
self.fuzz_sl, _ = add_slider(2, "Transition", 100)
|
|
640
|
+
g.addWidget(QLabel("Blur:"), 3, 0)
|
|
641
|
+
self.smooth_sl = QSlider(Qt.Orientation.Horizontal)
|
|
642
|
+
self.smooth_sl.setRange(1, 200) # σ in pixels
|
|
643
|
+
self.smooth_sl.setValue(3) # a sensible default
|
|
644
|
+
g.addWidget(self.smooth_sl, 3, 1)
|
|
645
|
+
|
|
646
|
+
self.smooth_lbl = QLabel("σ = 3 px")
|
|
647
|
+
g.addWidget(self.smooth_lbl, 3, 2)
|
|
648
|
+
|
|
649
|
+
# live label + live preview
|
|
650
|
+
def _upd_smooth(v):
|
|
651
|
+
self.smooth_lbl.setText(f"σ = {int(v)} px")
|
|
652
|
+
self._update_live_preview()
|
|
653
|
+
self.smooth_sl.valueChanged.connect(_upd_smooth)
|
|
654
|
+
self.link_cb = QCheckBox("Link limits"); g.addWidget(self.link_cb, 0, 3, 2, 1)
|
|
655
|
+
self.screen_cb = QCheckBox("Screening"); g.addWidget(self.screen_cb, 4, 0, 1, 4)
|
|
656
|
+
self.light_cb = QCheckBox("Lightness"); g.addWidget(self.light_cb, 5, 0, 1, 4)
|
|
657
|
+
self.invert_cb = QCheckBox("Invert"); g.addWidget(self.invert_cb, 6, 0, 1, 4)
|
|
658
|
+
self.lower_sl.valueChanged.connect(self._on_linked)
|
|
659
|
+
self.link_cb.toggled.connect(self._on_link_switch)
|
|
660
|
+
layout.addWidget(self.range_box); self.range_box.hide()
|
|
661
|
+
self.type_dd.currentTextChanged.connect(self._on_type_changed)
|
|
662
|
+
|
|
663
|
+
# Preview & Clear
|
|
664
|
+
rowb = QHBoxLayout()
|
|
665
|
+
b_preview = QPushButton("Preview Mask"); b_preview.clicked.connect(self._preview_mask)
|
|
666
|
+
b_clear = QPushButton("Clear Shapes"); b_clear.clicked.connect(self._clear_shapes)
|
|
667
|
+
rowb.addWidget(b_preview); rowb.addWidget(b_clear)
|
|
668
|
+
layout.addLayout(rowb)
|
|
669
|
+
|
|
670
|
+
# OK / Cancel
|
|
671
|
+
btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, parent=self)
|
|
672
|
+
btns.accepted.connect(self._accept_apply); btns.rejected.connect(self.reject)
|
|
673
|
+
layout.addWidget(btns)
|
|
674
|
+
|
|
675
|
+
self.canvas.installEventFilter(self)
|
|
676
|
+
|
|
677
|
+
self.resize(980, 640)
|
|
678
|
+
|
|
679
|
+
# ---- callbacks
|
|
680
|
+
def _set_mode(self, mode: str):
|
|
681
|
+
self.canvas.set_mode(mode)
|
|
682
|
+
if mode == 'select':
|
|
683
|
+
self.canvas.select_entire_image()
|
|
684
|
+
|
|
685
|
+
def _clear_shapes(self):
|
|
686
|
+
self.canvas.clear_shapes()
|
|
687
|
+
|
|
688
|
+
def _on_type_changed(self, txt: str):
|
|
689
|
+
show = (txt == "Range Selection")
|
|
690
|
+
self.range_box.setVisible(show)
|
|
691
|
+
if show:
|
|
692
|
+
if not self.live_preview.isVisible():
|
|
693
|
+
self.live_preview.show()
|
|
694
|
+
self._update_live_preview()
|
|
695
|
+
else:
|
|
696
|
+
if self.live_preview.isVisible():
|
|
697
|
+
self.live_preview.close()
|
|
698
|
+
|
|
699
|
+
def _on_link_switch(self, checked: bool):
|
|
700
|
+
if checked:
|
|
701
|
+
self.upper_sl.setValue(self.lower_sl.value())
|
|
702
|
+
|
|
703
|
+
def _on_linked(self, v: int):
|
|
704
|
+
if self.link_cb.isChecked():
|
|
705
|
+
self.upper_sl.setValue(v)
|
|
706
|
+
|
|
707
|
+
# ---- generators
|
|
708
|
+
def _component_lightness(self) -> np.ndarray:
|
|
709
|
+
if self.image.ndim == 3:
|
|
710
|
+
return (self.image[..., 0]*0.2989 + self.image[..., 1]*0.5870 + self.image[..., 2]*0.1140).astype(np.float32)
|
|
711
|
+
return self.image.astype(np.float32)
|
|
712
|
+
|
|
713
|
+
def _range_selection_mask(self, comp01: np.ndarray, L, U, fuzz, smooth, screening, invert):
|
|
714
|
+
m = np.zeros_like(comp01, dtype=np.float32)
|
|
715
|
+
inside = (comp01 >= L) & (comp01 <= U); m[inside] = 1.0
|
|
716
|
+
if fuzz > 0:
|
|
717
|
+
ramp = (comp01 - (L - fuzz)) / max(fuzz, 1e-12); m += np.clip(ramp, 0, 1)
|
|
718
|
+
ramp2 = ((U + fuzz) - comp01) / max(fuzz, 1e-12); m *= np.clip(ramp2, 0, 1)
|
|
719
|
+
if screening: m *= comp01
|
|
720
|
+
if smooth > 0 and cv2 is not None: m = cv2.GaussianBlur(m, (0, 0), float(smooth))
|
|
721
|
+
if invert: m = 1.0 - m
|
|
722
|
+
return np.clip(m, 0, 1)
|
|
723
|
+
|
|
724
|
+
def _generate_color_mask(self, color: str) -> np.ndarray:
|
|
725
|
+
if cv2 is None:
|
|
726
|
+
QMessageBox.warning(self, "Missing OpenCV", "Color masks require OpenCV (cv2).")
|
|
727
|
+
return np.zeros(self.image.shape[:2], dtype=np.float32)
|
|
728
|
+
ranges = {
|
|
729
|
+
"Red": [(0, 10), (350, 360)], "Orange": [(10, 40)], "Yellow": [(40, 70)],
|
|
730
|
+
"Green": [(70, 170)], "Cyan": [(170, 200)], "Blue": [(200, 270)], "Magenta": [(270, 350)],
|
|
731
|
+
}
|
|
732
|
+
if color not in ranges or self.image.ndim != 3:
|
|
733
|
+
return np.zeros(self.image.shape[:2], dtype=np.float32)
|
|
734
|
+
rgb8 = (np.clip(self.image, 0, 1) * 255).astype(np.uint8)
|
|
735
|
+
hls = cv2.cvtColor(rgb8, cv2.COLOR_RGB2HLS)
|
|
736
|
+
hue = (hls[..., 0].astype(np.float32) / 180.0) * 360.0
|
|
737
|
+
mask = np.zeros(hue.shape, dtype=np.float32)
|
|
738
|
+
for lo, hi in ranges[color]:
|
|
739
|
+
if lo < hi:
|
|
740
|
+
mask = np.maximum(mask, ((hue >= lo) & (hue <= hi)).astype(np.float32))
|
|
741
|
+
else:
|
|
742
|
+
mask = np.maximum(mask, ((hue >= lo) | (hue <= hi)).astype(np.float32))
|
|
743
|
+
return mask
|
|
744
|
+
|
|
745
|
+
def _generate_chrominance(self) -> np.ndarray:
|
|
746
|
+
if cv2 is None or self.image.ndim != 3:
|
|
747
|
+
QMessageBox.warning(self, "Needs RGB + OpenCV", "Chrominance mask requires an RGB image and OpenCV.")
|
|
748
|
+
return np.zeros(self.image.shape[:2], dtype=np.float32)
|
|
749
|
+
rgb8 = (np.clip(self.image, 0, 1) * 255).astype(np.uint8)
|
|
750
|
+
ycrcb = cv2.cvtColor(rgb8, cv2.COLOR_RGB2YCrCb)
|
|
751
|
+
cb = ycrcb[..., 1].astype(np.float32) / 255.0
|
|
752
|
+
cr = ycrcb[..., 2].astype(np.float32) / 255.0
|
|
753
|
+
out = np.sqrt((cb - cb.mean())**2 + (cr - cr.mean())**2)
|
|
754
|
+
return (out - out.min()) / (out.max() - out.min() + 1e-12)
|
|
755
|
+
|
|
756
|
+
def _generate_star_mask(self) -> np.ndarray:
|
|
757
|
+
if sep is None:
|
|
758
|
+
QMessageBox.warning(self, "Missing SEP", "Star mask requires the 'sep' package.")
|
|
759
|
+
return np.zeros(self.image.shape[:2], dtype=np.float32)
|
|
760
|
+
data = self._component_lightness().astype(np.float32)
|
|
761
|
+
bkg = sep.Background(data); data_sub = data - bkg.back()
|
|
762
|
+
thresh = float(self.blur_amount) if self.blur_amount > 0 else 3.0
|
|
763
|
+
objs = sep.extract(data_sub, thresh=thresh, err=bkg.globalrms)
|
|
764
|
+
h, w = data.shape; out = np.zeros((h, w), dtype=np.float32)
|
|
765
|
+
if cv2 is None: return out
|
|
766
|
+
MAX_RADIUS = 10
|
|
767
|
+
for o in objs:
|
|
768
|
+
x, y = int(o['x']), int(o['y'])
|
|
769
|
+
r = int(max(o['a'], o['b']) * 1.5)
|
|
770
|
+
if r <= MAX_RADIUS:
|
|
771
|
+
cv2.circle(out, (x, y), max(1, r), 1.0, -1)
|
|
772
|
+
return np.clip(out, 0, 1)
|
|
773
|
+
|
|
774
|
+
def generate_mask(self) -> np.ndarray | None:
|
|
775
|
+
try:
|
|
776
|
+
base = self.canvas.create_mask()
|
|
777
|
+
except RuntimeError as e:
|
|
778
|
+
QMessageBox.warning(self, "Mask creation failed", str(e)); return None
|
|
779
|
+
|
|
780
|
+
t = self.mask_type
|
|
781
|
+
if t == "Binary":
|
|
782
|
+
m = base
|
|
783
|
+
elif t == "Range Selection":
|
|
784
|
+
comp = self._component_lightness() if self.light_cb.isChecked() else self._component_lightness()
|
|
785
|
+
L = self.lower_sl.value() / self.lower_sl.maximum()
|
|
786
|
+
U = self.upper_sl.value() / self.upper_sl.maximum()
|
|
787
|
+
fuzz = self.fuzz_sl.value() / self.fuzz_sl.maximum()
|
|
788
|
+
smooth = float(self.smooth_sl.value())
|
|
789
|
+
rs = self._range_selection_mask(comp, L, U, fuzz, smooth,
|
|
790
|
+
self.screen_cb.isChecked(), self.invert_cb.isChecked())
|
|
791
|
+
m = base * rs
|
|
792
|
+
elif t == "Lightness":
|
|
793
|
+
m = np.where(base > 0, self._component_lightness(), 0.0)
|
|
794
|
+
elif t == "Chrominance":
|
|
795
|
+
m = np.where(base > 0, self._generate_chrominance(), 0.0)
|
|
796
|
+
elif t == "Star Mask":
|
|
797
|
+
m = np.where(base > 0, self._generate_star_mask(), 0.0)
|
|
798
|
+
elif t.startswith("Color:"):
|
|
799
|
+
color = t.split(":", 1)[1].strip()
|
|
800
|
+
m = np.where(base > 0, self._generate_color_mask(color), 0.0)
|
|
801
|
+
else:
|
|
802
|
+
m = base
|
|
803
|
+
|
|
804
|
+
if self.blur_amount > 0 and cv2 is not None:
|
|
805
|
+
k = max(1, int(self.blur_amount) * 2 + 1)
|
|
806
|
+
m = cv2.GaussianBlur(m, (k, k), 0.0)
|
|
807
|
+
return np.clip(m, 0.0, 1.0)
|
|
808
|
+
|
|
809
|
+
def _update_live_preview(self, *_):
|
|
810
|
+
m = self.generate_mask()
|
|
811
|
+
if m is None: return
|
|
812
|
+
if not self.live_preview.isVisible(): self.live_preview.show()
|
|
813
|
+
self.live_preview.update_mask(m)
|
|
814
|
+
|
|
815
|
+
def _preview_mask(self):
|
|
816
|
+
m = self.generate_mask()
|
|
817
|
+
if m is None: return
|
|
818
|
+
MaskPreviewDialog(m, self).exec()
|
|
819
|
+
|
|
820
|
+
def _accept_apply(self):
|
|
821
|
+
m = self.generate_mask()
|
|
822
|
+
if m is None:
|
|
823
|
+
return
|
|
824
|
+
|
|
825
|
+
# always store it on the dialog for callers
|
|
826
|
+
self.mask = m
|
|
827
|
+
|
|
828
|
+
# if this dialog was opened in "tool" mode, push it as a new doc
|
|
829
|
+
if self.auto_push_on_ok:
|
|
830
|
+
_push_numpy_as_new_document(self, m, default_name="Mask")
|
|
831
|
+
|
|
832
|
+
self.accept()
|
|
833
|
+
|
|
834
|
+
def closeEvent(self, ev):
|
|
835
|
+
if self.live_preview and self.live_preview.isVisible():
|
|
836
|
+
self.live_preview.close()
|
|
837
|
+
super().closeEvent(ev)
|
|
838
|
+
|
|
839
|
+
# --- NEW: generic zoom helpers -----------------------------------------
|
|
840
|
+
def _zoom_canvas(self, factor: float):
|
|
841
|
+
"""
|
|
842
|
+
Try several zoom APIs so we work with different MaskCanvas versions.
|
|
843
|
+
"""
|
|
844
|
+
c = self.canvas
|
|
845
|
+
try:
|
|
846
|
+
if hasattr(c, "zoom_in") and factor > 1.0:
|
|
847
|
+
c.zoom_in()
|
|
848
|
+
return
|
|
849
|
+
if hasattr(c, "zoom_out") and factor < 1.0:
|
|
850
|
+
c.zoom_out()
|
|
851
|
+
return
|
|
852
|
+
if hasattr(c, "set_zoom"):
|
|
853
|
+
z = getattr(c, "_zoom", 1.0)
|
|
854
|
+
c.set_zoom(max(0.05, min(z * float(factor), 8.0)))
|
|
855
|
+
return
|
|
856
|
+
# If it's a QGraphicsView or similar, scale its view transform
|
|
857
|
+
if isinstance(c, QGraphicsView):
|
|
858
|
+
c.scale(float(factor), float(factor))
|
|
859
|
+
return
|
|
860
|
+
except Exception:
|
|
861
|
+
pass # fall through to friendly message
|
|
862
|
+
QMessageBox.information(self, "Zoom", "Zoom is not supported by this canvas build.")
|
|
863
|
+
|
|
864
|
+
def _fit_canvas(self):
|
|
865
|
+
c = self.canvas
|
|
866
|
+
try:
|
|
867
|
+
if hasattr(c, "fit_to_view"):
|
|
868
|
+
c.fit_to_view()
|
|
869
|
+
return
|
|
870
|
+
if isinstance(c, QGraphicsView):
|
|
871
|
+
# Fit the full scene rect (keep aspect)
|
|
872
|
+
r = c.sceneRect() if hasattr(c, "sceneRect") else None
|
|
873
|
+
if r and r.isValid():
|
|
874
|
+
c.fitInView(r, Qt.AspectRatioMode.KeepAspectRatio)
|
|
875
|
+
return
|
|
876
|
+
except Exception:
|
|
877
|
+
pass
|
|
878
|
+
QMessageBox.information(self, "Fit", "Fit-to-preview is not supported by this canvas build.")
|
|
879
|
+
|
|
880
|
+
# --- NEW: Ctrl+Wheel zoom passthrough -----------------------------------
|
|
881
|
+
def eventFilter(self, obj, ev):
|
|
882
|
+
# Let the canvas keep its own interactions;
|
|
883
|
+
# only intercept Ctrl+Wheel to trigger our zoom.
|
|
884
|
+
if obj is self.canvas and ev.type() == QEvent.Type.Wheel:
|
|
885
|
+
if isinstance(ev, QWheelEvent) and (ev.modifiers() & Qt.KeyboardModifier.ControlModifier):
|
|
886
|
+
self._zoom_canvas(1.25 if ev.angleDelta().y() > 0 else 1/1.25)
|
|
887
|
+
return True
|
|
888
|
+
return super().eventFilter(obj, ev)
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
# ---------- Integration helper ----------
|
|
892
|
+
|
|
893
|
+
def create_mask_and_attach(parent, document) -> bool:
|
|
894
|
+
if document is None or getattr(document, "image", None) is None:
|
|
895
|
+
QMessageBox.information(parent, "No image", "Open an image first.")
|
|
896
|
+
return False
|
|
897
|
+
|
|
898
|
+
# NOW we let the dialog auto-push when user hits OK
|
|
899
|
+
dlg = MaskCreationDialog(document.image, parent=parent, auto_push_on_ok=True)
|
|
900
|
+
if dlg.exec() != QDialog.DialogCode.Accepted:
|
|
901
|
+
return False
|
|
902
|
+
|
|
903
|
+
mask = getattr(dlg, "mask", None)
|
|
904
|
+
if mask is None:
|
|
905
|
+
QMessageBox.information(parent, "No mask", "No mask was generated.")
|
|
906
|
+
return False
|
|
907
|
+
|
|
908
|
+
# since we already pushed a mask doc, just attach it quietly
|
|
909
|
+
layer = MaskLayer(
|
|
910
|
+
id=uuid.uuid4().hex,
|
|
911
|
+
name="Mask", # keep it simple; matches preview default
|
|
912
|
+
data=np.clip(mask.astype(np.float32, copy=False), 0.0, 1.0),
|
|
913
|
+
invert=False,
|
|
914
|
+
opacity=1.0,
|
|
915
|
+
mode="affect",
|
|
916
|
+
visible=True,
|
|
917
|
+
)
|
|
918
|
+
document.add_mask(layer, make_active=True)
|
|
919
|
+
|
|
920
|
+
try:
|
|
921
|
+
if hasattr(parent, "_log"):
|
|
922
|
+
parent._log(f"Added mask '{layer.name}' and set active (and pushed as document).")
|
|
923
|
+
except Exception:
|
|
924
|
+
pass
|
|
925
|
+
|
|
926
|
+
return True
|
|
927
|
+
|
|
928
|
+
|