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,136 @@
1
+ # pro/psf_utils.py
2
+ from __future__ import annotations
3
+ import numpy as np
4
+ import sep
5
+
6
+ EPS = 1e-6
7
+
8
+ def _to_luma(img: np.ndarray) -> np.ndarray:
9
+ if img.ndim == 2: return img.astype(np.float32, copy=False)
10
+ r, g, b = img[...,0], img[...,1], img[...,2]
11
+ return (0.2126*r + 0.7152*g + 0.0722*b).astype(np.float32)
12
+
13
+ def _cutout(arr, cy, cx, k):
14
+ H, W = arr.shape
15
+ y0 = int(round(cy)) - k//2
16
+ x0 = int(round(cx)) - k//2
17
+ y1, x1 = y0 + k, x0 + k
18
+ if y0 < 0 or x0 < 0 or y1 > H or x1 > W:
19
+ return None
20
+ return arr[y0:y1, x0:x1].astype(np.float32, copy=False)
21
+
22
+ def _subpixel_shift_to_center(patch: np.ndarray) -> np.ndarray:
23
+ """Shift brightest-peak to exact center using Fourier shift (sub-pixel)."""
24
+ from scipy.ndimage import fourier_shift
25
+ import numpy.fft as fft
26
+ k = patch.shape[0]
27
+ # peak location
28
+ yy, xx = np.unravel_index(np.argmax(patch), patch.shape)
29
+ cy = (k-1)/2
30
+ shift = (cy-yy, cy-xx)
31
+ F = fft.fftn(patch)
32
+ Fs = fourier_shift(F, shift)
33
+ out = fft.ifftn(Fs).real.astype(np.float32)
34
+ return out
35
+
36
+ def compute_psf_kernel_for_image(
37
+ image: np.ndarray,
38
+ *,
39
+ ksize: int | None = 21,
40
+ det_sigma: float = 6.0,
41
+ max_stars: int = 60,
42
+ max_ecc: float = 0.5,
43
+ min_flux: float = 0.0,
44
+ max_frac_saturation: float = 0.98, # was 0.8 → far too strict
45
+ return_info: bool = True # new: return (psf, info)
46
+ ) -> np.ndarray | tuple[np.ndarray, dict] | None:
47
+ """
48
+ Returns a normalized (ksize×ksize) PSF (or (psf, info) if return_info=True).
49
+ - SEP detects stars; rejects saturated/elongated/low-flux sources.
50
+ - Subpixel centers each cutout and median-combines.
51
+ - Auto-selects ksize if None, or downsizes when stars are very small.
52
+ """
53
+ if image is None:
54
+ return None
55
+ img = _to_luma(image)
56
+ info = {}
57
+
58
+ # Robust background & detection
59
+ bkg = sep.Background(img)
60
+ data = img - bkg.back()
61
+ try: err = bkg.globalrms
62
+ except Exception: err = float(np.median(bkg.rms()))
63
+ sources = sep.extract(data, det_sigma, err=err)
64
+ if sources is None or len(sources) == 0:
65
+ return None
66
+
67
+ # Star size & shape
68
+ a = np.array(sources["a"], dtype=np.float32) # SEP Gaussian sigma along major axis (≈ σ)
69
+ b = np.array(sources["b"], dtype=np.float32)
70
+ ecc = np.sqrt(1.0 - (b / np.maximum(a, 1e-9))**2)
71
+ flux = np.array(sources["flux"], dtype=np.float32)
72
+
73
+ # Estimate typical FWHM in px from 'a' (use median of central bulk)
74
+ good_a = a[np.isfinite(a) & (a > 0.5)]
75
+ if good_a.size:
76
+ sigma_med = float(np.median(good_a))
77
+ fwhm_med = 2.3548 * sigma_med
78
+ else:
79
+ sigma_med, fwhm_med = 1.2, 2.8 # fallback
80
+
81
+ # Auto kernel size if None or wildly big vs star size
82
+ if (ksize is None) or (ksize > int(6.0 * sigma_med) + 1):
83
+ ksize = int(2 * np.ceil(3.0 * sigma_med) + 1)
84
+ ksize = int(np.clip(ksize, 9, 25)) # clamp to practical window
85
+ k = int(ksize) | 1 # enforce odd
86
+ info.update({"ksize": k, "fwhm_med_px": fwhm_med})
87
+
88
+ # Filtering
89
+ idx = np.where(
90
+ (np.isfinite(a)) & (np.isfinite(b)) &
91
+ (a > 0.5) & (b > 0.5) &
92
+ (ecc <= max_ecc) &
93
+ (flux > min_flux)
94
+ )[0]
95
+ info["detected"] = int(len(sources))
96
+
97
+ if idx.size == 0:
98
+ return None
99
+
100
+ # Bright-ish first, cap
101
+ idx = idx[np.argsort(-flux[idx])]
102
+ idx = idx[:max_stars]
103
+
104
+ patches, rejected = [], 0
105
+ for i in idx:
106
+ cy, cx = float(sources["y"][i]), float(sources["x"][i])
107
+ patch = _cutout(data, cy, cx, k)
108
+ if patch is None:
109
+ rejected += 1; continue
110
+ peak = float(np.max(patch))
111
+ center = float(patch[k//2, k//2])
112
+ # reject *obvious* clipped cores only
113
+ if peak > 0 and (center / (peak + EPS)) >= max_frac_saturation:
114
+ rejected += 1; continue
115
+ try:
116
+ patch = _subpixel_shift_to_center(patch)
117
+ except Exception:
118
+ pass
119
+ s = float(np.sum(patch))
120
+ if s <= 0:
121
+ rejected += 1; continue
122
+ patches.append(patch / (s + EPS))
123
+
124
+ info["rejected"] = int(rejected)
125
+ info["used_stars"] = int(len(patches))
126
+
127
+ if not patches:
128
+ return None
129
+
130
+ psf = np.median(np.stack(patches, axis=0), axis=0).astype(np.float32, copy=False)
131
+ s = float(psf.sum())
132
+ if s <= 0:
133
+ return None
134
+ psf = psf / (s + EPS)
135
+ return (psf, info) if return_info else psf
136
+
@@ -0,0 +1,549 @@
1
+ # pro/psf_viewer.py
2
+ from __future__ import annotations
3
+
4
+ import numpy as np
5
+ import sep
6
+ from astropy.table import Table
7
+
8
+ from PyQt6.QtCore import Qt, QTimer
9
+ from PyQt6.QtGui import QPainter, QPen, QFont, QPixmap
10
+ from PyQt6.QtWidgets import (
11
+ QDialog, QLabel, QPushButton, QVBoxLayout, QHBoxLayout, QScrollArea,
12
+ QSlider, QTableWidget, QTableWidgetItem, QApplication, QMessageBox
13
+ )
14
+ from setiastro.saspro.widgets.themed_buttons import themed_toolbtn
15
+
16
+ from PyQt6.QtCore import Qt, QTimer, QThread, pyqtSignal, QObject
17
+ from PyQt6.QtWidgets import QWidget
18
+
19
+ class _ProcessingOverlay(QWidget):
20
+ def __init__(self, parent):
21
+ super().__init__(parent)
22
+ self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
23
+ self.setStyleSheet("""
24
+ QWidget {
25
+ background: rgba(0,0,0,140);
26
+ border-radius: 10px;
27
+ }
28
+ QLabel {
29
+ color: white;
30
+ font-size: 14px;
31
+ font-weight: 600;
32
+ }
33
+ """)
34
+ lay = QVBoxLayout(self)
35
+ lay.setContentsMargins(18, 14, 18, 14)
36
+ self.lbl = QLabel("Processing…", self)
37
+ self.lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
38
+ lay.addWidget(self.lbl)
39
+
40
+ def setText(self, s: str):
41
+ self.lbl.setText(s)
42
+
43
+ class _PSFWorker(QObject):
44
+ finished = pyqtSignal(object, str) # (Table or None, status_text)
45
+ failed = pyqtSignal(str)
46
+
47
+ def __init__(self, image: np.ndarray, threshold_sigma: float):
48
+ super().__init__()
49
+ self.image = image
50
+ self.threshold_sigma = float(threshold_sigma)
51
+
52
+ def run(self):
53
+ try:
54
+ if self.image is None:
55
+ self.finished.emit(None, "Status: No image.")
56
+ return
57
+
58
+ # grayscale
59
+ if self.image.ndim == 3:
60
+ image_gray = np.mean(self.image, axis=2)
61
+ else:
62
+ image_gray = self.image
63
+ data = image_gray.astype(np.float32, copy=False)
64
+
65
+ # background
66
+ bkg = sep.Background(data)
67
+ data_sub = data - bkg.back()
68
+ try:
69
+ err_val = bkg.globalrms
70
+ except Exception:
71
+ err_val = float(np.median(bkg.rms()))
72
+
73
+ sources = sep.extract(data_sub, self.threshold_sigma, err=err_val)
74
+ if sources is None or len(sources) == 0:
75
+ self.finished.emit(None, "Status: Extraction completed — 0 sources.")
76
+ return
77
+
78
+ # HFR proxy
79
+ try:
80
+ r = 2.0 * sources["a"]
81
+ except Exception:
82
+ r = np.zeros(len(sources), dtype=np.float32)
83
+
84
+ tbl = Table()
85
+ tbl["xcentroid"] = sources["x"]
86
+ tbl["ycentroid"] = sources["y"]
87
+ tbl["flux"] = sources["flux"]
88
+ tbl["HFR"] = r
89
+ tbl["a"] = sources["a"]
90
+ tbl["b"] = sources["b"]
91
+ tbl["theta"] = sources["theta"]
92
+
93
+ self.finished.emit(tbl, f"Status: Extraction completed — {len(sources)} sources.")
94
+ except Exception as e:
95
+ self.failed.emit(f"Extraction failed: {e}")
96
+
97
+
98
+ class PSFViewer(QDialog):
99
+ """
100
+ A lightweight PSF/Flux histogram viewer.
101
+ Pass an ImageSubWindow instance *or* a document (with .image and .changed).
102
+ Listens to doc.changed to keep results fresh.
103
+ """
104
+ def __init__(self, view_or_doc, parent=None):
105
+ super().__init__(parent)
106
+ self.setWindowTitle("PSF Viewer")
107
+
108
+ # Accept either a view (with .document) or a doc directly
109
+ doc = getattr(view_or_doc, "document", None)
110
+ self.doc = doc if doc is not None else view_or_doc
111
+
112
+ # Image + state
113
+ self.image = self._grab_image()
114
+ self.zoom_factor = 1.0
115
+ self.log_scale = False
116
+ self.star_list = None
117
+ self.histogram_mode = "PSF" # or "Flux"
118
+ self.detection_threshold = 5 # sigma
119
+
120
+ # Debounce timer for threshold slider
121
+ self.threshold_timer = QTimer(self)
122
+ self.threshold_timer.setSingleShot(True)
123
+ self.threshold_timer.setInterval(500)
124
+ self.threshold_timer.timeout.connect(self._applyThreshold)
125
+
126
+ # Auto-update when the document changes
127
+ if hasattr(self.doc, "changed"):
128
+ try:
129
+ self.doc.changed.connect(self._on_doc_changed)
130
+ except Exception:
131
+ pass
132
+
133
+ self._build_ui()
134
+ # Defer first compute until after the dialog is shown/layouted
135
+ QTimer.singleShot(0, self._applyThreshold)
136
+
137
+ # ---------- internals ----------
138
+ def _grab_image(self):
139
+ img = getattr(self.doc, "image", None)
140
+ if img is None:
141
+ return None
142
+ # Ensure ndarray
143
+ try:
144
+ return np.asarray(img)
145
+ except Exception:
146
+ return None
147
+
148
+ def _on_doc_changed(self, *_):
149
+ self.image = self._grab_image()
150
+ # reuse the existing debounce timer instead of immediate recompute
151
+ if self.threshold_timer.isActive():
152
+ self.threshold_timer.stop()
153
+ self.threshold_timer.start()
154
+
155
+ # ---------- UI ----------
156
+ def _build_ui(self):
157
+ main_layout = QVBoxLayout(self)
158
+
159
+ # Top: histogram + stats
160
+ top_layout = QHBoxLayout()
161
+ self.scroll_area = QScrollArea(self)
162
+ self.scroll_area.setFixedSize(520, 310)
163
+ self.scroll_area.setWidgetResizable(False)
164
+ self.hist_label = QLabel(self)
165
+ self.hist_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
166
+ self.scroll_area.setWidget(self.hist_label)
167
+ top_layout.addWidget(self.scroll_area)
168
+
169
+ self.stats_table = QTableWidget(self)
170
+ self.stats_table.setRowCount(4)
171
+ self.stats_table.setColumnCount(0)
172
+ self.stats_table.setVerticalHeaderLabels(["Min", "Max", "Median", "StdDev"])
173
+ self.stats_table.setFixedWidth(360)
174
+ top_layout.addWidget(self.stats_table)
175
+ main_layout.addLayout(top_layout)
176
+
177
+ self.status_label = QLabel("Status: Ready", self)
178
+ self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
179
+ main_layout.addWidget(self.status_label)
180
+
181
+ # Controls
182
+ controls_layout = QHBoxLayout()
183
+
184
+ controls_layout.addWidget(QLabel("Zoom:"))
185
+
186
+ # themed zoom buttons
187
+ btn_zoom_out = themed_toolbtn("zoom-out", "Zoom Out")
188
+ btn_zoom_in = themed_toolbtn("zoom-in", "Zoom In")
189
+ btn_fit = themed_toolbtn("zoom-fit-best", "Fit")
190
+
191
+ btn_zoom_out.clicked.connect(lambda: self._step_zoom(1/1.25))
192
+ btn_zoom_in.clicked.connect(lambda: self._step_zoom(1.25))
193
+ btn_fit.clicked.connect(self._fit_histogram)
194
+
195
+ controls_layout.addWidget(btn_zoom_out)
196
+ controls_layout.addWidget(btn_zoom_in)
197
+ controls_layout.addWidget(btn_fit)
198
+
199
+ # keep the slider (nice for big jumps)
200
+ self.zoom_slider = QSlider(Qt.Orientation.Horizontal, self)
201
+ self.zoom_slider.setRange(50, 1000)
202
+ self.zoom_slider.setValue(100)
203
+ self.zoom_slider.setTickInterval(10)
204
+ self.zoom_slider.setTickPosition(QSlider.TickPosition.TicksBelow)
205
+ self.zoom_slider.valueChanged.connect(self.updateZoom)
206
+ controls_layout.addWidget(self.zoom_slider, 1)
207
+
208
+ self.log_toggle_button = QPushButton("Toggle Log X-Axis", self)
209
+ self.log_toggle_button.setCheckable(True)
210
+ self.log_toggle_button.setToolTip("Toggle between linear and logarithmic x-axis.")
211
+ self.log_toggle_button.toggled.connect(self.toggleLogScale)
212
+ controls_layout.addWidget(self.log_toggle_button)
213
+
214
+ self.mode_toggle_button = QPushButton("Show Flux Histogram", self)
215
+ self.mode_toggle_button.setToolTip("Switch between PSF (HFR) and Flux histograms.")
216
+ self.mode_toggle_button.clicked.connect(self.toggleHistogramMode)
217
+ controls_layout.addWidget(self.mode_toggle_button)
218
+
219
+ main_layout.addLayout(controls_layout)
220
+
221
+ # Threshold
222
+ thresh_layout = QHBoxLayout()
223
+ thresh_layout.addWidget(QLabel("Detection Threshold (σ):", self))
224
+ self.threshold_slider = QSlider(Qt.Orientation.Horizontal, self)
225
+ self.threshold_slider.setRange(1, 20)
226
+ self.threshold_slider.setValue(self.detection_threshold)
227
+ self.threshold_slider.setTickInterval(1)
228
+ self.threshold_slider.setTickPosition(QSlider.TickPosition.TicksBelow)
229
+ self.threshold_slider.valueChanged.connect(self.onThresholdChange)
230
+ thresh_layout.addWidget(self.threshold_slider)
231
+
232
+ self.threshold_value_label = QLabel(str(self.detection_threshold), self)
233
+ thresh_layout.addWidget(self.threshold_value_label)
234
+ main_layout.addLayout(thresh_layout)
235
+
236
+ # Close
237
+ close_btn = QPushButton("Close", self)
238
+ close_btn.clicked.connect(self.accept)
239
+ main_layout.addWidget(close_btn)
240
+
241
+ self.setLayout(main_layout)
242
+ self.drawHistogram()
243
+
244
+ # ---------- interactions ----------
245
+ def onThresholdChange(self, value: int):
246
+ self.detection_threshold = int(value)
247
+ self.threshold_value_label.setText(str(value))
248
+ if self.threshold_timer.isActive():
249
+ self.threshold_timer.stop()
250
+ self.threshold_timer.start()
251
+
252
+ def _step_zoom(self, factor: float):
253
+ v = int(round(self.zoom_slider.value() * factor))
254
+ v = max(self.zoom_slider.minimum(), min(self.zoom_slider.maximum(), v))
255
+ self.zoom_slider.setValue(v) # drives updateZoom()
256
+
257
+ def _fit_histogram(self):
258
+ # Fit the histogram pixmap to the scroll viewport width.
259
+ # Keeps behavior consistent with your other preview dialogs.
260
+ if not hasattr(self, "_base_hist_pm") or self._base_hist_pm is None:
261
+ return
262
+ vp_w = self.scroll_area.viewport().width()
263
+ base_w = max(1, self._base_hist_pm.width())
264
+ z = vp_w / base_w
265
+ self.zoom_slider.setValue(int(round(z * 100)))
266
+
267
+ def _apply_hist_zoom(self):
268
+ if not hasattr(self, "_base_hist_pm") or self._base_hist_pm is None:
269
+ return
270
+ z = self.zoom_slider.value() / 100.0
271
+ w = max(1, int(self._base_hist_pm.width() * z))
272
+ h = max(1, int(self._base_hist_pm.height() * z))
273
+ scaled = self._base_hist_pm.scaled(
274
+ w, h,
275
+ Qt.AspectRatioMode.KeepAspectRatio,
276
+ Qt.TransformationMode.SmoothTransformation
277
+ )
278
+ self.hist_label.setPixmap(scaled)
279
+ self.hist_label.resize(scaled.size())
280
+
281
+ def _applyThreshold(self):
282
+ # kick off worker
283
+ if self.image is None:
284
+ self.star_list = None
285
+ self.status_label.setText("Status: No image.")
286
+ self.drawHistogram()
287
+ return
288
+
289
+ self._show_processing("Processing… extracting stars / PSFs")
290
+
291
+ # kill previous run if any
292
+ if hasattr(self, "_psf_thread") and self._psf_thread is not None:
293
+ try:
294
+ self._psf_thread.quit()
295
+ self._psf_thread.wait(50)
296
+ except Exception:
297
+ pass
298
+
299
+ self._psf_thread = QThread(self)
300
+ self._psf_worker = _PSFWorker(self.image, self.detection_threshold)
301
+ self._psf_worker.moveToThread(self._psf_thread)
302
+
303
+ self._psf_thread.started.connect(self._psf_worker.run)
304
+
305
+ def _done(tbl, status):
306
+ self.star_list = tbl
307
+ self.status_label.setText(status)
308
+ self._hide_processing()
309
+ self.drawHistogram()
310
+ self._psf_thread.quit()
311
+ self._psf_thread.wait(100)
312
+
313
+ def _fail(msg):
314
+ self.star_list = None
315
+ self.status_label.setText(f"Status: {msg}")
316
+ self._hide_processing()
317
+ self.drawHistogram()
318
+ self._psf_thread.quit()
319
+ self._psf_thread.wait(100)
320
+
321
+ self._psf_worker.finished.connect(_done)
322
+ self._psf_worker.failed.connect(_fail)
323
+
324
+ self._psf_thread.start()
325
+
326
+
327
+ def updateImage(self, new_image):
328
+ self.image = np.asarray(new_image) if new_image is not None else None
329
+ self.compute_star_list()
330
+ self.drawHistogram()
331
+
332
+ def updateZoom(self, _=None):
333
+ self._apply_hist_zoom()
334
+
335
+
336
+ def toggleLogScale(self, checked: bool):
337
+ self.log_scale = bool(checked)
338
+ self.drawHistogram()
339
+
340
+ def toggleHistogramMode(self):
341
+ if self.histogram_mode == "PSF":
342
+ self.histogram_mode = "Flux"
343
+ self.mode_toggle_button.setText("Show PSF Histogram")
344
+ else:
345
+ self.histogram_mode = "PSF"
346
+ self.mode_toggle_button.setText("Show Flux Histogram")
347
+ self.drawHistogram()
348
+
349
+ def _show_processing(self, msg="Processing…"):
350
+ if not hasattr(self, "_overlay") or self._overlay is None:
351
+ self._overlay = _ProcessingOverlay(self.scroll_area)
352
+ self._overlay.hide()
353
+ self._overlay.setText(msg)
354
+ self._overlay.resize(self.scroll_area.viewport().size())
355
+ self._overlay.move(0, 0)
356
+ self._overlay.show()
357
+ self._overlay.raise_()
358
+
359
+ def _hide_processing(self):
360
+ if hasattr(self, "_overlay") and self._overlay is not None:
361
+ self._overlay.hide()
362
+
363
+ def resizeEvent(self, e):
364
+ super().resizeEvent(e)
365
+ if hasattr(self, "_overlay") and self._overlay is not None and self._overlay.isVisible():
366
+ self._overlay.resize(self.scroll_area.viewport().size())
367
+
368
+
369
+ # ---------- compute ----------
370
+ def compute_star_list(self):
371
+ if self.image is None:
372
+ self.status_label.setText("Status: No image.")
373
+ self.star_list = None
374
+ return
375
+
376
+ # Convert to grayscale
377
+ if self.image.ndim == 3:
378
+ image_gray = np.mean(self.image, axis=2)
379
+ else:
380
+ image_gray = self.image
381
+ data = image_gray.astype(np.float32, copy=False)
382
+
383
+ # Background
384
+ try:
385
+ bkg = sep.Background(data)
386
+ data_sub = data - bkg.back()
387
+ try:
388
+ err_val = bkg.globalrms
389
+ except Exception:
390
+ err_val = float(np.median(bkg.rms()))
391
+ except Exception as e:
392
+ self.status_label.setText(f"Status: Background failed: {e}")
393
+ self.star_list = None
394
+ return
395
+
396
+ threshold = float(self.detection_threshold)
397
+
398
+ self.status_label.setText("Status: Starting star extraction...")
399
+ QApplication.processEvents()
400
+
401
+ try:
402
+ sources = sep.extract(data_sub, threshold, err=err_val)
403
+ n = len(sources) if sources is not None else 0
404
+ self.status_label.setText(f"Status: Extraction completed — {n} sources.")
405
+ except Exception as e:
406
+ self.status_label.setText(f"Status: Extraction failed: {e}")
407
+ sources = None
408
+
409
+ QApplication.processEvents()
410
+
411
+ if sources is None or len(sources) == 0:
412
+ self.star_list = None
413
+ return
414
+
415
+ # HFR (quick proxy): 2 * a (a ≈ semi-major Gaussian sigma in pixels for SEP)
416
+ try:
417
+ a = sources["a"]
418
+ r = 2 * a
419
+ except Exception:
420
+ r = np.zeros(len(sources), dtype=np.float32)
421
+
422
+ tbl = Table()
423
+ tbl["xcentroid"] = sources["x"]
424
+ tbl["ycentroid"] = sources["y"]
425
+ tbl["flux"] = sources["flux"]
426
+ tbl["HFR"] = r
427
+ tbl["a"] = sources["a"]
428
+ tbl["b"] = sources["b"]
429
+ tbl["theta"] = sources["theta"]
430
+ self.star_list = tbl
431
+
432
+ # ---------- drawing ----------
433
+ def drawHistogram(self):
434
+ base_w, h = 512, 300
435
+
436
+ # Render at fixed base resolution (no zoom here)
437
+ pix = QPixmap(base_w, h)
438
+ pix.fill(Qt.GlobalColor.white)
439
+
440
+ painter = QPainter(pix)
441
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
442
+
443
+ # Prepare data
444
+ if self.star_list is None or len(self.star_list) == 0:
445
+ data = np.array([], dtype=float)
446
+ edges = np.linspace(0, 1, 51)
447
+ low, high = float(edges[0]), float(edges[-1])
448
+ else:
449
+ if self.histogram_mode == "PSF":
450
+ data = np.array(self.star_list["HFR"], dtype=float)
451
+ edges = np.linspace(0, 7.5, 51)
452
+ else:
453
+ data = np.array(self.star_list["flux"], dtype=float)
454
+ edges = np.linspace(data.min(), data.max(), 51) if data.size else np.linspace(0, 1, 51)
455
+ low, high = float(edges[0]), float(edges[-1])
456
+
457
+ # Axis scale helpers (map value -> x in [0..base_w])
458
+ if self.log_scale and high > max(low, 1e-9):
459
+ low = max(low, 1e-4)
460
+ edges = np.logspace(np.log10(low), np.log10(high if high > low else low * 10), 51)
461
+
462
+ lo_l = np.log10(low)
463
+ hi_l = np.log10(high) if high > low else lo_l + 1.0
464
+
465
+ def xfun(v: float) -> int:
466
+ lv = np.log10(max(v, low))
467
+ return int((lv - lo_l) / (hi_l - lo_l) * base_w) if hi_l > lo_l else 0
468
+ else:
469
+ def xfun(v: float) -> int:
470
+ return int((v - low) / (high - low) * base_w) if high > low else 0
471
+
472
+ # Histogram
473
+ hist = np.histogram(data, bins=edges)[0].astype(float)
474
+ if hist.size and hist.max() > 0:
475
+ hist /= hist.max()
476
+
477
+ # Bars
478
+ painter.setPen(QPen(Qt.GlobalColor.black))
479
+ for i in range(len(hist)):
480
+ x0 = xfun(float(edges[i]))
481
+ x1 = xfun(float(edges[i + 1]))
482
+ bw = max(x1 - x0, 1)
483
+ bh = float(hist[i]) * h
484
+ painter.drawRect(x0, int(h - bh), bw, int(bh))
485
+
486
+ # X axis
487
+ painter.setPen(QPen(Qt.GlobalColor.black, 2))
488
+ painter.drawLine(0, h - 1, base_w, h - 1)
489
+ painter.setFont(QFont("Arial", 10))
490
+
491
+ ticks = (
492
+ np.logspace(np.log10(max(low, 1e-4)), np.log10(max(high, low * 10)), 6)
493
+ if self.log_scale and high > low
494
+ else np.linspace(low, high, 6)
495
+ )
496
+ for t in ticks:
497
+ x = xfun(float(t))
498
+ painter.drawLine(x, h - 1, x, h - 6)
499
+ painter.drawText(x - 28, h - 10, f"{t:.3f}" if self.log_scale else f"{t:.2f}")
500
+
501
+ painter.end()
502
+
503
+ # Store base pixmap for zooming
504
+ self._base_hist_pm = pix
505
+ self._apply_hist_zoom() # scales into hist_label
506
+ self.updateStatistics()
507
+
508
+ def updateStatistics(self):
509
+ data_map = {}
510
+ if self.star_list is not None and len(self.star_list) > 0:
511
+ cols = ["HFR", "eccentricity", "a", "b", "theta", "flux"]
512
+ a = np.array(self.star_list["a"], float)
513
+ b = np.array(self.star_list["b"], float)
514
+ ecc = np.nan_to_num(np.sqrt(1 - (b / np.maximum(a, 1e-9)) ** 2))
515
+ data_map["eccentricity"] = ecc
516
+ for c in self.star_list.colnames:
517
+ try:
518
+ data_map[c] = np.array(self.star_list[c], float)
519
+ except Exception:
520
+ pass
521
+ cols = [c for c in cols if c in data_map]
522
+ else:
523
+ cols = []
524
+
525
+ self.stats_table.setColumnCount(len(cols))
526
+ self.stats_table.setHorizontalHeaderLabels(cols)
527
+ self.stats_table.setRowCount(4)
528
+ self.stats_table.setVerticalHeaderLabels(["Min", "Max", "Median", "StdDev"])
529
+
530
+ for ci, col in enumerate(cols):
531
+ arr = data_map.get(col, np.zeros(0, dtype=float))
532
+ if arr.size:
533
+ vals = [np.min(arr), np.max(arr), np.median(arr), np.std(arr)]
534
+ else:
535
+ vals = [0.0, 0.0, 0.0, 0.0]
536
+ for ri, v in enumerate(vals):
537
+ it = QTableWidgetItem(f"{v:.3f}")
538
+ it.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
539
+ self.stats_table.setItem(ri, ci, it)
540
+
541
+ # ---------- lifecycle ----------
542
+ def closeEvent(self, e):
543
+ # Best-effort disconnect
544
+ if hasattr(self.doc, "changed"):
545
+ try:
546
+ self.doc.changed.disconnect(self._on_doc_changed)
547
+ except Exception:
548
+ pass
549
+ super().closeEvent(e)