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
@@ -1,7 +1,7 @@
1
1
  # pro/tools/star_spikes.py
2
2
  from __future__ import annotations
3
3
  import numpy as np
4
-
4
+ import math
5
5
  from PyQt6.QtCore import Qt
6
6
  from PyQt6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QSplitter, QSizePolicy, QWidget, QApplication,
7
7
  QFormLayout, QGroupBox, QDoubleSpinBox, QSpinBox,
@@ -50,6 +50,9 @@ class StarSpikesDialogPro(QDialog):
50
50
  spinner_path: str | None = None):
51
51
  super().__init__(parent)
52
52
  self.setWindowTitle(self.tr("Diffraction Spikes"))
53
+ self.setWindowFlag(Qt.WindowType.Window, True)
54
+ self.setWindowModality(Qt.WindowModality.NonModal)
55
+ self.setModal(False)
53
56
  self.docman = doc_manager
54
57
  self.doc = initial_doc or (self.docman.get_active_document() if self.docman else None)
55
58
  self.jwstpupil_path = jwstpupil_path
@@ -381,6 +384,13 @@ class StarSpikesDialogPro(QDialog):
381
384
  shrink_max = self.advanced["shrink_max"]
382
385
  color_boost = self.color_boost.value()
383
386
 
387
+ # Try OpenCV for faster zoom/blur
388
+ try:
389
+ import cv2
390
+ _HAS_CV2 = True
391
+ except ImportError:
392
+ _HAS_CV2 = False
393
+
384
394
  from concurrent.futures import ThreadPoolExecutor, as_completed
385
395
  def star_runner(x, y, flux, a, b):
386
396
  brightness = np.clip(np.log1p(flux)/8.0, 0.1, 3.0)
@@ -388,39 +398,79 @@ class StarSpikesDialogPro(QDialog):
388
398
  tile_size = min(tile_size, 768)
389
399
  tile_size += tile_size % 2
390
400
  pad = tile_size // 2
391
- if not (pad <= x < W - pad and pad <= y < H - pad):
401
+
402
+ # Guard against fully out-of-bounds, but allow partial overlaps
403
+ if not (0 <= x < W and 0 <= y < H):
392
404
  return None
393
405
 
406
+ # Measure star color
394
407
  r_ratio, g_ratio, b_ratio = self._measure_star_color(img, x, y, sampling_radius=3)
408
+
409
+ # Extract PSF tiles
395
410
  tile_r = self._extract_center_tile(psf_r, tile_size) * brightness * r_ratio * color_boost
396
411
  tile_g = self._extract_center_tile(psf_g, tile_size) * brightness * g_ratio * color_boost
397
412
  tile_b = self._extract_center_tile(psf_b, tile_size) * brightness * b_ratio * color_boost
398
413
 
414
+ # Boost/Shrink
399
415
  b_scale, s_factor = self._boost_shrink_from_flux(flux, self.flux_min.value(), flux_max,
400
416
  bscale_min, bscale_max, shrink_min, shrink_max)
401
- final_r = self._shrink_and_boost(tile_r, b_scale, s_factor)
402
- final_g = self._shrink_and_boost(tile_g, b_scale, s_factor)
403
- final_b = self._shrink_and_boost(tile_b, b_scale, s_factor)
404
-
405
- new_size = final_r.shape[0]
406
- pad_new = new_size // 2
407
- y0, y1 = y - pad_new, y - pad_new + new_size
408
- x0, x1 = x - pad_new, x - pad_new + new_size
409
- if (y0 < 0 or y1 > H or x0 < 0 or x1 > W):
410
- return None
411
417
 
412
- part = np.zeros((H, W, 3), dtype=np.float32)
413
- part[y0:y1, x0:x1, 0] = final_r
414
- part[y0:y1, x0:x1, 1] = final_g
415
- part[y0:y1, x0:x1, 2] = final_b
416
- return part
418
+ # --- Fast Resize (Zoom) ---
419
+ def _fast_zoom(arr, z):
420
+ if z == 1.0: return arr
421
+ if _HAS_CV2:
422
+ h, w = arr.shape
423
+ nw, nh = int(round(w * z)), int(round(h * z))
424
+ if nw <= 0 or nh <= 0: return np.zeros((2,2), dtype=np.float32)
425
+ return cv2.resize(arr, (nw, nh), interpolation=cv2.INTER_LINEAR)
426
+ else:
427
+ return ndi.zoom(arr, z, order=1)
428
+
429
+ final_r = np.clip(_fast_zoom(tile_r * b_scale, 1.0/s_factor), 0.0, 1.0)
430
+ final_g = np.clip(_fast_zoom(tile_g * b_scale, 1.0/s_factor), 0.0, 1.0)
431
+ final_b = np.clip(_fast_zoom(tile_b * b_scale, 1.0/s_factor), 0.0, 1.0)
432
+
433
+ # --- Return Patch Data (y, x, patch) ---
434
+ new_h, new_w = final_r.shape
435
+
436
+ # Coords of the *patch top-left* relative to the image
437
+ # The star is at (x,y), and the patch center is approx (new_w//2, new_h//2)
438
+ # We want to center the patch on the star.
439
+ py0 = y - (new_h // 2)
440
+ px0 = x - (new_w // 2)
441
+
442
+ # Combine channels
443
+ patch = np.dstack((final_r, final_g, final_b)).astype(np.float32)
444
+ return (int(py0), int(px0), patch)
417
445
 
418
446
  with ThreadPoolExecutor() as ex:
419
447
  futs = [ex.submit(star_runner, *s) for s in stars]
420
448
  for f in as_completed(futs):
421
- part = f.result()
422
- if part is not None:
423
- canvas += part
449
+ res = f.result()
450
+ if res is None:
451
+ continue
452
+
453
+ py0, px0, patch = res
454
+ ph, pw, _ = patch.shape
455
+
456
+ # Calculate intersection with canvas
457
+ y_start = max(0, py0)
458
+ y_end = min(H, py0 + ph)
459
+ x_start = max(0, px0)
460
+ x_end = min(W, px0 + pw)
461
+
462
+ # If no overlap, skip
463
+ if y_start >= y_end or x_start >= x_end:
464
+ continue
465
+
466
+ # Offsets into the patch
467
+ patch_y_start = y_start - py0
468
+ patch_y_end = patch_y_start + (y_end - y_start)
469
+ patch_x_start = x_start - px0
470
+ patch_x_end = patch_x_start + (x_end - x_start)
471
+
472
+ # Add to canvas
473
+ canvas[y_start:y_end, x_start:x_end] += patch[patch_y_start:patch_y_end, patch_x_start:patch_x_end]
424
474
 
425
475
  self.status.setText("Compositing…")
426
476
  QApplication.processEvents()
@@ -543,14 +593,56 @@ class StarSpikesDialogPro(QDialog):
543
593
  return img
544
594
 
545
595
  def _simulate_psf(self, pupil, wavelength_scale=1.0, blur_sigma=1.0):
546
- sp = gaussian_filter(pupil, sigma=0.1 * wavelength_scale)
596
+ # Try to use OpenCV for speed
597
+ if getattr(self, "_cv2_checked", False):
598
+ has_cv2 = True
599
+ import cv2
600
+ else:
601
+ try:
602
+ import cv2
603
+ has_cv2 = True
604
+ except ImportError:
605
+ has_cv2 = False
606
+ self._cv2_checked = has_cv2
607
+
608
+ if has_cv2:
609
+ # Gaussian blur on pupil
610
+ # kernel size usually ~6*sigma, must be odd
611
+ k_pupil = int(math.ceil(6 * (0.1 * wavelength_scale))) | 1
612
+ sp = cv2.GaussianBlur(pupil, (k_pupil, k_pupil), 0.1 * wavelength_scale)
613
+ else:
614
+ sp = gaussian_filter(pupil, sigma=0.1 * wavelength_scale)
615
+
547
616
  fft = np.fft.fftshift(np.fft.fft2(np.fft.ifftshift(sp)))
548
617
  intensity = np.abs(fft)**2
549
618
  intensity /= (intensity.max() + 1e-8)
550
- blurred = gaussian_filter(intensity, sigma=blur_sigma)
619
+
620
+ if has_cv2 and blur_sigma > 0:
621
+ k_blur = int(math.ceil(6 * blur_sigma)) | 1
622
+ blurred = cv2.GaussianBlur(intensity, (k_blur, k_blur), blur_sigma)
623
+ else:
624
+ blurred = gaussian_filter(intensity, sigma=blur_sigma)
625
+
551
626
  psf = blurred / max(blurred.max(), 1e-8)
627
+
552
628
  if wavelength_scale != 1.0:
553
- psf = ndi.zoom(psf, zoom=wavelength_scale, order=1)
629
+ if has_cv2:
630
+ h, w = psf.shape
631
+ # Zoom uses size, NOT scale factor in resize(..., dsize=(w,h))
632
+ # wavelength_scale > 1 => zoom in => crop middle? or simply scale?
633
+ # The original used ndi.zoom(psf, zoom=wavelength_scale).
634
+ # New size:
635
+ nw, nh = int(round(w * wavelength_scale)), int(round(h * wavelength_scale))
636
+ if nw > 0 and nh > 0:
637
+ scaled = cv2.resize(psf, (nw, nh), interpolation=cv2.INTER_LINEAR)
638
+ # We might need to crop back to original size or pad?
639
+ # ndi.zoom changes the array size.
640
+ # The simulator seems to assume we handle whatever size comes out?
641
+ # Let's check _extract_center_tile usage.
642
+ psf = scaled
643
+ else:
644
+ psf = ndi.zoom(psf, zoom=wavelength_scale, order=1)
645
+
554
646
  psf /= psf.max() + 1e-12
555
647
  return psf
556
648
 
@@ -591,15 +683,7 @@ class StarSpikesDialogPro(QDialog):
591
683
  stars.append((int(obj['x']), int(obj['y']), float(flux), float(a), float(b)))
592
684
  return stars
593
685
 
594
- @staticmethod
595
- def _shrink_and_boost(tile, brightness_scale=2.0, shrink_factor=1.5):
596
- tile = np.clip(tile * float(brightness_scale), 0.0, 1.0)
597
- in_sz = tile.shape[0]
598
- out_sz = int(in_sz // float(shrink_factor))
599
- out_sz += out_sz % 2
600
- if out_sz <= 0: out_sz = 2
601
- z = out_sz / float(in_sz)
602
- return np.clip(ndi.zoom(tile, z, order=1), 0.0, 1.0)
686
+ # _shrink_and_boost removed (replaced by inline _fast_zoom for performance)
603
687
 
604
688
  @staticmethod
605
689
  def _boost_shrink_from_flux(flux, flux_min, flux_max, bmin, bmax, smin, smax):
@@ -127,8 +127,16 @@ class StarStretchDialog(QDialog):
127
127
  def __init__(self, parent, document):
128
128
  super().__init__(parent)
129
129
  self.setWindowTitle(self.tr("Star Stretch"))
130
+ self.setWindowFlag(Qt.WindowType.Window, True)
131
+ self.setWindowModality(Qt.WindowModality.NonModal)
132
+ self.setModal(False)
133
+ self._main = parent
130
134
  self.doc = document
131
135
  self._preview: np.ndarray | None = None
136
+
137
+ # Connect to active document change signal
138
+ if hasattr(self._main, "currentDocumentChanged"):
139
+ self._main.currentDocumentChanged.connect(self._on_active_doc_changed)
132
140
  self._pix: QPixmap | None = None
133
141
  self._zoom = 0.25
134
142
  self._panning = False
@@ -225,6 +233,15 @@ class StarStretchDialog(QDialog):
225
233
  # initialize preview with current doc image
226
234
  self._update_preview_pix(self.doc.image)
227
235
 
236
+ # --- active document change ---
237
+ def _on_active_doc_changed(self, doc):
238
+ """Called when user clicks a different image window."""
239
+ if doc is None or getattr(doc, "image", None) is None:
240
+ return
241
+ self.doc = doc
242
+ self._preview = None
243
+ self._update_preview_pix(self.doc.image)
244
+
228
245
  # --- UI change handlers ---
229
246
  def _on_stretch_changed(self, v: int):
230
247
  self.lbl_st.setText(f"Stretch Amount: {v/100.0:.2f}")
@@ -325,7 +342,27 @@ class StarStretchDialog(QDialog):
325
342
  except Exception as e:
326
343
  QMessageBox.critical(self, "Apply failed", str(e))
327
344
  return
328
- self.accept()
345
+
346
+ # Dialog stays open so user can apply to other images
347
+ # Refresh document reference for next operation
348
+ self._refresh_document_from_active()
349
+
350
+ def _refresh_document_from_active(self):
351
+ """
352
+ Refresh the dialog's document reference to the currently active document.
353
+ This allows reusing the same dialog on different images.
354
+ """
355
+ try:
356
+ main = self._find_main_window()
357
+ if main and hasattr(main, "_active_doc"):
358
+ new_doc = main._active_doc()
359
+ if new_doc is not None and new_doc is not self.doc:
360
+ self.doc = new_doc
361
+ # Reset preview for new document
362
+ self._preview = None
363
+ self._compute_and_show_preview()
364
+ except Exception:
365
+ pass
329
366
 
330
367
 
331
368
  # --- preview rendering ---
@@ -25,13 +25,18 @@ class StatisticalStretchDialog(QDialog):
25
25
  # --- IMPORTANT: avoid “attached modal” behavior on some Linux WMs ---
26
26
  # Make this a proper top-level window (tool-style) rather than an attached sheet.
27
27
  self.setWindowFlag(Qt.WindowType.Window, True)
28
- # Block the app if you want, but don't use WindowModal
29
- self.setWindowModality(Qt.WindowModality.ApplicationModal)
28
+ # Non-modal: allow user to switch between images while dialog is open
29
+ self.setWindowModality(Qt.WindowModality.NonModal)
30
30
  # Don’t let the generic modal flag override the explicit modality
31
31
  self.setModal(False)
32
32
 
33
+ self._main = parent
33
34
  self.doc = document
34
35
  self._last_preview = None
36
+
37
+ # Connect to active document change signal
38
+ if hasattr(self._main, "currentDocumentChanged"):
39
+ self._main.currentDocumentChanged.connect(self._on_active_doc_changed)
35
40
  self._panning = False
36
41
  self._pan_last = None # QPoint
37
42
  self._preview_scale = 1.0 # NEW: zoom factor for preview
@@ -329,6 +334,14 @@ class StatisticalStretchDialog(QDialog):
329
334
  self._preview_qimg = qimg
330
335
  self._apply_current_zoom()
331
336
 
337
+ # ----- active document change -----
338
+ def _on_active_doc_changed(self, doc):
339
+ """Called when user clicks a different image window."""
340
+ if doc is None or getattr(doc, "image", None) is None:
341
+ return
342
+ self.doc = doc
343
+ self._populate_initial_preview()
344
+
332
345
  # ----- slots -----
333
346
  def _populate_initial_preview(self):
334
347
  # show the current (unstretched) image as baseline
@@ -433,12 +446,31 @@ class StatisticalStretchDialog(QDialog):
433
446
  # optional debug
434
447
  print("Statistical Stretch: replay recording suppressed for this apply()")
435
448
 
436
- self.accept()
449
+ # Dialog stays open so user can apply to other images
450
+ # Update the document reference to reflect the now-active document
451
+ self._refresh_document_from_active()
437
452
 
438
453
 
439
454
  except Exception as e:
440
455
  QMessageBox.critical(self, "Apply failed", str(e))
441
456
 
457
+ def _refresh_document_from_active(self):
458
+ """
459
+ Refresh the dialog's document reference to the currently active document.
460
+ This allows reusing the same dialog on different images.
461
+ """
462
+ try:
463
+ main = self.parent()
464
+ if main and hasattr(main, "_active_doc"):
465
+ new_doc = main._active_doc()
466
+ if new_doc is not None and new_doc is not self.doc:
467
+ self.doc = new_doc
468
+ # Reset preview state for new document
469
+ self._last_preview = None
470
+ self._preview_qimg = None
471
+ except Exception:
472
+ pass
473
+
442
474
 
443
475
  def _update_preview_scaled(self):
444
476
  if self._preview_qimg is None:
@@ -9,7 +9,7 @@ class StatusLogDock(QDockWidget):
9
9
  MAX_BLOCKS = 2000
10
10
 
11
11
  def __init__(self, parent=None):
12
- super().__init__("Stacking Log", parent)
12
+ super().__init__(self.tr("Stacking Log"), parent)
13
13
  self.setObjectName("StackingLogDock")
14
14
  self.setAllowedAreas(
15
15
  Qt.DockWidgetArea.BottomDockWidgetArea
@@ -371,7 +371,8 @@ class ImageSubWindow(QWidget):
371
371
  # pixel readout live-probe state
372
372
  self._space_down = False
373
373
  self._readout_dragging = False
374
- self._last_readout = None
374
+ # Pinch gesture state (macOS trackpad)
375
+ self._gesture_zoom_start = None
375
376
  self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
376
377
 
377
378
  # Title (doc/view) sync
@@ -484,6 +485,7 @@ class ImageSubWindow(QWidget):
484
485
 
485
486
  self.scroll = QScrollArea(full_host)
486
487
  self.scroll.setWidgetResizable(False)
488
+ self.scroll.setAlignment(Qt.AlignmentFlag.AlignCenter)
487
489
  self.label = QLabel(alignment=Qt.AlignmentFlag.AlignCenter)
488
490
  self.scroll.setWidget(self.label)
489
491
  self.scroll.viewport().setMouseTracking(True)
@@ -2526,6 +2528,43 @@ class ImageSubWindow(QWidget):
2526
2528
  p = p.parent()
2527
2529
  return p
2528
2530
 
2531
+ def event(self, e):
2532
+ """Override event() to handle native macOS gestures (pinch zoom)."""
2533
+ # Handle native gestures (macOS trackpad pinch zoom)
2534
+ if e.type() == QEvent.Type.NativeGesture:
2535
+ gesture_type = e.gestureType()
2536
+
2537
+ if gesture_type == Qt.NativeGestureType.BeginNativeGesture:
2538
+ # Start of pinch gesture - store initial scale
2539
+ self._gesture_zoom_start = self.scale
2540
+ e.accept()
2541
+ return True
2542
+
2543
+ elif gesture_type == Qt.NativeGestureType.ZoomNativeGesture:
2544
+ # Ongoing pinch zoom - value() is cumulative scale factor
2545
+ # Typical values: -0.5 to +0.5 for moderate pinches
2546
+ zoom_delta = e.value()
2547
+
2548
+ # Convert delta to zoom factor
2549
+ # Use smaller multiplier for smoother feel (0.5x damping)
2550
+ factor = 1.0 + (zoom_delta * 0.5)
2551
+
2552
+ # Apply incremental zoom
2553
+ self._zoom_at_anchor(factor)
2554
+ e.accept()
2555
+ return True
2556
+
2557
+ elif gesture_type == Qt.NativeGestureType.EndNativeGesture:
2558
+ # End of pinch gesture - cleanup
2559
+ self._gesture_zoom_start = None
2560
+ e.accept()
2561
+ return True
2562
+
2563
+ # Let parent handle all other events
2564
+ return super().event(e)
2565
+
2566
+
2567
+
2529
2568
  def eventFilter(self, obj, ev):
2530
2569
  is_on_view = (obj is self.label) or (obj is self.scroll.viewport())
2531
2570
 
@@ -2557,7 +2596,29 @@ class ImageSubWindow(QWidget):
2557
2596
  # 1) Ctrl + wheel → zoom
2558
2597
  if ev.type() == QEvent.Type.Wheel:
2559
2598
  if ev.modifiers() & Qt.KeyboardModifier.ControlModifier:
2560
- factor = 1.25 if ev.angleDelta().y() > 0 else 1/1.25
2599
+ # Try pixelDelta first (macOS trackpad gives smooth values)
2600
+ dy = ev.pixelDelta().y()
2601
+
2602
+ if dy != 0:
2603
+ # Smooth trackpad scrolling: use smaller base factor
2604
+ # Scale proportionally to delta magnitude for natural feel
2605
+ # Typical trackpad deltas are 1-10 pixels per event
2606
+ abs_dy = abs(dy)
2607
+ if abs_dy <= 3:
2608
+ base_factor = 1.01 # Very gentle for tiny movements
2609
+ elif abs_dy <= 10:
2610
+ base_factor = 1.02 # Gentle for small movements
2611
+ else:
2612
+ base_factor = 1.03 # Moderate for larger gestures
2613
+
2614
+ factor = base_factor if dy > 0 else 1/base_factor
2615
+ else:
2616
+ # Traditional mouse wheel: use angleDelta with moderate factor
2617
+ dy = ev.angleDelta().y()
2618
+ if dy == 0:
2619
+ return True
2620
+ # Use 1.15 for mouse wheel (gentler than original 1.25)
2621
+ factor = 1.15 if dy > 0 else 1/1.15
2561
2622
  self._zoom_at_anchor(factor)
2562
2623
  return True
2563
2624
  return False
@@ -362,6 +362,9 @@ class SupernovaAsteroidHunterDialog(QDialog):
362
362
  supernova_path=None, wrench_path=None, spinner_path=None):
363
363
  super().__init__(parent)
364
364
  self.setWindowTitle(self.tr("Supernova / Asteroid Hunter"))
365
+ self.setWindowFlag(Qt.WindowType.Window, True)
366
+ self.setWindowModality(Qt.WindowModality.NonModal)
367
+ self.setModal(False)
365
368
  if supernova_path:
366
369
  self.setWindowIcon(QIcon(supernova_path))
367
370
  # keep icon path for previews
@@ -1,10 +1,5 @@
1
- import os
2
- import shutil
3
- import tempfile
4
- import uuid
5
- import pickle
6
- import atexit
7
- import threading
1
+ import os, shutil, tempfile, uuid, atexit, threading
2
+ from collections import OrderedDict
8
3
  import numpy as np
9
4
 
10
5
  class SwapManager:
@@ -14,66 +9,110 @@ class SwapManager:
14
9
  def __new__(cls, *args, **kwargs):
15
10
  with cls._lock:
16
11
  if cls._instance is None:
17
- cls._instance = super(SwapManager, cls).__new__(cls)
12
+ cls._instance = super().__new__(cls)
18
13
  cls._instance._initialized = False
19
14
  return cls._instance
20
15
 
21
- def __init__(self):
16
+ def __init__(self, *, cache_bytes: int = 1_000_000_000):
22
17
  if self._initialized:
23
18
  return
24
19
  self._initialized = True
25
-
26
- # Create a unique temp directory for this session
27
- self.temp_dir = os.path.join(tempfile.gettempdir(), "SetiAstroSuitePro_Swap", str(uuid.uuid4()))
20
+
21
+ self.temp_dir = os.path.join(
22
+ tempfile.gettempdir(), "SetiAstroSuitePro_Swap", str(uuid.uuid4())
23
+ )
28
24
  os.makedirs(self.temp_dir, exist_ok=True)
29
-
30
- # Register cleanup on exit
31
25
  atexit.register(self.cleanup_all)
32
26
 
27
+ # LRU of in-RAM states: swap_id -> ndarray
28
+ self._cache = OrderedDict()
29
+ self._cache_bytes = int(cache_bytes)
30
+ self._cache_used = 0
31
+ self._cache_lock = threading.Lock()
32
+
33
33
  def get_swap_path(self, swap_id: str) -> str:
34
- return os.path.join(self.temp_dir, f"{swap_id}.swap")
34
+ # store as .npy (fast + supports mmap)
35
+ return os.path.join(self.temp_dir, f"{swap_id}.npy")
36
+
37
+ def _arr_nbytes(self, a: np.ndarray) -> int:
38
+ try:
39
+ return int(a.nbytes)
40
+ except Exception:
41
+ return 0
42
+
43
+ def _cache_put(self, swap_id: str, arr: np.ndarray):
44
+ if arr is None:
45
+ return
46
+ n = self._arr_nbytes(arr)
47
+ if n <= 0:
48
+ return
35
49
 
36
- def save_state(self, image: np.ndarray) -> str:
37
- """
38
- Save the image array to a swap file.
39
- Returns the unique swap_id.
40
- """
50
+ with self._cache_lock:
51
+ # If already present, refresh
52
+ old = self._cache.pop(swap_id, None)
53
+ if old is not None:
54
+ self._cache_used -= self._arr_nbytes(old)
55
+
56
+ self._cache[swap_id] = arr
57
+ self._cache_used += n
58
+
59
+ # Evict LRU until under budget
60
+ while self._cache_used > self._cache_bytes and self._cache:
61
+ k, v = self._cache.popitem(last=False)
62
+ self._cache_used -= self._arr_nbytes(v)
63
+
64
+ def _cache_get(self, swap_id: str):
65
+ with self._cache_lock:
66
+ arr = self._cache.pop(swap_id, None)
67
+ if arr is None:
68
+ return None
69
+ # move to MRU
70
+ self._cache[swap_id] = arr
71
+ return arr
72
+
73
+ def save_state(self, image: np.ndarray) -> str | None:
41
74
  swap_id = uuid.uuid4().hex
42
75
  path = self.get_swap_path(swap_id)
43
-
44
- # We only save the image data to disk. Metadata is kept in RAM by the caller.
45
- # Using pickle for simplicity and robustness with numpy arrays.
46
- # For pure numpy arrays, np.save might be slightly faster, but pickle is more flexible if we change what we store.
47
- # Let's stick to pickle for now as per plan.
48
76
  try:
49
- with open(path, "wb") as f:
50
- pickle.dump(image, f, protocol=pickle.HIGHEST_PROTOCOL)
77
+ # Write fast .npy
78
+ np.save(path, image, allow_pickle=False)
79
+ # Optionally keep it hot in RAM too (depends how you use it)
80
+ self._cache_put(swap_id, image)
81
+ return swap_id
51
82
  except Exception as e:
52
83
  print(f"[SwapManager] Failed to save state {swap_id}: {e}")
53
84
  return None
54
-
55
- return swap_id
56
85
 
57
86
  def load_state(self, swap_id: str) -> np.ndarray | None:
58
- """
59
- Load the image array from the swap file.
60
- """
87
+ #print("[SwapManager] LOAD", swap_id)
88
+ # First: try RAM
89
+ hot = self._cache_get(swap_id)
90
+ if hot is not None:
91
+ return hot
92
+
61
93
  path = self.get_swap_path(swap_id)
62
94
  if not os.path.exists(path):
63
95
  print(f"[SwapManager] Swap file not found: {path}")
64
96
  return None
65
-
97
+
66
98
  try:
67
- with open(path, "rb") as f:
68
- return pickle.load(f)
99
+ # mmap_mode="r" is extremely fast; convert to real ndarray only if needed
100
+ arr = np.load(path, mmap_mode="r", allow_pickle=False)
101
+ # If your pipeline needs a writable array, materialize:
102
+ # arr = np.array(arr, copy=True)
103
+ # Cache the loaded array (mmap object still OK to cache; you can decide)
104
+ self._cache_put(swap_id, np.array(arr, copy=False))
105
+ return np.array(arr, copy=False)
69
106
  except Exception as e:
70
107
  print(f"[SwapManager] Failed to load state {swap_id}: {e}")
71
108
  return None
72
109
 
73
110
  def delete_state(self, swap_id: str):
74
- """
75
- Delete a specific swap file.
76
- """
111
+ with self._cache_lock:
112
+ old = self._cache.pop(swap_id, None)
113
+ if old is not None:
114
+ self._cache_used -= self._arr_nbytes(old)
115
+
77
116
  path = self.get_swap_path(swap_id)
78
117
  try:
79
118
  if os.path.exists(path):
@@ -82,13 +121,9 @@ class SwapManager:
82
121
  print(f"[SwapManager] Failed to delete state {swap_id}: {e}")
83
122
 
84
123
  def cleanup_all(self):
85
- """
86
- Delete the entire temporary directory for this session.
87
- """
88
124
  try:
89
125
  if os.path.exists(self.temp_dir):
90
126
  shutil.rmtree(self.temp_dir, ignore_errors=True)
91
- # print(f"[SwapManager] Cleaned up {self.temp_dir}")
92
127
  except Exception as e:
93
128
  print(f"[SwapManager] Cleanup failed: {e}")
94
129