setiastrosuitepro 1.6.2.post1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of setiastrosuitepro might be problematic. Click here for more details.
- setiastro/__init__.py +2 -0
- setiastro/data/SASP_data.fits +0 -0
- setiastro/data/catalogs/List_of_Galaxies_with_Distances_Gly.csv +488 -0
- setiastro/data/catalogs/astrobin_filters.csv +890 -0
- setiastro/data/catalogs/astrobin_filters_page1_local.csv +51 -0
- setiastro/data/catalogs/cali2.csv +63 -0
- setiastro/data/catalogs/cali2color.csv +65 -0
- setiastro/data/catalogs/celestial_catalog - original.csv +16471 -0
- setiastro/data/catalogs/celestial_catalog.csv +24031 -0
- setiastro/data/catalogs/detected_stars.csv +24784 -0
- setiastro/data/catalogs/fits_header_data.csv +46 -0
- setiastro/data/catalogs/test.csv +8 -0
- setiastro/data/catalogs/updated_celestial_catalog.csv +16471 -0
- setiastro/images/Astro_Spikes.png +0 -0
- setiastro/images/Background_startup.jpg +0 -0
- setiastro/images/HRDiagram.png +0 -0
- setiastro/images/LExtract.png +0 -0
- setiastro/images/LInsert.png +0 -0
- setiastro/images/Oxygenation-atm-2.svg.png +0 -0
- setiastro/images/RGB080604.png +0 -0
- setiastro/images/abeicon.png +0 -0
- setiastro/images/aberration.png +0 -0
- setiastro/images/andromedatry.png +0 -0
- setiastro/images/andromedatry_satellited.png +0 -0
- setiastro/images/annotated.png +0 -0
- setiastro/images/aperture.png +0 -0
- setiastro/images/astrosuite.ico +0 -0
- setiastro/images/astrosuite.png +0 -0
- setiastro/images/astrosuitepro.icns +0 -0
- setiastro/images/astrosuitepro.ico +0 -0
- setiastro/images/astrosuitepro.png +0 -0
- setiastro/images/background.png +0 -0
- setiastro/images/background2.png +0 -0
- setiastro/images/benchmark.png +0 -0
- setiastro/images/big_moon_stabilizer_timeline.png +0 -0
- setiastro/images/big_moon_stabilizer_timeline_clean.png +0 -0
- setiastro/images/blaster.png +0 -0
- setiastro/images/blink.png +0 -0
- setiastro/images/clahe.png +0 -0
- setiastro/images/collage.png +0 -0
- setiastro/images/colorwheel.png +0 -0
- setiastro/images/contsub.png +0 -0
- setiastro/images/convo.png +0 -0
- setiastro/images/copyslot.png +0 -0
- setiastro/images/cosmic.png +0 -0
- setiastro/images/cosmicsat.png +0 -0
- setiastro/images/crop1.png +0 -0
- setiastro/images/cropicon.png +0 -0
- setiastro/images/curves.png +0 -0
- setiastro/images/cvs.png +0 -0
- setiastro/images/debayer.png +0 -0
- setiastro/images/denoise_cnn_custom.png +0 -0
- setiastro/images/denoise_cnn_graph.png +0 -0
- setiastro/images/disk.png +0 -0
- setiastro/images/dse.png +0 -0
- setiastro/images/exoicon.png +0 -0
- setiastro/images/eye.png +0 -0
- setiastro/images/fliphorizontal.png +0 -0
- setiastro/images/flipvertical.png +0 -0
- setiastro/images/font.png +0 -0
- setiastro/images/freqsep.png +0 -0
- setiastro/images/functionbundle.png +0 -0
- setiastro/images/graxpert.png +0 -0
- setiastro/images/green.png +0 -0
- setiastro/images/gridicon.png +0 -0
- setiastro/images/halo.png +0 -0
- setiastro/images/hdr.png +0 -0
- setiastro/images/histogram.png +0 -0
- setiastro/images/hubble.png +0 -0
- setiastro/images/imagecombine.png +0 -0
- setiastro/images/invert.png +0 -0
- setiastro/images/isophote.png +0 -0
- setiastro/images/isophote_demo_figure.png +0 -0
- setiastro/images/isophote_demo_image.png +0 -0
- setiastro/images/isophote_demo_model.png +0 -0
- setiastro/images/isophote_demo_residual.png +0 -0
- setiastro/images/jwstpupil.png +0 -0
- setiastro/images/linearfit.png +0 -0
- setiastro/images/livestacking.png +0 -0
- setiastro/images/mask.png +0 -0
- setiastro/images/maskapply.png +0 -0
- setiastro/images/maskcreate.png +0 -0
- setiastro/images/maskremove.png +0 -0
- setiastro/images/morpho.png +0 -0
- setiastro/images/mosaic.png +0 -0
- setiastro/images/multiscale_decomp.png +0 -0
- setiastro/images/nbtorgb.png +0 -0
- setiastro/images/neutral.png +0 -0
- setiastro/images/nuke.png +0 -0
- setiastro/images/openfile.png +0 -0
- setiastro/images/pedestal.png +0 -0
- setiastro/images/pen.png +0 -0
- setiastro/images/pixelmath.png +0 -0
- setiastro/images/platesolve.png +0 -0
- setiastro/images/ppp.png +0 -0
- setiastro/images/pro.png +0 -0
- setiastro/images/project.png +0 -0
- setiastro/images/psf.png +0 -0
- setiastro/images/redo.png +0 -0
- setiastro/images/redoicon.png +0 -0
- setiastro/images/rescale.png +0 -0
- setiastro/images/rgbalign.png +0 -0
- setiastro/images/rgbcombo.png +0 -0
- setiastro/images/rgbextract.png +0 -0
- setiastro/images/rotate180.png +0 -0
- setiastro/images/rotateclockwise.png +0 -0
- setiastro/images/rotatecounterclockwise.png +0 -0
- setiastro/images/satellite.png +0 -0
- setiastro/images/script.png +0 -0
- setiastro/images/selectivecolor.png +0 -0
- setiastro/images/simbad.png +0 -0
- setiastro/images/slot0.png +0 -0
- setiastro/images/slot1.png +0 -0
- setiastro/images/slot2.png +0 -0
- setiastro/images/slot3.png +0 -0
- setiastro/images/slot4.png +0 -0
- setiastro/images/slot5.png +0 -0
- setiastro/images/slot6.png +0 -0
- setiastro/images/slot7.png +0 -0
- setiastro/images/slot8.png +0 -0
- setiastro/images/slot9.png +0 -0
- setiastro/images/spcc.png +0 -0
- setiastro/images/spin_precession_vs_lunar_distance.png +0 -0
- setiastro/images/spinner.gif +0 -0
- setiastro/images/stacking.png +0 -0
- setiastro/images/staradd.png +0 -0
- setiastro/images/staralign.png +0 -0
- setiastro/images/starnet.png +0 -0
- setiastro/images/starregistration.png +0 -0
- setiastro/images/starspike.png +0 -0
- setiastro/images/starstretch.png +0 -0
- setiastro/images/statstretch.png +0 -0
- setiastro/images/supernova.png +0 -0
- setiastro/images/uhs.png +0 -0
- setiastro/images/undoicon.png +0 -0
- setiastro/images/upscale.png +0 -0
- setiastro/images/viewbundle.png +0 -0
- setiastro/images/whitebalance.png +0 -0
- setiastro/images/wimi_icon_256x256.png +0 -0
- setiastro/images/wimilogo.png +0 -0
- setiastro/images/wims.png +0 -0
- setiastro/images/wrench_icon.png +0 -0
- setiastro/images/xisfliberator.png +0 -0
- setiastro/qml/ResourceMonitor.qml +126 -0
- setiastro/saspro/__init__.py +20 -0
- setiastro/saspro/__main__.py +945 -0
- setiastro/saspro/_generated/__init__.py +7 -0
- setiastro/saspro/_generated/build_info.py +3 -0
- setiastro/saspro/abe.py +1346 -0
- setiastro/saspro/abe_preset.py +196 -0
- setiastro/saspro/aberration_ai.py +694 -0
- setiastro/saspro/aberration_ai_preset.py +224 -0
- setiastro/saspro/accel_installer.py +218 -0
- setiastro/saspro/accel_workers.py +30 -0
- setiastro/saspro/add_stars.py +624 -0
- setiastro/saspro/astrobin_exporter.py +1010 -0
- setiastro/saspro/astrospike.py +153 -0
- setiastro/saspro/astrospike_python.py +1841 -0
- setiastro/saspro/autostretch.py +198 -0
- setiastro/saspro/backgroundneutral.py +602 -0
- setiastro/saspro/batch_convert.py +328 -0
- setiastro/saspro/batch_renamer.py +522 -0
- setiastro/saspro/blemish_blaster.py +491 -0
- setiastro/saspro/blink_comparator_pro.py +2926 -0
- setiastro/saspro/bundles.py +61 -0
- setiastro/saspro/bundles_dock.py +114 -0
- setiastro/saspro/cheat_sheet.py +213 -0
- setiastro/saspro/clahe.py +368 -0
- setiastro/saspro/comet_stacking.py +1442 -0
- setiastro/saspro/common_tr.py +107 -0
- setiastro/saspro/config.py +38 -0
- setiastro/saspro/config_bootstrap.py +40 -0
- setiastro/saspro/config_manager.py +316 -0
- setiastro/saspro/continuum_subtract.py +1617 -0
- setiastro/saspro/convo.py +1400 -0
- setiastro/saspro/convo_preset.py +414 -0
- setiastro/saspro/copyastro.py +190 -0
- setiastro/saspro/cosmicclarity.py +1589 -0
- setiastro/saspro/cosmicclarity_preset.py +407 -0
- setiastro/saspro/crop_dialog_pro.py +973 -0
- setiastro/saspro/crop_preset.py +189 -0
- setiastro/saspro/curve_editor_pro.py +2562 -0
- setiastro/saspro/curves_preset.py +375 -0
- setiastro/saspro/debayer.py +673 -0
- setiastro/saspro/debug_utils.py +29 -0
- setiastro/saspro/dnd_mime.py +35 -0
- setiastro/saspro/doc_manager.py +2664 -0
- setiastro/saspro/exoplanet_detector.py +2166 -0
- setiastro/saspro/file_utils.py +284 -0
- setiastro/saspro/fitsmodifier.py +748 -0
- setiastro/saspro/fix_bom.py +32 -0
- setiastro/saspro/free_torch_memory.py +48 -0
- setiastro/saspro/frequency_separation.py +1349 -0
- setiastro/saspro/function_bundle.py +1596 -0
- setiastro/saspro/generate_translations.py +3092 -0
- setiastro/saspro/ghs_dialog_pro.py +663 -0
- setiastro/saspro/ghs_preset.py +284 -0
- setiastro/saspro/graxpert.py +637 -0
- setiastro/saspro/graxpert_preset.py +287 -0
- setiastro/saspro/gui/__init__.py +0 -0
- setiastro/saspro/gui/main_window.py +8810 -0
- setiastro/saspro/gui/mixins/__init__.py +33 -0
- setiastro/saspro/gui/mixins/dock_mixin.py +362 -0
- setiastro/saspro/gui/mixins/file_mixin.py +450 -0
- setiastro/saspro/gui/mixins/geometry_mixin.py +403 -0
- setiastro/saspro/gui/mixins/header_mixin.py +441 -0
- setiastro/saspro/gui/mixins/mask_mixin.py +421 -0
- setiastro/saspro/gui/mixins/menu_mixin.py +389 -0
- setiastro/saspro/gui/mixins/theme_mixin.py +367 -0
- setiastro/saspro/gui/mixins/toolbar_mixin.py +1457 -0
- setiastro/saspro/gui/mixins/update_mixin.py +309 -0
- setiastro/saspro/gui/mixins/view_mixin.py +435 -0
- setiastro/saspro/gui/statistics_dialog.py +47 -0
- setiastro/saspro/halobgon.py +488 -0
- setiastro/saspro/header_viewer.py +448 -0
- setiastro/saspro/headless_utils.py +88 -0
- setiastro/saspro/histogram.py +756 -0
- setiastro/saspro/history_explorer.py +941 -0
- setiastro/saspro/i18n.py +168 -0
- setiastro/saspro/image_combine.py +417 -0
- setiastro/saspro/image_peeker_pro.py +1604 -0
- setiastro/saspro/imageops/__init__.py +37 -0
- setiastro/saspro/imageops/mdi_snap.py +292 -0
- setiastro/saspro/imageops/scnr.py +36 -0
- setiastro/saspro/imageops/starbasedwhitebalance.py +210 -0
- setiastro/saspro/imageops/stretch.py +236 -0
- setiastro/saspro/isophote.py +1182 -0
- setiastro/saspro/layers.py +208 -0
- setiastro/saspro/layers_dock.py +714 -0
- setiastro/saspro/lazy_imports.py +193 -0
- setiastro/saspro/legacy/__init__.py +2 -0
- setiastro/saspro/legacy/image_manager.py +2226 -0
- setiastro/saspro/legacy/numba_utils.py +3676 -0
- setiastro/saspro/legacy/xisf.py +1071 -0
- setiastro/saspro/linear_fit.py +537 -0
- setiastro/saspro/live_stacking.py +1841 -0
- setiastro/saspro/log_bus.py +5 -0
- setiastro/saspro/logging_config.py +460 -0
- setiastro/saspro/luminancerecombine.py +309 -0
- setiastro/saspro/main_helpers.py +201 -0
- setiastro/saspro/mask_creation.py +931 -0
- setiastro/saspro/masks_core.py +56 -0
- setiastro/saspro/mdi_widgets.py +353 -0
- setiastro/saspro/memory_utils.py +666 -0
- setiastro/saspro/metadata_patcher.py +75 -0
- setiastro/saspro/mfdeconv.py +3831 -0
- setiastro/saspro/mfdeconv_earlystop.py +71 -0
- setiastro/saspro/mfdeconvcudnn.py +3263 -0
- setiastro/saspro/mfdeconvsport.py +2382 -0
- setiastro/saspro/minorbodycatalog.py +567 -0
- setiastro/saspro/morphology.py +407 -0
- setiastro/saspro/multiscale_decomp.py +1293 -0
- setiastro/saspro/nbtorgb_stars.py +541 -0
- setiastro/saspro/numba_utils.py +3145 -0
- setiastro/saspro/numba_warmup.py +141 -0
- setiastro/saspro/ops/__init__.py +9 -0
- setiastro/saspro/ops/command_help_dialog.py +623 -0
- setiastro/saspro/ops/command_runner.py +217 -0
- setiastro/saspro/ops/commands.py +1594 -0
- setiastro/saspro/ops/script_editor.py +1102 -0
- setiastro/saspro/ops/scripts.py +1473 -0
- setiastro/saspro/ops/settings.py +637 -0
- setiastro/saspro/parallel_utils.py +554 -0
- setiastro/saspro/pedestal.py +121 -0
- setiastro/saspro/perfect_palette_picker.py +1071 -0
- setiastro/saspro/pipeline.py +110 -0
- setiastro/saspro/pixelmath.py +1604 -0
- setiastro/saspro/plate_solver.py +2445 -0
- setiastro/saspro/project_io.py +797 -0
- setiastro/saspro/psf_utils.py +136 -0
- setiastro/saspro/psf_viewer.py +549 -0
- setiastro/saspro/pyi_rthook_astroquery.py +95 -0
- setiastro/saspro/remove_green.py +331 -0
- setiastro/saspro/remove_stars.py +1599 -0
- setiastro/saspro/remove_stars_preset.py +404 -0
- setiastro/saspro/resources.py +501 -0
- setiastro/saspro/rgb_combination.py +208 -0
- setiastro/saspro/rgb_extract.py +19 -0
- setiastro/saspro/rgbalign.py +723 -0
- setiastro/saspro/runtime_imports.py +7 -0
- setiastro/saspro/runtime_torch.py +754 -0
- setiastro/saspro/save_options.py +73 -0
- setiastro/saspro/selective_color.py +1552 -0
- setiastro/saspro/sfcc.py +1472 -0
- setiastro/saspro/shortcuts.py +3043 -0
- setiastro/saspro/signature_insert.py +1102 -0
- setiastro/saspro/stacking_suite.py +18470 -0
- setiastro/saspro/star_alignment.py +7435 -0
- setiastro/saspro/star_alignment_preset.py +329 -0
- setiastro/saspro/star_metrics.py +49 -0
- setiastro/saspro/star_spikes.py +765 -0
- setiastro/saspro/star_stretch.py +507 -0
- setiastro/saspro/stat_stretch.py +538 -0
- setiastro/saspro/status_log_dock.py +78 -0
- setiastro/saspro/subwindow.py +3328 -0
- setiastro/saspro/supernovaasteroidhunter.py +1719 -0
- setiastro/saspro/swap_manager.py +99 -0
- setiastro/saspro/torch_backend.py +89 -0
- setiastro/saspro/torch_rejection.py +434 -0
- setiastro/saspro/translations/all_source_strings.json +3654 -0
- setiastro/saspro/translations/ar_translations.py +3865 -0
- setiastro/saspro/translations/de_translations.py +3749 -0
- setiastro/saspro/translations/es_translations.py +3939 -0
- setiastro/saspro/translations/fr_translations.py +3858 -0
- setiastro/saspro/translations/hi_translations.py +3571 -0
- setiastro/saspro/translations/integrate_translations.py +270 -0
- setiastro/saspro/translations/it_translations.py +3678 -0
- setiastro/saspro/translations/ja_translations.py +3601 -0
- setiastro/saspro/translations/pt_translations.py +3869 -0
- setiastro/saspro/translations/ru_translations.py +2848 -0
- setiastro/saspro/translations/saspro_ar.qm +0 -0
- setiastro/saspro/translations/saspro_ar.ts +255 -0
- setiastro/saspro/translations/saspro_de.qm +0 -0
- setiastro/saspro/translations/saspro_de.ts +253 -0
- setiastro/saspro/translations/saspro_es.qm +0 -0
- setiastro/saspro/translations/saspro_es.ts +12520 -0
- setiastro/saspro/translations/saspro_fr.qm +0 -0
- setiastro/saspro/translations/saspro_fr.ts +12514 -0
- setiastro/saspro/translations/saspro_hi.qm +0 -0
- setiastro/saspro/translations/saspro_hi.ts +257 -0
- setiastro/saspro/translations/saspro_it.qm +0 -0
- setiastro/saspro/translations/saspro_it.ts +12520 -0
- setiastro/saspro/translations/saspro_ja.qm +0 -0
- setiastro/saspro/translations/saspro_ja.ts +257 -0
- setiastro/saspro/translations/saspro_pt.qm +0 -0
- setiastro/saspro/translations/saspro_pt.ts +257 -0
- setiastro/saspro/translations/saspro_ru.qm +0 -0
- setiastro/saspro/translations/saspro_ru.ts +237 -0
- setiastro/saspro/translations/saspro_sw.qm +0 -0
- setiastro/saspro/translations/saspro_sw.ts +257 -0
- setiastro/saspro/translations/saspro_uk.qm +0 -0
- setiastro/saspro/translations/saspro_uk.ts +10771 -0
- setiastro/saspro/translations/saspro_zh.qm +0 -0
- setiastro/saspro/translations/saspro_zh.ts +12520 -0
- setiastro/saspro/translations/sw_translations.py +3671 -0
- setiastro/saspro/translations/uk_translations.py +3700 -0
- setiastro/saspro/translations/zh_translations.py +3675 -0
- setiastro/saspro/versioning.py +77 -0
- setiastro/saspro/view_bundle.py +1558 -0
- setiastro/saspro/wavescale_hdr.py +645 -0
- setiastro/saspro/wavescale_hdr_preset.py +101 -0
- setiastro/saspro/wavescalede.py +680 -0
- setiastro/saspro/wavescalede_preset.py +230 -0
- setiastro/saspro/wcs_update.py +374 -0
- setiastro/saspro/whitebalance.py +492 -0
- setiastro/saspro/widgets/__init__.py +48 -0
- setiastro/saspro/widgets/common_utilities.py +306 -0
- setiastro/saspro/widgets/graphics_views.py +122 -0
- setiastro/saspro/widgets/image_utils.py +518 -0
- setiastro/saspro/widgets/minigame/game.js +986 -0
- setiastro/saspro/widgets/minigame/index.html +53 -0
- setiastro/saspro/widgets/minigame/style.css +241 -0
- setiastro/saspro/widgets/preview_dialogs.py +280 -0
- setiastro/saspro/widgets/resource_monitor.py +237 -0
- setiastro/saspro/widgets/spinboxes.py +275 -0
- setiastro/saspro/widgets/themed_buttons.py +13 -0
- setiastro/saspro/widgets/wavelet_utils.py +331 -0
- setiastro/saspro/wimi.py +7996 -0
- setiastro/saspro/wims.py +578 -0
- setiastro/saspro/window_shelf.py +185 -0
- setiastro/saspro/xisf.py +1123 -0
- setiastrosuitepro-1.6.2.post1.dist-info/METADATA +278 -0
- setiastrosuitepro-1.6.2.post1.dist-info/RECORD +367 -0
- setiastrosuitepro-1.6.2.post1.dist-info/WHEEL +4 -0
- setiastrosuitepro-1.6.2.post1.dist-info/entry_points.txt +6 -0
- setiastrosuitepro-1.6.2.post1.dist-info/licenses/LICENSE +674 -0
- setiastrosuitepro-1.6.2.post1.dist-info/licenses/license.txt +2580 -0
|
@@ -0,0 +1,2926 @@
|
|
|
1
|
+
# pro/blink_comparator_pro.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
# ⬇️ keep your existing imports used by the code you pasted
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import time
|
|
8
|
+
import psutil
|
|
9
|
+
import numpy as np
|
|
10
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
11
|
+
|
|
12
|
+
from typing import Optional, List
|
|
13
|
+
from collections import defaultdict
|
|
14
|
+
# Qt
|
|
15
|
+
from PyQt6.QtCore import Qt, QTimer, QEvent, QPointF, QRectF, pyqtSignal, QSettings, QPoint, QCoreApplication
|
|
16
|
+
from PyQt6.QtGui import (QAction, QIcon, QImage, QPixmap, QBrush, QColor, QPalette,
|
|
17
|
+
QKeySequence, QWheelEvent, QShortcut, QDoubleValidator, QIntValidator)
|
|
18
|
+
from PyQt6.QtWidgets import (
|
|
19
|
+
QWidget, QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QToolButton,
|
|
20
|
+
QTreeWidget, QTreeWidgetItem, QFileDialog, QMessageBox, QProgressBar,
|
|
21
|
+
QAbstractItemView, QMenu, QSplitter, QStyle, QScrollArea, QSlider, QDoubleSpinBox, QProgressDialog, QComboBox, QLineEdit, QApplication, QGridLayout, QCheckBox, QInputDialog,
|
|
22
|
+
QMdiArea, QDialogButtonBox
|
|
23
|
+
)
|
|
24
|
+
from bisect import bisect_right
|
|
25
|
+
# 3rd-party (your code already expects these)
|
|
26
|
+
import cv2
|
|
27
|
+
import sep
|
|
28
|
+
import pyqtgraph as pg
|
|
29
|
+
from collections import OrderedDict
|
|
30
|
+
from setiastro.saspro.legacy.image_manager import load_image
|
|
31
|
+
|
|
32
|
+
from setiastro.saspro.imageops.stretch import stretch_color_image, stretch_mono_image, siril_style_autostretch
|
|
33
|
+
|
|
34
|
+
from setiastro.saspro.legacy.numba_utils import debayer_fits_fast, debayer_raw_fast
|
|
35
|
+
from setiastro.saspro.widgets.themed_buttons import themed_toolbtn
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
from setiastro.saspro.star_metrics import measure_stars_sep
|
|
39
|
+
|
|
40
|
+
def _percentile_scale(arr, lo=0.5, hi=99.5):
|
|
41
|
+
a = np.asarray(arr, dtype=np.float32)
|
|
42
|
+
p1 = np.nanpercentile(a, lo)
|
|
43
|
+
p2 = np.nanpercentile(a, hi)
|
|
44
|
+
if not np.isfinite(p1) or not np.isfinite(p2) or p2 <= p1:
|
|
45
|
+
return np.clip(a, 0.0, 1.0)
|
|
46
|
+
return np.clip((a - p1) / (p2 - p1), 0.0, 1.0)
|
|
47
|
+
|
|
48
|
+
# ⬇️ your SASv2 classes — paste them unchanged (Qt6 compatible already)
|
|
49
|
+
class MetricsPanel(QWidget):
|
|
50
|
+
"""2×2 grid with clickable dots and draggable thresholds."""
|
|
51
|
+
pointClicked = pyqtSignal(int, int)
|
|
52
|
+
thresholdChanged = pyqtSignal(int, float)
|
|
53
|
+
|
|
54
|
+
def __init__(self, parent=None):
|
|
55
|
+
super().__init__(parent)
|
|
56
|
+
layout = QVBoxLayout(self)
|
|
57
|
+
grid = QGridLayout()
|
|
58
|
+
layout.addLayout(grid)
|
|
59
|
+
|
|
60
|
+
# caching slots
|
|
61
|
+
self._orig_images = None # last list passed
|
|
62
|
+
self.metrics_data = None # list of 4 numpy arrays
|
|
63
|
+
self.flags = None # list of bools
|
|
64
|
+
self._threshold_initialized = [False]*4
|
|
65
|
+
self._open_previews = []
|
|
66
|
+
|
|
67
|
+
self.plots, self.scats, self.lines = [], [], []
|
|
68
|
+
titles = [self.tr("FWHM (px)"), self.tr("Eccentricity"), self.tr("Background"), self.tr("Star Count")]
|
|
69
|
+
for idx, title in enumerate(titles):
|
|
70
|
+
pw = pg.PlotWidget()
|
|
71
|
+
pw.setTitle(title)
|
|
72
|
+
pw.showGrid(x=True, y=True, alpha=0.3)
|
|
73
|
+
pw.getPlotItem().getViewBox().setBackgroundColor(
|
|
74
|
+
self.palette().color(self.backgroundRole())
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
scat = pg.ScatterPlotItem(pen=pg.mkPen(None),
|
|
78
|
+
brush=pg.mkBrush(100,100,255,200),
|
|
79
|
+
size=8)
|
|
80
|
+
scat.sigClicked.connect(lambda plot, pts, m=idx: self._on_point_click(m, pts))
|
|
81
|
+
pw.addItem(scat)
|
|
82
|
+
|
|
83
|
+
line = pg.InfiniteLine(pos=0, angle=0, movable=True,
|
|
84
|
+
pen=pg.mkPen('r', width=2))
|
|
85
|
+
line.sigPositionChangeFinished.connect(
|
|
86
|
+
lambda ln, m=idx: self._on_line_move(m, ln))
|
|
87
|
+
pw.addItem(line)
|
|
88
|
+
|
|
89
|
+
grid.addWidget(pw, idx//2, idx%2)
|
|
90
|
+
self.plots.append(pw)
|
|
91
|
+
self.scats.append(scat)
|
|
92
|
+
self.lines.append(line)
|
|
93
|
+
|
|
94
|
+
@staticmethod
|
|
95
|
+
def _compute_one(i_entry):
|
|
96
|
+
idx, entry = i_entry
|
|
97
|
+
img = entry['image_data']
|
|
98
|
+
|
|
99
|
+
# normalize to float32 mono [0..1] exactly like live
|
|
100
|
+
data = np.asarray(img)
|
|
101
|
+
if data.ndim == 3:
|
|
102
|
+
data = data.mean(axis=2)
|
|
103
|
+
if data.dtype == np.uint8:
|
|
104
|
+
data = data.astype(np.float32) / 255.0
|
|
105
|
+
elif data.dtype == np.uint16:
|
|
106
|
+
data = data.astype(np.float32) / 65535.0
|
|
107
|
+
else:
|
|
108
|
+
data = data.astype(np.float32, copy=False)
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
# --- match old Blink’s SEP pipeline ---
|
|
112
|
+
bkg = sep.Background(data)
|
|
113
|
+
back = bkg.back()
|
|
114
|
+
try:
|
|
115
|
+
gr = float(bkg.globalrms)
|
|
116
|
+
except Exception:
|
|
117
|
+
# some SEP builds only expose per-cell rms map
|
|
118
|
+
gr = float(np.median(np.asarray(bkg.rms(), dtype=np.float32)))
|
|
119
|
+
|
|
120
|
+
cat = sep.extract(
|
|
121
|
+
data - back,
|
|
122
|
+
thresh=7.0,
|
|
123
|
+
err=gr,
|
|
124
|
+
minarea=16,
|
|
125
|
+
clean=True,
|
|
126
|
+
deblend_nthresh=32,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
if len(cat) > 0:
|
|
130
|
+
# FWHM via geometric-mean sigma (old Blink)
|
|
131
|
+
sig = np.sqrt(cat['a'] * cat['b']).astype(np.float32, copy=False)
|
|
132
|
+
fwhm = float(np.nanmedian(2.3548 * sig))
|
|
133
|
+
|
|
134
|
+
# TRUE eccentricity: e = sqrt(1 - (b/a)^2) (old Blink)
|
|
135
|
+
# guard against divide-by-zero and NaNs
|
|
136
|
+
a = np.maximum(cat['a'].astype(np.float32, copy=False), 1e-12)
|
|
137
|
+
b = np.clip(cat['b'].astype(np.float32, copy=False), 0.0, None)
|
|
138
|
+
q = np.clip(b / a, 0.0, 1.0) # b/a
|
|
139
|
+
e_true = np.sqrt(np.maximum(0.0, 1.0 - q * q))
|
|
140
|
+
ecc = float(np.nanmedian(e_true))
|
|
141
|
+
|
|
142
|
+
star_cnt = int(len(cat))
|
|
143
|
+
else:
|
|
144
|
+
fwhm, ecc, star_cnt = np.nan, np.nan, 0
|
|
145
|
+
|
|
146
|
+
except Exception:
|
|
147
|
+
# same sentinel behavior as before
|
|
148
|
+
fwhm, ecc, star_cnt = 10.0, 1.0, 0
|
|
149
|
+
|
|
150
|
+
orig_back = entry.get('orig_background', np.nan)
|
|
151
|
+
return idx, fwhm, ecc, orig_back, star_cnt
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def compute_all_metrics(self, loaded_images):
|
|
155
|
+
"""Run SEP over the full list in parallel using threads and cache results."""
|
|
156
|
+
n = len(loaded_images)
|
|
157
|
+
if n == 0:
|
|
158
|
+
# Clear any previous state and bail
|
|
159
|
+
self._orig_images = []
|
|
160
|
+
self.metrics_data = [np.array([])]*4
|
|
161
|
+
self.flags = []
|
|
162
|
+
self._threshold_initialized = [False]*4
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
# Heads-up dialog (as you already had)
|
|
166
|
+
settings = QSettings()
|
|
167
|
+
show = settings.value("metrics/showWarning", True, type=bool)
|
|
168
|
+
if show:
|
|
169
|
+
msg = QMessageBox(self)
|
|
170
|
+
msg.setWindowTitle(self.tr("Heads-up"))
|
|
171
|
+
msg.setText(self.tr(
|
|
172
|
+
"This is going to use ALL your CPU cores and the UI may lock up until it finishes.\n\n"
|
|
173
|
+
"Continue?"
|
|
174
|
+
))
|
|
175
|
+
msg.setStandardButtons(QMessageBox.StandardButton.Yes |
|
|
176
|
+
QMessageBox.StandardButton.No)
|
|
177
|
+
cb = QCheckBox(self.tr("Don't show again"), msg)
|
|
178
|
+
msg.setCheckBox(cb)
|
|
179
|
+
if msg.exec() != QMessageBox.StandardButton.Yes:
|
|
180
|
+
return
|
|
181
|
+
if cb.isChecked():
|
|
182
|
+
settings.setValue("metrics/showWarning", False)
|
|
183
|
+
|
|
184
|
+
# pre-allocate result arrays
|
|
185
|
+
m0 = np.full(n, np.nan, dtype=np.float32) # FWHM
|
|
186
|
+
m1 = np.full(n, np.nan, dtype=np.float32) # Eccentricity
|
|
187
|
+
m2 = np.full(n, np.nan, dtype=np.float32) # Background (cached)
|
|
188
|
+
m3 = np.full(n, np.nan, dtype=np.float32) # Star count
|
|
189
|
+
flags = [e.get('flagged', False) for e in loaded_images]
|
|
190
|
+
|
|
191
|
+
# progress dialog
|
|
192
|
+
prog = QProgressDialog(self.tr("Computing frame metrics…"), self.tr("Cancel"), 0, n, self)
|
|
193
|
+
prog.setWindowModality(Qt.WindowModality.WindowModal)
|
|
194
|
+
prog.setMinimumDuration(0)
|
|
195
|
+
prog.setValue(0)
|
|
196
|
+
prog.show()
|
|
197
|
+
QApplication.processEvents()
|
|
198
|
+
|
|
199
|
+
workers = min(os.cpu_count() or 1, 60)
|
|
200
|
+
tasks = [(i, loaded_images[i]) for i in range(n)]
|
|
201
|
+
done = 0 # <-- FIX: initialize before incrementing
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
with ThreadPoolExecutor(max_workers=workers) as exe:
|
|
205
|
+
futures = {exe.submit(self._compute_one, t): t[0] for t in tasks}
|
|
206
|
+
for fut in as_completed(futures):
|
|
207
|
+
if prog.wasCanceled():
|
|
208
|
+
break
|
|
209
|
+
try:
|
|
210
|
+
idx, fwhm, ecc, orig_back, star_cnt = fut.result()
|
|
211
|
+
except Exception:
|
|
212
|
+
# On failure, leave NaNs/sentinels and continue
|
|
213
|
+
idx, fwhm, ecc, orig_back, star_cnt = futures[fut], np.nan, np.nan, np.nan, 0
|
|
214
|
+
m0[idx], m1[idx], m2[idx], m3[idx] = fwhm, ecc, orig_back, float(star_cnt)
|
|
215
|
+
done += 1
|
|
216
|
+
prog.setValue(done)
|
|
217
|
+
QApplication.processEvents()
|
|
218
|
+
finally:
|
|
219
|
+
prog.close()
|
|
220
|
+
|
|
221
|
+
# stash results
|
|
222
|
+
self._orig_images = loaded_images
|
|
223
|
+
self.metrics_data = [m0, m1, m2, m3]
|
|
224
|
+
self.flags = flags
|
|
225
|
+
self._threshold_initialized = [False]*4
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def plot(self, loaded_images, indices=None):
|
|
229
|
+
"""
|
|
230
|
+
Plot metrics for loaded_images.
|
|
231
|
+
If indices is given (list/array of ints), only those frames are shown.
|
|
232
|
+
"""
|
|
233
|
+
# empty clear
|
|
234
|
+
if not loaded_images:
|
|
235
|
+
self.metrics_data = None
|
|
236
|
+
for pw, scat, line in zip(self.plots, self.scats, self.lines):
|
|
237
|
+
scat.setData(x=[], y=[])
|
|
238
|
+
line.setPos(0)
|
|
239
|
+
pw.getPlotItem().getViewBox().update()
|
|
240
|
+
pw.repaint()
|
|
241
|
+
return
|
|
242
|
+
|
|
243
|
+
# compute & cache on first call or new image list
|
|
244
|
+
if self._orig_images is not loaded_images or self.metrics_data is None:
|
|
245
|
+
self.compute_all_metrics(loaded_images)
|
|
246
|
+
|
|
247
|
+
# default to all indices
|
|
248
|
+
if indices is None:
|
|
249
|
+
indices = np.arange(len(loaded_images), dtype=int)
|
|
250
|
+
|
|
251
|
+
# store for later recoloring
|
|
252
|
+
self._cur_indices = np.array(indices, dtype=int)
|
|
253
|
+
|
|
254
|
+
x = np.arange(len(indices))
|
|
255
|
+
|
|
256
|
+
for m, (pw, scat, line) in enumerate(zip(self.plots, self.scats, self.lines)):
|
|
257
|
+
arr = self.metrics_data[m]
|
|
258
|
+
y = arr[indices]
|
|
259
|
+
|
|
260
|
+
brushes = [
|
|
261
|
+
pg.mkBrush(255,0,0,200) if self.flags[idx] else pg.mkBrush(100,100,255,200)
|
|
262
|
+
for idx in indices
|
|
263
|
+
]
|
|
264
|
+
scat.setData(x=x, y=y, brush=brushes, pen=pg.mkPen(None), size=8)
|
|
265
|
+
|
|
266
|
+
# initialize threshold line once
|
|
267
|
+
if not self._threshold_initialized[m]:
|
|
268
|
+
mx, mn = np.nanmax(y), np.nanmin(y)
|
|
269
|
+
span = mx-mn if mx!=mn else 1.0
|
|
270
|
+
line.setPos((mx+0.05*span) if m<3 else 0)
|
|
271
|
+
self._threshold_initialized[m] = True
|
|
272
|
+
|
|
273
|
+
def _refresh_scatter_colors(self):
|
|
274
|
+
if not hasattr(self, "_cur_indices") or self._cur_indices is None:
|
|
275
|
+
# default to all indices
|
|
276
|
+
self._cur_indices = np.arange(len(self.flags or []), dtype=int)
|
|
277
|
+
|
|
278
|
+
for scat in self.scats:
|
|
279
|
+
x, y = scat.getData()[:2]
|
|
280
|
+
brushes = []
|
|
281
|
+
for xi in x:
|
|
282
|
+
li = int(xi)
|
|
283
|
+
gi = self._cur_indices[li] if 0 <= li < len(self._cur_indices) else 0
|
|
284
|
+
brushes.append(pg.mkBrush(255,0,0,200) if (self.flags and gi < len(self.flags) and self.flags[gi])
|
|
285
|
+
else pg.mkBrush(100,100,255,200))
|
|
286
|
+
scat.setData(x=x, y=y, brush=brushes)
|
|
287
|
+
|
|
288
|
+
def remove_frames(self, removed_idx: List[int]):
|
|
289
|
+
"""
|
|
290
|
+
Drop frames from cached arrays and flags (no recomputation).
|
|
291
|
+
removed_idx: global indices in the *old* ordering.
|
|
292
|
+
"""
|
|
293
|
+
if self.metrics_data is None or not removed_idx:
|
|
294
|
+
return
|
|
295
|
+
import numpy as _np
|
|
296
|
+
removed = _np.unique(_np.asarray(removed_idx, dtype=int))
|
|
297
|
+
n = len(self.flags or [])
|
|
298
|
+
if n == 0:
|
|
299
|
+
return
|
|
300
|
+
keep = _np.ones(n, dtype=bool)
|
|
301
|
+
keep[removed[removed < n]] = False
|
|
302
|
+
|
|
303
|
+
# shrink cached arrays and flags
|
|
304
|
+
self.metrics_data = [arr[keep] for arr in self.metrics_data]
|
|
305
|
+
if self.flags is not None:
|
|
306
|
+
self.flags = list(_np.asarray(self.flags)[keep])
|
|
307
|
+
|
|
308
|
+
def refresh_colors_and_status(self):
|
|
309
|
+
"""Recolor dots based on self.flags; caller should also update the window status."""
|
|
310
|
+
self._refresh_scatter_colors()
|
|
311
|
+
|
|
312
|
+
def _on_point_click(self, metric_idx, points):
|
|
313
|
+
for pt in points:
|
|
314
|
+
# local index on the currently plotted subset
|
|
315
|
+
li = int(round(pt.pos().x()))
|
|
316
|
+
|
|
317
|
+
# map to global index
|
|
318
|
+
if hasattr(self, "_cur_indices") and self._cur_indices is not None and 0 <= li < len(self._cur_indices):
|
|
319
|
+
gi = int(self._cur_indices[li])
|
|
320
|
+
else:
|
|
321
|
+
gi = li # fallback (e.g., "All")
|
|
322
|
+
|
|
323
|
+
mods = QApplication.keyboardModifiers()
|
|
324
|
+
if mods & Qt.KeyboardModifier.ShiftModifier:
|
|
325
|
+
# preview the correct global frame
|
|
326
|
+
entry = self._orig_images[gi]
|
|
327
|
+
img = entry['image_data']
|
|
328
|
+
is_mono= entry.get('is_mono', False)
|
|
329
|
+
dlg = ImagePreviewDialog(img, is_mono)
|
|
330
|
+
dlg.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
|
|
331
|
+
dlg.show()
|
|
332
|
+
self._open_previews.append(dlg)
|
|
333
|
+
dlg.destroyed.connect(lambda _=None, d=dlg:
|
|
334
|
+
self._open_previews.remove(d) if d in self._open_previews else None)
|
|
335
|
+
else:
|
|
336
|
+
# emit the correct global frame index so Blink flags the right leaf
|
|
337
|
+
self.pointClicked.emit(metric_idx, gi)
|
|
338
|
+
|
|
339
|
+
def _on_line_move(self, metric_idx, line):
|
|
340
|
+
self.thresholdChanged.emit(metric_idx, line.value())
|
|
341
|
+
|
|
342
|
+
class MetricsWindow(QWidget):
|
|
343
|
+
def __init__(self, parent=None):
|
|
344
|
+
super().__init__(parent, Qt.WindowType.Window)
|
|
345
|
+
self._thresholds_per_group: dict[str, List[float|None]] = {}
|
|
346
|
+
self.setWindowTitle(self.tr("Frame Metrics"))
|
|
347
|
+
self.resize(800, 600)
|
|
348
|
+
|
|
349
|
+
vbox = QVBoxLayout(self)
|
|
350
|
+
|
|
351
|
+
# ← **new** instructions label
|
|
352
|
+
instr = QLabel(self.tr(
|
|
353
|
+
"Instructions:\n"
|
|
354
|
+
" • Use the filter dropdown to restrict by FILTER.\n"
|
|
355
|
+
" • Click a dot to flag/unflag a frame.\n"
|
|
356
|
+
" • Shift-click a dot to preview the image.\n"
|
|
357
|
+
" • Drag the red lines to set thresholds."
|
|
358
|
+
),
|
|
359
|
+
self
|
|
360
|
+
)
|
|
361
|
+
instr.setWordWrap(True)
|
|
362
|
+
instr.setStyleSheet("color: #ccc; font-size: 12px;")
|
|
363
|
+
vbox.addWidget(instr)
|
|
364
|
+
|
|
365
|
+
# → filter selector
|
|
366
|
+
self.group_combo = QComboBox(self)
|
|
367
|
+
self.group_combo.addItem(self.tr("All"))
|
|
368
|
+
self.group_combo.currentTextChanged.connect(self._on_group_change)
|
|
369
|
+
vbox.addWidget(self.group_combo)
|
|
370
|
+
|
|
371
|
+
# → the 2×2 metrics panel
|
|
372
|
+
self.metrics_panel = MetricsPanel(self)
|
|
373
|
+
vbox.addWidget(self.metrics_panel)
|
|
374
|
+
|
|
375
|
+
# keep status up‐to‐date when things happen
|
|
376
|
+
self.metrics_panel.thresholdChanged.connect(self._update_status)
|
|
377
|
+
self.metrics_panel.pointClicked .connect(self._update_status)
|
|
378
|
+
|
|
379
|
+
# ← status label
|
|
380
|
+
self.status_label = QLabel("", self)
|
|
381
|
+
vbox.addWidget(self.status_label)
|
|
382
|
+
|
|
383
|
+
# internal storage
|
|
384
|
+
self._all_images = []
|
|
385
|
+
self._current_indices: Optional[List[int]] = None
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _update_status(self, *args):
|
|
389
|
+
"""Recompute and show: Flagged Items X / Y (Z%). Robust to stale indices."""
|
|
390
|
+
flags = getattr(self.metrics_panel, "flags", []) or []
|
|
391
|
+
nflags = len(flags)
|
|
392
|
+
|
|
393
|
+
# what subset are we currently looking at?
|
|
394
|
+
idxs = self._current_indices if self._current_indices is not None else range(nflags)
|
|
395
|
+
|
|
396
|
+
total = 0
|
|
397
|
+
flagged_cnt = 0
|
|
398
|
+
|
|
399
|
+
for i in idxs:
|
|
400
|
+
# i can be np.int64 or a stale index from before a move/delete
|
|
401
|
+
j = int(i)
|
|
402
|
+
if 0 <= j < nflags:
|
|
403
|
+
total += 1
|
|
404
|
+
if flags[j]:
|
|
405
|
+
flagged_cnt += 1
|
|
406
|
+
else:
|
|
407
|
+
# stale index → just skip it
|
|
408
|
+
continue
|
|
409
|
+
|
|
410
|
+
pct = (flagged_cnt / total * 100.0) if total else 0.0
|
|
411
|
+
self.status_label.setText(self.tr("Flagged Items {0}/{1} ({2:.1f}%)").format(flagged_cnt, total, pct))
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def set_images(self, loaded_images, order=None):
|
|
415
|
+
self._all_images = loaded_images
|
|
416
|
+
self._order_all = list(order) if order is not None else list(range(len(loaded_images)))
|
|
417
|
+
|
|
418
|
+
# ─── rebuild the combo-list of FILTER groups ─────────────
|
|
419
|
+
self.group_combo.blockSignals(True)
|
|
420
|
+
self.group_combo.clear()
|
|
421
|
+
self.group_combo.addItem(self.tr("All"))
|
|
422
|
+
seen = set()
|
|
423
|
+
for entry in loaded_images:
|
|
424
|
+
filt = entry.get('header', {}).get('FILTER', 'Unknown')
|
|
425
|
+
if filt not in seen:
|
|
426
|
+
seen.add(filt)
|
|
427
|
+
self.group_combo.addItem(filt)
|
|
428
|
+
self.group_combo.blockSignals(False)
|
|
429
|
+
|
|
430
|
+
# ─── reset & seed per-group thresholds ────────────────────
|
|
431
|
+
self._thresholds_per_group.clear()
|
|
432
|
+
self._thresholds_per_group["All"] = [None]*4
|
|
433
|
+
for entry in loaded_images:
|
|
434
|
+
filt = entry.get('header', {}).get('FILTER', 'Unknown')
|
|
435
|
+
if filt not in self._thresholds_per_group:
|
|
436
|
+
self._thresholds_per_group[filt] = [None]*4
|
|
437
|
+
|
|
438
|
+
# ─── compute & cache all metrics once ────────────────────
|
|
439
|
+
self.metrics_panel.compute_all_metrics(self._all_images)
|
|
440
|
+
|
|
441
|
+
# ─── show “All” by default and plot ───────────────────────
|
|
442
|
+
self._current_indices = self._order_all
|
|
443
|
+
self._apply_thresholds("All")
|
|
444
|
+
self.metrics_panel.plot(self._all_images, indices=self._current_indices)
|
|
445
|
+
self._update_status()
|
|
446
|
+
|
|
447
|
+
def _reindex_list_after_remove(self, lst: List[int] | None, removed: List[int]) -> List[int] | None:
|
|
448
|
+
"""Return lst with removed indices dropped and others shifted."""
|
|
449
|
+
if lst is None:
|
|
450
|
+
return None
|
|
451
|
+
from bisect import bisect_right
|
|
452
|
+
removed = sorted(set(int(i) for i in removed))
|
|
453
|
+
rset = set(removed)
|
|
454
|
+
def new_idx(old):
|
|
455
|
+
return old - bisect_right(removed, old)
|
|
456
|
+
return [new_idx(i) for i in lst if i not in rset]
|
|
457
|
+
|
|
458
|
+
def _rebuild_groups_from_images(self):
|
|
459
|
+
"""Rebuild the FILTER combobox from current _all_images, keep current if possible."""
|
|
460
|
+
cur = self.group_combo.currentText()
|
|
461
|
+
self.group_combo.blockSignals(True)
|
|
462
|
+
self.group_combo.clear()
|
|
463
|
+
self.group_combo.addItem(self.tr("All"))
|
|
464
|
+
seen = set()
|
|
465
|
+
for entry in self._all_images:
|
|
466
|
+
filt = (entry.get('header', {}) or {}).get('FILTER', 'Unknown')
|
|
467
|
+
if filt not in seen:
|
|
468
|
+
self.group_combo.addItem(filt)
|
|
469
|
+
seen.add(filt)
|
|
470
|
+
self.group_combo.blockSignals(False)
|
|
471
|
+
# restore selection if still valid
|
|
472
|
+
idx = self.group_combo.findText(cur)
|
|
473
|
+
if idx >= 0:
|
|
474
|
+
self.group_combo.setCurrentIndex(idx)
|
|
475
|
+
else:
|
|
476
|
+
self.group_combo.setCurrentIndex(0)
|
|
477
|
+
|
|
478
|
+
def remove_indices(self, removed: List[int]):
|
|
479
|
+
"""
|
|
480
|
+
Called when some frames were deleted/moved out of the list.
|
|
481
|
+
Does NOT recompute metrics. Just trims cached arrays and re-plots.
|
|
482
|
+
"""
|
|
483
|
+
if not removed:
|
|
484
|
+
return
|
|
485
|
+
removed = sorted(set(int(i) for i in removed))
|
|
486
|
+
|
|
487
|
+
# 1) shrink cached arrays in the panel
|
|
488
|
+
self.metrics_panel.remove_frames(removed)
|
|
489
|
+
|
|
490
|
+
# 2) update our “master” list and ordering (object identity unchanged)
|
|
491
|
+
# (BlinkTab will already have mutated the underlying list for us)
|
|
492
|
+
self._order_all = self._reindex_list_after_remove(self._order_all, removed)
|
|
493
|
+
self._current_indices = self._reindex_list_after_remove(self._current_indices, removed)
|
|
494
|
+
|
|
495
|
+
# 3) rebuild group list (filters may have disappeared)
|
|
496
|
+
self._rebuild_groups_from_images()
|
|
497
|
+
|
|
498
|
+
# 4) replot current group with updated order
|
|
499
|
+
indices = self._current_indices if self._current_indices is not None else self._order_all
|
|
500
|
+
self.metrics_panel.plot(self._all_images, indices=indices)
|
|
501
|
+
|
|
502
|
+
# 5) recolor & status
|
|
503
|
+
self.metrics_panel.refresh_colors_and_status()
|
|
504
|
+
self._update_status()
|
|
505
|
+
|
|
506
|
+
def _on_group_change(self, name: str):
|
|
507
|
+
if name == self.tr("All"):
|
|
508
|
+
self._current_indices = self._order_all
|
|
509
|
+
else:
|
|
510
|
+
# preserve Tree order inside the chosen FILTER
|
|
511
|
+
filt = name
|
|
512
|
+
self._current_indices = [
|
|
513
|
+
i for i in self._order_all
|
|
514
|
+
if (self._all_images[i].get('header', {}) or {}).get('FILTER', 'Unknown') == filt
|
|
515
|
+
]
|
|
516
|
+
self._apply_thresholds(name)
|
|
517
|
+
self.metrics_panel.plot(self._all_images, indices=self._current_indices)
|
|
518
|
+
|
|
519
|
+
def _on_panel_threshold_change(self, metric_idx: int, new_val: float):
|
|
520
|
+
"""User just dragged a threshold line."""
|
|
521
|
+
grp = self.group_combo.currentText()
|
|
522
|
+
# save it for this group
|
|
523
|
+
self._thresholds_per_group[grp][metric_idx] = new_val
|
|
524
|
+
|
|
525
|
+
# (if you also want immediate re-flagging in the tree, keep your BlinkTab logic hooked here)
|
|
526
|
+
|
|
527
|
+
def _apply_thresholds(self, group_name: str):
|
|
528
|
+
"""Restore the four InfiniteLine positions for a given group."""
|
|
529
|
+
saved = self._thresholds_per_group.get(group_name, [None]*4)
|
|
530
|
+
for idx, line in enumerate(self.metrics_panel.lines):
|
|
531
|
+
if saved[idx] is not None:
|
|
532
|
+
line.setPos(saved[idx])
|
|
533
|
+
# if saved[idx] is None, we leave it so that
|
|
534
|
+
# the panel’s own auto-init can run on next plot()
|
|
535
|
+
|
|
536
|
+
def update_metrics(self, loaded_images, order=None):
|
|
537
|
+
if loaded_images is not self._all_images:
|
|
538
|
+
self.set_images(loaded_images, order=order)
|
|
539
|
+
else:
|
|
540
|
+
if order is not None:
|
|
541
|
+
self._order_all = list(order)
|
|
542
|
+
# re-plot the current group with the new ordering
|
|
543
|
+
self._on_group_change(self.group_combo.currentText())
|
|
544
|
+
|
|
545
|
+
class BlinkComparatorPro(QDialog):
|
|
546
|
+
sendToStacking = pyqtSignal(list, str)
|
|
547
|
+
|
|
548
|
+
def __init__(self, doc_manager=None, parent=None):
|
|
549
|
+
super().__init__(parent)
|
|
550
|
+
self.doc_manager = doc_manager
|
|
551
|
+
self.setWindowTitle(self.tr("Blink Comparator"))
|
|
552
|
+
self.resize(1200, 700)
|
|
553
|
+
|
|
554
|
+
self.tab = BlinkTab(doc_manager=self.doc_manager, parent=self)
|
|
555
|
+
layout = QVBoxLayout(self)
|
|
556
|
+
layout.addWidget(self.tab)
|
|
557
|
+
self.setLayout(layout)
|
|
558
|
+
|
|
559
|
+
# bridge tab → dialog
|
|
560
|
+
self.tab.sendToStacking.connect(self.sendToStacking)
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
class BlinkTab(QWidget):
|
|
564
|
+
imagesChanged = pyqtSignal(int)
|
|
565
|
+
sendToStacking = pyqtSignal(list, str)
|
|
566
|
+
def __init__(self, image_manager=None, doc_manager=None, parent=None):
|
|
567
|
+
super().__init__(parent)
|
|
568
|
+
|
|
569
|
+
self.image_paths = [] # Store the file paths of loaded images
|
|
570
|
+
self.loaded_images = [] # Store the image objects (as numpy arrays)
|
|
571
|
+
self.image_labels = [] # Store corresponding file names for the TreeWidget
|
|
572
|
+
self.doc_manager = doc_manager # ⬅️ new
|
|
573
|
+
self.image_manager = image_manager # ⬅️ ensure we don't use it
|
|
574
|
+
self.metrics_window: Optional[MetricsWindow] = None
|
|
575
|
+
self.zoom_level = 0.5 # Default zoom level
|
|
576
|
+
self.dragging = False # Track whether the mouse is dragging
|
|
577
|
+
self.last_mouse_pos = None # Store the last mouse position
|
|
578
|
+
self.thresholds_by_group: dict[str, List[float|None]] = {}
|
|
579
|
+
self.aggressive_stretch_enabled = False
|
|
580
|
+
self.current_sigma = 3.7
|
|
581
|
+
self.current_pixmap = None
|
|
582
|
+
self._last_preview_name = None
|
|
583
|
+
self._pending_preview_timer = QTimer(self)
|
|
584
|
+
self._pending_preview_timer.setSingleShot(True)
|
|
585
|
+
self._pending_preview_timer.setInterval(40) # 40–80ms is plenty
|
|
586
|
+
self._pending_preview_item = None
|
|
587
|
+
self._pending_preview_timer.timeout.connect(self._do_preview_update)
|
|
588
|
+
self.play_fps = 1 # default fps (200 ms/frame)
|
|
589
|
+
self._view_center_norm = None
|
|
590
|
+
self.initUI()
|
|
591
|
+
self.init_shortcuts()
|
|
592
|
+
|
|
593
|
+
def initUI(self):
|
|
594
|
+
main_layout = QHBoxLayout(self)
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
# Create a QSplitter to allow resizing between left and right panels
|
|
598
|
+
splitter = QSplitter(Qt.Orientation.Horizontal, self)
|
|
599
|
+
|
|
600
|
+
# Left Column for the file loading and TreeView
|
|
601
|
+
left_widget = QWidget(self)
|
|
602
|
+
left_layout = QVBoxLayout(left_widget)
|
|
603
|
+
|
|
604
|
+
# --------------------
|
|
605
|
+
# Instruction Label
|
|
606
|
+
# --------------------
|
|
607
|
+
instruction_text = self.tr("Press 'F' to flag/unflag an image.\nRight-click on an image for more options.")
|
|
608
|
+
self.instruction_label = QLabel(instruction_text, self)
|
|
609
|
+
self.instruction_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
610
|
+
self.instruction_label.setWordWrap(True)
|
|
611
|
+
self.instruction_label.setStyleSheet("font-weight: bold;") # Optional: Make the text bold for emphasis
|
|
612
|
+
|
|
613
|
+
self.instruction_label.setStyleSheet(f"""
|
|
614
|
+
QLabel {{
|
|
615
|
+
font-weight: bold;
|
|
616
|
+
}}
|
|
617
|
+
""")
|
|
618
|
+
|
|
619
|
+
# Add the instruction label to the left layout at the top
|
|
620
|
+
left_layout.addWidget(self.instruction_label)
|
|
621
|
+
|
|
622
|
+
# Horizontal layout for "Select Images" and "Select Directory" buttons
|
|
623
|
+
button_layout = QHBoxLayout()
|
|
624
|
+
|
|
625
|
+
# "Select Images" Button
|
|
626
|
+
self.fileButton = QPushButton(self.tr('Select Images'), self)
|
|
627
|
+
self.fileButton.clicked.connect(self.openFileDialog)
|
|
628
|
+
button_layout.addWidget(self.fileButton)
|
|
629
|
+
|
|
630
|
+
# "Select Directory" Button
|
|
631
|
+
self.dirButton = QPushButton(self.tr('Select Directory'), self)
|
|
632
|
+
self.dirButton.clicked.connect(self.openDirectoryDialog)
|
|
633
|
+
button_layout.addWidget(self.dirButton)
|
|
634
|
+
|
|
635
|
+
self.addButton = QPushButton(self.tr("Add Additional"), self)
|
|
636
|
+
self.addButton.clicked.connect(self.addAdditionalImages)
|
|
637
|
+
button_layout.addWidget(self.addButton)
|
|
638
|
+
|
|
639
|
+
left_layout.addLayout(button_layout)
|
|
640
|
+
|
|
641
|
+
self.metrics_button = QPushButton(self.tr("Show Metrics"), self)
|
|
642
|
+
self.metrics_button.clicked.connect(self.show_metrics)
|
|
643
|
+
left_layout.addWidget(self.metrics_button)
|
|
644
|
+
|
|
645
|
+
push_row = QHBoxLayout()
|
|
646
|
+
self.send_lights_btn = QPushButton(self.tr("→ Stacking: Lights"), self)
|
|
647
|
+
self.send_lights_btn.setToolTip(self.tr("Send selected (or all) blink files to the Stacking Suite → Light tab"))
|
|
648
|
+
self.send_lights_btn.clicked.connect(self._send_to_stacking_lights)
|
|
649
|
+
push_row.addWidget(self.send_lights_btn)
|
|
650
|
+
|
|
651
|
+
self.send_integ_btn = QPushButton(self.tr("→ Stacking: Integration"), self)
|
|
652
|
+
self.send_integ_btn.setToolTip(self.tr("Send selected (or all) blink files to the Stacking Suite → Image Integration tab"))
|
|
653
|
+
self.send_integ_btn.clicked.connect(self._send_to_stacking_integration)
|
|
654
|
+
push_row.addWidget(self.send_integ_btn)
|
|
655
|
+
|
|
656
|
+
left_layout.addLayout(push_row)
|
|
657
|
+
|
|
658
|
+
# Playback controls (left arrow, play, pause, right arrow)
|
|
659
|
+
playback_controls_layout = QHBoxLayout()
|
|
660
|
+
|
|
661
|
+
# Left Arrow Button
|
|
662
|
+
self.left_arrow_button = QPushButton(self)
|
|
663
|
+
self.left_arrow_button.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowLeft))
|
|
664
|
+
self.left_arrow_button.clicked.connect(self.previous_item)
|
|
665
|
+
playback_controls_layout.addWidget(self.left_arrow_button)
|
|
666
|
+
|
|
667
|
+
# Play Button
|
|
668
|
+
self.play_button = QPushButton(self)
|
|
669
|
+
self.play_button.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaPlay))
|
|
670
|
+
self.play_button.clicked.connect(self.start_playback)
|
|
671
|
+
playback_controls_layout.addWidget(self.play_button)
|
|
672
|
+
|
|
673
|
+
# Pause Button
|
|
674
|
+
self.pause_button = QPushButton(self)
|
|
675
|
+
self.pause_button.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaPause))
|
|
676
|
+
self.pause_button.clicked.connect(self.stop_playback)
|
|
677
|
+
playback_controls_layout.addWidget(self.pause_button)
|
|
678
|
+
|
|
679
|
+
# Right Arrow Button
|
|
680
|
+
self.right_arrow_button = QPushButton(self)
|
|
681
|
+
self.right_arrow_button.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowRight))
|
|
682
|
+
self.right_arrow_button.clicked.connect(self.next_item)
|
|
683
|
+
playback_controls_layout.addWidget(self.right_arrow_button)
|
|
684
|
+
|
|
685
|
+
left_layout.addLayout(playback_controls_layout)
|
|
686
|
+
|
|
687
|
+
# ----- Playback speed controls -----
|
|
688
|
+
# ----- Playback speed controls (0.1–10.0 fps) -----
|
|
689
|
+
speed_layout = QHBoxLayout()
|
|
690
|
+
|
|
691
|
+
speed_label = QLabel(self.tr("Speed:"), self)
|
|
692
|
+
speed_layout.addWidget(speed_label)
|
|
693
|
+
|
|
694
|
+
# Slider maps 1..100 -> 0.1..10.0 fps
|
|
695
|
+
self.speed_slider = QSlider(Qt.Orientation.Horizontal, self)
|
|
696
|
+
self.speed_slider.setRange(1, 100)
|
|
697
|
+
self.speed_slider.setValue(int(round(self.play_fps * 10))) # play_fps is float
|
|
698
|
+
self.speed_slider.setTickPosition(QSlider.TickPosition.NoTicks)
|
|
699
|
+
self.speed_slider.setToolTip(self.tr("Playback speed (0.1–10.0 fps)"))
|
|
700
|
+
speed_layout.addWidget(self.speed_slider, 1)
|
|
701
|
+
|
|
702
|
+
# Custom float spin (your class)
|
|
703
|
+
self.speed_spin = CustomDoubleSpinBox(
|
|
704
|
+
minimum=0.1, maximum=10.0, initial=self.play_fps, step=0.1, parent=self
|
|
705
|
+
)
|
|
706
|
+
speed_layout.addWidget(self.speed_spin)
|
|
707
|
+
|
|
708
|
+
# IMPORTANT: remove any old direct connects like:
|
|
709
|
+
# self.speed_slider.valueChanged.connect(self.speed_spin.setValue)
|
|
710
|
+
# self.speed_spin.valueChanged.connect(self.speed_slider.setValue)
|
|
711
|
+
|
|
712
|
+
# Use lambdas to cast types correctly
|
|
713
|
+
self.speed_slider.valueChanged.connect(lambda v: self.speed_spin.setValue(v / 10.0)) # int -> float
|
|
714
|
+
self.speed_spin.valueChanged.connect(lambda f: self.speed_slider.setValue(int(round(f * 10)))) # float -> int
|
|
715
|
+
|
|
716
|
+
self.speed_slider.valueChanged.connect(self._apply_playback_interval)
|
|
717
|
+
self.speed_spin.valueChanged.connect(self._apply_playback_interval)
|
|
718
|
+
|
|
719
|
+
left_layout.addLayout(speed_layout)
|
|
720
|
+
|
|
721
|
+
self.export_button = QPushButton(self.tr("Export Video…"), self)
|
|
722
|
+
self.export_button.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_DialogSaveButton))
|
|
723
|
+
self.export_button.clicked.connect(self.export_blink_video)
|
|
724
|
+
left_layout.addWidget(self.export_button)
|
|
725
|
+
|
|
726
|
+
# Tree view for file names
|
|
727
|
+
self.fileTree = QTreeWidget(self)
|
|
728
|
+
self.fileTree.setColumnCount(1)
|
|
729
|
+
self.fileTree.setHeaderLabels([self.tr("Image Files")])
|
|
730
|
+
self.fileTree.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) # Allow multiple selections
|
|
731
|
+
#self.fileTree.itemClicked.connect(self.on_item_clicked)
|
|
732
|
+
self.fileTree.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
|
733
|
+
self.fileTree.customContextMenuRequested.connect(self.on_right_click)
|
|
734
|
+
self.fileTree.currentItemChanged.connect(self._on_current_item_changed_safe)
|
|
735
|
+
self.fileTree.setStyleSheet("""
|
|
736
|
+
QTreeWidget::item:selected {
|
|
737
|
+
background-color: #3a75c4; /* Blue background for selected items */
|
|
738
|
+
color: #ffffff; /* White text color */
|
|
739
|
+
}
|
|
740
|
+
""")
|
|
741
|
+
left_layout.addWidget(self.fileTree)
|
|
742
|
+
|
|
743
|
+
# "Clear Flags" Button
|
|
744
|
+
self.clearFlagsButton = QPushButton(self.tr('Clear Flags'), self)
|
|
745
|
+
self.clearFlagsButton.clicked.connect(self.clearFlags)
|
|
746
|
+
left_layout.addWidget(self.clearFlagsButton)
|
|
747
|
+
|
|
748
|
+
# "Clear Images" Button
|
|
749
|
+
self.clearButton = QPushButton(self.tr('Clear Images'), self)
|
|
750
|
+
self.clearButton.clicked.connect(self.clearImages)
|
|
751
|
+
left_layout.addWidget(self.clearButton)
|
|
752
|
+
|
|
753
|
+
# Add progress bar
|
|
754
|
+
self.progress_bar = QProgressBar(self)
|
|
755
|
+
self.progress_bar.setRange(0, 100)
|
|
756
|
+
left_layout.addWidget(self.progress_bar)
|
|
757
|
+
|
|
758
|
+
# Add loading message label
|
|
759
|
+
self.loading_label = QLabel(self.tr("Loading images..."), self)
|
|
760
|
+
left_layout.addWidget(self.loading_label)
|
|
761
|
+
self.imagesChanged.emit(len(self.loaded_images))
|
|
762
|
+
|
|
763
|
+
# Set the layout for the left widget
|
|
764
|
+
left_widget.setLayout(left_layout)
|
|
765
|
+
|
|
766
|
+
# Add the left widget to the splitter
|
|
767
|
+
splitter.addWidget(left_widget)
|
|
768
|
+
|
|
769
|
+
# Right Column for Image Preview
|
|
770
|
+
right_widget = QWidget(self)
|
|
771
|
+
right_layout = QVBoxLayout(right_widget)
|
|
772
|
+
|
|
773
|
+
# Zoom / preview toolbar (standardized)
|
|
774
|
+
zoom_controls_layout = QHBoxLayout()
|
|
775
|
+
|
|
776
|
+
self.zoom_in_btn = themed_toolbtn("zoom-in", self.tr("Zoom In"))
|
|
777
|
+
self.zoom_out_btn = themed_toolbtn("zoom-out", self.tr("Zoom Out"))
|
|
778
|
+
self.fit_btn = themed_toolbtn("zoom-fit-best", self.tr("Fit to Preview"))
|
|
779
|
+
|
|
780
|
+
self.zoom_in_btn.clicked.connect(self.zoom_in)
|
|
781
|
+
self.zoom_out_btn.clicked.connect(self.zoom_out)
|
|
782
|
+
self.fit_btn.clicked.connect(self.fit_to_preview)
|
|
783
|
+
|
|
784
|
+
zoom_controls_layout.addWidget(self.zoom_in_btn)
|
|
785
|
+
zoom_controls_layout.addWidget(self.zoom_out_btn)
|
|
786
|
+
zoom_controls_layout.addWidget(self.fit_btn)
|
|
787
|
+
|
|
788
|
+
zoom_controls_layout.addStretch(1)
|
|
789
|
+
|
|
790
|
+
# Keep Aggressive Stretch as a text toggle (it’s not really a zoom action)
|
|
791
|
+
self.aggressive_button = QPushButton(self.tr("Aggressive Stretch"), self)
|
|
792
|
+
self.aggressive_button.setCheckable(True)
|
|
793
|
+
self.aggressive_button.clicked.connect(self.toggle_aggressive)
|
|
794
|
+
zoom_controls_layout.addWidget(self.aggressive_button)
|
|
795
|
+
|
|
796
|
+
right_layout.addLayout(zoom_controls_layout)
|
|
797
|
+
|
|
798
|
+
# Scroll area for the preview
|
|
799
|
+
self.scroll_area = QScrollArea(self)
|
|
800
|
+
self.scroll_area.setWidgetResizable(True)
|
|
801
|
+
self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
|
802
|
+
self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
|
803
|
+
self.scroll_area.viewport().installEventFilter(self)
|
|
804
|
+
|
|
805
|
+
# QLabel for the image preview
|
|
806
|
+
self.preview_label = QLabel(self)
|
|
807
|
+
self.preview_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
808
|
+
self.scroll_area.setWidget(self.preview_label)
|
|
809
|
+
|
|
810
|
+
right_layout.addWidget(self.scroll_area)
|
|
811
|
+
|
|
812
|
+
# Set the layout for the right widget
|
|
813
|
+
right_widget.setLayout(right_layout)
|
|
814
|
+
|
|
815
|
+
# Add the right widget to the splitter
|
|
816
|
+
splitter.addWidget(right_widget)
|
|
817
|
+
|
|
818
|
+
# Set initial splitter sizes
|
|
819
|
+
splitter.setSizes([300, 700]) # Adjust proportions as needed
|
|
820
|
+
|
|
821
|
+
# Add the splitter to the main layout
|
|
822
|
+
main_layout.addWidget(splitter)
|
|
823
|
+
|
|
824
|
+
# Set the main layout for the widget
|
|
825
|
+
self.setLayout(main_layout)
|
|
826
|
+
|
|
827
|
+
# Initialize playback timer
|
|
828
|
+
self.playback_timer = QTimer(self)
|
|
829
|
+
self._apply_playback_interval() # sets interval based on self.play_fps
|
|
830
|
+
self.playback_timer.timeout.connect(self.next_item)
|
|
831
|
+
|
|
832
|
+
# Connect the selection change signal to update the preview when arrow keys are used
|
|
833
|
+
self.fileTree.selectionModel().selectionChanged.connect(self.on_selection_changed)
|
|
834
|
+
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
|
835
|
+
|
|
836
|
+
self.scroll_area.horizontalScrollBar().valueChanged.connect(lambda _: self._capture_view_center_norm())
|
|
837
|
+
self.scroll_area.verticalScrollBar().valueChanged.connect(lambda _: self._capture_view_center_norm())
|
|
838
|
+
self.imagesChanged.connect(self._update_loaded_count_label)
|
|
839
|
+
|
|
840
|
+
@staticmethod
|
|
841
|
+
def _ensure_float01(img):
|
|
842
|
+
"""
|
|
843
|
+
Convert to float32 and force into [0..1] using:
|
|
844
|
+
- if min < 0: subtract min
|
|
845
|
+
- if max > 1: divide by max
|
|
846
|
+
Works for mono or RGB. Handles NaN/Inf safely.
|
|
847
|
+
"""
|
|
848
|
+
arr = np.asarray(img, dtype=np.float32)
|
|
849
|
+
|
|
850
|
+
finite = np.isfinite(arr)
|
|
851
|
+
if not finite.any():
|
|
852
|
+
return np.zeros_like(arr, dtype=np.float32)
|
|
853
|
+
|
|
854
|
+
mn = float(arr[finite].min())
|
|
855
|
+
if mn < 0.0:
|
|
856
|
+
arr = arr - mn
|
|
857
|
+
|
|
858
|
+
# recompute after possible shift
|
|
859
|
+
finite = np.isfinite(arr)
|
|
860
|
+
mx = float(arr[finite].max()) if finite.any() else 0.0
|
|
861
|
+
if mx > 1.0:
|
|
862
|
+
if mx > 0.0:
|
|
863
|
+
arr = arr / mx
|
|
864
|
+
|
|
865
|
+
return np.clip(arr, 0.0, 1.0)
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
def _aggressive_display_boost(self, x01: np.ndarray, strength: float = 3.7) -> np.ndarray:
|
|
869
|
+
"""
|
|
870
|
+
Stronger display stretch on top of an already stretched image.
|
|
871
|
+
Input/Output are float32 in [0..1].
|
|
872
|
+
Robust: percentile normalize + asinh boost.
|
|
873
|
+
"""
|
|
874
|
+
x = np.asarray(x01, dtype=np.float32)
|
|
875
|
+
x = np.nan_to_num(x, nan=0.0, posinf=1.0, neginf=0.0)
|
|
876
|
+
x = np.clip(x, 0.0, 1.0)
|
|
877
|
+
|
|
878
|
+
# Robust normalize: ignore extreme outliers so we actually expand contrast
|
|
879
|
+
lo = float(np.percentile(x, 0.25))
|
|
880
|
+
hi = float(np.percentile(x, 99.75))
|
|
881
|
+
if not np.isfinite(lo) or not np.isfinite(hi) or hi <= lo + 1e-8:
|
|
882
|
+
return x # nothing to do, but never return black
|
|
883
|
+
|
|
884
|
+
y = (x - lo) / (hi - lo)
|
|
885
|
+
y = np.clip(y, 0.0, 1.0)
|
|
886
|
+
|
|
887
|
+
# Asinh boost (stronger -> more aggressive midtone lift)
|
|
888
|
+
k = max(1.0, float(strength) * 1.25) # tune multiplier to taste
|
|
889
|
+
y = np.arcsinh(k * y) / np.arcsinh(k)
|
|
890
|
+
|
|
891
|
+
return np.clip(y, 0.0, 1.0)
|
|
892
|
+
|
|
893
|
+
|
|
894
|
+
# --------------------------------------------
|
|
895
|
+
# NEW: collect paths & emit to stacking
|
|
896
|
+
# --------------------------------------------
|
|
897
|
+
def _collect_paths_for_stacking(self) -> list[str]:
|
|
898
|
+
"""
|
|
899
|
+
Priority:
|
|
900
|
+
1) if user has rows selected in the tree → use those
|
|
901
|
+
2) else → use all loaded image_paths
|
|
902
|
+
"""
|
|
903
|
+
paths: list[str] = []
|
|
904
|
+
|
|
905
|
+
selected_items = self.fileTree.selectedItems()
|
|
906
|
+
if selected_items:
|
|
907
|
+
for it in selected_items:
|
|
908
|
+
p = it.data(0, Qt.ItemDataRole.UserRole)
|
|
909
|
+
if not p:
|
|
910
|
+
# some code uses text as path, fall back
|
|
911
|
+
p = it.text(0)
|
|
912
|
+
if p:
|
|
913
|
+
paths.append(p)
|
|
914
|
+
else:
|
|
915
|
+
# no selection → send all
|
|
916
|
+
for p in self.image_paths:
|
|
917
|
+
if p:
|
|
918
|
+
paths.append(p)
|
|
919
|
+
|
|
920
|
+
# de-dup, keep order
|
|
921
|
+
seen = set()
|
|
922
|
+
unique_paths = []
|
|
923
|
+
for p in paths:
|
|
924
|
+
if p not in seen:
|
|
925
|
+
seen.add(p)
|
|
926
|
+
unique_paths.append(p)
|
|
927
|
+
return unique_paths
|
|
928
|
+
|
|
929
|
+
def _send_to_stacking_lights(self):
|
|
930
|
+
paths = self._collect_paths_for_stacking()
|
|
931
|
+
if not paths:
|
|
932
|
+
QMessageBox.information(self, self.tr("No images"), self.tr("There are no images to send."))
|
|
933
|
+
return
|
|
934
|
+
self.sendToStacking.emit(paths, "lights")
|
|
935
|
+
|
|
936
|
+
def _send_to_stacking_integration(self):
|
|
937
|
+
paths = self._collect_paths_for_stacking()
|
|
938
|
+
if not paths:
|
|
939
|
+
QMessageBox.information(self, self.tr("No images"), self.tr("There are no images to send."))
|
|
940
|
+
return
|
|
941
|
+
self.sendToStacking.emit(paths, "integration")
|
|
942
|
+
|
|
943
|
+
|
|
944
|
+
def export_blink_video(self):
|
|
945
|
+
"""Export the blink sequence to a video. Defaults to all frames in current tree order."""
|
|
946
|
+
# Ensure we have frames
|
|
947
|
+
leaves = self.get_all_leaf_items()
|
|
948
|
+
if not leaves:
|
|
949
|
+
QMessageBox.information(self, self.tr("No Images"), self.tr("Load images before exporting."))
|
|
950
|
+
return
|
|
951
|
+
|
|
952
|
+
# Ask options first (size, fps, selection scope)
|
|
953
|
+
opts = self._ask_video_options(default_fps=float(self.play_fps))
|
|
954
|
+
if opts is None:
|
|
955
|
+
return
|
|
956
|
+
target_w, target_h = opts["size"]
|
|
957
|
+
fps = max(0.1, min(60.0, float(opts["fps"])))
|
|
958
|
+
only_selected = bool(opts.get("only_selected", False))
|
|
959
|
+
|
|
960
|
+
# Decide frame order
|
|
961
|
+
if only_selected:
|
|
962
|
+
sel_leaves = [it for it in self.fileTree.selectedItems() if it.childCount() == 0]
|
|
963
|
+
if not sel_leaves:
|
|
964
|
+
QMessageBox.information(self, self.tr("No Selection"), self.tr("No individual frames selected."))
|
|
965
|
+
return
|
|
966
|
+
names = {it.text(0).lstrip("⚠️ ").strip() for it in sel_leaves}
|
|
967
|
+
order = [i for i in self._tree_order_indices()
|
|
968
|
+
if os.path.basename(self.image_paths[i]) in names]
|
|
969
|
+
else:
|
|
970
|
+
order = self._tree_order_indices()
|
|
971
|
+
|
|
972
|
+
if not order:
|
|
973
|
+
QMessageBox.information(self, self.tr("No Frames"), self.tr("Nothing to export."))
|
|
974
|
+
return
|
|
975
|
+
|
|
976
|
+
if len(order) < 2:
|
|
977
|
+
ret = QMessageBox.question(
|
|
978
|
+
self, self.tr("Only one frame"),
|
|
979
|
+
self.tr("You're about to export a video with a single frame. Continue?"),
|
|
980
|
+
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
|
981
|
+
QMessageBox.StandardButton.No,
|
|
982
|
+
)
|
|
983
|
+
if ret != QMessageBox.StandardButton.Yes:
|
|
984
|
+
return
|
|
985
|
+
|
|
986
|
+
# Ask where to save
|
|
987
|
+
out_path, _ = QFileDialog.getSaveFileName(
|
|
988
|
+
self, self.tr("Export Blink Video"), "blink.mp4", self.tr("Video (*.mp4 *.avi)")
|
|
989
|
+
)
|
|
990
|
+
if not out_path:
|
|
991
|
+
return
|
|
992
|
+
# Let _open_video_writer_portable decide the real extension; we pass requested
|
|
993
|
+
writer, out_path, backend = self._open_video_writer_portable(out_path, (target_w, target_h), fps)
|
|
994
|
+
if writer is None:
|
|
995
|
+
QMessageBox.critical(self, self.tr("Export"),
|
|
996
|
+
self.tr("No compatible video codec found.\n\n"
|
|
997
|
+
"Tip: install FFmpeg or `pip install imageio[ffmpeg]` for a portable fallback.")
|
|
998
|
+
)
|
|
999
|
+
return
|
|
1000
|
+
|
|
1001
|
+
# Progress UI
|
|
1002
|
+
prog = QProgressDialog(self.tr("Rendering video…"), self.tr("Cancel"), 0, len(order), self)
|
|
1003
|
+
prog.setWindowTitle(self.tr("Export Blink Video"))
|
|
1004
|
+
prog.setAutoClose(True)
|
|
1005
|
+
prog.setMinimumDuration(300)
|
|
1006
|
+
|
|
1007
|
+
using_imageio = (backend == "imageio-ffmpeg")
|
|
1008
|
+
frames_written = 0
|
|
1009
|
+
|
|
1010
|
+
try:
|
|
1011
|
+
for i, idx in enumerate(order):
|
|
1012
|
+
if prog.wasCanceled():
|
|
1013
|
+
break
|
|
1014
|
+
|
|
1015
|
+
entry = self.loaded_images[idx]
|
|
1016
|
+
f = self._make_display_frame(entry) # uint8, gray or RGB
|
|
1017
|
+
|
|
1018
|
+
# Ensure 3-channel RGB
|
|
1019
|
+
if f.ndim == 2:
|
|
1020
|
+
f = cv2.cvtColor(f, cv2.COLOR_GRAY2RGB)
|
|
1021
|
+
|
|
1022
|
+
# Letterbox into target (keep aspect)
|
|
1023
|
+
tw, th = (target_w, target_h)
|
|
1024
|
+
h, w = f.shape[:2]
|
|
1025
|
+
s = min(tw / float(w), th / float(h))
|
|
1026
|
+
nw, nh = max(1, int(round(w * s))), max(1, int(round(h * s)))
|
|
1027
|
+
resized = cv2.resize(f, (nw, nh), interpolation=cv2.INTER_AREA)
|
|
1028
|
+
rgb_canvas = np.zeros((th, tw, 3), dtype=np.uint8)
|
|
1029
|
+
x0, y0 = (tw - nw) // 2, (th - nh) // 2
|
|
1030
|
+
rgb_canvas[y0:y0+nh, x0:x0+nw] = resized
|
|
1031
|
+
|
|
1032
|
+
if using_imageio:
|
|
1033
|
+
writer.append_data(rgb_canvas) # RGB
|
|
1034
|
+
else:
|
|
1035
|
+
writer.write(cv2.cvtColor(rgb_canvas, cv2.COLOR_RGB2BGR)) # BGR
|
|
1036
|
+
frames_written += 1
|
|
1037
|
+
|
|
1038
|
+
prog.setValue(i + 1)
|
|
1039
|
+
QApplication.processEvents()
|
|
1040
|
+
finally:
|
|
1041
|
+
try:
|
|
1042
|
+
writer.close() if using_imageio else writer.release()
|
|
1043
|
+
except Exception:
|
|
1044
|
+
pass
|
|
1045
|
+
|
|
1046
|
+
if prog.wasCanceled():
|
|
1047
|
+
try:
|
|
1048
|
+
os.remove(out_path)
|
|
1049
|
+
except Exception:
|
|
1050
|
+
pass
|
|
1051
|
+
QMessageBox.information(self, self.tr("Export"), self.tr("Export canceled."))
|
|
1052
|
+
return
|
|
1053
|
+
|
|
1054
|
+
if frames_written == 0:
|
|
1055
|
+
QMessageBox.critical(self, self.tr("Export"), self.tr("No frames were written (codec/back-end issue?)."))
|
|
1056
|
+
return
|
|
1057
|
+
|
|
1058
|
+
QMessageBox.information(self, self.tr("Export"), self.tr("Saved: {0}\nFrames: {1} @ {2} fps").format(out_path, frames_written, fps))
|
|
1059
|
+
|
|
1060
|
+
|
|
1061
|
+
|
|
1062
|
+
def _ask_video_options(self, default_fps: float):
|
|
1063
|
+
"""Options dialog for size, fps, and whether to limit to current selection."""
|
|
1064
|
+
dlg = QDialog(self)
|
|
1065
|
+
dlg.setWindowTitle(self.tr("Video Options"))
|
|
1066
|
+
layout = QGridLayout(dlg)
|
|
1067
|
+
|
|
1068
|
+
# Size
|
|
1069
|
+
layout.addWidget(QLabel(self.tr("Size:")), 0, 0)
|
|
1070
|
+
size_combo = QComboBox(dlg)
|
|
1071
|
+
size_combo.addItem("HD 1280×720", (1280, 720))
|
|
1072
|
+
size_combo.addItem("Full HD 1920×1080", (1920, 1080))
|
|
1073
|
+
size_combo.addItem("Square 1080×1080", (1080, 1080))
|
|
1074
|
+
size_combo.setCurrentIndex(0)
|
|
1075
|
+
layout.addWidget(size_combo, 0, 1)
|
|
1076
|
+
|
|
1077
|
+
# FPS
|
|
1078
|
+
layout.addWidget(QLabel(self.tr("FPS:")), 1, 0)
|
|
1079
|
+
fps_edit = QDoubleSpinBox(dlg)
|
|
1080
|
+
fps_edit.setRange(0.1, 60.0)
|
|
1081
|
+
fps_edit.setDecimals(2)
|
|
1082
|
+
fps_edit.setSingleStep(0.1)
|
|
1083
|
+
fps_edit.setValue(float(default_fps))
|
|
1084
|
+
layout.addWidget(fps_edit, 1, 1)
|
|
1085
|
+
|
|
1086
|
+
# Only selected?
|
|
1087
|
+
only_selected = QCheckBox(self.tr("Export only selected frames"), dlg)
|
|
1088
|
+
only_selected.setChecked(False) # default: export everything in tree order
|
|
1089
|
+
layout.addWidget(only_selected, 2, 0, 1, 2)
|
|
1090
|
+
|
|
1091
|
+
# Buttons
|
|
1092
|
+
btns = QHBoxLayout()
|
|
1093
|
+
ok = QPushButton(self.tr("OK"), dlg); cancel = QPushButton(self.tr("Cancel"), dlg)
|
|
1094
|
+
ok.clicked.connect(dlg.accept); cancel.clicked.connect(dlg.reject)
|
|
1095
|
+
btns.addWidget(ok); btns.addWidget(cancel)
|
|
1096
|
+
layout.addLayout(btns, 3, 0, 1, 2)
|
|
1097
|
+
|
|
1098
|
+
if dlg.exec() != QDialog.DialogCode.Accepted:
|
|
1099
|
+
return None
|
|
1100
|
+
return {
|
|
1101
|
+
"size": size_combo.currentData(),
|
|
1102
|
+
"fps": fps_edit.value(),
|
|
1103
|
+
"only_selected": only_selected.isChecked()
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
|
|
1107
|
+
|
|
1108
|
+
def _make_display_frame(self, entry):
|
|
1109
|
+
stored = entry['image_data']
|
|
1110
|
+
use_aggr = bool(self.aggressive_stretch_enabled)
|
|
1111
|
+
|
|
1112
|
+
if not use_aggr:
|
|
1113
|
+
if stored.dtype == np.uint8:
|
|
1114
|
+
disp8 = stored
|
|
1115
|
+
elif stored.dtype == np.uint16:
|
|
1116
|
+
disp8 = (stored >> 8).astype(np.uint8)
|
|
1117
|
+
else:
|
|
1118
|
+
disp8 = (np.clip(stored, 0.0, 1.0) * 255.0).astype(np.uint8)
|
|
1119
|
+
return disp8
|
|
1120
|
+
|
|
1121
|
+
base01 = self._as_float01(stored)
|
|
1122
|
+
|
|
1123
|
+
if base01.ndim == 2:
|
|
1124
|
+
disp01 = self._aggressive_display_boost(base01, strength=self.current_sigma)
|
|
1125
|
+
else:
|
|
1126
|
+
lum = base01.mean(axis=2).astype(np.float32)
|
|
1127
|
+
lum_boost = self._aggressive_display_boost(lum, strength=self.current_sigma)
|
|
1128
|
+
gain = lum_boost / (lum + 1e-6)
|
|
1129
|
+
disp01 = np.clip(base01 * gain[..., None], 0.0, 1.0)
|
|
1130
|
+
|
|
1131
|
+
return (disp01 * 255.0).astype(np.uint8)
|
|
1132
|
+
|
|
1133
|
+
|
|
1134
|
+
|
|
1135
|
+
def _fit_letterbox(self, frame_bgr_or_rgb, target_size):
|
|
1136
|
+
"""
|
|
1137
|
+
Fit 'frame' into target_size with letterboxing (black borders).
|
|
1138
|
+
Accepts uint8, shape (H,W,3). Returns BGR uint8 (H_t,W_t,3).
|
|
1139
|
+
"""
|
|
1140
|
+
tw, th = target_size
|
|
1141
|
+
h, w = frame_bgr_or_rgb.shape[:2]
|
|
1142
|
+
# Compute scale to fit inside
|
|
1143
|
+
s = min(tw / float(w), th / float(h))
|
|
1144
|
+
nw, nh = max(1, int(round(w * s))), max(1, int(round(h * s)))
|
|
1145
|
+
|
|
1146
|
+
# Resize (OpenCV uses BGR—this function doesn’t swap channels)
|
|
1147
|
+
resized = cv2.resize(frame_bgr_or_rgb, (nw, nh), interpolation=cv2.INTER_AREA)
|
|
1148
|
+
|
|
1149
|
+
# Pad into target
|
|
1150
|
+
out = np.zeros((th, tw, 3), dtype=np.uint8)
|
|
1151
|
+
x0 = (tw - nw) // 2
|
|
1152
|
+
y0 = (th - nh) // 2
|
|
1153
|
+
out[y0:y0+nh, x0:x0+nw] = resized if resized.ndim == 3 else cv2.cvtColor(resized, cv2.COLOR_GRAY2BGR)
|
|
1154
|
+
return out
|
|
1155
|
+
|
|
1156
|
+
def _open_video_writer_portable(self, requested_path: str, size: tuple[int, int], fps: float):
|
|
1157
|
+
"""
|
|
1158
|
+
Try several (container, fourcc) combos that work across platforms.
|
|
1159
|
+
Returns (writer, out_path, backend_name). If OpenCV fails, tries imageio-ffmpeg.
|
|
1160
|
+
Never writes a probe frame, so no accidental extra first frame.
|
|
1161
|
+
"""
|
|
1162
|
+
tw, th = size
|
|
1163
|
+
candidates = [
|
|
1164
|
+
(".mp4", "mp4v", "OpenCV-mp4v"),
|
|
1165
|
+
(".mp4", "avc1", "OpenCV-avc1"), # H.264 if available
|
|
1166
|
+
(".mp4", "H264", "OpenCV-H264"),
|
|
1167
|
+
(".avi", "MJPG", "OpenCV-MJPG"),
|
|
1168
|
+
(".avi", "XVID", "OpenCV-XVID"),
|
|
1169
|
+
]
|
|
1170
|
+
base, _ = os.path.splitext(requested_path)
|
|
1171
|
+
|
|
1172
|
+
# Try OpenCV containers/codecs first (without writing a test frame)
|
|
1173
|
+
for ext, fourcc_tag, label in candidates:
|
|
1174
|
+
out_path = base + ext
|
|
1175
|
+
fourcc = cv2.VideoWriter_fourcc(*fourcc_tag)
|
|
1176
|
+
|
|
1177
|
+
# open/close once to check the container initialization
|
|
1178
|
+
vw = cv2.VideoWriter(out_path, fourcc, float(fps), (tw, th))
|
|
1179
|
+
ok = vw.isOpened()
|
|
1180
|
+
try:
|
|
1181
|
+
vw.release()
|
|
1182
|
+
except Exception:
|
|
1183
|
+
pass
|
|
1184
|
+
|
|
1185
|
+
# some backends leave a tiny stub — clean it up before the real open
|
|
1186
|
+
try:
|
|
1187
|
+
if os.path.exists(out_path) and os.path.getsize(out_path) < 1024:
|
|
1188
|
+
os.remove(out_path)
|
|
1189
|
+
except Exception:
|
|
1190
|
+
pass
|
|
1191
|
+
|
|
1192
|
+
if ok:
|
|
1193
|
+
vw2 = cv2.VideoWriter(out_path, fourcc, float(fps), (tw, th))
|
|
1194
|
+
if vw2.isOpened():
|
|
1195
|
+
return vw2, out_path, label
|
|
1196
|
+
|
|
1197
|
+
# Fallback: imageio-ffmpeg (portable, needs imageio[ffmpeg])
|
|
1198
|
+
try:
|
|
1199
|
+
import imageio
|
|
1200
|
+
writer = imageio.get_writer(base + ".mp4", fps=float(fps), macro_block_size=None) # expects RGB frames
|
|
1201
|
+
return writer, base + ".mp4", "imageio-ffmpeg"
|
|
1202
|
+
except Exception:
|
|
1203
|
+
return None, None, None
|
|
1204
|
+
|
|
1205
|
+
|
|
1206
|
+
|
|
1207
|
+
|
|
1208
|
+
def _update_loaded_count_label(self, n: int):
|
|
1209
|
+
# pluralize nicely
|
|
1210
|
+
self.loading_label.setText(self.tr("Loaded {0} image{1}.").format(n, 's' if n != 1 else ''))
|
|
1211
|
+
|
|
1212
|
+
def _apply_playback_interval(self, *_):
|
|
1213
|
+
# read from custom spin if present
|
|
1214
|
+
fps = float(self.speed_spin.value) if hasattr(self, "speed_spin") else float(getattr(self, "play_fps", 1.0))
|
|
1215
|
+
fps = max(0.1, min(10.0, fps))
|
|
1216
|
+
self.play_fps = fps
|
|
1217
|
+
self.playback_timer.setInterval(int(round(1000.0 / fps))) # 0.1 fps -> 10000 ms
|
|
1218
|
+
|
|
1219
|
+
def _on_current_item_changed_safe(self, current, previous):
|
|
1220
|
+
if not current:
|
|
1221
|
+
return
|
|
1222
|
+
|
|
1223
|
+
# If mouse is down, defer a bit, but DO NOT capture the item
|
|
1224
|
+
if QApplication.mouseButtons() != Qt.MouseButton.NoButton:
|
|
1225
|
+
QTimer.singleShot(120, self._center_if_no_mouse)
|
|
1226
|
+
return
|
|
1227
|
+
|
|
1228
|
+
# Defer to allow selection to settle, then ensure the *current* item is visible
|
|
1229
|
+
QTimer.singleShot(0, self._ensure_current_visible)
|
|
1230
|
+
|
|
1231
|
+
def _ensure_current_visible(self):
|
|
1232
|
+
item = self.fileTree.currentItem()
|
|
1233
|
+
if item is not None:
|
|
1234
|
+
self.fileTree.scrollToItem(item, QAbstractItemView.ScrollHint.EnsureVisible)
|
|
1235
|
+
|
|
1236
|
+
def _center_if_no_mouse(self):
|
|
1237
|
+
if QApplication.mouseButtons() == Qt.MouseButton.NoButton:
|
|
1238
|
+
item = self.fileTree.currentItem()
|
|
1239
|
+
if item is not None:
|
|
1240
|
+
self.fileTree.scrollToItem(item, QAbstractItemView.ScrollHint.EnsureVisible)
|
|
1241
|
+
|
|
1242
|
+
def toggle_aggressive(self):
|
|
1243
|
+
self.aggressive_stretch_enabled = self.aggressive_button.isChecked()
|
|
1244
|
+
# force a redisplay of the current image
|
|
1245
|
+
cur = self.fileTree.currentItem()
|
|
1246
|
+
if cur:
|
|
1247
|
+
self.on_item_clicked(cur, 0)
|
|
1248
|
+
|
|
1249
|
+
def clearFlags(self):
|
|
1250
|
+
"""Clear all flagged states, update tree icons & metrics."""
|
|
1251
|
+
# 1) Reset internal flag state
|
|
1252
|
+
for entry in self.loaded_images:
|
|
1253
|
+
entry['flagged'] = False
|
|
1254
|
+
|
|
1255
|
+
# 2) Update tree widget: strip any "⚠️ " prefix and reset color
|
|
1256
|
+
normal = self.fileTree.palette().color(QPalette.ColorRole.WindowText)
|
|
1257
|
+
for item in self.get_all_leaf_items():
|
|
1258
|
+
name = item.text(0).lstrip("⚠️ ")
|
|
1259
|
+
item.setText(0, name)
|
|
1260
|
+
item.setForeground(0, QBrush(normal))
|
|
1261
|
+
|
|
1262
|
+
# 3) If metrics window is open, refresh its dots & status
|
|
1263
|
+
if self.metrics_window:
|
|
1264
|
+
panel = self.metrics_window.metrics_panel
|
|
1265
|
+
panel.flags = [False] * len(self.loaded_images)
|
|
1266
|
+
panel._refresh_scatter_colors()
|
|
1267
|
+
# update the "Flagged Items X/Y" label
|
|
1268
|
+
self.metrics_window._update_status()
|
|
1269
|
+
|
|
1270
|
+
# inside BlinkTab
|
|
1271
|
+
def _sync_metrics_flags(self):
|
|
1272
|
+
if self.metrics_window:
|
|
1273
|
+
panel = self.metrics_window.metrics_panel
|
|
1274
|
+
panel.flags = [entry['flagged'] for entry in self.loaded_images]
|
|
1275
|
+
panel._refresh_scatter_colors()
|
|
1276
|
+
# after a move/delete, current_indices might be stale → refresh text safely
|
|
1277
|
+
self.metrics_window._update_status()
|
|
1278
|
+
|
|
1279
|
+
|
|
1280
|
+
def addAdditionalImages(self):
|
|
1281
|
+
"""Let the user pick more images to append to the blink list."""
|
|
1282
|
+
file_paths, _ = QFileDialog.getOpenFileNames(
|
|
1283
|
+
self,
|
|
1284
|
+
self.tr("Add Additional Images"),
|
|
1285
|
+
"",
|
|
1286
|
+
self.tr("Images (*.png *.tif *.tiff *.fits *.fit *.xisf *.cr2 *.nef *.arw *.dng *.raf *.orf *.rw2 *.pef);;All Files (*)")
|
|
1287
|
+
)
|
|
1288
|
+
# filter out duplicates
|
|
1289
|
+
new_paths = [p for p in file_paths if p not in self.image_paths]
|
|
1290
|
+
if not new_paths:
|
|
1291
|
+
QMessageBox.information(self, self.tr("No New Images"), self.tr("No new images selected or already loaded."))
|
|
1292
|
+
return
|
|
1293
|
+
self._appendImages(new_paths)
|
|
1294
|
+
|
|
1295
|
+
def _appendImages(self, file_paths):
|
|
1296
|
+
# decide dtype exactly as in loadImages
|
|
1297
|
+
mem = psutil.virtual_memory()
|
|
1298
|
+
avail = mem.available / (1024**3)
|
|
1299
|
+
if avail <= 16:
|
|
1300
|
+
target_dtype = np.uint8
|
|
1301
|
+
elif avail <= 32:
|
|
1302
|
+
target_dtype = np.uint16
|
|
1303
|
+
else:
|
|
1304
|
+
target_dtype = np.float32
|
|
1305
|
+
|
|
1306
|
+
total_new = len(file_paths)
|
|
1307
|
+
self.progress_bar.setRange(0, total_new)
|
|
1308
|
+
self.progress_bar.setValue(0)
|
|
1309
|
+
QApplication.processEvents()
|
|
1310
|
+
|
|
1311
|
+
# load one-by-one (or you could parallelize as you like)
|
|
1312
|
+
for i, path in enumerate(sorted(file_paths, key=lambda p: self._natural_key(os.path.basename(p)))):
|
|
1313
|
+
try:
|
|
1314
|
+
_, hdr, bit_depth, is_mono, stored, back = self._load_one_image(path, target_dtype)
|
|
1315
|
+
except Exception as e:
|
|
1316
|
+
print(f"Failed to load {path}: {e}")
|
|
1317
|
+
continue
|
|
1318
|
+
|
|
1319
|
+
# append to our master lists
|
|
1320
|
+
self.image_paths.append(path)
|
|
1321
|
+
self.loaded_images.append({
|
|
1322
|
+
'file_path': path,
|
|
1323
|
+
'image_data': stored,
|
|
1324
|
+
'header': hdr or {},
|
|
1325
|
+
'bit_depth': bit_depth,
|
|
1326
|
+
'is_mono': is_mono,
|
|
1327
|
+
'flagged': False,
|
|
1328
|
+
'orig_background': back
|
|
1329
|
+
})
|
|
1330
|
+
|
|
1331
|
+
# update progress bar
|
|
1332
|
+
self.progress_bar.setValue(i+1)
|
|
1333
|
+
QApplication.processEvents()
|
|
1334
|
+
|
|
1335
|
+
# and add it into the tree under the correct object/filter/exp
|
|
1336
|
+
self.add_item_to_tree(path)
|
|
1337
|
+
|
|
1338
|
+
# update status
|
|
1339
|
+
self.loading_label.setText(self.tr("Loaded {0} images.").format(len(self.loaded_images)))
|
|
1340
|
+
if self.metrics_window and self.metrics_window.isVisible():
|
|
1341
|
+
self.metrics_window.update_metrics(self.loaded_images, order=self._tree_order_indices())
|
|
1342
|
+
|
|
1343
|
+
self.imagesChanged.emit(len(self.loaded_images))
|
|
1344
|
+
|
|
1345
|
+
def show_metrics(self):
|
|
1346
|
+
if self.metrics_window is None:
|
|
1347
|
+
self.metrics_window = MetricsWindow()
|
|
1348
|
+
mp = self.metrics_window.metrics_panel
|
|
1349
|
+
mp.pointClicked.connect(self.on_metrics_point)
|
|
1350
|
+
mp.thresholdChanged.connect(self.on_threshold_change)
|
|
1351
|
+
|
|
1352
|
+
order = self._tree_order_indices()
|
|
1353
|
+
self.metrics_window.set_images(self.loaded_images, order=order)
|
|
1354
|
+
panel = self.metrics_window.metrics_panel
|
|
1355
|
+
self.thresholds_by_group[self.tr("All")] = [line.value() for line in panel.lines]
|
|
1356
|
+
self.metrics_window.show()
|
|
1357
|
+
self.metrics_window.raise_()
|
|
1358
|
+
|
|
1359
|
+
def on_metrics_point(self, metric_idx, frame_idx):
|
|
1360
|
+
item = self.get_tree_item_for_index(frame_idx)
|
|
1361
|
+
if not item:
|
|
1362
|
+
return
|
|
1363
|
+
self._toggle_flag_on_item(item)
|
|
1364
|
+
|
|
1365
|
+
def _as_float01(self, arr):
|
|
1366
|
+
"""Convert any stored dtype to float32 in [0..1], with safety normalization."""
|
|
1367
|
+
if arr.dtype == np.uint8:
|
|
1368
|
+
out = arr.astype(np.float32) / 255.0
|
|
1369
|
+
return out
|
|
1370
|
+
|
|
1371
|
+
if arr.dtype == np.uint16:
|
|
1372
|
+
out = arr.astype(np.float32) / 65535.0
|
|
1373
|
+
return out
|
|
1374
|
+
|
|
1375
|
+
# float path (or anything else): normalize if needed
|
|
1376
|
+
out = np.asarray(arr, dtype=np.float32)
|
|
1377
|
+
|
|
1378
|
+
if out.size == 0:
|
|
1379
|
+
return out
|
|
1380
|
+
|
|
1381
|
+
# handle NaNs/Infs early
|
|
1382
|
+
out = np.nan_to_num(out, nan=0.0, posinf=0.0, neginf=0.0)
|
|
1383
|
+
|
|
1384
|
+
mn = float(out.min())
|
|
1385
|
+
if mn < 0.0:
|
|
1386
|
+
out = out - mn # shift so min becomes 0
|
|
1387
|
+
|
|
1388
|
+
mx = float(out.max())
|
|
1389
|
+
if mx > 1.0 and mx > 0.0:
|
|
1390
|
+
out = out / mx # scale so max becomes 1
|
|
1391
|
+
|
|
1392
|
+
return np.clip(out, 0.0, 1.0)
|
|
1393
|
+
|
|
1394
|
+
|
|
1395
|
+
|
|
1396
|
+
def on_threshold_change(self, metric_idx, threshold):
|
|
1397
|
+
panel = self.metrics_window.metrics_panel
|
|
1398
|
+
if panel.metrics_data is None:
|
|
1399
|
+
return
|
|
1400
|
+
|
|
1401
|
+
# figure out which FILTER group we're in
|
|
1402
|
+
group = self.metrics_window.group_combo.currentText()
|
|
1403
|
+
# ensure we have a 4-slot list for this group
|
|
1404
|
+
thr_list = self.thresholds_by_group.setdefault(group, [None]*4)
|
|
1405
|
+
# store the new threshold for this metric
|
|
1406
|
+
thr_list[metric_idx] = threshold
|
|
1407
|
+
|
|
1408
|
+
# build the list of indices to re-evaluate
|
|
1409
|
+
if group == self.tr("All"):
|
|
1410
|
+
indices = range(len(self.loaded_images))
|
|
1411
|
+
else:
|
|
1412
|
+
indices = [
|
|
1413
|
+
i for i, e in enumerate(self.loaded_images)
|
|
1414
|
+
if e.get('header', {}).get('FILTER','Unknown') == group
|
|
1415
|
+
]
|
|
1416
|
+
|
|
1417
|
+
# re‐flag only those frames in this group, OR across all 4 metrics
|
|
1418
|
+
for i in indices:
|
|
1419
|
+
entry = self.loaded_images[i]
|
|
1420
|
+
flagged = False
|
|
1421
|
+
for m, thr in enumerate(thr_list):
|
|
1422
|
+
if thr is None:
|
|
1423
|
+
continue
|
|
1424
|
+
val = panel.metrics_data[m][i]
|
|
1425
|
+
if np.isnan(val):
|
|
1426
|
+
continue
|
|
1427
|
+
if (m < 3 and val > thr) or (m == 3 and val < thr):
|
|
1428
|
+
flagged = True
|
|
1429
|
+
break
|
|
1430
|
+
entry['flagged'] = flagged
|
|
1431
|
+
|
|
1432
|
+
# update the tree icon
|
|
1433
|
+
item = self.get_tree_item_for_index(i)
|
|
1434
|
+
if item:
|
|
1435
|
+
RED = Qt.GlobalColor.red
|
|
1436
|
+
normal = self.fileTree.palette().color(QPalette.ColorRole.WindowText)
|
|
1437
|
+
name = item.text(0).lstrip("⚠️ ")
|
|
1438
|
+
if flagged:
|
|
1439
|
+
item.setText(0, f"⚠️ {name}")
|
|
1440
|
+
item.setForeground(0, QBrush(RED))
|
|
1441
|
+
else:
|
|
1442
|
+
item.setText(0, name)
|
|
1443
|
+
item.setForeground(0, QBrush(normal))
|
|
1444
|
+
|
|
1445
|
+
# now push the *entire* up-to-date flagged list into the panel
|
|
1446
|
+
panel.flags = [e['flagged'] for e in self.loaded_images]
|
|
1447
|
+
panel._refresh_scatter_colors()
|
|
1448
|
+
self.metrics_window._update_status()
|
|
1449
|
+
|
|
1450
|
+
def _rebuild_tree_from_loaded(self):
|
|
1451
|
+
"""Rebuild the left tree from self.loaded_images without reloading or recomputing."""
|
|
1452
|
+
self.fileTree.clear()
|
|
1453
|
+
from collections import defaultdict
|
|
1454
|
+
|
|
1455
|
+
grouped = defaultdict(list)
|
|
1456
|
+
for entry in self.loaded_images:
|
|
1457
|
+
hdr = entry.get('header', {}) or {}
|
|
1458
|
+
obj = hdr.get('OBJECT', 'Unknown')
|
|
1459
|
+
fil = hdr.get('FILTER', 'Unknown')
|
|
1460
|
+
exp = hdr.get('EXPOSURE', 'Unknown')
|
|
1461
|
+
grouped[(obj, fil, exp)].append(entry['file_path'])
|
|
1462
|
+
|
|
1463
|
+
# natural sort within each leaf group
|
|
1464
|
+
for key, paths in grouped.items():
|
|
1465
|
+
paths.sort(key=lambda p: self._natural_key(os.path.basename(p)))
|
|
1466
|
+
|
|
1467
|
+
by_object = defaultdict(lambda: defaultdict(dict))
|
|
1468
|
+
for (obj, fil, exp), paths in grouped.items():
|
|
1469
|
+
by_object[obj][fil][exp] = paths
|
|
1470
|
+
|
|
1471
|
+
for obj in sorted(by_object, key=lambda o: o.lower()):
|
|
1472
|
+
obj_item = QTreeWidgetItem([self.tr("Object: {0}").format(obj)])
|
|
1473
|
+
self.fileTree.addTopLevelItem(obj_item)
|
|
1474
|
+
obj_item.setExpanded(True)
|
|
1475
|
+
|
|
1476
|
+
for fil in sorted(by_object[obj], key=lambda f: f.lower()):
|
|
1477
|
+
filt_item = QTreeWidgetItem([self.tr("Filter: {0}").format(fil)])
|
|
1478
|
+
obj_item.addChild(filt_item)
|
|
1479
|
+
filt_item.setExpanded(True)
|
|
1480
|
+
|
|
1481
|
+
for exp in sorted(by_object[obj][fil], key=lambda e: str(e).lower()):
|
|
1482
|
+
exp_item = QTreeWidgetItem([self.tr("Exposure: {0}").format(exp)])
|
|
1483
|
+
filt_item.addChild(exp_item)
|
|
1484
|
+
exp_item.setExpanded(True)
|
|
1485
|
+
|
|
1486
|
+
for p in by_object[obj][fil][exp]:
|
|
1487
|
+
leaf = QTreeWidgetItem([os.path.basename(p)])
|
|
1488
|
+
leaf.setData(0, Qt.ItemDataRole.UserRole, p)
|
|
1489
|
+
exp_item.addChild(leaf)
|
|
1490
|
+
|
|
1491
|
+
# 🔹 Re-apply flagged styling
|
|
1492
|
+
RED = Qt.GlobalColor.red
|
|
1493
|
+
normal = self.fileTree.palette().color(QPalette.ColorRole.WindowText)
|
|
1494
|
+
|
|
1495
|
+
for idx, entry in enumerate(self.loaded_images):
|
|
1496
|
+
item = self.get_tree_item_for_index(idx)
|
|
1497
|
+
if not item:
|
|
1498
|
+
continue
|
|
1499
|
+
base = os.path.basename(self.image_paths[idx])
|
|
1500
|
+
if entry.get("flagged", False):
|
|
1501
|
+
item.setText(0, f"⚠️ {base}")
|
|
1502
|
+
item.setForeground(0, QBrush(RED))
|
|
1503
|
+
else:
|
|
1504
|
+
item.setText(0, base)
|
|
1505
|
+
item.setForeground(0, QBrush(normal))
|
|
1506
|
+
|
|
1507
|
+
|
|
1508
|
+
|
|
1509
|
+
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
|
+
self._rebuild_tree_from_loaded()
|
|
1513
|
+
self.imagesChanged.emit(len(self.loaded_images))
|
|
1514
|
+
|
|
1515
|
+
# 2) refresh metrics (if open) WITHOUT recomputing SEP
|
|
1516
|
+
if self.metrics_window and self.metrics_window.isVisible():
|
|
1517
|
+
if removed_indices:
|
|
1518
|
+
# drop points and reindex
|
|
1519
|
+
self.metrics_window._all_images = self.loaded_images
|
|
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
|
+
)
|
|
1527
|
+
|
|
1528
|
+
def get_tree_item_for_index(self, idx):
|
|
1529
|
+
target = os.path.basename(self.image_paths[idx])
|
|
1530
|
+
for item in self.get_all_leaf_items():
|
|
1531
|
+
if item.text(0).lstrip("⚠️ ") == target:
|
|
1532
|
+
return item
|
|
1533
|
+
return None
|
|
1534
|
+
|
|
1535
|
+
def compute_metric(self, metric_idx, entry):
|
|
1536
|
+
"""Recompute a single metric for one image. Use cached orig_background for metric 2."""
|
|
1537
|
+
# metric 2 is the pre-stretch background we already computed
|
|
1538
|
+
if metric_idx == 2:
|
|
1539
|
+
return entry.get('orig_background', np.nan)
|
|
1540
|
+
|
|
1541
|
+
# otherwise rebuild a float32 [0..1] array from whatever dtype we stored
|
|
1542
|
+
img = entry['image_data']
|
|
1543
|
+
if img.dtype == np.uint8:
|
|
1544
|
+
data = img.astype(np.float32)/255.0
|
|
1545
|
+
elif img.dtype == np.uint16:
|
|
1546
|
+
data = img.astype(np.float32)/65535.0
|
|
1547
|
+
else:
|
|
1548
|
+
data = np.asarray(img, dtype=np.float32)
|
|
1549
|
+
if data.ndim == 3:
|
|
1550
|
+
data = data.mean(axis=2)
|
|
1551
|
+
|
|
1552
|
+
# run SEP for the other metrics
|
|
1553
|
+
bkg = sep.Background(data)
|
|
1554
|
+
back, gr, rr = bkg.back(), bkg.globalback, bkg.globalrms
|
|
1555
|
+
cat = sep.extract(data - back, 5.0, err=gr, minarea=9)
|
|
1556
|
+
if len(cat)==0:
|
|
1557
|
+
return np.nan
|
|
1558
|
+
|
|
1559
|
+
sig = np.sqrt(cat['a']*cat['b'])
|
|
1560
|
+
if metric_idx == 0:
|
|
1561
|
+
return np.nanmedian(2.3548*sig)
|
|
1562
|
+
elif metric_idx == 1:
|
|
1563
|
+
return np.nanmedian(1 - (cat['b']/cat['a']))
|
|
1564
|
+
else: # metric_idx == 3 (star count)
|
|
1565
|
+
return len(cat)
|
|
1566
|
+
|
|
1567
|
+
|
|
1568
|
+
def init_shortcuts(self):
|
|
1569
|
+
"""Initialize keyboard shortcuts."""
|
|
1570
|
+
toggle_shortcut = QShortcut(QKeySequence("Space"), self.fileTree)
|
|
1571
|
+
def _toggle_play():
|
|
1572
|
+
if self.playback_timer.isActive():
|
|
1573
|
+
self.stop_playback()
|
|
1574
|
+
else:
|
|
1575
|
+
self.start_playback()
|
|
1576
|
+
toggle_shortcut.activated.connect(_toggle_play)
|
|
1577
|
+
# Create a shortcut for the "F" key to flag images
|
|
1578
|
+
flag_shortcut = QShortcut(QKeySequence("F"), self.fileTree)
|
|
1579
|
+
flag_shortcut.activated.connect(self.flag_current_image)
|
|
1580
|
+
|
|
1581
|
+
def openDirectoryDialog(self):
|
|
1582
|
+
"""Allow users to select a directory and load all images within it recursively."""
|
|
1583
|
+
directory = QFileDialog.getExistingDirectory(self, self.tr("Select Directory"), "")
|
|
1584
|
+
if directory:
|
|
1585
|
+
# Supported image extensions
|
|
1586
|
+
supported_extensions = (
|
|
1587
|
+
'.png', '.tif', '.tiff', '.fits', '.fit',
|
|
1588
|
+
'.xisf', '.cr2', '.nef', '.arw', '.dng', '.raf',
|
|
1589
|
+
'.orf', '.rw2', '.pef'
|
|
1590
|
+
)
|
|
1591
|
+
|
|
1592
|
+
# Collect all image file paths recursively
|
|
1593
|
+
new_file_paths = []
|
|
1594
|
+
for root, _, files in os.walk(directory):
|
|
1595
|
+
for file in sorted(files, key=str.lower): # 🔹 Sort alphabetically (case-insensitive)
|
|
1596
|
+
if file.lower().endswith(supported_extensions):
|
|
1597
|
+
full_path = os.path.join(root, file)
|
|
1598
|
+
if full_path not in self.image_paths: # Avoid duplicates
|
|
1599
|
+
new_file_paths.append(full_path)
|
|
1600
|
+
|
|
1601
|
+
if new_file_paths:
|
|
1602
|
+
self.loadImages(new_file_paths)
|
|
1603
|
+
else:
|
|
1604
|
+
QMessageBox.information(self, self.tr("No Images Found"), self.tr("No supported image files were found in the selected directory."))
|
|
1605
|
+
|
|
1606
|
+
|
|
1607
|
+
def clearImages(self):
|
|
1608
|
+
"""Clear all loaded images and reset the tree view."""
|
|
1609
|
+
confirmation = QMessageBox.question(
|
|
1610
|
+
self,
|
|
1611
|
+
self.tr("Clear All Images"),
|
|
1612
|
+
self.tr("Are you sure you want to clear all loaded images?"),
|
|
1613
|
+
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
|
1614
|
+
QMessageBox.StandardButton.No
|
|
1615
|
+
)
|
|
1616
|
+
if confirmation == QMessageBox.StandardButton.Yes:
|
|
1617
|
+
self.stop_playback()
|
|
1618
|
+
self.image_paths.clear()
|
|
1619
|
+
self.loaded_images.clear()
|
|
1620
|
+
self.image_labels.clear()
|
|
1621
|
+
self.fileTree.clear()
|
|
1622
|
+
self.preview_label.clear()
|
|
1623
|
+
self.preview_label.setText(self.tr('No image selected.'))
|
|
1624
|
+
self.current_pixmap = None
|
|
1625
|
+
self.progress_bar.setValue(0)
|
|
1626
|
+
self.loading_label.setText(self.tr("Loading images..."))
|
|
1627
|
+
self.imagesChanged.emit(len(self.loaded_images))
|
|
1628
|
+
|
|
1629
|
+
# (legacy) if you still have this, you can delete it:
|
|
1630
|
+
# self.thresholds = [None, None, None, None]
|
|
1631
|
+
|
|
1632
|
+
# also reset the metrics panel (if it’s open)
|
|
1633
|
+
if self.metrics_window is not None:
|
|
1634
|
+
mp = self.metrics_window.metrics_panel
|
|
1635
|
+
# clear out old data & reset flags / thresholds
|
|
1636
|
+
mp.metrics_data = None
|
|
1637
|
+
mp._threshold_initialized = [False]*4
|
|
1638
|
+
for scat in mp.scats:
|
|
1639
|
+
scat.clear()
|
|
1640
|
+
for line in mp.lines:
|
|
1641
|
+
line.setPos(0)
|
|
1642
|
+
|
|
1643
|
+
# clear per‐group threshold storage
|
|
1644
|
+
self.metrics_window._thresholds_per_group.clear()
|
|
1645
|
+
|
|
1646
|
+
# finally, tell the MetricsWindow to fully re‐init with no images
|
|
1647
|
+
if self.metrics_window is not None:
|
|
1648
|
+
self.metrics_window.update_metrics([])
|
|
1649
|
+
|
|
1650
|
+
|
|
1651
|
+
|
|
1652
|
+
@staticmethod
|
|
1653
|
+
def _load_one_image(file_path: str, target_dtype):
|
|
1654
|
+
"""Load + pre-process one image & return all metadata."""
|
|
1655
|
+
|
|
1656
|
+
# 1) load
|
|
1657
|
+
image, header, bit_depth, is_mono = load_image(file_path)
|
|
1658
|
+
if image is None or image.size == 0:
|
|
1659
|
+
msg = QCoreApplication.translate("BlinkTab", "Empty image")
|
|
1660
|
+
raise ValueError(msg)
|
|
1661
|
+
|
|
1662
|
+
# 2) optional debayer
|
|
1663
|
+
if is_mono:
|
|
1664
|
+
image = BlinkTab.debayer_image(image, file_path, header)
|
|
1665
|
+
|
|
1666
|
+
image = BlinkTab._ensure_float01(image)
|
|
1667
|
+
|
|
1668
|
+
data = np.asarray(image, dtype=np.float32, order='C')
|
|
1669
|
+
if data.ndim == 3:
|
|
1670
|
+
data = data.mean(axis=2)
|
|
1671
|
+
bkg = sep.Background(data)
|
|
1672
|
+
global_back = bkg.globalback
|
|
1673
|
+
|
|
1674
|
+
target_med = 0.25
|
|
1675
|
+
if image.ndim == 2:
|
|
1676
|
+
stretched = stretch_mono_image(image, target_med)
|
|
1677
|
+
else:
|
|
1678
|
+
stretched = stretch_color_image(image, target_med, linked=False)
|
|
1679
|
+
|
|
1680
|
+
clipped = np.clip(stretched, 0.0, 1.0)
|
|
1681
|
+
if target_dtype is np.uint8:
|
|
1682
|
+
stored = (clipped * 255).astype(np.uint8)
|
|
1683
|
+
elif target_dtype is np.uint16:
|
|
1684
|
+
stored = (clipped * 65535).astype(np.uint16)
|
|
1685
|
+
else:
|
|
1686
|
+
stored = clipped.astype(np.float32)
|
|
1687
|
+
|
|
1688
|
+
return file_path, header, bit_depth, is_mono, stored, global_back
|
|
1689
|
+
|
|
1690
|
+
@staticmethod
|
|
1691
|
+
def debayer_image(image, file_path, header):
|
|
1692
|
+
"""Check if image is OSC (One-Shot Color) and debayer if required."""
|
|
1693
|
+
if file_path.lower().endswith(('.fits', '.fit')):
|
|
1694
|
+
bayer_pattern = header.get('BAYERPAT', None)
|
|
1695
|
+
if bayer_pattern:
|
|
1696
|
+
image = debayer_fits_fast(image, bayer_pattern)
|
|
1697
|
+
elif file_path.lower().endswith(('.cr2', '.nef', '.arw', '.dng', '.raf', '.orf', '.rw2', '.pef')):
|
|
1698
|
+
image = debayer_raw_fast(image, bayer_pattern="RGGB")
|
|
1699
|
+
return image
|
|
1700
|
+
|
|
1701
|
+
@staticmethod
|
|
1702
|
+
def _natural_key(path: str):
|
|
1703
|
+
"""
|
|
1704
|
+
Split a filename into text and integer chunks so that
|
|
1705
|
+
“…_2.fit” sorts before “…_10.fit”.
|
|
1706
|
+
"""
|
|
1707
|
+
name = os.path.basename(path)
|
|
1708
|
+
return [int(tok) if tok.isdigit() else tok.lower()
|
|
1709
|
+
for tok in re.split(r'(\d+)', name)]
|
|
1710
|
+
|
|
1711
|
+
def loadImages(self, file_paths):
|
|
1712
|
+
# 0) early out
|
|
1713
|
+
if not file_paths:
|
|
1714
|
+
return
|
|
1715
|
+
|
|
1716
|
+
# ---------- NEW: natural sort the list of filenames ----------
|
|
1717
|
+
file_paths = sorted(file_paths, key=lambda p: self._natural_key(os.path.basename(p)))
|
|
1718
|
+
|
|
1719
|
+
# 1) pick dtype based on RAM
|
|
1720
|
+
mem = psutil.virtual_memory()
|
|
1721
|
+
avail = mem.available / (1024**3)
|
|
1722
|
+
if avail <= 16:
|
|
1723
|
+
target_dtype = np.uint8
|
|
1724
|
+
elif avail <= 32:
|
|
1725
|
+
target_dtype = np.uint16
|
|
1726
|
+
else:
|
|
1727
|
+
target_dtype = np.float32
|
|
1728
|
+
|
|
1729
|
+
total = len(file_paths)
|
|
1730
|
+
self.progress_bar.setRange(0, 100)
|
|
1731
|
+
self.progress_bar.setValue(0)
|
|
1732
|
+
QApplication.processEvents()
|
|
1733
|
+
|
|
1734
|
+
self.image_paths.clear()
|
|
1735
|
+
self.loaded_images.clear()
|
|
1736
|
+
self.fileTree.clear()
|
|
1737
|
+
|
|
1738
|
+
# ---------- NEW: Retry-aware parallel load ----------
|
|
1739
|
+
MAX_RETRIES = 2
|
|
1740
|
+
RETRY_DELAY = 2
|
|
1741
|
+
remaining = list(file_paths)
|
|
1742
|
+
completed = []
|
|
1743
|
+
attempt = 0
|
|
1744
|
+
|
|
1745
|
+
while remaining and attempt <= MAX_RETRIES:
|
|
1746
|
+
|
|
1747
|
+
total_cpus = os.cpu_count() or 1
|
|
1748
|
+
reserved_cpus = min(4, max(1, int(total_cpus * 0.25)))
|
|
1749
|
+
max_workers = max(1, min(total_cpus - reserved_cpus, 60))
|
|
1750
|
+
|
|
1751
|
+
futures = {}
|
|
1752
|
+
failed = []
|
|
1753
|
+
|
|
1754
|
+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
1755
|
+
for path in remaining:
|
|
1756
|
+
futures[executor.submit(self._load_one_image, path, target_dtype)] = path
|
|
1757
|
+
for fut in as_completed(futures):
|
|
1758
|
+
path = futures[fut]
|
|
1759
|
+
try:
|
|
1760
|
+
result = fut.result()
|
|
1761
|
+
completed.append(result)
|
|
1762
|
+
done = len(completed)
|
|
1763
|
+
self.progress_bar.setValue(int(100 * done / total))
|
|
1764
|
+
QApplication.processEvents()
|
|
1765
|
+
except Exception as e:
|
|
1766
|
+
print(f"[WARN][Attempt {attempt}] Failed to load {path}: {e}")
|
|
1767
|
+
failed.append(path)
|
|
1768
|
+
|
|
1769
|
+
remaining = failed
|
|
1770
|
+
attempt += 1
|
|
1771
|
+
if remaining:
|
|
1772
|
+
print(f"[Retry] {len(remaining)} images will be retried after {RETRY_DELAY}s...")
|
|
1773
|
+
time.sleep(RETRY_DELAY)
|
|
1774
|
+
|
|
1775
|
+
if remaining:
|
|
1776
|
+
print(f"[FAILURE] These files failed to load after {MAX_RETRIES} retries:")
|
|
1777
|
+
for path in remaining:
|
|
1778
|
+
print(f" - {path}")
|
|
1779
|
+
|
|
1780
|
+
# ---------- Unpack completed results ----------
|
|
1781
|
+
for path, header, bit_depth, is_mono, stored, back in completed:
|
|
1782
|
+
header = header or {}
|
|
1783
|
+
self.image_paths.append(path)
|
|
1784
|
+
self.loaded_images.append({
|
|
1785
|
+
'file_path': path,
|
|
1786
|
+
'image_data': stored,
|
|
1787
|
+
'header': header,
|
|
1788
|
+
'bit_depth': bit_depth,
|
|
1789
|
+
'is_mono': is_mono,
|
|
1790
|
+
'flagged': False,
|
|
1791
|
+
'orig_background': back
|
|
1792
|
+
})
|
|
1793
|
+
|
|
1794
|
+
# 3) rebuild object/filter/exposure tree
|
|
1795
|
+
grouped = defaultdict(list)
|
|
1796
|
+
for entry in self.loaded_images:
|
|
1797
|
+
hdr = entry['header']
|
|
1798
|
+
obj = hdr.get('OBJECT', 'Unknown')
|
|
1799
|
+
filt = hdr.get('FILTER', 'Unknown')
|
|
1800
|
+
exp = hdr.get('EXPOSURE', 'Unknown')
|
|
1801
|
+
grouped[(obj, filt, exp)].append(entry['file_path'])
|
|
1802
|
+
|
|
1803
|
+
for key, paths in grouped.items():
|
|
1804
|
+
paths.sort(key=lambda p: self._natural_key(os.path.basename(p)))
|
|
1805
|
+
by_object = defaultdict(lambda: defaultdict(dict))
|
|
1806
|
+
for (obj, filt, exp), paths in grouped.items():
|
|
1807
|
+
by_object[obj][filt][exp] = paths
|
|
1808
|
+
|
|
1809
|
+
for obj in sorted(by_object, key=lambda o: o.lower()):
|
|
1810
|
+
obj_item = QTreeWidgetItem([f"Object: {obj}"])
|
|
1811
|
+
self.fileTree.addTopLevelItem(obj_item)
|
|
1812
|
+
obj_item.setExpanded(True)
|
|
1813
|
+
|
|
1814
|
+
for filt in sorted(by_object[obj], key=lambda f: f.lower()):
|
|
1815
|
+
filt_item = QTreeWidgetItem([f"Filter: {filt}"])
|
|
1816
|
+
obj_item.addChild(filt_item)
|
|
1817
|
+
filt_item.setExpanded(True)
|
|
1818
|
+
|
|
1819
|
+
for exp in sorted(by_object[obj][filt], key=lambda e: str(e).lower()):
|
|
1820
|
+
exp_item = QTreeWidgetItem([f"Exposure: {exp}"])
|
|
1821
|
+
filt_item.addChild(exp_item)
|
|
1822
|
+
exp_item.setExpanded(True)
|
|
1823
|
+
|
|
1824
|
+
for p in by_object[obj][filt][exp]:
|
|
1825
|
+
leaf = QTreeWidgetItem([os.path.basename(p)])
|
|
1826
|
+
leaf.setData(0, Qt.ItemDataRole.UserRole, p)
|
|
1827
|
+
exp_item.addChild(leaf)
|
|
1828
|
+
|
|
1829
|
+
self.loading_label.setText(self.tr("Loaded {0} images.").format(len(self.loaded_images)))
|
|
1830
|
+
self.progress_bar.setValue(100)
|
|
1831
|
+
self.imagesChanged.emit(len(self.loaded_images))
|
|
1832
|
+
if self.metrics_window and self.metrics_window.isVisible():
|
|
1833
|
+
self.metrics_window.update_metrics(self.loaded_images, order=self._tree_order_indices())
|
|
1834
|
+
|
|
1835
|
+
|
|
1836
|
+
def findTopLevelItemByName(self, name):
|
|
1837
|
+
"""Find a top-level item in the tree by its name."""
|
|
1838
|
+
for index in range(self.fileTree.topLevelItemCount()):
|
|
1839
|
+
item = self.fileTree.topLevelItem(index)
|
|
1840
|
+
if item.text(0) == name:
|
|
1841
|
+
return item
|
|
1842
|
+
return None
|
|
1843
|
+
|
|
1844
|
+
def findChildItemByName(self, parent, name):
|
|
1845
|
+
"""Find a child item under a given parent by its name."""
|
|
1846
|
+
for index in range(parent.childCount()):
|
|
1847
|
+
child = parent.child(index)
|
|
1848
|
+
if child.text(0) == name:
|
|
1849
|
+
return child
|
|
1850
|
+
return None
|
|
1851
|
+
|
|
1852
|
+
|
|
1853
|
+
def _toggle_flag_on_item(self, item: QTreeWidgetItem, *, sync_metrics: bool = True):
|
|
1854
|
+
file_name = item.text(0).lstrip("⚠️ ")
|
|
1855
|
+
file_path = next((p for p in self.image_paths if os.path.basename(p) == file_name), None)
|
|
1856
|
+
if file_path is None:
|
|
1857
|
+
return
|
|
1858
|
+
|
|
1859
|
+
idx = self.image_paths.index(file_path)
|
|
1860
|
+
entry = self.loaded_images[idx]
|
|
1861
|
+
entry['flagged'] = not entry['flagged']
|
|
1862
|
+
|
|
1863
|
+
RED = Qt.GlobalColor.red
|
|
1864
|
+
palette = self.fileTree.palette()
|
|
1865
|
+
normal_color = palette.color(QPalette.ColorRole.WindowText)
|
|
1866
|
+
|
|
1867
|
+
if entry['flagged']:
|
|
1868
|
+
item.setText(0, f"⚠️ {file_name}")
|
|
1869
|
+
item.setForeground(0, QBrush(RED))
|
|
1870
|
+
else:
|
|
1871
|
+
item.setText(0, file_name)
|
|
1872
|
+
item.setForeground(0, QBrush(normal_color))
|
|
1873
|
+
|
|
1874
|
+
if sync_metrics:
|
|
1875
|
+
self._sync_metrics_flags()
|
|
1876
|
+
|
|
1877
|
+
def flag_current_image(self):
|
|
1878
|
+
item = self.fileTree.currentItem()
|
|
1879
|
+
if not item:
|
|
1880
|
+
QMessageBox.warning(self, self.tr("No Selection"), self.tr("No image is currently selected to flag."))
|
|
1881
|
+
return
|
|
1882
|
+
self._toggle_flag_on_item(item) # ← this now updates the metrics panel too
|
|
1883
|
+
self.next_item()
|
|
1884
|
+
|
|
1885
|
+
|
|
1886
|
+
def on_current_item_changed(self, current, previous):
|
|
1887
|
+
"""Ensure the selected item is visible by scrolling to it."""
|
|
1888
|
+
if current:
|
|
1889
|
+
self.fileTree.scrollToItem(current, QAbstractItemView.ScrollHint.PositionAtCenter)
|
|
1890
|
+
|
|
1891
|
+
def previous_item(self):
|
|
1892
|
+
"""Select the previous item in the TreeWidget."""
|
|
1893
|
+
current_item = self.fileTree.currentItem()
|
|
1894
|
+
if current_item:
|
|
1895
|
+
all_items = self.get_all_leaf_items()
|
|
1896
|
+
current_index = all_items.index(current_item)
|
|
1897
|
+
if current_index > 0:
|
|
1898
|
+
previous_item = all_items[current_index - 1]
|
|
1899
|
+
else:
|
|
1900
|
+
previous_item = all_items[-1] # Loop back to the last item
|
|
1901
|
+
self.fileTree.setCurrentItem(previous_item)
|
|
1902
|
+
#self.on_item_clicked(previous_item, 0) # Update the preview
|
|
1903
|
+
|
|
1904
|
+
def next_item(self):
|
|
1905
|
+
"""Select the next item in the TreeWidget, looping back to the first item if at the end."""
|
|
1906
|
+
current_item = self.fileTree.currentItem()
|
|
1907
|
+
if current_item:
|
|
1908
|
+
# Get all leaf items
|
|
1909
|
+
all_items = self.get_all_leaf_items()
|
|
1910
|
+
|
|
1911
|
+
# Check if the current item is in the leaf items
|
|
1912
|
+
try:
|
|
1913
|
+
current_index = all_items.index(current_item)
|
|
1914
|
+
except ValueError:
|
|
1915
|
+
# If the current item is not a leaf, move to the first leaf item
|
|
1916
|
+
print("Current item is not a leaf. Selecting the first leaf item.")
|
|
1917
|
+
if all_items:
|
|
1918
|
+
next_item = all_items[0]
|
|
1919
|
+
self.fileTree.setCurrentItem(next_item)
|
|
1920
|
+
self.on_item_clicked(next_item, 0)
|
|
1921
|
+
return
|
|
1922
|
+
|
|
1923
|
+
# Select the next leaf item or loop back to the first
|
|
1924
|
+
if current_index < len(all_items) - 1:
|
|
1925
|
+
next_item = all_items[current_index + 1]
|
|
1926
|
+
else:
|
|
1927
|
+
next_item = all_items[0] # Loop back to the first item
|
|
1928
|
+
|
|
1929
|
+
self.fileTree.setCurrentItem(next_item)
|
|
1930
|
+
#self.on_item_clicked(next_item, 0) # Update the preview
|
|
1931
|
+
else:
|
|
1932
|
+
print("No current item selected.")
|
|
1933
|
+
|
|
1934
|
+
def get_all_leaf_items(self):
|
|
1935
|
+
"""Get a flat list of all leaf items (actual files) in the TreeWidget."""
|
|
1936
|
+
def recurse(parent):
|
|
1937
|
+
items = []
|
|
1938
|
+
for index in range(parent.childCount()):
|
|
1939
|
+
child = parent.child(index)
|
|
1940
|
+
if child.childCount() == 0: # It's a leaf item
|
|
1941
|
+
items.append(child)
|
|
1942
|
+
else:
|
|
1943
|
+
items.extend(recurse(child))
|
|
1944
|
+
return items
|
|
1945
|
+
|
|
1946
|
+
root = self.fileTree.invisibleRootItem()
|
|
1947
|
+
return recurse(root)
|
|
1948
|
+
|
|
1949
|
+
def start_playback(self):
|
|
1950
|
+
"""Start playing through the items in the TreeWidget."""
|
|
1951
|
+
if self.playback_timer.isActive():
|
|
1952
|
+
return
|
|
1953
|
+
|
|
1954
|
+
leaves = self.get_all_leaf_items()
|
|
1955
|
+
if not leaves:
|
|
1956
|
+
QMessageBox.information(self, self.tr("No Images"), self.tr("Load some images first."))
|
|
1957
|
+
return
|
|
1958
|
+
|
|
1959
|
+
# Ensure a current leaf item is selected
|
|
1960
|
+
cur = self.fileTree.currentItem()
|
|
1961
|
+
if cur is None or cur.childCount() > 0:
|
|
1962
|
+
self.fileTree.setCurrentItem(leaves[0])
|
|
1963
|
+
|
|
1964
|
+
# Honor current fps setting
|
|
1965
|
+
self._apply_playback_interval()
|
|
1966
|
+
self.playback_timer.start()
|
|
1967
|
+
|
|
1968
|
+
def stop_playback(self):
|
|
1969
|
+
"""Stop playing through the items."""
|
|
1970
|
+
if self.playback_timer.isActive():
|
|
1971
|
+
self.playback_timer.stop()
|
|
1972
|
+
|
|
1973
|
+
|
|
1974
|
+
def openFileDialog(self):
|
|
1975
|
+
"""Allow users to select multiple images and add them to the existing list."""
|
|
1976
|
+
file_paths, _ = QFileDialog.getOpenFileNames(
|
|
1977
|
+
self,
|
|
1978
|
+
self.tr("Open Images"),
|
|
1979
|
+
"",
|
|
1980
|
+
self.tr("Images (*.png *.tif *.tiff *.fits *.fit *.xisf *.cr2 *.cr3 *.nef *.arw *.dng *.raf *.orf *.rw2 *.pef);;All Files (*)")
|
|
1981
|
+
)
|
|
1982
|
+
|
|
1983
|
+
# Filter out already loaded images to prevent duplicates
|
|
1984
|
+
new_file_paths = [path for path in file_paths if path not in self.image_paths]
|
|
1985
|
+
|
|
1986
|
+
if new_file_paths:
|
|
1987
|
+
self.loadImages(new_file_paths)
|
|
1988
|
+
else:
|
|
1989
|
+
QMessageBox.information(self, self.tr("No New Images"), self.tr("No new images were selected or all selected images are already loaded."))
|
|
1990
|
+
|
|
1991
|
+
|
|
1992
|
+
def debayer_fits(self, image_data, bayer_pattern):
|
|
1993
|
+
"""Debayer a FITS image using a basic Bayer pattern (2x2)."""
|
|
1994
|
+
if bayer_pattern == 'RGGB':
|
|
1995
|
+
# RGGB Bayer pattern
|
|
1996
|
+
r = image_data[::2, ::2] # Red
|
|
1997
|
+
g1 = image_data[::2, 1::2] # Green 1
|
|
1998
|
+
g2 = image_data[1::2, ::2] # Green 2
|
|
1999
|
+
b = image_data[1::2, 1::2] # Blue
|
|
2000
|
+
|
|
2001
|
+
# Average green channels
|
|
2002
|
+
g = (g1 + g2) / 2
|
|
2003
|
+
return np.stack([r, g, b], axis=-1)
|
|
2004
|
+
|
|
2005
|
+
elif bayer_pattern == 'BGGR':
|
|
2006
|
+
# BGGR Bayer pattern
|
|
2007
|
+
b = image_data[::2, ::2] # Blue
|
|
2008
|
+
g1 = image_data[::2, 1::2] # Green 1
|
|
2009
|
+
g2 = image_data[1::2, ::2] # Green 2
|
|
2010
|
+
r = image_data[1::2, 1::2] # Red
|
|
2011
|
+
|
|
2012
|
+
# Average green channels
|
|
2013
|
+
g = (g1 + g2) / 2
|
|
2014
|
+
return np.stack([r, g, b], axis=-1)
|
|
2015
|
+
|
|
2016
|
+
elif bayer_pattern == 'GRBG':
|
|
2017
|
+
# GRBG Bayer pattern
|
|
2018
|
+
g1 = image_data[::2, ::2] # Green 1
|
|
2019
|
+
r = image_data[::2, 1::2] # Red
|
|
2020
|
+
b = image_data[1::2, ::2] # Blue
|
|
2021
|
+
g2 = image_data[1::2, 1::2] # Green 2
|
|
2022
|
+
|
|
2023
|
+
# Average green channels
|
|
2024
|
+
g = (g1 + g2) / 2
|
|
2025
|
+
return np.stack([r, g, b], axis=-1)
|
|
2026
|
+
|
|
2027
|
+
elif bayer_pattern == 'GBRG':
|
|
2028
|
+
# GBRG Bayer pattern
|
|
2029
|
+
g1 = image_data[::2, ::2] # Green 1
|
|
2030
|
+
b = image_data[::2, 1::2] # Blue
|
|
2031
|
+
r = image_data[1::2, ::2] # Red
|
|
2032
|
+
g2 = image_data[1::2, 1::2] # Green 2
|
|
2033
|
+
|
|
2034
|
+
# Average green channels
|
|
2035
|
+
g = (g1 + g2) / 2
|
|
2036
|
+
return np.stack([r, g, b], axis=-1)
|
|
2037
|
+
|
|
2038
|
+
else:
|
|
2039
|
+
raise ValueError(self.tr("Unsupported Bayer pattern: {0}").format(bayer_pattern))
|
|
2040
|
+
|
|
2041
|
+
def remove_item_from_tree(self, file_path):
|
|
2042
|
+
"""Remove a specific item from the tree view based on file path."""
|
|
2043
|
+
file_name = os.path.basename(file_path)
|
|
2044
|
+
root = self.fileTree.invisibleRootItem()
|
|
2045
|
+
|
|
2046
|
+
def recurse(parent):
|
|
2047
|
+
for index in range(parent.childCount()):
|
|
2048
|
+
child = parent.child(index)
|
|
2049
|
+
if child.text(0).endswith(file_name):
|
|
2050
|
+
parent.removeChild(child)
|
|
2051
|
+
return True
|
|
2052
|
+
if recurse(child):
|
|
2053
|
+
return True
|
|
2054
|
+
return False
|
|
2055
|
+
|
|
2056
|
+
recurse(root)
|
|
2057
|
+
|
|
2058
|
+
def add_item_to_tree(self, file_path):
|
|
2059
|
+
"""Add a specific item to the tree view based on file path."""
|
|
2060
|
+
# Extract metadata for grouping
|
|
2061
|
+
image_entry = next((img for img in self.loaded_images if img['file_path'] == file_path), None)
|
|
2062
|
+
if not image_entry:
|
|
2063
|
+
return
|
|
2064
|
+
|
|
2065
|
+
header = image_entry['header']
|
|
2066
|
+
object_name = header.get('OBJECT', 'Unknown') if header else 'Unknown'
|
|
2067
|
+
filter_name = header.get('FILTER', 'Unknown') if header else 'Unknown'
|
|
2068
|
+
exposure_time = header.get('EXPOSURE', 'Unknown') if header else 'Unknown'
|
|
2069
|
+
|
|
2070
|
+
# Group images by filter and exposure time
|
|
2071
|
+
group_key = (object_name, filter_name, exposure_time)
|
|
2072
|
+
|
|
2073
|
+
# Find or create the object item
|
|
2074
|
+
object_item = self.findTopLevelItemByName(f"Object: {object_name}")
|
|
2075
|
+
if not object_item:
|
|
2076
|
+
object_item = QTreeWidgetItem([f"Object: {object_name}"])
|
|
2077
|
+
self.fileTree.addTopLevelItem(object_item)
|
|
2078
|
+
object_item.setExpanded(True)
|
|
2079
|
+
|
|
2080
|
+
# Find or create the filter item
|
|
2081
|
+
filter_item = self.findChildItemByName(object_item, f"Filter: {filter_name}")
|
|
2082
|
+
if not filter_item:
|
|
2083
|
+
filter_item = QTreeWidgetItem([f"Filter: {filter_name}"])
|
|
2084
|
+
object_item.addChild(filter_item)
|
|
2085
|
+
filter_item.setExpanded(True)
|
|
2086
|
+
|
|
2087
|
+
# Find or create the exposure item
|
|
2088
|
+
exposure_item = self.findChildItemByName(filter_item, f"Exposure: {exposure_time}")
|
|
2089
|
+
if not exposure_item:
|
|
2090
|
+
exposure_item = QTreeWidgetItem([f"Exposure: {exposure_time}"])
|
|
2091
|
+
filter_item.addChild(exposure_item)
|
|
2092
|
+
exposure_item.setExpanded(True)
|
|
2093
|
+
|
|
2094
|
+
# Add the file item
|
|
2095
|
+
file_name = os.path.basename(file_path)
|
|
2096
|
+
item = QTreeWidgetItem([file_name])
|
|
2097
|
+
item.setData(0, Qt.ItemDataRole.UserRole, file_path)
|
|
2098
|
+
exposure_item.addChild(item)
|
|
2099
|
+
|
|
2100
|
+
def _tree_order_indices(self) -> list[int]:
|
|
2101
|
+
"""Return the indices of loaded_images in the exact order the Tree shows."""
|
|
2102
|
+
order = []
|
|
2103
|
+
for leaf in self.get_all_leaf_items():
|
|
2104
|
+
path = leaf.data(0, Qt.ItemDataRole.UserRole)
|
|
2105
|
+
if not path:
|
|
2106
|
+
# fallback by basename if old items exist
|
|
2107
|
+
name = leaf.text(0).lstrip("⚠️ ").strip()
|
|
2108
|
+
path = next((p for p in self.image_paths if os.path.basename(p) == name), None)
|
|
2109
|
+
if path and path in self.image_paths:
|
|
2110
|
+
order.append(self.image_paths.index(path))
|
|
2111
|
+
return order
|
|
2112
|
+
|
|
2113
|
+
def debayer_raw(self, raw_image_data, bayer_pattern="RGGB"):
|
|
2114
|
+
"""Debayer a RAW image based on the Bayer pattern, ensuring even dimensions."""
|
|
2115
|
+
H, W = raw_image_data.shape
|
|
2116
|
+
# Crop to even dimensions if necessary
|
|
2117
|
+
if H % 2 != 0:
|
|
2118
|
+
raw_image_data = raw_image_data[:H-1, :]
|
|
2119
|
+
if W % 2 != 0:
|
|
2120
|
+
raw_image_data = raw_image_data[:, :W-1]
|
|
2121
|
+
|
|
2122
|
+
if bayer_pattern == 'RGGB':
|
|
2123
|
+
r = raw_image_data[::2, ::2] # Red
|
|
2124
|
+
g1 = raw_image_data[::2, 1::2] # Green 1
|
|
2125
|
+
g2 = raw_image_data[1::2, ::2] # Green 2
|
|
2126
|
+
b = raw_image_data[1::2, 1::2] # Blue
|
|
2127
|
+
|
|
2128
|
+
# Average green channels
|
|
2129
|
+
g = (g1 + g2) / 2
|
|
2130
|
+
return np.stack([r, g, b], axis=-1)
|
|
2131
|
+
elif bayer_pattern == 'BGGR':
|
|
2132
|
+
b = raw_image_data[::2, ::2] # Blue
|
|
2133
|
+
g1 = raw_image_data[::2, 1::2] # Green 1
|
|
2134
|
+
g2 = raw_image_data[1::2, ::2] # Green 2
|
|
2135
|
+
r = raw_image_data[1::2, 1::2] # Red
|
|
2136
|
+
|
|
2137
|
+
g = (g1 + g2) / 2
|
|
2138
|
+
return np.stack([r, g, b], axis=-1)
|
|
2139
|
+
elif bayer_pattern == 'GRBG':
|
|
2140
|
+
g1 = raw_image_data[::2, ::2] # Green 1
|
|
2141
|
+
r = raw_image_data[::2, 1::2] # Red
|
|
2142
|
+
b = raw_image_data[1::2, ::2] # Blue
|
|
2143
|
+
g2 = raw_image_data[1::2, 1::2] # Green 2
|
|
2144
|
+
|
|
2145
|
+
g = (g1 + g2) / 2
|
|
2146
|
+
return np.stack([r, g, b], axis=-1)
|
|
2147
|
+
elif bayer_pattern == 'GBRG':
|
|
2148
|
+
g1 = raw_image_data[::2, ::2] # Green 1
|
|
2149
|
+
b = raw_image_data[::2, 1::2] # Blue
|
|
2150
|
+
r = raw_image_data[1::2, ::2] # Red
|
|
2151
|
+
g2 = raw_image_data[1::2, 1::2] # Green 2
|
|
2152
|
+
|
|
2153
|
+
g = (g1 + g2) / 2
|
|
2154
|
+
return np.stack([r, g, b], axis=-1)
|
|
2155
|
+
else:
|
|
2156
|
+
raise ValueError(self.tr("Unsupported Bayer pattern: {0}").format(bayer_pattern))
|
|
2157
|
+
|
|
2158
|
+
|
|
2159
|
+
|
|
2160
|
+
def on_item_clicked(self, item, column):
|
|
2161
|
+
self.fileTree.setFocus()
|
|
2162
|
+
|
|
2163
|
+
name = item.text(0).lstrip("⚠️ ").strip()
|
|
2164
|
+
file_path = next((p for p in self.image_paths if os.path.basename(p) == name), None)
|
|
2165
|
+
if not file_path:
|
|
2166
|
+
return
|
|
2167
|
+
|
|
2168
|
+
self._capture_view_center_norm()
|
|
2169
|
+
|
|
2170
|
+
idx = self.image_paths.index(file_path)
|
|
2171
|
+
entry = self.loaded_images[idx]
|
|
2172
|
+
stored = entry['image_data'] # already stretched & clipped at load time
|
|
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)
|
|
2201
|
+
|
|
2202
|
+
disp8 = (disp01 * 255.0).astype(np.uint8)
|
|
2203
|
+
|
|
2204
|
+
|
|
2205
|
+
qimage = self.convert_to_qimage(disp8)
|
|
2206
|
+
self.current_pixmap = QPixmap.fromImage(qimage)
|
|
2207
|
+
self.apply_zoom()
|
|
2208
|
+
|
|
2209
|
+
def _capture_view_center_norm(self):
|
|
2210
|
+
"""Remember the current viewport center as a fraction of the content size."""
|
|
2211
|
+
sa = self.scroll_area
|
|
2212
|
+
vp = sa.viewport()
|
|
2213
|
+
content_w = max(1, self.preview_label.width())
|
|
2214
|
+
content_h = max(1, self.preview_label.height())
|
|
2215
|
+
if content_w <= 1 or content_h <= 1:
|
|
2216
|
+
return
|
|
2217
|
+
hbar = sa.horizontalScrollBar()
|
|
2218
|
+
vbar = sa.verticalScrollBar()
|
|
2219
|
+
cx = hbar.value() + vp.width() / 2.0
|
|
2220
|
+
cy = vbar.value() + vp.height() / 2.0
|
|
2221
|
+
self._view_center_norm = (cx / content_w, cy / content_h)
|
|
2222
|
+
|
|
2223
|
+
def _restore_view_center_norm(self):
|
|
2224
|
+
"""Restore the viewport center captured earlier (if any)."""
|
|
2225
|
+
if not self._view_center_norm:
|
|
2226
|
+
return
|
|
2227
|
+
sa = self.scroll_area
|
|
2228
|
+
vp = sa.viewport()
|
|
2229
|
+
content_w = max(1, self.preview_label.width())
|
|
2230
|
+
content_h = max(1, self.preview_label.height())
|
|
2231
|
+
cx = self._view_center_norm[0] * content_w
|
|
2232
|
+
cy = self._view_center_norm[1] * content_h
|
|
2233
|
+
hbar = sa.horizontalScrollBar()
|
|
2234
|
+
vbar = sa.verticalScrollBar()
|
|
2235
|
+
h_target = int(round(cx - vp.width() / 2.0))
|
|
2236
|
+
v_target = int(round(cy - vp.height() / 2.0))
|
|
2237
|
+
h_target = max(hbar.minimum(), min(hbar.maximum(), h_target))
|
|
2238
|
+
v_target = max(vbar.minimum(), min(vbar.maximum(), v_target))
|
|
2239
|
+
# Set after layout settles to avoid fighting size changes
|
|
2240
|
+
QTimer.singleShot(0, lambda: (hbar.setValue(h_target), vbar.setValue(v_target)))
|
|
2241
|
+
|
|
2242
|
+
def apply_zoom(self):
|
|
2243
|
+
"""Apply current zoom to pixmap without losing scroll position."""
|
|
2244
|
+
if not self.current_pixmap:
|
|
2245
|
+
return
|
|
2246
|
+
|
|
2247
|
+
# keep current center if we already showed something
|
|
2248
|
+
had_content = (self.preview_label.pixmap() is not None) and (self.preview_label.width() > 0)
|
|
2249
|
+
|
|
2250
|
+
if had_content:
|
|
2251
|
+
self._capture_view_center_norm()
|
|
2252
|
+
else:
|
|
2253
|
+
# first time: default center
|
|
2254
|
+
self._view_center_norm = (0.5, 0.5)
|
|
2255
|
+
|
|
2256
|
+
# scale and show
|
|
2257
|
+
base_w = self.current_pixmap.width()
|
|
2258
|
+
base_h = self.current_pixmap.height()
|
|
2259
|
+
scaled_w = max(1, int(round(base_w * self.zoom_level)))
|
|
2260
|
+
scaled_h = max(1, int(round(base_h * self.zoom_level)))
|
|
2261
|
+
|
|
2262
|
+
scaled = self.current_pixmap.scaled(
|
|
2263
|
+
scaled_w, scaled_h,
|
|
2264
|
+
Qt.AspectRatioMode.KeepAspectRatio,
|
|
2265
|
+
Qt.TransformationMode.SmoothTransformation,
|
|
2266
|
+
)
|
|
2267
|
+
self.preview_label.setPixmap(scaled)
|
|
2268
|
+
self.preview_label.resize(scaled.size())
|
|
2269
|
+
|
|
2270
|
+
# restore the center we captured (or 0.5,0.5 for first time)
|
|
2271
|
+
self._restore_view_center_norm()
|
|
2272
|
+
|
|
2273
|
+
def wheelEvent(self, event: QWheelEvent):
|
|
2274
|
+
# Check the vertical delta to determine zoom direction.
|
|
2275
|
+
if event.angleDelta().y() > 0:
|
|
2276
|
+
self.zoom_in()
|
|
2277
|
+
else:
|
|
2278
|
+
self.zoom_out()
|
|
2279
|
+
# Accept the event so it isn’t propagated further (e.g. to the scroll area).
|
|
2280
|
+
event.accept()
|
|
2281
|
+
|
|
2282
|
+
|
|
2283
|
+
def zoom_in(self):
|
|
2284
|
+
"""Increase the zoom level and refresh the image."""
|
|
2285
|
+
self.zoom_level = min(self.zoom_level * 1.2, 3.0) # Cap at 3x
|
|
2286
|
+
self.apply_zoom()
|
|
2287
|
+
|
|
2288
|
+
|
|
2289
|
+
def zoom_out(self):
|
|
2290
|
+
"""Decrease the zoom level and refresh the image."""
|
|
2291
|
+
self.zoom_level = max(self.zoom_level / 1.2, 0.05) # Cap at 0.2x
|
|
2292
|
+
self.apply_zoom()
|
|
2293
|
+
|
|
2294
|
+
|
|
2295
|
+
def fit_to_preview(self):
|
|
2296
|
+
"""Adjust the zoom level so the image fits within the QScrollArea viewport."""
|
|
2297
|
+
if self.current_pixmap:
|
|
2298
|
+
# Get the size of the QScrollArea's viewport
|
|
2299
|
+
viewport_size = self.scroll_area.viewport().size()
|
|
2300
|
+
pixmap_size = self.current_pixmap.size()
|
|
2301
|
+
|
|
2302
|
+
# Calculate the zoom level required to fit the pixmap in the QScrollArea viewport
|
|
2303
|
+
width_ratio = viewport_size.width() / pixmap_size.width()
|
|
2304
|
+
height_ratio = viewport_size.height() / pixmap_size.height()
|
|
2305
|
+
self.zoom_level = min(width_ratio, height_ratio)
|
|
2306
|
+
|
|
2307
|
+
# Apply the zoom level
|
|
2308
|
+
self.apply_zoom()
|
|
2309
|
+
else:
|
|
2310
|
+
print("No image loaded. Cannot fit to preview.")
|
|
2311
|
+
QMessageBox.warning(self, self.tr("Warning"), self.tr("No image loaded. Cannot fit to preview."))
|
|
2312
|
+
|
|
2313
|
+
def _is_leaf(self, item: Optional[QTreeWidgetItem]) -> bool:
|
|
2314
|
+
return bool(item and item.childCount() == 0)
|
|
2315
|
+
|
|
2316
|
+
def on_right_click(self, pos):
|
|
2317
|
+
item = self.fileTree.itemAt(pos)
|
|
2318
|
+
if not self._is_leaf(item):
|
|
2319
|
+
# Optional: expand/collapse-only menu, or just ignore
|
|
2320
|
+
return
|
|
2321
|
+
|
|
2322
|
+
menu = QMenu(self)
|
|
2323
|
+
|
|
2324
|
+
push_action = QAction(self.tr("Open in Document Window"), self)
|
|
2325
|
+
push_action.triggered.connect(lambda: self.push_to_docs(item))
|
|
2326
|
+
menu.addAction(push_action)
|
|
2327
|
+
|
|
2328
|
+
rename_action = QAction(self.tr("Rename"), self)
|
|
2329
|
+
rename_action.triggered.connect(lambda: self.rename_item(item))
|
|
2330
|
+
menu.addAction(rename_action)
|
|
2331
|
+
|
|
2332
|
+
# 🔹 NEW: batch rename selected
|
|
2333
|
+
batch_rename_action = QAction(self.tr("Batch Rename Selected…"), self)
|
|
2334
|
+
batch_rename_action.triggered.connect(self.batch_rename_items)
|
|
2335
|
+
menu.addAction(batch_rename_action)
|
|
2336
|
+
|
|
2337
|
+
move_action = QAction(self.tr("Move Selected Items"), self)
|
|
2338
|
+
move_action.triggered.connect(self.move_items)
|
|
2339
|
+
menu.addAction(move_action)
|
|
2340
|
+
|
|
2341
|
+
delete_action = QAction(self.tr("Delete Selected Items"), self)
|
|
2342
|
+
delete_action.triggered.connect(self.delete_items)
|
|
2343
|
+
menu.addAction(delete_action)
|
|
2344
|
+
|
|
2345
|
+
menu.addSeparator()
|
|
2346
|
+
|
|
2347
|
+
batch_delete_action = QAction(self.tr("Delete All Flagged Images"), self)
|
|
2348
|
+
batch_delete_action.triggered.connect(self.batch_delete_flagged_images)
|
|
2349
|
+
menu.addAction(batch_delete_action)
|
|
2350
|
+
|
|
2351
|
+
batch_move_action = QAction(self.tr("Move All Flagged Images"), self)
|
|
2352
|
+
batch_move_action.triggered.connect(self.batch_move_flagged_images)
|
|
2353
|
+
menu.addAction(batch_move_action)
|
|
2354
|
+
|
|
2355
|
+
# 🔹 NEW: rename all flagged images
|
|
2356
|
+
rename_flagged_action = QAction(self.tr("Rename Flagged Images…"), self)
|
|
2357
|
+
rename_flagged_action.triggered.connect(self.rename_flagged_images)
|
|
2358
|
+
menu.addAction(rename_flagged_action)
|
|
2359
|
+
|
|
2360
|
+
menu.addSeparator()
|
|
2361
|
+
|
|
2362
|
+
send_lights_act = QAction(self.tr("Send to Stacking → Lights"), self)
|
|
2363
|
+
send_lights_act.triggered.connect(self._send_to_stacking_lights)
|
|
2364
|
+
menu.addAction(send_lights_act)
|
|
2365
|
+
|
|
2366
|
+
send_integ_act = QAction(self.tr("Send to Stacking → Integration"), self)
|
|
2367
|
+
send_integ_act.triggered.connect(self._send_to_stacking_integration)
|
|
2368
|
+
menu.addAction(send_integ_act)
|
|
2369
|
+
|
|
2370
|
+
menu.exec(self.fileTree.mapToGlobal(pos))
|
|
2371
|
+
|
|
2372
|
+
|
|
2373
|
+
def push_to_docs(self, item):
|
|
2374
|
+
# Resolve file + entry
|
|
2375
|
+
file_name = item.text(0).lstrip("⚠️ ")
|
|
2376
|
+
file_path = next((p for p in self.image_paths if os.path.basename(p) == file_name), None)
|
|
2377
|
+
if not file_path:
|
|
2378
|
+
return
|
|
2379
|
+
idx = self.image_paths.index(file_path)
|
|
2380
|
+
entry = self.loaded_images[idx]
|
|
2381
|
+
|
|
2382
|
+
# Find main window + doc manager
|
|
2383
|
+
mw = self._main_window()
|
|
2384
|
+
dm = self.doc_manager or (getattr(mw, "docman", None) if mw else None)
|
|
2385
|
+
if not mw or not dm:
|
|
2386
|
+
QMessageBox.warning(self, self.tr("Document Manager"), self.tr("Main window or DocManager not available."))
|
|
2387
|
+
return
|
|
2388
|
+
|
|
2389
|
+
# Prepare image + metadata for a real document
|
|
2390
|
+
np_image_f01 = self._as_float01(entry['image_data']) # ensure float32 [0..1]
|
|
2391
|
+
metadata = {
|
|
2392
|
+
'file_path': file_path,
|
|
2393
|
+
'original_header': entry.get('header', {}),
|
|
2394
|
+
'bit_depth': entry.get('bit_depth'),
|
|
2395
|
+
'is_mono': entry.get('is_mono'),
|
|
2396
|
+
'source': 'BlinkComparatorPro',
|
|
2397
|
+
}
|
|
2398
|
+
title = os.path.basename(file_path)
|
|
2399
|
+
|
|
2400
|
+
# Create the document using whatever API your DocManager has
|
|
2401
|
+
doc = None
|
|
2402
|
+
try:
|
|
2403
|
+
if hasattr(dm, "open_array"):
|
|
2404
|
+
doc = dm.open_array(np_image_f01, metadata=metadata, title=title)
|
|
2405
|
+
elif hasattr(dm, "open_numpy"):
|
|
2406
|
+
doc = dm.open_numpy(np_image_f01, metadata=metadata, title=title)
|
|
2407
|
+
elif hasattr(dm, "create_document"):
|
|
2408
|
+
doc = dm.create_document(image=np_image_f01, metadata=metadata, name=title)
|
|
2409
|
+
else:
|
|
2410
|
+
raise AttributeError(self.tr("DocManager lacks open_array/open_numpy/create_document"))
|
|
2411
|
+
except Exception as e:
|
|
2412
|
+
QMessageBox.critical(self, self.tr("Doc Manager"), self.tr("Failed to create document:\n{0}").format(e))
|
|
2413
|
+
return
|
|
2414
|
+
|
|
2415
|
+
if doc is None:
|
|
2416
|
+
QMessageBox.critical(self, self.tr("Doc Manager"), self.tr("DocManager returned no document."))
|
|
2417
|
+
return
|
|
2418
|
+
|
|
2419
|
+
# SHOW it: ask the main window to spawn an MDI subwindow
|
|
2420
|
+
try:
|
|
2421
|
+
mw._spawn_subwindow_for(doc)
|
|
2422
|
+
if hasattr(mw, "_log"):
|
|
2423
|
+
mw._log(f"Blink → opened '{title}' as new document")
|
|
2424
|
+
except Exception as e:
|
|
2425
|
+
QMessageBox.critical(self, self.tr("UI"), self.tr("Failed to open subwindow:\n{0}").format(e))
|
|
2426
|
+
|
|
2427
|
+
|
|
2428
|
+
# optional shim to keep any old calls working
|
|
2429
|
+
def push_image_to_manager(self, item):
|
|
2430
|
+
self.push_to_docs(item)
|
|
2431
|
+
|
|
2432
|
+
|
|
2433
|
+
|
|
2434
|
+
def rename_item(self, item):
|
|
2435
|
+
"""Allow the user to rename the selected image."""
|
|
2436
|
+
current_name = item.text(0).lstrip("⚠️ ")
|
|
2437
|
+
new_name, ok = QInputDialog.getText(self, self.tr("Rename Image"), self.tr("Enter new name:"), text=current_name)
|
|
2438
|
+
|
|
2439
|
+
if ok and new_name:
|
|
2440
|
+
file_path = next((path for path in self.image_paths if os.path.basename(path) == current_name), None)
|
|
2441
|
+
if file_path:
|
|
2442
|
+
# Get the new file path with the new name
|
|
2443
|
+
new_file_path = os.path.join(os.path.dirname(file_path), new_name)
|
|
2444
|
+
|
|
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
|
+
|
|
2456
|
+
def rename_flagged_images(self):
|
|
2457
|
+
"""Prefix all *flagged* images on disk and in the tree."""
|
|
2458
|
+
# Collect indices of flagged frames
|
|
2459
|
+
flagged_indices = [i for i, e in enumerate(self.loaded_images)
|
|
2460
|
+
if e.get("flagged", False)]
|
|
2461
|
+
|
|
2462
|
+
if not flagged_indices:
|
|
2463
|
+
QMessageBox.information(
|
|
2464
|
+
self,
|
|
2465
|
+
self.tr("Rename Flagged Images"),
|
|
2466
|
+
self.tr("There are no flagged images to rename.")
|
|
2467
|
+
)
|
|
2468
|
+
return
|
|
2469
|
+
|
|
2470
|
+
# Small dialog like in your mockup: just a prefix field
|
|
2471
|
+
dlg = QDialog(self)
|
|
2472
|
+
dlg.setWindowTitle(self.tr("Rename flagged images"))
|
|
2473
|
+
layout = QVBoxLayout(dlg)
|
|
2474
|
+
|
|
2475
|
+
layout.addWidget(QLabel(self.tr("Prefix to add to flagged image filenames:"), dlg))
|
|
2476
|
+
|
|
2477
|
+
prefix_edit = QLineEdit(dlg)
|
|
2478
|
+
prefix_edit.setText("Bad_") # sensible default
|
|
2479
|
+
layout.addWidget(prefix_edit)
|
|
2480
|
+
|
|
2481
|
+
btn_box = QDialogButtonBox(
|
|
2482
|
+
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel,
|
|
2483
|
+
parent=dlg,
|
|
2484
|
+
)
|
|
2485
|
+
btn_box.accepted.connect(dlg.accept)
|
|
2486
|
+
btn_box.rejected.connect(dlg.reject)
|
|
2487
|
+
layout.addWidget(btn_box)
|
|
2488
|
+
|
|
2489
|
+
if dlg.exec() != QDialog.DialogCode.Accepted:
|
|
2490
|
+
return
|
|
2491
|
+
|
|
2492
|
+
prefix = prefix_edit.text()
|
|
2493
|
+
if prefix is None:
|
|
2494
|
+
prefix = ""
|
|
2495
|
+
prefix = prefix.strip()
|
|
2496
|
+
if not prefix:
|
|
2497
|
+
# Allow empty but warn – otherwise user may be confused
|
|
2498
|
+
ret = QMessageBox.question(
|
|
2499
|
+
self,
|
|
2500
|
+
self.tr("No Prefix"),
|
|
2501
|
+
self.tr("No prefix entered. This will not change any filenames.\n\n"
|
|
2502
|
+
"Continue anyway?"),
|
|
2503
|
+
QMessageBox.StandardButton.Yes | QDialogButtonBox.StandardButton.No,
|
|
2504
|
+
QMessageBox.StandardButton.No,
|
|
2505
|
+
)
|
|
2506
|
+
if ret != QMessageBox.StandardButton.Yes:
|
|
2507
|
+
return
|
|
2508
|
+
|
|
2509
|
+
successes = 0
|
|
2510
|
+
failures = []
|
|
2511
|
+
|
|
2512
|
+
for idx in flagged_indices:
|
|
2513
|
+
old_path = self.image_paths[idx]
|
|
2514
|
+
directory, base = os.path.split(old_path)
|
|
2515
|
+
|
|
2516
|
+
new_base = f"{prefix}{base}"
|
|
2517
|
+
new_path = os.path.join(directory, new_base)
|
|
2518
|
+
|
|
2519
|
+
# Skip if unchanged
|
|
2520
|
+
if new_path == old_path:
|
|
2521
|
+
continue
|
|
2522
|
+
|
|
2523
|
+
# Avoid overwriting an existing file
|
|
2524
|
+
if os.path.exists(new_path):
|
|
2525
|
+
failures.append((old_path, "target already exists"))
|
|
2526
|
+
continue
|
|
2527
|
+
|
|
2528
|
+
try:
|
|
2529
|
+
os.rename(old_path, new_path)
|
|
2530
|
+
except Exception as e:
|
|
2531
|
+
failures.append((old_path, str(e)))
|
|
2532
|
+
continue
|
|
2533
|
+
|
|
2534
|
+
# Update internal paths
|
|
2535
|
+
self.image_paths[idx] = new_path
|
|
2536
|
+
self.loaded_images[idx]["file_path"] = new_path
|
|
2537
|
+
|
|
2538
|
+
# Update tree item text + UserRole data
|
|
2539
|
+
item = self.get_tree_item_for_index(idx)
|
|
2540
|
+
if item is not None:
|
|
2541
|
+
# preserve ⚠️ prefix
|
|
2542
|
+
disp_name = new_base
|
|
2543
|
+
if self.loaded_images[idx].get("flagged", False):
|
|
2544
|
+
disp_name = f"⚠️ {disp_name}"
|
|
2545
|
+
item.setText(0, disp_name)
|
|
2546
|
+
item.setData(0, Qt.ItemDataRole.UserRole, new_path)
|
|
2547
|
+
|
|
2548
|
+
successes += 1
|
|
2549
|
+
|
|
2550
|
+
# Rebuild tree so new names are naturally re-sorted, keep flags
|
|
2551
|
+
self._after_list_changed()
|
|
2552
|
+
# Also sync the metrics panel flags/colors
|
|
2553
|
+
self._sync_metrics_flags()
|
|
2554
|
+
|
|
2555
|
+
msg = self.tr("Renamed {0} flagged image{1}.").format(successes, 's' if successes != 1 else '')
|
|
2556
|
+
if failures:
|
|
2557
|
+
msg += self.tr("\n\n{0} file(s) could not be renamed:").format(len(failures))
|
|
2558
|
+
for old, err in failures[:10]: # don’t spam too hard
|
|
2559
|
+
msg += f"\n• {os.path.basename(old)} – {err}"
|
|
2560
|
+
|
|
2561
|
+
QMessageBox.information(self, self.tr("Rename Flagged Images"), msg)
|
|
2562
|
+
|
|
2563
|
+
|
|
2564
|
+
def batch_rename_items(self):
|
|
2565
|
+
"""Batch rename selected items by adding a prefix or suffix."""
|
|
2566
|
+
selected_items = self.fileTree.selectedItems()
|
|
2567
|
+
|
|
2568
|
+
if not selected_items:
|
|
2569
|
+
QMessageBox.warning(self, self.tr("Warning"), self.tr("No items selected for renaming."))
|
|
2570
|
+
return
|
|
2571
|
+
|
|
2572
|
+
# Create a custom dialog for entering the prefix and suffix
|
|
2573
|
+
dialog = QDialog(self)
|
|
2574
|
+
dialog.setWindowTitle(self.tr("Batch Rename"))
|
|
2575
|
+
dialog_layout = QVBoxLayout(dialog)
|
|
2576
|
+
|
|
2577
|
+
instruction_label = QLabel(self.tr("Enter a prefix or suffix to rename selected files:"))
|
|
2578
|
+
dialog_layout.addWidget(instruction_label)
|
|
2579
|
+
|
|
2580
|
+
# Create fields for prefix and suffix
|
|
2581
|
+
form_layout = QHBoxLayout()
|
|
2582
|
+
|
|
2583
|
+
prefix_field = QLineEdit(dialog)
|
|
2584
|
+
prefix_field.setPlaceholderText(self.tr("Prefix"))
|
|
2585
|
+
form_layout.addWidget(prefix_field)
|
|
2586
|
+
|
|
2587
|
+
current_filename_label = QLabel("currentfilename", dialog)
|
|
2588
|
+
form_layout.addWidget(current_filename_label)
|
|
2589
|
+
|
|
2590
|
+
suffix_field = QLineEdit(dialog)
|
|
2591
|
+
suffix_field.setPlaceholderText(self.tr("Suffix"))
|
|
2592
|
+
form_layout.addWidget(suffix_field)
|
|
2593
|
+
|
|
2594
|
+
dialog_layout.addLayout(form_layout)
|
|
2595
|
+
|
|
2596
|
+
# Add OK and Cancel buttons
|
|
2597
|
+
button_layout = QHBoxLayout()
|
|
2598
|
+
ok_button = QPushButton(self.tr("OK"), dialog)
|
|
2599
|
+
ok_button.clicked.connect(dialog.accept)
|
|
2600
|
+
button_layout.addWidget(ok_button)
|
|
2601
|
+
|
|
2602
|
+
cancel_button = QPushButton(self.tr("Cancel"), dialog)
|
|
2603
|
+
cancel_button.clicked.connect(dialog.reject)
|
|
2604
|
+
button_layout.addWidget(cancel_button)
|
|
2605
|
+
|
|
2606
|
+
dialog_layout.addLayout(button_layout)
|
|
2607
|
+
|
|
2608
|
+
# Show the dialog and handle user input
|
|
2609
|
+
if dialog.exec() == QDialog.DialogCode.Accepted:
|
|
2610
|
+
prefix = prefix_field.text().strip()
|
|
2611
|
+
suffix = suffix_field.text().strip()
|
|
2612
|
+
|
|
2613
|
+
# Rename each selected file
|
|
2614
|
+
for item in selected_items:
|
|
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)
|
|
2617
|
+
|
|
2618
|
+
if file_path:
|
|
2619
|
+
# Construct the new filename
|
|
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)
|
|
2623
|
+
|
|
2624
|
+
try:
|
|
2625
|
+
# Rename the file
|
|
2626
|
+
os.rename(file_path, new_file_path)
|
|
2627
|
+
print(f"File renamed from {file_path} to {new_file_path}")
|
|
2628
|
+
|
|
2629
|
+
# Update the paths and tree view
|
|
2630
|
+
self.image_paths[self.image_paths.index(file_path)] = new_file_path
|
|
2631
|
+
item.setText(0, new_name)
|
|
2632
|
+
|
|
2633
|
+
except Exception as e:
|
|
2634
|
+
print(f"Failed to rename {file_path}: {e}")
|
|
2635
|
+
QMessageBox.critical(self, self.tr("Error"), self.tr("Failed to rename the file: {0}").format(e))
|
|
2636
|
+
|
|
2637
|
+
print(f"Batch renamed {len(selected_items)} items.")
|
|
2638
|
+
|
|
2639
|
+
def batch_delete_flagged_images(self):
|
|
2640
|
+
"""Delete all flagged images."""
|
|
2641
|
+
flagged_images = [img for img in self.loaded_images if img['flagged']]
|
|
2642
|
+
|
|
2643
|
+
if not flagged_images:
|
|
2644
|
+
QMessageBox.information(self, self.tr("No Flagged Images"), self.tr("There are no flagged images to delete."))
|
|
2645
|
+
return
|
|
2646
|
+
|
|
2647
|
+
confirmation = QMessageBox.question(
|
|
2648
|
+
self,
|
|
2649
|
+
self.tr("Confirm Batch Deletion"),
|
|
2650
|
+
self.tr("Are you sure you want to permanently delete {0} flagged images? This action is irreversible.").format(len(flagged_images)),
|
|
2651
|
+
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
|
2652
|
+
QMessageBox.StandardButton.No
|
|
2653
|
+
)
|
|
2654
|
+
|
|
2655
|
+
if confirmation == QMessageBox.StandardButton.Yes:
|
|
2656
|
+
removed_indices = []
|
|
2657
|
+
# snapshot the indices before mutation
|
|
2658
|
+
for img in flagged_images:
|
|
2659
|
+
try:
|
|
2660
|
+
removed_indices.append(self.image_paths.index(img['file_path']))
|
|
2661
|
+
except ValueError:
|
|
2662
|
+
pass
|
|
2663
|
+
|
|
2664
|
+
# perform deletions
|
|
2665
|
+
for img in flagged_images:
|
|
2666
|
+
file_path = img['file_path']
|
|
2667
|
+
try:
|
|
2668
|
+
os.remove(file_path)
|
|
2669
|
+
except Exception as e:
|
|
2670
|
+
...
|
|
2671
|
+
# remove from structures
|
|
2672
|
+
if file_path in self.image_paths:
|
|
2673
|
+
self.image_paths.remove(file_path)
|
|
2674
|
+
if img in self.loaded_images:
|
|
2675
|
+
self.loaded_images.remove(img)
|
|
2676
|
+
self.remove_item_from_tree(file_path)
|
|
2677
|
+
|
|
2678
|
+
QMessageBox.information(self, self.tr("Batch Deletion"), self.tr("Deleted {0} flagged images.").format(len(removed_indices)))
|
|
2679
|
+
|
|
2680
|
+
# 🔁 refresh tree + metrics (no recompute)
|
|
2681
|
+
self._after_list_changed(removed_indices)
|
|
2682
|
+
|
|
2683
|
+
def batch_move_flagged_images(self):
|
|
2684
|
+
"""Move all flagged images to a selected directory."""
|
|
2685
|
+
flagged_images = [img for img in self.loaded_images if img['flagged']]
|
|
2686
|
+
|
|
2687
|
+
if not flagged_images:
|
|
2688
|
+
QMessageBox.information(self, self.tr("No Flagged Images"), self.tr("There are no flagged images to move."))
|
|
2689
|
+
return
|
|
2690
|
+
|
|
2691
|
+
# Select destination directory
|
|
2692
|
+
destination_dir = QFileDialog.getExistingDirectory(self, self.tr("Select Destination Folder"), "")
|
|
2693
|
+
if not destination_dir:
|
|
2694
|
+
return # User canceled
|
|
2695
|
+
|
|
2696
|
+
for img in flagged_images:
|
|
2697
|
+
src_path = img['file_path']
|
|
2698
|
+
file_name = os.path.basename(src_path)
|
|
2699
|
+
dest_path = os.path.join(destination_dir, file_name)
|
|
2700
|
+
|
|
2701
|
+
try:
|
|
2702
|
+
os.rename(src_path, dest_path)
|
|
2703
|
+
print(f"Moved flagged image from {src_path} to {dest_path}")
|
|
2704
|
+
except Exception as e:
|
|
2705
|
+
print(f"Failed to move {src_path}: {e}")
|
|
2706
|
+
QMessageBox.critical(self, self.tr("Error"), self.tr("Failed to move {0}: {1}").format(src_path, e))
|
|
2707
|
+
continue
|
|
2708
|
+
|
|
2709
|
+
# Update data structures
|
|
2710
|
+
self.image_paths.remove(src_path)
|
|
2711
|
+
self.image_paths.append(dest_path)
|
|
2712
|
+
img['file_path'] = dest_path
|
|
2713
|
+
img['flagged'] = False # Reset flag if desired
|
|
2714
|
+
|
|
2715
|
+
# Update tree view
|
|
2716
|
+
self.remove_item_from_tree(src_path)
|
|
2717
|
+
self.add_item_to_tree(dest_path)
|
|
2718
|
+
|
|
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
|
+
|
|
2722
|
+
def move_items(self):
|
|
2723
|
+
"""Move selected images *and* remove them from the tree+metrics."""
|
|
2724
|
+
selected_items = self.fileTree.selectedItems()
|
|
2725
|
+
if not selected_items:
|
|
2726
|
+
QMessageBox.warning(self, self.tr("Warning"), self.tr("No items selected for moving."))
|
|
2727
|
+
return
|
|
2728
|
+
|
|
2729
|
+
# Ask where to move
|
|
2730
|
+
new_dir = QFileDialog.getExistingDirectory(self,
|
|
2731
|
+
self.tr("Select Destination Folder"),
|
|
2732
|
+
"")
|
|
2733
|
+
if not new_dir:
|
|
2734
|
+
return
|
|
2735
|
+
|
|
2736
|
+
# Keep track of which on‐disk paths we actually moved
|
|
2737
|
+
moved_old_paths = []
|
|
2738
|
+
removed_indices = []
|
|
2739
|
+
|
|
2740
|
+
for item in selected_items:
|
|
2741
|
+
name = item.text(0).lstrip("⚠️ ")
|
|
2742
|
+
old_path = next((p for p in self.image_paths
|
|
2743
|
+
if os.path.basename(p) == name), None)
|
|
2744
|
+
if not old_path:
|
|
2745
|
+
continue
|
|
2746
|
+
removed_indices.append(self.image_paths.index(old_path))
|
|
2747
|
+
|
|
2748
|
+
new_path = os.path.join(new_dir, name)
|
|
2749
|
+
try:
|
|
2750
|
+
os.rename(old_path, new_path)
|
|
2751
|
+
except Exception as e:
|
|
2752
|
+
QMessageBox.critical(self, self.tr("Error"), self.tr("Failed to move {0}: {1}").format(old_path, e))
|
|
2753
|
+
continue
|
|
2754
|
+
|
|
2755
|
+
moved_old_paths.append(old_path)
|
|
2756
|
+
|
|
2757
|
+
# 1) Remove the leaf from the tree
|
|
2758
|
+
parent = item.parent() or self.fileTree.invisibleRootItem()
|
|
2759
|
+
parent.removeChild(item)
|
|
2760
|
+
|
|
2761
|
+
# 2) Purge them from your internal lists
|
|
2762
|
+
for idx in sorted(removed_indices, reverse=True):
|
|
2763
|
+
del self.image_paths[idx]
|
|
2764
|
+
del self.loaded_images[idx]
|
|
2765
|
+
|
|
2766
|
+
self._after_list_changed(removed_indices)
|
|
2767
|
+
print(f"Moved and removed {len(removed_indices)} items.")
|
|
2768
|
+
|
|
2769
|
+
|
|
2770
|
+
|
|
2771
|
+
def delete_items(self):
|
|
2772
|
+
"""Delete the selected items from the tree, the loaded images list, and the file system."""
|
|
2773
|
+
selected_items = self.fileTree.selectedItems()
|
|
2774
|
+
|
|
2775
|
+
if not selected_items:
|
|
2776
|
+
QMessageBox.warning(self, self.tr("Warning"), self.tr("No items selected for deletion."))
|
|
2777
|
+
return
|
|
2778
|
+
|
|
2779
|
+
# Confirmation dialog
|
|
2780
|
+
reply = QMessageBox.question(
|
|
2781
|
+
self,
|
|
2782
|
+
self.tr('Confirm Deletion'),
|
|
2783
|
+
self.tr("Are you sure you want to permanently delete {0} selected images? This action is irreversible.").format(len(selected_items)),
|
|
2784
|
+
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
|
2785
|
+
QMessageBox.StandardButton.No
|
|
2786
|
+
)
|
|
2787
|
+
|
|
2788
|
+
removed_indices = []
|
|
2789
|
+
if reply == QMessageBox.StandardButton.Yes:
|
|
2790
|
+
for item in selected_items:
|
|
2791
|
+
file_name = item.text(0).lstrip("⚠️ ")
|
|
2792
|
+
file_path = next((path for path in self.image_paths if os.path.basename(path) == file_name), None)
|
|
2793
|
+
if file_path:
|
|
2794
|
+
try:
|
|
2795
|
+
idx = self.image_paths.index(file_path)
|
|
2796
|
+
removed_indices.append(idx) # collect BEFORE mutation
|
|
2797
|
+
...
|
|
2798
|
+
os.remove(file_path)
|
|
2799
|
+
except Exception as e:
|
|
2800
|
+
...
|
|
2801
|
+
# Remove from widgets
|
|
2802
|
+
for item in selected_items:
|
|
2803
|
+
parent = item.parent() or self.fileTree.invisibleRootItem()
|
|
2804
|
+
parent.removeChild(item)
|
|
2805
|
+
|
|
2806
|
+
# Purge arrays (descending order)
|
|
2807
|
+
for idx in sorted(removed_indices, reverse=True):
|
|
2808
|
+
del self.image_paths[idx]
|
|
2809
|
+
del self.loaded_images[idx]
|
|
2810
|
+
|
|
2811
|
+
# Clear preview
|
|
2812
|
+
self.preview_label.clear()
|
|
2813
|
+
self.preview_label.setText(self.tr('No image selected.'))
|
|
2814
|
+
self.current_image = None
|
|
2815
|
+
|
|
2816
|
+
# 🔁 refresh tree + metrics (no recompute)
|
|
2817
|
+
self._after_list_changed(removed_indices)
|
|
2818
|
+
|
|
2819
|
+
def eventFilter(self, source, event):
|
|
2820
|
+
"""Handle mouse events for dragging."""
|
|
2821
|
+
if source == self.scroll_area.viewport():
|
|
2822
|
+
if event.type() == QEvent.Type.MouseButtonPress and event.button() == Qt.MouseButton.LeftButton:
|
|
2823
|
+
# Start dragging
|
|
2824
|
+
self.dragging = True
|
|
2825
|
+
self.last_mouse_pos = event.pos()
|
|
2826
|
+
return True
|
|
2827
|
+
elif event.type() == QEvent.Type.MouseMove and self.dragging:
|
|
2828
|
+
# Handle dragging
|
|
2829
|
+
delta = event.pos() - self.last_mouse_pos
|
|
2830
|
+
self.scroll_area.horizontalScrollBar().setValue(
|
|
2831
|
+
self.scroll_area.horizontalScrollBar().value() - delta.x()
|
|
2832
|
+
)
|
|
2833
|
+
self.scroll_area.verticalScrollBar().setValue(
|
|
2834
|
+
self.scroll_area.verticalScrollBar().value() - delta.y()
|
|
2835
|
+
)
|
|
2836
|
+
self.last_mouse_pos = event.pos()
|
|
2837
|
+
return True
|
|
2838
|
+
elif event.type() == QEvent.Type.MouseButtonRelease and event.button() == Qt.MouseButton.LeftButton:
|
|
2839
|
+
self.dragging = False
|
|
2840
|
+
self._capture_view_center_norm() # remember where the user panned to
|
|
2841
|
+
return True
|
|
2842
|
+
return super().eventFilter(source, event)
|
|
2843
|
+
|
|
2844
|
+
def on_selection_changed(self, selected, deselected):
|
|
2845
|
+
items = self.fileTree.selectedItems()
|
|
2846
|
+
if not items:
|
|
2847
|
+
return
|
|
2848
|
+
item = items[0]
|
|
2849
|
+
|
|
2850
|
+
# if a group got selected, ignore (or auto-drill to first leaf if you prefer)
|
|
2851
|
+
if item.childCount() > 0:
|
|
2852
|
+
return
|
|
2853
|
+
|
|
2854
|
+
name = item.text(0).lstrip("⚠️ ").strip()
|
|
2855
|
+
if self._last_preview_name == name:
|
|
2856
|
+
return # no-op, same item
|
|
2857
|
+
|
|
2858
|
+
# debounce: only preview the last selection after brief idle
|
|
2859
|
+
self._pending_preview_item = item
|
|
2860
|
+
self._pending_preview_timer.start()
|
|
2861
|
+
|
|
2862
|
+
def _do_preview_update(self):
|
|
2863
|
+
item = self._pending_preview_item
|
|
2864
|
+
if not item or item.treeWidget() is None: # ← item got deleted
|
|
2865
|
+
return
|
|
2866
|
+
cur = self.fileTree.currentItem()
|
|
2867
|
+
if cur is not item:
|
|
2868
|
+
return
|
|
2869
|
+
name = item.text(0).lstrip("⚠️ ").strip()
|
|
2870
|
+
self._last_preview_name = name
|
|
2871
|
+
self.on_item_clicked(item, 0)
|
|
2872
|
+
|
|
2873
|
+
def toggle_aggressive(self):
|
|
2874
|
+
self.aggressive_stretch_enabled = self.aggressive_button.isChecked()
|
|
2875
|
+
cur = self.fileTree.currentItem()
|
|
2876
|
+
if cur:
|
|
2877
|
+
self._last_preview_name = None # force reload even if same item
|
|
2878
|
+
self.on_item_clicked(cur, 0)
|
|
2879
|
+
|
|
2880
|
+
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
|
+
if img_array.dtype == np.uint8:
|
|
2884
|
+
arr8 = img_array
|
|
2885
|
+
elif img_array.dtype == np.uint16:
|
|
2886
|
+
# downscale 16-bit → 8-bit
|
|
2887
|
+
arr8 = (img_array.astype(np.float32) / 65535.0 * 255.0).clip(0,255).astype(np.uint8)
|
|
2888
|
+
else:
|
|
2889
|
+
# assume float in [0..1]
|
|
2890
|
+
arr8 = (img_array.clip(0.0, 1.0) * 255.0).astype(np.uint8)
|
|
2891
|
+
|
|
2892
|
+
h, w = arr8.shape[:2]
|
|
2893
|
+
buffer = arr8.tobytes()
|
|
2894
|
+
|
|
2895
|
+
if arr8.ndim == 3:
|
|
2896
|
+
# RGB
|
|
2897
|
+
return QImage(buffer, w, h, 3*w, QImage.Format.Format_RGB888)
|
|
2898
|
+
else:
|
|
2899
|
+
# grayscale
|
|
2900
|
+
return QImage(buffer, w, h, w, QImage.Format.Format_Grayscale8)
|
|
2901
|
+
|
|
2902
|
+
def _main_window(self):
|
|
2903
|
+
w = self
|
|
2904
|
+
from PyQt6.QtWidgets import QMainWindow, QApplication
|
|
2905
|
+
while w is not None and not isinstance(w, QMainWindow):
|
|
2906
|
+
w = w.parentWidget()
|
|
2907
|
+
if w is not None:
|
|
2908
|
+
return w
|
|
2909
|
+
# fallback: scan toplevels
|
|
2910
|
+
for tlw in QApplication.topLevelWidgets():
|
|
2911
|
+
if isinstance(tlw, QMainWindow):
|
|
2912
|
+
return tlw
|
|
2913
|
+
return None
|
|
2914
|
+
|
|
2915
|
+
# Import centralized widgets
|
|
2916
|
+
from setiastro.saspro.widgets.spinboxes import CustomSpinBox, CustomDoubleSpinBox
|
|
2917
|
+
from setiastro.saspro.widgets.preview_dialogs import ImagePreviewDialog
|
|
2918
|
+
|
|
2919
|
+
|
|
2920
|
+
BlinkComparatorPro = BlinkTab
|
|
2921
|
+
|
|
2922
|
+
# ⬇️ paste your SASv2 code here (exactly as you sent), then end with:
|
|
2923
|
+
class BlinkComparatorPro(BlinkTab):
|
|
2924
|
+
"""Alias class so the main app can import a SASpro-named tool."""
|
|
2925
|
+
pass
|
|
2926
|
+
|