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.

Files changed (367) hide show
  1. setiastro/__init__.py +2 -0
  2. setiastro/data/SASP_data.fits +0 -0
  3. setiastro/data/catalogs/List_of_Galaxies_with_Distances_Gly.csv +488 -0
  4. setiastro/data/catalogs/astrobin_filters.csv +890 -0
  5. setiastro/data/catalogs/astrobin_filters_page1_local.csv +51 -0
  6. setiastro/data/catalogs/cali2.csv +63 -0
  7. setiastro/data/catalogs/cali2color.csv +65 -0
  8. setiastro/data/catalogs/celestial_catalog - original.csv +16471 -0
  9. setiastro/data/catalogs/celestial_catalog.csv +24031 -0
  10. setiastro/data/catalogs/detected_stars.csv +24784 -0
  11. setiastro/data/catalogs/fits_header_data.csv +46 -0
  12. setiastro/data/catalogs/test.csv +8 -0
  13. setiastro/data/catalogs/updated_celestial_catalog.csv +16471 -0
  14. setiastro/images/Astro_Spikes.png +0 -0
  15. setiastro/images/Background_startup.jpg +0 -0
  16. setiastro/images/HRDiagram.png +0 -0
  17. setiastro/images/LExtract.png +0 -0
  18. setiastro/images/LInsert.png +0 -0
  19. setiastro/images/Oxygenation-atm-2.svg.png +0 -0
  20. setiastro/images/RGB080604.png +0 -0
  21. setiastro/images/abeicon.png +0 -0
  22. setiastro/images/aberration.png +0 -0
  23. setiastro/images/andromedatry.png +0 -0
  24. setiastro/images/andromedatry_satellited.png +0 -0
  25. setiastro/images/annotated.png +0 -0
  26. setiastro/images/aperture.png +0 -0
  27. setiastro/images/astrosuite.ico +0 -0
  28. setiastro/images/astrosuite.png +0 -0
  29. setiastro/images/astrosuitepro.icns +0 -0
  30. setiastro/images/astrosuitepro.ico +0 -0
  31. setiastro/images/astrosuitepro.png +0 -0
  32. setiastro/images/background.png +0 -0
  33. setiastro/images/background2.png +0 -0
  34. setiastro/images/benchmark.png +0 -0
  35. setiastro/images/big_moon_stabilizer_timeline.png +0 -0
  36. setiastro/images/big_moon_stabilizer_timeline_clean.png +0 -0
  37. setiastro/images/blaster.png +0 -0
  38. setiastro/images/blink.png +0 -0
  39. setiastro/images/clahe.png +0 -0
  40. setiastro/images/collage.png +0 -0
  41. setiastro/images/colorwheel.png +0 -0
  42. setiastro/images/contsub.png +0 -0
  43. setiastro/images/convo.png +0 -0
  44. setiastro/images/copyslot.png +0 -0
  45. setiastro/images/cosmic.png +0 -0
  46. setiastro/images/cosmicsat.png +0 -0
  47. setiastro/images/crop1.png +0 -0
  48. setiastro/images/cropicon.png +0 -0
  49. setiastro/images/curves.png +0 -0
  50. setiastro/images/cvs.png +0 -0
  51. setiastro/images/debayer.png +0 -0
  52. setiastro/images/denoise_cnn_custom.png +0 -0
  53. setiastro/images/denoise_cnn_graph.png +0 -0
  54. setiastro/images/disk.png +0 -0
  55. setiastro/images/dse.png +0 -0
  56. setiastro/images/exoicon.png +0 -0
  57. setiastro/images/eye.png +0 -0
  58. setiastro/images/fliphorizontal.png +0 -0
  59. setiastro/images/flipvertical.png +0 -0
  60. setiastro/images/font.png +0 -0
  61. setiastro/images/freqsep.png +0 -0
  62. setiastro/images/functionbundle.png +0 -0
  63. setiastro/images/graxpert.png +0 -0
  64. setiastro/images/green.png +0 -0
  65. setiastro/images/gridicon.png +0 -0
  66. setiastro/images/halo.png +0 -0
  67. setiastro/images/hdr.png +0 -0
  68. setiastro/images/histogram.png +0 -0
  69. setiastro/images/hubble.png +0 -0
  70. setiastro/images/imagecombine.png +0 -0
  71. setiastro/images/invert.png +0 -0
  72. setiastro/images/isophote.png +0 -0
  73. setiastro/images/isophote_demo_figure.png +0 -0
  74. setiastro/images/isophote_demo_image.png +0 -0
  75. setiastro/images/isophote_demo_model.png +0 -0
  76. setiastro/images/isophote_demo_residual.png +0 -0
  77. setiastro/images/jwstpupil.png +0 -0
  78. setiastro/images/linearfit.png +0 -0
  79. setiastro/images/livestacking.png +0 -0
  80. setiastro/images/mask.png +0 -0
  81. setiastro/images/maskapply.png +0 -0
  82. setiastro/images/maskcreate.png +0 -0
  83. setiastro/images/maskremove.png +0 -0
  84. setiastro/images/morpho.png +0 -0
  85. setiastro/images/mosaic.png +0 -0
  86. setiastro/images/multiscale_decomp.png +0 -0
  87. setiastro/images/nbtorgb.png +0 -0
  88. setiastro/images/neutral.png +0 -0
  89. setiastro/images/nuke.png +0 -0
  90. setiastro/images/openfile.png +0 -0
  91. setiastro/images/pedestal.png +0 -0
  92. setiastro/images/pen.png +0 -0
  93. setiastro/images/pixelmath.png +0 -0
  94. setiastro/images/platesolve.png +0 -0
  95. setiastro/images/ppp.png +0 -0
  96. setiastro/images/pro.png +0 -0
  97. setiastro/images/project.png +0 -0
  98. setiastro/images/psf.png +0 -0
  99. setiastro/images/redo.png +0 -0
  100. setiastro/images/redoicon.png +0 -0
  101. setiastro/images/rescale.png +0 -0
  102. setiastro/images/rgbalign.png +0 -0
  103. setiastro/images/rgbcombo.png +0 -0
  104. setiastro/images/rgbextract.png +0 -0
  105. setiastro/images/rotate180.png +0 -0
  106. setiastro/images/rotateclockwise.png +0 -0
  107. setiastro/images/rotatecounterclockwise.png +0 -0
  108. setiastro/images/satellite.png +0 -0
  109. setiastro/images/script.png +0 -0
  110. setiastro/images/selectivecolor.png +0 -0
  111. setiastro/images/simbad.png +0 -0
  112. setiastro/images/slot0.png +0 -0
  113. setiastro/images/slot1.png +0 -0
  114. setiastro/images/slot2.png +0 -0
  115. setiastro/images/slot3.png +0 -0
  116. setiastro/images/slot4.png +0 -0
  117. setiastro/images/slot5.png +0 -0
  118. setiastro/images/slot6.png +0 -0
  119. setiastro/images/slot7.png +0 -0
  120. setiastro/images/slot8.png +0 -0
  121. setiastro/images/slot9.png +0 -0
  122. setiastro/images/spcc.png +0 -0
  123. setiastro/images/spin_precession_vs_lunar_distance.png +0 -0
  124. setiastro/images/spinner.gif +0 -0
  125. setiastro/images/stacking.png +0 -0
  126. setiastro/images/staradd.png +0 -0
  127. setiastro/images/staralign.png +0 -0
  128. setiastro/images/starnet.png +0 -0
  129. setiastro/images/starregistration.png +0 -0
  130. setiastro/images/starspike.png +0 -0
  131. setiastro/images/starstretch.png +0 -0
  132. setiastro/images/statstretch.png +0 -0
  133. setiastro/images/supernova.png +0 -0
  134. setiastro/images/uhs.png +0 -0
  135. setiastro/images/undoicon.png +0 -0
  136. setiastro/images/upscale.png +0 -0
  137. setiastro/images/viewbundle.png +0 -0
  138. setiastro/images/whitebalance.png +0 -0
  139. setiastro/images/wimi_icon_256x256.png +0 -0
  140. setiastro/images/wimilogo.png +0 -0
  141. setiastro/images/wims.png +0 -0
  142. setiastro/images/wrench_icon.png +0 -0
  143. setiastro/images/xisfliberator.png +0 -0
  144. setiastro/qml/ResourceMonitor.qml +126 -0
  145. setiastro/saspro/__init__.py +20 -0
  146. setiastro/saspro/__main__.py +945 -0
  147. setiastro/saspro/_generated/__init__.py +7 -0
  148. setiastro/saspro/_generated/build_info.py +3 -0
  149. setiastro/saspro/abe.py +1346 -0
  150. setiastro/saspro/abe_preset.py +196 -0
  151. setiastro/saspro/aberration_ai.py +694 -0
  152. setiastro/saspro/aberration_ai_preset.py +224 -0
  153. setiastro/saspro/accel_installer.py +218 -0
  154. setiastro/saspro/accel_workers.py +30 -0
  155. setiastro/saspro/add_stars.py +624 -0
  156. setiastro/saspro/astrobin_exporter.py +1010 -0
  157. setiastro/saspro/astrospike.py +153 -0
  158. setiastro/saspro/astrospike_python.py +1841 -0
  159. setiastro/saspro/autostretch.py +198 -0
  160. setiastro/saspro/backgroundneutral.py +602 -0
  161. setiastro/saspro/batch_convert.py +328 -0
  162. setiastro/saspro/batch_renamer.py +522 -0
  163. setiastro/saspro/blemish_blaster.py +491 -0
  164. setiastro/saspro/blink_comparator_pro.py +2926 -0
  165. setiastro/saspro/bundles.py +61 -0
  166. setiastro/saspro/bundles_dock.py +114 -0
  167. setiastro/saspro/cheat_sheet.py +213 -0
  168. setiastro/saspro/clahe.py +368 -0
  169. setiastro/saspro/comet_stacking.py +1442 -0
  170. setiastro/saspro/common_tr.py +107 -0
  171. setiastro/saspro/config.py +38 -0
  172. setiastro/saspro/config_bootstrap.py +40 -0
  173. setiastro/saspro/config_manager.py +316 -0
  174. setiastro/saspro/continuum_subtract.py +1617 -0
  175. setiastro/saspro/convo.py +1400 -0
  176. setiastro/saspro/convo_preset.py +414 -0
  177. setiastro/saspro/copyastro.py +190 -0
  178. setiastro/saspro/cosmicclarity.py +1589 -0
  179. setiastro/saspro/cosmicclarity_preset.py +407 -0
  180. setiastro/saspro/crop_dialog_pro.py +973 -0
  181. setiastro/saspro/crop_preset.py +189 -0
  182. setiastro/saspro/curve_editor_pro.py +2562 -0
  183. setiastro/saspro/curves_preset.py +375 -0
  184. setiastro/saspro/debayer.py +673 -0
  185. setiastro/saspro/debug_utils.py +29 -0
  186. setiastro/saspro/dnd_mime.py +35 -0
  187. setiastro/saspro/doc_manager.py +2664 -0
  188. setiastro/saspro/exoplanet_detector.py +2166 -0
  189. setiastro/saspro/file_utils.py +284 -0
  190. setiastro/saspro/fitsmodifier.py +748 -0
  191. setiastro/saspro/fix_bom.py +32 -0
  192. setiastro/saspro/free_torch_memory.py +48 -0
  193. setiastro/saspro/frequency_separation.py +1349 -0
  194. setiastro/saspro/function_bundle.py +1596 -0
  195. setiastro/saspro/generate_translations.py +3092 -0
  196. setiastro/saspro/ghs_dialog_pro.py +663 -0
  197. setiastro/saspro/ghs_preset.py +284 -0
  198. setiastro/saspro/graxpert.py +637 -0
  199. setiastro/saspro/graxpert_preset.py +287 -0
  200. setiastro/saspro/gui/__init__.py +0 -0
  201. setiastro/saspro/gui/main_window.py +8810 -0
  202. setiastro/saspro/gui/mixins/__init__.py +33 -0
  203. setiastro/saspro/gui/mixins/dock_mixin.py +362 -0
  204. setiastro/saspro/gui/mixins/file_mixin.py +450 -0
  205. setiastro/saspro/gui/mixins/geometry_mixin.py +403 -0
  206. setiastro/saspro/gui/mixins/header_mixin.py +441 -0
  207. setiastro/saspro/gui/mixins/mask_mixin.py +421 -0
  208. setiastro/saspro/gui/mixins/menu_mixin.py +389 -0
  209. setiastro/saspro/gui/mixins/theme_mixin.py +367 -0
  210. setiastro/saspro/gui/mixins/toolbar_mixin.py +1457 -0
  211. setiastro/saspro/gui/mixins/update_mixin.py +309 -0
  212. setiastro/saspro/gui/mixins/view_mixin.py +435 -0
  213. setiastro/saspro/gui/statistics_dialog.py +47 -0
  214. setiastro/saspro/halobgon.py +488 -0
  215. setiastro/saspro/header_viewer.py +448 -0
  216. setiastro/saspro/headless_utils.py +88 -0
  217. setiastro/saspro/histogram.py +756 -0
  218. setiastro/saspro/history_explorer.py +941 -0
  219. setiastro/saspro/i18n.py +168 -0
  220. setiastro/saspro/image_combine.py +417 -0
  221. setiastro/saspro/image_peeker_pro.py +1604 -0
  222. setiastro/saspro/imageops/__init__.py +37 -0
  223. setiastro/saspro/imageops/mdi_snap.py +292 -0
  224. setiastro/saspro/imageops/scnr.py +36 -0
  225. setiastro/saspro/imageops/starbasedwhitebalance.py +210 -0
  226. setiastro/saspro/imageops/stretch.py +236 -0
  227. setiastro/saspro/isophote.py +1182 -0
  228. setiastro/saspro/layers.py +208 -0
  229. setiastro/saspro/layers_dock.py +714 -0
  230. setiastro/saspro/lazy_imports.py +193 -0
  231. setiastro/saspro/legacy/__init__.py +2 -0
  232. setiastro/saspro/legacy/image_manager.py +2226 -0
  233. setiastro/saspro/legacy/numba_utils.py +3676 -0
  234. setiastro/saspro/legacy/xisf.py +1071 -0
  235. setiastro/saspro/linear_fit.py +537 -0
  236. setiastro/saspro/live_stacking.py +1841 -0
  237. setiastro/saspro/log_bus.py +5 -0
  238. setiastro/saspro/logging_config.py +460 -0
  239. setiastro/saspro/luminancerecombine.py +309 -0
  240. setiastro/saspro/main_helpers.py +201 -0
  241. setiastro/saspro/mask_creation.py +931 -0
  242. setiastro/saspro/masks_core.py +56 -0
  243. setiastro/saspro/mdi_widgets.py +353 -0
  244. setiastro/saspro/memory_utils.py +666 -0
  245. setiastro/saspro/metadata_patcher.py +75 -0
  246. setiastro/saspro/mfdeconv.py +3831 -0
  247. setiastro/saspro/mfdeconv_earlystop.py +71 -0
  248. setiastro/saspro/mfdeconvcudnn.py +3263 -0
  249. setiastro/saspro/mfdeconvsport.py +2382 -0
  250. setiastro/saspro/minorbodycatalog.py +567 -0
  251. setiastro/saspro/morphology.py +407 -0
  252. setiastro/saspro/multiscale_decomp.py +1293 -0
  253. setiastro/saspro/nbtorgb_stars.py +541 -0
  254. setiastro/saspro/numba_utils.py +3145 -0
  255. setiastro/saspro/numba_warmup.py +141 -0
  256. setiastro/saspro/ops/__init__.py +9 -0
  257. setiastro/saspro/ops/command_help_dialog.py +623 -0
  258. setiastro/saspro/ops/command_runner.py +217 -0
  259. setiastro/saspro/ops/commands.py +1594 -0
  260. setiastro/saspro/ops/script_editor.py +1102 -0
  261. setiastro/saspro/ops/scripts.py +1473 -0
  262. setiastro/saspro/ops/settings.py +637 -0
  263. setiastro/saspro/parallel_utils.py +554 -0
  264. setiastro/saspro/pedestal.py +121 -0
  265. setiastro/saspro/perfect_palette_picker.py +1071 -0
  266. setiastro/saspro/pipeline.py +110 -0
  267. setiastro/saspro/pixelmath.py +1604 -0
  268. setiastro/saspro/plate_solver.py +2445 -0
  269. setiastro/saspro/project_io.py +797 -0
  270. setiastro/saspro/psf_utils.py +136 -0
  271. setiastro/saspro/psf_viewer.py +549 -0
  272. setiastro/saspro/pyi_rthook_astroquery.py +95 -0
  273. setiastro/saspro/remove_green.py +331 -0
  274. setiastro/saspro/remove_stars.py +1599 -0
  275. setiastro/saspro/remove_stars_preset.py +404 -0
  276. setiastro/saspro/resources.py +501 -0
  277. setiastro/saspro/rgb_combination.py +208 -0
  278. setiastro/saspro/rgb_extract.py +19 -0
  279. setiastro/saspro/rgbalign.py +723 -0
  280. setiastro/saspro/runtime_imports.py +7 -0
  281. setiastro/saspro/runtime_torch.py +754 -0
  282. setiastro/saspro/save_options.py +73 -0
  283. setiastro/saspro/selective_color.py +1552 -0
  284. setiastro/saspro/sfcc.py +1472 -0
  285. setiastro/saspro/shortcuts.py +3043 -0
  286. setiastro/saspro/signature_insert.py +1102 -0
  287. setiastro/saspro/stacking_suite.py +18470 -0
  288. setiastro/saspro/star_alignment.py +7435 -0
  289. setiastro/saspro/star_alignment_preset.py +329 -0
  290. setiastro/saspro/star_metrics.py +49 -0
  291. setiastro/saspro/star_spikes.py +765 -0
  292. setiastro/saspro/star_stretch.py +507 -0
  293. setiastro/saspro/stat_stretch.py +538 -0
  294. setiastro/saspro/status_log_dock.py +78 -0
  295. setiastro/saspro/subwindow.py +3328 -0
  296. setiastro/saspro/supernovaasteroidhunter.py +1719 -0
  297. setiastro/saspro/swap_manager.py +99 -0
  298. setiastro/saspro/torch_backend.py +89 -0
  299. setiastro/saspro/torch_rejection.py +434 -0
  300. setiastro/saspro/translations/all_source_strings.json +3654 -0
  301. setiastro/saspro/translations/ar_translations.py +3865 -0
  302. setiastro/saspro/translations/de_translations.py +3749 -0
  303. setiastro/saspro/translations/es_translations.py +3939 -0
  304. setiastro/saspro/translations/fr_translations.py +3858 -0
  305. setiastro/saspro/translations/hi_translations.py +3571 -0
  306. setiastro/saspro/translations/integrate_translations.py +270 -0
  307. setiastro/saspro/translations/it_translations.py +3678 -0
  308. setiastro/saspro/translations/ja_translations.py +3601 -0
  309. setiastro/saspro/translations/pt_translations.py +3869 -0
  310. setiastro/saspro/translations/ru_translations.py +2848 -0
  311. setiastro/saspro/translations/saspro_ar.qm +0 -0
  312. setiastro/saspro/translations/saspro_ar.ts +255 -0
  313. setiastro/saspro/translations/saspro_de.qm +0 -0
  314. setiastro/saspro/translations/saspro_de.ts +253 -0
  315. setiastro/saspro/translations/saspro_es.qm +0 -0
  316. setiastro/saspro/translations/saspro_es.ts +12520 -0
  317. setiastro/saspro/translations/saspro_fr.qm +0 -0
  318. setiastro/saspro/translations/saspro_fr.ts +12514 -0
  319. setiastro/saspro/translations/saspro_hi.qm +0 -0
  320. setiastro/saspro/translations/saspro_hi.ts +257 -0
  321. setiastro/saspro/translations/saspro_it.qm +0 -0
  322. setiastro/saspro/translations/saspro_it.ts +12520 -0
  323. setiastro/saspro/translations/saspro_ja.qm +0 -0
  324. setiastro/saspro/translations/saspro_ja.ts +257 -0
  325. setiastro/saspro/translations/saspro_pt.qm +0 -0
  326. setiastro/saspro/translations/saspro_pt.ts +257 -0
  327. setiastro/saspro/translations/saspro_ru.qm +0 -0
  328. setiastro/saspro/translations/saspro_ru.ts +237 -0
  329. setiastro/saspro/translations/saspro_sw.qm +0 -0
  330. setiastro/saspro/translations/saspro_sw.ts +257 -0
  331. setiastro/saspro/translations/saspro_uk.qm +0 -0
  332. setiastro/saspro/translations/saspro_uk.ts +10771 -0
  333. setiastro/saspro/translations/saspro_zh.qm +0 -0
  334. setiastro/saspro/translations/saspro_zh.ts +12520 -0
  335. setiastro/saspro/translations/sw_translations.py +3671 -0
  336. setiastro/saspro/translations/uk_translations.py +3700 -0
  337. setiastro/saspro/translations/zh_translations.py +3675 -0
  338. setiastro/saspro/versioning.py +77 -0
  339. setiastro/saspro/view_bundle.py +1558 -0
  340. setiastro/saspro/wavescale_hdr.py +645 -0
  341. setiastro/saspro/wavescale_hdr_preset.py +101 -0
  342. setiastro/saspro/wavescalede.py +680 -0
  343. setiastro/saspro/wavescalede_preset.py +230 -0
  344. setiastro/saspro/wcs_update.py +374 -0
  345. setiastro/saspro/whitebalance.py +492 -0
  346. setiastro/saspro/widgets/__init__.py +48 -0
  347. setiastro/saspro/widgets/common_utilities.py +306 -0
  348. setiastro/saspro/widgets/graphics_views.py +122 -0
  349. setiastro/saspro/widgets/image_utils.py +518 -0
  350. setiastro/saspro/widgets/minigame/game.js +986 -0
  351. setiastro/saspro/widgets/minigame/index.html +53 -0
  352. setiastro/saspro/widgets/minigame/style.css +241 -0
  353. setiastro/saspro/widgets/preview_dialogs.py +280 -0
  354. setiastro/saspro/widgets/resource_monitor.py +237 -0
  355. setiastro/saspro/widgets/spinboxes.py +275 -0
  356. setiastro/saspro/widgets/themed_buttons.py +13 -0
  357. setiastro/saspro/widgets/wavelet_utils.py +331 -0
  358. setiastro/saspro/wimi.py +7996 -0
  359. setiastro/saspro/wims.py +578 -0
  360. setiastro/saspro/window_shelf.py +185 -0
  361. setiastro/saspro/xisf.py +1123 -0
  362. setiastrosuitepro-1.6.2.post1.dist-info/METADATA +278 -0
  363. setiastrosuitepro-1.6.2.post1.dist-info/RECORD +367 -0
  364. setiastrosuitepro-1.6.2.post1.dist-info/WHEEL +4 -0
  365. setiastrosuitepro-1.6.2.post1.dist-info/entry_points.txt +6 -0
  366. setiastrosuitepro-1.6.2.post1.dist-info/licenses/LICENSE +674 -0
  367. 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
+