setiastrosuitepro 1.6.2__py3-none-any.whl → 1.6.12__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 (162) hide show
  1. setiastro/images/abeicon.svg +16 -0
  2. setiastro/images/acv_icon.png +0 -0
  3. setiastro/images/colorwheel.svg +97 -0
  4. setiastro/images/cosmic.svg +40 -0
  5. setiastro/images/cosmicsat.svg +24 -0
  6. setiastro/images/first_quarter.png +0 -0
  7. setiastro/images/full_moon.png +0 -0
  8. setiastro/images/graxpert.svg +19 -0
  9. setiastro/images/last_quarter.png +0 -0
  10. setiastro/images/linearfit.svg +32 -0
  11. setiastro/images/new_moon.png +0 -0
  12. setiastro/images/pixelmath.svg +42 -0
  13. setiastro/images/rotatearbitrary.png +0 -0
  14. setiastro/images/waning_crescent_1.png +0 -0
  15. setiastro/images/waning_crescent_2.png +0 -0
  16. setiastro/images/waning_crescent_3.png +0 -0
  17. setiastro/images/waning_crescent_4.png +0 -0
  18. setiastro/images/waning_crescent_5.png +0 -0
  19. setiastro/images/waning_gibbous_1.png +0 -0
  20. setiastro/images/waning_gibbous_2.png +0 -0
  21. setiastro/images/waning_gibbous_3.png +0 -0
  22. setiastro/images/waning_gibbous_4.png +0 -0
  23. setiastro/images/waning_gibbous_5.png +0 -0
  24. setiastro/images/waxing_crescent_1.png +0 -0
  25. setiastro/images/waxing_crescent_2.png +0 -0
  26. setiastro/images/waxing_crescent_3.png +0 -0
  27. setiastro/images/waxing_crescent_4.png +0 -0
  28. setiastro/images/waxing_crescent_5.png +0 -0
  29. setiastro/images/waxing_gibbous_1.png +0 -0
  30. setiastro/images/waxing_gibbous_2.png +0 -0
  31. setiastro/images/waxing_gibbous_3.png +0 -0
  32. setiastro/images/waxing_gibbous_4.png +0 -0
  33. setiastro/images/waxing_gibbous_5.png +0 -0
  34. setiastro/qml/ResourceMonitor.qml +84 -82
  35. setiastro/saspro/__main__.py +20 -1
  36. setiastro/saspro/_generated/build_info.py +2 -2
  37. setiastro/saspro/abe.py +37 -4
  38. setiastro/saspro/aberration_ai.py +237 -21
  39. setiastro/saspro/acv_exporter.py +379 -0
  40. setiastro/saspro/add_stars.py +33 -6
  41. setiastro/saspro/backgroundneutral.py +114 -37
  42. setiastro/saspro/blemish_blaster.py +4 -1
  43. setiastro/saspro/blink_comparator_pro.py +548 -275
  44. setiastro/saspro/clahe.py +4 -1
  45. setiastro/saspro/continuum_subtract.py +4 -1
  46. setiastro/saspro/convo.py +13 -7
  47. setiastro/saspro/cosmicclarity.py +129 -18
  48. setiastro/saspro/crop_dialog_pro.py +134 -8
  49. setiastro/saspro/curve_editor_pro.py +109 -42
  50. setiastro/saspro/doc_manager.py +246 -16
  51. setiastro/saspro/exoplanet_detector.py +120 -28
  52. setiastro/saspro/frequency_separation.py +1158 -204
  53. setiastro/saspro/function_bundle.py +16 -16
  54. setiastro/saspro/ghs_dialog_pro.py +81 -16
  55. setiastro/saspro/graxpert.py +1 -0
  56. setiastro/saspro/gui/main_window.py +519 -289
  57. setiastro/saspro/gui/mixins/dock_mixin.py +276 -42
  58. setiastro/saspro/gui/mixins/geometry_mixin.py +105 -5
  59. setiastro/saspro/gui/mixins/menu_mixin.py +28 -1
  60. setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
  61. setiastro/saspro/gui/mixins/toolbar_mixin.py +416 -27
  62. setiastro/saspro/gui/mixins/update_mixin.py +138 -36
  63. setiastro/saspro/gui/mixins/view_mixin.py +42 -0
  64. setiastro/saspro/halobgon.py +4 -0
  65. setiastro/saspro/histogram.py +5 -1
  66. setiastro/saspro/image_combine.py +4 -0
  67. setiastro/saspro/image_peeker_pro.py +4 -0
  68. setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
  69. setiastro/saspro/imageops/stretch.py +582 -62
  70. setiastro/saspro/isophote.py +4 -0
  71. setiastro/saspro/layers.py +13 -9
  72. setiastro/saspro/layers_dock.py +183 -3
  73. setiastro/saspro/legacy/image_manager.py +154 -20
  74. setiastro/saspro/legacy/numba_utils.py +67 -47
  75. setiastro/saspro/legacy/xisf.py +240 -98
  76. setiastro/saspro/live_stacking.py +180 -79
  77. setiastro/saspro/luminancerecombine.py +228 -27
  78. setiastro/saspro/mask_creation.py +174 -15
  79. setiastro/saspro/mfdeconv.py +113 -35
  80. setiastro/saspro/mfdeconvcudnn.py +119 -70
  81. setiastro/saspro/mfdeconvsport.py +112 -35
  82. setiastro/saspro/morphology.py +4 -0
  83. setiastro/saspro/multiscale_decomp.py +748 -255
  84. setiastro/saspro/numba_utils.py +72 -57
  85. setiastro/saspro/ops/commands.py +18 -18
  86. setiastro/saspro/ops/script_editor.py +10 -2
  87. setiastro/saspro/ops/scripts.py +122 -0
  88. setiastro/saspro/perfect_palette_picker.py +37 -3
  89. setiastro/saspro/plate_solver.py +84 -49
  90. setiastro/saspro/psf_viewer.py +119 -37
  91. setiastro/saspro/remove_stars_preset.py +55 -13
  92. setiastro/saspro/resources.py +97 -11
  93. setiastro/saspro/rgbalign.py +4 -0
  94. setiastro/saspro/selective_color.py +83 -21
  95. setiastro/saspro/sfcc.py +364 -152
  96. setiastro/saspro/shortcuts.py +253 -49
  97. setiastro/saspro/signature_insert.py +692 -33
  98. setiastro/saspro/stacking_suite.py +1610 -574
  99. setiastro/saspro/star_alignment.py +522 -453
  100. setiastro/saspro/star_spikes.py +4 -0
  101. setiastro/saspro/star_stretch.py +38 -3
  102. setiastro/saspro/stat_stretch.py +743 -128
  103. setiastro/saspro/status_log_dock.py +1 -1
  104. setiastro/saspro/subwindow.py +786 -360
  105. setiastro/saspro/supernovaasteroidhunter.py +1 -1
  106. setiastro/saspro/swap_manager.py +77 -42
  107. setiastro/saspro/translations/all_source_strings.json +1588 -516
  108. setiastro/saspro/translations/ar_translations.py +915 -684
  109. setiastro/saspro/translations/de_translations.py +442 -463
  110. setiastro/saspro/translations/es_translations.py +277 -47
  111. setiastro/saspro/translations/fr_translations.py +279 -47
  112. setiastro/saspro/translations/hi_translations.py +253 -21
  113. setiastro/saspro/translations/integrate_translations.py +3 -2
  114. setiastro/saspro/translations/it_translations.py +1211 -161
  115. setiastro/saspro/translations/ja_translations.py +3340 -3107
  116. setiastro/saspro/translations/pt_translations.py +3315 -3337
  117. setiastro/saspro/translations/ru_translations.py +351 -117
  118. setiastro/saspro/translations/saspro_ar.qm +0 -0
  119. setiastro/saspro/translations/saspro_ar.ts +15902 -138
  120. setiastro/saspro/translations/saspro_de.qm +0 -0
  121. setiastro/saspro/translations/saspro_de.ts +14428 -133
  122. setiastro/saspro/translations/saspro_es.qm +0 -0
  123. setiastro/saspro/translations/saspro_es.ts +11503 -7821
  124. setiastro/saspro/translations/saspro_fr.qm +0 -0
  125. setiastro/saspro/translations/saspro_fr.ts +11168 -7812
  126. setiastro/saspro/translations/saspro_hi.qm +0 -0
  127. setiastro/saspro/translations/saspro_hi.ts +14733 -135
  128. setiastro/saspro/translations/saspro_it.qm +0 -0
  129. setiastro/saspro/translations/saspro_it.ts +14347 -7821
  130. setiastro/saspro/translations/saspro_ja.qm +0 -0
  131. setiastro/saspro/translations/saspro_ja.ts +14860 -137
  132. setiastro/saspro/translations/saspro_pt.qm +0 -0
  133. setiastro/saspro/translations/saspro_pt.ts +14904 -137
  134. setiastro/saspro/translations/saspro_ru.qm +0 -0
  135. setiastro/saspro/translations/saspro_ru.ts +11766 -168
  136. setiastro/saspro/translations/saspro_sw.qm +0 -0
  137. setiastro/saspro/translations/saspro_sw.ts +15115 -135
  138. setiastro/saspro/translations/saspro_uk.qm +0 -0
  139. setiastro/saspro/translations/saspro_uk.ts +11206 -6729
  140. setiastro/saspro/translations/saspro_zh.qm +0 -0
  141. setiastro/saspro/translations/saspro_zh.ts +10581 -7812
  142. setiastro/saspro/translations/sw_translations.py +282 -56
  143. setiastro/saspro/translations/uk_translations.py +264 -35
  144. setiastro/saspro/translations/zh_translations.py +282 -47
  145. setiastro/saspro/view_bundle.py +17 -17
  146. setiastro/saspro/wavescale_hdr.py +4 -1
  147. setiastro/saspro/wavescalede.py +4 -1
  148. setiastro/saspro/whitebalance.py +84 -12
  149. setiastro/saspro/widgets/common_utilities.py +28 -21
  150. setiastro/saspro/widgets/minigame/game.js +11 -6
  151. setiastro/saspro/widgets/resource_monitor.py +133 -57
  152. setiastro/saspro/widgets/spinboxes.py +28 -13
  153. setiastro/saspro/wimi.py +92 -721
  154. setiastro/saspro/wims.py +46 -36
  155. setiastro/saspro/window_shelf.py +2 -2
  156. setiastro/saspro/xisf.py +101 -11
  157. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/METADATA +8 -7
  158. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/RECORD +162 -128
  159. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/WHEEL +0 -0
  160. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/entry_points.txt +0 -0
  161. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/licenses/LICENSE +0 -0
  162. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/licenses/license.txt +0 -0
@@ -363,6 +363,10 @@ class IsophoteModelerDialog(QDialog):
363
363
  self.setWindowFlag(Qt.WindowType.Window, True)
364
364
  self.setWindowModality(Qt.WindowModality.NonModal)
365
365
  self.setModal(False)
366
+ try:
367
+ self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
368
+ except Exception:
369
+ pass # older PyQt6 versions
366
370
  self.image_manager = image_manager
367
371
  self.doc_manager = doc_manager
368
372
 
@@ -23,18 +23,19 @@ BLEND_MODES = [
23
23
  @dataclass
24
24
  class ImageLayer:
25
25
  name: str
26
- src_doc: object # ImageDocument
26
+ src_doc: Optional[object] = None # ImageDocument (can be None for baked raster)
27
+ pixels: Optional[np.ndarray] = None # NEW: baked raster pixels in float32 or any dtype
28
+
27
29
  visible: bool = True
28
- opacity: float = 1.0 # 0..1
29
- mode: str = "Normal" # one of BLEND_MODES
30
- mask_doc: Optional[object] = None # ImageDocument whose active mask we read
30
+ opacity: float = 1.0
31
+ mode: str = "Normal"
32
+ mask_doc: Optional[object] = None
31
33
  mask_invert: bool = False
32
- mask_feather: float = 0.0 # (reserved)
34
+ mask_feather: float = 0.0
33
35
  mask_use_luma: bool = False
34
36
 
35
- # Sigmoid blend parameters
36
- sigmoid_center: float = 0.5 # where transition happens (0..1)
37
- sigmoid_strength: float = 10.0 # steepness of the curve
37
+ sigmoid_center: float = 0.5
38
+ sigmoid_strength: float = 10.0
38
39
 
39
40
  def _float01(arr: np.ndarray) -> np.ndarray:
40
41
  a = np.asarray(arr)
@@ -182,7 +183,10 @@ def composite_stack(base_img: np.ndarray, layers: List[ImageLayer]) -> np.ndarra
182
183
  for L in reversed(layers or []):
183
184
  if not L.visible:
184
185
  continue
185
- src = getattr(L.src_doc, "image", None)
186
+ src = getattr(L, "pixels", None)
187
+ if src is None:
188
+ src_doc = getattr(L, "src_doc", None)
189
+ src = getattr(src_doc, "image", None) if src_doc is not None else None
186
190
  if src is None:
187
191
  continue
188
192
  s = _ensure_3c(_float01(src))
@@ -212,19 +212,29 @@ class LayersDock(QDockWidget):
212
212
  top.addWidget(self.view_combo, 1)
213
213
 
214
214
  self.list = QListWidget()
215
- self.list.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
215
+ self.list.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
216
216
  self.list.setAlternatingRowColors(True)
217
217
  v.addWidget(self.list, 1)
218
218
 
219
219
  # buttons
220
220
  row = QHBoxLayout(); v.addLayout(row)
221
+
221
222
  self.btn_clear = QPushButton("Clear All Layers")
222
- self.btn_merge = QPushButton("Merge Layers and Push to View")
223
+
224
+ self.btn_merge = QPushButton("Merge → Push to View")
223
225
  self.btn_merge.setToolTip("Flatten the visible layers into the current view and add an undo step.")
226
+
227
+ self.btn_merge_new = QPushButton("Merge → New Document")
228
+ self.btn_merge_new.setToolTip("Flatten the visible layers into a new document (does not modify the base view).")
229
+
230
+ self.btn_merge_sel = QPushButton("Merge Selected → Single Layer")
231
+ self.btn_merge_sel.setToolTip("Merge the selected layers into one raster layer (Photoshop-style).")
232
+
224
233
  row.addWidget(self.btn_merge)
234
+ row.addWidget(self.btn_merge_new)
235
+ row.addWidget(self.btn_merge_sel)
225
236
  row.addStretch(1)
226
237
  row.addWidget(self.btn_clear)
227
-
228
238
  self.setWidget(w)
229
239
 
230
240
  # dnd (accept drops from views)
@@ -240,6 +250,8 @@ class LayersDock(QDockWidget):
240
250
  self.docman.documentRemoved.connect(lambda _d: self._refresh_views())
241
251
 
242
252
  self.btn_merge.clicked.connect(self._merge_and_push)
253
+ self.btn_merge_new.clicked.connect(self._merge_to_new_doc)
254
+ self.btn_merge_sel.clicked.connect(self._merge_selected_to_single_layer)
243
255
 
244
256
  # initial
245
257
  self._refresh_views()
@@ -420,8 +432,107 @@ class LayersDock(QDockWidget):
420
432
  has_layers = bool(getattr(vw, "_layers", []))
421
433
  self.btn_merge.setEnabled(has_layers)
422
434
  self.btn_clear.setEnabled(has_layers)
435
+ if hasattr(self, "btn_merge_new"):
436
+ self.btn_merge_new.setEnabled(has_layers)
437
+ has_layers = bool(getattr(vw, "_layers", []))
438
+ self.btn_merge_sel.setEnabled(has_layers)
423
439
  self._refresh_row_heights()
424
440
 
441
+ def _selected_layer_indices(self) -> list[int]:
442
+ vw = self.current_view()
443
+ if not vw:
444
+ return []
445
+ n = len(getattr(vw, "_layers", []) or [])
446
+ idxs = []
447
+ for it in self.list.selectedItems():
448
+ r = self.list.row(it)
449
+ if 0 <= r < n:
450
+ idxs.append(r)
451
+ idxs = sorted(set(idxs))
452
+ return idxs
453
+
454
+ def _render_stack(self, base_img: np.ndarray, layers: list[ImageLayer]) -> np.ndarray:
455
+ # composite_stack already respects visibility/opacity/modes/masks
456
+ out = composite_stack(base_img, layers)
457
+ return out if out is not None else base_img
458
+
459
+ def _open_baked_layer_doc(self, base_doc, arr: np.ndarray, title: str):
460
+ dm = getattr(self.mw, "docman", None)
461
+ if not dm or not hasattr(dm, "open_array"):
462
+ return None
463
+ meta = dict(getattr(base_doc, "metadata", {}) or {})
464
+ meta.update({
465
+ "bit_depth": "32-bit floating point",
466
+ "is_mono": (arr.ndim == 2 or (arr.ndim == 3 and arr.shape[-1] == 1)),
467
+ "source": "Layers Merge Selected",
468
+ })
469
+ return dm.open_array(arr.astype(np.float32, copy=False), metadata=meta, title=title)
470
+
471
+ def _merge_selected_to_single_layer(self):
472
+ vw = self.current_view()
473
+ if not vw:
474
+ return
475
+
476
+ layers = list(getattr(vw, "_layers", []) or [])
477
+ if not layers:
478
+ QMessageBox.information(self, "Layers", "There are no layers to merge.")
479
+ return
480
+
481
+ sel = self._selected_layer_indices()
482
+ if len(sel) < 2:
483
+ QMessageBox.information(self, "Layers", "Select two or more layers to merge.")
484
+ return
485
+
486
+ try:
487
+ base_doc = getattr(vw, "document", None)
488
+ if base_doc is None or getattr(base_doc, "image", None) is None:
489
+ QMessageBox.warning(self, "Layers", "No base image available for this view.")
490
+ return
491
+ base_img = base_doc.image
492
+
493
+ i0, i1 = sel[0], sel[-1]
494
+
495
+ # IMPORTANT ASSUMPTION (matches your UI order):
496
+ # vw._layers is top-to-bottom in the list, and "below" means larger index.
497
+ layers_above = layers[:i0]
498
+ layers_sel = layers[i0:i1+1]
499
+ layers_below = layers[i1+1:]
500
+
501
+ # 1) Render what exists directly under the selected range
502
+ under = self._render_stack(base_img, layers_below)
503
+
504
+ # 2) Render selected layers on top of that "under" image
505
+ baked = self._render_stack(under, layers_sel)
506
+
507
+
508
+ # 3) Create a baked raster layer (NO new document)
509
+ merged_layer = ImageLayer(
510
+ name=f"Merged ({len(layers_sel)})",
511
+ src_doc=None,
512
+ pixels=baked.astype(np.float32, copy=False),
513
+ visible=True,
514
+ opacity=1.0,
515
+ mode="Normal",
516
+ )
517
+
518
+ # Keep masks off by default; you can also decide to inherit the topmost mask
519
+ merged_layer.mask_doc = None
520
+ merged_layer.mask_use_luma = True
521
+ merged_layer.mask_invert = False
522
+
523
+ new_layers = layers_above + [merged_layer] + layers_below
524
+ vw._layers = new_layers
525
+
526
+ vw._reinstall_layer_watchers()
527
+ self._rebuild_list()
528
+ vw.apply_layer_stack(vw._layers)
529
+
530
+ QMessageBox.information(self, "Layers", f"Merged {len(layers_sel)} layers into a single layer.")
531
+ except Exception as ex:
532
+ print("[LayersDock] merge_selected error:", ex)
533
+ QMessageBox.critical(self, "Layers", f"Merge Selected failed:\n{ex}")
534
+
535
+
425
536
  def _layer_count(self) -> int:
426
537
  vw = self.current_view()
427
538
  return len(getattr(vw, "_layers", [])) if vw else 0
@@ -712,3 +823,72 @@ class LayersDock(QDockWidget):
712
823
  print("[LayersDock] merge error:", ex)
713
824
  QMessageBox.critical(self, "Layers", f"Merge failed:\n{ex}")
714
825
 
826
+ def _merge_to_new_doc(self):
827
+ vw = self.current_view()
828
+ if not vw:
829
+ return
830
+
831
+ layers = list(getattr(vw, "_layers", []) or [])
832
+ if not layers:
833
+ QMessageBox.information(self, "Layers", "There are no layers to merge.")
834
+ return
835
+
836
+ try:
837
+ base_doc = getattr(vw, "document", None)
838
+ if base_doc is None or getattr(base_doc, "image", None) is None:
839
+ QMessageBox.warning(self, "Layers", "No base image available for this view.")
840
+ return
841
+
842
+ base_img = base_doc.image
843
+ merged = composite_stack(base_img, layers)
844
+ if merged is None:
845
+ QMessageBox.warning(self, "Layers", "Composite failed (empty result).")
846
+ return
847
+
848
+ # Push as a new document (same pattern as stars-only)
849
+ self._push_merged_as_new_doc(base_doc, merged)
850
+
851
+ QMessageBox.information(self, "Layers",
852
+ "Merged visible layers and created a new document.")
853
+ except Exception as ex:
854
+ print("[LayersDock] merge_to_new_doc error:", ex)
855
+ QMessageBox.critical(self, "Layers", f"Merge failed:\n{ex}")
856
+
857
+ def _push_merged_as_new_doc(self, base_doc, arr: np.ndarray):
858
+ dm = getattr(self.mw, "docman", None)
859
+ if not dm or not hasattr(dm, "open_array"):
860
+ return
861
+
862
+ # Derive a friendly title based on the *view title* if possible
863
+ title = None
864
+ try:
865
+ # Use current view title (respects per-view rename)
866
+ vw = self.current_view()
867
+ if vw and hasattr(vw, "_effective_title"):
868
+ base = (vw._effective_title() or "").strip()
869
+ else:
870
+ base = ""
871
+
872
+ if not base:
873
+ dn = getattr(base_doc, "display_name", None)
874
+ base = dn() if callable(dn) else (dn or "Untitled")
875
+
876
+ suffix = "_merged"
877
+ title = base if base.endswith(suffix) else f"{base}{suffix}"
878
+ except Exception:
879
+ title = "Merged Layers"
880
+
881
+ try:
882
+ meta = dict(getattr(base_doc, "metadata", {}) or {})
883
+ meta.update({
884
+ "bit_depth": "32-bit floating point",
885
+ "is_mono": (arr.ndim == 2 or (arr.ndim == 3 and arr.shape[-1] == 1)),
886
+ "source": "Layers Merge",
887
+ "step_name": "Layers Merge",
888
+ })
889
+
890
+ newdoc = dm.open_array(arr.astype(np.float32, copy=False), metadata=meta, title=title)
891
+ if hasattr(self.mw, "_spawn_subwindow_for"):
892
+ self.mw._spawn_subwindow_for(newdoc)
893
+ except Exception as ex:
894
+ print("[LayersDock] _push_merged_as_new_doc error:", ex)
@@ -986,6 +986,124 @@ def _fill_hdr_from_raw_metadata(raw, hdr: fits.Header | None = None) -> fits.Hea
986
986
 
987
987
  from astropy.wcs import WCS
988
988
 
989
+ import ast
990
+
991
+ def _coerce_fits_value(v):
992
+ if v is None:
993
+ return None
994
+ if isinstance(v, (int, float, bool)):
995
+ return v
996
+ s = str(v).strip()
997
+
998
+ # PixInsight T/F
999
+ if s in ("T", "TRUE", "True", "true"):
1000
+ return True
1001
+ if s in ("F", "FALSE", "False", "false"):
1002
+ return False
1003
+
1004
+ # int?
1005
+ try:
1006
+ if s.isdigit() or (s.startswith(("+", "-")) and s[1:].isdigit()):
1007
+ return int(s)
1008
+ except Exception:
1009
+ pass
1010
+
1011
+ # float? (handles 8.9669e+03 etc)
1012
+ try:
1013
+ return float(s)
1014
+ except Exception:
1015
+ pass
1016
+
1017
+ # strip quotes
1018
+ if len(s) >= 2 and s[0] == s[-1] and s[0] in ("'", '"'):
1019
+ s = s[1:-1]
1020
+ return s
1021
+
1022
+
1023
+ def xisf_fits_header_from_meta(image_meta: dict, file_meta: dict | None = None) -> fits.Header:
1024
+ """
1025
+ Robustly extract FITSKeywords from XISF wrappers matching your real structure.
1026
+
1027
+ Handles:
1028
+ - image_meta["FITSKeywords"]
1029
+ - image_meta["xisf_meta"]["FITSKeywords"]
1030
+ - image_meta["xisf_meta"] as a stringified dict containing FITSKeywords
1031
+ - file_meta FITSKeywords (only fills missing keys)
1032
+ """
1033
+ hdr = fits.Header()
1034
+
1035
+ def _get_kw_dict(meta: dict):
1036
+ if not isinstance(meta, dict):
1037
+ return None
1038
+
1039
+ # direct
1040
+ kw = meta.get("FITSKeywords")
1041
+ if isinstance(kw, dict):
1042
+ return kw
1043
+
1044
+ # nested dict
1045
+ xm = meta.get("xisf_meta")
1046
+ if isinstance(xm, dict):
1047
+ kw = xm.get("FITSKeywords")
1048
+ if isinstance(kw, dict):
1049
+ return kw
1050
+
1051
+ # stringified dict
1052
+ if isinstance(xm, str) and "FITSKeywords" in xm:
1053
+ try:
1054
+ xm2 = ast.literal_eval(xm)
1055
+ if isinstance(xm2, dict) and isinstance(xm2.get("FITSKeywords"), dict):
1056
+ return xm2["FITSKeywords"]
1057
+ except Exception:
1058
+ pass
1059
+
1060
+ return None
1061
+
1062
+ def _apply_kw_dict(kw: dict, only_missing: bool):
1063
+ for key, entries in kw.items():
1064
+ try:
1065
+ k = str(key).strip()
1066
+ if not k:
1067
+ continue
1068
+ if only_missing and (k in hdr):
1069
+ continue
1070
+
1071
+ # your structure: KEY: [ {"value": "...", "comment": "..."} ]
1072
+ val = None
1073
+ com = None
1074
+ if isinstance(entries, list) and entries:
1075
+ e0 = entries[0]
1076
+ if isinstance(e0, dict):
1077
+ val = _coerce_fits_value(e0.get("value"))
1078
+ com = e0.get("comment")
1079
+ else:
1080
+ val = _coerce_fits_value(e0)
1081
+ elif isinstance(entries, dict):
1082
+ val = _coerce_fits_value(entries.get("value"))
1083
+ com = entries.get("comment")
1084
+ else:
1085
+ val = _coerce_fits_value(entries)
1086
+
1087
+ if com is not None:
1088
+ hdr[k] = (val, str(com))
1089
+ else:
1090
+ hdr[k] = val
1091
+ except Exception:
1092
+ pass
1093
+
1094
+ # First: image-level FITSKeywords (authoritative)
1095
+ kw_img = _get_kw_dict(image_meta) or {}
1096
+ if isinstance(kw_img, dict):
1097
+ _apply_kw_dict(kw_img, only_missing=False)
1098
+
1099
+ # Then: file-level FITSKeywords (fill gaps only)
1100
+ kw_file = _get_kw_dict(file_meta or {}) or {}
1101
+ if isinstance(kw_file, dict):
1102
+ _apply_kw_dict(kw_file, only_missing=True)
1103
+
1104
+ return hdr
1105
+
1106
+
989
1107
  def attach_wcs_to_metadata(meta: dict, hdr: fits.Header | dict | None) -> dict:
990
1108
  """
991
1109
  If hdr contains WCS, create an astropy.wcs.WCS and stash in metadata.
@@ -1378,7 +1496,7 @@ def load_image(filename, max_retries=3, wait_seconds=3, return_metadata: bool =
1378
1496
 
1379
1497
  # ─── Build FITS header from PixInsight XISFProperties ─────────────────
1380
1498
  # ─── Build FITS header from XISFProperties, then fallback to FITSKeywords & Pixel‐Scale ─────────────────
1381
- props = image_meta.get('XISFProperties', {})
1499
+
1382
1500
  def _dump_astrometric_keys(props, image_meta, file_meta):
1383
1501
  print("🔎 [XISF] XISFProperties AstrometricSolution-related keys:")
1384
1502
  for k in sorted(props.keys()):
@@ -1395,31 +1513,47 @@ def load_image(filename, max_retries=3, wait_seconds=3, return_metadata: bool =
1395
1513
 
1396
1514
  _dump_fk(image_meta, "image_meta")
1397
1515
  _dump_fk(file_meta, "file_meta")
1398
- #_dump_astrometric_keys(props, image_meta, file_meta)
1399
- hdr = fits.Header()
1400
- _filled = set()
1516
+ # Build base header from FITSKeywords (typed) first
1517
+ hdr = xisf_fits_header_from_meta(image_meta, file_meta) # your new helper
1518
+ _filled = set(hdr.keys())
1519
+
1520
+ # Now get XISFProperties (for PI grids + fallback)
1521
+ props = (image_meta.get("XISFProperties", {}) or
1522
+ file_meta.get("XISFProperties", {}) or {})
1523
+ #_filled = set()
1401
1524
 
1402
- # 1) PixInsight astrometric solution
1525
+ # 1) PixInsight astrometric solution (fallback only)
1526
+ # 1) PixInsight astrometric solution (fallback only)
1403
1527
  try:
1404
- im0, im1 = props['PCL:AstrometricSolution:ReferenceImageCoordinates']['value']
1405
- w0, w1 = props['PCL:AstrometricSolution:ReferenceCelestialCoordinates']['value']
1406
- hdr['CRPIX1'], hdr['CRPIX2'] = float(im0), float(im1)
1407
- hdr['CRVAL1'], hdr['CRVAL2'] = float(w0), float(w1)
1408
- hdr['CTYPE1'], hdr['CTYPE2'] = 'RA---TAN-SIP','DEC--TAN-SIP'
1409
- _filled |= {'CRPIX1','CRPIX2','CRVAL1','CRVAL2','CTYPE1','CTYPE2'}
1410
- print("🔷 Injected CRPIX/CRVAL from XISFProperties")
1528
+ if not all(k in hdr for k in ("CRPIX1","CRPIX2","CRVAL1","CRVAL2")):
1529
+ ref_img = props['PCL:AstrometricSolution:ReferenceImageCoordinates']['value']
1530
+ ref_sky = props['PCL:AstrometricSolution:ReferenceCelestialCoordinates']['value']
1531
+
1532
+ # Some files store extra values; only first two are CRPIX/CRVAL
1533
+ im0, im1 = float(ref_img[0]), float(ref_img[1])
1534
+ w0, w1 = float(ref_sky[0]), float(ref_sky[1])
1535
+
1536
+ hdr['CRPIX1'], hdr['CRPIX2'] = im0, im1
1537
+ hdr['CRVAL1'], hdr['CRVAL2'] = w0, w1
1538
+ hdr.setdefault('CTYPE1', 'RA---TAN-SIP')
1539
+ hdr.setdefault('CTYPE2', 'DEC--TAN-SIP')
1540
+ _filled |= {'CRPIX1','CRPIX2','CRVAL1','CRVAL2','CTYPE1','CTYPE2'}
1541
+ print("🔷 Injected CRPIX/CRVAL from XISFProperties (fallback)")
1411
1542
  except KeyError:
1412
- print("⚠️ Missing reference coords in XISFProperties")
1543
+ pass
1544
+ except Exception as e:
1545
+ print(f"⚠️ XISFProperties CRPIX/CRVAL parse failed; skipping. Reason: {e}")
1413
1546
 
1414
- # 2) CD matrix
1547
+ # 2) CD matrix (fallback only)
1415
1548
  try:
1416
- lin = np.asarray(props['PCL:AstrometricSolution:LinearTransformationMatrix']['value'], float)
1417
- hdr['CD1_1'], hdr['CD1_2'] = lin[0,0], lin[0,1]
1418
- hdr['CD2_1'], hdr['CD2_2'] = lin[1,0], lin[1,1]
1419
- _filled |= {'CD1_1','CD1_2','CD2_1','CD2_2'}
1420
- print("🔷 Injected CD matrix from XISFProperties")
1549
+ if not all(k in hdr for k in ("CD1_1","CD1_2","CD2_1","CD2_2")):
1550
+ lin = np.asarray(props['PCL:AstrometricSolution:LinearTransformationMatrix']['value'], float)
1551
+ hdr['CD1_1'], hdr['CD1_2'] = float(lin[0,0]), float(lin[0,1])
1552
+ hdr['CD2_1'], hdr['CD2_2'] = float(lin[1,0]), float(lin[1,1])
1553
+ _filled |= {'CD1_1','CD1_2','CD2_1','CD2_2'}
1554
+ print("🔷 Injected CD matrix from XISFProperties (fallback)")
1421
1555
  except KeyError:
1422
- print("⚠️ Missing CD matrix in XISFProperties")
1556
+ pass
1423
1557
 
1424
1558
  # 3) SIP polynomial fitting (CORRECTED for PI ImageToNative grids)
1425
1559
  def _try_inject_sip_from_fitskeywords(hdr, image_meta, file_meta):
@@ -407,43 +407,7 @@ def normalize_flat_cfa_inplace(flat2d: np.ndarray, pattern: str, *, combine_gree
407
407
  flat2d[flat2d == 0] = 1.0
408
408
  return flat2d
409
409
 
410
- @njit(parallel=True, fastmath=True)
411
- def apply_flat_division_numba_2d(image, master_flat, master_bias=None):
412
- """
413
- Mono version: image.shape == (H,W)
414
- """
415
- if master_bias is not None:
416
- master_flat = master_flat - master_bias
417
- image = image - master_bias
418
-
419
- median_flat = np.mean(master_flat)
420
- height, width = image.shape
421
-
422
- for y in prange(height):
423
- for x in range(width):
424
- image[y, x] /= (master_flat[y, x] / median_flat)
425
-
426
- return image
427
-
428
-
429
- @njit(parallel=True, fastmath=True)
430
- def apply_flat_division_numba_3d(image, master_flat, master_bias=None):
431
- """
432
- Color version: image.shape == (H,W,C)
433
- """
434
- if master_bias is not None:
435
- master_flat = master_flat - master_bias
436
- image = image - master_bias
437
410
 
438
- median_flat = np.mean(master_flat)
439
- height, width, channels = image.shape
440
-
441
- for y in prange(height):
442
- for x in range(width):
443
- for c in range(channels):
444
- image[y, x, c] /= (master_flat[y, x, c] / median_flat)
445
-
446
- return image
447
411
 
448
412
  @njit(parallel=True, fastmath=True)
449
413
  def _flat_div_2d(img, flat):
@@ -563,24 +527,37 @@ def apply_flat_division_numba_bayer_2d(image, master_flat, med4, pat_id):
563
527
  Bayer-aware mono division. image/master_flat are (H,W).
564
528
  med4 is [R,G1,G2,B] for that master_flat, pat_id in {0..3}.
565
529
  """
530
+ # parity index = (row&1)*2 + (col&1)
531
+ # med4 index order: 0=R, 1=G1, 2=G2, 3=B
532
+
533
+ # tables map parity_index -> med4 index
534
+ # parity_index: 0:(0,0) 1:(0,1) 2:(1,0) 3:(1,1)
535
+ if pat_id == 0: # RGGB: (0,0)R (0,1)G1 (1,0)G2 (1,1)B
536
+ t0, t1, t2, t3 = 0, 1, 2, 3
537
+ elif pat_id == 1: # BGGR: (0,0)B (0,1)G1 (1,0)G2 (1,1)R
538
+ t0, t1, t2, t3 = 3, 1, 2, 0
539
+ elif pat_id == 2: # GRBG: (0,0)G1 (0,1)R (1,0)B (1,1)G2
540
+ t0, t1, t2, t3 = 1, 0, 3, 2
541
+ else: # GBRG: (0,0)G1 (0,1)B (1,0)R (1,1)G2
542
+ t0, t1, t2, t3 = 1, 3, 0, 2
543
+
566
544
  H, W = image.shape
567
545
  for y in prange(H):
568
546
  y1 = y & 1
569
547
  for x in range(W):
570
548
  x1 = x & 1
571
-
572
- # map parity->plane index
573
- if pat_id == 0: # RGGB: (0,0)R (0,1)G1 (1,0)G2 (1,1)B
574
- pi = 0 if (y1==0 and x1==0) else 1 if (y1==0 and x1==1) else 2 if (y1==1 and x1==0) else 3
575
- elif pat_id == 1: # BGGR
576
- pi = 3 if (y1==1 and x1==1) else 1 if (y1==0 and x1==1) else 2 if (y1==1 and x1==0) else 0
577
- elif pat_id == 2: # GRBG
578
- pi = 1 if (y1==0 and x1==0) else 0 if (y1==0 and x1==1) else 3 if (y1==1 and x1==0) else 2
579
- else: # GBRG
580
- pi = 1 if (y1==0 and x1==0) else 3 if (y1==0 and x1==1) else 0 if (y1==1 and x1==0) else 2
549
+ p = (y1 << 1) | x1 # 0..3
550
+ if p == 0:
551
+ pi = t0
552
+ elif p == 1:
553
+ pi = t1
554
+ elif p == 2:
555
+ pi = t2
556
+ else:
557
+ pi = t3
581
558
 
582
559
  denom = master_flat[y, x] / med4[pi]
583
- if denom == 0.0 or not np.isfinite(denom):
560
+ if denom == 0.0 or (not np.isfinite(denom)):
584
561
  denom = 1.0
585
562
  image[y, x] /= denom
586
563
  return image
@@ -2790,7 +2767,50 @@ def evaluate_polynomial(H: int, W: int, coeffs: np.ndarray, degree: int) -> np.n
2790
2767
  A_full = build_poly_terms(xx.ravel(), yy.ravel(), degree)
2791
2768
  return (A_full @ coeffs).reshape(H, W)
2792
2769
 
2770
+ @njit(parallel=True, fastmath=True)
2771
+ def numba_mono_from_img(img, bp, denom, median_rescaled, target_median):
2772
+ H, W = img.shape
2773
+ out = np.empty_like(img)
2774
+ for y in prange(H):
2775
+ for x in range(W):
2776
+ r = (img[y, x] - bp) / denom
2777
+ numer = (median_rescaled - 1.0) * target_median * r
2778
+ denom2 = median_rescaled * (target_median + r - 1.0) - target_median * r
2779
+ if abs(denom2) < 1e-12:
2780
+ denom2 = 1e-12
2781
+ out[y, x] = numer / denom2
2782
+ return out
2793
2783
 
2784
+ @njit(parallel=True, fastmath=True)
2785
+ def numba_color_linked_from_img(img, bp, denom, median_rescaled, target_median):
2786
+ H, W, C = img.shape
2787
+ out = np.empty_like(img)
2788
+ for y in prange(H):
2789
+ for x in range(W):
2790
+ for c in range(C):
2791
+ r = (img[y, x, c] - bp) / denom
2792
+ numer = (median_rescaled - 1.0) * target_median * r
2793
+ denom2 = median_rescaled * (target_median + r - 1.0) - target_median * r
2794
+ if abs(denom2) < 1e-12:
2795
+ denom2 = 1e-12
2796
+ out[y, x, c] = numer / denom2
2797
+ return out
2798
+
2799
+ @njit(parallel=True, fastmath=True)
2800
+ def numba_color_unlinked_from_img(img, bp3, denom3, meds_rescaled3, target_median):
2801
+ H, W, C = img.shape
2802
+ out = np.empty_like(img)
2803
+ for y in prange(H):
2804
+ for x in range(W):
2805
+ for c in range(C):
2806
+ r = (img[y, x, c] - bp3[c]) / denom3[c]
2807
+ med = meds_rescaled3[c]
2808
+ numer = (med - 1.0) * target_median * r
2809
+ denom2 = med * (target_median + r - 1.0) - target_median * r
2810
+ if abs(denom2) < 1e-12:
2811
+ denom2 = 1e-12
2812
+ out[y, x, c] = numer / denom2
2813
+ return out
2794
2814
 
2795
2815
  @njit(parallel=True, fastmath=True)
2796
2816
  def numba_mono_final_formula(rescaled, median_rescaled, target_median):