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.

Files changed (51) hide show
  1. setiastro/images/3dplanet.png +0 -0
  2. setiastro/images/TextureClarity.svg +56 -0
  3. setiastro/images/narrowbandnormalization.png +0 -0
  4. setiastro/images/planetarystacker.png +0 -0
  5. setiastro/saspro/__init__.py +9 -8
  6. setiastro/saspro/__main__.py +326 -285
  7. setiastro/saspro/_generated/build_info.py +2 -2
  8. setiastro/saspro/aberration_ai.py +128 -13
  9. setiastro/saspro/aberration_ai_preset.py +29 -3
  10. setiastro/saspro/astrospike_python.py +45 -3
  11. setiastro/saspro/blink_comparator_pro.py +116 -71
  12. setiastro/saspro/curve_editor_pro.py +72 -22
  13. setiastro/saspro/curves_preset.py +249 -47
  14. setiastro/saspro/doc_manager.py +4 -1
  15. setiastro/saspro/gui/main_window.py +326 -46
  16. setiastro/saspro/gui/mixins/file_mixin.py +41 -18
  17. setiastro/saspro/gui/mixins/menu_mixin.py +9 -0
  18. setiastro/saspro/gui/mixins/toolbar_mixin.py +123 -7
  19. setiastro/saspro/histogram.py +179 -7
  20. setiastro/saspro/imageops/narrowband_normalization.py +816 -0
  21. setiastro/saspro/imageops/serloader.py +1429 -0
  22. setiastro/saspro/layers.py +186 -10
  23. setiastro/saspro/layers_dock.py +198 -5
  24. setiastro/saspro/legacy/image_manager.py +10 -4
  25. setiastro/saspro/legacy/numba_utils.py +1 -1
  26. setiastro/saspro/live_stacking.py +24 -4
  27. setiastro/saspro/multiscale_decomp.py +30 -17
  28. setiastro/saspro/narrowband_normalization.py +1618 -0
  29. setiastro/saspro/planetprojection.py +3854 -0
  30. setiastro/saspro/remove_green.py +1 -1
  31. setiastro/saspro/resources.py +8 -0
  32. setiastro/saspro/rgbalign.py +456 -12
  33. setiastro/saspro/save_options.py +45 -13
  34. setiastro/saspro/ser_stack_config.py +102 -0
  35. setiastro/saspro/ser_stacker.py +2327 -0
  36. setiastro/saspro/ser_stacker_dialog.py +1865 -0
  37. setiastro/saspro/ser_tracking.py +228 -0
  38. setiastro/saspro/serviewer.py +1773 -0
  39. setiastro/saspro/sfcc.py +298 -64
  40. setiastro/saspro/shortcuts.py +14 -7
  41. setiastro/saspro/stacking_suite.py +21 -6
  42. setiastro/saspro/stat_stretch.py +179 -31
  43. setiastro/saspro/subwindow.py +38 -5
  44. setiastro/saspro/texture_clarity.py +593 -0
  45. setiastro/saspro/widgets/resource_monitor.py +122 -74
  46. {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.3.dist-info}/METADATA +3 -2
  47. {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.3.dist-info}/RECORD +51 -37
  48. {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.3.dist-info}/WHEEL +0 -0
  49. {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.3.dist-info}/entry_points.txt +0 -0
  50. {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.3.dist-info}/licenses/LICENSE +0 -0
  51. {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.3.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,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
 
@@ -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}")