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,414 @@
|
|
|
1
|
+
# pro/image_combine.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
from PyQt6.QtCore import Qt, QPoint, QRect, QEvent
|
|
6
|
+
from PyQt6.QtGui import QImage, QPixmap
|
|
7
|
+
from PyQt6.QtWidgets import (
|
|
8
|
+
QDialog, QVBoxLayout, QHBoxLayout, QFormLayout, QLabel, QComboBox, QSlider,
|
|
9
|
+
QCheckBox, QScrollArea, QPushButton, QDialogButtonBox, QApplication, QMessageBox
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
# NEW: optional cv2 for fast gray/resize
|
|
13
|
+
try:
|
|
14
|
+
import cv2
|
|
15
|
+
except Exception:
|
|
16
|
+
cv2 = None
|
|
17
|
+
|
|
18
|
+
# Shared utilities
|
|
19
|
+
from setiastro.saspro.widgets.image_utils import (
|
|
20
|
+
to_float01 as _to_float01,
|
|
21
|
+
extract_mask_from_document as _active_mask_array_from_doc
|
|
22
|
+
)
|
|
23
|
+
from setiastro.saspro.widgets.themed_buttons import themed_toolbtn
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
_LUMA_WEIGHTS = np.array([0.2126, 0.7152, 0.0722], dtype=np.float32)
|
|
27
|
+
|
|
28
|
+
# ---------- helpers ----------
|
|
29
|
+
def _doc_name(d) -> str:
|
|
30
|
+
try: return d.display_name()
|
|
31
|
+
except Exception: return "Untitled"
|
|
32
|
+
|
|
33
|
+
def _rgb_to_luma(img: np.ndarray) -> np.ndarray:
|
|
34
|
+
f = _to_float01(img)
|
|
35
|
+
if f.ndim == 2: return f
|
|
36
|
+
if f.ndim == 3 and f.shape[2] == 1: return f[..., 0]
|
|
37
|
+
if f.ndim == 3 and f.shape[2] == 3:
|
|
38
|
+
w = _LUMA_WEIGHTS
|
|
39
|
+
return f[..., 0]*w[0] + f[..., 1]*w[1] + f[..., 2]*w[2]
|
|
40
|
+
raise ValueError(f"Unsupported image shape: {img.shape}")
|
|
41
|
+
|
|
42
|
+
def _recombine_luma_into_rgb(Y: np.ndarray, RGB: np.ndarray) -> np.ndarray:
|
|
43
|
+
rgb = _to_float01(RGB)
|
|
44
|
+
if rgb.ndim != 3 or rgb.shape[2] != 3:
|
|
45
|
+
raise ValueError("Recombine requires RGB target.")
|
|
46
|
+
w = _LUMA_WEIGHTS
|
|
47
|
+
orig_Y = rgb[..., 0]*w[0] + rgb[..., 1]*w[1] + rgb[..., 2]*w[2]
|
|
48
|
+
chroma = rgb / (orig_Y[..., None] + 1e-6)
|
|
49
|
+
return np.clip(chroma * Y[..., None], 0.0, 1.0)
|
|
50
|
+
|
|
51
|
+
def _blend_dispatch(A: np.ndarray, B: np.ndarray, mode: str, alpha: float) -> np.ndarray:
|
|
52
|
+
A = _to_float01(A); B = _to_float01(B)
|
|
53
|
+
if A.ndim == 2: A = A[..., None]
|
|
54
|
+
if B.ndim == 2: B = B[..., None]
|
|
55
|
+
if A.shape != B.shape:
|
|
56
|
+
raise ValueError("Images must have same size/channels.")
|
|
57
|
+
|
|
58
|
+
if mode == "Average": return np.clip(0.5*(A+B), 0.0, 1.0)
|
|
59
|
+
if mode == "Blend": return np.clip(A*(1-alpha) + B*alpha, 0.0, 1.0)
|
|
60
|
+
def mix(x): return np.clip(A*(1-alpha) + x*alpha, 0.0, 1.0)
|
|
61
|
+
|
|
62
|
+
eps = 1e-6
|
|
63
|
+
if mode == "Add": return mix(np.clip(A+B, 0.0, 1.0))
|
|
64
|
+
if mode == "Subtract": return mix(np.clip(A-B, 0.0, 1.0))
|
|
65
|
+
if mode == "Multiply": return mix(A*B)
|
|
66
|
+
if mode == "Divide": return mix(np.clip(A/(B+eps), 0.0, 1.0))
|
|
67
|
+
if mode == "Screen": return mix(1.0 - (1.0-A)*(1.0-B))
|
|
68
|
+
if mode == "Overlay": return mix(np.clip(np.where(A<=0.5, 2*A*B, 1-2*(1-A)*(1-B)), 0.0, 1.0))
|
|
69
|
+
if mode == "Difference": return mix(np.abs(A-B))
|
|
70
|
+
return np.clip(A*(1-alpha) + B*alpha, 0.0, 1.0)
|
|
71
|
+
|
|
72
|
+
# ---------- mask helpers ----------
|
|
73
|
+
def _resize_mask_nearest(m: np.ndarray, shape_hw: tuple[int,int]) -> np.ndarray:
|
|
74
|
+
"""Resize mask to (H,W) with nearest neighbor."""
|
|
75
|
+
h, w = shape_hw
|
|
76
|
+
if m.shape == (h, w):
|
|
77
|
+
return m
|
|
78
|
+
if cv2 is not None:
|
|
79
|
+
return cv2.resize(m, (w, h), interpolation=cv2.INTER_NEAREST).astype(np.float32, copy=False)
|
|
80
|
+
# fallback NN without cv2
|
|
81
|
+
yi = (np.linspace(0, m.shape[0]-1, h)).astype(np.int32)
|
|
82
|
+
xi = (np.linspace(0, m.shape[1]-1, w)).astype(np.int32)
|
|
83
|
+
return m[yi][:, xi].astype(np.float32, copy=False)
|
|
84
|
+
|
|
85
|
+
# ---------- dialog ----------
|
|
86
|
+
class ImageCombineDialog(QDialog):
|
|
87
|
+
"""
|
|
88
|
+
Views-based Image Combine with realtime preview, zoom/pan, luma-only, and mask overlay.
|
|
89
|
+
Output: replace A or create new view.
|
|
90
|
+
"""
|
|
91
|
+
def __init__(self, main_window):
|
|
92
|
+
super().__init__(main_window)
|
|
93
|
+
self.setWindowTitle("Image Combine")
|
|
94
|
+
self.mw = main_window
|
|
95
|
+
self.dm = getattr(main_window, "doc_manager", None) or getattr(main_window, "dm", None)
|
|
96
|
+
self.zoom = 1.0
|
|
97
|
+
self._pan_origin = None
|
|
98
|
+
self._hstart = 0; self._vstart = 0
|
|
99
|
+
self._pix = None # last preview QPixmap
|
|
100
|
+
|
|
101
|
+
# --- UI ---
|
|
102
|
+
root = QVBoxLayout(self)
|
|
103
|
+
|
|
104
|
+
frm = QFormLayout()
|
|
105
|
+
self.cbA = QComboBox(); self.cbB = QComboBox()
|
|
106
|
+
frm.addRow("Source A:", self.cbA)
|
|
107
|
+
frm.addRow("Source B:", self.cbB)
|
|
108
|
+
|
|
109
|
+
row = QHBoxLayout()
|
|
110
|
+
row.addWidget(QLabel("Mode:"))
|
|
111
|
+
self.cbMode = QComboBox()
|
|
112
|
+
self.cbMode.addItems(["Average","Add","Subtract","Blend","Multiply","Divide","Screen","Overlay","Difference"])
|
|
113
|
+
row.addWidget(self.cbMode, 1)
|
|
114
|
+
row.addWidget(QLabel("Opacity:"))
|
|
115
|
+
self.slAlpha = QSlider(Qt.Orientation.Horizontal); self.slAlpha.setRange(0,100); self.slAlpha.setValue(100)
|
|
116
|
+
row.addWidget(self.slAlpha, 2)
|
|
117
|
+
frm.addRow(row)
|
|
118
|
+
|
|
119
|
+
# luma-only
|
|
120
|
+
self.chkLuma = QCheckBox("Combine luminance only (keep A’s color)")
|
|
121
|
+
frm.addRow(self.chkLuma)
|
|
122
|
+
|
|
123
|
+
# mask overlay
|
|
124
|
+
mrow = QHBoxLayout()
|
|
125
|
+
self.chkOverlay = QCheckBox("Show mask overlay")
|
|
126
|
+
self.chkInvert = QCheckBox("Invert mask")
|
|
127
|
+
mrow.addWidget(self.chkOverlay)
|
|
128
|
+
mrow.addWidget(self.chkInvert)
|
|
129
|
+
mrow.addWidget(QLabel("Overlay opacity:"))
|
|
130
|
+
self.slOverlay = QSlider(Qt.Orientation.Horizontal); self.slOverlay.setRange(5,95); self.slOverlay.setValue(40)
|
|
131
|
+
mrow.addWidget(self.slOverlay, 1)
|
|
132
|
+
frm.addRow(mrow)
|
|
133
|
+
root.addLayout(frm)
|
|
134
|
+
|
|
135
|
+
# preview
|
|
136
|
+
self.scroll = QScrollArea(self); self.scroll.setWidgetResizable(True)
|
|
137
|
+
self.lbl = QLabel(""); self.lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
138
|
+
self.scroll.setWidget(self.lbl)
|
|
139
|
+
root.addWidget(self.scroll, 1)
|
|
140
|
+
|
|
141
|
+
# zoom (themed)
|
|
142
|
+
zrow = QHBoxLayout()
|
|
143
|
+
|
|
144
|
+
btnOut = themed_toolbtn("zoom-out", "Zoom Out")
|
|
145
|
+
btnFit = themed_toolbtn("zoom-fit-best", "Fit to Preview")
|
|
146
|
+
btnIn = themed_toolbtn("zoom-in", "Zoom In")
|
|
147
|
+
|
|
148
|
+
btnOut.clicked.connect(self._zoom_out)
|
|
149
|
+
btnIn .clicked.connect(self._zoom_in)
|
|
150
|
+
btnFit.clicked.connect(self._fit)
|
|
151
|
+
|
|
152
|
+
zrow.addWidget(btnOut)
|
|
153
|
+
zrow.addWidget(btnFit)
|
|
154
|
+
zrow.addWidget(btnIn)
|
|
155
|
+
root.addLayout(zrow)
|
|
156
|
+
|
|
157
|
+
# buttons
|
|
158
|
+
btns = QDialogButtonBox()
|
|
159
|
+
self.btnApply = btns.addButton("Apply", QDialogButtonBox.ButtonRole.AcceptRole)
|
|
160
|
+
self.btnClose = btns.addButton("Close", QDialogButtonBox.ButtonRole.RejectRole)
|
|
161
|
+
self.btnClose.clicked.connect(self.reject)
|
|
162
|
+
self.btnApply.clicked.connect(self._commit)
|
|
163
|
+
root.addWidget(btns)
|
|
164
|
+
|
|
165
|
+
# hooks
|
|
166
|
+
for w in (self.cbA, self.cbB, self.cbMode):
|
|
167
|
+
w.currentIndexChanged.connect(self._update_preview)
|
|
168
|
+
self.slAlpha.valueChanged.connect(self._update_preview)
|
|
169
|
+
self.chkLuma.toggled.connect(self._update_preview)
|
|
170
|
+
self.chkOverlay.toggled.connect(self._update_preview)
|
|
171
|
+
self.chkInvert.toggled.connect(self._update_preview)
|
|
172
|
+
self.slOverlay.valueChanged.connect(self._update_preview)
|
|
173
|
+
self.scroll.viewport().installEventFilter(self)
|
|
174
|
+
|
|
175
|
+
self._populate_docs()
|
|
176
|
+
self._update_preview()
|
|
177
|
+
|
|
178
|
+
# ---------- doc utilities ----------
|
|
179
|
+
def _open_docs(self) -> list:
|
|
180
|
+
if not self.dm: return []
|
|
181
|
+
docs = list(getattr(self.dm, "_docs", []) or [])
|
|
182
|
+
return [d for d in docs if getattr(d, "image", None) is not None]
|
|
183
|
+
|
|
184
|
+
def _active_doc(self):
|
|
185
|
+
if self.dm and hasattr(self.dm, "get_active_document"):
|
|
186
|
+
return self.dm.get_active_document()
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
def _populate_docs(self):
|
|
190
|
+
docs = self._open_docs()
|
|
191
|
+
self.cbA.blockSignals(True); self.cbB.blockSignals(True)
|
|
192
|
+
self.cbA.clear(); self.cbB.clear()
|
|
193
|
+
for d in docs:
|
|
194
|
+
self.cbA.addItem(_doc_name(d), userData=d)
|
|
195
|
+
self.cbB.addItem(_doc_name(d), userData=d)
|
|
196
|
+
self.cbA.blockSignals(False); self.cbB.blockSignals(False)
|
|
197
|
+
if docs:
|
|
198
|
+
act = self._active_doc()
|
|
199
|
+
if act in docs:
|
|
200
|
+
self.cbA.setCurrentIndex(docs.index(act))
|
|
201
|
+
# B defaults to “other”
|
|
202
|
+
j = 0 if len(docs) < 2 else (1 if docs[0] is act else 0)
|
|
203
|
+
self.cbB.setCurrentIndex(j)
|
|
204
|
+
|
|
205
|
+
# ---------- mask helpers ----------
|
|
206
|
+
def _mask01_for_doc(self, doc, *, shape_hw: tuple[int,int], channels: int | None, invert_flag: bool):
|
|
207
|
+
"""
|
|
208
|
+
Return mask for the given doc resized to (H,W).
|
|
209
|
+
If channels is 3 and mask is 2D, expand with np.repeat.
|
|
210
|
+
"""
|
|
211
|
+
m = _active_mask_array_from_doc(doc)
|
|
212
|
+
if m is None:
|
|
213
|
+
# last-resort fallback to global mask manager (in case user applied a global mask)
|
|
214
|
+
mm = getattr(getattr(self.mw, "image_manager", None), "mask_manager", None)
|
|
215
|
+
if mm and hasattr(mm, "get_applied_mask"):
|
|
216
|
+
try:
|
|
217
|
+
mg = mm.get_applied_mask()
|
|
218
|
+
if mg is not None:
|
|
219
|
+
mg = np.asarray(mg).astype(np.float32)
|
|
220
|
+
if mg.ndim == 3:
|
|
221
|
+
mg = mg.mean(axis=2)
|
|
222
|
+
if mg.max() > 1.0:
|
|
223
|
+
mg /= 255.0
|
|
224
|
+
m = np.clip(mg, 0.0, 1.0)
|
|
225
|
+
except Exception:
|
|
226
|
+
m = None
|
|
227
|
+
if m is None:
|
|
228
|
+
return None
|
|
229
|
+
|
|
230
|
+
m = _resize_mask_nearest(m, shape_hw)
|
|
231
|
+
if invert_flag:
|
|
232
|
+
m = 1.0 - m
|
|
233
|
+
m = np.clip(m, 0.0, 1.0)
|
|
234
|
+
if channels and channels > 1 and m.ndim == 2:
|
|
235
|
+
m = np.repeat(m[:, :, None], channels, axis=2)
|
|
236
|
+
return m
|
|
237
|
+
|
|
238
|
+
def _apply_overlay(self, img, mask, opacity):
|
|
239
|
+
# show protected region (A) as red wash: vis = 1 - m
|
|
240
|
+
vis = 1.0 - np.clip(mask, 0.0, 1.0)
|
|
241
|
+
if img.ndim == 2:
|
|
242
|
+
rgb = np.stack([img, img, img], axis=-1)
|
|
243
|
+
else:
|
|
244
|
+
rgb = img
|
|
245
|
+
overlay = np.zeros_like(rgb, dtype=np.float32); overlay[..., 0] = 1.0
|
|
246
|
+
if vis.ndim == 2: vis = vis[..., None]
|
|
247
|
+
return np.clip(rgb*(1.0 - vis*opacity) + overlay*(vis*opacity), 0.0, 1.0)
|
|
248
|
+
|
|
249
|
+
# ---------- preview ----------
|
|
250
|
+
def _update_preview(self, *_):
|
|
251
|
+
A = self.cbA.currentData(); B = self.cbB.currentData()
|
|
252
|
+
if not (A and B): return
|
|
253
|
+
imgA = getattr(A, "image", None); imgB = getattr(B, "image", None)
|
|
254
|
+
if imgA is None or imgB is None: return
|
|
255
|
+
if imgA.shape[:2] != imgB.shape[:2]:
|
|
256
|
+
self.lbl.setText("Images must be the same size.")
|
|
257
|
+
return
|
|
258
|
+
|
|
259
|
+
alpha = self.slAlpha.value()/100.0
|
|
260
|
+
mode = self.cbMode.currentText()
|
|
261
|
+
|
|
262
|
+
try:
|
|
263
|
+
if self.chkLuma.isChecked():
|
|
264
|
+
if imgA.ndim != 3 or imgA.shape[2] != 3:
|
|
265
|
+
self.lbl.setText("Luminance mode requires RGB A."); return
|
|
266
|
+
YA = _rgb_to_luma(imgA)
|
|
267
|
+
YB = _rgb_to_luma(imgB)
|
|
268
|
+
Ymix = _blend_dispatch(YA[...,None], YB[...,None], mode, alpha)[...,0]
|
|
269
|
+
|
|
270
|
+
# mask from destination doc (A)
|
|
271
|
+
m = self._mask01_for_doc(A, shape_hw=Ymix.shape[:2], channels=None,
|
|
272
|
+
invert_flag=self.chkInvert.isChecked())
|
|
273
|
+
if m is not None:
|
|
274
|
+
Ymix = Ymix*m + YA*(1.0 - m)
|
|
275
|
+
|
|
276
|
+
blended = _recombine_luma_into_rgb(Ymix, imgA)
|
|
277
|
+
|
|
278
|
+
else:
|
|
279
|
+
A3 = imgA if imgA.ndim == 3 else imgA[..., None]
|
|
280
|
+
B3 = imgB if imgB.ndim == 3 else imgB[..., None]
|
|
281
|
+
blended = _blend_dispatch(A3, B3, mode, alpha)
|
|
282
|
+
if imgA.ndim == 2:
|
|
283
|
+
blended = blended[...,0]
|
|
284
|
+
|
|
285
|
+
# mask from destination doc (A)
|
|
286
|
+
m = self._mask01_for_doc(
|
|
287
|
+
A, shape_hw=blended.shape[:2],
|
|
288
|
+
channels=(blended.shape[2] if blended.ndim == 3 else 1),
|
|
289
|
+
invert_flag=self.chkInvert.isChecked()
|
|
290
|
+
)
|
|
291
|
+
if m is not None:
|
|
292
|
+
blended = np.clip(blended*m + _to_float01(imgA)*(1.0 - m), 0.0, 1.0)
|
|
293
|
+
|
|
294
|
+
# optional red overlay
|
|
295
|
+
if self.chkOverlay.isChecked():
|
|
296
|
+
m = self._mask01_for_doc(
|
|
297
|
+
A, shape_hw=blended.shape[:2],
|
|
298
|
+
channels=(blended.shape[2] if blended.ndim == 3 else 1),
|
|
299
|
+
invert_flag=self.chkInvert.isChecked()
|
|
300
|
+
)
|
|
301
|
+
if m is not None:
|
|
302
|
+
blended = self._apply_overlay(_to_float01(blended), m, self.slOverlay.value()/100.0)
|
|
303
|
+
|
|
304
|
+
# to pixmap
|
|
305
|
+
f = _to_float01(blended); h, w = f.shape[:2]
|
|
306
|
+
if f.ndim == 2:
|
|
307
|
+
buf = (f*255).astype(np.uint8); q = QImage(buf.data, w, h, w, QImage.Format.Format_Grayscale8)
|
|
308
|
+
else:
|
|
309
|
+
buf = (f*255).astype(np.uint8); q = QImage(buf.data, w, h, 3*w, QImage.Format.Format_RGB888)
|
|
310
|
+
self._pix = QPixmap.fromImage(q)
|
|
311
|
+
self._apply_zoom()
|
|
312
|
+
except Exception as e:
|
|
313
|
+
self.lbl.setText(f"Error: {e}")
|
|
314
|
+
|
|
315
|
+
# ---------- apply ----------
|
|
316
|
+
def _commit(self):
|
|
317
|
+
A = self.cbA.currentData(); B = self.cbB.currentData()
|
|
318
|
+
if not (A and B): return
|
|
319
|
+
imgA = getattr(A, "image", None); imgB = getattr(B, "image", None)
|
|
320
|
+
if imgA is None or imgB is None: return
|
|
321
|
+
if imgA.shape[:2] != imgB.shape[:2]:
|
|
322
|
+
QMessageBox.warning(self, "Image Combine", "Image sizes must match."); return
|
|
323
|
+
|
|
324
|
+
alpha = self.slAlpha.value()/100.0
|
|
325
|
+
mode = self.cbMode.currentText()
|
|
326
|
+
|
|
327
|
+
try:
|
|
328
|
+
if self.chkLuma.isChecked():
|
|
329
|
+
YA = _rgb_to_luma(imgA); YB = _rgb_to_luma(imgB)
|
|
330
|
+
Ymix = _blend_dispatch(YA[...,None], YB[...,None], mode, alpha)[...,0]
|
|
331
|
+
|
|
332
|
+
m = self._mask01_for_doc(A, shape_hw=Ymix.shape[:2], channels=None,
|
|
333
|
+
invert_flag=self.chkInvert.isChecked())
|
|
334
|
+
if m is not None:
|
|
335
|
+
Ymix = Ymix*m + YA*(1.0 - m)
|
|
336
|
+
|
|
337
|
+
result = _recombine_luma_into_rgb(Ymix, imgA)
|
|
338
|
+
step = f"Luminance {mode}"
|
|
339
|
+
else:
|
|
340
|
+
A3 = imgA if imgA.ndim == 3 else imgA[..., None]
|
|
341
|
+
B3 = imgB if imgB.ndim == 3 else imgB[..., None]
|
|
342
|
+
result = _blend_dispatch(A3, B3, mode, alpha)
|
|
343
|
+
if imgA.ndim == 2: result = result[...,0]
|
|
344
|
+
|
|
345
|
+
m = self._mask01_for_doc(
|
|
346
|
+
A, shape_hw=result.shape[:2],
|
|
347
|
+
channels=(result.shape[2] if result.ndim == 3 else 1),
|
|
348
|
+
invert_flag=self.chkInvert.isChecked()
|
|
349
|
+
)
|
|
350
|
+
if m is not None:
|
|
351
|
+
result = np.clip(result*m + _to_float01(imgA)*(1.0 - m), 0.0, 1.0)
|
|
352
|
+
step = f"{mode} Combine"
|
|
353
|
+
|
|
354
|
+
result = _to_float01(result)
|
|
355
|
+
|
|
356
|
+
# Replace A (overwrite active view) or create new?
|
|
357
|
+
replace = True
|
|
358
|
+
if replace:
|
|
359
|
+
if hasattr(A, "set_image"):
|
|
360
|
+
A.set_image(result, step_name=f"Image Combine: {step}")
|
|
361
|
+
else:
|
|
362
|
+
A.image = result
|
|
363
|
+
try: self.mw._log(f"Image Combine → replaced '{_doc_name(A)}' ({step})")
|
|
364
|
+
except Exception as e:
|
|
365
|
+
import logging
|
|
366
|
+
logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
|
|
367
|
+
else:
|
|
368
|
+
newdoc = self.dm.create_document(result, metadata={
|
|
369
|
+
"display_name": f"Combined ({step})",
|
|
370
|
+
"bit_depth": "32-bit floating point",
|
|
371
|
+
"is_mono": (result.ndim == 2),
|
|
372
|
+
"source": f"Combine: {step}",
|
|
373
|
+
}, name=f"Combined ({step})")
|
|
374
|
+
self.mw._spawn_subwindow_for(newdoc)
|
|
375
|
+
try: self.mw._log(f"Image Combine → new view '{_doc_name(newdoc)}' ({step})")
|
|
376
|
+
except Exception as e:
|
|
377
|
+
import logging
|
|
378
|
+
logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
|
|
379
|
+
|
|
380
|
+
except Exception as e:
|
|
381
|
+
QMessageBox.critical(self, "Image Combine", f"Failed:\n{e}")
|
|
382
|
+
|
|
383
|
+
# ---------- zoom/pan ----------
|
|
384
|
+
def _apply_zoom(self):
|
|
385
|
+
if self._pix is None: return
|
|
386
|
+
scaled = self._pix.scaled(self._pix.size()*self.zoom, Qt.AspectRatioMode.KeepAspectRatio,
|
|
387
|
+
Qt.TransformationMode.SmoothTransformation)
|
|
388
|
+
self.lbl.setPixmap(scaled)
|
|
389
|
+
|
|
390
|
+
def _zoom_in(self): self.zoom *= 1.25; self._apply_zoom()
|
|
391
|
+
def _zoom_out(self): self.zoom /= 1.25; self._apply_zoom()
|
|
392
|
+
def _fit(self):
|
|
393
|
+
if self._pix is None: return
|
|
394
|
+
area = self.scroll.viewport().size(); pix = self._pix.size()
|
|
395
|
+
sx = area.width()/max(1,pix.width()); sy = area.height()/max(1,pix.height())
|
|
396
|
+
self.zoom = min(sx, sy, 1.0); self._apply_zoom()
|
|
397
|
+
|
|
398
|
+
def eventFilter(self, src, ev):
|
|
399
|
+
if src is self.scroll.viewport():
|
|
400
|
+
if ev.type() == QEvent.Type.MouseButtonPress and ev.button() == Qt.MouseButton.LeftButton:
|
|
401
|
+
self._pan_origin = ev.pos()
|
|
402
|
+
self._hstart = self.scroll.horizontalScrollBar().value()
|
|
403
|
+
self._vstart = self.scroll.verticalScrollBar().value()
|
|
404
|
+
return True
|
|
405
|
+
if ev.type() == QEvent.Type.MouseMove and self._pan_origin is not None:
|
|
406
|
+
d = ev.pos() - self._pan_origin
|
|
407
|
+
self.scroll.horizontalScrollBar().setValue(self._hstart - d.x())
|
|
408
|
+
self.scroll.verticalScrollBar().setValue(self._vstart - d.y())
|
|
409
|
+
return True
|
|
410
|
+
if ev.type() == QEvent.Type.MouseButtonRelease and ev.button() == Qt.MouseButton.LeftButton:
|
|
411
|
+
self._pan_origin = None; return True
|
|
412
|
+
return False
|
|
413
|
+
return super().eventFilter(src, ev)
|
|
414
|
+
|