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