setiastrosuitepro 1.6.2__py3-none-any.whl → 1.6.12__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/abeicon.svg +16 -0
- setiastro/images/acv_icon.png +0 -0
- setiastro/images/colorwheel.svg +97 -0
- setiastro/images/cosmic.svg +40 -0
- setiastro/images/cosmicsat.svg +24 -0
- setiastro/images/first_quarter.png +0 -0
- setiastro/images/full_moon.png +0 -0
- setiastro/images/graxpert.svg +19 -0
- setiastro/images/last_quarter.png +0 -0
- setiastro/images/linearfit.svg +32 -0
- setiastro/images/new_moon.png +0 -0
- setiastro/images/pixelmath.svg +42 -0
- setiastro/images/rotatearbitrary.png +0 -0
- setiastro/images/waning_crescent_1.png +0 -0
- setiastro/images/waning_crescent_2.png +0 -0
- setiastro/images/waning_crescent_3.png +0 -0
- setiastro/images/waning_crescent_4.png +0 -0
- setiastro/images/waning_crescent_5.png +0 -0
- setiastro/images/waning_gibbous_1.png +0 -0
- setiastro/images/waning_gibbous_2.png +0 -0
- setiastro/images/waning_gibbous_3.png +0 -0
- setiastro/images/waning_gibbous_4.png +0 -0
- setiastro/images/waning_gibbous_5.png +0 -0
- setiastro/images/waxing_crescent_1.png +0 -0
- setiastro/images/waxing_crescent_2.png +0 -0
- setiastro/images/waxing_crescent_3.png +0 -0
- setiastro/images/waxing_crescent_4.png +0 -0
- setiastro/images/waxing_crescent_5.png +0 -0
- setiastro/images/waxing_gibbous_1.png +0 -0
- setiastro/images/waxing_gibbous_2.png +0 -0
- setiastro/images/waxing_gibbous_3.png +0 -0
- setiastro/images/waxing_gibbous_4.png +0 -0
- setiastro/images/waxing_gibbous_5.png +0 -0
- setiastro/qml/ResourceMonitor.qml +84 -82
- setiastro/saspro/__main__.py +20 -1
- setiastro/saspro/_generated/build_info.py +2 -2
- setiastro/saspro/abe.py +37 -4
- setiastro/saspro/aberration_ai.py +237 -21
- setiastro/saspro/acv_exporter.py +379 -0
- setiastro/saspro/add_stars.py +33 -6
- setiastro/saspro/backgroundneutral.py +114 -37
- setiastro/saspro/blemish_blaster.py +4 -1
- setiastro/saspro/blink_comparator_pro.py +548 -275
- setiastro/saspro/clahe.py +4 -1
- setiastro/saspro/continuum_subtract.py +4 -1
- setiastro/saspro/convo.py +13 -7
- setiastro/saspro/cosmicclarity.py +129 -18
- setiastro/saspro/crop_dialog_pro.py +134 -8
- setiastro/saspro/curve_editor_pro.py +109 -42
- setiastro/saspro/doc_manager.py +246 -16
- setiastro/saspro/exoplanet_detector.py +120 -28
- setiastro/saspro/frequency_separation.py +1158 -204
- setiastro/saspro/function_bundle.py +16 -16
- setiastro/saspro/ghs_dialog_pro.py +81 -16
- setiastro/saspro/graxpert.py +1 -0
- setiastro/saspro/gui/main_window.py +519 -289
- setiastro/saspro/gui/mixins/dock_mixin.py +276 -42
- setiastro/saspro/gui/mixins/geometry_mixin.py +105 -5
- setiastro/saspro/gui/mixins/menu_mixin.py +28 -1
- setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
- setiastro/saspro/gui/mixins/toolbar_mixin.py +416 -27
- setiastro/saspro/gui/mixins/update_mixin.py +138 -36
- setiastro/saspro/gui/mixins/view_mixin.py +42 -0
- setiastro/saspro/halobgon.py +4 -0
- setiastro/saspro/histogram.py +5 -1
- setiastro/saspro/image_combine.py +4 -0
- setiastro/saspro/image_peeker_pro.py +4 -0
- setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
- setiastro/saspro/imageops/stretch.py +582 -62
- setiastro/saspro/isophote.py +4 -0
- setiastro/saspro/layers.py +13 -9
- setiastro/saspro/layers_dock.py +183 -3
- setiastro/saspro/legacy/image_manager.py +154 -20
- setiastro/saspro/legacy/numba_utils.py +67 -47
- setiastro/saspro/legacy/xisf.py +240 -98
- setiastro/saspro/live_stacking.py +180 -79
- setiastro/saspro/luminancerecombine.py +228 -27
- setiastro/saspro/mask_creation.py +174 -15
- setiastro/saspro/mfdeconv.py +113 -35
- setiastro/saspro/mfdeconvcudnn.py +119 -70
- setiastro/saspro/mfdeconvsport.py +112 -35
- setiastro/saspro/morphology.py +4 -0
- setiastro/saspro/multiscale_decomp.py +748 -255
- setiastro/saspro/numba_utils.py +72 -57
- setiastro/saspro/ops/commands.py +18 -18
- setiastro/saspro/ops/script_editor.py +10 -2
- setiastro/saspro/ops/scripts.py +122 -0
- setiastro/saspro/perfect_palette_picker.py +37 -3
- setiastro/saspro/plate_solver.py +84 -49
- setiastro/saspro/psf_viewer.py +119 -37
- setiastro/saspro/remove_stars_preset.py +55 -13
- setiastro/saspro/resources.py +97 -11
- setiastro/saspro/rgbalign.py +4 -0
- setiastro/saspro/selective_color.py +83 -21
- setiastro/saspro/sfcc.py +364 -152
- setiastro/saspro/shortcuts.py +253 -49
- setiastro/saspro/signature_insert.py +692 -33
- setiastro/saspro/stacking_suite.py +1610 -574
- setiastro/saspro/star_alignment.py +522 -453
- setiastro/saspro/star_spikes.py +4 -0
- setiastro/saspro/star_stretch.py +38 -3
- setiastro/saspro/stat_stretch.py +743 -128
- setiastro/saspro/status_log_dock.py +1 -1
- setiastro/saspro/subwindow.py +786 -360
- setiastro/saspro/supernovaasteroidhunter.py +1 -1
- setiastro/saspro/swap_manager.py +77 -42
- setiastro/saspro/translations/all_source_strings.json +1588 -516
- setiastro/saspro/translations/ar_translations.py +915 -684
- setiastro/saspro/translations/de_translations.py +442 -463
- setiastro/saspro/translations/es_translations.py +277 -47
- setiastro/saspro/translations/fr_translations.py +279 -47
- setiastro/saspro/translations/hi_translations.py +253 -21
- setiastro/saspro/translations/integrate_translations.py +3 -2
- setiastro/saspro/translations/it_translations.py +1211 -161
- setiastro/saspro/translations/ja_translations.py +3340 -3107
- setiastro/saspro/translations/pt_translations.py +3315 -3337
- setiastro/saspro/translations/ru_translations.py +351 -117
- setiastro/saspro/translations/saspro_ar.qm +0 -0
- setiastro/saspro/translations/saspro_ar.ts +15902 -138
- 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 +14733 -135
- 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 +11766 -168
- setiastro/saspro/translations/saspro_sw.qm +0 -0
- setiastro/saspro/translations/saspro_sw.ts +15115 -135
- setiastro/saspro/translations/saspro_uk.qm +0 -0
- setiastro/saspro/translations/saspro_uk.ts +11206 -6729
- setiastro/saspro/translations/saspro_zh.qm +0 -0
- setiastro/saspro/translations/saspro_zh.ts +10581 -7812
- setiastro/saspro/translations/sw_translations.py +282 -56
- setiastro/saspro/translations/uk_translations.py +264 -35
- setiastro/saspro/translations/zh_translations.py +282 -47
- setiastro/saspro/view_bundle.py +17 -17
- setiastro/saspro/wavescale_hdr.py +4 -1
- setiastro/saspro/wavescalede.py +4 -1
- setiastro/saspro/whitebalance.py +84 -12
- setiastro/saspro/widgets/common_utilities.py +28 -21
- setiastro/saspro/widgets/minigame/game.js +11 -6
- setiastro/saspro/widgets/resource_monitor.py +133 -57
- setiastro/saspro/widgets/spinboxes.py +28 -13
- setiastro/saspro/wimi.py +92 -721
- setiastro/saspro/wims.py +46 -36
- setiastro/saspro/window_shelf.py +2 -2
- setiastro/saspro/xisf.py +101 -11
- {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/METADATA +8 -7
- {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/RECORD +162 -128
- {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/WHEEL +0 -0
- {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/entry_points.txt +0 -0
- {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/licenses/LICENSE +0 -0
- {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/licenses/license.txt +0 -0
|
@@ -150,45 +150,75 @@ class MetricsPanel(QWidget):
|
|
|
150
150
|
orig_back = entry.get('orig_background', np.nan)
|
|
151
151
|
return idx, fwhm, ecc, orig_back, star_cnt
|
|
152
152
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
153
|
+
def compute_all_metrics(self, loaded_images) -> bool:
|
|
154
|
+
"""Run SEP over the full list in parallel using threads and cache results.
|
|
155
|
+
Returns True if metrics were computed, False if user declined/canceled.
|
|
156
|
+
"""
|
|
156
157
|
n = len(loaded_images)
|
|
157
158
|
if n == 0:
|
|
158
|
-
# Clear any previous state and bail
|
|
159
159
|
self._orig_images = []
|
|
160
|
-
self.metrics_data = [np.array([])]*4
|
|
160
|
+
self.metrics_data = [np.array([])] * 4
|
|
161
161
|
self.flags = []
|
|
162
|
-
self._threshold_initialized = [False]*4
|
|
163
|
-
return
|
|
162
|
+
self._threshold_initialized = [False] * 4
|
|
163
|
+
return True
|
|
164
|
+
|
|
165
|
+
def _has_metrics(md):
|
|
166
|
+
try:
|
|
167
|
+
return md is not None and len(md) == 4 and md[0] is not None and len(md[0]) > 0
|
|
168
|
+
except Exception:
|
|
169
|
+
return False
|
|
164
170
|
|
|
165
|
-
# Heads-up dialog (as you already had)
|
|
166
171
|
settings = QSettings()
|
|
167
|
-
|
|
168
|
-
|
|
172
|
+
show_warning = settings.value("metrics/showWarning", True, type=bool)
|
|
173
|
+
|
|
174
|
+
if (not show_warning) and (not _has_metrics(getattr(self, "metrics_data", None))):
|
|
175
|
+
settings.setValue("metrics/showWarning", True)
|
|
176
|
+
show_warning = True
|
|
177
|
+
|
|
178
|
+
# ----------------------------
|
|
179
|
+
# 1) Optional warning gate
|
|
180
|
+
# ----------------------------
|
|
181
|
+
if show_warning:
|
|
169
182
|
msg = QMessageBox(self)
|
|
170
183
|
msg.setWindowTitle(self.tr("Heads-up"))
|
|
171
184
|
msg.setText(self.tr(
|
|
172
185
|
"This is going to use ALL your CPU cores and the UI may lock up until it finishes.\n\n"
|
|
173
186
|
"Continue?"
|
|
174
187
|
))
|
|
175
|
-
msg.setStandardButtons(
|
|
176
|
-
|
|
188
|
+
msg.setStandardButtons(
|
|
189
|
+
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
|
|
190
|
+
)
|
|
177
191
|
cb = QCheckBox(self.tr("Don't show again"), msg)
|
|
178
192
|
msg.setCheckBox(cb)
|
|
179
|
-
|
|
180
|
-
|
|
193
|
+
|
|
194
|
+
clicked = msg.exec()
|
|
195
|
+
clicked_yes = (clicked == QMessageBox.StandardButton.Yes)
|
|
196
|
+
|
|
197
|
+
if not clicked_yes:
|
|
198
|
+
# If they said NO, never allow "Don't show again" to lock them out.
|
|
199
|
+
# Keep the warning enabled so they can opt-in later.
|
|
200
|
+
if cb.isChecked():
|
|
201
|
+
settings.setValue("metrics/showWarning", True)
|
|
202
|
+
return False
|
|
203
|
+
|
|
204
|
+
# They said YES: now it's safe to honor "Don't show again"
|
|
181
205
|
if cb.isChecked():
|
|
182
206
|
settings.setValue("metrics/showWarning", False)
|
|
183
207
|
|
|
184
|
-
#
|
|
208
|
+
# If show_warning is False, we compute with no prompt.
|
|
209
|
+
|
|
210
|
+
# ----------------------------
|
|
211
|
+
# 2) Allocate result arrays
|
|
212
|
+
# ----------------------------
|
|
185
213
|
m0 = np.full(n, np.nan, dtype=np.float32) # FWHM
|
|
186
214
|
m1 = np.full(n, np.nan, dtype=np.float32) # Eccentricity
|
|
187
215
|
m2 = np.full(n, np.nan, dtype=np.float32) # Background (cached)
|
|
188
216
|
m3 = np.full(n, np.nan, dtype=np.float32) # Star count
|
|
189
217
|
flags = [e.get('flagged', False) for e in loaded_images]
|
|
190
218
|
|
|
191
|
-
#
|
|
219
|
+
# ----------------------------
|
|
220
|
+
# 3) Progress dialog
|
|
221
|
+
# ----------------------------
|
|
192
222
|
prog = QProgressDialog(self.tr("Computing frame metrics…"), self.tr("Cancel"), 0, n, self)
|
|
193
223
|
prog.setWindowModality(Qt.WindowModality.WindowModal)
|
|
194
224
|
prog.setMinimumDuration(0)
|
|
@@ -198,32 +228,43 @@ class MetricsPanel(QWidget):
|
|
|
198
228
|
|
|
199
229
|
workers = min(os.cpu_count() or 1, 60)
|
|
200
230
|
tasks = [(i, loaded_images[i]) for i in range(n)]
|
|
201
|
-
done = 0
|
|
231
|
+
done = 0
|
|
232
|
+
canceled = False
|
|
202
233
|
|
|
203
234
|
try:
|
|
204
235
|
with ThreadPoolExecutor(max_workers=workers) as exe:
|
|
205
236
|
futures = {exe.submit(self._compute_one, t): t[0] for t in tasks}
|
|
206
237
|
for fut in as_completed(futures):
|
|
207
238
|
if prog.wasCanceled():
|
|
239
|
+
canceled = True
|
|
208
240
|
break
|
|
209
241
|
try:
|
|
210
242
|
idx, fwhm, ecc, orig_back, star_cnt = fut.result()
|
|
211
243
|
except Exception:
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
244
|
+
idx = futures.get(fut, 0)
|
|
245
|
+
fwhm, ecc, orig_back, star_cnt = np.nan, np.nan, np.nan, 0
|
|
246
|
+
|
|
247
|
+
if 0 <= idx < n:
|
|
248
|
+
m0[idx], m1[idx], m2[idx], m3[idx] = fwhm, ecc, orig_back, float(star_cnt)
|
|
249
|
+
|
|
215
250
|
done += 1
|
|
216
251
|
prog.setValue(done)
|
|
217
252
|
QApplication.processEvents()
|
|
218
253
|
finally:
|
|
219
254
|
prog.close()
|
|
220
255
|
|
|
221
|
-
|
|
256
|
+
if canceled:
|
|
257
|
+
# IMPORTANT: leave caches alone; caller will clear/return
|
|
258
|
+
return False
|
|
259
|
+
|
|
260
|
+
# ----------------------------
|
|
261
|
+
# 4) Stash results
|
|
262
|
+
# ----------------------------
|
|
222
263
|
self._orig_images = loaded_images
|
|
223
264
|
self.metrics_data = [m0, m1, m2, m3]
|
|
224
265
|
self.flags = flags
|
|
225
|
-
self._threshold_initialized = [False]*4
|
|
226
|
-
|
|
266
|
+
self._threshold_initialized = [False] * 4
|
|
267
|
+
return True
|
|
227
268
|
|
|
228
269
|
def plot(self, loaded_images, indices=None):
|
|
229
270
|
"""
|
|
@@ -242,7 +283,16 @@ class MetricsPanel(QWidget):
|
|
|
242
283
|
|
|
243
284
|
# compute & cache on first call or new image list
|
|
244
285
|
if self._orig_images is not loaded_images or self.metrics_data is None:
|
|
245
|
-
self.compute_all_metrics(loaded_images)
|
|
286
|
+
ok = self.compute_all_metrics(loaded_images)
|
|
287
|
+
if not ok or self.metrics_data is None:
|
|
288
|
+
# user declined/canceled -> clear plots and exit cleanly
|
|
289
|
+
for pw, scat, line in zip(self.plots, self.scats, self.lines):
|
|
290
|
+
scat.setData(x=[], y=[])
|
|
291
|
+
line.setPos(0)
|
|
292
|
+
pw.getPlotItem().getViewBox().update()
|
|
293
|
+
pw.repaint()
|
|
294
|
+
return
|
|
295
|
+
|
|
246
296
|
|
|
247
297
|
# default to all indices
|
|
248
298
|
if indices is None:
|
|
@@ -479,27 +529,77 @@ class MetricsWindow(QWidget):
|
|
|
479
529
|
"""
|
|
480
530
|
Called when some frames were deleted/moved out of the list.
|
|
481
531
|
Does NOT recompute metrics. Just trims cached arrays and re-plots.
|
|
532
|
+
|
|
533
|
+
Robust against:
|
|
534
|
+
- removed indices referring to the old list (out of range)
|
|
535
|
+
- metrics_panel arrays being a different length than _all_images
|
|
536
|
+
- stale _order_all / _current_indices containing out-of-bounds indices
|
|
482
537
|
"""
|
|
483
538
|
if not removed:
|
|
484
539
|
return
|
|
485
|
-
removed = sorted(set(int(i) for i in removed))
|
|
486
540
|
|
|
487
|
-
#
|
|
488
|
-
|
|
541
|
+
# Unique + int
|
|
542
|
+
removed = sorted({int(i) for i in removed})
|
|
543
|
+
|
|
544
|
+
# ---- 1) Trim metrics panel caches SAFELY ----
|
|
545
|
+
# Prefer panel's current frame count, because it represents the arrays we must slice.
|
|
546
|
+
n_panel = getattr(self.metrics_panel, "n_frames", None)
|
|
547
|
+
if callable(n_panel):
|
|
548
|
+
n_panel = n_panel()
|
|
549
|
+
if not isinstance(n_panel, int) or n_panel <= 0:
|
|
550
|
+
# fallback: infer from metrics_data if present
|
|
551
|
+
md = getattr(self.metrics_panel, "metrics_data", None)
|
|
552
|
+
if md is not None and len(md) and md[0] is not None:
|
|
553
|
+
try:
|
|
554
|
+
n_panel = int(len(md[0]))
|
|
555
|
+
except Exception:
|
|
556
|
+
n_panel = 0
|
|
557
|
+
else:
|
|
558
|
+
n_panel = 0
|
|
559
|
+
|
|
560
|
+
if n_panel > 0:
|
|
561
|
+
removed_panel = [i for i in removed if 0 <= i < n_panel]
|
|
562
|
+
if removed_panel:
|
|
563
|
+
self.metrics_panel.remove_frames(removed_panel)
|
|
564
|
+
# else: panel has nothing (or isn't initialized) — just continue with ordering cleanup
|
|
489
565
|
|
|
490
|
-
# 2)
|
|
491
|
-
# (BlinkTab will already have mutated the underlying list for us)
|
|
566
|
+
# ---- 2) Update ordering arrays with the SAME removed set (but clamp later) ----
|
|
492
567
|
self._order_all = self._reindex_list_after_remove(self._order_all, removed)
|
|
493
|
-
self._current_indices
|
|
568
|
+
if self._current_indices is not None:
|
|
569
|
+
self._current_indices = self._reindex_list_after_remove(self._current_indices, removed)
|
|
494
570
|
|
|
495
|
-
# 3)
|
|
571
|
+
# ---- 3) Rebuild groups (filters may have disappeared) ----
|
|
496
572
|
self._rebuild_groups_from_images()
|
|
497
573
|
|
|
498
|
-
# 4)
|
|
574
|
+
# ---- 4) Plot with VALID indices only ----
|
|
575
|
+
n_imgs = len(self._all_images) if self._all_images is not None else 0
|
|
576
|
+
|
|
577
|
+
def _sanitize_indices(ixs):
|
|
578
|
+
if not ixs:
|
|
579
|
+
return []
|
|
580
|
+
out = []
|
|
581
|
+
seen = set()
|
|
582
|
+
for i in ixs:
|
|
583
|
+
try:
|
|
584
|
+
ii = int(i)
|
|
585
|
+
except Exception:
|
|
586
|
+
continue
|
|
587
|
+
if 0 <= ii < n_imgs and ii not in seen:
|
|
588
|
+
seen.add(ii)
|
|
589
|
+
out.append(ii)
|
|
590
|
+
return out
|
|
591
|
+
|
|
499
592
|
indices = self._current_indices if self._current_indices is not None else self._order_all
|
|
593
|
+
indices = _sanitize_indices(indices)
|
|
594
|
+
|
|
595
|
+
# If the current group became empty, fall back to "all"
|
|
596
|
+
if not indices and n_imgs:
|
|
597
|
+
indices = list(range(n_imgs))
|
|
598
|
+
self._current_indices = indices # optional: keeps UI consistent
|
|
599
|
+
|
|
500
600
|
self.metrics_panel.plot(self._all_images, indices=indices)
|
|
501
601
|
|
|
502
|
-
# 5)
|
|
602
|
+
# ---- 5) Recolor & status ----
|
|
503
603
|
self.metrics_panel.refresh_colors_and_status()
|
|
504
604
|
self._update_status()
|
|
505
605
|
|
|
@@ -1111,12 +1211,13 @@ class BlinkTab(QWidget):
|
|
|
1111
1211
|
|
|
1112
1212
|
if not use_aggr:
|
|
1113
1213
|
if stored.dtype == np.uint8:
|
|
1114
|
-
|
|
1214
|
+
return stored
|
|
1115
1215
|
elif stored.dtype == np.uint16:
|
|
1116
|
-
|
|
1216
|
+
return (stored >> 8).astype(np.uint8)
|
|
1117
1217
|
else:
|
|
1118
|
-
|
|
1119
|
-
|
|
1218
|
+
# ✅ display-only normalization for float / weird ranges
|
|
1219
|
+
f01 = self._ensure_float01(stored)
|
|
1220
|
+
return (f01 * 255.0).astype(np.uint8)
|
|
1120
1221
|
|
|
1121
1222
|
base01 = self._as_float01(stored)
|
|
1122
1223
|
|
|
@@ -1210,11 +1311,29 @@ class BlinkTab(QWidget):
|
|
|
1210
1311
|
self.loading_label.setText(self.tr("Loaded {0} image{1}.").format(n, 's' if n != 1 else ''))
|
|
1211
1312
|
|
|
1212
1313
|
def _apply_playback_interval(self, *_):
|
|
1213
|
-
# read from custom spin if present
|
|
1214
|
-
fps = float(
|
|
1314
|
+
# read from custom spin if present (support both .value() and .value attribute)
|
|
1315
|
+
fps = float(getattr(self, "play_fps", 1.0))
|
|
1316
|
+
|
|
1317
|
+
if hasattr(self, "speed_spin") and self.speed_spin is not None:
|
|
1318
|
+
try:
|
|
1319
|
+
v = getattr(self.speed_spin, "value", None)
|
|
1320
|
+
if callable(v):
|
|
1321
|
+
fps = float(v()) # QDoubleSpinBox-style
|
|
1322
|
+
elif v is not None:
|
|
1323
|
+
fps = float(v) # CustomDoubleSpinBox stores numeric attribute
|
|
1324
|
+
else:
|
|
1325
|
+
# last-resort: try Qt API name
|
|
1326
|
+
fps = float(self.speed_spin.value())
|
|
1327
|
+
except Exception:
|
|
1328
|
+
# fall back to existing play_fps
|
|
1329
|
+
pass
|
|
1330
|
+
|
|
1215
1331
|
fps = max(0.1, min(10.0, fps))
|
|
1216
1332
|
self.play_fps = fps
|
|
1217
|
-
|
|
1333
|
+
|
|
1334
|
+
if hasattr(self, "playback_timer") and self.playback_timer is not None:
|
|
1335
|
+
self.playback_timer.setInterval(int(round(1000.0 / fps))) # 0.1 fps -> 10000 ms
|
|
1336
|
+
|
|
1218
1337
|
|
|
1219
1338
|
def _on_current_item_changed_safe(self, current, previous):
|
|
1220
1339
|
if not current:
|
|
@@ -1239,12 +1358,41 @@ class BlinkTab(QWidget):
|
|
|
1239
1358
|
if item is not None:
|
|
1240
1359
|
self.fileTree.scrollToItem(item, QAbstractItemView.ScrollHint.EnsureVisible)
|
|
1241
1360
|
|
|
1242
|
-
def
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1361
|
+
def _leaf_path(self, item: QTreeWidgetItem) -> str | None:
|
|
1362
|
+
"""Return full path for a leaf item, preferring UserRole; fallback to basename match."""
|
|
1363
|
+
if not item or item.childCount() > 0:
|
|
1364
|
+
return None
|
|
1365
|
+
|
|
1366
|
+
p = item.data(0, Qt.ItemDataRole.UserRole)
|
|
1367
|
+
if p and isinstance(p, str):
|
|
1368
|
+
return p
|
|
1369
|
+
|
|
1370
|
+
# fallback: basename match (legacy items)
|
|
1371
|
+
name = item.text(0).lstrip("⚠️ ").strip()
|
|
1372
|
+
if not name:
|
|
1373
|
+
return None
|
|
1374
|
+
return next((x for x in self.image_paths if os.path.basename(x) == name), None)
|
|
1375
|
+
|
|
1376
|
+
|
|
1377
|
+
def _leaf_index(self, item: QTreeWidgetItem) -> int | None:
|
|
1378
|
+
"""Return index into image_paths/loaded_images for a leaf item."""
|
|
1379
|
+
p = self._leaf_path(item)
|
|
1380
|
+
if not p:
|
|
1381
|
+
return None
|
|
1382
|
+
try:
|
|
1383
|
+
return self.image_paths.index(p)
|
|
1384
|
+
except ValueError:
|
|
1385
|
+
return None
|
|
1386
|
+
|
|
1387
|
+
|
|
1388
|
+
def _set_leaf_display(self, item: QTreeWidgetItem, *, base_name: str, flagged: bool, full_path: str):
|
|
1389
|
+
"""Update a leaf item's text + UserRole consistently."""
|
|
1390
|
+
disp = base_name
|
|
1391
|
+
if flagged:
|
|
1392
|
+
disp = f"⚠️ {disp}"
|
|
1393
|
+
item.setText(0, disp)
|
|
1394
|
+
item.setData(0, Qt.ItemDataRole.UserRole, full_path)
|
|
1395
|
+
|
|
1248
1396
|
|
|
1249
1397
|
def clearFlags(self):
|
|
1250
1398
|
"""Clear all flagged states, update tree icons & metrics."""
|
|
@@ -1507,31 +1655,23 @@ class BlinkTab(QWidget):
|
|
|
1507
1655
|
|
|
1508
1656
|
|
|
1509
1657
|
def _after_list_changed(self, removed_indices: List[int] | None = None):
|
|
1510
|
-
"""Call after you mutate image_paths/loaded_images. Keeps UI + metrics in sync w/o recompute."""
|
|
1511
|
-
# 1) rebuild the tree (groups collapse if empty)
|
|
1512
1658
|
self._rebuild_tree_from_loaded()
|
|
1513
1659
|
self.imagesChanged.emit(len(self.loaded_images))
|
|
1514
1660
|
|
|
1515
|
-
# 2) refresh metrics (if open) WITHOUT recomputing SEP
|
|
1516
1661
|
if self.metrics_window and self.metrics_window.isVisible():
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
self.metrics_window.remove_indices(list(removed_indices))
|
|
1521
|
-
else:
|
|
1522
|
-
# just order changed or paths changed -> replot current group
|
|
1523
|
-
self.metrics_window.update_metrics(
|
|
1524
|
-
self.loaded_images,
|
|
1525
|
-
order=self._tree_order_indices()
|
|
1526
|
-
)
|
|
1662
|
+
# ✅ safest: rebind images + rebuild plot order from tree
|
|
1663
|
+
self.metrics_window.set_images(self.loaded_images, order=self._tree_order_indices())
|
|
1664
|
+
self._sync_metrics_flags()
|
|
1527
1665
|
|
|
1528
1666
|
def get_tree_item_for_index(self, idx):
|
|
1529
|
-
|
|
1667
|
+
target_path = self.image_paths[idx]
|
|
1530
1668
|
for item in self.get_all_leaf_items():
|
|
1531
|
-
|
|
1669
|
+
p = item.data(0, Qt.ItemDataRole.UserRole)
|
|
1670
|
+
if p == target_path:
|
|
1532
1671
|
return item
|
|
1533
1672
|
return None
|
|
1534
1673
|
|
|
1674
|
+
|
|
1535
1675
|
def compute_metric(self, metric_idx, entry):
|
|
1536
1676
|
"""Recompute a single metric for one image. Use cached orig_background for metric 2."""
|
|
1537
1677
|
# metric 2 is the pre-stretch background we already computed
|
|
@@ -1851,26 +1991,28 @@ class BlinkTab(QWidget):
|
|
|
1851
1991
|
|
|
1852
1992
|
|
|
1853
1993
|
def _toggle_flag_on_item(self, item: QTreeWidgetItem, *, sync_metrics: bool = True):
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
if file_path is None:
|
|
1994
|
+
idx = self._leaf_index(item)
|
|
1995
|
+
if idx is None:
|
|
1857
1996
|
return
|
|
1858
1997
|
|
|
1859
|
-
idx = self.image_paths.index(file_path)
|
|
1860
1998
|
entry = self.loaded_images[idx]
|
|
1861
|
-
entry['flagged'] = not entry
|
|
1999
|
+
entry['flagged'] = not bool(entry.get('flagged', False))
|
|
1862
2000
|
|
|
1863
2001
|
RED = Qt.GlobalColor.red
|
|
1864
|
-
|
|
1865
|
-
|
|
2002
|
+
normal_color = self.fileTree.palette().color(QPalette.ColorRole.WindowText)
|
|
2003
|
+
|
|
2004
|
+
base = os.path.basename(self.image_paths[idx])
|
|
1866
2005
|
|
|
1867
2006
|
if entry['flagged']:
|
|
1868
|
-
item.setText(0, f"⚠️ {
|
|
2007
|
+
item.setText(0, f"⚠️ {base}")
|
|
1869
2008
|
item.setForeground(0, QBrush(RED))
|
|
1870
2009
|
else:
|
|
1871
|
-
item.setText(0,
|
|
2010
|
+
item.setText(0, base)
|
|
1872
2011
|
item.setForeground(0, QBrush(normal_color))
|
|
1873
2012
|
|
|
2013
|
+
# Keep UserRole correct (in case this was a legacy leaf)
|
|
2014
|
+
item.setData(0, Qt.ItemDataRole.UserRole, self.image_paths[idx])
|
|
2015
|
+
|
|
1874
2016
|
if sync_metrics:
|
|
1875
2017
|
self._sync_metrics_flags()
|
|
1876
2018
|
|
|
@@ -2159,53 +2301,30 @@ class BlinkTab(QWidget):
|
|
|
2159
2301
|
|
|
2160
2302
|
def on_item_clicked(self, item, column):
|
|
2161
2303
|
self.fileTree.setFocus()
|
|
2304
|
+
if not item or item.childCount() > 0:
|
|
2305
|
+
return
|
|
2162
2306
|
|
|
2163
|
-
|
|
2164
|
-
file_path = next((p for p in self.image_paths if os.path.basename(p) == name), None)
|
|
2307
|
+
file_path = self._leaf_path(item)
|
|
2165
2308
|
if not file_path:
|
|
2166
2309
|
return
|
|
2167
2310
|
|
|
2168
2311
|
self._capture_view_center_norm()
|
|
2169
2312
|
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
# --- Fast path: just display what we cached in RAM ---
|
|
2175
|
-
if not self.aggressive_stretch_enabled:
|
|
2176
|
-
# Convert to 8-bit only if needed (no additional stretch)
|
|
2177
|
-
if stored.dtype == np.uint8:
|
|
2178
|
-
disp8 = stored
|
|
2179
|
-
elif stored.dtype == np.uint16:
|
|
2180
|
-
disp8 = (stored >> 8).astype(np.uint8) # ~ /257, quick & vectorized
|
|
2181
|
-
else: # float32 in [0..1]
|
|
2182
|
-
disp8 = (np.clip(stored, 0.0, 1.0) * 255.0).astype(np.uint8)
|
|
2183
|
-
|
|
2184
|
-
else:
|
|
2185
|
-
# Aggressive mode: compute only here (from float01)
|
|
2186
|
-
base01 = self._as_float01(stored)
|
|
2187
|
-
# Siril-style autostretch
|
|
2188
|
-
if base01.ndim == 2:
|
|
2189
|
-
st = siril_style_autostretch(base01, sigma=self.current_sigma)
|
|
2190
|
-
disp01 = self._as_float01(st) # <-- IMPORTANT: handles 0..255 or 0..1 correctly
|
|
2191
|
-
else:
|
|
2192
|
-
base01 = self._as_float01(stored)
|
|
2193
|
-
|
|
2194
|
-
if base01.ndim == 2:
|
|
2195
|
-
disp01 = self._aggressive_display_boost(base01, strength=self.current_sigma)
|
|
2196
|
-
else:
|
|
2197
|
-
lum = base01.mean(axis=2).astype(np.float32)
|
|
2198
|
-
lum_boost = self._aggressive_display_boost(lum, strength=self.current_sigma)
|
|
2199
|
-
gain = lum_boost / (lum + 1e-6)
|
|
2200
|
-
disp01 = np.clip(base01 * gain[..., None], 0.0, 1.0)
|
|
2313
|
+
try:
|
|
2314
|
+
idx = self.image_paths.index(file_path)
|
|
2315
|
+
except ValueError:
|
|
2316
|
+
return
|
|
2201
2317
|
|
|
2202
|
-
|
|
2318
|
+
entry = self.loaded_images[idx]
|
|
2203
2319
|
|
|
2320
|
+
# ✅ single source of truth (handles aggressive + mono + color)
|
|
2321
|
+
disp8 = self._make_display_frame(entry)
|
|
2204
2322
|
|
|
2205
2323
|
qimage = self.convert_to_qimage(disp8)
|
|
2206
2324
|
self.current_pixmap = QPixmap.fromImage(qimage)
|
|
2207
2325
|
self.apply_zoom()
|
|
2208
2326
|
|
|
2327
|
+
|
|
2209
2328
|
def _capture_view_center_norm(self):
|
|
2210
2329
|
"""Remember the current viewport center as a fraction of the content size."""
|
|
2211
2330
|
sa = self.scroll_area
|
|
@@ -2370,44 +2489,94 @@ class BlinkTab(QWidget):
|
|
|
2370
2489
|
menu.exec(self.fileTree.mapToGlobal(pos))
|
|
2371
2490
|
|
|
2372
2491
|
|
|
2373
|
-
def push_to_docs(self, item):
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2492
|
+
def push_to_docs(self, item: QTreeWidgetItem):
|
|
2493
|
+
"""
|
|
2494
|
+
Push the currently selected blink leaf image into DocManager as a new document,
|
|
2495
|
+
preserving all original metadata (original_header, meta, bit_depth, is_mono, etc.)
|
|
2496
|
+
and swapping ONLY the numpy image array.
|
|
2497
|
+
"""
|
|
2498
|
+
if not item or item.childCount() > 0:
|
|
2499
|
+
return
|
|
2500
|
+
|
|
2501
|
+
# --- Resolve full path safely (UserRole-first) ---
|
|
2502
|
+
file_path = item.data(0, Qt.ItemDataRole.UserRole)
|
|
2503
|
+
if not file_path or not isinstance(file_path, str):
|
|
2504
|
+
# legacy fallback: try to map by displayed name
|
|
2505
|
+
file_name = item.text(0).lstrip("⚠️ ").strip()
|
|
2506
|
+
file_path = next((p for p in self.image_paths if os.path.basename(p) == file_name), None)
|
|
2507
|
+
|
|
2377
2508
|
if not file_path:
|
|
2378
2509
|
return
|
|
2379
|
-
|
|
2510
|
+
|
|
2511
|
+
try:
|
|
2512
|
+
idx = self.image_paths.index(file_path)
|
|
2513
|
+
except ValueError:
|
|
2514
|
+
return
|
|
2515
|
+
|
|
2380
2516
|
entry = self.loaded_images[idx]
|
|
2381
2517
|
|
|
2382
|
-
# Find main window + doc manager
|
|
2518
|
+
# --- Find main window + doc manager ---
|
|
2383
2519
|
mw = self._main_window()
|
|
2384
2520
|
dm = self.doc_manager or (getattr(mw, "docman", None) if mw else None)
|
|
2385
2521
|
if not mw or not dm:
|
|
2386
2522
|
QMessageBox.warning(self, self.tr("Document Manager"), self.tr("Main window or DocManager not available."))
|
|
2387
2523
|
return
|
|
2388
2524
|
|
|
2389
|
-
#
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2525
|
+
# --- Build the swapped payload (image replaced, metadata preserved) ---
|
|
2526
|
+
# Whatever you're storing as entry['image_data'] (uint16/float/etc), normalize to float01 for display pipeline.
|
|
2527
|
+
# If your DocManager expects native dtype instead, swap _as_float01 for your native image.
|
|
2528
|
+
np_image_f01 = self._as_float01(entry["image_data"]).astype(np.float32, copy=False)
|
|
2529
|
+
|
|
2530
|
+
# Preserve your full load_image return structure as much as possible:
|
|
2531
|
+
# load_image returns: image, original_header, bit_depth, is_mono, meta
|
|
2532
|
+
original_header = entry.get("original_header", entry.get("header", None))
|
|
2533
|
+
bit_depth = entry.get("bit_depth", None)
|
|
2534
|
+
is_mono = entry.get("is_mono", None)
|
|
2535
|
+
meta = entry.get("meta", {})
|
|
2536
|
+
|
|
2537
|
+
# Keep meta dict style your app uses; add source tag without clobbering
|
|
2538
|
+
if isinstance(meta, dict):
|
|
2539
|
+
meta = dict(meta)
|
|
2540
|
+
meta.setdefault("source", "BlinkComparatorPro")
|
|
2541
|
+
meta.setdefault("file_path", file_path)
|
|
2542
|
+
|
|
2543
|
+
# This is the "all the other stuff" you wanted preserved
|
|
2544
|
+
payload = {
|
|
2545
|
+
"file_path": file_path,
|
|
2546
|
+
"original_header": original_header,
|
|
2547
|
+
"bit_depth": bit_depth,
|
|
2548
|
+
"is_mono": is_mono,
|
|
2549
|
+
"meta": meta,
|
|
2550
|
+
"source": "BlinkComparatorPro",
|
|
2397
2551
|
}
|
|
2552
|
+
|
|
2398
2553
|
title = os.path.basename(file_path)
|
|
2399
2554
|
|
|
2400
|
-
# Create
|
|
2555
|
+
# --- Create document using whatever DocManager API exists ---
|
|
2401
2556
|
doc = None
|
|
2402
2557
|
try:
|
|
2403
|
-
if
|
|
2404
|
-
|
|
2558
|
+
# Preferred: if you have a method that mirrors open_file/load_image shape
|
|
2559
|
+
if hasattr(dm, "open_from_load_image"):
|
|
2560
|
+
# (image, original_header, bit_depth, is_mono, meta)
|
|
2561
|
+
doc = dm.open_from_load_image(np_image_f01, original_header, bit_depth, is_mono, meta, title=title)
|
|
2562
|
+
|
|
2563
|
+
elif hasattr(dm, "open_array"):
|
|
2564
|
+
# Some of your code expects metadata in doc.metadata; pass payload whole
|
|
2565
|
+
doc = dm.open_array(np_image_f01, metadata=payload, title=title)
|
|
2566
|
+
|
|
2405
2567
|
elif hasattr(dm, "open_numpy"):
|
|
2406
|
-
doc = dm.open_numpy(np_image_f01, metadata=
|
|
2568
|
+
doc = dm.open_numpy(np_image_f01, metadata=payload, title=title)
|
|
2569
|
+
|
|
2407
2570
|
elif hasattr(dm, "create_document"):
|
|
2408
|
-
|
|
2571
|
+
# Try both signatures
|
|
2572
|
+
try:
|
|
2573
|
+
doc = dm.create_document(image=np_image_f01, metadata=payload, name=title)
|
|
2574
|
+
except TypeError:
|
|
2575
|
+
doc = dm.create_document(np_image_f01, payload, title)
|
|
2576
|
+
|
|
2409
2577
|
else:
|
|
2410
|
-
raise AttributeError(
|
|
2578
|
+
raise AttributeError("DocManager lacks a known creation method")
|
|
2579
|
+
|
|
2411
2580
|
except Exception as e:
|
|
2412
2581
|
QMessageBox.critical(self, self.tr("Doc Manager"), self.tr("Failed to create document:\n{0}").format(e))
|
|
2413
2582
|
return
|
|
@@ -2416,42 +2585,85 @@ class BlinkTab(QWidget):
|
|
|
2416
2585
|
QMessageBox.critical(self, self.tr("Doc Manager"), self.tr("DocManager returned no document."))
|
|
2417
2586
|
return
|
|
2418
2587
|
|
|
2419
|
-
#
|
|
2588
|
+
# --- Hand off to DocManager flow (DocManager should trigger MDI + window creation) ---
|
|
2420
2589
|
try:
|
|
2421
|
-
|
|
2590
|
+
# If your architecture already auto-spawns windows on documentAdded,
|
|
2591
|
+
# you should NOT call mw._spawn_subwindow_for(doc) here.
|
|
2592
|
+
if hasattr(dm, "add_document"):
|
|
2593
|
+
dm.add_document(doc)
|
|
2594
|
+
elif hasattr(dm, "register_document"):
|
|
2595
|
+
dm.register_document(doc)
|
|
2596
|
+
else:
|
|
2597
|
+
# If open_array/open_numpy already registers the doc internally, do nothing.
|
|
2598
|
+
pass
|
|
2599
|
+
|
|
2600
|
+
# If you *must* spawn manually (older path), keep as fallback
|
|
2601
|
+
if hasattr(mw, "_spawn_subwindow_for"):
|
|
2602
|
+
mw._spawn_subwindow_for(doc)
|
|
2603
|
+
|
|
2422
2604
|
if hasattr(mw, "_log"):
|
|
2423
2605
|
mw._log(f"Blink → opened '{title}' as new document")
|
|
2606
|
+
|
|
2424
2607
|
except Exception as e:
|
|
2425
2608
|
QMessageBox.critical(self, self.tr("UI"), self.tr("Failed to open subwindow:\n{0}").format(e))
|
|
2426
2609
|
|
|
2427
2610
|
|
|
2611
|
+
|
|
2428
2612
|
# optional shim to keep any old calls working
|
|
2429
2613
|
def push_image_to_manager(self, item):
|
|
2430
2614
|
self.push_to_docs(item)
|
|
2431
2615
|
|
|
2432
2616
|
|
|
2433
2617
|
|
|
2434
|
-
def rename_item(self, item):
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
new_name, ok = QInputDialog.getText(self, self.tr("Rename Image"), self.tr("Enter new name:"), text=current_name)
|
|
2618
|
+
def rename_item(self, item: QTreeWidgetItem):
|
|
2619
|
+
if not item or item.childCount() > 0:
|
|
2620
|
+
return
|
|
2438
2621
|
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2622
|
+
idx = self._leaf_index(item)
|
|
2623
|
+
if idx is None:
|
|
2624
|
+
return
|
|
2625
|
+
|
|
2626
|
+
old_path = self.image_paths[idx]
|
|
2627
|
+
old_base = os.path.basename(old_path)
|
|
2628
|
+
|
|
2629
|
+
new_name, ok = QInputDialog.getText(
|
|
2630
|
+
self,
|
|
2631
|
+
self.tr("Rename Image"),
|
|
2632
|
+
self.tr("Enter new name:"),
|
|
2633
|
+
text=old_base
|
|
2634
|
+
)
|
|
2635
|
+
if not ok:
|
|
2636
|
+
return
|
|
2637
|
+
|
|
2638
|
+
new_name = (new_name or "").strip()
|
|
2639
|
+
if not new_name:
|
|
2640
|
+
return
|
|
2641
|
+
|
|
2642
|
+
new_path = os.path.join(os.path.dirname(old_path), new_name)
|
|
2643
|
+
|
|
2644
|
+
# Avoid overwrite
|
|
2645
|
+
if os.path.exists(new_path):
|
|
2646
|
+
QMessageBox.critical(self, self.tr("Error"), self.tr("A file with that name already exists."))
|
|
2647
|
+
return
|
|
2648
|
+
|
|
2649
|
+
try:
|
|
2650
|
+
os.rename(old_path, new_path)
|
|
2651
|
+
except Exception as e:
|
|
2652
|
+
QMessageBox.critical(self, self.tr("Error"), self.tr("Failed to rename the file: {0}").format(e))
|
|
2653
|
+
return
|
|
2654
|
+
|
|
2655
|
+
# Update internal structures
|
|
2656
|
+
self.image_paths[idx] = new_path
|
|
2657
|
+
self.loaded_images[idx]['file_path'] = new_path
|
|
2658
|
+
|
|
2659
|
+
# Update the leaf item
|
|
2660
|
+
flagged = bool(self.loaded_images[idx].get("flagged", False))
|
|
2661
|
+
self._set_leaf_display(item, base_name=new_name, flagged=flagged, full_path=new_path)
|
|
2662
|
+
|
|
2663
|
+
# Rebuild so natural sort stays correct and groups update
|
|
2664
|
+
self._after_list_changed()
|
|
2665
|
+
self._sync_metrics_flags()
|
|
2444
2666
|
|
|
2445
|
-
try:
|
|
2446
|
-
# Rename the file
|
|
2447
|
-
os.rename(file_path, new_file_path)
|
|
2448
|
-
print(f"File renamed from {current_name} to {new_name}")
|
|
2449
|
-
|
|
2450
|
-
# Update the image paths and tree view
|
|
2451
|
-
self.image_paths[self.image_paths.index(file_path)] = new_file_path
|
|
2452
|
-
item.setText(0, new_name)
|
|
2453
|
-
except Exception as e:
|
|
2454
|
-
QMessageBox.critical(self, self.tr("Error"), self.tr("Failed to rename the file: {0}").format(e))
|
|
2455
2667
|
|
|
2456
2668
|
def rename_flagged_images(self):
|
|
2457
2669
|
"""Prefix all *flagged* images on disk and in the tree."""
|
|
@@ -2562,79 +2774,102 @@ class BlinkTab(QWidget):
|
|
|
2562
2774
|
|
|
2563
2775
|
|
|
2564
2776
|
def batch_rename_items(self):
|
|
2565
|
-
"""Batch rename selected items by adding a prefix or suffix."""
|
|
2566
|
-
selected_items = self.fileTree.selectedItems()
|
|
2567
|
-
|
|
2777
|
+
"""Batch rename selected leaf items by adding a prefix and/or suffix."""
|
|
2778
|
+
selected_items = [it for it in self.fileTree.selectedItems() if it and it.childCount() == 0]
|
|
2568
2779
|
if not selected_items:
|
|
2569
|
-
QMessageBox.warning(self, self.tr("Warning"), self.tr("No items selected for renaming."))
|
|
2780
|
+
QMessageBox.warning(self, self.tr("Warning"), self.tr("No individual image items selected for renaming."))
|
|
2570
2781
|
return
|
|
2571
2782
|
|
|
2572
|
-
# Create a custom dialog for entering the prefix and suffix
|
|
2573
2783
|
dialog = QDialog(self)
|
|
2574
2784
|
dialog.setWindowTitle(self.tr("Batch Rename"))
|
|
2575
2785
|
dialog_layout = QVBoxLayout(dialog)
|
|
2576
2786
|
|
|
2577
|
-
|
|
2578
|
-
dialog_layout.addWidget(instruction_label)
|
|
2787
|
+
dialog_layout.addWidget(QLabel(self.tr("Enter a prefix or suffix to rename selected files:"), dialog))
|
|
2579
2788
|
|
|
2580
|
-
# Create fields for prefix and suffix
|
|
2581
2789
|
form_layout = QHBoxLayout()
|
|
2582
|
-
|
|
2583
2790
|
prefix_field = QLineEdit(dialog)
|
|
2584
2791
|
prefix_field.setPlaceholderText(self.tr("Prefix"))
|
|
2585
2792
|
form_layout.addWidget(prefix_field)
|
|
2586
2793
|
|
|
2587
|
-
|
|
2588
|
-
|
|
2794
|
+
mid_label = QLabel(self.tr("filename"), dialog)
|
|
2795
|
+
mid_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
2796
|
+
form_layout.addWidget(mid_label)
|
|
2589
2797
|
|
|
2590
2798
|
suffix_field = QLineEdit(dialog)
|
|
2591
2799
|
suffix_field.setPlaceholderText(self.tr("Suffix"))
|
|
2592
2800
|
form_layout.addWidget(suffix_field)
|
|
2593
|
-
|
|
2594
2801
|
dialog_layout.addLayout(form_layout)
|
|
2595
2802
|
|
|
2596
|
-
|
|
2597
|
-
button_layout = QHBoxLayout()
|
|
2803
|
+
btns = QHBoxLayout()
|
|
2598
2804
|
ok_button = QPushButton(self.tr("OK"), dialog)
|
|
2599
|
-
ok_button.clicked.connect(dialog.accept)
|
|
2600
|
-
button_layout.addWidget(ok_button)
|
|
2601
|
-
|
|
2602
2805
|
cancel_button = QPushButton(self.tr("Cancel"), dialog)
|
|
2806
|
+
ok_button.clicked.connect(dialog.accept)
|
|
2603
2807
|
cancel_button.clicked.connect(dialog.reject)
|
|
2604
|
-
|
|
2808
|
+
btns.addWidget(ok_button)
|
|
2809
|
+
btns.addWidget(cancel_button)
|
|
2810
|
+
dialog_layout.addLayout(btns)
|
|
2605
2811
|
|
|
2606
|
-
|
|
2812
|
+
if dialog.exec() != QDialog.DialogCode.Accepted:
|
|
2813
|
+
return
|
|
2607
2814
|
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
prefix = prefix_field.text().strip()
|
|
2611
|
-
suffix = suffix_field.text().strip()
|
|
2815
|
+
prefix = (prefix_field.text() or "").strip()
|
|
2816
|
+
suffix = (suffix_field.text() or "").strip()
|
|
2612
2817
|
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
file_path = next((path for path in self.image_paths if os.path.basename(path) == current_name), None)
|
|
2818
|
+
if not prefix and not suffix:
|
|
2819
|
+
QMessageBox.information(self, self.tr("Batch Rename"), self.tr("No prefix or suffix entered. Nothing to do."))
|
|
2820
|
+
return
|
|
2617
2821
|
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
directory = os.path.dirname(file_path)
|
|
2621
|
-
new_name = f"{prefix}{current_name}{suffix}"
|
|
2622
|
-
new_file_path = os.path.join(directory, new_name)
|
|
2822
|
+
renamed = 0
|
|
2823
|
+
failures = []
|
|
2623
2824
|
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2825
|
+
# Work on indices so we can update lists safely
|
|
2826
|
+
indices = []
|
|
2827
|
+
for it in selected_items:
|
|
2828
|
+
idx = self._leaf_index(it)
|
|
2829
|
+
if idx is not None:
|
|
2830
|
+
indices.append((idx, it))
|
|
2628
2831
|
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2832
|
+
for idx, it in indices:
|
|
2833
|
+
old_path = self.image_paths[idx]
|
|
2834
|
+
directory, base = os.path.split(old_path)
|
|
2632
2835
|
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2836
|
+
new_base = f"{prefix}{base}{suffix}"
|
|
2837
|
+
new_path = os.path.join(directory, new_base)
|
|
2838
|
+
|
|
2839
|
+
if new_path == old_path:
|
|
2840
|
+
continue
|
|
2841
|
+
|
|
2842
|
+
if os.path.exists(new_path):
|
|
2843
|
+
failures.append((old_path, self.tr("target already exists")))
|
|
2844
|
+
continue
|
|
2845
|
+
|
|
2846
|
+
try:
|
|
2847
|
+
os.rename(old_path, new_path)
|
|
2848
|
+
except Exception as e:
|
|
2849
|
+
failures.append((old_path, str(e)))
|
|
2850
|
+
continue
|
|
2851
|
+
|
|
2852
|
+
# Update internal lists
|
|
2853
|
+
self.image_paths[idx] = new_path
|
|
2854
|
+
self.loaded_images[idx]["file_path"] = new_path
|
|
2855
|
+
|
|
2856
|
+
# Update leaf item
|
|
2857
|
+
flagged = bool(self.loaded_images[idx].get("flagged", False))
|
|
2858
|
+
self._set_leaf_display(it, base_name=new_base, flagged=flagged, full_path=new_path)
|
|
2859
|
+
|
|
2860
|
+
renamed += 1
|
|
2861
|
+
|
|
2862
|
+
# Rebuild so group headers + natural order stay correct
|
|
2863
|
+
self._after_list_changed()
|
|
2864
|
+
self._sync_metrics_flags()
|
|
2865
|
+
|
|
2866
|
+
msg = self.tr("Batch renamed {0} file{1}.").format(renamed, "s" if renamed != 1 else "")
|
|
2867
|
+
if failures:
|
|
2868
|
+
msg += self.tr("\n\n{0} file(s) failed:").format(len(failures))
|
|
2869
|
+
for old, err in failures[:10]:
|
|
2870
|
+
msg += f"\n• {os.path.basename(old)} – {err}"
|
|
2871
|
+
QMessageBox.information(self, self.tr("Batch Rename"), msg)
|
|
2636
2872
|
|
|
2637
|
-
print(f"Batch renamed {len(selected_items)} items.")
|
|
2638
2873
|
|
|
2639
2874
|
def batch_delete_flagged_images(self):
|
|
2640
2875
|
"""Delete all flagged images."""
|
|
@@ -2681,141 +2916,181 @@ class BlinkTab(QWidget):
|
|
|
2681
2916
|
self._after_list_changed(removed_indices)
|
|
2682
2917
|
|
|
2683
2918
|
def batch_move_flagged_images(self):
|
|
2684
|
-
"""Move all flagged images to a selected directory."""
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
if not flagged_images:
|
|
2919
|
+
"""Move all flagged images to a selected directory AND remove them from the blink list."""
|
|
2920
|
+
flagged_indices = [i for i, e in enumerate(self.loaded_images) if e.get("flagged", False)]
|
|
2921
|
+
if not flagged_indices:
|
|
2688
2922
|
QMessageBox.information(self, self.tr("No Flagged Images"), self.tr("There are no flagged images to move."))
|
|
2689
2923
|
return
|
|
2690
2924
|
|
|
2691
|
-
# Select destination directory
|
|
2692
2925
|
destination_dir = QFileDialog.getExistingDirectory(self, self.tr("Select Destination Folder"), "")
|
|
2693
2926
|
if not destination_dir:
|
|
2694
|
-
return
|
|
2927
|
+
return
|
|
2695
2928
|
|
|
2696
|
-
|
|
2697
|
-
src_path = img['file_path']
|
|
2698
|
-
file_name = os.path.basename(src_path)
|
|
2699
|
-
dest_path = os.path.join(destination_dir, file_name)
|
|
2929
|
+
failures = []
|
|
2700
2930
|
|
|
2931
|
+
# Move first (use current paths from indices)
|
|
2932
|
+
for i in flagged_indices:
|
|
2933
|
+
src_path = self.image_paths[i]
|
|
2934
|
+
dest_path = os.path.join(destination_dir, os.path.basename(src_path))
|
|
2701
2935
|
try:
|
|
2702
2936
|
os.rename(src_path, dest_path)
|
|
2703
|
-
print(f"Moved flagged image from {src_path} to {dest_path}")
|
|
2704
2937
|
except Exception as e:
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2938
|
+
failures.append((src_path, str(e)))
|
|
2939
|
+
|
|
2940
|
+
# Remove from lists ONLY if move succeeded
|
|
2941
|
+
# Build a set of indices to remove: those that did NOT fail
|
|
2942
|
+
failed_src = {p for p, _ in failures}
|
|
2943
|
+
removed_indices = [i for i in flagged_indices if self.image_paths[i] not in failed_src]
|
|
2708
2944
|
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
self.image_paths
|
|
2712
|
-
|
|
2713
|
-
|
|
2945
|
+
removed_indices = sorted(set(removed_indices), reverse=True)
|
|
2946
|
+
for idx in removed_indices:
|
|
2947
|
+
if 0 <= idx < len(self.image_paths):
|
|
2948
|
+
del self.image_paths[idx]
|
|
2949
|
+
if 0 <= idx < len(self.loaded_images):
|
|
2950
|
+
del self.loaded_images[idx]
|
|
2714
2951
|
|
|
2715
|
-
|
|
2716
|
-
self.
|
|
2717
|
-
|
|
2952
|
+
if removed_indices:
|
|
2953
|
+
self._after_list_changed(removed_indices)
|
|
2954
|
+
|
|
2955
|
+
if failures:
|
|
2956
|
+
msg = self.tr("Moved {0} flagged file(s). {1} failed:").format(len(removed_indices), len(failures))
|
|
2957
|
+
for p, err in failures[:10]:
|
|
2958
|
+
msg += f"\n• {os.path.basename(p)} – {err}"
|
|
2959
|
+
QMessageBox.warning(self, self.tr("Batch Move"), msg)
|
|
2960
|
+
else:
|
|
2961
|
+
QMessageBox.information(self, self.tr("Batch Move"), self.tr("Moved and removed {0} flagged image(s).").format(len(removed_indices)))
|
|
2718
2962
|
|
|
2719
|
-
QMessageBox.information(self, self.tr("Batch Move"), self.tr("Moved {0} flagged images.").format(len(flagged_images)))
|
|
2720
|
-
self._after_list_changed(removed_indices=None)
|
|
2721
2963
|
|
|
2722
2964
|
def move_items(self):
|
|
2723
|
-
"""Move selected images
|
|
2724
|
-
selected_items = self.fileTree.selectedItems()
|
|
2965
|
+
"""Move selected leaf images to a selected directory AND remove them from the blink list."""
|
|
2966
|
+
selected_items = [it for it in self.fileTree.selectedItems() if it and it.childCount() == 0]
|
|
2725
2967
|
if not selected_items:
|
|
2726
|
-
QMessageBox.warning(self, self.tr("Warning"), self.tr("No items selected for moving."))
|
|
2968
|
+
QMessageBox.warning(self, self.tr("Warning"), self.tr("No individual image items selected for moving."))
|
|
2727
2969
|
return
|
|
2728
2970
|
|
|
2729
|
-
|
|
2730
|
-
new_dir = QFileDialog.getExistingDirectory(self,
|
|
2731
|
-
self.tr("Select Destination Folder"),
|
|
2732
|
-
"")
|
|
2971
|
+
new_dir = QFileDialog.getExistingDirectory(self, self.tr("Select Destination Folder"), "")
|
|
2733
2972
|
if not new_dir:
|
|
2734
2973
|
return
|
|
2735
2974
|
|
|
2736
|
-
# Keep track of which on‐disk paths we actually moved
|
|
2737
|
-
moved_old_paths = []
|
|
2738
2975
|
removed_indices = []
|
|
2976
|
+
failures = []
|
|
2739
2977
|
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
if not
|
|
2978
|
+
# Collect (idx, old_path, item) first to avoid index drift
|
|
2979
|
+
triplets = []
|
|
2980
|
+
for it in selected_items:
|
|
2981
|
+
p = self._leaf_path(it)
|
|
2982
|
+
if not p:
|
|
2983
|
+
continue
|
|
2984
|
+
try:
|
|
2985
|
+
idx = self.image_paths.index(p)
|
|
2986
|
+
except ValueError:
|
|
2745
2987
|
continue
|
|
2746
|
-
|
|
2988
|
+
triplets.append((idx, p, it))
|
|
2747
2989
|
|
|
2748
|
-
|
|
2990
|
+
for idx, old_path, it in triplets:
|
|
2991
|
+
base = os.path.basename(old_path)
|
|
2992
|
+
new_path = os.path.join(new_dir, base)
|
|
2749
2993
|
try:
|
|
2750
2994
|
os.rename(old_path, new_path)
|
|
2751
2995
|
except Exception as e:
|
|
2752
|
-
|
|
2996
|
+
failures.append((old_path, str(e)))
|
|
2753
2997
|
continue
|
|
2754
2998
|
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
# 1) Remove the leaf from the tree
|
|
2758
|
-
parent = item.parent() or self.fileTree.invisibleRootItem()
|
|
2759
|
-
parent.removeChild(item)
|
|
2999
|
+
removed_indices.append(idx)
|
|
2760
3000
|
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
del self.loaded_images[idx]
|
|
3001
|
+
# remove leaf from tree immediately (optional; _after_list_changed will rebuild anyway)
|
|
3002
|
+
#parent = it.parent() or self.fileTree.invisibleRootItem()
|
|
3003
|
+
#parent.removeChild(it)
|
|
2765
3004
|
|
|
2766
|
-
|
|
2767
|
-
|
|
3005
|
+
# Purge arrays descending
|
|
3006
|
+
removed_indices = sorted(set(removed_indices), reverse=True)
|
|
3007
|
+
for idx in removed_indices:
|
|
3008
|
+
if 0 <= idx < len(self.image_paths):
|
|
3009
|
+
del self.image_paths[idx]
|
|
3010
|
+
if 0 <= idx < len(self.loaded_images):
|
|
3011
|
+
del self.loaded_images[idx]
|
|
2768
3012
|
|
|
3013
|
+
if removed_indices:
|
|
3014
|
+
self._after_list_changed(removed_indices)
|
|
2769
3015
|
|
|
3016
|
+
if failures:
|
|
3017
|
+
msg = self.tr("Moved {0} file(s). {1} failed:").format(len(removed_indices), len(failures))
|
|
3018
|
+
for old, err in failures[:10]:
|
|
3019
|
+
msg += f"\n• {os.path.basename(old)} – {err}"
|
|
3020
|
+
QMessageBox.warning(self, self.tr("Move Selected Items"), msg)
|
|
3021
|
+
else:
|
|
3022
|
+
QMessageBox.information(self, self.tr("Move Selected Items"), self.tr("Moved and removed {0} item(s).").format(len(removed_indices)))
|
|
2770
3023
|
|
|
2771
3024
|
def delete_items(self):
|
|
2772
|
-
"""Delete
|
|
2773
|
-
selected_items = self.fileTree.selectedItems()
|
|
2774
|
-
|
|
3025
|
+
"""Delete selected leaf images from disk and remove them from the blink list."""
|
|
3026
|
+
selected_items = [it for it in self.fileTree.selectedItems() if it and it.childCount() == 0]
|
|
2775
3027
|
if not selected_items:
|
|
2776
|
-
QMessageBox.warning(self, self.tr("Warning"), self.tr("No items selected for deletion."))
|
|
3028
|
+
QMessageBox.warning(self, self.tr("Warning"), self.tr("No individual image items selected for deletion."))
|
|
2777
3029
|
return
|
|
2778
3030
|
|
|
2779
|
-
# Confirmation dialog
|
|
2780
3031
|
reply = QMessageBox.question(
|
|
2781
3032
|
self,
|
|
2782
|
-
self.tr(
|
|
3033
|
+
self.tr("Confirm Deletion"),
|
|
2783
3034
|
self.tr("Are you sure you want to permanently delete {0} selected images? This action is irreversible.").format(len(selected_items)),
|
|
2784
3035
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
|
2785
3036
|
QMessageBox.StandardButton.No
|
|
2786
3037
|
)
|
|
3038
|
+
if reply != QMessageBox.StandardButton.Yes:
|
|
3039
|
+
return
|
|
2787
3040
|
|
|
2788
3041
|
removed_indices = []
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
|
|
2795
|
-
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
|
|
3042
|
+
failures = []
|
|
3043
|
+
|
|
3044
|
+
# Snapshot first
|
|
3045
|
+
triplets = []
|
|
3046
|
+
for it in selected_items:
|
|
3047
|
+
p = self._leaf_path(it)
|
|
3048
|
+
if not p:
|
|
3049
|
+
continue
|
|
3050
|
+
try:
|
|
3051
|
+
idx = self.image_paths.index(p)
|
|
3052
|
+
except ValueError:
|
|
3053
|
+
continue
|
|
3054
|
+
triplets.append((idx, p, it))
|
|
3055
|
+
|
|
3056
|
+
for idx, path, it in triplets:
|
|
3057
|
+
try:
|
|
3058
|
+
os.remove(path)
|
|
3059
|
+
except Exception as e:
|
|
3060
|
+
failures.append((path, str(e)))
|
|
3061
|
+
continue
|
|
3062
|
+
|
|
3063
|
+
removed_indices.append(idx)
|
|
3064
|
+
|
|
3065
|
+
# remove from tree immediately (optional)
|
|
3066
|
+
parent = it.parent() or self.fileTree.invisibleRootItem()
|
|
3067
|
+
parent.removeChild(it)
|
|
3068
|
+
|
|
3069
|
+
# Purge arrays descending
|
|
3070
|
+
removed_indices = sorted(set(removed_indices), reverse=True)
|
|
3071
|
+
for idx in removed_indices:
|
|
3072
|
+
if 0 <= idx < len(self.image_paths):
|
|
2808
3073
|
del self.image_paths[idx]
|
|
3074
|
+
if 0 <= idx < len(self.loaded_images):
|
|
2809
3075
|
del self.loaded_images[idx]
|
|
2810
3076
|
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
3077
|
+
# Clear preview safely
|
|
3078
|
+
self.preview_label.clear()
|
|
3079
|
+
self.preview_label.setText(self.tr("No image selected."))
|
|
3080
|
+
self.current_pixmap = None
|
|
2815
3081
|
|
|
2816
|
-
|
|
3082
|
+
if removed_indices:
|
|
2817
3083
|
self._after_list_changed(removed_indices)
|
|
2818
3084
|
|
|
3085
|
+
if failures:
|
|
3086
|
+
msg = self.tr("Deleted {0} file(s). {1} failed:").format(len(removed_indices), len(failures))
|
|
3087
|
+
for p, err in failures[:10]:
|
|
3088
|
+
msg += f"\n• {os.path.basename(p)} – {err}"
|
|
3089
|
+
QMessageBox.warning(self, self.tr("Delete Selected Items"), msg)
|
|
3090
|
+
else:
|
|
3091
|
+
QMessageBox.information(self, self.tr("Delete Selected Items"), self.tr("Deleted {0} item(s).").format(len(removed_indices)))
|
|
3092
|
+
|
|
3093
|
+
|
|
2819
3094
|
def eventFilter(self, source, event):
|
|
2820
3095
|
"""Handle mouse events for dragging."""
|
|
2821
3096
|
if source == self.scroll_area.viewport():
|
|
@@ -2878,16 +3153,14 @@ class BlinkTab(QWidget):
|
|
|
2878
3153
|
self.on_item_clicked(cur, 0)
|
|
2879
3154
|
|
|
2880
3155
|
def convert_to_qimage(self, img_array):
|
|
2881
|
-
"""Convert numpy image array to QImage."""
|
|
2882
|
-
# 1) Bring everything into a uint8 (0–255) array
|
|
2883
3156
|
if img_array.dtype == np.uint8:
|
|
2884
3157
|
arr8 = img_array
|
|
2885
3158
|
elif img_array.dtype == np.uint16:
|
|
2886
|
-
# downscale 16-bit → 8-bit
|
|
2887
3159
|
arr8 = (img_array.astype(np.float32) / 65535.0 * 255.0).clip(0,255).astype(np.uint8)
|
|
2888
3160
|
else:
|
|
2889
|
-
#
|
|
2890
|
-
|
|
3161
|
+
# ✅ display-only normalize floats outside 0..1
|
|
3162
|
+
f01 = self._ensure_float01(img_array)
|
|
3163
|
+
arr8 = (f01 * 255.0).astype(np.uint8)
|
|
2891
3164
|
|
|
2892
3165
|
h, w = arr8.shape[:2]
|
|
2893
3166
|
buffer = arr8.tobytes()
|