setiastrosuitepro 1.6.12__py3-none-any.whl → 1.7.3__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/images/3dplanet.png +0 -0
- setiastro/images/TextureClarity.svg +56 -0
- setiastro/images/narrowbandnormalization.png +0 -0
- setiastro/images/planetarystacker.png +0 -0
- setiastro/saspro/__init__.py +9 -8
- setiastro/saspro/__main__.py +326 -285
- 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/doc_manager.py +4 -1
- setiastro/saspro/gui/main_window.py +326 -46
- setiastro/saspro/gui/mixins/file_mixin.py +41 -18
- setiastro/saspro/gui/mixins/menu_mixin.py +9 -0
- setiastro/saspro/gui/mixins/toolbar_mixin.py +123 -7
- setiastro/saspro/histogram.py +179 -7
- setiastro/saspro/imageops/narrowband_normalization.py +816 -0
- setiastro/saspro/imageops/serloader.py +1429 -0
- setiastro/saspro/layers.py +186 -10
- setiastro/saspro/layers_dock.py +198 -5
- setiastro/saspro/legacy/image_manager.py +10 -4
- 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/planetprojection.py +3854 -0
- setiastro/saspro/remove_green.py +1 -1
- setiastro/saspro/resources.py +8 -0
- setiastro/saspro/rgbalign.py +456 -12
- setiastro/saspro/save_options.py +45 -13
- setiastro/saspro/ser_stack_config.py +102 -0
- setiastro/saspro/ser_stacker.py +2327 -0
- setiastro/saspro/ser_stacker_dialog.py +1865 -0
- setiastro/saspro/ser_tracking.py +228 -0
- setiastro/saspro/serviewer.py +1773 -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 +38 -5
- setiastro/saspro/texture_clarity.py +593 -0
- setiastro/saspro/widgets/resource_monitor.py +122 -74
- {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.3.dist-info}/METADATA +3 -2
- {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.3.dist-info}/RECORD +51 -37
- {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.3.dist-info}/WHEEL +0 -0
- {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.3.dist-info}/entry_points.txt +0 -0
- {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.3.dist-info}/licenses/LICENSE +0 -0
- {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.3.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,8 @@ 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'))
|
|
249
|
+
PLANET_PROJECTION = property(lambda self: _resource_path('3dplanet.png'))
|
|
247
250
|
|
|
248
251
|
# Moon phase (WIMS)
|
|
249
252
|
MOON_NEW = property(lambda self: _resource_path('new_moon.png'))
|
|
@@ -301,6 +304,7 @@ class Icons:
|
|
|
301
304
|
COLOR_WHEEL = property(lambda self: _resource_path('colorwheel.png'))
|
|
302
305
|
SELECTIVE_COLOR = property(lambda self: _resource_path('selectivecolor.png'))
|
|
303
306
|
NB_TO_RGB = property(lambda self: _resource_path('nbtorgb.png'))
|
|
307
|
+
NARROWBANDNORMALIZATION = property(lambda self: _resource_path('narrowbandnormalization.png'))
|
|
304
308
|
|
|
305
309
|
# Stretching
|
|
306
310
|
STAT_STRETCH = property(lambda self: _resource_path('statstretch.png'))
|
|
@@ -411,6 +415,7 @@ def _init_legacy_paths():
|
|
|
411
415
|
'green_path': get_icon_path('green.png'),
|
|
412
416
|
'neutral_path': get_icon_path('neutral.png'),
|
|
413
417
|
'whitebalance_path': get_icon_path('whitebalance.png'),
|
|
418
|
+
'texture_clarity_path': get_icon_path('TextureClarity.svg'),
|
|
414
419
|
'morpho_path': get_icon_path('morpho.png'),
|
|
415
420
|
'clahe_path': get_icon_path('clahe.png'),
|
|
416
421
|
'starnet_path': get_icon_path('starnet.png'),
|
|
@@ -531,6 +536,7 @@ def _init_legacy_paths():
|
|
|
531
536
|
'collage_path': get_icon_path('collage.png'),
|
|
532
537
|
'annotated_path': get_icon_path('annotated.png'),
|
|
533
538
|
'colorwheel_path': get_icon_path('colorwheel.png'),
|
|
539
|
+
'narrowbandnormalization_path': get_icon_path('narrowbandnormalization.png'),
|
|
534
540
|
'font_path': get_icon_path('font.png'),
|
|
535
541
|
'csv_icon_path': get_icon_path('cvs.png'),
|
|
536
542
|
'spinner_path': get_data_path('spinner.gif'),
|
|
@@ -540,11 +546,13 @@ def _init_legacy_paths():
|
|
|
540
546
|
'debayer_path': get_icon_path('debayer.png'),
|
|
541
547
|
'aberration_path': get_icon_path('aberration.png'),
|
|
542
548
|
'functionbundles_path': get_icon_path('functionbundle.png'),
|
|
549
|
+
'planetarystacker_path': get_icon_path('planetarystacker.png'),
|
|
543
550
|
'viewbundles_path': get_icon_path('viewbundle.png'),
|
|
544
551
|
'selectivecolor_path': get_icon_path('selectivecolor.png'),
|
|
545
552
|
'rgbalign_path': get_icon_path('rgbalign.png'),
|
|
546
553
|
'background_path': get_icon_path('background.png'),
|
|
547
554
|
'script_icon_path': get_icon_path('script.png'),
|
|
555
|
+
'planetprojection_path': get_icon_path('3dplanet.png'),
|
|
548
556
|
}
|
|
549
557
|
|
|
550
558
|
|
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}")
|