setiastrosuitepro 1.6.0__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/__init__.py +2 -0
- setiastro/saspro/__init__.py +20 -0
- setiastro/saspro/__main__.py +784 -0
- setiastro/saspro/_generated/__init__.py +7 -0
- setiastro/saspro/_generated/build_info.py +2 -0
- setiastro/saspro/abe.py +1295 -0
- setiastro/saspro/abe_preset.py +196 -0
- setiastro/saspro/aberration_ai.py +694 -0
- setiastro/saspro/aberration_ai_preset.py +224 -0
- setiastro/saspro/accel_installer.py +218 -0
- setiastro/saspro/accel_workers.py +30 -0
- setiastro/saspro/add_stars.py +621 -0
- setiastro/saspro/astrobin_exporter.py +1007 -0
- setiastro/saspro/astrospike.py +153 -0
- setiastro/saspro/astrospike_python.py +1839 -0
- setiastro/saspro/autostretch.py +196 -0
- setiastro/saspro/backgroundneutral.py +560 -0
- setiastro/saspro/batch_convert.py +325 -0
- setiastro/saspro/batch_renamer.py +519 -0
- setiastro/saspro/blemish_blaster.py +488 -0
- setiastro/saspro/blink_comparator_pro.py +2923 -0
- setiastro/saspro/bundles.py +61 -0
- setiastro/saspro/bundles_dock.py +114 -0
- setiastro/saspro/cheat_sheet.py +168 -0
- setiastro/saspro/clahe.py +342 -0
- setiastro/saspro/comet_stacking.py +1377 -0
- setiastro/saspro/config.py +38 -0
- setiastro/saspro/config_bootstrap.py +40 -0
- setiastro/saspro/config_manager.py +316 -0
- setiastro/saspro/continuum_subtract.py +1617 -0
- setiastro/saspro/convo.py +1397 -0
- setiastro/saspro/convo_preset.py +414 -0
- setiastro/saspro/copyastro.py +187 -0
- setiastro/saspro/cosmicclarity.py +1564 -0
- setiastro/saspro/cosmicclarity_preset.py +407 -0
- setiastro/saspro/crop_dialog_pro.py +948 -0
- setiastro/saspro/crop_preset.py +189 -0
- setiastro/saspro/curve_editor_pro.py +2544 -0
- setiastro/saspro/curves_preset.py +375 -0
- setiastro/saspro/debayer.py +670 -0
- setiastro/saspro/debug_utils.py +29 -0
- setiastro/saspro/dnd_mime.py +35 -0
- setiastro/saspro/doc_manager.py +2634 -0
- setiastro/saspro/exoplanet_detector.py +2166 -0
- setiastro/saspro/file_utils.py +284 -0
- setiastro/saspro/fitsmodifier.py +744 -0
- setiastro/saspro/free_torch_memory.py +48 -0
- setiastro/saspro/frequency_separation.py +1343 -0
- setiastro/saspro/function_bundle.py +1594 -0
- setiastro/saspro/ghs_dialog_pro.py +660 -0
- setiastro/saspro/ghs_preset.py +284 -0
- setiastro/saspro/graxpert.py +634 -0
- setiastro/saspro/graxpert_preset.py +287 -0
- setiastro/saspro/gui/__init__.py +0 -0
- setiastro/saspro/gui/main_window.py +8494 -0
- setiastro/saspro/gui/mixins/__init__.py +33 -0
- setiastro/saspro/gui/mixins/dock_mixin.py +263 -0
- setiastro/saspro/gui/mixins/file_mixin.py +445 -0
- setiastro/saspro/gui/mixins/geometry_mixin.py +403 -0
- setiastro/saspro/gui/mixins/header_mixin.py +441 -0
- setiastro/saspro/gui/mixins/mask_mixin.py +421 -0
- setiastro/saspro/gui/mixins/menu_mixin.py +361 -0
- setiastro/saspro/gui/mixins/theme_mixin.py +367 -0
- setiastro/saspro/gui/mixins/toolbar_mixin.py +1324 -0
- setiastro/saspro/gui/mixins/update_mixin.py +309 -0
- setiastro/saspro/gui/mixins/view_mixin.py +435 -0
- setiastro/saspro/halobgon.py +462 -0
- setiastro/saspro/header_viewer.py +445 -0
- setiastro/saspro/headless_utils.py +88 -0
- setiastro/saspro/histogram.py +753 -0
- setiastro/saspro/history_explorer.py +939 -0
- setiastro/saspro/image_combine.py +414 -0
- setiastro/saspro/image_peeker_pro.py +1596 -0
- setiastro/saspro/imageops/__init__.py +37 -0
- setiastro/saspro/imageops/mdi_snap.py +292 -0
- setiastro/saspro/imageops/scnr.py +36 -0
- setiastro/saspro/imageops/starbasedwhitebalance.py +210 -0
- setiastro/saspro/imageops/stretch.py +244 -0
- setiastro/saspro/isophote.py +1179 -0
- setiastro/saspro/layers.py +208 -0
- setiastro/saspro/layers_dock.py +714 -0
- setiastro/saspro/lazy_imports.py +193 -0
- setiastro/saspro/legacy/__init__.py +2 -0
- setiastro/saspro/legacy/image_manager.py +2226 -0
- setiastro/saspro/legacy/numba_utils.py +3659 -0
- setiastro/saspro/legacy/xisf.py +1071 -0
- setiastro/saspro/linear_fit.py +534 -0
- setiastro/saspro/live_stacking.py +1830 -0
- setiastro/saspro/log_bus.py +5 -0
- setiastro/saspro/logging_config.py +460 -0
- setiastro/saspro/luminancerecombine.py +309 -0
- setiastro/saspro/main_helpers.py +201 -0
- setiastro/saspro/mask_creation.py +928 -0
- setiastro/saspro/masks_core.py +56 -0
- setiastro/saspro/mdi_widgets.py +353 -0
- setiastro/saspro/memory_utils.py +666 -0
- setiastro/saspro/metadata_patcher.py +75 -0
- setiastro/saspro/mfdeconv.py +3826 -0
- setiastro/saspro/mfdeconv_earlystop.py +71 -0
- setiastro/saspro/mfdeconvcudnn.py +3263 -0
- setiastro/saspro/mfdeconvsport.py +2382 -0
- setiastro/saspro/minorbodycatalog.py +567 -0
- setiastro/saspro/morphology.py +382 -0
- setiastro/saspro/multiscale_decomp.py +1290 -0
- setiastro/saspro/nbtorgb_stars.py +531 -0
- setiastro/saspro/numba_utils.py +3044 -0
- setiastro/saspro/numba_warmup.py +141 -0
- setiastro/saspro/ops/__init__.py +9 -0
- setiastro/saspro/ops/command_help_dialog.py +623 -0
- setiastro/saspro/ops/command_runner.py +217 -0
- setiastro/saspro/ops/commands.py +1594 -0
- setiastro/saspro/ops/script_editor.py +1102 -0
- setiastro/saspro/ops/scripts.py +1413 -0
- setiastro/saspro/ops/settings.py +560 -0
- setiastro/saspro/parallel_utils.py +554 -0
- setiastro/saspro/pedestal.py +121 -0
- setiastro/saspro/perfect_palette_picker.py +1053 -0
- setiastro/saspro/pipeline.py +110 -0
- setiastro/saspro/pixelmath.py +1600 -0
- setiastro/saspro/plate_solver.py +2435 -0
- setiastro/saspro/project_io.py +797 -0
- setiastro/saspro/psf_utils.py +136 -0
- setiastro/saspro/psf_viewer.py +549 -0
- setiastro/saspro/pyi_rthook_astroquery.py +95 -0
- setiastro/saspro/remove_green.py +314 -0
- setiastro/saspro/remove_stars.py +1625 -0
- setiastro/saspro/remove_stars_preset.py +404 -0
- setiastro/saspro/resources.py +472 -0
- setiastro/saspro/rgb_combination.py +207 -0
- setiastro/saspro/rgb_extract.py +19 -0
- setiastro/saspro/rgbalign.py +723 -0
- setiastro/saspro/runtime_imports.py +7 -0
- setiastro/saspro/runtime_torch.py +754 -0
- setiastro/saspro/save_options.py +72 -0
- setiastro/saspro/selective_color.py +1552 -0
- setiastro/saspro/sfcc.py +1425 -0
- setiastro/saspro/shortcuts.py +2807 -0
- setiastro/saspro/signature_insert.py +1099 -0
- setiastro/saspro/stacking_suite.py +17712 -0
- setiastro/saspro/star_alignment.py +7420 -0
- setiastro/saspro/star_alignment_preset.py +329 -0
- setiastro/saspro/star_metrics.py +49 -0
- setiastro/saspro/star_spikes.py +681 -0
- setiastro/saspro/star_stretch.py +470 -0
- setiastro/saspro/stat_stretch.py +502 -0
- setiastro/saspro/status_log_dock.py +78 -0
- setiastro/saspro/subwindow.py +3267 -0
- setiastro/saspro/supernovaasteroidhunter.py +1712 -0
- setiastro/saspro/swap_manager.py +99 -0
- setiastro/saspro/torch_backend.py +89 -0
- setiastro/saspro/torch_rejection.py +434 -0
- setiastro/saspro/view_bundle.py +1555 -0
- setiastro/saspro/wavescale_hdr.py +624 -0
- setiastro/saspro/wavescale_hdr_preset.py +100 -0
- setiastro/saspro/wavescalede.py +657 -0
- setiastro/saspro/wavescalede_preset.py +228 -0
- setiastro/saspro/wcs_update.py +374 -0
- setiastro/saspro/whitebalance.py +456 -0
- setiastro/saspro/widgets/__init__.py +48 -0
- setiastro/saspro/widgets/common_utilities.py +305 -0
- setiastro/saspro/widgets/graphics_views.py +122 -0
- setiastro/saspro/widgets/image_utils.py +518 -0
- setiastro/saspro/widgets/preview_dialogs.py +280 -0
- setiastro/saspro/widgets/spinboxes.py +275 -0
- setiastro/saspro/widgets/themed_buttons.py +13 -0
- setiastro/saspro/widgets/wavelet_utils.py +299 -0
- setiastro/saspro/window_shelf.py +185 -0
- setiastro/saspro/xisf.py +1123 -0
- setiastrosuitepro-1.6.0.dist-info/METADATA +266 -0
- setiastrosuitepro-1.6.0.dist-info/RECORD +174 -0
- setiastrosuitepro-1.6.0.dist-info/WHEEL +4 -0
- setiastrosuitepro-1.6.0.dist-info/entry_points.txt +6 -0
- setiastrosuitepro-1.6.0.dist-info/licenses/LICENSE +674 -0
- setiastrosuitepro-1.6.0.dist-info/licenses/license.txt +2580 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# pro/psf_utils.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import numpy as np
|
|
4
|
+
import sep
|
|
5
|
+
|
|
6
|
+
EPS = 1e-6
|
|
7
|
+
|
|
8
|
+
def _to_luma(img: np.ndarray) -> np.ndarray:
|
|
9
|
+
if img.ndim == 2: return img.astype(np.float32, copy=False)
|
|
10
|
+
r, g, b = img[...,0], img[...,1], img[...,2]
|
|
11
|
+
return (0.2126*r + 0.7152*g + 0.0722*b).astype(np.float32)
|
|
12
|
+
|
|
13
|
+
def _cutout(arr, cy, cx, k):
|
|
14
|
+
H, W = arr.shape
|
|
15
|
+
y0 = int(round(cy)) - k//2
|
|
16
|
+
x0 = int(round(cx)) - k//2
|
|
17
|
+
y1, x1 = y0 + k, x0 + k
|
|
18
|
+
if y0 < 0 or x0 < 0 or y1 > H or x1 > W:
|
|
19
|
+
return None
|
|
20
|
+
return arr[y0:y1, x0:x1].astype(np.float32, copy=False)
|
|
21
|
+
|
|
22
|
+
def _subpixel_shift_to_center(patch: np.ndarray) -> np.ndarray:
|
|
23
|
+
"""Shift brightest-peak to exact center using Fourier shift (sub-pixel)."""
|
|
24
|
+
from scipy.ndimage import fourier_shift
|
|
25
|
+
import numpy.fft as fft
|
|
26
|
+
k = patch.shape[0]
|
|
27
|
+
# peak location
|
|
28
|
+
yy, xx = np.unravel_index(np.argmax(patch), patch.shape)
|
|
29
|
+
cy = (k-1)/2
|
|
30
|
+
shift = (cy-yy, cy-xx)
|
|
31
|
+
F = fft.fftn(patch)
|
|
32
|
+
Fs = fourier_shift(F, shift)
|
|
33
|
+
out = fft.ifftn(Fs).real.astype(np.float32)
|
|
34
|
+
return out
|
|
35
|
+
|
|
36
|
+
def compute_psf_kernel_for_image(
|
|
37
|
+
image: np.ndarray,
|
|
38
|
+
*,
|
|
39
|
+
ksize: int | None = 21,
|
|
40
|
+
det_sigma: float = 6.0,
|
|
41
|
+
max_stars: int = 60,
|
|
42
|
+
max_ecc: float = 0.5,
|
|
43
|
+
min_flux: float = 0.0,
|
|
44
|
+
max_frac_saturation: float = 0.98, # was 0.8 → far too strict
|
|
45
|
+
return_info: bool = True # new: return (psf, info)
|
|
46
|
+
) -> np.ndarray | tuple[np.ndarray, dict] | None:
|
|
47
|
+
"""
|
|
48
|
+
Returns a normalized (ksize×ksize) PSF (or (psf, info) if return_info=True).
|
|
49
|
+
- SEP detects stars; rejects saturated/elongated/low-flux sources.
|
|
50
|
+
- Subpixel centers each cutout and median-combines.
|
|
51
|
+
- Auto-selects ksize if None, or downsizes when stars are very small.
|
|
52
|
+
"""
|
|
53
|
+
if image is None:
|
|
54
|
+
return None
|
|
55
|
+
img = _to_luma(image)
|
|
56
|
+
info = {}
|
|
57
|
+
|
|
58
|
+
# Robust background & detection
|
|
59
|
+
bkg = sep.Background(img)
|
|
60
|
+
data = img - bkg.back()
|
|
61
|
+
try: err = bkg.globalrms
|
|
62
|
+
except Exception: err = float(np.median(bkg.rms()))
|
|
63
|
+
sources = sep.extract(data, det_sigma, err=err)
|
|
64
|
+
if sources is None or len(sources) == 0:
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
# Star size & shape
|
|
68
|
+
a = np.array(sources["a"], dtype=np.float32) # SEP Gaussian sigma along major axis (≈ σ)
|
|
69
|
+
b = np.array(sources["b"], dtype=np.float32)
|
|
70
|
+
ecc = np.sqrt(1.0 - (b / np.maximum(a, 1e-9))**2)
|
|
71
|
+
flux = np.array(sources["flux"], dtype=np.float32)
|
|
72
|
+
|
|
73
|
+
# Estimate typical FWHM in px from 'a' (use median of central bulk)
|
|
74
|
+
good_a = a[np.isfinite(a) & (a > 0.5)]
|
|
75
|
+
if good_a.size:
|
|
76
|
+
sigma_med = float(np.median(good_a))
|
|
77
|
+
fwhm_med = 2.3548 * sigma_med
|
|
78
|
+
else:
|
|
79
|
+
sigma_med, fwhm_med = 1.2, 2.8 # fallback
|
|
80
|
+
|
|
81
|
+
# Auto kernel size if None or wildly big vs star size
|
|
82
|
+
if (ksize is None) or (ksize > int(6.0 * sigma_med) + 1):
|
|
83
|
+
ksize = int(2 * np.ceil(3.0 * sigma_med) + 1)
|
|
84
|
+
ksize = int(np.clip(ksize, 9, 25)) # clamp to practical window
|
|
85
|
+
k = int(ksize) | 1 # enforce odd
|
|
86
|
+
info.update({"ksize": k, "fwhm_med_px": fwhm_med})
|
|
87
|
+
|
|
88
|
+
# Filtering
|
|
89
|
+
idx = np.where(
|
|
90
|
+
(np.isfinite(a)) & (np.isfinite(b)) &
|
|
91
|
+
(a > 0.5) & (b > 0.5) &
|
|
92
|
+
(ecc <= max_ecc) &
|
|
93
|
+
(flux > min_flux)
|
|
94
|
+
)[0]
|
|
95
|
+
info["detected"] = int(len(sources))
|
|
96
|
+
|
|
97
|
+
if idx.size == 0:
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
# Bright-ish first, cap
|
|
101
|
+
idx = idx[np.argsort(-flux[idx])]
|
|
102
|
+
idx = idx[:max_stars]
|
|
103
|
+
|
|
104
|
+
patches, rejected = [], 0
|
|
105
|
+
for i in idx:
|
|
106
|
+
cy, cx = float(sources["y"][i]), float(sources["x"][i])
|
|
107
|
+
patch = _cutout(data, cy, cx, k)
|
|
108
|
+
if patch is None:
|
|
109
|
+
rejected += 1; continue
|
|
110
|
+
peak = float(np.max(patch))
|
|
111
|
+
center = float(patch[k//2, k//2])
|
|
112
|
+
# reject *obvious* clipped cores only
|
|
113
|
+
if peak > 0 and (center / (peak + EPS)) >= max_frac_saturation:
|
|
114
|
+
rejected += 1; continue
|
|
115
|
+
try:
|
|
116
|
+
patch = _subpixel_shift_to_center(patch)
|
|
117
|
+
except Exception:
|
|
118
|
+
pass
|
|
119
|
+
s = float(np.sum(patch))
|
|
120
|
+
if s <= 0:
|
|
121
|
+
rejected += 1; continue
|
|
122
|
+
patches.append(patch / (s + EPS))
|
|
123
|
+
|
|
124
|
+
info["rejected"] = int(rejected)
|
|
125
|
+
info["used_stars"] = int(len(patches))
|
|
126
|
+
|
|
127
|
+
if not patches:
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
psf = np.median(np.stack(patches, axis=0), axis=0).astype(np.float32, copy=False)
|
|
131
|
+
s = float(psf.sum())
|
|
132
|
+
if s <= 0:
|
|
133
|
+
return None
|
|
134
|
+
psf = psf / (s + EPS)
|
|
135
|
+
return (psf, info) if return_info else psf
|
|
136
|
+
|
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
# pro/psf_viewer.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
import sep
|
|
6
|
+
from astropy.table import Table
|
|
7
|
+
|
|
8
|
+
from PyQt6.QtCore import Qt, QTimer
|
|
9
|
+
from PyQt6.QtGui import QPainter, QPen, QFont, QPixmap
|
|
10
|
+
from PyQt6.QtWidgets import (
|
|
11
|
+
QDialog, QLabel, QPushButton, QVBoxLayout, QHBoxLayout, QScrollArea,
|
|
12
|
+
QSlider, QTableWidget, QTableWidgetItem, QApplication, QMessageBox
|
|
13
|
+
)
|
|
14
|
+
from setiastro.saspro.widgets.themed_buttons import themed_toolbtn
|
|
15
|
+
|
|
16
|
+
from PyQt6.QtCore import Qt, QTimer, QThread, pyqtSignal, QObject
|
|
17
|
+
from PyQt6.QtWidgets import QWidget
|
|
18
|
+
|
|
19
|
+
class _ProcessingOverlay(QWidget):
|
|
20
|
+
def __init__(self, parent):
|
|
21
|
+
super().__init__(parent)
|
|
22
|
+
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
|
|
23
|
+
self.setStyleSheet("""
|
|
24
|
+
QWidget {
|
|
25
|
+
background: rgba(0,0,0,140);
|
|
26
|
+
border-radius: 10px;
|
|
27
|
+
}
|
|
28
|
+
QLabel {
|
|
29
|
+
color: white;
|
|
30
|
+
font-size: 14px;
|
|
31
|
+
font-weight: 600;
|
|
32
|
+
}
|
|
33
|
+
""")
|
|
34
|
+
lay = QVBoxLayout(self)
|
|
35
|
+
lay.setContentsMargins(18, 14, 18, 14)
|
|
36
|
+
self.lbl = QLabel("Processing…", self)
|
|
37
|
+
self.lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
38
|
+
lay.addWidget(self.lbl)
|
|
39
|
+
|
|
40
|
+
def setText(self, s: str):
|
|
41
|
+
self.lbl.setText(s)
|
|
42
|
+
|
|
43
|
+
class _PSFWorker(QObject):
|
|
44
|
+
finished = pyqtSignal(object, str) # (Table or None, status_text)
|
|
45
|
+
failed = pyqtSignal(str)
|
|
46
|
+
|
|
47
|
+
def __init__(self, image: np.ndarray, threshold_sigma: float):
|
|
48
|
+
super().__init__()
|
|
49
|
+
self.image = image
|
|
50
|
+
self.threshold_sigma = float(threshold_sigma)
|
|
51
|
+
|
|
52
|
+
def run(self):
|
|
53
|
+
try:
|
|
54
|
+
if self.image is None:
|
|
55
|
+
self.finished.emit(None, "Status: No image.")
|
|
56
|
+
return
|
|
57
|
+
|
|
58
|
+
# grayscale
|
|
59
|
+
if self.image.ndim == 3:
|
|
60
|
+
image_gray = np.mean(self.image, axis=2)
|
|
61
|
+
else:
|
|
62
|
+
image_gray = self.image
|
|
63
|
+
data = image_gray.astype(np.float32, copy=False)
|
|
64
|
+
|
|
65
|
+
# background
|
|
66
|
+
bkg = sep.Background(data)
|
|
67
|
+
data_sub = data - bkg.back()
|
|
68
|
+
try:
|
|
69
|
+
err_val = bkg.globalrms
|
|
70
|
+
except Exception:
|
|
71
|
+
err_val = float(np.median(bkg.rms()))
|
|
72
|
+
|
|
73
|
+
sources = sep.extract(data_sub, self.threshold_sigma, err=err_val)
|
|
74
|
+
if sources is None or len(sources) == 0:
|
|
75
|
+
self.finished.emit(None, "Status: Extraction completed — 0 sources.")
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
# HFR proxy
|
|
79
|
+
try:
|
|
80
|
+
r = 2.0 * sources["a"]
|
|
81
|
+
except Exception:
|
|
82
|
+
r = np.zeros(len(sources), dtype=np.float32)
|
|
83
|
+
|
|
84
|
+
tbl = Table()
|
|
85
|
+
tbl["xcentroid"] = sources["x"]
|
|
86
|
+
tbl["ycentroid"] = sources["y"]
|
|
87
|
+
tbl["flux"] = sources["flux"]
|
|
88
|
+
tbl["HFR"] = r
|
|
89
|
+
tbl["a"] = sources["a"]
|
|
90
|
+
tbl["b"] = sources["b"]
|
|
91
|
+
tbl["theta"] = sources["theta"]
|
|
92
|
+
|
|
93
|
+
self.finished.emit(tbl, f"Status: Extraction completed — {len(sources)} sources.")
|
|
94
|
+
except Exception as e:
|
|
95
|
+
self.failed.emit(f"Extraction failed: {e}")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class PSFViewer(QDialog):
|
|
99
|
+
"""
|
|
100
|
+
A lightweight PSF/Flux histogram viewer.
|
|
101
|
+
Pass an ImageSubWindow instance *or* a document (with .image and .changed).
|
|
102
|
+
Listens to doc.changed to keep results fresh.
|
|
103
|
+
"""
|
|
104
|
+
def __init__(self, view_or_doc, parent=None):
|
|
105
|
+
super().__init__(parent)
|
|
106
|
+
self.setWindowTitle("PSF Viewer")
|
|
107
|
+
|
|
108
|
+
# Accept either a view (with .document) or a doc directly
|
|
109
|
+
doc = getattr(view_or_doc, "document", None)
|
|
110
|
+
self.doc = doc if doc is not None else view_or_doc
|
|
111
|
+
|
|
112
|
+
# Image + state
|
|
113
|
+
self.image = self._grab_image()
|
|
114
|
+
self.zoom_factor = 1.0
|
|
115
|
+
self.log_scale = False
|
|
116
|
+
self.star_list = None
|
|
117
|
+
self.histogram_mode = "PSF" # or "Flux"
|
|
118
|
+
self.detection_threshold = 5 # sigma
|
|
119
|
+
|
|
120
|
+
# Debounce timer for threshold slider
|
|
121
|
+
self.threshold_timer = QTimer(self)
|
|
122
|
+
self.threshold_timer.setSingleShot(True)
|
|
123
|
+
self.threshold_timer.setInterval(500)
|
|
124
|
+
self.threshold_timer.timeout.connect(self._applyThreshold)
|
|
125
|
+
|
|
126
|
+
# Auto-update when the document changes
|
|
127
|
+
if hasattr(self.doc, "changed"):
|
|
128
|
+
try:
|
|
129
|
+
self.doc.changed.connect(self._on_doc_changed)
|
|
130
|
+
except Exception:
|
|
131
|
+
pass
|
|
132
|
+
|
|
133
|
+
self._build_ui()
|
|
134
|
+
# Defer first compute until after the dialog is shown/layouted
|
|
135
|
+
QTimer.singleShot(0, self._applyThreshold)
|
|
136
|
+
|
|
137
|
+
# ---------- internals ----------
|
|
138
|
+
def _grab_image(self):
|
|
139
|
+
img = getattr(self.doc, "image", None)
|
|
140
|
+
if img is None:
|
|
141
|
+
return None
|
|
142
|
+
# Ensure ndarray
|
|
143
|
+
try:
|
|
144
|
+
return np.asarray(img)
|
|
145
|
+
except Exception:
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
def _on_doc_changed(self, *_):
|
|
149
|
+
self.image = self._grab_image()
|
|
150
|
+
# reuse the existing debounce timer instead of immediate recompute
|
|
151
|
+
if self.threshold_timer.isActive():
|
|
152
|
+
self.threshold_timer.stop()
|
|
153
|
+
self.threshold_timer.start()
|
|
154
|
+
|
|
155
|
+
# ---------- UI ----------
|
|
156
|
+
def _build_ui(self):
|
|
157
|
+
main_layout = QVBoxLayout(self)
|
|
158
|
+
|
|
159
|
+
# Top: histogram + stats
|
|
160
|
+
top_layout = QHBoxLayout()
|
|
161
|
+
self.scroll_area = QScrollArea(self)
|
|
162
|
+
self.scroll_area.setFixedSize(520, 310)
|
|
163
|
+
self.scroll_area.setWidgetResizable(False)
|
|
164
|
+
self.hist_label = QLabel(self)
|
|
165
|
+
self.hist_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
166
|
+
self.scroll_area.setWidget(self.hist_label)
|
|
167
|
+
top_layout.addWidget(self.scroll_area)
|
|
168
|
+
|
|
169
|
+
self.stats_table = QTableWidget(self)
|
|
170
|
+
self.stats_table.setRowCount(4)
|
|
171
|
+
self.stats_table.setColumnCount(0)
|
|
172
|
+
self.stats_table.setVerticalHeaderLabels(["Min", "Max", "Median", "StdDev"])
|
|
173
|
+
self.stats_table.setFixedWidth(360)
|
|
174
|
+
top_layout.addWidget(self.stats_table)
|
|
175
|
+
main_layout.addLayout(top_layout)
|
|
176
|
+
|
|
177
|
+
self.status_label = QLabel("Status: Ready", self)
|
|
178
|
+
self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
179
|
+
main_layout.addWidget(self.status_label)
|
|
180
|
+
|
|
181
|
+
# Controls
|
|
182
|
+
controls_layout = QHBoxLayout()
|
|
183
|
+
|
|
184
|
+
controls_layout.addWidget(QLabel("Zoom:"))
|
|
185
|
+
|
|
186
|
+
# themed zoom buttons
|
|
187
|
+
btn_zoom_out = themed_toolbtn("zoom-out", "Zoom Out")
|
|
188
|
+
btn_zoom_in = themed_toolbtn("zoom-in", "Zoom In")
|
|
189
|
+
btn_fit = themed_toolbtn("zoom-fit-best", "Fit")
|
|
190
|
+
|
|
191
|
+
btn_zoom_out.clicked.connect(lambda: self._step_zoom(1/1.25))
|
|
192
|
+
btn_zoom_in.clicked.connect(lambda: self._step_zoom(1.25))
|
|
193
|
+
btn_fit.clicked.connect(self._fit_histogram)
|
|
194
|
+
|
|
195
|
+
controls_layout.addWidget(btn_zoom_out)
|
|
196
|
+
controls_layout.addWidget(btn_zoom_in)
|
|
197
|
+
controls_layout.addWidget(btn_fit)
|
|
198
|
+
|
|
199
|
+
# keep the slider (nice for big jumps)
|
|
200
|
+
self.zoom_slider = QSlider(Qt.Orientation.Horizontal, self)
|
|
201
|
+
self.zoom_slider.setRange(50, 1000)
|
|
202
|
+
self.zoom_slider.setValue(100)
|
|
203
|
+
self.zoom_slider.setTickInterval(10)
|
|
204
|
+
self.zoom_slider.setTickPosition(QSlider.TickPosition.TicksBelow)
|
|
205
|
+
self.zoom_slider.valueChanged.connect(self.updateZoom)
|
|
206
|
+
controls_layout.addWidget(self.zoom_slider, 1)
|
|
207
|
+
|
|
208
|
+
self.log_toggle_button = QPushButton("Toggle Log X-Axis", self)
|
|
209
|
+
self.log_toggle_button.setCheckable(True)
|
|
210
|
+
self.log_toggle_button.setToolTip("Toggle between linear and logarithmic x-axis.")
|
|
211
|
+
self.log_toggle_button.toggled.connect(self.toggleLogScale)
|
|
212
|
+
controls_layout.addWidget(self.log_toggle_button)
|
|
213
|
+
|
|
214
|
+
self.mode_toggle_button = QPushButton("Show Flux Histogram", self)
|
|
215
|
+
self.mode_toggle_button.setToolTip("Switch between PSF (HFR) and Flux histograms.")
|
|
216
|
+
self.mode_toggle_button.clicked.connect(self.toggleHistogramMode)
|
|
217
|
+
controls_layout.addWidget(self.mode_toggle_button)
|
|
218
|
+
|
|
219
|
+
main_layout.addLayout(controls_layout)
|
|
220
|
+
|
|
221
|
+
# Threshold
|
|
222
|
+
thresh_layout = QHBoxLayout()
|
|
223
|
+
thresh_layout.addWidget(QLabel("Detection Threshold (σ):", self))
|
|
224
|
+
self.threshold_slider = QSlider(Qt.Orientation.Horizontal, self)
|
|
225
|
+
self.threshold_slider.setRange(1, 20)
|
|
226
|
+
self.threshold_slider.setValue(self.detection_threshold)
|
|
227
|
+
self.threshold_slider.setTickInterval(1)
|
|
228
|
+
self.threshold_slider.setTickPosition(QSlider.TickPosition.TicksBelow)
|
|
229
|
+
self.threshold_slider.valueChanged.connect(self.onThresholdChange)
|
|
230
|
+
thresh_layout.addWidget(self.threshold_slider)
|
|
231
|
+
|
|
232
|
+
self.threshold_value_label = QLabel(str(self.detection_threshold), self)
|
|
233
|
+
thresh_layout.addWidget(self.threshold_value_label)
|
|
234
|
+
main_layout.addLayout(thresh_layout)
|
|
235
|
+
|
|
236
|
+
# Close
|
|
237
|
+
close_btn = QPushButton("Close", self)
|
|
238
|
+
close_btn.clicked.connect(self.accept)
|
|
239
|
+
main_layout.addWidget(close_btn)
|
|
240
|
+
|
|
241
|
+
self.setLayout(main_layout)
|
|
242
|
+
self.drawHistogram()
|
|
243
|
+
|
|
244
|
+
# ---------- interactions ----------
|
|
245
|
+
def onThresholdChange(self, value: int):
|
|
246
|
+
self.detection_threshold = int(value)
|
|
247
|
+
self.threshold_value_label.setText(str(value))
|
|
248
|
+
if self.threshold_timer.isActive():
|
|
249
|
+
self.threshold_timer.stop()
|
|
250
|
+
self.threshold_timer.start()
|
|
251
|
+
|
|
252
|
+
def _step_zoom(self, factor: float):
|
|
253
|
+
v = int(round(self.zoom_slider.value() * factor))
|
|
254
|
+
v = max(self.zoom_slider.minimum(), min(self.zoom_slider.maximum(), v))
|
|
255
|
+
self.zoom_slider.setValue(v) # drives updateZoom()
|
|
256
|
+
|
|
257
|
+
def _fit_histogram(self):
|
|
258
|
+
# Fit the histogram pixmap to the scroll viewport width.
|
|
259
|
+
# Keeps behavior consistent with your other preview dialogs.
|
|
260
|
+
if not hasattr(self, "_base_hist_pm") or self._base_hist_pm is None:
|
|
261
|
+
return
|
|
262
|
+
vp_w = self.scroll_area.viewport().width()
|
|
263
|
+
base_w = max(1, self._base_hist_pm.width())
|
|
264
|
+
z = vp_w / base_w
|
|
265
|
+
self.zoom_slider.setValue(int(round(z * 100)))
|
|
266
|
+
|
|
267
|
+
def _apply_hist_zoom(self):
|
|
268
|
+
if not hasattr(self, "_base_hist_pm") or self._base_hist_pm is None:
|
|
269
|
+
return
|
|
270
|
+
z = self.zoom_slider.value() / 100.0
|
|
271
|
+
w = max(1, int(self._base_hist_pm.width() * z))
|
|
272
|
+
h = max(1, int(self._base_hist_pm.height() * z))
|
|
273
|
+
scaled = self._base_hist_pm.scaled(
|
|
274
|
+
w, h,
|
|
275
|
+
Qt.AspectRatioMode.KeepAspectRatio,
|
|
276
|
+
Qt.TransformationMode.SmoothTransformation
|
|
277
|
+
)
|
|
278
|
+
self.hist_label.setPixmap(scaled)
|
|
279
|
+
self.hist_label.resize(scaled.size())
|
|
280
|
+
|
|
281
|
+
def _applyThreshold(self):
|
|
282
|
+
# kick off worker
|
|
283
|
+
if self.image is None:
|
|
284
|
+
self.star_list = None
|
|
285
|
+
self.status_label.setText("Status: No image.")
|
|
286
|
+
self.drawHistogram()
|
|
287
|
+
return
|
|
288
|
+
|
|
289
|
+
self._show_processing("Processing… extracting stars / PSFs")
|
|
290
|
+
|
|
291
|
+
# kill previous run if any
|
|
292
|
+
if hasattr(self, "_psf_thread") and self._psf_thread is not None:
|
|
293
|
+
try:
|
|
294
|
+
self._psf_thread.quit()
|
|
295
|
+
self._psf_thread.wait(50)
|
|
296
|
+
except Exception:
|
|
297
|
+
pass
|
|
298
|
+
|
|
299
|
+
self._psf_thread = QThread(self)
|
|
300
|
+
self._psf_worker = _PSFWorker(self.image, self.detection_threshold)
|
|
301
|
+
self._psf_worker.moveToThread(self._psf_thread)
|
|
302
|
+
|
|
303
|
+
self._psf_thread.started.connect(self._psf_worker.run)
|
|
304
|
+
|
|
305
|
+
def _done(tbl, status):
|
|
306
|
+
self.star_list = tbl
|
|
307
|
+
self.status_label.setText(status)
|
|
308
|
+
self._hide_processing()
|
|
309
|
+
self.drawHistogram()
|
|
310
|
+
self._psf_thread.quit()
|
|
311
|
+
self._psf_thread.wait(100)
|
|
312
|
+
|
|
313
|
+
def _fail(msg):
|
|
314
|
+
self.star_list = None
|
|
315
|
+
self.status_label.setText(f"Status: {msg}")
|
|
316
|
+
self._hide_processing()
|
|
317
|
+
self.drawHistogram()
|
|
318
|
+
self._psf_thread.quit()
|
|
319
|
+
self._psf_thread.wait(100)
|
|
320
|
+
|
|
321
|
+
self._psf_worker.finished.connect(_done)
|
|
322
|
+
self._psf_worker.failed.connect(_fail)
|
|
323
|
+
|
|
324
|
+
self._psf_thread.start()
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def updateImage(self, new_image):
|
|
328
|
+
self.image = np.asarray(new_image) if new_image is not None else None
|
|
329
|
+
self.compute_star_list()
|
|
330
|
+
self.drawHistogram()
|
|
331
|
+
|
|
332
|
+
def updateZoom(self, _=None):
|
|
333
|
+
self._apply_hist_zoom()
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def toggleLogScale(self, checked: bool):
|
|
337
|
+
self.log_scale = bool(checked)
|
|
338
|
+
self.drawHistogram()
|
|
339
|
+
|
|
340
|
+
def toggleHistogramMode(self):
|
|
341
|
+
if self.histogram_mode == "PSF":
|
|
342
|
+
self.histogram_mode = "Flux"
|
|
343
|
+
self.mode_toggle_button.setText("Show PSF Histogram")
|
|
344
|
+
else:
|
|
345
|
+
self.histogram_mode = "PSF"
|
|
346
|
+
self.mode_toggle_button.setText("Show Flux Histogram")
|
|
347
|
+
self.drawHistogram()
|
|
348
|
+
|
|
349
|
+
def _show_processing(self, msg="Processing…"):
|
|
350
|
+
if not hasattr(self, "_overlay") or self._overlay is None:
|
|
351
|
+
self._overlay = _ProcessingOverlay(self.scroll_area)
|
|
352
|
+
self._overlay.hide()
|
|
353
|
+
self._overlay.setText(msg)
|
|
354
|
+
self._overlay.resize(self.scroll_area.viewport().size())
|
|
355
|
+
self._overlay.move(0, 0)
|
|
356
|
+
self._overlay.show()
|
|
357
|
+
self._overlay.raise_()
|
|
358
|
+
|
|
359
|
+
def _hide_processing(self):
|
|
360
|
+
if hasattr(self, "_overlay") and self._overlay is not None:
|
|
361
|
+
self._overlay.hide()
|
|
362
|
+
|
|
363
|
+
def resizeEvent(self, e):
|
|
364
|
+
super().resizeEvent(e)
|
|
365
|
+
if hasattr(self, "_overlay") and self._overlay is not None and self._overlay.isVisible():
|
|
366
|
+
self._overlay.resize(self.scroll_area.viewport().size())
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
# ---------- compute ----------
|
|
370
|
+
def compute_star_list(self):
|
|
371
|
+
if self.image is None:
|
|
372
|
+
self.status_label.setText("Status: No image.")
|
|
373
|
+
self.star_list = None
|
|
374
|
+
return
|
|
375
|
+
|
|
376
|
+
# Convert to grayscale
|
|
377
|
+
if self.image.ndim == 3:
|
|
378
|
+
image_gray = np.mean(self.image, axis=2)
|
|
379
|
+
else:
|
|
380
|
+
image_gray = self.image
|
|
381
|
+
data = image_gray.astype(np.float32, copy=False)
|
|
382
|
+
|
|
383
|
+
# Background
|
|
384
|
+
try:
|
|
385
|
+
bkg = sep.Background(data)
|
|
386
|
+
data_sub = data - bkg.back()
|
|
387
|
+
try:
|
|
388
|
+
err_val = bkg.globalrms
|
|
389
|
+
except Exception:
|
|
390
|
+
err_val = float(np.median(bkg.rms()))
|
|
391
|
+
except Exception as e:
|
|
392
|
+
self.status_label.setText(f"Status: Background failed: {e}")
|
|
393
|
+
self.star_list = None
|
|
394
|
+
return
|
|
395
|
+
|
|
396
|
+
threshold = float(self.detection_threshold)
|
|
397
|
+
|
|
398
|
+
self.status_label.setText("Status: Starting star extraction...")
|
|
399
|
+
QApplication.processEvents()
|
|
400
|
+
|
|
401
|
+
try:
|
|
402
|
+
sources = sep.extract(data_sub, threshold, err=err_val)
|
|
403
|
+
n = len(sources) if sources is not None else 0
|
|
404
|
+
self.status_label.setText(f"Status: Extraction completed — {n} sources.")
|
|
405
|
+
except Exception as e:
|
|
406
|
+
self.status_label.setText(f"Status: Extraction failed: {e}")
|
|
407
|
+
sources = None
|
|
408
|
+
|
|
409
|
+
QApplication.processEvents()
|
|
410
|
+
|
|
411
|
+
if sources is None or len(sources) == 0:
|
|
412
|
+
self.star_list = None
|
|
413
|
+
return
|
|
414
|
+
|
|
415
|
+
# HFR (quick proxy): 2 * a (a ≈ semi-major Gaussian sigma in pixels for SEP)
|
|
416
|
+
try:
|
|
417
|
+
a = sources["a"]
|
|
418
|
+
r = 2 * a
|
|
419
|
+
except Exception:
|
|
420
|
+
r = np.zeros(len(sources), dtype=np.float32)
|
|
421
|
+
|
|
422
|
+
tbl = Table()
|
|
423
|
+
tbl["xcentroid"] = sources["x"]
|
|
424
|
+
tbl["ycentroid"] = sources["y"]
|
|
425
|
+
tbl["flux"] = sources["flux"]
|
|
426
|
+
tbl["HFR"] = r
|
|
427
|
+
tbl["a"] = sources["a"]
|
|
428
|
+
tbl["b"] = sources["b"]
|
|
429
|
+
tbl["theta"] = sources["theta"]
|
|
430
|
+
self.star_list = tbl
|
|
431
|
+
|
|
432
|
+
# ---------- drawing ----------
|
|
433
|
+
def drawHistogram(self):
|
|
434
|
+
base_w, h = 512, 300
|
|
435
|
+
|
|
436
|
+
# Render at fixed base resolution (no zoom here)
|
|
437
|
+
pix = QPixmap(base_w, h)
|
|
438
|
+
pix.fill(Qt.GlobalColor.white)
|
|
439
|
+
|
|
440
|
+
painter = QPainter(pix)
|
|
441
|
+
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
|
442
|
+
|
|
443
|
+
# Prepare data
|
|
444
|
+
if self.star_list is None or len(self.star_list) == 0:
|
|
445
|
+
data = np.array([], dtype=float)
|
|
446
|
+
edges = np.linspace(0, 1, 51)
|
|
447
|
+
low, high = float(edges[0]), float(edges[-1])
|
|
448
|
+
else:
|
|
449
|
+
if self.histogram_mode == "PSF":
|
|
450
|
+
data = np.array(self.star_list["HFR"], dtype=float)
|
|
451
|
+
edges = np.linspace(0, 7.5, 51)
|
|
452
|
+
else:
|
|
453
|
+
data = np.array(self.star_list["flux"], dtype=float)
|
|
454
|
+
edges = np.linspace(data.min(), data.max(), 51) if data.size else np.linspace(0, 1, 51)
|
|
455
|
+
low, high = float(edges[0]), float(edges[-1])
|
|
456
|
+
|
|
457
|
+
# Axis scale helpers (map value -> x in [0..base_w])
|
|
458
|
+
if self.log_scale and high > max(low, 1e-9):
|
|
459
|
+
low = max(low, 1e-4)
|
|
460
|
+
edges = np.logspace(np.log10(low), np.log10(high if high > low else low * 10), 51)
|
|
461
|
+
|
|
462
|
+
lo_l = np.log10(low)
|
|
463
|
+
hi_l = np.log10(high) if high > low else lo_l + 1.0
|
|
464
|
+
|
|
465
|
+
def xfun(v: float) -> int:
|
|
466
|
+
lv = np.log10(max(v, low))
|
|
467
|
+
return int((lv - lo_l) / (hi_l - lo_l) * base_w) if hi_l > lo_l else 0
|
|
468
|
+
else:
|
|
469
|
+
def xfun(v: float) -> int:
|
|
470
|
+
return int((v - low) / (high - low) * base_w) if high > low else 0
|
|
471
|
+
|
|
472
|
+
# Histogram
|
|
473
|
+
hist = np.histogram(data, bins=edges)[0].astype(float)
|
|
474
|
+
if hist.size and hist.max() > 0:
|
|
475
|
+
hist /= hist.max()
|
|
476
|
+
|
|
477
|
+
# Bars
|
|
478
|
+
painter.setPen(QPen(Qt.GlobalColor.black))
|
|
479
|
+
for i in range(len(hist)):
|
|
480
|
+
x0 = xfun(float(edges[i]))
|
|
481
|
+
x1 = xfun(float(edges[i + 1]))
|
|
482
|
+
bw = max(x1 - x0, 1)
|
|
483
|
+
bh = float(hist[i]) * h
|
|
484
|
+
painter.drawRect(x0, int(h - bh), bw, int(bh))
|
|
485
|
+
|
|
486
|
+
# X axis
|
|
487
|
+
painter.setPen(QPen(Qt.GlobalColor.black, 2))
|
|
488
|
+
painter.drawLine(0, h - 1, base_w, h - 1)
|
|
489
|
+
painter.setFont(QFont("Arial", 10))
|
|
490
|
+
|
|
491
|
+
ticks = (
|
|
492
|
+
np.logspace(np.log10(max(low, 1e-4)), np.log10(max(high, low * 10)), 6)
|
|
493
|
+
if self.log_scale and high > low
|
|
494
|
+
else np.linspace(low, high, 6)
|
|
495
|
+
)
|
|
496
|
+
for t in ticks:
|
|
497
|
+
x = xfun(float(t))
|
|
498
|
+
painter.drawLine(x, h - 1, x, h - 6)
|
|
499
|
+
painter.drawText(x - 28, h - 10, f"{t:.3f}" if self.log_scale else f"{t:.2f}")
|
|
500
|
+
|
|
501
|
+
painter.end()
|
|
502
|
+
|
|
503
|
+
# Store base pixmap for zooming
|
|
504
|
+
self._base_hist_pm = pix
|
|
505
|
+
self._apply_hist_zoom() # scales into hist_label
|
|
506
|
+
self.updateStatistics()
|
|
507
|
+
|
|
508
|
+
def updateStatistics(self):
|
|
509
|
+
data_map = {}
|
|
510
|
+
if self.star_list is not None and len(self.star_list) > 0:
|
|
511
|
+
cols = ["HFR", "eccentricity", "a", "b", "theta", "flux"]
|
|
512
|
+
a = np.array(self.star_list["a"], float)
|
|
513
|
+
b = np.array(self.star_list["b"], float)
|
|
514
|
+
ecc = np.nan_to_num(np.sqrt(1 - (b / np.maximum(a, 1e-9)) ** 2))
|
|
515
|
+
data_map["eccentricity"] = ecc
|
|
516
|
+
for c in self.star_list.colnames:
|
|
517
|
+
try:
|
|
518
|
+
data_map[c] = np.array(self.star_list[c], float)
|
|
519
|
+
except Exception:
|
|
520
|
+
pass
|
|
521
|
+
cols = [c for c in cols if c in data_map]
|
|
522
|
+
else:
|
|
523
|
+
cols = []
|
|
524
|
+
|
|
525
|
+
self.stats_table.setColumnCount(len(cols))
|
|
526
|
+
self.stats_table.setHorizontalHeaderLabels(cols)
|
|
527
|
+
self.stats_table.setRowCount(4)
|
|
528
|
+
self.stats_table.setVerticalHeaderLabels(["Min", "Max", "Median", "StdDev"])
|
|
529
|
+
|
|
530
|
+
for ci, col in enumerate(cols):
|
|
531
|
+
arr = data_map.get(col, np.zeros(0, dtype=float))
|
|
532
|
+
if arr.size:
|
|
533
|
+
vals = [np.min(arr), np.max(arr), np.median(arr), np.std(arr)]
|
|
534
|
+
else:
|
|
535
|
+
vals = [0.0, 0.0, 0.0, 0.0]
|
|
536
|
+
for ri, v in enumerate(vals):
|
|
537
|
+
it = QTableWidgetItem(f"{v:.3f}")
|
|
538
|
+
it.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
539
|
+
self.stats_table.setItem(ri, ci, it)
|
|
540
|
+
|
|
541
|
+
# ---------- lifecycle ----------
|
|
542
|
+
def closeEvent(self, e):
|
|
543
|
+
# Best-effort disconnect
|
|
544
|
+
if hasattr(self.doc, "changed"):
|
|
545
|
+
try:
|
|
546
|
+
self.doc.changed.disconnect(self._on_doc_changed)
|
|
547
|
+
except Exception:
|
|
548
|
+
pass
|
|
549
|
+
super().closeEvent(e)
|