setiastrosuitepro 1.6.4__py3-none-any.whl → 1.7.1.post2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of setiastrosuitepro might be problematic. Click here for more details.
- setiastro/images/TextureClarity.svg +56 -0
- setiastro/images/abeicon.svg +16 -0
- setiastro/images/acv_icon.png +0 -0
- setiastro/images/colorwheel.svg +97 -0
- setiastro/images/cosmic.svg +40 -0
- setiastro/images/cosmicsat.svg +24 -0
- setiastro/images/first_quarter.png +0 -0
- setiastro/images/full_moon.png +0 -0
- setiastro/images/graxpert.svg +19 -0
- setiastro/images/last_quarter.png +0 -0
- setiastro/images/linearfit.svg +32 -0
- setiastro/images/narrowbandnormalization.png +0 -0
- setiastro/images/new_moon.png +0 -0
- setiastro/images/pixelmath.svg +42 -0
- setiastro/images/planetarystacker.png +0 -0
- setiastro/images/waning_crescent_1.png +0 -0
- setiastro/images/waning_crescent_2.png +0 -0
- setiastro/images/waning_crescent_3.png +0 -0
- setiastro/images/waning_crescent_4.png +0 -0
- setiastro/images/waning_crescent_5.png +0 -0
- setiastro/images/waning_gibbous_1.png +0 -0
- setiastro/images/waning_gibbous_2.png +0 -0
- setiastro/images/waning_gibbous_3.png +0 -0
- setiastro/images/waning_gibbous_4.png +0 -0
- setiastro/images/waning_gibbous_5.png +0 -0
- setiastro/images/waxing_crescent_1.png +0 -0
- setiastro/images/waxing_crescent_2.png +0 -0
- setiastro/images/waxing_crescent_3.png +0 -0
- setiastro/images/waxing_crescent_4.png +0 -0
- setiastro/images/waxing_crescent_5.png +0 -0
- setiastro/images/waxing_gibbous_1.png +0 -0
- setiastro/images/waxing_gibbous_2.png +0 -0
- setiastro/images/waxing_gibbous_3.png +0 -0
- setiastro/images/waxing_gibbous_4.png +0 -0
- setiastro/images/waxing_gibbous_5.png +0 -0
- setiastro/qml/ResourceMonitor.qml +84 -82
- setiastro/saspro/__main__.py +20 -1
- setiastro/saspro/_generated/build_info.py +2 -2
- setiastro/saspro/abe.py +37 -4
- setiastro/saspro/aberration_ai.py +364 -33
- setiastro/saspro/aberration_ai_preset.py +29 -3
- setiastro/saspro/acv_exporter.py +379 -0
- setiastro/saspro/add_stars.py +33 -6
- setiastro/saspro/astrospike_python.py +45 -3
- setiastro/saspro/backgroundneutral.py +108 -40
- setiastro/saspro/blemish_blaster.py +4 -1
- setiastro/saspro/blink_comparator_pro.py +150 -55
- setiastro/saspro/clahe.py +4 -1
- setiastro/saspro/continuum_subtract.py +4 -1
- setiastro/saspro/convo.py +13 -7
- setiastro/saspro/cosmicclarity.py +129 -18
- setiastro/saspro/crop_dialog_pro.py +123 -7
- setiastro/saspro/curve_editor_pro.py +181 -64
- setiastro/saspro/curves_preset.py +249 -47
- setiastro/saspro/doc_manager.py +245 -15
- setiastro/saspro/exoplanet_detector.py +120 -28
- setiastro/saspro/frequency_separation.py +1158 -204
- setiastro/saspro/ghs_dialog_pro.py +81 -16
- setiastro/saspro/graxpert.py +1 -0
- setiastro/saspro/gui/main_window.py +706 -264
- 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 +35 -1
- setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
- setiastro/saspro/gui/mixins/toolbar_mixin.py +499 -24
- setiastro/saspro/gui/mixins/update_mixin.py +138 -36
- setiastro/saspro/gui/mixins/view_mixin.py +42 -0
- setiastro/saspro/halobgon.py +4 -0
- setiastro/saspro/histogram.py +184 -8
- setiastro/saspro/image_combine.py +4 -0
- setiastro/saspro/image_peeker_pro.py +4 -0
- setiastro/saspro/imageops/narrowband_normalization.py +816 -0
- setiastro/saspro/imageops/serloader.py +1345 -0
- setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
- setiastro/saspro/imageops/stretch.py +582 -62
- setiastro/saspro/isophote.py +4 -0
- setiastro/saspro/layers.py +13 -9
- setiastro/saspro/layers_dock.py +183 -3
- setiastro/saspro/legacy/image_manager.py +154 -20
- setiastro/saspro/legacy/numba_utils.py +68 -48
- setiastro/saspro/legacy/xisf.py +240 -98
- setiastro/saspro/live_stacking.py +203 -82
- setiastro/saspro/luminancerecombine.py +228 -27
- setiastro/saspro/mask_creation.py +174 -15
- setiastro/saspro/mfdeconv.py +113 -35
- setiastro/saspro/mfdeconvcudnn.py +119 -70
- setiastro/saspro/mfdeconvsport.py +112 -35
- setiastro/saspro/morphology.py +4 -0
- setiastro/saspro/multiscale_decomp.py +81 -29
- setiastro/saspro/narrowband_normalization.py +1618 -0
- setiastro/saspro/numba_utils.py +72 -57
- setiastro/saspro/ops/commands.py +18 -18
- setiastro/saspro/ops/script_editor.py +10 -2
- setiastro/saspro/ops/scripts.py +122 -0
- setiastro/saspro/perfect_palette_picker.py +37 -3
- setiastro/saspro/plate_solver.py +84 -49
- setiastro/saspro/psf_viewer.py +119 -37
- setiastro/saspro/remove_green.py +1 -1
- setiastro/saspro/resources.py +73 -0
- setiastro/saspro/rgbalign.py +460 -12
- setiastro/saspro/selective_color.py +4 -1
- setiastro/saspro/ser_stack_config.py +82 -0
- setiastro/saspro/ser_stacker.py +2321 -0
- setiastro/saspro/ser_stacker_dialog.py +1838 -0
- setiastro/saspro/ser_tracking.py +206 -0
- setiastro/saspro/serviewer.py +1625 -0
- setiastro/saspro/sfcc.py +662 -216
- setiastro/saspro/shortcuts.py +171 -33
- setiastro/saspro/signature_insert.py +692 -33
- setiastro/saspro/stacking_suite.py +1347 -485
- setiastro/saspro/star_alignment.py +247 -123
- setiastro/saspro/star_spikes.py +4 -0
- setiastro/saspro/star_stretch.py +38 -3
- setiastro/saspro/stat_stretch.py +892 -129
- setiastro/saspro/subwindow.py +787 -363
- setiastro/saspro/supernovaasteroidhunter.py +1 -1
- setiastro/saspro/texture_clarity.py +593 -0
- setiastro/saspro/wavescale_hdr.py +4 -1
- setiastro/saspro/wavescalede.py +4 -1
- setiastro/saspro/whitebalance.py +84 -12
- setiastro/saspro/widgets/common_utilities.py +28 -21
- setiastro/saspro/widgets/resource_monitor.py +209 -111
- setiastro/saspro/widgets/spinboxes.py +10 -13
- setiastro/saspro/wimi.py +27 -656
- setiastro/saspro/wims.py +13 -3
- setiastro/saspro/xisf.py +101 -11
- {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/METADATA +4 -2
- {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/RECORD +132 -87
- {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/WHEEL +0 -0
- {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/entry_points.txt +0 -0
- {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/licenses/LICENSE +0 -0
- {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/licenses/license.txt +0 -0
setiastro/saspro/isophote.py
CHANGED
|
@@ -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
|
|
setiastro/saspro/layers.py
CHANGED
|
@@ -23,18 +23,19 @@ BLEND_MODES = [
|
|
|
23
23
|
@dataclass
|
|
24
24
|
class ImageLayer:
|
|
25
25
|
name: str
|
|
26
|
-
src_doc: object
|
|
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
|
|
29
|
-
mode: str = "Normal"
|
|
30
|
-
mask_doc: Optional[object] = None
|
|
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
|
|
34
|
+
mask_feather: float = 0.0
|
|
33
35
|
mask_use_luma: bool = False
|
|
34
36
|
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
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))
|
setiastro/saspro/layers_dock.py
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
1399
|
-
hdr
|
|
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
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
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
|
-
|
|
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):
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
#legacy.numba_utils.py
|
|
1
|
+
#src.setiastro.saspro.legacy.numba_utils.py
|
|
2
2
|
import numpy as np
|
|
3
3
|
from numba import njit, prange
|
|
4
4
|
from numba.typed import List
|
|
@@ -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
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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):
|