setiastrosuitepro 1.6.10__py3-none-any.whl → 1.7.0.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.
- setiastro/images/colorwheel.svg +97 -0
- setiastro/images/narrowbandnormalization.png +0 -0
- setiastro/images/planetarystacker.png +0 -0
- setiastro/saspro/__main__.py +1 -1
- setiastro/saspro/_generated/build_info.py +2 -2
- setiastro/saspro/aberration_ai.py +49 -11
- setiastro/saspro/aberration_ai_preset.py +29 -3
- setiastro/saspro/backgroundneutral.py +73 -33
- setiastro/saspro/blink_comparator_pro.py +116 -71
- setiastro/saspro/convo.py +9 -6
- setiastro/saspro/curve_editor_pro.py +72 -22
- setiastro/saspro/curves_preset.py +249 -47
- setiastro/saspro/doc_manager.py +178 -11
- setiastro/saspro/gui/main_window.py +305 -66
- setiastro/saspro/gui/mixins/dock_mixin.py +245 -24
- setiastro/saspro/gui/mixins/file_mixin.py +35 -16
- setiastro/saspro/gui/mixins/menu_mixin.py +32 -1
- setiastro/saspro/gui/mixins/toolbar_mixin.py +135 -11
- setiastro/saspro/histogram.py +179 -7
- setiastro/saspro/imageops/narrowband_normalization.py +816 -0
- setiastro/saspro/imageops/serloader.py +972 -0
- setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
- setiastro/saspro/imageops/stretch.py +66 -15
- setiastro/saspro/legacy/numba_utils.py +25 -48
- setiastro/saspro/live_stacking.py +24 -4
- setiastro/saspro/multiscale_decomp.py +30 -17
- setiastro/saspro/narrowband_normalization.py +1618 -0
- setiastro/saspro/numba_utils.py +0 -55
- setiastro/saspro/ops/script_editor.py +5 -0
- setiastro/saspro/ops/scripts.py +119 -0
- setiastro/saspro/remove_green.py +1 -1
- setiastro/saspro/resources.py +4 -0
- setiastro/saspro/ser_stack_config.py +74 -0
- setiastro/saspro/ser_stacker.py +2310 -0
- setiastro/saspro/ser_stacker_dialog.py +1500 -0
- setiastro/saspro/ser_tracking.py +206 -0
- setiastro/saspro/serviewer.py +1258 -0
- setiastro/saspro/sfcc.py +602 -214
- setiastro/saspro/shortcuts.py +35 -16
- setiastro/saspro/stacking_suite.py +332 -87
- setiastro/saspro/star_alignment.py +243 -122
- setiastro/saspro/stat_stretch.py +220 -31
- setiastro/saspro/subwindow.py +2 -4
- setiastro/saspro/whitebalance.py +24 -0
- setiastro/saspro/widgets/resource_monitor.py +122 -74
- {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/METADATA +2 -2
- {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/RECORD +51 -40
- {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/WHEEL +0 -0
- {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/entry_points.txt +0 -0
- {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/licenses/LICENSE +0 -0
- {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/licenses/license.txt +0 -0
|
@@ -4586,11 +4586,12 @@ def load_api_key():
|
|
|
4586
4586
|
class MosaicMasterDialog(QDialog):
|
|
4587
4587
|
def __init__(self, settings: QSettings, parent=None, image_manager=None,
|
|
4588
4588
|
doc_manager=None, wrench_path=None, spinner_path=None,
|
|
4589
|
-
list_open_docs_fn=None):
|
|
4589
|
+
list_open_docs_fn=None):
|
|
4590
4590
|
super().__init__(parent)
|
|
4591
4591
|
self.settings = settings
|
|
4592
4592
|
self.image_manager = image_manager
|
|
4593
4593
|
self._docman = doc_manager or getattr(parent, "doc_manager", None)
|
|
4594
|
+
|
|
4594
4595
|
# same pattern as StellarAlignmentDialog
|
|
4595
4596
|
if list_open_docs_fn is None:
|
|
4596
4597
|
cand = getattr(parent, "_list_open_docs", None)
|
|
@@ -4602,19 +4603,22 @@ class MosaicMasterDialog(QDialog):
|
|
|
4602
4603
|
self.setWindowFlag(Qt.WindowType.Window, True)
|
|
4603
4604
|
self.setWindowModality(Qt.WindowModality.NonModal)
|
|
4604
4605
|
self.setModal(False)
|
|
4606
|
+
|
|
4605
4607
|
self.wrench_path = wrench_path
|
|
4606
4608
|
self.spinner_path = spinner_path
|
|
4607
4609
|
|
|
4608
4610
|
self.resize(600, 400)
|
|
4609
|
-
self.loaded_images = []
|
|
4611
|
+
self.loaded_images = []
|
|
4610
4612
|
self.final_mosaic = None
|
|
4611
4613
|
self.weight_mosaic = None
|
|
4612
4614
|
self.wcs_metadata = None # To store mosaic WCS header
|
|
4613
4615
|
self.astap_exe = self.settings.value("paths/astap", "", type=str)
|
|
4616
|
+
|
|
4614
4617
|
# Variables to store stretching parameters:
|
|
4615
4618
|
self.stretch_original_mins = []
|
|
4616
4619
|
self.stretch_original_medians = []
|
|
4617
4620
|
self.was_single_channel = False
|
|
4621
|
+
|
|
4618
4622
|
self.initUI()
|
|
4619
4623
|
|
|
4620
4624
|
def initUI(self):
|
|
@@ -4635,12 +4639,11 @@ class MosaicMasterDialog(QDialog):
|
|
|
4635
4639
|
layout.addWidget(instructions)
|
|
4636
4640
|
|
|
4637
4641
|
btn_layout = QHBoxLayout()
|
|
4638
|
-
|
|
4642
|
+
|
|
4639
4643
|
add_btn = QPushButton("Add Image")
|
|
4640
4644
|
add_btn.clicked.connect(self.add_image)
|
|
4641
4645
|
btn_layout.addWidget(add_btn)
|
|
4642
4646
|
|
|
4643
|
-
# New button to add an image from one of the ImageManager slots
|
|
4644
4647
|
add_from_view_btn = QPushButton("Add from View")
|
|
4645
4648
|
add_from_view_btn.setToolTip("Add an image from any open View")
|
|
4646
4649
|
add_from_view_btn.clicked.connect(self.add_image_from_view)
|
|
@@ -4662,25 +4665,80 @@ class MosaicMasterDialog(QDialog):
|
|
|
4662
4665
|
save_btn.clicked.connect(self.save_mosaic_to_new_view)
|
|
4663
4666
|
btn_layout.addWidget(save_btn)
|
|
4664
4667
|
|
|
4665
|
-
# Add the wrench button for settings.
|
|
4666
4668
|
wrench_btn = QPushButton()
|
|
4667
|
-
|
|
4669
|
+
if self.wrench_path:
|
|
4670
|
+
wrench_btn.setIcon(QIcon(self.wrench_path))
|
|
4668
4671
|
wrench_btn.setToolTip("Mosaic Settings")
|
|
4669
4672
|
wrench_btn.clicked.connect(self.openSettings)
|
|
4670
4673
|
btn_layout.addWidget(wrench_btn)
|
|
4671
4674
|
|
|
4672
4675
|
layout.addLayout(btn_layout)
|
|
4673
4676
|
|
|
4674
|
-
#
|
|
4677
|
+
# ------------------------------------------------------------------
|
|
4678
|
+
# Mode checkboxes (mutually exclusive: Seestar vs WCS-only)
|
|
4679
|
+
# ------------------------------------------------------------------
|
|
4675
4680
|
checkbox_layout = QHBoxLayout()
|
|
4681
|
+
|
|
4676
4682
|
self.forceBlindCheckBox = QCheckBox("Force Blind Solve (ignore existing WCS)")
|
|
4677
4683
|
checkbox_layout.addWidget(self.forceBlindCheckBox)
|
|
4678
|
-
|
|
4684
|
+
|
|
4679
4685
|
self.seestarCheckBox = QCheckBox("Seestar Mode")
|
|
4680
|
-
self.seestarCheckBox.setToolTip(
|
|
4686
|
+
self.seestarCheckBox.setToolTip(
|
|
4687
|
+
"When enabled, images are aligned iteratively using astroalign without plate solving."
|
|
4688
|
+
)
|
|
4681
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
|
+
|
|
4682
4699
|
layout.addLayout(checkbox_layout)
|
|
4683
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
|
+
# ------------------------------------------------------------------
|
|
4684
4742
|
self.normalizeCheckBox = QCheckBox("Normalize images (median match)")
|
|
4685
4743
|
self.normalizeCheckBox.setChecked(True)
|
|
4686
4744
|
layout.addWidget(self.normalizeCheckBox)
|
|
@@ -4704,24 +4762,21 @@ class MosaicMasterDialog(QDialog):
|
|
|
4704
4762
|
"Precise — Full WCS: astropy.reproject per channel; slowest, most exact."
|
|
4705
4763
|
)
|
|
4706
4764
|
|
|
4707
|
-
|
|
4708
|
-
|
|
4709
|
-
_default_mode
|
|
4710
|
-
"Fast — SIP-aware (Exact Remap)")
|
|
4711
|
-
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:
|
|
4712
4768
|
_default_mode = "Fast — SIP-aware (Exact Remap)"
|
|
4713
4769
|
self.reprojectModeCombo.setCurrentText(_default_mode)
|
|
4714
4770
|
self.reprojectModeCombo.currentTextChanged.connect(
|
|
4715
4771
|
lambda t: QSettings("SetiAstro", "SASpro").setValue("mosaic/reproject_mode", t)
|
|
4716
4772
|
)
|
|
4717
4773
|
|
|
4718
|
-
# Add to layout where the old checkbox lived
|
|
4719
4774
|
row = QHBoxLayout()
|
|
4720
4775
|
row.addWidget(self.reprojectModeLabel)
|
|
4721
4776
|
row.addWidget(self.reprojectModeCombo, 1)
|
|
4722
4777
|
layout.addLayout(row)
|
|
4723
4778
|
|
|
4724
|
-
|
|
4779
|
+
# Transform selection
|
|
4725
4780
|
self.transform_combo = QComboBox()
|
|
4726
4781
|
self.transform_combo.addItems([
|
|
4727
4782
|
"Partial Affine Transform",
|
|
@@ -4729,11 +4784,14 @@ class MosaicMasterDialog(QDialog):
|
|
|
4729
4784
|
"Homography Transform",
|
|
4730
4785
|
"Polynomial Warp Based Transform"
|
|
4731
4786
|
])
|
|
4732
|
-
# Set the default selection to "Affine Transform" (index 1)
|
|
4733
4787
|
self.transform_combo.setCurrentIndex(1)
|
|
4734
4788
|
layout.addWidget(QLabel("Select Transformation Method:"))
|
|
4735
4789
|
layout.addWidget(self.transform_combo)
|
|
4736
4790
|
|
|
4791
|
+
# Now that transform_combo exists, apply WCS-only UI state
|
|
4792
|
+
_sync_wcs_only_ui()
|
|
4793
|
+
|
|
4794
|
+
# List + status + spinner
|
|
4737
4795
|
self.images_list = QListWidget()
|
|
4738
4796
|
self.images_list.setSelectionMode(self.images_list.SelectionMode.SingleSelection)
|
|
4739
4797
|
layout.addWidget(self.images_list)
|
|
@@ -4743,13 +4801,15 @@ class MosaicMasterDialog(QDialog):
|
|
|
4743
4801
|
|
|
4744
4802
|
self.spinnerLabel = QLabel(self)
|
|
4745
4803
|
self.spinnerLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
4746
|
-
self.spinnerMovie = QMovie(self.spinner_path)
|
|
4747
|
-
self.
|
|
4748
|
-
|
|
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()
|
|
4749
4808
|
layout.addWidget(self.spinnerLabel)
|
|
4750
4809
|
|
|
4751
4810
|
self.setLayout(layout)
|
|
4752
4811
|
|
|
4812
|
+
|
|
4753
4813
|
def _target_median_from_first(self, items):
|
|
4754
4814
|
# Pick a stable target (median of first image after safe clipping)
|
|
4755
4815
|
if not items:
|
|
@@ -5296,6 +5356,7 @@ class MosaicMasterDialog(QDialog):
|
|
|
5296
5356
|
|
|
5297
5357
|
# ---------- Align (Entry Point) ----------
|
|
5298
5358
|
def align_images(self):
|
|
5359
|
+
# Seestar mode is its own pipeline
|
|
5299
5360
|
if self.seestarCheckBox.isChecked():
|
|
5300
5361
|
self.align_images_seestar_mode()
|
|
5301
5362
|
return
|
|
@@ -5306,28 +5367,33 @@ class MosaicMasterDialog(QDialog):
|
|
|
5306
5367
|
|
|
5307
5368
|
# Show spinner and start animation.
|
|
5308
5369
|
self.spinnerLabel.show()
|
|
5309
|
-
self.spinnerMovie
|
|
5370
|
+
if self.spinnerMovie:
|
|
5371
|
+
self.spinnerMovie.start()
|
|
5310
5372
|
QApplication.processEvents()
|
|
5311
5373
|
|
|
5312
|
-
#
|
|
5374
|
+
# ------------------------------------------------------------
|
|
5375
|
+
# 1) Plate solve (unless already solved and not forcing blind)
|
|
5376
|
+
# ------------------------------------------------------------
|
|
5313
5377
|
force_blind = self.forceBlindCheckBox.isChecked()
|
|
5314
|
-
images_to_process = (
|
|
5315
|
-
|
|
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
|
+
)
|
|
5316
5382
|
|
|
5317
|
-
# Process each image for plate solving.
|
|
5318
5383
|
for item in images_to_process:
|
|
5319
|
-
#
|
|
5384
|
+
# Ensure ASTAP path (or fall back to blind solve)
|
|
5320
5385
|
if not self.astap_exe or not os.path.exists(self.astap_exe):
|
|
5321
5386
|
executable_filter = "Executables (*.exe);;All Files (*)" if sys.platform.startswith("win") else "Executables (*)"
|
|
5322
5387
|
new_path, _ = QFileDialog.getOpenFileName(self, "Select ASTAP Executable", "", executable_filter)
|
|
5323
5388
|
if new_path:
|
|
5324
5389
|
self._save_astap_exe_to_settings(new_path)
|
|
5390
|
+
self.astap_exe = new_path
|
|
5325
5391
|
QMessageBox.information(self, "Mosaic Master", "ASTAP path updated successfully.")
|
|
5326
5392
|
else:
|
|
5327
|
-
|
|
5393
|
+
self.status_label.setText(f"No ASTAP path; blind solving {item['path']}...")
|
|
5394
|
+
QApplication.processEvents()
|
|
5328
5395
|
solved_header = self.perform_blind_solve(item)
|
|
5329
5396
|
if solved_header:
|
|
5330
|
-
# normalize + build WCS with relax=True so SIP is retained
|
|
5331
5397
|
solved_header = self._normalize_wcs_header(solved_header, item["image"].shape)
|
|
5332
5398
|
item["wcs"] = self._build_wcs(solved_header, item["image"].shape)
|
|
5333
5399
|
continue
|
|
@@ -5341,49 +5407,53 @@ class MosaicMasterDialog(QDialog):
|
|
|
5341
5407
|
self.status_label.setText(f"ASTAP failed for {item['path']}. Falling back to blind solve...")
|
|
5342
5408
|
QApplication.processEvents()
|
|
5343
5409
|
solved_header = self.perform_blind_solve(item)
|
|
5344
|
-
else:
|
|
5345
|
-
self.status_label.setText(f"Plate solve successful using ASTAP for {item['path']}.")
|
|
5346
5410
|
|
|
5347
5411
|
if solved_header:
|
|
5348
|
-
# Single, centralized sanitize → keeps SIP and fixes types
|
|
5349
5412
|
solved_header = self._normalize_wcs_header(solved_header, item["image"].shape)
|
|
5350
5413
|
item["wcs"] = self._build_wcs(solved_header, item["image"].shape)
|
|
5351
5414
|
else:
|
|
5352
|
-
print(f"Plate solving failed for {item['path']}.")
|
|
5415
|
+
print(f"[Mosaic] Plate solving failed for {item['path']}.")
|
|
5353
5416
|
|
|
5354
|
-
#
|
|
5417
|
+
# ------------------------------------------------------------
|
|
5418
|
+
# 2) Gather WCS-valid panels
|
|
5419
|
+
# ------------------------------------------------------------
|
|
5355
5420
|
wcs_items = [x for x in self.loaded_images if x.get("wcs") is not None]
|
|
5356
5421
|
if not wcs_items:
|
|
5357
|
-
print("No images have WCS
|
|
5358
|
-
self.spinnerMovie
|
|
5422
|
+
print("[Mosaic] No images have WCS; cannot build WCS mosaic.")
|
|
5423
|
+
if self.spinnerMovie:
|
|
5424
|
+
self.spinnerMovie.stop()
|
|
5359
5425
|
self.spinnerLabel.hide()
|
|
5360
5426
|
return
|
|
5361
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()
|
|
5362
5435
|
|
|
5363
|
-
# Use the first image's WCS as reference and compute the mosaic bounding box.
|
|
5364
|
-
# (Rebuild with relax=True just in case, then deepcopy.)
|
|
5365
|
-
reference_wcs = self._build_wcs(wcs_items[0]["wcs"].to_header(relax=True), wcs_items[0]["image"].shape).deepcopy()
|
|
5366
5436
|
min_x, min_y, max_x, max_y = self.compute_mosaic_bounding_box(wcs_items, reference_wcs)
|
|
5367
|
-
mosaic_width
|
|
5437
|
+
mosaic_width = int(max_x - min_x)
|
|
5368
5438
|
mosaic_height = int(max_y - min_y)
|
|
5369
5439
|
|
|
5370
5440
|
if mosaic_width < 1 or mosaic_height < 1:
|
|
5371
|
-
print("ERROR: Computed mosaic size
|
|
5372
|
-
self.spinnerMovie
|
|
5441
|
+
print("[Mosaic] ERROR: Computed mosaic size invalid. Check WCS/inputs.")
|
|
5442
|
+
if self.spinnerMovie:
|
|
5443
|
+
self.spinnerMovie.stop()
|
|
5373
5444
|
self.spinnerLabel.hide()
|
|
5374
5445
|
return
|
|
5375
5446
|
|
|
5376
|
-
# Adjust the reference WCS so that (min_x, min_y) becomes (0,0).
|
|
5377
5447
|
mosaic_wcs = reference_wcs.deepcopy()
|
|
5378
5448
|
mosaic_wcs.wcs.crpix[0] -= min_x
|
|
5379
5449
|
mosaic_wcs.wcs.crpix[1] -= min_y
|
|
5380
|
-
# keep SIP in the stored header
|
|
5381
5450
|
self.wcs_metadata = mosaic_wcs.to_header(relax=True)
|
|
5382
5451
|
|
|
5383
|
-
#
|
|
5452
|
+
# ------------------------------------------------------------
|
|
5453
|
+
# 4) Allocate accumulators
|
|
5454
|
+
# ------------------------------------------------------------
|
|
5384
5455
|
is_color = any(not item["is_mono"] for item in wcs_items)
|
|
5385
5456
|
|
|
5386
|
-
# stats for optional "unstretch"
|
|
5387
5457
|
self.stretch_original_mins = []
|
|
5388
5458
|
self.stretch_original_medians = []
|
|
5389
5459
|
self.was_single_channel = (not is_color)
|
|
@@ -5394,49 +5464,77 @@ class MosaicMasterDialog(QDialog):
|
|
|
5394
5464
|
self.final_mosaic, _ = smart_zeros((mosaic_height, mosaic_width), dtype=np.float32)
|
|
5395
5465
|
self.weight_mosaic, _ = smart_zeros((mosaic_height, mosaic_width), dtype=np.float32)
|
|
5396
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
|
+
# ------------------------------------------------------------
|
|
5397
5500
|
first_image = True
|
|
5501
|
+
|
|
5398
5502
|
for idx, itm in enumerate(wcs_items):
|
|
5399
5503
|
arr = itm["image"]
|
|
5504
|
+
|
|
5400
5505
|
self.status_label.setText(f"Mapping {itm['path']} into mosaic frame...")
|
|
5401
5506
|
QApplication.processEvents()
|
|
5402
5507
|
|
|
5403
5508
|
img_lin = arr.astype(np.float32, copy=False)
|
|
5404
5509
|
|
|
5405
|
-
# --- record original stats for optional
|
|
5510
|
+
# --- record original stats (pre-normalization) for optional unstretch ---
|
|
5406
5511
|
mono_for_stats = img_lin if img_lin.ndim == 2 else np.mean(img_lin, axis=2)
|
|
5407
5512
|
self.stretch_original_mins.append(float(np.min(mono_for_stats)))
|
|
5408
5513
|
self.stretch_original_medians.append(float(np.median(mono_for_stats)))
|
|
5409
5514
|
|
|
5410
|
-
#
|
|
5411
|
-
if
|
|
5412
|
-
|
|
5413
|
-
if target_med is None:
|
|
5414
|
-
self._mosaic_target_median = self._target_median_from_first(wcs_items)
|
|
5415
|
-
target_med = self._mosaic_target_median
|
|
5416
|
-
img_lin = self._normalize_linear(img_lin, target_med)
|
|
5417
|
-
|
|
5418
|
-
# 2) Reprojection (3 modes)
|
|
5419
|
-
if not hasattr(self, "_H_cache"):
|
|
5420
|
-
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))
|
|
5421
5518
|
|
|
5519
|
+
# --- Reprojection mode ---
|
|
5422
5520
|
mode = self.reprojectModeCombo.currentText()
|
|
5423
5521
|
|
|
5424
5522
|
if mode.startswith("Fast — SIP"):
|
|
5425
|
-
# Exact dense remap (SIP-aware), tiled; keep mono as 2D
|
|
5426
5523
|
reprojected = self._warp_via_wcs_remap_exact(
|
|
5427
5524
|
img_lin, itm["wcs"], mosaic_wcs, (mosaic_height, mosaic_width), tile=512
|
|
5428
|
-
).astype(np.float32)
|
|
5525
|
+
).astype(np.float32, copy=False)
|
|
5526
|
+
reprojected = self._polish_reprojected(reprojected)
|
|
5429
5527
|
reproj_red = reprojected[..., 0] if reprojected.ndim == 3 else reprojected
|
|
5430
5528
|
|
|
5431
5529
|
elif mode.startswith("Fast — Homography"):
|
|
5432
|
-
# Single global homography; keep mono as 2D
|
|
5433
5530
|
reprojected = self._warp_via_wcs_homography(
|
|
5434
5531
|
img_lin, itm["wcs"], mosaic_wcs, (mosaic_height, mosaic_width), H_cache=self._H_cache
|
|
5435
|
-
).astype(np.float32)
|
|
5532
|
+
).astype(np.float32, copy=False)
|
|
5533
|
+
reprojected = self._polish_reprojected(reprojected)
|
|
5436
5534
|
reproj_red = reprojected[..., 0] if reprojected.ndim == 3 else reprojected
|
|
5437
5535
|
|
|
5438
5536
|
else:
|
|
5439
|
-
# Precise — Full WCS
|
|
5537
|
+
# Precise — Full WCS
|
|
5440
5538
|
if img_lin.ndim == 3:
|
|
5441
5539
|
channels = []
|
|
5442
5540
|
for c in range(3):
|
|
@@ -5446,52 +5544,49 @@ class MosaicMasterDialog(QDialog):
|
|
|
5446
5544
|
reprojected = np.stack(channels, axis=-1)
|
|
5447
5545
|
reproj_red = reprojected[..., 0]
|
|
5448
5546
|
else:
|
|
5449
|
-
|
|
5450
|
-
|
|
5451
|
-
reprojected = np.nan_to_num(
|
|
5452
|
-
|
|
5453
|
-
|
|
5454
|
-
self.status_label.setText(f"WCS map: {itm['path']} processed.")
|
|
5455
|
-
QApplication.processEvents()
|
|
5456
|
-
|
|
5457
|
-
# --- Stellar Alignment ---
|
|
5458
|
-
if not first_image:
|
|
5459
|
-
transform_method = self.transform_combo.currentText()
|
|
5460
|
-
mosaic_gray = (self.final_mosaic if self.final_mosaic.ndim == 2
|
|
5461
|
-
else np.mean(self.final_mosaic, axis=-1))
|
|
5462
|
-
try:
|
|
5463
|
-
self.status_label.setText("Computing affine transform with astroalign...")
|
|
5464
|
-
QApplication.processEvents()
|
|
5465
|
-
transform_obj, (src_pts, dst_pts) = self._aa_find_transform_with_backoff(reproj_red, mosaic_gray)
|
|
5466
|
-
transform_matrix = transform_obj.params[0:2, :].astype(np.float32)
|
|
5467
|
-
self.status_label.setText("Astroalign computed transform successfully.")
|
|
5468
|
-
except Exception as e:
|
|
5469
|
-
self.status_label.setText(f"Astroalign failed: {e}. Using identity transform.")
|
|
5470
|
-
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
|
|
5471
5551
|
|
|
5472
|
-
|
|
5473
|
-
|
|
5474
|
-
|
|
5475
|
-
|
|
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))
|
|
5476
5565
|
|
|
5477
|
-
|
|
5478
|
-
|
|
5479
|
-
|
|
5480
|
-
|
|
5481
|
-
|
|
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)
|
|
5482
5574
|
|
|
5483
|
-
|
|
5484
|
-
|
|
5485
|
-
|
|
5486
|
-
|
|
5487
|
-
|
|
5488
|
-
|
|
5489
|
-
|
|
5490
|
-
|
|
5491
|
-
|
|
5492
|
-
|
|
5493
|
-
|
|
5494
|
-
|
|
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.")
|
|
5495
5590
|
|
|
5496
5591
|
# If mosaic is color but aligned is mono, expand for accumulation only
|
|
5497
5592
|
if is_color and aligned.ndim == 2:
|
|
@@ -5499,13 +5594,13 @@ class MosaicMasterDialog(QDialog):
|
|
|
5499
5594
|
|
|
5500
5595
|
gray_aligned = aligned[..., 0] if aligned.ndim == 3 else aligned
|
|
5501
5596
|
|
|
5502
|
-
#
|
|
5597
|
+
# --- Weight mask ---
|
|
5503
5598
|
binary_mask = (gray_aligned > 0).astype(np.uint8)
|
|
5504
5599
|
smooth_mask = cv2.distanceTransform(binary_mask, cv2.DIST_L2, 5)
|
|
5505
5600
|
smooth_mask = (smooth_mask / np.max(smooth_mask)) if np.max(smooth_mask) > 0 else binary_mask.astype(np.float32)
|
|
5506
5601
|
smooth_mask = cv2.GaussianBlur(smooth_mask, (15, 15), 0)
|
|
5507
5602
|
|
|
5508
|
-
# Accumulate
|
|
5603
|
+
# --- Accumulate ---
|
|
5509
5604
|
if is_color:
|
|
5510
5605
|
self.final_mosaic += aligned * smooth_mask[..., np.newaxis]
|
|
5511
5606
|
else:
|
|
@@ -5515,22 +5610,27 @@ class MosaicMasterDialog(QDialog):
|
|
|
5515
5610
|
self.status_label.setText(f"Processed: {itm['path']}")
|
|
5516
5611
|
QApplication.processEvents()
|
|
5517
5612
|
|
|
5518
|
-
#
|
|
5519
|
-
|
|
5613
|
+
# ------------------------------------------------------------
|
|
5614
|
+
# 7) Final blend
|
|
5615
|
+
# ------------------------------------------------------------
|
|
5520
5616
|
if is_color:
|
|
5521
|
-
self.final_mosaic = np.where(
|
|
5522
|
-
|
|
5523
|
-
|
|
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
|
+
)
|
|
5524
5622
|
else:
|
|
5525
|
-
|
|
5623
|
+
nz = (self.weight_mosaic > 0)
|
|
5624
|
+
self.final_mosaic[nz] = self.final_mosaic[nz] / np.maximum(self.weight_mosaic[nz], 1e-12)
|
|
5526
5625
|
|
|
5527
|
-
|
|
5528
|
-
|
|
5626
|
+
self.status_label.setText("Mosaic built. De-normalizing mosaic...")
|
|
5627
|
+
QApplication.processEvents()
|
|
5529
5628
|
|
|
5530
|
-
#
|
|
5531
|
-
|
|
5629
|
+
# ------------------------------------------------------------
|
|
5630
|
+
# 8) Optional “unstretch” (your existing logic)
|
|
5631
|
+
# ------------------------------------------------------------
|
|
5532
5632
|
if (did_normalize and
|
|
5533
|
-
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
|
|
5534
5634
|
getattr(self, "stretch_original_medians", None) and len(self.stretch_original_medians) > 0 and
|
|
5535
5635
|
getattr(self, "stretch_original_mins", None) and len(self.stretch_original_mins) > 0):
|
|
5536
5636
|
self.final_mosaic = self.unstretch_image(self.final_mosaic)
|
|
@@ -5538,16 +5638,37 @@ class MosaicMasterDialog(QDialog):
|
|
|
5538
5638
|
self.status_label.setText("Final Mosaic Ready.")
|
|
5539
5639
|
QApplication.processEvents()
|
|
5540
5640
|
|
|
5541
|
-
display_image = (np.stack([self.final_mosaic]*3, axis=-1)
|
|
5641
|
+
display_image = (np.stack([self.final_mosaic] * 3, axis=-1)
|
|
5542
5642
|
if self.final_mosaic.ndim == 2 else self.final_mosaic)
|
|
5543
5643
|
display_image = self._autostretch_if_requested(display_image)
|
|
5544
|
-
MosaicPreviewWindow(display_image, title="Final Mosaic", parent=self,
|
|
5545
|
-
push_cb=self._push_mosaic_to_new_doc).show()
|
|
5546
5644
|
|
|
5547
|
-
|
|
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()
|
|
5548
5654
|
self.spinnerLabel.hide()
|
|
5549
5655
|
QApplication.processEvents()
|
|
5656
|
+
|
|
5550
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
|
|
5551
5672
|
|
|
5552
5673
|
def debayer_image(self, image, file_path, header):
|
|
5553
5674
|
from setiastro.saspro.legacy.numba_utils import debayer_raw_fast, debayer_fits_fast
|