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,1397 @@
|
|
|
1
|
+
# pro/convo.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import math
|
|
6
|
+
import numpy as np
|
|
7
|
+
from typing import Optional, Tuple
|
|
8
|
+
from functools import lru_cache
|
|
9
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
10
|
+
|
|
11
|
+
# ── SciPy / scikit-image
|
|
12
|
+
from scipy.signal import fftconvolve
|
|
13
|
+
from scipy.ndimage import laplace
|
|
14
|
+
from numpy.fft import fft2, ifft2, ifftshift
|
|
15
|
+
|
|
16
|
+
from skimage.restoration import denoise_tv_chambolle, denoise_bilateral
|
|
17
|
+
from skimage.color import rgb2lab, lab2rgb
|
|
18
|
+
from skimage.util import img_as_float32
|
|
19
|
+
from skimage.transform import warp, AffineTransform
|
|
20
|
+
|
|
21
|
+
# ── Qt
|
|
22
|
+
from PyQt6.QtCore import Qt, pyqtSignal
|
|
23
|
+
from PyQt6.QtGui import QDoubleValidator, QImage, QPainter, QPen, QColor, QIcon, QPixmap
|
|
24
|
+
from PyQt6.QtWidgets import (
|
|
25
|
+
QApplication, QMessageBox,
|
|
26
|
+
QDialog, QHBoxLayout, QVBoxLayout, QFrame, QLabel, QSlider, QLineEdit,
|
|
27
|
+
QFormLayout, QTabWidget, QComboBox, QCheckBox, QPushButton, QToolButton,
|
|
28
|
+
QGraphicsView, QGraphicsScene, QGraphicsPixmapItem, QFileDialog, QWidget,
|
|
29
|
+
QSpinBox
|
|
30
|
+
)
|
|
31
|
+
import cv2
|
|
32
|
+
# Optional FITS export
|
|
33
|
+
from astropy.io import fits
|
|
34
|
+
|
|
35
|
+
import sep # PSF estimator
|
|
36
|
+
|
|
37
|
+
# Import centralized widgets
|
|
38
|
+
from setiastro.saspro.widgets.spinboxes import CustomSpinBox
|
|
39
|
+
from setiastro.saspro.widgets.themed_buttons import themed_toolbtn
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# --- GraphicsView with Shift+Click LS center + optional scene ctor -----------
|
|
43
|
+
class InteractiveGraphicsView(QGraphicsView):
|
|
44
|
+
def __init__(self, scene: QGraphicsScene | None = None, parent=None):
|
|
45
|
+
super().__init__(parent)
|
|
46
|
+
if scene is not None:
|
|
47
|
+
self.setScene(scene)
|
|
48
|
+
self.ls_center: Optional[Tuple[float, float]] = None
|
|
49
|
+
self.cross_items = []
|
|
50
|
+
|
|
51
|
+
def mousePressEvent(self, event):
|
|
52
|
+
if (event.modifiers() & Qt.KeyboardModifier.ShiftModifier) and event.button() == Qt.MouseButton.LeftButton:
|
|
53
|
+
scene_pt = self.mapToScene(event.position().toPoint())
|
|
54
|
+
x, y = scene_pt.x(), scene_pt.y()
|
|
55
|
+
self.ls_center = (x, y)
|
|
56
|
+
self._draw_crosshair_at(x, y)
|
|
57
|
+
return
|
|
58
|
+
super().mousePressEvent(event)
|
|
59
|
+
|
|
60
|
+
def _draw_crosshair_at(self, x: float, y: float):
|
|
61
|
+
for item in self.cross_items:
|
|
62
|
+
self.scene().removeItem(item)
|
|
63
|
+
self.cross_items.clear()
|
|
64
|
+
size = 10
|
|
65
|
+
pen = QPen(QColor(255, 0, 0), 2)
|
|
66
|
+
hline = self.scene().addLine(x - size, y, x + size, y, pen)
|
|
67
|
+
vline = self.scene().addLine(x, y - size, x, y + size, pen)
|
|
68
|
+
self.cross_items.extend([hline, vline])
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class FloatSliderWithEdit(QWidget):
|
|
72
|
+
"""
|
|
73
|
+
Integer slider + float line edit, mapped by fixed step; emits valueChanged(float)
|
|
74
|
+
"""
|
|
75
|
+
valueChanged = pyqtSignal(float)
|
|
76
|
+
|
|
77
|
+
def __init__(self, *, minimum: float, maximum: float, step: float, initial: float, suffix: str = "", parent=None):
|
|
78
|
+
super().__init__(parent)
|
|
79
|
+
self._min = minimum
|
|
80
|
+
self._max = maximum
|
|
81
|
+
self._step = step
|
|
82
|
+
self._suffix = suffix
|
|
83
|
+
self._factor = 1.0 / step
|
|
84
|
+
self._int_min = int(round(minimum * self._factor))
|
|
85
|
+
self._int_max = int(round(maximum * self._factor))
|
|
86
|
+
|
|
87
|
+
layout = QHBoxLayout(self)
|
|
88
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
89
|
+
|
|
90
|
+
self.slider = QSlider(Qt.Orientation.Horizontal, self)
|
|
91
|
+
self.slider.setRange(self._int_min, self._int_max)
|
|
92
|
+
layout.addWidget(self.slider, stretch=1)
|
|
93
|
+
|
|
94
|
+
self.edit = QLineEdit(self)
|
|
95
|
+
self.edit.setFixedWidth(60)
|
|
96
|
+
validator = QDoubleValidator(minimum, maximum, int(abs(np.log10(step))), self)
|
|
97
|
+
validator.setNotation(QDoubleValidator.Notation.StandardNotation)
|
|
98
|
+
self.edit.setValidator(validator)
|
|
99
|
+
layout.addWidget(self.edit)
|
|
100
|
+
|
|
101
|
+
self.setValue(initial)
|
|
102
|
+
self.slider.valueChanged.connect(self._on_slider_changed)
|
|
103
|
+
self.edit.editingFinished.connect(self._on_edit_finished)
|
|
104
|
+
|
|
105
|
+
def _on_slider_changed(self, int_val: int):
|
|
106
|
+
f = int_val / self._factor
|
|
107
|
+
f = min(max(f, self._min), self._max)
|
|
108
|
+
text = f"{f:.{max(0, int(-np.log10(self._step)))}f}{self._suffix}"
|
|
109
|
+
self.edit.blockSignals(True)
|
|
110
|
+
self.edit.setText(text)
|
|
111
|
+
self.edit.blockSignals(False)
|
|
112
|
+
self.valueChanged.emit(f)
|
|
113
|
+
|
|
114
|
+
def _on_edit_finished(self):
|
|
115
|
+
txt = self.edit.text().rstrip(self._suffix)
|
|
116
|
+
try:
|
|
117
|
+
f = float(txt)
|
|
118
|
+
except ValueError:
|
|
119
|
+
f = self.slider.value() / self._factor
|
|
120
|
+
f = min(max(f, self._min), self._max)
|
|
121
|
+
int_val = int(round(f * self._factor))
|
|
122
|
+
self.slider.blockSignals(True)
|
|
123
|
+
self.slider.setValue(int_val)
|
|
124
|
+
self.slider.blockSignals(False)
|
|
125
|
+
|
|
126
|
+
def value(self) -> float:
|
|
127
|
+
return self.slider.value() / self._factor
|
|
128
|
+
|
|
129
|
+
def setValue(self, f: float):
|
|
130
|
+
f = min(max(f, self._min), self._max)
|
|
131
|
+
int_val = int(round(f * self._factor))
|
|
132
|
+
self.slider.blockSignals(True)
|
|
133
|
+
self.slider.setValue(int_val)
|
|
134
|
+
self.slider.blockSignals(False)
|
|
135
|
+
s = f"{(int_val / self._factor):.{max(0, int(-np.log10(self._step)))}f}{self._suffix}"
|
|
136
|
+
self.edit.setText(s)
|
|
137
|
+
self.valueChanged.emit(int_val / self._factor)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ============= Convo/Deconvo dialog (DocManager-powered) =====================
|
|
141
|
+
class ConvoDeconvoDialog(QDialog):
|
|
142
|
+
"""
|
|
143
|
+
SASpro version: takes a DocManager, no ImageManager dependency.
|
|
144
|
+
"""
|
|
145
|
+
def __init__(self, doc_manager, parent=None, doc=None):
|
|
146
|
+
super().__init__(parent)
|
|
147
|
+
self.doc_manager = doc_manager
|
|
148
|
+
self._main = parent # keep a ref to the main window (has _active_doc + signal)
|
|
149
|
+
self._doc_override = doc # ← explicit doc (ROI or full) from the MDI
|
|
150
|
+
|
|
151
|
+
# Only follow global active-doc changes if we *weren't* given a doc
|
|
152
|
+
if hasattr(self._main, "currentDocumentChanged") and self._doc_override is None:
|
|
153
|
+
self._main.currentDocumentChanged.connect(self._on_active_doc_changed)
|
|
154
|
+
|
|
155
|
+
self.setWindowTitle("Convolution / Deconvolution")
|
|
156
|
+
self.resize(1000, 650)
|
|
157
|
+
self._use_custom_psf = False
|
|
158
|
+
self._custom_psf: Optional[np.ndarray] = None
|
|
159
|
+
self._last_stellar_psf: Optional[np.ndarray] = None
|
|
160
|
+
self._original_image: Optional[np.ndarray] = None
|
|
161
|
+
self._preview_result: Optional[np.ndarray] = None
|
|
162
|
+
self._auto_fit = False
|
|
163
|
+
self._load_original_on_show = True
|
|
164
|
+
|
|
165
|
+
# ── Layout: left controls / right preview
|
|
166
|
+
main_layout = QHBoxLayout(self)
|
|
167
|
+
# Left
|
|
168
|
+
left_panel = QFrame(); left_panel.setFrameShape(QFrame.Shape.StyledPanel); left_panel.setFixedWidth(350)
|
|
169
|
+
left_layout = QVBoxLayout(left_panel); main_layout.addWidget(left_panel)
|
|
170
|
+
# Right
|
|
171
|
+
preview_panel = QFrame(); preview_layout = QVBoxLayout(preview_panel); main_layout.addWidget(preview_panel, stretch=1)
|
|
172
|
+
|
|
173
|
+
# Tabs
|
|
174
|
+
self.tabs = QTabWidget(); left_layout.addWidget(self.tabs)
|
|
175
|
+
self.deconv_param_stack: dict[str, QWidget] = {}
|
|
176
|
+
self._build_convolution_tab()
|
|
177
|
+
self._build_deconvolution_tab()
|
|
178
|
+
self._build_psf_estimator_tab()
|
|
179
|
+
self._build_tv_denoise_tab()
|
|
180
|
+
|
|
181
|
+
# PSF preview chip
|
|
182
|
+
self.conv_psf_label = QLabel(); self.conv_psf_label.setFixedSize(64, 64)
|
|
183
|
+
self.conv_psf_label.setStyleSheet("border: 1px solid #888;")
|
|
184
|
+
left_layout.addWidget(self.conv_psf_label, alignment=Qt.AlignmentFlag.AlignHCenter)
|
|
185
|
+
|
|
186
|
+
# Strength
|
|
187
|
+
self.strength_slider = FloatSliderWithEdit(minimum=0.0, maximum=1.0, step=0.01, initial=1.0, suffix="")
|
|
188
|
+
srow = QHBoxLayout(); srow.addWidget(QLabel("Strength:")); srow.addWidget(self.strength_slider)
|
|
189
|
+
left_layout.addLayout(srow)
|
|
190
|
+
|
|
191
|
+
# Buttons
|
|
192
|
+
row1 = QHBoxLayout()
|
|
193
|
+
self.preview_btn = QPushButton("Preview")
|
|
194
|
+
self.undo_btn = QPushButton("Undo")
|
|
195
|
+
self.close_btn = QPushButton("Close")
|
|
196
|
+
row1.addWidget(self.preview_btn); row1.addWidget(self.undo_btn)
|
|
197
|
+
left_layout.addLayout(row1)
|
|
198
|
+
|
|
199
|
+
row2 = QHBoxLayout()
|
|
200
|
+
self.push_btn = QPushButton("Push")
|
|
201
|
+
row2.addWidget(self.push_btn); row2.addWidget(self.close_btn)
|
|
202
|
+
left_layout.addLayout(row2)
|
|
203
|
+
|
|
204
|
+
left_layout.addStretch()
|
|
205
|
+
self.rl_status_label = QLabel(""); self.rl_status_label.setStyleSheet("color:#fff;background:#333;padding:4px;")
|
|
206
|
+
self.rl_status_label.setFixedHeight(24)
|
|
207
|
+
left_layout.addWidget(self.rl_status_label)
|
|
208
|
+
|
|
209
|
+
# Zoom & Preview
|
|
210
|
+
zrow = QHBoxLayout(); zrow.addStretch()
|
|
211
|
+
self.zoom_in_btn = QToolButton(); self.zoom_in_btn.setIcon(QIcon.fromTheme("zoom-in")); self.zoom_in_btn.setToolTip("Zoom In")
|
|
212
|
+
self.zoom_out_btn= QToolButton(); self.zoom_out_btn.setIcon(QIcon.fromTheme("zoom-out")); self.zoom_out_btn.setToolTip("Zoom Out")
|
|
213
|
+
self.fit_btn = QToolButton(); self.fit_btn.setIcon(QIcon.fromTheme("zoom-fit-best")); self.fit_btn.setToolTip("Fit to Preview")
|
|
214
|
+
zrow.addWidget(self.zoom_in_btn); zrow.addWidget(self.zoom_out_btn); zrow.addWidget(self.fit_btn)
|
|
215
|
+
preview_layout.addLayout(zrow)
|
|
216
|
+
|
|
217
|
+
self.scene = QGraphicsScene()
|
|
218
|
+
self.view = InteractiveGraphicsView(self.scene)
|
|
219
|
+
self.view.setDragMode(QGraphicsView.DragMode.ScrollHandDrag)
|
|
220
|
+
self.view.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
|
|
221
|
+
self.pixmap_item = QGraphicsPixmapItem(); self.scene.addItem(self.pixmap_item)
|
|
222
|
+
preview_layout.addWidget(self.view)
|
|
223
|
+
|
|
224
|
+
# Signals
|
|
225
|
+
self.preview_btn.clicked.connect(self._on_preview)
|
|
226
|
+
self.undo_btn.clicked.connect(self._on_undo)
|
|
227
|
+
self.push_btn.clicked.connect(self._on_push_to_doc)
|
|
228
|
+
self.close_btn.clicked.connect(self.close)
|
|
229
|
+
|
|
230
|
+
self.zoom_in_btn.clicked.connect(self.zoom_in)
|
|
231
|
+
self.zoom_out_btn.clicked.connect(self.zoom_out)
|
|
232
|
+
self.fit_btn.clicked.connect(self._on_fit_clicked)
|
|
233
|
+
|
|
234
|
+
self.tabs.currentChanged.connect(self._update_psf_preview)
|
|
235
|
+
self.deconv_algo_combo.currentTextChanged.connect(self._update_psf_preview)
|
|
236
|
+
|
|
237
|
+
self.sep_run_button.clicked.connect(self._on_run_sep)
|
|
238
|
+
self.sep_use_button.clicked.connect(self._on_use_stellar_psf)
|
|
239
|
+
self.sep_save_button.clicked.connect(self._on_save_stellar_psf)
|
|
240
|
+
|
|
241
|
+
for s in (self.conv_radius_slider, self.conv_shape_slider, self.conv_aspect_slider, self.conv_rotation_slider):
|
|
242
|
+
s.valueChanged.connect(self._update_psf_preview)
|
|
243
|
+
for s in (self.rl_psf_radius_slider, self.rl_psf_shape_slider, self.rl_psf_aspect_slider, self.rl_psf_rotation_slider):
|
|
244
|
+
s.valueChanged.connect(self._update_psf_preview)
|
|
245
|
+
|
|
246
|
+
self._update_psf_preview()
|
|
247
|
+
|
|
248
|
+
def _active_doc(self):
|
|
249
|
+
# 1) If we were given a specific doc (ROI or full), always use that.
|
|
250
|
+
if getattr(self, "_doc_override", None) is not None:
|
|
251
|
+
return self._doc_override
|
|
252
|
+
|
|
253
|
+
# 2) Otherwise fall back to the MDI's notion of active
|
|
254
|
+
if self._main is not None and hasattr(self._main, "_active_doc") and callable(self._main._active_doc):
|
|
255
|
+
try:
|
|
256
|
+
return self._main._active_doc()
|
|
257
|
+
except Exception:
|
|
258
|
+
pass
|
|
259
|
+
|
|
260
|
+
# 3) Last resort: DocManager's active doc
|
|
261
|
+
if hasattr(self.doc_manager, "get_active_document"):
|
|
262
|
+
return self.doc_manager.get_active_document()
|
|
263
|
+
|
|
264
|
+
return None
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _on_active_doc_changed(self, doc):
|
|
268
|
+
# If this dialog is bound to a specific doc (ROI/full), ignore global changes
|
|
269
|
+
if getattr(self, "_doc_override", None) is not None:
|
|
270
|
+
return
|
|
271
|
+
|
|
272
|
+
img = getattr(doc, "image", None)
|
|
273
|
+
self._preview_result = None
|
|
274
|
+
self._original_image = img.copy() if isinstance(img, np.ndarray) else None
|
|
275
|
+
if self._original_image is not None:
|
|
276
|
+
self._auto_fit = True
|
|
277
|
+
self._display_in_view(self._original_image)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
# ---------------- DocManager IO helpers ----------------
|
|
281
|
+
def _get_active_image_and_meta(self) -> tuple[Optional[np.ndarray], dict]:
|
|
282
|
+
doc = self._active_doc()
|
|
283
|
+
if doc is None or getattr(doc, "image", None) is None:
|
|
284
|
+
return None, {}
|
|
285
|
+
return doc.image, (getattr(doc, "metadata", {}) or {})
|
|
286
|
+
|
|
287
|
+
# ---------------- Qt life-cycle ----------------
|
|
288
|
+
def showEvent(self, ev):
|
|
289
|
+
super().showEvent(ev)
|
|
290
|
+
self._preview_result = None
|
|
291
|
+
if self._load_original_on_show:
|
|
292
|
+
img, _ = self._get_active_image_and_meta()
|
|
293
|
+
if img is not None:
|
|
294
|
+
self._original_image = img.copy()
|
|
295
|
+
self._auto_fit = True
|
|
296
|
+
self._display_in_view(img)
|
|
297
|
+
self._load_original_on_show = False
|
|
298
|
+
self.conv_psf_label.clear()
|
|
299
|
+
self.sep_psf_preview.clear() if hasattr(self, "sep_psf_preview") else None
|
|
300
|
+
self._update_psf_preview()
|
|
301
|
+
|
|
302
|
+
def closeEvent(self, ev):
|
|
303
|
+
# Clear state so next open starts fresh
|
|
304
|
+
if hasattr(self.view, "ls_center"):
|
|
305
|
+
self.view.ls_center = None
|
|
306
|
+
self._original_image = None
|
|
307
|
+
self._preview_result = None
|
|
308
|
+
self._last_stellar_psf = None
|
|
309
|
+
self._custom_psf = None
|
|
310
|
+
self._use_custom_psf = False
|
|
311
|
+
self.conv_psf_label.clear() if hasattr(self, "conv_psf_label") else None
|
|
312
|
+
self.sep_psf_preview.clear() if hasattr(self, "sep_psf_preview") else None
|
|
313
|
+
self.rl_status_label.setText("") if hasattr(self, "rl_status_label") else None
|
|
314
|
+
self.custom_psf_bar.setVisible(False) if hasattr(self, "custom_psf_bar") else None
|
|
315
|
+
super().closeEvent(ev)
|
|
316
|
+
|
|
317
|
+
# ---------------- Build tabs ----------------
|
|
318
|
+
def _build_convolution_tab(self):
|
|
319
|
+
conv_tab = QWidget()
|
|
320
|
+
layout = QVBoxLayout(conv_tab)
|
|
321
|
+
form = QFormLayout(); form.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
|
|
322
|
+
|
|
323
|
+
self.conv_radius_slider = FloatSliderWithEdit(minimum=0.1, maximum=200.0, step=0.1, initial=5.0, suffix=" px")
|
|
324
|
+
form.addRow("Radius:", self.conv_radius_slider)
|
|
325
|
+
|
|
326
|
+
self.conv_shape_slider = FloatSliderWithEdit(minimum=0.1, maximum=10.0, step=0.1, initial=2.0, suffix="σ")
|
|
327
|
+
form.addRow("Kurtosis (σ):", self.conv_shape_slider)
|
|
328
|
+
|
|
329
|
+
self.conv_aspect_slider = FloatSliderWithEdit(minimum=0.1, maximum=10.0, step=0.1, initial=1.0, suffix="")
|
|
330
|
+
form.addRow("Aspect Ratio:", self.conv_aspect_slider)
|
|
331
|
+
|
|
332
|
+
self.conv_rotation_slider = FloatSliderWithEdit(minimum=0.0, maximum=360.0, step=1.0, initial=0.0, suffix="°")
|
|
333
|
+
form.addRow("Rotation:", self.conv_rotation_slider)
|
|
334
|
+
|
|
335
|
+
layout.addLayout(form); layout.addStretch()
|
|
336
|
+
self.tabs.addTab(conv_tab, "Convolution")
|
|
337
|
+
|
|
338
|
+
def _build_deconvolution_tab(self):
|
|
339
|
+
deconv_tab = QWidget()
|
|
340
|
+
outer_layout = QVBoxLayout(deconv_tab)
|
|
341
|
+
|
|
342
|
+
# Algo row
|
|
343
|
+
algo_layout = QHBoxLayout()
|
|
344
|
+
algo_layout.addWidget(QLabel("Algorithm:"))
|
|
345
|
+
self.deconv_algo_combo = QComboBox()
|
|
346
|
+
self.deconv_algo_combo.addItems(["Richardson-Lucy", "Wiener", "Larson-Sekanina", "Van Cittert"])
|
|
347
|
+
self.deconv_algo_combo.currentTextChanged.connect(self._on_deconv_algo_changed)
|
|
348
|
+
algo_layout.addWidget(self.deconv_algo_combo); algo_layout.addStretch()
|
|
349
|
+
outer_layout.addLayout(algo_layout)
|
|
350
|
+
|
|
351
|
+
# PSF sliders (shared for RL/Wiener)
|
|
352
|
+
self.psf_param_group = QWidget()
|
|
353
|
+
psf_group_layout = QFormLayout(self.psf_param_group)
|
|
354
|
+
psf_group_layout.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
|
|
355
|
+
|
|
356
|
+
self.rl_psf_radius_slider = FloatSliderWithEdit(minimum=0.1, maximum=100.0, step=0.1, initial=3.0, suffix=" px")
|
|
357
|
+
psf_group_layout.addRow("PSF Radius:", self.rl_psf_radius_slider)
|
|
358
|
+
self.rl_psf_shape_slider = FloatSliderWithEdit(minimum=0.1, maximum=10.0, step=0.1, initial=2.0, suffix="σ")
|
|
359
|
+
psf_group_layout.addRow("PSF Kurtosis (σ):", self.rl_psf_shape_slider)
|
|
360
|
+
self.rl_psf_aspect_slider = FloatSliderWithEdit(minimum=0.1, maximum=10.0, step=0.1, initial=1.0, suffix="")
|
|
361
|
+
psf_group_layout.addRow("PSF Aspect Ratio:", self.rl_psf_aspect_slider)
|
|
362
|
+
self.rl_psf_rotation_slider = FloatSliderWithEdit(minimum=0.0, maximum=360.0, step=1.0, initial=0.0, suffix="°")
|
|
363
|
+
psf_group_layout.addRow("PSF Rotation:", self.rl_psf_rotation_slider)
|
|
364
|
+
outer_layout.addWidget(self.psf_param_group)
|
|
365
|
+
self.psf_param_group.setVisible(self.deconv_algo_combo.currentText() in ("Richardson-Lucy", "Wiener"))
|
|
366
|
+
|
|
367
|
+
# “Using Stellar PSF” bar
|
|
368
|
+
self.custom_psf_bar = QWidget()
|
|
369
|
+
bar_layout = QHBoxLayout(self.custom_psf_bar); bar_layout.setContentsMargins(0, 0, 0, 0); bar_layout.setSpacing(4)
|
|
370
|
+
self.rl_custom_label = QLabel("Using Stellar PSF")
|
|
371
|
+
self.rl_custom_label.setStyleSheet("color:#fff;background-color:#007acc;padding:2px;")
|
|
372
|
+
self.rl_custom_label.setVisible(False)
|
|
373
|
+
self.rl_disable_custom_btn = QPushButton("Disable Stellar PSF")
|
|
374
|
+
self.rl_disable_custom_btn.setToolTip("Revert to PSF sliders")
|
|
375
|
+
self.rl_disable_custom_btn.setVisible(False)
|
|
376
|
+
self.rl_disable_custom_btn.clicked.connect(self._clear_custom_psf_flag)
|
|
377
|
+
bar_layout.addWidget(self.rl_custom_label); bar_layout.addWidget(self.rl_disable_custom_btn); bar_layout.addStretch()
|
|
378
|
+
outer_layout.addWidget(self.custom_psf_bar)
|
|
379
|
+
self.custom_psf_bar.setVisible(False)
|
|
380
|
+
|
|
381
|
+
# Stacked parameter panels
|
|
382
|
+
self.deconv_param_stack.clear()
|
|
383
|
+
self.deconv_stack_container = QWidget(); self.deconv_stack_layout = QVBoxLayout(self.deconv_stack_container)
|
|
384
|
+
|
|
385
|
+
# RL
|
|
386
|
+
rl_widget = QWidget()
|
|
387
|
+
rl_form = QFormLayout(rl_widget); rl_form.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
|
|
388
|
+
self.rl_iterations_slider = FloatSliderWithEdit(minimum=1.0, maximum=100.0, step=1.0, initial=30.0, suffix="")
|
|
389
|
+
rl_form.addRow("Iterations:", self.rl_iterations_slider)
|
|
390
|
+
self.rl_reg_combo = QComboBox(); self.rl_reg_combo.addItems(["None (Plain R–L)", "Tikhonov (L2)", "Total Variation (TV)"])
|
|
391
|
+
rl_form.addRow("Regularization:", self.rl_reg_combo)
|
|
392
|
+
self.rl_clip_checkbox = QCheckBox("Enable de‐ring"); self.rl_clip_checkbox.setChecked(True)
|
|
393
|
+
rl_form.addRow("", self.rl_clip_checkbox)
|
|
394
|
+
self.rl_luminance_only_checkbox = QCheckBox("Deconvolve L* Only"); self.rl_luminance_only_checkbox.setChecked(True)
|
|
395
|
+
self.rl_luminance_only_checkbox.setToolTip("If checked and the image is color, RL runs only on the L* channel.")
|
|
396
|
+
rl_form.addRow("", self.rl_luminance_only_checkbox)
|
|
397
|
+
rl_widget.setLayout(rl_form)
|
|
398
|
+
self.deconv_param_stack["Richardson-Lucy"] = rl_widget
|
|
399
|
+
|
|
400
|
+
# Wiener
|
|
401
|
+
wiener_widget = QWidget(); wiener_layout = QVBoxLayout(wiener_widget)
|
|
402
|
+
wiener_form = QFormLayout(); wiener_form.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
|
|
403
|
+
self.wiener_nsr_slider = FloatSliderWithEdit(minimum=0.0, maximum=1.0, step=0.001, initial=0.01, suffix="")
|
|
404
|
+
wiener_form.addRow("Noise/Signal (λ):", self.wiener_nsr_slider)
|
|
405
|
+
self.wiener_reg_combo = QComboBox(); self.wiener_reg_combo.addItems(["None (Classical Wiener)", "Tikhonov (L2)"])
|
|
406
|
+
wiener_form.addRow("Regularization:", self.wiener_reg_combo)
|
|
407
|
+
self.wiener_luminance_only_checkbox = QCheckBox("Deconvolve L* Only"); self.wiener_luminance_only_checkbox.setChecked(True)
|
|
408
|
+
self.wiener_luminance_only_checkbox.setToolTip("If checked and the image is color, Wiener runs only on the L* channel.")
|
|
409
|
+
wiener_form.addRow("", self.wiener_luminance_only_checkbox)
|
|
410
|
+
self.wiener_dering_checkbox = QCheckBox("Enable de-ring"); self.wiener_dering_checkbox.setChecked(True)
|
|
411
|
+
self.wiener_dering_checkbox.setToolTip("Applies a single bilateral pass after Wiener deconvolution")
|
|
412
|
+
wiener_form.addRow("", self.wiener_dering_checkbox)
|
|
413
|
+
wiener_layout.addLayout(wiener_form)
|
|
414
|
+
self.deconv_param_stack["Wiener"] = wiener_widget
|
|
415
|
+
|
|
416
|
+
# Larson–Sekanina
|
|
417
|
+
ls_widget = QWidget(); ls_form = QFormLayout(ls_widget); ls_form.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
|
|
418
|
+
self.ls_radial_slider = FloatSliderWithEdit(minimum=0.0, maximum=50.0, step=0.1, initial=0.0, suffix=" px")
|
|
419
|
+
self.ls_angular_slider = FloatSliderWithEdit(minimum=0.1, maximum=360.0, step=0.1, initial=1.0, suffix="°")
|
|
420
|
+
self.ls_operator_combo = QComboBox(); self.ls_operator_combo.addItems(["Divide", "Subtract"])
|
|
421
|
+
self.ls_blend_combo = QComboBox(); self.ls_blend_combo.addItems(["SoftLight", "Screen"])
|
|
422
|
+
ls_form.addRow("Radial Step (px):", self.ls_radial_slider)
|
|
423
|
+
ls_form.addRow("Angular Step (°):", self.ls_angular_slider)
|
|
424
|
+
ls_form.addRow("LS Operator:", self.ls_operator_combo)
|
|
425
|
+
ls_form.addRow("Blend Mode:", self.ls_blend_combo)
|
|
426
|
+
self.ls_operator_combo.currentTextChanged.connect(self._on_ls_operator_changed)
|
|
427
|
+
self.deconv_param_stack["Larson-Sekanina"] = ls_widget
|
|
428
|
+
|
|
429
|
+
# Van Cittert
|
|
430
|
+
vc_widget = QWidget(); vc_form = QFormLayout(vc_widget); vc_form.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
|
|
431
|
+
self.vc_iterations_slider = FloatSliderWithEdit(minimum=1, maximum=1000, step=1, initial=10, suffix="")
|
|
432
|
+
self.vc_relax_slider = FloatSliderWithEdit(minimum=0.0, maximum=1.0, step=0.01, initial=0.0, suffix="")
|
|
433
|
+
vc_form.addRow("Iterations:", self.vc_iterations_slider)
|
|
434
|
+
vc_form.addRow("Relaxation (0–1):", self.vc_relax_slider)
|
|
435
|
+
self.deconv_param_stack["Van Cittert"] = vc_widget
|
|
436
|
+
|
|
437
|
+
# Add all panels (hidden initially)
|
|
438
|
+
for widget in self.deconv_param_stack.values():
|
|
439
|
+
widget.setVisible(False)
|
|
440
|
+
self.deconv_stack_layout.addWidget(widget)
|
|
441
|
+
|
|
442
|
+
first_algo = self.deconv_algo_combo.currentText()
|
|
443
|
+
if first_algo in self.deconv_param_stack:
|
|
444
|
+
self.deconv_param_stack[first_algo].setVisible(True)
|
|
445
|
+
|
|
446
|
+
outer_layout.addWidget(self.deconv_stack_container)
|
|
447
|
+
outer_layout.addStretch()
|
|
448
|
+
self.tabs.addTab(deconv_tab, "Deconvolution")
|
|
449
|
+
|
|
450
|
+
# Clear “custom PSF” if sliders change
|
|
451
|
+
for s in (self.rl_psf_radius_slider, self.rl_psf_shape_slider, self.rl_psf_aspect_slider, self.rl_psf_rotation_slider):
|
|
452
|
+
s.valueChanged.connect(self._clear_custom_psf_flag)
|
|
453
|
+
|
|
454
|
+
def _build_psf_estimator_tab(self):
|
|
455
|
+
psf_tab = QWidget(); layout = QVBoxLayout(psf_tab)
|
|
456
|
+
|
|
457
|
+
h_image = QHBoxLayout()
|
|
458
|
+
h_image.addWidget(QLabel("Image for PSF Estimate:"))
|
|
459
|
+
self.sep_image_label = QLabel("(Current Active Image)")
|
|
460
|
+
h_image.addWidget(self.sep_image_label); layout.addLayout(h_image)
|
|
461
|
+
|
|
462
|
+
form = QFormLayout(); form.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
|
|
463
|
+
self.sep_threshold_slider = FloatSliderWithEdit(minimum=1.0, maximum=5.0, step=0.1, initial=2.5, suffix=" σ")
|
|
464
|
+
form.addRow("Detection σ:", self.sep_threshold_slider)
|
|
465
|
+
self.sep_minarea_spin = CustomSpinBox(minimum=1, maximum=100, initial=5, step=1)
|
|
466
|
+
form.addRow("Min Area (px²):", self.sep_minarea_spin)
|
|
467
|
+
self.sep_sat_slider = FloatSliderWithEdit(minimum=1000, maximum=100000, step=500, initial=50000, suffix=" ADU")
|
|
468
|
+
form.addRow("Saturation Cutoff:", self.sep_sat_slider)
|
|
469
|
+
self.sep_maxstars_spin = CustomSpinBox(minimum=1, maximum=500, initial=50, step=1)
|
|
470
|
+
form.addRow("Max Stars:", self.sep_maxstars_spin)
|
|
471
|
+
self.sep_stamp_spin = CustomSpinBox(minimum=5, maximum=50, initial=15, step=1)
|
|
472
|
+
form.addRow("Half‐Width (px):", self.sep_stamp_spin)
|
|
473
|
+
layout.addLayout(form)
|
|
474
|
+
|
|
475
|
+
h_buttons = QHBoxLayout()
|
|
476
|
+
self.sep_run_button = QPushButton("Run SEP Extraction")
|
|
477
|
+
self.sep_save_button = QPushButton("Save PSF…")
|
|
478
|
+
self.sep_use_button = QPushButton("Use as Current PSF")
|
|
479
|
+
h_buttons.addWidget(self.sep_run_button); h_buttons.addWidget(self.sep_save_button); h_buttons.addWidget(self.sep_use_button)
|
|
480
|
+
layout.addLayout(h_buttons)
|
|
481
|
+
|
|
482
|
+
self.psf_estimate_title = QLabel("Estimated PSF (64×64):")
|
|
483
|
+
layout.addWidget(self.psf_estimate_title, alignment=Qt.AlignmentFlag.AlignLeft)
|
|
484
|
+
self.sep_psf_preview = QLabel(); self.sep_psf_preview.setFixedSize(64, 64)
|
|
485
|
+
self.sep_psf_preview.setStyleSheet("border: 1px solid #888;")
|
|
486
|
+
layout.addWidget(self.sep_psf_preview, alignment=Qt.AlignmentFlag.AlignHCenter)
|
|
487
|
+
|
|
488
|
+
layout.addStretch()
|
|
489
|
+
self.tabs.addTab(psf_tab, "PSF Estimator")
|
|
490
|
+
|
|
491
|
+
def _build_tv_denoise_tab(self):
|
|
492
|
+
tvd_tab = QWidget(); layout = QVBoxLayout(tvd_tab)
|
|
493
|
+
form = QFormLayout(); form.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
|
|
494
|
+
self.tv_weight_slider = FloatSliderWithEdit(minimum=0.0, maximum=1.0, step=0.01, initial=0.1, suffix="")
|
|
495
|
+
form.addRow("TV Weight:", self.tv_weight_slider)
|
|
496
|
+
self.tv_iter_slider = FloatSliderWithEdit(minimum=1, maximum=100, step=1, initial=10, suffix="")
|
|
497
|
+
form.addRow("Max Iterations:", self.tv_iter_slider)
|
|
498
|
+
self.tv_multichannel_checkbox = QCheckBox("Multi‐channel"); self.tv_multichannel_checkbox.setChecked(True)
|
|
499
|
+
self.tv_multichannel_checkbox.setToolTip("If checked and the image is color, run TV on all channels jointly")
|
|
500
|
+
form.addRow("", self.tv_multichannel_checkbox)
|
|
501
|
+
layout.addLayout(form); layout.addStretch()
|
|
502
|
+
self.tabs.addTab(tvd_tab, "TV Denoise")
|
|
503
|
+
|
|
504
|
+
# ---------------- UI reactions ----------------
|
|
505
|
+
def _on_deconv_algo_changed(self, selected: str):
|
|
506
|
+
for w in self.deconv_param_stack.values():
|
|
507
|
+
w.setVisible(False)
|
|
508
|
+
if selected in self.deconv_param_stack:
|
|
509
|
+
self.deconv_param_stack[selected].setVisible(True)
|
|
510
|
+
|
|
511
|
+
# Show/hide PSF sliders & bar
|
|
512
|
+
on_psf_algo = selected in ("Richardson-Lucy", "Wiener")
|
|
513
|
+
self.psf_param_group.setVisible(on_psf_algo)
|
|
514
|
+
self.custom_psf_bar.setVisible(on_psf_algo and self._use_custom_psf and (self._custom_psf is not None))
|
|
515
|
+
|
|
516
|
+
def _on_ls_operator_changed(self, op_text: str):
|
|
517
|
+
self.ls_blend_combo.setCurrentText("SoftLight" if op_text == "Divide" else "Screen")
|
|
518
|
+
|
|
519
|
+
def _make_psf_pixmap(self, radius, kurtosis, aspect, rotation_deg) -> QPixmap:
|
|
520
|
+
psf = make_elliptical_gaussian_psf(radius, kurtosis, aspect, rotation_deg)
|
|
521
|
+
h, w = psf.shape
|
|
522
|
+
img8 = ((psf / psf.max()) * 255.0).astype(np.uint8) if psf.max() > 0 else psf.astype(np.uint8)
|
|
523
|
+
qimg = QImage(img8.data, w, h, w, QImage.Format.Format_Grayscale8)
|
|
524
|
+
scaled = QPixmap.fromImage(qimg).scaled(64, 64, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
|
|
525
|
+
final = QPixmap(64, 64); final.fill(Qt.GlobalColor.transparent)
|
|
526
|
+
p = QPainter(final); p.drawPixmap((64 - scaled.width()) // 2, (64 - scaled.height()) // 2, scaled); p.end()
|
|
527
|
+
return final
|
|
528
|
+
|
|
529
|
+
def _make_stellar_psf_pixmap(self, psf_kernel: np.ndarray) -> QPixmap:
|
|
530
|
+
h, w = psf_kernel.shape
|
|
531
|
+
img8 = ((psf_kernel / max(psf_kernel.max(), 1e-12)) * 255.0).astype(np.uint8)
|
|
532
|
+
qimg = QImage(img8.data, w, h, w, QImage.Format.Format_Grayscale8)
|
|
533
|
+
scaled = QPixmap.fromImage(qimg).scaled(64, 64, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
|
|
534
|
+
final = QPixmap(64, 64); final.fill(Qt.GlobalColor.transparent)
|
|
535
|
+
p = QPainter(final); p.drawPixmap((64 - scaled.width()) // 2, (64 - scaled.height()) // 2, scaled); p.end()
|
|
536
|
+
return final
|
|
537
|
+
|
|
538
|
+
def _update_psf_preview(self):
|
|
539
|
+
current_tab = self.tabs.tabText(self.tabs.currentIndex())
|
|
540
|
+
algo = getattr(self, "deconv_algo_combo", None)
|
|
541
|
+
algo_text = algo.currentText() if algo is not None else ""
|
|
542
|
+
|
|
543
|
+
if current_tab == "Convolution":
|
|
544
|
+
r, k, a, rot = (self.conv_radius_slider.value(), self.conv_shape_slider.value(),
|
|
545
|
+
self.conv_aspect_slider.value(), self.conv_rotation_slider.value())
|
|
546
|
+
self.conv_psf_label.setPixmap(self._make_psf_pixmap(r, k, a, rot))
|
|
547
|
+
elif current_tab == "Deconvolution" and algo_text in ("Richardson-Lucy", "Wiener"):
|
|
548
|
+
if self._use_custom_psf and (self._custom_psf is not None):
|
|
549
|
+
self.conv_psf_label.setPixmap(self._make_stellar_psf_pixmap(self._custom_psf))
|
|
550
|
+
else:
|
|
551
|
+
r, k, a, rot = (self.rl_psf_radius_slider.value(), self.rl_psf_shape_slider.value(),
|
|
552
|
+
self.rl_psf_aspect_slider.value(), self.rl_psf_rotation_slider.value())
|
|
553
|
+
self.conv_psf_label.setPixmap(self._make_psf_pixmap(r, k, a, rot))
|
|
554
|
+
else:
|
|
555
|
+
self.conv_psf_label.clear()
|
|
556
|
+
|
|
557
|
+
# ---------------- Mask helper (from active document) ----------------
|
|
558
|
+
def _active_mask_array_from_active_doc(self) -> np.ndarray | None:
|
|
559
|
+
"""
|
|
560
|
+
Read the active mask from the active document:
|
|
561
|
+
doc.active_mask_id -> doc.masks[mid].data
|
|
562
|
+
Return a 2-D float32 mask in [0..1], or None.
|
|
563
|
+
"""
|
|
564
|
+
try:
|
|
565
|
+
doc = self._active_doc()
|
|
566
|
+
if doc is None:
|
|
567
|
+
return None
|
|
568
|
+
mid = getattr(doc, "active_mask_id", None)
|
|
569
|
+
if not mid:
|
|
570
|
+
return None
|
|
571
|
+
masks = getattr(doc, "masks", {}) or {}
|
|
572
|
+
layer = masks.get(mid)
|
|
573
|
+
data = getattr(layer, "data", None) if layer is not None else None
|
|
574
|
+
if data is None:
|
|
575
|
+
return None
|
|
576
|
+
|
|
577
|
+
m = np.asarray(data)
|
|
578
|
+
# If RGB(A) mask, convert to gray
|
|
579
|
+
if m.ndim == 3:
|
|
580
|
+
if cv2 is not None:
|
|
581
|
+
m = cv2.cvtColor(m, cv2.COLOR_BGR2GRAY)
|
|
582
|
+
else:
|
|
583
|
+
m = m.mean(axis=2)
|
|
584
|
+
|
|
585
|
+
m = m.astype(np.float32, copy=False)
|
|
586
|
+
if m.max() > 1.0:
|
|
587
|
+
m /= 255.0
|
|
588
|
+
return np.clip(m, 0.0, 1.0)
|
|
589
|
+
except Exception:
|
|
590
|
+
return None
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def _resize_mask_nearest(self, mask2d: np.ndarray, target_hw: tuple[int, int]) -> np.ndarray:
|
|
594
|
+
"""Resize 2-D mask to (H, W) using nearest neighbor."""
|
|
595
|
+
H, W = target_hw
|
|
596
|
+
if mask2d.shape == (H, W):
|
|
597
|
+
return mask2d
|
|
598
|
+
if cv2 is not None:
|
|
599
|
+
return cv2.resize(mask2d, (W, H), interpolation=cv2.INTER_NEAREST).astype(np.float32, copy=False)
|
|
600
|
+
# NumPy fallback NN
|
|
601
|
+
yi = (np.linspace(0, mask2d.shape[0] - 1, H)).astype(np.int32)
|
|
602
|
+
xi = (np.linspace(0, mask2d.shape[1] - 1, W)).astype(np.int32)
|
|
603
|
+
return mask2d[yi][:, xi].astype(np.float32, copy=False)
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def _get_active_mask_from_doc(self, target_shape) -> np.ndarray | None:
|
|
607
|
+
"""
|
|
608
|
+
Return mask resized to `target_shape`; broadcast to channels if needed.
|
|
609
|
+
"""
|
|
610
|
+
m = self._active_mask_array_from_active_doc()
|
|
611
|
+
if m is None:
|
|
612
|
+
return None
|
|
613
|
+
|
|
614
|
+
H, W = target_shape[:2]
|
|
615
|
+
m = self._resize_mask_nearest(m, (H, W))
|
|
616
|
+
|
|
617
|
+
# If the processed image is RGB, expand mask to 3 channels
|
|
618
|
+
if len(target_shape) == 3 and m.ndim == 2:
|
|
619
|
+
m = np.repeat(m[:, :, None], target_shape[2], axis=2)
|
|
620
|
+
|
|
621
|
+
return np.clip(m.astype(np.float32, copy=False), 0.0, 1.0)
|
|
622
|
+
|
|
623
|
+
# ---------------- Core actions ----------------
|
|
624
|
+
def _on_preview(self):
|
|
625
|
+
doc = self._active_doc()
|
|
626
|
+
if hasattr(self.doc_manager, "set_active_document"):
|
|
627
|
+
self.doc_manager.set_active_document(doc)
|
|
628
|
+
img, _ = self._get_active_image_and_meta()
|
|
629
|
+
if img is None:
|
|
630
|
+
self._show_message("No active image to process.")
|
|
631
|
+
return
|
|
632
|
+
|
|
633
|
+
if self._original_image is None:
|
|
634
|
+
self._original_image = img.copy()
|
|
635
|
+
|
|
636
|
+
current_tab_name = self.tabs.tabText(self.tabs.currentIndex())
|
|
637
|
+
|
|
638
|
+
if current_tab_name == "Convolution":
|
|
639
|
+
radius = self.conv_radius_slider.value()
|
|
640
|
+
kurtosis= self.conv_shape_slider.value()
|
|
641
|
+
aspect = self.conv_aspect_slider.value()
|
|
642
|
+
rotation= self.conv_rotation_slider.value()
|
|
643
|
+
psf_kernel = make_elliptical_gaussian_psf(radius, kurtosis, aspect, rotation).astype(np.float32)
|
|
644
|
+
processed = self._convolve_color(img, psf_kernel)
|
|
645
|
+
processed = processed * self.strength_slider.value() + (1 - self.strength_slider.value()) * img
|
|
646
|
+
|
|
647
|
+
elif current_tab_name == "Deconvolution":
|
|
648
|
+
algo = self.deconv_algo_combo.currentText()
|
|
649
|
+
if algo == "Richardson-Lucy":
|
|
650
|
+
iters = int(round(self.rl_iterations_slider.value()))
|
|
651
|
+
reg_type = self.rl_reg_combo.currentText()
|
|
652
|
+
pr, ps, pa, pt = (self.rl_psf_radius_slider.value(), self.rl_psf_shape_slider.value(),
|
|
653
|
+
self.rl_psf_aspect_slider.value(), self.rl_psf_rotation_slider.value())
|
|
654
|
+
psf_kernel = make_elliptical_gaussian_psf(pr, ps, pa, pt).astype(np.float32)
|
|
655
|
+
clip_flag = self.rl_clip_checkbox.isChecked()
|
|
656
|
+
|
|
657
|
+
if self.rl_luminance_only_checkbox.isChecked() and img.ndim == 3 and img.shape[2] == 3:
|
|
658
|
+
lab = rgb2lab(img.astype(np.float32))
|
|
659
|
+
L = (lab[:, :, 0] / 100.0).astype(np.float32)
|
|
660
|
+
deconv_L = self._richardson_lucy_color(L, psf_kernel, iterations=iters, reg_type=reg_type, clip_flag=clip_flag)
|
|
661
|
+
lab[:, :, 0] = np.clip(deconv_L * 100.0, 0.0, 100.0)
|
|
662
|
+
rgb_deconv = lab2rgb(lab.astype(np.float32))
|
|
663
|
+
processed = np.clip(rgb_deconv.astype(np.float32), 0.0, 1.0)
|
|
664
|
+
else:
|
|
665
|
+
processed = self._richardson_lucy_color(img.astype(np.float32), psf_kernel, iters, reg_type, clip_flag)
|
|
666
|
+
|
|
667
|
+
processed = processed * self.strength_slider.value() + (1 - self.strength_slider.value()) * img
|
|
668
|
+
|
|
669
|
+
elif algo == "Wiener":
|
|
670
|
+
if self._use_custom_psf and (self._custom_psf is not None):
|
|
671
|
+
small_psf = self._custom_psf.astype(np.float32)
|
|
672
|
+
else:
|
|
673
|
+
pr, ps, pa, pt = (self.rl_psf_radius_slider.value(), self.rl_psf_shape_slider.value(),
|
|
674
|
+
self.rl_psf_aspect_slider.value(), self.rl_psf_rotation_slider.value())
|
|
675
|
+
small_psf = make_elliptical_gaussian_psf(pr, ps, pa, pt).astype(np.float32)
|
|
676
|
+
|
|
677
|
+
nsr = self.wiener_nsr_slider.value()
|
|
678
|
+
reg_type = "Wiener" if self.wiener_reg_combo.currentText() == "None (Classical Wiener)" else "Tikhonov"
|
|
679
|
+
do_dering = self.wiener_dering_checkbox.isChecked()
|
|
680
|
+
|
|
681
|
+
if self.wiener_luminance_only_checkbox.isChecked() and img.ndim == 3 and img.shape[2] == 3:
|
|
682
|
+
lab = rgb2lab(img.astype(np.float32))
|
|
683
|
+
L = (lab[:, :, 0] / 100.0).astype(np.float32)
|
|
684
|
+
deconv_L = self._wiener_deconv_with_kernel(L, small_psf, nsr, reg_type, do_dering)
|
|
685
|
+
lab[:, :, 0] = np.clip(deconv_L * 100.0, 0.0, 100.0)
|
|
686
|
+
rgb_deconv = lab2rgb(lab.astype(np.float32))
|
|
687
|
+
processed = np.clip(rgb_deconv.astype(np.float32), 0.0, 1.0)
|
|
688
|
+
else:
|
|
689
|
+
processed = self._wiener_deconv_with_kernel(img, small_psf, nsr, reg_type, do_dering)
|
|
690
|
+
processed = np.clip(processed, 0.0, 1.0)
|
|
691
|
+
|
|
692
|
+
processed = processed * self.strength_slider.value() + (1 - self.strength_slider.value()) * img
|
|
693
|
+
|
|
694
|
+
elif algo == "Larson-Sekanina":
|
|
695
|
+
if not hasattr(self.view, "ls_center") or self.view.ls_center is None:
|
|
696
|
+
QMessageBox.information(self, "Hold Shift + Click",
|
|
697
|
+
"To choose a Larson–Sekanina center, hold Shift and click on the preview.")
|
|
698
|
+
return
|
|
699
|
+
|
|
700
|
+
center = self.view.ls_center
|
|
701
|
+
rstep = self.ls_radial_slider.value()
|
|
702
|
+
astep = self.ls_angular_slider.value()
|
|
703
|
+
operator = self.ls_operator_combo.currentText()
|
|
704
|
+
blend_mode = self.ls_blend_combo.currentText()
|
|
705
|
+
|
|
706
|
+
B = larson_sekanina(image=img, center=center, radial_step=rstep, angular_step_deg=astep, operator=operator)
|
|
707
|
+
A = img
|
|
708
|
+
if A.ndim == 3 and A.shape[2] == 3:
|
|
709
|
+
B_rgb, A_rgb = np.repeat(B[:, :, None], 3, axis=2), A
|
|
710
|
+
else:
|
|
711
|
+
B_rgb, A_rgb = B[..., None], A[..., None]
|
|
712
|
+
C = (A_rgb + B_rgb - (A_rgb * B_rgb)) if blend_mode == "Screen" else ((1 - 2 * B_rgb) * (A_rgb**2) + 2 * B_rgb * A_rgb)
|
|
713
|
+
processed = np.clip(C, 0.0, 1.0)
|
|
714
|
+
processed = processed[..., 0] if img.ndim == 2 else processed
|
|
715
|
+
processed = processed * self.strength_slider.value() + (1 - self.strength_slider.value()) * img
|
|
716
|
+
|
|
717
|
+
elif algo == "Van Cittert":
|
|
718
|
+
iters2 = self.vc_iterations_slider.value()
|
|
719
|
+
relax = self.vc_relax_slider.value()
|
|
720
|
+
if img.ndim == 3 and img.shape[2] == 3:
|
|
721
|
+
chans = [van_cittert_deconv(img[:, :, c], iters2, relax) for c in range(3)]
|
|
722
|
+
processed = np.stack(chans, axis=2)
|
|
723
|
+
else:
|
|
724
|
+
processed = van_cittert_deconv(img, iters2, relax)
|
|
725
|
+
processed = np.clip(processed, 0.0, 1.0)
|
|
726
|
+
processed = processed * self.strength_slider.value() + (1 - self.strength_slider.value()) * img
|
|
727
|
+
|
|
728
|
+
else:
|
|
729
|
+
self._show_message("Unknown deconvolution algorithm")
|
|
730
|
+
return
|
|
731
|
+
|
|
732
|
+
elif current_tab_name == "TV Denoise":
|
|
733
|
+
weight = self.tv_weight_slider.value()
|
|
734
|
+
max_iter = int(self.tv_iter_slider.value())
|
|
735
|
+
multichannel = self.tv_multichannel_checkbox.isChecked()
|
|
736
|
+
|
|
737
|
+
if img.ndim == 3 and multichannel:
|
|
738
|
+
processed = denoise_tv_chambolle(img.astype(np.float32), weight=weight, max_num_iter=max_iter, channel_axis=-1).astype(np.float32)
|
|
739
|
+
else:
|
|
740
|
+
if img.ndim == 3 and img.shape[2] == 3:
|
|
741
|
+
channels_out = [
|
|
742
|
+
denoise_tv_chambolle(img[:, :, c].astype(np.float32), weight=weight, max_num_iter=max_iter, channel_axis=None).astype(np.float32)
|
|
743
|
+
for c in range(3)
|
|
744
|
+
]
|
|
745
|
+
processed = np.stack(channels_out, axis=2)
|
|
746
|
+
else:
|
|
747
|
+
gray = img.astype(np.float32) if img.ndim == 2 else img
|
|
748
|
+
processed = denoise_tv_chambolle(gray, weight=weight, max_num_iter=max_iter, channel_axis=None).astype(np.float32)
|
|
749
|
+
|
|
750
|
+
processed = np.clip(processed, 0.0, 1.0)
|
|
751
|
+
processed = processed * self.strength_slider.value() + (1 - self.strength_slider.value()) * img
|
|
752
|
+
|
|
753
|
+
else:
|
|
754
|
+
self._show_message("Unknown tab")
|
|
755
|
+
return
|
|
756
|
+
|
|
757
|
+
# Masked blend if an active mask exists
|
|
758
|
+
mask = self._get_active_mask_from_doc(processed.shape)
|
|
759
|
+
if mask is not None:
|
|
760
|
+
if processed.ndim == 3 and mask.ndim == 2:
|
|
761
|
+
mask = mask[..., None]
|
|
762
|
+
final_result = np.clip(processed * mask + self._original_image * (1.0 - mask), 0.0, 1.0)
|
|
763
|
+
else:
|
|
764
|
+
final_result = processed
|
|
765
|
+
|
|
766
|
+
self._preview_result = final_result
|
|
767
|
+
self._display_in_view(final_result)
|
|
768
|
+
|
|
769
|
+
def _on_undo(self):
|
|
770
|
+
if self._original_image is not None:
|
|
771
|
+
self._preview_result = None
|
|
772
|
+
self._display_in_view(self._original_image)
|
|
773
|
+
else:
|
|
774
|
+
self._show_message("Nothing to undo.")
|
|
775
|
+
|
|
776
|
+
def _build_replay_preset(self) -> dict | None:
|
|
777
|
+
"""
|
|
778
|
+
Capture the current UI state as a preset-style dict so Replay Last Action
|
|
779
|
+
can re-run the same Convo/Deconvo/TV operation on another document.
|
|
780
|
+
Matches the schema used by ConvoPresetDialog.result_dict().
|
|
781
|
+
"""
|
|
782
|
+
current_tab_name = self.tabs.tabText(self.tabs.currentIndex())
|
|
783
|
+
strength = float(self.strength_slider.value())
|
|
784
|
+
|
|
785
|
+
# ── Convolution tab ─────────────────────────────────────────────
|
|
786
|
+
if current_tab_name == "Convolution":
|
|
787
|
+
return {
|
|
788
|
+
"op": "convolution",
|
|
789
|
+
"radius": float(self.conv_radius_slider.value()),
|
|
790
|
+
"kurtosis": float(self.conv_shape_slider.value()),
|
|
791
|
+
"aspect": float(self.conv_aspect_slider.value()),
|
|
792
|
+
"rotation": float(self.conv_rotation_slider.value()),
|
|
793
|
+
"strength": strength,
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
# ── Deconvolution tab ───────────────────────────────────────────
|
|
797
|
+
if current_tab_name == "Deconvolution":
|
|
798
|
+
algo = self.deconv_algo_combo.currentText()
|
|
799
|
+
p: dict[str, object] = {
|
|
800
|
+
"op": "deconvolution",
|
|
801
|
+
"algo": algo,
|
|
802
|
+
# RL/Wiener PSF params
|
|
803
|
+
"psf_radius": float(self.rl_psf_radius_slider.value()),
|
|
804
|
+
"psf_kurtosis": float(self.rl_psf_shape_slider.value()),
|
|
805
|
+
"psf_aspect": float(self.rl_psf_aspect_slider.value()),
|
|
806
|
+
"psf_rotation": float(self.rl_psf_rotation_slider.value()),
|
|
807
|
+
# RL options
|
|
808
|
+
"rl_iter": float(self.rl_iterations_slider.value()),
|
|
809
|
+
"rl_reg": self.rl_reg_combo.currentText(),
|
|
810
|
+
"rl_dering": bool(self.rl_clip_checkbox.isChecked()),
|
|
811
|
+
"luminance_only": bool(self.rl_luminance_only_checkbox.isChecked()),
|
|
812
|
+
# Wiener options
|
|
813
|
+
"wiener_nsr": float(self.wiener_nsr_slider.value()),
|
|
814
|
+
"wiener_reg": self.wiener_reg_combo.currentText(),
|
|
815
|
+
"wiener_dering": bool(self.wiener_dering_checkbox.isChecked()),
|
|
816
|
+
# Larson–Sekanina options
|
|
817
|
+
"ls_rstep": float(self.ls_radial_slider.value()),
|
|
818
|
+
"ls_astep": float(self.ls_angular_slider.value()),
|
|
819
|
+
"ls_operator": self.ls_operator_combo.currentText(),
|
|
820
|
+
"ls_blend": self.ls_blend_combo.currentText(),
|
|
821
|
+
# Van Cittert options
|
|
822
|
+
"vc_iter": float(self.vc_iterations_slider.value()),
|
|
823
|
+
"vc_relax": float(self.vc_relax_slider.value()),
|
|
824
|
+
# Global blend strength
|
|
825
|
+
"strength": strength,
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
# If user actually picked an LS center, preserve it for replay.
|
|
829
|
+
# Interactive view stores (x,y). apply_convo_via_preset expects [cx, cy].
|
|
830
|
+
if hasattr(self.view, "ls_center") and self.view.ls_center is not None:
|
|
831
|
+
cx, cy = self.view.ls_center # (x, y)
|
|
832
|
+
p["center"] = [float(cx), float(cy)]
|
|
833
|
+
|
|
834
|
+
return p
|
|
835
|
+
|
|
836
|
+
# ── TV Denoise tab ──────────────────────────────────────────────
|
|
837
|
+
if current_tab_name == "TV Denoise":
|
|
838
|
+
return {
|
|
839
|
+
"op": "tv",
|
|
840
|
+
"tv_weight": float(self.tv_weight_slider.value()),
|
|
841
|
+
"tv_iter": int(round(float(self.tv_iter_slider.value()))),
|
|
842
|
+
"tv_multichannel": bool(self.tv_multichannel_checkbox.isChecked()),
|
|
843
|
+
"strength": strength,
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
return None
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
def _on_push_to_doc(self):
|
|
850
|
+
doc = self._active_doc()
|
|
851
|
+
if doc is None:
|
|
852
|
+
QMessageBox.warning(self, "No Document", "No active document to push into.")
|
|
853
|
+
return
|
|
854
|
+
|
|
855
|
+
if self._preview_result is None:
|
|
856
|
+
QMessageBox.warning(self, "No Preview", "No preview to push. Click Preview first.")
|
|
857
|
+
return
|
|
858
|
+
|
|
859
|
+
# Grab current metadata from this specific doc
|
|
860
|
+
_, meta = self._get_active_image_and_meta()
|
|
861
|
+
new_meta = dict(meta)
|
|
862
|
+
new_meta["source"] = "ConvoDeconvo"
|
|
863
|
+
|
|
864
|
+
try:
|
|
865
|
+
if hasattr(doc, "apply_edit"):
|
|
866
|
+
# ⭐ Preferred: update this exact Document (ROI or full) so all views update
|
|
867
|
+
doc.apply_edit(
|
|
868
|
+
self._preview_result.copy(),
|
|
869
|
+
metadata=new_meta,
|
|
870
|
+
step_name="Convo/Deconvo",
|
|
871
|
+
)
|
|
872
|
+
else:
|
|
873
|
+
# Fallback for older paths: go through DocManager active-doc API
|
|
874
|
+
if hasattr(self.doc_manager, "set_active_document"):
|
|
875
|
+
self.doc_manager.set_active_document(doc)
|
|
876
|
+
self.doc_manager.update_active_document(
|
|
877
|
+
self._preview_result.copy(),
|
|
878
|
+
metadata=new_meta,
|
|
879
|
+
step_name="Convo/Deconvo",
|
|
880
|
+
)
|
|
881
|
+
except Exception as e:
|
|
882
|
+
QMessageBox.critical(self, "Push failed", str(e))
|
|
883
|
+
return
|
|
884
|
+
|
|
885
|
+
# Make the pushed image the new baseline so you can iterate
|
|
886
|
+
img_after, _ = self._get_active_image_and_meta()
|
|
887
|
+
if img_after is not None:
|
|
888
|
+
self._original_image = img_after.copy()
|
|
889
|
+
self._preview_result = None
|
|
890
|
+
self._display_in_view(self._original_image)
|
|
891
|
+
|
|
892
|
+
# 🔴 Replay wiring (unchanged, just moved under try/except)
|
|
893
|
+
try:
|
|
894
|
+
if self._main is not None:
|
|
895
|
+
preset = self._build_replay_preset()
|
|
896
|
+
if preset:
|
|
897
|
+
self._main._last_headless_command = {
|
|
898
|
+
"cid": "convo",
|
|
899
|
+
"preset": preset,
|
|
900
|
+
}
|
|
901
|
+
if hasattr(self._main, "_log"):
|
|
902
|
+
op = preset.get("op", "convolution")
|
|
903
|
+
self._main._log(f"Replay: stored Convo/Deconvo ({op}) from dialog.")
|
|
904
|
+
except Exception:
|
|
905
|
+
# Replay wiring should never break the actual push
|
|
906
|
+
pass
|
|
907
|
+
|
|
908
|
+
QMessageBox.information(self, "Pushed", "Result committed to the active document.")
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
# ---------------- Utils ----------------
|
|
913
|
+
def _show_message(self, text: str):
|
|
914
|
+
self.scene.clear()
|
|
915
|
+
self.pixmap_item = QGraphicsPixmapItem()
|
|
916
|
+
self.scene.addItem(self.pixmap_item)
|
|
917
|
+
self.view.resetTransform()
|
|
918
|
+
temp_label = QLabel(text); temp_label.setStyleSheet("color: white; background-color: #222;"); temp_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
919
|
+
pixmap = temp_label.grab()
|
|
920
|
+
self.pixmap_item.setPixmap(pixmap)
|
|
921
|
+
self.view.fitInView(self.pixmap_item, Qt.AspectRatioMode.KeepAspectRatio)
|
|
922
|
+
|
|
923
|
+
def _convolve_color(self, image: np.ndarray, psf_kernel: np.ndarray) -> np.ndarray:
|
|
924
|
+
"""
|
|
925
|
+
Convolve image with psf_kernel using reflect padding so we don't get
|
|
926
|
+
dark borders from zero–padding. Returns same H×W (and channels) as input.
|
|
927
|
+
"""
|
|
928
|
+
if image is None or psf_kernel is None:
|
|
929
|
+
return image
|
|
930
|
+
|
|
931
|
+
img = image.astype(np.float32, copy=False)
|
|
932
|
+
kh, kw = psf_kernel.shape
|
|
933
|
+
pad_y = kh // 2
|
|
934
|
+
pad_x = kw // 2
|
|
935
|
+
|
|
936
|
+
def _conv_single_channel(im2d: np.ndarray) -> np.ndarray:
|
|
937
|
+
if pad_y or pad_x:
|
|
938
|
+
padded = np.pad(
|
|
939
|
+
im2d,
|
|
940
|
+
((pad_y, pad_y), (pad_x, pad_x)),
|
|
941
|
+
mode="reflect"
|
|
942
|
+
)
|
|
943
|
+
else:
|
|
944
|
+
padded = im2d
|
|
945
|
+
|
|
946
|
+
conv_full = fftconvolve(padded, psf_kernel, mode="same")
|
|
947
|
+
|
|
948
|
+
if pad_y or pad_x:
|
|
949
|
+
conv = conv_full[pad_y:-pad_y or None, pad_x:-pad_x or None]
|
|
950
|
+
else:
|
|
951
|
+
conv = conv_full
|
|
952
|
+
|
|
953
|
+
return conv.astype(np.float32)
|
|
954
|
+
|
|
955
|
+
if img.ndim == 2:
|
|
956
|
+
out = _conv_single_channel(img)
|
|
957
|
+
elif img.ndim == 3 and img.shape[2] == 3:
|
|
958
|
+
chans = [_conv_single_channel(img[:, :, c]) for c in range(3)]
|
|
959
|
+
out = np.stack(chans, axis=2)
|
|
960
|
+
else:
|
|
961
|
+
# Unknown layout; just return a copy to be safe
|
|
962
|
+
return img.copy()
|
|
963
|
+
|
|
964
|
+
# PSF is normalized, but clamp just in case of numeric noise
|
|
965
|
+
return np.clip(out, 0.0, 1.0)
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
def _richardson_lucy_color(self, image: np.ndarray, psf_kernel: np.ndarray, iterations: int,
|
|
969
|
+
reg_type: str = "None (Plain R–L)", clip_flag: bool = True) -> np.ndarray:
|
|
970
|
+
iters = int(round(iterations))
|
|
971
|
+
psf = psf_kernel.astype(np.float32)
|
|
972
|
+
|
|
973
|
+
def _deconv_2d_parallel(gray: np.ndarray) -> np.ndarray:
|
|
974
|
+
H, W = gray.shape
|
|
975
|
+
psf_h, psf_w = psf.shape
|
|
976
|
+
half_psf = max(psf_h, psf_w) // 2
|
|
977
|
+
extra = 15
|
|
978
|
+
pad = half_psf + extra
|
|
979
|
+
overlap = pad
|
|
980
|
+
|
|
981
|
+
n_cores = min((os.cpu_count() or 1), H)
|
|
982
|
+
tile_h = math.ceil(H / n_cores)
|
|
983
|
+
tile_ranges = []
|
|
984
|
+
for i in range(n_cores):
|
|
985
|
+
y0 = i * tile_h; y1 = min((i + 1) * tile_h, H)
|
|
986
|
+
if y0 >= H: break
|
|
987
|
+
tile_ranges.append((y0, y1))
|
|
988
|
+
|
|
989
|
+
accum_image = np.zeros((H, W), dtype=np.float32)
|
|
990
|
+
accum_weight = np.zeros((H, W), dtype=np.float32)
|
|
991
|
+
|
|
992
|
+
def _build_vertical_ramp(L: int, ov: int) -> np.ndarray:
|
|
993
|
+
w = np.ones(L, dtype=np.float32)
|
|
994
|
+
if ov <= 0: return w
|
|
995
|
+
if 2 * ov >= L:
|
|
996
|
+
for i in range(L):
|
|
997
|
+
w[i] = 1.0 - abs((i - (L - 1) / 2) / ((L - 1) / 2))
|
|
998
|
+
return w
|
|
999
|
+
for i in range(ov):
|
|
1000
|
+
w[i] = (i + 1) / float(ov)
|
|
1001
|
+
w[L - 1 - i] = (i + 1) / float(ov)
|
|
1002
|
+
return w
|
|
1003
|
+
|
|
1004
|
+
tile_inputs = []
|
|
1005
|
+
for idx, (y0, y1) in enumerate(tile_ranges):
|
|
1006
|
+
y0_ext = max(0, y0 - overlap); y1_ext = min(H, y1 + overlap)
|
|
1007
|
+
core_tile = gray[y0_ext:y1_ext, :]
|
|
1008
|
+
padded = np.pad(core_tile, ((pad, pad), (pad, pad)), mode="reflect")
|
|
1009
|
+
L_ext = y1_ext - y0_ext
|
|
1010
|
+
tile_inputs.append((idx, padded, psf, iters, clip_flag, pad, reg_type, y0_ext, y1_ext, L_ext))
|
|
1011
|
+
|
|
1012
|
+
results = [None] * len(tile_inputs)
|
|
1013
|
+
max_workers = min(len(tile_inputs), os.cpu_count() or 1)
|
|
1014
|
+
if max_workers < 1:
|
|
1015
|
+
max_workers = 1
|
|
1016
|
+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
1017
|
+
for tile_index, deconv_ext in executor.map(_rl_tile_process_reg, tile_inputs):
|
|
1018
|
+
results[tile_index] = deconv_ext
|
|
1019
|
+
|
|
1020
|
+
for idx, (y0, y1) in enumerate(tile_ranges):
|
|
1021
|
+
(_, _, _, _, _, _, _, y0_ext, y1_ext, L_ext) = tile_inputs[idx]
|
|
1022
|
+
deconv_ext = results[idx]
|
|
1023
|
+
w = _build_vertical_ramp(L_ext, overlap)
|
|
1024
|
+
w2d = np.broadcast_to(w[:, None], (L_ext, W)).astype(np.float32)
|
|
1025
|
+
accum_image[y0_ext:y1_ext, :] += deconv_ext * w2d
|
|
1026
|
+
accum_weight[y0_ext:y1_ext, :] += w2d
|
|
1027
|
+
|
|
1028
|
+
final_deconv = np.zeros_like(accum_image, dtype=np.float32)
|
|
1029
|
+
nz = accum_weight > 0
|
|
1030
|
+
final_deconv[nz] = accum_image[nz] / accum_weight[nz]
|
|
1031
|
+
return final_deconv
|
|
1032
|
+
|
|
1033
|
+
if image.ndim == 2:
|
|
1034
|
+
self.rl_status_label.setText(f"Running RL for {iters} iterations"); QApplication.processEvents()
|
|
1035
|
+
deconv = _deconv_2d_parallel(image.astype(np.float32))
|
|
1036
|
+
self.rl_status_label.setText(""); QApplication.processEvents()
|
|
1037
|
+
return np.clip(deconv, 0.0, 1.0)
|
|
1038
|
+
elif image.ndim == 3 and image.shape[2] == 3:
|
|
1039
|
+
outs = []
|
|
1040
|
+
for c in range(3):
|
|
1041
|
+
self.rl_status_label.setText(f"Running RL on ch {c+1} for {iters} iterations"); QApplication.processEvents()
|
|
1042
|
+
outs.append(np.clip(_deconv_2d_parallel(image[:, :, c].astype(np.float32)), 0.0, 1.0))
|
|
1043
|
+
self.rl_status_label.setText(""); QApplication.processEvents()
|
|
1044
|
+
return np.stack(outs, axis=2)
|
|
1045
|
+
else:
|
|
1046
|
+
return image.copy()
|
|
1047
|
+
|
|
1048
|
+
def _wiener_deconv_with_kernel(self, image: np.ndarray, small_psf: np.ndarray, nsr: float,
|
|
1049
|
+
reg_type: str, do_dering: bool) -> np.ndarray:
|
|
1050
|
+
def _deconv_gray(im2d: np.ndarray, do_dering_flag: bool) -> np.ndarray:
|
|
1051
|
+
H, W = im2d.shape
|
|
1052
|
+
psf_h, psf_w = small_psf.shape
|
|
1053
|
+
Hpsf = np.zeros((H, W), dtype=np.float32)
|
|
1054
|
+
cy, cx = H // 2, W // 2
|
|
1055
|
+
y0 = cy - psf_h // 2; x0 = cx - psf_w // 2
|
|
1056
|
+
Hpsf[y0:y0+psf_h, x0:x0+psf_w] = small_psf
|
|
1057
|
+
H_f = fft2(ifftshift(Hpsf)); H_f_conj = np.conj(H_f); mag2 = np.abs(H_f) ** 2
|
|
1058
|
+
K = nsr * nsr if reg_type == "Tikhonov" else nsr
|
|
1059
|
+
Wf = H_f_conj / (mag2 + K)
|
|
1060
|
+
deconv = np.real(ifft2(Wf * fft2(im2d))).astype(np.float32)
|
|
1061
|
+
if do_dering_flag:
|
|
1062
|
+
deconv = denoise_bilateral(deconv, sigma_color=0.08, sigma_spatial=1)
|
|
1063
|
+
return deconv.clip(0.0, 1.0)
|
|
1064
|
+
|
|
1065
|
+
if image.ndim == 2:
|
|
1066
|
+
return _deconv_gray(image.astype(np.float32), do_dering)
|
|
1067
|
+
elif image.ndim == 3 and image.shape[2] == 3:
|
|
1068
|
+
return np.stack([_deconv_gray(image[:, :, c].astype(np.float32), do_dering) for c in range(3)], axis=2)
|
|
1069
|
+
else:
|
|
1070
|
+
return image.copy()
|
|
1071
|
+
|
|
1072
|
+
def _display_in_view(self, array: np.ndarray):
|
|
1073
|
+
arr = array.copy()
|
|
1074
|
+
if arr.dtype in (np.float32, np.float64):
|
|
1075
|
+
arr = np.clip(arr, 0.0, 1.0); arr8 = (arr * 255).astype(np.uint8)
|
|
1076
|
+
elif arr.dtype == np.uint16:
|
|
1077
|
+
arr8 = (np.clip(arr, 0, 65535) // 257).astype(np.uint8)
|
|
1078
|
+
elif arr.dtype == np.uint8:
|
|
1079
|
+
arr8 = arr
|
|
1080
|
+
else:
|
|
1081
|
+
mn, mx = arr.min(), arr.max()
|
|
1082
|
+
arr8 = ((arr - mn) / (mx - mn) * 255).astype(np.uint8) if mx > mn else np.zeros_like(arr, dtype=np.uint8)
|
|
1083
|
+
|
|
1084
|
+
h, w = arr8.shape[:2]
|
|
1085
|
+
if arr8.ndim == 2:
|
|
1086
|
+
fmt = QImage.Format.Format_Grayscale8; bytespp = w
|
|
1087
|
+
else:
|
|
1088
|
+
fmt = QImage.Format.Format_RGB888; bytespp = 3 * w
|
|
1089
|
+
|
|
1090
|
+
qimg = QImage(arr8.data, w, h, bytespp, fmt)
|
|
1091
|
+
self.pixmap_item.setPixmap(QPixmap.fromImage(qimg))
|
|
1092
|
+
self.scene.setSceneRect(0, 0, w, h)
|
|
1093
|
+
|
|
1094
|
+
if self._auto_fit:
|
|
1095
|
+
self.view.resetTransform()
|
|
1096
|
+
self.view.fitInView(self.scene.sceneRect(), Qt.AspectRatioMode.KeepAspectRatio)
|
|
1097
|
+
self._auto_fit = False
|
|
1098
|
+
|
|
1099
|
+
def zoom_in(self): self.view.scale(1.2, 1.2)
|
|
1100
|
+
def zoom_out(self): self.view.scale(1/1.2, 1/1.2)
|
|
1101
|
+
|
|
1102
|
+
def _on_fit_clicked(self):
|
|
1103
|
+
self._auto_fit = True
|
|
1104
|
+
if self._preview_result is not None:
|
|
1105
|
+
self._display_in_view(self._preview_result)
|
|
1106
|
+
elif self._original_image is not None:
|
|
1107
|
+
self._display_in_view(self._original_image)
|
|
1108
|
+
|
|
1109
|
+
# ---------------- SEP PSF estimator ----------------
|
|
1110
|
+
def _on_run_sep(self):
|
|
1111
|
+
img, _ = self._get_active_image_and_meta()
|
|
1112
|
+
if img is None:
|
|
1113
|
+
QMessageBox.warning(self, "No Image", "Please select an image before estimating PSF.")
|
|
1114
|
+
return
|
|
1115
|
+
img_gray = img.mean(axis=2).astype(np.float32) if (img.ndim == 3) else img.astype(np.float32)
|
|
1116
|
+
|
|
1117
|
+
sigma = self.sep_threshold_slider.value()
|
|
1118
|
+
minarea = self.sep_minarea_spin.value
|
|
1119
|
+
sat = self.sep_sat_slider.value()
|
|
1120
|
+
maxstars= self.sep_maxstars_spin.value
|
|
1121
|
+
half_w = self.sep_stamp_spin.value
|
|
1122
|
+
|
|
1123
|
+
try:
|
|
1124
|
+
psf_kernel = estimate_psf_from_image(
|
|
1125
|
+
image_array=img_gray,
|
|
1126
|
+
threshold_sigma=sigma,
|
|
1127
|
+
min_area=minarea,
|
|
1128
|
+
saturation_limit=sat,
|
|
1129
|
+
max_stars=maxstars,
|
|
1130
|
+
stamp_half_width=half_w
|
|
1131
|
+
)
|
|
1132
|
+
except RuntimeError as e:
|
|
1133
|
+
QMessageBox.critical(self, "PSF Error", str(e)); return
|
|
1134
|
+
|
|
1135
|
+
self._last_stellar_psf = psf_kernel
|
|
1136
|
+
self._show_stellar_psf_preview(psf_kernel)
|
|
1137
|
+
|
|
1138
|
+
def _show_stellar_psf_preview(self, psf_kernel: np.ndarray):
|
|
1139
|
+
h, w = psf_kernel.shape
|
|
1140
|
+
img8 = ((psf_kernel / max(psf_kernel.max(), 1e-12)) * 255.0).astype(np.uint8)
|
|
1141
|
+
qimg = QImage(img8.data, w, h, w, QImage.Format.Format_Grayscale8)
|
|
1142
|
+
scaled = QPixmap.fromImage(qimg).scaled(64, 64, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
|
|
1143
|
+
final = QPixmap(64, 64); final.fill(Qt.GlobalColor.transparent)
|
|
1144
|
+
p = QPainter(final); p.drawPixmap((64 - scaled.width()) // 2, (64 - scaled.height()) // 2, scaled); p.end()
|
|
1145
|
+
self.sep_psf_preview.setPixmap(final)
|
|
1146
|
+
|
|
1147
|
+
def _on_use_stellar_psf(self):
|
|
1148
|
+
if self._last_stellar_psf is None:
|
|
1149
|
+
QMessageBox.warning(self, "No PSF", "Run SEP extraction first.")
|
|
1150
|
+
return
|
|
1151
|
+
self._custom_psf = self._last_stellar_psf.copy()
|
|
1152
|
+
self._use_custom_psf = True
|
|
1153
|
+
self.conv_psf_label.setPixmap(self._make_stellar_psf_pixmap(self._custom_psf))
|
|
1154
|
+
self.deconv_algo_combo.setCurrentText("Richardson-Lucy")
|
|
1155
|
+
self.rl_custom_label.setVisible(True)
|
|
1156
|
+
self.rl_disable_custom_btn.setVisible(True)
|
|
1157
|
+
self.custom_psf_bar.setVisible(True)
|
|
1158
|
+
QMessageBox.information(self, "PSF Selected", "Stellar PSF is now active for Richardson–Lucy.")
|
|
1159
|
+
|
|
1160
|
+
def _clear_custom_psf_flag(self, _=None):
|
|
1161
|
+
if self._use_custom_psf:
|
|
1162
|
+
self._use_custom_psf = False
|
|
1163
|
+
self._custom_psf = None
|
|
1164
|
+
self.rl_custom_label.setVisible(False)
|
|
1165
|
+
self.rl_disable_custom_btn.setVisible(False)
|
|
1166
|
+
self.custom_psf_bar.setVisible(False)
|
|
1167
|
+
|
|
1168
|
+
def _on_save_stellar_psf(self):
|
|
1169
|
+
if self._last_stellar_psf is None:
|
|
1170
|
+
QMessageBox.warning(self, "No PSF", "Run SEP extraction before saving.")
|
|
1171
|
+
return
|
|
1172
|
+
|
|
1173
|
+
path, _ = QFileDialog.getSaveFileName(
|
|
1174
|
+
self,
|
|
1175
|
+
"Save PSF as...",
|
|
1176
|
+
"",
|
|
1177
|
+
"TIFF (*.tif);;FITS (*.fits)"
|
|
1178
|
+
)
|
|
1179
|
+
if not path:
|
|
1180
|
+
return
|
|
1181
|
+
|
|
1182
|
+
ext = path.lower().split('.')[-1]
|
|
1183
|
+
|
|
1184
|
+
if ext == 'fits':
|
|
1185
|
+
fits.PrimaryHDU(self._last_stellar_psf.astype(np.float32)).writeto(path, overwrite=True)
|
|
1186
|
+
|
|
1187
|
+
elif ext in ('tif', 'tiff'):
|
|
1188
|
+
import tifffile
|
|
1189
|
+
tifffile.imwrite(path, self._last_stellar_psf.astype(np.float32))
|
|
1190
|
+
|
|
1191
|
+
else:
|
|
1192
|
+
QMessageBox.warning(self, "Invalid Extension", "Please choose .fits or .tif.")
|
|
1193
|
+
return
|
|
1194
|
+
|
|
1195
|
+
QMessageBox.information(self, "Saved", f"PSF saved to:\n{path}")
|
|
1196
|
+
|
|
1197
|
+
|
|
1198
|
+
|
|
1199
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
1200
|
+
def estimate_psf_from_image(image_array: np.ndarray,
|
|
1201
|
+
threshold_sigma: float,
|
|
1202
|
+
min_area: int,
|
|
1203
|
+
saturation_limit: float,
|
|
1204
|
+
max_stars: int,
|
|
1205
|
+
stamp_half_width: int) -> np.ndarray:
|
|
1206
|
+
data = image_array.astype(np.float32)
|
|
1207
|
+
bkg = sep.Background(data)
|
|
1208
|
+
bkg_sub = data - bkg.back()
|
|
1209
|
+
sources = sep.extract(bkg_sub, thresh=threshold_sigma, err=bkg.globalrms, minarea=min_area)
|
|
1210
|
+
if len(sources) == 0:
|
|
1211
|
+
raise RuntimeError(f"No sources found with SEP threshold = {threshold_sigma:.1f} σ.")
|
|
1212
|
+
|
|
1213
|
+
valid_sources = [s for s in sources if s['peak'] < saturation_limit]
|
|
1214
|
+
if len(valid_sources) == 0:
|
|
1215
|
+
raise RuntimeError(f"All detected sources exceed saturation limit {int(saturation_limit)}.")
|
|
1216
|
+
|
|
1217
|
+
valid_sources.sort(key=lambda s: s['peak'], reverse=True)
|
|
1218
|
+
selected = valid_sources[:max_stars]
|
|
1219
|
+
|
|
1220
|
+
w = stamp_half_width
|
|
1221
|
+
ksize = 2*w + 1
|
|
1222
|
+
psf_sum = np.zeros((ksize, ksize), dtype=np.float32)
|
|
1223
|
+
count = 0
|
|
1224
|
+
|
|
1225
|
+
H, W = data.shape[:2]
|
|
1226
|
+
for src in selected:
|
|
1227
|
+
xi = int(round(src['x'])); yi = int(round(src['y']))
|
|
1228
|
+
y0, y1 = yi - w, yi + w + 1
|
|
1229
|
+
x0, x1 = xi - w, xi + w + 1
|
|
1230
|
+
if y0 < 0 or x0 < 0 or y1 > H or x1 > W:
|
|
1231
|
+
continue
|
|
1232
|
+
stamp = bkg_sub[y0:y1, x0:x1].astype(np.float32)
|
|
1233
|
+
total_flux = float(np.sum(stamp))
|
|
1234
|
+
if total_flux <= 0:
|
|
1235
|
+
continue
|
|
1236
|
+
psf_sum += (stamp / total_flux)
|
|
1237
|
+
count += 1
|
|
1238
|
+
|
|
1239
|
+
if count == 0:
|
|
1240
|
+
raise RuntimeError("No valid postage stamps extracted (all were off-edge or zero).")
|
|
1241
|
+
|
|
1242
|
+
psf_kernel = (psf_sum / count).astype(np.float32)
|
|
1243
|
+
total = float(psf_kernel.sum())
|
|
1244
|
+
if total > 0:
|
|
1245
|
+
psf_kernel /= total
|
|
1246
|
+
else:
|
|
1247
|
+
psf_kernel[:] = 0; psf_kernel[w, w] = 1.0
|
|
1248
|
+
return psf_kernel
|
|
1249
|
+
|
|
1250
|
+
|
|
1251
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
1252
|
+
@lru_cache(maxsize=64)
|
|
1253
|
+
def make_elliptical_gaussian_psf(radius: float, kurtosis: float, aspect: float, rotation_deg: float) -> np.ndarray:
|
|
1254
|
+
"""Generate elliptical Gaussian PSF kernel. Results are cached."""
|
|
1255
|
+
sigma_x = radius
|
|
1256
|
+
sigma_y = radius / max(aspect, 1e-8)
|
|
1257
|
+
|
|
1258
|
+
size = int(np.ceil(6 * sigma_x))
|
|
1259
|
+
size = size + 1 if size % 2 == 0 else size
|
|
1260
|
+
half = size // 2
|
|
1261
|
+
|
|
1262
|
+
xs = np.linspace(-half, half, size)
|
|
1263
|
+
ys = np.linspace(-half, half, size)
|
|
1264
|
+
xv, yv = np.meshgrid(xs, ys)
|
|
1265
|
+
|
|
1266
|
+
theta = np.deg2rad(rotation_deg)
|
|
1267
|
+
cos_t, sin_t = np.cos(theta), np.sin(theta)
|
|
1268
|
+
x_rot = cos_t * xv + sin_t * yv
|
|
1269
|
+
y_rot = -sin_t * xv + cos_t * yv
|
|
1270
|
+
|
|
1271
|
+
beta = kurtosis
|
|
1272
|
+
squared_sum = (x_rot / max(sigma_x, 1e-8))**2 + (y_rot / max(sigma_y, 1e-8))**2
|
|
1273
|
+
psf = np.exp(-(squared_sum ** beta))
|
|
1274
|
+
total = psf.sum()
|
|
1275
|
+
return (psf / total).astype(np.float32) if total != 0 else np.zeros_like(psf, dtype=np.float32)
|
|
1276
|
+
|
|
1277
|
+
|
|
1278
|
+
def _rl_tile_process_reg(tile_and_meta: Tuple[int, np.ndarray]) -> Tuple[int, np.ndarray]:
|
|
1279
|
+
(tile_index, padded_tile, psf, num_iter, clip_flag, pad, reg_type, y0_ext, y1_ext, L_ext) = tile_and_meta
|
|
1280
|
+
alpha_L2 = 0.01
|
|
1281
|
+
alpha_tv = 0.01
|
|
1282
|
+
f = np.clip(padded_tile.astype(np.float32), 1e-8, None)
|
|
1283
|
+
psf_flipped = psf[::-1, ::-1]
|
|
1284
|
+
|
|
1285
|
+
for _ in range(num_iter):
|
|
1286
|
+
estimate_blurred = fftconvolve(f, psf, mode="same")
|
|
1287
|
+
ratio = padded_tile / (estimate_blurred + 1e-8)
|
|
1288
|
+
correction = fftconvolve(ratio, psf_flipped, mode="same")
|
|
1289
|
+
f = f * correction
|
|
1290
|
+
if reg_type == "Tikhonov (L2)":
|
|
1291
|
+
f = f - alpha_L2 * laplace(f)
|
|
1292
|
+
elif reg_type == "Total Variation (TV)":
|
|
1293
|
+
f = denoise_tv_chambolle(f, weight=alpha_tv, channel_axis=None).astype(np.float32)
|
|
1294
|
+
f = np.clip(f, 0.0, 1.0)
|
|
1295
|
+
|
|
1296
|
+
if clip_flag:
|
|
1297
|
+
f = denoise_bilateral(f, sigma_color=0.08, sigma_spatial=1).astype(np.float32)
|
|
1298
|
+
|
|
1299
|
+
full_h, full_w = f.shape
|
|
1300
|
+
Wcore = full_w - 2 * pad
|
|
1301
|
+
deconv_core = f[pad: pad + L_ext, pad: pad + Wcore].astype(np.float32)
|
|
1302
|
+
return (tile_index, deconv_core)
|
|
1303
|
+
|
|
1304
|
+
|
|
1305
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
1306
|
+
def van_cittert_deconv(image: np.ndarray, iterations: int, relaxation: float) -> np.ndarray:
|
|
1307
|
+
sigma = 3.0
|
|
1308
|
+
size = int(np.ceil(6 * sigma)); size = size + 1 if size % 2 == 0 else size
|
|
1309
|
+
xs = np.linspace(-size//2, size//2, size)
|
|
1310
|
+
kernel_1d = np.exp(-(xs**2) / (2*sigma**2)); kernel_1d = kernel_1d / kernel_1d.sum()
|
|
1311
|
+
psf = np.outer(kernel_1d, kernel_1d).astype(np.float32)
|
|
1312
|
+
|
|
1313
|
+
f = image.copy().astype(np.float32)
|
|
1314
|
+
for _ in range(iterations):
|
|
1315
|
+
conv = fftconvolve(f, psf, mode="same")
|
|
1316
|
+
f = f + relaxation * (image.astype(np.float32) - conv)
|
|
1317
|
+
return np.clip(f, 0.0, 1.0)
|
|
1318
|
+
|
|
1319
|
+
|
|
1320
|
+
def rotate_about_center(image: np.ndarray, angle_deg: float, center: Tuple[float, float]) -> np.ndarray:
|
|
1321
|
+
img_f = img_as_float32(image)
|
|
1322
|
+
H, W = img_f.shape[:2]
|
|
1323
|
+
y0, x0 = center
|
|
1324
|
+
theta = np.deg2rad(angle_deg)
|
|
1325
|
+
cos_t, sin_t = np.cos(theta), np.sin(theta)
|
|
1326
|
+
tx = x0 - ( x0 * cos_t - y0 * sin_t )
|
|
1327
|
+
ty = y0 - ( x0 * sin_t + y0 * cos_t )
|
|
1328
|
+
M3 = np.array([[ cos_t, -sin_t, tx ],
|
|
1329
|
+
[ sin_t, cos_t, ty ],
|
|
1330
|
+
[ 0.0 , 0.0 , 1.0 ]], dtype=np.float32)
|
|
1331
|
+
tform = AffineTransform(matrix=np.linalg.inv(M3))
|
|
1332
|
+
rotated = warp(img_f, inverse_map=tform, order=1, mode='constant', cval=0.0, preserve_range=True)
|
|
1333
|
+
return rotated.astype(np.float32)
|
|
1334
|
+
|
|
1335
|
+
|
|
1336
|
+
def _bilinear_interpolate_gray(gray: np.ndarray, y_coords: np.ndarray, x_coords: np.ndarray, cval: float = 0.0) -> np.ndarray:
|
|
1337
|
+
H, W = gray.shape
|
|
1338
|
+
x0 = np.floor(x_coords).astype(int); x1 = x0 + 1
|
|
1339
|
+
y0 = np.floor(y_coords).astype(int); y1 = y0 + 1
|
|
1340
|
+
dx = x_coords - x0; dy = y_coords - y0
|
|
1341
|
+
x0c = np.clip(x0, 0, W - 1); x1c = np.clip(x1, 0, W - 1)
|
|
1342
|
+
y0c = np.clip(y0, 0, H - 1); y1c = np.clip(y1, 0, H - 1)
|
|
1343
|
+
Ia = gray[y0c, x0c]; Ib = gray[y0c, x1c]; Ic = gray[y1c, x0c]; Id = gray[y1c, x1c]
|
|
1344
|
+
wa = (1 - dx) * (1 - dy); wb = dx * (1 - dy); wc = (1 - dx) * dy; wd = dx * dy
|
|
1345
|
+
interp = (Ia * wa) + (Ib * wb) + (Ic * wc) + (Id * wd)
|
|
1346
|
+
oob = (x_coords < 0) | (x_coords >= W) | (y_coords < 0) | (y_coords >= H)
|
|
1347
|
+
interp[oob] = cval
|
|
1348
|
+
return interp.astype(np.float32)
|
|
1349
|
+
|
|
1350
|
+
|
|
1351
|
+
def larson_sekanina(image: np.ndarray, center: Tuple[float, float], radial_step: Optional[float],
|
|
1352
|
+
angular_step_deg: float, operator: str = "Divide") -> np.ndarray:
|
|
1353
|
+
if image.dtype != np.float32:
|
|
1354
|
+
raise ValueError("larson_sekanina: input must be float32 in [0..1]")
|
|
1355
|
+
if image.ndim == 3 and image.shape[2] == 3:
|
|
1356
|
+
from skimage.color import rgb2gray
|
|
1357
|
+
gray = rgb2gray(image)
|
|
1358
|
+
else:
|
|
1359
|
+
gray = image
|
|
1360
|
+
|
|
1361
|
+
H, W = gray.shape
|
|
1362
|
+
y0, x0 = center
|
|
1363
|
+
dtheta = (angular_step_deg / 180.0) * np.pi
|
|
1364
|
+
|
|
1365
|
+
ys = np.arange(H, dtype=np.float32)[:, None]
|
|
1366
|
+
xs = np.arange(W, dtype=np.float32)[None, :]
|
|
1367
|
+
dy = np.broadcast_to(ys - y0, (H, W))
|
|
1368
|
+
dx = np.broadcast_to(xs - x0, (H, W))
|
|
1369
|
+
r = np.sqrt(dx*dx + dy*dy)
|
|
1370
|
+
theta = np.arctan2(dy, dx); theta[theta < 0] += 2*np.pi
|
|
1371
|
+
|
|
1372
|
+
r2 = r if (radial_step is None or radial_step <= 0) else (r + radial_step)
|
|
1373
|
+
theta2 = (theta + dtheta) % (2*np.pi)
|
|
1374
|
+
|
|
1375
|
+
x2 = x0 + r2 * np.cos(theta2)
|
|
1376
|
+
y2 = y0 + r2 * np.sin(theta2)
|
|
1377
|
+
|
|
1378
|
+
J = _bilinear_interpolate_gray(gray, y2.ravel(), x2.ravel(), cval=0.0).reshape(H, W)
|
|
1379
|
+
|
|
1380
|
+
if operator == "Divide":
|
|
1381
|
+
eps = 1e-6
|
|
1382
|
+
med = np.median(J) if np.median(J) > 0 else 1e-6
|
|
1383
|
+
B = np.clip(gray * (med / (J + eps)), 0.0, 1.0)
|
|
1384
|
+
else:
|
|
1385
|
+
diff = gray - J
|
|
1386
|
+
B = np.clip(diff, 0.0, None)
|
|
1387
|
+
maxv = B.max()
|
|
1388
|
+
B = (B / maxv) if maxv > 0 else np.zeros_like(B)
|
|
1389
|
+
|
|
1390
|
+
return B.astype(np.float32)
|
|
1391
|
+
|
|
1392
|
+
|
|
1393
|
+
# Optional helper to open like SFCC:
|
|
1394
|
+
def open_convo_deconvo(doc_manager, parent=None, doc=None) -> ConvoDeconvoDialog:
|
|
1395
|
+
dlg = ConvoDeconvoDialog(doc_manager=doc_manager, parent=parent, doc=doc)
|
|
1396
|
+
dlg.show()
|
|
1397
|
+
return dlg
|