setiastrosuitepro 1.6.4__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 (115) 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/waning_crescent_1.png +0 -0
  14. setiastro/images/waning_crescent_2.png +0 -0
  15. setiastro/images/waning_crescent_3.png +0 -0
  16. setiastro/images/waning_crescent_4.png +0 -0
  17. setiastro/images/waning_crescent_5.png +0 -0
  18. setiastro/images/waning_gibbous_1.png +0 -0
  19. setiastro/images/waning_gibbous_2.png +0 -0
  20. setiastro/images/waning_gibbous_3.png +0 -0
  21. setiastro/images/waning_gibbous_4.png +0 -0
  22. setiastro/images/waning_gibbous_5.png +0 -0
  23. setiastro/images/waxing_crescent_1.png +0 -0
  24. setiastro/images/waxing_crescent_2.png +0 -0
  25. setiastro/images/waxing_crescent_3.png +0 -0
  26. setiastro/images/waxing_crescent_4.png +0 -0
  27. setiastro/images/waxing_crescent_5.png +0 -0
  28. setiastro/images/waxing_gibbous_1.png +0 -0
  29. setiastro/images/waxing_gibbous_2.png +0 -0
  30. setiastro/images/waxing_gibbous_3.png +0 -0
  31. setiastro/images/waxing_gibbous_4.png +0 -0
  32. setiastro/images/waxing_gibbous_5.png +0 -0
  33. setiastro/qml/ResourceMonitor.qml +84 -82
  34. setiastro/saspro/__main__.py +20 -1
  35. setiastro/saspro/_generated/build_info.py +2 -2
  36. setiastro/saspro/abe.py +37 -4
  37. setiastro/saspro/aberration_ai.py +237 -21
  38. setiastro/saspro/acv_exporter.py +379 -0
  39. setiastro/saspro/add_stars.py +33 -6
  40. setiastro/saspro/backgroundneutral.py +108 -40
  41. setiastro/saspro/blemish_blaster.py +4 -1
  42. setiastro/saspro/blink_comparator_pro.py +74 -24
  43. setiastro/saspro/clahe.py +4 -1
  44. setiastro/saspro/continuum_subtract.py +4 -1
  45. setiastro/saspro/convo.py +13 -7
  46. setiastro/saspro/cosmicclarity.py +129 -18
  47. setiastro/saspro/crop_dialog_pro.py +123 -7
  48. setiastro/saspro/curve_editor_pro.py +109 -42
  49. setiastro/saspro/doc_manager.py +245 -15
  50. setiastro/saspro/exoplanet_detector.py +120 -28
  51. setiastro/saspro/frequency_separation.py +1158 -204
  52. setiastro/saspro/ghs_dialog_pro.py +81 -16
  53. setiastro/saspro/graxpert.py +1 -0
  54. setiastro/saspro/gui/main_window.py +429 -228
  55. setiastro/saspro/gui/mixins/dock_mixin.py +245 -24
  56. setiastro/saspro/gui/mixins/menu_mixin.py +27 -1
  57. setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
  58. setiastro/saspro/gui/mixins/toolbar_mixin.py +384 -18
  59. setiastro/saspro/gui/mixins/update_mixin.py +138 -36
  60. setiastro/saspro/gui/mixins/view_mixin.py +42 -0
  61. setiastro/saspro/halobgon.py +4 -0
  62. setiastro/saspro/histogram.py +5 -1
  63. setiastro/saspro/image_combine.py +4 -0
  64. setiastro/saspro/image_peeker_pro.py +4 -0
  65. setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
  66. setiastro/saspro/imageops/stretch.py +582 -62
  67. setiastro/saspro/isophote.py +4 -0
  68. setiastro/saspro/layers.py +13 -9
  69. setiastro/saspro/layers_dock.py +183 -3
  70. setiastro/saspro/legacy/image_manager.py +154 -20
  71. setiastro/saspro/legacy/numba_utils.py +67 -47
  72. setiastro/saspro/legacy/xisf.py +240 -98
  73. setiastro/saspro/live_stacking.py +180 -79
  74. setiastro/saspro/luminancerecombine.py +228 -27
  75. setiastro/saspro/mask_creation.py +174 -15
  76. setiastro/saspro/mfdeconv.py +113 -35
  77. setiastro/saspro/mfdeconvcudnn.py +119 -70
  78. setiastro/saspro/mfdeconvsport.py +112 -35
  79. setiastro/saspro/morphology.py +4 -0
  80. setiastro/saspro/multiscale_decomp.py +51 -12
  81. setiastro/saspro/numba_utils.py +72 -57
  82. setiastro/saspro/ops/commands.py +18 -18
  83. setiastro/saspro/ops/script_editor.py +10 -2
  84. setiastro/saspro/ops/scripts.py +122 -0
  85. setiastro/saspro/perfect_palette_picker.py +37 -3
  86. setiastro/saspro/plate_solver.py +84 -49
  87. setiastro/saspro/psf_viewer.py +119 -37
  88. setiastro/saspro/resources.py +67 -0
  89. setiastro/saspro/rgbalign.py +4 -0
  90. setiastro/saspro/selective_color.py +4 -1
  91. setiastro/saspro/sfcc.py +364 -152
  92. setiastro/saspro/shortcuts.py +160 -29
  93. setiastro/saspro/signature_insert.py +692 -33
  94. setiastro/saspro/stacking_suite.py +1331 -484
  95. setiastro/saspro/star_alignment.py +247 -123
  96. setiastro/saspro/star_spikes.py +4 -0
  97. setiastro/saspro/star_stretch.py +38 -3
  98. setiastro/saspro/stat_stretch.py +743 -128
  99. setiastro/saspro/subwindow.py +786 -360
  100. setiastro/saspro/supernovaasteroidhunter.py +1 -1
  101. setiastro/saspro/wavescale_hdr.py +4 -1
  102. setiastro/saspro/wavescalede.py +4 -1
  103. setiastro/saspro/whitebalance.py +84 -12
  104. setiastro/saspro/widgets/common_utilities.py +28 -21
  105. setiastro/saspro/widgets/resource_monitor.py +109 -59
  106. setiastro/saspro/widgets/spinboxes.py +10 -13
  107. setiastro/saspro/wimi.py +27 -656
  108. setiastro/saspro/wims.py +13 -3
  109. setiastro/saspro/xisf.py +101 -11
  110. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.12.dist-info}/METADATA +2 -1
  111. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.12.dist-info}/RECORD +115 -82
  112. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.12.dist-info}/WHEEL +0 -0
  113. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.12.dist-info}/entry_points.txt +0 -0
  114. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.12.dist-info}/licenses/LICENSE +0 -0
  115. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.12.dist-info}/licenses/license.txt +0 -0
@@ -219,6 +219,10 @@ class MultiscaleDecompDialog(QDialog):
219
219
  self.setWindowFlag(Qt.WindowType.Window, True)
220
220
  self.setWindowModality(Qt.WindowModality.NonModal)
221
221
  self.setModal(False)
222
+ try:
223
+ self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
224
+ except Exception:
225
+ pass # older PyQt6 versions
222
226
  self.setMinimumSize(1050, 700)
223
227
  self.residual_enabled = True
224
228
  self._layer_noise = None # list[float] per detail layer
@@ -575,19 +579,29 @@ class MultiscaleDecompDialog(QDialog):
575
579
 
576
580
  # ---------- Preview plumbing ----------
577
581
  def _spinner_on(self):
578
- if getattr(self, "busy_spinner", None) is None:
582
+ if getattr(self, "_closing", False):
583
+ return
584
+ try:
585
+ sp = getattr(self, "busy_spinner", None)
586
+ if sp is None:
587
+ return
588
+ sp.setVisible(True)
589
+ mv = getattr(self, "_busy_movie", None)
590
+ if mv is not None and mv.state() != QMovie.MovieState.Running:
591
+ mv.start()
592
+ except RuntimeError:
579
593
  return
580
- self.busy_spinner.setVisible(True)
581
- if getattr(self, "_busy_movie", None) is not None:
582
- if self._busy_movie.state() != QMovie.MovieState.Running:
583
- self._busy_movie.start()
584
594
 
585
595
  def _spinner_off(self):
586
- if getattr(self, "busy_spinner", None) is None:
596
+ try:
597
+ sp = getattr(self, "busy_spinner", None)
598
+ mv = getattr(self, "_busy_movie", None)
599
+ if mv is not None:
600
+ mv.stop()
601
+ if sp is not None:
602
+ sp.setVisible(False)
603
+ except RuntimeError:
587
604
  return
588
- if getattr(self, "_busy_movie", None) is not None:
589
- self._busy_movie.stop()
590
- self.busy_spinner.setVisible(False)
591
605
 
592
606
 
593
607
  def _show_busy_overlay(self):
@@ -619,11 +633,13 @@ class MultiscaleDecompDialog(QDialog):
619
633
  self._schedule_preview()
620
634
 
621
635
  def _schedule_preview(self):
622
- # generic “something changed” entry point
636
+ if getattr(self, "_closing", False):
637
+ return
623
638
  self._preview_timer.start(60)
624
639
 
625
640
  def _schedule_roi_preview(self):
626
- # view changed (scroll/zoom/pan) — still debounced
641
+ if getattr(self, "_closing", False):
642
+ return
627
643
  self._preview_timer.start(60)
628
644
 
629
645
  def _connect_viewport_signals(self):
@@ -760,8 +776,15 @@ class MultiscaleDecompDialog(QDialog):
760
776
  return tuned, residual
761
777
 
762
778
  def _rebuild_preview(self):
779
+ if getattr(self, "_closing", False):
780
+ return
763
781
  self._spinner_on()
764
- QApplication.processEvents()
782
+ QTimer.singleShot(0, self._rebuild_preview_impl)
783
+
784
+ def _rebuild_preview_impl(self):
785
+ if getattr(self, "_closing", False):
786
+ return
787
+
765
788
  #self._begin_busy()
766
789
  try:
767
790
  # ROI preview can't work until we have *some* pixmap in the scene to derive visible rects from.
@@ -1745,3 +1768,19 @@ class _MultiScaleDecompPresetDialog(QDialog):
1745
1768
  "linked_rgb": bool(self.cb_linked.isChecked()),
1746
1769
  "layers_cfg": out_layers,
1747
1770
  }
1771
+ def closeEvent(self, ev):
1772
+ self._closing = True
1773
+ try:
1774
+ if hasattr(self, "_preview_timer"):
1775
+ self._preview_timer.stop()
1776
+ if hasattr(self, "_busy_show_timer"):
1777
+ self._busy_show_timer.stop()
1778
+ # Optional: disconnect scrollbars to stop ROI scheduling
1779
+ try:
1780
+ self.view.horizontalScrollBar().valueChanged.disconnect(self._schedule_roi_preview)
1781
+ self.view.verticalScrollBar().valueChanged.disconnect(self._schedule_roi_preview)
1782
+ except Exception:
1783
+ pass
1784
+ except Exception:
1785
+ pass
1786
+ super().closeEvent(ev)
@@ -317,61 +317,6 @@ def invert_image_numba(image):
317
317
  output[y, x, c] = 1.0 - image[y, x, c]
318
318
  return output
319
319
 
320
-
321
-
322
- @njit(parallel=True, fastmath=True, cache=True)
323
- def apply_flat_division_numba_2d(image, master_flat, master_bias=None):
324
- """
325
- Mono version: image.shape == (H,W)
326
- """
327
- if master_bias is not None:
328
- master_flat = master_flat - master_bias
329
- image = image - master_bias
330
-
331
- median_flat = np.mean(master_flat)
332
- height, width = image.shape
333
-
334
- for y in prange(height):
335
- for x in range(width):
336
- image[y, x] /= (master_flat[y, x] / median_flat)
337
-
338
- return image
339
-
340
-
341
- @njit(parallel=True, fastmath=True, cache=True)
342
- def apply_flat_division_numba_3d(image, master_flat, master_bias=None):
343
- """
344
- Color version: image.shape == (H,W,C)
345
- """
346
- if master_bias is not None:
347
- master_flat = master_flat - master_bias
348
- image = image - master_bias
349
-
350
- median_flat = np.mean(master_flat)
351
- height, width, channels = image.shape
352
-
353
- for y in prange(height):
354
- for x in range(width):
355
- for c in range(channels):
356
- image[y, x, c] /= (master_flat[y, x, c] / median_flat)
357
-
358
- return image
359
-
360
- def apply_flat_division_numba(image, master_flat, master_bias=None):
361
- """
362
- Dispatcher that calls the correct Numba function
363
- depending on whether 'image' is 2D or 3D.
364
- """
365
- if image.ndim == 2:
366
- # Mono
367
- return apply_flat_division_numba_2d(image, master_flat, master_bias)
368
- elif image.ndim == 3:
369
- # Color
370
- return apply_flat_division_numba_3d(image, master_flat, master_bias)
371
- else:
372
- raise ValueError(f"apply_flat_division_numba: expected 2D or 3D, got shape {image.shape}")
373
-
374
-
375
320
  @njit(parallel=True, cache=True)
376
321
  def subtract_dark_3d(frames, dark_frame):
377
322
  """
@@ -2495,7 +2440,77 @@ def drizzle_deposit_color_naive(image_data, affine_2x3, drizzle_buffer, coverage
2495
2440
 
2496
2441
  return drizzle_buffer, coverage_buffer
2497
2442
 
2498
- @njit(parallel=True, fastmath=True, cache=True)
2443
+ @njit(parallel=True, fastmath=True)
2444
+ def numba_mono_from_img(img, bp, denom, median_rescaled, target_median):
2445
+ H, W = img.shape
2446
+ out = np.empty_like(img)
2447
+ for y in prange(H):
2448
+ for x in range(W):
2449
+ r = (img[y, x] - bp) / denom
2450
+ numer = (median_rescaled - 1.0) * target_median * r
2451
+ denom2 = median_rescaled * (target_median + r - 1.0) - target_median * r
2452
+ if abs(denom2) < 1e-12:
2453
+ denom2 = 1e-12
2454
+ out[y, x] = numer / denom2
2455
+ return out
2456
+
2457
+ @njit(parallel=True, fastmath=True)
2458
+ def numba_color_linked_from_img(img, bp, denom, median_rescaled, target_median):
2459
+ H, W, C = img.shape
2460
+ out = np.empty_like(img)
2461
+ for y in prange(H):
2462
+ for x in range(W):
2463
+ for c in range(C):
2464
+ r = (img[y, x, c] - bp) / denom
2465
+ numer = (median_rescaled - 1.0) * target_median * r
2466
+ denom2 = median_rescaled * (target_median + r - 1.0) - target_median * r
2467
+ if abs(denom2) < 1e-12:
2468
+ denom2 = 1e-12
2469
+ out[y, x, c] = numer / denom2
2470
+ return out
2471
+
2472
+ @njit(parallel=True, fastmath=True)
2473
+ def numba_color_unlinked_from_img(img, bp3, denom3, meds_rescaled3, target_median):
2474
+ H, W, C = img.shape
2475
+ out = np.empty_like(img)
2476
+ for y in prange(H):
2477
+ for x in range(W):
2478
+ for c in range(C):
2479
+ r = (img[y, x, c] - bp3[c]) / denom3[c]
2480
+ med = meds_rescaled3[c]
2481
+ numer = (med - 1.0) * target_median * r
2482
+ denom2 = med * (target_median + r - 1.0) - target_median * r
2483
+ if abs(denom2) < 1e-12:
2484
+ denom2 = 1e-12
2485
+ out[y, x, c] = numer / denom2
2486
+ return out
2487
+
2488
+ @njit(parallel=True, fastmath=True)
2489
+ def numba_mono_final_formula(rescaled, median_rescaled, target_median):
2490
+ """
2491
+ Applies the final formula *after* we already have the rescaled values.
2492
+
2493
+ rescaled[y,x] = (original[y,x] - black_point) / (1 - black_point)
2494
+ median_rescaled = median(rescaled)
2495
+
2496
+ out_val = ((median_rescaled - 1) * target_median * r) /
2497
+ ( median_rescaled*(target_median + r -1) - target_median*r )
2498
+ """
2499
+ H, W = rescaled.shape
2500
+ out = np.empty_like(rescaled)
2501
+
2502
+ for y in prange(H):
2503
+ for x in range(W):
2504
+ r = rescaled[y, x]
2505
+ numer = (median_rescaled - 1.0) * target_median * r
2506
+ denom = median_rescaled * (target_median + r - 1.0) - target_median * r
2507
+ if np.abs(denom) < 1e-12:
2508
+ denom = 1e-12
2509
+ out[y, x] = numer / denom
2510
+
2511
+ return out
2512
+
2513
+ @njit(parallel=True, fastmath=True)
2499
2514
  def numba_color_final_formula_linked(rescaled, median_rescaled, target_median):
2500
2515
  """
2501
2516
  Linked color transform: we use one median_rescaled for all channels.
@@ -2517,7 +2532,7 @@ def numba_color_final_formula_linked(rescaled, median_rescaled, target_median):
2517
2532
 
2518
2533
  return out
2519
2534
 
2520
- @njit(parallel=True, fastmath=True, cache=True)
2535
+ @njit(parallel=True, fastmath=True)
2521
2536
  def numba_color_final_formula_unlinked(rescaled, medians_rescaled, target_median):
2522
2537
  """
2523
2538
  Unlinked color transform: a separate median_rescaled per channel.
@@ -202,7 +202,7 @@ register(CommandSpec(
202
202
  "optional targets, inherit_target."
203
203
  ),
204
204
  call_style="ctx.run_command",
205
- import_path="pro.function_bundle", # <── important
205
+ import_path="setiastro.saspro.function_bundle", # <── important
206
206
  callable_name="run_function_bundle_command",# <── important
207
207
  notes=(
208
208
  "Use this command from scripts to run a saved Function Bundle or an "
@@ -274,7 +274,7 @@ register(CommandSpec(
274
274
  group="Bundles",
275
275
  summary="Internal bundle runner. steps=[...], targets='all_open'|[doc_ptrs], stop_on_error.",
276
276
  call_style="ctx.run_command",
277
- import_path="pro.function_bundle",
277
+ import_path="setiastro.saspro.function_bundle",
278
278
  callable_name="run_function_bundle_command",
279
279
  ))
280
280
 
@@ -388,7 +388,7 @@ register(CommandSpec(
388
388
  id="ghs",
389
389
  name="Generalized Hyperbolic Stretch",
390
390
  group="Stretch",
391
- import_path="pro.ghs_preset",
391
+ import_path="setiastro.saspro.ghs_preset",
392
392
  callable_name="apply_ghs_via_preset",
393
393
  ui_method="open_ghs_with_preset",
394
394
  summary=(
@@ -499,7 +499,7 @@ register(CommandSpec(
499
499
  id="curves",
500
500
  title="Curves",
501
501
  group="Stretch",
502
- import_path="pro.curves_preset",
502
+ import_path="setiastro.saspro.curves_preset",
503
503
  callable_name="apply_curves_via_preset",
504
504
  ui_method="open_curves_with_preset",
505
505
  summary=(
@@ -597,7 +597,7 @@ register(CommandSpec(
597
597
  id="abe",
598
598
  title="Automatic Background Extraction",
599
599
  group="Background",
600
- import_path="pro.abe_preset",
600
+ import_path="setiastro.saspro.abe_preset",
601
601
  callable_name="apply_abe_via_preset",
602
602
  ui_method="open_abe_with_preset", # ✅ matches your pro/abe_preset.py
603
603
  summary=(
@@ -683,7 +683,7 @@ register(CommandSpec(
683
683
  id="graxpert",
684
684
  title="GraXpert Gradient / Denoise",
685
685
  group="Background",
686
- import_path="pro.graxpert_preset",
686
+ import_path="setiastro.saspro.graxpert_preset",
687
687
  callable_name="run_graxpert_via_preset",
688
688
  # no ui_method here unless you want to open your optional preset dialog from drops
689
689
  # ui_method="open_graxpert_with_preset", # (only if/when you add one)
@@ -807,7 +807,7 @@ register(CommandSpec(
807
807
  id="background_neutral",
808
808
  name="Background Neutralization",
809
809
  group="Background",
810
- import_path="pro.backgroundneutral",
810
+ import_path="setiastro.saspro.backgroundneutral",
811
811
  callable_name="run_background_neutral_via_preset",
812
812
  summary=(
813
813
  "Neutralizes RGB background either automatically or using a user-specified "
@@ -865,7 +865,7 @@ register(CommandSpec(
865
865
  id="remove_green",
866
866
  name="Remove Green (SCNR)",
867
867
  group="Color",
868
- import_path="pro.remove_green",
868
+ import_path="setiastro.saspro.remove_green",
869
869
  callable_name="apply_remove_green_preset_to_doc",
870
870
  ui_method="open_remove_green_dialog",
871
871
  summary=(
@@ -1044,7 +1044,7 @@ register(CommandSpec(
1044
1044
  id="recombine_luminance",
1045
1045
  name="Recombine Luminance",
1046
1046
  group="Luminance",
1047
- import_path="pro.luminancerecombine",
1047
+ import_path="setiastro.saspro.luminancerecombine",
1048
1048
  callable_name="run_recombine_luminance_via_preset",
1049
1049
  ui_method="_recombine_luminance_ui",
1050
1050
  notes=(
@@ -1144,7 +1144,7 @@ register(CommandSpec(
1144
1144
  id="wavescale_hdr",
1145
1145
  name="WaveScale HDR",
1146
1146
  group="Contrast",
1147
- import_path="pro.wavescale_hdr_preset",
1147
+ import_path="setiastro.saspro.wavescale_hdr_preset",
1148
1148
  callable_name="run_wavescale_hdr_via_preset",
1149
1149
  ui_method="_open_wavescale_hdr", # or whatever your main window uses
1150
1150
  summary=(
@@ -1188,7 +1188,7 @@ register(CommandSpec(
1188
1188
  id="wavescale_dark_enhance",
1189
1189
  name="WaveScale Dark Enhance",
1190
1190
  group="Contrast",
1191
- import_path="pro.wavescalede_preset",
1191
+ import_path="setiastro.saspro.wavescalede_preset",
1192
1192
  callable_name="run_wavescalede_via_preset",
1193
1193
  ui_method="_open_wavescale_dark_enhance", # adjust if your main window uses a different name
1194
1194
  summary=(
@@ -1295,7 +1295,7 @@ register(CommandSpec(
1295
1295
  id="aberration_ai",
1296
1296
  title="Aberration AI",
1297
1297
  group="Optics",
1298
- import_path="pro.aberration_ai_preset",
1298
+ import_path="setiastro.saspro.aberration_ai_preset",
1299
1299
  callable_name="run_aberration_ai_via_preset",
1300
1300
  # ui_method="open_aberration_ai_dialog", # if you have one; otherwise omit
1301
1301
  presets=[
@@ -1338,7 +1338,7 @@ register(CommandSpec(
1338
1338
  id="convo",
1339
1339
  title="Convolution / Deconvolution",
1340
1340
  group="Blur & Sharpen",
1341
- import_path="pro.convo_preset",
1341
+ import_path="setiastro.saspro.convo_preset",
1342
1342
  callable_name="run_convo_via_preset",
1343
1343
  aliases=[
1344
1344
  "convolution",
@@ -1433,7 +1433,7 @@ register(CommandSpec(
1433
1433
  id="cosmic_clarity",
1434
1434
  title="Cosmic Clarity",
1435
1435
  group="AI",
1436
- import_path="pro.cosmicclarity_preset",
1436
+ import_path="setiastro.saspro.cosmicclarity_preset",
1437
1437
  callable_name="run_cosmicclarity_via_preset",
1438
1438
  presets=[
1439
1439
  PresetSpec("mode", "enum", default="sharpen",
@@ -1484,7 +1484,7 @@ register(CommandSpec(
1484
1484
  id="debayer",
1485
1485
  title="Debayer",
1486
1486
  group="Color / CFA",
1487
- import_path="pro.debayer",
1487
+ import_path="setiastro.saspro.debayer",
1488
1488
  callable_name="run_debayer_via_preset",
1489
1489
  presets=[
1490
1490
  PresetSpec(
@@ -1506,7 +1506,7 @@ register(CommandSpec(
1506
1506
  id="linear_fit",
1507
1507
  title="Linear Fit",
1508
1508
  group="Calibration",
1509
- import_path="pro.linear_fit",
1509
+ import_path="setiastro.saspro.linear_fit",
1510
1510
  callable_name="run_linear_fit_via_preset",
1511
1511
  presets=[
1512
1512
  PresetSpec(
@@ -1527,7 +1527,7 @@ register(CommandSpec(
1527
1527
  id="morphology",
1528
1528
  title="Morphology",
1529
1529
  group="Masks & Morphology",
1530
- import_path="pro.morphology",
1530
+ import_path="setiastro.saspro.morphology",
1531
1531
  callable_name="apply_morphology_to_doc",
1532
1532
  presets=[
1533
1533
  PresetSpec(
@@ -1556,7 +1556,7 @@ register(CommandSpec(
1556
1556
  id="remove_stars",
1557
1557
  title="Remove Stars",
1558
1558
  group="Star Tools",
1559
- import_path="pro.remove_stars_preset",
1559
+ import_path="setiastro.saspro.remove_stars_preset",
1560
1560
  callable_name="run_remove_stars_via_preset",
1561
1561
  replay_apply_name="apply_remove_stars_to_doc",
1562
1562
  presets=[
@@ -15,6 +15,11 @@ from PyQt6.QtWidgets import (
15
15
  QLineEdit, QToolButton, QCheckBox, QTextEdit
16
16
  )
17
17
 
18
+ from typing import TYPE_CHECKING
19
+
20
+ if TYPE_CHECKING:
21
+ from setiastro.saspro.ops.scripts import ScriptEntry
22
+
18
23
  # -----------------------------------------------------------------------------
19
24
  # Code editor with line numbers (QPlainTextEdit subclass)
20
25
  # -----------------------------------------------------------------------------
@@ -1058,7 +1063,8 @@ class ScriptEditorDock(QDockWidget):
1058
1063
  if man is None:
1059
1064
  raise RuntimeError("ScriptManager not initialized on main window.")
1060
1065
 
1061
- entry = man._load_one_script(self._current_path)
1066
+ entry = man.load_script_from_path(self._current_path)
1067
+
1062
1068
  if entry is None or entry.run is None:
1063
1069
  raise RuntimeError("Script has no run(ctx).")
1064
1070
 
@@ -1074,7 +1080,9 @@ class ScriptEditorDock(QDockWidget):
1074
1080
  self.output.appendPlainText(tb)
1075
1081
  self._log("Script ERROR:\n" + tb)
1076
1082
 
1077
-
1083
+ def load_script_from_path(self, path: Path) -> ScriptEntry | None:
1084
+ scripts_root = get_scripts_dir()
1085
+ return self._load_one_script(path, scripts_root)
1078
1086
 
1079
1087
  # ------------------------------------------------------------------
1080
1088
  # ui helpers
@@ -294,6 +294,125 @@ class ScriptContext:
294
294
  # ✅ Normal run: let DocManager decide (ROI preview vs full)
295
295
  dm.update_active_document(img, metadata={}, step_name=step_name)
296
296
 
297
+ def _find_subwindow_for_doc(self, base_doc):
298
+ """Return (sw, widget) for the first subwindow showing base_doc."""
299
+ for sw, w in self._iter_open_subwindows():
300
+ d = self._base_doc_for_widget(w)
301
+ if d is base_doc:
302
+ return sw, w
303
+ return None, None
304
+
305
+ def rename_active_view(self, new_title: str) -> bool:
306
+ """Rename only the active MDI view title (this window)."""
307
+ w = self.active_view()
308
+ if w is None:
309
+ return False
310
+
311
+ t = (new_title or "").strip()
312
+ if not t:
313
+ return False
314
+
315
+ try:
316
+ # ImageSubWindow convention
317
+ setattr(w, "_view_title_override", t)
318
+ if hasattr(w, "_sync_host_title"):
319
+ w._sync_host_title()
320
+ return True
321
+ except Exception:
322
+ return False
323
+
324
+ def rename_active_document(self, new_name: str) -> bool:
325
+ """Rename the underlying document display name (affects explorer + other views)."""
326
+ doc = self.active_document()
327
+ if doc is None:
328
+ return False
329
+
330
+ n = (new_name or "").strip()
331
+ if not n:
332
+ return False
333
+
334
+ try:
335
+ old = ""
336
+ try:
337
+ old = str(doc.display_name() or "")
338
+ except Exception:
339
+ old = str(getattr(doc, "metadata", {}).get("display_name", "") or "")
340
+
341
+ doc.metadata["display_name"] = n
342
+ try:
343
+ doc.changed.emit()
344
+ except Exception:
345
+ pass
346
+
347
+ # If the active view had an override equal to the old doc name, drop it (matches your UI behavior)
348
+ w = self.active_view()
349
+ if w is not None:
350
+ try:
351
+ if getattr(w, "_view_title_override", None) == old:
352
+ setattr(w, "_view_title_override", None)
353
+ except Exception:
354
+ pass
355
+ try:
356
+ if hasattr(w, "_sync_host_title"):
357
+ w._sync_host_title()
358
+ except Exception:
359
+ pass
360
+
361
+ return True
362
+ except Exception:
363
+ return False
364
+
365
+ def rename_view(self, view_name_or_uid: str, new_title: str) -> bool:
366
+ """Rename a specific *view/window* by name/title/uid (first match)."""
367
+ doc = self.get_document(view_name_or_uid)
368
+ if doc is None:
369
+ return False
370
+
371
+ sw, w = self._find_subwindow_for_doc(doc)
372
+ if w is None:
373
+ return False
374
+
375
+ t = (new_title or "").strip()
376
+ if not t:
377
+ return False
378
+
379
+ try:
380
+ setattr(w, "_view_title_override", t)
381
+ if hasattr(w, "_sync_host_title"):
382
+ w._sync_host_title()
383
+ return True
384
+ except Exception:
385
+ return False
386
+
387
+ def rename_document(self, view_name_or_uid: str, new_name: str) -> bool:
388
+ """Rename a specific *document* by view name/title/uid."""
389
+ doc = self.get_document(view_name_or_uid)
390
+ if doc is None:
391
+ return False
392
+ n = (new_name or "").strip()
393
+ if not n:
394
+ return False
395
+
396
+ try:
397
+ doc.metadata["display_name"] = n
398
+ try:
399
+ doc.changed.emit()
400
+ except Exception:
401
+ pass
402
+
403
+ # resync any window currently showing it
404
+ sw, w = self._find_subwindow_for_doc(doc)
405
+ if w is not None and hasattr(w, "_sync_host_title"):
406
+ try:
407
+ w._sync_host_title()
408
+ except Exception:
409
+ pass
410
+
411
+ return True
412
+ except Exception:
413
+ return False
414
+
415
+
297
416
  # ---- convenience wrappers into main window ----
298
417
  def run_command(self, command_id: str, preset=None, **kwargs):
299
418
  return _run_command(self, command_id, preset, **kwargs)
@@ -678,6 +797,9 @@ class ScriptManager(QObject):
678
797
 
679
798
  self._log(f"[Scripts] Loaded {len(self.registry)} script(s) from {scripts_dir}")
680
799
 
800
+ def load_script_from_path(self, path: Path) -> ScriptEntry | None:
801
+ scripts_root = get_scripts_dir()
802
+ return self._load_one_script(path, scripts_root)
681
803
 
682
804
  def _load_one_script(self, path: Path, scripts_root: Path) -> ScriptEntry | None:
683
805
  """
@@ -227,6 +227,7 @@ class PerfectPalettePicker(QWidget):
227
227
  self.sii = None
228
228
  self.osc1 = None
229
229
  self.osc2 = None
230
+ self._dim_mismatch_accepted = False
230
231
 
231
232
  # stretched cache (per input name → stretched array)
232
233
  self._stretched: dict[str, np.ndarray] = {}
@@ -675,10 +676,42 @@ class PerfectPalettePicker(QWidget):
675
676
  oo = g2b2 if oo is None else 0.5*oo + 0.5*g2b2
676
677
 
677
678
  # shapes must match for full-size
678
- shapes = [x.shape for x in (ha, oo, si) if x is not None]
679
+ # shapes must match for full-size
680
+ shapes = [x.shape[:2] for x in (ha, oo, si) if x is not None]
679
681
  if len(shapes) and len(set(shapes)) > 1 and not for_thumbs:
680
- QMessageBox.critical(self, "Size Mismatch", f"Channel sizes differ: {set(shapes)}")
681
- return None, None, None
682
+ # pick a reference size (prefer Ha, then OIII, then SII)
683
+ ref = ha if ha is not None else (oo if oo is not None else si)
684
+ ref_name = "Ha" if ha is not None else ("OIII" if oo is not None else "SII")
685
+ ref_h, ref_w = ref.shape[:2]
686
+
687
+ # Only prompt once per session unless you want every time
688
+ if not self._dim_mismatch_accepted:
689
+ msg = (
690
+ "The loaded channels have different image dimensions.\n\n"
691
+ f"• Ha: {None if ha is None else ha.shape}\n"
692
+ f"• OIII: {None if oo is None else oo.shape}\n"
693
+ f"• SII: {None if si is None else si.shape}\n\n"
694
+ f"SASpro can resize (warp) the channels to match the reference frame:\n"
695
+ f"• Reference: {ref_name}\n"
696
+ f"• Target size: ({ref_w} × {ref_h})\n\n"
697
+ "Proceed and resize mismatched channels?"
698
+ )
699
+ ret = QMessageBox.question(
700
+ self,
701
+ "Channel Size Mismatch",
702
+ msg,
703
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
704
+ QMessageBox.StandardButton.Yes
705
+ )
706
+ if ret != QMessageBox.StandardButton.Yes:
707
+ return None, None, None
708
+
709
+ self._dim_mismatch_accepted = True
710
+
711
+ # resize to reference
712
+ ha = self._resize_to(ha, (ref_w, ref_h)) if ha is not None else None
713
+ oo = self._resize_to(oo, (ref_w, ref_h)) if oo is not None else None
714
+ si = self._resize_to(si, (ref_w, ref_h)) if si is not None else None
682
715
 
683
716
  # thumbnails: crop AFTER stretch/synth
684
717
  if for_thumbs:
@@ -953,6 +986,7 @@ class PerfectPalettePicker(QWidget):
953
986
  def _clear_channels(self):
954
987
  self.ha = self.oiii = self.sii = self.osc1 = self.osc2 = None
955
988
  self._stretched.clear()
989
+ self._dim_mismatch_accepted = False
956
990
  self.final = None
957
991
  self.preview.clear()
958
992
  for which in ("Ha","OIII","SII","OSC1","OSC2"):