setiastrosuitepro 1.6.12__py3-none-any.whl → 1.7.1.post2__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.
- setiastro/images/TextureClarity.svg +56 -0
- setiastro/images/narrowbandnormalization.png +0 -0
- setiastro/images/planetarystacker.png +0 -0
- setiastro/saspro/_generated/build_info.py +2 -2
- setiastro/saspro/aberration_ai.py +128 -13
- setiastro/saspro/aberration_ai_preset.py +29 -3
- setiastro/saspro/astrospike_python.py +45 -3
- setiastro/saspro/blink_comparator_pro.py +116 -71
- setiastro/saspro/curve_editor_pro.py +72 -22
- setiastro/saspro/curves_preset.py +249 -47
- setiastro/saspro/gui/main_window.py +285 -44
- setiastro/saspro/gui/mixins/file_mixin.py +35 -16
- setiastro/saspro/gui/mixins/menu_mixin.py +8 -0
- setiastro/saspro/gui/mixins/toolbar_mixin.py +115 -6
- setiastro/saspro/histogram.py +179 -7
- setiastro/saspro/imageops/narrowband_normalization.py +816 -0
- setiastro/saspro/imageops/serloader.py +1345 -0
- setiastro/saspro/legacy/numba_utils.py +1 -1
- setiastro/saspro/live_stacking.py +24 -4
- setiastro/saspro/multiscale_decomp.py +30 -17
- setiastro/saspro/narrowband_normalization.py +1618 -0
- setiastro/saspro/remove_green.py +1 -1
- setiastro/saspro/resources.py +6 -0
- setiastro/saspro/rgbalign.py +456 -12
- setiastro/saspro/ser_stack_config.py +82 -0
- setiastro/saspro/ser_stacker.py +2321 -0
- setiastro/saspro/ser_stacker_dialog.py +1838 -0
- setiastro/saspro/ser_tracking.py +206 -0
- setiastro/saspro/serviewer.py +1625 -0
- setiastro/saspro/sfcc.py +298 -64
- setiastro/saspro/shortcuts.py +14 -7
- setiastro/saspro/stacking_suite.py +21 -6
- setiastro/saspro/stat_stretch.py +179 -31
- setiastro/saspro/subwindow.py +2 -4
- setiastro/saspro/texture_clarity.py +593 -0
- setiastro/saspro/widgets/resource_monitor.py +122 -74
- {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/METADATA +3 -2
- {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/RECORD +42 -30
- {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/WHEEL +0 -0
- {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/entry_points.txt +0 -0
- {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/licenses/LICENSE +0 -0
- {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/licenses/license.txt +0 -0
setiastro/saspro/remove_green.py
CHANGED
|
@@ -164,7 +164,7 @@ class RemoveGreenDialog(QDialog):
|
|
|
164
164
|
|
|
165
165
|
def _build_ui(self):
|
|
166
166
|
lay = QVBoxLayout(self)
|
|
167
|
-
lay.addWidget(QLabel(self.tr("Select the amount to remove green
|
|
167
|
+
lay.addWidget(QLabel(self.tr("Select the amount to remove green:")))
|
|
168
168
|
|
|
169
169
|
# amount
|
|
170
170
|
self.slider = QSlider(Qt.Orientation.Horizontal)
|
setiastro/saspro/resources.py
CHANGED
|
@@ -167,6 +167,7 @@ class Icons:
|
|
|
167
167
|
GREEN = property(lambda self: _resource_path('green.png'))
|
|
168
168
|
NEUTRAL = property(lambda self: _resource_path('neutral.png'))
|
|
169
169
|
WHITE_BALANCE = property(lambda self: _resource_path('whitebalance.png'))
|
|
170
|
+
TEXTURE_CLARITY = property(lambda self: _resource_path('TextureClarity.svg'))
|
|
170
171
|
MORPHOLOGY = property(lambda self: _resource_path('morpho.png'))
|
|
171
172
|
CLAHE = property(lambda self: _resource_path('clahe.png'))
|
|
172
173
|
HDR = property(lambda self: _resource_path('hdr.png'))
|
|
@@ -244,6 +245,7 @@ class Icons:
|
|
|
244
245
|
STACKING = property(lambda self: _resource_path('stacking.png'))
|
|
245
246
|
LIVE_STACKING = property(lambda self: _resource_path('livestacking.png'))
|
|
246
247
|
IMAGE_COMBINE = property(lambda self: _resource_path('imagecombine.png'))
|
|
248
|
+
PLANETARY_STACKER = property(lambda self: _resource_path('planetarystacker.png'))
|
|
247
249
|
|
|
248
250
|
# Moon phase (WIMS)
|
|
249
251
|
MOON_NEW = property(lambda self: _resource_path('new_moon.png'))
|
|
@@ -301,6 +303,7 @@ class Icons:
|
|
|
301
303
|
COLOR_WHEEL = property(lambda self: _resource_path('colorwheel.png'))
|
|
302
304
|
SELECTIVE_COLOR = property(lambda self: _resource_path('selectivecolor.png'))
|
|
303
305
|
NB_TO_RGB = property(lambda self: _resource_path('nbtorgb.png'))
|
|
306
|
+
NARROWBANDNORMALIZATION = property(lambda self: _resource_path('narrowbandnormalization.png'))
|
|
304
307
|
|
|
305
308
|
# Stretching
|
|
306
309
|
STAT_STRETCH = property(lambda self: _resource_path('statstretch.png'))
|
|
@@ -411,6 +414,7 @@ def _init_legacy_paths():
|
|
|
411
414
|
'green_path': get_icon_path('green.png'),
|
|
412
415
|
'neutral_path': get_icon_path('neutral.png'),
|
|
413
416
|
'whitebalance_path': get_icon_path('whitebalance.png'),
|
|
417
|
+
'texture_clarity_path': get_icon_path('TextureClarity.svg'),
|
|
414
418
|
'morpho_path': get_icon_path('morpho.png'),
|
|
415
419
|
'clahe_path': get_icon_path('clahe.png'),
|
|
416
420
|
'starnet_path': get_icon_path('starnet.png'),
|
|
@@ -531,6 +535,7 @@ def _init_legacy_paths():
|
|
|
531
535
|
'collage_path': get_icon_path('collage.png'),
|
|
532
536
|
'annotated_path': get_icon_path('annotated.png'),
|
|
533
537
|
'colorwheel_path': get_icon_path('colorwheel.png'),
|
|
538
|
+
'narrowbandnormalization_path': get_icon_path('narrowbandnormalization.png'),
|
|
534
539
|
'font_path': get_icon_path('font.png'),
|
|
535
540
|
'csv_icon_path': get_icon_path('cvs.png'),
|
|
536
541
|
'spinner_path': get_data_path('spinner.gif'),
|
|
@@ -540,6 +545,7 @@ def _init_legacy_paths():
|
|
|
540
545
|
'debayer_path': get_icon_path('debayer.png'),
|
|
541
546
|
'aberration_path': get_icon_path('aberration.png'),
|
|
542
547
|
'functionbundles_path': get_icon_path('functionbundle.png'),
|
|
548
|
+
'planetarystacker_path': get_icon_path('planetarystacker.png'),
|
|
543
549
|
'viewbundles_path': get_icon_path('viewbundle.png'),
|
|
544
550
|
'selectivecolor_path': get_icon_path('selectivecolor.png'),
|
|
545
551
|
'rgbalign_path': get_icon_path('rgbalign.png'),
|
setiastro/saspro/rgbalign.py
CHANGED
|
@@ -4,11 +4,13 @@ from __future__ import annotations
|
|
|
4
4
|
import os
|
|
5
5
|
import numpy as np
|
|
6
6
|
|
|
7
|
-
from PyQt6.QtCore import Qt, QThread, pyqtSignal
|
|
7
|
+
from PyQt6.QtCore import Qt, QThread, pyqtSignal, QEvent, QTimer
|
|
8
8
|
from PyQt6.QtWidgets import (
|
|
9
9
|
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QApplication,
|
|
10
|
-
QComboBox, QCheckBox, QMessageBox, QProgressBar, QPlainTextEdit,
|
|
10
|
+
QComboBox, QCheckBox, QMessageBox, QProgressBar, QPlainTextEdit,
|
|
11
|
+
QSpinBox, QGridLayout, QWidget
|
|
11
12
|
)
|
|
13
|
+
from PyQt6.QtGui import QImage, QPixmap, QMouseEvent, QCursor
|
|
12
14
|
|
|
13
15
|
|
|
14
16
|
import astroalign
|
|
@@ -24,6 +26,54 @@ except Exception:
|
|
|
24
26
|
PolynomialTransform = None
|
|
25
27
|
|
|
26
28
|
|
|
29
|
+
def _translate_channel(ch: np.ndarray, dx: float, dy: float) -> np.ndarray:
|
|
30
|
+
if cv2 is None:
|
|
31
|
+
return ch
|
|
32
|
+
H, W = ch.shape[:2]
|
|
33
|
+
A = np.array([[1, 0, dx],
|
|
34
|
+
[0, 1, dy]], dtype=np.float32)
|
|
35
|
+
return cv2.warpAffine(
|
|
36
|
+
ch, A, (W, H),
|
|
37
|
+
flags=cv2.INTER_LANCZOS4,
|
|
38
|
+
borderMode=cv2.BORDER_CONSTANT,
|
|
39
|
+
borderValue=0,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
def _planet_centroid(ch: np.ndarray):
|
|
43
|
+
"""Return (cx, cy, area) of dominant planet blob, or None."""
|
|
44
|
+
if cv2 is None:
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
img = ch.astype(np.float32, copy=False)
|
|
48
|
+
p1 = float(np.percentile(img, 1.0))
|
|
49
|
+
p99 = float(np.percentile(img, 99.5))
|
|
50
|
+
if p99 <= p1:
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
scaled = (img - p1) * (255.0 / (p99 - p1))
|
|
54
|
+
scaled = np.clip(scaled, 0, 255).astype(np.uint8)
|
|
55
|
+
scaled = cv2.GaussianBlur(scaled, (0, 0), 1.2)
|
|
56
|
+
|
|
57
|
+
_, bw = cv2.threshold(scaled, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
|
|
58
|
+
|
|
59
|
+
k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (7, 7))
|
|
60
|
+
bw = cv2.morphologyEx(bw, cv2.MORPH_OPEN, k, iterations=1)
|
|
61
|
+
bw = cv2.morphologyEx(bw, cv2.MORPH_CLOSE, k, iterations=2)
|
|
62
|
+
|
|
63
|
+
num, labels, stats, cents = cv2.connectedComponentsWithStats(bw, connectivity=8)
|
|
64
|
+
if num <= 1:
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
areas = stats[1:, cv2.CC_STAT_AREA]
|
|
68
|
+
j = int(np.argmax(areas)) + 1
|
|
69
|
+
area = float(stats[j, cv2.CC_STAT_AREA])
|
|
70
|
+
if area < 200:
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
cx, cy = cents[j]
|
|
74
|
+
return (float(cx), float(cy), area)
|
|
75
|
+
|
|
76
|
+
|
|
27
77
|
# ─────────────────────────────────────────────────────────────────────
|
|
28
78
|
# Worker
|
|
29
79
|
# ─────────────────────────────────────────────────────────────────────
|
|
@@ -160,11 +210,83 @@ class RGBAlignWorker(QThread):
|
|
|
160
210
|
except Exception as e:
|
|
161
211
|
self.failed.emit(str(e))
|
|
162
212
|
|
|
213
|
+
def _planet_centroid(self, ch: np.ndarray):
|
|
214
|
+
"""
|
|
215
|
+
Return (cx, cy, area) in pixel coordinates for the dominant planet blob.
|
|
216
|
+
Robust for uint8/uint16/float images.
|
|
217
|
+
"""
|
|
218
|
+
if cv2 is None:
|
|
219
|
+
return None
|
|
220
|
+
|
|
221
|
+
img = ch.astype(np.float32, copy=False)
|
|
222
|
+
|
|
223
|
+
# Normalize to 0..255 for thresholding (robust percentile scaling)
|
|
224
|
+
p1 = float(np.percentile(img, 1.0))
|
|
225
|
+
p99 = float(np.percentile(img, 99.5))
|
|
226
|
+
if p99 <= p1:
|
|
227
|
+
return None
|
|
228
|
+
|
|
229
|
+
scaled = (img - p1) * (255.0 / (p99 - p1))
|
|
230
|
+
scaled = np.clip(scaled, 0, 255).astype(np.uint8)
|
|
231
|
+
|
|
232
|
+
# Blur to suppress banding/noise
|
|
233
|
+
scaled = cv2.GaussianBlur(scaled, (0, 0), 1.2)
|
|
234
|
+
|
|
235
|
+
# Otsu threshold to separate planet from background
|
|
236
|
+
_, bw = cv2.threshold(scaled, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
|
|
237
|
+
|
|
238
|
+
# Clean up small junk / fill holes a bit
|
|
239
|
+
k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (7, 7))
|
|
240
|
+
bw = cv2.morphologyEx(bw, cv2.MORPH_OPEN, k, iterations=1)
|
|
241
|
+
bw = cv2.morphologyEx(bw, cv2.MORPH_CLOSE, k, iterations=2)
|
|
242
|
+
|
|
243
|
+
# Largest connected component = planet
|
|
244
|
+
num, labels, stats, cents = cv2.connectedComponentsWithStats(bw, connectivity=8)
|
|
245
|
+
if num <= 1:
|
|
246
|
+
return None
|
|
247
|
+
|
|
248
|
+
# stats: [label, x, y, w, h, area] but area is stats[:, cv2.CC_STAT_AREA]
|
|
249
|
+
areas = stats[1:, cv2.CC_STAT_AREA]
|
|
250
|
+
j = int(np.argmax(areas)) + 1
|
|
251
|
+
area = float(stats[j, cv2.CC_STAT_AREA])
|
|
252
|
+
if area < 200: # too tiny = probably noise
|
|
253
|
+
return None
|
|
254
|
+
|
|
255
|
+
cx, cy = cents[j]
|
|
256
|
+
return (float(cx), float(cy), area)
|
|
257
|
+
|
|
258
|
+
def _estimate_translation_by_centroid(self, src: np.ndarray, ref: np.ndarray):
|
|
259
|
+
c_src = self._planet_centroid(src)
|
|
260
|
+
c_ref = self._planet_centroid(ref)
|
|
261
|
+
if c_src is None or c_ref is None:
|
|
262
|
+
return None
|
|
263
|
+
|
|
264
|
+
sx, sy, sa = c_src
|
|
265
|
+
rx, ry, ra = c_ref
|
|
266
|
+
|
|
267
|
+
dx = rx - sx
|
|
268
|
+
dy = ry - sy
|
|
269
|
+
return (dx, dy, (sx, sy, sa), (rx, ry, ra))
|
|
270
|
+
|
|
163
271
|
|
|
164
272
|
# ───── helpers (basically mini versions of your big star alignment logic) ─────
|
|
165
273
|
def _estimate_transform(self, src: np.ndarray, ref: np.ndarray, model: str):
|
|
166
274
|
H, W = ref.shape[:2]
|
|
167
275
|
|
|
276
|
+
# ── Planet centroid ADC mode ─────────────────────────────
|
|
277
|
+
if model == "planet-centroid":
|
|
278
|
+
t = self._estimate_translation_by_centroid(src, ref)
|
|
279
|
+
if t is None:
|
|
280
|
+
# fall back to affine/astroalign if centroid fails
|
|
281
|
+
# (or you can hard-fail here if you prefer)
|
|
282
|
+
pass
|
|
283
|
+
else:
|
|
284
|
+
dx, dy, src_info, ref_info = t
|
|
285
|
+
# store "pairs" as tiny diagnostic info
|
|
286
|
+
src_xy = np.array([[src_info[0], src_info[1]]], dtype=np.float32)
|
|
287
|
+
dst_xy = np.array([[ref_info[0], ref_info[1]]], dtype=np.float32)
|
|
288
|
+
X = (float(dx), float(dy))
|
|
289
|
+
return ("translate", X, (src_xy, dst_xy))
|
|
168
290
|
# ── 0) edge-only, SEP-based path ─────────────────────────────
|
|
169
291
|
if model == "edge-sep":
|
|
170
292
|
src_xy, dst_xy = self._pair_edge_points(src, ref, (H, W))
|
|
@@ -323,6 +445,17 @@ class RGBAlignWorker(QThread):
|
|
|
323
445
|
|
|
324
446
|
def _warp_channel(self, ch: np.ndarray, kind: str, X, ref_shape):
|
|
325
447
|
H, W = ref_shape[:2]
|
|
448
|
+
|
|
449
|
+
if kind == "translate":
|
|
450
|
+
dx, dy = X
|
|
451
|
+
A = np.array([[1, 0, dx],
|
|
452
|
+
[0, 1, dy]], dtype=np.float32)
|
|
453
|
+
return cv2.warpAffine(
|
|
454
|
+
ch, A, (W, H),
|
|
455
|
+
flags=cv2.INTER_LANCZOS4,
|
|
456
|
+
borderMode=cv2.BORDER_CONSTANT,
|
|
457
|
+
borderValue=0
|
|
458
|
+
)
|
|
326
459
|
if kind == "affine":
|
|
327
460
|
# Just assume cv2 is available (standard dependency) for perf
|
|
328
461
|
A = np.asarray(X, dtype=np.float32).reshape(2, 3)
|
|
@@ -368,17 +501,28 @@ class RGBAlignDialog(QDialog):
|
|
|
368
501
|
hl.addWidget(QLabel(self.tr("Alignment model:")))
|
|
369
502
|
self.model_combo = QComboBox()
|
|
370
503
|
self.model_combo.addItems([
|
|
504
|
+
"Planet Centroid (ADC)", # NEW
|
|
371
505
|
"EDGE", # ← first, new default
|
|
372
506
|
"Homography",
|
|
373
507
|
"Affine",
|
|
374
508
|
"Poly 3",
|
|
375
509
|
"Poly 4",
|
|
376
510
|
])
|
|
377
|
-
self.model_combo.setCurrentIndex(
|
|
378
|
-
|
|
379
|
-
# tooltips for each mode
|
|
511
|
+
self.model_combo.setCurrentIndex(1)
|
|
380
512
|
self.model_combo.setItemData(
|
|
381
513
|
0,
|
|
514
|
+
(
|
|
515
|
+
"Planet Centroid (Atmospheric Dispersion)\n"
|
|
516
|
+
"• Find planet disk in each channel\n"
|
|
517
|
+
"• Compute centroid (moments)\n"
|
|
518
|
+
"• Shift R and B so their centroids match G\n"
|
|
519
|
+
"• Translation only (no warp), ideal for planets"
|
|
520
|
+
),
|
|
521
|
+
Qt.ItemDataRole.ToolTipRole,
|
|
522
|
+
)
|
|
523
|
+
# tooltips for each mode
|
|
524
|
+
self.model_combo.setItemData(
|
|
525
|
+
1,
|
|
382
526
|
(
|
|
383
527
|
"EDGE (Edge-Detected Guided Estimator)\n"
|
|
384
528
|
"• Detect stars in both channels with SEP\n"
|
|
@@ -390,22 +534,22 @@ class RGBAlignDialog(QDialog):
|
|
|
390
534
|
Qt.ItemDataRole.ToolTipRole,
|
|
391
535
|
)
|
|
392
536
|
self.model_combo.setItemData(
|
|
393
|
-
|
|
537
|
+
2,
|
|
394
538
|
"Standard homography using astroalign matches (good general-purpose choice).",
|
|
395
539
|
Qt.ItemDataRole.ToolTipRole,
|
|
396
540
|
)
|
|
397
541
|
self.model_combo.setItemData(
|
|
398
|
-
|
|
542
|
+
3,
|
|
399
543
|
"Affine (shift + scale + rotate + shear). Good when channels are mostly parallel.",
|
|
400
544
|
Qt.ItemDataRole.ToolTipRole,
|
|
401
545
|
)
|
|
402
546
|
self.model_combo.setItemData(
|
|
403
|
-
|
|
547
|
+
4,
|
|
404
548
|
"Polynomial (order 3). Use when you have mild field distortion.",
|
|
405
549
|
Qt.ItemDataRole.ToolTipRole,
|
|
406
550
|
)
|
|
407
551
|
self.model_combo.setItemData(
|
|
408
|
-
|
|
552
|
+
5,
|
|
409
553
|
"Polynomial (order 4). Use for stronger distortion, but needs more/better matches.",
|
|
410
554
|
Qt.ItemDataRole.ToolTipRole,
|
|
411
555
|
)
|
|
@@ -454,8 +598,15 @@ class RGBAlignDialog(QDialog):
|
|
|
454
598
|
|
|
455
599
|
btns = QHBoxLayout()
|
|
456
600
|
self.btn_run = QPushButton(self.tr("Align"))
|
|
601
|
+
|
|
602
|
+
self.btn_manual = QPushButton(self.tr("Manual…"))
|
|
603
|
+
self.btn_manual.setToolTip("Manual planetary RGB alignment (nudge channels by 1 px with preview).")
|
|
604
|
+
|
|
605
|
+
self.btn_manual.clicked.connect(self._open_manual)
|
|
606
|
+
|
|
457
607
|
self.btn_close = QPushButton(self.tr("Close"))
|
|
458
608
|
btns.addWidget(self.btn_run)
|
|
609
|
+
btns.addWidget(self.btn_manual)
|
|
459
610
|
btns.addWidget(self.btn_close)
|
|
460
611
|
lay.addLayout(btns)
|
|
461
612
|
|
|
@@ -464,6 +615,56 @@ class RGBAlignDialog(QDialog):
|
|
|
464
615
|
|
|
465
616
|
self.worker: RGBAlignWorker | None = None
|
|
466
617
|
|
|
618
|
+
def _open_manual(self):
|
|
619
|
+
if self.image is None or self.image.ndim != 3 or self.image.shape[2] < 3:
|
|
620
|
+
QMessageBox.warning(self, "RGB Align", "Image must be RGB (3 channels).")
|
|
621
|
+
return
|
|
622
|
+
|
|
623
|
+
dlg = RGBAlignManualDialog(self, self.image, planet_roi=("planet" in self.model_combo.currentText().lower()))
|
|
624
|
+
|
|
625
|
+
if dlg.exec() != QDialog.DialogCode.Accepted:
|
|
626
|
+
return
|
|
627
|
+
|
|
628
|
+
rdx, rdy, bdx, bdy = dlg.result_offsets()
|
|
629
|
+
self.summary_box.setPlainText(
|
|
630
|
+
"[Manual Planetary]\n"
|
|
631
|
+
f"R shift: dx={rdx}, dy={rdy}\n"
|
|
632
|
+
f"B shift: dx={bdx}, dy={bdy}\n"
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
# apply full-res
|
|
636
|
+
img = self.image
|
|
637
|
+
R = img[..., 0].astype(np.float32, copy=False)
|
|
638
|
+
G = img[..., 1].astype(np.float32, copy=False)
|
|
639
|
+
B = img[..., 2].astype(np.float32, copy=False)
|
|
640
|
+
|
|
641
|
+
R2 = _translate_channel(R, rdx, rdy)
|
|
642
|
+
B2 = _translate_channel(B, bdx, bdy)
|
|
643
|
+
|
|
644
|
+
out = np.stack([R2, G, B2], axis=2)
|
|
645
|
+
if out.dtype != img.dtype:
|
|
646
|
+
out = out.astype(img.dtype, copy=False)
|
|
647
|
+
|
|
648
|
+
# same create-new-doc logic you already use
|
|
649
|
+
try:
|
|
650
|
+
if self.chk_new_doc.isChecked():
|
|
651
|
+
dm = getattr(self.parent, "docman", None)
|
|
652
|
+
if dm is not None:
|
|
653
|
+
dm.open_array(out, {"display_name": "RGB Aligned (Manual)"}, title="RGB Aligned (Manual)")
|
|
654
|
+
else:
|
|
655
|
+
if hasattr(self.doc, "apply_edit"):
|
|
656
|
+
self.doc.apply_edit(out, {"step_name": "RGB Align (Manual)"}, step_name="RGB Align (Manual)")
|
|
657
|
+
else:
|
|
658
|
+
self.doc.image = out
|
|
659
|
+
else:
|
|
660
|
+
if hasattr(self.doc, "apply_edit"):
|
|
661
|
+
self.doc.apply_edit(out, {"step_name": "RGB Align (Manual)"}, step_name="RGB Align (Manual)")
|
|
662
|
+
else:
|
|
663
|
+
self.doc.image = out
|
|
664
|
+
except Exception as e:
|
|
665
|
+
QMessageBox.warning(self, "RGB Align", f"Manual aligned image created, but applying failed:\n{e}")
|
|
666
|
+
|
|
667
|
+
|
|
467
668
|
def _trial_sep_detect(self):
|
|
468
669
|
if self.image is None:
|
|
469
670
|
QMessageBox.warning(self, "RGB Align", "No image loaded.")
|
|
@@ -529,6 +730,8 @@ class RGBAlignDialog(QDialog):
|
|
|
529
730
|
|
|
530
731
|
def _selected_model(self) -> str:
|
|
531
732
|
txt = self.model_combo.currentText().lower()
|
|
733
|
+
if "planet" in txt or "centroid" in txt or "adc" in txt:
|
|
734
|
+
return "planet-centroid"
|
|
532
735
|
if "edge" in txt:
|
|
533
736
|
return "edge-sep"
|
|
534
737
|
if "affine" in txt:
|
|
@@ -539,7 +742,7 @@ class RGBAlignDialog(QDialog):
|
|
|
539
742
|
return "poly4"
|
|
540
743
|
if "homography" in txt:
|
|
541
744
|
return "homography"
|
|
542
|
-
return "edge-sep"
|
|
745
|
+
return "edge-sep"
|
|
543
746
|
|
|
544
747
|
# slots
|
|
545
748
|
def _on_worker_progress(self, pct: int, msg: str):
|
|
@@ -593,7 +796,10 @@ class RGBAlignDialog(QDialog):
|
|
|
593
796
|
if w.r_pairs is not None and len(w.r_pairs) == 2:
|
|
594
797
|
summary_lines.append(_spread_stats(w.r_pairs[1], (h_img, w_img)))
|
|
595
798
|
summary_lines.append(f" model: {kind}")
|
|
596
|
-
if kind == "
|
|
799
|
+
if kind == "translate":
|
|
800
|
+
dx, dy = X
|
|
801
|
+
summary_lines.append(f" shift: dx={dx:.3f}, dy={dy:.3f}")
|
|
802
|
+
elif kind == "affine":
|
|
597
803
|
A = np.asarray(X, dtype=float).reshape(2, 3)
|
|
598
804
|
M = np.vstack([A, [0, 0, 1]])
|
|
599
805
|
summary_lines.append(_fmt_mat(M))
|
|
@@ -611,7 +817,10 @@ class RGBAlignDialog(QDialog):
|
|
|
611
817
|
if w.b_pairs is not None and len(w.b_pairs) == 2:
|
|
612
818
|
summary_lines.append(_spread_stats(w.b_pairs[1], (h_img, w_img)))
|
|
613
819
|
summary_lines.append(f" model: {kind}")
|
|
614
|
-
if kind == "
|
|
820
|
+
if kind == "translate":
|
|
821
|
+
dx, dy = X
|
|
822
|
+
summary_lines.append(f" shift: dx={dx:.3f}, dy={dy:.3f}")
|
|
823
|
+
elif kind == "affine":
|
|
615
824
|
A = np.asarray(X, dtype=float).reshape(2, 3)
|
|
616
825
|
M = np.vstack([A, [0, 0, 1]])
|
|
617
826
|
summary_lines.append(_fmt_mat(M))
|
|
@@ -725,3 +934,238 @@ def run_rgb_align_headless(main_window, document, preset: dict | None = None):
|
|
|
725
934
|
|
|
726
935
|
if callable(sb):
|
|
727
936
|
sb().showMessage("RGB Align done.", 3000)
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
class RGBAlignManualDialog(QDialog):
|
|
940
|
+
"""
|
|
941
|
+
Manual channel translation for ADC / dispersion.
|
|
942
|
+
Adjust R and B offsets relative to G with 1px steps and live preview.
|
|
943
|
+
"""
|
|
944
|
+
def __init__(self, parent=None, image: np.ndarray | None = None, planet_roi: bool = False):
|
|
945
|
+
super().__init__(parent)
|
|
946
|
+
self.setWindowTitle("RGB Align — Manual (Planetary)")
|
|
947
|
+
self.setModal(True)
|
|
948
|
+
|
|
949
|
+
self.image = np.asarray(image) if image is not None else None
|
|
950
|
+
self._timer = None
|
|
951
|
+
self._result = None # (r_dx, r_dy, b_dx, b_dy)
|
|
952
|
+
|
|
953
|
+
# offsets
|
|
954
|
+
self.r_dx = 0
|
|
955
|
+
self.r_dy = 0
|
|
956
|
+
self.b_dx = 0
|
|
957
|
+
self.b_dy = 0
|
|
958
|
+
|
|
959
|
+
lay = QVBoxLayout(self)
|
|
960
|
+
|
|
961
|
+
self.lbl = QLabel("Nudge Red and Blue so they line up with Green (1 px steps).")
|
|
962
|
+
lay.addWidget(self.lbl)
|
|
963
|
+
|
|
964
|
+
# preview label
|
|
965
|
+
self.preview = QLabel()
|
|
966
|
+
self.preview.setMinimumSize(420, 320)
|
|
967
|
+
self.preview.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
968
|
+
self.preview.setStyleSheet("background:#111; border:1px solid #333;")
|
|
969
|
+
lay.addWidget(self.preview)
|
|
970
|
+
|
|
971
|
+
# controls grid
|
|
972
|
+
row = QHBoxLayout()
|
|
973
|
+
lay.addLayout(row)
|
|
974
|
+
|
|
975
|
+
# controls
|
|
976
|
+
row = QHBoxLayout()
|
|
977
|
+
lay.addLayout(row)
|
|
978
|
+
|
|
979
|
+
self.red_pad = OffsetPad("Red offset (relative to Green):")
|
|
980
|
+
self.blue_pad = OffsetPad("Blue offset (relative to Green):")
|
|
981
|
+
|
|
982
|
+
self.red_pad.changed.connect(self._on_change)
|
|
983
|
+
self.blue_pad.changed.connect(self._on_change)
|
|
984
|
+
|
|
985
|
+
row.addWidget(self.red_pad)
|
|
986
|
+
row.addWidget(self.blue_pad)
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
# buttons
|
|
990
|
+
btns = QHBoxLayout()
|
|
991
|
+
self.btn_reset = QPushButton("Reset")
|
|
992
|
+
self.btn_apply = QPushButton("Apply")
|
|
993
|
+
self.btn_close = QPushButton("Close")
|
|
994
|
+
btns.addWidget(self.btn_reset)
|
|
995
|
+
btns.addStretch(1)
|
|
996
|
+
btns.addWidget(self.btn_apply)
|
|
997
|
+
btns.addWidget(self.btn_close)
|
|
998
|
+
lay.addLayout(btns)
|
|
999
|
+
|
|
1000
|
+
self.btn_reset.clicked.connect(self._reset)
|
|
1001
|
+
self.btn_apply.clicked.connect(self._apply)
|
|
1002
|
+
self.btn_close.clicked.connect(self.reject)
|
|
1003
|
+
|
|
1004
|
+
# build a reasonable ROI for preview (center crop)
|
|
1005
|
+
self._roi = None
|
|
1006
|
+
if self.image is not None and self.image.ndim == 3 and self.image.shape[2] >= 3:
|
|
1007
|
+
H, W = self.image.shape[:2]
|
|
1008
|
+
|
|
1009
|
+
if planet_roi:
|
|
1010
|
+
# Use green channel to find the planet (most stable)
|
|
1011
|
+
c = _planet_centroid(self.image[..., 1])
|
|
1012
|
+
if c is not None:
|
|
1013
|
+
cx, cy, area = c
|
|
1014
|
+
# Estimate radius from area, then choose a padded ROI
|
|
1015
|
+
r = max(32.0, np.sqrt(area / np.pi))
|
|
1016
|
+
s = int(np.clip(r * 3.2, 160, 900)) # ROI size = ~3.2x radius
|
|
1017
|
+
cx_i, cy_i = int(round(cx)), int(round(cy))
|
|
1018
|
+
else:
|
|
1019
|
+
cx_i, cy_i = W // 2, H // 2
|
|
1020
|
+
s = int(np.clip(min(H, W) * 0.45, 160, 900))
|
|
1021
|
+
else:
|
|
1022
|
+
cx_i, cy_i = W // 2, H // 2
|
|
1023
|
+
s = int(np.clip(min(H, W) * 0.45, 160, 900))
|
|
1024
|
+
|
|
1025
|
+
x0 = max(0, cx_i - s // 2)
|
|
1026
|
+
y0 = max(0, cy_i - s // 2)
|
|
1027
|
+
x1 = min(W, x0 + s)
|
|
1028
|
+
y1 = min(H, y0 + s)
|
|
1029
|
+
self._roi = (x0, y0, x1, y1)
|
|
1030
|
+
|
|
1031
|
+
|
|
1032
|
+
# debounce timer
|
|
1033
|
+
from PyQt6.QtCore import QTimer
|
|
1034
|
+
self._timer = QTimer(self)
|
|
1035
|
+
self._timer.setSingleShot(True)
|
|
1036
|
+
self._timer.timeout.connect(self._render_preview)
|
|
1037
|
+
if self._roi is not None:
|
|
1038
|
+
x0,y0,x1,y1 = self._roi
|
|
1039
|
+
self.lbl.setText(self.lbl.text() + f"\nPreview ROI: x={x0}:{x1}, y={y0}:{y1}")
|
|
1040
|
+
self._render_preview()
|
|
1041
|
+
|
|
1042
|
+
def result_offsets(self):
|
|
1043
|
+
return self._result
|
|
1044
|
+
|
|
1045
|
+
def _on_change(self, *_):
|
|
1046
|
+
self._timer.start(60)
|
|
1047
|
+
|
|
1048
|
+
def _reset(self):
|
|
1049
|
+
self.red_pad.reset()
|
|
1050
|
+
self.blue_pad.reset()
|
|
1051
|
+
|
|
1052
|
+
def _apply(self):
|
|
1053
|
+
rdx, rdy = self.red_pad.offsets()
|
|
1054
|
+
bdx, bdy = self.blue_pad.offsets()
|
|
1055
|
+
self._result = (int(rdx), int(rdy), int(bdx), int(bdy))
|
|
1056
|
+
self.accept()
|
|
1057
|
+
|
|
1058
|
+
def _render_preview(self):
|
|
1059
|
+
if self.image is None or self.image.ndim != 3 or self.image.shape[2] < 3:
|
|
1060
|
+
self.preview.setText("No RGB image.")
|
|
1061
|
+
return
|
|
1062
|
+
|
|
1063
|
+
x0, y0, x1, y1 = self._roi if self._roi is not None else (0, 0, self.image.shape[1], self.image.shape[0])
|
|
1064
|
+
crop = self.image[y0:y1, x0:x1, :3]
|
|
1065
|
+
|
|
1066
|
+
R = crop[..., 0].astype(np.float32, copy=False)
|
|
1067
|
+
G = crop[..., 1].astype(np.float32, copy=False)
|
|
1068
|
+
B = crop[..., 2].astype(np.float32, copy=False)
|
|
1069
|
+
|
|
1070
|
+
rdx, rdy = self.red_pad.offsets()
|
|
1071
|
+
bdx, bdy = self.blue_pad.offsets()
|
|
1072
|
+
|
|
1073
|
+
R2 = _translate_channel(R, rdx, rdy)
|
|
1074
|
+
B2 = _translate_channel(B, bdx, bdy)
|
|
1075
|
+
|
|
1076
|
+
out = np.stack([R2, G, B2], axis=2)
|
|
1077
|
+
|
|
1078
|
+
# display: robust scale to uint8
|
|
1079
|
+
out8 = self._to_u8_preview(out)
|
|
1080
|
+
qimg = QImage(out8.data, out8.shape[1], out8.shape[0], out8.strides[0], QImage.Format.Format_RGB888)
|
|
1081
|
+
pix = QPixmap.fromImage(qimg).scaled(
|
|
1082
|
+
self.preview.size(),
|
|
1083
|
+
Qt.AspectRatioMode.KeepAspectRatio,
|
|
1084
|
+
Qt.TransformationMode.SmoothTransformation,
|
|
1085
|
+
)
|
|
1086
|
+
self.preview.setPixmap(pix)
|
|
1087
|
+
|
|
1088
|
+
@staticmethod
|
|
1089
|
+
def _to_u8_preview(rgb: np.ndarray) -> np.ndarray:
|
|
1090
|
+
x = rgb.astype(np.float32, copy=False)
|
|
1091
|
+
# percentile stretch per-channel, simple + robust
|
|
1092
|
+
out = np.empty_like(x, dtype=np.uint8)
|
|
1093
|
+
for c in range(3):
|
|
1094
|
+
ch = x[..., c]
|
|
1095
|
+
p1 = float(np.percentile(ch, 1.0))
|
|
1096
|
+
p99 = float(np.percentile(ch, 99.5))
|
|
1097
|
+
if p99 <= p1:
|
|
1098
|
+
out[..., c] = 0
|
|
1099
|
+
else:
|
|
1100
|
+
y = (ch - p1) * (255.0 / (p99 - p1))
|
|
1101
|
+
out[..., c] = np.clip(y, 0, 255).astype(np.uint8)
|
|
1102
|
+
return out
|
|
1103
|
+
|
|
1104
|
+
class OffsetPad(QWidget):
|
|
1105
|
+
changed = pyqtSignal(int, int) # dx, dy
|
|
1106
|
+
|
|
1107
|
+
def __init__(self, title: str, parent=None):
|
|
1108
|
+
super().__init__(parent)
|
|
1109
|
+
self._dx = 0
|
|
1110
|
+
self._dy = 0
|
|
1111
|
+
|
|
1112
|
+
outer = QVBoxLayout(self)
|
|
1113
|
+
outer.setContentsMargins(0, 0, 0, 0)
|
|
1114
|
+
outer.setSpacing(4)
|
|
1115
|
+
|
|
1116
|
+
hdr = QLabel(title)
|
|
1117
|
+
hdr.setStyleSheet("font-weight:600;")
|
|
1118
|
+
outer.addWidget(hdr)
|
|
1119
|
+
|
|
1120
|
+
self.lbl = QLabel("dx=0 dy=0")
|
|
1121
|
+
self.lbl.setStyleSheet("color:#bbb;")
|
|
1122
|
+
outer.addWidget(self.lbl)
|
|
1123
|
+
|
|
1124
|
+
grid = QGridLayout()
|
|
1125
|
+
grid.setSpacing(2)
|
|
1126
|
+
|
|
1127
|
+
self.btn_up = QPushButton("↑")
|
|
1128
|
+
self.btn_dn = QPushButton("↓")
|
|
1129
|
+
self.btn_lt = QPushButton("←")
|
|
1130
|
+
self.btn_rt = QPushButton("→")
|
|
1131
|
+
self.btn_mid = QPushButton("⟲") # reset
|
|
1132
|
+
|
|
1133
|
+
for b in (self.btn_up, self.btn_dn, self.btn_lt, self.btn_rt, self.btn_mid):
|
|
1134
|
+
b.setFixedSize(34, 34)
|
|
1135
|
+
b.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
|
1136
|
+
|
|
1137
|
+
grid.addWidget(self.btn_up, 0, 1)
|
|
1138
|
+
grid.addWidget(self.btn_lt, 1, 0)
|
|
1139
|
+
grid.addWidget(self.btn_mid, 1, 1)
|
|
1140
|
+
grid.addWidget(self.btn_rt, 1, 2)
|
|
1141
|
+
grid.addWidget(self.btn_dn, 2, 1)
|
|
1142
|
+
|
|
1143
|
+
outer.addLayout(grid)
|
|
1144
|
+
|
|
1145
|
+
# connections
|
|
1146
|
+
self.btn_up.clicked.connect(lambda: self.nudge(0, -1))
|
|
1147
|
+
self.btn_dn.clicked.connect(lambda: self.nudge(0, +1))
|
|
1148
|
+
self.btn_lt.clicked.connect(lambda: self.nudge(-1, 0))
|
|
1149
|
+
self.btn_rt.clicked.connect(lambda: self.nudge(+1, 0))
|
|
1150
|
+
self.btn_mid.clicked.connect(self.reset)
|
|
1151
|
+
|
|
1152
|
+
def set_offsets(self, dx: int, dy: int):
|
|
1153
|
+
self._dx = int(dx)
|
|
1154
|
+
self._dy = int(dy)
|
|
1155
|
+
self._update_label()
|
|
1156
|
+
self.changed.emit(self._dx, self._dy)
|
|
1157
|
+
|
|
1158
|
+
def offsets(self):
|
|
1159
|
+
return (self._dx, self._dy)
|
|
1160
|
+
|
|
1161
|
+
def reset(self):
|
|
1162
|
+
self.set_offsets(0, 0)
|
|
1163
|
+
|
|
1164
|
+
def nudge(self, ddx: int, ddy: int, step: int = 1):
|
|
1165
|
+
self._dx += int(ddx) * int(step)
|
|
1166
|
+
self._dy += int(ddy) * int(step)
|
|
1167
|
+
self._update_label()
|
|
1168
|
+
self.changed.emit(self._dx, self._dy)
|
|
1169
|
+
|
|
1170
|
+
def _update_label(self):
|
|
1171
|
+
self.lbl.setText(f"dx={self._dx:+d} dy={self._dy:+d}")
|