setiastrosuitepro 1.6.2.post1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of setiastrosuitepro might be problematic. Click here for more details.

Files changed (367) hide show
  1. setiastro/__init__.py +2 -0
  2. setiastro/data/SASP_data.fits +0 -0
  3. setiastro/data/catalogs/List_of_Galaxies_with_Distances_Gly.csv +488 -0
  4. setiastro/data/catalogs/astrobin_filters.csv +890 -0
  5. setiastro/data/catalogs/astrobin_filters_page1_local.csv +51 -0
  6. setiastro/data/catalogs/cali2.csv +63 -0
  7. setiastro/data/catalogs/cali2color.csv +65 -0
  8. setiastro/data/catalogs/celestial_catalog - original.csv +16471 -0
  9. setiastro/data/catalogs/celestial_catalog.csv +24031 -0
  10. setiastro/data/catalogs/detected_stars.csv +24784 -0
  11. setiastro/data/catalogs/fits_header_data.csv +46 -0
  12. setiastro/data/catalogs/test.csv +8 -0
  13. setiastro/data/catalogs/updated_celestial_catalog.csv +16471 -0
  14. setiastro/images/Astro_Spikes.png +0 -0
  15. setiastro/images/Background_startup.jpg +0 -0
  16. setiastro/images/HRDiagram.png +0 -0
  17. setiastro/images/LExtract.png +0 -0
  18. setiastro/images/LInsert.png +0 -0
  19. setiastro/images/Oxygenation-atm-2.svg.png +0 -0
  20. setiastro/images/RGB080604.png +0 -0
  21. setiastro/images/abeicon.png +0 -0
  22. setiastro/images/aberration.png +0 -0
  23. setiastro/images/andromedatry.png +0 -0
  24. setiastro/images/andromedatry_satellited.png +0 -0
  25. setiastro/images/annotated.png +0 -0
  26. setiastro/images/aperture.png +0 -0
  27. setiastro/images/astrosuite.ico +0 -0
  28. setiastro/images/astrosuite.png +0 -0
  29. setiastro/images/astrosuitepro.icns +0 -0
  30. setiastro/images/astrosuitepro.ico +0 -0
  31. setiastro/images/astrosuitepro.png +0 -0
  32. setiastro/images/background.png +0 -0
  33. setiastro/images/background2.png +0 -0
  34. setiastro/images/benchmark.png +0 -0
  35. setiastro/images/big_moon_stabilizer_timeline.png +0 -0
  36. setiastro/images/big_moon_stabilizer_timeline_clean.png +0 -0
  37. setiastro/images/blaster.png +0 -0
  38. setiastro/images/blink.png +0 -0
  39. setiastro/images/clahe.png +0 -0
  40. setiastro/images/collage.png +0 -0
  41. setiastro/images/colorwheel.png +0 -0
  42. setiastro/images/contsub.png +0 -0
  43. setiastro/images/convo.png +0 -0
  44. setiastro/images/copyslot.png +0 -0
  45. setiastro/images/cosmic.png +0 -0
  46. setiastro/images/cosmicsat.png +0 -0
  47. setiastro/images/crop1.png +0 -0
  48. setiastro/images/cropicon.png +0 -0
  49. setiastro/images/curves.png +0 -0
  50. setiastro/images/cvs.png +0 -0
  51. setiastro/images/debayer.png +0 -0
  52. setiastro/images/denoise_cnn_custom.png +0 -0
  53. setiastro/images/denoise_cnn_graph.png +0 -0
  54. setiastro/images/disk.png +0 -0
  55. setiastro/images/dse.png +0 -0
  56. setiastro/images/exoicon.png +0 -0
  57. setiastro/images/eye.png +0 -0
  58. setiastro/images/fliphorizontal.png +0 -0
  59. setiastro/images/flipvertical.png +0 -0
  60. setiastro/images/font.png +0 -0
  61. setiastro/images/freqsep.png +0 -0
  62. setiastro/images/functionbundle.png +0 -0
  63. setiastro/images/graxpert.png +0 -0
  64. setiastro/images/green.png +0 -0
  65. setiastro/images/gridicon.png +0 -0
  66. setiastro/images/halo.png +0 -0
  67. setiastro/images/hdr.png +0 -0
  68. setiastro/images/histogram.png +0 -0
  69. setiastro/images/hubble.png +0 -0
  70. setiastro/images/imagecombine.png +0 -0
  71. setiastro/images/invert.png +0 -0
  72. setiastro/images/isophote.png +0 -0
  73. setiastro/images/isophote_demo_figure.png +0 -0
  74. setiastro/images/isophote_demo_image.png +0 -0
  75. setiastro/images/isophote_demo_model.png +0 -0
  76. setiastro/images/isophote_demo_residual.png +0 -0
  77. setiastro/images/jwstpupil.png +0 -0
  78. setiastro/images/linearfit.png +0 -0
  79. setiastro/images/livestacking.png +0 -0
  80. setiastro/images/mask.png +0 -0
  81. setiastro/images/maskapply.png +0 -0
  82. setiastro/images/maskcreate.png +0 -0
  83. setiastro/images/maskremove.png +0 -0
  84. setiastro/images/morpho.png +0 -0
  85. setiastro/images/mosaic.png +0 -0
  86. setiastro/images/multiscale_decomp.png +0 -0
  87. setiastro/images/nbtorgb.png +0 -0
  88. setiastro/images/neutral.png +0 -0
  89. setiastro/images/nuke.png +0 -0
  90. setiastro/images/openfile.png +0 -0
  91. setiastro/images/pedestal.png +0 -0
  92. setiastro/images/pen.png +0 -0
  93. setiastro/images/pixelmath.png +0 -0
  94. setiastro/images/platesolve.png +0 -0
  95. setiastro/images/ppp.png +0 -0
  96. setiastro/images/pro.png +0 -0
  97. setiastro/images/project.png +0 -0
  98. setiastro/images/psf.png +0 -0
  99. setiastro/images/redo.png +0 -0
  100. setiastro/images/redoicon.png +0 -0
  101. setiastro/images/rescale.png +0 -0
  102. setiastro/images/rgbalign.png +0 -0
  103. setiastro/images/rgbcombo.png +0 -0
  104. setiastro/images/rgbextract.png +0 -0
  105. setiastro/images/rotate180.png +0 -0
  106. setiastro/images/rotateclockwise.png +0 -0
  107. setiastro/images/rotatecounterclockwise.png +0 -0
  108. setiastro/images/satellite.png +0 -0
  109. setiastro/images/script.png +0 -0
  110. setiastro/images/selectivecolor.png +0 -0
  111. setiastro/images/simbad.png +0 -0
  112. setiastro/images/slot0.png +0 -0
  113. setiastro/images/slot1.png +0 -0
  114. setiastro/images/slot2.png +0 -0
  115. setiastro/images/slot3.png +0 -0
  116. setiastro/images/slot4.png +0 -0
  117. setiastro/images/slot5.png +0 -0
  118. setiastro/images/slot6.png +0 -0
  119. setiastro/images/slot7.png +0 -0
  120. setiastro/images/slot8.png +0 -0
  121. setiastro/images/slot9.png +0 -0
  122. setiastro/images/spcc.png +0 -0
  123. setiastro/images/spin_precession_vs_lunar_distance.png +0 -0
  124. setiastro/images/spinner.gif +0 -0
  125. setiastro/images/stacking.png +0 -0
  126. setiastro/images/staradd.png +0 -0
  127. setiastro/images/staralign.png +0 -0
  128. setiastro/images/starnet.png +0 -0
  129. setiastro/images/starregistration.png +0 -0
  130. setiastro/images/starspike.png +0 -0
  131. setiastro/images/starstretch.png +0 -0
  132. setiastro/images/statstretch.png +0 -0
  133. setiastro/images/supernova.png +0 -0
  134. setiastro/images/uhs.png +0 -0
  135. setiastro/images/undoicon.png +0 -0
  136. setiastro/images/upscale.png +0 -0
  137. setiastro/images/viewbundle.png +0 -0
  138. setiastro/images/whitebalance.png +0 -0
  139. setiastro/images/wimi_icon_256x256.png +0 -0
  140. setiastro/images/wimilogo.png +0 -0
  141. setiastro/images/wims.png +0 -0
  142. setiastro/images/wrench_icon.png +0 -0
  143. setiastro/images/xisfliberator.png +0 -0
  144. setiastro/qml/ResourceMonitor.qml +126 -0
  145. setiastro/saspro/__init__.py +20 -0
  146. setiastro/saspro/__main__.py +945 -0
  147. setiastro/saspro/_generated/__init__.py +7 -0
  148. setiastro/saspro/_generated/build_info.py +3 -0
  149. setiastro/saspro/abe.py +1346 -0
  150. setiastro/saspro/abe_preset.py +196 -0
  151. setiastro/saspro/aberration_ai.py +694 -0
  152. setiastro/saspro/aberration_ai_preset.py +224 -0
  153. setiastro/saspro/accel_installer.py +218 -0
  154. setiastro/saspro/accel_workers.py +30 -0
  155. setiastro/saspro/add_stars.py +624 -0
  156. setiastro/saspro/astrobin_exporter.py +1010 -0
  157. setiastro/saspro/astrospike.py +153 -0
  158. setiastro/saspro/astrospike_python.py +1841 -0
  159. setiastro/saspro/autostretch.py +198 -0
  160. setiastro/saspro/backgroundneutral.py +602 -0
  161. setiastro/saspro/batch_convert.py +328 -0
  162. setiastro/saspro/batch_renamer.py +522 -0
  163. setiastro/saspro/blemish_blaster.py +491 -0
  164. setiastro/saspro/blink_comparator_pro.py +2926 -0
  165. setiastro/saspro/bundles.py +61 -0
  166. setiastro/saspro/bundles_dock.py +114 -0
  167. setiastro/saspro/cheat_sheet.py +213 -0
  168. setiastro/saspro/clahe.py +368 -0
  169. setiastro/saspro/comet_stacking.py +1442 -0
  170. setiastro/saspro/common_tr.py +107 -0
  171. setiastro/saspro/config.py +38 -0
  172. setiastro/saspro/config_bootstrap.py +40 -0
  173. setiastro/saspro/config_manager.py +316 -0
  174. setiastro/saspro/continuum_subtract.py +1617 -0
  175. setiastro/saspro/convo.py +1400 -0
  176. setiastro/saspro/convo_preset.py +414 -0
  177. setiastro/saspro/copyastro.py +190 -0
  178. setiastro/saspro/cosmicclarity.py +1589 -0
  179. setiastro/saspro/cosmicclarity_preset.py +407 -0
  180. setiastro/saspro/crop_dialog_pro.py +973 -0
  181. setiastro/saspro/crop_preset.py +189 -0
  182. setiastro/saspro/curve_editor_pro.py +2562 -0
  183. setiastro/saspro/curves_preset.py +375 -0
  184. setiastro/saspro/debayer.py +673 -0
  185. setiastro/saspro/debug_utils.py +29 -0
  186. setiastro/saspro/dnd_mime.py +35 -0
  187. setiastro/saspro/doc_manager.py +2664 -0
  188. setiastro/saspro/exoplanet_detector.py +2166 -0
  189. setiastro/saspro/file_utils.py +284 -0
  190. setiastro/saspro/fitsmodifier.py +748 -0
  191. setiastro/saspro/fix_bom.py +32 -0
  192. setiastro/saspro/free_torch_memory.py +48 -0
  193. setiastro/saspro/frequency_separation.py +1349 -0
  194. setiastro/saspro/function_bundle.py +1596 -0
  195. setiastro/saspro/generate_translations.py +3092 -0
  196. setiastro/saspro/ghs_dialog_pro.py +663 -0
  197. setiastro/saspro/ghs_preset.py +284 -0
  198. setiastro/saspro/graxpert.py +637 -0
  199. setiastro/saspro/graxpert_preset.py +287 -0
  200. setiastro/saspro/gui/__init__.py +0 -0
  201. setiastro/saspro/gui/main_window.py +8810 -0
  202. setiastro/saspro/gui/mixins/__init__.py +33 -0
  203. setiastro/saspro/gui/mixins/dock_mixin.py +362 -0
  204. setiastro/saspro/gui/mixins/file_mixin.py +450 -0
  205. setiastro/saspro/gui/mixins/geometry_mixin.py +403 -0
  206. setiastro/saspro/gui/mixins/header_mixin.py +441 -0
  207. setiastro/saspro/gui/mixins/mask_mixin.py +421 -0
  208. setiastro/saspro/gui/mixins/menu_mixin.py +389 -0
  209. setiastro/saspro/gui/mixins/theme_mixin.py +367 -0
  210. setiastro/saspro/gui/mixins/toolbar_mixin.py +1457 -0
  211. setiastro/saspro/gui/mixins/update_mixin.py +309 -0
  212. setiastro/saspro/gui/mixins/view_mixin.py +435 -0
  213. setiastro/saspro/gui/statistics_dialog.py +47 -0
  214. setiastro/saspro/halobgon.py +488 -0
  215. setiastro/saspro/header_viewer.py +448 -0
  216. setiastro/saspro/headless_utils.py +88 -0
  217. setiastro/saspro/histogram.py +756 -0
  218. setiastro/saspro/history_explorer.py +941 -0
  219. setiastro/saspro/i18n.py +168 -0
  220. setiastro/saspro/image_combine.py +417 -0
  221. setiastro/saspro/image_peeker_pro.py +1604 -0
  222. setiastro/saspro/imageops/__init__.py +37 -0
  223. setiastro/saspro/imageops/mdi_snap.py +292 -0
  224. setiastro/saspro/imageops/scnr.py +36 -0
  225. setiastro/saspro/imageops/starbasedwhitebalance.py +210 -0
  226. setiastro/saspro/imageops/stretch.py +236 -0
  227. setiastro/saspro/isophote.py +1182 -0
  228. setiastro/saspro/layers.py +208 -0
  229. setiastro/saspro/layers_dock.py +714 -0
  230. setiastro/saspro/lazy_imports.py +193 -0
  231. setiastro/saspro/legacy/__init__.py +2 -0
  232. setiastro/saspro/legacy/image_manager.py +2226 -0
  233. setiastro/saspro/legacy/numba_utils.py +3676 -0
  234. setiastro/saspro/legacy/xisf.py +1071 -0
  235. setiastro/saspro/linear_fit.py +537 -0
  236. setiastro/saspro/live_stacking.py +1841 -0
  237. setiastro/saspro/log_bus.py +5 -0
  238. setiastro/saspro/logging_config.py +460 -0
  239. setiastro/saspro/luminancerecombine.py +309 -0
  240. setiastro/saspro/main_helpers.py +201 -0
  241. setiastro/saspro/mask_creation.py +931 -0
  242. setiastro/saspro/masks_core.py +56 -0
  243. setiastro/saspro/mdi_widgets.py +353 -0
  244. setiastro/saspro/memory_utils.py +666 -0
  245. setiastro/saspro/metadata_patcher.py +75 -0
  246. setiastro/saspro/mfdeconv.py +3831 -0
  247. setiastro/saspro/mfdeconv_earlystop.py +71 -0
  248. setiastro/saspro/mfdeconvcudnn.py +3263 -0
  249. setiastro/saspro/mfdeconvsport.py +2382 -0
  250. setiastro/saspro/minorbodycatalog.py +567 -0
  251. setiastro/saspro/morphology.py +407 -0
  252. setiastro/saspro/multiscale_decomp.py +1293 -0
  253. setiastro/saspro/nbtorgb_stars.py +541 -0
  254. setiastro/saspro/numba_utils.py +3145 -0
  255. setiastro/saspro/numba_warmup.py +141 -0
  256. setiastro/saspro/ops/__init__.py +9 -0
  257. setiastro/saspro/ops/command_help_dialog.py +623 -0
  258. setiastro/saspro/ops/command_runner.py +217 -0
  259. setiastro/saspro/ops/commands.py +1594 -0
  260. setiastro/saspro/ops/script_editor.py +1102 -0
  261. setiastro/saspro/ops/scripts.py +1473 -0
  262. setiastro/saspro/ops/settings.py +637 -0
  263. setiastro/saspro/parallel_utils.py +554 -0
  264. setiastro/saspro/pedestal.py +121 -0
  265. setiastro/saspro/perfect_palette_picker.py +1071 -0
  266. setiastro/saspro/pipeline.py +110 -0
  267. setiastro/saspro/pixelmath.py +1604 -0
  268. setiastro/saspro/plate_solver.py +2445 -0
  269. setiastro/saspro/project_io.py +797 -0
  270. setiastro/saspro/psf_utils.py +136 -0
  271. setiastro/saspro/psf_viewer.py +549 -0
  272. setiastro/saspro/pyi_rthook_astroquery.py +95 -0
  273. setiastro/saspro/remove_green.py +331 -0
  274. setiastro/saspro/remove_stars.py +1599 -0
  275. setiastro/saspro/remove_stars_preset.py +404 -0
  276. setiastro/saspro/resources.py +501 -0
  277. setiastro/saspro/rgb_combination.py +208 -0
  278. setiastro/saspro/rgb_extract.py +19 -0
  279. setiastro/saspro/rgbalign.py +723 -0
  280. setiastro/saspro/runtime_imports.py +7 -0
  281. setiastro/saspro/runtime_torch.py +754 -0
  282. setiastro/saspro/save_options.py +73 -0
  283. setiastro/saspro/selective_color.py +1552 -0
  284. setiastro/saspro/sfcc.py +1472 -0
  285. setiastro/saspro/shortcuts.py +3043 -0
  286. setiastro/saspro/signature_insert.py +1102 -0
  287. setiastro/saspro/stacking_suite.py +18470 -0
  288. setiastro/saspro/star_alignment.py +7435 -0
  289. setiastro/saspro/star_alignment_preset.py +329 -0
  290. setiastro/saspro/star_metrics.py +49 -0
  291. setiastro/saspro/star_spikes.py +765 -0
  292. setiastro/saspro/star_stretch.py +507 -0
  293. setiastro/saspro/stat_stretch.py +538 -0
  294. setiastro/saspro/status_log_dock.py +78 -0
  295. setiastro/saspro/subwindow.py +3328 -0
  296. setiastro/saspro/supernovaasteroidhunter.py +1719 -0
  297. setiastro/saspro/swap_manager.py +99 -0
  298. setiastro/saspro/torch_backend.py +89 -0
  299. setiastro/saspro/torch_rejection.py +434 -0
  300. setiastro/saspro/translations/all_source_strings.json +3654 -0
  301. setiastro/saspro/translations/ar_translations.py +3865 -0
  302. setiastro/saspro/translations/de_translations.py +3749 -0
  303. setiastro/saspro/translations/es_translations.py +3939 -0
  304. setiastro/saspro/translations/fr_translations.py +3858 -0
  305. setiastro/saspro/translations/hi_translations.py +3571 -0
  306. setiastro/saspro/translations/integrate_translations.py +270 -0
  307. setiastro/saspro/translations/it_translations.py +3678 -0
  308. setiastro/saspro/translations/ja_translations.py +3601 -0
  309. setiastro/saspro/translations/pt_translations.py +3869 -0
  310. setiastro/saspro/translations/ru_translations.py +2848 -0
  311. setiastro/saspro/translations/saspro_ar.qm +0 -0
  312. setiastro/saspro/translations/saspro_ar.ts +255 -0
  313. setiastro/saspro/translations/saspro_de.qm +0 -0
  314. setiastro/saspro/translations/saspro_de.ts +253 -0
  315. setiastro/saspro/translations/saspro_es.qm +0 -0
  316. setiastro/saspro/translations/saspro_es.ts +12520 -0
  317. setiastro/saspro/translations/saspro_fr.qm +0 -0
  318. setiastro/saspro/translations/saspro_fr.ts +12514 -0
  319. setiastro/saspro/translations/saspro_hi.qm +0 -0
  320. setiastro/saspro/translations/saspro_hi.ts +257 -0
  321. setiastro/saspro/translations/saspro_it.qm +0 -0
  322. setiastro/saspro/translations/saspro_it.ts +12520 -0
  323. setiastro/saspro/translations/saspro_ja.qm +0 -0
  324. setiastro/saspro/translations/saspro_ja.ts +257 -0
  325. setiastro/saspro/translations/saspro_pt.qm +0 -0
  326. setiastro/saspro/translations/saspro_pt.ts +257 -0
  327. setiastro/saspro/translations/saspro_ru.qm +0 -0
  328. setiastro/saspro/translations/saspro_ru.ts +237 -0
  329. setiastro/saspro/translations/saspro_sw.qm +0 -0
  330. setiastro/saspro/translations/saspro_sw.ts +257 -0
  331. setiastro/saspro/translations/saspro_uk.qm +0 -0
  332. setiastro/saspro/translations/saspro_uk.ts +10771 -0
  333. setiastro/saspro/translations/saspro_zh.qm +0 -0
  334. setiastro/saspro/translations/saspro_zh.ts +12520 -0
  335. setiastro/saspro/translations/sw_translations.py +3671 -0
  336. setiastro/saspro/translations/uk_translations.py +3700 -0
  337. setiastro/saspro/translations/zh_translations.py +3675 -0
  338. setiastro/saspro/versioning.py +77 -0
  339. setiastro/saspro/view_bundle.py +1558 -0
  340. setiastro/saspro/wavescale_hdr.py +645 -0
  341. setiastro/saspro/wavescale_hdr_preset.py +101 -0
  342. setiastro/saspro/wavescalede.py +680 -0
  343. setiastro/saspro/wavescalede_preset.py +230 -0
  344. setiastro/saspro/wcs_update.py +374 -0
  345. setiastro/saspro/whitebalance.py +492 -0
  346. setiastro/saspro/widgets/__init__.py +48 -0
  347. setiastro/saspro/widgets/common_utilities.py +306 -0
  348. setiastro/saspro/widgets/graphics_views.py +122 -0
  349. setiastro/saspro/widgets/image_utils.py +518 -0
  350. setiastro/saspro/widgets/minigame/game.js +986 -0
  351. setiastro/saspro/widgets/minigame/index.html +53 -0
  352. setiastro/saspro/widgets/minigame/style.css +241 -0
  353. setiastro/saspro/widgets/preview_dialogs.py +280 -0
  354. setiastro/saspro/widgets/resource_monitor.py +237 -0
  355. setiastro/saspro/widgets/spinboxes.py +275 -0
  356. setiastro/saspro/widgets/themed_buttons.py +13 -0
  357. setiastro/saspro/widgets/wavelet_utils.py +331 -0
  358. setiastro/saspro/wimi.py +7996 -0
  359. setiastro/saspro/wims.py +578 -0
  360. setiastro/saspro/window_shelf.py +185 -0
  361. setiastro/saspro/xisf.py +1123 -0
  362. setiastrosuitepro-1.6.2.post1.dist-info/METADATA +278 -0
  363. setiastrosuitepro-1.6.2.post1.dist-info/RECORD +367 -0
  364. setiastrosuitepro-1.6.2.post1.dist-info/WHEEL +4 -0
  365. setiastrosuitepro-1.6.2.post1.dist-info/entry_points.txt +6 -0
  366. setiastrosuitepro-1.6.2.post1.dist-info/licenses/LICENSE +674 -0
  367. setiastrosuitepro-1.6.2.post1.dist-info/licenses/license.txt +2580 -0
@@ -0,0 +1,1841 @@
1
+ from __future__ import annotations
2
+ import os
3
+ import glob
4
+ import shutil
5
+ import tempfile
6
+ import datetime as _dt
7
+ import numpy as np
8
+ import time
9
+
10
+ from PyQt6.QtCore import Qt, QTimer, QSettings, pyqtSignal
11
+ from PyQt6.QtGui import QIcon, QImage, QPixmap, QAction, QIntValidator, QDoubleValidator
12
+ from PyQt6.QtWidgets import (QDialog, QWidget, QLabel, QPushButton, QVBoxLayout, QHBoxLayout, QLineEdit,
13
+ QFormLayout, QDialogButtonBox, QToolBar, QToolButton, QFileDialog,
14
+ QSizePolicy, QGraphicsView, QGraphicsScene, QGraphicsPixmapItem, QApplication,
15
+ QMessageBox, QSlider, QCheckBox, QInputDialog, QComboBox)
16
+
17
+ import pyqtgraph as pg
18
+ from astropy.io import fits
19
+ from astropy.stats import sigma_clipped_stats
20
+
21
+ # optional deps used in your code; guard if not installed
22
+ try:
23
+ import rawpy
24
+ except Exception:
25
+ rawpy = None
26
+ try:
27
+ import exifread
28
+ except Exception:
29
+ exifread = None
30
+
31
+ import sep
32
+ import exifread
33
+
34
+ # your helpers/utilities
35
+ from setiastro.saspro.imageops.stretch import stretch_mono_image, stretch_color_image
36
+ from setiastro.saspro.legacy.numba_utils import apply_flat_division_numba, debayer_fits_fast # adjust names if different
37
+ from setiastro.saspro.legacy.image_manager import load_image
38
+ from setiastro.saspro.star_alignment import StarRegistrationWorker, StarRegistrationThread, IDENTITY_2x3
39
+ from setiastro.saspro.widgets.spinboxes import CustomSpinBox, CustomDoubleSpinBox
40
+
41
+
42
+ class LiveStackSettingsDialog(QDialog):
43
+ """
44
+ Combined dialog for:
45
+ • Live‐stack parameters (bootstrap frames, σ‐clip threshold)
46
+ • Culling thresholds (max FWHM, max eccentricity, min star count)
47
+ """
48
+ def __init__(self, parent):
49
+ super().__init__(parent)
50
+ self.setWindowTitle("Live Stack & Culling Settings")
51
+ self.setWindowFlag(Qt.WindowType.Window, True)
52
+ self.setWindowModality(Qt.WindowModality.NonModal)
53
+ self.setModal(False)
54
+
55
+ # — Live Stack Settings —
56
+ # Bootstrap frames (int)
57
+ self.bs_spin = CustomSpinBox(
58
+ minimum=1,
59
+ maximum=100,
60
+ initial=parent.bootstrap_frames,
61
+ step=1
62
+ )
63
+ self.bs_spin.valueChanged.connect(lambda v: None)
64
+
65
+ # Sigma threshold (float)
66
+ self.sigma_spin = CustomDoubleSpinBox(
67
+ minimum=0.1,
68
+ maximum=10.0,
69
+ initial=parent.clip_threshold,
70
+ step=0.1
71
+ )
72
+ self.sigma_spin.valueChanged.connect(lambda v: None)
73
+
74
+ # — Culling Thresholds —
75
+ # Max FWHM (float)
76
+ self.fwhm_spin = CustomDoubleSpinBox(
77
+ minimum=0.1,
78
+ maximum=50.0,
79
+ initial=parent.max_fwhm,
80
+ step=0.1
81
+ )
82
+ self.fwhm_spin.valueChanged.connect(lambda v: None)
83
+
84
+ # Max eccentricity (float)
85
+ self.ecc_spin = CustomDoubleSpinBox(
86
+ minimum=0.0,
87
+ maximum=1.0,
88
+ initial=parent.max_ecc,
89
+ step=0.01
90
+ )
91
+ self.ecc_spin.valueChanged.connect(lambda v: None)
92
+
93
+ # Min star count (int)
94
+ self.star_spin = CustomSpinBox(
95
+ minimum=0,
96
+ maximum=5000,
97
+ initial=parent.min_star_count,
98
+ step=1
99
+ )
100
+ self.star_spin.valueChanged.connect(lambda v: None)
101
+
102
+ # Acquisition Dely (int)
103
+ self.delay_spin = CustomDoubleSpinBox(
104
+ minimum=0.0,
105
+ maximum=60.0,
106
+ initial=parent.FILE_STABLE_SECS,
107
+ step=0.5
108
+ )
109
+
110
+
111
+
112
+ # Build form layout
113
+ form = QFormLayout()
114
+ form.addRow("Switch to μ–σ clipping after:", self.bs_spin)
115
+ form.addRow("Clip threshold:", self.sigma_spin)
116
+ form.addRow(QLabel("")) # blank row for separation
117
+ form.addRow("Max FWHM (px):", self.fwhm_spin)
118
+ form.addRow("Max Eccentricity:", self.ecc_spin)
119
+ form.addRow("Min Star Count:", self.star_spin)
120
+ form.addRow("Acquisition Delay:", self.delay_spin)
121
+
122
+ self.mapping_combo = QComboBox()
123
+ opts = ["Natural", "SHO", "HSO", "OSH", "SOH", "HOS", "OHS"]
124
+ self.mapping_combo.addItems(opts)
125
+ # preselect current
126
+ idx = opts.index(parent.narrowband_mapping) \
127
+ if parent.narrowband_mapping in opts else 0
128
+ self.mapping_combo.setCurrentIndex(idx)
129
+ form.addRow("Narrowband Mapping:", self.mapping_combo)
130
+
131
+ # OK / Cancel buttons
132
+ btns = QDialogButtonBox(
133
+ QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
134
+ )
135
+ btns.accepted.connect(self.accept)
136
+ btns.rejected.connect(self.reject)
137
+
138
+ # Assemble dialog layout
139
+ layout = QVBoxLayout()
140
+ layout.addLayout(form)
141
+ layout.addWidget(btns)
142
+ self.setLayout(layout)
143
+
144
+ def getValues(self):
145
+ """
146
+ Returns a tuple of five values in order:
147
+ (bootstrap_frames, clip_threshold,
148
+ max_fwhm, max_ecc, min_star_count, delay)
149
+ """
150
+ bs = self.bs_spin.value
151
+ sigma = self.sigma_spin.value()
152
+ fwhm = self.fwhm_spin.value()
153
+ ecc = self.ecc_spin.value()
154
+ stars = self.star_spin.value
155
+ mapping = self.mapping_combo.currentText()
156
+ delay = self.delay_spin.value()
157
+ return bs, sigma, fwhm, ecc, stars, mapping, delay
158
+
159
+
160
+
161
+ class LiveMetricsPanel(QWidget):
162
+ """
163
+ A simple 2×2 grid of PyQtGraph plots to show, in real time:
164
+ [0,0] → FWHM (px) vs. frame index
165
+ [0,1] → Eccentricity vs. frame index
166
+ [1,0] → Star Count vs. frame index
167
+ [1,1] → (μ–ν)/σ (∝SNR) vs. frame index
168
+ """
169
+ def __init__(self, parent=None):
170
+ super().__init__(parent)
171
+ titles = ["FWHM (px)", "Eccentricity", "Star Count", "(μ–ν)/σ (∝SNR)"]
172
+
173
+ layout = QVBoxLayout(self)
174
+ grid = pg.GraphicsLayoutWidget()
175
+ layout.addWidget(grid)
176
+
177
+ self.plots = []
178
+ self.scats = []
179
+ self._data_x = [[], [], [], []]
180
+ self._data_y = [[], [], [], []]
181
+ self._flags = [[], [], [], []] # track if each point was “bad” (True) or “good” (False)
182
+
183
+ for row in range(2):
184
+ for col in range(2):
185
+ pw = grid.addPlot(row=row, col=col)
186
+ idx = row * 2 + col
187
+ pw.setTitle(titles[idx])
188
+ pw.showGrid(x=True, y=True, alpha=0.3)
189
+ pw.setLabel('bottom', "Frame #")
190
+ pw.setLabel('left', titles[idx])
191
+
192
+ scat = pg.ScatterPlotItem(pen=pg.mkPen(None),
193
+ brush=pg.mkBrush(100, 100, 255, 200),
194
+ size=6)
195
+ pw.addItem(scat)
196
+ self.plots.append(pw)
197
+ self.scats.append(scat)
198
+
199
+ def add_point(self, frame_idx: int, fwhm: float, ecc: float, star_cnt: int, snr_val: float, flagged: bool):
200
+ """
201
+ Append one new data point to each metric.
202
+ If flagged == True, draw that single point in red; else blue.
203
+ But keep all previously-plotted points at their original colors.
204
+ """
205
+ values = [fwhm, ecc, star_cnt, snr_val]
206
+ for i in range(4):
207
+ self._data_x[i].append(frame_idx)
208
+ self._data_y[i].append(values[i])
209
+ self._flags[i].append(flagged)
210
+
211
+ # Now build a brush list for *all* points up to index i,
212
+ # coloring each point according to its own flag.
213
+ brushes = [
214
+ pg.mkBrush(255, 0, 0, 200) if self._flags[i][j]
215
+ else pg.mkBrush(100, 100, 255, 200)
216
+ for j in range(len(self._data_x[i]))
217
+ ]
218
+
219
+ self.scats[i].setData(
220
+ self._data_x[i],
221
+ self._data_y[i],
222
+ brush=brushes,
223
+ pen=pg.mkPen(None),
224
+ size=6
225
+ )
226
+
227
+ def clear_all(self):
228
+ """Clear data from all four plots."""
229
+ for i in range(4):
230
+ self._data_x[i].clear()
231
+ self._data_y[i].clear()
232
+ self._flags[i].clear()
233
+ self.scats[i].clear()
234
+
235
+ class LiveMetricsWindow(QWidget):
236
+ def __init__(self, parent=None):
237
+ super().__init__(parent)
238
+ self.setWindowTitle("Live Stack Metrics")
239
+ self.resize(600, 400)
240
+
241
+ layout = QVBoxLayout(self)
242
+ self.metrics_panel = LiveMetricsPanel(self)
243
+ layout.addWidget(self.metrics_panel)
244
+
245
+ from setiastro.saspro.star_metrics import measure_stars_sep
246
+
247
+ def compute_frame_star_metrics(image_2d):
248
+ """
249
+ Harmonized with Blink metrics:
250
+ - SEP.Background() for back/noise
251
+ - thresh = 7σ
252
+ - median aggregation for FWHM & Ecc
253
+ """
254
+ # ensure float32 mono [0..1]
255
+ data = np.asarray(image_2d)
256
+ if data.ndim == 3:
257
+ data = data.mean(axis=2)
258
+ if data.dtype == np.uint8:
259
+ data = data.astype(np.float32) / 255.0
260
+ elif data.dtype == np.uint16:
261
+ data = data.astype(np.float32) / 65535.0
262
+ else:
263
+ data = data.astype(np.float32, copy=False)
264
+
265
+ star_count, fwhm, ecc = measure_stars_sep(
266
+ data,
267
+ thresh_sigma=7.0,
268
+ minarea=16,
269
+ deblend_nthresh=32,
270
+ aggregate="median",
271
+ )
272
+ return star_count, fwhm, ecc
273
+
274
+ def estimate_global_snr(
275
+ stack_image: np.ndarray,
276
+ bkg_box_size: int = 200
277
+ ) -> float:
278
+ """
279
+ “Hybrid” global SNR ≔ (μ_patch − median_patch) / σ_central,
280
+ where:
281
+ • μ_patch and median_patch come from a small bkg_box_size×bkg_box_size patch
282
+ centered inside the middle 50% of the image.
283
+ • σ_central is the standard deviation computed over the entire “middle 50%” region.
284
+
285
+ Steps:
286
+ 1) Collapse to grayscale (H×W) if needed.
287
+ 2) Identify the middle 50% rectangle of the image.
288
+ 3) Within that, center a patch of size up to bkg_box_size×bkg_box_size.
289
+ 4) Compute μ_patch = mean(patch), median_patch = median(patch).
290
+ 5) Compute σ_central = std(middle50_region).
291
+ 6) If σ_central ≤ 0, return 0. Otherwise return (μ_patch − median_patch) / σ_central.
292
+ """
293
+
294
+ # 1) Collapse to simple 2D float array (grayscale)
295
+ if stack_image.ndim == 3 and stack_image.shape[2] == 3:
296
+ try:
297
+ import cv2
298
+ # cv2.cvtColor is significantly faster than mean(axis=2)
299
+ # Assuming RGB input, but even if BGR, for SNR estimation luma difference is negligible
300
+ gray = cv2.cvtColor(stack_image, cv2.COLOR_RGB2GRAY)
301
+ if gray.dtype != np.float32:
302
+ gray = gray.astype(np.float32)
303
+ except ImportError:
304
+ # Fallback
305
+ gray = stack_image.mean(axis=2).astype(np.float32)
306
+ else:
307
+ # Already mono: just cast to float32
308
+ gray = stack_image.astype(np.float32)
309
+
310
+ H, W = gray.shape
311
+
312
+ # 2) Compute coordinates of the “middle 50%” rectangle
313
+ y0 = H // 4
314
+ y1 = y0 + (H // 2)
315
+ x0 = W // 4
316
+ x1 = x0 + (W // 2)
317
+
318
+ # Extract that central50 region as a view (no copy)
319
+ central50 = gray[y0:y1, x0:x1]
320
+
321
+ # 3) Within that central50, choose a patch of up to bkg_box_size×bkg_box_size, centered
322
+ center_h = (y1 - y0)
323
+ center_w = (x1 - x0)
324
+
325
+ # Clamp box size so it does not exceed central50 dimensions
326
+ box_h = min(bkg_box_size, center_h)
327
+ box_w = min(bkg_box_size, center_w)
328
+
329
+ # Compute top-left corner of that patch so it’s centered in central50
330
+ cy0 = y0 + (center_h - box_h) // 2
331
+ cx0 = x0 + (center_w - box_w) // 2
332
+
333
+ patch = gray[cy0 : cy0 + box_h, cx0 : cx0 + box_w]
334
+
335
+ # 4) Compute patch statistics
336
+ mu_patch = float(np.mean(patch))
337
+ med_patch = float(np.median(patch))
338
+ min_patch = float(np.min(patch))
339
+
340
+ # 5) Compute σ over the entire central50 region
341
+ sigma_central = float(np.std(central50))
342
+ if sigma_central <= 0.0:
343
+ return 0.0
344
+
345
+ nu = med_patch - 3.0 * sigma_central * med_patch
346
+
347
+ # 6) Return (mean − nu) / σ
348
+ return (mu_patch - nu) / sigma_central
349
+ #return (mu_patch) / sigma_central
350
+
351
+ class LiveStackWindow(QDialog):
352
+ def __init__(self, parent=None, doc_manager=None, wrench_path=None, spinner_path=None):
353
+ super().__init__(parent)
354
+ self.parent = parent
355
+ self._docman = doc_manager
356
+ self._wrench_path = wrench_path
357
+ self._spinner_path = spinner_path
358
+ self.setWindowTitle("Live Stacking")
359
+ self.resize(900, 600)
360
+
361
+ # ─── State Variables ─────────────────────────────────────
362
+ self.watch_folder = None
363
+ self.processed_files = set()
364
+ self.master_dark = None
365
+ self.master_flat = None
366
+ self.master_flats = {}
367
+
368
+ self.filter_stacks = {} # key → np.ndarray (float32)
369
+ self.filter_counts = {} # key → int
370
+ self.filter_buffers = {} # key → list of bootstrap frames [H×W arrays]
371
+ self.filter_mus = {} # key → µ array after bootstrap (H×W)
372
+ self.filter_m2s = {} # key → M2 array after bootstrap (H×W)
373
+
374
+ self.cull_folder = None
375
+
376
+ self.is_running = False
377
+ self.frame_count = 0
378
+ self.current_stack = None
379
+
380
+ self._probe = {} # path -> {"size": int, "mtime": float, "since": float, "penalty_until": float}
381
+ # Tunables:
382
+ self.FILE_STABLE_SECS = 3.0 # how long size+mtime must stay unchanged
383
+ self.OPEN_RETRY_PENALTY_SECS = 10.0 # cool-down after a read/permission failure
384
+ self.MAX_FILE_WAIT_SECS = 600.0 # optional safety cap (unused by default logic)
385
+
386
+ # ── Load persisted settings ───────────────────────────────
387
+ s = QSettings()
388
+ self.bootstrap_frames = s.value("LiveStack/bootstrap_frames", 24, type=int)
389
+ self.clip_threshold = s.value("LiveStack/clip_threshold", 3.5, type=float)
390
+ self.max_fwhm = s.value("LiveStack/max_fwhm", 15.0, type=float)
391
+ self.max_ecc = s.value("LiveStack/max_ecc", 0.9, type=float)
392
+ self.min_star_count = s.value("LiveStack/min_star_count", 5, type=int)
393
+ self.narrowband_mapping = s.value("LiveStack/narrowband_mapping", "Natural", type=str)
394
+ self.star_trail_mode = s.value("LiveStack/star_trail_mode", False, type=bool)
395
+
396
+
397
+ self.total_exposure = 0.0 # seconds
398
+ self.exposure_label = QLabel("Total Exp: 00:00:00")
399
+ self.exposure_label.setStyleSheet("color: #cccccc; font-weight: bold;")
400
+
401
+ self.brightness = 0.0 # [-1.0..+1.0]
402
+ self.contrast = 1.0 # [0.1..3.0]
403
+
404
+
405
+ self._buffer = [] # store up to bootstrap_frames normalized frames
406
+ self._mu = None # per-pixel mean (after bootstrap)
407
+ self._m2 = None # per-pixel sum of squares differences (for Welford)
408
+
409
+ # ─── Create Separate Metrics Window (initially hidden) ─────
410
+ # We do NOT embed this in the stacking dialog’s layout!
411
+ self.metrics_window = LiveMetricsWindow(None)
412
+ self.metrics_window.hide()
413
+
414
+ # ─── UI ELEMENTS FOR STACKING DIALOG ───────────────────────
415
+ # 1) Folder selection
416
+ self.folder_label = QLabel("Folder: (none)")
417
+ self.select_folder_btn = QPushButton("Select Folder…")
418
+ self.select_folder_btn.clicked.connect(self.select_folder)
419
+
420
+ # 2) Load master dark/flat
421
+ self.load_darks_btn = QPushButton("Load Master Dark…")
422
+ self.load_darks_btn.clicked.connect(self.load_masters)
423
+ self.load_flats_btn = QPushButton("Load Master Flat…")
424
+ self.load_flats_btn.clicked.connect(self.load_masters)
425
+ self.load_filter_flats_btn = QPushButton("Load MonoFilter Flats…")
426
+ self.load_filter_flats_btn.clicked.connect(self.load_filter_flats)
427
+
428
+ # 2b) Cull folder selection
429
+ self.cull_folder_label = QLabel("Cull Folder: (none)")
430
+ self.select_cull_btn = QPushButton("Select Cull Folder…")
431
+ self.select_cull_btn.clicked.connect(self.select_cull_folder)
432
+
433
+ self.dark_status_label = QLabel("Dark: ❌")
434
+ self.flat_status_label = QLabel("Flat: ❌")
435
+ for lbl in (self.dark_status_label, self.flat_status_label):
436
+ lbl.setStyleSheet("color: #cccccc; font-weight: bold;")
437
+ # 3) “Process & Monitor” / “Monitor Only” / “Stop” / “Reset”
438
+ self.mono_color_checkbox = QCheckBox("Mono → Color Stacking")
439
+ self.mono_color_checkbox.setToolTip(
440
+ "When checked, bucket mono frames by FILTER and composite R, G, B, Ha, OIII, SII."
441
+ )
442
+ # **Connect the toggled(bool) signal** before we ever call it
443
+ self.mono_color_checkbox.toggled.connect(self._on_mono_color_toggled)
444
+
445
+ # ** new: Star-Trail mode checkbox **
446
+ self.star_trail_checkbox = QCheckBox("★★ Star-Trail Mode ★★")
447
+ self.star_trail_checkbox.setChecked(self.star_trail_mode)
448
+ self.star_trail_checkbox.setToolTip("If checked, build a max-value trail instead of a running stack")
449
+ self.star_trail_checkbox.toggled.connect(self._on_star_trail_toggled)
450
+
451
+ self.process_and_monitor_btn = QPushButton("Process && Monitor")
452
+ self.process_and_monitor_btn.clicked.connect(self.start_and_process)
453
+ self.monitor_only_btn = QPushButton("Monitor Only")
454
+ self.monitor_only_btn.clicked.connect(self.start_monitor_only)
455
+ self.stop_btn = QPushButton("Stop")
456
+ self.stop_btn.clicked.connect(self.stop_live)
457
+ self.reset_btn = QPushButton("Reset")
458
+ self.reset_btn.clicked.connect(self.reset_live)
459
+
460
+ self.frame_count_label = QLabel("Frames: 0")
461
+
462
+ # 4) Live‐stack preview area (QGraphicsView)
463
+ self.scene = QGraphicsScene(self)
464
+ self.view = QGraphicsView(self.scene, self)
465
+ self.view.setDragMode(QGraphicsView.DragMode.ScrollHandDrag)
466
+ self.view.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
467
+ self.pixmap_item = QGraphicsPixmapItem()
468
+ self.scene.addItem(self.pixmap_item)
469
+ self._did_initial_fit = False
470
+
471
+ # 5) Zoom toolbar + Settings icon
472
+ tb = QToolBar()
473
+
474
+ zi = QAction(QIcon.fromTheme("zoom-in"), "Zoom In", self)
475
+ zo = QAction(QIcon.fromTheme("zoom-out"), "Zoom Out", self)
476
+ fit = QAction(QIcon.fromTheme("zoom-fit-best"), "Fit to Window", self)
477
+
478
+ tb.addAction(zi)
479
+ tb.addAction(zo)
480
+ tb.addAction(fit)
481
+
482
+ spacer = QWidget()
483
+ spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
484
+ tb.addWidget(spacer)
485
+ # — Replace the QAction “wrench” with a styled QToolButton —
486
+ self.wrench_button = QToolButton()
487
+ self.wrench_button.setIcon(QIcon(self._wrench_path))
488
+ self.wrench_button.setToolTip("Settings")
489
+ # Apply your stylesheet to the QToolButton
490
+ self.wrench_button.setStyleSheet("""
491
+ QToolButton {
492
+ background-color: #FF4500;
493
+ color: white;
494
+ font-size: 16px;
495
+ padding: 8px;
496
+ border-radius: 5px;
497
+ font-weight: bold;
498
+ }
499
+ QToolButton:hover {
500
+ background-color: #FF6347;
501
+ }
502
+ """)
503
+ # Connect the clicked signal to open_settings()
504
+ self.wrench_button.clicked.connect(self.open_settings)
505
+
506
+ # Add the styled QToolButton into the toolbar
507
+ tb.addWidget(self.wrench_button)
508
+
509
+ zi.triggered.connect(self.zoom_in)
510
+ zo.triggered.connect(self.zoom_out)
511
+ fit.triggered.connect(self.fit_to_window)
512
+
513
+
514
+ # 6) Brightness & Contrast sliders
515
+ bright_slider = QSlider(Qt.Orientation.Horizontal)
516
+ bright_slider.setRange(-100, 100)
517
+ bright_slider.setValue(0)
518
+ bright_slider.setToolTip("Brightness")
519
+ bright_slider.valueChanged.connect(self.on_brightness_changed)
520
+
521
+ contrast_slider = QSlider(Qt.Orientation.Horizontal)
522
+ contrast_slider.setRange(10, 1000)
523
+ contrast_slider.setValue(100)
524
+ contrast_slider.setToolTip("Contrast")
525
+ contrast_slider.valueChanged.connect(self.on_contrast_changed)
526
+
527
+ bc_layout = QHBoxLayout()
528
+ bc_layout.addWidget(QLabel("Brightness"))
529
+ bc_layout.addWidget(bright_slider)
530
+ bc_layout.addWidget(QLabel("Contrast"))
531
+ bc_layout.addWidget(contrast_slider)
532
+
533
+ # 7) “Send to Slot” button
534
+ open_btn = QPushButton("Open in New View ▶")
535
+ open_btn.clicked.connect(self.send_to_new_view)
536
+
537
+ # 8) “Show Metrics” button
538
+ self.show_metrics_btn = QPushButton("Show Metrics")
539
+ self.show_metrics_btn.clicked.connect(self.show_metrics_window)
540
+
541
+ # ─── ASSEMBLE MAIN LAYOUT (exactly one setLayout call!) ─────
542
+ main_layout = QVBoxLayout()
543
+
544
+ # A) Top‐row controls
545
+ controls = QHBoxLayout()
546
+ controls.addWidget(self.select_folder_btn)
547
+ controls.addWidget(self.load_darks_btn)
548
+ controls.addWidget(self.load_flats_btn)
549
+ controls.addWidget(self.load_filter_flats_btn)
550
+ controls.addWidget(self.select_cull_btn)
551
+ controls.addStretch()
552
+ controls.addWidget(self.mono_color_checkbox)
553
+ controls.addWidget(self.star_trail_checkbox)
554
+ controls.addWidget(self.process_and_monitor_btn)
555
+ controls.addWidget(self.monitor_only_btn)
556
+ controls.addWidget(self.stop_btn)
557
+ controls.addWidget(self.reset_btn)
558
+ main_layout.addLayout(controls)
559
+
560
+ # B) Status line: folder label + frame count
561
+ status_line = QHBoxLayout()
562
+ status_line.addWidget(self.folder_label)
563
+ status_line.addWidget(self.dark_status_label)
564
+ status_line.addWidget(self.flat_status_label)
565
+ status_line.addWidget(self.cull_folder_label)
566
+ status_line.addStretch()
567
+ status_line.addWidget(self.frame_count_label)
568
+ status_line.addWidget(self.exposure_label)
569
+ main_layout.addLayout(status_line)
570
+
571
+ # C) Zoom toolbar
572
+ main_layout.addWidget(tb)
573
+
574
+ # D) Show Metrics button (separate window)
575
+ main_layout.addWidget(self.show_metrics_btn)
576
+
577
+ # E) Live‐stack preview area
578
+ main_layout.addWidget(self.view)
579
+
580
+ # F) Brightness/Contrast sliders
581
+ main_layout.addLayout(bc_layout)
582
+
583
+ # G) “Send to Slot” + mode/idle labels
584
+ main_layout.addWidget(open_btn)
585
+ self.mode_label = QLabel("Mode: Linear Average")
586
+ self.mode_label.setStyleSheet("color: #a0a0a0;")
587
+ main_layout.addWidget(self.mode_label)
588
+ self.status_label = QLabel("Idle")
589
+ self.status_label.setStyleSheet("color: #a0a0a0;")
590
+ main_layout.addWidget(self.status_label)
591
+
592
+ # Finalize
593
+ self.setLayout(main_layout)
594
+
595
+ # Timer for polling new files
596
+ self.poll_timer = QTimer(self)
597
+ self.poll_timer.setInterval(1500)
598
+ self.poll_timer.timeout.connect(self.check_for_new_frames)
599
+ self._on_mono_color_toggled(self.mono_color_checkbox.isChecked())
600
+
601
+
602
+ # ─────────────────────────────────────────────────────────────────────────
603
+ def _on_star_trail_toggled(self, checked: bool):
604
+ """Enable/disable star-trail mode."""
605
+ self.star_trail_mode = checked
606
+ QSettings().setValue("LiveStack/star_trail_mode", checked)
607
+ self.mode_label.setText("Mode: Star-Trail" if checked else "Mode: Linear Average")
608
+ # if you want, disable mono/color checkbox when star-trail is on:
609
+ self.mono_color_checkbox.setEnabled(not checked)
610
+
611
+ def _on_mono_color_toggled(self, checked: bool):
612
+ self.mono_color_mode = checked
613
+ self.filter_stacks.clear()
614
+ self.filter_counts.clear()
615
+
616
+ msg = "Enabled" if checked else "Disabled"
617
+ self.status_label.setText(f"Mono→Color Mode {msg}")
618
+
619
+ def show_metrics_window(self):
620
+ """Pop up the separate metrics window (never embed it here)."""
621
+ self.metrics_window.show()
622
+ self.metrics_window.raise_()
623
+
624
+
625
+ def select_cull_folder(self):
626
+ folder = QFileDialog.getExistingDirectory(self, "Select Cull Folder")
627
+ if folder:
628
+ self.cull_folder = folder
629
+ self.cull_folder_label.setText(f"Cull: {os.path.basename(folder)}")
630
+
631
+ def _cull_frame(self, path: str):
632
+ """
633
+ Move a flagged frame into the cull folder (if set),
634
+ or just update the status label if not.
635
+ """
636
+ name = os.path.basename(path)
637
+ if self.cull_folder:
638
+ try:
639
+ os.makedirs(self.cull_folder, exist_ok=True)
640
+ dst = os.path.join(self.cull_folder, name)
641
+ shutil.move(path, dst)
642
+ self.status_label.setText(f"⚠ Culled {name} → {self.cull_folder}")
643
+ except Exception:
644
+ self.status_label.setText(f"⚠ Failed to cull {name}")
645
+ else:
646
+ self.status_label.setText(f"⚠ Flagged (not stacked): {name}")
647
+ QApplication.processEvents()
648
+
649
+ def open_settings(self):
650
+ dlg = LiveStackSettingsDialog(self)
651
+ if dlg.exec() == QDialog.DialogCode.Accepted:
652
+ bs, sigma, fwhm, ecc, stars, mapping, delay = dlg.getValues()
653
+
654
+ # 1) Persist into QSettings
655
+ s = QSettings()
656
+ s.setValue("LiveStack/bootstrap_frames", bs)
657
+ s.setValue("LiveStack/clip_threshold", sigma)
658
+ s.setValue("LiveStack/max_fwhm", fwhm)
659
+ s.setValue("LiveStack/max_ecc", ecc)
660
+ s.setValue("LiveStack/min_star_count", stars)
661
+ s.setValue("LiveStack/narrowband_mapping", mapping)
662
+ s.setValue("LiveStack/file_stable_secs", delay)
663
+
664
+ # 2) Apply to this live‐stack session
665
+ self.bootstrap_frames = bs
666
+ self.clip_threshold = sigma
667
+ self.max_fwhm = fwhm
668
+ self.max_ecc = ecc
669
+ self.min_star_count = stars
670
+ self.narrowband_mapping = mapping
671
+ self.FILE_STABLE_SECS = delay
672
+
673
+ self.status_label.setText(
674
+ f"↺ Settings saved: BS={bs}, σ={sigma:.1f}, "
675
+ f"FWHM≤{fwhm:.1f}, ECC≤{ecc:.2f}, Stars≥{stars}, "
676
+ f"Mapping={mapping}"
677
+ )
678
+ QApplication.processEvents()
679
+
680
+ def zoom_in(self):
681
+ self.view.scale(1.2, 1.2)
682
+
683
+ def zoom_out(self):
684
+ self.view.scale(1/1.2, 1/1.2)
685
+
686
+ def fit_to_window(self):
687
+ self.view.fitInView(self.scene.sceneRect(), Qt.AspectRatioMode.KeepAspectRatio)
688
+
689
+ # — Brightness / Contrast —
690
+
691
+ def _refresh_preview(self):
692
+ """
693
+ Recompute the current preview array (stack vs. composite)
694
+ and call update_preview on it.
695
+ """
696
+ if self.mono_color_mode:
697
+ # build the composite from filter_stacks
698
+ preview = self._build_color_composite()
699
+ else:
700
+ # use the normal running stack
701
+ preview = self.current_stack
702
+
703
+ if preview is not None:
704
+ self.update_preview(preview)
705
+
706
+ def on_brightness_changed(self, val: int):
707
+ self.brightness = val / 100.0 # map to [-1,1]
708
+ self._refresh_preview()
709
+
710
+ def on_contrast_changed(self, val: int):
711
+ self.contrast = val / 100.0 # map to [0.1,10.0]
712
+ self._refresh_preview()
713
+
714
+ # — Sending out —
715
+
716
+ def send_to_new_view(self):
717
+ """
718
+ Create a brand-new document/view from the current live stack or composite.
719
+ Prefers using doc_manager's native numpy-open methods; otherwise falls back
720
+ to writing a temp TIFF and asking the host window to open it.
721
+ """
722
+ # pick what to export
723
+ if self.mono_color_mode:
724
+ img = self._build_color_composite()
725
+ else:
726
+ img = self.current_stack
727
+
728
+ if img is None:
729
+ self.status_label.setText("⚠ Nothing to open")
730
+ return
731
+
732
+ # ensure float32 in [0,1]
733
+ img = np.clip(img, 0.0, 1.0).astype(np.float32)
734
+
735
+ title = f"LiveStack_{_dt.datetime.now():%Y%m%d_%H%M%S}_{self.frame_count}f"
736
+ metadata = {"source": "LiveStack", "frames_stacked": int(self.frame_count)}
737
+
738
+ # 1) Try doc_manager direct numpy APIs (several common names)
739
+ dm = self._docman
740
+ if dm is not None:
741
+ for name in ("create_numpy_document",
742
+ "new_document_from_numpy",
743
+ "open_numpy",
744
+ "open_array",
745
+ "open_image_array",
746
+ "add_document_from_array"):
747
+ fn = getattr(dm, name, None)
748
+ if callable(fn):
749
+ try:
750
+ fn(img, title=title, metadata=metadata)
751
+ self.status_label.setText(f"Opened new view: {title}")
752
+ return
753
+ except TypeError:
754
+ # some variants might not accept title/metadata
755
+ try:
756
+ fn(img)
757
+ self.status_label.setText(f"Opened new view: {title}")
758
+ return
759
+ except Exception:
760
+ pass
761
+ except Exception:
762
+ pass
763
+
764
+ # 2) Fallback: write a temp 16-bit TIFF and ask main window to open it
765
+ try:
766
+ import tifffile as tiff
767
+ tmp = tempfile.NamedTemporaryFile(suffix=".tiff", delete=False)
768
+ tmp_path = tmp.name
769
+ tmp.close()
770
+ # export as 16-bit so it's friendly to the rest of the app
771
+ arr16 = np.clip(img * 65535.0, 0, 65535).astype(np.uint16)
772
+ tiff.imwrite(tmp_path, arr16)
773
+
774
+ host = self.parent
775
+ for name in ("open_files", "open_file", "load_paths", "load_path"):
776
+ fn = getattr(host, name, None)
777
+ if callable(fn):
778
+ try:
779
+ fn([tmp_path]) if fn.__code__.co_argcount != 2 else fn(tmp_path)
780
+ self.status_label.setText(f"Opened new view from temp: {os.path.basename(tmp_path)}")
781
+ return
782
+ except Exception:
783
+ pass
784
+
785
+ # ultimate fallback: let the user know where it went
786
+ QMessageBox.information(self, "Saved Temp Image",
787
+ f"Saved temp: {tmp_path}\nOpen it from File → Open.")
788
+ except Exception as e:
789
+ QMessageBox.warning(self, "Open Failed",
790
+ f"Could not open in new view:\n{e}")
791
+
792
+
793
+ # ── New helper: map header["FILTER"] to a single letter key
794
+ def _get_filter_key(self, header):
795
+ """
796
+ Map a FITS header FILTER string to one of:
797
+ 'L' (luminance),
798
+ 'R','G','B',
799
+ 'H' (H-alpha),
800
+ 'O' (OIII),
801
+ 'S' (SII),
802
+ or return None if it doesn’t match.
803
+ """
804
+ raw = header.get('FILTER', '')
805
+ fn = raw.strip().upper()
806
+ if not fn:
807
+ return None
808
+
809
+ # H-alpha
810
+ if fn in ('H', 'HA', 'HALPHA', 'H-ALPHA'):
811
+ return 'H'
812
+ # OIII
813
+ if fn in ('O', 'O3', 'OIII'):
814
+ return 'O'
815
+ # SII
816
+ if fn in ('S', 'S2', 'SII'):
817
+ return 'S'
818
+ # Red
819
+ if fn in ('R', 'RED', 'RD'):
820
+ return 'R'
821
+ # Green
822
+ if fn in ('G', 'GREEN', 'GRN'):
823
+ return 'G'
824
+ # Blue
825
+ if fn in ('B', 'BLUE', 'BL'):
826
+ return 'B'
827
+ # Luminance
828
+ if fn in ('L', 'LUM', 'LUMI', 'LUMINANCE'):
829
+ return 'L'
830
+
831
+ return None
832
+
833
+ # ── New helper: stack a single mono frame under filter key
834
+ def _stack_mono_channel(self, key, img, delta=None):
835
+ # img: 2D or 3D array; we convert to 2D mono always
836
+ mono = img if img.ndim==2 else np.mean(img,axis=2)
837
+ # align if you need (use same logic as color branch)
838
+ if hasattr(self, 'reference_image_2d'):
839
+ d = delta or StarRegistrationWorker.compute_affine_transform_astroalign(
840
+ mono, self.reference_image_2d)
841
+ if d is not None:
842
+ mono = StarRegistrationThread.apply_affine_transform_static(mono, d)
843
+ # normalize
844
+ norm = stretch_mono_image(mono, target_median=0.3)
845
+ # first frame?
846
+ if key not in self.filter_stacks:
847
+ self.filter_stacks[key] = norm.copy()
848
+ self.filter_counts[key] = 1
849
+ # set reference on first good channel frame
850
+ if not hasattr(self, 'reference_image_2d'):
851
+ self.reference_image_2d = norm.copy()
852
+ else:
853
+ cnt = self.filter_counts[key]
854
+ self.filter_stacks[key] = (cnt/self.filter_counts[key]+1)*self.filter_stacks[key] \
855
+ + (1.0/(cnt+1))*norm
856
+ self.filter_counts[key] += 1
857
+
858
+ # ── New helper: build an RGB preview from whatever channels we have
859
+ def _build_color_composite(self):
860
+ """
861
+ Composite filters into an RGB preview according to self.narrowband_mapping:
862
+
863
+ • "Natural":
864
+ – If SII present:
865
+ R = 0.5*(Ha + SII)
866
+ G = 0.5*(SII + OIII)
867
+ B = OIII
868
+ – Elif any R/G/B loaded:
869
+ R = R_filter
870
+ G = G_filter + OIII
871
+ B = B_filter + OIII
872
+ – Else (no SII, no R/G/B):
873
+ R = Ha
874
+ G = OIII
875
+ B = OIII
876
+
877
+ • Any 3-letter code (e.g. "SHO", "OHS"):
878
+ R = filter_stacks[mapping[0]]
879
+ G = filter_stacks[mapping[1]]
880
+ B = filter_stacks[mapping[2]]
881
+
882
+ Missing channels default to zero.
883
+ """
884
+ # 1) Determine H, W
885
+ if self.filter_stacks:
886
+ first = next(iter(self.filter_stacks.values()))
887
+ H, W = first.shape
888
+ elif getattr(self, 'current_stack', None) is not None:
889
+ H, W = self.current_stack.shape[:2]
890
+ else:
891
+ return None
892
+
893
+ # helper: get stack or zeros
894
+ def getf(k):
895
+ return self.filter_stacks.get(k, np.zeros((H, W), np.float32))
896
+
897
+ mode = self.narrowband_mapping.upper()
898
+ if mode == "NATURAL":
899
+ Ha = getf('H')
900
+ O3 = getf('O')
901
+ S2 = self.filter_stacks.get('S', None)
902
+ Rf = self.filter_stacks.get('R', None)
903
+ Gf = self.filter_stacks.get('G', None)
904
+ Bf = self.filter_stacks.get('B', None)
905
+
906
+ if S2 is not None:
907
+ # narrowband SII branch
908
+ R = 0.5 * (Ha + S2)
909
+ G = 0.5 * (S2 + O3)
910
+ B = O3.copy()
911
+
912
+ elif any(x is not None for x in (Rf, Gf, Bf)):
913
+ # broadband branch: Rf/Gf/Bf with OIII boost
914
+ R = Rf if Rf is not None else np.zeros((H, W), np.float32)
915
+ G = (Gf if Gf is not None else np.zeros((H, W), np.float32)) + O3
916
+ B = (Bf if Bf is not None else np.zeros((H, W), np.float32)) + O3
917
+
918
+ else:
919
+ # fallback HOO
920
+ R = Ha
921
+ G = O3
922
+ B = O3
923
+
924
+ else:
925
+ # direct mapping: e.g. "SHO" → R=S, G=H, B=O
926
+ letters = list(mode)
927
+ if len(letters) != 3 or any(l not in ("S","H","O") for l in letters):
928
+ # invalid code → fallback to natural
929
+ return self._build_color_composite.__wrapped__(self)
930
+
931
+ R = getf(letters[0])
932
+ G = getf(letters[1])
933
+ B = getf(letters[2])
934
+
935
+ return np.stack([R, G, B], axis=2)
936
+
937
+
938
+ def select_folder(self):
939
+ folder = QFileDialog.getExistingDirectory(self, "Select Folder to Watch")
940
+ if folder:
941
+ self.watch_folder = folder
942
+ self.folder_label.setText(f"Folder: {os.path.basename(folder)}")
943
+
944
+ def load_masters(self):
945
+ """
946
+ When the user picks “Load Master Dark…” or “Load Master Flat…”, we load exactly one file
947
+ (the first in the dialog). We simply store it in `self.master_dark` or `self.master_flat`,
948
+ but we also check its dimensions against any existing master so that the user can’t load
949
+ a 2D flat while the dark is 3D (for example).
950
+ """
951
+ sender = self.sender()
952
+ dlg = QFileDialog(self, "Select Master Files",
953
+ filter="FITS TIFF or XISF (*.fit *.fits *.tif *.tiff *.xisf)")
954
+ dlg.setFileMode(QFileDialog.FileMode.ExistingFiles)
955
+ if not dlg.exec():
956
+ return
957
+
958
+ chosen = dlg.selectedFiles()[0]
959
+ img, hdr, bit_depth, is_mono = load_image(chosen)
960
+ if img is None:
961
+ QMessageBox.warning(self, "Load Error",
962
+ f"Failed to load master file:\n{chosen}")
963
+ return
964
+
965
+ # Convert everything to float32 for consistency
966
+ img = img.astype(np.float32)
967
+
968
+ if "Dark" in sender.text():
969
+ # If a flat is already loaded, ensure shape‐compatibility
970
+ if self.master_flat is not None:
971
+ if not self._shapes_compatible(master=img, other=self.master_flat):
972
+ QMessageBox.warning(
973
+ self, "Shape Mismatch",
974
+ "Cannot load this master dark: it has incompatible shape "
975
+ "vs. the already‐loaded master flat."
976
+ )
977
+ return
978
+
979
+ self.master_dark = img
980
+ self.dark_status_label.setText("Dark: ✅")
981
+ self.dark_status_label.setStyleSheet("color: #00cc66; font-weight: bold;")
982
+ QMessageBox.information(
983
+ self, "Master Dark Loaded",
984
+ f"Loaded master dark:\n{os.path.basename(chosen)}"
985
+ )
986
+ else:
987
+ # "Flat" was clicked
988
+ if self.master_dark is not None:
989
+ if not self._shapes_compatible(master=self.master_dark, other=img):
990
+ QMessageBox.warning(
991
+ self, "Shape Mismatch",
992
+ "Cannot load this master flat: it has incompatible shape "
993
+ "vs. the already‐loaded master dark."
994
+ )
995
+ return
996
+
997
+ self.master_flat = img
998
+ self.flat_status_label.setText("Flat: ✅")
999
+ self.flat_status_label.setStyleSheet("color: #00cc66; font-weight: bold;")
1000
+ QMessageBox.information(
1001
+ self, "Master Flat Loaded",
1002
+ f"Loaded master flat:\n{os.path.basename(chosen)}"
1003
+ )
1004
+
1005
+ def load_filter_flats(self):
1006
+ """
1007
+ Let the user pick one or more flat files.
1008
+ We try to read the FITS header FILTER key to decide which filter
1009
+ each flat belongs to; otherwise fall back to the filename.
1010
+ """
1011
+ dlg = QFileDialog(self, "Select Filter Flats",
1012
+ filter="FITS or TIFF (*.fit *.fits *.tif *.tiff)")
1013
+ dlg.setFileMode(QFileDialog.FileMode.ExistingFiles)
1014
+ if not dlg.exec():
1015
+ return
1016
+
1017
+ files = dlg.selectedFiles()
1018
+ loaded = []
1019
+ for path in files:
1020
+ img, hdr, bit_depth, is_mono = load_image(path)
1021
+ if img is None:
1022
+ continue
1023
+ # guess filter key from header, else from filename
1024
+ key = None
1025
+ if hdr and hdr.get("FILTER"):
1026
+ key = self._get_filter_key(hdr)
1027
+ if not key:
1028
+ # fallback: basename before extension
1029
+ key = os.path.splitext(os.path.basename(path))[0]
1030
+
1031
+ # store it
1032
+ self.master_flats[key] = img.astype(np.float32)
1033
+ loaded.append(key)
1034
+
1035
+ # update the flat status label to list loaded filters
1036
+ if loaded:
1037
+ names = ", ".join(loaded)
1038
+ self.flat_status_label.setText(f"Flats: {names}")
1039
+ self.flat_status_label.setStyleSheet("color: #00cc66; font-weight: bold;")
1040
+ QMessageBox.information(
1041
+ self, "Filter Flats Loaded",
1042
+ f"Loaded flats for filters: {names}"
1043
+ )
1044
+ else:
1045
+ QMessageBox.warning(self, "No Flats Loaded",
1046
+ "No flats could be loaded.")
1047
+
1048
+ def _shapes_compatible(self, master: np.ndarray, other: np.ndarray) -> bool:
1049
+ """
1050
+ Return True if `master` and `other` can be used together in calibration:
1051
+ - Exactly the same shape, OR
1052
+ - master is 2D (H×W) and other is 3D (H×W×3), OR
1053
+ - vice versa.
1054
+ """
1055
+ if master.shape == other.shape:
1056
+ return True
1057
+
1058
+ # If one is 2D and the other is H×W×3, check the first two dims
1059
+ if master.ndim == 2 and other.ndim == 3 and other.shape[:2] == master.shape:
1060
+ return True
1061
+ if other.ndim == 2 and master.ndim == 3 and master.shape[:2] == other.shape:
1062
+ return True
1063
+
1064
+ return False
1065
+
1066
+ def _average_images(self, paths):
1067
+ # stub: load each via load_image(), convert to float32, accumulate & divide
1068
+ return None
1069
+
1070
+ def _normalized_average(self, paths):
1071
+ # stub: load each, divide by its mean, average them, then renormalize
1072
+ return None
1073
+
1074
+ def start_and_process(self):
1075
+ """Process everything currently in folder, then begin monitoring."""
1076
+ if not self.watch_folder:
1077
+ self.status_label.setText("❗ No folder selected")
1078
+ return
1079
+ # Clear any old record so existing files are re-processed
1080
+ self.processed_files.clear()
1081
+ # Process all current files once
1082
+ self.check_for_new_frames()
1083
+ # Now start monitoring
1084
+ self.is_running = True
1085
+ self.poll_timer.start()
1086
+ self.status_label.setText(f"▶ Processing & Monitoring: {os.path.basename(self.watch_folder)}")
1087
+
1088
+ def start_monitor_only(self):
1089
+ """Mark existing files as seen and only process new arrivals."""
1090
+ if not self.watch_folder:
1091
+ self.status_label.setText("❗ No folder selected")
1092
+ return
1093
+ # Populate processed_files with all existing files so they won't be re-processed
1094
+ exts = (
1095
+ "*.fit", "*.fits", "*.tif", "*.tiff",
1096
+ "*.cr2", "*.cr3", "*.nef", "*.arw",
1097
+ "*.dng", "*.raf", "*.orf", "*.rw2", "*.pef", "*.xisf", "*.png", "*.jpg", "*.jpeg"
1098
+ )
1099
+ all_paths = []
1100
+ for ext in exts:
1101
+ all_paths += glob.glob(os.path.join(self.watch_folder, "**", ext), recursive=True)
1102
+ self.processed_files = set(all_paths)
1103
+
1104
+ # Start monitoring
1105
+ self.is_running = True
1106
+ self.poll_timer.start()
1107
+ self.status_label.setText(f"▶ Monitoring Only: {os.path.basename(self.watch_folder)}")
1108
+
1109
+ def start_live(self):
1110
+ if not self.watch_folder:
1111
+ self.status_label.setText("❗ No folder selected")
1112
+ return
1113
+ self.is_running = True
1114
+ self.poll_timer.start()
1115
+ self.status_label.setText(f"▶ Monitoring: {os.path.basename(self.watch_folder)}")
1116
+ self.mode_label.setText("Mode: Linear Average")
1117
+
1118
+ def stop_live(self):
1119
+ if self.is_running:
1120
+ self.is_running = False
1121
+ self.poll_timer.stop()
1122
+ self.status_label.setText("■ Stopped")
1123
+ else:
1124
+ self.status_label.setText("■ Already stopped")
1125
+
1126
+ def reset_live(self):
1127
+ if self.is_running:
1128
+ self.is_running = False
1129
+ self.poll_timer.stop()
1130
+ self.status_label.setText("■ Stopped")
1131
+ else:
1132
+ self.status_label.setText("■ Already stopped")
1133
+
1134
+ # Clear all state
1135
+ self.processed_files.clear()
1136
+ self.frame_count = 0
1137
+ self.current_stack = None
1138
+
1139
+ self.total_exposure = 0.0
1140
+ self.exposure_label.setText("Total Exp: 00:00:00")
1141
+
1142
+ self.filter_stacks.clear()
1143
+ self.filter_counts.clear()
1144
+ self.filter_buffers.clear()
1145
+ self.filter_mus.clear()
1146
+ self.filter_m2s.clear()
1147
+
1148
+ if hasattr(self, 'reference_image_2d'):
1149
+ del self.reference_image_2d
1150
+
1151
+ # Re-initialize bootstrapping stats
1152
+ self._buffer = []
1153
+ self._mu = None
1154
+ self._m2 = None
1155
+
1156
+ # NEW: clear the metrics panel
1157
+ self.metrics_window.metrics_panel.clear_all()
1158
+
1159
+ # Update labels
1160
+ self.frame_count_label.setText("Frames: 0")
1161
+ self.status_label.setText("↺ Reset")
1162
+ self.mode_label.setText("Mode: Linear Average")
1163
+
1164
+ # Clear the displayed image
1165
+ self.pixmap_item.setPixmap(QPixmap())
1166
+
1167
+ # Reset zoom/pan fit flag
1168
+ self._did_initial_fit = False
1169
+ #self.master_dark = None
1170
+ #self.master_flat = None
1171
+ #self.dark_status_label.setText("Dark: ❌")
1172
+ #self.flat_status_label.setText("Flat: ❌")
1173
+ #self.dark_status_label.setStyleSheet("color: #cccccc; font-weight: bold;")
1174
+ #self.flat_status_label.setStyleSheet("color: #cccccc; font-weight: bold;")
1175
+
1176
+
1177
+ def _update_probe(self, path: str) -> dict:
1178
+ """Update probe info (size, mtime) for path and return the info dict."""
1179
+ try:
1180
+ st = os.stat(path)
1181
+ except FileNotFoundError:
1182
+ # file disappeared; clear any probe info
1183
+ self._probe.pop(path, None)
1184
+ return None
1185
+ now = time.time()
1186
+ size, mtime = st.st_size, st.st_mtime
1187
+
1188
+ info = self._probe.get(path)
1189
+ if info is None:
1190
+ info = {"size": size, "mtime": mtime, "since": now, "penalty_until": 0.0}
1191
+ self._probe[path] = info
1192
+ return info
1193
+
1194
+ # If size or mtime changed, reset stability timer
1195
+ if size != info["size"] or mtime != info["mtime"]:
1196
+ info["size"] = size
1197
+ info["mtime"] = mtime
1198
+ info["since"] = now
1199
+ return info
1200
+
1201
+ def _can_open_for_read(self, path: str) -> bool:
1202
+ """
1203
+ Try a tiny open+read to ensure the writer has released the handle.
1204
+ If we hit PermissionError / OSError, we mark a penalty and say 'not ready'.
1205
+ """
1206
+ try:
1207
+ with open(path, "rb") as f:
1208
+ _ = f.read(1)
1209
+ return True
1210
+ except (PermissionError, OSError):
1211
+ # mark a penalty window so we don't hammer the file immediately
1212
+ info = self._probe.get(path) or self._update_probe(path)
1213
+ if info:
1214
+ info["penalty_until"] = time.time() + self.OPEN_RETRY_PENALTY_SECS
1215
+ return False
1216
+
1217
+ def _file_ready(self, path: str) -> bool:
1218
+ """
1219
+ A file is 'ready' when:
1220
+ - we are not inside a penalty window,
1221
+ - size+mtime have been unchanged for FILE_STABLE_SECS,
1222
+ - and we can actually open it for reading.
1223
+ """
1224
+ info = self._update_probe(path)
1225
+ if info is None:
1226
+ return False # missing
1227
+
1228
+ now = time.time()
1229
+ if now < info.get("penalty_until", 0.0):
1230
+ return False
1231
+
1232
+ # Require size+mtime to be unchanged for FILE_STABLE_SECS
1233
+ if (now - info["since"]) < self.FILE_STABLE_SECS:
1234
+ return False
1235
+
1236
+ # Finally confirm we can open the file (this also sets penalty if it fails)
1237
+ return self._can_open_for_read(path)
1238
+
1239
+
1240
+ def check_for_new_frames(self):
1241
+ if not self.is_running or not self.watch_folder:
1242
+ return
1243
+
1244
+ # Gather candidates
1245
+ exts = (
1246
+ "*.fit", "*.fits", "*.tif", "*.tiff",
1247
+ "*.cr2", "*.cr3", "*.nef", "*.arw",
1248
+ "*.dng", "*.raf", "*.orf", "*.rw2", "*.pef", "*.xisf",
1249
+ "*.png", "*.jpg", "*.jpeg"
1250
+ )
1251
+ all_paths = []
1252
+ for ext in exts:
1253
+ all_paths += glob.glob(os.path.join(self.watch_folder, '**', ext), recursive=True)
1254
+
1255
+ # Only consider paths not yet processed
1256
+ candidates = [p for p in sorted(all_paths) if p not in self.processed_files]
1257
+ if not candidates:
1258
+ return
1259
+
1260
+ # Show first new file name (status only)
1261
+ self.status_label.setText(f"➜ New/updated files: {len(candidates)}")
1262
+ QApplication.processEvents()
1263
+
1264
+ # Probe each candidate: only process when 'ready'
1265
+ processed_now = 0
1266
+ for path in candidates:
1267
+ # Skip if we recently penalized this path
1268
+ info = self._probe.get(path)
1269
+ if info and time.time() < info.get("penalty_until", 0.0):
1270
+ continue
1271
+
1272
+ # Check readiness: stable size/mtime and can open-for-read
1273
+ if not self._file_ready(path):
1274
+ continue # not yet ready; we'll see it again on the next tick
1275
+
1276
+ # Only *now* do we mark as processed and actually process the frame
1277
+ self.processed_files.add(path)
1278
+ base = os.path.basename(path)
1279
+ self.status_label.setText(f"→ Processing: {base}")
1280
+ QApplication.processEvents()
1281
+
1282
+ try:
1283
+ self.process_frame(path)
1284
+ processed_now += 1
1285
+ except Exception as e:
1286
+ # If anything unexpected happens, clear 'processed' so we can retry later
1287
+ # but add a penalty to avoid tight loops.
1288
+ self.processed_files.discard(path)
1289
+ info = self._probe.get(path) or self._update_probe(path)
1290
+ if info:
1291
+ info["penalty_until"] = time.time() + self.OPEN_RETRY_PENALTY_SECS
1292
+ self.status_label.setText(f"⚠ Error on {base}: {e}")
1293
+ QApplication.processEvents()
1294
+
1295
+ if processed_now > 0:
1296
+ self.status_label.setText(f"✔ Processed {processed_now} file(s)")
1297
+ QApplication.processEvents()
1298
+
1299
+ def process_frame(self, path):
1300
+ if not self._file_ready(path):
1301
+ # do not mark as processed here; monitor will retry after cool-down
1302
+ return
1303
+
1304
+ # if star-trail mode is on, bypass the normal pipeline entirely:
1305
+ if self.star_trail_mode:
1306
+ return self._process_star_trail(path)
1307
+
1308
+ # 1) Load
1309
+ # ─── 1) RAW‐file check ────────────────────────────────────────────
1310
+ lower = path.lower()
1311
+ raw_exts = ('.cr2', '.cr3', '.nef', '.arw', '.dng', '.raf', '.orf', '.rw2', '.pef')
1312
+ if lower.endswith(raw_exts):
1313
+ # Attempt to decode using rawpy
1314
+ try:
1315
+ with rawpy.imread(path) as raw:
1316
+ # Postprocess into an 8‐bit RGB array
1317
+ # (you could tweak postprocess params if desired)
1318
+ img_rgb8 = raw.postprocess(
1319
+ use_camera_wb=True,
1320
+ no_auto_bright=True,
1321
+ output_bps=16
1322
+ ) # shape (H, W, 3), dtype=uint8
1323
+
1324
+ # Convert to float32 [0..1] so it matches load_image() behavior
1325
+ img = img_rgb8.astype(np.float32) / 65535.0
1326
+
1327
+ # Build a minimal FITS header and attempt to extract EXIF tags
1328
+ header = fits.Header()
1329
+ header["SIMPLE"] = True
1330
+ header["BITPIX"] = 16
1331
+ header["CREATOR"] = "LiveStack(RAW)"
1332
+ header["IMAGETYP"] = "RAW"
1333
+ # Default EXPTIME/ISO/DATE-OBS in case EXIF fails
1334
+ header["EXPTIME"] = "Unknown"
1335
+ header["ISO"] = "Unknown"
1336
+ header["DATE-OBS"] = "Unknown"
1337
+
1338
+ try:
1339
+ with open(path, 'rb') as f:
1340
+ tags = exifread.process_file(f, details=False)
1341
+ # EXIF: ExposureTime
1342
+ exp_tag = tags.get("EXIF ExposureTime") or tags.get("EXIF ShutterSpeedValue")
1343
+ if exp_tag:
1344
+ exp_str = str(exp_tag.values)
1345
+ if '/' in exp_str:
1346
+ top, bot = exp_str.split('/', 1)
1347
+ header["EXPTIME"] = (float(top)/float(bot), "Exposure Time (s)")
1348
+ else:
1349
+ header["EXPTIME"] = (float(exp_str), "Exposure Time (s)")
1350
+ # ISO
1351
+ iso_tag = tags.get("EXIF ISOSpeedRatings")
1352
+ if iso_tag:
1353
+ header["ISO"] = str(iso_tag.values)
1354
+ # Date/time original
1355
+ date_obs = tags.get("EXIF DateTimeOriginal")
1356
+ if date_obs:
1357
+ header["DATE-OBS"] = str(date_obs.values)
1358
+ except Exception:
1359
+ # If EXIF parsing fails, just leave defaults
1360
+ pass
1361
+
1362
+ bit_depth = 16
1363
+ is_mono = False
1364
+
1365
+ except Exception as e:
1366
+ # If rawpy fails, bail out early
1367
+ self.status_label.setText(f"⚠ Failed to decode RAW: {os.path.basename(path)}")
1368
+ QApplication.processEvents()
1369
+ return
1370
+
1371
+ else:
1372
+ # ─── 2) Not RAW → call your existing load_image()
1373
+ img, header, bit_depth, is_mono = load_image(path)
1374
+ if img is None:
1375
+ self.status_label.setText(f"⚠ Failed to load {os.path.basename(path)}")
1376
+ QApplication.processEvents()
1377
+ return
1378
+
1379
+ # ——— 2) CALIBRATION (once) ————————————————————————
1380
+ # ——— 2a) DETECT MONO→COLOR MODE ————————————————————
1381
+ mono_key = None
1382
+ if self.mono_color_mode and is_mono and header.get('FILTER') and 'BAYERPAT' not in header:
1383
+ mono_key = self._get_filter_key(header)
1384
+
1385
+ # ——— 2b) CALIBRATION (once) ————————————————————————
1386
+ if self.master_dark is not None:
1387
+ img = img.astype(np.float32) - self.master_dark
1388
+ # prefer per-filter flat if we’re in mono→color and have one
1389
+ if mono_key and mono_key in self.master_flats:
1390
+ img = apply_flat_division_numba(img, self.master_flats[mono_key])
1391
+ elif self.master_flat is not None:
1392
+ img = apply_flat_division_numba(img, self.master_flat)
1393
+
1394
+ # ——— 3) DEBAYER if BAYERPAT ——————————————————————
1395
+ if is_mono and header.get('BAYERPAT'):
1396
+ pat = header['BAYERPAT'][0] if isinstance(header['BAYERPAT'], tuple) else header['BAYERPAT']
1397
+ img = debayer_fits_fast(img, pat)
1398
+ is_mono = False
1399
+
1400
+ # ——— 5) PROMOTION TO 3-CHANNEL if NOT in mono-mode —————
1401
+ if mono_key is None and img.ndim == 2:
1402
+ img = np.stack([img, img, img], axis=2)
1403
+
1404
+ # ——— 6) BUILD PLANE for alignment & metrics —————————
1405
+ plane = img if (mono_key and img.ndim == 2) else np.mean(img, axis=2)
1406
+
1407
+ # ——— 7) ALIGN to reference_image_2d ——————————————————
1408
+ if hasattr(self, 'reference_image_2d'):
1409
+ delta = StarRegistrationWorker.compute_affine_transform_astroalign(
1410
+ plane, self.reference_image_2d
1411
+ )
1412
+ if delta is None:
1413
+ delta = IDENTITY_2x3
1414
+ # apply to full img (if color) and to plane
1415
+ if mono_key is None:
1416
+ img = StarRegistrationThread.apply_affine_transform_static(img, delta)
1417
+ plane = StarRegistrationThread.apply_affine_transform_static(
1418
+ plane if plane.ndim == 2 else plane[:, :, None], delta
1419
+ ).squeeze()
1420
+
1421
+ # ——— 8) NORMALIZE —————————————————————————————
1422
+ if mono_key:
1423
+ norm_plane = stretch_mono_image(plane, target_median=0.3)
1424
+ norm_color = None
1425
+ else:
1426
+ norm_color = stretch_color_image(img, target_median=0.3, linked=False)
1427
+ norm_plane = np.mean(norm_color, axis=2)
1428
+
1429
+ # ——— 9) METRICS & SNR —————————————————————————
1430
+ sc, fwhm, ecc = compute_frame_star_metrics(norm_plane)
1431
+ # instead, use the cumulative stack (or composite) for SNR:
1432
+ if mono_key:
1433
+ # once we have any filter_stacks, build the composite;
1434
+ # fall back to this frame’s plane if it’s the first one
1435
+ if self.filter_stacks:
1436
+ stack_img = self._build_color_composite()
1437
+ else:
1438
+ stack_img = norm_plane
1439
+ else:
1440
+ # for color‐only, use the running‐average stack once it exists,
1441
+ # else fall back to this frame’s normalized color
1442
+ if self.current_stack is not None:
1443
+ stack_img = self.current_stack
1444
+ else:
1445
+ stack_img = norm_color
1446
+ snr_val = estimate_global_snr(stack_img)
1447
+
1448
+ # ——— 10) CULLING? ————————————————————————————
1449
+ flagged = (
1450
+ (fwhm > self.max_fwhm) or
1451
+ (ecc > self.max_ecc) or
1452
+ (sc < self.min_star_count)
1453
+ )
1454
+ if flagged:
1455
+ self._cull_frame(path)
1456
+ self.metrics_window.metrics_panel.add_point(
1457
+ self.frame_count + 1, fwhm, ecc, sc, snr_val, True
1458
+ )
1459
+ return
1460
+
1461
+ # ─── 11) FIRST-FRAME INITIALIZATION ──────────────────────────────
1462
+ if self.frame_count == 0:
1463
+ # set reference on the very first good frame
1464
+ self.reference_image_2d = norm_plane.copy()
1465
+ self.frame_count = 1
1466
+ self.frame_count_label.setText("Frames: 1")
1467
+ # always start in linear‐average mode
1468
+ if mono_key:
1469
+ self.mode_label.setText(f"Mode: Linear Average ({mono_key})")
1470
+ self.status_label.setText(f"Started {mono_key}-filter linear stack")
1471
+ else:
1472
+ self.mode_label.setText("Mode: Linear Average")
1473
+ self.status_label.setText("Started linear stack")
1474
+ QApplication.processEvents()
1475
+
1476
+ if mono_key:
1477
+ # start the filter stack
1478
+ self.filter_stacks[mono_key] = norm_plane.copy()
1479
+ self.filter_counts[mono_key] = 1
1480
+ self.filter_buffers[mono_key] = [norm_plane.copy()]
1481
+ else:
1482
+ # start the normal running stack
1483
+ self.current_stack = norm_color.copy()
1484
+ self._buffer = [norm_color.copy()]
1485
+ # ─── accumulate exposure ─────────────────────
1486
+ exp_val = header.get("EXPOSURE", header.get("EXPTIME", None))
1487
+ if exp_val is not None:
1488
+ try:
1489
+ secs = float(exp_val)
1490
+ self.total_exposure += secs
1491
+ hrs = int(self.total_exposure // 3600)
1492
+ mins = int((self.total_exposure % 3600) // 60)
1493
+ secs_rem = int(self.total_exposure % 60)
1494
+ self.exposure_label.setText(
1495
+ f"Total Exp: {hrs:02d}:{mins:02d}:{secs_rem:02d}"
1496
+ )
1497
+ except Exception:
1498
+ pass # Ignore exposure parsing errors
1499
+ QApplication.processEvents()
1500
+
1501
+
1502
+ else:
1503
+ # ─── 12) RUNNING–AVERAGE or CLIP-σ UPDATE ────────────────────
1504
+ if mono_key is None:
1505
+ # — Color-only stacking —
1506
+ if self.frame_count < self.bootstrap_frames:
1507
+ # 12a) Linear bootstrap
1508
+ n = self.frame_count + 1
1509
+ self.current_stack = (
1510
+ (self.frame_count / n) * self.current_stack
1511
+ + (1.0 / n) * norm_color
1512
+ )
1513
+ self._buffer.append(norm_color.copy())
1514
+
1515
+ # hit the bootstrap threshold?
1516
+ if n == self.bootstrap_frames:
1517
+ # init Welford stats
1518
+ buf = np.stack(self._buffer, axis=0)
1519
+ self._mu = np.mean(buf, axis=0)
1520
+ diffs = buf - self._mu[np.newaxis, ...]
1521
+ self._m2 = np.sum(diffs * diffs, axis=0)
1522
+ self._buffer = None
1523
+
1524
+ # switch to clipping mode
1525
+ self.mode_label.setText("Mode: μ-σ Clipping Average")
1526
+ self.status_label.setText("Switched to μ–σ clipping (color)")
1527
+ QApplication.processEvents()
1528
+ else:
1529
+ # still linear
1530
+ self.mode_label.setText("Mode: Linear Average")
1531
+ self.status_label.setText(f"Processed color frame #{n} (linear)")
1532
+ QApplication.processEvents()
1533
+ else:
1534
+ # 12b) μ–σ clipping
1535
+ sigma = np.sqrt(self._m2 / (self.frame_count - 1))
1536
+ mask = np.abs(norm_color - self._mu) <= (self.clip_threshold * sigma)
1537
+ clipped = np.where(mask, norm_color, self._mu)
1538
+
1539
+ n = self.frame_count + 1
1540
+ self.current_stack = (
1541
+ (self.frame_count / n) * self.current_stack
1542
+ + (1.0 / n) * clipped
1543
+ )
1544
+
1545
+ # Welford update
1546
+ delta_mu = clipped - self._mu
1547
+ self._mu += delta_mu / n
1548
+ delta2 = clipped - self._mu
1549
+ self._m2 += delta_mu * delta2
1550
+
1551
+ # stay in clipping mode
1552
+ self.mode_label.setText("Mode: μ-σ Clipping Average")
1553
+ self.status_label.setText(f"Processed color frame #{n} (clipped)")
1554
+ QApplication.processEvents()
1555
+
1556
+ # bump global frame count
1557
+ self.frame_count = n
1558
+ # ─── accumulate exposure ─────────────────────
1559
+ exp_val = header.get("EXPOSURE", header.get("EXPTIME", None))
1560
+ if exp_val is not None:
1561
+ try:
1562
+ secs = float(exp_val)
1563
+ self.total_exposure += secs
1564
+ hrs = int(self.total_exposure // 3600)
1565
+ mins = int((self.total_exposure % 3600) // 60)
1566
+ secs_rem = int(self.total_exposure % 60)
1567
+ self.exposure_label.setText(
1568
+ f"Total Exp: {hrs:02d}:{mins:02d}:{secs_rem:02d}"
1569
+ )
1570
+ except Exception:
1571
+ pass # Ignore exposure parsing errors
1572
+ QApplication.processEvents()
1573
+
1574
+
1575
+ else:
1576
+ # — Mono→color (per-filter) stacking —
1577
+ count = self.filter_counts.get(mono_key, 0)
1578
+ buf = self.filter_buffers.setdefault(mono_key, [])
1579
+
1580
+ if count < self.bootstrap_frames:
1581
+ # 12c) Linear bootstrap per-filter
1582
+ new_count = count + 1
1583
+ if count == 0:
1584
+ self.filter_stacks[mono_key] = norm_plane.copy()
1585
+ else:
1586
+ self.filter_stacks[mono_key] = (
1587
+ (count / new_count) * self.filter_stacks[mono_key]
1588
+ + (1.0 / new_count) * norm_plane
1589
+ )
1590
+ buf.append(norm_plane.copy())
1591
+ self.filter_counts[mono_key] = new_count
1592
+
1593
+ if new_count == self.bootstrap_frames:
1594
+ # init Welford
1595
+ stacked = np.stack(buf, axis=0)
1596
+ mu = np.mean(stacked, axis=0)
1597
+ diffs = stacked - mu[np.newaxis, ...]
1598
+ m2 = np.sum(diffs * diffs, axis=0)
1599
+ self.filter_mus[mono_key] = mu
1600
+ self.filter_m2s[mono_key] = m2
1601
+
1602
+ self.mode_label.setText(f"Mode: μ-σ Clipping Average ({mono_key})")
1603
+ self.status_label.setText(f"Switched to μ–σ clipping ({mono_key})")
1604
+ QApplication.processEvents()
1605
+ else:
1606
+ # still linear
1607
+ self.mode_label.setText(f"Mode: Linear Average ({mono_key})")
1608
+ self.status_label.setText(
1609
+ f"Processed {mono_key}-filter frame #{new_count} (linear)"
1610
+ )
1611
+ QApplication.processEvents()
1612
+
1613
+ else:
1614
+ # 12d) μ–σ clipping per-filter
1615
+ mu = self.filter_mus[mono_key]
1616
+ m2 = self.filter_m2s[mono_key]
1617
+ sigma = np.sqrt(m2 / (count - 1))
1618
+ mask = np.abs(norm_plane - mu) <= (self.clip_threshold * sigma)
1619
+ clipped = np.where(mask, norm_plane, mu)
1620
+
1621
+ new_count = count + 1
1622
+ self.filter_stacks[mono_key] = (
1623
+ (count / new_count) * self.filter_stacks[mono_key]
1624
+ + (1.0 / new_count) * clipped
1625
+ )
1626
+
1627
+ # Welford update on µ and m2
1628
+ delta = clipped - mu
1629
+ new_mu = mu + delta / new_count
1630
+ delta2 = clipped - new_mu
1631
+ new_m2 = m2 + delta * delta2
1632
+ self.filter_mus[mono_key] = new_mu
1633
+ self.filter_m2s[mono_key] = new_m2
1634
+ self.filter_counts[mono_key] = new_count
1635
+
1636
+ self.mode_label.setText(f"Mode: μ-σ Clipping Average ({mono_key})")
1637
+ self.status_label.setText(
1638
+ f"Processed {mono_key}-filter frame #{new_count} (clipped)"
1639
+ )
1640
+ QApplication.processEvents()
1641
+
1642
+ # bump global frame count
1643
+ self.frame_count += 1
1644
+ self.frame_count_label.setText(f"Frames: {self.frame_count}")
1645
+ # ─── accumulate exposure ─────────────────────
1646
+ exp_val = header.get("EXPOSURE", header.get("EXPTIME", None))
1647
+ if exp_val is not None:
1648
+ try:
1649
+ secs = float(exp_val)
1650
+ self.total_exposure += secs
1651
+ hrs = int(self.total_exposure // 3600)
1652
+ mins = int((self.total_exposure % 3600) // 60)
1653
+ secs_rem = int(self.total_exposure % 60)
1654
+ self.exposure_label.setText(
1655
+ f"Total Exp: {hrs:02d}:{mins:02d}:{secs_rem:02d}"
1656
+ )
1657
+ except Exception:
1658
+ pass # Ignore exposure parsing errors
1659
+ QApplication.processEvents()
1660
+
1661
+ # ─── 13) Update UI ─────────────────────────────────────────
1662
+ self.frame_count_label.setText(f"Frames: {self.frame_count}")
1663
+ QApplication.processEvents()
1664
+
1665
+ # ——— 13) METRICS PANEL for good frame —————————————
1666
+ self.metrics_window.metrics_panel.add_point(
1667
+ self.frame_count, fwhm, ecc, sc, snr_val, False
1668
+ )
1669
+
1670
+ # ——— 14) PREVIEW & STATUS LABEL —————————————————————
1671
+ if mono_key:
1672
+ preview = self._build_color_composite()
1673
+ self.status_label.setText(f"Stacked {mono_key}-filter frame {os.path.basename(path)}")
1674
+ QApplication.processEvents()
1675
+ else:
1676
+ preview = self.current_stack
1677
+ self.status_label.setText(f"✔ processed {os.path.basename(path)}")
1678
+ QApplication.processEvents()
1679
+
1680
+ self.update_preview(preview)
1681
+ QApplication.processEvents()
1682
+
1683
+ def _process_star_trail(self, path: str):
1684
+ """
1685
+ Load/calibrate a single frame (RAW or FITS/TIFF), debayer if needed,
1686
+ normalize, then build a max‐value “star trail” in self.current_stack.
1687
+ """
1688
+ # ─── 1) Load (RAW vs FITS) ─────────────────────────────
1689
+ lower = path.lower()
1690
+ raw_exts = ('.cr2', '.cr3', '.nef', '.arw', '.dng', '.raf',
1691
+ '.orf', '.rw2', '.pef')
1692
+ if lower.endswith(raw_exts):
1693
+ try:
1694
+ with rawpy.imread(path) as raw:
1695
+ img_rgb8 = raw.postprocess(use_camera_wb=True,
1696
+ no_auto_bright=True,
1697
+ output_bps=16)
1698
+ img = img_rgb8.astype(np.float32) / 65535.0
1699
+ header = fits.Header()
1700
+ header["SIMPLE"] = True
1701
+ header["BITPIX"] = 16
1702
+ header["CREATOR"] = "LiveStack(RAW)"
1703
+ header["IMAGETYP"] = "RAW"
1704
+ header["EXPTIME"] = "Unknown"
1705
+ # attempt EXIF, same as process_frame…
1706
+ try:
1707
+ with open(path,'rb') as f:
1708
+ tags = exifread.process_file(f, details=False)
1709
+ exp_tag = tags.get("EXIF ExposureTime") \
1710
+ or tags.get("EXIF ShutterSpeedValue")
1711
+ if exp_tag:
1712
+ ev = str(exp_tag.values)
1713
+ if '/' in ev:
1714
+ n,d = ev.split('/',1)
1715
+ header["EXPTIME"] = (float(n)/float(d),
1716
+ "Exposure Time (s)")
1717
+ else:
1718
+ header["EXPTIME"] = (float(ev),
1719
+ "Exposure Time (s)")
1720
+ except Exception:
1721
+ pass # Ignore EXIF parsing errors
1722
+ bit_depth = 16
1723
+ is_mono = False
1724
+ except Exception:
1725
+ self.status_label.setText(
1726
+ f"⚠ Failed to decode RAW: {os.path.basename(path)}"
1727
+ )
1728
+ QApplication.processEvents()
1729
+ return
1730
+ else:
1731
+ # FITS / TIFF / XISF
1732
+ img, header, bit_depth, is_mono = load_image(path)
1733
+ if img is None:
1734
+ self.status_label.setText(
1735
+ f"⚠ Failed to load {os.path.basename(path)}"
1736
+ )
1737
+ QApplication.processEvents()
1738
+ return
1739
+
1740
+ # ─── 2) Calibration ─────────────────────────────────────
1741
+ mono_key = None
1742
+ if (self.mono_color_mode
1743
+ and is_mono
1744
+ and header.get('FILTER')
1745
+ and 'BAYERPAT' not in header):
1746
+ mono_key = self._get_filter_key(header)
1747
+
1748
+ if self.master_dark is not None:
1749
+ img = img.astype(np.float32) - self.master_dark
1750
+
1751
+ if mono_key and mono_key in self.master_flats:
1752
+ img = apply_flat_division_numba(img,
1753
+ self.master_flats[mono_key])
1754
+ elif self.master_flat is not None:
1755
+ img = apply_flat_division_numba(img,
1756
+ self.master_flat)
1757
+
1758
+ # ─── 3) Debayer ─────────────────────────────────────────
1759
+ if is_mono and header.get('BAYERPAT'):
1760
+ pat = (header['BAYERPAT'][0]
1761
+ if isinstance(header['BAYERPAT'], tuple)
1762
+ else header['BAYERPAT'])
1763
+ img = debayer_fits_fast(img, pat)
1764
+ is_mono = False
1765
+
1766
+ # ─── 4) Force 3-channel if still mono ───────────────────
1767
+ if not mono_key and img.ndim == 2:
1768
+ img = np.stack([img, img, img], axis=2)
1769
+
1770
+ # ─── 5) Normalize ───────────────────────────────────────
1771
+ # for star-trail we want a visible, stretched version:
1772
+ if img.ndim == 2:
1773
+ plane = stretch_mono_image(img, target_median=0.3)
1774
+ norm_color = np.stack([plane]*3, axis=2)
1775
+ else:
1776
+ norm_color = stretch_color_image(img,
1777
+ target_median=0.3,
1778
+ linked=False)
1779
+
1780
+ # ─── 6) Build max-value stack ───────────────────────────
1781
+ if self.frame_count == 0:
1782
+ self.current_stack = norm_color.copy()
1783
+ else:
1784
+ # elementwise max over all frames so far
1785
+ self.current_stack = np.maximum(self.current_stack,
1786
+ norm_color)
1787
+
1788
+ # ─── 7) Update counters and labels ──────────────────────
1789
+ self.frame_count += 1
1790
+ self.frame_count_label.setText(f"Frames: {self.frame_count}")
1791
+
1792
+ exp_val = header.get("EXPOSURE", header.get("EXPTIME", None))
1793
+ if exp_val is not None:
1794
+ try:
1795
+ secs = float(exp_val)
1796
+ self.total_exposure += secs
1797
+ h = int(self.total_exposure // 3600)
1798
+ m = int((self.total_exposure % 3600)//60)
1799
+ s = int(self.total_exposure % 60)
1800
+ self.exposure_label.setText(
1801
+ f"Total Exp: {h:02d}:{m:02d}:{s:02d}")
1802
+ except Exception:
1803
+ pass # Ignore exposure parsing errors
1804
+
1805
+ self.status_label.setText(
1806
+ f"★ Star-Trail frame {self.frame_count}: "
1807
+ f"{os.path.basename(path)}"
1808
+ )
1809
+ self.update_preview(self.current_stack)
1810
+ QApplication.processEvents()
1811
+
1812
+
1813
+
1814
+ def update_preview(self, array: np.ndarray):
1815
+ # 1) normalize, apply contrast/brightness
1816
+ arr = np.clip(array, 0.0, 1.0).astype(np.float32)
1817
+ pivot = 0.3
1818
+ arr = ((arr - pivot) * self.contrast + pivot) + self.brightness
1819
+ arr = np.clip(arr, 0.0, 1.0)
1820
+
1821
+ # 2) convert to uint8 and KEEP a reference on self
1822
+ self._last_frame_bytes = (arr * 255).astype(np.uint8)
1823
+ h, w = self._last_frame_bytes.shape[:2]
1824
+
1825
+ # 3) build QImage from the kept buffer
1826
+ if self._last_frame_bytes.ndim == 2:
1827
+ fmt = QImage.Format.Format_Grayscale8
1828
+ bytespp = w
1829
+ else:
1830
+ fmt = QImage.Format.Format_RGB888
1831
+ bytespp = 3 * w
1832
+ qimg = QImage(self._last_frame_bytes.data, w, h, bytespp, fmt)
1833
+
1834
+ # 4) update scene
1835
+ self.pixmap_item.setPixmap(QPixmap.fromImage(qimg))
1836
+ self.scene.setSceneRect(0, 0, w, h)
1837
+
1838
+ if not self._did_initial_fit:
1839
+ self.view.fitInView(self.scene.sceneRect(), Qt.AspectRatioMode.KeepAspectRatio)
1840
+ self._did_initial_fit = True
1841
+