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,1182 @@
1
+ #pro.isophote.py
2
+ from __future__ import annotations
3
+
4
+ # --- Stdlib ---
5
+ import time
6
+ import inspect
7
+ from types import SimpleNamespace
8
+ from typing import Optional
9
+
10
+ # --- Third-party ---
11
+ import numpy as np
12
+ from astropy.io import fits
13
+
14
+ # photutils is optional; we degrade gracefully if missing
15
+ try:
16
+ from photutils.isophote import Ellipse, EllipseGeometry, build_ellipse_model
17
+ except Exception: # pragma: no cover
18
+ Ellipse = None
19
+ EllipseGeometry = None
20
+ build_ellipse_model = None
21
+
22
+ # --- Qt (PyQt6) ---
23
+ from PyQt6.QtCore import (
24
+ pyqtSignal, QObject, Qt, QSize, QEvent, QThread, QPointF, QRectF
25
+ )
26
+ from PyQt6.QtGui import (
27
+ QIcon, QPixmap, QImage, QPen, QBrush, QPainterPath, QCursor, QFontMetrics, QAction, QTransform
28
+ )
29
+ from PyQt6.QtWidgets import (
30
+ QApplication, QWidget, QDialog, QGraphicsView, QGraphicsScene, QGraphicsPixmapItem,
31
+ QGraphicsEllipseItem, QGraphicsPathItem, QFormLayout, QHBoxLayout, QVBoxLayout,
32
+ QLabel, QSlider, QPushButton, QCheckBox, QDoubleSpinBox, QSizePolicy, QSplitter,
33
+ QToolButton, QMenu, QMessageBox, QStyle, QProgressDialog, QGraphicsItem, QFileDialog
34
+ )
35
+
36
+ from setiastro.saspro.imageops.stretch import stretch_mono_image, stretch_color_image
37
+ from setiastro.saspro.widgets.themed_buttons import themed_toolbtn
38
+
39
+
40
+ # ===========================
41
+ # UI Helpers / Components
42
+ # ===========================
43
+
44
+ class _SyncedView(QGraphicsView):
45
+ """Zoom/pan-enabled view that mirrors BOTH transform and scrollbars to a peer.
46
+ Shift+LeftClick emits image coords for 'pick center'."""
47
+ viewChanged = pyqtSignal(QTransform, int, int)
48
+ mousePosClicked = pyqtSignal(float, float)
49
+
50
+ def __init__(self):
51
+ super().__init__()
52
+ self.setRenderHints(self.renderHints())
53
+ self.setDragMode(QGraphicsView.DragMode.ScrollHandDrag)
54
+ self.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
55
+ self.setResizeAnchor(QGraphicsView.ViewportAnchor.AnchorViewCenter)
56
+ self._sync_block = False
57
+ self._img_item: Optional[QGraphicsPixmapItem] = None
58
+ self._img_shape = None
59
+ self.horizontalScrollBar().valueChanged.connect(self._emit_view_changed)
60
+ self.verticalScrollBar().valueChanged.connect(self._emit_view_changed)
61
+
62
+ def setSceneImage(self, qpix: QPixmap, img_shape):
63
+ scene = QGraphicsScene(self)
64
+ self._img_item = QGraphicsPixmapItem(qpix)
65
+ scene.addItem(self._img_item)
66
+ self.setScene(scene)
67
+ self._img_shape = img_shape
68
+ self.fitInView(self._img_item, Qt.AspectRatioMode.KeepAspectRatio)
69
+ self._emit_view_changed()
70
+
71
+ def wheelEvent(self, e):
72
+ factor = 1.25 if e.angleDelta().y() > 0 else 0.8
73
+ self.scale(factor, factor)
74
+ self._emit_view_changed()
75
+
76
+ def resizeEvent(self, e):
77
+ super().resizeEvent(e)
78
+ self._emit_view_changed()
79
+
80
+ def _emit_view_changed(self, *_):
81
+ if self._sync_block:
82
+ return
83
+ self.viewChanged.emit(
84
+ self.transform(),
85
+ self.horizontalScrollBar().value(),
86
+ self.verticalScrollBar().value()
87
+ )
88
+
89
+ def setPeerView(self, tr: QTransform, hval: int, vval: int):
90
+ if self._sync_block:
91
+ return
92
+ self._sync_block = True
93
+ try:
94
+ self.setTransform(tr)
95
+ self.horizontalScrollBar().setValue(hval)
96
+ self.verticalScrollBar().setValue(vval)
97
+ finally:
98
+ self._sync_block = False
99
+
100
+ def mousePressEvent(self, ev):
101
+ if (ev.button() == Qt.MouseButton.LeftButton and
102
+ (ev.modifiers() & Qt.KeyboardModifier.ShiftModifier) and
103
+ self._img_item is not None):
104
+ p = self.mapToScene(ev.position().toPoint())
105
+ self.mousePosClicked.emit(p.x(), p.y())
106
+ ev.accept()
107
+ return
108
+ super().mousePressEvent(ev)
109
+
110
+
111
+ class FloatSlider(QWidget):
112
+ """Labeled horizontal slider that emits/accepts float values, with fixed-width value label."""
113
+ valueChanged = pyqtSignal(float)
114
+
115
+ def __init__(self, minimum: float, maximum: float, value: float,
116
+ decimals: int = 2, unit: str = "", tick: Optional[float] = None,
117
+ parent: Optional[QWidget] = None):
118
+ super().__init__(parent)
119
+ self._scale = 10 ** decimals
120
+ self._decimals = decimals
121
+ self._unit = unit
122
+
123
+ self._slider = QSlider(Qt.Orientation.Horizontal, self)
124
+ self._label = QLabel(self)
125
+
126
+ self._slider.setRange(int(round(minimum * self._scale)),
127
+ int(round(maximum * self._scale)))
128
+ if tick:
129
+ self._slider.setSingleStep(int(max(1, round(tick * self._scale))))
130
+
131
+ fm = QFontMetrics(self._label.font())
132
+ max_abs = max(abs(minimum), abs(maximum))
133
+ sample_text = f"-{max_abs:.{self._decimals}f}{self._unit}"
134
+ self._label.setMinimumWidth(fm.horizontalAdvance(sample_text) + 8)
135
+ self._label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
136
+ self._label.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
137
+
138
+ lay = QHBoxLayout(self); lay.setContentsMargins(0, 0, 0, 0)
139
+ lay.addWidget(self._slider, 1); lay.addWidget(self._label, 0)
140
+
141
+ self._slider.valueChanged.connect(self._on_slider)
142
+ self.setValue(value)
143
+
144
+ def _on_slider(self, iv):
145
+ val = iv / self._scale
146
+ self._label.setText(f"{val:.{self._decimals}f}{self._unit}")
147
+ self.valueChanged.emit(val)
148
+
149
+ def value(self) -> float:
150
+ return self._slider.value() / self._scale
151
+
152
+ def setValue(self, v: float):
153
+ self._slider.blockSignals(True)
154
+ self._slider.setValue(int(round(v * self._scale)))
155
+ self._slider.blockSignals(False)
156
+ self._label.setText(f"{self.value():.{self._decimals}f}{self._unit}")
157
+
158
+
159
+ class DraggableEllipse(QGraphicsEllipseItem):
160
+ """Seed ellipse: movable only while holding Ctrl."""
161
+ def __init__(self, rect: QRectF, on_center_moved=None):
162
+ super().__init__(rect)
163
+ self.setFlags(
164
+ QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges |
165
+ QGraphicsItem.GraphicsItemFlag.ItemIsSelectable
166
+ )
167
+ self.setAcceptHoverEvents(True)
168
+ self.setZValue(10)
169
+ pen = QPen(Qt.GlobalColor.cyan); pen.setWidthF(1.5)
170
+ self.setPen(pen)
171
+ self.setBrush(QBrush(Qt.BrushStyle.NoBrush))
172
+ self._drag_active = False
173
+ self._drag_offset = QPointF(0, 0)
174
+ self._on_center_moved = on_center_moved
175
+
176
+ def center_scene(self) -> QPointF:
177
+ return self.mapToScene(self.rect().center())
178
+
179
+ def set_center_scene(self, p: QPointF):
180
+ d = p - self.center_scene()
181
+ self.moveBy(d.x(), d.y())
182
+
183
+ def hoverMoveEvent(self, ev):
184
+ if ev.modifiers() & Qt.KeyboardModifier.ControlModifier:
185
+ self.setCursor(QCursor(Qt.CursorShape.SizeAllCursor))
186
+ else:
187
+ self.setCursor(QCursor(Qt.CursorShape.ArrowCursor))
188
+ super().hoverMoveEvent(ev)
189
+
190
+ def mousePressEvent(self, ev):
191
+ if (ev.button() == Qt.MouseButton.LeftButton and
192
+ (ev.modifiers() & Qt.KeyboardModifier.ControlModifier)):
193
+ self._drag_active = True
194
+ self._drag_offset = self.mapToScene(ev.pos()) - self.center_scene()
195
+ ev.accept()
196
+ else:
197
+ self._drag_active = False
198
+ ev.ignore()
199
+
200
+ def mouseMoveEvent(self, ev):
201
+ if self._drag_active:
202
+ new_center = self.mapToScene(ev.pos()) - self._drag_offset
203
+ self.set_center_scene(new_center)
204
+ ev.accept()
205
+ else:
206
+ ev.ignore()
207
+
208
+ def mouseReleaseEvent(self, ev):
209
+ if self._drag_active:
210
+ self._drag_active = False
211
+ if self._on_center_moved:
212
+ c = self.center_scene()
213
+ self._on_center_moved(c.x(), c.y())
214
+ ev.accept()
215
+ else:
216
+ ev.ignore()
217
+
218
+
219
+ # ===========================
220
+ # Fitting Worker (threaded)
221
+ # ===========================
222
+
223
+ class _FitWorker(QObject):
224
+ finished = pyqtSignal(object, object, object) # model, resid, isolist_or_scaled
225
+ error = pyqtSignal(str)
226
+ progress = pyqtSignal(int, str)
227
+
228
+ def __init__(self, img, params, parent=None):
229
+ super().__init__(parent)
230
+ self.img = img
231
+ self.p = params
232
+
233
+ @staticmethod
234
+ def _downsample_mean(img, ds):
235
+ ds = int(max(1, ds))
236
+ H, W = img.shape
237
+ if ds == 1:
238
+ return img, (H, W)
239
+ Hc, Wc = (H // ds) * ds, (W // ds) * ds
240
+ if Hc == 0 or Wc == 0:
241
+ return img, (H, W)
242
+ crop = img[:Hc, :Wc]
243
+ small = crop.reshape(Hc // ds, ds, Wc // ds, ds).mean(axis=(1, 3))
244
+ return small.astype(img.dtype, copy=False), (H, W)
245
+
246
+ @staticmethod
247
+ def _upsample_nn(arr, ds, out_shape, pad_mode="edge"):
248
+ ds = int(max(1, ds))
249
+ if ds == 1:
250
+ up = arr
251
+ else:
252
+ up = arr.repeat(ds, axis=0).repeat(ds, axis=1)
253
+ H, W = out_shape
254
+ uh, uw = up.shape
255
+ if uh < H or uw < W:
256
+ up = np.pad(up, ((0, max(0, H - uh)), (0, max(0, W - uw))), mode=pad_mode)
257
+ return up[:H, :W].astype(arr.dtype, copy=False)
258
+
259
+ def run(self):
260
+ try:
261
+ if Ellipse is None or EllipseGeometry is None or build_ellipse_model is None:
262
+ raise RuntimeError("photutils.isophote not available")
263
+
264
+ self.progress.emit(5, "Preparing…")
265
+ ds = int(max(1, self.p.get("downsample", 1)))
266
+
267
+ # --- geometry at full-res ---
268
+ pa_rad = np.deg2rad(self.p["pa_deg"])
269
+ try:
270
+ geom_full = EllipseGeometry(x0=self.p["cx"], y0=self.p["cy"],
271
+ sma=self.p["sma0"], eps=self.p["eps"], pa=pa_rad)
272
+ except TypeError:
273
+ geom_full = EllipseGeometry(x0=self.p["cx"], y0=self.p["cy"],
274
+ sma=self.p["sma0"], eps=self.p["eps"],
275
+ position_angle=pa_rad)
276
+
277
+ # --- build (possibly) downsampled image & geometry ---
278
+ img_for_fit = self.img
279
+ geom_for_fit = geom_full
280
+ minsma = self.p["minsma"]; maxsma = self.p["maxsma"]; step = self.p["step"]
281
+ if ds > 1:
282
+ small, full_shape = self._downsample_mean(self.img, ds)
283
+ img_for_fit = small
284
+ geom_for_fit = EllipseGeometry(
285
+ x0=geom_full.x0/ds, y0=geom_full.y0/ds,
286
+ sma=geom_full.sma/ds, eps=geom_full.eps, pa=geom_full.pa
287
+ )
288
+ minsma = minsma/ds; maxsma = maxsma/ds; step = max(step/ds, 0.5)
289
+
290
+ self.progress.emit(20, "Building mask…")
291
+ h, w = img_for_fit.shape
292
+ if self.p["use_wedge"]:
293
+ yy, xx = np.mgrid[0:h, 0:w]
294
+ cx_fit, cy_fit = geom_for_fit.x0, geom_for_fit.y0
295
+ ang = np.arctan2(yy - cy_fit, xx - cx_fit)
296
+ pa = np.deg2rad(self.p["wedge_pa"])
297
+ half = np.deg2rad(self.p["wedge_width"] / 2.0)
298
+ d = np.arctan2(np.sin(ang - pa), np.cos(ang - pa))
299
+ wedge_mask = (np.abs(d) <= half)
300
+ else:
301
+ wedge_mask = np.zeros((h, w), dtype=bool)
302
+
303
+ img_ma = np.ma.masked_array(img_for_fit, mask=wedge_mask)
304
+ ell = Ellipse(img_ma, geometry=geom_for_fit)
305
+
306
+ # --- fit kwargs (version-safe) ---
307
+ fit_kwargs = dict(
308
+ sma0=geom_for_fit.sma, minsma=minsma, maxsma=maxsma,
309
+ step=step, sclip=self.p["sclip"], nclip=int(self.p["nclip"]),
310
+ fix_center=self.p["fix_center"], fix_pa=self.p["fix_pa"], fix_eps=self.p["fix_eps"],
311
+ )
312
+ sig = inspect.signature(ell.fit_image).parameters
313
+ mode_key = "integrmode" if "integrmode" in sig else ("integr_mode" if "integr_mode" in sig else None)
314
+ if mode_key:
315
+ fit_kwargs[mode_key] = "bilinear"
316
+
317
+ self.progress.emit(40, "Fitting isophotes…")
318
+ isolist = ell.fit_image(**fit_kwargs)
319
+ if hasattr(isolist, "__len__") and len(isolist) == 0:
320
+ raise ValueError("isolist must not be empty")
321
+
322
+ self.progress.emit(60, "Building model…")
323
+ model_fit = build_ellipse_model(img_for_fit.shape, isolist,
324
+ high_harmonics=self.p["high_harm"])
325
+ resid_fit = img_for_fit - model_fit
326
+
327
+ self.progress.emit(95, "Upsampling / finalizing…")
328
+
329
+ if ds > 1:
330
+ model_full = self._upsample_nn(model_fit, ds, (self.img.shape[0], self.img.shape[1]))
331
+ resid_full = self.img - model_full
332
+ # scale isolist params to full-res
333
+ scaled = []
334
+ for iso in isolist:
335
+ x0 = float(getattr(iso, "x0", getattr(iso, "x0_center", geom_for_fit.x0))) * ds
336
+ y0 = float(getattr(iso, "y0", getattr(iso, "y0_center", geom_for_fit.y0))) * ds
337
+ sma = float(getattr(iso, "sma", getattr(iso, "sma0", geom_for_fit.sma))) * ds
338
+ eps = float(getattr(iso, "eps", getattr(iso, "ellipticity", geom_for_fit.eps)))
339
+ pa = float(getattr(iso, "pa", getattr(iso, "position_angle", geom_for_fit.pa)))
340
+ scaled.append(SimpleNamespace(x0=x0, y0=y0, sma=sma, eps=eps, pa=pa))
341
+ self.finished.emit(model_full.astype(np.float32),
342
+ resid_full.astype(np.float32),
343
+ scaled)
344
+ else:
345
+ self.finished.emit(model_fit.astype(np.float32),
346
+ resid_fit.astype(np.float32),
347
+ isolist)
348
+
349
+ except Exception as e:
350
+ self.error.emit(str(e))
351
+
352
+
353
+ # ===========================
354
+ # Main Dialog
355
+ # ===========================
356
+
357
+ class IsophoteModelerDialog(QDialog):
358
+ pushRequested = pyqtSignal(str, int, object) # kept for legacy, not used with doc_manager
359
+
360
+ def __init__(self, mono_image: np.ndarray, parent: Optional[QWidget] = None,
361
+ title_hint: Optional[str] = None, image_manager=None, doc_manager=None):
362
+ super().__init__(parent)
363
+ self.setWindowFlag(Qt.WindowType.Window, True)
364
+ self.setWindowModality(Qt.WindowModality.NonModal)
365
+ self.setModal(False)
366
+ self.image_manager = image_manager
367
+ self.doc_manager = doc_manager
368
+
369
+ self._ellipse_item = None
370
+ self._max_item = None
371
+ self._min_item = None
372
+ self._isolist = None
373
+ self._last_fit_params = None
374
+ self._preview_right01 = None
375
+
376
+ self._perf = {1: 0.060714, 4: 0.004286}
377
+ self._last_run_timer = None
378
+
379
+ if Ellipse is None:
380
+ QMessageBox.critical(self, "Photutils Missing",
381
+ "photutils.isophote is required for GLIMR.")
382
+ self.close(); return
383
+
384
+ self.setWindowTitle(title_hint or "GLIMR — GaLaxy Isophote Modeler & Residual Revealer")
385
+ self.setMinimumSize(1100, 700)
386
+ self.setWindowFlags(self.windowFlags()
387
+ | Qt.WindowType.WindowMaximizeButtonHint
388
+ | Qt.WindowType.WindowMinimizeButtonHint)
389
+ self.setSizeGripEnabled(True)
390
+
391
+ self._img = mono_image.astype(np.float32, copy=False)
392
+ self._model = None
393
+ self._resid = None
394
+
395
+ # ---- Views ----
396
+ self.left = _SyncedView()
397
+ self.right = _SyncedView()
398
+ self.left.viewChanged.connect(self.right.setPeerView)
399
+ self.right.viewChanged.connect(self.left.setPeerView)
400
+ self.left.mousePosClicked.connect(self._on_left_click)
401
+
402
+ self._in01 = self._compute_input01()
403
+ lpix = self._np_to_qpix_linear01(self._in01)
404
+ self.left.setSceneImage(lpix, self._in01.shape)
405
+ self.right.setSceneImage(lpix, self._in01.shape)
406
+ self.right.setPeerView(
407
+ self.left.transform(),
408
+ self.left.horizontalScrollBar().value(),
409
+ self.left.verticalScrollBar().value()
410
+ )
411
+
412
+ # overlays
413
+ self._wedge_item = None
414
+
415
+ # ---- Controls ----
416
+ ctl = QWidget(); form = QFormLayout(ctl)
417
+
418
+ self.fix_center = QCheckBox("Fix Center")
419
+ self.fix_pa = QCheckBox("Fix PA")
420
+ self.fix_eps = QCheckBox("Fix Ellipticity")
421
+ self.high_harm = QCheckBox("Add a3/b3/a4/b4 in model")
422
+
423
+ h, w = self._img.shape
424
+ max_rad = min(h, w) / 1.2
425
+
426
+ self.sma0 = FloatSlider(1.0, max_rad, 20.0, decimals=1, unit=" px")
427
+ self.minsma = FloatSlider(0.0, max_rad, 0.0, decimals=1, unit=" px")
428
+ self.maxsma = FloatSlider(1.0, max_rad, max_rad, decimals=1, unit=" px")
429
+ self.step = FloatSlider(0.01, 3.00, 1.00, decimals=2)
430
+ self.sclip = FloatSlider(1.0, 10.0, 3.0, decimals=2)
431
+ self.nclip = FloatSlider(0.0, 20.0, 1.0, decimals=0)
432
+
433
+ self.eps = FloatSlider(0.0, 0.95, 0.20, decimals=3)
434
+ self.pa_deg = FloatSlider(-180.0, 180.0, 90.0, decimals=1, unit="°")
435
+
436
+ self.use_wedge = QCheckBox("Exclude wedge (deg)")
437
+ self.wedge_pa = FloatSlider(-180.0, 180.0, 0.0, decimals=1, unit="°")
438
+ self.wedge_width = FloatSlider(0.0, 180.0, 30.0, decimals=1, unit="°")
439
+
440
+ self._cx, self._cy = w/2.0, h/2.0
441
+ self.center_label = QLabel(f"Center: ({self._cx:.1f}, {self._cy:.1f})")
442
+ pick_center_btn = QPushButton("Pick Center (Shift+click) • Move (Ctrl+drag ellipse)")
443
+
444
+ self.hq_interp = QCheckBox("High-quality interpolation (slower)")
445
+ self.hq_interp.setChecked(False)
446
+ self.quick_preview = QCheckBox("Quick preview (4× downsample)")
447
+ self.quick_preview.setChecked(True)
448
+
449
+ run_btn = QPushButton("Fit Model"); run_btn.clicked.connect(self._run_fit)
450
+ self.preview_blend = QCheckBox("Show original outside max ellipse")
451
+ self.preview_blend.setChecked(True)
452
+
453
+ self.normalize_input = QCheckBox("Normalize before fitting (Linear Data)")
454
+ self.normalize_input.setToolTip(
455
+ "Apply global statistical stretch to the input before fitting/preview.\n"
456
+ "Uses mono median target of 0.25."
457
+ )
458
+ self.normalize_input.setChecked(False)
459
+
460
+ form.addRow(self.normalize_input)
461
+ form.addRow(QLabel("<b>Geometry & Start</b>"))
462
+ form.addRow("sma0", self.sma0)
463
+ form.addRow("min sma", self.minsma)
464
+ form.addRow("max sma", self.maxsma)
465
+ form.addRow("step", self.step)
466
+ self.ring_est_label = QLabel("≈ 0 rings"); form.addRow(self.ring_est_label)
467
+ form.addRow("σ-clip (sclip)", self.sclip)
468
+ form.addRow("σ-clip iters", self.nclip)
469
+ form.addRow(self._help_row(self.fix_center, "Fix (x0,y0) across radii."))
470
+ form.addRow(self._help_row(self.fix_pa, "Fix PA across radii."))
471
+ form.addRow(self._help_row(self.fix_eps, "Fix ellipticity ε across radii."))
472
+ form.addRow(self._help_row(self.high_harm, "Include a3/b3/a4/b4 in model."))
473
+ form.addRow(pick_center_btn)
474
+
475
+ form.addRow(QLabel("<b>Center / Shape</b>"))
476
+ form.addRow(self.center_label)
477
+ form.addRow("ellipticity ε", self.eps)
478
+ form.addRow("PA (deg)", self.pa_deg)
479
+
480
+ form.addRow(QLabel("<b>Wedge Mask</b>"))
481
+ wr = QWidget(); wr_l = QHBoxLayout(wr); wr_l.setContentsMargins(0,0,0,0)
482
+ wr_l.addWidget(self.use_wedge); wr_l.addWidget(QLabel("PA0")); wr_l.addWidget(self.wedge_pa)
483
+ wr_l.addWidget(QLabel("±width/2")); wr_l.addWidget(self.wedge_width)
484
+ form.addRow(wr)
485
+ form.addRow(self.hq_interp)
486
+ form.addRow(self.preview_blend)
487
+ form.addRow(self.quick_preview)
488
+ form.addRow(run_btn)
489
+
490
+ self.save_resid_shifted = QCheckBox("Shift residuals to ≥ 0 on save")
491
+ self.save_resid_shifted.setChecked(True)
492
+ form.addRow(self.save_resid_shifted)
493
+
494
+ # Export rows: push to NEW documents via doc_manager
495
+ model_row, self._model_lowres_hint = self._make_export_row("model")
496
+ resid_row, self._resid_lowres_hint = self._make_export_row("resid")
497
+ form.addRow(model_row); form.addRow(resid_row)
498
+
499
+ split = QSplitter(Qt.Orientation.Horizontal)
500
+ split.addWidget(self.left); split.addWidget(self.right)
501
+ split.setSizes([700, 700])
502
+
503
+ root = QHBoxLayout(self); root.addWidget(split, 4); root.addWidget(ctl, 0)
504
+
505
+ # connections
506
+ pick_center_btn.clicked.connect(lambda: QMessageBox.information(
507
+ self, "Center Picking",
508
+ "Shift+LeftClick in the left image to set the center.\n"
509
+ "Hold Ctrl and drag the cyan ellipse to adjust."
510
+ ))
511
+ for s in (self.sma0, self.maxsma, self.eps, self.pa_deg, self.minsma):
512
+ s.valueChanged.connect(lambda _=None: self._create_or_update_overlay())
513
+ self.preview_blend.stateChanged.connect(lambda _=None: self._rebuild_right_preview())
514
+ for s in (self.minsma, self.sma0, self.maxsma):
515
+ s.valueChanged.connect(lambda _=None: self._enforce_sma_order())
516
+ for s in (self.wedge_pa, self.wedge_width):
517
+ s.valueChanged.connect(lambda _=None: self._update_wedge_overlay())
518
+ self.use_wedge.stateChanged.connect(self._update_wedge_overlay)
519
+
520
+ def _update_ring_estimate():
521
+ mn = float(self.minsma.value()); mx = float(self.maxsma.value())
522
+ st = max(1e-6, float(self.step.value()))
523
+ n = int(max(0, (mx - mn) / st))
524
+ ds = 4 if self.quick_preview.isChecked() else 1
525
+ spr = self._perf.get(ds)
526
+ if spr is None:
527
+ other = 4 if ds == 1 else 1
528
+ other_spr = self._perf.get(other)
529
+ if other_spr is not None:
530
+ spr = other_spr * ((other / ds) ** 2)
531
+ if spr is not None and n > 0:
532
+ eta_sec = spr * n
533
+ s = max(0.0, float(eta_sec))
534
+ if s < 1.0: eta = f"{s:.1f}s"
535
+ else:
536
+ m, s = divmod(int(round(s)), 60)
537
+ if m < 1: eta = f"{s:d}s"
538
+ else:
539
+ h, m = divmod(m, 60)
540
+ eta = f"{m:d}m {s:d}s" if h < 1 else f"{h:d}h {m:d}m"
541
+ tag = "quick" if ds > 1 else "full"
542
+ self.ring_est_label.setText(f"≈ {n:,} rings • est: {eta} ({tag})")
543
+ else:
544
+ self.ring_est_label.setText(f"≈ {n:,} rings")
545
+ if n > 10000:
546
+ new_step = max(st, (mx - mn) / 10000.0)
547
+ if abs(new_step - st) > 1e-12:
548
+ self.step.setValue(new_step)
549
+ for s in (self.minsma, self.maxsma, self.step):
550
+ s.valueChanged.connect(lambda _=None: self._update_ring_estimate())
551
+ self._update_ring_estimate()
552
+
553
+ self.quick_preview.stateChanged.connect(lambda _=None: self._update_ring_estimate())
554
+ self._update_lowres_hints()
555
+ self.quick_preview.stateChanged.connect(lambda _=None: self._update_lowres_hints())
556
+ self.normalize_input.stateChanged.connect(lambda _=None: self._recompute_input_view())
557
+ self._update_wedge_overlay()
558
+
559
+ # ---------- event/utility ----------
560
+ def _compute_input01(self) -> np.ndarray:
561
+ x = self._img.astype(np.float32, copy=False)
562
+ try:
563
+ if self.normalize_input.isChecked():
564
+ x = stretch_mono_image(
565
+ x, target_median=0.25, normalize=False, apply_curves=False, curves_boost=0.0
566
+ ).astype(np.float32, copy=False)
567
+ else:
568
+ x = np.clip(x, 0.0, 1.0).astype(np.float32, copy=False)
569
+ except Exception:
570
+ x = np.clip(x, 0.0, 1.0).astype(np.float32, copy=False)
571
+ return x
572
+
573
+ def _recompute_input_view(self):
574
+ self._in01 = self._compute_input01()
575
+ pix = self._np_to_qpix_linear01(self._in01)
576
+ self.left.setSceneImage(pix, self._in01.shape)
577
+ if self._resid is None:
578
+ self.right.setSceneImage(pix, self._in01.shape)
579
+ else:
580
+ self._rebuild_right_preview()
581
+
582
+ def _update_lowres_hints(self):
583
+ on = self.quick_preview.isChecked()
584
+ ds = 4 if on else 1
585
+ text = f"low-res (fit at {ds}× downsample)" if on else ""
586
+ for lbl in (self._model_lowres_hint, self._resid_lowres_hint):
587
+ lbl.setText(text); lbl.setVisible(on)
588
+
589
+ def _make_export_row(self, which: str):
590
+ row = QWidget(self); lay = QHBoxLayout(row); lay.setContentsMargins(0,0,0,0)
591
+ save_btn = QPushButton(f"Save {which.capitalize()} FITS…", row)
592
+ save_btn.clicked.connect(lambda: self._save_fits(which=which))
593
+ lay.addWidget(save_btn, 0)
594
+
595
+ new_btn = QPushButton(f"New Doc: {which.capitalize()} (normalized)", row)
596
+ new_btn.clicked.connect(lambda: self._push_product(which=which, variant="normalized"))
597
+ lay.addWidget(new_btn, 0)
598
+
599
+ if which == "resid":
600
+ vis_btn = QPushButton("New Doc: Residual (visible)", row)
601
+ vis_btn.setToolTip("Push exactly what you see in the right preview pane")
602
+ vis_btn.clicked.connect(lambda: self._push_product(which="resid", variant="visible"))
603
+ lay.addWidget(vis_btn, 0)
604
+
605
+ stretch_btn = QPushButton("New Doc: Residual (stretched)", row)
606
+ stretch_btn.setToolTip("Symmetric preview stretch (0 → gray)")
607
+ stretch_btn.clicked.connect(lambda: self._push_product(which="resid", variant="stretched"))
608
+ lay.addWidget(stretch_btn, 0)
609
+
610
+ hint = QLabel("", row)
611
+ hint.setStyleSheet("color:#b58900; font-style: italic;"); hint.setVisible(False)
612
+ lay.addWidget(hint, 0, Qt.AlignmentFlag.AlignVCenter); lay.addStretch(1)
613
+ return row, hint
614
+
615
+ def _update_ring_estimate(self):
616
+ """Update the '≈ N rings' label (and ETA if we have a profile)."""
617
+ mn = float(self.minsma.value())
618
+ mx = float(self.maxsma.value())
619
+ st = max(1e-6, float(self.step.value()))
620
+ rings = int(max(0, (mx - mn) / st))
621
+
622
+ # Soft cap to ~10k rings by raising step if needed
623
+ if rings > 10000:
624
+ new_step = (mx - mn) / 10000.0
625
+ if new_step > st:
626
+ self.step.setValue(new_step)
627
+ st = new_step
628
+ rings = int(max(0, (mx - mn) / st))
629
+
630
+ ds = 4 if self.quick_preview.isChecked() else 1
631
+ txt = f"≈ {rings:,} rings"
632
+
633
+ # Seconds-per-ring profile (EMA-learned); scale from the other ds if missing
634
+ spr = self._perf.get(ds)
635
+ if spr is None:
636
+ other = 4 if ds == 1 else 1
637
+ if self._perf.get(other) is not None:
638
+ spr = self._perf[other] * ((other / ds) ** 2)
639
+
640
+ if spr is not None and rings > 0:
641
+ eta = self._humanize_secs(spr * rings)
642
+ txt += f" • est: {eta}" + (" (quick preview)" if ds > 1 else "")
643
+
644
+ self.ring_est_label.setText(txt)
645
+
646
+ def _humanize_secs(self, secs: float) -> str:
647
+ secs = max(0.0, float(secs))
648
+ if secs < 1.0:
649
+ return f"{secs:.2f}s"
650
+ m, s = divmod(int(round(secs)), 60)
651
+ if m == 0:
652
+ return f"{s}s"
653
+ h, m = divmod(m, 60)
654
+ if h == 0:
655
+ return f"{m}m {s}s"
656
+ return f"{h}h {m}m"
657
+
658
+ def _apply_geometry_to_overlay(self):
659
+ if self._ellipse_item is None:
660
+ return
661
+ self._create_or_update_overlay()
662
+
663
+ def _resid_to_disp01(self, resid, mask, pct=99.5):
664
+ r = np.nan_to_num(resid, 0.0, 0.0, 0.0).astype(np.float32)
665
+ abs_in = np.abs(r[mask])
666
+ S = float(np.percentile(abs_in, pct)) if abs_in.size else 1.0
667
+ if not np.isfinite(S) or S <= 0:
668
+ S = float(np.max(np.abs(r)) or 1.0)
669
+ disp = np.empty_like(self._img, dtype=np.float32)
670
+ disp[mask] = 0.5 + (r[mask] / (2.0 * S))
671
+ disp[~mask] = np.clip(self._img[~mask], 0.0, 1.0)
672
+ return np.clip(disp, 0.0, 1.0)
673
+
674
+ def _auto_estimate_from_moments(self):
675
+ img = np.nan_to_num(self._img, nan=0.0).astype(np.float64)
676
+ h, w = img.shape
677
+ yy, xx = np.mgrid[0:h, 0:w]
678
+ q = np.quantile(img, 0.80)
679
+ mask = img >= q
680
+ if not np.any(mask):
681
+ QMessageBox.information(self, "Auto-estimate", "Could not find bright core pixels.")
682
+ return
683
+ I = img[mask]; x = xx[mask]; y = yy[mask]
684
+ I_sum = I.sum()
685
+ cx = float((I * x).sum() / I_sum)
686
+ cy = float((I * y).sum() / I_sum)
687
+ x0 = x - cx; y0 = y - cy
688
+ cov_xx = float((I * x0 * x0).sum() / I_sum)
689
+ cov_yy = float((I * y0 * y0).sum() / I_sum)
690
+ cov_xy = float((I * x0 * y0).sum() / I_sum)
691
+ cov = np.array([[cov_xx, cov_xy],[cov_xy, cov_yy]])
692
+ evals, evecs = np.linalg.eigh(cov)
693
+ order = np.argsort(evals)[::-1]
694
+ evals = evals[order]; evecs = evecs[:, order]
695
+ sigma_a = np.sqrt(max(evals[0], 1e-6))
696
+ sigma_b = np.sqrt(max(evals[1], 1e-6))
697
+ axis_ratio = float(np.clip(sigma_b / sigma_a, 1e-3, 0.999))
698
+ eps = 1.0 - axis_ratio
699
+ vx, vy = evecs[0,0], evecs[1,0]
700
+ pa_deg = float(np.rad2deg(np.arctan2(vy, vx)))
701
+ sma = float(2.5 * sigma_a)
702
+ self._set_center(cx, cy)
703
+ self.eps.setValue(min(0.95, max(0.0, eps)))
704
+ self.pa_deg.setValue(pa_deg)
705
+ self.sma0.setValue(max(5.0, min(sma, min(h, w)/1.2)))
706
+
707
+
708
+ def _normalize01_for_push(self, arr: np.ndarray):
709
+ a = np.asarray(arr, dtype=np.float32)
710
+ a = np.nan_to_num(a, nan=0.0, posinf=0.0, neginf=0.0)
711
+ vmin = float(a.min()); vmax = float(a.max())
712
+ if not np.isfinite(vmin) or not np.isfinite(vmax) or vmax <= vmin + 1e-12:
713
+ return np.zeros_like(a, dtype=np.float32), vmin, vmax
714
+ out = (a - vmin) / (vmax - vmin)
715
+ return out.astype(np.float32, copy=False), vmin, vmax
716
+
717
+ def _residual_preview_stretch01(self, resid: np.ndarray, pct: float = 99.5):
718
+ r = np.nan_to_num(resid, nan=0.0, posinf=0.0, neginf=0.0).astype(np.float32)
719
+ cx, cy, sma, eps, pa_deg = self._fit_boundary()
720
+ _, m = self._elliptical_alpha(r.shape, cx, cy, sma, eps, pa_deg, feather_frac=0.04)
721
+ inside = (m <= 1.0)
722
+ abs_in = np.abs(r[inside]) if inside.any() else np.abs(r)
723
+ S = float(np.percentile(abs_in, pct)) if abs_in.size else 1.0
724
+ if not np.isfinite(S) or S <= 0:
725
+ S = float(np.max(np.abs(r)) or 1.0)
726
+ out01 = np.clip(0.5 + (r / (2.0 * S)), 0.0, 1.0).astype(np.float32, copy=False)
727
+ return out01, S
728
+
729
+ def _pix_from_01(self, img01):
730
+ vals = np.nan_to_num(img01, nan=0.0, posinf=1.0, neginf=0.0)
731
+ u8 = (np.clip(vals, 0.0, 1.0) * 255.0).astype(np.uint8)
732
+ h, w = u8.shape
733
+ qimg = QImage(u8.data, w, h, w, QImage.Format.Format_Grayscale8)
734
+ return QPixmap.fromImage(qimg.copy())
735
+
736
+ def _np_to_qpix_linear01(self, img: np.ndarray) -> QPixmap:
737
+ img = np.nan_to_num(img, 0.0, 0.0, 0.0)
738
+ u8 = np.clip(img * 255.0, 0, 255).astype(np.uint8)
739
+ h, w = u8.shape
740
+ qimg = QImage(u8.data, w, h, w, QImage.Format.Format_Grayscale8)
741
+ return QPixmap.fromImage(qimg.copy())
742
+
743
+ def _enforce_sma_order(self):
744
+ changed = False
745
+ if self.minsma.value() > self.sma0.value():
746
+ self.minsma.setValue(self.sma0.value()); changed = True
747
+ if self.sma0.value() > self.maxsma.value():
748
+ self.sma0.setValue(self.maxsma.value()); changed = True
749
+ if changed:
750
+ self._create_or_update_overlay()
751
+
752
+ def _ellipse_mask(self, shape, cx, cy, sma, eps, pa_deg):
753
+ h, w = shape
754
+ a = float(max(1.0, sma))
755
+ b = float(max(1.0, a * (1.0 - eps)))
756
+ pa = np.deg2rad(pa_deg)
757
+ yy, xx = np.mgrid[0:h, 0:w]
758
+ x0 = xx - cx; y0 = yy - cy
759
+ c, s = np.cos(pa), np.sin(pa)
760
+ xr = x0 * c + y0 * s
761
+ yr = -x0 * s + y0 * c
762
+ return (xr / a) ** 2 + (yr / b) ** 2 <= 1.0
763
+
764
+ def _create_or_update_overlay(self):
765
+ if self.left.scene() is None:
766
+ return
767
+ a0 = max(1.0, float(self.sma0.value()))
768
+ b0 = max(1.0, a0 * (1.0 - float(self.eps.value())))
769
+ rect0 = QRectF(self._cx - a0, self._cy - b0, 2*a0, 2*b0)
770
+
771
+ if getattr(self, "_ellipse_item", None) is None:
772
+ self._ellipse_item = DraggableEllipse(rect0, on_center_moved=self._set_center)
773
+ self._ellipse_item.setTransformOriginPoint(self._ellipse_item.rect().center())
774
+ self._ellipse_item.setRotation(self.pa_deg.value())
775
+ self.left.scene().addItem(self._ellipse_item)
776
+ else:
777
+ c = self._ellipse_item.center_scene()
778
+ self._ellipse_item.setRect(rect0)
779
+ self._ellipse_item.setTransformOriginPoint(self._ellipse_item.rect().center())
780
+ self._ellipse_item.setRotation(self.pa_deg.value())
781
+ self._ellipse_item.set_center_scene(c)
782
+
783
+ aI = max(1.0, float(self.minsma.value()))
784
+ bI = max(1.0, aI * (1.0 - float(self.eps.value())))
785
+ rectI = QRectF(self._cx - aI, self._cy - bI, 2*aI, 2*bI)
786
+ if self._min_item is None:
787
+ self._min_item = QGraphicsEllipseItem(rectI)
788
+ penI = QPen(Qt.GlobalColor.magenta); penI.setWidthF(1.0); penI.setStyle(Qt.PenStyle.DotLine)
789
+ self._min_item.setPen(penI); self._min_item.setBrush(QBrush(Qt.BrushStyle.NoBrush))
790
+ self._min_item.setZValue(8); self.left.scene().addItem(self._min_item)
791
+ else:
792
+ self._min_item.setRect(rectI)
793
+ self._min_item.setTransformOriginPoint(self._min_item.rect().center())
794
+ self._min_item.setRotation(self.pa_deg.value())
795
+
796
+ aM = max(1.0, float(self.maxsma.value()))
797
+ bM = max(1.0, aM * (1.0 - float(self.eps.value())))
798
+ rectM = QRectF(self._cx - aM, self._cy - bM, 2*aM, 2*bM)
799
+ if self._max_item is None:
800
+ self._max_item = QGraphicsEllipseItem(rectM)
801
+ penM = QPen(Qt.GlobalColor.yellow); penM.setWidthF(1.0); penM.setStyle(Qt.PenStyle.DashLine)
802
+ self._max_item.setPen(penM); self._max_item.setBrush(QBrush(Qt.BrushStyle.NoBrush))
803
+ self._max_item.setZValue(9); self.left.scene().addItem(self._max_item)
804
+ else:
805
+ self._max_item.setRect(rectM)
806
+ self._max_item.setTransformOriginPoint(self._max_item.rect().center())
807
+ self._max_item.setRotation(self.pa_deg.value())
808
+
809
+ self._update_wedge_overlay()
810
+
811
+ def eventFilter(self, obj, ev):
812
+ if obj is self.left.scene() and getattr(self, "_ellipse_item", None) is not None:
813
+ if ev.type() == QEvent.Type.GraphicsSceneMouseRelease:
814
+ c = self._ellipse_item.center_scene()
815
+ self._set_center(c.x(), c.y())
816
+ return super().eventFilter(obj, ev)
817
+
818
+ def _update_wedge_overlay(self):
819
+ if getattr(self, "_wedge_item", None) and self.left.scene():
820
+ self.left.scene().removeItem(self._wedge_item); self._wedge_item = None
821
+ if not self.use_wedge.isChecked() or self.left.scene() is None:
822
+ return
823
+ cx, cy = self._cx, self._cy
824
+ pa = np.deg2rad(self.wedge_pa.value())
825
+ half = np.deg2rad(self.wedge_width.value()/2.0)
826
+ h, w = self._img.shape
827
+ R = float(np.hypot(w, h))
828
+ path = QPainterPath(QPointF(cx, cy))
829
+ path.arcTo(cx-R, cy-R, 2*R, 2*R, -np.rad2deg(pa-half), self.wedge_width.value())
830
+ path.lineTo(QPointF(cx, cy))
831
+ item = QGraphicsPathItem(path)
832
+ item.setOpacity(0.2)
833
+ item.setBrush(QBrush(Qt.BrushStyle.Dense4Pattern))
834
+ item.setPen(QPen(Qt.PenStyle.NoPen))
835
+ self.left.scene().addItem(item)
836
+ self._wedge_item = item
837
+
838
+ def _make_wedge_mask(self, h, w):
839
+ if not self.use_wedge.isChecked():
840
+ return np.zeros((h, w), dtype=bool)
841
+ cx, cy = self._cx, self._cy
842
+ pa = np.deg2rad(self.wedge_pa.value())
843
+ half = np.deg2rad(self.wedge_width.value()/2.0)
844
+ yy, xx = np.mgrid[0:h, 0:w]
845
+ ang = np.arctan2(yy - cy, xx - cx)
846
+ d = np.arctan2(np.sin(ang - pa), np.cos(ang - pa))
847
+ return np.abs(d) <= half
848
+
849
+ def _help_row(self, ctrl: QWidget, help_text: str) -> QWidget:
850
+ row = QWidget(self); lay = QHBoxLayout(row); lay.setContentsMargins(0, 0, 0, 0)
851
+ lay.addWidget(ctrl, 1)
852
+ btn = QToolButton(row); btn.setAutoRaise(True)
853
+ btn.setCursor(Qt.CursorShape.PointingHandCursor)
854
+ btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MessageBoxQuestion))
855
+ btn.setFixedSize(QSize(18, 18)); btn.setIconSize(QSize(14, 14))
856
+ btn.setToolTip(help_text); ctrl.setToolTip(help_text); ctrl.setWhatsThis(help_text)
857
+ title = ctrl.text() if hasattr(ctrl, "text") else "Help"
858
+ btn.clicked.connect(lambda: QMessageBox.information(self, title, help_text))
859
+ lay.addWidget(btn, 0)
860
+ return row
861
+
862
+ def _set_center(self, x: float, y: float):
863
+ h, w = self._img.shape
864
+ self._cx = float(np.clip(x, 0, w-1)); self._cy = float(np.clip(y, 0, h-1))
865
+ self.center_label.setText(f"Center: ({self._cx:.1f}, {self._cy:.1f})")
866
+ self._update_wedge_overlay(); self._create_or_update_overlay()
867
+
868
+ def _on_left_click(self, x, y):
869
+ self._set_center(x, y)
870
+
871
+ # ---------- fit / preview ----------
872
+ def _run_fit(self):
873
+ p = dict(
874
+ cx=self._cx, cy=self._cy,
875
+ sma0=float(self.sma0.value()),
876
+ minsma=float(self.minsma.value()),
877
+ maxsma=float(self.maxsma.value()),
878
+ step=float(self.step.value()),
879
+ sclip=float(self.sclip.value()),
880
+ nclip=float(self.nclip.value()),
881
+ eps=float(self.eps.value()),
882
+ pa_deg=float(self.pa_deg.value()),
883
+ fix_center=self.fix_center.isChecked(),
884
+ fix_pa=self.fix_pa.isChecked(),
885
+ fix_eps=self.fix_eps.isChecked(),
886
+ high_harm=self.high_harm.isChecked(),
887
+ use_wedge=self.use_wedge.isChecked(),
888
+ wedge_pa=float(self.wedge_pa.value()),
889
+ wedge_width=float(self.wedge_width.value()),
890
+ hq_interp=self.hq_interp.isChecked(),
891
+ downsample=4 if self.quick_preview.isChecked() else 1,
892
+ )
893
+
894
+ n_est = int(max(0, (p["maxsma"] - p["minsma"]) / max(1e-6, p["step"])))
895
+ ds = int(max(1, p["downsample"]))
896
+ self._last_run_timer = (time.perf_counter(), n_est, ds)
897
+
898
+ spr = self._perf.get(ds)
899
+ if spr is None:
900
+ other = 4 if ds == 1 else 1
901
+ other_spr = self._perf.get(other)
902
+ if other_spr is not None:
903
+ spr = other_spr * ((other / ds) ** 2)
904
+ busy_hint = ""
905
+ if spr is not None and n_est > 0:
906
+ eta_sec = spr * n_est
907
+ s = max(0.0, float(eta_sec))
908
+ if s < 1.0: eta = f"{s:.1f}s"
909
+ else:
910
+ m, s = divmod(int(round(s)), 60)
911
+ if m < 1: eta = f"{s:d}s"
912
+ else:
913
+ h, m = divmod(m, 60)
914
+ eta = f"{m:d}m {s:d}s" if h < 1 else f"{h:d}h {m:d}m"
915
+ busy_hint = f" (~{n_est:,} rings, est {eta})"
916
+
917
+ self._last_fit_params = dict(p)
918
+ self._busy = QProgressDialog(f"Fitting…{busy_hint}", None, 0, 100, self)
919
+ self._busy.setWindowModality(Qt.WindowModality.ApplicationModal)
920
+ self._busy.setAutoClose(False); self._busy.setAutoReset(False)
921
+ self._busy.show()
922
+
923
+ fit_img = self._in01
924
+ self._thread = QThread(self)
925
+ self._worker = _FitWorker(fit_img, p)
926
+ self._worker.moveToThread(self._thread)
927
+ self._thread.started.connect(self._worker.run)
928
+ self._worker.progress.connect(lambda pct, msg: (self._busy.setValue(pct), self._busy.setLabelText(msg)))
929
+ self._worker.finished.connect(self._on_fit_finished)
930
+ self._worker.error.connect(self._on_fit_error)
931
+ self._worker.finished.connect(self._thread.quit)
932
+ self._worker.finished.connect(self._worker.deleteLater)
933
+ self._thread.finished.connect(self._thread.deleteLater)
934
+ self._thread.start()
935
+
936
+ def _build_preview_cache_from_fit(self):
937
+ cx, cy, sma, eps, pa_deg = self._fit_boundary()
938
+ alpha, m = self._elliptical_alpha(self._resid.shape, cx, cy, sma, eps, pa_deg, feather_frac=0.04)
939
+ r = np.nan_to_num(self._resid, 0.0, 0.0, 0.0).astype(np.float32, copy=False)
940
+ inside = (m <= 1.0)
941
+ abs_in = np.abs(r[inside]) if inside.any() else np.abs(r)
942
+ S = float(np.percentile(abs_in, 99.5)) if abs_in.size else 1.0
943
+ if not np.isfinite(S) or S <= 0:
944
+ S = float(np.max(np.abs(r)) or 1.0)
945
+ self._alpha_fit = alpha
946
+ self._resid01_fit = np.clip(0.5 + (r/(2.0*S)), 0.0, 1.0)
947
+ self._S_fit = S
948
+
949
+ def _rebuild_right_preview(self):
950
+ if self._resid is None:
951
+ return
952
+ orig01 = getattr(self, "_orig01", np.clip(self._img, 0.0, 1.0).astype(np.float32))
953
+ if self.preview_blend.isChecked():
954
+ disp01 = self._alpha_fit * self._resid01_fit + (1.0 - self._alpha_fit) * orig01
955
+ else:
956
+ disp01 = self._resid01_fit
957
+ self._preview_right01 = disp01.astype(np.float32, copy=True)
958
+ self.right.setSceneImage(self._pix_from_01(disp01), self._resid.shape)
959
+
960
+ def _refresh_preview(self):
961
+ if self._resid is None:
962
+ return
963
+ if getattr(self, "_in01", None) is None:
964
+ self._in01 = self._compute_input01()
965
+ cx, cy, sma, eps, pa_deg = self._fit_boundary()
966
+ alpha, m = self._elliptical_alpha(self._resid.shape, cx, cy, sma, eps, pa_deg, feather_frac=0.04)
967
+ r = np.nan_to_num(self._resid, 0.0, 0.0, 0.0).astype(np.float32)
968
+ inside = (m <= 1.0)
969
+ if self.use_wedge.isChecked():
970
+ inside &= ~self._make_wedge_mask(*self._resid.shape)
971
+ abs_in = np.abs(r[inside]) if inside.any() else np.abs(r)
972
+ S = float(np.percentile(abs_in, 99.5)) if abs_in.size else 1.0
973
+ if not np.isfinite(S) or S <= 0:
974
+ S = float(np.max(np.abs(r)) or 1.0)
975
+ self._last_preview_scale_S = S
976
+ resid01 = np.clip(0.5 + (r / (2.0 * S)), 0.0, 1.0)
977
+ orig01 = self._in01
978
+ disp01 = alpha * resid01 + (1.0 - alpha) * orig01 if self.preview_blend.isChecked() else resid01
979
+ self._preview_right01 = disp01.astype(np.float32, copy=True)
980
+ self.right.setSceneImage(self._pix_from_01(disp01), self._resid.shape)
981
+
982
+ def _fit_boundary(self):
983
+ if getattr(self, "_isolist", None):
984
+ for iso in reversed(self._isolist):
985
+ cx = float(getattr(iso, "x0", getattr(iso, "x0_center", np.nan)))
986
+ cy = float(getattr(iso, "y0", getattr(iso, "y0_center", np.nan)))
987
+ sma = float(getattr(iso, "sma", getattr(iso, "sma0", np.nan)))
988
+ eps = float(getattr(iso, "eps", getattr(iso, "ellipticity", np.nan)))
989
+ pa = float(getattr(iso, "pa", getattr(iso, "position_angle", np.nan)))
990
+ if (np.isfinite([cx, cy, sma, eps, pa]).all() and sma > 0.0 and 0.0 <= eps < 1.0):
991
+ return cx, cy, sma, float(np.clip(eps, 0.0, 0.95)), float(np.rad2deg(pa))
992
+ return (self._cx, self._cy,
993
+ float(self.maxsma.value()),
994
+ float(self.eps.value()),
995
+ float(self.pa_deg.value()))
996
+
997
+ def _elliptical_alpha(self, shape, cx, cy, sma, eps, pa_deg, feather_frac=0.04):
998
+ if not np.isfinite(pa_deg):
999
+ pa_deg = float(self.pa_deg.value())
1000
+ a = float(max(1.0, sma))
1001
+ b = float(max(1.0, a * (1.0 - float(eps))))
1002
+ th = np.deg2rad(pa_deg)
1003
+ h, w = shape
1004
+ yy, xx = np.mgrid[0:h, 0:w]
1005
+ x = xx - cx; y = yy - cy
1006
+ c, s = np.cos(th), np.sin(th)
1007
+ xr = x*c + y*s
1008
+ yr = -x*s + y*c
1009
+ m = np.sqrt((xr/a)**2 + (yr/b)**2)
1010
+ band = max(1e-3, float(feather_frac))
1011
+ alpha = np.clip((1.0 - m) / band, 0.0, 1.0)
1012
+ return alpha, m
1013
+
1014
+ def _on_fit_finished(self, model, resid, isolist):
1015
+ if self._last_run_timer is not None:
1016
+ start_t, rings, ds = self._last_run_timer
1017
+ self._last_run_timer = None
1018
+ if rings > 0:
1019
+ elapsed = max(0.0, time.perf_counter() - start_t)
1020
+ spr_meas = elapsed / rings
1021
+ prev = self._perf.get(ds)
1022
+ alpha = 0.35
1023
+ self._perf[ds] = spr_meas if prev is None else (alpha * spr_meas + (1 - alpha) * prev)
1024
+ self._update_lowres_hints()
1025
+ self._model = model; self._resid = resid; self._isolist = isolist
1026
+ if hasattr(self, "_busy") and self._busy is not None:
1027
+ self._busy.close(); self._busy = None
1028
+ self._build_preview_cache_from_fit(); self._rebuild_right_preview()
1029
+
1030
+ def _on_fit_error(self, msg):
1031
+ if hasattr(self, "_busy") and self._busy is not None:
1032
+ self._busy.close(); self._busy = None
1033
+ self._last_run_timer = None
1034
+ QMessageBox.critical(self, "Isophote Fit Error", msg)
1035
+
1036
+ # ---------- export (push to doc_manager) ----------
1037
+ def _push_product(self, which: str, variant: str):
1038
+ if self.doc_manager is None:
1039
+ QMessageBox.information(self, "GLIMR", "No document manager available.")
1040
+ return
1041
+ arr = self._resid if which == "resid" else self._model
1042
+ if arr is None:
1043
+ QMessageBox.information(self, "GLIMR", f"Run the fit first to generate the {which}.")
1044
+ return
1045
+
1046
+ step_name = f"Isophote {which.capitalize()}"
1047
+ ds = int(max(1, (self._last_fit_params or {}).get("downsample", 1)))
1048
+ if ds > 1: step_name += " (quick preview)"
1049
+
1050
+ meta_common = {
1051
+ "from": "GLIMR",
1052
+ "product": which,
1053
+ "downsample": ds,
1054
+ "isophote_params": (
1055
+ {k: self._last_fit_params.get(k) for k in (
1056
+ "cx","cy","sma0","minsma","maxsma","step","sclip","nclip",
1057
+ "eps","pa_deg","fix_center","fix_pa","fix_eps",
1058
+ "high_harm","use_wedge","wedge_pa","wedge_width","hq_interp","downsample"
1059
+ )} if self._last_fit_params else None
1060
+ )
1061
+ }
1062
+
1063
+ title = ""
1064
+ if which == "resid" and variant == "visible":
1065
+ if self._preview_right01 is None:
1066
+ self._refresh_preview()
1067
+ data01 = np.clip(np.nan_to_num(self._preview_right01, nan=0.0, posinf=1.0, neginf=0.0), 0.0, 1.0).astype(np.float32)
1068
+ meta = {**meta_common, "push_variant": "visible_preview",
1069
+ "preview_blend": bool(self.preview_blend.isChecked()),
1070
+ "feather_frac": 0.04,
1071
+ "note": "Exact right-pane display"}
1072
+ title = "GLIMR Residual (visible)"
1073
+ elif which == "resid" and variant == "stretched":
1074
+ data01, S = self._residual_preview_stretch01(arr, pct=99.5)
1075
+ meta = {**meta_common, "push_variant": "preview_stretch",
1076
+ "stretch_pct": 99.5, "stretch_scale_S": float(S),
1077
+ "zero_maps_to_gray_0p5": True}
1078
+ title = "GLIMR Residual (stretched)"
1079
+ else:
1080
+ # normalized (min→0, max→1), optionally shift residuals to ≥0 first
1081
+ data = np.asarray(arr, dtype=np.float32)
1082
+ if which == "resid" and self.save_resid_shifted.isChecked():
1083
+ mn = float(np.nanmin(data))
1084
+ if np.isfinite(mn) and mn < 0.0:
1085
+ data = data - mn
1086
+ data01, vmin, vmax = self._normalize01_for_push(data)
1087
+ meta = {**meta_common, "push_variant": "normalized_01",
1088
+ "source_range_min": float(vmin), "source_range_max": float(vmax)}
1089
+ title = f"GLIMR {which.capitalize()}"
1090
+ if ds > 1: title += " (quick)"
1091
+
1092
+ ok = self._push_array_to_doc_manager(data01, title, meta)
1093
+ if not ok:
1094
+ QMessageBox.warning(self, "GLIMR", "Could not create a new document in doc manager.")
1095
+
1096
+ def _push_array_to_doc_manager(self, arr01: np.ndarray, title: str, meta: dict) -> bool:
1097
+ """Create a brand-new document in your DocManager."""
1098
+ dm = self.doc_manager
1099
+ if dm is None:
1100
+ return False
1101
+
1102
+ # ensure float32 [0..1]
1103
+ img = np.asarray(arr01, dtype=np.float32)
1104
+
1105
+ # 1) Preferred: open_array(img, metadata=None, title=None)
1106
+ fn = getattr(dm, "open_array", None)
1107
+ if callable(fn):
1108
+ try:
1109
+ fn(img, metadata=dict(meta or {}), title=title)
1110
+ return True
1111
+ except Exception:
1112
+ pass
1113
+
1114
+ # 2) Also supported in your manager: create_document(image, metadata=None, name=None)
1115
+ fn = getattr(dm, "create_document", None)
1116
+ if callable(fn):
1117
+ try:
1118
+ fn(image=img, metadata=dict(meta or {}), name=title)
1119
+ return True
1120
+ except Exception:
1121
+ pass
1122
+
1123
+ # 3) Alias present: open_numpy == open_array (same signature)
1124
+ fn = getattr(dm, "open_numpy", None)
1125
+ if callable(fn):
1126
+ try:
1127
+ fn(img, metadata=dict(meta or {}), title=title)
1128
+ return True
1129
+ except Exception:
1130
+ pass
1131
+
1132
+ # If none of the above worked, report failure
1133
+ return False
1134
+
1135
+
1136
+ # ---------- save ----------
1137
+ def _save_fits(self, which="resid"):
1138
+ arr = self._resid if which == "resid" else self._model
1139
+ if arr is None:
1140
+ QMessageBox.information(self, "Nothing to save",
1141
+ f"Run the fit first to generate the {which}.")
1142
+ return
1143
+ fn, _ = QFileDialog.getSaveFileName(self, f"Save {which} FITS", f"{which}.fits", "FITS files (*.fits *.fit)")
1144
+ if not fn:
1145
+ return
1146
+ try:
1147
+ data = np.asarray(arr, dtype=np.float32)
1148
+ orig_min = float(np.nanmin(data)); orig_max = float(np.nanmax(data))
1149
+ pedestal = 0.0
1150
+ if which == "resid" and self.save_resid_shifted.isChecked():
1151
+ if np.isfinite(orig_min) and orig_min < 0.0:
1152
+ pedestal = -orig_min; data = data + pedestal
1153
+ hdr = fits.Header()
1154
+ hdr["CREATOR"] = ("GLIMR", "SASpro Isophote Modeler")
1155
+ hdr["PRODUCT"] = (which, "model or resid")
1156
+ hdr["ORIGMIN"] = (orig_min, "Min before any pedestal")
1157
+ hdr["ORIGMAX"] = (orig_max, "Max before any pedestal")
1158
+ hdr["PEDESTAL"] = (float(pedestal), "Added so min(data)>=0 at save time")
1159
+ ds = 1
1160
+ if getattr(self, "_last_fit_params", None):
1161
+ ds = int(max(1, self._last_fit_params.get("downsample", 1)))
1162
+ hdr["DSFACTOR"] = (ds, "Downsample factor used for fit (then upsampled)")
1163
+ p = self._last_fit_params or {}
1164
+ hdr["ISO_EPS"] = (float(p.get("eps", np.nan)), "Seed ellipticity")
1165
+ hdr["ISO_PA"] = (float(p.get("pa_deg", np.nan)), "Seed PA (deg)")
1166
+ hdr["ISO_SMA0"] = (float(p.get("sma0", np.nan)), "Initial SMA (px)")
1167
+ hdr["ISO_MIN"] = (float(p.get("minsma", np.nan)), "Min SMA (px)")
1168
+ hdr["ISO_MAX"] = (float(p.get("maxsma", np.nan)), "Max SMA (px)")
1169
+ hdr["ISO_STEP"] = (float(p.get("step", np.nan)), "SMA step (px)")
1170
+ hdr["ISO_SCLIP"] = (float(p.get("sclip", np.nan)), "Sigma clip")
1171
+ hdr["ISO_NCLIP"] = (int(p.get("nclip", 0)), "Sigma clip iters")
1172
+ hdr["ISO_FXC"] = (bool(p.get("fix_center", False)), "Fix center")
1173
+ hdr["ISO_FPA"] = (bool(p.get("fix_pa", False)), "Fix PA")
1174
+ hdr["ISO_FEPS"] = (bool(p.get("fix_eps", False)), "Fix ellipticity")
1175
+ hdr["ISO_HARM"] = (bool(p.get("high_harm", False)), "Use a3/b3/a4/b4")
1176
+ hdr["ISO_WEDGE"] = (bool(p.get("use_wedge", False)), "Exclude wedge")
1177
+ if p.get("use_wedge", False):
1178
+ hdr["ISO_WPA"] = (float(p.get("wedge_pa", np.nan)), "Wedge PA (deg)")
1179
+ hdr["ISO_WWID"] = (float(p.get("wedge_width", np.nan)),"Wedge width (deg)")
1180
+ fits.PrimaryHDU(data.astype(np.float32), header=hdr).writeto(fn, overwrite=True)
1181
+ except Exception as e:
1182
+ QMessageBox.critical(self, "Save Error", str(e))