setiastrosuitepro 1.6.2__py3-none-any.whl → 1.6.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of setiastrosuitepro might be problematic. Click here for more details.
- setiastro/images/rotatearbitrary.png +0 -0
- setiastro/saspro/_generated/build_info.py +2 -2
- setiastro/saspro/backgroundneutral.py +10 -1
- setiastro/saspro/blink_comparator_pro.py +474 -251
- setiastro/saspro/crop_dialog_pro.py +11 -1
- setiastro/saspro/doc_manager.py +1 -1
- setiastro/saspro/function_bundle.py +16 -16
- setiastro/saspro/gui/main_window.py +93 -64
- setiastro/saspro/gui/mixins/dock_mixin.py +31 -18
- setiastro/saspro/gui/mixins/geometry_mixin.py +105 -5
- setiastro/saspro/gui/mixins/menu_mixin.py +1 -0
- setiastro/saspro/gui/mixins/toolbar_mixin.py +33 -10
- setiastro/saspro/multiscale_decomp.py +710 -256
- setiastro/saspro/remove_stars_preset.py +55 -13
- setiastro/saspro/resources.py +30 -11
- setiastro/saspro/selective_color.py +79 -20
- setiastro/saspro/shortcuts.py +94 -21
- setiastro/saspro/stacking_suite.py +296 -107
- setiastro/saspro/star_alignment.py +275 -330
- setiastro/saspro/status_log_dock.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/widgets/minigame/game.js +11 -6
- setiastro/saspro/widgets/resource_monitor.py +26 -0
- setiastro/saspro/widgets/spinboxes.py +18 -0
- setiastro/saspro/wimi.py +65 -65
- setiastro/saspro/wims.py +33 -33
- setiastro/saspro/window_shelf.py +2 -2
- {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.4.dist-info}/METADATA +7 -7
- {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.4.dist-info}/RECORD +72 -71
- {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.4.dist-info}/WHEEL +0 -0
- {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.4.dist-info}/entry_points.txt +0 -0
- {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.4.dist-info}/licenses/LICENSE +0 -0
- {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.4.dist-info}/licenses/license.txt +0 -0
|
@@ -479,27 +479,77 @@ class MetricsWindow(QWidget):
|
|
|
479
479
|
"""
|
|
480
480
|
Called when some frames were deleted/moved out of the list.
|
|
481
481
|
Does NOT recompute metrics. Just trims cached arrays and re-plots.
|
|
482
|
+
|
|
483
|
+
Robust against:
|
|
484
|
+
- removed indices referring to the old list (out of range)
|
|
485
|
+
- metrics_panel arrays being a different length than _all_images
|
|
486
|
+
- stale _order_all / _current_indices containing out-of-bounds indices
|
|
482
487
|
"""
|
|
483
488
|
if not removed:
|
|
484
489
|
return
|
|
485
|
-
removed = sorted(set(int(i) for i in removed))
|
|
486
490
|
|
|
487
|
-
#
|
|
488
|
-
|
|
491
|
+
# Unique + int
|
|
492
|
+
removed = sorted({int(i) for i in removed})
|
|
493
|
+
|
|
494
|
+
# ---- 1) Trim metrics panel caches SAFELY ----
|
|
495
|
+
# Prefer panel's current frame count, because it represents the arrays we must slice.
|
|
496
|
+
n_panel = getattr(self.metrics_panel, "n_frames", None)
|
|
497
|
+
if callable(n_panel):
|
|
498
|
+
n_panel = n_panel()
|
|
499
|
+
if not isinstance(n_panel, int) or n_panel <= 0:
|
|
500
|
+
# fallback: infer from metrics_data if present
|
|
501
|
+
md = getattr(self.metrics_panel, "metrics_data", None)
|
|
502
|
+
if md is not None and len(md) and md[0] is not None:
|
|
503
|
+
try:
|
|
504
|
+
n_panel = int(len(md[0]))
|
|
505
|
+
except Exception:
|
|
506
|
+
n_panel = 0
|
|
507
|
+
else:
|
|
508
|
+
n_panel = 0
|
|
489
509
|
|
|
490
|
-
|
|
491
|
-
|
|
510
|
+
if n_panel > 0:
|
|
511
|
+
removed_panel = [i for i in removed if 0 <= i < n_panel]
|
|
512
|
+
if removed_panel:
|
|
513
|
+
self.metrics_panel.remove_frames(removed_panel)
|
|
514
|
+
# else: panel has nothing (or isn't initialized) — just continue with ordering cleanup
|
|
515
|
+
|
|
516
|
+
# ---- 2) Update ordering arrays with the SAME removed set (but clamp later) ----
|
|
492
517
|
self._order_all = self._reindex_list_after_remove(self._order_all, removed)
|
|
493
|
-
self._current_indices
|
|
518
|
+
if self._current_indices is not None:
|
|
519
|
+
self._current_indices = self._reindex_list_after_remove(self._current_indices, removed)
|
|
494
520
|
|
|
495
|
-
# 3)
|
|
521
|
+
# ---- 3) Rebuild groups (filters may have disappeared) ----
|
|
496
522
|
self._rebuild_groups_from_images()
|
|
497
523
|
|
|
498
|
-
# 4)
|
|
524
|
+
# ---- 4) Plot with VALID indices only ----
|
|
525
|
+
n_imgs = len(self._all_images) if self._all_images is not None else 0
|
|
526
|
+
|
|
527
|
+
def _sanitize_indices(ixs):
|
|
528
|
+
if not ixs:
|
|
529
|
+
return []
|
|
530
|
+
out = []
|
|
531
|
+
seen = set()
|
|
532
|
+
for i in ixs:
|
|
533
|
+
try:
|
|
534
|
+
ii = int(i)
|
|
535
|
+
except Exception:
|
|
536
|
+
continue
|
|
537
|
+
if 0 <= ii < n_imgs and ii not in seen:
|
|
538
|
+
seen.add(ii)
|
|
539
|
+
out.append(ii)
|
|
540
|
+
return out
|
|
541
|
+
|
|
499
542
|
indices = self._current_indices if self._current_indices is not None else self._order_all
|
|
543
|
+
indices = _sanitize_indices(indices)
|
|
544
|
+
|
|
545
|
+
# If the current group became empty, fall back to "all"
|
|
546
|
+
if not indices and n_imgs:
|
|
547
|
+
indices = list(range(n_imgs))
|
|
548
|
+
self._current_indices = indices # optional: keeps UI consistent
|
|
549
|
+
|
|
500
550
|
self.metrics_panel.plot(self._all_images, indices=indices)
|
|
501
551
|
|
|
502
|
-
# 5)
|
|
552
|
+
# ---- 5) Recolor & status ----
|
|
503
553
|
self.metrics_panel.refresh_colors_and_status()
|
|
504
554
|
self._update_status()
|
|
505
555
|
|
|
@@ -1111,12 +1161,13 @@ class BlinkTab(QWidget):
|
|
|
1111
1161
|
|
|
1112
1162
|
if not use_aggr:
|
|
1113
1163
|
if stored.dtype == np.uint8:
|
|
1114
|
-
|
|
1164
|
+
return stored
|
|
1115
1165
|
elif stored.dtype == np.uint16:
|
|
1116
|
-
|
|
1166
|
+
return (stored >> 8).astype(np.uint8)
|
|
1117
1167
|
else:
|
|
1118
|
-
|
|
1119
|
-
|
|
1168
|
+
# ✅ display-only normalization for float / weird ranges
|
|
1169
|
+
f01 = self._ensure_float01(stored)
|
|
1170
|
+
return (f01 * 255.0).astype(np.uint8)
|
|
1120
1171
|
|
|
1121
1172
|
base01 = self._as_float01(stored)
|
|
1122
1173
|
|
|
@@ -1210,11 +1261,29 @@ class BlinkTab(QWidget):
|
|
|
1210
1261
|
self.loading_label.setText(self.tr("Loaded {0} image{1}.").format(n, 's' if n != 1 else ''))
|
|
1211
1262
|
|
|
1212
1263
|
def _apply_playback_interval(self, *_):
|
|
1213
|
-
# read from custom spin if present
|
|
1214
|
-
fps = float(
|
|
1264
|
+
# read from custom spin if present (support both .value() and .value attribute)
|
|
1265
|
+
fps = float(getattr(self, "play_fps", 1.0))
|
|
1266
|
+
|
|
1267
|
+
if hasattr(self, "speed_spin") and self.speed_spin is not None:
|
|
1268
|
+
try:
|
|
1269
|
+
v = getattr(self.speed_spin, "value", None)
|
|
1270
|
+
if callable(v):
|
|
1271
|
+
fps = float(v()) # QDoubleSpinBox-style
|
|
1272
|
+
elif v is not None:
|
|
1273
|
+
fps = float(v) # CustomDoubleSpinBox stores numeric attribute
|
|
1274
|
+
else:
|
|
1275
|
+
# last-resort: try Qt API name
|
|
1276
|
+
fps = float(self.speed_spin.value())
|
|
1277
|
+
except Exception:
|
|
1278
|
+
# fall back to existing play_fps
|
|
1279
|
+
pass
|
|
1280
|
+
|
|
1215
1281
|
fps = max(0.1, min(10.0, fps))
|
|
1216
1282
|
self.play_fps = fps
|
|
1217
|
-
|
|
1283
|
+
|
|
1284
|
+
if hasattr(self, "playback_timer") and self.playback_timer is not None:
|
|
1285
|
+
self.playback_timer.setInterval(int(round(1000.0 / fps))) # 0.1 fps -> 10000 ms
|
|
1286
|
+
|
|
1218
1287
|
|
|
1219
1288
|
def _on_current_item_changed_safe(self, current, previous):
|
|
1220
1289
|
if not current:
|
|
@@ -1239,12 +1308,41 @@ class BlinkTab(QWidget):
|
|
|
1239
1308
|
if item is not None:
|
|
1240
1309
|
self.fileTree.scrollToItem(item, QAbstractItemView.ScrollHint.EnsureVisible)
|
|
1241
1310
|
|
|
1242
|
-
def
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1311
|
+
def _leaf_path(self, item: QTreeWidgetItem) -> str | None:
|
|
1312
|
+
"""Return full path for a leaf item, preferring UserRole; fallback to basename match."""
|
|
1313
|
+
if not item or item.childCount() > 0:
|
|
1314
|
+
return None
|
|
1315
|
+
|
|
1316
|
+
p = item.data(0, Qt.ItemDataRole.UserRole)
|
|
1317
|
+
if p and isinstance(p, str):
|
|
1318
|
+
return p
|
|
1319
|
+
|
|
1320
|
+
# fallback: basename match (legacy items)
|
|
1321
|
+
name = item.text(0).lstrip("⚠️ ").strip()
|
|
1322
|
+
if not name:
|
|
1323
|
+
return None
|
|
1324
|
+
return next((x for x in self.image_paths if os.path.basename(x) == name), None)
|
|
1325
|
+
|
|
1326
|
+
|
|
1327
|
+
def _leaf_index(self, item: QTreeWidgetItem) -> int | None:
|
|
1328
|
+
"""Return index into image_paths/loaded_images for a leaf item."""
|
|
1329
|
+
p = self._leaf_path(item)
|
|
1330
|
+
if not p:
|
|
1331
|
+
return None
|
|
1332
|
+
try:
|
|
1333
|
+
return self.image_paths.index(p)
|
|
1334
|
+
except ValueError:
|
|
1335
|
+
return None
|
|
1336
|
+
|
|
1337
|
+
|
|
1338
|
+
def _set_leaf_display(self, item: QTreeWidgetItem, *, base_name: str, flagged: bool, full_path: str):
|
|
1339
|
+
"""Update a leaf item's text + UserRole consistently."""
|
|
1340
|
+
disp = base_name
|
|
1341
|
+
if flagged:
|
|
1342
|
+
disp = f"⚠️ {disp}"
|
|
1343
|
+
item.setText(0, disp)
|
|
1344
|
+
item.setData(0, Qt.ItemDataRole.UserRole, full_path)
|
|
1345
|
+
|
|
1248
1346
|
|
|
1249
1347
|
def clearFlags(self):
|
|
1250
1348
|
"""Clear all flagged states, update tree icons & metrics."""
|
|
@@ -1507,31 +1605,23 @@ class BlinkTab(QWidget):
|
|
|
1507
1605
|
|
|
1508
1606
|
|
|
1509
1607
|
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
1608
|
self._rebuild_tree_from_loaded()
|
|
1513
1609
|
self.imagesChanged.emit(len(self.loaded_images))
|
|
1514
1610
|
|
|
1515
|
-
# 2) refresh metrics (if open) WITHOUT recomputing SEP
|
|
1516
1611
|
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
|
-
)
|
|
1612
|
+
# ✅ safest: rebind images + rebuild plot order from tree
|
|
1613
|
+
self.metrics_window.set_images(self.loaded_images, order=self._tree_order_indices())
|
|
1614
|
+
self._sync_metrics_flags()
|
|
1527
1615
|
|
|
1528
1616
|
def get_tree_item_for_index(self, idx):
|
|
1529
|
-
|
|
1617
|
+
target_path = self.image_paths[idx]
|
|
1530
1618
|
for item in self.get_all_leaf_items():
|
|
1531
|
-
|
|
1619
|
+
p = item.data(0, Qt.ItemDataRole.UserRole)
|
|
1620
|
+
if p == target_path:
|
|
1532
1621
|
return item
|
|
1533
1622
|
return None
|
|
1534
1623
|
|
|
1624
|
+
|
|
1535
1625
|
def compute_metric(self, metric_idx, entry):
|
|
1536
1626
|
"""Recompute a single metric for one image. Use cached orig_background for metric 2."""
|
|
1537
1627
|
# metric 2 is the pre-stretch background we already computed
|
|
@@ -1851,26 +1941,28 @@ class BlinkTab(QWidget):
|
|
|
1851
1941
|
|
|
1852
1942
|
|
|
1853
1943
|
def _toggle_flag_on_item(self, item: QTreeWidgetItem, *, sync_metrics: bool = True):
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
if file_path is None:
|
|
1944
|
+
idx = self._leaf_index(item)
|
|
1945
|
+
if idx is None:
|
|
1857
1946
|
return
|
|
1858
1947
|
|
|
1859
|
-
idx = self.image_paths.index(file_path)
|
|
1860
1948
|
entry = self.loaded_images[idx]
|
|
1861
|
-
entry['flagged'] = not entry
|
|
1949
|
+
entry['flagged'] = not bool(entry.get('flagged', False))
|
|
1862
1950
|
|
|
1863
1951
|
RED = Qt.GlobalColor.red
|
|
1864
|
-
|
|
1865
|
-
|
|
1952
|
+
normal_color = self.fileTree.palette().color(QPalette.ColorRole.WindowText)
|
|
1953
|
+
|
|
1954
|
+
base = os.path.basename(self.image_paths[idx])
|
|
1866
1955
|
|
|
1867
1956
|
if entry['flagged']:
|
|
1868
|
-
item.setText(0, f"⚠️ {
|
|
1957
|
+
item.setText(0, f"⚠️ {base}")
|
|
1869
1958
|
item.setForeground(0, QBrush(RED))
|
|
1870
1959
|
else:
|
|
1871
|
-
item.setText(0,
|
|
1960
|
+
item.setText(0, base)
|
|
1872
1961
|
item.setForeground(0, QBrush(normal_color))
|
|
1873
1962
|
|
|
1963
|
+
# Keep UserRole correct (in case this was a legacy leaf)
|
|
1964
|
+
item.setData(0, Qt.ItemDataRole.UserRole, self.image_paths[idx])
|
|
1965
|
+
|
|
1874
1966
|
if sync_metrics:
|
|
1875
1967
|
self._sync_metrics_flags()
|
|
1876
1968
|
|
|
@@ -2159,53 +2251,30 @@ class BlinkTab(QWidget):
|
|
|
2159
2251
|
|
|
2160
2252
|
def on_item_clicked(self, item, column):
|
|
2161
2253
|
self.fileTree.setFocus()
|
|
2254
|
+
if not item or item.childCount() > 0:
|
|
2255
|
+
return
|
|
2162
2256
|
|
|
2163
|
-
|
|
2164
|
-
file_path = next((p for p in self.image_paths if os.path.basename(p) == name), None)
|
|
2257
|
+
file_path = self._leaf_path(item)
|
|
2165
2258
|
if not file_path:
|
|
2166
2259
|
return
|
|
2167
2260
|
|
|
2168
2261
|
self._capture_view_center_norm()
|
|
2169
2262
|
|
|
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)
|
|
2263
|
+
try:
|
|
2264
|
+
idx = self.image_paths.index(file_path)
|
|
2265
|
+
except ValueError:
|
|
2266
|
+
return
|
|
2201
2267
|
|
|
2202
|
-
|
|
2268
|
+
entry = self.loaded_images[idx]
|
|
2203
2269
|
|
|
2270
|
+
# ✅ single source of truth (handles aggressive + mono + color)
|
|
2271
|
+
disp8 = self._make_display_frame(entry)
|
|
2204
2272
|
|
|
2205
2273
|
qimage = self.convert_to_qimage(disp8)
|
|
2206
2274
|
self.current_pixmap = QPixmap.fromImage(qimage)
|
|
2207
2275
|
self.apply_zoom()
|
|
2208
2276
|
|
|
2277
|
+
|
|
2209
2278
|
def _capture_view_center_norm(self):
|
|
2210
2279
|
"""Remember the current viewport center as a fraction of the content size."""
|
|
2211
2280
|
sa = self.scroll_area
|
|
@@ -2370,44 +2439,94 @@ class BlinkTab(QWidget):
|
|
|
2370
2439
|
menu.exec(self.fileTree.mapToGlobal(pos))
|
|
2371
2440
|
|
|
2372
2441
|
|
|
2373
|
-
def push_to_docs(self, item):
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2442
|
+
def push_to_docs(self, item: QTreeWidgetItem):
|
|
2443
|
+
"""
|
|
2444
|
+
Push the currently selected blink leaf image into DocManager as a new document,
|
|
2445
|
+
preserving all original metadata (original_header, meta, bit_depth, is_mono, etc.)
|
|
2446
|
+
and swapping ONLY the numpy image array.
|
|
2447
|
+
"""
|
|
2448
|
+
if not item or item.childCount() > 0:
|
|
2449
|
+
return
|
|
2450
|
+
|
|
2451
|
+
# --- Resolve full path safely (UserRole-first) ---
|
|
2452
|
+
file_path = item.data(0, Qt.ItemDataRole.UserRole)
|
|
2453
|
+
if not file_path or not isinstance(file_path, str):
|
|
2454
|
+
# legacy fallback: try to map by displayed name
|
|
2455
|
+
file_name = item.text(0).lstrip("⚠️ ").strip()
|
|
2456
|
+
file_path = next((p for p in self.image_paths if os.path.basename(p) == file_name), None)
|
|
2457
|
+
|
|
2377
2458
|
if not file_path:
|
|
2378
2459
|
return
|
|
2379
|
-
|
|
2460
|
+
|
|
2461
|
+
try:
|
|
2462
|
+
idx = self.image_paths.index(file_path)
|
|
2463
|
+
except ValueError:
|
|
2464
|
+
return
|
|
2465
|
+
|
|
2380
2466
|
entry = self.loaded_images[idx]
|
|
2381
2467
|
|
|
2382
|
-
# Find main window + doc manager
|
|
2468
|
+
# --- Find main window + doc manager ---
|
|
2383
2469
|
mw = self._main_window()
|
|
2384
2470
|
dm = self.doc_manager or (getattr(mw, "docman", None) if mw else None)
|
|
2385
2471
|
if not mw or not dm:
|
|
2386
2472
|
QMessageBox.warning(self, self.tr("Document Manager"), self.tr("Main window or DocManager not available."))
|
|
2387
2473
|
return
|
|
2388
2474
|
|
|
2389
|
-
#
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2475
|
+
# --- Build the swapped payload (image replaced, metadata preserved) ---
|
|
2476
|
+
# Whatever you're storing as entry['image_data'] (uint16/float/etc), normalize to float01 for display pipeline.
|
|
2477
|
+
# If your DocManager expects native dtype instead, swap _as_float01 for your native image.
|
|
2478
|
+
np_image_f01 = self._as_float01(entry["image_data"]).astype(np.float32, copy=False)
|
|
2479
|
+
|
|
2480
|
+
# Preserve your full load_image return structure as much as possible:
|
|
2481
|
+
# load_image returns: image, original_header, bit_depth, is_mono, meta
|
|
2482
|
+
original_header = entry.get("original_header", entry.get("header", None))
|
|
2483
|
+
bit_depth = entry.get("bit_depth", None)
|
|
2484
|
+
is_mono = entry.get("is_mono", None)
|
|
2485
|
+
meta = entry.get("meta", {})
|
|
2486
|
+
|
|
2487
|
+
# Keep meta dict style your app uses; add source tag without clobbering
|
|
2488
|
+
if isinstance(meta, dict):
|
|
2489
|
+
meta = dict(meta)
|
|
2490
|
+
meta.setdefault("source", "BlinkComparatorPro")
|
|
2491
|
+
meta.setdefault("file_path", file_path)
|
|
2492
|
+
|
|
2493
|
+
# This is the "all the other stuff" you wanted preserved
|
|
2494
|
+
payload = {
|
|
2495
|
+
"file_path": file_path,
|
|
2496
|
+
"original_header": original_header,
|
|
2497
|
+
"bit_depth": bit_depth,
|
|
2498
|
+
"is_mono": is_mono,
|
|
2499
|
+
"meta": meta,
|
|
2500
|
+
"source": "BlinkComparatorPro",
|
|
2397
2501
|
}
|
|
2502
|
+
|
|
2398
2503
|
title = os.path.basename(file_path)
|
|
2399
2504
|
|
|
2400
|
-
# Create
|
|
2505
|
+
# --- Create document using whatever DocManager API exists ---
|
|
2401
2506
|
doc = None
|
|
2402
2507
|
try:
|
|
2403
|
-
if
|
|
2404
|
-
|
|
2508
|
+
# Preferred: if you have a method that mirrors open_file/load_image shape
|
|
2509
|
+
if hasattr(dm, "open_from_load_image"):
|
|
2510
|
+
# (image, original_header, bit_depth, is_mono, meta)
|
|
2511
|
+
doc = dm.open_from_load_image(np_image_f01, original_header, bit_depth, is_mono, meta, title=title)
|
|
2512
|
+
|
|
2513
|
+
elif hasattr(dm, "open_array"):
|
|
2514
|
+
# Some of your code expects metadata in doc.metadata; pass payload whole
|
|
2515
|
+
doc = dm.open_array(np_image_f01, metadata=payload, title=title)
|
|
2516
|
+
|
|
2405
2517
|
elif hasattr(dm, "open_numpy"):
|
|
2406
|
-
doc = dm.open_numpy(np_image_f01, metadata=
|
|
2518
|
+
doc = dm.open_numpy(np_image_f01, metadata=payload, title=title)
|
|
2519
|
+
|
|
2407
2520
|
elif hasattr(dm, "create_document"):
|
|
2408
|
-
|
|
2521
|
+
# Try both signatures
|
|
2522
|
+
try:
|
|
2523
|
+
doc = dm.create_document(image=np_image_f01, metadata=payload, name=title)
|
|
2524
|
+
except TypeError:
|
|
2525
|
+
doc = dm.create_document(np_image_f01, payload, title)
|
|
2526
|
+
|
|
2409
2527
|
else:
|
|
2410
|
-
raise AttributeError(
|
|
2528
|
+
raise AttributeError("DocManager lacks a known creation method")
|
|
2529
|
+
|
|
2411
2530
|
except Exception as e:
|
|
2412
2531
|
QMessageBox.critical(self, self.tr("Doc Manager"), self.tr("Failed to create document:\n{0}").format(e))
|
|
2413
2532
|
return
|
|
@@ -2416,42 +2535,85 @@ class BlinkTab(QWidget):
|
|
|
2416
2535
|
QMessageBox.critical(self, self.tr("Doc Manager"), self.tr("DocManager returned no document."))
|
|
2417
2536
|
return
|
|
2418
2537
|
|
|
2419
|
-
#
|
|
2538
|
+
# --- Hand off to DocManager flow (DocManager should trigger MDI + window creation) ---
|
|
2420
2539
|
try:
|
|
2421
|
-
|
|
2540
|
+
# If your architecture already auto-spawns windows on documentAdded,
|
|
2541
|
+
# you should NOT call mw._spawn_subwindow_for(doc) here.
|
|
2542
|
+
if hasattr(dm, "add_document"):
|
|
2543
|
+
dm.add_document(doc)
|
|
2544
|
+
elif hasattr(dm, "register_document"):
|
|
2545
|
+
dm.register_document(doc)
|
|
2546
|
+
else:
|
|
2547
|
+
# If open_array/open_numpy already registers the doc internally, do nothing.
|
|
2548
|
+
pass
|
|
2549
|
+
|
|
2550
|
+
# If you *must* spawn manually (older path), keep as fallback
|
|
2551
|
+
if hasattr(mw, "_spawn_subwindow_for"):
|
|
2552
|
+
mw._spawn_subwindow_for(doc)
|
|
2553
|
+
|
|
2422
2554
|
if hasattr(mw, "_log"):
|
|
2423
2555
|
mw._log(f"Blink → opened '{title}' as new document")
|
|
2556
|
+
|
|
2424
2557
|
except Exception as e:
|
|
2425
2558
|
QMessageBox.critical(self, self.tr("UI"), self.tr("Failed to open subwindow:\n{0}").format(e))
|
|
2426
2559
|
|
|
2427
2560
|
|
|
2561
|
+
|
|
2428
2562
|
# optional shim to keep any old calls working
|
|
2429
2563
|
def push_image_to_manager(self, item):
|
|
2430
2564
|
self.push_to_docs(item)
|
|
2431
2565
|
|
|
2432
2566
|
|
|
2433
2567
|
|
|
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)
|
|
2568
|
+
def rename_item(self, item: QTreeWidgetItem):
|
|
2569
|
+
if not item or item.childCount() > 0:
|
|
2570
|
+
return
|
|
2438
2571
|
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2572
|
+
idx = self._leaf_index(item)
|
|
2573
|
+
if idx is None:
|
|
2574
|
+
return
|
|
2575
|
+
|
|
2576
|
+
old_path = self.image_paths[idx]
|
|
2577
|
+
old_base = os.path.basename(old_path)
|
|
2578
|
+
|
|
2579
|
+
new_name, ok = QInputDialog.getText(
|
|
2580
|
+
self,
|
|
2581
|
+
self.tr("Rename Image"),
|
|
2582
|
+
self.tr("Enter new name:"),
|
|
2583
|
+
text=old_base
|
|
2584
|
+
)
|
|
2585
|
+
if not ok:
|
|
2586
|
+
return
|
|
2587
|
+
|
|
2588
|
+
new_name = (new_name or "").strip()
|
|
2589
|
+
if not new_name:
|
|
2590
|
+
return
|
|
2591
|
+
|
|
2592
|
+
new_path = os.path.join(os.path.dirname(old_path), new_name)
|
|
2593
|
+
|
|
2594
|
+
# Avoid overwrite
|
|
2595
|
+
if os.path.exists(new_path):
|
|
2596
|
+
QMessageBox.critical(self, self.tr("Error"), self.tr("A file with that name already exists."))
|
|
2597
|
+
return
|
|
2598
|
+
|
|
2599
|
+
try:
|
|
2600
|
+
os.rename(old_path, new_path)
|
|
2601
|
+
except Exception as e:
|
|
2602
|
+
QMessageBox.critical(self, self.tr("Error"), self.tr("Failed to rename the file: {0}").format(e))
|
|
2603
|
+
return
|
|
2604
|
+
|
|
2605
|
+
# Update internal structures
|
|
2606
|
+
self.image_paths[idx] = new_path
|
|
2607
|
+
self.loaded_images[idx]['file_path'] = new_path
|
|
2608
|
+
|
|
2609
|
+
# Update the leaf item
|
|
2610
|
+
flagged = bool(self.loaded_images[idx].get("flagged", False))
|
|
2611
|
+
self._set_leaf_display(item, base_name=new_name, flagged=flagged, full_path=new_path)
|
|
2612
|
+
|
|
2613
|
+
# Rebuild so natural sort stays correct and groups update
|
|
2614
|
+
self._after_list_changed()
|
|
2615
|
+
self._sync_metrics_flags()
|
|
2444
2616
|
|
|
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
2617
|
|
|
2456
2618
|
def rename_flagged_images(self):
|
|
2457
2619
|
"""Prefix all *flagged* images on disk and in the tree."""
|
|
@@ -2562,79 +2724,102 @@ class BlinkTab(QWidget):
|
|
|
2562
2724
|
|
|
2563
2725
|
|
|
2564
2726
|
def batch_rename_items(self):
|
|
2565
|
-
"""Batch rename selected items by adding a prefix or suffix."""
|
|
2566
|
-
selected_items = self.fileTree.selectedItems()
|
|
2567
|
-
|
|
2727
|
+
"""Batch rename selected leaf items by adding a prefix and/or suffix."""
|
|
2728
|
+
selected_items = [it for it in self.fileTree.selectedItems() if it and it.childCount() == 0]
|
|
2568
2729
|
if not selected_items:
|
|
2569
|
-
QMessageBox.warning(self, self.tr("Warning"), self.tr("No items selected for renaming."))
|
|
2730
|
+
QMessageBox.warning(self, self.tr("Warning"), self.tr("No individual image items selected for renaming."))
|
|
2570
2731
|
return
|
|
2571
2732
|
|
|
2572
|
-
# Create a custom dialog for entering the prefix and suffix
|
|
2573
2733
|
dialog = QDialog(self)
|
|
2574
2734
|
dialog.setWindowTitle(self.tr("Batch Rename"))
|
|
2575
2735
|
dialog_layout = QVBoxLayout(dialog)
|
|
2576
2736
|
|
|
2577
|
-
|
|
2578
|
-
dialog_layout.addWidget(instruction_label)
|
|
2737
|
+
dialog_layout.addWidget(QLabel(self.tr("Enter a prefix or suffix to rename selected files:"), dialog))
|
|
2579
2738
|
|
|
2580
|
-
# Create fields for prefix and suffix
|
|
2581
2739
|
form_layout = QHBoxLayout()
|
|
2582
|
-
|
|
2583
2740
|
prefix_field = QLineEdit(dialog)
|
|
2584
2741
|
prefix_field.setPlaceholderText(self.tr("Prefix"))
|
|
2585
2742
|
form_layout.addWidget(prefix_field)
|
|
2586
2743
|
|
|
2587
|
-
|
|
2588
|
-
|
|
2744
|
+
mid_label = QLabel(self.tr("filename"), dialog)
|
|
2745
|
+
mid_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
2746
|
+
form_layout.addWidget(mid_label)
|
|
2589
2747
|
|
|
2590
2748
|
suffix_field = QLineEdit(dialog)
|
|
2591
2749
|
suffix_field.setPlaceholderText(self.tr("Suffix"))
|
|
2592
2750
|
form_layout.addWidget(suffix_field)
|
|
2593
|
-
|
|
2594
2751
|
dialog_layout.addLayout(form_layout)
|
|
2595
2752
|
|
|
2596
|
-
|
|
2597
|
-
button_layout = QHBoxLayout()
|
|
2753
|
+
btns = QHBoxLayout()
|
|
2598
2754
|
ok_button = QPushButton(self.tr("OK"), dialog)
|
|
2599
|
-
ok_button.clicked.connect(dialog.accept)
|
|
2600
|
-
button_layout.addWidget(ok_button)
|
|
2601
|
-
|
|
2602
2755
|
cancel_button = QPushButton(self.tr("Cancel"), dialog)
|
|
2756
|
+
ok_button.clicked.connect(dialog.accept)
|
|
2603
2757
|
cancel_button.clicked.connect(dialog.reject)
|
|
2604
|
-
|
|
2758
|
+
btns.addWidget(ok_button)
|
|
2759
|
+
btns.addWidget(cancel_button)
|
|
2760
|
+
dialog_layout.addLayout(btns)
|
|
2761
|
+
|
|
2762
|
+
if dialog.exec() != QDialog.DialogCode.Accepted:
|
|
2763
|
+
return
|
|
2605
2764
|
|
|
2606
|
-
|
|
2765
|
+
prefix = (prefix_field.text() or "").strip()
|
|
2766
|
+
suffix = (suffix_field.text() or "").strip()
|
|
2607
2767
|
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
suffix = suffix_field.text().strip()
|
|
2768
|
+
if not prefix and not suffix:
|
|
2769
|
+
QMessageBox.information(self, self.tr("Batch Rename"), self.tr("No prefix or suffix entered. Nothing to do."))
|
|
2770
|
+
return
|
|
2612
2771
|
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
current_name = item.text(0)
|
|
2616
|
-
file_path = next((path for path in self.image_paths if os.path.basename(path) == current_name), None)
|
|
2772
|
+
renamed = 0
|
|
2773
|
+
failures = []
|
|
2617
2774
|
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2775
|
+
# Work on indices so we can update lists safely
|
|
2776
|
+
indices = []
|
|
2777
|
+
for it in selected_items:
|
|
2778
|
+
idx = self._leaf_index(it)
|
|
2779
|
+
if idx is not None:
|
|
2780
|
+
indices.append((idx, it))
|
|
2623
2781
|
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
print(f"File renamed from {file_path} to {new_file_path}")
|
|
2782
|
+
for idx, it in indices:
|
|
2783
|
+
old_path = self.image_paths[idx]
|
|
2784
|
+
directory, base = os.path.split(old_path)
|
|
2628
2785
|
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
item.setText(0, new_name)
|
|
2786
|
+
new_base = f"{prefix}{base}{suffix}"
|
|
2787
|
+
new_path = os.path.join(directory, new_base)
|
|
2632
2788
|
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2789
|
+
if new_path == old_path:
|
|
2790
|
+
continue
|
|
2791
|
+
|
|
2792
|
+
if os.path.exists(new_path):
|
|
2793
|
+
failures.append((old_path, self.tr("target already exists")))
|
|
2794
|
+
continue
|
|
2795
|
+
|
|
2796
|
+
try:
|
|
2797
|
+
os.rename(old_path, new_path)
|
|
2798
|
+
except Exception as e:
|
|
2799
|
+
failures.append((old_path, str(e)))
|
|
2800
|
+
continue
|
|
2801
|
+
|
|
2802
|
+
# Update internal lists
|
|
2803
|
+
self.image_paths[idx] = new_path
|
|
2804
|
+
self.loaded_images[idx]["file_path"] = new_path
|
|
2805
|
+
|
|
2806
|
+
# Update leaf item
|
|
2807
|
+
flagged = bool(self.loaded_images[idx].get("flagged", False))
|
|
2808
|
+
self._set_leaf_display(it, base_name=new_base, flagged=flagged, full_path=new_path)
|
|
2809
|
+
|
|
2810
|
+
renamed += 1
|
|
2811
|
+
|
|
2812
|
+
# Rebuild so group headers + natural order stay correct
|
|
2813
|
+
self._after_list_changed()
|
|
2814
|
+
self._sync_metrics_flags()
|
|
2815
|
+
|
|
2816
|
+
msg = self.tr("Batch renamed {0} file{1}.").format(renamed, "s" if renamed != 1 else "")
|
|
2817
|
+
if failures:
|
|
2818
|
+
msg += self.tr("\n\n{0} file(s) failed:").format(len(failures))
|
|
2819
|
+
for old, err in failures[:10]:
|
|
2820
|
+
msg += f"\n• {os.path.basename(old)} – {err}"
|
|
2821
|
+
QMessageBox.information(self, self.tr("Batch Rename"), msg)
|
|
2636
2822
|
|
|
2637
|
-
print(f"Batch renamed {len(selected_items)} items.")
|
|
2638
2823
|
|
|
2639
2824
|
def batch_delete_flagged_images(self):
|
|
2640
2825
|
"""Delete all flagged images."""
|
|
@@ -2681,141 +2866,181 @@ class BlinkTab(QWidget):
|
|
|
2681
2866
|
self._after_list_changed(removed_indices)
|
|
2682
2867
|
|
|
2683
2868
|
def batch_move_flagged_images(self):
|
|
2684
|
-
"""Move all flagged images to a selected directory."""
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
if not flagged_images:
|
|
2869
|
+
"""Move all flagged images to a selected directory AND remove them from the blink list."""
|
|
2870
|
+
flagged_indices = [i for i, e in enumerate(self.loaded_images) if e.get("flagged", False)]
|
|
2871
|
+
if not flagged_indices:
|
|
2688
2872
|
QMessageBox.information(self, self.tr("No Flagged Images"), self.tr("There are no flagged images to move."))
|
|
2689
2873
|
return
|
|
2690
2874
|
|
|
2691
|
-
# Select destination directory
|
|
2692
2875
|
destination_dir = QFileDialog.getExistingDirectory(self, self.tr("Select Destination Folder"), "")
|
|
2693
2876
|
if not destination_dir:
|
|
2694
|
-
return
|
|
2877
|
+
return
|
|
2695
2878
|
|
|
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)
|
|
2879
|
+
failures = []
|
|
2700
2880
|
|
|
2881
|
+
# Move first (use current paths from indices)
|
|
2882
|
+
for i in flagged_indices:
|
|
2883
|
+
src_path = self.image_paths[i]
|
|
2884
|
+
dest_path = os.path.join(destination_dir, os.path.basename(src_path))
|
|
2701
2885
|
try:
|
|
2702
2886
|
os.rename(src_path, dest_path)
|
|
2703
|
-
print(f"Moved flagged image from {src_path} to {dest_path}")
|
|
2704
2887
|
except Exception as e:
|
|
2705
|
-
|
|
2706
|
-
QMessageBox.critical(self, self.tr("Error"), self.tr("Failed to move {0}: {1}").format(src_path, e))
|
|
2707
|
-
continue
|
|
2888
|
+
failures.append((src_path, str(e)))
|
|
2708
2889
|
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
img['flagged'] = False # Reset flag if desired
|
|
2890
|
+
# Remove from lists ONLY if move succeeded
|
|
2891
|
+
# Build a set of indices to remove: those that did NOT fail
|
|
2892
|
+
failed_src = {p for p, _ in failures}
|
|
2893
|
+
removed_indices = [i for i in flagged_indices if self.image_paths[i] not in failed_src]
|
|
2714
2894
|
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
self.
|
|
2895
|
+
removed_indices = sorted(set(removed_indices), reverse=True)
|
|
2896
|
+
for idx in removed_indices:
|
|
2897
|
+
if 0 <= idx < len(self.image_paths):
|
|
2898
|
+
del self.image_paths[idx]
|
|
2899
|
+
if 0 <= idx < len(self.loaded_images):
|
|
2900
|
+
del self.loaded_images[idx]
|
|
2901
|
+
|
|
2902
|
+
if removed_indices:
|
|
2903
|
+
self._after_list_changed(removed_indices)
|
|
2904
|
+
|
|
2905
|
+
if failures:
|
|
2906
|
+
msg = self.tr("Moved {0} flagged file(s). {1} failed:").format(len(removed_indices), len(failures))
|
|
2907
|
+
for p, err in failures[:10]:
|
|
2908
|
+
msg += f"\n• {os.path.basename(p)} – {err}"
|
|
2909
|
+
QMessageBox.warning(self, self.tr("Batch Move"), msg)
|
|
2910
|
+
else:
|
|
2911
|
+
QMessageBox.information(self, self.tr("Batch Move"), self.tr("Moved and removed {0} flagged image(s).").format(len(removed_indices)))
|
|
2718
2912
|
|
|
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
2913
|
|
|
2722
2914
|
def move_items(self):
|
|
2723
|
-
"""Move selected images
|
|
2724
|
-
selected_items = self.fileTree.selectedItems()
|
|
2915
|
+
"""Move selected leaf images to a selected directory AND remove them from the blink list."""
|
|
2916
|
+
selected_items = [it for it in self.fileTree.selectedItems() if it and it.childCount() == 0]
|
|
2725
2917
|
if not selected_items:
|
|
2726
|
-
QMessageBox.warning(self, self.tr("Warning"), self.tr("No items selected for moving."))
|
|
2918
|
+
QMessageBox.warning(self, self.tr("Warning"), self.tr("No individual image items selected for moving."))
|
|
2727
2919
|
return
|
|
2728
2920
|
|
|
2729
|
-
|
|
2730
|
-
new_dir = QFileDialog.getExistingDirectory(self,
|
|
2731
|
-
self.tr("Select Destination Folder"),
|
|
2732
|
-
"")
|
|
2921
|
+
new_dir = QFileDialog.getExistingDirectory(self, self.tr("Select Destination Folder"), "")
|
|
2733
2922
|
if not new_dir:
|
|
2734
2923
|
return
|
|
2735
2924
|
|
|
2736
|
-
# Keep track of which on‐disk paths we actually moved
|
|
2737
|
-
moved_old_paths = []
|
|
2738
2925
|
removed_indices = []
|
|
2926
|
+
failures = []
|
|
2739
2927
|
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
if not
|
|
2928
|
+
# Collect (idx, old_path, item) first to avoid index drift
|
|
2929
|
+
triplets = []
|
|
2930
|
+
for it in selected_items:
|
|
2931
|
+
p = self._leaf_path(it)
|
|
2932
|
+
if not p:
|
|
2745
2933
|
continue
|
|
2746
|
-
|
|
2934
|
+
try:
|
|
2935
|
+
idx = self.image_paths.index(p)
|
|
2936
|
+
except ValueError:
|
|
2937
|
+
continue
|
|
2938
|
+
triplets.append((idx, p, it))
|
|
2747
2939
|
|
|
2748
|
-
|
|
2940
|
+
for idx, old_path, it in triplets:
|
|
2941
|
+
base = os.path.basename(old_path)
|
|
2942
|
+
new_path = os.path.join(new_dir, base)
|
|
2749
2943
|
try:
|
|
2750
2944
|
os.rename(old_path, new_path)
|
|
2751
2945
|
except Exception as e:
|
|
2752
|
-
|
|
2946
|
+
failures.append((old_path, str(e)))
|
|
2753
2947
|
continue
|
|
2754
2948
|
|
|
2755
|
-
|
|
2949
|
+
removed_indices.append(idx)
|
|
2756
2950
|
|
|
2757
|
-
#
|
|
2758
|
-
parent =
|
|
2759
|
-
parent.removeChild(
|
|
2951
|
+
# remove leaf from tree immediately (optional; _after_list_changed will rebuild anyway)
|
|
2952
|
+
#parent = it.parent() or self.fileTree.invisibleRootItem()
|
|
2953
|
+
#parent.removeChild(it)
|
|
2760
2954
|
|
|
2761
|
-
#
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
2955
|
+
# Purge arrays descending
|
|
2956
|
+
removed_indices = sorted(set(removed_indices), reverse=True)
|
|
2957
|
+
for idx in removed_indices:
|
|
2958
|
+
if 0 <= idx < len(self.image_paths):
|
|
2959
|
+
del self.image_paths[idx]
|
|
2960
|
+
if 0 <= idx < len(self.loaded_images):
|
|
2961
|
+
del self.loaded_images[idx]
|
|
2768
2962
|
|
|
2963
|
+
if removed_indices:
|
|
2964
|
+
self._after_list_changed(removed_indices)
|
|
2769
2965
|
|
|
2966
|
+
if failures:
|
|
2967
|
+
msg = self.tr("Moved {0} file(s). {1} failed:").format(len(removed_indices), len(failures))
|
|
2968
|
+
for old, err in failures[:10]:
|
|
2969
|
+
msg += f"\n• {os.path.basename(old)} – {err}"
|
|
2970
|
+
QMessageBox.warning(self, self.tr("Move Selected Items"), msg)
|
|
2971
|
+
else:
|
|
2972
|
+
QMessageBox.information(self, self.tr("Move Selected Items"), self.tr("Moved and removed {0} item(s).").format(len(removed_indices)))
|
|
2770
2973
|
|
|
2771
2974
|
def delete_items(self):
|
|
2772
|
-
"""Delete
|
|
2773
|
-
selected_items = self.fileTree.selectedItems()
|
|
2774
|
-
|
|
2975
|
+
"""Delete selected leaf images from disk and remove them from the blink list."""
|
|
2976
|
+
selected_items = [it for it in self.fileTree.selectedItems() if it and it.childCount() == 0]
|
|
2775
2977
|
if not selected_items:
|
|
2776
|
-
QMessageBox.warning(self, self.tr("Warning"), self.tr("No items selected for deletion."))
|
|
2978
|
+
QMessageBox.warning(self, self.tr("Warning"), self.tr("No individual image items selected for deletion."))
|
|
2777
2979
|
return
|
|
2778
2980
|
|
|
2779
|
-
# Confirmation dialog
|
|
2780
2981
|
reply = QMessageBox.question(
|
|
2781
2982
|
self,
|
|
2782
|
-
self.tr(
|
|
2983
|
+
self.tr("Confirm Deletion"),
|
|
2783
2984
|
self.tr("Are you sure you want to permanently delete {0} selected images? This action is irreversible.").format(len(selected_items)),
|
|
2784
2985
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
|
2785
2986
|
QMessageBox.StandardButton.No
|
|
2786
2987
|
)
|
|
2988
|
+
if reply != QMessageBox.StandardButton.Yes:
|
|
2989
|
+
return
|
|
2787
2990
|
|
|
2788
2991
|
removed_indices = []
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
|
|
2795
|
-
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
|
|
2992
|
+
failures = []
|
|
2993
|
+
|
|
2994
|
+
# Snapshot first
|
|
2995
|
+
triplets = []
|
|
2996
|
+
for it in selected_items:
|
|
2997
|
+
p = self._leaf_path(it)
|
|
2998
|
+
if not p:
|
|
2999
|
+
continue
|
|
3000
|
+
try:
|
|
3001
|
+
idx = self.image_paths.index(p)
|
|
3002
|
+
except ValueError:
|
|
3003
|
+
continue
|
|
3004
|
+
triplets.append((idx, p, it))
|
|
3005
|
+
|
|
3006
|
+
for idx, path, it in triplets:
|
|
3007
|
+
try:
|
|
3008
|
+
os.remove(path)
|
|
3009
|
+
except Exception as e:
|
|
3010
|
+
failures.append((path, str(e)))
|
|
3011
|
+
continue
|
|
3012
|
+
|
|
3013
|
+
removed_indices.append(idx)
|
|
3014
|
+
|
|
3015
|
+
# remove from tree immediately (optional)
|
|
3016
|
+
parent = it.parent() or self.fileTree.invisibleRootItem()
|
|
3017
|
+
parent.removeChild(it)
|
|
3018
|
+
|
|
3019
|
+
# Purge arrays descending
|
|
3020
|
+
removed_indices = sorted(set(removed_indices), reverse=True)
|
|
3021
|
+
for idx in removed_indices:
|
|
3022
|
+
if 0 <= idx < len(self.image_paths):
|
|
2808
3023
|
del self.image_paths[idx]
|
|
3024
|
+
if 0 <= idx < len(self.loaded_images):
|
|
2809
3025
|
del self.loaded_images[idx]
|
|
2810
3026
|
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
3027
|
+
# Clear preview safely
|
|
3028
|
+
self.preview_label.clear()
|
|
3029
|
+
self.preview_label.setText(self.tr("No image selected."))
|
|
3030
|
+
self.current_pixmap = None
|
|
2815
3031
|
|
|
2816
|
-
|
|
3032
|
+
if removed_indices:
|
|
2817
3033
|
self._after_list_changed(removed_indices)
|
|
2818
3034
|
|
|
3035
|
+
if failures:
|
|
3036
|
+
msg = self.tr("Deleted {0} file(s). {1} failed:").format(len(removed_indices), len(failures))
|
|
3037
|
+
for p, err in failures[:10]:
|
|
3038
|
+
msg += f"\n• {os.path.basename(p)} – {err}"
|
|
3039
|
+
QMessageBox.warning(self, self.tr("Delete Selected Items"), msg)
|
|
3040
|
+
else:
|
|
3041
|
+
QMessageBox.information(self, self.tr("Delete Selected Items"), self.tr("Deleted {0} item(s).").format(len(removed_indices)))
|
|
3042
|
+
|
|
3043
|
+
|
|
2819
3044
|
def eventFilter(self, source, event):
|
|
2820
3045
|
"""Handle mouse events for dragging."""
|
|
2821
3046
|
if source == self.scroll_area.viewport():
|
|
@@ -2878,16 +3103,14 @@ class BlinkTab(QWidget):
|
|
|
2878
3103
|
self.on_item_clicked(cur, 0)
|
|
2879
3104
|
|
|
2880
3105
|
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
3106
|
if img_array.dtype == np.uint8:
|
|
2884
3107
|
arr8 = img_array
|
|
2885
3108
|
elif img_array.dtype == np.uint16:
|
|
2886
|
-
# downscale 16-bit → 8-bit
|
|
2887
3109
|
arr8 = (img_array.astype(np.float32) / 65535.0 * 255.0).clip(0,255).astype(np.uint8)
|
|
2888
3110
|
else:
|
|
2889
|
-
#
|
|
2890
|
-
|
|
3111
|
+
# ✅ display-only normalize floats outside 0..1
|
|
3112
|
+
f01 = self._ensure_float01(img_array)
|
|
3113
|
+
arr8 = (f01 * 255.0).astype(np.uint8)
|
|
2891
3114
|
|
|
2892
3115
|
h, w = arr8.shape[:2]
|
|
2893
3116
|
buffer = arr8.tobytes()
|