setiastrosuitepro 1.6.1.post1__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 (127) hide show
  1. setiastro/images/Background_startup.jpg +0 -0
  2. setiastro/qml/ResourceMonitor.qml +126 -0
  3. setiastro/saspro/__main__.py +162 -25
  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 +272 -29
  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 +1 -0
  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 +36 -5
  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 +35 -15
  122. {setiastrosuitepro-1.6.1.post1.dist-info → setiastrosuitepro-1.6.2.dist-info}/METADATA +15 -4
  123. {setiastrosuitepro-1.6.1.post1.dist-info → setiastrosuitepro-1.6.2.dist-info}/RECORD +127 -104
  124. {setiastrosuitepro-1.6.1.post1.dist-info → setiastrosuitepro-1.6.2.dist-info}/WHEEL +0 -0
  125. {setiastrosuitepro-1.6.1.post1.dist-info → setiastrosuitepro-1.6.2.dist-info}/entry_points.txt +0 -0
  126. {setiastrosuitepro-1.6.1.post1.dist-info → setiastrosuitepro-1.6.2.dist-info}/licenses/LICENSE +0 -0
  127. {setiastrosuitepro-1.6.1.post1.dist-info → setiastrosuitepro-1.6.2.dist-info}/licenses/license.txt +0 -0
@@ -24,6 +24,7 @@ def _make_executor(max_workers: int):
24
24
  # return ProcessPoolExecutor(max_workers=max_workers)
25
25
  return ThreadPoolExecutor(max_workers=max_workers)
26
26
 
27
+
27
28
  import gc # For explicit memory cleanup after heavy operations
28
29
  import os as _os
29
30
  import threading as _threading
@@ -286,8 +287,18 @@ def _warp_like_ref(target_img: np.ndarray, M_2x3: np.ndarray, ref_shape_hw: tupl
286
287
  return cv2.warpAffine(target_img, M_2x3, (W, H),
287
288
  flags=cv2.INTER_LANCZOS4, borderMode=cv2.BORDER_CONSTANT, borderValue=0)
288
289
 
290
+ # Optimization: If standard RGB/BGR (3 channels) or 4 channels, OpenCV handles it natively.
291
+ # Note: OpenCV warpAffine support n-channel images, but typically 1, 3, or 4.
292
+ C = target_img.shape[2]
293
+ if C <= 4:
294
+ if not target_img.flags['C_CONTIGUOUS']:
295
+ target_img = np.ascontiguousarray(target_img)
296
+ return cv2.warpAffine(target_img, M_2x3, (W, H),
297
+ flags=cv2.INTER_LANCZOS4, borderMode=cv2.BORDER_CONSTANT, borderValue=0)
298
+
299
+ # Fallback for >4 channels (e.g. hyperspectral or special stacks)
289
300
  chs = []
290
- for i in range(target_img.shape[2]):
301
+ for i in range(C):
291
302
  ch = target_img[..., i]
292
303
  if not ch.flags['C_CONTIGUOUS']:
293
304
  ch = np.ascontiguousarray(ch)
@@ -618,6 +629,7 @@ class StellarAlignmentDialog(QDialog):
618
629
  super().__init__(parent)
619
630
  self.setWindowTitle("Stellar Alignment")
620
631
  self.setWindowFlag(Qt.WindowType.Window, True)
632
+ self.setWindowModality(Qt.WindowModality.NonModal)
621
633
  self.setModal(False)
622
634
  #self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
623
635
 
@@ -4639,6 +4651,9 @@ class MosaicMasterDialog(QDialog):
4639
4651
  self._list_open_docs_fn = list_open_docs_fn
4640
4652
 
4641
4653
  self.setWindowTitle("Mosaic Master")
4654
+ self.setWindowFlag(Qt.WindowType.Window, True)
4655
+ self.setWindowModality(Qt.WindowModality.NonModal)
4656
+ self.setModal(False)
4642
4657
  self.wrench_path = wrench_path
4643
4658
  self.spinner_path = spinner_path
4644
4659
 
@@ -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:
@@ -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