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.
Files changed (42) hide show
  1. setiastro/images/TextureClarity.svg +56 -0
  2. setiastro/images/narrowbandnormalization.png +0 -0
  3. setiastro/images/planetarystacker.png +0 -0
  4. setiastro/saspro/_generated/build_info.py +2 -2
  5. setiastro/saspro/aberration_ai.py +128 -13
  6. setiastro/saspro/aberration_ai_preset.py +29 -3
  7. setiastro/saspro/astrospike_python.py +45 -3
  8. setiastro/saspro/blink_comparator_pro.py +116 -71
  9. setiastro/saspro/curve_editor_pro.py +72 -22
  10. setiastro/saspro/curves_preset.py +249 -47
  11. setiastro/saspro/gui/main_window.py +285 -44
  12. setiastro/saspro/gui/mixins/file_mixin.py +35 -16
  13. setiastro/saspro/gui/mixins/menu_mixin.py +8 -0
  14. setiastro/saspro/gui/mixins/toolbar_mixin.py +115 -6
  15. setiastro/saspro/histogram.py +179 -7
  16. setiastro/saspro/imageops/narrowband_normalization.py +816 -0
  17. setiastro/saspro/imageops/serloader.py +1345 -0
  18. setiastro/saspro/legacy/numba_utils.py +1 -1
  19. setiastro/saspro/live_stacking.py +24 -4
  20. setiastro/saspro/multiscale_decomp.py +30 -17
  21. setiastro/saspro/narrowband_normalization.py +1618 -0
  22. setiastro/saspro/remove_green.py +1 -1
  23. setiastro/saspro/resources.py +6 -0
  24. setiastro/saspro/rgbalign.py +456 -12
  25. setiastro/saspro/ser_stack_config.py +82 -0
  26. setiastro/saspro/ser_stacker.py +2321 -0
  27. setiastro/saspro/ser_stacker_dialog.py +1838 -0
  28. setiastro/saspro/ser_tracking.py +206 -0
  29. setiastro/saspro/serviewer.py +1625 -0
  30. setiastro/saspro/sfcc.py +298 -64
  31. setiastro/saspro/shortcuts.py +14 -7
  32. setiastro/saspro/stacking_suite.py +21 -6
  33. setiastro/saspro/stat_stretch.py +179 -31
  34. setiastro/saspro/subwindow.py +2 -4
  35. setiastro/saspro/texture_clarity.py +593 -0
  36. setiastro/saspro/widgets/resource_monitor.py +122 -74
  37. {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/METADATA +3 -2
  38. {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/RECORD +42 -30
  39. {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/WHEEL +0 -0
  40. {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/entry_points.txt +0 -0
  41. {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/licenses/LICENSE +0 -0
  42. {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/licenses/license.txt +0 -0
@@ -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 noise:")))
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)
@@ -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'),
@@ -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)
@@ -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(0)
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
- 1,
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
- 2,
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
- 3,
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
- 4,
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" # super-safe fallback
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 == "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":
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 == "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":
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}")