setiastrosuitepro 1.6.10__py3-none-any.whl → 1.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. setiastro/images/colorwheel.svg +97 -0
  2. setiastro/images/narrowbandnormalization.png +0 -0
  3. setiastro/images/planetarystacker.png +0 -0
  4. setiastro/saspro/__main__.py +1 -1
  5. setiastro/saspro/_generated/build_info.py +2 -2
  6. setiastro/saspro/aberration_ai.py +49 -11
  7. setiastro/saspro/aberration_ai_preset.py +29 -3
  8. setiastro/saspro/backgroundneutral.py +73 -33
  9. setiastro/saspro/blink_comparator_pro.py +116 -71
  10. setiastro/saspro/convo.py +9 -6
  11. setiastro/saspro/curve_editor_pro.py +72 -22
  12. setiastro/saspro/curves_preset.py +249 -47
  13. setiastro/saspro/doc_manager.py +178 -11
  14. setiastro/saspro/gui/main_window.py +218 -66
  15. setiastro/saspro/gui/mixins/dock_mixin.py +245 -24
  16. setiastro/saspro/gui/mixins/file_mixin.py +35 -16
  17. setiastro/saspro/gui/mixins/menu_mixin.py +31 -1
  18. setiastro/saspro/gui/mixins/toolbar_mixin.py +132 -10
  19. setiastro/saspro/histogram.py +179 -7
  20. setiastro/saspro/imageops/narrowband_normalization.py +816 -0
  21. setiastro/saspro/imageops/serloader.py +769 -0
  22. setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
  23. setiastro/saspro/imageops/stretch.py +66 -15
  24. setiastro/saspro/legacy/numba_utils.py +25 -48
  25. setiastro/saspro/live_stacking.py +24 -4
  26. setiastro/saspro/multiscale_decomp.py +30 -17
  27. setiastro/saspro/narrowband_normalization.py +1618 -0
  28. setiastro/saspro/numba_utils.py +0 -55
  29. setiastro/saspro/ops/script_editor.py +5 -0
  30. setiastro/saspro/ops/scripts.py +119 -0
  31. setiastro/saspro/remove_green.py +1 -1
  32. setiastro/saspro/resources.py +4 -0
  33. setiastro/saspro/ser_stack_config.py +68 -0
  34. setiastro/saspro/ser_stacker.py +2245 -0
  35. setiastro/saspro/ser_stacker_dialog.py +1481 -0
  36. setiastro/saspro/ser_tracking.py +206 -0
  37. setiastro/saspro/serviewer.py +1242 -0
  38. setiastro/saspro/sfcc.py +602 -214
  39. setiastro/saspro/shortcuts.py +35 -16
  40. setiastro/saspro/stacking_suite.py +332 -87
  41. setiastro/saspro/star_alignment.py +243 -122
  42. setiastro/saspro/stat_stretch.py +220 -31
  43. setiastro/saspro/subwindow.py +2 -4
  44. setiastro/saspro/whitebalance.py +24 -0
  45. setiastro/saspro/widgets/resource_monitor.py +122 -74
  46. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.dist-info}/METADATA +2 -2
  47. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.dist-info}/RECORD +51 -40
  48. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.dist-info}/WHEEL +0 -0
  49. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.dist-info}/entry_points.txt +0 -0
  50. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.dist-info}/licenses/LICENSE +0 -0
  51. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.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): # ← add param
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
- # Button to add image from disk
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
- wrench_btn.setIcon(QIcon(self.wrench_path))
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
- # Horizontal sizer for checkboxes.
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
- # New Seestar Mode checkbox:
4684
+
4679
4685
  self.seestarCheckBox = QCheckBox("Seestar Mode")
4680
- 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
+ )
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
- # Persist user choice
4708
- _settings = QSettings("SetiAstro", "SASpro")
4709
- _default_mode = _settings.value("mosaic/reproject_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.spinnerLabel.setMovie(self.spinnerMovie)
4748
- 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()
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.start()
5370
+ if self.spinnerMovie:
5371
+ self.spinnerMovie.start()
5310
5372
  QApplication.processEvents()
5311
5373
 
5312
- # Step 1: Force blind solve if requested.
5374
+ # ------------------------------------------------------------
5375
+ # 1) Plate solve (unless already solved and not forcing blind)
5376
+ # ------------------------------------------------------------
5313
5377
  force_blind = self.forceBlindCheckBox.isChecked()
5314
- images_to_process = (self.loaded_images if force_blind
5315
- 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
+ )
5316
5382
 
5317
- # Process each image for plate solving.
5318
5383
  for item in images_to_process:
5319
- # Check if ASTAP is set.
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
- 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()
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
- # After processing, get all images with valid WCS.
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, skipping WCS alignment.")
5358
- self.spinnerMovie.stop()
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 = int(max_x - min_x)
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 is invalid. Check WCS or inputs.")
5372
- self.spinnerMovie.stop()
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
- # Set up accumulators.
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 "unstretch" ---
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
- # 1) optional median normalization only
5411
- if self.normalizeCheckBox.isChecked():
5412
- target_med = getattr(self, "_mosaic_target_median", None)
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 (astropy.reproject); mono stays 2D
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
- reproj_red, _ = reproject_interp((img_lin, itm["wcs"]), mosaic_wcs,
5450
- shape_out=(mosaic_height, mosaic_width))
5451
- reprojected = np.nan_to_num(reproj_red, nan=0.0).astype(np.float32) # 2D mono
5452
- # no fake stacking here
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
- A = transform_matrix[:, :2]
5473
- scale1 = np.linalg.norm(A[:, 0])
5474
- scale2 = np.linalg.norm(A[:, 1])
5475
- 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))
5476
5565
 
5477
- self.status_label.setText("Affine alignment computed. Warping image...")
5478
- QApplication.processEvents()
5479
- affine_aligned = cv2.warpAffine(reprojected, transform_matrix, (mosaic_width, mosaic_height),
5480
- flags=cv2.INTER_LANCZOS4)
5481
- 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)
5482
5574
 
5483
- if transform_method in ["Homography Transform", "Polynomial Warp Based Transform"]:
5484
- self.status_label.setText(f"Starting refined alignment using {transform_method}...")
5485
- QApplication.processEvents()
5486
- refined_result = self.refined_alignment(affine_aligned, mosaic_gray, method=transform_method)
5487
- if refined_result is not None:
5488
- aligned, best_inliers2 = refined_result
5489
- self.status_label.setText(f"Refined alignment succeeded with {best_inliers2} inliers.")
5490
- else:
5491
- self.status_label.setText("Refined alignment failed; falling back to affine alignment.")
5492
- else:
5493
- aligned = reprojected
5494
- 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.")
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
- # Compute weight mask
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
- # Final blending.
5519
- nonzero_mask = (self.weight_mosaic > 0)
5613
+ # ------------------------------------------------------------
5614
+ # 7) Final blend
5615
+ # ------------------------------------------------------------
5520
5616
  if is_color:
5521
- self.final_mosaic = np.where(self.weight_mosaic[..., None] > 0,
5522
- self.final_mosaic / self.weight_mosaic[..., None],
5523
- 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
+ )
5524
5622
  else:
5525
- 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)
5526
5625
 
5527
- print("WCS + Star Alignment Complete.")
5528
- self.status_label.setText("WCS + Star Alignment Complete. De-Normalizing Mosaic...")
5626
+ self.status_label.setText("Mosaic built. De-normalizing mosaic...")
5627
+ QApplication.processEvents()
5529
5628
 
5530
- # Call-guard: only unstretch if we normalized AND we actually recorded stats
5531
- did_normalize = self.normalizeCheckBox.isChecked()
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
- 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()
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