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,1053 @@
|
|
|
1
|
+
# pro/perfect_palette_picker.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import os
|
|
4
|
+
import numpy as np
|
|
5
|
+
from PIL import Image
|
|
6
|
+
import cv2
|
|
7
|
+
from PyQt6.QtCore import Qt, QSize, QEvent, QTimer, QPoint, pyqtSignal
|
|
8
|
+
from PyQt6.QtWidgets import (
|
|
9
|
+
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QScrollArea,
|
|
10
|
+
QFileDialog, QInputDialog, QMessageBox, QGridLayout, QCheckBox, QSizePolicy, QDialog
|
|
11
|
+
)
|
|
12
|
+
from PyQt6.QtGui import QPixmap, QImage, QIcon, QPainter, QPen, QColor, QFont, QFontMetrics, QCursor
|
|
13
|
+
|
|
14
|
+
# legacy loader (same one DocManager uses)
|
|
15
|
+
from setiastro.saspro.legacy.image_manager import load_image as legacy_load_image
|
|
16
|
+
|
|
17
|
+
# your statistical stretch (mono + color) like SASv2
|
|
18
|
+
# (same signatures you use elsewhere)
|
|
19
|
+
from setiastro.saspro.imageops.stretch import stretch_mono_image, stretch_color_image
|
|
20
|
+
from setiastro.saspro.widgets.themed_buttons import themed_toolbtn
|
|
21
|
+
|
|
22
|
+
class PaletteAdjustDialog(QDialog):
|
|
23
|
+
adjusted_image = pyqtSignal(np.ndarray)
|
|
24
|
+
|
|
25
|
+
def __init__(self, base_rgb, palette_name, ha_src, oiii_src, sii_src, owner):
|
|
26
|
+
from PyQt6.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QScrollArea, QSlider
|
|
27
|
+
from PyQt6.QtCore import QTimer, Qt, QPoint, QEvent
|
|
28
|
+
super().__init__(owner)
|
|
29
|
+
self.setWindowTitle("Adjust Palette Intensities")
|
|
30
|
+
self.setWindowFlag(Qt.WindowType.Window, True)
|
|
31
|
+
self.setModal(False)
|
|
32
|
+
#self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
|
|
33
|
+
|
|
34
|
+
self.base_rgb = base_rgb.astype(np.float32)
|
|
35
|
+
self.palette_name = palette_name
|
|
36
|
+
self.ha_src = ha_src
|
|
37
|
+
self.oiii_src = oiii_src
|
|
38
|
+
self.sii_src = sii_src
|
|
39
|
+
self.owner = owner
|
|
40
|
+
|
|
41
|
+
self.ha_factor = 1.0
|
|
42
|
+
self.oiii_factor = 1.0
|
|
43
|
+
self.sii_factor = 1.0
|
|
44
|
+
|
|
45
|
+
self._debounce = QTimer(self); self._debounce.setInterval(300); self._debounce.setSingleShot(True)
|
|
46
|
+
self._debounce.timeout.connect(self._update_preview)
|
|
47
|
+
|
|
48
|
+
self.zoom_factor = 1.0
|
|
49
|
+
self._dragging = False
|
|
50
|
+
self._last_pos = QPoint()
|
|
51
|
+
|
|
52
|
+
vlayout = QVBoxLayout(self)
|
|
53
|
+
|
|
54
|
+
# Zoom controls
|
|
55
|
+
zoom_layout = QHBoxLayout()
|
|
56
|
+
btn_zoom_in = themed_toolbtn("zoom-in", "Zoom In")
|
|
57
|
+
btn_zoom_out = themed_toolbtn("zoom-out", "Zoom Out")
|
|
58
|
+
btn_fit = themed_toolbtn("zoom-fit-best", "Fit to Preview")
|
|
59
|
+
zoom_layout.addWidget(btn_zoom_in); zoom_layout.addWidget(btn_zoom_out); zoom_layout.addWidget(btn_fit)
|
|
60
|
+
vlayout.addLayout(zoom_layout)
|
|
61
|
+
|
|
62
|
+
# Preview
|
|
63
|
+
self.preview_area = QScrollArea(self); self.preview_area.setWidgetResizable(True)
|
|
64
|
+
self.preview_label = QLabel(alignment=Qt.AlignmentFlag.AlignCenter)
|
|
65
|
+
self.preview_label.setCursor(Qt.CursorShape.OpenHandCursor)
|
|
66
|
+
self.preview_label.setMouseTracking(True)
|
|
67
|
+
self.preview_area.setWidget(self.preview_label)
|
|
68
|
+
self.preview_label.installEventFilter(self)
|
|
69
|
+
vlayout.addWidget(self.preview_area, stretch=1)
|
|
70
|
+
|
|
71
|
+
# Sliders
|
|
72
|
+
for name in ("Ha","OIII","SII"):
|
|
73
|
+
row = QHBoxLayout()
|
|
74
|
+
row.addWidget(QLabel(f"{name} Intensity:", self))
|
|
75
|
+
sl = QSlider(Qt.Orientation.Horizontal, self); sl.setRange(0,200); sl.setValue(100)
|
|
76
|
+
sl.valueChanged.connect(self._on_slider_change)
|
|
77
|
+
setattr(self, f"_{name.lower()}_slider", sl)
|
|
78
|
+
row.addWidget(sl)
|
|
79
|
+
vlayout.addLayout(row)
|
|
80
|
+
|
|
81
|
+
# Buttons
|
|
82
|
+
btns = QHBoxLayout(); btns.addStretch()
|
|
83
|
+
accept = QPushButton("Accept", self); accept.clicked.connect(self._on_accept)
|
|
84
|
+
reset = QPushButton("Reset", self); reset.clicked.connect(self._on_reset)
|
|
85
|
+
discard = QPushButton("Discard",self); discard.clicked.connect(self.reject)
|
|
86
|
+
btns.addWidget(accept); btns.addWidget(reset); btns.addWidget(discard)
|
|
87
|
+
vlayout.addLayout(btns)
|
|
88
|
+
|
|
89
|
+
self._update_preview()
|
|
90
|
+
|
|
91
|
+
def _on_slider_change(self, _):
|
|
92
|
+
self.ha_factor = self._ha_slider.value()/100.0
|
|
93
|
+
self.oiii_factor = self._oiii_slider.value()/100.0
|
|
94
|
+
self.sii_factor = self._sii_slider.value()/100.0
|
|
95
|
+
self._debounce.start()
|
|
96
|
+
|
|
97
|
+
def _update_preview(self):
|
|
98
|
+
ha = (self.ha_src * self.ha_factor) if self.ha_src is not None else None
|
|
99
|
+
oo = (self.oiii_src * self.oiii_factor) if self.oiii_src is not None else None
|
|
100
|
+
si = (self.sii_src * self.sii_factor) if self.sii_src is not None else None
|
|
101
|
+
|
|
102
|
+
r,g,b = self.owner._map_channels_or_special(self.palette_name, ha, oo, si)
|
|
103
|
+
|
|
104
|
+
# --- make sure channels match the base palette size ---
|
|
105
|
+
H, W = self.base_rgb.shape[:2]
|
|
106
|
+
def fit(ch):
|
|
107
|
+
if ch is None: return None
|
|
108
|
+
if ch.shape[:2] != (H, W):
|
|
109
|
+
return self.owner._resize_to(ch, (W, H))
|
|
110
|
+
return ch
|
|
111
|
+
r, g, b = fit(r), fit(g), fit(b)
|
|
112
|
+
# ------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
img = np.zeros_like(self.base_rgb, dtype=np.float32)
|
|
115
|
+
if r is not None: img[...,0] = r
|
|
116
|
+
if g is not None: img[...,1] = g
|
|
117
|
+
if b is not None: img[...,2] = b
|
|
118
|
+
m = float(img.max()) or 1.0
|
|
119
|
+
img = np.clip(img/m, 0.0, 1.0)
|
|
120
|
+
|
|
121
|
+
qimg = self.owner._to_qimage(img)
|
|
122
|
+
self._base_pixmap = QPixmap.fromImage(qimg)
|
|
123
|
+
self._rescale_pixmap()
|
|
124
|
+
|
|
125
|
+
def _rescale_pixmap(self):
|
|
126
|
+
if not hasattr(self, "_base_pixmap"): return
|
|
127
|
+
w = int(self._base_pixmap.width() * self.zoom_factor)
|
|
128
|
+
h = int(self._base_pixmap.height() * self.zoom_factor)
|
|
129
|
+
scaled = self._base_pixmap.scaled(w, h, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
|
|
130
|
+
self._current_pixmap = scaled
|
|
131
|
+
self.preview_label.setPixmap(scaled)
|
|
132
|
+
self.preview_label.resize(scaled.size())
|
|
133
|
+
|
|
134
|
+
def _change_zoom(self, factor: float):
|
|
135
|
+
self.zoom_factor = max(0.1, min(10.0, self.zoom_factor * factor))
|
|
136
|
+
self._rescale_pixmap()
|
|
137
|
+
|
|
138
|
+
def _fit_to_preview(self):
|
|
139
|
+
if not hasattr(self, "_base_pixmap"): return
|
|
140
|
+
vp_w = self.preview_area.viewport().width()
|
|
141
|
+
self.zoom_factor = vp_w / max(1, self._base_pixmap.width())
|
|
142
|
+
self._rescale_pixmap()
|
|
143
|
+
|
|
144
|
+
def _on_reset(self):
|
|
145
|
+
for s in (self._ha_slider, self._oiii_slider, self._sii_slider):
|
|
146
|
+
s.setValue(100)
|
|
147
|
+
self._on_slider_change(None)
|
|
148
|
+
|
|
149
|
+
def _on_accept(self):
|
|
150
|
+
ha = (self.ha_src * self.ha_factor) if self.ha_src is not None else None
|
|
151
|
+
oo = (self.oiii_src * self.oiii_factor) if self.oiii_src is not None else None
|
|
152
|
+
si = (self.sii_src * self.sii_factor) if self.sii_src is not None else None
|
|
153
|
+
|
|
154
|
+
r,g,b = self.owner._map_channels_or_special(self.palette_name, ha, oo, si)
|
|
155
|
+
|
|
156
|
+
# match base size
|
|
157
|
+
H, W = self.base_rgb.shape[:2]
|
|
158
|
+
def fit(ch):
|
|
159
|
+
if ch is None: return None
|
|
160
|
+
if ch.shape[:2] != (H, W):
|
|
161
|
+
return self.owner._resize_to(ch, (W, H))
|
|
162
|
+
return ch
|
|
163
|
+
r, g, b = fit(r), fit(g), fit(b)
|
|
164
|
+
|
|
165
|
+
final = np.zeros_like(self.base_rgb, dtype=np.float32)
|
|
166
|
+
if r is not None: final[...,0] = r
|
|
167
|
+
if g is not None: final[...,1] = g
|
|
168
|
+
if b is not None: final[...,2] = b
|
|
169
|
+
|
|
170
|
+
m = float(final.max()) or 1.0
|
|
171
|
+
final = np.clip(final/m, 0.0, 1.0)
|
|
172
|
+
|
|
173
|
+
self.adjusted_image.emit(final)
|
|
174
|
+
self.accept()
|
|
175
|
+
|
|
176
|
+
def eventFilter(self, obj, evt):
|
|
177
|
+
if obj is self.preview_label:
|
|
178
|
+
if evt.type() == QEvent.Type.MouseButtonPress and evt.button() == Qt.MouseButton.LeftButton:
|
|
179
|
+
self._dragging = True; self._last_pos = evt.pos()
|
|
180
|
+
self.preview_label.setCursor(Qt.CursorShape.ClosedHandCursor); return True
|
|
181
|
+
if evt.type() == QEvent.Type.MouseMove and self._dragging:
|
|
182
|
+
d = evt.pos() - self._last_pos; self._last_pos = evt.pos()
|
|
183
|
+
self.preview_area.horizontalScrollBar().setValue(self.preview_area.horizontalScrollBar().value() - d.x())
|
|
184
|
+
self.preview_area.verticalScrollBar().setValue(self.preview_area.verticalScrollBar().value() - d.y())
|
|
185
|
+
return True
|
|
186
|
+
if evt.type() == QEvent.Type.MouseButtonRelease and evt.button() == Qt.MouseButton.LeftButton:
|
|
187
|
+
self._dragging = False; self.preview_label.setCursor(Qt.CursorShape.OpenHandCursor); return True
|
|
188
|
+
if evt.type() == QEvent.Type.Wheel:
|
|
189
|
+
self._change_zoom(1.1 if evt.angleDelta().y() > 0 else 0.9); return True
|
|
190
|
+
return super().eventFilter(obj, evt)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class PerfectPalettePicker(QWidget):
|
|
194
|
+
THUMB_CROP = 512 # side length for thumbnail center crops
|
|
195
|
+
PALETTES = [
|
|
196
|
+
"SHO","HOO","HSO","HOS",
|
|
197
|
+
"OSS","OHH","OSH","OHS",
|
|
198
|
+
"HSS","Realistic1","Realistic2","Foraxx"
|
|
199
|
+
]
|
|
200
|
+
|
|
201
|
+
def __init__(self, doc_manager=None, parent=None):
|
|
202
|
+
super().__init__(parent)
|
|
203
|
+
self.doc_manager = doc_manager
|
|
204
|
+
self.setWindowTitle("Perfect Palette Picker")
|
|
205
|
+
|
|
206
|
+
# raw channels (float32 ~[0..1])
|
|
207
|
+
self.ha = None
|
|
208
|
+
self.oiii = None
|
|
209
|
+
self.sii = None
|
|
210
|
+
self.osc1 = None
|
|
211
|
+
self.osc2 = None
|
|
212
|
+
|
|
213
|
+
# stretched cache (per input name → stretched array)
|
|
214
|
+
self._stretched: dict[str, np.ndarray] = {}
|
|
215
|
+
|
|
216
|
+
self.final = None
|
|
217
|
+
self.current_palette = None
|
|
218
|
+
self._thumb_base_pm: dict[str, QPixmap] = {} # palette name -> base pixmap (image only)
|
|
219
|
+
self._selected_name: str | None = None
|
|
220
|
+
|
|
221
|
+
# thumbs
|
|
222
|
+
self._thumb_buttons: dict[str, QPushButton] = {}
|
|
223
|
+
|
|
224
|
+
self._base_pm: QPixmap | None = None
|
|
225
|
+
self._zoom = 1.0
|
|
226
|
+
self._min_zoom = 0.05
|
|
227
|
+
self._max_zoom = 6.0
|
|
228
|
+
self._panning = False
|
|
229
|
+
self._pan_last: QPoint | None = None
|
|
230
|
+
|
|
231
|
+
self._build_ui()
|
|
232
|
+
|
|
233
|
+
# ---------------- UI ----------------
|
|
234
|
+
def _build_ui(self):
|
|
235
|
+
root = QHBoxLayout(self)
|
|
236
|
+
|
|
237
|
+
# -------- left controls
|
|
238
|
+
left = QVBoxLayout()
|
|
239
|
+
left_host = QWidget(self); left_host.setLayout(left); left_host.setFixedWidth(300)
|
|
240
|
+
|
|
241
|
+
left.addWidget(QLabel("<b>Load channels</b>"))
|
|
242
|
+
|
|
243
|
+
# Load buttons + status labels
|
|
244
|
+
self.btn_ha = QPushButton("Load Ha…"); self.btn_ha.clicked.connect(lambda: self._load_channel("Ha"))
|
|
245
|
+
self.btn_oiii = QPushButton("Load OIII…"); self.btn_oiii.clicked.connect(lambda: self._load_channel("OIII"))
|
|
246
|
+
self.btn_sii = QPushButton("Load SII…"); self.btn_sii.clicked.connect(lambda: self._load_channel("SII"))
|
|
247
|
+
self.btn_osc1 = QPushButton("Load OSC1 (Ha/OIII)…"); self.btn_osc1.clicked.connect(lambda: self._load_channel("OSC1"))
|
|
248
|
+
self.btn_osc2 = QPushButton("Load OSC2 (SII/OIII)…"); self.btn_osc2.clicked.connect(lambda: self._load_channel("OSC2"))
|
|
249
|
+
|
|
250
|
+
self.lbl_ha = QLabel("No Ha loaded.")
|
|
251
|
+
self.lbl_oiii = QLabel("No OIII loaded.")
|
|
252
|
+
self.lbl_sii = QLabel("No SII loaded.")
|
|
253
|
+
self.lbl_osc1 = QLabel("No OSC1 loaded.")
|
|
254
|
+
self.lbl_osc2 = QLabel("No OSC2 loaded.")
|
|
255
|
+
for lab in (self.lbl_ha, self.lbl_oiii, self.lbl_sii, self.lbl_osc1, self.lbl_osc2):
|
|
256
|
+
lab.setWordWrap(True); lab.setStyleSheet("color:#888; margin-left:8px;")
|
|
257
|
+
|
|
258
|
+
for btn, lab in (
|
|
259
|
+
(self.btn_ha, self.lbl_ha),
|
|
260
|
+
(self.btn_oiii, self.lbl_oiii),
|
|
261
|
+
(self.btn_sii, self.lbl_sii),
|
|
262
|
+
(self.btn_osc1, self.lbl_osc1),
|
|
263
|
+
(self.btn_osc2, self.lbl_osc2),
|
|
264
|
+
):
|
|
265
|
+
left.addWidget(btn); left.addWidget(lab)
|
|
266
|
+
|
|
267
|
+
# Linear toggle (stretch BEFORE palette build)
|
|
268
|
+
self.chk_linear = QCheckBox("Linear input (apply statistical stretch before build)")
|
|
269
|
+
self.chk_linear.setChecked(True)
|
|
270
|
+
self.chk_linear.stateChanged.connect(self._rebuild_stretch_cache_for_all)
|
|
271
|
+
left.addSpacing(6); left.addWidget(self.chk_linear)
|
|
272
|
+
|
|
273
|
+
# Actions
|
|
274
|
+
self.btn_clear = QPushButton("Clear Loaded Channels")
|
|
275
|
+
self.btn_clear.clicked.connect(self._clear_channels)
|
|
276
|
+
left.addWidget(self.btn_clear)
|
|
277
|
+
|
|
278
|
+
self.btn_create = QPushButton("Create Palettes")
|
|
279
|
+
self.btn_create.clicked.connect(self._create_palettes)
|
|
280
|
+
left.addWidget(self.btn_create)
|
|
281
|
+
|
|
282
|
+
self.btn_push = QPushButton("Push Final to New View")
|
|
283
|
+
self.btn_push.clicked.connect(self._push_final)
|
|
284
|
+
left.addWidget(self.btn_push)
|
|
285
|
+
|
|
286
|
+
left.addStretch(1)
|
|
287
|
+
root.addWidget(left_host, 0)
|
|
288
|
+
|
|
289
|
+
# -------- right: preview + fixed-size 4×3 grid
|
|
290
|
+
right = QVBoxLayout()
|
|
291
|
+
|
|
292
|
+
# zoom toolbar
|
|
293
|
+
# zoom toolbar (themed)
|
|
294
|
+
tools = QHBoxLayout()
|
|
295
|
+
self.btn_zoom_in = themed_toolbtn("zoom-in", "Zoom In")
|
|
296
|
+
self.btn_zoom_out = themed_toolbtn("zoom-out", "Zoom Out")
|
|
297
|
+
self.btn_fit = themed_toolbtn("zoom-fit-best", "Fit to Preview")
|
|
298
|
+
|
|
299
|
+
self.btn_zoom_in.clicked.connect(lambda: self._zoom_at(1.25))
|
|
300
|
+
self.btn_zoom_out.clicked.connect(lambda: self._zoom_at(0.8))
|
|
301
|
+
self.btn_fit.clicked.connect(self._fit_to_preview)
|
|
302
|
+
|
|
303
|
+
tools.addStretch(1)
|
|
304
|
+
tools.addWidget(self.btn_zoom_out)
|
|
305
|
+
tools.addWidget(self.btn_zoom_in)
|
|
306
|
+
tools.addWidget(self.btn_fit)
|
|
307
|
+
tools.addStretch(1)
|
|
308
|
+
right.addLayout(tools)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
# main preview (expands)
|
|
312
|
+
self.scroll = QScrollArea(self); self.scroll.setWidgetResizable(True)
|
|
313
|
+
self.scroll.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
314
|
+
self.preview = QLabel(alignment=Qt.AlignmentFlag.AlignCenter)
|
|
315
|
+
self.scroll.setWidget(self.preview)
|
|
316
|
+
self.preview.setMouseTracking(True)
|
|
317
|
+
self.preview.installEventFilter(self)
|
|
318
|
+
self.scroll.viewport().installEventFilter(self)
|
|
319
|
+
self.scroll.installEventFilter(self)
|
|
320
|
+
self.scroll.horizontalScrollBar().installEventFilter(self) # NEW
|
|
321
|
+
self.scroll.verticalScrollBar().installEventFilter(self) # NEW
|
|
322
|
+
right.addWidget(self.scroll, 1)
|
|
323
|
+
|
|
324
|
+
# fixed-size grid
|
|
325
|
+
self.grid = QGridLayout()
|
|
326
|
+
self.grid.setHorizontalSpacing(8); self.grid.setVerticalSpacing(8)
|
|
327
|
+
self.grid.setContentsMargins(8, 8, 8, 8)
|
|
328
|
+
|
|
329
|
+
self.thumb_size = QSize(220, 110)
|
|
330
|
+
btn_w = self.thumb_size.width() + 2
|
|
331
|
+
btn_h = self.thumb_size.height() + 2
|
|
332
|
+
cols, rows = 4, 3
|
|
333
|
+
|
|
334
|
+
for idx, name in enumerate(self.PALETTES):
|
|
335
|
+
r, c = divmod(idx, cols)
|
|
336
|
+
b = QPushButton("") # we draw the text onto the icon itself
|
|
337
|
+
b.setToolTip(name)
|
|
338
|
+
b.setIconSize(self.thumb_size)
|
|
339
|
+
b.setFixedSize(btn_w, btn_h)
|
|
340
|
+
b.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
|
|
341
|
+
b.clicked.connect(lambda _=None, n=name: self._on_palette_clicked(n))
|
|
342
|
+
b.setStyleSheet("QPushButton{background:#222;border:1px solid #333;} QPushButton:hover{border-color:#555;}")
|
|
343
|
+
self._thumb_buttons[name] = b
|
|
344
|
+
self.grid.addWidget(b, r, c)
|
|
345
|
+
|
|
346
|
+
grid_host = QWidget(self); grid_host.setLayout(self.grid)
|
|
347
|
+
hspacing = self.grid.horizontalSpacing(); vspacing = self.grid.verticalSpacing()
|
|
348
|
+
m = self.grid.contentsMargins()
|
|
349
|
+
grid_w = cols*btn_w + (cols-1)*hspacing + m.left() + m.right()
|
|
350
|
+
grid_h = rows*btn_h + (rows-1)*vspacing + m.top() + m.bottom()
|
|
351
|
+
grid_host.setFixedSize(grid_w, grid_h)
|
|
352
|
+
grid_host.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
|
|
353
|
+
right.addWidget(grid_host, 0, alignment=Qt.AlignmentFlag.AlignHCenter)
|
|
354
|
+
|
|
355
|
+
self.status = QLabel(""); right.addWidget(self.status, 0)
|
|
356
|
+
|
|
357
|
+
right_host = QWidget(self); right_host.setLayout(right)
|
|
358
|
+
root.addWidget(right_host, 1)
|
|
359
|
+
|
|
360
|
+
self.setLayout(root)
|
|
361
|
+
self.setMinimumSize(left_host.width() + grid_w + 48, max(560, grid_h + 200))
|
|
362
|
+
|
|
363
|
+
def _resize_to(self, arr: np.ndarray | None, size: tuple[int, int]) -> np.ndarray | None:
|
|
364
|
+
"""Resize np array to (w,h). Keeps dtype/scale. Uses INTER_AREA for downsizing."""
|
|
365
|
+
if arr is None:
|
|
366
|
+
return None
|
|
367
|
+
w, h = size
|
|
368
|
+
if arr.ndim == 2:
|
|
369
|
+
src_h, src_w = arr.shape
|
|
370
|
+
else:
|
|
371
|
+
src_h, src_w = arr.shape[:2]
|
|
372
|
+
if (src_w, src_h) == (w, h):
|
|
373
|
+
return arr
|
|
374
|
+
if cv2 is None:
|
|
375
|
+
# ultra-simple fallback: nearest; OK for thumbs if OpenCV isn't present
|
|
376
|
+
if arr.ndim == 2:
|
|
377
|
+
return np.array(Image.fromarray((arr*255).astype(np.uint8)).resize((w, h))).astype(np.float32) / 255.0
|
|
378
|
+
return np.array(Image.fromarray((arr*255).astype(np.uint8)).resize((w, h))).astype(np.float32) / 255.0
|
|
379
|
+
interp = cv2.INTER_AREA if (w < src_w or h < src_h) else cv2.INTER_LINEAR
|
|
380
|
+
if arr.ndim == 2:
|
|
381
|
+
return cv2.resize(arr, (w, h), interpolation=interp)
|
|
382
|
+
return cv2.resize(arr, (w, h), interpolation=interp)
|
|
383
|
+
|
|
384
|
+
def _capture_view_state(self):
|
|
385
|
+
"""Capture current view center in base-image coordinates + zoom."""
|
|
386
|
+
if self._base_pm is None:
|
|
387
|
+
return None
|
|
388
|
+
vp = self.scroll.viewport()
|
|
389
|
+
hbar = self.scroll.horizontalScrollBar()
|
|
390
|
+
vbar = self.scroll.verticalScrollBar()
|
|
391
|
+
|
|
392
|
+
# center of viewport in viewport coords
|
|
393
|
+
anchor_vp = QPoint(vp.width() // 2, vp.height() // 2)
|
|
394
|
+
|
|
395
|
+
# convert to label coords (scaled image coords)
|
|
396
|
+
anchor_lbl = self.preview.mapFrom(vp, anchor_vp)
|
|
397
|
+
|
|
398
|
+
# scaled -> base image coords
|
|
399
|
+
base_x = anchor_lbl.x() / max(self._zoom, 1e-6)
|
|
400
|
+
base_y = anchor_lbl.y() / max(self._zoom, 1e-6)
|
|
401
|
+
|
|
402
|
+
pm = self._base_pm.size()
|
|
403
|
+
fx = 0.5 if pm.width() <= 0 else (base_x / pm.width())
|
|
404
|
+
fy = 0.5 if pm.height() <= 0 else (base_y / pm.height())
|
|
405
|
+
|
|
406
|
+
return {"zoom": float(self._zoom), "fx": float(fx), "fy": float(fy)}
|
|
407
|
+
|
|
408
|
+
def _restore_view_state(self, state):
|
|
409
|
+
"""Restore zoom and pan using stored base-image fractions."""
|
|
410
|
+
if not state or self._base_pm is None:
|
|
411
|
+
return
|
|
412
|
+
|
|
413
|
+
# restore zoom first
|
|
414
|
+
self._zoom = max(self._min_zoom, min(self._max_zoom, float(state["zoom"])))
|
|
415
|
+
self._update_preview_pixmap()
|
|
416
|
+
|
|
417
|
+
# now restore center point
|
|
418
|
+
pm = self._base_pm.size()
|
|
419
|
+
fx = float(state.get("fx", 0.5))
|
|
420
|
+
fy = float(state.get("fy", 0.5))
|
|
421
|
+
|
|
422
|
+
base_x = fx * pm.width()
|
|
423
|
+
base_y = fy * pm.height()
|
|
424
|
+
|
|
425
|
+
# base -> scaled label coords
|
|
426
|
+
lbl_x = int(base_x * self._zoom)
|
|
427
|
+
lbl_y = int(base_y * self._zoom)
|
|
428
|
+
|
|
429
|
+
vp = self.scroll.viewport()
|
|
430
|
+
anchor_vp = QPoint(vp.width() // 2, vp.height() // 2)
|
|
431
|
+
|
|
432
|
+
hbar = self.scroll.horizontalScrollBar()
|
|
433
|
+
vbar = self.scroll.verticalScrollBar()
|
|
434
|
+
hbar.setValue(max(hbar.minimum(), min(hbar.maximum(), lbl_x - anchor_vp.x())))
|
|
435
|
+
vbar.setValue(max(vbar.minimum(), min(vbar.maximum(), lbl_y - anchor_vp.y())))
|
|
436
|
+
|
|
437
|
+
# ---------- status helpers ----------
|
|
438
|
+
def _set_status_label(self, which: str, text: str | None):
|
|
439
|
+
lab = getattr(self, f"lbl_{which.lower()}")
|
|
440
|
+
if text:
|
|
441
|
+
lab.setText(text)
|
|
442
|
+
lab.setStyleSheet("color:#2a7; font-weight:600; margin-left:8px;")
|
|
443
|
+
else:
|
|
444
|
+
lab.setText(f"No {which} loaded.")
|
|
445
|
+
lab.setStyleSheet("color:#888; margin-left:8px;")
|
|
446
|
+
|
|
447
|
+
# ------------- load by view/file -------------
|
|
448
|
+
def _load_channel(self, which: str):
|
|
449
|
+
src, ok = QInputDialog.getItem(
|
|
450
|
+
self, f"Load {which}", "Source:", ["From View", "From File"], 0, False
|
|
451
|
+
)
|
|
452
|
+
if not ok:
|
|
453
|
+
return
|
|
454
|
+
|
|
455
|
+
if src == "From View":
|
|
456
|
+
out = self._load_from_view(which)
|
|
457
|
+
else:
|
|
458
|
+
out = self._load_from_file(which)
|
|
459
|
+
if out is None:
|
|
460
|
+
return
|
|
461
|
+
|
|
462
|
+
img, header, bit_depth, is_mono, path, label = out
|
|
463
|
+
|
|
464
|
+
# NB channels → mono; OSC → RGB
|
|
465
|
+
if which in ("Ha","OIII","SII"):
|
|
466
|
+
if img.ndim == 3:
|
|
467
|
+
img = img[:, :, 0]
|
|
468
|
+
else:
|
|
469
|
+
if img.ndim == 2:
|
|
470
|
+
img = np.stack([img]*3, axis=-1)
|
|
471
|
+
|
|
472
|
+
# store raw, normalized
|
|
473
|
+
setattr(self, which.lower(), self._as_float01(img))
|
|
474
|
+
self._set_status_label(which, label)
|
|
475
|
+
self.status.setText(f"{which} loaded ({'mono' if img.ndim==2 else 'RGB'}) shape={img.shape}")
|
|
476
|
+
|
|
477
|
+
# build/clear stretched cache for this input
|
|
478
|
+
self._cache_stretch(which)
|
|
479
|
+
|
|
480
|
+
if self.current_palette is None:
|
|
481
|
+
self.current_palette = "SHO"
|
|
482
|
+
|
|
483
|
+
def _load_from_view(self, which):
|
|
484
|
+
views = self._list_open_views()
|
|
485
|
+
if not views:
|
|
486
|
+
QMessageBox.warning(self, "No Views", "No open image views were found.")
|
|
487
|
+
return None
|
|
488
|
+
|
|
489
|
+
labels = [lab for lab, _ in views]
|
|
490
|
+
choice, ok = QInputDialog.getItem(
|
|
491
|
+
self, f"Select View for {which}", "Choose a view (by name):", labels, 0, False
|
|
492
|
+
)
|
|
493
|
+
if not ok or not choice:
|
|
494
|
+
return None
|
|
495
|
+
|
|
496
|
+
sw = dict(views)[choice]
|
|
497
|
+
doc = getattr(sw, "document", None)
|
|
498
|
+
if doc is None or getattr(doc, "image", None) is None:
|
|
499
|
+
QMessageBox.warning(self, "Empty View", "Selected view has no image.")
|
|
500
|
+
return None
|
|
501
|
+
|
|
502
|
+
img = doc.image
|
|
503
|
+
meta = getattr(doc, "metadata", {}) or {}
|
|
504
|
+
header = meta.get("original_header", None)
|
|
505
|
+
bit_depth = meta.get("bit_depth", "Unknown")
|
|
506
|
+
is_mono = (img.ndim == 2) or (img.ndim == 3 and img.shape[2] == 1)
|
|
507
|
+
path = meta.get("file_path", None)
|
|
508
|
+
return img, header, bit_depth, is_mono, path, f"From View: {choice}"
|
|
509
|
+
|
|
510
|
+
def _load_from_file(self, which):
|
|
511
|
+
filt = "Images (*.png *.tif *.tiff *.fits *.fit *.xisf)"
|
|
512
|
+
path, _ = QFileDialog.getOpenFileName(self, f"Select {which} File", "", filt)
|
|
513
|
+
if not path:
|
|
514
|
+
return None
|
|
515
|
+
img, header, bit_depth, is_mono = legacy_load_image(path)
|
|
516
|
+
if img is None:
|
|
517
|
+
QMessageBox.critical(self, "Load Error", f"Could not load {os.path.basename(path)}")
|
|
518
|
+
return None
|
|
519
|
+
label = f"From File: {os.path.basename(path)}"
|
|
520
|
+
return img, header, bit_depth, is_mono, path, label
|
|
521
|
+
|
|
522
|
+
def showEvent(self, e):
|
|
523
|
+
super().showEvent(e)
|
|
524
|
+
QTimer.singleShot(0, self._center_scrollbars)
|
|
525
|
+
|
|
526
|
+
# ------------- build/caches -------------
|
|
527
|
+
def _cache_stretch(self, which: str):
|
|
528
|
+
"""Compute and cache stretched version of a just-loaded input (if linear checked)."""
|
|
529
|
+
arr = getattr(self, which.lower())
|
|
530
|
+
if arr is None:
|
|
531
|
+
self._stretched.pop(which, None); return
|
|
532
|
+
if not self.chk_linear.isChecked():
|
|
533
|
+
self._stretched.pop(which, None); return
|
|
534
|
+
self._stretched[which] = self._stretch_input(arr)
|
|
535
|
+
|
|
536
|
+
def _rebuild_stretch_cache_for_all(self, _state: int):
|
|
537
|
+
"""Rebuild (or clear) stretched cache for all loaded inputs when checkbox toggles."""
|
|
538
|
+
for which in ("Ha","OIII","SII","OSC1","OSC2"):
|
|
539
|
+
self._cache_stretch(which)
|
|
540
|
+
|
|
541
|
+
def _render_thumb(self, name: str):
|
|
542
|
+
base = self._thumb_base_pm.get(name)
|
|
543
|
+
if base is None:
|
|
544
|
+
return
|
|
545
|
+
pm = base.copy()
|
|
546
|
+
|
|
547
|
+
p = QPainter(pm)
|
|
548
|
+
p.setRenderHint(QPainter.RenderHint.Antialiasing)
|
|
549
|
+
|
|
550
|
+
font = QFont("Helvetica", 10, QFont.Weight.DemiBold)
|
|
551
|
+
p.setFont(font)
|
|
552
|
+
fm = QFontMetrics(font)
|
|
553
|
+
|
|
554
|
+
pad = 6
|
|
555
|
+
strip_h = fm.height() + pad * 2
|
|
556
|
+
strip = pm.rect().adjusted(0, pm.height() - strip_h, 0, 0)
|
|
557
|
+
|
|
558
|
+
# translucent bottom strip
|
|
559
|
+
p.fillRect(strip, QColor(0, 0, 0, 160))
|
|
560
|
+
color = QColor(102, 255, 102) if self._selected_name == name else QColor(255, 255, 255)
|
|
561
|
+
p.setPen(QPen(color))
|
|
562
|
+
p.drawText(strip, Qt.AlignmentFlag.AlignCenter, name)
|
|
563
|
+
p.end()
|
|
564
|
+
|
|
565
|
+
btn = self._thumb_buttons[name]
|
|
566
|
+
btn.setIcon(QIcon(pm))
|
|
567
|
+
btn.setIconSize(self.thumb_size) # <- ensures no clipping
|
|
568
|
+
|
|
569
|
+
# ------------- thumbnails -------------
|
|
570
|
+
def _create_palettes(self):
|
|
571
|
+
"""
|
|
572
|
+
Build the 12 palette thumbnails from a **center crop of the stretched inputs**
|
|
573
|
+
and draw the palette name directly on each thumbnail. Names turn green when selected.
|
|
574
|
+
"""
|
|
575
|
+
ha, oo, si = self._prepared_channels(for_thumbs=True)
|
|
576
|
+
if oo is None or (ha is None and si is None):
|
|
577
|
+
QMessageBox.warning(self, "Need Channels", "Load at least OIII + (Ha or SII).")
|
|
578
|
+
return
|
|
579
|
+
|
|
580
|
+
built = 0
|
|
581
|
+
for name in self.PALETTES:
|
|
582
|
+
r, g, b = self._map_channels_or_special(name, ha, oo, si)
|
|
583
|
+
if any(ch is None for ch in (r, g, b)):
|
|
584
|
+
self._thumb_base_pm.pop(name, None)
|
|
585
|
+
self._thumb_buttons[name].setIcon(QIcon())
|
|
586
|
+
continue
|
|
587
|
+
|
|
588
|
+
r = np.clip(np.nan_to_num(r), 0, 1)
|
|
589
|
+
g = np.clip(np.nan_to_num(g), 0, 1)
|
|
590
|
+
b = np.clip(np.nan_to_num(b), 0, 1)
|
|
591
|
+
rgb = np.stack([r, g, b], axis=2).astype(np.float32)
|
|
592
|
+
|
|
593
|
+
# scale the thumbnail to EXACTLY the button icon size first
|
|
594
|
+
pm = QPixmap.fromImage(self._to_qimage(rgb)).scaled(
|
|
595
|
+
self.thumb_size, Qt.AspectRatioMode.KeepAspectRatio,
|
|
596
|
+
Qt.TransformationMode.SmoothTransformation
|
|
597
|
+
)
|
|
598
|
+
self._thumb_base_pm[name] = pm
|
|
599
|
+
self._render_thumb(name)
|
|
600
|
+
built += 1
|
|
601
|
+
|
|
602
|
+
self.status.setText(f"Created {built} palette previews.")
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _on_palette_clicked(self, name: str):
|
|
606
|
+
self._selected_name = name
|
|
607
|
+
for n in self.PALETTES:
|
|
608
|
+
self._render_thumb(n)
|
|
609
|
+
self.current_palette = name
|
|
610
|
+
self._generate_for_palette(name)
|
|
611
|
+
|
|
612
|
+
# ------------- palette build helpers -------------
|
|
613
|
+
def _center_crop(self, img: np.ndarray, side: int) -> np.ndarray:
|
|
614
|
+
"""Center-crop to a square of size 'side' (no upscaling)."""
|
|
615
|
+
h, w = img.shape[:2]; s = min(side, h, w)
|
|
616
|
+
y0 = (h - s) // 2; x0 = (w - s) // 2
|
|
617
|
+
return img[y0:y0+s, x0:x0+s] if img.ndim == 2 else img[y0:y0+s, x0:x0+s, :]
|
|
618
|
+
|
|
619
|
+
def _center_crop_all_to_side(self, side: int, *imgs):
|
|
620
|
+
"""Center-crop all provided images to the same square side (no upscaling)."""
|
|
621
|
+
s = None
|
|
622
|
+
for im in imgs:
|
|
623
|
+
if im is None: continue
|
|
624
|
+
h, w = im.shape[:2]
|
|
625
|
+
s = min(side, h, w) if s is None else min(s, h, w, side)
|
|
626
|
+
if s is None: s = side
|
|
627
|
+
return [self._center_crop(im, s) if im is not None else None for im in imgs], s
|
|
628
|
+
|
|
629
|
+
def _prepared_channels(self, for_thumbs: bool = False):
|
|
630
|
+
"""
|
|
631
|
+
Build Ha/OIII/SII bases from inputs. If 'Linear input' is checked,
|
|
632
|
+
**use stretched versions** (cached). Then optionally center-crop for thumbnails.
|
|
633
|
+
"""
|
|
634
|
+
# choose raw vs stretched
|
|
635
|
+
def pick(name):
|
|
636
|
+
if self.chk_linear.isChecked() and (name in self._stretched):
|
|
637
|
+
return self._stretched[name]
|
|
638
|
+
return getattr(self, name.lower())
|
|
639
|
+
|
|
640
|
+
ha = pick("Ha")
|
|
641
|
+
oo = pick("OIII")
|
|
642
|
+
si = pick("SII")
|
|
643
|
+
o1 = pick("OSC1")
|
|
644
|
+
o2 = pick("OSC2")
|
|
645
|
+
|
|
646
|
+
# synthesize from stretched OSC first (stretch-before-crop)
|
|
647
|
+
if o1 is not None: # OSC1: R≈Ha, mean(G,B)≈OIII
|
|
648
|
+
h1 = o1[..., 0]
|
|
649
|
+
g1b1 = o1[..., 1:3].mean(axis=2)
|
|
650
|
+
ha = h1 if ha is None else 0.5*ha + 0.5*h1
|
|
651
|
+
oo = g1b1 if oo is None else 0.5*oo + 0.5*g1b1
|
|
652
|
+
|
|
653
|
+
if o2 is not None: # OSC2: R≈SII, mean(G,B)≈OIII
|
|
654
|
+
s2 = o2[..., 0]
|
|
655
|
+
g2b2 = o2[..., 1:3].mean(axis=2)
|
|
656
|
+
si = s2 if si is None else 0.5*si + 0.5*s2
|
|
657
|
+
oo = g2b2 if oo is None else 0.5*oo + 0.5*g2b2
|
|
658
|
+
|
|
659
|
+
# shapes must match for full-size
|
|
660
|
+
shapes = [x.shape for x in (ha, oo, si) if x is not None]
|
|
661
|
+
if len(shapes) and len(set(shapes)) > 1 and not for_thumbs:
|
|
662
|
+
QMessageBox.critical(self, "Size Mismatch", f"Channel sizes differ: {set(shapes)}")
|
|
663
|
+
return None, None, None
|
|
664
|
+
|
|
665
|
+
# thumbnails: crop AFTER stretch/synth
|
|
666
|
+
if for_thumbs:
|
|
667
|
+
# choose a reference (prefer OIII, then Ha, then SII)
|
|
668
|
+
ref = oo if oo is not None else (ha if ha is not None else si)
|
|
669
|
+
if ref is not None:
|
|
670
|
+
ref_h, ref_w = ref.shape[:2]
|
|
671
|
+
|
|
672
|
+
# 1) first, size-match all channels to the reference full frame
|
|
673
|
+
ha = self._resize_to(ha, (ref_w, ref_h)) if ha is not None else None
|
|
674
|
+
oo = self._resize_to(oo, (ref_w, ref_h)) if oo is not None else None
|
|
675
|
+
si = self._resize_to(si, (ref_w, ref_h)) if si is not None else None
|
|
676
|
+
|
|
677
|
+
# 2) then, make a 50% view of the full rectangle
|
|
678
|
+
half_w = max(1, int(ref_w * 0.5))
|
|
679
|
+
half_h = max(1, int(ref_h * 0.5))
|
|
680
|
+
ha = self._resize_to(ha, (half_w, half_h)) if ha is not None else None
|
|
681
|
+
oo = self._resize_to(oo, (half_w, half_h)) if oo is not None else None
|
|
682
|
+
si = self._resize_to(si, (half_w, half_h)) if si is not None else None
|
|
683
|
+
|
|
684
|
+
return ha, oo, si
|
|
685
|
+
|
|
686
|
+
def _generate_for_palette(self, pal: str):
|
|
687
|
+
ha, oo, si = self._prepared_channels()
|
|
688
|
+
if oo is None or (ha is None and si is None):
|
|
689
|
+
return
|
|
690
|
+
|
|
691
|
+
r,g,b = self._map_channels_or_special(pal, ha, oo, si)
|
|
692
|
+
if any(ch is None for ch in (r,g,b)):
|
|
693
|
+
QMessageBox.critical(self, "Palette Error", f"Could not build palette {pal}."); return
|
|
694
|
+
|
|
695
|
+
r = np.clip(np.nan_to_num(r), 0, 1)
|
|
696
|
+
g = np.clip(np.nan_to_num(g), 0, 1)
|
|
697
|
+
b = np.clip(np.nan_to_num(b), 0, 1)
|
|
698
|
+
rgb = np.stack([r,g,b], axis=2).astype(np.float32)
|
|
699
|
+
|
|
700
|
+
mx = float(rgb.max()) or 1.0
|
|
701
|
+
self.final = (rgb / mx).astype(np.float32)
|
|
702
|
+
|
|
703
|
+
# Fit only when there wasn't an existing preview yet
|
|
704
|
+
first = (self._base_pm is None)
|
|
705
|
+
self._set_preview_image(self._to_qimage(self.final), fit=first, preserve_view=True)
|
|
706
|
+
self.status.setText(f"Preview generated: {pal}")
|
|
707
|
+
|
|
708
|
+
def _set_preview_image(self, qimg: QImage, *, fit: bool = False, preserve_view: bool = True):
|
|
709
|
+
state = None
|
|
710
|
+
if preserve_view and (not fit) and (self._base_pm is not None):
|
|
711
|
+
state = self._capture_view_state()
|
|
712
|
+
|
|
713
|
+
self._base_pm = QPixmap.fromImage(qimg)
|
|
714
|
+
|
|
715
|
+
# If we’re fitting, ignore old zoom/pan.
|
|
716
|
+
if fit or state is None:
|
|
717
|
+
self._zoom = 1.0
|
|
718
|
+
self._update_preview_pixmap()
|
|
719
|
+
if fit:
|
|
720
|
+
QTimer.singleShot(0, self._fit_to_preview)
|
|
721
|
+
else:
|
|
722
|
+
QTimer.singleShot(0, self._center_scrollbars)
|
|
723
|
+
return
|
|
724
|
+
|
|
725
|
+
# restore prior zoom/pan
|
|
726
|
+
self._restore_view_state(state)
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def _update_preview_pixmap(self):
|
|
730
|
+
if self._base_pm is None:
|
|
731
|
+
return
|
|
732
|
+
# explicit int size (QSize * float can crash on some PyQt6 builds)
|
|
733
|
+
base_sz = self._base_pm.size()
|
|
734
|
+
w = max(1, int(base_sz.width() * self._zoom))
|
|
735
|
+
h = max(1, int(base_sz.height() * self._zoom))
|
|
736
|
+
scaled = self._base_pm.scaled(
|
|
737
|
+
w, h,
|
|
738
|
+
Qt.AspectRatioMode.KeepAspectRatio,
|
|
739
|
+
Qt.TransformationMode.SmoothTransformation
|
|
740
|
+
)
|
|
741
|
+
self.preview.setPixmap(scaled)
|
|
742
|
+
self.preview.resize(scaled.size())
|
|
743
|
+
|
|
744
|
+
def _set_zoom(self, new_zoom: float):
|
|
745
|
+
self._zoom = max(self._min_zoom, min(self._max_zoom, new_zoom))
|
|
746
|
+
self._update_preview_pixmap()
|
|
747
|
+
|
|
748
|
+
def _zoom_at(self, factor: float = 1.25, anchor_vp: QPoint | None = None):
|
|
749
|
+
if self._base_pm is None:
|
|
750
|
+
return
|
|
751
|
+
|
|
752
|
+
vp = self.scroll.viewport()
|
|
753
|
+
if anchor_vp is None:
|
|
754
|
+
anchor_vp = QPoint(vp.width() // 2, vp.height() // 2) # view center
|
|
755
|
+
|
|
756
|
+
# label coords under the anchor *before* zoom
|
|
757
|
+
lbl_before = self.preview.mapFrom(vp, anchor_vp)
|
|
758
|
+
|
|
759
|
+
old_zoom = self._zoom
|
|
760
|
+
new_zoom = max(self._min_zoom, min(self._max_zoom, old_zoom * factor))
|
|
761
|
+
ratio = new_zoom / max(old_zoom, 1e-6)
|
|
762
|
+
if abs(ratio - 1.0) < 1e-6:
|
|
763
|
+
return
|
|
764
|
+
|
|
765
|
+
# apply zoom (updates label size & scrollbar ranges)
|
|
766
|
+
self._zoom = new_zoom
|
|
767
|
+
self._update_preview_pixmap()
|
|
768
|
+
|
|
769
|
+
# desired label coords *after* zoom
|
|
770
|
+
lbl_after_x = int(lbl_before.x() * ratio)
|
|
771
|
+
lbl_after_y = int(lbl_before.y() * ratio)
|
|
772
|
+
|
|
773
|
+
# move scrollbars so anchor_vp keeps the same content point
|
|
774
|
+
hbar = self.scroll.horizontalScrollBar()
|
|
775
|
+
vbar = self.scroll.verticalScrollBar()
|
|
776
|
+
hbar.setValue(max(hbar.minimum(), min(hbar.maximum(), lbl_after_x - anchor_vp.x())))
|
|
777
|
+
vbar.setValue(max(vbar.minimum(), min(vbar.maximum(), lbl_after_y - anchor_vp.y())))
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def _fit_to_preview(self):
|
|
781
|
+
if self._base_pm is None:
|
|
782
|
+
return
|
|
783
|
+
vp = self.scroll.viewport().size()
|
|
784
|
+
pm = self._base_pm.size()
|
|
785
|
+
if pm.width() == 0 or pm.height() == 0:
|
|
786
|
+
return
|
|
787
|
+
k = min(vp.width() / pm.width(), vp.height() / pm.height())
|
|
788
|
+
self._set_zoom(max(self._min_zoom, min(self._max_zoom, k)))
|
|
789
|
+
self._center_scrollbars()
|
|
790
|
+
|
|
791
|
+
def _center_scrollbars(self):
|
|
792
|
+
# center the view on the image
|
|
793
|
+
h = self.scroll.horizontalScrollBar()
|
|
794
|
+
v = self.scroll.verticalScrollBar()
|
|
795
|
+
h.setValue((h.maximum() + h.minimum()) // 2)
|
|
796
|
+
v.setValue((v.maximum() + v.minimum()) // 2)
|
|
797
|
+
|
|
798
|
+
def _map_channels_or_special(self, name, ha, oo, si):
|
|
799
|
+
# substitution
|
|
800
|
+
if ha is None and si is not None: ha = si
|
|
801
|
+
if si is None and ha is not None: si = ha
|
|
802
|
+
|
|
803
|
+
basic = {
|
|
804
|
+
"SHO": (si, ha, oo),
|
|
805
|
+
"HOO": (ha, oo, oo),
|
|
806
|
+
"HSO": (ha, si, oo),
|
|
807
|
+
"HOS": (ha, oo, si),
|
|
808
|
+
"OSS": (oo, si, si),
|
|
809
|
+
"OHH": (oo, ha, ha),
|
|
810
|
+
"OSH": (oo, si, ha),
|
|
811
|
+
"OHS": (oo, ha, si),
|
|
812
|
+
"HSS": (ha, si, si),
|
|
813
|
+
}
|
|
814
|
+
if name in basic:
|
|
815
|
+
return basic[name]
|
|
816
|
+
|
|
817
|
+
try:
|
|
818
|
+
if name == "Realistic1":
|
|
819
|
+
r = (ha + si)/2 if (ha is not None and si is not None) else (ha if ha is not None else 0)
|
|
820
|
+
g = 0.3*(ha if ha is not None else 0) + 0.7*(oo if oo is not None else 0)
|
|
821
|
+
b = 0.9*(oo if oo is not None else 0) + 0.1*(ha if ha is not None else 0)
|
|
822
|
+
return r,g,b
|
|
823
|
+
if name == "Realistic2":
|
|
824
|
+
r = 0.7*(ha if ha is not None else 0) + 0.3*(si if si is not None else 0)
|
|
825
|
+
g = 0.3*(si if si is not None else 0) + 0.7*(oo if oo is not None else 0)
|
|
826
|
+
b = (oo if oo is not None else 0)
|
|
827
|
+
return r,g,b
|
|
828
|
+
if name == "Foraxx":
|
|
829
|
+
if ha is not None and oo is not None and si is None:
|
|
830
|
+
r = ha; b = oo
|
|
831
|
+
t = ha * oo
|
|
832
|
+
g = (t**(1 - t))*ha + (1 - (t**(1 - t)))*oo
|
|
833
|
+
return r,g,b
|
|
834
|
+
if ha is not None and oo is not None and si is not None:
|
|
835
|
+
t = np.clip(oo, 1e-6, 1.0)**(1 - np.clip(oo, 1e-6, 1.0))
|
|
836
|
+
r = t*si + (1 - t)*ha
|
|
837
|
+
t2 = ha * oo
|
|
838
|
+
g = (t2**(1 - t2))*ha + (1 - (t2**(1 - t2)))*oo
|
|
839
|
+
b = oo
|
|
840
|
+
return r,g,b
|
|
841
|
+
return basic["SHO"]
|
|
842
|
+
except Exception:
|
|
843
|
+
return basic.get("SHO", (ha, oo, si))
|
|
844
|
+
|
|
845
|
+
return basic.get("SHO", (ha, oo, si))
|
|
846
|
+
|
|
847
|
+
# ------------- push to new subwindow -------------
|
|
848
|
+
# ------------- push to new subwindow -------------
|
|
849
|
+
def _get_doc_manager(self):
|
|
850
|
+
"""
|
|
851
|
+
Try several ways to get a DocManager:
|
|
852
|
+
1) explicit doc_manager passed into PerfectPalettePicker
|
|
853
|
+
2) main window's .docman or .doc_manager attribute
|
|
854
|
+
"""
|
|
855
|
+
if self.doc_manager is not None:
|
|
856
|
+
return self.doc_manager
|
|
857
|
+
|
|
858
|
+
mw = self._find_main_window()
|
|
859
|
+
if mw is None:
|
|
860
|
+
return None
|
|
861
|
+
|
|
862
|
+
return getattr(mw, "docman", None) or getattr(mw, "doc_manager", None)
|
|
863
|
+
|
|
864
|
+
def _push_final(self):
|
|
865
|
+
if self.final is None:
|
|
866
|
+
QMessageBox.warning(self, "No Image", "Generate a palette first.")
|
|
867
|
+
return
|
|
868
|
+
|
|
869
|
+
# Use the SAME prepared channels the palette was built with
|
|
870
|
+
ha_prep, oo_prep, si_prep = self._prepared_channels()
|
|
871
|
+
if oo_prep is None or (ha_prep is None and si_prep is None):
|
|
872
|
+
QMessageBox.warning(self, "Need Channels", "Load at least OIII + (Ha or SII).")
|
|
873
|
+
return
|
|
874
|
+
|
|
875
|
+
dlg = PaletteAdjustDialog(
|
|
876
|
+
base_rgb = self.final, # fully formed palette
|
|
877
|
+
palette_name = self.current_palette or "SHO",
|
|
878
|
+
ha_src = ha_prep, # prepared (stretched/OSC-synth)
|
|
879
|
+
oiii_src = oo_prep,
|
|
880
|
+
sii_src = si_prep,
|
|
881
|
+
owner = self
|
|
882
|
+
)
|
|
883
|
+
adjusted = {"img": None}
|
|
884
|
+
dlg.adjusted_image.connect(lambda img: adjusted.__setitem__("img", img))
|
|
885
|
+
dlg.exec()
|
|
886
|
+
|
|
887
|
+
if adjusted["img"] is None:
|
|
888
|
+
return # user canceled
|
|
889
|
+
|
|
890
|
+
# Update preview with adjusted result and set as final
|
|
891
|
+
self.final = adjusted["img"]
|
|
892
|
+
self._set_preview_image(self._to_qimage(self.final))
|
|
893
|
+
|
|
894
|
+
title = self.current_palette or "Palette"
|
|
895
|
+
|
|
896
|
+
# ---- get DocManager the robust way ----
|
|
897
|
+
dm = self._get_doc_manager()
|
|
898
|
+
|
|
899
|
+
if dm is None:
|
|
900
|
+
# Fallback: open a simple viewer instead of erroring out
|
|
901
|
+
viewer = QDialog(self)
|
|
902
|
+
viewer.setWindowTitle(title)
|
|
903
|
+
vlayout = QVBoxLayout(viewer)
|
|
904
|
+
lbl = QLabel()
|
|
905
|
+
lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
906
|
+
lbl.setPixmap(QPixmap.fromImage(self._to_qimage(self.final)))
|
|
907
|
+
vlayout.addWidget(lbl)
|
|
908
|
+
viewer.resize(lbl.pixmap().size())
|
|
909
|
+
viewer.show()
|
|
910
|
+
# keep ref so it isn't GC'd
|
|
911
|
+
self._last_popup_viewer = viewer
|
|
912
|
+
self.status.setText("DocManager not found; opened palette in stand-alone viewer.")
|
|
913
|
+
return
|
|
914
|
+
|
|
915
|
+
# ---- normal SAS path: create a new document ----
|
|
916
|
+
try:
|
|
917
|
+
if hasattr(dm, "open_array"):
|
|
918
|
+
# many of your tools already use this signature
|
|
919
|
+
doc = dm.open_array(self.final, metadata={"is_mono": False}, title=title)
|
|
920
|
+
elif hasattr(dm, "create_document"):
|
|
921
|
+
doc = dm.create_document(image=self.final, metadata={"is_mono": False}, name=title)
|
|
922
|
+
else:
|
|
923
|
+
raise RuntimeError("DocManager lacks open_array/create_document")
|
|
924
|
+
|
|
925
|
+
# If DocManager or main window auto-spawns subwindows on new docs,
|
|
926
|
+
# this is all we need. If not, you can optionally keep the
|
|
927
|
+
# _spawn_subwindow_for hook here.
|
|
928
|
+
self.status.setText("Opened final palette in a new view.")
|
|
929
|
+
except Exception as e:
|
|
930
|
+
QMessageBox.critical(self, "Error", f"Failed to open new view:\n{e}")
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
|
|
934
|
+
# ------------- utilities -------------
|
|
935
|
+
def _clear_channels(self):
|
|
936
|
+
self.ha = self.oiii = self.sii = self.osc1 = self.osc2 = None
|
|
937
|
+
self._stretched.clear()
|
|
938
|
+
self.final = None
|
|
939
|
+
self.preview.clear()
|
|
940
|
+
for which in ("Ha","OIII","SII","OSC1","OSC2"):
|
|
941
|
+
self._set_status_label(which, None)
|
|
942
|
+
for name, b in self._thumb_buttons.items():
|
|
943
|
+
b.setIcon(QIcon())
|
|
944
|
+
self._thumb_base_pm.clear()
|
|
945
|
+
self._selected_name = None
|
|
946
|
+
for b in self._thumb_buttons.values():
|
|
947
|
+
b.setIcon(QIcon())
|
|
948
|
+
self.status.setText("Cleared all loaded channels.")
|
|
949
|
+
|
|
950
|
+
def _as_float01(self, arr):
|
|
951
|
+
a = np.asarray(arr)
|
|
952
|
+
if a.dtype == np.uint8: return a.astype(np.float32)/255.0
|
|
953
|
+
if a.dtype == np.uint16: return a.astype(np.float32)/65535.0
|
|
954
|
+
return np.clip(a.astype(np.float32), 0.0, 1.0)
|
|
955
|
+
|
|
956
|
+
def _stretch_input(self, img):
|
|
957
|
+
"""Run statistical stretch on mono or color inputs (target_median=0.25)."""
|
|
958
|
+
if img.ndim == 2:
|
|
959
|
+
return np.clip(stretch_mono_image(img, target_median=0.25), 0.0, 1.0)
|
|
960
|
+
if img.ndim == 3 and img.shape[2] == 3:
|
|
961
|
+
return np.clip(stretch_color_image(img, target_median=0.25, linked=False), 0.0, 1.0)
|
|
962
|
+
if img.ndim == 3 and img.shape[2] == 1:
|
|
963
|
+
mono = img[...,0]
|
|
964
|
+
return np.clip(stretch_mono_image(mono, target_median=0.25), 0.0, 1.0)
|
|
965
|
+
return img
|
|
966
|
+
|
|
967
|
+
def _to_qimage(self, arr):
|
|
968
|
+
a = np.clip(arr, 0, 1)
|
|
969
|
+
if a.ndim == 2:
|
|
970
|
+
u = (a * 255).astype(np.uint8); h, w = u.shape
|
|
971
|
+
return QImage(u.data, w, h, w, QImage.Format.Format_Grayscale8).copy()
|
|
972
|
+
if a.ndim == 3 and a.shape[2] == 3:
|
|
973
|
+
u = (a * 255).astype(np.uint8); h, w, _ = u.shape
|
|
974
|
+
return QImage(u.data, w, h, w*3, QImage.Format.Format_RGB888).copy()
|
|
975
|
+
raise ValueError(f"Unexpected image shape: {a.shape}")
|
|
976
|
+
|
|
977
|
+
def _find_main_window(self):
|
|
978
|
+
w = self
|
|
979
|
+
from PyQt6.QtWidgets import QMainWindow, QApplication
|
|
980
|
+
while w is not None and not isinstance(w, QMainWindow):
|
|
981
|
+
w = w.parentWidget()
|
|
982
|
+
if w: return w
|
|
983
|
+
for tlw in QApplication.topLevelWidgets():
|
|
984
|
+
if isinstance(tlw, QMainWindow):
|
|
985
|
+
return tlw
|
|
986
|
+
return None
|
|
987
|
+
|
|
988
|
+
def _list_open_views(self):
|
|
989
|
+
mw = self._find_main_window()
|
|
990
|
+
if not mw:
|
|
991
|
+
return []
|
|
992
|
+
try:
|
|
993
|
+
from setiastro.saspro.subwindow import ImageSubWindow
|
|
994
|
+
subs = mw.findChildren(ImageSubWindow)
|
|
995
|
+
except Exception:
|
|
996
|
+
subs = []
|
|
997
|
+
out = []
|
|
998
|
+
for sw in subs:
|
|
999
|
+
title = getattr(sw, "view_title", None) or sw.windowTitle() or getattr(sw.document, "display_name", lambda: "Untitled")()
|
|
1000
|
+
out.append((str(title), sw))
|
|
1001
|
+
return out
|
|
1002
|
+
|
|
1003
|
+
def eventFilter(self, obj, ev):
|
|
1004
|
+
# Ctrl+wheel = zoom at mouse (no scrolling). Wheel without Ctrl = eaten.
|
|
1005
|
+
if ev.type() == QEvent.Type.Wheel and (
|
|
1006
|
+
obj is self.preview
|
|
1007
|
+
or obj is self.scroll
|
|
1008
|
+
or obj is self.scroll.viewport()
|
|
1009
|
+
or obj is self.scroll.horizontalScrollBar()
|
|
1010
|
+
or obj is self.scroll.verticalScrollBar()
|
|
1011
|
+
):
|
|
1012
|
+
# always stop the wheel from scrolling
|
|
1013
|
+
ev.accept()
|
|
1014
|
+
|
|
1015
|
+
# Zoom only when Ctrl is held
|
|
1016
|
+
if ev.modifiers() & Qt.KeyboardModifier.ControlModifier:
|
|
1017
|
+
factor = 1.25 if ev.angleDelta().y() > 0 else 0.8
|
|
1018
|
+
|
|
1019
|
+
# Get mouse position in global screen coords and map into the viewport
|
|
1020
|
+
vp = self.scroll.viewport()
|
|
1021
|
+
anchor_vp = vp.mapFromGlobal(ev.globalPosition().toPoint())
|
|
1022
|
+
|
|
1023
|
+
# Clamp to viewport rect (robust if the event originated on scrollbars)
|
|
1024
|
+
r = vp.rect()
|
|
1025
|
+
if not r.contains(anchor_vp):
|
|
1026
|
+
anchor_vp.setX(max(r.left(), min(r.right(), anchor_vp.x())))
|
|
1027
|
+
anchor_vp.setY(max(r.top(), min(r.bottom(), anchor_vp.y())))
|
|
1028
|
+
|
|
1029
|
+
self._zoom_at(factor, anchor_vp)
|
|
1030
|
+
return True
|
|
1031
|
+
# click-drag pan on viewport
|
|
1032
|
+
if obj is self.scroll.viewport():
|
|
1033
|
+
if ev.type() == QEvent.Type.MouseButtonPress and ev.button() == Qt.MouseButton.LeftButton:
|
|
1034
|
+
self._panning = True
|
|
1035
|
+
self._pan_last = ev.position().toPoint()
|
|
1036
|
+
self.scroll.viewport().setCursor(QCursor(Qt.CursorShape.ClosedHandCursor))
|
|
1037
|
+
return True
|
|
1038
|
+
if ev.type() == QEvent.Type.MouseMove and self._panning:
|
|
1039
|
+
cur = ev.position().toPoint()
|
|
1040
|
+
delta = cur - (self._pan_last or cur)
|
|
1041
|
+
self._pan_last = cur
|
|
1042
|
+
h = self.scroll.horizontalScrollBar()
|
|
1043
|
+
v = self.scroll.verticalScrollBar()
|
|
1044
|
+
h.setValue(h.value() - delta.x())
|
|
1045
|
+
v.setValue(v.value() - delta.y())
|
|
1046
|
+
return True
|
|
1047
|
+
if ev.type() == QEvent.Type.MouseButtonRelease and ev.button() == Qt.MouseButton.LeftButton:
|
|
1048
|
+
self._panning = False
|
|
1049
|
+
self._pan_last = None
|
|
1050
|
+
self.scroll.viewport().setCursor(QCursor(Qt.CursorShape.ArrowCursor))
|
|
1051
|
+
return True
|
|
1052
|
+
|
|
1053
|
+
return super().eventFilter(obj, ev)
|