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,723 @@
|
|
|
1
|
+
# pro/rgbalign.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from PyQt6.QtCore import Qt, QThread, pyqtSignal
|
|
8
|
+
from PyQt6.QtWidgets import (
|
|
9
|
+
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QApplication,
|
|
10
|
+
QComboBox, QCheckBox, QMessageBox, QProgressBar, QPlainTextEdit, QSpinBox
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
import astroalign
|
|
15
|
+
|
|
16
|
+
import sep
|
|
17
|
+
|
|
18
|
+
import cv2
|
|
19
|
+
|
|
20
|
+
# try to reuse poly from star_alignment if present
|
|
21
|
+
try:
|
|
22
|
+
from setiastro.saspro.star_alignment import PolynomialTransform
|
|
23
|
+
except Exception:
|
|
24
|
+
PolynomialTransform = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
28
|
+
# Worker
|
|
29
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
30
|
+
class RGBAlignWorker(QThread):
|
|
31
|
+
progress = pyqtSignal(int, str) # (percent, message)
|
|
32
|
+
done = pyqtSignal(np.ndarray) # aligned RGB image
|
|
33
|
+
failed = pyqtSignal(str)
|
|
34
|
+
EDGE_FRAC = 0.55 # 55% of max radius
|
|
35
|
+
MIN_EDGE_PTS = 6 # want at least 6 out there
|
|
36
|
+
EDGE_INNER_FRAC = 0.38 # toss center 38% radius
|
|
37
|
+
MATCH_MAX_DIST = 10.0 # px, generous for CA
|
|
38
|
+
MIN_MATCHES = 6
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def __init__(self, img: np.ndarray, model: str, sep_sigma: float = 3.0):
|
|
42
|
+
super().__init__()
|
|
43
|
+
self.img = img
|
|
44
|
+
self.model = model
|
|
45
|
+
self.sep_sigma = float(sep_sigma)
|
|
46
|
+
|
|
47
|
+
self.r_xform = None
|
|
48
|
+
self.b_xform = None
|
|
49
|
+
self.r_pairs = None
|
|
50
|
+
self.b_pairs = None
|
|
51
|
+
|
|
52
|
+
def _pts_too_central(self, pts: np.ndarray | None, shape) -> bool:
|
|
53
|
+
"""
|
|
54
|
+
Return True if the matched points are all bunched near the center.
|
|
55
|
+
pts: (N, 2) in x,y
|
|
56
|
+
shape: (H, W)
|
|
57
|
+
"""
|
|
58
|
+
if pts is None or len(pts) == 0:
|
|
59
|
+
return True
|
|
60
|
+
h, w = shape[:2]
|
|
61
|
+
cx, cy = w * 0.5, h * 0.5
|
|
62
|
+
# distance of each point from center
|
|
63
|
+
r = np.hypot(pts[:, 0] - cx, pts[:, 1] - cy)
|
|
64
|
+
rmax = np.hypot(cx, cy) # radius to corner
|
|
65
|
+
edge_mask = r > (self.EDGE_FRAC * rmax)
|
|
66
|
+
return edge_mask.sum() < self.MIN_EDGE_PTS
|
|
67
|
+
|
|
68
|
+
def _sep_detect_points(self, img: np.ndarray):
|
|
69
|
+
"""Return (N,2) points from SEP, brightest first, using user sigma."""
|
|
70
|
+
if sep is None:
|
|
71
|
+
return None
|
|
72
|
+
data = img.astype(np.float32, copy=False)
|
|
73
|
+
bkg = sep.Background(data)
|
|
74
|
+
data_sub = data - bkg
|
|
75
|
+
# use the promoted sigma here 👇
|
|
76
|
+
objs = sep.extract(data_sub, self.sep_sigma, err=bkg.globalrms)
|
|
77
|
+
if objs is None or len(objs) == 0:
|
|
78
|
+
return None
|
|
79
|
+
idx = np.argsort(objs["peak"])[::-1]
|
|
80
|
+
pts = np.stack([objs["x"][idx], objs["y"][idx]], axis=1)
|
|
81
|
+
return pts
|
|
82
|
+
|
|
83
|
+
def _filter_edge_ring(self, pts: np.ndarray, shape, inner_frac=EDGE_INNER_FRAC):
|
|
84
|
+
"""Keep only points outside inner_frac * Rmax."""
|
|
85
|
+
if pts is None or pts.size == 0:
|
|
86
|
+
return None
|
|
87
|
+
h, w = shape[:2]
|
|
88
|
+
cx, cy = w * 0.5, h * 0.5
|
|
89
|
+
r = np.hypot(pts[:,0] - cx, pts[:,1] - cy)
|
|
90
|
+
rmax = np.hypot(cx, cy)
|
|
91
|
+
mask = r >= (inner_frac * rmax)
|
|
92
|
+
pts_edge = pts[mask]
|
|
93
|
+
return pts_edge if pts_edge.size else None
|
|
94
|
+
|
|
95
|
+
def _pair_edge_points(self, src_img, ref_img, shape):
|
|
96
|
+
"""Detect in BOTH images, keep only edge ring in REF, then NN-match in SRC."""
|
|
97
|
+
ref_pts = self._sep_detect_points(ref_img)
|
|
98
|
+
src_pts = self._sep_detect_points(src_img)
|
|
99
|
+
if ref_pts is None or src_pts is None:
|
|
100
|
+
return None, None
|
|
101
|
+
|
|
102
|
+
ref_edge = self._filter_edge_ring(ref_pts, shape)
|
|
103
|
+
if ref_edge is None:
|
|
104
|
+
return None, None
|
|
105
|
+
|
|
106
|
+
# brute-force NN, small N, so ok
|
|
107
|
+
src_arr = np.asarray(src_pts, dtype=np.float32)
|
|
108
|
+
pairs_src = []
|
|
109
|
+
pairs_dst = []
|
|
110
|
+
for (x_ref, y_ref) in ref_edge:
|
|
111
|
+
dxy = src_arr - np.array([x_ref, y_ref], dtype=np.float32)
|
|
112
|
+
dist = np.hypot(dxy[:,0], dxy[:,1])
|
|
113
|
+
j = np.argmin(dist)
|
|
114
|
+
if dist[j] <= self.MATCH_MAX_DIST:
|
|
115
|
+
# src point is in the channel we want to warp → source
|
|
116
|
+
pairs_src.append(src_arr[j])
|
|
117
|
+
# ref point is the green channel → destination
|
|
118
|
+
pairs_dst.append([x_ref, y_ref])
|
|
119
|
+
|
|
120
|
+
if len(pairs_src) < self.MIN_MATCHES:
|
|
121
|
+
return None, None
|
|
122
|
+
|
|
123
|
+
return (np.array(pairs_src, dtype=np.float32),
|
|
124
|
+
np.array(pairs_dst, dtype=np.float32))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def run(self):
|
|
128
|
+
if self.img is None or self.img.ndim != 3 or self.img.shape[2] < 3:
|
|
129
|
+
self.failed.emit("Image must be RGB (3 channels).")
|
|
130
|
+
return
|
|
131
|
+
if astroalign is None:
|
|
132
|
+
self.failed.emit("astroalign is not available.")
|
|
133
|
+
return
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
self.progress.emit(5, "Preparing channels…")
|
|
137
|
+
R = np.ascontiguousarray(self.img[..., 0].astype(np.float32, copy=False))
|
|
138
|
+
G = np.ascontiguousarray(self.img[..., 1].astype(np.float32, copy=False))
|
|
139
|
+
B = np.ascontiguousarray(self.img[..., 2].astype(np.float32, copy=False))
|
|
140
|
+
|
|
141
|
+
# R → G
|
|
142
|
+
self.progress.emit(15, "Aligning Red → Green…")
|
|
143
|
+
kind_R, X_R, (r_src, r_dst) = self._estimate_transform(R, G, self.model)
|
|
144
|
+
self.r_xform = (kind_R, X_R)
|
|
145
|
+
self.r_pairs = (r_src, r_dst)
|
|
146
|
+
self.progress.emit(35, f"Red transform = {kind_R}")
|
|
147
|
+
R_aligned = self._warp_channel(R, kind_R, X_R, G.shape)
|
|
148
|
+
|
|
149
|
+
# B → G
|
|
150
|
+
self.progress.emit(55, "Aligning Blue → Green…")
|
|
151
|
+
kind_B, X_B, (b_src, b_dst) = self._estimate_transform(B, G, self.model)
|
|
152
|
+
self.b_xform = (kind_B, X_B)
|
|
153
|
+
self.b_pairs = (b_src, b_dst)
|
|
154
|
+
self.progress.emit(75, f"Blue transform = {kind_B}")
|
|
155
|
+
B_aligned = self._warp_channel(B, kind_B, X_B, G.shape)
|
|
156
|
+
|
|
157
|
+
out = np.stack([R_aligned, G, B_aligned], axis=2).astype(self.img.dtype, copy=False)
|
|
158
|
+
self.progress.emit(100, "Done.")
|
|
159
|
+
self.done.emit(out)
|
|
160
|
+
except Exception as e:
|
|
161
|
+
self.failed.emit(str(e))
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# ───── helpers (basically mini versions of your big star alignment logic) ─────
|
|
165
|
+
def _estimate_transform(self, src: np.ndarray, ref: np.ndarray, model: str):
|
|
166
|
+
H, W = ref.shape[:2]
|
|
167
|
+
|
|
168
|
+
# ── 0) edge-only, SEP-based path ─────────────────────────────
|
|
169
|
+
if model == "edge-sep":
|
|
170
|
+
src_xy, dst_xy = self._pair_edge_points(src, ref, (H, W))
|
|
171
|
+
if src_xy is not None and dst_xy is not None and cv2 is not None:
|
|
172
|
+
# 0a) try homography first (better for corner warp)
|
|
173
|
+
Hh, inliers = cv2.findHomography(
|
|
174
|
+
src_xy, dst_xy,
|
|
175
|
+
method=cv2.RANSAC,
|
|
176
|
+
ransacReprojThreshold=2.5,
|
|
177
|
+
maxIters=2000,
|
|
178
|
+
confidence=0.999,
|
|
179
|
+
)
|
|
180
|
+
if Hh is not None:
|
|
181
|
+
return ("homography", Hh, (src_xy, dst_xy))
|
|
182
|
+
|
|
183
|
+
# 0b) fallback → affine
|
|
184
|
+
A, inliers = cv2.estimateAffine2D(
|
|
185
|
+
src_xy, dst_xy,
|
|
186
|
+
method=cv2.RANSAC,
|
|
187
|
+
ransacReprojThreshold=2.5,
|
|
188
|
+
maxIters=2000,
|
|
189
|
+
confidence=0.999,
|
|
190
|
+
)
|
|
191
|
+
if A is not None:
|
|
192
|
+
return ("affine", A, (src_xy, dst_xy))
|
|
193
|
+
# if SEP failed or cv2 missing → fall through to astroalign normal path
|
|
194
|
+
|
|
195
|
+
# ─────────────────────────────────────────────────────────────
|
|
196
|
+
# 1) astroalign normal pass
|
|
197
|
+
# ─────────────────────────────────────────────────────────────
|
|
198
|
+
tform, (src_pts, dst_pts) = astroalign.find_transform(
|
|
199
|
+
np.ascontiguousarray(src),
|
|
200
|
+
np.ascontiguousarray(ref),
|
|
201
|
+
max_control_points=50,
|
|
202
|
+
detection_sigma=5.0,
|
|
203
|
+
min_area=5,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
# 2) 'hungry' pass if too central
|
|
207
|
+
if self._pts_too_central(dst_pts, ref.shape):
|
|
208
|
+
tform2, (src_pts2, dst_pts2) = astroalign.find_transform(
|
|
209
|
+
np.ascontiguousarray(src),
|
|
210
|
+
np.ascontiguousarray(ref),
|
|
211
|
+
max_control_points=120,
|
|
212
|
+
detection_sigma=3.0,
|
|
213
|
+
min_area=3,
|
|
214
|
+
)
|
|
215
|
+
if not self._pts_too_central(dst_pts2, ref.shape):
|
|
216
|
+
tform, src_pts, dst_pts = tform2, src_pts2, dst_pts2
|
|
217
|
+
|
|
218
|
+
# 3) original branching
|
|
219
|
+
P = np.asarray(tform.params, dtype=np.float64)
|
|
220
|
+
src_xy = np.asarray(src_pts, dtype=np.float32)
|
|
221
|
+
dst_xy = np.asarray(dst_pts, dtype=np.float32)
|
|
222
|
+
|
|
223
|
+
# affine
|
|
224
|
+
if model == "affine":
|
|
225
|
+
if cv2 is None:
|
|
226
|
+
return ("affine", P[0:2, :], (src_xy, dst_xy))
|
|
227
|
+
A, _ = cv2.estimateAffine2D(
|
|
228
|
+
src_xy, dst_xy, method=cv2.RANSAC, ransacReprojThreshold=3.0
|
|
229
|
+
)
|
|
230
|
+
if A is None:
|
|
231
|
+
return ("affine", P[0:2, :], (src_xy, dst_xy))
|
|
232
|
+
return ("affine", A, (src_xy, dst_xy))
|
|
233
|
+
|
|
234
|
+
# homography
|
|
235
|
+
if model == "homography":
|
|
236
|
+
if cv2 is None:
|
|
237
|
+
if P.shape == (3, 3):
|
|
238
|
+
return ("homography", P, (src_xy, dst_xy))
|
|
239
|
+
A3 = np.vstack([P[0:2, :], [0, 0, 1]])
|
|
240
|
+
return ("homography", A3, (src_xy, dst_xy))
|
|
241
|
+
Hh, _ = cv2.findHomography(
|
|
242
|
+
src_xy, dst_xy, method=cv2.RANSAC, ransacReprojThreshold=3.0
|
|
243
|
+
)
|
|
244
|
+
if Hh is None:
|
|
245
|
+
if P.shape == (3, 3):
|
|
246
|
+
return ("homography", P, (src_xy, dst_xy))
|
|
247
|
+
A3 = np.vstack([P[0:2, :], [0, 0, 1]])
|
|
248
|
+
return ("homography", A3, (src_xy, dst_xy))
|
|
249
|
+
return ("homography", Hh, (src_xy, dst_xy))
|
|
250
|
+
|
|
251
|
+
# poly3 / poly4
|
|
252
|
+
if model in ("poly3", "poly4") and PolynomialTransform is not None and cv2 is not None:
|
|
253
|
+
order = 3 if model == "poly3" else 4
|
|
254
|
+
scale_vec = np.array([W, H], dtype=np.float32)
|
|
255
|
+
src_n = src_xy / scale_vec
|
|
256
|
+
dst_n = dst_xy / scale_vec
|
|
257
|
+
|
|
258
|
+
t_poly = PolynomialTransform()
|
|
259
|
+
ok = t_poly.estimate(dst_n, src_n, order=order) # dst → src
|
|
260
|
+
if not ok:
|
|
261
|
+
Hh, _ = cv2.findHomography(
|
|
262
|
+
src_xy, dst_xy, method=cv2.RANSAC, ransacReprojThreshold=3.0
|
|
263
|
+
)
|
|
264
|
+
return ("homography", Hh, (src_xy, dst_xy))
|
|
265
|
+
|
|
266
|
+
def _warp_poly(img: np.ndarray, out_shape: tuple[int, int]):
|
|
267
|
+
Hh_, Ww_ = out_shape
|
|
268
|
+
yy, xx = np.mgrid[0:Hh_, 0:Ww_].astype(np.float32)
|
|
269
|
+
coords = np.stack([xx, yy], axis=-1).reshape(-1, 2)
|
|
270
|
+
coords_n = coords / scale_vec
|
|
271
|
+
mapped_n = t_poly(coords_n)
|
|
272
|
+
mapped = mapped_n * scale_vec
|
|
273
|
+
map_x = mapped[:, 0].reshape(Hh_, Ww_).astype(np.float32)
|
|
274
|
+
map_y = mapped[:, 1].reshape(Hh_, Ww_).astype(np.float32)
|
|
275
|
+
return cv2.remap(
|
|
276
|
+
img, map_x, map_y,
|
|
277
|
+
interpolation=cv2.INTER_LANCZOS4,
|
|
278
|
+
borderMode=cv2.BORDER_CONSTANT, borderValue=0
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
return (model, _warp_poly, (src_xy, dst_xy))
|
|
282
|
+
|
|
283
|
+
# fallback → homography
|
|
284
|
+
if cv2 is None:
|
|
285
|
+
if P.shape == (3, 3):
|
|
286
|
+
return ("homography", P, (src_xy, dst_xy))
|
|
287
|
+
A3 = np.vstack([P[0:2, :], [0, 0, 1]])
|
|
288
|
+
return ("homography", A3, (src_xy, dst_xy))
|
|
289
|
+
|
|
290
|
+
Hh, _ = cv2.findHomography(
|
|
291
|
+
src_xy, dst_xy, method=cv2.RANSAC, ransacReprojThreshold=3.0
|
|
292
|
+
)
|
|
293
|
+
return ("homography", Hh, (src_xy, dst_xy))
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _pick_edge_stars_with_sep(self, img, tiles=(3,3), per_tile=2):
|
|
298
|
+
if sep is None:
|
|
299
|
+
return []
|
|
300
|
+
data = img.astype(np.float32, copy=False)
|
|
301
|
+
bkg = sep.Background(data)
|
|
302
|
+
data_sub = data - bkg
|
|
303
|
+
objs = sep.extract(data_sub, 1.5, err=bkg.globalrms)
|
|
304
|
+
H, W = data.shape[:2]
|
|
305
|
+
th, tw = H // tiles[0], W // tiles[1]
|
|
306
|
+
picked = []
|
|
307
|
+
for ty in range(tiles[0]):
|
|
308
|
+
for tx in range(tiles[1]):
|
|
309
|
+
y0, y1 = ty*th, min((ty+1)*th, H)
|
|
310
|
+
x0, x1 = tx*tw, min((tx+1)*tw, W)
|
|
311
|
+
box = objs[
|
|
312
|
+
(objs['y'] >= y0) & (objs['y'] < y1) &
|
|
313
|
+
(objs['x'] >= x0) & (objs['x'] < x1)
|
|
314
|
+
]
|
|
315
|
+
if len(box) == 0:
|
|
316
|
+
continue
|
|
317
|
+
# brightest first
|
|
318
|
+
box = box[np.argsort(box['peak'])][::-1][:per_tile]
|
|
319
|
+
for o in box:
|
|
320
|
+
picked.append((float(o['x']), float(o['y'])))
|
|
321
|
+
return picked
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _warp_channel(self, ch: np.ndarray, kind: str, X, ref_shape):
|
|
325
|
+
H, W = ref_shape[:2]
|
|
326
|
+
if kind == "affine":
|
|
327
|
+
if cv2 is None:
|
|
328
|
+
return ch
|
|
329
|
+
A = np.asarray(X, dtype=np.float32).reshape(2, 3)
|
|
330
|
+
return cv2.warpAffine(ch, A, (W, H), flags=cv2.INTER_LANCZOS4,
|
|
331
|
+
borderMode=cv2.BORDER_CONSTANT, borderValue=0)
|
|
332
|
+
|
|
333
|
+
if kind == "homography":
|
|
334
|
+
if cv2 is None:
|
|
335
|
+
return ch
|
|
336
|
+
Hm = np.asarray(X, dtype=np.float32).reshape(3, 3)
|
|
337
|
+
return cv2.warpPerspective(ch, Hm, (W, H), flags=cv2.INTER_LANCZOS4,
|
|
338
|
+
borderMode=cv2.BORDER_CONSTANT, borderValue=0)
|
|
339
|
+
|
|
340
|
+
if kind.startswith("poly"):
|
|
341
|
+
return X(ch, (H, W))
|
|
342
|
+
|
|
343
|
+
return ch
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
347
|
+
# Dialog
|
|
348
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
349
|
+
class RGBAlignDialog(QDialog):
|
|
350
|
+
def __init__(self, parent=None, document=None):
|
|
351
|
+
super().__init__(parent)
|
|
352
|
+
self.setWindowTitle("RGB Align")
|
|
353
|
+
self.parent = parent
|
|
354
|
+
# document could be a view; try to unwrap
|
|
355
|
+
self.doc_view = document
|
|
356
|
+
self.doc = getattr(document, "document", document)
|
|
357
|
+
self.image = getattr(self.doc, "image", None) if self.doc is not None else None
|
|
358
|
+
|
|
359
|
+
lay = QVBoxLayout(self)
|
|
360
|
+
lay.addWidget(QLabel("Align R and B channels to G.\n"
|
|
361
|
+
"Select model and run."))
|
|
362
|
+
|
|
363
|
+
hl = QHBoxLayout()
|
|
364
|
+
hl.addWidget(QLabel("Alignment model:"))
|
|
365
|
+
self.model_combo = QComboBox()
|
|
366
|
+
self.model_combo.addItems([
|
|
367
|
+
"EDGE", # ← first, new default
|
|
368
|
+
"Homography",
|
|
369
|
+
"Affine",
|
|
370
|
+
"Poly 3",
|
|
371
|
+
"Poly 4",
|
|
372
|
+
])
|
|
373
|
+
self.model_combo.setCurrentIndex(0)
|
|
374
|
+
|
|
375
|
+
# tooltips for each mode
|
|
376
|
+
self.model_combo.setItemData(
|
|
377
|
+
0,
|
|
378
|
+
(
|
|
379
|
+
"EDGE (Edge-Detected Guided Estimator)\n"
|
|
380
|
+
"• Detect stars in both channels with SEP\n"
|
|
381
|
+
"• Keep only outer-ring stars (ignore center)\n"
|
|
382
|
+
"• Try homography first for corner CA\n"
|
|
383
|
+
"• If homography fails → try affine\n"
|
|
384
|
+
"• If that fails → fall back to astroalign"
|
|
385
|
+
),
|
|
386
|
+
Qt.ItemDataRole.ToolTipRole,
|
|
387
|
+
)
|
|
388
|
+
self.model_combo.setItemData(
|
|
389
|
+
1,
|
|
390
|
+
"Standard homography using astroalign matches (good general-purpose choice).",
|
|
391
|
+
Qt.ItemDataRole.ToolTipRole,
|
|
392
|
+
)
|
|
393
|
+
self.model_combo.setItemData(
|
|
394
|
+
2,
|
|
395
|
+
"Affine (shift + scale + rotate + shear). Good when channels are mostly parallel.",
|
|
396
|
+
Qt.ItemDataRole.ToolTipRole,
|
|
397
|
+
)
|
|
398
|
+
self.model_combo.setItemData(
|
|
399
|
+
3,
|
|
400
|
+
"Polynomial (order 3). Use when you have mild field distortion.",
|
|
401
|
+
Qt.ItemDataRole.ToolTipRole,
|
|
402
|
+
)
|
|
403
|
+
self.model_combo.setItemData(
|
|
404
|
+
4,
|
|
405
|
+
"Polynomial (order 4). Use for stronger distortion, but needs more/better matches.",
|
|
406
|
+
Qt.ItemDataRole.ToolTipRole,
|
|
407
|
+
)
|
|
408
|
+
hl.addWidget(self.model_combo)
|
|
409
|
+
lay.addLayout(hl)
|
|
410
|
+
|
|
411
|
+
# ── SEP controls ─────────────────────────
|
|
412
|
+
sep_row = QHBoxLayout()
|
|
413
|
+
sep_row.addWidget(QLabel("SEP sigma:"))
|
|
414
|
+
|
|
415
|
+
self.sep_spin = QSpinBox()
|
|
416
|
+
self.sep_spin.setRange(1, 100)
|
|
417
|
+
self.sep_spin.setValue(5) # default; 1.5 was too hungry
|
|
418
|
+
self.sep_spin.setToolTip("Detection threshold (σ) for SEP star finding in EDGE mode.\n"
|
|
419
|
+
"Higher = fewer stars, lower = more stars.")
|
|
420
|
+
sep_row.addWidget(self.sep_spin)
|
|
421
|
+
|
|
422
|
+
self.btn_trial_sep = QPushButton("Trial detect stars")
|
|
423
|
+
self.btn_trial_sep.setToolTip("Run SEP on the green channel with this sigma and report how many "
|
|
424
|
+
"stars it finds and how many are in the EDGE ring.")
|
|
425
|
+
self.btn_trial_sep.clicked.connect(self._trial_sep_detect)
|
|
426
|
+
sep_row.addWidget(self.btn_trial_sep)
|
|
427
|
+
|
|
428
|
+
lay.addLayout(sep_row)
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
self.chk_new_doc = QCheckBox("Create new document (keep original)")
|
|
432
|
+
self.chk_new_doc.setChecked(True)
|
|
433
|
+
lay.addWidget(self.chk_new_doc)
|
|
434
|
+
|
|
435
|
+
# progress
|
|
436
|
+
self.progress_label = QLabel("Idle.")
|
|
437
|
+
self.progress_bar = QProgressBar()
|
|
438
|
+
self.progress_bar.setRange(0, 100)
|
|
439
|
+
self.progress_bar.setValue(0)
|
|
440
|
+
lay.addWidget(self.progress_label)
|
|
441
|
+
lay.addWidget(self.progress_bar)
|
|
442
|
+
|
|
443
|
+
self.summary_box = QPlainTextEdit()
|
|
444
|
+
self.summary_box.setReadOnly(True)
|
|
445
|
+
self.summary_box.setPlaceholderText("Transform summary will appear here…")
|
|
446
|
+
self.summary_box.setMinimumHeight(140)
|
|
447
|
+
# optional: monospace
|
|
448
|
+
self.summary_box.setStyleSheet("font-family: Consolas, 'Courier New', monospace; font-size: 11px;")
|
|
449
|
+
lay.addWidget(self.summary_box)
|
|
450
|
+
|
|
451
|
+
btns = QHBoxLayout()
|
|
452
|
+
self.btn_run = QPushButton("Align")
|
|
453
|
+
self.btn_close = QPushButton("Close")
|
|
454
|
+
btns.addWidget(self.btn_run)
|
|
455
|
+
btns.addWidget(self.btn_close)
|
|
456
|
+
lay.addLayout(btns)
|
|
457
|
+
|
|
458
|
+
self.btn_run.clicked.connect(self._start_align)
|
|
459
|
+
self.btn_close.clicked.connect(self.close)
|
|
460
|
+
|
|
461
|
+
self.worker: RGBAlignWorker | None = None
|
|
462
|
+
|
|
463
|
+
def _trial_sep_detect(self):
|
|
464
|
+
if self.image is None:
|
|
465
|
+
QMessageBox.warning(self, "RGB Align", "No image loaded.")
|
|
466
|
+
return
|
|
467
|
+
if sep is None:
|
|
468
|
+
QMessageBox.warning(self, "RGB Align", "python-sep is not available.")
|
|
469
|
+
return
|
|
470
|
+
self.progress_label.setText(f"Trial Detection In Progress…")
|
|
471
|
+
QApplication.processEvents()
|
|
472
|
+
# use green channel as reference, same as align
|
|
473
|
+
G = np.ascontiguousarray(self.image[..., 1].astype(np.float32, copy=False))
|
|
474
|
+
sigma = float(self.sep_spin.value())
|
|
475
|
+
|
|
476
|
+
# run a mini version of what the worker does
|
|
477
|
+
bkg = sep.Background(G)
|
|
478
|
+
data_sub = G - bkg
|
|
479
|
+
objs = sep.extract(data_sub, sigma, err=bkg.globalrms)
|
|
480
|
+
total = 0 if objs is None else len(objs)
|
|
481
|
+
|
|
482
|
+
# compute how many are in the EDGE ring, using same logic/constants
|
|
483
|
+
h, w = G.shape[:2]
|
|
484
|
+
cx, cy = w * 0.5, h * 0.5
|
|
485
|
+
rmax = np.hypot(cx, cy)
|
|
486
|
+
edge_inner = RGBAlignWorker.EDGE_INNER_FRAC * rmax
|
|
487
|
+
|
|
488
|
+
if objs is not None and total > 0:
|
|
489
|
+
r = np.hypot(objs["x"] - cx, objs["y"] - cy)
|
|
490
|
+
edge_mask = r >= edge_inner
|
|
491
|
+
edge_count = int(edge_mask.sum())
|
|
492
|
+
else:
|
|
493
|
+
edge_count = 0
|
|
494
|
+
|
|
495
|
+
msg = (f"[Trial SEP]\n"
|
|
496
|
+
f"sigma = {sigma}\n"
|
|
497
|
+
f"total stars (green): {total}\n"
|
|
498
|
+
f"outer-ring stars (used by EDGE): {edge_count}")
|
|
499
|
+
self.summary_box.setPlainText(msg)
|
|
500
|
+
self.progress_label.setText(f"Trial SEP: {total} stars, {edge_count} edge")
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _start_align(self):
|
|
504
|
+
if self.image is None:
|
|
505
|
+
QMessageBox.warning(self, "RGB Align", "No image found in active view.")
|
|
506
|
+
return
|
|
507
|
+
if self.image.ndim != 3 or self.image.shape[2] < 3:
|
|
508
|
+
QMessageBox.warning(self, "RGB Align", "Image must be RGB (3 channels).")
|
|
509
|
+
return
|
|
510
|
+
if astroalign is None:
|
|
511
|
+
QMessageBox.warning(self, "RGB Align", "astroalign is not available.")
|
|
512
|
+
return
|
|
513
|
+
|
|
514
|
+
model = self._selected_model()
|
|
515
|
+
sep_sigma = float(self.sep_spin.value())
|
|
516
|
+
self.progress_label.setText("Starting…")
|
|
517
|
+
self.progress_bar.setValue(0)
|
|
518
|
+
|
|
519
|
+
self.worker = RGBAlignWorker(self.image, model, sep_sigma=sep_sigma)
|
|
520
|
+
self.worker.progress.connect(self._on_worker_progress)
|
|
521
|
+
self.worker.done.connect(self._on_worker_done)
|
|
522
|
+
self.worker.failed.connect(self._on_worker_failed)
|
|
523
|
+
self.worker.start()
|
|
524
|
+
self.btn_run.setEnabled(False)
|
|
525
|
+
|
|
526
|
+
def _selected_model(self) -> str:
|
|
527
|
+
txt = self.model_combo.currentText().lower()
|
|
528
|
+
if "edge" in txt:
|
|
529
|
+
return "edge-sep"
|
|
530
|
+
if "affine" in txt:
|
|
531
|
+
return "affine"
|
|
532
|
+
if "poly 3" in txt:
|
|
533
|
+
return "poly3"
|
|
534
|
+
if "poly 4" in txt:
|
|
535
|
+
return "poly4"
|
|
536
|
+
if "homography" in txt:
|
|
537
|
+
return "homography"
|
|
538
|
+
return "edge-sep" # super-safe fallback
|
|
539
|
+
|
|
540
|
+
# slots
|
|
541
|
+
def _on_worker_progress(self, pct: int, msg: str):
|
|
542
|
+
self.progress_bar.setValue(pct)
|
|
543
|
+
self.progress_label.setText(msg)
|
|
544
|
+
|
|
545
|
+
def _on_worker_failed(self, err: str):
|
|
546
|
+
self.btn_run.setEnabled(True)
|
|
547
|
+
self.progress_bar.setValue(0)
|
|
548
|
+
self.progress_label.setText("Failed.")
|
|
549
|
+
QMessageBox.critical(self, "RGB Align", err)
|
|
550
|
+
|
|
551
|
+
def _on_worker_done(self, out: np.ndarray):
|
|
552
|
+
self.btn_run.setEnabled(True)
|
|
553
|
+
self.progress_bar.setValue(100)
|
|
554
|
+
self.progress_label.setText("Applying…")
|
|
555
|
+
|
|
556
|
+
summary_lines = []
|
|
557
|
+
w = self.worker # type: ignore
|
|
558
|
+
|
|
559
|
+
if w is not None:
|
|
560
|
+
def _fmt_mat(M):
|
|
561
|
+
return "\n".join(
|
|
562
|
+
[" " + " ".join(f"{v: .6f}" for v in row) for row in M]
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
def _spread_stats(pts, shape):
|
|
566
|
+
if pts is None:
|
|
567
|
+
return " points: 0"
|
|
568
|
+
pts = np.asarray(pts, dtype=float)
|
|
569
|
+
if pts.size == 0:
|
|
570
|
+
return " points: 0"
|
|
571
|
+
h, w_ = shape[:2]
|
|
572
|
+
cx, cy = w_ * 0.5, h * 0.5
|
|
573
|
+
if pts.ndim != 2 or pts.shape[1] != 2:
|
|
574
|
+
return f" points: {len(pts)} (unusual shape {pts.shape})"
|
|
575
|
+
r = np.hypot(pts[:, 0] - cx, pts[:, 1] - cy)
|
|
576
|
+
rmax = np.hypot(cx, cy)
|
|
577
|
+
edge = r > (RGBAlignWorker.EDGE_FRAC * rmax)
|
|
578
|
+
return (
|
|
579
|
+
f" points: {len(pts)} "
|
|
580
|
+
f"(edge: {edge.sum()} ≥{RGBAlignWorker.EDGE_FRAC*100:.0f}%Rmax)"
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
h_img, w_img = self.image.shape[:2]
|
|
584
|
+
|
|
585
|
+
# ── R → G ──
|
|
586
|
+
if w.r_xform is not None:
|
|
587
|
+
kind, X = w.r_xform
|
|
588
|
+
summary_lines.append("Red → Green:")
|
|
589
|
+
if w.r_pairs is not None and len(w.r_pairs) == 2:
|
|
590
|
+
summary_lines.append(_spread_stats(w.r_pairs[1], (h_img, w_img)))
|
|
591
|
+
summary_lines.append(f" model: {kind}")
|
|
592
|
+
if kind == "affine":
|
|
593
|
+
A = np.asarray(X, dtype=float).reshape(2, 3)
|
|
594
|
+
M = np.vstack([A, [0, 0, 1]])
|
|
595
|
+
summary_lines.append(_fmt_mat(M))
|
|
596
|
+
elif kind == "homography":
|
|
597
|
+
Hm = np.asarray(X, dtype=float).reshape(3, 3)
|
|
598
|
+
summary_lines.append(_fmt_mat(Hm))
|
|
599
|
+
else:
|
|
600
|
+
summary_lines.append(" (non-matrix; warp callable)")
|
|
601
|
+
|
|
602
|
+
# ── B → G ──
|
|
603
|
+
if w.b_xform is not None:
|
|
604
|
+
kind, X = w.b_xform
|
|
605
|
+
summary_lines.append("")
|
|
606
|
+
summary_lines.append("Blue → Green:")
|
|
607
|
+
if w.b_pairs is not None and len(w.b_pairs) == 2:
|
|
608
|
+
summary_lines.append(_spread_stats(w.b_pairs[1], (h_img, w_img)))
|
|
609
|
+
summary_lines.append(f" model: {kind}")
|
|
610
|
+
if kind == "affine":
|
|
611
|
+
A = np.asarray(X, dtype=float).reshape(2, 3)
|
|
612
|
+
M = np.vstack([A, [0, 0, 1]])
|
|
613
|
+
summary_lines.append(_fmt_mat(M))
|
|
614
|
+
elif kind == "homography":
|
|
615
|
+
Hm = np.asarray(X, dtype=float).reshape(3, 3)
|
|
616
|
+
summary_lines.append(_fmt_mat(Hm))
|
|
617
|
+
else:
|
|
618
|
+
summary_lines.append(" (non-matrix; warp callable)")
|
|
619
|
+
|
|
620
|
+
summary_text = "\n".join(summary_lines) if summary_lines else "No transform info."
|
|
621
|
+
self.summary_box.setPlainText(summary_text)
|
|
622
|
+
|
|
623
|
+
if self.parent is not None and hasattr(self.parent, "_log") and callable(self.parent._log):
|
|
624
|
+
self.parent._log("[RGB Align]\n" + summary_text)
|
|
625
|
+
|
|
626
|
+
try:
|
|
627
|
+
if self.chk_new_doc.isChecked():
|
|
628
|
+
dm = getattr(self.parent, "docman", None)
|
|
629
|
+
if dm is not None:
|
|
630
|
+
dm.open_array(out, {"display_name": "RGB Aligned"}, title="RGB Aligned")
|
|
631
|
+
else:
|
|
632
|
+
if hasattr(self.doc, "apply_edit"):
|
|
633
|
+
self.doc.apply_edit(out, {"step_name": "RGB Align"}, step_name="RGB Align")
|
|
634
|
+
else:
|
|
635
|
+
self.doc.image = out
|
|
636
|
+
else:
|
|
637
|
+
if hasattr(self.doc, "apply_edit"):
|
|
638
|
+
self.doc.apply_edit(out, {"step_name": "RGB Align"}, step_name="RGB Align")
|
|
639
|
+
else:
|
|
640
|
+
self.doc.image = out
|
|
641
|
+
|
|
642
|
+
self.progress_label.setText("Done.")
|
|
643
|
+
except Exception as e:
|
|
644
|
+
self.progress_label.setText("Apply failed.")
|
|
645
|
+
QMessageBox.warning(self, "RGB Align", f"Aligned image created, but applying failed:\n{e}")
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def align_rgb_array(img: np.ndarray, model: str = "edge-sep", sep_sigma: float = 3.0) -> np.ndarray:
|
|
652
|
+
"""
|
|
653
|
+
Headless core: returns a new RGB image with R,B aligned to G.
|
|
654
|
+
Raises RuntimeError on problems.
|
|
655
|
+
"""
|
|
656
|
+
if img is None or img.ndim != 3 or img.shape[2] < 3:
|
|
657
|
+
raise RuntimeError("Image must be RGB (3 channels).")
|
|
658
|
+
if astroalign is None:
|
|
659
|
+
raise RuntimeError("astroalign is not available.")
|
|
660
|
+
|
|
661
|
+
worker = RGBAlignWorker(img, model, sep_sigma=sep_sigma)
|
|
662
|
+
|
|
663
|
+
try:
|
|
664
|
+
R = np.ascontiguousarray(img[..., 0].astype(np.float32, copy=False))
|
|
665
|
+
G = np.ascontiguousarray(img[..., 1].astype(np.float32, copy=False))
|
|
666
|
+
B = np.ascontiguousarray(img[..., 2].astype(np.float32, copy=False))
|
|
667
|
+
|
|
668
|
+
def _estimate_and_warp(src, ref):
|
|
669
|
+
# NOTE: _estimate_transform now returns 3 values
|
|
670
|
+
kind, X, _pairs = worker._estimate_transform(src, ref, model)
|
|
671
|
+
return worker._warp_channel(src, kind, X, ref.shape)
|
|
672
|
+
|
|
673
|
+
R_aligned = _estimate_and_warp(R, G)
|
|
674
|
+
B_aligned = _estimate_and_warp(B, G)
|
|
675
|
+
|
|
676
|
+
out = np.stack([R_aligned, G, B_aligned], axis=2)
|
|
677
|
+
if img.dtype != out.dtype:
|
|
678
|
+
out = out.astype(img.dtype, copy=False)
|
|
679
|
+
return out
|
|
680
|
+
except Exception as e:
|
|
681
|
+
raise RuntimeError(str(e))
|
|
682
|
+
|
|
683
|
+
def run_rgb_align_headless(main_window, document, preset: dict | None = None):
|
|
684
|
+
if document is None:
|
|
685
|
+
QMessageBox.warning(main_window, "RGB Align", "No active document.")
|
|
686
|
+
return
|
|
687
|
+
|
|
688
|
+
img = np.asarray(document.image)
|
|
689
|
+
p = dict(preset or {})
|
|
690
|
+
model = p.get("model", "edge").lower()
|
|
691
|
+
sep_sigma = float(p.get("sep_sigma", 3.0))
|
|
692
|
+
create_new = bool(p.get("new_doc", False))
|
|
693
|
+
|
|
694
|
+
sb = getattr(main_window, "statusBar", None)
|
|
695
|
+
if callable(sb):
|
|
696
|
+
sb().showMessage(f"RGB Align ({model})…", 3000)
|
|
697
|
+
|
|
698
|
+
try:
|
|
699
|
+
out = align_rgb_array(img, model=model if model != "edge" else "edge-sep",
|
|
700
|
+
sep_sigma=sep_sigma)
|
|
701
|
+
except Exception as e:
|
|
702
|
+
QMessageBox.critical(main_window, "RGB Align (headless)", str(e))
|
|
703
|
+
return
|
|
704
|
+
|
|
705
|
+
if create_new:
|
|
706
|
+
dm = getattr(main_window, "docman", None)
|
|
707
|
+
if dm is not None:
|
|
708
|
+
dm.open_array(out, {"display_name": "RGB Aligned"}, title="RGB Aligned")
|
|
709
|
+
else:
|
|
710
|
+
# fallback to replace if we can't create new
|
|
711
|
+
try:
|
|
712
|
+
document.apply_edit(out, {"step_name": "RGB Align"})
|
|
713
|
+
except Exception:
|
|
714
|
+
document.image = out
|
|
715
|
+
else:
|
|
716
|
+
# in-place
|
|
717
|
+
if hasattr(document, "apply_edit"):
|
|
718
|
+
document.apply_edit(out, {"step_name": "RGB Align"}, step_name="RGB Align")
|
|
719
|
+
else:
|
|
720
|
+
document.image = out
|
|
721
|
+
|
|
722
|
+
if callable(sb):
|
|
723
|
+
sb().showMessage("RGB Align done.", 3000)
|