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.
- setiastro/images/Background_startup.jpg +0 -0
- setiastro/images/rotatearbitrary.png +0 -0
- setiastro/qml/ResourceMonitor.qml +126 -0
- setiastro/saspro/__main__.py +162 -25
- setiastro/saspro/_generated/build_info.py +2 -1
- setiastro/saspro/abe.py +62 -11
- setiastro/saspro/aberration_ai.py +3 -3
- setiastro/saspro/add_stars.py +5 -2
- setiastro/saspro/astrobin_exporter.py +3 -0
- setiastro/saspro/astrospike_python.py +3 -1
- setiastro/saspro/autostretch.py +4 -2
- setiastro/saspro/backgroundneutral.py +60 -9
- setiastro/saspro/batch_convert.py +3 -0
- setiastro/saspro/batch_renamer.py +3 -0
- setiastro/saspro/blemish_blaster.py +3 -0
- setiastro/saspro/blink_comparator_pro.py +474 -251
- setiastro/saspro/cheat_sheet.py +50 -15
- setiastro/saspro/clahe.py +27 -1
- setiastro/saspro/comet_stacking.py +103 -38
- setiastro/saspro/convo.py +3 -0
- setiastro/saspro/copyastro.py +3 -0
- setiastro/saspro/cosmicclarity.py +70 -45
- setiastro/saspro/crop_dialog_pro.py +28 -1
- setiastro/saspro/curve_editor_pro.py +18 -0
- setiastro/saspro/debayer.py +3 -0
- setiastro/saspro/doc_manager.py +40 -17
- setiastro/saspro/fitsmodifier.py +3 -0
- setiastro/saspro/frequency_separation.py +8 -2
- setiastro/saspro/function_bundle.py +18 -16
- setiastro/saspro/generate_translations.py +715 -1
- setiastro/saspro/ghs_dialog_pro.py +3 -0
- setiastro/saspro/graxpert.py +3 -0
- setiastro/saspro/gui/main_window.py +364 -92
- setiastro/saspro/gui/mixins/dock_mixin.py +119 -7
- setiastro/saspro/gui/mixins/file_mixin.py +7 -0
- setiastro/saspro/gui/mixins/geometry_mixin.py +105 -5
- setiastro/saspro/gui/mixins/menu_mixin.py +29 -0
- setiastro/saspro/gui/mixins/toolbar_mixin.py +33 -10
- setiastro/saspro/gui/statistics_dialog.py +47 -0
- setiastro/saspro/halobgon.py +29 -3
- setiastro/saspro/histogram.py +3 -0
- setiastro/saspro/history_explorer.py +2 -0
- setiastro/saspro/i18n.py +22 -10
- setiastro/saspro/image_combine.py +3 -0
- setiastro/saspro/image_peeker_pro.py +3 -0
- setiastro/saspro/imageops/stretch.py +5 -13
- setiastro/saspro/isophote.py +3 -0
- setiastro/saspro/legacy/numba_utils.py +64 -47
- setiastro/saspro/linear_fit.py +3 -0
- setiastro/saspro/live_stacking.py +13 -2
- setiastro/saspro/mask_creation.py +3 -0
- setiastro/saspro/mfdeconv.py +5 -0
- setiastro/saspro/morphology.py +30 -5
- setiastro/saspro/multiscale_decomp.py +713 -256
- setiastro/saspro/nbtorgb_stars.py +12 -2
- setiastro/saspro/numba_utils.py +148 -47
- setiastro/saspro/ops/scripts.py +77 -17
- setiastro/saspro/ops/settings.py +1 -43
- setiastro/saspro/perfect_palette_picker.py +1 -0
- setiastro/saspro/pixelmath.py +6 -2
- setiastro/saspro/plate_solver.py +1 -0
- setiastro/saspro/remove_green.py +18 -1
- setiastro/saspro/remove_stars.py +136 -162
- setiastro/saspro/remove_stars_preset.py +55 -13
- setiastro/saspro/resources.py +36 -10
- setiastro/saspro/rgb_combination.py +1 -0
- setiastro/saspro/rgbalign.py +4 -4
- setiastro/saspro/save_options.py +1 -0
- setiastro/saspro/selective_color.py +79 -20
- setiastro/saspro/sfcc.py +50 -8
- setiastro/saspro/shortcuts.py +94 -21
- setiastro/saspro/signature_insert.py +3 -0
- setiastro/saspro/stacking_suite.py +924 -446
- setiastro/saspro/star_alignment.py +291 -331
- setiastro/saspro/star_spikes.py +116 -32
- setiastro/saspro/star_stretch.py +38 -1
- setiastro/saspro/stat_stretch.py +35 -3
- setiastro/saspro/status_log_dock.py +1 -1
- setiastro/saspro/subwindow.py +63 -2
- setiastro/saspro/supernovaasteroidhunter.py +3 -0
- setiastro/saspro/swap_manager.py +77 -42
- setiastro/saspro/translations/all_source_strings.json +4726 -0
- setiastro/saspro/translations/ar_translations.py +4096 -0
- setiastro/saspro/translations/de_translations.py +441 -446
- setiastro/saspro/translations/es_translations.py +278 -32
- setiastro/saspro/translations/fr_translations.py +280 -32
- setiastro/saspro/translations/hi_translations.py +3803 -0
- setiastro/saspro/translations/integrate_translations.py +38 -1
- setiastro/saspro/translations/it_translations.py +1211 -145
- setiastro/saspro/translations/ja_translations.py +556 -307
- setiastro/saspro/translations/pt_translations.py +3316 -3322
- setiastro/saspro/translations/ru_translations.py +3082 -0
- setiastro/saspro/translations/saspro_ar.qm +0 -0
- setiastro/saspro/translations/saspro_ar.ts +16019 -0
- setiastro/saspro/translations/saspro_de.qm +0 -0
- setiastro/saspro/translations/saspro_de.ts +14428 -133
- setiastro/saspro/translations/saspro_es.qm +0 -0
- setiastro/saspro/translations/saspro_es.ts +11503 -7821
- setiastro/saspro/translations/saspro_fr.qm +0 -0
- setiastro/saspro/translations/saspro_fr.ts +11168 -7812
- setiastro/saspro/translations/saspro_hi.qm +0 -0
- setiastro/saspro/translations/saspro_hi.ts +14855 -0
- setiastro/saspro/translations/saspro_it.qm +0 -0
- setiastro/saspro/translations/saspro_it.ts +14347 -7821
- setiastro/saspro/translations/saspro_ja.qm +0 -0
- setiastro/saspro/translations/saspro_ja.ts +14860 -137
- setiastro/saspro/translations/saspro_pt.qm +0 -0
- setiastro/saspro/translations/saspro_pt.ts +14904 -137
- setiastro/saspro/translations/saspro_ru.qm +0 -0
- setiastro/saspro/translations/saspro_ru.ts +11835 -0
- setiastro/saspro/translations/saspro_sw.qm +0 -0
- setiastro/saspro/translations/saspro_sw.ts +15237 -0
- setiastro/saspro/translations/saspro_uk.qm +0 -0
- setiastro/saspro/translations/saspro_uk.ts +15248 -0
- setiastro/saspro/translations/saspro_zh.qm +0 -0
- setiastro/saspro/translations/saspro_zh.ts +10581 -7812
- setiastro/saspro/translations/sw_translations.py +3897 -0
- setiastro/saspro/translations/uk_translations.py +3929 -0
- setiastro/saspro/translations/zh_translations.py +283 -32
- setiastro/saspro/versioning.py +36 -5
- setiastro/saspro/view_bundle.py +20 -17
- setiastro/saspro/wavescale_hdr.py +22 -1
- setiastro/saspro/wavescalede.py +23 -1
- setiastro/saspro/whitebalance.py +39 -3
- setiastro/saspro/widgets/minigame/game.js +991 -0
- setiastro/saspro/widgets/minigame/index.html +53 -0
- setiastro/saspro/widgets/minigame/style.css +241 -0
- setiastro/saspro/widgets/resource_monitor.py +263 -0
- setiastro/saspro/widgets/spinboxes.py +18 -0
- setiastro/saspro/widgets/wavelet_utils.py +52 -20
- setiastro/saspro/wimi.py +100 -80
- setiastro/saspro/wims.py +33 -33
- setiastro/saspro/window_shelf.py +2 -2
- {setiastrosuitepro-1.6.1.post1.dist-info → setiastrosuitepro-1.6.4.dist-info}/METADATA +15 -4
- {setiastrosuitepro-1.6.1.post1.dist-info → setiastrosuitepro-1.6.4.dist-info}/RECORD +139 -115
- {setiastrosuitepro-1.6.1.post1.dist-info → setiastrosuitepro-1.6.4.dist-info}/WHEEL +0 -0
- {setiastrosuitepro-1.6.1.post1.dist-info → setiastrosuitepro-1.6.4.dist-info}/entry_points.txt +0 -0
- {setiastrosuitepro-1.6.1.post1.dist-info → setiastrosuitepro-1.6.4.dist-info}/licenses/LICENSE +0 -0
- {setiastrosuitepro-1.6.1.post1.dist-info → setiastrosuitepro-1.6.4.dist-info}/licenses/license.txt +0 -0
setiastro/saspro/star_spikes.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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
|
-
|
|
422
|
-
if
|
|
423
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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):
|
setiastro/saspro/star_stretch.py
CHANGED
|
@@ -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
|
-
|
|
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 ---
|
setiastro/saspro/stat_stretch.py
CHANGED
|
@@ -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
|
-
#
|
|
29
|
-
self.setWindowModality(Qt.WindowModality.
|
|
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
|
-
|
|
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
|
setiastro/saspro/subwindow.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
setiastro/saspro/swap_manager.py
CHANGED
|
@@ -1,10 +1,5 @@
|
|
|
1
|
-
import os
|
|
2
|
-
import
|
|
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(
|
|
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
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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
|
|