setiastrosuitepro 1.6.7__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.

Potentially problematic release.


This version of setiastrosuitepro might be problematic. Click here for more details.

Files changed (68) hide show
  1. setiastro/images/abeicon.svg +16 -0
  2. setiastro/images/colorwheel.svg +97 -0
  3. setiastro/images/cosmic.svg +40 -0
  4. setiastro/images/cosmicsat.svg +24 -0
  5. setiastro/images/graxpert.svg +19 -0
  6. setiastro/images/linearfit.svg +32 -0
  7. setiastro/images/narrowbandnormalization.png +0 -0
  8. setiastro/images/pixelmath.svg +42 -0
  9. setiastro/images/planetarystacker.png +0 -0
  10. setiastro/saspro/__main__.py +1 -1
  11. setiastro/saspro/_generated/build_info.py +2 -2
  12. setiastro/saspro/aberration_ai.py +49 -11
  13. setiastro/saspro/aberration_ai_preset.py +29 -3
  14. setiastro/saspro/add_stars.py +29 -5
  15. setiastro/saspro/backgroundneutral.py +73 -33
  16. setiastro/saspro/blink_comparator_pro.py +150 -55
  17. setiastro/saspro/convo.py +9 -6
  18. setiastro/saspro/cosmicclarity.py +125 -18
  19. setiastro/saspro/crop_dialog_pro.py +96 -2
  20. setiastro/saspro/curve_editor_pro.py +132 -61
  21. setiastro/saspro/curves_preset.py +249 -47
  22. setiastro/saspro/doc_manager.py +178 -11
  23. setiastro/saspro/frequency_separation.py +1159 -208
  24. setiastro/saspro/gui/main_window.py +340 -88
  25. setiastro/saspro/gui/mixins/dock_mixin.py +245 -24
  26. setiastro/saspro/gui/mixins/file_mixin.py +35 -16
  27. setiastro/saspro/gui/mixins/menu_mixin.py +31 -1
  28. setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
  29. setiastro/saspro/gui/mixins/toolbar_mixin.py +132 -10
  30. setiastro/saspro/gui/mixins/update_mixin.py +121 -33
  31. setiastro/saspro/histogram.py +179 -7
  32. setiastro/saspro/imageops/narrowband_normalization.py +816 -0
  33. setiastro/saspro/imageops/serloader.py +769 -0
  34. setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
  35. setiastro/saspro/imageops/stretch.py +582 -62
  36. setiastro/saspro/layers.py +13 -9
  37. setiastro/saspro/layers_dock.py +183 -3
  38. setiastro/saspro/legacy/numba_utils.py +68 -48
  39. setiastro/saspro/live_stacking.py +181 -73
  40. setiastro/saspro/multiscale_decomp.py +77 -29
  41. setiastro/saspro/narrowband_normalization.py +1618 -0
  42. setiastro/saspro/numba_utils.py +72 -57
  43. setiastro/saspro/ops/commands.py +18 -18
  44. setiastro/saspro/ops/script_editor.py +5 -0
  45. setiastro/saspro/ops/scripts.py +119 -0
  46. setiastro/saspro/remove_green.py +1 -1
  47. setiastro/saspro/resources.py +4 -0
  48. setiastro/saspro/ser_stack_config.py +68 -0
  49. setiastro/saspro/ser_stacker.py +2245 -0
  50. setiastro/saspro/ser_stacker_dialog.py +1481 -0
  51. setiastro/saspro/ser_tracking.py +206 -0
  52. setiastro/saspro/serviewer.py +1242 -0
  53. setiastro/saspro/sfcc.py +602 -214
  54. setiastro/saspro/shortcuts.py +154 -25
  55. setiastro/saspro/signature_insert.py +688 -33
  56. setiastro/saspro/stacking_suite.py +853 -401
  57. setiastro/saspro/star_alignment.py +243 -122
  58. setiastro/saspro/stat_stretch.py +878 -131
  59. setiastro/saspro/subwindow.py +303 -74
  60. setiastro/saspro/whitebalance.py +24 -0
  61. setiastro/saspro/widgets/common_utilities.py +28 -21
  62. setiastro/saspro/widgets/resource_monitor.py +128 -80
  63. {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/METADATA +2 -2
  64. {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/RECORD +68 -51
  65. {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/WHEEL +0 -0
  66. {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/entry_points.txt +0 -0
  67. {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/licenses/LICENSE +0 -0
  68. {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/licenses/license.txt +0 -0
@@ -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)
@@ -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
- # 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):