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.
- 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
|
@@ -83,11 +83,12 @@ from setiastro.saspro.legacy.numba_utils import (
|
|
|
83
83
|
finalize_drizzle_2d,
|
|
84
84
|
finalize_drizzle_3d,
|
|
85
85
|
)
|
|
86
|
-
from setiastro.saspro.
|
|
86
|
+
from setiastro.saspro.numba_utils import (
|
|
87
87
|
bulk_cosmetic_correction_numba,
|
|
88
88
|
drizzle_deposit_numba_naive,
|
|
89
89
|
drizzle_deposit_color_naive,
|
|
90
|
-
bulk_cosmetic_correction_bayer
|
|
90
|
+
bulk_cosmetic_correction_bayer,
|
|
91
|
+
gradient_descent_to_dim_spot_numba
|
|
91
92
|
)
|
|
92
93
|
from setiastro.saspro.legacy.image_manager import load_image, save_image, get_valid_header
|
|
93
94
|
from setiastro.saspro.star_alignment import StarRegistrationWorker, StarRegistrationThread, IDENTITY_2x3
|
|
@@ -153,64 +154,107 @@ _FITS_EXTS = ('.fits', '.fit', '.fts', '.fits.gz', '.fit.gz', '.fts.gz', '.fz')
|
|
|
153
154
|
|
|
154
155
|
def get_valid_header(path: str):
|
|
155
156
|
"""
|
|
156
|
-
|
|
157
|
+
Fast header-only FITS peek with a targeted fallback:
|
|
158
|
+
|
|
159
|
+
1) Header-only scan (lazy_load_hdus=True, never touches .data)
|
|
160
|
+
2) If NAXIS1/2 still missing/invalid, fallback to reading ONE image HDU's data
|
|
161
|
+
to get shape, then patch NAXIS/NAXIS1/NAXIS2.
|
|
157
162
|
|
|
158
|
-
|
|
159
|
-
- Forces NAXIS, NAXIS1, NAXIS2 from the actual data.shape if possible.
|
|
160
|
-
- Falls back to ZNAXIS1/2 for tile-compressed images.
|
|
163
|
+
Returns: (hdr, ok_bool)
|
|
161
164
|
"""
|
|
162
165
|
try:
|
|
163
166
|
from astropy.io import fits
|
|
164
167
|
|
|
165
|
-
|
|
168
|
+
def _is_good_dim(v):
|
|
169
|
+
try:
|
|
170
|
+
return int(v) > 0
|
|
171
|
+
except Exception:
|
|
172
|
+
return False
|
|
173
|
+
|
|
174
|
+
# ---------------------------
|
|
175
|
+
# Pass 1: header-only
|
|
176
|
+
# ---------------------------
|
|
177
|
+
with fits.open(path, mode="readonly", memmap=True, lazy_load_hdus=True) as hdul:
|
|
166
178
|
science_hdu = None
|
|
167
179
|
|
|
168
|
-
# Prefer the first HDU that actually has 2D+ image data
|
|
169
180
|
for hdu in hdul:
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
181
|
+
hdr = hdu.header
|
|
182
|
+
|
|
183
|
+
# Prefer HDUs that *declare* 2D+ via header
|
|
184
|
+
naxis = hdr.get("NAXIS", None)
|
|
185
|
+
znaxis = hdr.get("ZNAXIS", None)
|
|
186
|
+
|
|
187
|
+
looks_2d = False
|
|
188
|
+
try:
|
|
189
|
+
if naxis is not None and int(naxis) >= 2:
|
|
190
|
+
looks_2d = True
|
|
191
|
+
except Exception:
|
|
192
|
+
pass
|
|
193
|
+
try:
|
|
194
|
+
if znaxis is not None and int(znaxis) >= 2:
|
|
195
|
+
looks_2d = True
|
|
196
|
+
except Exception:
|
|
197
|
+
pass
|
|
198
|
+
|
|
199
|
+
if looks_2d:
|
|
174
200
|
science_hdu = hdu
|
|
175
201
|
break
|
|
176
202
|
|
|
177
203
|
if science_hdu is None:
|
|
178
|
-
# Fallback: just use primary
|
|
179
204
|
science_hdu = hdul[0]
|
|
180
205
|
|
|
181
206
|
hdr = science_hdu.header.copy()
|
|
182
|
-
data = science_hdu.data
|
|
183
207
|
|
|
184
|
-
#
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
ny, nx = shape[-2], shape[-1]
|
|
190
|
-
hdr["NAXIS"] = int(data.ndim)
|
|
191
|
-
hdr["NAXIS1"] = int(nx)
|
|
192
|
-
hdr["NAXIS2"] = int(ny)
|
|
193
|
-
except Exception:
|
|
194
|
-
pass
|
|
208
|
+
# Prefer normal NAXISn; fallback to ZNAXISn for tile-compressed
|
|
209
|
+
if not _is_good_dim(hdr.get("NAXIS1")) and _is_good_dim(hdr.get("ZNAXIS1")):
|
|
210
|
+
hdr["NAXIS1"] = int(hdr["ZNAXIS1"])
|
|
211
|
+
if not _is_good_dim(hdr.get("NAXIS2")) and _is_good_dim(hdr.get("ZNAXIS2")):
|
|
212
|
+
hdr["NAXIS2"] = int(hdr["ZNAXIS2"])
|
|
195
213
|
|
|
196
|
-
#
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
214
|
+
# If we already have good dims, we are done (FAST PATH)
|
|
215
|
+
if _is_good_dim(hdr.get("NAXIS1")) and _is_good_dim(hdr.get("NAXIS2")):
|
|
216
|
+
return hdr, True
|
|
217
|
+
|
|
218
|
+
# ---------------------------
|
|
219
|
+
# Pass 2: slow fallback (ONLY if needed)
|
|
220
|
+
# ---------------------------
|
|
221
|
+
# Re-open without lazy semantics and read ONE image-like HDU's data to infer shape.
|
|
222
|
+
with fits.open(path, mode="readonly", memmap=False) as hdul:
|
|
223
|
+
target_hdu = None
|
|
224
|
+
for hdu in hdul:
|
|
225
|
+
# data access is expensive; try to choose wisely by header first
|
|
226
|
+
naxis = hdu.header.get("NAXIS", 0)
|
|
227
|
+
znaxis = hdu.header.get("ZNAXIS", 0)
|
|
206
228
|
|
|
207
|
-
|
|
229
|
+
try:
|
|
230
|
+
if int(naxis) >= 2 or int(znaxis) >= 2:
|
|
231
|
+
target_hdu = hdu
|
|
232
|
+
break
|
|
233
|
+
except Exception:
|
|
234
|
+
continue
|
|
208
235
|
|
|
209
|
-
|
|
210
|
-
|
|
236
|
+
if target_hdu is None:
|
|
237
|
+
target_hdu = hdul[0]
|
|
238
|
+
|
|
239
|
+
# Now (and only now) touch data
|
|
240
|
+
data = getattr(target_hdu, "data", None)
|
|
211
241
|
|
|
242
|
+
hdr2 = target_hdu.header.copy()
|
|
243
|
+
if data is not None and getattr(data, "ndim", 0) >= 2:
|
|
244
|
+
try:
|
|
245
|
+
ny, nx = data.shape[-2], data.shape[-1]
|
|
246
|
+
hdr2["NAXIS"] = int(getattr(data, "ndim", hdr2.get("NAXIS", 2)))
|
|
247
|
+
hdr2["NAXIS1"] = int(nx)
|
|
248
|
+
hdr2["NAXIS2"] = int(ny)
|
|
249
|
+
return hdr2, True
|
|
250
|
+
except Exception:
|
|
251
|
+
pass
|
|
212
252
|
|
|
253
|
+
# If still unknown, return header anyway (caller can show "Unknown")
|
|
254
|
+
return hdr2, True
|
|
213
255
|
|
|
256
|
+
except Exception:
|
|
257
|
+
return None, False
|
|
214
258
|
|
|
215
259
|
def _read_tile_stack(file_list, y0, y1, x0, x1, channels, out_buf):
|
|
216
260
|
"""
|
|
@@ -704,12 +748,33 @@ def normalize_images(stack: np.ndarray,
|
|
|
704
748
|
print(f"Normalizing {i}")
|
|
705
749
|
f = stack[i].astype(np.float32, copy=False)
|
|
706
750
|
L = _L(f)
|
|
751
|
+
|
|
752
|
+
# Optimization: Don't allocate f0 and L0. Use math properties.
|
|
753
|
+
# fmin = min(L)
|
|
754
|
+
# f0 = f - fmin
|
|
755
|
+
# L0 = L(f0) = L(f - fmin) = L(f) - fmin (since L is linear sum of channels)
|
|
756
|
+
# median(L0) = median(L - fmin) = median(L) - fmin
|
|
757
|
+
|
|
758
|
+
# Calculate stats on original L
|
|
759
|
+
# Note: nanmin/nanmedian are used to be safe against bad pixels
|
|
707
760
|
fmin = float(np.nanmin(L))
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
761
|
+
lmed_original = float(np.nanmedian(L))
|
|
762
|
+
|
|
763
|
+
# The median of the zero-shifted image
|
|
764
|
+
fmed = lmed_original - fmin
|
|
765
|
+
|
|
711
766
|
gain = (target_median / max(fmed, eps)) if target_median > 0 else 1.0
|
|
712
|
-
|
|
767
|
+
|
|
768
|
+
# Combine subtraction and multiplication into one operation for 'out'
|
|
769
|
+
# out = (f - fmin) * gain
|
|
770
|
+
# This avoids creating the large temporary array 'f0'
|
|
771
|
+
|
|
772
|
+
# We can implement this as: out[i] = f * gain - (fmin * gain)
|
|
773
|
+
# But we must be careful with precision. Typically fine.
|
|
774
|
+
# Or just: np.subtract(f, fmin, out=out[i]); np.multiply(out[i], gain, out=out[i])
|
|
775
|
+
|
|
776
|
+
# Using direct assignment is cleaner and numpy optimizes it well enough
|
|
777
|
+
out[i] = (f - fmin) * gain
|
|
713
778
|
|
|
714
779
|
return np.ascontiguousarray(out, dtype=np.float32)
|
|
715
780
|
|
|
@@ -864,6 +929,11 @@ def _to_Luma(img: np.ndarray) -> np.ndarray:
|
|
|
864
929
|
if img.ndim == 2:
|
|
865
930
|
return img.astype(np.float32, copy=False)
|
|
866
931
|
# HWC RGB
|
|
932
|
+
if img.shape[2] == 3:
|
|
933
|
+
try:
|
|
934
|
+
return cv2.cvtColor(img, cv2.COLOR_RGB2GRAY).astype(np.float32, copy=False)
|
|
935
|
+
except Exception:
|
|
936
|
+
pass # fallback
|
|
867
937
|
r, g, b = img[..., 0].astype(np.float32), img[..., 1].astype(np.float32), img[..., 2].astype(np.float32)
|
|
868
938
|
return 0.2989 * r + 0.5870 * g + 0.1140 * b
|
|
869
939
|
|
|
@@ -898,27 +968,8 @@ def _exclude_bright_regions(gray_small: np.ndarray, exclusion_fraction: float =
|
|
|
898
968
|
|
|
899
969
|
|
|
900
970
|
def _gradient_descent_to_dim_spot(gray_small: np.ndarray, x: int, y: int, patch: int) -> tuple[int, int]:
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
def patch_median(px, py):
|
|
904
|
-
x0, x1 = max(0, px - half), min(W, px + half + 1)
|
|
905
|
-
y0, y1 = max(0, py - half), min(H, py + half + 1)
|
|
906
|
-
return float(np.median(gray_small[y0:y1, x0:x1]))
|
|
907
|
-
cx, cy = int(np.clip(x, 0, W-1)), int(np.clip(y, 0, H-1))
|
|
908
|
-
for _ in range(60):
|
|
909
|
-
cur = patch_median(cx, cy)
|
|
910
|
-
best = (cx, cy); best_val = cur
|
|
911
|
-
for nx in (cx-1, cx, cx+1):
|
|
912
|
-
for ny in (cy-1, cy, cy+1):
|
|
913
|
-
if nx == cx and ny == cy: continue
|
|
914
|
-
if nx < 0 or ny < 0 or nx >= W or ny >= H: continue
|
|
915
|
-
val = patch_median(nx, ny)
|
|
916
|
-
if val < best_val:
|
|
917
|
-
best_val = val; best = (nx, ny)
|
|
918
|
-
if best == (cx, cy):
|
|
919
|
-
break
|
|
920
|
-
cx, cy = best
|
|
921
|
-
return cx, cy
|
|
971
|
+
# Delegate to Numba optimized version
|
|
972
|
+
return gradient_descent_to_dim_spot_numba(gray_small, int(x), int(y), int(patch))
|
|
922
973
|
|
|
923
974
|
def _generate_sample_points_small(
|
|
924
975
|
img_small: np.ndarray,
|
|
@@ -3908,7 +3959,11 @@ class StackingSuiteDialog(QDialog):
|
|
|
3908
3959
|
self._wrench_path = wrench_path
|
|
3909
3960
|
self._spinner_path = spinner_path
|
|
3910
3961
|
self._post_progress_label = None
|
|
3911
|
-
|
|
3962
|
+
self._dark_group_item = {} # key -> QTreeWidgetItem
|
|
3963
|
+
self._flat_filter_item = {} # filter_name -> QTreeWidgetItem
|
|
3964
|
+
self._flat_exp_item = {} # (filter_name, want_label) -> QTreeWidgetItem
|
|
3965
|
+
self._light_filter_item = {} # filter_name -> QTreeWidgetItem
|
|
3966
|
+
self._light_exp_item = {} # (filter_name, want_label) -> QTreeWidgetItem
|
|
3912
3967
|
|
|
3913
3968
|
self.setWindowTitle(self.tr("Stacking Suite"))
|
|
3914
3969
|
self.setGeometry(300, 200, 800, 600)
|
|
@@ -5115,8 +5170,9 @@ class StackingSuiteDialog(QDialog):
|
|
|
5115
5170
|
disto_form.addRow(self.tr("Max control points:"), self.align_max_cp)
|
|
5116
5171
|
|
|
5117
5172
|
self.align_downsample = QSpinBox()
|
|
5118
|
-
self.align_downsample.setRange(1,
|
|
5119
|
-
self.align_downsample.setValue(self.settings.value("stacking/align/downsample",
|
|
5173
|
+
self.align_downsample.setRange(1, 64) # or 1..32; 64 if you want “any integer”
|
|
5174
|
+
self.align_downsample.setValue(self.settings.value("stacking/align/downsample", 3, type=int))
|
|
5175
|
+
self.align_downsample.setToolTip(self.tr("Alignment solve downsample. 1 = full res; higher = faster but less accurate."))
|
|
5120
5176
|
disto_form.addRow(self.tr("Solve downsample:"), self.align_downsample)
|
|
5121
5177
|
|
|
5122
5178
|
# Homography / Similarity-specific RANSAC reprojection threshold
|
|
@@ -6004,12 +6060,19 @@ class StackingSuiteDialog(QDialog):
|
|
|
6004
6060
|
w.blockSignals(True); w.setValue(v); w.blockSignals(False)
|
|
6005
6061
|
|
|
6006
6062
|
def _get_drizzle_scale(self) -> float:
|
|
6007
|
-
|
|
6008
|
-
|
|
6009
|
-
|
|
6010
|
-
|
|
6011
|
-
|
|
6012
|
-
|
|
6063
|
+
val = self.settings.value("stacking/drizzle_scale", "2x")
|
|
6064
|
+
if isinstance(val, (int, float)):
|
|
6065
|
+
return float(val)
|
|
6066
|
+
if isinstance(val, str):
|
|
6067
|
+
s = val.strip().lower()
|
|
6068
|
+
if s.endswith("x"):
|
|
6069
|
+
s = s[:-1]
|
|
6070
|
+
try:
|
|
6071
|
+
return float(s)
|
|
6072
|
+
except Exception:
|
|
6073
|
+
return 2.0
|
|
6074
|
+
return 2.0
|
|
6075
|
+
|
|
6013
6076
|
|
|
6014
6077
|
def _set_drizzle_scale(self, r: float | str) -> None:
|
|
6015
6078
|
if isinstance(r, str):
|
|
@@ -6025,6 +6088,25 @@ class StackingSuiteDialog(QDialog):
|
|
|
6025
6088
|
self.drizzle_scale_combo.setCurrentText(txt)
|
|
6026
6089
|
self.drizzle_scale_combo.blockSignals(False)
|
|
6027
6090
|
|
|
6091
|
+
def _get_drizzle_enabled(self) -> bool:
|
|
6092
|
+
# UI checkbox wins if it exists (most “live” truth)
|
|
6093
|
+
cb = getattr(self, "drizzle_checkbox", None)
|
|
6094
|
+
if cb is not None:
|
|
6095
|
+
try:
|
|
6096
|
+
return bool(cb.isChecked())
|
|
6097
|
+
except Exception:
|
|
6098
|
+
pass
|
|
6099
|
+
# fallback to settings (headless / older flows)
|
|
6100
|
+
return bool(self.settings.value("stacking/drizzle_enabled", False, type=bool))
|
|
6101
|
+
|
|
6102
|
+
def _set_drizzle_enabled(self, on: bool) -> None:
|
|
6103
|
+
on = bool(on)
|
|
6104
|
+
self.settings.setValue("stacking/drizzle_enabled", on)
|
|
6105
|
+
cb = getattr(self, "drizzle_checkbox", None)
|
|
6106
|
+
if cb is not None and cb.isChecked() != on:
|
|
6107
|
+
cb.blockSignals(True)
|
|
6108
|
+
cb.setChecked(on)
|
|
6109
|
+
cb.blockSignals(False)
|
|
6028
6110
|
|
|
6029
6111
|
def closeEvent(self, e):
|
|
6030
6112
|
# Graceful shutdown for any running workers
|
|
@@ -6340,7 +6422,7 @@ class StackingSuiteDialog(QDialog):
|
|
|
6340
6422
|
dark_frames_layout.addLayout(btn_layout)
|
|
6341
6423
|
|
|
6342
6424
|
self.clear_dark_selection_btn = QPushButton(self.tr("Clear Selection"))
|
|
6343
|
-
self.clear_dark_selection_btn.clicked.connect(lambda: self.
|
|
6425
|
+
self.clear_dark_selection_btn.clicked.connect(lambda: self.clear_tree_selection_dark(self.dark_tree, self.dark_files))
|
|
6344
6426
|
dark_frames_layout.addWidget(self.clear_dark_selection_btn)
|
|
6345
6427
|
|
|
6346
6428
|
darks_layout.addLayout(dark_frames_layout, 2) # Dark Frames Tree takes more space
|
|
@@ -6413,6 +6495,9 @@ class StackingSuiteDialog(QDialog):
|
|
|
6413
6495
|
)
|
|
6414
6496
|
main_layout.addWidget(self.clear_master_dark_selection_btn)
|
|
6415
6497
|
|
|
6498
|
+
self.dark_tree.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
|
6499
|
+
self.dark_tree.customContextMenuRequested.connect(self.dark_tree_context_menu)
|
|
6500
|
+
|
|
6416
6501
|
return tab
|
|
6417
6502
|
|
|
6418
6503
|
def _tree_for_type(self, t: str):
|
|
@@ -6644,6 +6729,23 @@ class StackingSuiteDialog(QDialog):
|
|
|
6644
6729
|
|
|
6645
6730
|
return tab
|
|
6646
6731
|
|
|
6732
|
+
def dark_tree_context_menu(self, pos):
|
|
6733
|
+
item = self.dark_tree.itemAt(pos)
|
|
6734
|
+
if not item:
|
|
6735
|
+
return
|
|
6736
|
+
|
|
6737
|
+
# ✅ same selection behavior
|
|
6738
|
+
if not item.isSelected():
|
|
6739
|
+
if not (QApplication.keyboardModifiers() & (Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.ShiftModifier)):
|
|
6740
|
+
self.dark_tree.clearSelection()
|
|
6741
|
+
item.setSelected(True)
|
|
6742
|
+
|
|
6743
|
+
menu = QMenu(self.dark_tree)
|
|
6744
|
+
set_session_action = menu.addAction(self.tr("Set Session Tag..."))
|
|
6745
|
+
|
|
6746
|
+
action = menu.exec(self.dark_tree.viewport().mapToGlobal(pos))
|
|
6747
|
+
if action == set_session_action:
|
|
6748
|
+
self.prompt_set_session(item, "DARK")
|
|
6647
6749
|
|
|
6648
6750
|
|
|
6649
6751
|
def flat_tree_context_menu(self, pos):
|
|
@@ -6938,27 +7040,27 @@ class StackingSuiteDialog(QDialog):
|
|
|
6938
7040
|
name = (self.settings.value("stacking/session_keyword", "Default", type=str) or "").strip()
|
|
6939
7041
|
return name or "Default"
|
|
6940
7042
|
|
|
6941
|
-
def
|
|
6942
|
-
|
|
7043
|
+
def _is_leaf(it):
|
|
7044
|
+
# leaf == no children AND looks like a file row (has a filename)
|
|
7045
|
+
if not it or it.childCount() != 0:
|
|
7046
|
+
return False
|
|
7047
|
+
name = (it.text(0) or "").strip()
|
|
7048
|
+
# file rows in your UI are actual filenames
|
|
7049
|
+
return bool(name) and "." in name
|
|
7050
|
+
|
|
6943
7051
|
|
|
6944
7052
|
def _iter_leaf_descendants(self, it: QTreeWidgetItem):
|
|
6945
|
-
"""Yield all leaf grandchildren under a filter row or exposure row."""
|
|
6946
7053
|
if not it:
|
|
6947
7054
|
return
|
|
6948
|
-
|
|
6949
|
-
|
|
6950
|
-
|
|
6951
|
-
|
|
6952
|
-
|
|
6953
|
-
|
|
6954
|
-
|
|
6955
|
-
|
|
6956
|
-
|
|
6957
|
-
elif it.parent() and it.parent().parent() is None and it.childCount() > 0:
|
|
6958
|
-
for k in range(it.childCount()):
|
|
6959
|
-
leaf = it.child(k)
|
|
6960
|
-
if self._is_leaf_item(leaf):
|
|
6961
|
-
yield leaf
|
|
7055
|
+
stack = [it]
|
|
7056
|
+
while stack:
|
|
7057
|
+
cur = stack.pop()
|
|
7058
|
+
if self._is_leaf_item(cur):
|
|
7059
|
+
yield cur
|
|
7060
|
+
continue
|
|
7061
|
+
for i in range(cur.childCount()):
|
|
7062
|
+
stack.append(cur.child(i))
|
|
7063
|
+
|
|
6962
7064
|
|
|
6963
7065
|
def _collect_target_leaves(self, tree: QTreeWidget, clicked_item: QTreeWidgetItem | None = None) -> list[QTreeWidgetItem]:
|
|
6964
7066
|
"""
|
|
@@ -7074,43 +7176,59 @@ class StackingSuiteDialog(QDialog):
|
|
|
7074
7176
|
return kw
|
|
7075
7177
|
|
|
7076
7178
|
def prompt_set_session(self, item, frame_type):
|
|
7077
|
-
text, ok = QInputDialog.getText(
|
|
7179
|
+
text, ok = QInputDialog.getText(
|
|
7180
|
+
self,
|
|
7181
|
+
self.tr("Set Session Tag"),
|
|
7182
|
+
self.tr("Enter session name:")
|
|
7183
|
+
)
|
|
7078
7184
|
if not (ok and (text or "").strip()):
|
|
7079
7185
|
return
|
|
7080
7186
|
session_name = text.strip()
|
|
7081
7187
|
|
|
7082
|
-
|
|
7083
|
-
|
|
7084
|
-
|
|
7188
|
+
ft = (frame_type or "").upper()
|
|
7189
|
+
is_flat = (ft == "FLAT")
|
|
7190
|
+
is_light = (ft == "LIGHT")
|
|
7191
|
+
is_dark = (ft == "DARK")
|
|
7192
|
+
|
|
7193
|
+
if is_flat:
|
|
7194
|
+
tree = self.flat_tree
|
|
7195
|
+
target_dict = self.flat_files
|
|
7196
|
+
elif is_light:
|
|
7197
|
+
tree = self.light_tree
|
|
7198
|
+
target_dict = self.light_files
|
|
7199
|
+
elif is_dark:
|
|
7200
|
+
tree = self.dark_tree
|
|
7201
|
+
target_dict = self.dark_files
|
|
7202
|
+
else:
|
|
7203
|
+
return
|
|
7085
7204
|
|
|
7086
7205
|
if not hasattr(self, "session_tags") or self.session_tags is None:
|
|
7087
7206
|
self.session_tags = {}
|
|
7088
7207
|
|
|
7089
7208
|
# --- helper: identify a "leaf" row in your tree (file row) ---
|
|
7090
7209
|
def _is_leaf(it):
|
|
7091
|
-
|
|
7210
|
+
# leaf == no children AND looks like a file row (has a filename)
|
|
7211
|
+
if not it or it.childCount() != 0:
|
|
7212
|
+
return False
|
|
7213
|
+
name = (it.text(0) or "").strip()
|
|
7214
|
+
# file rows in your UI are actual filenames
|
|
7215
|
+
return bool(name) and "." in name
|
|
7216
|
+
|
|
7092
7217
|
|
|
7093
7218
|
def _iter_leaf_descendants(parent_item):
|
|
7094
|
-
"""Yield all leaf file rows under
|
|
7219
|
+
"""Yield all leaf file rows under any parent row (any depth)."""
|
|
7095
7220
|
if not parent_item:
|
|
7096
7221
|
return
|
|
7097
|
-
|
|
7098
|
-
|
|
7099
|
-
|
|
7100
|
-
|
|
7101
|
-
|
|
7102
|
-
|
|
7103
|
-
|
|
7104
|
-
|
|
7105
|
-
# exposure row
|
|
7106
|
-
elif parent_item.parent() and parent_item.parent().parent() is None and parent_item.childCount() > 0:
|
|
7107
|
-
for k in range(parent_item.childCount()):
|
|
7108
|
-
leaf = parent_item.child(k)
|
|
7109
|
-
if _is_leaf(leaf):
|
|
7110
|
-
yield leaf
|
|
7222
|
+
stack = [parent_item]
|
|
7223
|
+
while stack:
|
|
7224
|
+
cur = stack.pop()
|
|
7225
|
+
if _is_leaf(cur):
|
|
7226
|
+
yield cur
|
|
7227
|
+
continue
|
|
7228
|
+
for j in range(cur.childCount()):
|
|
7229
|
+
stack.append(cur.child(j))
|
|
7111
7230
|
|
|
7112
7231
|
def _session_from_leaf(leaf):
|
|
7113
|
-
# Prefer cached value (we’ll set it during ingest/retag)
|
|
7114
7232
|
try:
|
|
7115
7233
|
s = leaf.data(0, Qt.ItemDataRole.UserRole + 1)
|
|
7116
7234
|
if isinstance(s, str) and s.strip():
|
|
@@ -7134,42 +7252,65 @@ class StackingSuiteDialog(QDialog):
|
|
|
7134
7252
|
except Exception:
|
|
7135
7253
|
pass
|
|
7136
7254
|
|
|
7137
|
-
def _rekey_session_for_path(
|
|
7255
|
+
def _rekey_session_for_path(target_dict: dict, fpath: str, new_session: str, *, group_key_hint: str | None = None):
|
|
7138
7256
|
"""
|
|
7139
|
-
Move fpath from (group_key, old_session)
|
|
7140
|
-
|
|
7257
|
+
Move fpath from whatever (group_key, old_session) bucket(s) it's currently in
|
|
7258
|
+
to (same_group_key, new_session).
|
|
7259
|
+
|
|
7260
|
+
This is robust even if the tree-derived group_key string doesn't exactly match
|
|
7261
|
+
the dict key[0] that was used when the file was added.
|
|
7141
7262
|
"""
|
|
7142
|
-
|
|
7143
|
-
|
|
7144
|
-
|
|
7145
|
-
|
|
7146
|
-
|
|
7147
|
-
|
|
7148
|
-
|
|
7149
|
-
|
|
7150
|
-
|
|
7151
|
-
|
|
7152
|
-
|
|
7153
|
-
|
|
7154
|
-
|
|
7155
|
-
|
|
7156
|
-
|
|
7157
|
-
|
|
7158
|
-
|
|
7159
|
-
|
|
7160
|
-
|
|
7161
|
-
|
|
7162
|
-
|
|
7263
|
+
new_session = (new_session or "Default").strip() or "Default"
|
|
7264
|
+
f_norm = os.path.normcase(os.path.abspath(fpath))
|
|
7265
|
+
|
|
7266
|
+
# 1) Find all tuple-keys containing this file, regardless of session
|
|
7267
|
+
found_group_keys: list[str] = []
|
|
7268
|
+
keys_to_delete = []
|
|
7269
|
+
|
|
7270
|
+
for key, lst in list(target_dict.items()):
|
|
7271
|
+
if not (isinstance(key, tuple) and len(key) >= 2):
|
|
7272
|
+
continue
|
|
7273
|
+
|
|
7274
|
+
# check if file exists in this bucket
|
|
7275
|
+
keep = []
|
|
7276
|
+
removed_here = False
|
|
7277
|
+
for p in (lst or []):
|
|
7278
|
+
if os.path.normcase(os.path.abspath(p)) == f_norm:
|
|
7279
|
+
removed_here = True
|
|
7280
|
+
else:
|
|
7281
|
+
keep.append(p)
|
|
7282
|
+
|
|
7283
|
+
if removed_here:
|
|
7284
|
+
gk = str(key[0])
|
|
7285
|
+
if gk not in found_group_keys:
|
|
7286
|
+
found_group_keys.append(gk)
|
|
7287
|
+
|
|
7288
|
+
# write back / delete empty
|
|
7289
|
+
if removed_here:
|
|
7290
|
+
if keep:
|
|
7291
|
+
target_dict[key] = keep
|
|
7292
|
+
else:
|
|
7293
|
+
keys_to_delete.append(key)
|
|
7294
|
+
|
|
7295
|
+
for k in keys_to_delete:
|
|
7296
|
+
target_dict.pop(k, None)
|
|
7297
|
+
|
|
7298
|
+
# If not found anywhere (rare), fall back to hint so at least it gets added
|
|
7299
|
+
if not found_group_keys and group_key_hint:
|
|
7300
|
+
found_group_keys = [group_key_hint]
|
|
7301
|
+
|
|
7302
|
+
# 2) Add to new-session bucket(s)
|
|
7303
|
+
for gk in found_group_keys:
|
|
7304
|
+
new_key = (gk, new_session)
|
|
7305
|
+
cur = list(target_dict.get(new_key, []) or [])
|
|
7306
|
+
cur_norms = {os.path.normcase(os.path.abspath(p)) for p in cur}
|
|
7307
|
+
if f_norm not in cur_norms:
|
|
7308
|
+
cur.append(fpath)
|
|
7309
|
+
target_dict[new_key] = cur
|
|
7163
7310
|
|
|
7164
|
-
# add to new key (avoid dupes)
|
|
7165
|
-
target_dict.setdefault(new_ck, [])
|
|
7166
|
-
if fpath not in target_dict[new_ck]:
|
|
7167
|
-
target_dict[new_ck].append(fpath)
|
|
7168
7311
|
|
|
7169
7312
|
# --- Build the set of leaf rows to retag ---
|
|
7170
7313
|
selected = list(tree.selectedItems() or [])
|
|
7171
|
-
|
|
7172
|
-
# Include the right-clicked item even if it wasn’t selected
|
|
7173
7314
|
if item and item not in selected:
|
|
7174
7315
|
selected.append(item)
|
|
7175
7316
|
|
|
@@ -7203,35 +7344,64 @@ class StackingSuiteDialog(QDialog):
|
|
|
7203
7344
|
# fallback once for legacy rows missing UserRole
|
|
7204
7345
|
if not fpath:
|
|
7205
7346
|
filename = leaf.text(0).lstrip("⚠️ ").strip()
|
|
7206
|
-
|
|
7207
|
-
|
|
7208
|
-
|
|
7209
|
-
|
|
7210
|
-
|
|
7347
|
+
# NOTE: this only works for tuple-keyed dicts; that's fine for flats/lights
|
|
7348
|
+
try:
|
|
7349
|
+
fpath = next(
|
|
7350
|
+
(p for (gk, sess), lst in target_dict.items() for p in (lst or [])
|
|
7351
|
+
if os.path.basename(p) == filename),
|
|
7352
|
+
None
|
|
7353
|
+
)
|
|
7354
|
+
except Exception:
|
|
7355
|
+
fpath = None
|
|
7211
7356
|
if fpath:
|
|
7212
7357
|
leaf.setData(0, Qt.ItemDataRole.UserRole, fpath)
|
|
7213
7358
|
|
|
7214
7359
|
if not fpath:
|
|
7215
7360
|
continue
|
|
7216
7361
|
|
|
7217
|
-
|
|
7218
|
-
|
|
7219
|
-
|
|
7220
|
-
if
|
|
7362
|
+
parent = leaf.parent()
|
|
7363
|
+
grand = parent.parent() if parent else None
|
|
7364
|
+
|
|
7365
|
+
if parent is None:
|
|
7366
|
+
continue
|
|
7367
|
+
|
|
7368
|
+
if is_dark:
|
|
7369
|
+
# DARK tree is 2-level: group -> file
|
|
7370
|
+
group_key = parent.text(0)
|
|
7371
|
+
|
|
7372
|
+
elif is_flat:
|
|
7373
|
+
# FLAT tree is (typically) 3-level: filter -> group -> file
|
|
7374
|
+
# Your create_master_flat groups by EXACT string: "{filter} - {group}"
|
|
7375
|
+
# where group is like "Unknown (4096x4096)" (what the middle node shows).
|
|
7376
|
+
if grand is None:
|
|
7377
|
+
# If your flat tree is actually 2-level in some configs, fall back safely
|
|
7378
|
+
group_key = parent.text(0)
|
|
7379
|
+
else:
|
|
7380
|
+
group_key = f"{grand.text(0)} - {parent.text(0)}"
|
|
7381
|
+
|
|
7382
|
+
elif is_light:
|
|
7383
|
+
# LIGHT is 3-level: filter -> exposure -> file
|
|
7384
|
+
if grand is None:
|
|
7385
|
+
continue
|
|
7386
|
+
group_key = f"{grand.text(0)} - {parent.text(0)}"
|
|
7387
|
+
else:
|
|
7221
7388
|
continue
|
|
7222
7389
|
|
|
7223
|
-
group_key = f"{filter_item.text(0)} - {exposure_item.text(0)}"
|
|
7224
|
-
old_session = _session_from_leaf(leaf)
|
|
7225
7390
|
|
|
7226
|
-
|
|
7227
|
-
|
|
7391
|
+
# We still compute group_key for a fallback hint, but removal is now bucket-scan based.
|
|
7392
|
+
if _session_from_leaf(leaf) != session_name:
|
|
7393
|
+
_rekey_session_for_path(target_dict, fpath, session_name, group_key_hint=group_key)
|
|
7228
7394
|
|
|
7395
|
+
|
|
7396
|
+
# Tag always updates UI + cache
|
|
7229
7397
|
self.session_tags[fpath] = session_name
|
|
7230
7398
|
_set_leaf_session_text(leaf, session_name)
|
|
7231
7399
|
changed += 1
|
|
7232
7400
|
|
|
7233
|
-
|
|
7234
|
-
|
|
7401
|
+
self._normalize_sessioned_files_map(target_dict)
|
|
7402
|
+
|
|
7403
|
+
# Only LIGHT needs reassignment of best master files
|
|
7404
|
+
if is_light:
|
|
7235
7405
|
try:
|
|
7236
7406
|
self.assign_best_master_files(fill_only=True)
|
|
7237
7407
|
except Exception:
|
|
@@ -7240,7 +7410,6 @@ class StackingSuiteDialog(QDialog):
|
|
|
7240
7410
|
tree.viewport().update()
|
|
7241
7411
|
self.update_status(self.tr(f"🟢 Assigned session '{session_name}' to {changed} file(s)."))
|
|
7242
7412
|
|
|
7243
|
-
|
|
7244
7413
|
def _quad_coverage_add(self, cov: np.ndarray, quad: np.ndarray):
|
|
7245
7414
|
"""
|
|
7246
7415
|
Rasterize a convex quad (4x2 float array of (x,y) in aligned coords) into 'cov' by +1 filling.
|
|
@@ -8467,6 +8636,28 @@ class StackingSuiteDialog(QDialog):
|
|
|
8467
8636
|
self.settings.setValue("stacking/master_darks", dark_paths)
|
|
8468
8637
|
self.settings.setValue("stacking/master_flats", flat_paths)
|
|
8469
8638
|
|
|
8639
|
+
def _purge_removed_paths(self, removed_paths: list[str]):
|
|
8640
|
+
if not removed_paths:
|
|
8641
|
+
return
|
|
8642
|
+
# purge session override cache
|
|
8643
|
+
if hasattr(self, "session_tags") and isinstance(self.session_tags, dict):
|
|
8644
|
+
for p in removed_paths:
|
|
8645
|
+
self.session_tags.pop(p, None)
|
|
8646
|
+
|
|
8647
|
+
# If you have any "ingested" caches, clear those too:
|
|
8648
|
+
if hasattr(self, "_ingested_paths") and isinstance(self._ingested_paths, set):
|
|
8649
|
+
for p in removed_paths:
|
|
8650
|
+
self._ingested_paths.discard(p)
|
|
8651
|
+
|
|
8652
|
+
if hasattr(self, "manual_flat_files") and isinstance(self.manual_flat_files, list):
|
|
8653
|
+
dead = {os.path.normcase(os.path.abspath(p)) for p in removed_paths if isinstance(p, str)}
|
|
8654
|
+
self.manual_flat_files = [p for p in self.manual_flat_files if os.path.normcase(os.path.abspath(p)) not in dead]
|
|
8655
|
+
|
|
8656
|
+
if hasattr(self, "manual_light_files") and isinstance(self.manual_light_files, list):
|
|
8657
|
+
dead = {os.path.normcase(os.path.abspath(p)) for p in removed_paths if isinstance(p, str)}
|
|
8658
|
+
self.manual_light_files = [p for p in self.manual_light_files if os.path.normcase(os.path.abspath(p)) not in dead]
|
|
8659
|
+
|
|
8660
|
+
|
|
8470
8661
|
def clear_tree_selection(self, tree, file_dict):
|
|
8471
8662
|
"""Clears selected items from a simple (non-tuple-keyed) tree like Master Darks or Darks tab."""
|
|
8472
8663
|
selected_items = tree.selectedItems()
|
|
@@ -8491,96 +8682,249 @@ class StackingSuiteDialog(QDialog):
|
|
|
8491
8682
|
del file_dict[key]
|
|
8492
8683
|
parent.removeChild(item)
|
|
8493
8684
|
|
|
8494
|
-
|
|
8495
|
-
def clear_tree_selection_light(self, tree):
|
|
8496
|
-
"""Clears the selection in the light tree and updates self.light_files accordingly."""
|
|
8685
|
+
def clear_tree_selection_dark(self, tree, file_dict):
|
|
8497
8686
|
selected_items = tree.selectedItems()
|
|
8498
8687
|
if not selected_items:
|
|
8499
8688
|
return
|
|
8500
8689
|
|
|
8690
|
+
removed_paths = []
|
|
8691
|
+
|
|
8501
8692
|
for item in selected_items:
|
|
8502
8693
|
parent = item.parent()
|
|
8694
|
+
|
|
8503
8695
|
if parent is None:
|
|
8504
|
-
#
|
|
8505
|
-
|
|
8506
|
-
|
|
8507
|
-
|
|
8508
|
-
|
|
8509
|
-
for
|
|
8510
|
-
|
|
8696
|
+
# top-level exposure group
|
|
8697
|
+
gk = item.text(0)
|
|
8698
|
+
|
|
8699
|
+
# remove ALL sessions for this exposure group
|
|
8700
|
+
keys_to_remove = []
|
|
8701
|
+
for k in list(file_dict.keys()):
|
|
8702
|
+
if isinstance(k, tuple) and len(k) >= 2:
|
|
8703
|
+
if str(k[0]) == gk:
|
|
8704
|
+
keys_to_remove.append(k)
|
|
8705
|
+
else:
|
|
8706
|
+
if str(k) == gk:
|
|
8707
|
+
keys_to_remove.append(k)
|
|
8708
|
+
|
|
8709
|
+
for k in keys_to_remove:
|
|
8710
|
+
for p in file_dict.get(k, []) or []:
|
|
8711
|
+
removed_paths.append(p)
|
|
8712
|
+
del file_dict[k]
|
|
8713
|
+
|
|
8511
8714
|
tree.takeTopLevelItem(tree.indexOfTopLevelItem(item))
|
|
8512
|
-
|
|
8513
|
-
|
|
8514
|
-
|
|
8515
|
-
|
|
8516
|
-
|
|
8517
|
-
|
|
8518
|
-
|
|
8519
|
-
|
|
8520
|
-
|
|
8521
|
-
|
|
8522
|
-
|
|
8715
|
+
continue
|
|
8716
|
+
|
|
8717
|
+
# leaf file node under exposure group
|
|
8718
|
+
gk = parent.text(0)
|
|
8719
|
+
fpath = item.data(0, Qt.ItemDataRole.UserRole)
|
|
8720
|
+
filename = item.text(0).lstrip("⚠️ ").strip()
|
|
8721
|
+
|
|
8722
|
+
keys_to_check = []
|
|
8723
|
+
for k in list(file_dict.keys()):
|
|
8724
|
+
if isinstance(k, tuple) and len(k) >= 2:
|
|
8725
|
+
if str(k[0]) == gk:
|
|
8726
|
+
keys_to_check.append(k)
|
|
8523
8727
|
else:
|
|
8524
|
-
|
|
8525
|
-
|
|
8526
|
-
|
|
8527
|
-
|
|
8528
|
-
|
|
8728
|
+
if str(k) == gk:
|
|
8729
|
+
keys_to_check.append(k)
|
|
8730
|
+
|
|
8731
|
+
for k in keys_to_check:
|
|
8732
|
+
lst = file_dict.get(k, []) or []
|
|
8733
|
+
new_lst = []
|
|
8734
|
+
for p in lst:
|
|
8735
|
+
if fpath and p == fpath:
|
|
8736
|
+
removed_paths.append(p)
|
|
8737
|
+
continue
|
|
8738
|
+
if (not fpath) and os.path.basename(p) == filename:
|
|
8739
|
+
removed_paths.append(p)
|
|
8740
|
+
continue
|
|
8741
|
+
new_lst.append(p)
|
|
8742
|
+
if new_lst:
|
|
8743
|
+
file_dict[k] = new_lst
|
|
8744
|
+
else:
|
|
8745
|
+
del file_dict[k]
|
|
8746
|
+
|
|
8747
|
+
parent.removeChild(item)
|
|
8748
|
+
|
|
8749
|
+
self._purge_removed_paths(removed_paths)
|
|
8750
|
+
|
|
8751
|
+
# normalize if sessioned (or if legacy)
|
|
8752
|
+
self._normalize_sessioned_files_map(file_dict)
|
|
8753
|
+
|
|
8754
|
+
def clear_tree_selection_light(self, tree):
|
|
8755
|
+
selected_items = tree.selectedItems()
|
|
8756
|
+
if not selected_items:
|
|
8757
|
+
return
|
|
8529
8758
|
|
|
8530
|
-
|
|
8531
|
-
if isinstance(key, tuple) and key[0] == group_key]
|
|
8759
|
+
removed_paths = []
|
|
8532
8760
|
|
|
8533
|
-
|
|
8534
|
-
|
|
8535
|
-
f for f in self.light_files[key] if os.path.basename(f) != filename
|
|
8536
|
-
]
|
|
8537
|
-
if not self.light_files[key]:
|
|
8538
|
-
del self.light_files[key]
|
|
8539
|
-
parent.removeChild(item)
|
|
8761
|
+
def _norm(p: str) -> str:
|
|
8762
|
+
return os.path.normcase(os.path.abspath(p))
|
|
8540
8763
|
|
|
8541
|
-
|
|
8764
|
+
def _remove_path_everywhere(fpath: str):
|
|
8765
|
+
if not fpath:
|
|
8766
|
+
return
|
|
8767
|
+
f_norm = _norm(fpath)
|
|
8768
|
+
keys_to_delete = []
|
|
8769
|
+
for k, lst in list(self.light_files.items()):
|
|
8770
|
+
if not (isinstance(k, tuple) and len(k) >= 2):
|
|
8771
|
+
continue
|
|
8772
|
+
keep = []
|
|
8773
|
+
removed = False
|
|
8774
|
+
for p in (lst or []):
|
|
8775
|
+
if _norm(p) == f_norm:
|
|
8776
|
+
removed = True
|
|
8777
|
+
else:
|
|
8778
|
+
keep.append(p)
|
|
8779
|
+
if removed:
|
|
8780
|
+
removed_paths.append(fpath)
|
|
8781
|
+
if keep:
|
|
8782
|
+
self.light_files[k] = keep
|
|
8783
|
+
else:
|
|
8784
|
+
keys_to_delete.append(k)
|
|
8785
|
+
for k in keys_to_delete:
|
|
8786
|
+
self.light_files.pop(k, None)
|
|
8787
|
+
|
|
8788
|
+
def _collect_leaf_paths_under(node):
|
|
8789
|
+
out = []
|
|
8790
|
+
stack = [node]
|
|
8791
|
+
while stack:
|
|
8792
|
+
cur = stack.pop()
|
|
8793
|
+
if cur.childCount() == 0:
|
|
8794
|
+
fp = cur.data(0, Qt.ItemDataRole.UserRole)
|
|
8795
|
+
if isinstance(fp, str) and fp.strip():
|
|
8796
|
+
out.append(fp)
|
|
8797
|
+
continue
|
|
8798
|
+
for j in range(cur.childCount()):
|
|
8799
|
+
stack.append(cur.child(j))
|
|
8800
|
+
return out
|
|
8801
|
+
|
|
8802
|
+
for item in selected_items:
|
|
8803
|
+
parent = item.parent()
|
|
8804
|
+
|
|
8805
|
+
if parent is None:
|
|
8806
|
+
for fp in _collect_leaf_paths_under(item):
|
|
8807
|
+
_remove_path_everywhere(fp)
|
|
8808
|
+
idx = tree.indexOfTopLevelItem(item)
|
|
8809
|
+
if idx >= 0:
|
|
8810
|
+
tree.takeTopLevelItem(idx)
|
|
8811
|
+
continue
|
|
8812
|
+
|
|
8813
|
+
for fp in _collect_leaf_paths_under(item):
|
|
8814
|
+
_remove_path_everywhere(fp)
|
|
8815
|
+
|
|
8816
|
+
parent.removeChild(item)
|
|
8817
|
+
|
|
8818
|
+
self._purge_removed_paths(removed_paths)
|
|
8819
|
+
self._normalize_sessioned_files_map(self.light_files)
|
|
8820
|
+
|
|
8821
|
+
try:
|
|
8822
|
+
self.rebuild_light_tree()
|
|
8823
|
+
except Exception:
|
|
8824
|
+
try:
|
|
8825
|
+
self._refresh_light_tree_summaries()
|
|
8826
|
+
except Exception:
|
|
8827
|
+
pass
|
|
8828
|
+
|
|
8542
8829
|
|
|
8543
8830
|
def clear_tree_selection_flat(self, tree, file_dict):
|
|
8544
|
-
"""
|
|
8831
|
+
"""
|
|
8832
|
+
Clears selection in FLATS tree and removes from (group_key, session)->[paths].
|
|
8833
|
+
|
|
8834
|
+
Works for BOTH layouts:
|
|
8835
|
+
- 2-level: group -> file leaves (current rebuild_flat_tree)
|
|
8836
|
+
- 3-level: filter -> exposure -> file leaves (older layout)
|
|
8837
|
+
"""
|
|
8545
8838
|
selected_items = tree.selectedItems()
|
|
8546
8839
|
if not selected_items:
|
|
8547
8840
|
return
|
|
8548
8841
|
|
|
8842
|
+
removed_paths = []
|
|
8843
|
+
|
|
8844
|
+
def _norm(p: str) -> str:
|
|
8845
|
+
return os.path.normcase(os.path.abspath(p))
|
|
8846
|
+
|
|
8847
|
+
def _remove_path_everywhere(fpath: str):
|
|
8848
|
+
"""Remove fpath from ALL buckets in file_dict (robust against group_key mismatches)."""
|
|
8849
|
+
if not fpath:
|
|
8850
|
+
return
|
|
8851
|
+
f_norm = _norm(fpath)
|
|
8852
|
+
|
|
8853
|
+
keys_to_delete = []
|
|
8854
|
+
for k, lst in list(file_dict.items()):
|
|
8855
|
+
if not (isinstance(k, tuple) and len(k) >= 2):
|
|
8856
|
+
continue
|
|
8857
|
+
keep = []
|
|
8858
|
+
removed = False
|
|
8859
|
+
for p in (lst or []):
|
|
8860
|
+
if _norm(p) == f_norm:
|
|
8861
|
+
removed = True
|
|
8862
|
+
else:
|
|
8863
|
+
keep.append(p)
|
|
8864
|
+
|
|
8865
|
+
if removed:
|
|
8866
|
+
removed_paths.append(fpath)
|
|
8867
|
+
if keep:
|
|
8868
|
+
file_dict[k] = keep
|
|
8869
|
+
else:
|
|
8870
|
+
keys_to_delete.append(k)
|
|
8871
|
+
|
|
8872
|
+
for k in keys_to_delete:
|
|
8873
|
+
file_dict.pop(k, None)
|
|
8874
|
+
|
|
8875
|
+
def _collect_leaf_paths_under(node):
|
|
8876
|
+
"""Return all descendant leaf file paths under a node (supports group nodes)."""
|
|
8877
|
+
out = []
|
|
8878
|
+
stack = [node]
|
|
8879
|
+
while stack:
|
|
8880
|
+
cur = stack.pop()
|
|
8881
|
+
if cur.childCount() == 0:
|
|
8882
|
+
fp = cur.data(0, Qt.ItemDataRole.UserRole)
|
|
8883
|
+
if isinstance(fp, str) and fp.strip():
|
|
8884
|
+
out.append(fp)
|
|
8885
|
+
continue
|
|
8886
|
+
for j in range(cur.childCount()):
|
|
8887
|
+
stack.append(cur.child(j))
|
|
8888
|
+
return out
|
|
8889
|
+
|
|
8890
|
+
# We’ll delete dict entries by file paths (most robust), then rebuild UI.
|
|
8549
8891
|
for item in selected_items:
|
|
8550
8892
|
parent = item.parent()
|
|
8551
8893
|
|
|
8552
|
-
if parent:
|
|
8553
|
-
#
|
|
8554
|
-
|
|
8555
|
-
|
|
8556
|
-
|
|
8557
|
-
group_key = f"{filter_name} - {exposure_text}"
|
|
8558
|
-
else:
|
|
8559
|
-
# Exposure level
|
|
8560
|
-
filter_name = parent.text(0)
|
|
8561
|
-
exposure_text = item.text(0)
|
|
8562
|
-
group_key = f"{filter_name} - {exposure_text}"
|
|
8894
|
+
if parent is None:
|
|
8895
|
+
# Selected a top-level node (either "group" in 2-level, or "filter" in 3-level).
|
|
8896
|
+
# Remove every leaf path under it from the dict.
|
|
8897
|
+
for fp in _collect_leaf_paths_under(item):
|
|
8898
|
+
_remove_path_everywhere(fp)
|
|
8563
8899
|
|
|
8564
|
-
|
|
8900
|
+
# Remove UI node
|
|
8901
|
+
idx = tree.indexOfTopLevelItem(item)
|
|
8902
|
+
if idx >= 0:
|
|
8903
|
+
tree.takeTopLevelItem(idx)
|
|
8904
|
+
continue
|
|
8565
8905
|
|
|
8566
|
-
|
|
8567
|
-
|
|
8568
|
-
|
|
8906
|
+
# Selected a leaf or mid-level node; remove all descendant leaf paths
|
|
8907
|
+
for fp in _collect_leaf_paths_under(item):
|
|
8908
|
+
_remove_path_everywhere(fp)
|
|
8909
|
+
|
|
8910
|
+
# Remove UI node
|
|
8911
|
+
parent.removeChild(item)
|
|
8912
|
+
|
|
8913
|
+
# purge caches + normalize
|
|
8914
|
+
self._purge_removed_paths(removed_paths)
|
|
8915
|
+
self._normalize_sessioned_files_map(file_dict)
|
|
8916
|
+
|
|
8917
|
+
# Rebuild from dict (this ensures UI reflects the dict truth)
|
|
8918
|
+
try:
|
|
8919
|
+
self.rebuild_flat_tree()
|
|
8920
|
+
except Exception:
|
|
8921
|
+
# If you really don't want rebuild here, at least:
|
|
8922
|
+
try:
|
|
8923
|
+
self._refresh_flat_tree_summaries()
|
|
8924
|
+
except Exception:
|
|
8925
|
+
pass
|
|
8569
8926
|
|
|
8570
|
-
for key in keys_to_check:
|
|
8571
|
-
file_dict[key] = [f for f in file_dict[key] if os.path.basename(f) != filename]
|
|
8572
|
-
if not file_dict[key]:
|
|
8573
|
-
del file_dict[key]
|
|
8574
8927
|
|
|
8575
|
-
parent.removeChild(item)
|
|
8576
|
-
else:
|
|
8577
|
-
# Top-level (filter group) selected
|
|
8578
|
-
filter_name = item.text(0)
|
|
8579
|
-
keys_to_remove = [key for key in list(file_dict.keys())
|
|
8580
|
-
if isinstance(key, tuple) and key[0].startswith(f"{filter_name} - ")]
|
|
8581
|
-
for key in keys_to_remove:
|
|
8582
|
-
del file_dict[key]
|
|
8583
|
-
tree.takeTopLevelItem(tree.indexOfTopLevelItem(item))
|
|
8584
8928
|
|
|
8585
8929
|
def _sync_group_userrole(self, top_item: QTreeWidgetItem):
|
|
8586
8930
|
paths = []
|
|
@@ -8603,9 +8947,13 @@ class StackingSuiteDialog(QDialog):
|
|
|
8603
8947
|
# ensure attrs exist
|
|
8604
8948
|
if not hasattr(self, "_reg_excluded_files"):
|
|
8605
8949
|
self._reg_excluded_files = set()
|
|
8606
|
-
if not hasattr(self, "deleted_calibrated_files"):
|
|
8607
|
-
self.deleted_calibrated_files = []
|
|
8608
8950
|
|
|
8951
|
+
# Track "removed from Registration tab" for this session so stacking won't use them
|
|
8952
|
+
if (not hasattr(self, "deleted_calibrated_files")) or (self.deleted_calibrated_files is None):
|
|
8953
|
+
self.deleted_calibrated_files = set()
|
|
8954
|
+
elif isinstance(self.deleted_calibrated_files, list):
|
|
8955
|
+
# backward compat if you previously used list
|
|
8956
|
+
self.deleted_calibrated_files = set(self.deleted_calibrated_files)
|
|
8609
8957
|
removed_paths = []
|
|
8610
8958
|
|
|
8611
8959
|
for item in selected_items:
|
|
@@ -8657,22 +9005,27 @@ class StackingSuiteDialog(QDialog):
|
|
|
8657
9005
|
# Keep parent's stored list in sync (your helper)
|
|
8658
9006
|
self._sync_group_userrole(parent)
|
|
8659
9007
|
|
|
8660
|
-
#
|
|
8661
|
-
|
|
9008
|
+
# --- DO NOT persist exclusions for manual removals in reg tab ---
|
|
9009
|
+
# If you want a separate "Exclude" feature later, keep _reg_excluded_files for that.
|
|
9010
|
+
# For now, removing should be reversible via "Add Light Files".
|
|
8662
9011
|
|
|
8663
|
-
#
|
|
8664
|
-
for p in removed_paths
|
|
8665
|
-
|
|
8666
|
-
|
|
9012
|
+
# Persist "removed from registration" list (session)
|
|
9013
|
+
dead = {os.path.normcase(os.path.abspath(p)) for p in removed_paths if isinstance(p, str)}
|
|
9014
|
+
if dead:
|
|
9015
|
+
self.deleted_calibrated_files |= dead
|
|
8667
9016
|
|
|
8668
|
-
# Also prune manual list so it doesn't re-inject removed files
|
|
9017
|
+
# Also prune manual list so it doesn't re-inject removed files *in this session*
|
|
8669
9018
|
if hasattr(self, "manual_light_files") and self.manual_light_files:
|
|
8670
|
-
self.manual_light_files = [
|
|
9019
|
+
self.manual_light_files = [
|
|
9020
|
+
p for p in self.manual_light_files
|
|
9021
|
+
if os.path.normcase(os.path.abspath(p)) not in dead
|
|
9022
|
+
]
|
|
8671
9023
|
|
|
8672
|
-
#
|
|
8673
|
-
|
|
9024
|
+
# refresh UI
|
|
9025
|
+
# IMPORTANT: do NOT call populate_calibrated_lights() here, it can resurrect removed items
|
|
8674
9026
|
self._refresh_reg_tree_summaries()
|
|
8675
9027
|
|
|
9028
|
+
|
|
8676
9029
|
def rebuild_flat_tree(self):
|
|
8677
9030
|
"""Regroup flat frames in the flat_tree based on the exposure tolerance."""
|
|
8678
9031
|
self.flat_tree.clear()
|
|
@@ -9017,6 +9370,22 @@ class StackingSuiteDialog(QDialog):
|
|
|
9017
9370
|
return (f"Drizzle: True, Scale: {scale:g}x, Drop: {drop:.2f}"
|
|
9018
9371
|
if enabled else "Drizzle: False")
|
|
9019
9372
|
|
|
9373
|
+
def _get_group_key(self, top_item) -> str:
|
|
9374
|
+
"""Stable key for a group item; survives UI text decoration."""
|
|
9375
|
+
key = top_item.data(0, Qt.ItemDataRole.UserRole)
|
|
9376
|
+
if key:
|
|
9377
|
+
return str(key)
|
|
9378
|
+
# fallback to visible text if older items don't have it yet
|
|
9379
|
+
return str(top_item.text(0)).strip()
|
|
9380
|
+
|
|
9381
|
+
def _ensure_group_key(self, top_item, group_key: str | None = None) -> str:
|
|
9382
|
+
"""Set canonical key on item if missing."""
|
|
9383
|
+
if group_key is None:
|
|
9384
|
+
group_key = str(top_item.text(0)).strip()
|
|
9385
|
+
if not top_item.data(0, Qt.ItemDataRole.UserRole):
|
|
9386
|
+
top_item.setData(0, Qt.ItemDataRole.UserRole, group_key)
|
|
9387
|
+
return str(group_key)
|
|
9388
|
+
|
|
9020
9389
|
def _set_drizzle_on_items(self, items, enabled: bool, scale: float, drop: float):
|
|
9021
9390
|
txt_on = self._format_drizzle_text(True, scale, drop)
|
|
9022
9391
|
txt_off = self._format_drizzle_text(False, scale, drop)
|
|
@@ -9024,7 +9393,8 @@ class StackingSuiteDialog(QDialog):
|
|
|
9024
9393
|
# dedupe child selection → parent group
|
|
9025
9394
|
if it.parent() is not None:
|
|
9026
9395
|
it = it.parent()
|
|
9027
|
-
|
|
9396
|
+
# Canonical key stored on the item (NOT display label)
|
|
9397
|
+
group_key = self._ensure_group_key(it)
|
|
9028
9398
|
it.setText(2, txt_on if enabled else txt_off)
|
|
9029
9399
|
self.per_group_drizzle[group_key] = {
|
|
9030
9400
|
"enabled": bool(enabled),
|
|
@@ -9049,11 +9419,10 @@ class StackingSuiteDialog(QDialog):
|
|
|
9049
9419
|
return
|
|
9050
9420
|
|
|
9051
9421
|
for item in selected_items:
|
|
9052
|
-
# If the user selected a child row, go up to its parent group
|
|
9053
9422
|
if item.parent() is not None:
|
|
9054
9423
|
item = item.parent()
|
|
9055
9424
|
|
|
9056
|
-
group_key =
|
|
9425
|
+
group_key = self._ensure_group_key(item) # ✅ stable key
|
|
9057
9426
|
|
|
9058
9427
|
if drizzle_enabled:
|
|
9059
9428
|
# Show scale + drop shrink
|
|
@@ -9085,7 +9454,7 @@ class StackingSuiteDialog(QDialog):
|
|
|
9085
9454
|
seen, targets = set(), []
|
|
9086
9455
|
for it in sel:
|
|
9087
9456
|
top = it if it.parent() is None else it.parent()
|
|
9088
|
-
key =
|
|
9457
|
+
key = self._ensure_group_key(top)
|
|
9089
9458
|
if key not in seen:
|
|
9090
9459
|
seen.add(key); targets.append(top)
|
|
9091
9460
|
else:
|
|
@@ -9108,7 +9477,7 @@ class StackingSuiteDialog(QDialog):
|
|
|
9108
9477
|
|
|
9109
9478
|
out = {}
|
|
9110
9479
|
for top in self._iter_group_items():
|
|
9111
|
-
group_key =
|
|
9480
|
+
group_key = self._ensure_group_key(top) # ✅ stable key
|
|
9112
9481
|
state = self.per_group_drizzle.get(group_key)
|
|
9113
9482
|
if not state:
|
|
9114
9483
|
state = {"enabled": global_enabled, "scale": global_scale, "drop": global_drop}
|
|
@@ -9186,6 +9555,58 @@ class StackingSuiteDialog(QDialog):
|
|
|
9186
9555
|
self.add_directory(self.light_tree, "Select Light Directory", "LIGHT")
|
|
9187
9556
|
self.assign_best_master_files()
|
|
9188
9557
|
|
|
9558
|
+
def _normalize_sessioned_files_map(self, files_map: dict):
|
|
9559
|
+
"""
|
|
9560
|
+
Canonicalize dict that should be keyed like: (group_key, session) -> [paths]
|
|
9561
|
+
|
|
9562
|
+
- Drops empty lists
|
|
9563
|
+
- Dedupe paths
|
|
9564
|
+
- Coerces keys to (str, str)
|
|
9565
|
+
"""
|
|
9566
|
+
if not isinstance(files_map, dict):
|
|
9567
|
+
return
|
|
9568
|
+
|
|
9569
|
+
new_map = {}
|
|
9570
|
+
for k, lst in list(files_map.items()):
|
|
9571
|
+
if not lst:
|
|
9572
|
+
continue
|
|
9573
|
+
|
|
9574
|
+
# Coerce key to (group_key, session)
|
|
9575
|
+
if isinstance(k, tuple) and len(k) >= 2:
|
|
9576
|
+
gk = str(k[0])
|
|
9577
|
+
sess = str(k[1])
|
|
9578
|
+
else:
|
|
9579
|
+
# legacy/no-session dict; keep but force Default
|
|
9580
|
+
gk = str(k)
|
|
9581
|
+
sess = "Default"
|
|
9582
|
+
|
|
9583
|
+
# Deduplicate paths while preserving order
|
|
9584
|
+
seen = set()
|
|
9585
|
+
out = []
|
|
9586
|
+
for p in lst:
|
|
9587
|
+
if not p:
|
|
9588
|
+
continue
|
|
9589
|
+
p = str(p)
|
|
9590
|
+
if p in seen:
|
|
9591
|
+
continue
|
|
9592
|
+
seen.add(p)
|
|
9593
|
+
out.append(p)
|
|
9594
|
+
|
|
9595
|
+
if not out:
|
|
9596
|
+
continue
|
|
9597
|
+
|
|
9598
|
+
ck = (gk, sess)
|
|
9599
|
+
if ck not in new_map:
|
|
9600
|
+
new_map[ck] = out
|
|
9601
|
+
else:
|
|
9602
|
+
# merge
|
|
9603
|
+
for p in out:
|
|
9604
|
+
if p not in new_map[ck]:
|
|
9605
|
+
new_map[ck].append(p)
|
|
9606
|
+
|
|
9607
|
+
files_map.clear()
|
|
9608
|
+
files_map.update(new_map)
|
|
9609
|
+
|
|
9189
9610
|
|
|
9190
9611
|
def prompt_session_before_adding(self, frame_type, directory_mode=False):
|
|
9191
9612
|
# Respect auto-detect; do nothing here if auto is ON
|
|
@@ -9678,24 +10099,32 @@ class StackingSuiteDialog(QDialog):
|
|
|
9678
10099
|
manual_session_name = self._resolve_manual_session_name_for_ingest()
|
|
9679
10100
|
|
|
9680
10101
|
added = 0
|
|
9681
|
-
|
|
9682
|
-
|
|
9683
|
-
|
|
9684
|
-
|
|
9685
|
-
|
|
9686
|
-
|
|
9687
|
-
|
|
10102
|
+
tree.setUpdatesEnabled(False)
|
|
10103
|
+
tree.blockSignals(True)
|
|
10104
|
+
try:
|
|
10105
|
+
for i, path in enumerate(paths, start=1):
|
|
10106
|
+
if dlg.wasCanceled():
|
|
10107
|
+
break
|
|
10108
|
+
try:
|
|
10109
|
+
base = os.path.basename(path)
|
|
10110
|
+
dlg.setLabelText(f"{base} ({i}/{total})")
|
|
10111
|
+
QCoreApplication.processEvents()
|
|
9688
10112
|
|
|
9689
|
-
|
|
9690
|
-
|
|
9691
|
-
|
|
9692
|
-
|
|
9693
|
-
|
|
9694
|
-
|
|
9695
|
-
|
|
10113
|
+
self.process_fits_header(
|
|
10114
|
+
path, tree, expected_type,
|
|
10115
|
+
manual_session_name=manual_session_name
|
|
10116
|
+
)
|
|
10117
|
+
added += 1
|
|
10118
|
+
except Exception:
|
|
10119
|
+
pass
|
|
10120
|
+
|
|
10121
|
+
dlg.setValue(i)
|
|
10122
|
+
QCoreApplication.processEvents()
|
|
10123
|
+
finally:
|
|
10124
|
+
tree.blockSignals(False)
|
|
10125
|
+
tree.setUpdatesEnabled(True)
|
|
10126
|
+
tree.viewport().update()
|
|
9696
10127
|
|
|
9697
|
-
dlg.setValue(i)
|
|
9698
|
-
QCoreApplication.processEvents()
|
|
9699
10128
|
|
|
9700
10129
|
dlg.setValue(total)
|
|
9701
10130
|
QCoreApplication.processEvents()
|
|
@@ -9898,16 +10327,16 @@ class StackingSuiteDialog(QDialog):
|
|
|
9898
10327
|
if expected_type_u == "DARK":
|
|
9899
10328
|
key = f"{exposure_text} ({image_size})"
|
|
9900
10329
|
self.dark_files.setdefault(key, []).append(path)
|
|
9901
|
-
self.session_tags[path] = session_tag # not strictly needed, but consistent
|
|
9902
10330
|
|
|
9903
|
-
|
|
9904
|
-
|
|
9905
|
-
|
|
10331
|
+
exposure_item = self._dark_group_item.get(key)
|
|
10332
|
+
if exposure_item is None:
|
|
10333
|
+
exposure_item = QTreeWidgetItem([key])
|
|
9906
10334
|
tree.addTopLevelItem(exposure_item)
|
|
10335
|
+
self._dark_group_item[key] = exposure_item
|
|
9907
10336
|
|
|
9908
10337
|
leaf = QTreeWidgetItem([os.path.basename(path), meta_text])
|
|
9909
10338
|
leaf.setData(0, Qt.ItemDataRole.UserRole, path)
|
|
9910
|
-
leaf.setData(0, Qt.ItemDataRole.UserRole + 1, session_tag)
|
|
10339
|
+
leaf.setData(0, Qt.ItemDataRole.UserRole + 1, session_tag)
|
|
9911
10340
|
exposure_item.addChild(leaf)
|
|
9912
10341
|
|
|
9913
10342
|
# === FLATs ===
|
|
@@ -9917,20 +10346,20 @@ class StackingSuiteDialog(QDialog):
|
|
|
9917
10346
|
self.flat_files.setdefault(composite_key, []).append(path)
|
|
9918
10347
|
self.session_tags[path] = session_tag
|
|
9919
10348
|
|
|
9920
|
-
|
|
9921
|
-
|
|
9922
|
-
|
|
10349
|
+
filter_item = self._flat_filter_item.get(filter_name)
|
|
10350
|
+
if filter_item is None:
|
|
10351
|
+
filter_item = QTreeWidgetItem([filter_name])
|
|
9923
10352
|
tree.addTopLevelItem(filter_item)
|
|
10353
|
+
self._flat_filter_item[filter_name] = filter_item
|
|
9924
10354
|
|
|
9925
10355
|
want_label = f"{exposure_text} ({image_size})"
|
|
9926
|
-
|
|
9927
|
-
|
|
9928
|
-
|
|
9929
|
-
exposure_item = filter_item.child(i)
|
|
9930
|
-
break
|
|
10356
|
+
exp_key = (filter_name, want_label)
|
|
10357
|
+
|
|
10358
|
+
exposure_item = self._flat_exp_item.get(exp_key)
|
|
9931
10359
|
if exposure_item is None:
|
|
9932
10360
|
exposure_item = QTreeWidgetItem([want_label])
|
|
9933
10361
|
filter_item.addChild(exposure_item)
|
|
10362
|
+
self._flat_exp_item[exp_key] = exposure_item
|
|
9934
10363
|
|
|
9935
10364
|
leaf = QTreeWidgetItem([os.path.basename(path), meta_text])
|
|
9936
10365
|
leaf.setData(0, Qt.ItemDataRole.UserRole, path)
|
|
@@ -9944,23 +10373,25 @@ class StackingSuiteDialog(QDialog):
|
|
|
9944
10373
|
self.light_files.setdefault(composite_key, []).append(path)
|
|
9945
10374
|
self.session_tags[path] = session_tag
|
|
9946
10375
|
|
|
9947
|
-
|
|
9948
|
-
filter_item =
|
|
9949
|
-
if
|
|
10376
|
+
# Cached filter item
|
|
10377
|
+
filter_item = self._light_filter_item.get(filter_name)
|
|
10378
|
+
if filter_item is None:
|
|
10379
|
+
filter_item = QTreeWidgetItem([filter_name])
|
|
9950
10380
|
tree.addTopLevelItem(filter_item)
|
|
10381
|
+
self._light_filter_item[filter_name] = filter_item
|
|
9951
10382
|
|
|
9952
10383
|
want_label = f"{exposure_text} ({image_size})"
|
|
9953
|
-
|
|
9954
|
-
|
|
9955
|
-
|
|
9956
|
-
|
|
9957
|
-
break
|
|
10384
|
+
exp_key = (filter_name, want_label)
|
|
10385
|
+
|
|
10386
|
+
# Cached exposure item
|
|
10387
|
+
exposure_item = self._light_exp_item.get(exp_key)
|
|
9958
10388
|
if exposure_item is None:
|
|
9959
10389
|
exposure_item = QTreeWidgetItem([want_label])
|
|
9960
10390
|
filter_item.addChild(exposure_item)
|
|
10391
|
+
self._light_exp_item[exp_key] = exposure_item
|
|
9961
10392
|
|
|
9962
10393
|
leaf = QTreeWidgetItem([os.path.basename(path), meta_text])
|
|
9963
|
-
leaf.setData(0, Qt.ItemDataRole.UserRole, path) # ✅
|
|
10394
|
+
leaf.setData(0, Qt.ItemDataRole.UserRole, path) # ✅ keep this
|
|
9964
10395
|
leaf.setData(0, Qt.ItemDataRole.UserRole + 1, session_tag)
|
|
9965
10396
|
exposure_item.addChild(leaf)
|
|
9966
10397
|
|
|
@@ -10051,32 +10482,58 @@ class StackingSuiteDialog(QDialog):
|
|
|
10051
10482
|
QMessageBox.warning(self, "Error", "Output directory is not set.")
|
|
10052
10483
|
return
|
|
10053
10484
|
|
|
10054
|
-
# Keep both paths available; we'll override algo selection per group.
|
|
10055
10485
|
ui_algo = getattr(self, "calib_rejection_algorithm", "Windsorized Sigma Clipping")
|
|
10056
10486
|
if ui_algo == "Weighted Windsorized Sigma Clipping":
|
|
10057
10487
|
ui_algo = "Windsorized Sigma Clipping"
|
|
10058
10488
|
|
|
10059
10489
|
exposure_tolerance = self.exposure_tolerance_spinbox.value()
|
|
10060
|
-
dark_files_by_group: dict[tuple[float, str], list[str]] = {}
|
|
10061
10490
|
|
|
10062
10491
|
# -------------------------------------------------------------------------
|
|
10063
|
-
# Group darks by (exposure +/- tolerance, image size string)
|
|
10492
|
+
# Group darks by (exposure +/- tolerance, image size string, session)
|
|
10493
|
+
# self.dark_files can be either:
|
|
10494
|
+
# legacy: exposure_key -> [paths]
|
|
10495
|
+
# session: (exposure_key, session) -> [paths]
|
|
10064
10496
|
# -------------------------------------------------------------------------
|
|
10065
|
-
|
|
10066
|
-
|
|
10067
|
-
|
|
10068
|
-
|
|
10069
|
-
|
|
10497
|
+
dark_files_by_group: dict[tuple[float, str, str], list[str]] = {} # (exp, size, session)->list
|
|
10498
|
+
|
|
10499
|
+
for key, file_list in (self.dark_files or {}).items():
|
|
10500
|
+
if isinstance(key, tuple) and len(key) >= 2:
|
|
10501
|
+
exposure_key = str(key[0])
|
|
10502
|
+
session = str(key[1]) if str(key[1]).strip() else "Default"
|
|
10503
|
+
else:
|
|
10504
|
+
exposure_key = str(key)
|
|
10505
|
+
session = "Default"
|
|
10506
|
+
|
|
10507
|
+
try:
|
|
10508
|
+
exposure_time_str, image_size = exposure_key.split(" (", 1)
|
|
10509
|
+
image_size = image_size.rstrip(")")
|
|
10510
|
+
except ValueError:
|
|
10511
|
+
# If some malformed key got in, skip safely
|
|
10512
|
+
continue
|
|
10513
|
+
|
|
10514
|
+
if "Unknown" in exposure_time_str:
|
|
10515
|
+
exposure_time = 0.0
|
|
10516
|
+
else:
|
|
10517
|
+
try:
|
|
10518
|
+
exposure_time = float(exposure_time_str.replace("s", "").strip())
|
|
10519
|
+
except Exception:
|
|
10520
|
+
exposure_time = 0.0
|
|
10070
10521
|
|
|
10071
10522
|
matched_group = None
|
|
10072
|
-
for (existing_exposure, existing_size) in dark_files_by_group.keys():
|
|
10073
|
-
if
|
|
10074
|
-
|
|
10523
|
+
for (existing_exposure, existing_size, existing_session) in list(dark_files_by_group.keys()):
|
|
10524
|
+
if (
|
|
10525
|
+
existing_session == session
|
|
10526
|
+
and existing_size == image_size
|
|
10527
|
+
and abs(existing_exposure - exposure_time) <= exposure_tolerance
|
|
10528
|
+
):
|
|
10529
|
+
matched_group = (existing_exposure, existing_size, existing_session)
|
|
10075
10530
|
break
|
|
10531
|
+
|
|
10076
10532
|
if matched_group is None:
|
|
10077
|
-
matched_group = (exposure_time, image_size)
|
|
10533
|
+
matched_group = (exposure_time, image_size, session)
|
|
10078
10534
|
dark_files_by_group[matched_group] = []
|
|
10079
|
-
|
|
10535
|
+
|
|
10536
|
+
dark_files_by_group[matched_group].extend(file_list or [])
|
|
10080
10537
|
|
|
10081
10538
|
master_dir = os.path.join(self.stacking_directory, "Master_Calibration_Files")
|
|
10082
10539
|
os.makedirs(master_dir, exist_ok=True)
|
|
@@ -10085,7 +10542,7 @@ class StackingSuiteDialog(QDialog):
|
|
|
10085
10542
|
# Informative status about discovery
|
|
10086
10543
|
# -------------------------------------------------------------------------
|
|
10087
10544
|
try:
|
|
10088
|
-
n_groups = sum(1 for
|
|
10545
|
+
n_groups = sum(1 for _, v in dark_files_by_group.items() if len(v) >= 2)
|
|
10089
10546
|
total_files = sum(len(v) for v in dark_files_by_group.values())
|
|
10090
10547
|
self.update_status(self.tr(
|
|
10091
10548
|
f"🔎 Discovered {len(dark_files_by_group)} grouped exposures "
|
|
@@ -10096,15 +10553,15 @@ class StackingSuiteDialog(QDialog):
|
|
|
10096
10553
|
QApplication.processEvents()
|
|
10097
10554
|
|
|
10098
10555
|
# -------------------------------------------------------------------------
|
|
10099
|
-
# Pre-count tiles for progress bar (
|
|
10556
|
+
# Pre-count tiles for progress bar (per-group safe chunk sizes)
|
|
10100
10557
|
# -------------------------------------------------------------------------
|
|
10101
10558
|
total_tiles = 0
|
|
10102
|
-
group_shapes: dict[tuple[float, str], tuple[int, int, int, int, int]] = {}
|
|
10559
|
+
group_shapes: dict[tuple[float, str, str], tuple[int, int, int, int, int]] = {} # (exp,size,session)->(H,W,C,ch,cw)
|
|
10103
10560
|
pref_chunk_h = self.chunk_height
|
|
10104
10561
|
pref_chunk_w = self.chunk_width
|
|
10105
|
-
DTYPE = np.float32
|
|
10562
|
+
DTYPE = np.float32
|
|
10106
10563
|
|
|
10107
|
-
for (exposure_time, image_size), file_list in dark_files_by_group.items():
|
|
10564
|
+
for (exposure_time, image_size, session), file_list in dark_files_by_group.items():
|
|
10108
10565
|
if len(file_list) < 2:
|
|
10109
10566
|
continue
|
|
10110
10567
|
|
|
@@ -10117,16 +10574,12 @@ class StackingSuiteDialog(QDialog):
|
|
|
10117
10574
|
C = max(1, C)
|
|
10118
10575
|
N = len(file_list)
|
|
10119
10576
|
|
|
10120
|
-
# Use the same safe-chunk logic as normal integration
|
|
10121
10577
|
try:
|
|
10122
|
-
chunk_h, chunk_w = compute_safe_chunk(
|
|
10123
|
-
H, W, N, C, DTYPE, pref_chunk_h, pref_chunk_w
|
|
10124
|
-
)
|
|
10578
|
+
chunk_h, chunk_w = compute_safe_chunk(H, W, N, C, DTYPE, pref_chunk_h, pref_chunk_w)
|
|
10125
10579
|
except MemoryError:
|
|
10126
|
-
# Fall back to user chunk config if memory check failed
|
|
10127
10580
|
chunk_h, chunk_w = pref_chunk_h, pref_chunk_w
|
|
10128
10581
|
|
|
10129
|
-
group_shapes[(exposure_time, image_size)] = (H, W, C, chunk_h, chunk_w)
|
|
10582
|
+
group_shapes[(exposure_time, image_size, session)] = (H, W, C, chunk_h, chunk_w)
|
|
10130
10583
|
total_tiles += _count_tiles(H, W, chunk_h, chunk_w)
|
|
10131
10584
|
|
|
10132
10585
|
if total_tiles == 0:
|
|
@@ -10139,7 +10592,7 @@ class StackingSuiteDialog(QDialog):
|
|
|
10139
10592
|
QApplication.processEvents()
|
|
10140
10593
|
|
|
10141
10594
|
# -------------------------------------------------------------------------
|
|
10142
|
-
# Local CPU reducers
|
|
10595
|
+
# Local CPU reducers (unchanged)
|
|
10143
10596
|
# -------------------------------------------------------------------------
|
|
10144
10597
|
def _select_reducer(kind: str, N: int):
|
|
10145
10598
|
if kind == "dark":
|
|
@@ -10149,8 +10602,7 @@ class StackingSuiteDialog(QDialog):
|
|
|
10149
10602
|
return ("Simple Median (No Rejection)", {}, "median")
|
|
10150
10603
|
else:
|
|
10151
10604
|
return ("Trimmed Mean", {"trim_fraction": 0.05}, "trimmed")
|
|
10152
|
-
|
|
10153
|
-
raise ValueError("wrong kind")
|
|
10605
|
+
raise ValueError("wrong kind")
|
|
10154
10606
|
|
|
10155
10607
|
def _cpu_tile_median(ts4: np.ndarray) -> np.ndarray:
|
|
10156
10608
|
return np.median(ts4, axis=0).astype(np.float32, copy=False)
|
|
@@ -10178,17 +10630,16 @@ class StackingSuiteDialog(QDialog):
|
|
|
10178
10630
|
return out.astype(np.float32, copy=False)
|
|
10179
10631
|
|
|
10180
10632
|
pd = _Progress(self, "Create Master Darks", total_tiles)
|
|
10181
|
-
|
|
10182
10633
|
from concurrent.futures import ThreadPoolExecutor
|
|
10183
10634
|
|
|
10184
10635
|
try:
|
|
10185
10636
|
# ---------------------------------------------------------------------
|
|
10186
10637
|
# Per-group stacking loop
|
|
10187
10638
|
# ---------------------------------------------------------------------
|
|
10188
|
-
for (exposure_time, image_size), file_list in dark_files_by_group.items():
|
|
10639
|
+
for (exposure_time, image_size, session), file_list in dark_files_by_group.items():
|
|
10189
10640
|
if len(file_list) < 2:
|
|
10190
10641
|
self.update_status(self.tr(
|
|
10191
|
-
f"⚠️ Skipping {exposure_time}s ({image_size}) - Not enough frames to stack."
|
|
10642
|
+
f"⚠️ Skipping {exposure_time}s ({image_size}) [{session}] - Not enough frames to stack."
|
|
10192
10643
|
))
|
|
10193
10644
|
QApplication.processEvents()
|
|
10194
10645
|
continue
|
|
@@ -10198,21 +10649,17 @@ class StackingSuiteDialog(QDialog):
|
|
|
10198
10649
|
break
|
|
10199
10650
|
|
|
10200
10651
|
self.update_status(self.tr(
|
|
10201
|
-
f"🟢 Processing {len(file_list)} darks for {exposure_time}s ({image_size})
|
|
10652
|
+
f"🟢 Processing {len(file_list)} darks for {exposure_time}s ({image_size}) in session '{session}'…"
|
|
10202
10653
|
))
|
|
10203
10654
|
QApplication.processEvents()
|
|
10204
10655
|
|
|
10205
10656
|
# --- reference shape and per-group chunk size ---
|
|
10206
|
-
if (exposure_time, image_size) in group_shapes:
|
|
10207
|
-
height, width, channels, chunk_height, chunk_width = group_shapes[
|
|
10208
|
-
(exposure_time, image_size)
|
|
10209
|
-
]
|
|
10657
|
+
if (exposure_time, image_size, session) in group_shapes:
|
|
10658
|
+
height, width, channels, chunk_height, chunk_width = group_shapes[(exposure_time, image_size, session)]
|
|
10210
10659
|
else:
|
|
10211
10660
|
ref_data, _, _, _ = load_image(file_list[0])
|
|
10212
10661
|
if ref_data is None:
|
|
10213
|
-
self.update_status(self.tr(
|
|
10214
|
-
f"❌ Failed to load reference {os.path.basename(file_list[0])}"
|
|
10215
|
-
))
|
|
10662
|
+
self.update_status(self.tr(f"❌ Failed to load reference {os.path.basename(file_list[0])}"))
|
|
10216
10663
|
continue
|
|
10217
10664
|
height, width = ref_data.shape[:2]
|
|
10218
10665
|
channels = 1 if ref_data.ndim == 2 else 3
|
|
@@ -10220,31 +10667,25 @@ class StackingSuiteDialog(QDialog):
|
|
|
10220
10667
|
N_tmp = len(file_list)
|
|
10221
10668
|
try:
|
|
10222
10669
|
chunk_height, chunk_width = compute_safe_chunk(
|
|
10223
|
-
height, width, N_tmp, channels, DTYPE,
|
|
10224
|
-
pref_chunk_h, pref_chunk_w
|
|
10670
|
+
height, width, N_tmp, channels, DTYPE, pref_chunk_h, pref_chunk_w
|
|
10225
10671
|
)
|
|
10226
10672
|
except MemoryError:
|
|
10227
10673
|
chunk_height, chunk_width = pref_chunk_h, pref_chunk_w
|
|
10228
10674
|
|
|
10229
|
-
channels = max(1, channels)
|
|
10230
10675
|
N = len(file_list)
|
|
10231
10676
|
|
|
10232
|
-
# --- choose reducer adaptively ---
|
|
10233
10677
|
algo_name, params, cpu_label = _select_reducer("dark", N)
|
|
10234
10678
|
use_gpu = bool(self._hw_accel_enabled()) and _torch_ok() and _gpu_algo_supported(algo_name)
|
|
10235
10679
|
algo_brief = ("GPU" if use_gpu else "CPU") + " " + algo_name
|
|
10236
|
-
self.update_status(self.tr(
|
|
10237
|
-
f"⚙️ {algo_brief} selected for {N} frames (channels={channels})"
|
|
10238
|
-
))
|
|
10680
|
+
self.update_status(self.tr(f"⚙️ {algo_brief} selected for {N} frames (channels={channels})"))
|
|
10239
10681
|
QApplication.processEvents()
|
|
10240
10682
|
|
|
10241
|
-
# --- open
|
|
10683
|
+
# --- open sources ---
|
|
10242
10684
|
sources = []
|
|
10243
10685
|
try:
|
|
10244
10686
|
for p in file_list:
|
|
10245
|
-
sources.append(_MMImage(p))
|
|
10687
|
+
sources.append(_MMImage(p))
|
|
10246
10688
|
except Exception as e:
|
|
10247
|
-
# Clean up any partially opened sources
|
|
10248
10689
|
for s in sources:
|
|
10249
10690
|
try:
|
|
10250
10691
|
s.close()
|
|
@@ -10254,93 +10695,64 @@ class StackingSuiteDialog(QDialog):
|
|
|
10254
10695
|
QApplication.processEvents()
|
|
10255
10696
|
continue
|
|
10256
10697
|
|
|
10257
|
-
#
|
|
10258
|
-
memmap_path = os.path.join(
|
|
10259
|
-
|
|
10260
|
-
)
|
|
10698
|
+
# Include session to prevent collisions
|
|
10699
|
+
memmap_path = os.path.join(master_dir, f"temp_dark_{session}_{exposure_time}_{image_size}.dat")
|
|
10700
|
+
|
|
10261
10701
|
self.update_status(self.tr(
|
|
10262
10702
|
f"🗂️ Creating temp memmap: {os.path.basename(memmap_path)} "
|
|
10263
10703
|
f"(shape={height}×{width}×{channels}, dtype=float32)"
|
|
10264
10704
|
))
|
|
10265
10705
|
QApplication.processEvents()
|
|
10266
|
-
final_stacked = np.memmap(
|
|
10267
|
-
memmap_path,
|
|
10268
|
-
dtype=np.float32,
|
|
10269
|
-
mode="w+",
|
|
10270
|
-
shape=(height, width, channels),
|
|
10271
|
-
)
|
|
10272
10706
|
|
|
10273
|
-
|
|
10707
|
+
final_stacked = np.memmap(memmap_path, dtype=np.float32, mode="w+", shape=(height, width, channels))
|
|
10708
|
+
|
|
10274
10709
|
tiles = _tile_grid(height, width, chunk_height, chunk_width)
|
|
10275
10710
|
total_tiles_group = len(tiles)
|
|
10276
10711
|
self.update_status(self.tr(
|
|
10277
|
-
f"📦 {total_tiles_group} tiles to process for this group "
|
|
10278
|
-
f"(chunk {chunk_height}×{chunk_width})."
|
|
10712
|
+
f"📦 {total_tiles_group} tiles to process for this group (chunk {chunk_height}×{chunk_width})."
|
|
10279
10713
|
))
|
|
10280
10714
|
QApplication.processEvents()
|
|
10281
10715
|
|
|
10282
|
-
|
|
10283
|
-
buf0 = np.empty(
|
|
10284
|
-
(N, chunk_height, chunk_width, channels),
|
|
10285
|
-
dtype=np.float32,
|
|
10286
|
-
order="C",
|
|
10287
|
-
)
|
|
10716
|
+
buf0 = np.empty((N, chunk_height, chunk_width, channels), dtype=np.float32, order="C")
|
|
10288
10717
|
buf1 = np.empty_like(buf0)
|
|
10289
10718
|
|
|
10290
|
-
# Helper: read one tile into the given buffer from all memmapped sources
|
|
10291
10719
|
def _read_tile_into(buf, y0, y1, x0, x1):
|
|
10292
10720
|
th = y1 - y0
|
|
10293
10721
|
tw = x1 - x0
|
|
10294
10722
|
ts = buf[:N, :th, :tw, :channels]
|
|
10295
10723
|
for i, src in enumerate(sources):
|
|
10296
|
-
sub = src.read_tile(y0, y1, x0, x1)
|
|
10724
|
+
sub = src.read_tile(y0, y1, x0, x1)
|
|
10297
10725
|
if sub.ndim == 2:
|
|
10298
|
-
if channels == 3
|
|
10299
|
-
sub = sub[:, :, None].repeat(3, axis=2)
|
|
10300
|
-
else:
|
|
10301
|
-
sub = sub[:, :, None]
|
|
10726
|
+
sub = sub[:, :, None] if channels == 1 else sub[:, :, None].repeat(3, axis=2)
|
|
10302
10727
|
ts[i, :, :, :] = sub
|
|
10303
|
-
return th, tw
|
|
10728
|
+
return th, tw
|
|
10304
10729
|
|
|
10305
10730
|
tp = ThreadPoolExecutor(max_workers=1)
|
|
10306
10731
|
|
|
10307
|
-
# Prime first read
|
|
10308
10732
|
(y0, y1, x0, x1) = tiles[0]
|
|
10309
10733
|
fut = tp.submit(_read_tile_into, buf0, y0, y1, x0, x1)
|
|
10310
10734
|
use0 = True
|
|
10311
|
-
|
|
10312
|
-
# Uniform weights for darks (no quality weighting)
|
|
10313
10735
|
weights_np = np.ones((N,), dtype=np.float32)
|
|
10314
10736
|
|
|
10315
|
-
# --- per-tile loop ---
|
|
10316
10737
|
cancelled_group = False
|
|
10317
10738
|
for t_idx, (y0, y1, x0, x1) in enumerate(tiles, start=1):
|
|
10318
10739
|
if pd.cancelled:
|
|
10319
10740
|
cancelled_group = True
|
|
10320
|
-
self.update_status(self.tr(
|
|
10321
|
-
"⛔ Master Dark creation cancelled during tile processing."
|
|
10322
|
-
))
|
|
10741
|
+
self.update_status(self.tr("⛔ Master Dark creation cancelled during tile processing."))
|
|
10323
10742
|
break
|
|
10324
10743
|
|
|
10325
10744
|
th, tw = fut.result()
|
|
10326
10745
|
ts_np = (buf0 if use0 else buf1)[:N, :th, :tw, :channels]
|
|
10327
10746
|
|
|
10328
|
-
# Prefetch next tile
|
|
10329
10747
|
if t_idx < total_tiles_group:
|
|
10330
10748
|
ny0, ny1, nx0, nx1 = tiles[t_idx]
|
|
10331
|
-
fut = tp.submit(
|
|
10332
|
-
_read_tile_into,
|
|
10333
|
-
(buf1 if use0 else buf0),
|
|
10334
|
-
ny0, ny1, nx0, nx1,
|
|
10335
|
-
)
|
|
10749
|
+
fut = tp.submit(_read_tile_into, (buf1 if use0 else buf0), ny0, ny1, nx0, nx1)
|
|
10336
10750
|
|
|
10337
10751
|
pd.set_label(
|
|
10338
|
-
f"{int(exposure_time)}s ({image_size}) — "
|
|
10339
|
-
f"tile {t_idx}/{total_tiles_group} "
|
|
10340
|
-
f"y:{y0}-{y1} x:{x0}-{x1}"
|
|
10752
|
+
f"{int(exposure_time)}s ({image_size}) [{session}] — "
|
|
10753
|
+
f"tile {t_idx}/{total_tiles_group} y:{y0}-{y1} x:{x0}-{x1}"
|
|
10341
10754
|
)
|
|
10342
10755
|
|
|
10343
|
-
# ---- reduction (GPU or CPU) ----
|
|
10344
10756
|
if use_gpu:
|
|
10345
10757
|
tile_result, _ = _torch_reduce_tile(
|
|
10346
10758
|
ts_np,
|
|
@@ -10350,59 +10762,39 @@ class StackingSuiteDialog(QDialog):
|
|
|
10350
10762
|
iterations=int(params.get("iterations", getattr(self, "iterations", 1))),
|
|
10351
10763
|
sigma_low=float(getattr(self, "sigma_low", 2.5)),
|
|
10352
10764
|
sigma_high=float(getattr(self, "sigma_high", 2.5)),
|
|
10353
|
-
trim_fraction=float(
|
|
10354
|
-
params.get("trim_fraction", getattr(self, "trim_fraction", 0.05))
|
|
10355
|
-
),
|
|
10765
|
+
trim_fraction=float(params.get("trim_fraction", getattr(self, "trim_fraction", 0.05))),
|
|
10356
10766
|
esd_threshold=float(getattr(self, "esd_threshold", 3.0)),
|
|
10357
|
-
biweight_constant=float(
|
|
10358
|
-
getattr(self, "biweight_constant", 6.0)
|
|
10359
|
-
),
|
|
10767
|
+
biweight_constant=float(getattr(self, "biweight_constant", 6.0)),
|
|
10360
10768
|
modz_threshold=float(getattr(self, "modz_threshold", 3.5)),
|
|
10361
|
-
comet_hclip_k=float(
|
|
10362
|
-
|
|
10363
|
-
),
|
|
10364
|
-
comet_hclip_p=float(
|
|
10365
|
-
self.settings.value("stacking/comet_hclip_p", 25.0, type=float)
|
|
10366
|
-
),
|
|
10769
|
+
comet_hclip_k=float(self.settings.value("stacking/comet_hclip_k", 1.30, type=float)),
|
|
10770
|
+
comet_hclip_p=float(self.settings.value("stacking/comet_hclip_p", 25.0, type=float)),
|
|
10367
10771
|
)
|
|
10368
10772
|
else:
|
|
10369
10773
|
if cpu_label == "median":
|
|
10370
10774
|
tile_result = _cpu_tile_median(ts_np)
|
|
10371
10775
|
elif cpu_label == "trimmed":
|
|
10372
|
-
tile_result = _cpu_tile_trimmed_mean(
|
|
10373
|
-
|
|
10374
|
-
|
|
10375
|
-
)
|
|
10376
|
-
else: # 'kappa1'
|
|
10377
|
-
tile_result = _cpu_tile_kappa_sigma_1iter(
|
|
10378
|
-
ts_np,
|
|
10379
|
-
float(params.get("kappa", 3.0)),
|
|
10380
|
-
)
|
|
10776
|
+
tile_result = _cpu_tile_trimmed_mean(ts_np, float(params.get("trim_fraction", 0.05)))
|
|
10777
|
+
else:
|
|
10778
|
+
tile_result = _cpu_tile_kappa_sigma_1iter(ts_np, float(params.get("kappa", 3.0)))
|
|
10381
10779
|
|
|
10382
|
-
# Ensure tile_result has correct shape (th, tw, channels)
|
|
10383
10780
|
if tile_result.ndim == 2:
|
|
10384
10781
|
tile_result = tile_result[:, :, None]
|
|
10385
10782
|
expected_shape = (th, tw, channels)
|
|
10386
10783
|
if tile_result.shape != expected_shape:
|
|
10387
|
-
if tile_result.shape[2] ==
|
|
10388
|
-
tile_result = np.zeros(expected_shape, dtype=np.float32)
|
|
10389
|
-
elif tile_result.shape[:2] == (th, tw):
|
|
10784
|
+
if tile_result.shape[:2] == (th, tw):
|
|
10390
10785
|
if tile_result.shape[2] > channels:
|
|
10391
10786
|
tile_result = tile_result[:, :, :channels]
|
|
10392
10787
|
else:
|
|
10393
|
-
tile_result = np.repeat(
|
|
10394
|
-
|
|
10395
|
-
|
|
10788
|
+
tile_result = np.repeat(tile_result, channels, axis=2)[:, :, :channels]
|
|
10789
|
+
else:
|
|
10790
|
+
tile_result = np.zeros(expected_shape, dtype=np.float32)
|
|
10396
10791
|
|
|
10397
|
-
# Commit tile result into final memmap
|
|
10398
10792
|
final_stacked[y0:y1, x0:x1, :] = tile_result
|
|
10399
|
-
|
|
10400
10793
|
pd.step()
|
|
10401
10794
|
use0 = not use0
|
|
10402
10795
|
|
|
10403
10796
|
tp.shutdown(wait=True)
|
|
10404
10797
|
|
|
10405
|
-
# Close memmapped sources for this group
|
|
10406
10798
|
for s in sources:
|
|
10407
10799
|
try:
|
|
10408
10800
|
s.close()
|
|
@@ -10410,9 +10802,7 @@ class StackingSuiteDialog(QDialog):
|
|
|
10410
10802
|
pass
|
|
10411
10803
|
|
|
10412
10804
|
if cancelled_group:
|
|
10413
|
-
self.update_status(self.tr(
|
|
10414
|
-
"⛔ Master Dark creation cancelled; cleaning up temporary files."
|
|
10415
|
-
))
|
|
10805
|
+
self.update_status(self.tr("⛔ Master Dark creation cancelled; cleaning up temporary files."))
|
|
10416
10806
|
try:
|
|
10417
10807
|
del final_stacked
|
|
10418
10808
|
except Exception:
|
|
@@ -10423,7 +10813,6 @@ class StackingSuiteDialog(QDialog):
|
|
|
10423
10813
|
pass
|
|
10424
10814
|
break
|
|
10425
10815
|
|
|
10426
|
-
# Convert memmap to regular array and free the file
|
|
10427
10816
|
master_dark_data = np.asarray(final_stacked, dtype=np.float32)
|
|
10428
10817
|
del final_stacked
|
|
10429
10818
|
gc.collect()
|
|
@@ -10432,38 +10821,29 @@ class StackingSuiteDialog(QDialog):
|
|
|
10432
10821
|
except Exception:
|
|
10433
10822
|
pass
|
|
10434
10823
|
|
|
10435
|
-
|
|
10824
|
+
# Include session in output name
|
|
10825
|
+
master_dark_stem = f"MasterDark_{session}_{int(exposure_time)}s_{image_size}"
|
|
10436
10826
|
master_dark_path = self._build_out(master_dir, master_dark_stem, "fit")
|
|
10437
10827
|
|
|
10438
10828
|
master_header = fits.Header()
|
|
10439
10829
|
master_header["IMAGETYP"] = "DARK"
|
|
10440
|
-
master_header["EXPTIME"] = (
|
|
10441
|
-
|
|
10442
|
-
"User-specified or from grouping",
|
|
10443
|
-
)
|
|
10830
|
+
master_header["EXPTIME"] = (exposure_time, "User-specified or from grouping")
|
|
10831
|
+
master_header["SESSION"] = (session, "User session tag") # optional but useful
|
|
10444
10832
|
master_header["NAXIS"] = 3 if channels == 3 else 2
|
|
10445
10833
|
master_header["NAXIS1"] = master_dark_data.shape[1]
|
|
10446
10834
|
master_header["NAXIS2"] = master_dark_data.shape[0]
|
|
10447
10835
|
if channels == 3:
|
|
10448
10836
|
master_header["NAXIS3"] = 3
|
|
10449
10837
|
|
|
10450
|
-
save_image(
|
|
10451
|
-
|
|
10452
|
-
|
|
10453
|
-
"fit",
|
|
10454
|
-
"32-bit floating point",
|
|
10455
|
-
master_header,
|
|
10456
|
-
is_mono=(channels == 1),
|
|
10457
|
-
)
|
|
10458
|
-
self.add_master_dark_to_tree(
|
|
10459
|
-
f"{exposure_time}s ({image_size})", master_dark_path
|
|
10460
|
-
)
|
|
10838
|
+
save_image(master_dark_data, master_dark_path, "fit", "32-bit floating point", master_header, is_mono=(channels == 1))
|
|
10839
|
+
|
|
10840
|
+
self.add_master_dark_to_tree(f"{exposure_time}s ({image_size}) [{session}]", master_dark_path)
|
|
10461
10841
|
self.update_status(self.tr(f"✅ Master Dark saved: {master_dark_path}"))
|
|
10462
10842
|
QApplication.processEvents()
|
|
10843
|
+
|
|
10463
10844
|
self.assign_best_master_files()
|
|
10464
10845
|
self.save_master_paths_to_settings()
|
|
10465
10846
|
|
|
10466
|
-
# wrap-up
|
|
10467
10847
|
self.assign_best_master_dark()
|
|
10468
10848
|
self.update_override_dark_combo()
|
|
10469
10849
|
self.assign_best_master_files()
|
|
@@ -10475,6 +10855,7 @@ class StackingSuiteDialog(QDialog):
|
|
|
10475
10855
|
import logging
|
|
10476
10856
|
logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
|
|
10477
10857
|
pd.close()
|
|
10858
|
+
|
|
10478
10859
|
|
|
10479
10860
|
def add_master_dark_to_tree(self, exposure_label: str, master_dark_path: str):
|
|
10480
10861
|
"""
|
|
@@ -10691,7 +11072,15 @@ class StackingSuiteDialog(QDialog):
|
|
|
10691
11072
|
# -------------------------------------------------------------------------
|
|
10692
11073
|
# Group flats exactly as before
|
|
10693
11074
|
# -------------------------------------------------------------------------
|
|
10694
|
-
for
|
|
11075
|
+
for key, file_list in (self.flat_files or {}).items():
|
|
11076
|
+
# Support both legacy and new key formats
|
|
11077
|
+
if isinstance(key, tuple) and len(key) >= 2:
|
|
11078
|
+
filter_exposure = str(key[0])
|
|
11079
|
+
session = str(key[1] or "Default").strip() or "Default"
|
|
11080
|
+
else:
|
|
11081
|
+
filter_exposure = str(key)
|
|
11082
|
+
session = "Default"
|
|
11083
|
+
|
|
10695
11084
|
try:
|
|
10696
11085
|
filter_name, exposure_size = filter_exposure.split(" - ")
|
|
10697
11086
|
exposure_time_str, image_size = exposure_size.split(" (")
|
|
@@ -10704,21 +11093,35 @@ class StackingSuiteDialog(QDialog):
|
|
|
10704
11093
|
exposure_time = float(match.group(1)) if match else -10.0
|
|
10705
11094
|
|
|
10706
11095
|
matched_group = None
|
|
10707
|
-
for
|
|
10708
|
-
existing_exposure, existing_size, existing_filter, existing_session =
|
|
11096
|
+
for k in flat_files_by_group:
|
|
11097
|
+
existing_exposure, existing_size, existing_filter, existing_session = k
|
|
10709
11098
|
if (
|
|
10710
11099
|
abs(existing_exposure - exposure_time) <= exposure_tolerance
|
|
10711
11100
|
and existing_size == image_size
|
|
10712
11101
|
and existing_filter == filter_name
|
|
10713
11102
|
and existing_session == session
|
|
10714
11103
|
):
|
|
10715
|
-
matched_group =
|
|
11104
|
+
matched_group = k
|
|
10716
11105
|
break
|
|
10717
11106
|
|
|
10718
11107
|
if matched_group is None:
|
|
10719
11108
|
matched_group = (exposure_time, image_size, filter_name, session)
|
|
10720
11109
|
flat_files_by_group[matched_group] = []
|
|
10721
|
-
|
|
11110
|
+
|
|
11111
|
+
flat_files_by_group[matched_group].extend(file_list or [])
|
|
11112
|
+
|
|
11113
|
+
# Dedupe paths within each group (prevents accidental double-counts)
|
|
11114
|
+
for k, lst in list(flat_files_by_group.items()):
|
|
11115
|
+
seen = set()
|
|
11116
|
+
out = []
|
|
11117
|
+
for p in (lst or []):
|
|
11118
|
+
pn = os.path.normcase(os.path.abspath(p))
|
|
11119
|
+
if pn in seen:
|
|
11120
|
+
continue
|
|
11121
|
+
seen.add(pn)
|
|
11122
|
+
out.append(p)
|
|
11123
|
+
flat_files_by_group[k] = out
|
|
11124
|
+
|
|
10722
11125
|
|
|
10723
11126
|
# Discovery summary
|
|
10724
11127
|
try:
|
|
@@ -12522,6 +12925,20 @@ class StackingSuiteDialog(QDialog):
|
|
|
12522
12925
|
"drop": float(self.drizzle_drop_shrink_spin.value())
|
|
12523
12926
|
}
|
|
12524
12927
|
|
|
12928
|
+
def _global_drizzle_state(self) -> dict:
|
|
12929
|
+
# UI is the source of truth at runtime
|
|
12930
|
+
enabled = bool(self.drizzle_checkbox.isChecked())
|
|
12931
|
+
|
|
12932
|
+
# Scale from combo text like "1x", "2x", "3x"
|
|
12933
|
+
try:
|
|
12934
|
+
scale = float(self.drizzle_scale_combo.currentText().replace("x", "", 1).strip())
|
|
12935
|
+
except Exception:
|
|
12936
|
+
scale = 1.0
|
|
12937
|
+
|
|
12938
|
+
drop = float(self.drizzle_drop_shrink_spin.value())
|
|
12939
|
+
|
|
12940
|
+
return {"enabled": enabled, "scale": scale, "drop": drop}
|
|
12941
|
+
|
|
12525
12942
|
def _split_dual_band_osc(self, selected_groups=None):
|
|
12526
12943
|
"""
|
|
12527
12944
|
Create mono Ha/SII/OIII frames from dual-band OSC files and
|
|
@@ -13284,6 +13701,24 @@ class StackingSuiteDialog(QDialog):
|
|
|
13284
13701
|
self.update_status(self.tr("🔄 Image Registration Started..."))
|
|
13285
13702
|
self.extract_light_files_from_tree(debug=True)
|
|
13286
13703
|
|
|
13704
|
+
# --- Apply "removed from Registration tab" exclusions (session-level) ---
|
|
13705
|
+
dead = set()
|
|
13706
|
+
if hasattr(self, "deleted_calibrated_files") and self.deleted_calibrated_files:
|
|
13707
|
+
dead = set(self.deleted_calibrated_files)
|
|
13708
|
+
|
|
13709
|
+
if dead:
|
|
13710
|
+
for g in list(self.light_files.keys()):
|
|
13711
|
+
self.light_files[g] = [
|
|
13712
|
+
p for p in self.light_files[g]
|
|
13713
|
+
if os.path.normcase(os.path.abspath(p)) not in dead
|
|
13714
|
+
]
|
|
13715
|
+
if not self.light_files[g]:
|
|
13716
|
+
del self.light_files[g]
|
|
13717
|
+
|
|
13718
|
+
self.update_status(self.tr(f"🚫 Excluding {len(dead)} removed frame(s) from registration/stacking."))
|
|
13719
|
+
QApplication.processEvents()
|
|
13720
|
+
|
|
13721
|
+
|
|
13287
13722
|
comet_mode = bool(getattr(self, "comet_cb", None) and self.comet_cb.isChecked())
|
|
13288
13723
|
if comet_mode:
|
|
13289
13724
|
self.update_status(self.tr("🌠 Comet mode: please click the comet center to continue…"))
|
|
@@ -14896,6 +15331,14 @@ class StackingSuiteDialog(QDialog):
|
|
|
14896
15331
|
# Snapshot UI-dependent settings (your existing code)
|
|
14897
15332
|
# ----------------------------
|
|
14898
15333
|
drizzle_dict = self.gather_drizzle_settings_from_tree()
|
|
15334
|
+
try:
|
|
15335
|
+
self.update_status(self.tr(
|
|
15336
|
+
"🧾 Drizzle dict: " + ", ".join(f"{k}:{'ON' if v.get('drizzle_enabled') else 'off'}"
|
|
15337
|
+
for k, v in drizzle_dict.items())
|
|
15338
|
+
))
|
|
15339
|
+
except Exception:
|
|
15340
|
+
pass
|
|
15341
|
+
QApplication.processEvents()
|
|
14899
15342
|
try:
|
|
14900
15343
|
autocrop_enabled = self.autocrop_cb.isChecked()
|
|
14901
15344
|
autocrop_pct = float(self.autocrop_pct.value())
|
|
@@ -15310,6 +15753,22 @@ class StackingSuiteDialog(QDialog):
|
|
|
15310
15753
|
|
|
15311
15754
|
self._set_registration_busy(False)
|
|
15312
15755
|
|
|
15756
|
+
def _on_after_align_finished(self, success: bool, message: str):
|
|
15757
|
+
# Stop thread/progress UI first (whatever you already do)
|
|
15758
|
+
|
|
15759
|
+
if success:
|
|
15760
|
+
QMessageBox.information(
|
|
15761
|
+
self,
|
|
15762
|
+
self.tr("Stacking Complete"),
|
|
15763
|
+
message
|
|
15764
|
+
)
|
|
15765
|
+
else:
|
|
15766
|
+
QMessageBox.critical(
|
|
15767
|
+
self,
|
|
15768
|
+
self.tr("Stacking Failed"),
|
|
15769
|
+
message
|
|
15770
|
+
)
|
|
15771
|
+
|
|
15313
15772
|
def _on_mf_progress(self, s: str):
|
|
15314
15773
|
# Mirror non-token messages
|
|
15315
15774
|
if not s.startswith("__PROGRESS__"):
|
|
@@ -15338,25 +15797,48 @@ class StackingSuiteDialog(QDialog):
|
|
|
15338
15797
|
|
|
15339
15798
|
@pyqtSlot(bool, str)
|
|
15340
15799
|
def _on_post_pipeline_finished(self, ok: bool, message: str):
|
|
15800
|
+
# ---- close progress dialog ----
|
|
15341
15801
|
try:
|
|
15342
|
-
if getattr(self, "post_progress", None):
|
|
15802
|
+
if getattr(self, "post_progress", None) is not None:
|
|
15343
15803
|
self.post_progress.close()
|
|
15804
|
+
self.post_progress.deleteLater()
|
|
15344
15805
|
self.post_progress = None
|
|
15345
15806
|
except Exception:
|
|
15346
15807
|
pass
|
|
15347
15808
|
|
|
15809
|
+
# ---- stop thread ----
|
|
15348
15810
|
try:
|
|
15349
|
-
self
|
|
15350
|
-
|
|
15811
|
+
if getattr(self, "post_thread", None) is not None:
|
|
15812
|
+
self.post_thread.quit()
|
|
15813
|
+
self.post_thread.wait()
|
|
15351
15814
|
except Exception:
|
|
15352
15815
|
pass
|
|
15816
|
+
|
|
15817
|
+
# ---- cleanup objects ----
|
|
15353
15818
|
try:
|
|
15354
|
-
self
|
|
15355
|
-
|
|
15819
|
+
if getattr(self, "post_worker", None) is not None:
|
|
15820
|
+
self.post_worker.deleteLater()
|
|
15821
|
+
self.post_worker = None
|
|
15822
|
+
if getattr(self, "post_thread", None) is not None:
|
|
15823
|
+
self.post_thread.deleteLater()
|
|
15824
|
+
self.post_thread = None
|
|
15356
15825
|
except Exception:
|
|
15357
15826
|
pass
|
|
15358
15827
|
|
|
15359
|
-
|
|
15828
|
+
# ---- update status (keep this behavior) ----
|
|
15829
|
+
try:
|
|
15830
|
+
# message already includes "Post-alignment complete..." text
|
|
15831
|
+
self.update_status(self.tr(message))
|
|
15832
|
+
except Exception:
|
|
15833
|
+
pass
|
|
15834
|
+
|
|
15835
|
+
# ---- popup summary ----
|
|
15836
|
+
# (Do this after progress dialog is gone so it doesn't hide behind it)
|
|
15837
|
+
if ok:
|
|
15838
|
+
QMessageBox.information(self, self.tr("Post-Alignment Complete"), message)
|
|
15839
|
+
else:
|
|
15840
|
+
QMessageBox.critical(self, self.tr("Post-Alignment Failed"), message)
|
|
15841
|
+
|
|
15360
15842
|
self._cfa_for_this_run = None
|
|
15361
15843
|
QApplication.processEvents()
|
|
15362
15844
|
|
|
@@ -15463,6 +15945,8 @@ class StackingSuiteDialog(QDialog):
|
|
|
15463
15945
|
log(f"📁 Post-align: {n_groups} group(s), {n_frames} aligned frame(s).")
|
|
15464
15946
|
QApplication.processEvents()
|
|
15465
15947
|
|
|
15948
|
+
drizzle_enabled_global = self._get_drizzle_enabled()
|
|
15949
|
+
|
|
15466
15950
|
# Precompute a single global crop rect if enabled (pure computation, no UI).
|
|
15467
15951
|
global_rect = None
|
|
15468
15952
|
if autocrop_enabled:
|
|
@@ -15896,8 +16380,7 @@ class StackingSuiteDialog(QDialog):
|
|
|
15896
16380
|
log(f"✂️ Saved CometBlend (auto-cropped) → {blend_path_crop}")
|
|
15897
16381
|
|
|
15898
16382
|
# ---- Drizzle bookkeeping for this group ----
|
|
15899
|
-
|
|
15900
|
-
if dconf.get("drizzle_enabled", False):
|
|
16383
|
+
if drizzle_enabled_global:
|
|
15901
16384
|
sasr_path = os.path.join(self.stacking_directory, f"{group_key}_rejections.sasr")
|
|
15902
16385
|
self.save_rejection_map_sasr(rejection_map, sasr_path)
|
|
15903
16386
|
log(f"✅ Saved rejection map to {sasr_path}")
|
|
@@ -15929,17 +16412,17 @@ class StackingSuiteDialog(QDialog):
|
|
|
15929
16412
|
originals_by_group[group] = orig_list
|
|
15930
16413
|
# ---- Drizzle pass (only for groups with drizzle enabled) ----
|
|
15931
16414
|
for group_key, file_list in grouped_files.items():
|
|
15932
|
-
|
|
15933
|
-
|
|
15934
|
-
log(f"✅ Group '{group_key}' not set for drizzle. Integrated image already saved.")
|
|
16415
|
+
if not drizzle_enabled_global:
|
|
16416
|
+
log(f"✅ Drizzle disabled (checkbox off). Group '{group_key}' integrated image already saved.")
|
|
15935
16417
|
continue
|
|
15936
16418
|
|
|
16419
|
+
# Use your existing getters (they can read UI/settings)
|
|
15937
16420
|
scale_factor = self._get_drizzle_scale()
|
|
15938
16421
|
drop_shrink = self._get_drizzle_pixfrac()
|
|
15939
16422
|
|
|
15940
|
-
# Optional: also read kernel for logging/branching
|
|
15941
16423
|
kernel = (self.settings.value("stacking/drizzle_kernel", "square", type=str) or "square").lower()
|
|
15942
|
-
|
|
16424
|
+
log(f"Drizzle cfg → scale={scale_factor}×, pixfrac={drop_shrink:.3f}, kernel={kernel}")
|
|
16425
|
+
|
|
15943
16426
|
rejections_for_group = group_integration_data[group_key]["rejection_map"]
|
|
15944
16427
|
n_frames_group = group_integration_data[group_key]["n_frames"]
|
|
15945
16428
|
|
|
@@ -15947,8 +16430,8 @@ class StackingSuiteDialog(QDialog):
|
|
|
15947
16430
|
|
|
15948
16431
|
self.drizzle_stack_one_group(
|
|
15949
16432
|
group_key=group_key,
|
|
15950
|
-
file_list=file_list,
|
|
15951
|
-
original_list=originals_by_group.get(group_key, []),
|
|
16433
|
+
file_list=file_list,
|
|
16434
|
+
original_list=originals_by_group.get(group_key, []),
|
|
15952
16435
|
transforms_dict=transforms_dict,
|
|
15953
16436
|
frame_weights=frame_weights,
|
|
15954
16437
|
scale_factor=scale_factor,
|
|
@@ -17809,12 +18292,7 @@ class StackingSuiteDialog(QDialog):
|
|
|
17809
18292
|
# --- reusable C-order tile buffers (avoid copies before GPU) ---
|
|
17810
18293
|
def _mk_buf():
|
|
17811
18294
|
buf = np.empty((N, chunk_h, chunk_w, channels), dtype=np.float32, order='C')
|
|
17812
|
-
|
|
17813
|
-
# We'll pin tensors inside _torch_reduce_tile; nothing to do here.
|
|
17814
|
-
try:
|
|
17815
|
-
import torch # noqa: F401
|
|
17816
|
-
except Exception:
|
|
17817
|
-
pass
|
|
18295
|
+
|
|
17818
18296
|
return buf
|
|
17819
18297
|
|
|
17820
18298
|
buf0 = _mk_buf()
|