setiastrosuitepro 1.6.5.post3__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.
Files changed (368) hide show
  1. setiastro/__init__.py +2 -0
  2. setiastro/data/SASP_data.fits +0 -0
  3. setiastro/data/catalogs/List_of_Galaxies_with_Distances_Gly.csv +488 -0
  4. setiastro/data/catalogs/astrobin_filters.csv +890 -0
  5. setiastro/data/catalogs/astrobin_filters_page1_local.csv +51 -0
  6. setiastro/data/catalogs/cali2.csv +63 -0
  7. setiastro/data/catalogs/cali2color.csv +65 -0
  8. setiastro/data/catalogs/celestial_catalog - original.csv +16471 -0
  9. setiastro/data/catalogs/celestial_catalog.csv +24031 -0
  10. setiastro/data/catalogs/detected_stars.csv +24784 -0
  11. setiastro/data/catalogs/fits_header_data.csv +46 -0
  12. setiastro/data/catalogs/test.csv +8 -0
  13. setiastro/data/catalogs/updated_celestial_catalog.csv +16471 -0
  14. setiastro/images/Astro_Spikes.png +0 -0
  15. setiastro/images/Background_startup.jpg +0 -0
  16. setiastro/images/HRDiagram.png +0 -0
  17. setiastro/images/LExtract.png +0 -0
  18. setiastro/images/LInsert.png +0 -0
  19. setiastro/images/Oxygenation-atm-2.svg.png +0 -0
  20. setiastro/images/RGB080604.png +0 -0
  21. setiastro/images/abeicon.png +0 -0
  22. setiastro/images/aberration.png +0 -0
  23. setiastro/images/andromedatry.png +0 -0
  24. setiastro/images/andromedatry_satellited.png +0 -0
  25. setiastro/images/annotated.png +0 -0
  26. setiastro/images/aperture.png +0 -0
  27. setiastro/images/astrosuite.ico +0 -0
  28. setiastro/images/astrosuite.png +0 -0
  29. setiastro/images/astrosuitepro.icns +0 -0
  30. setiastro/images/astrosuitepro.ico +0 -0
  31. setiastro/images/astrosuitepro.png +0 -0
  32. setiastro/images/background.png +0 -0
  33. setiastro/images/background2.png +0 -0
  34. setiastro/images/benchmark.png +0 -0
  35. setiastro/images/big_moon_stabilizer_timeline.png +0 -0
  36. setiastro/images/big_moon_stabilizer_timeline_clean.png +0 -0
  37. setiastro/images/blaster.png +0 -0
  38. setiastro/images/blink.png +0 -0
  39. setiastro/images/clahe.png +0 -0
  40. setiastro/images/collage.png +0 -0
  41. setiastro/images/colorwheel.png +0 -0
  42. setiastro/images/contsub.png +0 -0
  43. setiastro/images/convo.png +0 -0
  44. setiastro/images/copyslot.png +0 -0
  45. setiastro/images/cosmic.png +0 -0
  46. setiastro/images/cosmicsat.png +0 -0
  47. setiastro/images/crop1.png +0 -0
  48. setiastro/images/cropicon.png +0 -0
  49. setiastro/images/curves.png +0 -0
  50. setiastro/images/cvs.png +0 -0
  51. setiastro/images/debayer.png +0 -0
  52. setiastro/images/denoise_cnn_custom.png +0 -0
  53. setiastro/images/denoise_cnn_graph.png +0 -0
  54. setiastro/images/disk.png +0 -0
  55. setiastro/images/dse.png +0 -0
  56. setiastro/images/exoicon.png +0 -0
  57. setiastro/images/eye.png +0 -0
  58. setiastro/images/fliphorizontal.png +0 -0
  59. setiastro/images/flipvertical.png +0 -0
  60. setiastro/images/font.png +0 -0
  61. setiastro/images/freqsep.png +0 -0
  62. setiastro/images/functionbundle.png +0 -0
  63. setiastro/images/graxpert.png +0 -0
  64. setiastro/images/green.png +0 -0
  65. setiastro/images/gridicon.png +0 -0
  66. setiastro/images/halo.png +0 -0
  67. setiastro/images/hdr.png +0 -0
  68. setiastro/images/histogram.png +0 -0
  69. setiastro/images/hubble.png +0 -0
  70. setiastro/images/imagecombine.png +0 -0
  71. setiastro/images/invert.png +0 -0
  72. setiastro/images/isophote.png +0 -0
  73. setiastro/images/isophote_demo_figure.png +0 -0
  74. setiastro/images/isophote_demo_image.png +0 -0
  75. setiastro/images/isophote_demo_model.png +0 -0
  76. setiastro/images/isophote_demo_residual.png +0 -0
  77. setiastro/images/jwstpupil.png +0 -0
  78. setiastro/images/linearfit.png +0 -0
  79. setiastro/images/livestacking.png +0 -0
  80. setiastro/images/mask.png +0 -0
  81. setiastro/images/maskapply.png +0 -0
  82. setiastro/images/maskcreate.png +0 -0
  83. setiastro/images/maskremove.png +0 -0
  84. setiastro/images/morpho.png +0 -0
  85. setiastro/images/mosaic.png +0 -0
  86. setiastro/images/multiscale_decomp.png +0 -0
  87. setiastro/images/nbtorgb.png +0 -0
  88. setiastro/images/neutral.png +0 -0
  89. setiastro/images/nuke.png +0 -0
  90. setiastro/images/openfile.png +0 -0
  91. setiastro/images/pedestal.png +0 -0
  92. setiastro/images/pen.png +0 -0
  93. setiastro/images/pixelmath.png +0 -0
  94. setiastro/images/platesolve.png +0 -0
  95. setiastro/images/ppp.png +0 -0
  96. setiastro/images/pro.png +0 -0
  97. setiastro/images/project.png +0 -0
  98. setiastro/images/psf.png +0 -0
  99. setiastro/images/redo.png +0 -0
  100. setiastro/images/redoicon.png +0 -0
  101. setiastro/images/rescale.png +0 -0
  102. setiastro/images/rgbalign.png +0 -0
  103. setiastro/images/rgbcombo.png +0 -0
  104. setiastro/images/rgbextract.png +0 -0
  105. setiastro/images/rotate180.png +0 -0
  106. setiastro/images/rotatearbitrary.png +0 -0
  107. setiastro/images/rotateclockwise.png +0 -0
  108. setiastro/images/rotatecounterclockwise.png +0 -0
  109. setiastro/images/satellite.png +0 -0
  110. setiastro/images/script.png +0 -0
  111. setiastro/images/selectivecolor.png +0 -0
  112. setiastro/images/simbad.png +0 -0
  113. setiastro/images/slot0.png +0 -0
  114. setiastro/images/slot1.png +0 -0
  115. setiastro/images/slot2.png +0 -0
  116. setiastro/images/slot3.png +0 -0
  117. setiastro/images/slot4.png +0 -0
  118. setiastro/images/slot5.png +0 -0
  119. setiastro/images/slot6.png +0 -0
  120. setiastro/images/slot7.png +0 -0
  121. setiastro/images/slot8.png +0 -0
  122. setiastro/images/slot9.png +0 -0
  123. setiastro/images/spcc.png +0 -0
  124. setiastro/images/spin_precession_vs_lunar_distance.png +0 -0
  125. setiastro/images/spinner.gif +0 -0
  126. setiastro/images/stacking.png +0 -0
  127. setiastro/images/staradd.png +0 -0
  128. setiastro/images/staralign.png +0 -0
  129. setiastro/images/starnet.png +0 -0
  130. setiastro/images/starregistration.png +0 -0
  131. setiastro/images/starspike.png +0 -0
  132. setiastro/images/starstretch.png +0 -0
  133. setiastro/images/statstretch.png +0 -0
  134. setiastro/images/supernova.png +0 -0
  135. setiastro/images/uhs.png +0 -0
  136. setiastro/images/undoicon.png +0 -0
  137. setiastro/images/upscale.png +0 -0
  138. setiastro/images/viewbundle.png +0 -0
  139. setiastro/images/whitebalance.png +0 -0
  140. setiastro/images/wimi_icon_256x256.png +0 -0
  141. setiastro/images/wimilogo.png +0 -0
  142. setiastro/images/wims.png +0 -0
  143. setiastro/images/wrench_icon.png +0 -0
  144. setiastro/images/xisfliberator.png +0 -0
  145. setiastro/qml/ResourceMonitor.qml +126 -0
  146. setiastro/saspro/__init__.py +20 -0
  147. setiastro/saspro/__main__.py +958 -0
  148. setiastro/saspro/_generated/__init__.py +7 -0
  149. setiastro/saspro/_generated/build_info.py +3 -0
  150. setiastro/saspro/abe.py +1346 -0
  151. setiastro/saspro/abe_preset.py +196 -0
  152. setiastro/saspro/aberration_ai.py +698 -0
  153. setiastro/saspro/aberration_ai_preset.py +224 -0
  154. setiastro/saspro/accel_installer.py +218 -0
  155. setiastro/saspro/accel_workers.py +30 -0
  156. setiastro/saspro/add_stars.py +624 -0
  157. setiastro/saspro/astrobin_exporter.py +1010 -0
  158. setiastro/saspro/astrospike.py +153 -0
  159. setiastro/saspro/astrospike_python.py +1841 -0
  160. setiastro/saspro/autostretch.py +198 -0
  161. setiastro/saspro/backgroundneutral.py +611 -0
  162. setiastro/saspro/batch_convert.py +328 -0
  163. setiastro/saspro/batch_renamer.py +522 -0
  164. setiastro/saspro/blemish_blaster.py +491 -0
  165. setiastro/saspro/blink_comparator_pro.py +3149 -0
  166. setiastro/saspro/bundles.py +61 -0
  167. setiastro/saspro/bundles_dock.py +114 -0
  168. setiastro/saspro/cheat_sheet.py +213 -0
  169. setiastro/saspro/clahe.py +368 -0
  170. setiastro/saspro/comet_stacking.py +1442 -0
  171. setiastro/saspro/common_tr.py +107 -0
  172. setiastro/saspro/config.py +38 -0
  173. setiastro/saspro/config_bootstrap.py +40 -0
  174. setiastro/saspro/config_manager.py +316 -0
  175. setiastro/saspro/continuum_subtract.py +1617 -0
  176. setiastro/saspro/convo.py +1400 -0
  177. setiastro/saspro/convo_preset.py +414 -0
  178. setiastro/saspro/copyastro.py +190 -0
  179. setiastro/saspro/cosmicclarity.py +1589 -0
  180. setiastro/saspro/cosmicclarity_preset.py +407 -0
  181. setiastro/saspro/crop_dialog_pro.py +983 -0
  182. setiastro/saspro/crop_preset.py +189 -0
  183. setiastro/saspro/curve_editor_pro.py +2562 -0
  184. setiastro/saspro/curves_preset.py +375 -0
  185. setiastro/saspro/debayer.py +673 -0
  186. setiastro/saspro/debug_utils.py +29 -0
  187. setiastro/saspro/dnd_mime.py +35 -0
  188. setiastro/saspro/doc_manager.py +2664 -0
  189. setiastro/saspro/exoplanet_detector.py +2166 -0
  190. setiastro/saspro/file_utils.py +284 -0
  191. setiastro/saspro/fitsmodifier.py +748 -0
  192. setiastro/saspro/fix_bom.py +32 -0
  193. setiastro/saspro/free_torch_memory.py +48 -0
  194. setiastro/saspro/frequency_separation.py +1349 -0
  195. setiastro/saspro/function_bundle.py +1596 -0
  196. setiastro/saspro/generate_translations.py +3092 -0
  197. setiastro/saspro/ghs_dialog_pro.py +663 -0
  198. setiastro/saspro/ghs_preset.py +284 -0
  199. setiastro/saspro/graxpert.py +637 -0
  200. setiastro/saspro/graxpert_preset.py +287 -0
  201. setiastro/saspro/gui/__init__.py +0 -0
  202. setiastro/saspro/gui/main_window.py +8792 -0
  203. setiastro/saspro/gui/mixins/__init__.py +33 -0
  204. setiastro/saspro/gui/mixins/dock_mixin.py +375 -0
  205. setiastro/saspro/gui/mixins/file_mixin.py +450 -0
  206. setiastro/saspro/gui/mixins/geometry_mixin.py +503 -0
  207. setiastro/saspro/gui/mixins/header_mixin.py +441 -0
  208. setiastro/saspro/gui/mixins/mask_mixin.py +421 -0
  209. setiastro/saspro/gui/mixins/menu_mixin.py +390 -0
  210. setiastro/saspro/gui/mixins/theme_mixin.py +367 -0
  211. setiastro/saspro/gui/mixins/toolbar_mixin.py +1619 -0
  212. setiastro/saspro/gui/mixins/update_mixin.py +323 -0
  213. setiastro/saspro/gui/mixins/view_mixin.py +435 -0
  214. setiastro/saspro/gui/statistics_dialog.py +47 -0
  215. setiastro/saspro/halobgon.py +488 -0
  216. setiastro/saspro/header_viewer.py +448 -0
  217. setiastro/saspro/headless_utils.py +88 -0
  218. setiastro/saspro/histogram.py +756 -0
  219. setiastro/saspro/history_explorer.py +941 -0
  220. setiastro/saspro/i18n.py +168 -0
  221. setiastro/saspro/image_combine.py +417 -0
  222. setiastro/saspro/image_peeker_pro.py +1604 -0
  223. setiastro/saspro/imageops/__init__.py +37 -0
  224. setiastro/saspro/imageops/mdi_snap.py +292 -0
  225. setiastro/saspro/imageops/scnr.py +36 -0
  226. setiastro/saspro/imageops/starbasedwhitebalance.py +210 -0
  227. setiastro/saspro/imageops/stretch.py +236 -0
  228. setiastro/saspro/isophote.py +1182 -0
  229. setiastro/saspro/layers.py +208 -0
  230. setiastro/saspro/layers_dock.py +714 -0
  231. setiastro/saspro/lazy_imports.py +193 -0
  232. setiastro/saspro/legacy/__init__.py +2 -0
  233. setiastro/saspro/legacy/image_manager.py +2360 -0
  234. setiastro/saspro/legacy/numba_utils.py +3676 -0
  235. setiastro/saspro/legacy/xisf.py +1213 -0
  236. setiastro/saspro/linear_fit.py +537 -0
  237. setiastro/saspro/live_stacking.py +1854 -0
  238. setiastro/saspro/log_bus.py +5 -0
  239. setiastro/saspro/logging_config.py +460 -0
  240. setiastro/saspro/luminancerecombine.py +510 -0
  241. setiastro/saspro/main_helpers.py +201 -0
  242. setiastro/saspro/mask_creation.py +1086 -0
  243. setiastro/saspro/masks_core.py +56 -0
  244. setiastro/saspro/mdi_widgets.py +353 -0
  245. setiastro/saspro/memory_utils.py +666 -0
  246. setiastro/saspro/metadata_patcher.py +75 -0
  247. setiastro/saspro/mfdeconv.py +3909 -0
  248. setiastro/saspro/mfdeconv_earlystop.py +71 -0
  249. setiastro/saspro/mfdeconvcudnn.py +3312 -0
  250. setiastro/saspro/mfdeconvsport.py +2459 -0
  251. setiastro/saspro/minorbodycatalog.py +567 -0
  252. setiastro/saspro/morphology.py +407 -0
  253. setiastro/saspro/multiscale_decomp.py +1747 -0
  254. setiastro/saspro/nbtorgb_stars.py +541 -0
  255. setiastro/saspro/numba_utils.py +3145 -0
  256. setiastro/saspro/numba_warmup.py +141 -0
  257. setiastro/saspro/ops/__init__.py +9 -0
  258. setiastro/saspro/ops/command_help_dialog.py +623 -0
  259. setiastro/saspro/ops/command_runner.py +217 -0
  260. setiastro/saspro/ops/commands.py +1594 -0
  261. setiastro/saspro/ops/script_editor.py +1105 -0
  262. setiastro/saspro/ops/scripts.py +1476 -0
  263. setiastro/saspro/ops/settings.py +637 -0
  264. setiastro/saspro/parallel_utils.py +554 -0
  265. setiastro/saspro/pedestal.py +121 -0
  266. setiastro/saspro/perfect_palette_picker.py +1105 -0
  267. setiastro/saspro/pipeline.py +110 -0
  268. setiastro/saspro/pixelmath.py +1604 -0
  269. setiastro/saspro/plate_solver.py +2445 -0
  270. setiastro/saspro/project_io.py +797 -0
  271. setiastro/saspro/psf_utils.py +136 -0
  272. setiastro/saspro/psf_viewer.py +549 -0
  273. setiastro/saspro/pyi_rthook_astroquery.py +95 -0
  274. setiastro/saspro/remove_green.py +331 -0
  275. setiastro/saspro/remove_stars.py +1599 -0
  276. setiastro/saspro/remove_stars_preset.py +446 -0
  277. setiastro/saspro/resources.py +503 -0
  278. setiastro/saspro/rgb_combination.py +208 -0
  279. setiastro/saspro/rgb_extract.py +19 -0
  280. setiastro/saspro/rgbalign.py +723 -0
  281. setiastro/saspro/runtime_imports.py +7 -0
  282. setiastro/saspro/runtime_torch.py +754 -0
  283. setiastro/saspro/save_options.py +73 -0
  284. setiastro/saspro/selective_color.py +1611 -0
  285. setiastro/saspro/sfcc.py +1472 -0
  286. setiastro/saspro/shortcuts.py +3116 -0
  287. setiastro/saspro/signature_insert.py +1102 -0
  288. setiastro/saspro/stacking_suite.py +19066 -0
  289. setiastro/saspro/star_alignment.py +7380 -0
  290. setiastro/saspro/star_alignment_preset.py +329 -0
  291. setiastro/saspro/star_metrics.py +49 -0
  292. setiastro/saspro/star_spikes.py +765 -0
  293. setiastro/saspro/star_stretch.py +507 -0
  294. setiastro/saspro/stat_stretch.py +538 -0
  295. setiastro/saspro/status_log_dock.py +78 -0
  296. setiastro/saspro/subwindow.py +3407 -0
  297. setiastro/saspro/supernovaasteroidhunter.py +1719 -0
  298. setiastro/saspro/swap_manager.py +134 -0
  299. setiastro/saspro/torch_backend.py +89 -0
  300. setiastro/saspro/torch_rejection.py +434 -0
  301. setiastro/saspro/translations/all_source_strings.json +4726 -0
  302. setiastro/saspro/translations/ar_translations.py +4096 -0
  303. setiastro/saspro/translations/de_translations.py +3728 -0
  304. setiastro/saspro/translations/es_translations.py +4169 -0
  305. setiastro/saspro/translations/fr_translations.py +4090 -0
  306. setiastro/saspro/translations/hi_translations.py +3803 -0
  307. setiastro/saspro/translations/integrate_translations.py +271 -0
  308. setiastro/saspro/translations/it_translations.py +4728 -0
  309. setiastro/saspro/translations/ja_translations.py +3834 -0
  310. setiastro/saspro/translations/pt_translations.py +3847 -0
  311. setiastro/saspro/translations/ru_translations.py +3082 -0
  312. setiastro/saspro/translations/saspro_ar.qm +0 -0
  313. setiastro/saspro/translations/saspro_ar.ts +16019 -0
  314. setiastro/saspro/translations/saspro_de.qm +0 -0
  315. setiastro/saspro/translations/saspro_de.ts +14548 -0
  316. setiastro/saspro/translations/saspro_es.qm +0 -0
  317. setiastro/saspro/translations/saspro_es.ts +16202 -0
  318. setiastro/saspro/translations/saspro_fr.qm +0 -0
  319. setiastro/saspro/translations/saspro_fr.ts +15870 -0
  320. setiastro/saspro/translations/saspro_hi.qm +0 -0
  321. setiastro/saspro/translations/saspro_hi.ts +14855 -0
  322. setiastro/saspro/translations/saspro_it.qm +0 -0
  323. setiastro/saspro/translations/saspro_it.ts +19046 -0
  324. setiastro/saspro/translations/saspro_ja.qm +0 -0
  325. setiastro/saspro/translations/saspro_ja.ts +14980 -0
  326. setiastro/saspro/translations/saspro_pt.qm +0 -0
  327. setiastro/saspro/translations/saspro_pt.ts +15024 -0
  328. setiastro/saspro/translations/saspro_ru.qm +0 -0
  329. setiastro/saspro/translations/saspro_ru.ts +11835 -0
  330. setiastro/saspro/translations/saspro_sw.qm +0 -0
  331. setiastro/saspro/translations/saspro_sw.ts +15237 -0
  332. setiastro/saspro/translations/saspro_uk.qm +0 -0
  333. setiastro/saspro/translations/saspro_uk.ts +15248 -0
  334. setiastro/saspro/translations/saspro_zh.qm +0 -0
  335. setiastro/saspro/translations/saspro_zh.ts +15289 -0
  336. setiastro/saspro/translations/sw_translations.py +3897 -0
  337. setiastro/saspro/translations/uk_translations.py +3929 -0
  338. setiastro/saspro/translations/zh_translations.py +3910 -0
  339. setiastro/saspro/versioning.py +77 -0
  340. setiastro/saspro/view_bundle.py +1558 -0
  341. setiastro/saspro/wavescale_hdr.py +645 -0
  342. setiastro/saspro/wavescale_hdr_preset.py +101 -0
  343. setiastro/saspro/wavescalede.py +680 -0
  344. setiastro/saspro/wavescalede_preset.py +230 -0
  345. setiastro/saspro/wcs_update.py +374 -0
  346. setiastro/saspro/whitebalance.py +513 -0
  347. setiastro/saspro/widgets/__init__.py +48 -0
  348. setiastro/saspro/widgets/common_utilities.py +306 -0
  349. setiastro/saspro/widgets/graphics_views.py +122 -0
  350. setiastro/saspro/widgets/image_utils.py +518 -0
  351. setiastro/saspro/widgets/minigame/game.js +991 -0
  352. setiastro/saspro/widgets/minigame/index.html +53 -0
  353. setiastro/saspro/widgets/minigame/style.css +241 -0
  354. setiastro/saspro/widgets/preview_dialogs.py +280 -0
  355. setiastro/saspro/widgets/resource_monitor.py +263 -0
  356. setiastro/saspro/widgets/spinboxes.py +290 -0
  357. setiastro/saspro/widgets/themed_buttons.py +13 -0
  358. setiastro/saspro/widgets/wavelet_utils.py +331 -0
  359. setiastro/saspro/wimi.py +7996 -0
  360. setiastro/saspro/wims.py +578 -0
  361. setiastro/saspro/window_shelf.py +185 -0
  362. setiastro/saspro/xisf.py +1213 -0
  363. setiastrosuitepro-1.6.5.post3.dist-info/METADATA +278 -0
  364. setiastrosuitepro-1.6.5.post3.dist-info/RECORD +368 -0
  365. setiastrosuitepro-1.6.5.post3.dist-info/WHEEL +4 -0
  366. setiastrosuitepro-1.6.5.post3.dist-info/entry_points.txt +6 -0
  367. setiastrosuitepro-1.6.5.post3.dist-info/licenses/LICENSE +674 -0
  368. setiastrosuitepro-1.6.5.post3.dist-info/licenses/license.txt +2580 -0
@@ -0,0 +1,765 @@
1
+ # pro/tools/star_spikes.py
2
+ from __future__ import annotations
3
+ import numpy as np
4
+ import math
5
+ from PyQt6.QtCore import Qt
6
+ from PyQt6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QSplitter, QSizePolicy, QWidget, QApplication,
7
+ QFormLayout, QGroupBox, QDoubleSpinBox, QSpinBox,
8
+ QMessageBox, QGraphicsView, QGraphicsScene, QGraphicsPixmapItem)
9
+
10
+ from PyQt6.QtGui import QPixmap, QImage, QPainter
11
+ from setiastro.saspro.widgets.themed_buttons import themed_toolbtn
12
+
13
+ # deps
14
+ try:
15
+ import sep
16
+ except Exception as _e_sep:
17
+ sep = None
18
+ try:
19
+ import cv2
20
+ except Exception as _e_cv2:
21
+ cv2 = None
22
+ try:
23
+ from scipy.ndimage import gaussian_filter
24
+ import scipy.ndimage as ndi
25
+ except Exception as _e_scipy:
26
+ gaussian_filter = None
27
+ ndi = None
28
+
29
+ class PreviewView(QGraphicsView):
30
+ def __init__(self, *a, **k):
31
+ super().__init__(*a, **k)
32
+ # drag to pan
33
+ self.setDragMode(QGraphicsView.DragMode.ScrollHandDrag)
34
+ # zoom toward mouse
35
+ self.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
36
+ # when the view resizes, keep the scene centered
37
+ self.setResizeAnchor(QGraphicsView.ViewportAnchor.AnchorViewCenter)
38
+ # nicer defaults
39
+ self.setRenderHints(self.renderHints() | QPainter.RenderHint.SmoothPixmapTransform)
40
+ self.setViewportUpdateMode(QGraphicsView.ViewportUpdateMode.SmartViewportUpdate)
41
+
42
+ class StarSpikesDialogPro(QDialog):
43
+ WARN_LIMIT = 1000
44
+ MAX_AUTO_RETRIES = 2
45
+
46
+ def __init__(self, parent=None, doc_manager=None,
47
+ initial_doc=None,
48
+ jwstpupil_path: str | None = None,
49
+ aperture_help_path: str | None = None,
50
+ spinner_path: str | None = None):
51
+ super().__init__(parent)
52
+ self.setWindowTitle(self.tr("Diffraction Spikes"))
53
+ self.setWindowFlag(Qt.WindowType.Window, True)
54
+ self.setWindowModality(Qt.WindowModality.NonModal)
55
+ self.setModal(False)
56
+ self.docman = doc_manager
57
+ self.doc = initial_doc or (self.docman.get_active_document() if self.docman else None)
58
+ self.jwstpupil_path = jwstpupil_path
59
+ self.aperture_help_path = aperture_help_path
60
+
61
+ self.final_image = None
62
+ self._img_src = None # float32, 2D or 3D, [0..1]
63
+
64
+ # defaults (aligned to your SASv2 tool)
65
+ self.advanced = {
66
+ "flux_max": 300.0, "bscale_min": 10.0, "bscale_max": 30.0,
67
+ "shrink_min": 1.0, "shrink_max": 5.0, "detect_thresh": 5.0,
68
+ }
69
+
70
+ self._build_ui()
71
+ self._load_active_image()
72
+
73
+ # ---------- UI ----------
74
+ def _build_ui(self):
75
+ # top-level splitter: controls (left) | preview (right)
76
+ splitter = QSplitter(Qt.Orientation.Horizontal, self)
77
+ splitter.setChildrenCollapsible(False)
78
+
79
+ # ----- LEFT: controls panel (stacked groups) -----
80
+ left = QWidget()
81
+ left_v = QVBoxLayout(left)
82
+ left_v.setContentsMargins(10, 10, 10, 10)
83
+ left_v.setSpacing(10)
84
+
85
+ def dspin(lo, hi, step, val, decimals=2):
86
+ sp = QDoubleSpinBox()
87
+ sp.setRange(lo, hi)
88
+ sp.setSingleStep(step)
89
+ sp.setDecimals(decimals)
90
+ sp.setValue(val)
91
+ sp.setMaximumWidth(140)
92
+ return sp
93
+
94
+ def ispin(lo, hi, step, val):
95
+ sp = QSpinBox()
96
+ sp.setRange(lo, hi)
97
+ sp.setSingleStep(step)
98
+ sp.setValue(val)
99
+ sp.setMaximumWidth(140)
100
+ return sp
101
+
102
+ # --- Group: Star Detection ---
103
+ grp_detect = QGroupBox(self.tr("Star Detection"))
104
+ f_detect = QFormLayout(grp_detect)
105
+ f_detect.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
106
+ f_detect.setFormAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
107
+
108
+ self.flux_min = dspin(0.0, 999999.0, 10.0, 30.0, decimals=1)
109
+ self.detect_thresh = dspin(0.5, 50.0, 0.5, float(self.advanced.get("detect_thresh", 5.0)), decimals=2)
110
+ self.detect_thresh.setToolTip("σ threshold for SEP detection (higher = fewer stars).")
111
+ # keep self.advanced in sync if user edits
112
+ self.detect_thresh.valueChanged.connect(lambda v: self.advanced.__setitem__("detect_thresh", float(v)))
113
+
114
+ f_detect.addRow(self.tr("Flux Min:"), self.flux_min)
115
+ f_detect.addRow(self.tr("Detection Threshold (σ):"), self.detect_thresh)
116
+
117
+ # --- Group: Aperture (Geometry) ---
118
+ grp_ap = QGroupBox(self.tr("Aperture"))
119
+ f_ap = QFormLayout(grp_ap)
120
+ f_ap.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
121
+ f_ap.setFormAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
122
+
123
+ self.pupil_jwst = QPushButton("Circular")
124
+ self.pupil_jwst.setCheckable(True)
125
+ self.pupil_jwst.setChecked(False)
126
+ self.pupil_jwst.toggled.connect(lambda on: self._toggle_pupil(on))
127
+ self.pupil_jwst.setToolTip("Toggle between circular aperture and JWST pupil image.")
128
+ self.pupil_jwst.setStyleSheet("""
129
+ QPushButton { min-width: 72px; max-width: 72px; min-height: 28px; max-height: 28px;
130
+ border-radius: 14px; background:#ccc; border:1px solid #999;}
131
+ QPushButton:checked { background:#66bb6a; }
132
+ """)
133
+ f_ap.addRow(self.tr("Aperture Type:"), self.pupil_jwst)
134
+
135
+ self.radius = dspin(1.0, 512.0, 1.0, 128.0, decimals=1)
136
+ self.obstruction = dspin(0.0, 0.99, 0.01, 0.2, decimals=2)
137
+ self.num_vanes = ispin(2, 8, 1, 4)
138
+ self.vane_width = dspin(0.0, 50.0, 0.5, 4.0, decimals=2)
139
+ self.rotation = dspin(0.0, 360.0, 1.0, 0.0, decimals=1)
140
+
141
+ f_ap.addRow(self.tr("Pupil Radius:"), self.radius)
142
+ f_ap.addRow(self.tr("Obstruction:"), self.obstruction)
143
+ f_ap.addRow(self.tr("Number of Vanes:"), self.num_vanes)
144
+ f_ap.addRow(self.tr("Vane Width:"), self.vane_width)
145
+ f_ap.addRow(self.tr("Rotation (deg):"), self.rotation)
146
+
147
+ # --- Group: PSF & Synthesis ---
148
+ grp_psf = QGroupBox(self.tr("PSF & Synthesis"))
149
+ f_psf = QFormLayout(grp_psf)
150
+ f_psf.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
151
+ f_psf.setFormAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
152
+
153
+ self.color_boost = dspin(0.1, 10.0, 0.1, 1.5, decimals=2)
154
+ self.blur_sigma = dspin(0.1, 10.0, 0.1, 2.0, decimals=2)
155
+
156
+ f_psf.addRow(self.tr("Spike Boost:"), self.color_boost)
157
+ f_psf.addRow(self.tr("PSF Blur Sigma:"), self.blur_sigma)
158
+
159
+ # --- Actions ---
160
+ row_actions = QHBoxLayout()
161
+ row_actions.setSpacing(8)
162
+ self.btn_run = QPushButton(self.tr("Generate Spikes"))
163
+ self.btn_run.clicked.connect(self._run)
164
+ self.btn_apply = QPushButton(self.tr("Apply to Active Document"))
165
+ self.btn_apply.clicked.connect(self._apply_to_doc)
166
+ self.btn_apply.setEnabled(False)
167
+ self.btn_help = QPushButton(self.tr("Aperture Help"))
168
+ self.btn_help.clicked.connect(self._show_help)
169
+ row_actions.addWidget(self.btn_run)
170
+ row_actions.addWidget(self.btn_apply)
171
+ row_actions.addWidget(self.btn_help)
172
+ row_actions.addStretch(1)
173
+
174
+ # --- Status ---
175
+ self.status = QLabel(self.tr("Ready"))
176
+ self.status.setAlignment(Qt.AlignmentFlag.AlignCenter)
177
+ self.status.setWordWrap(True)
178
+
179
+ # assemble left panel
180
+ left_v.addWidget(grp_detect)
181
+ left_v.addWidget(grp_ap)
182
+ left_v.addWidget(grp_psf)
183
+ left_v.addLayout(row_actions)
184
+ left_v.addWidget(self.status)
185
+ left_v.addStretch(1)
186
+
187
+ splitter.addWidget(left)
188
+
189
+ # ----- RIGHT: preview panel -----
190
+ right = QWidget()
191
+ right_v = QVBoxLayout(right)
192
+
193
+ # zoom toolbar
194
+ zrow = QHBoxLayout()
195
+ self.btn_zoom_in = QPushButton(self.tr("Zoom In"))
196
+ self.btn_zoom_out = QPushButton(self.tr("Zoom Out"))
197
+ self.btn_fit = QPushButton(self.tr("Fit to Preview"))
198
+ self.btn_zoom_in.clicked.connect(self._zoom_in)
199
+ self.btn_zoom_out.clicked.connect(self._zoom_out)
200
+ self.btn_fit.clicked.connect(self._fit_to_preview)
201
+ zrow.addWidget(self.btn_zoom_in)
202
+ zrow.addWidget(self.btn_zoom_out)
203
+ zrow.addWidget(self.btn_fit)
204
+ zrow.addStretch(1)
205
+ right_v.addLayout(zrow)
206
+
207
+ # graphics scene/view
208
+ self.scene = QGraphicsScene()
209
+ self.view = PreviewView()
210
+ self.view.setScene(self.scene)
211
+ self.view.setAlignment(Qt.AlignmentFlag.AlignCenter)
212
+ self.view.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
213
+ self.view.setMinimumSize(600, 450)
214
+ self.pix = QGraphicsPixmapItem()
215
+ self.scene.addItem(self.pix)
216
+ right_v.addWidget(self.view, 1)
217
+
218
+ splitter.addWidget(right)
219
+
220
+ # make preview side bigger by default
221
+ splitter.setStretchFactor(0, 0) # left
222
+ splitter.setStretchFactor(1, 1) # right
223
+ splitter.setSizes([360, 900])
224
+
225
+ # top-level layout contains just the splitter
226
+ root = QVBoxLayout(self)
227
+ root.addWidget(splitter)
228
+
229
+ # init pupil visibility
230
+ self._toggle_pupil(False)
231
+
232
+ # zoom state
233
+ self._zoom = 1.0
234
+ self._fit_mode = True # start fitted
235
+
236
+ def _toggle_pupil(self, jwst: bool):
237
+ self.pupil_jwst.setText("JWST" if jwst else "Circular")
238
+ # hide circular-only params when JWST pupil is used
239
+ for w in (self.num_vanes, self.vane_width, self.obstruction, self.radius):
240
+ w.setVisible(not jwst)
241
+
242
+ # ---------- data/preset ----------
243
+ def _load_active_image(self):
244
+ if not self.doc or getattr(self.doc, "image", None) is None:
245
+ self.status.setText("No active image.")
246
+ return
247
+ arr = np.asarray(self.doc.image)
248
+ if arr.dtype != np.float32:
249
+ arr = arr.astype(np.float32, copy=False)
250
+ # strip alpha
251
+ if arr.ndim == 3 and arr.shape[2] == 4:
252
+ arr = arr[..., :3]
253
+ # keep within [0..1] for the math we use
254
+ if np.issubdtype(arr.dtype, np.floating):
255
+ mx = float(arr.max()) if arr.size else 1.0
256
+ if mx > 1.0:
257
+ arr = arr / (65535.0 if mx > 5.0 else mx)
258
+ self._img_src = np.clip(arr, 0.0, 1.0)
259
+
260
+ def apply_preset(self, p: dict):
261
+ if not p:
262
+ return
263
+ self.flux_min.setValue(float(p.get("flux_min", self.flux_min.value())))
264
+ self.advanced["flux_max"] = float(p.get("flux_max", self.advanced["flux_max"]))
265
+ self.advanced["bscale_min"] = float(p.get("bscale_min", self.advanced["bscale_min"]))
266
+ self.advanced["bscale_max"] = float(p.get("bscale_max", self.advanced["bscale_max"]))
267
+ self.advanced["shrink_min"] = float(p.get("shrink_min", self.advanced["shrink_min"]))
268
+ self.advanced["shrink_max"] = float(p.get("shrink_max", self.advanced["shrink_max"]))
269
+ self.advanced["detect_thresh"] = float(p.get("detect_thresh", self.advanced["detect_thresh"]))
270
+ self.detect_thresh.setValue(float(self.advanced["detect_thresh"])) # reflect in UI
271
+ self.radius.setValue(float(p.get("radius", self.radius.value())))
272
+ self.obstruction.setValue(float(p.get("obstruction", self.obstruction.value())))
273
+ self.num_vanes.setValue(int(p.get("num_vanes", self.num_vanes.value())))
274
+ self.vane_width.setValue(float(p.get("vane_width", self.vane_width.value())))
275
+ self.rotation.setValue(float(p.get("rotation", self.rotation.value())))
276
+ self.color_boost.setValue(float(p.get("color_boost", self.color_boost.value())))
277
+ self.blur_sigma.setValue(float(p.get("blur_sigma", self.blur_sigma.value())))
278
+ self.pupil_jwst.setChecked(bool(p.get("jwst", self.pupil_jwst.isChecked())))
279
+
280
+ # ---------- core ----------
281
+ def _run(self):
282
+ if self._img_src is None:
283
+ self._load_active_image()
284
+ if self._img_src is None:
285
+ QMessageBox.information(self, "Diffraction Spikes", "No active image.")
286
+ return
287
+
288
+ # deps check
289
+ if sep is None:
290
+ QMessageBox.critical(self, "Missing Dependency", "python-sep is required for star detection.")
291
+ return
292
+ if gaussian_filter is None or ndi is None:
293
+ QMessageBox.critical(self, "Missing Dependency", "scipy.ndimage is required.")
294
+ return
295
+
296
+ self.status.setText("Detecting stars…")
297
+ QApplication.processEvents()
298
+ img = self._img_src
299
+ # un-stretch via midtones(0.95) for detection
300
+ if img.ndim == 3:
301
+ lin = img.copy()
302
+ for c in range(3):
303
+ lin[..., c] = self._midtones_m(lin[..., c], 0.95)
304
+ base = 0.2126*lin[...,0] + 0.7152*lin[...,1] + 0.0722*lin[...,2]
305
+ else:
306
+ lin = self._midtones_m(img, 0.95)
307
+ base = lin
308
+
309
+ # initial detection
310
+ thresh = float(self.detect_thresh.value())
311
+ stars = self._detect_stars(base,
312
+ threshold=thresh,
313
+ flux_min=self.flux_min.value(),
314
+ size_min=1.0)
315
+
316
+ # interactive guardrail for dense fields
317
+ tries = 0
318
+ while len(stars) > self.WARN_LIMIT and tries < self.MAX_AUTO_RETRIES:
319
+ suggested = min(50.0, max(thresh + 1.0,
320
+ thresh * (len(stars) / float(self.WARN_LIMIT))**0.5))
321
+ msg = QMessageBox(self)
322
+ msg.setWindowTitle("Too Many Stars Detected")
323
+ msg.setIcon(QMessageBox.Icon.Warning)
324
+ msg.setText(f"{len(stars)} stars detected (limit {self.WARN_LIMIT}).\n"
325
+ "Increase the detection threshold to reduce clutter?")
326
+ raise_btn = msg.addButton(f"Raise to σ={suggested:.2f}", QMessageBox.ButtonRole.AcceptRole)
327
+ cont_btn = msg.addButton("Continue Anyway", QMessageBox.ButtonRole.DestructiveRole)
328
+ cancel_btn= msg.addButton("Cancel", QMessageBox.ButtonRole.RejectRole)
329
+ msg.setDefaultButton(raise_btn)
330
+ msg.exec()
331
+
332
+ clicked = msg.clickedButton()
333
+ if clicked is raise_btn:
334
+ thresh = suggested
335
+ self.detect_thresh.setValue(thresh) # reflect in UI
336
+ self.status.setText(f"Re-detecting stars at σ={thresh:.2f}…")
337
+ QApplication.processEvents()
338
+ stars = self._detect_stars(base,
339
+ threshold=thresh,
340
+ flux_min=self.flux_min.value(),
341
+ size_min=1.0)
342
+ tries += 1
343
+ continue
344
+ elif clicked is cont_btn:
345
+ break
346
+ else: # cancel
347
+ self.status.setText("Cancelled.")
348
+ return
349
+
350
+ if len(stars) == 0:
351
+ self.status.setText("No stars found.")
352
+ QMessageBox.information(self, "Diffraction Spikes", "No stars found above flux_min.")
353
+ return
354
+
355
+ self.status.setText(f"Building pupil/PSFs… ({len(stars)} stars)")
356
+ QApplication.processEvents()
357
+ if self.pupil_jwst.isChecked():
358
+ if cv2 is None or not self.jwstpupil_path:
359
+ QMessageBox.critical(self, "Missing JWST Pupil",
360
+ "OpenCV not available or JWST pupil image path missing.")
361
+ return
362
+ pupil = self._load_pupil_from_png(self.jwstpupil_path, size=1024, rotation=self.rotation.value())
363
+ else:
364
+ pupil = self._make_pupil(size=1024,
365
+ radius=self.radius.value(),
366
+ obstruction=self.obstruction.value(),
367
+ vane_width=self.vane_width.value(),
368
+ num_vanes=self.num_vanes.value(),
369
+ rotation=self.rotation.value())
370
+
371
+ psf_r = self._simulate_psf(pupil, wavelength_scale=1.15, blur_sigma=self.blur_sigma.value())
372
+ psf_g = self._simulate_psf(pupil, wavelength_scale=1.00, blur_sigma=self.blur_sigma.value())
373
+ psf_b = self._simulate_psf(pupil, wavelength_scale=0.85, blur_sigma=self.blur_sigma.value())
374
+
375
+ self.status.setText("Synthesizing spikes…")
376
+ QApplication.processEvents()
377
+ H, W = img.shape[:2]
378
+ canvas = np.zeros((H, W, 3), dtype=np.float32)
379
+
380
+ flux_max = self.advanced["flux_max"]
381
+ bscale_min = self.advanced["bscale_min"]
382
+ bscale_max = self.advanced["bscale_max"]
383
+ shrink_min = self.advanced["shrink_min"]
384
+ shrink_max = self.advanced["shrink_max"]
385
+ color_boost = self.color_boost.value()
386
+
387
+ # Try OpenCV for faster zoom/blur
388
+ try:
389
+ import cv2
390
+ _HAS_CV2 = True
391
+ except ImportError:
392
+ _HAS_CV2 = False
393
+
394
+ from concurrent.futures import ThreadPoolExecutor, as_completed
395
+ def star_runner(x, y, flux, a, b):
396
+ brightness = np.clip(np.log1p(flux)/8.0, 0.1, 3.0)
397
+ tile_size = int(256 + brightness*20)
398
+ tile_size = min(tile_size, 768)
399
+ tile_size += tile_size % 2
400
+ pad = tile_size // 2
401
+
402
+ # Guard against fully out-of-bounds, but allow partial overlaps
403
+ if not (0 <= x < W and 0 <= y < H):
404
+ return None
405
+
406
+ # Measure star color
407
+ r_ratio, g_ratio, b_ratio = self._measure_star_color(img, x, y, sampling_radius=3)
408
+
409
+ # Extract PSF tiles
410
+ tile_r = self._extract_center_tile(psf_r, tile_size) * brightness * r_ratio * color_boost
411
+ tile_g = self._extract_center_tile(psf_g, tile_size) * brightness * g_ratio * color_boost
412
+ tile_b = self._extract_center_tile(psf_b, tile_size) * brightness * b_ratio * color_boost
413
+
414
+ # Boost/Shrink
415
+ b_scale, s_factor = self._boost_shrink_from_flux(flux, self.flux_min.value(), flux_max,
416
+ bscale_min, bscale_max, shrink_min, shrink_max)
417
+
418
+ # --- Fast Resize (Zoom) ---
419
+ def _fast_zoom(arr, z):
420
+ if z == 1.0: return arr
421
+ if _HAS_CV2:
422
+ h, w = arr.shape
423
+ nw, nh = int(round(w * z)), int(round(h * z))
424
+ if nw <= 0 or nh <= 0: return np.zeros((2,2), dtype=np.float32)
425
+ return cv2.resize(arr, (nw, nh), interpolation=cv2.INTER_LINEAR)
426
+ else:
427
+ return ndi.zoom(arr, z, order=1)
428
+
429
+ final_r = np.clip(_fast_zoom(tile_r * b_scale, 1.0/s_factor), 0.0, 1.0)
430
+ final_g = np.clip(_fast_zoom(tile_g * b_scale, 1.0/s_factor), 0.0, 1.0)
431
+ final_b = np.clip(_fast_zoom(tile_b * b_scale, 1.0/s_factor), 0.0, 1.0)
432
+
433
+ # --- Return Patch Data (y, x, patch) ---
434
+ new_h, new_w = final_r.shape
435
+
436
+ # Coords of the *patch top-left* relative to the image
437
+ # The star is at (x,y), and the patch center is approx (new_w//2, new_h//2)
438
+ # We want to center the patch on the star.
439
+ py0 = y - (new_h // 2)
440
+ px0 = x - (new_w // 2)
441
+
442
+ # Combine channels
443
+ patch = np.dstack((final_r, final_g, final_b)).astype(np.float32)
444
+ return (int(py0), int(px0), patch)
445
+
446
+ with ThreadPoolExecutor() as ex:
447
+ futs = [ex.submit(star_runner, *s) for s in stars]
448
+ for f in as_completed(futs):
449
+ res = f.result()
450
+ if res is None:
451
+ continue
452
+
453
+ py0, px0, patch = res
454
+ ph, pw, _ = patch.shape
455
+
456
+ # Calculate intersection with canvas
457
+ y_start = max(0, py0)
458
+ y_end = min(H, py0 + ph)
459
+ x_start = max(0, px0)
460
+ x_end = min(W, px0 + pw)
461
+
462
+ # If no overlap, skip
463
+ if y_start >= y_end or x_start >= x_end:
464
+ continue
465
+
466
+ # Offsets into the patch
467
+ patch_y_start = y_start - py0
468
+ patch_y_end = patch_y_start + (y_end - y_start)
469
+ patch_x_start = x_start - px0
470
+ patch_x_end = patch_x_start + (x_end - x_start)
471
+
472
+ # Add to canvas
473
+ canvas[y_start:y_end, x_start:x_end] += patch[patch_y_start:patch_y_end, patch_x_start:patch_x_end]
474
+
475
+ self.status.setText("Compositing…")
476
+ QApplication.processEvents()
477
+ if lin.ndim == 3:
478
+ spiked_lin = np.clip(lin + canvas, 0, 1)
479
+ else:
480
+ spikes_mono = 0.2126*canvas[...,0] + 0.7152*canvas[...,1] + 0.0722*canvas[...,2]
481
+ spiked_lin = np.clip(lin + spikes_mono, 0, 1)
482
+
483
+ # protect by active mask (document-level)
484
+ if spiked_lin.ndim == 3:
485
+ spiked_final = np.empty_like(spiked_lin)
486
+ for c in range(3):
487
+ spiked_final[..., c] = self._midtones_m(spiked_lin[..., c], 0.05)
488
+ else:
489
+ spiked_final = self._midtones_m(spiked_lin, 0.05)
490
+
491
+ # ---- apply mask AFTER full processing ----
492
+ m = self._active_mask_array(self.doc)
493
+ if m is not None:
494
+ if spiked_final.ndim == 3 and m.ndim == 2:
495
+ m = m[..., None]
496
+
497
+ # white = apply effect, black = protect original
498
+ final = np.clip(spiked_final * m + img * (1.0 - m), 0.0, 1.0)
499
+ else:
500
+ final = spiked_final
501
+
502
+ self.final_image = final
503
+ self._update_preview(final)
504
+ self.btn_apply.setEnabled(True)
505
+ self.status.setText("Done.")
506
+
507
+ def _apply_to_doc(self):
508
+ if self.final_image is None:
509
+ QMessageBox.information(self, "Diffraction Spikes", "Nothing to apply yet.")
510
+ return
511
+ if not self.docman:
512
+ QMessageBox.warning(self, "No DocManager", "DocManager not available.")
513
+ return
514
+ self.docman.apply_edit_to_active(self.final_image, step_name="Diffraction Spikes")
515
+ self.status.setText("Applied to active document.")
516
+ # keep dialog open so user can tweak more if desired
517
+
518
+ # ---------- helpers ----------
519
+ def _update_preview(self, arr):
520
+ arr8 = np.clip(arr, 0, 1)
521
+ arr8 = (arr8 * 255.0).astype(np.uint8)
522
+ if arr8.ndim == 2:
523
+ h, w = arr8.shape
524
+ qimg = QImage(arr8.data, w, h, w, QImage.Format.Format_Grayscale8)
525
+ else:
526
+ h, w, _ = arr8.shape
527
+ qimg = QImage(arr8.data, w, h, 3*w, QImage.Format.Format_RGB888)
528
+ self.pix.setPixmap(QPixmap.fromImage(qimg))
529
+ self.scene.setSceneRect(self.pix.boundingRect())
530
+ # keep current zoom mode
531
+ self._apply_zoom()
532
+
533
+ def _show_help(self):
534
+ if not self.aperture_help_path:
535
+ QMessageBox.information(self, "Aperture Help", "No help image configured.")
536
+ return
537
+ pm = QPixmap(self.aperture_help_path)
538
+ if pm.isNull():
539
+ QMessageBox.critical(self, "Aperture Help", "Failed to load help image.")
540
+ return
541
+ dlg = QDialog(self)
542
+ dlg.setWindowTitle("Aperture Help")
543
+ v = QVBoxLayout(dlg)
544
+ lab = QLabel()
545
+ lab.setPixmap(pm)
546
+ lab.setAlignment(Qt.AlignmentFlag.AlignCenter)
547
+ v.addWidget(lab)
548
+ dlg.setWindowFlag(Qt.WindowType.Window, True)
549
+ dlg.resize(480, 480)
550
+ dlg.show()
551
+
552
+ # ----- math from SASv2, adapted -----
553
+ @staticmethod
554
+ def _midtones_m(x, m):
555
+ x = np.clip(x, 0.0, 1.0).astype(np.float32)
556
+ out = np.zeros_like(x, dtype=np.float32)
557
+ mask0 = (x == 0); out[mask0] = 0.0
558
+ mask1 = (x == 1); out[mask1] = 1.0
559
+ eps = 1e-7
560
+ maskm = (np.abs(x - m) < eps); out[maskm] = 0.5
561
+ mask_oth = ~(mask0 | mask1 | maskm)
562
+ xm = x[mask_oth]
563
+ num = (m - 1.0)*xm
564
+ den = (2.0*m - 1.0)*xm - m
565
+ out[mask_oth] = np.clip(num/(den+1e-12),0,1)
566
+ return out
567
+
568
+ def _make_pupil(self, size=512, radius=100, obstruction=0.3, vane_width=2, num_vanes=4, rotation=0):
569
+ y, x = np.indices((size, size)) - size // 2
570
+ r = np.sqrt(x**2 + y**2)
571
+ pupil = (r <= radius).astype(np.float32)
572
+ pupil[r < radius * obstruction] = 0.0
573
+ if num_vanes >= 2:
574
+ rot = np.deg2rad(rotation)
575
+ for angle in np.linspace(0, np.pi, num_vanes, endpoint=False) + rot:
576
+ xp = x * np.cos(angle) + y * np.sin(angle)
577
+ vane = np.abs(xp) < vane_width
578
+ pupil[vane] = 0.0
579
+ return pupil
580
+
581
+ def _load_pupil_from_png(self, filepath, size=1024, rotation=0.0):
582
+ img = cv2.imread(filepath, cv2.IMREAD_GRAYSCALE)
583
+ if img is None:
584
+ raise ValueError(f"Failed to load image from {filepath}")
585
+ img = img.astype(np.float32) / 255.0
586
+ if img.shape != (size, size):
587
+ img = cv2.resize(img, (size, size), interpolation=cv2.INTER_AREA)
588
+ if abs(rotation) > 1e-3:
589
+ center = (size // 2, size // 2)
590
+ M = cv2.getRotationMatrix2D(center, rotation, 1.0)
591
+ img = cv2.warpAffine(img, M, (size, size), flags=cv2.INTER_LINEAR,
592
+ borderMode=cv2.BORDER_CONSTANT, borderValue=0)
593
+ return img
594
+
595
+ def _simulate_psf(self, pupil, wavelength_scale=1.0, blur_sigma=1.0):
596
+ # Try to use OpenCV for speed
597
+ if getattr(self, "_cv2_checked", False):
598
+ has_cv2 = True
599
+ import cv2
600
+ else:
601
+ try:
602
+ import cv2
603
+ has_cv2 = True
604
+ except ImportError:
605
+ has_cv2 = False
606
+ self._cv2_checked = has_cv2
607
+
608
+ if has_cv2:
609
+ # Gaussian blur on pupil
610
+ # kernel size usually ~6*sigma, must be odd
611
+ k_pupil = int(math.ceil(6 * (0.1 * wavelength_scale))) | 1
612
+ sp = cv2.GaussianBlur(pupil, (k_pupil, k_pupil), 0.1 * wavelength_scale)
613
+ else:
614
+ sp = gaussian_filter(pupil, sigma=0.1 * wavelength_scale)
615
+
616
+ fft = np.fft.fftshift(np.fft.fft2(np.fft.ifftshift(sp)))
617
+ intensity = np.abs(fft)**2
618
+ intensity /= (intensity.max() + 1e-8)
619
+
620
+ if has_cv2 and blur_sigma > 0:
621
+ k_blur = int(math.ceil(6 * blur_sigma)) | 1
622
+ blurred = cv2.GaussianBlur(intensity, (k_blur, k_blur), blur_sigma)
623
+ else:
624
+ blurred = gaussian_filter(intensity, sigma=blur_sigma)
625
+
626
+ psf = blurred / max(blurred.max(), 1e-8)
627
+
628
+ if wavelength_scale != 1.0:
629
+ if has_cv2:
630
+ h, w = psf.shape
631
+ # Zoom uses size, NOT scale factor in resize(..., dsize=(w,h))
632
+ # wavelength_scale > 1 => zoom in => crop middle? or simply scale?
633
+ # The original used ndi.zoom(psf, zoom=wavelength_scale).
634
+ # New size:
635
+ nw, nh = int(round(w * wavelength_scale)), int(round(h * wavelength_scale))
636
+ if nw > 0 and nh > 0:
637
+ scaled = cv2.resize(psf, (nw, nh), interpolation=cv2.INTER_LINEAR)
638
+ # We might need to crop back to original size or pad?
639
+ # ndi.zoom changes the array size.
640
+ # The simulator seems to assume we handle whatever size comes out?
641
+ # Let's check _extract_center_tile usage.
642
+ psf = scaled
643
+ else:
644
+ psf = ndi.zoom(psf, zoom=wavelength_scale, order=1)
645
+
646
+ psf /= psf.max() + 1e-12
647
+ return psf
648
+
649
+ @staticmethod
650
+ def _extract_center_tile(psf, tile_size):
651
+ c = psf.shape[0]//2
652
+ h = tile_size//2
653
+ y0 = max(0, c-h); x0 = max(0, c-h)
654
+ y1 = y0 + tile_size; x1 = x0 + tile_size
655
+ cropped = psf[y0:y1, x0:x1]
656
+ if cropped.shape != (tile_size, tile_size):
657
+ out = np.zeros((tile_size, tile_size), dtype=np.float32)
658
+ ph, pw = cropped.shape
659
+ out[:ph, :pw] = cropped
660
+ return out
661
+ return cropped
662
+
663
+ @staticmethod
664
+ def _detect_stars(image, threshold=5.0, flux_min=30.0, size_min=1.0):
665
+ data = image.astype(np.float32)
666
+ bkg = sep.Background(data)
667
+ data_sub = data - bkg.back()
668
+ err_val = bkg.globalrms
669
+ try:
670
+ objects = sep.extract(data_sub, threshold, err=err_val)
671
+ except Exception as e:
672
+ if "internal pixel buffer full" in str(e):
673
+ QMessageBox.warning(None, "Star Detection Failed",
674
+ "Star detection failed: internal pixel buffer full.\n"
675
+ "Increase detection threshold or minimum flux.")
676
+ else:
677
+ QMessageBox.critical(None, "Star Detection Failed", str(e))
678
+ return []
679
+ stars = []
680
+ for obj in objects:
681
+ flux = obj['flux']; a = obj['a']; b = obj['b']
682
+ if flux >= flux_min and max(a,b) >= size_min:
683
+ stars.append((int(obj['x']), int(obj['y']), float(flux), float(a), float(b)))
684
+ return stars
685
+
686
+ # _shrink_and_boost removed (replaced by inline _fast_zoom for performance)
687
+
688
+ @staticmethod
689
+ def _boost_shrink_from_flux(flux, flux_min, flux_max, bmin, bmax, smin, smax):
690
+ f = np.clip(flux, flux_min, flux_max)
691
+ alpha = 0.0 if flux_max <= flux_min else (f - flux_min) / (flux_max - flux_min)
692
+ bscale = bmin + alpha * (bmax - bmin)
693
+ shrink = smax - alpha * (smax - smin)
694
+ return float(bscale), float(shrink)
695
+
696
+ @staticmethod
697
+ def _measure_star_color(img_color, x, y, sampling_radius=20):
698
+ if img_color.ndim == 2:
699
+ return (1.0, 1.0, 1.0)
700
+ H, W, C = img_color.shape
701
+ if C != 3:
702
+ return (1.0, 1.0, 1.0)
703
+ x0 = max(0, int(x - sampling_radius)); x1 = min(W, int(x + sampling_radius + 1))
704
+ y0 = max(0, int(y - sampling_radius)); y1 = min(H, int(y + sampling_radius + 1))
705
+ if x1 <= x0 or y1 <= y0:
706
+ return (1.0, 1.0, 1.0)
707
+ patch = img_color[y0:y1, x0:x1, :]
708
+ mean_col = np.mean(patch, axis=(0, 1))
709
+ mx = float(np.max(mean_col))
710
+ if mx < 1e-9:
711
+ return (1.0, 1.0, 1.0)
712
+ return (float(mean_col[0]/mx), float(mean_col[1]/mx), float(mean_col[2]/mx))
713
+
714
+ @staticmethod
715
+ def _active_mask_array(doc) -> np.ndarray | None:
716
+ if not doc:
717
+ return None
718
+ mid = getattr(doc, "active_mask_id", None)
719
+ if not mid:
720
+ return None
721
+ masks = getattr(doc, "masks", {}) or {}
722
+ layer = masks.get(mid)
723
+ data = getattr(layer, "data", None) if layer is not None else None
724
+ if data is None:
725
+ return None
726
+ a = np.asarray(data)
727
+ if a.ndim == 3 and a.shape[2] == 1:
728
+ a = a[..., 0]
729
+ if a.ndim != 2:
730
+ return None
731
+ a = a.astype(np.float32, copy=False)
732
+ a = np.clip(a, 0.0, 1.0)
733
+ # keep original where mask == 1.0 (protection mask semantics)
734
+ return a
735
+
736
+ def _apply_zoom(self):
737
+ if self._fit_mode:
738
+ self.view.fitInView(self.pix, Qt.AspectRatioMode.KeepAspectRatio)
739
+ return
740
+ self.view.resetTransform()
741
+ self.view.scale(self._zoom, self._zoom)
742
+
743
+ def _zoom_in(self):
744
+ if self.pix.pixmap().isNull():
745
+ return
746
+ if self._fit_mode:
747
+ self._fit_mode = False
748
+ self._zoom = 1.0
749
+ self._zoom = min(self._zoom * 1.25, 20.0)
750
+ self._apply_zoom()
751
+
752
+ def _zoom_out(self):
753
+ if self.pix.pixmap().isNull():
754
+ return
755
+ if self._fit_mode:
756
+ self._fit_mode = False
757
+ self._zoom = 1.0
758
+ self._zoom = max(self._zoom / 1.25, 0.05)
759
+ self._apply_zoom()
760
+
761
+ def _fit_to_preview(self):
762
+ if self.pix.pixmap().isNull():
763
+ return
764
+ self._fit_mode = True
765
+ self._apply_zoom()