setiastrosuitepro 1.6.1.post1__py3-none-any.whl → 1.6.4__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 (139) hide show
  1. setiastro/images/Background_startup.jpg +0 -0
  2. setiastro/images/rotatearbitrary.png +0 -0
  3. setiastro/qml/ResourceMonitor.qml +126 -0
  4. setiastro/saspro/__main__.py +162 -25
  5. setiastro/saspro/_generated/build_info.py +2 -1
  6. setiastro/saspro/abe.py +62 -11
  7. setiastro/saspro/aberration_ai.py +3 -3
  8. setiastro/saspro/add_stars.py +5 -2
  9. setiastro/saspro/astrobin_exporter.py +3 -0
  10. setiastro/saspro/astrospike_python.py +3 -1
  11. setiastro/saspro/autostretch.py +4 -2
  12. setiastro/saspro/backgroundneutral.py +60 -9
  13. setiastro/saspro/batch_convert.py +3 -0
  14. setiastro/saspro/batch_renamer.py +3 -0
  15. setiastro/saspro/blemish_blaster.py +3 -0
  16. setiastro/saspro/blink_comparator_pro.py +474 -251
  17. setiastro/saspro/cheat_sheet.py +50 -15
  18. setiastro/saspro/clahe.py +27 -1
  19. setiastro/saspro/comet_stacking.py +103 -38
  20. setiastro/saspro/convo.py +3 -0
  21. setiastro/saspro/copyastro.py +3 -0
  22. setiastro/saspro/cosmicclarity.py +70 -45
  23. setiastro/saspro/crop_dialog_pro.py +28 -1
  24. setiastro/saspro/curve_editor_pro.py +18 -0
  25. setiastro/saspro/debayer.py +3 -0
  26. setiastro/saspro/doc_manager.py +40 -17
  27. setiastro/saspro/fitsmodifier.py +3 -0
  28. setiastro/saspro/frequency_separation.py +8 -2
  29. setiastro/saspro/function_bundle.py +18 -16
  30. setiastro/saspro/generate_translations.py +715 -1
  31. setiastro/saspro/ghs_dialog_pro.py +3 -0
  32. setiastro/saspro/graxpert.py +3 -0
  33. setiastro/saspro/gui/main_window.py +364 -92
  34. setiastro/saspro/gui/mixins/dock_mixin.py +119 -7
  35. setiastro/saspro/gui/mixins/file_mixin.py +7 -0
  36. setiastro/saspro/gui/mixins/geometry_mixin.py +105 -5
  37. setiastro/saspro/gui/mixins/menu_mixin.py +29 -0
  38. setiastro/saspro/gui/mixins/toolbar_mixin.py +33 -10
  39. setiastro/saspro/gui/statistics_dialog.py +47 -0
  40. setiastro/saspro/halobgon.py +29 -3
  41. setiastro/saspro/histogram.py +3 -0
  42. setiastro/saspro/history_explorer.py +2 -0
  43. setiastro/saspro/i18n.py +22 -10
  44. setiastro/saspro/image_combine.py +3 -0
  45. setiastro/saspro/image_peeker_pro.py +3 -0
  46. setiastro/saspro/imageops/stretch.py +5 -13
  47. setiastro/saspro/isophote.py +3 -0
  48. setiastro/saspro/legacy/numba_utils.py +64 -47
  49. setiastro/saspro/linear_fit.py +3 -0
  50. setiastro/saspro/live_stacking.py +13 -2
  51. setiastro/saspro/mask_creation.py +3 -0
  52. setiastro/saspro/mfdeconv.py +5 -0
  53. setiastro/saspro/morphology.py +30 -5
  54. setiastro/saspro/multiscale_decomp.py +713 -256
  55. setiastro/saspro/nbtorgb_stars.py +12 -2
  56. setiastro/saspro/numba_utils.py +148 -47
  57. setiastro/saspro/ops/scripts.py +77 -17
  58. setiastro/saspro/ops/settings.py +1 -43
  59. setiastro/saspro/perfect_palette_picker.py +1 -0
  60. setiastro/saspro/pixelmath.py +6 -2
  61. setiastro/saspro/plate_solver.py +1 -0
  62. setiastro/saspro/remove_green.py +18 -1
  63. setiastro/saspro/remove_stars.py +136 -162
  64. setiastro/saspro/remove_stars_preset.py +55 -13
  65. setiastro/saspro/resources.py +36 -10
  66. setiastro/saspro/rgb_combination.py +1 -0
  67. setiastro/saspro/rgbalign.py +4 -4
  68. setiastro/saspro/save_options.py +1 -0
  69. setiastro/saspro/selective_color.py +79 -20
  70. setiastro/saspro/sfcc.py +50 -8
  71. setiastro/saspro/shortcuts.py +94 -21
  72. setiastro/saspro/signature_insert.py +3 -0
  73. setiastro/saspro/stacking_suite.py +924 -446
  74. setiastro/saspro/star_alignment.py +291 -331
  75. setiastro/saspro/star_spikes.py +116 -32
  76. setiastro/saspro/star_stretch.py +38 -1
  77. setiastro/saspro/stat_stretch.py +35 -3
  78. setiastro/saspro/status_log_dock.py +1 -1
  79. setiastro/saspro/subwindow.py +63 -2
  80. setiastro/saspro/supernovaasteroidhunter.py +3 -0
  81. setiastro/saspro/swap_manager.py +77 -42
  82. setiastro/saspro/translations/all_source_strings.json +4726 -0
  83. setiastro/saspro/translations/ar_translations.py +4096 -0
  84. setiastro/saspro/translations/de_translations.py +441 -446
  85. setiastro/saspro/translations/es_translations.py +278 -32
  86. setiastro/saspro/translations/fr_translations.py +280 -32
  87. setiastro/saspro/translations/hi_translations.py +3803 -0
  88. setiastro/saspro/translations/integrate_translations.py +38 -1
  89. setiastro/saspro/translations/it_translations.py +1211 -145
  90. setiastro/saspro/translations/ja_translations.py +556 -307
  91. setiastro/saspro/translations/pt_translations.py +3316 -3322
  92. setiastro/saspro/translations/ru_translations.py +3082 -0
  93. setiastro/saspro/translations/saspro_ar.qm +0 -0
  94. setiastro/saspro/translations/saspro_ar.ts +16019 -0
  95. setiastro/saspro/translations/saspro_de.qm +0 -0
  96. setiastro/saspro/translations/saspro_de.ts +14428 -133
  97. setiastro/saspro/translations/saspro_es.qm +0 -0
  98. setiastro/saspro/translations/saspro_es.ts +11503 -7821
  99. setiastro/saspro/translations/saspro_fr.qm +0 -0
  100. setiastro/saspro/translations/saspro_fr.ts +11168 -7812
  101. setiastro/saspro/translations/saspro_hi.qm +0 -0
  102. setiastro/saspro/translations/saspro_hi.ts +14855 -0
  103. setiastro/saspro/translations/saspro_it.qm +0 -0
  104. setiastro/saspro/translations/saspro_it.ts +14347 -7821
  105. setiastro/saspro/translations/saspro_ja.qm +0 -0
  106. setiastro/saspro/translations/saspro_ja.ts +14860 -137
  107. setiastro/saspro/translations/saspro_pt.qm +0 -0
  108. setiastro/saspro/translations/saspro_pt.ts +14904 -137
  109. setiastro/saspro/translations/saspro_ru.qm +0 -0
  110. setiastro/saspro/translations/saspro_ru.ts +11835 -0
  111. setiastro/saspro/translations/saspro_sw.qm +0 -0
  112. setiastro/saspro/translations/saspro_sw.ts +15237 -0
  113. setiastro/saspro/translations/saspro_uk.qm +0 -0
  114. setiastro/saspro/translations/saspro_uk.ts +15248 -0
  115. setiastro/saspro/translations/saspro_zh.qm +0 -0
  116. setiastro/saspro/translations/saspro_zh.ts +10581 -7812
  117. setiastro/saspro/translations/sw_translations.py +3897 -0
  118. setiastro/saspro/translations/uk_translations.py +3929 -0
  119. setiastro/saspro/translations/zh_translations.py +283 -32
  120. setiastro/saspro/versioning.py +36 -5
  121. setiastro/saspro/view_bundle.py +20 -17
  122. setiastro/saspro/wavescale_hdr.py +22 -1
  123. setiastro/saspro/wavescalede.py +23 -1
  124. setiastro/saspro/whitebalance.py +39 -3
  125. setiastro/saspro/widgets/minigame/game.js +991 -0
  126. setiastro/saspro/widgets/minigame/index.html +53 -0
  127. setiastro/saspro/widgets/minigame/style.css +241 -0
  128. setiastro/saspro/widgets/resource_monitor.py +263 -0
  129. setiastro/saspro/widgets/spinboxes.py +18 -0
  130. setiastro/saspro/widgets/wavelet_utils.py +52 -20
  131. setiastro/saspro/wimi.py +100 -80
  132. setiastro/saspro/wims.py +33 -33
  133. setiastro/saspro/window_shelf.py +2 -2
  134. {setiastrosuitepro-1.6.1.post1.dist-info → setiastrosuitepro-1.6.4.dist-info}/METADATA +15 -4
  135. {setiastrosuitepro-1.6.1.post1.dist-info → setiastrosuitepro-1.6.4.dist-info}/RECORD +139 -115
  136. {setiastrosuitepro-1.6.1.post1.dist-info → setiastrosuitepro-1.6.4.dist-info}/WHEEL +0 -0
  137. {setiastrosuitepro-1.6.1.post1.dist-info → setiastrosuitepro-1.6.4.dist-info}/entry_points.txt +0 -0
  138. {setiastrosuitepro-1.6.1.post1.dist-info → setiastrosuitepro-1.6.4.dist-info}/licenses/LICENSE +0 -0
  139. {setiastrosuitepro-1.6.1.post1.dist-info → setiastrosuitepro-1.6.4.dist-info}/licenses/license.txt +0 -0
@@ -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
@@ -584,39 +584,55 @@ def kappa_sigma_clip_weighted_3d(stack, weights, kappa=2.5, iterations=3):
584
584
  pixel_weights = weights[:]
585
585
  else:
586
586
  pixel_weights = weights[:, i, j].copy()
587
- # Initialize tracking of indices
588
- current_idx = np.empty(num_frames, dtype=np.int64)
589
- for f in range(num_frames):
590
- current_idx[f] = f
591
- current_vals = pixel_values
592
- current_w = pixel_weights
593
- current_indices = current_idx
587
+
588
+ # Use boolean mask instead of tracking indices
589
+ valid_mask = pixel_values != 0
590
+
594
591
  med = 0.0
595
592
  for _ in range(iterations):
596
- if current_vals.size == 0:
593
+ # Count valid pixels
594
+ count = 0
595
+ for k in range(num_frames):
596
+ if valid_mask[k]:
597
+ count += 1
598
+
599
+ if count == 0:
597
600
  break
601
+
602
+ # Extract valid values for stats (this allocation is unavoidable but smaller/temp)
603
+ # In numba this usually lowers to efficient code if we avoid 'np.empty' overhead inside loops
604
+ # but pure mask operations are often faster.
605
+ # However, for median/std we need the compacted array.
606
+ current_vals = pixel_values[valid_mask]
607
+
598
608
  med = np.median(current_vals)
599
609
  std = np.std(current_vals)
600
610
  lower_bound = med - kappa * std
601
611
  upper_bound = med + kappa * std
602
- valid = (current_vals != 0) & (current_vals >= lower_bound) & (current_vals <= upper_bound)
603
- current_vals = current_vals[valid]
604
- current_w = current_w[valid]
605
- current_indices = current_indices[valid]
606
- # Mark rejected: frames not in current_indices are rejected.
612
+
613
+ # Update mask: must be valid AND within bounds
614
+ for k in range(num_frames):
615
+ if valid_mask[k]:
616
+ val = pixel_values[k]
617
+ if val < lower_bound or val > upper_bound:
618
+ valid_mask[k] = False
619
+
620
+ # Fill rejection mask
607
621
  for f in range(num_frames):
608
- # Check if f is in current_indices
609
- found = False
610
- for k in range(current_indices.size):
611
- if current_indices[k] == f:
612
- found = True
613
- break
614
- if not found:
615
- rej_mask[f, i, j] = True
616
- else:
617
- rej_mask[f, i, j] = False
618
- if current_w.size > 0 and current_w.sum() > 0:
619
- clipped[i, j] = np.sum(current_vals * current_w) / current_w.sum()
622
+ rej_mask[f, i, j] = not valid_mask[f]
623
+
624
+ # Compute weighted mean of final valid pixels
625
+ wsum = 0.0
626
+ vsum = 0.0
627
+ for k in range(num_frames):
628
+ if valid_mask[k]:
629
+ w = pixel_weights[k]
630
+ v = pixel_values[k]
631
+ wsum += w
632
+ vsum += v * w
633
+
634
+ if wsum > 0:
635
+ clipped[i, j] = vsum / wsum
620
636
  else:
621
637
  clipped[i, j] = med
622
638
  return clipped, rej_mask
@@ -641,36 +657,46 @@ def kappa_sigma_clip_weighted_4d(stack, weights, kappa=2.5, iterations=3):
641
657
  pixel_weights = weights[:]
642
658
  else:
643
659
  pixel_weights = weights[:, i, j, c].copy()
644
- current_idx = np.empty(num_frames, dtype=np.int64)
645
- for f in range(num_frames):
646
- current_idx[f] = f
647
- current_vals = pixel_values
648
- current_w = pixel_weights
649
- current_indices = current_idx
660
+
661
+ valid_mask = pixel_values != 0
662
+
650
663
  med = 0.0
651
664
  for _ in range(iterations):
652
- if current_vals.size == 0:
665
+ count = 0
666
+ for k in range(num_frames):
667
+ if valid_mask[k]:
668
+ count += 1
669
+
670
+ if count == 0:
653
671
  break
672
+
673
+ current_vals = pixel_values[valid_mask]
674
+
654
675
  med = np.median(current_vals)
655
676
  std = np.std(current_vals)
656
677
  lower_bound = med - kappa * std
657
678
  upper_bound = med + kappa * std
658
- valid = (current_vals != 0) & (current_vals >= lower_bound) & (current_vals <= upper_bound)
659
- current_vals = current_vals[valid]
660
- current_w = current_w[valid]
661
- current_indices = current_indices[valid]
679
+
680
+ for k in range(num_frames):
681
+ if valid_mask[k]:
682
+ val = pixel_values[k]
683
+ if val < lower_bound or val > upper_bound:
684
+ valid_mask[k] = False
685
+
662
686
  for f in range(num_frames):
663
- found = False
664
- for k in range(current_indices.size):
665
- if current_indices[k] == f:
666
- found = True
667
- break
668
- if not found:
669
- rej_mask[f, i, j, c] = True
670
- else:
671
- rej_mask[f, i, j, c] = False
672
- if current_w.size > 0 and current_w.sum() > 0:
673
- clipped[i, j, c] = np.sum(current_vals * current_w) / current_w.sum()
687
+ rej_mask[f, i, j, c] = not valid_mask[f]
688
+
689
+ wsum = 0.0
690
+ vsum = 0.0
691
+ for k in range(num_frames):
692
+ if valid_mask[k]:
693
+ w = pixel_weights[k]
694
+ v = pixel_values[k]
695
+ wsum += w
696
+ vsum += v * w
697
+
698
+ if wsum > 0:
699
+ clipped[i, j, c] = vsum / wsum
674
700
  else:
675
701
  clipped[i, j, c] = med
676
702
  return clipped, rej_mask
@@ -3042,3 +3068,78 @@ def fast_star_detect(image,
3042
3068
  return np.empty((0,2), dtype=np.float32)
3043
3069
  else:
3044
3070
  return np.array(star_positions, dtype=np.float32)
3071
+
3072
+
3073
+ @njit(fastmath=True, cache=True)
3074
+ def gradient_descent_to_dim_spot_numba(gray_small, start_x, start_y, patch_size):
3075
+ """
3076
+ Numba implementation of _gradient_descent_to_dim_spot.
3077
+ Walks to the local minimum (median-of-patch) around (start_x, start_y).
3078
+ gray_small: 2D float32 array
3079
+ """
3080
+ H, W = gray_small.shape
3081
+ half = patch_size // 2
3082
+
3083
+ cx = int(min(max(start_x, 0), W - 1))
3084
+ cy = int(min(max(start_y, 0), H - 1))
3085
+
3086
+ # Helper to compute patch median manually or efficiently
3087
+ # Numba supports np.median on arrays, but slicing inside a loop can be costly.
3088
+ # However, for small patches (e.g. 15x15), it should be okay.
3089
+
3090
+ for _ in range(60):
3091
+ # Current value
3092
+ x0 = max(0, cx - half)
3093
+ x1 = min(W, cx + half + 1)
3094
+ y0 = max(0, cy - half)
3095
+ y1 = min(H, cy + half + 1)
3096
+ sub = gray_small[y0:y1, x0:x1].flatten()
3097
+ if sub.size == 0:
3098
+ cur_val = 1e9 # Should not happen
3099
+ else:
3100
+ cur_val = np.median(sub)
3101
+
3102
+ best_x, best_y = cx, cy
3103
+ best_val = cur_val
3104
+
3105
+ # 3x3 search
3106
+ changed = False
3107
+
3108
+ # Unroll for strict 3x3 neighborhood
3109
+ for dy in range(-1, 2):
3110
+ for dx in range(-1, 2):
3111
+ if dx == 0 and dy == 0:
3112
+ continue
3113
+
3114
+ nx = cx + dx
3115
+ ny = cy + dy
3116
+
3117
+ if nx < 0 or ny < 0 or nx >= W or ny >= H:
3118
+ continue
3119
+
3120
+ # Compute median for neighbor
3121
+ nx0 = max(0, nx - half)
3122
+ nx1 = min(W, nx + half + 1)
3123
+ ny0 = max(0, ny - half)
3124
+ ny1 = min(H, ny + half + 1)
3125
+
3126
+ # In Numba, median on a slice creates a copy.
3127
+ # For small patches this is acceptable given the huge speedup vs Python interpreter overhead.
3128
+ n_sub = gray_small[ny0:ny1, nx0:nx1].flatten()
3129
+ if n_sub.size == 0:
3130
+ val = 1e9
3131
+ else:
3132
+ val = np.median(n_sub)
3133
+
3134
+ if val < best_val:
3135
+ best_val = val
3136
+ best_x = nx
3137
+ best_y = ny
3138
+ changed = True
3139
+
3140
+ if not changed:
3141
+ break
3142
+
3143
+ cx, cy = best_x, best_y
3144
+
3145
+ return cx, cy
@@ -644,13 +644,33 @@ class ScriptManager(QObject):
644
644
 
645
645
  # ---- loading ----
646
646
  def load_registry(self):
647
- migrate_old_scripts_if_needed()
647
+ """
648
+ Discover scripts recursively under SASpro/scripts, load them, and build registry.
649
+ Skips __pycache__, hidden/underscore-prefixed files, and __init__.py.
650
+ """
651
+ migrate_old_scripts_if_needed()
648
652
  scripts_dir = get_scripts_dir()
649
653
  self.registry = []
650
654
 
651
- for path in sorted(scripts_dir.glob("*.py")):
655
+ try:
656
+ candidates = sorted(scripts_dir.rglob("*.py"))
657
+ except Exception:
658
+ candidates = []
659
+
660
+ for path in candidates:
661
+ # Skip pycache anywhere in path
662
+ parts_l = {p.lower() for p in path.parts}
663
+ if "__pycache__" in parts_l:
664
+ continue
665
+
666
+ # Skip hidden/private python files and package init
667
+ if path.name == "__init__.py":
668
+ continue
669
+ if path.name.startswith((".", "_")):
670
+ continue
671
+
652
672
  try:
653
- entry = self._load_one_script(path)
673
+ entry = self._load_one_script(path, scripts_dir)
654
674
  if entry:
655
675
  self.registry.append(entry)
656
676
  except Exception:
@@ -658,8 +678,18 @@ class ScriptManager(QObject):
658
678
 
659
679
  self._log(f"[Scripts] Loaded {len(self.registry)} script(s) from {scripts_dir}")
660
680
 
661
- def _load_one_script(self, path: Path) -> ScriptEntry | None:
662
- # Make a unique module name so reload actually reloads
681
+
682
+ def _load_one_script(self, path: Path, scripts_root: Path) -> ScriptEntry | None:
683
+ """
684
+ Load a single user script from disk.
685
+
686
+ - Creates a unique module name based on mtime so reload picks up changes.
687
+ - Imports module.
688
+ - Determines stable script_id (prefer SCRIPT_ID in module, else persisted id).
689
+ - Pulls metadata: SCRIPT_NAME/GROUP/SHORTCUT.
690
+ Group defaults to relative folder under scripts_root.
691
+ """
692
+ # Unique module name so reloading actually re-imports
663
693
  try:
664
694
  mtime_ns = path.stat().st_mtime_ns
665
695
  except Exception:
@@ -671,7 +701,8 @@ class ScriptManager(QObject):
671
701
  return None
672
702
 
673
703
  mod = importlib.util.module_from_spec(spec)
674
- script_id = self._script_id_for_path(path, mod)
704
+
705
+ # Import module first (so SCRIPT_ID / metadata exists)
675
706
  try:
676
707
  spec.loader.exec_module(mod) # type: ignore
677
708
  except Exception:
@@ -687,7 +718,7 @@ class ScriptManager(QObject):
687
718
  self._log(f"[Scripts] {path.name} has no run(ctx) or main(ctx); skipping.")
688
719
  return None
689
720
 
690
- # ---- metadata: allow CAPS or lowercase ----
721
+ # ---- helper: allow CAPS or lowercase ----
691
722
  def _pick(*names, default=None):
692
723
  for n in names:
693
724
  if hasattr(mod, n):
@@ -695,11 +726,23 @@ class ScriptManager(QObject):
695
726
  return default
696
727
 
697
728
  name = _pick("SCRIPT_NAME", "script_name", default=path.stem)
698
- group = _pick("SCRIPT_GROUP", "script_group", default="")
729
+
730
+ # Prefer explicit group; else derive group from relative folder
731
+ group = _pick("SCRIPT_GROUP", "script_group", default=None)
732
+ if group is None or not str(group).strip():
733
+ try:
734
+ rel_parent = path.parent.relative_to(scripts_root)
735
+ group = "" if str(rel_parent) in ("", ".") else rel_parent.as_posix()
736
+ except Exception:
737
+ group = ""
738
+
699
739
  shortcut = _pick("SCRIPT_SHORTCUT", "script_shortcut", default=None)
700
740
 
741
+ # Stable script id (prefer explicit SCRIPT_ID; else persisted by rel-path)
742
+ script_id = self._script_id_for_path(path, scripts_root, mod)
743
+
701
744
  entry = ScriptEntry(
702
- script_id=script_id,
745
+ script_id=str(script_id),
703
746
  path=path,
704
747
  name=str(name),
705
748
  group=str(group or ""),
@@ -710,6 +753,7 @@ class ScriptManager(QObject):
710
753
  return entry
711
754
 
712
755
 
756
+
713
757
  # ---- menu wiring ----
714
758
  def rebuild_menu(self, menu_scripts):
715
759
  """
@@ -1394,15 +1438,31 @@ def run(ctx):
1394
1438
  self._log(f"[Scripts] Failed to write sample script:\n{traceback.format_exc()}")
1395
1439
 
1396
1440
 
1397
- def _script_id_for_path(self, path: Path, mod) -> str:
1398
- # 1) Prefer explicit SCRIPT_ID in the script file (best, survives renames)
1399
- sid = getattr(mod, "SCRIPT_ID", None) or getattr(mod, "script_id", None)
1400
- if isinstance(sid, str) and sid.strip():
1401
- return sid.strip()
1441
+ def _script_id_for_path(self, path: Path, scripts_root: Path, mod=None) -> str:
1442
+ """
1443
+ Determine a stable script_id.
1444
+
1445
+ Priority:
1446
+ 1) SCRIPT_ID / script_id defined in the script (best; survives renames/moves)
1447
+ 2) Persisted id in QSettings keyed by *relative path inside scripts_root*
1448
+ (stable across machines if folder structure is same)
1449
+
1450
+ NOTE: We intentionally DO NOT key by absolute path.
1451
+ """
1452
+ # 1) Prefer explicit SCRIPT_ID in the script file (best)
1453
+ if mod is not None:
1454
+ sid = getattr(mod, "SCRIPT_ID", None) or getattr(mod, "script_id", None)
1455
+ if isinstance(sid, str) and sid.strip():
1456
+ return sid.strip()
1457
+
1458
+ # 2) Persist per-relative-path (not absolute)
1459
+ try:
1460
+ rel = path.relative_to(scripts_root).as_posix()
1461
+ except Exception:
1462
+ rel = path.as_posix()
1402
1463
 
1403
- # 2) Fallback: persist per-path in QSettings (ok for existing scripts)
1404
1464
  s = QSettings()
1405
- key = f"Scripts/ids/{str(path)}"
1465
+ key = f"Scripts/ids_rel/{rel}"
1406
1466
  sid = s.value(key, "", type=str) or ""
1407
1467
  if sid:
1408
1468
  return sid
@@ -1410,4 +1470,4 @@ def run(ctx):
1410
1470
  sid = uuid.uuid4().hex
1411
1471
  s.setValue(key, sid)
1412
1472
  s.sync()
1413
- return sid
1473
+ return sid
@@ -500,54 +500,12 @@ class SettingsDialog(QDialog):
500
500
  # Apply language change immediately if changed
501
501
  if new_lang != self._initial_language:
502
502
  from PyQt6.QtWidgets import QMessageBox
503
- import sys
504
- import os
505
-
506
- # Save UI state before restart to avoid losing toolbar/window changes
507
- p = self.parent()
508
- if p and hasattr(p, "save_ui_state"):
509
- try:
510
- p.save_ui_state()
511
- except Exception:
512
- pass
513
-
514
- # Set restart flag on parent to bypass exit confirmation
515
- if p:
516
- p._is_restarting = True
517
-
518
- self.settings.sync()
519
503
 
520
504
  QMessageBox.information(
521
505
  self,
522
506
  self.tr("Restart required"),
523
- self.tr("The application will now restart to apply the language change.")
507
+ self.tr("Language changed. Please manually restart the application to apply the new language.")
524
508
  )
525
-
526
- # Restart logic
527
- import subprocess
528
- try:
529
- # Prepare arguments for restart
530
- args = sys.argv[:]
531
- if getattr(sys, 'frozen', False):
532
- # If frozen, sys.executable is the app, and args[0] is also the app
533
- cmd = [sys.executable] + args[1:]
534
- else:
535
- # If running from source, sys.executable is python, args[0] is the script
536
- cmd = [sys.executable] + args
537
-
538
- # Start new process and exit current one
539
- if os.name == 'nt':
540
- # On Windows, use DETACHED_PROCESS to break the process tree connection to the terminal
541
- # and close_fds to ensure no handles are inherited.
542
- subprocess.Popen(cmd, creationflags=subprocess.DETACHED_PROCESS, close_fds=True)
543
- else:
544
- subprocess.Popen(cmd)
545
-
546
- QApplication.instance().quit()
547
- sys.exit(0)
548
- except Exception:
549
- # Fallback to execl if Popen fails
550
- os.execl(sys.executable, sys.executable, *sys.argv)
551
509
 
552
510
  self.settings.sync()
553
511
 
@@ -28,6 +28,7 @@ class PaletteAdjustDialog(QDialog):
28
28
  super().__init__(owner)
29
29
  self.setWindowTitle("Adjust Palette Intensities")
30
30
  self.setWindowFlag(Qt.WindowType.Window, True)
31
+ self.setWindowModality(Qt.WindowModality.NonModal)
31
32
  self.setModal(False)
32
33
  #self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
33
34
 
@@ -59,9 +59,10 @@ class PixelImage:
59
59
  a = np.asarray(a, dtype=np.float32)
60
60
  b = np.asarray(b, dtype=np.float32)
61
61
  if a.ndim == 3 and b.ndim == 2:
62
- b = np.repeat(b[..., None], a.shape[2], axis=2)
62
+ # Broadcast b to (H,W,1) virtual view; numpy ufuncs handle (H,W,3) vs (H,W,1) automatically
63
+ b = b[..., None]
63
64
  elif a.ndim == 2 and b.ndim == 3:
64
- a = np.repeat(a[..., None], b.shape[2], axis=2)
65
+ a = a[..., None]
65
66
  return a, b
66
67
 
67
68
  # ---- binary arithmetic helpers ----
@@ -756,6 +757,9 @@ class PixelMathDialogPro(QDialog):
756
757
  def __init__(self, parent, doc, icon: QIcon | None = None):
757
758
  super().__init__(parent)
758
759
  self.setWindowTitle(self.tr("Pixel Math"))
760
+ self.setWindowFlag(Qt.WindowType.Window, True)
761
+ self.setWindowModality(Qt.WindowModality.NonModal)
762
+ self.setModal(False)
759
763
  if icon:
760
764
  try:
761
765
  self.setWindowIcon(icon)
@@ -2059,6 +2059,7 @@ class PlateSolverDialog(QDialog):
2059
2059
  self.setWindowTitle(self.tr("Plate Solver"))
2060
2060
  self.setMinimumWidth(560)
2061
2061
  self.setWindowFlag(Qt.WindowType.Window, True)
2062
+ self.setWindowModality(Qt.WindowModality.NonModal)
2062
2063
  self.setModal(False)
2063
2064
  #self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
2064
2065
 
@@ -157,6 +157,9 @@ class RemoveGreenDialog(QDialog):
157
157
  self.main = main
158
158
  self.doc = doc
159
159
  self.setWindowTitle(self.tr("Remove Green (SCNR)"))
160
+ self.setWindowFlag(Qt.WindowType.Window, True)
161
+ self.setWindowModality(Qt.WindowModality.NonModal)
162
+ self.setModal(False)
160
163
  self._build_ui()
161
164
 
162
165
  def _build_ui(self):
@@ -263,8 +266,22 @@ class RemoveGreenDialog(QDialog):
263
266
  # Never let replay bookkeeping kill the dialog
264
267
  pass
265
268
 
266
- self.accept()
269
+ # Dialog stays open so user can apply to other images
270
+ # Refresh document reference for next operation
271
+ self._refresh_document_from_active()
267
272
 
273
+ def _refresh_document_from_active(self):
274
+ """
275
+ Refresh the dialog's document reference to the currently active document.
276
+ This allows reusing the same dialog on different images.
277
+ """
278
+ try:
279
+ if self.main and hasattr(self.main, "_active_doc"):
280
+ new_doc = self.main._active_doc()
281
+ if new_doc is not None and new_doc is not self.doc:
282
+ self.doc = new_doc
283
+ except Exception:
284
+ pass
268
285
 
269
286
 
270
287
  # ---------- entry points used by main ----------