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