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