setiastrosuitepro 1.6.1__py3-none-any.whl → 1.6.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. setiastro/images/Background_startup.jpg +0 -0
  2. setiastro/qml/ResourceMonitor.qml +126 -0
  3. setiastro/saspro/__main__.py +159 -23
  4. setiastro/saspro/_generated/build_info.py +2 -1
  5. setiastro/saspro/abe.py +62 -11
  6. setiastro/saspro/aberration_ai.py +3 -3
  7. setiastro/saspro/add_stars.py +5 -2
  8. setiastro/saspro/astrobin_exporter.py +3 -0
  9. setiastro/saspro/astrospike_python.py +3 -1
  10. setiastro/saspro/autostretch.py +4 -2
  11. setiastro/saspro/backgroundneutral.py +52 -10
  12. setiastro/saspro/batch_convert.py +3 -0
  13. setiastro/saspro/batch_renamer.py +3 -0
  14. setiastro/saspro/blemish_blaster.py +3 -0
  15. setiastro/saspro/cheat_sheet.py +50 -15
  16. setiastro/saspro/clahe.py +27 -1
  17. setiastro/saspro/comet_stacking.py +103 -38
  18. setiastro/saspro/convo.py +3 -0
  19. setiastro/saspro/copyastro.py +3 -0
  20. setiastro/saspro/cosmicclarity.py +70 -45
  21. setiastro/saspro/crop_dialog_pro.py +17 -0
  22. setiastro/saspro/curve_editor_pro.py +18 -0
  23. setiastro/saspro/debayer.py +3 -0
  24. setiastro/saspro/doc_manager.py +39 -16
  25. setiastro/saspro/fitsmodifier.py +3 -0
  26. setiastro/saspro/frequency_separation.py +8 -2
  27. setiastro/saspro/function_bundle.py +2 -0
  28. setiastro/saspro/generate_translations.py +715 -1
  29. setiastro/saspro/ghs_dialog_pro.py +3 -0
  30. setiastro/saspro/graxpert.py +3 -0
  31. setiastro/saspro/gui/main_window.py +275 -32
  32. setiastro/saspro/gui/mixins/dock_mixin.py +100 -1
  33. setiastro/saspro/gui/mixins/file_mixin.py +7 -0
  34. setiastro/saspro/gui/mixins/menu_mixin.py +28 -0
  35. setiastro/saspro/gui/statistics_dialog.py +47 -0
  36. setiastro/saspro/halobgon.py +29 -3
  37. setiastro/saspro/histogram.py +3 -0
  38. setiastro/saspro/history_explorer.py +2 -0
  39. setiastro/saspro/i18n.py +22 -10
  40. setiastro/saspro/image_combine.py +3 -0
  41. setiastro/saspro/image_peeker_pro.py +3 -0
  42. setiastro/saspro/imageops/stretch.py +5 -13
  43. setiastro/saspro/isophote.py +3 -0
  44. setiastro/saspro/legacy/numba_utils.py +64 -47
  45. setiastro/saspro/linear_fit.py +3 -0
  46. setiastro/saspro/live_stacking.py +13 -2
  47. setiastro/saspro/mask_creation.py +3 -0
  48. setiastro/saspro/mfdeconv.py +5 -0
  49. setiastro/saspro/morphology.py +30 -5
  50. setiastro/saspro/multiscale_decomp.py +3 -0
  51. setiastro/saspro/nbtorgb_stars.py +12 -2
  52. setiastro/saspro/numba_utils.py +148 -47
  53. setiastro/saspro/ops/scripts.py +77 -17
  54. setiastro/saspro/ops/settings.py +1 -43
  55. setiastro/saspro/perfect_palette_picker.py +1 -0
  56. setiastro/saspro/pixelmath.py +6 -2
  57. setiastro/saspro/plate_solver.py +2 -1
  58. setiastro/saspro/remove_green.py +18 -1
  59. setiastro/saspro/remove_stars.py +136 -162
  60. setiastro/saspro/resources.py +7 -0
  61. setiastro/saspro/rgb_combination.py +1 -0
  62. setiastro/saspro/rgbalign.py +4 -4
  63. setiastro/saspro/save_options.py +1 -0
  64. setiastro/saspro/sfcc.py +50 -8
  65. setiastro/saspro/signature_insert.py +3 -0
  66. setiastro/saspro/stacking_suite.py +630 -341
  67. setiastro/saspro/star_alignment.py +16 -1
  68. setiastro/saspro/star_spikes.py +116 -32
  69. setiastro/saspro/star_stretch.py +38 -1
  70. setiastro/saspro/stat_stretch.py +35 -3
  71. setiastro/saspro/subwindow.py +63 -2
  72. setiastro/saspro/supernovaasteroidhunter.py +3 -0
  73. setiastro/saspro/translations/all_source_strings.json +3654 -0
  74. setiastro/saspro/translations/ar_translations.py +3865 -0
  75. setiastro/saspro/translations/de_translations.py +16 -0
  76. setiastro/saspro/translations/es_translations.py +16 -0
  77. setiastro/saspro/translations/fr_translations.py +16 -0
  78. setiastro/saspro/translations/hi_translations.py +3571 -0
  79. setiastro/saspro/translations/integrate_translations.py +36 -0
  80. setiastro/saspro/translations/it_translations.py +16 -0
  81. setiastro/saspro/translations/ja_translations.py +16 -0
  82. setiastro/saspro/translations/pt_translations.py +16 -0
  83. setiastro/saspro/translations/ru_translations.py +2848 -0
  84. setiastro/saspro/translations/saspro_ar.qm +0 -0
  85. setiastro/saspro/translations/saspro_ar.ts +255 -0
  86. setiastro/saspro/translations/saspro_de.qm +0 -0
  87. setiastro/saspro/translations/saspro_de.ts +3 -3
  88. setiastro/saspro/translations/saspro_es.qm +0 -0
  89. setiastro/saspro/translations/saspro_es.ts +3 -3
  90. setiastro/saspro/translations/saspro_fr.qm +0 -0
  91. setiastro/saspro/translations/saspro_fr.ts +3 -3
  92. setiastro/saspro/translations/saspro_hi.qm +0 -0
  93. setiastro/saspro/translations/saspro_hi.ts +257 -0
  94. setiastro/saspro/translations/saspro_it.qm +0 -0
  95. setiastro/saspro/translations/saspro_it.ts +3 -3
  96. setiastro/saspro/translations/saspro_ja.qm +0 -0
  97. setiastro/saspro/translations/saspro_ja.ts +4 -4
  98. setiastro/saspro/translations/saspro_pt.qm +0 -0
  99. setiastro/saspro/translations/saspro_pt.ts +3 -3
  100. setiastro/saspro/translations/saspro_ru.qm +0 -0
  101. setiastro/saspro/translations/saspro_ru.ts +237 -0
  102. setiastro/saspro/translations/saspro_sw.qm +0 -0
  103. setiastro/saspro/translations/saspro_sw.ts +257 -0
  104. setiastro/saspro/translations/saspro_uk.qm +0 -0
  105. setiastro/saspro/translations/saspro_uk.ts +10771 -0
  106. setiastro/saspro/translations/saspro_zh.qm +0 -0
  107. setiastro/saspro/translations/saspro_zh.ts +3 -3
  108. setiastro/saspro/translations/sw_translations.py +3671 -0
  109. setiastro/saspro/translations/uk_translations.py +3700 -0
  110. setiastro/saspro/translations/zh_translations.py +16 -0
  111. setiastro/saspro/versioning.py +12 -6
  112. setiastro/saspro/view_bundle.py +3 -0
  113. setiastro/saspro/wavescale_hdr.py +22 -1
  114. setiastro/saspro/wavescalede.py +23 -1
  115. setiastro/saspro/whitebalance.py +39 -3
  116. setiastro/saspro/widgets/minigame/game.js +986 -0
  117. setiastro/saspro/widgets/minigame/index.html +53 -0
  118. setiastro/saspro/widgets/minigame/style.css +241 -0
  119. setiastro/saspro/widgets/resource_monitor.py +237 -0
  120. setiastro/saspro/widgets/wavelet_utils.py +52 -20
  121. setiastro/saspro/wimi.py +7996 -0
  122. setiastro/saspro/wims.py +578 -0
  123. {setiastrosuitepro-1.6.1.dist-info → setiastrosuitepro-1.6.2.dist-info}/METADATA +15 -4
  124. {setiastrosuitepro-1.6.1.dist-info → setiastrosuitepro-1.6.2.dist-info}/RECORD +128 -103
  125. {setiastrosuitepro-1.6.1.dist-info → setiastrosuitepro-1.6.2.dist-info}/WHEEL +0 -0
  126. {setiastrosuitepro-1.6.1.dist-info → setiastrosuitepro-1.6.2.dist-info}/entry_points.txt +0 -0
  127. {setiastrosuitepro-1.6.1.dist-info → setiastrosuitepro-1.6.2.dist-info}/licenses/LICENSE +0 -0
  128. {setiastrosuitepro-1.6.1.dist-info → setiastrosuitepro-1.6.2.dist-info}/licenses/license.txt +0 -0
@@ -249,6 +249,9 @@ class HaloBGonDialogPro(QDialog):
249
249
  def __init__(self, parent, doc, icon: Optional[QIcon] = None):
250
250
  super().__init__(parent)
251
251
  self.setWindowTitle("Halo-B-Gon")
252
+ self.setWindowFlag(Qt.WindowType.Window, True)
253
+ self.setWindowModality(Qt.WindowModality.NonModal)
254
+ self.setModal(False)
252
255
  if icon:
253
256
  try: self.setWindowIcon(icon)
254
257
  except Exception as e:
@@ -427,7 +430,8 @@ class HaloBGonDialogPro(QDialog):
427
430
  except Exception:
428
431
  pass
429
432
 
430
- self.accept()
433
+ # Dialog stays open - refresh document for next operation
434
+ self._refresh_document_from_active()
431
435
  return
432
436
  else:
433
437
  # Fallback: try legacy spawner if present; else warn and overwrite.
@@ -437,7 +441,8 @@ class HaloBGonDialogPro(QDialog):
437
441
  if callable(spawner):
438
442
  title = self.doc.display_name() if hasattr(self.doc, "display_name") else "Image"
439
443
  spawner(out, f"{title} [Halo-B-Gon]")
440
- self.accept()
444
+ # Dialog stays open - refresh document for next operation
445
+ self._refresh_document_from_active()
441
446
  return
442
447
  else:
443
448
  QMessageBox.warning(
@@ -448,11 +453,32 @@ class HaloBGonDialogPro(QDialog):
448
453
 
449
454
  # Overwrite current (original behavior)
450
455
  self._apply_overwrite(out)
451
- self.accept()
456
+ # Dialog stays open - refresh document for next operation
457
+ self._refresh_document_from_active()
452
458
 
453
459
  except Exception as e:
454
460
  QMessageBox.critical(self, "Halo-B-Gon", f"Failed to apply:\n{e}")
455
461
 
462
+ def _refresh_document_from_active(self):
463
+ """
464
+ Refresh the dialog's document reference to the currently active document.
465
+ This allows reusing the same dialog on different images.
466
+ """
467
+ try:
468
+ main = self.parent()
469
+ if main and hasattr(main, "_active_doc"):
470
+ new_doc = main._active_doc()
471
+ if new_doc is not None and new_doc is not self.doc:
472
+ self.doc = new_doc
473
+ # Refresh preview for new document
474
+ self.orig = np.clip(np.asarray(new_doc.image, dtype=np.float32), 0.0, 1.0)
475
+ disp = self.orig
476
+ if disp.ndim == 2: disp = disp[..., None].repeat(3, axis=2)
477
+ elif disp.ndim == 3 and disp.shape[2] == 1: disp = disp.repeat(3, axis=2)
478
+ self._disp_base = disp
479
+ self._update_preview()
480
+ except Exception:
481
+ pass
456
482
 
457
483
 
458
484
  def _reset(self):
@@ -30,6 +30,9 @@ class HistogramDialog(QDialog):
30
30
  def __init__(self, parent, document):
31
31
  super().__init__(parent)
32
32
  self.setWindowTitle(self.tr("Histogram"))
33
+ self.setWindowFlag(Qt.WindowType.Window, True)
34
+ self.setWindowModality(Qt.WindowModality.NonModal)
35
+ self.setModal(False)
33
36
  self.doc = document
34
37
  self.image = _to_float_preserve(document.image)
35
38
 
@@ -436,6 +436,8 @@ class HistoryExplorerDialog(QDialog):
436
436
  def __init__(self, document, parent=None):
437
437
  super().__init__(parent)
438
438
  self.setWindowTitle("History Explorer")
439
+ self.setWindowFlag(Qt.WindowType.Window, True)
440
+ self.setWindowModality(Qt.WindowModality.NonModal)
439
441
  self.setModal(False)
440
442
  self.doc = document
441
443
 
setiastro/saspro/i18n.py CHANGED
@@ -25,22 +25,34 @@ AVAILABLE_LANGUAGES: Dict[str, str] = {
25
25
  "de": "Deutsch",
26
26
  "pt": "Português",
27
27
  "ja": "日本語",
28
+ "hi": "हिन्दी",
29
+ "sw": "Kiswahili",
30
+ "uk": "Українська",
31
+ "ru": "Русский",
32
+ "ar": "العربية",
28
33
  }
29
34
 
30
35
 
31
36
  def get_translations_dir() -> str:
32
37
  """Get the path to the translations directory."""
33
- # When running from source or installed package
38
+ # Source / installed package location
34
39
  module_dir = os.path.dirname(os.path.abspath(__file__))
35
- translations_dir = os.path.join(module_dir, "translations")
36
-
37
- # Fallback for PyInstaller frozen builds
38
- if hasattr(os.sys, '_MEIPASS'):
39
- frozen_dir = os.path.join(os.sys._MEIPASS, "translations")
40
- if os.path.exists(frozen_dir):
41
- return frozen_dir
42
-
43
- return translations_dir
40
+ pkg_dir = os.path.join(module_dir, "translations")
41
+
42
+ # PyInstaller frozen builds
43
+ if hasattr(os.sys, "_MEIPASS"):
44
+ # New bundle layout (preferred)
45
+ frozen_internal = os.path.join(os.sys._MEIPASS, "_internal", "translations")
46
+ if os.path.exists(frozen_internal):
47
+ return frozen_internal
48
+
49
+ # Legacy bundle layout fallback
50
+ frozen_legacy = os.path.join(os.sys._MEIPASS, "translations")
51
+ if os.path.exists(frozen_legacy):
52
+ return frozen_legacy
53
+
54
+ return pkg_dir
55
+
44
56
 
45
57
 
46
58
  def get_available_languages() -> Dict[str, str]:
@@ -91,6 +91,9 @@ class ImageCombineDialog(QDialog):
91
91
  def __init__(self, main_window):
92
92
  super().__init__(main_window)
93
93
  self.setWindowTitle("Image Combine")
94
+ self.setWindowFlag(Qt.WindowType.Window, True)
95
+ self.setWindowModality(Qt.WindowModality.NonModal)
96
+ self.setModal(False)
94
97
  self.mw = main_window
95
98
  self.dm = getattr(main_window, "doc_manager", None) or getattr(main_window, "dm", None)
96
99
  self.zoom = 1.0
@@ -1314,6 +1314,9 @@ class ImagePeekerDialogPro(QDialog):
1314
1314
  def __init__(self, parent, document, settings):
1315
1315
  super().__init__(parent)
1316
1316
  self.setWindowTitle(self.tr("Image Peeker"))
1317
+ self.setWindowFlag(Qt.WindowType.Window, True)
1318
+ self.setWindowModality(Qt.WindowModality.NonModal)
1319
+ self.setModal(False)
1317
1320
  self.document = self._coerce_doc(document) # <- ensure we hold a real doc
1318
1321
  self.settings = settings
1319
1322
  # status / progress line
@@ -95,19 +95,11 @@ def apply_curves_adjustment(image: np.ndarray,
95
95
  xvals, yvals = _calculate_curve_points(target_median, curves_boost)
96
96
 
97
97
  # Apply the 1D LUT per channel using np.interp (piecewise linear)
98
- if img.ndim == 2:
99
- flat = img.ravel()
100
- out = np.interp(flat, xvals, yvals).reshape(img.shape).astype(np.float32)
101
- elif img.ndim == 3 and img.shape[2] in (3, 4):
102
- h, w, c = img.shape
103
- out = np.empty_like(img, dtype=np.float32)
104
- # Apply same curve to each color channel
105
- for ch in range(c):
106
- flat = img[..., ch].ravel()
107
- out[..., ch] = np.interp(flat, xvals, yvals).reshape(h, w)
108
- else:
109
- # Fallback: just return clamped image
110
- out = img
98
+ # Apply the 1D LUT per channel using np.interp (piecewise linear)
99
+ # Optimization: np.interp can handle N-D 'x' array directly.
100
+ # No need to loop over channels or flatten/reshape if we pass the whole array.
101
+
102
+ out = np.interp(img, xvals, yvals).astype(np.float32, copy=False)
111
103
 
112
104
  return np.clip(out, 0.0, 1.0)
113
105
 
@@ -360,6 +360,9 @@ class IsophoteModelerDialog(QDialog):
360
360
  def __init__(self, mono_image: np.ndarray, parent: Optional[QWidget] = None,
361
361
  title_hint: Optional[str] = None, image_manager=None, doc_manager=None):
362
362
  super().__init__(parent)
363
+ self.setWindowFlag(Qt.WindowType.Window, True)
364
+ self.setWindowModality(Qt.WindowModality.NonModal)
365
+ self.setModal(False)
363
366
  self.image_manager = image_manager
364
367
  self.doc_manager = doc_manager
365
368
 
@@ -802,39 +802,46 @@ def kappa_sigma_clip_weighted_3d(stack, weights, kappa=2.5, iterations=3):
802
802
  pixel_weights = weights[:]
803
803
  else:
804
804
  pixel_weights = weights[:, i, j].copy()
805
- # Initialize tracking of indices
806
- current_idx = np.empty(num_frames, dtype=np.int64)
807
- for f in range(num_frames):
808
- current_idx[f] = f
809
- current_vals = pixel_values
810
- current_w = pixel_weights
811
- current_indices = current_idx
805
+
806
+ valid_mask = pixel_values != 0
807
+
812
808
  med = 0.0
813
809
  for _ in range(iterations):
814
- if current_vals.size == 0:
810
+ count = 0
811
+ for k in range(num_frames):
812
+ if valid_mask[k]:
813
+ count += 1
814
+
815
+ if count == 0:
815
816
  break
817
+
818
+ current_vals = pixel_values[valid_mask]
819
+
816
820
  med = np.median(current_vals)
817
821
  std = np.std(current_vals)
818
822
  lower_bound = med - kappa * std
819
823
  upper_bound = med + kappa * std
820
- valid = (current_vals != 0) & (current_vals >= lower_bound) & (current_vals <= upper_bound)
821
- current_vals = current_vals[valid]
822
- current_w = current_w[valid]
823
- current_indices = current_indices[valid]
824
- # Mark rejected: frames not in current_indices are rejected.
824
+
825
+ for k in range(num_frames):
826
+ if valid_mask[k]:
827
+ val = pixel_values[k]
828
+ if val < lower_bound or val > upper_bound:
829
+ valid_mask[k] = False
830
+
825
831
  for f in range(num_frames):
826
- # Check if f is in current_indices
827
- found = False
828
- for k in range(current_indices.size):
829
- if current_indices[k] == f:
830
- found = True
831
- break
832
- if not found:
833
- rej_mask[f, i, j] = True
834
- else:
835
- rej_mask[f, i, j] = False
836
- if current_w.size > 0 and current_w.sum() > 0:
837
- clipped[i, j] = np.sum(current_vals * current_w) / current_w.sum()
832
+ rej_mask[f, i, j] = not valid_mask[f]
833
+
834
+ wsum = 0.0
835
+ vsum = 0.0
836
+ for k in range(num_frames):
837
+ if valid_mask[k]:
838
+ w = pixel_weights[k]
839
+ v = pixel_values[k]
840
+ wsum += w
841
+ vsum += v * w
842
+
843
+ if wsum > 0:
844
+ clipped[i, j] = vsum / wsum
838
845
  else:
839
846
  clipped[i, j] = med
840
847
  return clipped, rej_mask
@@ -859,36 +866,46 @@ def kappa_sigma_clip_weighted_4d(stack, weights, kappa=2.5, iterations=3):
859
866
  pixel_weights = weights[:]
860
867
  else:
861
868
  pixel_weights = weights[:, i, j, c].copy()
862
- current_idx = np.empty(num_frames, dtype=np.int64)
863
- for f in range(num_frames):
864
- current_idx[f] = f
865
- current_vals = pixel_values
866
- current_w = pixel_weights
867
- current_indices = current_idx
869
+
870
+ valid_mask = pixel_values != 0
871
+
868
872
  med = 0.0
869
873
  for _ in range(iterations):
870
- if current_vals.size == 0:
874
+ count = 0
875
+ for k in range(num_frames):
876
+ if valid_mask[k]:
877
+ count += 1
878
+
879
+ if count == 0:
871
880
  break
881
+
882
+ current_vals = pixel_values[valid_mask]
883
+
872
884
  med = np.median(current_vals)
873
885
  std = np.std(current_vals)
874
886
  lower_bound = med - kappa * std
875
887
  upper_bound = med + kappa * std
876
- valid = (current_vals != 0) & (current_vals >= lower_bound) & (current_vals <= upper_bound)
877
- current_vals = current_vals[valid]
878
- current_w = current_w[valid]
879
- current_indices = current_indices[valid]
888
+
889
+ for k in range(num_frames):
890
+ if valid_mask[k]:
891
+ val = pixel_values[k]
892
+ if val < lower_bound or val > upper_bound:
893
+ valid_mask[k] = False
894
+
880
895
  for f in range(num_frames):
881
- found = False
882
- for k in range(current_indices.size):
883
- if current_indices[k] == f:
884
- found = True
885
- break
886
- if not found:
887
- rej_mask[f, i, j, c] = True
888
- else:
889
- rej_mask[f, i, j, c] = False
890
- if current_w.size > 0 and current_w.sum() > 0:
891
- clipped[i, j, c] = np.sum(current_vals * current_w) / current_w.sum()
896
+ rej_mask[f, i, j, c] = not valid_mask[f]
897
+
898
+ wsum = 0.0
899
+ vsum = 0.0
900
+ for k in range(num_frames):
901
+ if valid_mask[k]:
902
+ w = pixel_weights[k]
903
+ v = pixel_values[k]
904
+ wsum += w
905
+ vsum += v * w
906
+
907
+ if wsum > 0:
908
+ clipped[i, j, c] = vsum / wsum
892
909
  else:
893
910
  clipped[i, j, c] = med
894
911
  return clipped, rej_mask
@@ -224,6 +224,9 @@ class LinearFitDialog(QDialog):
224
224
  def __init__(self, parent, doc_manager, active_doc):
225
225
  super().__init__(parent)
226
226
  self.setWindowTitle("Linear Fit")
227
+ self.setWindowFlag(Qt.WindowType.Window, True)
228
+ self.setWindowModality(Qt.WindowModality.NonModal)
229
+ self.setModal(False)
227
230
  self.dm = doc_manager
228
231
  self.doc = active_doc
229
232
  self.worker: Optional[_LinearFitWorker] = None
@@ -48,6 +48,9 @@ class LiveStackSettingsDialog(QDialog):
48
48
  def __init__(self, parent):
49
49
  super().__init__(parent)
50
50
  self.setWindowTitle("Live Stack & Culling Settings")
51
+ self.setWindowFlag(Qt.WindowType.Window, True)
52
+ self.setWindowModality(Qt.WindowModality.NonModal)
53
+ self.setModal(False)
51
54
 
52
55
  # — Live Stack Settings —
53
56
  # Bootstrap frames (int)
@@ -290,8 +293,16 @@ def estimate_global_snr(
290
293
 
291
294
  # 1) Collapse to simple 2D float array (grayscale)
292
295
  if stack_image.ndim == 3 and stack_image.shape[2] == 3:
293
- # RGB → grayscale by averaging channels
294
- gray = stack_image.mean(axis=2).astype(np.float32)
296
+ try:
297
+ import cv2
298
+ # cv2.cvtColor is significantly faster than mean(axis=2)
299
+ # Assuming RGB input, but even if BGR, for SNR estimation luma difference is negligible
300
+ gray = cv2.cvtColor(stack_image, cv2.COLOR_RGB2GRAY)
301
+ if gray.dtype != np.float32:
302
+ gray = gray.astype(np.float32)
303
+ except ImportError:
304
+ # Fallback
305
+ gray = stack_image.mean(axis=2).astype(np.float32)
295
306
  else:
296
307
  # Already mono: just cast to float32
297
308
  gray = stack_image.astype(np.float32)
@@ -555,6 +555,9 @@ class MaskCreationDialog(QDialog):
555
555
  def __init__(self, image01: np.ndarray, parent=None, auto_push_on_ok: bool = True):
556
556
  super().__init__(parent)
557
557
  self.setWindowTitle(self.tr("Mask Creation"))
558
+ self.setWindowFlag(Qt.WindowType.Window, True)
559
+ self.setWindowModality(Qt.WindowModality.NonModal)
560
+ self.setModal(False)
558
561
  self.image = np.asarray(image01, dtype=np.float32).copy()
559
562
  self.mask: np.ndarray | None = None
560
563
  self.live_preview = LivePreviewDialog(self.image, parent=self)
@@ -822,6 +822,11 @@ def _to_luma_local(a: np.ndarray) -> np.ndarray:
822
822
  return a
823
823
  # (H,W,3) or (3,H,W)
824
824
  if a.ndim == 3 and a.shape[-1] == 3:
825
+ try:
826
+ import cv2
827
+ return cv2.cvtColor(a, cv2.COLOR_RGB2GRAY).astype(np.float32, copy=False)
828
+ except Exception:
829
+ pass
825
830
  r, g, b = a[..., 0], a[..., 1], a[..., 2]
826
831
  return (0.2126*r + 0.7152*g + 0.0722*b).astype(np.float32, copy=False)
827
832
  if a.ndim == 3 and a.shape[0] == 3:
@@ -46,10 +46,9 @@ def apply_morphology(image: np.ndarray, *, operation: str = "erosion",
46
46
 
47
47
  if img.ndim == 3 and img.shape[2] == 3:
48
48
  u8 = (img * 255.0).astype(np.uint8)
49
- ch = cv2.split(u8)
50
- ch = [_do(c) for c in ch]
51
- out = cv2.merge(ch).astype(np.float32) / 255.0
52
- return np.clip(out, 0.0, 1.0)
49
+ # OpenCV morphology functions handle multi-channel images natively (independent channels)
50
+ out_u8 = _do(u8)
51
+ return (out_u8.astype(np.float32) / 255.0).clip(0.0, 1.0)
53
52
 
54
53
  raise ValueError("Input image must be mono (H,W)/(H,W,1) or RGB (H,W,3).")
55
54
 
@@ -92,6 +91,9 @@ class MorphologyDialogPro(QDialog):
92
91
  def __init__(self, parent, doc, icon: QIcon | None = None, initial: dict | None = None):
93
92
  super().__init__(parent)
94
93
  self.setWindowTitle(self.tr("Morphological Operations"))
94
+ self.setWindowFlag(Qt.WindowType.Window, True)
95
+ self.setWindowModality(Qt.WindowModality.NonModal)
96
+ self.setModal(False)
95
97
  if icon:
96
98
  try: self.setWindowIcon(icon)
97
99
  except Exception as e:
@@ -258,10 +260,33 @@ class MorphologyDialogPro(QDialog):
258
260
  pass
259
261
  # ────────────────────────────────────────────────────────────
260
262
 
261
- self.accept()
263
+ # Dialog stays open so user can apply to other images
264
+ # Refresh document reference for next operation
265
+ self._refresh_document_from_active()
262
266
  except Exception as e:
263
267
  QMessageBox.critical(self, "Morphology", f"Failed to apply:\n{e}")
264
268
 
269
+ def _refresh_document_from_active(self):
270
+ """
271
+ Refresh the dialog's document reference to the currently active document.
272
+ This allows reusing the same dialog on different images.
273
+ """
274
+ try:
275
+ main = self.parent()
276
+ if main and hasattr(main, "_active_doc"):
277
+ new_doc = main._active_doc()
278
+ if new_doc is not None and new_doc is not self.doc:
279
+ self.doc = new_doc
280
+ # Refresh preview for new document
281
+ self.orig = np.clip(np.asarray(new_doc.image, dtype=np.float32), 0.0, 1.0)
282
+ disp = self.orig
283
+ if disp.ndim == 2: disp = disp[..., None].repeat(3, axis=2)
284
+ elif disp.ndim == 3 and disp.shape[2] == 1: disp = disp.repeat(3, axis=2)
285
+ self._disp_base = disp
286
+ self._update_preview()
287
+ except Exception:
288
+ pass
289
+
265
290
 
266
291
 
267
292
  def _reset(self):
@@ -196,6 +196,9 @@ class MultiscaleDecompDialog(QDialog):
196
196
  def __init__(self, parent, doc):
197
197
  super().__init__(parent)
198
198
  self.setWindowTitle("Multiscale Decomposition")
199
+ self.setWindowFlag(Qt.WindowType.Window, True)
200
+ self.setWindowModality(Qt.WindowModality.NonModal)
201
+ self.setModal(False)
199
202
  self.setMinimumSize(1050, 700)
200
203
  self.residual_enabled = True
201
204
  self._layer_noise = None # list[float] per detail layer
@@ -220,7 +220,10 @@ class NBtoRGBStars(QWidget):
220
220
  if img.ndim == 3: img = img[...,0]
221
221
  setattr(self, which.lower(), self._as_float01(img))
222
222
  else: # OSC
223
- if img.ndim == 2: img = np.stack([img]*3, axis=-1)
223
+ # Optimization: Store mono OSC as-is (2D) to save memory
224
+ # The combine step will handle expansion.
225
+ if img.ndim == 3 and img.shape[2] == 1:
226
+ img = img[..., 0]
224
227
  setattr(self, which.lower(), self._as_float01(img))
225
228
 
226
229
  setattr(self, f"_file_{which.lower()}", path)
@@ -309,7 +312,14 @@ class NBtoRGBStars(QWidget):
309
312
  raise ValueError(f"Channel sizes differ: {set(shapes)}")
310
313
 
311
314
  if self.osc is not None:
312
- r = self.osc[...,0]; g = self.osc[...,1]; b = self.osc[...,2]
315
+ if self.osc.ndim == 2:
316
+ r = self.osc; g = self.osc; b = self.osc
317
+ elif self.osc.ndim == 3 and self.osc.shape[2] >= 3:
318
+ r = self.osc[...,0]; g = self.osc[...,1]; b = self.osc[...,2]
319
+ else:
320
+ # fallback for unexpected shapes (e.g. 3D but 1-channel)
321
+ r = self.osc.squeeze(); g = r; b = r
322
+
313
323
  sii = self.sii if self.sii is not None else r
314
324
  ha = self.ha if self.ha is not None else r
315
325
  oiii= self.oiii if self.oiii is not None else b