setiastrosuitepro 1.6.4__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.

Potentially problematic release.


This version of setiastrosuitepro might be problematic. Click here for more details.

Files changed (132) hide show
  1. setiastro/images/TextureClarity.svg +56 -0
  2. setiastro/images/abeicon.svg +16 -0
  3. setiastro/images/acv_icon.png +0 -0
  4. setiastro/images/colorwheel.svg +97 -0
  5. setiastro/images/cosmic.svg +40 -0
  6. setiastro/images/cosmicsat.svg +24 -0
  7. setiastro/images/first_quarter.png +0 -0
  8. setiastro/images/full_moon.png +0 -0
  9. setiastro/images/graxpert.svg +19 -0
  10. setiastro/images/last_quarter.png +0 -0
  11. setiastro/images/linearfit.svg +32 -0
  12. setiastro/images/narrowbandnormalization.png +0 -0
  13. setiastro/images/new_moon.png +0 -0
  14. setiastro/images/pixelmath.svg +42 -0
  15. setiastro/images/planetarystacker.png +0 -0
  16. setiastro/images/waning_crescent_1.png +0 -0
  17. setiastro/images/waning_crescent_2.png +0 -0
  18. setiastro/images/waning_crescent_3.png +0 -0
  19. setiastro/images/waning_crescent_4.png +0 -0
  20. setiastro/images/waning_crescent_5.png +0 -0
  21. setiastro/images/waning_gibbous_1.png +0 -0
  22. setiastro/images/waning_gibbous_2.png +0 -0
  23. setiastro/images/waning_gibbous_3.png +0 -0
  24. setiastro/images/waning_gibbous_4.png +0 -0
  25. setiastro/images/waning_gibbous_5.png +0 -0
  26. setiastro/images/waxing_crescent_1.png +0 -0
  27. setiastro/images/waxing_crescent_2.png +0 -0
  28. setiastro/images/waxing_crescent_3.png +0 -0
  29. setiastro/images/waxing_crescent_4.png +0 -0
  30. setiastro/images/waxing_crescent_5.png +0 -0
  31. setiastro/images/waxing_gibbous_1.png +0 -0
  32. setiastro/images/waxing_gibbous_2.png +0 -0
  33. setiastro/images/waxing_gibbous_3.png +0 -0
  34. setiastro/images/waxing_gibbous_4.png +0 -0
  35. setiastro/images/waxing_gibbous_5.png +0 -0
  36. setiastro/qml/ResourceMonitor.qml +84 -82
  37. setiastro/saspro/__main__.py +20 -1
  38. setiastro/saspro/_generated/build_info.py +2 -2
  39. setiastro/saspro/abe.py +37 -4
  40. setiastro/saspro/aberration_ai.py +364 -33
  41. setiastro/saspro/aberration_ai_preset.py +29 -3
  42. setiastro/saspro/acv_exporter.py +379 -0
  43. setiastro/saspro/add_stars.py +33 -6
  44. setiastro/saspro/astrospike_python.py +45 -3
  45. setiastro/saspro/backgroundneutral.py +108 -40
  46. setiastro/saspro/blemish_blaster.py +4 -1
  47. setiastro/saspro/blink_comparator_pro.py +150 -55
  48. setiastro/saspro/clahe.py +4 -1
  49. setiastro/saspro/continuum_subtract.py +4 -1
  50. setiastro/saspro/convo.py +13 -7
  51. setiastro/saspro/cosmicclarity.py +129 -18
  52. setiastro/saspro/crop_dialog_pro.py +123 -7
  53. setiastro/saspro/curve_editor_pro.py +181 -64
  54. setiastro/saspro/curves_preset.py +249 -47
  55. setiastro/saspro/doc_manager.py +245 -15
  56. setiastro/saspro/exoplanet_detector.py +120 -28
  57. setiastro/saspro/frequency_separation.py +1158 -204
  58. setiastro/saspro/ghs_dialog_pro.py +81 -16
  59. setiastro/saspro/graxpert.py +1 -0
  60. setiastro/saspro/gui/main_window.py +706 -264
  61. setiastro/saspro/gui/mixins/dock_mixin.py +245 -24
  62. setiastro/saspro/gui/mixins/file_mixin.py +35 -16
  63. setiastro/saspro/gui/mixins/menu_mixin.py +35 -1
  64. setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
  65. setiastro/saspro/gui/mixins/toolbar_mixin.py +499 -24
  66. setiastro/saspro/gui/mixins/update_mixin.py +138 -36
  67. setiastro/saspro/gui/mixins/view_mixin.py +42 -0
  68. setiastro/saspro/halobgon.py +4 -0
  69. setiastro/saspro/histogram.py +184 -8
  70. setiastro/saspro/image_combine.py +4 -0
  71. setiastro/saspro/image_peeker_pro.py +4 -0
  72. setiastro/saspro/imageops/narrowband_normalization.py +816 -0
  73. setiastro/saspro/imageops/serloader.py +1345 -0
  74. setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
  75. setiastro/saspro/imageops/stretch.py +582 -62
  76. setiastro/saspro/isophote.py +4 -0
  77. setiastro/saspro/layers.py +13 -9
  78. setiastro/saspro/layers_dock.py +183 -3
  79. setiastro/saspro/legacy/image_manager.py +154 -20
  80. setiastro/saspro/legacy/numba_utils.py +68 -48
  81. setiastro/saspro/legacy/xisf.py +240 -98
  82. setiastro/saspro/live_stacking.py +203 -82
  83. setiastro/saspro/luminancerecombine.py +228 -27
  84. setiastro/saspro/mask_creation.py +174 -15
  85. setiastro/saspro/mfdeconv.py +113 -35
  86. setiastro/saspro/mfdeconvcudnn.py +119 -70
  87. setiastro/saspro/mfdeconvsport.py +112 -35
  88. setiastro/saspro/morphology.py +4 -0
  89. setiastro/saspro/multiscale_decomp.py +81 -29
  90. setiastro/saspro/narrowband_normalization.py +1618 -0
  91. setiastro/saspro/numba_utils.py +72 -57
  92. setiastro/saspro/ops/commands.py +18 -18
  93. setiastro/saspro/ops/script_editor.py +10 -2
  94. setiastro/saspro/ops/scripts.py +122 -0
  95. setiastro/saspro/perfect_palette_picker.py +37 -3
  96. setiastro/saspro/plate_solver.py +84 -49
  97. setiastro/saspro/psf_viewer.py +119 -37
  98. setiastro/saspro/remove_green.py +1 -1
  99. setiastro/saspro/resources.py +73 -0
  100. setiastro/saspro/rgbalign.py +460 -12
  101. setiastro/saspro/selective_color.py +4 -1
  102. setiastro/saspro/ser_stack_config.py +82 -0
  103. setiastro/saspro/ser_stacker.py +2321 -0
  104. setiastro/saspro/ser_stacker_dialog.py +1838 -0
  105. setiastro/saspro/ser_tracking.py +206 -0
  106. setiastro/saspro/serviewer.py +1625 -0
  107. setiastro/saspro/sfcc.py +662 -216
  108. setiastro/saspro/shortcuts.py +171 -33
  109. setiastro/saspro/signature_insert.py +692 -33
  110. setiastro/saspro/stacking_suite.py +1347 -485
  111. setiastro/saspro/star_alignment.py +247 -123
  112. setiastro/saspro/star_spikes.py +4 -0
  113. setiastro/saspro/star_stretch.py +38 -3
  114. setiastro/saspro/stat_stretch.py +892 -129
  115. setiastro/saspro/subwindow.py +787 -363
  116. setiastro/saspro/supernovaasteroidhunter.py +1 -1
  117. setiastro/saspro/texture_clarity.py +593 -0
  118. setiastro/saspro/wavescale_hdr.py +4 -1
  119. setiastro/saspro/wavescalede.py +4 -1
  120. setiastro/saspro/whitebalance.py +84 -12
  121. setiastro/saspro/widgets/common_utilities.py +28 -21
  122. setiastro/saspro/widgets/resource_monitor.py +209 -111
  123. setiastro/saspro/widgets/spinboxes.py +10 -13
  124. setiastro/saspro/wimi.py +27 -656
  125. setiastro/saspro/wims.py +13 -3
  126. setiastro/saspro/xisf.py +101 -11
  127. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/METADATA +4 -2
  128. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/RECORD +132 -87
  129. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/WHEEL +0 -0
  130. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/entry_points.txt +0 -0
  131. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/licenses/LICENSE +0 -0
  132. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/licenses/license.txt +0 -0
@@ -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, QSpinBox
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)
@@ -350,6 +483,10 @@ class RGBAlignDialog(QDialog):
350
483
  self.setWindowFlag(Qt.WindowType.Window, True)
351
484
  self.setWindowModality(Qt.WindowModality.NonModal)
352
485
  self.setModal(False)
486
+ try:
487
+ self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
488
+ except Exception:
489
+ pass # older PyQt6 versions
353
490
  self.parent = parent
354
491
  # document could be a view; try to unwrap
355
492
  self.doc_view = document
@@ -364,17 +501,28 @@ class RGBAlignDialog(QDialog):
364
501
  hl.addWidget(QLabel(self.tr("Alignment model:")))
365
502
  self.model_combo = QComboBox()
366
503
  self.model_combo.addItems([
504
+ "Planet Centroid (ADC)", # NEW
367
505
  "EDGE", # ← first, new default
368
506
  "Homography",
369
507
  "Affine",
370
508
  "Poly 3",
371
509
  "Poly 4",
372
510
  ])
373
- self.model_combo.setCurrentIndex(0)
374
-
375
- # tooltips for each mode
511
+ self.model_combo.setCurrentIndex(1)
376
512
  self.model_combo.setItemData(
377
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,
378
526
  (
379
527
  "EDGE (Edge-Detected Guided Estimator)\n"
380
528
  "• Detect stars in both channels with SEP\n"
@@ -386,22 +534,22 @@ class RGBAlignDialog(QDialog):
386
534
  Qt.ItemDataRole.ToolTipRole,
387
535
  )
388
536
  self.model_combo.setItemData(
389
- 1,
537
+ 2,
390
538
  "Standard homography using astroalign matches (good general-purpose choice).",
391
539
  Qt.ItemDataRole.ToolTipRole,
392
540
  )
393
541
  self.model_combo.setItemData(
394
- 2,
542
+ 3,
395
543
  "Affine (shift + scale + rotate + shear). Good when channels are mostly parallel.",
396
544
  Qt.ItemDataRole.ToolTipRole,
397
545
  )
398
546
  self.model_combo.setItemData(
399
- 3,
547
+ 4,
400
548
  "Polynomial (order 3). Use when you have mild field distortion.",
401
549
  Qt.ItemDataRole.ToolTipRole,
402
550
  )
403
551
  self.model_combo.setItemData(
404
- 4,
552
+ 5,
405
553
  "Polynomial (order 4). Use for stronger distortion, but needs more/better matches.",
406
554
  Qt.ItemDataRole.ToolTipRole,
407
555
  )
@@ -450,8 +598,15 @@ class RGBAlignDialog(QDialog):
450
598
 
451
599
  btns = QHBoxLayout()
452
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
+
453
607
  self.btn_close = QPushButton(self.tr("Close"))
454
608
  btns.addWidget(self.btn_run)
609
+ btns.addWidget(self.btn_manual)
455
610
  btns.addWidget(self.btn_close)
456
611
  lay.addLayout(btns)
457
612
 
@@ -460,6 +615,56 @@ class RGBAlignDialog(QDialog):
460
615
 
461
616
  self.worker: RGBAlignWorker | None = None
462
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
+
463
668
  def _trial_sep_detect(self):
464
669
  if self.image is None:
465
670
  QMessageBox.warning(self, "RGB Align", "No image loaded.")
@@ -525,6 +730,8 @@ class RGBAlignDialog(QDialog):
525
730
 
526
731
  def _selected_model(self) -> str:
527
732
  txt = self.model_combo.currentText().lower()
733
+ if "planet" in txt or "centroid" in txt or "adc" in txt:
734
+ return "planet-centroid"
528
735
  if "edge" in txt:
529
736
  return "edge-sep"
530
737
  if "affine" in txt:
@@ -535,7 +742,7 @@ class RGBAlignDialog(QDialog):
535
742
  return "poly4"
536
743
  if "homography" in txt:
537
744
  return "homography"
538
- return "edge-sep" # super-safe fallback
745
+ return "edge-sep"
539
746
 
540
747
  # slots
541
748
  def _on_worker_progress(self, pct: int, msg: str):
@@ -589,7 +796,10 @@ class RGBAlignDialog(QDialog):
589
796
  if w.r_pairs is not None and len(w.r_pairs) == 2:
590
797
  summary_lines.append(_spread_stats(w.r_pairs[1], (h_img, w_img)))
591
798
  summary_lines.append(f" model: {kind}")
592
- if kind == "affine":
799
+ if kind == "translate":
800
+ dx, dy = X
801
+ summary_lines.append(f" shift: dx={dx:.3f}, dy={dy:.3f}")
802
+ elif kind == "affine":
593
803
  A = np.asarray(X, dtype=float).reshape(2, 3)
594
804
  M = np.vstack([A, [0, 0, 1]])
595
805
  summary_lines.append(_fmt_mat(M))
@@ -607,7 +817,10 @@ class RGBAlignDialog(QDialog):
607
817
  if w.b_pairs is not None and len(w.b_pairs) == 2:
608
818
  summary_lines.append(_spread_stats(w.b_pairs[1], (h_img, w_img)))
609
819
  summary_lines.append(f" model: {kind}")
610
- if kind == "affine":
820
+ if kind == "translate":
821
+ dx, dy = X
822
+ summary_lines.append(f" shift: dx={dx:.3f}, dy={dy:.3f}")
823
+ elif kind == "affine":
611
824
  A = np.asarray(X, dtype=float).reshape(2, 3)
612
825
  M = np.vstack([A, [0, 0, 1]])
613
826
  summary_lines.append(_fmt_mat(M))
@@ -721,3 +934,238 @@ def run_rgb_align_headless(main_window, document, preset: dict | None = None):
721
934
 
722
935
  if callable(sb):
723
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}")
@@ -458,7 +458,10 @@ class SelectiveColorCorrection(QDialog):
458
458
  self.setWindowTitle(self.tr("Selective Color Correction"))
459
459
  if window_icon:
460
460
  self.setWindowIcon(window_icon)
461
-
461
+ try:
462
+ self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
463
+ except Exception:
464
+ pass # older PyQt6 versions
462
465
  self.docman = doc_manager
463
466
  self.document = document
464
467
  if self.document is None or getattr(self.document, "image", None) is None:
@@ -0,0 +1,82 @@
1
+ # src/setiastro/saspro/ser_stack_config.py
2
+ from __future__ import annotations
3
+ from dataclasses import dataclass
4
+ from typing import Optional, Tuple, Literal, Union, Sequence, TYPE_CHECKING
5
+
6
+ if TYPE_CHECKING:
7
+ import numpy as np
8
+ KeepMask = "np.ndarray"
9
+ else:
10
+ KeepMask = object
11
+
12
+ from setiastro.saspro.imageops.serloader import PlanetaryFrameSource
13
+
14
+ TrackMode = Literal["off", "planetary", "surface"]
15
+ PlanetarySource = Union[str, Sequence[str], PlanetaryFrameSource]
16
+
17
+ @dataclass
18
+ class SERStackConfig:
19
+ source: PlanetarySource
20
+ roi: Optional[Tuple[int, int, int, int]] = None
21
+ track_mode: TrackMode = "planetary"
22
+ surface_anchor: Optional[Tuple[int, int, int, int]] = None
23
+ keep_percent: float = 20.0
24
+ bayer_pattern: Optional[str] = None
25
+
26
+ # AP / alignment
27
+ ap_size: int = 64
28
+ ap_spacing: int = 48
29
+ ap_min_mean: float = 0.03
30
+ ap_multiscale: bool = False
31
+ ssd_refine_bruteforce: bool = False
32
+ keep_mask: Optional[KeepMask] = None
33
+
34
+ # ✅ Drizzle
35
+ drizzle_scale: float = 1.0 # 1.0 = off, 1.5, 2.0
36
+ drizzle_pixfrac: float = 0.80 # "drop shrink" in output pixels (roughly)
37
+ drizzle_kernel: str = "gaussian" # "square" | "circle" | "gaussian"
38
+ drizzle_sigma: float = 0.0 # only used for gaussian; 0 => auto from pixfrac
39
+
40
+ def __init__(self, source: PlanetarySource, **kwargs):
41
+ # Allow deprecated/ignored kwargs without crashing
42
+ kwargs.pop("multipoint", None) # accept but ignore
43
+
44
+ self.source = source
45
+ self.roi = kwargs.pop("roi", None)
46
+ self.track_mode = kwargs.pop("track_mode", "planetary")
47
+ self.surface_anchor = kwargs.pop("surface_anchor", None)
48
+ self.keep_percent = float(kwargs.pop("keep_percent", 20.0))
49
+ self.bayer_pattern = kwargs.pop("bayer_pattern", None)
50
+ if isinstance(self.bayer_pattern, str):
51
+ s = self.bayer_pattern.strip().upper()
52
+ self.bayer_pattern = s if s in ("RGGB", "BGGR", "GRBG", "GBRG") else None
53
+ else:
54
+ self.bayer_pattern = None
55
+ self.ap_size = int(kwargs.pop("ap_size", 64))
56
+ self.ap_spacing = int(kwargs.pop("ap_spacing", 48))
57
+ self.ap_min_mean = float(kwargs.pop("ap_min_mean", 0.03))
58
+ self.ap_multiscale = bool(kwargs.pop("ap_multiscale", False))
59
+ self.ssd_refine_bruteforce = bool(kwargs.pop("ssd_refine_bruteforce", False))
60
+ self.keep_mask = kwargs.pop("keep_mask", None)
61
+
62
+ # ✅ NEW: Drizzle params
63
+ self.drizzle_scale = float(kwargs.pop("drizzle_scale", 1.0))
64
+ if self.drizzle_scale not in (1.0, 1.5, 2.0):
65
+ self.drizzle_scale = 1.0
66
+
67
+ self.drizzle_pixfrac = float(kwargs.pop("drizzle_pixfrac", 0.80))
68
+ self.drizzle_kernel = str(kwargs.pop("drizzle_kernel", "gaussian")).strip().lower()
69
+ self.drizzle_sigma = float(kwargs.pop("drizzle_sigma", 0.0))
70
+
71
+ # sanitize a bit
72
+ if self.drizzle_scale < 1.0:
73
+ self.drizzle_scale = 1.0
74
+ if self.drizzle_pixfrac <= 0.0:
75
+ self.drizzle_pixfrac = 0.01
76
+ if self.drizzle_kernel not in ("square", "circle", "gaussian"):
77
+ self.drizzle_kernel = "gaussian"
78
+ if self.drizzle_sigma < 0.0:
79
+ self.drizzle_sigma = 0.0
80
+
81
+ if kwargs:
82
+ raise TypeError(f"Unexpected config keys: {sorted(kwargs.keys())}")