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.
- 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,309 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import numpy as np
|
|
3
|
+
import cv2
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from setiastro.saspro.headless_utils import normalize_headless_main, unwrap_docproxy
|
|
7
|
+
from setiastro.saspro.ops.command_runner import CommandError
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
# Shared utilities
|
|
11
|
+
from setiastro.saspro.widgets.image_utils import (
|
|
12
|
+
extract_mask_from_document as _active_mask_array_from_doc,
|
|
13
|
+
to_float01_strict as _to_float01_strict,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
# Linear luma weights
|
|
17
|
+
_LUMA_REC709 = np.array([0.2126, 0.7152, 0.0722], dtype=np.float32)
|
|
18
|
+
_LUMA_REC601 = np.array([0.2990, 0.5870, 0.1140], dtype=np.float32)
|
|
19
|
+
_LUMA_REC2020 = np.array([0.2627, 0.6780, 0.0593], dtype=np.float32)
|
|
20
|
+
|
|
21
|
+
# ---------- helpers ----------
|
|
22
|
+
|
|
23
|
+
def _estimate_noise_sigma_per_channel(img01: np.ndarray) -> np.ndarray:
|
|
24
|
+
# unchanged (but call with strict input)
|
|
25
|
+
a = img01
|
|
26
|
+
if a.ndim == 2:
|
|
27
|
+
a = a[..., None]
|
|
28
|
+
a = a[::4, ::4, :].astype(np.float32, copy=False)
|
|
29
|
+
med = np.median(a, axis=(0,1))
|
|
30
|
+
mad = np.median(np.abs(a - med), axis=(0,1))
|
|
31
|
+
sigma = 1.4826 * mad
|
|
32
|
+
sigma[sigma <= 1e-12] = 1e-12
|
|
33
|
+
return sigma.astype(np.float32)
|
|
34
|
+
|
|
35
|
+
# ---------- luminance compute (linear) ----------
|
|
36
|
+
|
|
37
|
+
def compute_luminance(
|
|
38
|
+
img: np.ndarray,
|
|
39
|
+
method: str | None = "rec709",
|
|
40
|
+
weights: Optional[np.ndarray] = None,
|
|
41
|
+
noise_sigma: Optional[np.ndarray] = None,
|
|
42
|
+
normalize_weights: bool = True
|
|
43
|
+
) -> np.ndarray:
|
|
44
|
+
"""
|
|
45
|
+
Returns 2-D linear luminance Y in [0,1] (float32).
|
|
46
|
+
No per-image normalization. If custom `weights` are supplied and
|
|
47
|
+
`normalize_weights=False`, their absolute sum is respected.
|
|
48
|
+
"""
|
|
49
|
+
f = _to_float01_strict(img)
|
|
50
|
+
|
|
51
|
+
if f.ndim == 2:
|
|
52
|
+
return np.ascontiguousarray(f.astype(np.float32, copy=False))
|
|
53
|
+
if f.ndim != 3:
|
|
54
|
+
raise ValueError("compute_luminance: expected 2-D or 3-D array.")
|
|
55
|
+
|
|
56
|
+
H, W, C = f.shape
|
|
57
|
+
if C == 1:
|
|
58
|
+
return np.ascontiguousarray(f[..., 0].astype(np.float32, copy=False))
|
|
59
|
+
|
|
60
|
+
if weights is not None:
|
|
61
|
+
w = np.asarray(weights, dtype=np.float32)
|
|
62
|
+
if w.ndim != 1 or w.size not in (C, 3):
|
|
63
|
+
raise ValueError("weights must be 1-D with length equal to channel count or 3.")
|
|
64
|
+
if normalize_weights:
|
|
65
|
+
s = float(w.sum())
|
|
66
|
+
if s != 0.0:
|
|
67
|
+
w = w / s
|
|
68
|
+
useC = w.size
|
|
69
|
+
lum = np.tensordot(f[..., :useC], w, axes=([2], [0]))
|
|
70
|
+
elif method == "equal":
|
|
71
|
+
lum = f[..., :3].mean(axis=2)
|
|
72
|
+
elif method == "snr":
|
|
73
|
+
if noise_sigma is None:
|
|
74
|
+
raise ValueError("snr method requires noise_sigma per channel.")
|
|
75
|
+
ns = np.asarray(noise_sigma, dtype=np.float32)
|
|
76
|
+
if ns.ndim != 1 or ns.size not in (C, 3):
|
|
77
|
+
raise ValueError("noise_sigma must be 1-D with length equal to channel count or 3.")
|
|
78
|
+
useC = ns.size
|
|
79
|
+
w = 1.0 / (ns[:useC]**2 + 1e-12)
|
|
80
|
+
w = w / w.sum()
|
|
81
|
+
lum = np.tensordot(f[..., :useC], w, axes=([2],[0]))
|
|
82
|
+
elif method == "max":
|
|
83
|
+
lum = f.max(axis=2)
|
|
84
|
+
elif method == "median":
|
|
85
|
+
lum = np.median(f, axis=2)
|
|
86
|
+
else: # default rec709
|
|
87
|
+
lum = np.tensordot(f[..., :3], _LUMA_REC709, axes=([2],[0]))
|
|
88
|
+
|
|
89
|
+
return np.clip(lum.astype(np.float32, copy=False), 0.0, 1.0)
|
|
90
|
+
|
|
91
|
+
# ---------- luminance recombine (linear scaling) ----------
|
|
92
|
+
|
|
93
|
+
def recombine_luminance_linear_scale(
|
|
94
|
+
target_rgb: np.ndarray,
|
|
95
|
+
new_L: np.ndarray,
|
|
96
|
+
weights: np.ndarray = _LUMA_REC709,
|
|
97
|
+
eps: float = 1e-6,
|
|
98
|
+
blend: float = 1.0, # 0..1, 1=full replace
|
|
99
|
+
highlight_soft_knee: float = 0.0 # 0..1, optional protection
|
|
100
|
+
) -> np.ndarray:
|
|
101
|
+
"""
|
|
102
|
+
Replace linear luminance Y (w·RGB) with `new_L` by per-pixel scaling:
|
|
103
|
+
s = new_L / (Y + eps); RGB' = RGB * s
|
|
104
|
+
This preserves hue/chroma in linear space and round-trips when new_L==Y.
|
|
105
|
+
Optional: blend (mix with original) and highlight soft-knee protection.
|
|
106
|
+
"""
|
|
107
|
+
rgb = _to_float01_strict(target_rgb)
|
|
108
|
+
if rgb.ndim != 3 or rgb.shape[2] != 3:
|
|
109
|
+
raise ValueError("Recombine Luminance requires an RGB target image.")
|
|
110
|
+
|
|
111
|
+
H, W, _ = rgb.shape
|
|
112
|
+
L = new_L.astype(np.float32)
|
|
113
|
+
if L.shape[:2] != (H, W):
|
|
114
|
+
L = cv2.resize(L, (W, H), interpolation=cv2.INTER_LINEAR)
|
|
115
|
+
|
|
116
|
+
w = np.asarray(weights, dtype=np.float32)
|
|
117
|
+
if w.shape != (3,):
|
|
118
|
+
raise ValueError("weights must be length-3 for RGB recombine.")
|
|
119
|
+
|
|
120
|
+
# current Y
|
|
121
|
+
Y = rgb[..., 0]*w[0] + rgb[..., 1]*w[1] + rgb[..., 2]*w[2]
|
|
122
|
+
s = L / (Y + eps)
|
|
123
|
+
|
|
124
|
+
if highlight_soft_knee > 0.0:
|
|
125
|
+
# compress extreme upsizing to avoid blowing out tiny Y
|
|
126
|
+
# knee in [0..1], higher = more protection
|
|
127
|
+
k = np.clip(highlight_soft_knee, 0.0, 1.0)
|
|
128
|
+
s = s / (1.0 + k*(s - 1.0))
|
|
129
|
+
|
|
130
|
+
out = rgb * s[..., None]
|
|
131
|
+
out = np.clip(out, 0.0, 1.0)
|
|
132
|
+
|
|
133
|
+
if 0.0 <= blend < 1.0:
|
|
134
|
+
out = rgb*(1.0 - blend) + out*blend
|
|
135
|
+
|
|
136
|
+
return out.astype(np.float32, copy=False)
|
|
137
|
+
|
|
138
|
+
def _resolve_active_doc_from(main, target_doc=None):
|
|
139
|
+
doc = target_doc
|
|
140
|
+
if doc is None:
|
|
141
|
+
d = getattr(main, "_active_doc", None)
|
|
142
|
+
doc = d() if callable(d) else d
|
|
143
|
+
doc = unwrap_docproxy(doc)
|
|
144
|
+
return doc
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def apply_recombine_to_doc(
|
|
148
|
+
target_doc,
|
|
149
|
+
luminance_source_img: np.ndarray,
|
|
150
|
+
method: str = "rec709",
|
|
151
|
+
weights: Optional[np.ndarray] = None,
|
|
152
|
+
noise_sigma: Optional[np.ndarray] = None,
|
|
153
|
+
blend: float = 1.0,
|
|
154
|
+
soft_knee: float = 0.0
|
|
155
|
+
):
|
|
156
|
+
"""
|
|
157
|
+
Overwrite target_doc.image by recombining with luminance from source (RGB or mono).
|
|
158
|
+
Uses linear scaling recombine; honors destination mask if present.
|
|
159
|
+
"""
|
|
160
|
+
base = _to_float01_strict(np.asarray(target_doc.image))
|
|
161
|
+
|
|
162
|
+
# Decide weights for both compute+recombine
|
|
163
|
+
if method == "rec601":
|
|
164
|
+
w = _LUMA_REC601
|
|
165
|
+
elif method == "rec2020":
|
|
166
|
+
w = _LUMA_REC2020
|
|
167
|
+
elif weights is not None:
|
|
168
|
+
w = np.asarray(weights, dtype=np.float32)
|
|
169
|
+
if w.size != 3:
|
|
170
|
+
raise ValueError("Custom weights must be length-3.")
|
|
171
|
+
else:
|
|
172
|
+
w = _LUMA_REC709
|
|
173
|
+
|
|
174
|
+
# Build L (mono source passes through; RGB is weighted)
|
|
175
|
+
src = _to_float01_strict(luminance_source_img)
|
|
176
|
+
if src.ndim == 2 or (src.ndim == 3 and src.shape[2] == 1):
|
|
177
|
+
L = src if src.ndim == 2 else src[..., 0]
|
|
178
|
+
else:
|
|
179
|
+
ns = None
|
|
180
|
+
if method == "snr":
|
|
181
|
+
ns = _estimate_noise_sigma_per_channel(src)
|
|
182
|
+
L = compute_luminance(src, method=method, weights=w if weights is not None else None, noise_sigma=ns)
|
|
183
|
+
|
|
184
|
+
replaced = recombine_luminance_linear_scale(base, L, weights=w, blend=blend, highlight_soft_knee=soft_knee)
|
|
185
|
+
|
|
186
|
+
# destination-mask blend if active
|
|
187
|
+
m = _active_mask_array_from_doc(target_doc)
|
|
188
|
+
if m is not None:
|
|
189
|
+
m3 = np.repeat(m[..., None], 3, axis=2).astype(np.float32)
|
|
190
|
+
replaced = base * (1.0 - m3) + replaced * m3
|
|
191
|
+
|
|
192
|
+
target_doc.apply_edit(
|
|
193
|
+
replaced,
|
|
194
|
+
metadata={"step_name": "Recombine Luminance", "luma_method": method, "luma_weights": w.tolist()},
|
|
195
|
+
step_name="Recombine Luminance",
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def run_recombine_luminance_via_preset(main_or_ctx, preset=None, target_doc=None):
|
|
200
|
+
"""
|
|
201
|
+
Headless entrypoint for recombine_luminance.
|
|
202
|
+
|
|
203
|
+
preset supports:
|
|
204
|
+
- source_doc_ptr: int (id(doc)) [highest priority]
|
|
205
|
+
- source_title: str [next priority]
|
|
206
|
+
- method, weights, blend, soft_knee (existing)
|
|
207
|
+
If neither source_* is given, first eligible non-target open doc is used.
|
|
208
|
+
"""
|
|
209
|
+
from setiastro.saspro.luminancerecombine import apply_recombine_to_doc
|
|
210
|
+
|
|
211
|
+
p = dict(preset or {})
|
|
212
|
+
main, doc, dm = normalize_headless_main(main_or_ctx, target_doc)
|
|
213
|
+
|
|
214
|
+
# ---- Validate target ----
|
|
215
|
+
if doc is None or getattr(doc, "image", None) is None:
|
|
216
|
+
raise CommandError("recombine_luminance: no active RGB ImageDocument. Load an image first.")
|
|
217
|
+
|
|
218
|
+
# ---- Collect open docs (unwrapped) ----
|
|
219
|
+
open_docs = []
|
|
220
|
+
if dm is not None:
|
|
221
|
+
try:
|
|
222
|
+
if hasattr(dm, "all_documents") and callable(dm.all_documents):
|
|
223
|
+
open_docs = [unwrap_docproxy(d) for d in dm.all_documents()]
|
|
224
|
+
elif hasattr(dm, "_docs"):
|
|
225
|
+
open_docs = [unwrap_docproxy(d) for d in dm._docs]
|
|
226
|
+
except Exception:
|
|
227
|
+
open_docs = []
|
|
228
|
+
|
|
229
|
+
# Filter to docs that look like images
|
|
230
|
+
def _has_image(d):
|
|
231
|
+
return d is not None and getattr(d, "image", None) is not None
|
|
232
|
+
|
|
233
|
+
open_docs = [d for d in open_docs if _has_image(d)]
|
|
234
|
+
|
|
235
|
+
# ---- Resolve luminance source ----
|
|
236
|
+
src_doc = None
|
|
237
|
+
|
|
238
|
+
# 1) source_doc_ptr
|
|
239
|
+
src_ptr = p.get("source_doc_ptr", None)
|
|
240
|
+
if src_ptr is not None:
|
|
241
|
+
try:
|
|
242
|
+
src_ptr = int(src_ptr)
|
|
243
|
+
for d in open_docs:
|
|
244
|
+
if id(d) == src_ptr:
|
|
245
|
+
src_doc = d
|
|
246
|
+
break
|
|
247
|
+
except Exception:
|
|
248
|
+
src_doc = None
|
|
249
|
+
|
|
250
|
+
# 2) source_title
|
|
251
|
+
if src_doc is None:
|
|
252
|
+
st = p.get("source_title", None)
|
|
253
|
+
if st:
|
|
254
|
+
st_low = str(st).strip().lower()
|
|
255
|
+
|
|
256
|
+
def _title_of(d):
|
|
257
|
+
# prefer display_name() if available
|
|
258
|
+
try:
|
|
259
|
+
if hasattr(d, "display_name") and callable(d.display_name):
|
|
260
|
+
return str(d.display_name())
|
|
261
|
+
except Exception:
|
|
262
|
+
pass
|
|
263
|
+
# fallback to metadata display_name or file basename
|
|
264
|
+
try:
|
|
265
|
+
md = getattr(d, "metadata", {}) or {}
|
|
266
|
+
if md.get("display_name"):
|
|
267
|
+
return str(md["display_name"])
|
|
268
|
+
fp = md.get("file_path")
|
|
269
|
+
if fp:
|
|
270
|
+
import os
|
|
271
|
+
return os.path.basename(fp)
|
|
272
|
+
except Exception:
|
|
273
|
+
pass
|
|
274
|
+
return ""
|
|
275
|
+
|
|
276
|
+
for d in open_docs:
|
|
277
|
+
if d is doc:
|
|
278
|
+
continue
|
|
279
|
+
if _title_of(d).lower() == st_low:
|
|
280
|
+
src_doc = d
|
|
281
|
+
break
|
|
282
|
+
|
|
283
|
+
# 3) auto-pick first eligible non-target doc
|
|
284
|
+
if src_doc is None:
|
|
285
|
+
for d in open_docs:
|
|
286
|
+
if d is doc:
|
|
287
|
+
continue
|
|
288
|
+
src_doc = d
|
|
289
|
+
break
|
|
290
|
+
|
|
291
|
+
if src_doc is None:
|
|
292
|
+
raise CommandError(
|
|
293
|
+
"recombine_luminance: no luminance source found. "
|
|
294
|
+
"Open another image, or pass preset {'source_title': ...} "
|
|
295
|
+
"or {'source_doc_ptr': id(doc)}."
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
# ---- Execute recombine ----
|
|
299
|
+
src_img = np.asarray(src_doc.image)
|
|
300
|
+
|
|
301
|
+
apply_recombine_to_doc(
|
|
302
|
+
doc,
|
|
303
|
+
src_img,
|
|
304
|
+
method=p.get("method", "rec709"),
|
|
305
|
+
weights=p.get("weights", None),
|
|
306
|
+
blend=float(p.get("blend", 1.0)),
|
|
307
|
+
soft_knee=float(p.get("soft_knee", 0.0)),
|
|
308
|
+
)
|
|
309
|
+
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# pro/main_helpers.py
|
|
2
|
+
"""
|
|
3
|
+
Helper functions extracted from the main module.
|
|
4
|
+
|
|
5
|
+
Contains utility functions used throughout the main window:
|
|
6
|
+
- File path utilities
|
|
7
|
+
- Document name/type detection
|
|
8
|
+
- Widget safety checks
|
|
9
|
+
- WCS/FITS header utilities
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
from typing import Optional, Tuple
|
|
14
|
+
|
|
15
|
+
from PyQt6 import sip
|
|
16
|
+
|
|
17
|
+
from setiastro.saspro.file_utils import (
|
|
18
|
+
_normalize_ext,
|
|
19
|
+
_sanitize_filename,
|
|
20
|
+
_exts_from_filter,
|
|
21
|
+
REPLACE_SPACES_WITH_UNDERSCORES,
|
|
22
|
+
WIN_RESERVED_NAMES,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def safe_join_dir_and_name(directory: str, basename: str) -> str:
|
|
27
|
+
"""
|
|
28
|
+
Join directory + sanitized basename.
|
|
29
|
+
Ensures the directory exists or raises a clear error.
|
|
30
|
+
"""
|
|
31
|
+
safe_name = _sanitize_filename(basename)
|
|
32
|
+
final_dir = directory or ""
|
|
33
|
+
if final_dir and not os.path.isdir(final_dir):
|
|
34
|
+
try:
|
|
35
|
+
os.makedirs(final_dir, exist_ok=True)
|
|
36
|
+
except Exception:
|
|
37
|
+
pass
|
|
38
|
+
return os.path.join(final_dir, safe_name)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def normalize_save_path_chosen_filter(path: str, selected_filter: str) -> Tuple[str, str]:
|
|
42
|
+
"""
|
|
43
|
+
Returns (final_path, final_ext_norm). Ensures:
|
|
44
|
+
- appends extension if missing (from chosen filter)
|
|
45
|
+
- avoids double extensions (*.png.png)
|
|
46
|
+
- if user provided a conflicting ext, enforce the chosen filter's default
|
|
47
|
+
- sanitizes the basename (spaces, illegal chars, trailing dots)
|
|
48
|
+
"""
|
|
49
|
+
raw_path = (path or "").strip().rstrip(".")
|
|
50
|
+
allowed = _exts_from_filter(selected_filter) or ["png"] # safe fallback
|
|
51
|
+
default_ext = allowed[0]
|
|
52
|
+
|
|
53
|
+
# Split dir + basename (sanitize only the basename)
|
|
54
|
+
directory, base = os.path.split(raw_path)
|
|
55
|
+
if not base:
|
|
56
|
+
base = "untitled"
|
|
57
|
+
|
|
58
|
+
# If the user typed something like "name.png" but selected TIFF, fix after sanitization
|
|
59
|
+
base_stem, base_ext = os.path.splitext(base)
|
|
60
|
+
typed = _normalize_ext(base_ext) if base_ext else ""
|
|
61
|
+
|
|
62
|
+
def strip_trailing_allowed(stem: str) -> str:
|
|
63
|
+
"""Remove repeated extension in stem (e.g. 'foo.png' then + '.png')."""
|
|
64
|
+
lowered = stem.lower()
|
|
65
|
+
for a in allowed:
|
|
66
|
+
suf = "." + a
|
|
67
|
+
if lowered.endswith(suf):
|
|
68
|
+
return stem[:-len(suf)]
|
|
69
|
+
return stem
|
|
70
|
+
|
|
71
|
+
base_stem = strip_trailing_allowed(base_stem)
|
|
72
|
+
|
|
73
|
+
# Choose final extension
|
|
74
|
+
if not typed:
|
|
75
|
+
final_ext = default_ext
|
|
76
|
+
else:
|
|
77
|
+
final_ext = typed if typed in allowed else default_ext
|
|
78
|
+
|
|
79
|
+
# Rebuild name with the chosen extension, then sanitize the WHOLE basename
|
|
80
|
+
basename_target = f"{base_stem}.{final_ext}"
|
|
81
|
+
basename_safe = _sanitize_filename(basename_target, replace_spaces=REPLACE_SPACES_WITH_UNDERSCORES)
|
|
82
|
+
|
|
83
|
+
# Final join (create dir if missing)
|
|
84
|
+
final_path = safe_join_dir_and_name(directory, basename_safe)
|
|
85
|
+
return final_path, final_ext
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def display_name(doc) -> str:
|
|
89
|
+
"""Best-effort title for any doc-like object."""
|
|
90
|
+
# Prefer a method
|
|
91
|
+
for attr in ("display_name", "title", "name"):
|
|
92
|
+
v = getattr(doc, attr, None)
|
|
93
|
+
if callable(v):
|
|
94
|
+
try:
|
|
95
|
+
s = v()
|
|
96
|
+
if isinstance(s, str) and s.strip():
|
|
97
|
+
return s
|
|
98
|
+
except Exception:
|
|
99
|
+
pass
|
|
100
|
+
elif isinstance(v, str) and v.strip():
|
|
101
|
+
return v
|
|
102
|
+
|
|
103
|
+
# Metadata fallbacks
|
|
104
|
+
md = getattr(doc, "metadata", {}) or {}
|
|
105
|
+
if isinstance(md, dict):
|
|
106
|
+
for k in ("display_name", "title", "name", "filename", "basename"):
|
|
107
|
+
s = md.get(k)
|
|
108
|
+
if isinstance(s, str) and s.strip():
|
|
109
|
+
return s
|
|
110
|
+
|
|
111
|
+
# Last resort: id snippet
|
|
112
|
+
return f"Document-{id(doc) & 0xFFFF:04X}"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def best_doc_name(doc) -> str:
|
|
116
|
+
"""Get the best available name for a document."""
|
|
117
|
+
# Try common attributes in order
|
|
118
|
+
for attr in ("display_name", "name", "title"):
|
|
119
|
+
v = getattr(doc, attr, None)
|
|
120
|
+
if callable(v):
|
|
121
|
+
try:
|
|
122
|
+
v = v()
|
|
123
|
+
except Exception:
|
|
124
|
+
v = None
|
|
125
|
+
if isinstance(v, str) and v.strip():
|
|
126
|
+
return v.strip()
|
|
127
|
+
|
|
128
|
+
# Fallback: derive from original path if we have it
|
|
129
|
+
try:
|
|
130
|
+
meta = getattr(doc, "metadata", {}) or {}
|
|
131
|
+
fp = meta.get("file_path")
|
|
132
|
+
if isinstance(fp, str) and fp:
|
|
133
|
+
return os.path.splitext(os.path.basename(fp))[0]
|
|
134
|
+
except Exception:
|
|
135
|
+
pass
|
|
136
|
+
|
|
137
|
+
return "untitled"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def doc_looks_like_table(doc) -> bool:
|
|
141
|
+
"""Determine if a document represents tabular data rather than an image."""
|
|
142
|
+
md = getattr(doc, "metadata", {}) or {}
|
|
143
|
+
|
|
144
|
+
# Explicit type hints from own pipeline
|
|
145
|
+
if str(md.get("doc_type", "")).lower() in {"table", "catalog", "fits_table"}:
|
|
146
|
+
return True
|
|
147
|
+
if str(md.get("fits_hdu_type", "")).lower().endswith("tablehdu"):
|
|
148
|
+
return True
|
|
149
|
+
if str(md.get("hdu_class", "")).lower().endswith("tablehdu"):
|
|
150
|
+
return True
|
|
151
|
+
|
|
152
|
+
# FITS header inspection (common with astropy)
|
|
153
|
+
hdr = md.get("original_header") or md.get("fits_header") or {}
|
|
154
|
+
try:
|
|
155
|
+
xt = str(hdr.get("XTENSION", "")).upper()
|
|
156
|
+
if xt in {"TABLE", "BINTABLE", "ASCIITABLE"}:
|
|
157
|
+
return True
|
|
158
|
+
except Exception:
|
|
159
|
+
pass
|
|
160
|
+
|
|
161
|
+
# Structural hints from the doc
|
|
162
|
+
if hasattr(doc, "table"):
|
|
163
|
+
return True
|
|
164
|
+
if hasattr(doc, "columns"):
|
|
165
|
+
return True
|
|
166
|
+
if hasattr(doc, "rows") or hasattr(doc, "headers"):
|
|
167
|
+
return True
|
|
168
|
+
|
|
169
|
+
# Last resort: no image but we clearly have column metadata
|
|
170
|
+
if getattr(doc, "image", None) is None and isinstance(md.get("columns"), (list, tuple)):
|
|
171
|
+
return True
|
|
172
|
+
|
|
173
|
+
return False
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def is_alive(obj) -> bool:
|
|
177
|
+
"""True if obj is a live Qt wrapper (not deleted)."""
|
|
178
|
+
if obj is None:
|
|
179
|
+
return False
|
|
180
|
+
if sip is not None:
|
|
181
|
+
try:
|
|
182
|
+
return not sip.isdeleted(obj)
|
|
183
|
+
except Exception:
|
|
184
|
+
pass
|
|
185
|
+
# Touch-test: some cheap attribute access; if wrapper is dead this raises RuntimeError
|
|
186
|
+
try:
|
|
187
|
+
getattr(obj, "objectName", None)
|
|
188
|
+
return True
|
|
189
|
+
except RuntimeError:
|
|
190
|
+
return False
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def safe_widget(sw) -> Optional[object]:
|
|
194
|
+
"""Returns sw.widget() if both subwindow and its widget are alive; else None."""
|
|
195
|
+
try:
|
|
196
|
+
if not is_alive(sw):
|
|
197
|
+
return None
|
|
198
|
+
w = sw.widget()
|
|
199
|
+
return w if is_alive(w) else None
|
|
200
|
+
except Exception:
|
|
201
|
+
return None
|