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
@@ -631,7 +631,10 @@ class StellarAlignmentDialog(QDialog):
631
631
  self.setWindowFlag(Qt.WindowType.Window, True)
632
632
  self.setWindowModality(Qt.WindowModality.NonModal)
633
633
  self.setModal(False)
634
- #self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
634
+ try:
635
+ self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
636
+ except Exception:
637
+ pass # older PyQt6 versions
635
638
 
636
639
  self.settings = settings
637
640
  self.parent_window = parent
@@ -4583,11 +4586,12 @@ def load_api_key():
4583
4586
  class MosaicMasterDialog(QDialog):
4584
4587
  def __init__(self, settings: QSettings, parent=None, image_manager=None,
4585
4588
  doc_manager=None, wrench_path=None, spinner_path=None,
4586
- list_open_docs_fn=None): # ← add param
4589
+ list_open_docs_fn=None):
4587
4590
  super().__init__(parent)
4588
4591
  self.settings = settings
4589
4592
  self.image_manager = image_manager
4590
4593
  self._docman = doc_manager or getattr(parent, "doc_manager", None)
4594
+
4591
4595
  # same pattern as StellarAlignmentDialog
4592
4596
  if list_open_docs_fn is None:
4593
4597
  cand = getattr(parent, "_list_open_docs", None)
@@ -4599,19 +4603,22 @@ class MosaicMasterDialog(QDialog):
4599
4603
  self.setWindowFlag(Qt.WindowType.Window, True)
4600
4604
  self.setWindowModality(Qt.WindowModality.NonModal)
4601
4605
  self.setModal(False)
4606
+
4602
4607
  self.wrench_path = wrench_path
4603
4608
  self.spinner_path = spinner_path
4604
4609
 
4605
4610
  self.resize(600, 400)
4606
- self.loaded_images = []
4611
+ self.loaded_images = []
4607
4612
  self.final_mosaic = None
4608
4613
  self.weight_mosaic = None
4609
4614
  self.wcs_metadata = None # To store mosaic WCS header
4610
4615
  self.astap_exe = self.settings.value("paths/astap", "", type=str)
4616
+
4611
4617
  # Variables to store stretching parameters:
4612
4618
  self.stretch_original_mins = []
4613
4619
  self.stretch_original_medians = []
4614
4620
  self.was_single_channel = False
4621
+
4615
4622
  self.initUI()
4616
4623
 
4617
4624
  def initUI(self):
@@ -4632,12 +4639,11 @@ class MosaicMasterDialog(QDialog):
4632
4639
  layout.addWidget(instructions)
4633
4640
 
4634
4641
  btn_layout = QHBoxLayout()
4635
- # Button to add image from disk
4642
+
4636
4643
  add_btn = QPushButton("Add Image")
4637
4644
  add_btn.clicked.connect(self.add_image)
4638
4645
  btn_layout.addWidget(add_btn)
4639
4646
 
4640
- # New button to add an image from one of the ImageManager slots
4641
4647
  add_from_view_btn = QPushButton("Add from View")
4642
4648
  add_from_view_btn.setToolTip("Add an image from any open View")
4643
4649
  add_from_view_btn.clicked.connect(self.add_image_from_view)
@@ -4659,25 +4665,80 @@ class MosaicMasterDialog(QDialog):
4659
4665
  save_btn.clicked.connect(self.save_mosaic_to_new_view)
4660
4666
  btn_layout.addWidget(save_btn)
4661
4667
 
4662
- # Add the wrench button for settings.
4663
4668
  wrench_btn = QPushButton()
4664
- wrench_btn.setIcon(QIcon(self.wrench_path))
4669
+ if self.wrench_path:
4670
+ wrench_btn.setIcon(QIcon(self.wrench_path))
4665
4671
  wrench_btn.setToolTip("Mosaic Settings")
4666
4672
  wrench_btn.clicked.connect(self.openSettings)
4667
4673
  btn_layout.addWidget(wrench_btn)
4668
4674
 
4669
4675
  layout.addLayout(btn_layout)
4670
4676
 
4671
- # Horizontal sizer for checkboxes.
4677
+ # ------------------------------------------------------------------
4678
+ # Mode checkboxes (mutually exclusive: Seestar vs WCS-only)
4679
+ # ------------------------------------------------------------------
4672
4680
  checkbox_layout = QHBoxLayout()
4681
+
4673
4682
  self.forceBlindCheckBox = QCheckBox("Force Blind Solve (ignore existing WCS)")
4674
4683
  checkbox_layout.addWidget(self.forceBlindCheckBox)
4675
- # New Seestar Mode checkbox:
4684
+
4676
4685
  self.seestarCheckBox = QCheckBox("Seestar Mode")
4677
- self.seestarCheckBox.setToolTip("Wwen enabled, images are aligned iteratively using astroalign without plate solving.")
4686
+ self.seestarCheckBox.setToolTip(
4687
+ "When enabled, images are aligned iteratively using astroalign without plate solving."
4688
+ )
4678
4689
  checkbox_layout.addWidget(self.seestarCheckBox)
4690
+
4691
+ self.wcsOnlyCheckBox = QCheckBox("Disable Star Alignment (WCS placement only)")
4692
+ self.wcsOnlyCheckBox.setToolTip(
4693
+ "Skips astroalign + refined alignment.\n"
4694
+ "Panels are only reprojected into the mosaic celestial-sphere frame using WCS, then blended.\n"
4695
+ "Useful when panels have little/no overlap but have valid WCS."
4696
+ )
4697
+ checkbox_layout.addWidget(self.wcsOnlyCheckBox)
4698
+
4679
4699
  layout.addLayout(checkbox_layout)
4680
4700
 
4701
+ # Persisted WCS-only
4702
+ _settings = QSettings("SetiAstro", "SASpro")
4703
+ self.wcsOnlyCheckBox.setChecked(_settings.value("mosaic/wcs_only", False, type=bool))
4704
+
4705
+ # Helpers ----------------------------------------------------------
4706
+ def _set_checked(cb: QCheckBox, checked: bool):
4707
+ cb.blockSignals(True)
4708
+ cb.setChecked(checked)
4709
+ cb.blockSignals(False)
4710
+
4711
+ def _sync_wcs_only_ui():
4712
+ # WCS-only disables transform selection (because refinement is unused)
4713
+ wcs_only = self.wcsOnlyCheckBox.isChecked()
4714
+ if hasattr(self, "transform_combo"):
4715
+ self.transform_combo.setEnabled(not wcs_only)
4716
+ self.transform_combo.setToolTip(
4717
+ "" if not wcs_only else "Disabled because WCS-only placement is enabled."
4718
+ )
4719
+
4720
+ def _on_seestar_changed(state: int):
4721
+ # If Seestar is turned ON, force WCS-only OFF
4722
+ if self.seestarCheckBox.isChecked():
4723
+ _set_checked(self.wcsOnlyCheckBox, False)
4724
+ _sync_wcs_only_ui()
4725
+
4726
+ def _on_wcs_only_changed(state: int):
4727
+ # Persist setting first (this one is user-visible preference)
4728
+ QSettings("SetiAstro", "SASpro").setValue("mosaic/wcs_only", self.wcsOnlyCheckBox.isChecked())
4729
+
4730
+ # If WCS-only is turned ON, force Seestar OFF
4731
+ if self.wcsOnlyCheckBox.isChecked():
4732
+ _set_checked(self.seestarCheckBox, False)
4733
+ _sync_wcs_only_ui()
4734
+
4735
+ # Wire handlers (NO lambdas, no duplicates)
4736
+ self.seestarCheckBox.stateChanged.connect(_on_seestar_changed)
4737
+ self.wcsOnlyCheckBox.stateChanged.connect(_on_wcs_only_changed)
4738
+
4739
+ # ------------------------------------------------------------------
4740
+ # Other controls
4741
+ # ------------------------------------------------------------------
4681
4742
  self.normalizeCheckBox = QCheckBox("Normalize images (median match)")
4682
4743
  self.normalizeCheckBox.setChecked(True)
4683
4744
  layout.addWidget(self.normalizeCheckBox)
@@ -4701,24 +4762,21 @@ class MosaicMasterDialog(QDialog):
4701
4762
  "Precise — Full WCS: astropy.reproject per channel; slowest, most exact."
4702
4763
  )
4703
4764
 
4704
- # Persist user choice
4705
- _settings = QSettings("SetiAstro", "SASpro")
4706
- _default_mode = _settings.value("mosaic/reproject_mode",
4707
- "Fast — SIP-aware (Exact Remap)")
4708
- if _default_mode not in [self.reprojectModeCombo.itemText(i) for i in range(self.reprojectModeCombo.count())]:
4765
+ _default_mode = _settings.value("mosaic/reproject_mode", "Fast — SIP-aware (Exact Remap)")
4766
+ valid_modes = [self.reprojectModeCombo.itemText(i) for i in range(self.reprojectModeCombo.count())]
4767
+ if _default_mode not in valid_modes:
4709
4768
  _default_mode = "Fast — SIP-aware (Exact Remap)"
4710
4769
  self.reprojectModeCombo.setCurrentText(_default_mode)
4711
4770
  self.reprojectModeCombo.currentTextChanged.connect(
4712
4771
  lambda t: QSettings("SetiAstro", "SASpro").setValue("mosaic/reproject_mode", t)
4713
4772
  )
4714
4773
 
4715
- # Add to layout where the old checkbox lived
4716
4774
  row = QHBoxLayout()
4717
4775
  row.addWidget(self.reprojectModeLabel)
4718
4776
  row.addWidget(self.reprojectModeCombo, 1)
4719
4777
  layout.addLayout(row)
4720
4778
 
4721
-
4779
+ # Transform selection
4722
4780
  self.transform_combo = QComboBox()
4723
4781
  self.transform_combo.addItems([
4724
4782
  "Partial Affine Transform",
@@ -4726,11 +4784,14 @@ class MosaicMasterDialog(QDialog):
4726
4784
  "Homography Transform",
4727
4785
  "Polynomial Warp Based Transform"
4728
4786
  ])
4729
- # Set the default selection to "Affine Transform" (index 1)
4730
4787
  self.transform_combo.setCurrentIndex(1)
4731
4788
  layout.addWidget(QLabel("Select Transformation Method:"))
4732
4789
  layout.addWidget(self.transform_combo)
4733
4790
 
4791
+ # Now that transform_combo exists, apply WCS-only UI state
4792
+ _sync_wcs_only_ui()
4793
+
4794
+ # List + status + spinner
4734
4795
  self.images_list = QListWidget()
4735
4796
  self.images_list.setSelectionMode(self.images_list.SelectionMode.SingleSelection)
4736
4797
  layout.addWidget(self.images_list)
@@ -4740,13 +4801,15 @@ class MosaicMasterDialog(QDialog):
4740
4801
 
4741
4802
  self.spinnerLabel = QLabel(self)
4742
4803
  self.spinnerLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
4743
- self.spinnerMovie = QMovie(self.spinner_path)
4744
- self.spinnerLabel.setMovie(self.spinnerMovie)
4745
- self.spinnerLabel.hide()
4804
+ self.spinnerMovie = QMovie(self.spinner_path) if self.spinner_path else None
4805
+ if self.spinnerMovie:
4806
+ self.spinnerLabel.setMovie(self.spinnerMovie)
4807
+ self.spinnerLabel.hide()
4746
4808
  layout.addWidget(self.spinnerLabel)
4747
4809
 
4748
4810
  self.setLayout(layout)
4749
4811
 
4812
+
4750
4813
  def _target_median_from_first(self, items):
4751
4814
  # Pick a stable target (median of first image after safe clipping)
4752
4815
  if not items:
@@ -5293,6 +5356,7 @@ class MosaicMasterDialog(QDialog):
5293
5356
 
5294
5357
  # ---------- Align (Entry Point) ----------
5295
5358
  def align_images(self):
5359
+ # Seestar mode is its own pipeline
5296
5360
  if self.seestarCheckBox.isChecked():
5297
5361
  self.align_images_seestar_mode()
5298
5362
  return
@@ -5303,28 +5367,33 @@ class MosaicMasterDialog(QDialog):
5303
5367
 
5304
5368
  # Show spinner and start animation.
5305
5369
  self.spinnerLabel.show()
5306
- self.spinnerMovie.start()
5370
+ if self.spinnerMovie:
5371
+ self.spinnerMovie.start()
5307
5372
  QApplication.processEvents()
5308
5373
 
5309
- # Step 1: Force blind solve if requested.
5374
+ # ------------------------------------------------------------
5375
+ # 1) Plate solve (unless already solved and not forcing blind)
5376
+ # ------------------------------------------------------------
5310
5377
  force_blind = self.forceBlindCheckBox.isChecked()
5311
- images_to_process = (self.loaded_images if force_blind
5312
- else [item for item in self.loaded_images if item.get("wcs") is None])
5378
+ images_to_process = (
5379
+ self.loaded_images if force_blind
5380
+ else [item for item in self.loaded_images if item.get("wcs") is None]
5381
+ )
5313
5382
 
5314
- # Process each image for plate solving.
5315
5383
  for item in images_to_process:
5316
- # Check if ASTAP is set.
5384
+ # Ensure ASTAP path (or fall back to blind solve)
5317
5385
  if not self.astap_exe or not os.path.exists(self.astap_exe):
5318
5386
  executable_filter = "Executables (*.exe);;All Files (*)" if sys.platform.startswith("win") else "Executables (*)"
5319
5387
  new_path, _ = QFileDialog.getOpenFileName(self, "Select ASTAP Executable", "", executable_filter)
5320
5388
  if new_path:
5321
5389
  self._save_astap_exe_to_settings(new_path)
5390
+ self.astap_exe = new_path
5322
5391
  QMessageBox.information(self, "Mosaic Master", "ASTAP path updated successfully.")
5323
5392
  else:
5324
- QMessageBox.warning(self, "Mosaic Master", "ASTAP path not provided. Falling back to blind solve.")
5393
+ self.status_label.setText(f"No ASTAP path; blind solving {item['path']}...")
5394
+ QApplication.processEvents()
5325
5395
  solved_header = self.perform_blind_solve(item)
5326
5396
  if solved_header:
5327
- # normalize + build WCS with relax=True so SIP is retained
5328
5397
  solved_header = self._normalize_wcs_header(solved_header, item["image"].shape)
5329
5398
  item["wcs"] = self._build_wcs(solved_header, item["image"].shape)
5330
5399
  continue
@@ -5338,49 +5407,53 @@ class MosaicMasterDialog(QDialog):
5338
5407
  self.status_label.setText(f"ASTAP failed for {item['path']}. Falling back to blind solve...")
5339
5408
  QApplication.processEvents()
5340
5409
  solved_header = self.perform_blind_solve(item)
5341
- else:
5342
- self.status_label.setText(f"Plate solve successful using ASTAP for {item['path']}.")
5343
5410
 
5344
5411
  if solved_header:
5345
- # Single, centralized sanitize → keeps SIP and fixes types
5346
5412
  solved_header = self._normalize_wcs_header(solved_header, item["image"].shape)
5347
5413
  item["wcs"] = self._build_wcs(solved_header, item["image"].shape)
5348
5414
  else:
5349
- print(f"Plate solving failed for {item['path']}.")
5415
+ print(f"[Mosaic] Plate solving failed for {item['path']}.")
5350
5416
 
5351
- # After processing, get all images with valid WCS.
5417
+ # ------------------------------------------------------------
5418
+ # 2) Gather WCS-valid panels
5419
+ # ------------------------------------------------------------
5352
5420
  wcs_items = [x for x in self.loaded_images if x.get("wcs") is not None]
5353
5421
  if not wcs_items:
5354
- print("No images have WCS, skipping WCS alignment.")
5355
- self.spinnerMovie.stop()
5422
+ print("[Mosaic] No images have WCS; cannot build WCS mosaic.")
5423
+ if self.spinnerMovie:
5424
+ self.spinnerMovie.stop()
5356
5425
  self.spinnerLabel.hide()
5357
5426
  return
5358
5427
 
5428
+ # ------------------------------------------------------------
5429
+ # 3) Establish mosaic WCS + output bounding box
5430
+ # ------------------------------------------------------------
5431
+ reference_wcs = self._build_wcs(
5432
+ wcs_items[0]["wcs"].to_header(relax=True),
5433
+ wcs_items[0]["image"].shape
5434
+ ).deepcopy()
5359
5435
 
5360
- # Use the first image's WCS as reference and compute the mosaic bounding box.
5361
- # (Rebuild with relax=True just in case, then deepcopy.)
5362
- reference_wcs = self._build_wcs(wcs_items[0]["wcs"].to_header(relax=True), wcs_items[0]["image"].shape).deepcopy()
5363
5436
  min_x, min_y, max_x, max_y = self.compute_mosaic_bounding_box(wcs_items, reference_wcs)
5364
- mosaic_width = int(max_x - min_x)
5437
+ mosaic_width = int(max_x - min_x)
5365
5438
  mosaic_height = int(max_y - min_y)
5366
5439
 
5367
5440
  if mosaic_width < 1 or mosaic_height < 1:
5368
- print("ERROR: Computed mosaic size is invalid. Check WCS or inputs.")
5369
- self.spinnerMovie.stop()
5441
+ print("[Mosaic] ERROR: Computed mosaic size invalid. Check WCS/inputs.")
5442
+ if self.spinnerMovie:
5443
+ self.spinnerMovie.stop()
5370
5444
  self.spinnerLabel.hide()
5371
5445
  return
5372
5446
 
5373
- # Adjust the reference WCS so that (min_x, min_y) becomes (0,0).
5374
5447
  mosaic_wcs = reference_wcs.deepcopy()
5375
5448
  mosaic_wcs.wcs.crpix[0] -= min_x
5376
5449
  mosaic_wcs.wcs.crpix[1] -= min_y
5377
- # keep SIP in the stored header
5378
5450
  self.wcs_metadata = mosaic_wcs.to_header(relax=True)
5379
5451
 
5380
- # Set up accumulators.
5452
+ # ------------------------------------------------------------
5453
+ # 4) Allocate accumulators
5454
+ # ------------------------------------------------------------
5381
5455
  is_color = any(not item["is_mono"] for item in wcs_items)
5382
5456
 
5383
- # stats for optional "unstretch"
5384
5457
  self.stretch_original_mins = []
5385
5458
  self.stretch_original_medians = []
5386
5459
  self.was_single_channel = (not is_color)
@@ -5391,49 +5464,77 @@ class MosaicMasterDialog(QDialog):
5391
5464
  self.final_mosaic, _ = smart_zeros((mosaic_height, mosaic_width), dtype=np.float32)
5392
5465
  self.weight_mosaic, _ = smart_zeros((mosaic_height, mosaic_width), dtype=np.float32)
5393
5466
 
5467
+ # ------------------------------------------------------------
5468
+ # 5) Normalization target — compute ONCE, apply ALWAYS
5469
+ # ------------------------------------------------------------
5470
+ did_normalize = bool(self.normalizeCheckBox.isChecked())
5471
+
5472
+ # IMPORTANT: compute from RAW first panel image, not from dict list in a way that can drift
5473
+ if did_normalize:
5474
+ try:
5475
+ first_raw = wcs_items[0]["image"]
5476
+ self._mosaic_target_median = self._target_median_from_first([{"image": first_raw}])
5477
+ except Exception:
5478
+ # fallback: compute directly
5479
+ a0 = wcs_items[0]["image"].astype(np.float32, copy=False)
5480
+ if a0.ndim == 3:
5481
+ a0m = np.mean(a0, axis=2)
5482
+ else:
5483
+ a0m = a0
5484
+ lo = np.percentile(a0m, 1)
5485
+ hi = np.percentile(a0m, 99)
5486
+ self._mosaic_target_median = float(max(np.median(np.clip(a0m, lo, hi)), 1e-6))
5487
+
5488
+ print(f"[Mosaic] normalization target median = {self._mosaic_target_median:.6g}")
5489
+
5490
+ # Reprojection helpers cache
5491
+ if not hasattr(self, "_H_cache"):
5492
+ self._H_cache = {}
5493
+
5494
+ # WCS-only toggle (no star alignment/refinement)
5495
+ wcs_only = bool(getattr(self, "wcsOnlyCheckBox", None) and self.wcsOnlyCheckBox.isChecked())
5496
+
5497
+ # ------------------------------------------------------------
5498
+ # 6) Main loop: normalize -> reproject -> (optional) star align -> accumulate
5499
+ # ------------------------------------------------------------
5394
5500
  first_image = True
5501
+
5395
5502
  for idx, itm in enumerate(wcs_items):
5396
5503
  arr = itm["image"]
5504
+
5397
5505
  self.status_label.setText(f"Mapping {itm['path']} into mosaic frame...")
5398
5506
  QApplication.processEvents()
5399
5507
 
5400
5508
  img_lin = arr.astype(np.float32, copy=False)
5401
5509
 
5402
- # --- record original stats for optional "unstretch" ---
5510
+ # --- record original stats (pre-normalization) for optional unstretch ---
5403
5511
  mono_for_stats = img_lin if img_lin.ndim == 2 else np.mean(img_lin, axis=2)
5404
5512
  self.stretch_original_mins.append(float(np.min(mono_for_stats)))
5405
5513
  self.stretch_original_medians.append(float(np.median(mono_for_stats)))
5406
5514
 
5407
- # 1) optional median normalization only
5408
- if self.normalizeCheckBox.isChecked():
5409
- target_med = getattr(self, "_mosaic_target_median", None)
5410
- if target_med is None:
5411
- self._mosaic_target_median = self._target_median_from_first(wcs_items)
5412
- target_med = self._mosaic_target_median
5413
- img_lin = self._normalize_linear(img_lin, target_med)
5414
-
5415
- # 2) Reprojection (3 modes)
5416
- if not hasattr(self, "_H_cache"):
5417
- self._H_cache = {}
5515
+ # --- ALWAYS normalize here if enabled (even in WCS-only) ---
5516
+ if did_normalize:
5517
+ img_lin = self._normalize_linear(img_lin, float(self._mosaic_target_median))
5418
5518
 
5519
+ # --- Reprojection mode ---
5419
5520
  mode = self.reprojectModeCombo.currentText()
5420
5521
 
5421
5522
  if mode.startswith("Fast — SIP"):
5422
- # Exact dense remap (SIP-aware), tiled; keep mono as 2D
5423
5523
  reprojected = self._warp_via_wcs_remap_exact(
5424
5524
  img_lin, itm["wcs"], mosaic_wcs, (mosaic_height, mosaic_width), tile=512
5425
- ).astype(np.float32)
5525
+ ).astype(np.float32, copy=False)
5526
+ reprojected = self._polish_reprojected(reprojected)
5426
5527
  reproj_red = reprojected[..., 0] if reprojected.ndim == 3 else reprojected
5427
5528
 
5428
5529
  elif mode.startswith("Fast — Homography"):
5429
- # Single global homography; keep mono as 2D
5430
5530
  reprojected = self._warp_via_wcs_homography(
5431
5531
  img_lin, itm["wcs"], mosaic_wcs, (mosaic_height, mosaic_width), H_cache=self._H_cache
5432
- ).astype(np.float32)
5532
+ ).astype(np.float32, copy=False)
5533
+ reprojected = self._polish_reprojected(reprojected)
5433
5534
  reproj_red = reprojected[..., 0] if reprojected.ndim == 3 else reprojected
5434
5535
 
5435
5536
  else:
5436
- # Precise — Full WCS (astropy.reproject); mono stays 2D
5537
+ # Precise — Full WCS
5437
5538
  if img_lin.ndim == 3:
5438
5539
  channels = []
5439
5540
  for c in range(3):
@@ -5443,52 +5544,49 @@ class MosaicMasterDialog(QDialog):
5443
5544
  reprojected = np.stack(channels, axis=-1)
5444
5545
  reproj_red = reprojected[..., 0]
5445
5546
  else:
5446
- reproj_red, _ = reproject_interp((img_lin, itm["wcs"]), mosaic_wcs,
5447
- shape_out=(mosaic_height, mosaic_width))
5448
- reprojected = np.nan_to_num(reproj_red, nan=0.0).astype(np.float32) # 2D mono
5449
- # no fake stacking here
5450
-
5451
- self.status_label.setText(f"WCS map: {itm['path']} processed.")
5452
- QApplication.processEvents()
5453
-
5454
- # --- Stellar Alignment ---
5455
- if not first_image:
5456
- transform_method = self.transform_combo.currentText()
5457
- mosaic_gray = (self.final_mosaic if self.final_mosaic.ndim == 2
5458
- else np.mean(self.final_mosaic, axis=-1))
5459
- try:
5460
- self.status_label.setText("Computing affine transform with astroalign...")
5461
- QApplication.processEvents()
5462
- transform_obj, (src_pts, dst_pts) = self._aa_find_transform_with_backoff(reproj_red, mosaic_gray)
5463
- transform_matrix = transform_obj.params[0:2, :].astype(np.float32)
5464
- self.status_label.setText("Astroalign computed transform successfully.")
5465
- except Exception as e:
5466
- self.status_label.setText(f"Astroalign failed: {e}. Using identity transform.")
5467
- transform_matrix = np.eye(2, 3, dtype=np.float32)
5547
+ rpj, _ = reproject_interp((img_lin, itm["wcs"]), mosaic_wcs,
5548
+ shape_out=(mosaic_height, mosaic_width))
5549
+ reprojected = np.nan_to_num(rpj, nan=0.0).astype(np.float32)
5550
+ reproj_red = reprojected # 2D
5468
5551
 
5469
- A = transform_matrix[:, :2]
5470
- scale1 = np.linalg.norm(A[:, 0])
5471
- scale2 = np.linalg.norm(A[:, 1])
5472
- print(f"Computed affine scales: {scale1:.6f}, {scale2:.6f}")
5552
+ # --- Optional star alignment/refinement (skipped in WCS-only) ---
5553
+ if wcs_only:
5554
+ aligned = reprojected
5555
+ if first_image:
5556
+ first_image = False
5557
+ else:
5558
+ if first_image:
5559
+ aligned = reprojected
5560
+ first_image = False
5561
+ else:
5562
+ transform_method = self.transform_combo.currentText()
5563
+ mosaic_gray = (self.final_mosaic if self.final_mosaic.ndim == 2
5564
+ else np.mean(self.final_mosaic, axis=-1))
5473
5565
 
5474
- self.status_label.setText("Affine alignment computed. Warping image...")
5475
- QApplication.processEvents()
5476
- affine_aligned = cv2.warpAffine(reprojected, transform_matrix, (mosaic_width, mosaic_height),
5477
- flags=cv2.INTER_LANCZOS4)
5478
- aligned = affine_aligned
5566
+ try:
5567
+ self.status_label.setText("Computing affine transform with astroalign...")
5568
+ QApplication.processEvents()
5569
+ transform_obj, (src_pts, dst_pts) = self._aa_find_transform_with_backoff(reproj_red, mosaic_gray)
5570
+ transform_matrix = transform_obj.params[0:2, :].astype(np.float32)
5571
+ except Exception as e:
5572
+ self.status_label.setText(f"Astroalign failed: {e}. Using identity transform.")
5573
+ transform_matrix = np.eye(2, 3, dtype=np.float32)
5479
5574
 
5480
- if transform_method in ["Homography Transform", "Polynomial Warp Based Transform"]:
5481
- self.status_label.setText(f"Starting refined alignment using {transform_method}...")
5482
- QApplication.processEvents()
5483
- refined_result = self.refined_alignment(affine_aligned, mosaic_gray, method=transform_method)
5484
- if refined_result is not None:
5485
- aligned, best_inliers2 = refined_result
5486
- self.status_label.setText(f"Refined alignment succeeded with {best_inliers2} inliers.")
5487
- else:
5488
- self.status_label.setText("Refined alignment failed; falling back to affine alignment.")
5489
- else:
5490
- aligned = reprojected
5491
- first_image = False
5575
+ affine_aligned = cv2.warpAffine(
5576
+ reprojected, transform_matrix, (mosaic_width, mosaic_height),
5577
+ flags=cv2.INTER_LANCZOS4
5578
+ )
5579
+ aligned = affine_aligned
5580
+
5581
+ if transform_method in ["Homography Transform", "Polynomial Warp Based Transform"]:
5582
+ self.status_label.setText(f"Starting refined alignment using {transform_method}...")
5583
+ QApplication.processEvents()
5584
+ refined_result = self.refined_alignment(affine_aligned, mosaic_gray, method=transform_method)
5585
+ if refined_result is not None:
5586
+ aligned, best_inliers2 = refined_result
5587
+ self.status_label.setText(f"Refined alignment succeeded with {best_inliers2} inliers.")
5588
+ else:
5589
+ self.status_label.setText("Refined alignment failed; falling back to affine alignment.")
5492
5590
 
5493
5591
  # If mosaic is color but aligned is mono, expand for accumulation only
5494
5592
  if is_color and aligned.ndim == 2:
@@ -5496,13 +5594,13 @@ class MosaicMasterDialog(QDialog):
5496
5594
 
5497
5595
  gray_aligned = aligned[..., 0] if aligned.ndim == 3 else aligned
5498
5596
 
5499
- # Compute weight mask
5597
+ # --- Weight mask ---
5500
5598
  binary_mask = (gray_aligned > 0).astype(np.uint8)
5501
5599
  smooth_mask = cv2.distanceTransform(binary_mask, cv2.DIST_L2, 5)
5502
5600
  smooth_mask = (smooth_mask / np.max(smooth_mask)) if np.max(smooth_mask) > 0 else binary_mask.astype(np.float32)
5503
5601
  smooth_mask = cv2.GaussianBlur(smooth_mask, (15, 15), 0)
5504
5602
 
5505
- # Accumulate
5603
+ # --- Accumulate ---
5506
5604
  if is_color:
5507
5605
  self.final_mosaic += aligned * smooth_mask[..., np.newaxis]
5508
5606
  else:
@@ -5512,22 +5610,27 @@ class MosaicMasterDialog(QDialog):
5512
5610
  self.status_label.setText(f"Processed: {itm['path']}")
5513
5611
  QApplication.processEvents()
5514
5612
 
5515
- # Final blending.
5516
- nonzero_mask = (self.weight_mosaic > 0)
5613
+ # ------------------------------------------------------------
5614
+ # 7) Final blend
5615
+ # ------------------------------------------------------------
5517
5616
  if is_color:
5518
- self.final_mosaic = np.where(self.weight_mosaic[..., None] > 0,
5519
- self.final_mosaic / self.weight_mosaic[..., None],
5520
- self.final_mosaic)
5617
+ self.final_mosaic = np.where(
5618
+ self.weight_mosaic[..., None] > 0,
5619
+ self.final_mosaic / np.maximum(self.weight_mosaic[..., None], 1e-12),
5620
+ self.final_mosaic
5621
+ )
5521
5622
  else:
5522
- self.final_mosaic[nonzero_mask] = self.final_mosaic[nonzero_mask] / self.weight_mosaic[nonzero_mask]
5623
+ nz = (self.weight_mosaic > 0)
5624
+ self.final_mosaic[nz] = self.final_mosaic[nz] / np.maximum(self.weight_mosaic[nz], 1e-12)
5523
5625
 
5524
- print("WCS + Star Alignment Complete.")
5525
- self.status_label.setText("WCS + Star Alignment Complete. De-Normalizing Mosaic...")
5626
+ self.status_label.setText("Mosaic built. De-normalizing mosaic...")
5627
+ QApplication.processEvents()
5526
5628
 
5527
- # Call-guard: only unstretch if we normalized AND we actually recorded stats
5528
- did_normalize = self.normalizeCheckBox.isChecked()
5629
+ # ------------------------------------------------------------
5630
+ # 8) Optional “unstretch” (your existing logic)
5631
+ # ------------------------------------------------------------
5529
5632
  if (did_normalize and
5530
- hasattr(self, "_mosaic_target_median") and self._mosaic_target_median > 0 and
5633
+ hasattr(self, "_mosaic_target_median") and float(self._mosaic_target_median) > 0 and
5531
5634
  getattr(self, "stretch_original_medians", None) and len(self.stretch_original_medians) > 0 and
5532
5635
  getattr(self, "stretch_original_mins", None) and len(self.stretch_original_mins) > 0):
5533
5636
  self.final_mosaic = self.unstretch_image(self.final_mosaic)
@@ -5535,16 +5638,37 @@ class MosaicMasterDialog(QDialog):
5535
5638
  self.status_label.setText("Final Mosaic Ready.")
5536
5639
  QApplication.processEvents()
5537
5640
 
5538
- display_image = (np.stack([self.final_mosaic]*3, axis=-1)
5641
+ display_image = (np.stack([self.final_mosaic] * 3, axis=-1)
5539
5642
  if self.final_mosaic.ndim == 2 else self.final_mosaic)
5540
5643
  display_image = self._autostretch_if_requested(display_image)
5541
- MosaicPreviewWindow(display_image, title="Final Mosaic", parent=self,
5542
- push_cb=self._push_mosaic_to_new_doc).show()
5543
5644
 
5544
- self.spinnerMovie.stop()
5645
+ MosaicPreviewWindow(
5646
+ display_image,
5647
+ title=("Final Mosaic (WCS-only)" if wcs_only else "Final Mosaic"),
5648
+ parent=self,
5649
+ push_cb=self._push_mosaic_to_new_doc
5650
+ ).show()
5651
+
5652
+ if self.spinnerMovie:
5653
+ self.spinnerMovie.stop()
5545
5654
  self.spinnerLabel.hide()
5546
5655
  QApplication.processEvents()
5656
+
5547
5657
 
5658
+ def _polish_reprojected(self, img: np.ndarray) -> np.ndarray:
5659
+ # 1) kill NaNs/Infs from reprojection
5660
+ out = np.nan_to_num(img, nan=0.0, posinf=0.0, neginf=0.0).astype(np.float32, copy=False)
5661
+
5662
+ # 2) trim tiny negative ringing
5663
+ out[out < 0] = 0.0
5664
+
5665
+ # 3) optional: zero a 1px border to avoid remap edge seams
5666
+ if out.ndim == 2:
5667
+ out[:1, :] = 0; out[-1:, :] = 0; out[:, :1] = 0; out[:, -1:] = 0
5668
+ else:
5669
+ out[:1, :, :] = 0; out[-1:, :, :] = 0; out[:, :1, :] = 0; out[:, -1:, :] = 0
5670
+
5671
+ return out
5548
5672
 
5549
5673
  def debayer_image(self, image, file_path, header):
5550
5674
  from setiastro.saspro.legacy.numba_utils import debayer_raw_fast, debayer_fits_fast
@@ -53,6 +53,10 @@ class StarSpikesDialogPro(QDialog):
53
53
  self.setWindowFlag(Qt.WindowType.Window, True)
54
54
  self.setWindowModality(Qt.WindowModality.NonModal)
55
55
  self.setModal(False)
56
+ try:
57
+ self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
58
+ except Exception:
59
+ pass # older PyQt6 versions
56
60
  self.docman = doc_manager
57
61
  self.doc = initial_doc or (self.docman.get_active_document() if self.docman else None)
58
62
  self.jwstpupil_path = jwstpupil_path