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,1604 @@
1
+ # pro/image_peeker_pro.py
2
+ from __future__ import annotations
3
+ import os
4
+ import math
5
+ import re
6
+ import tempfile
7
+ import numpy as np
8
+
9
+ from typing import Optional, Tuple, List
10
+
11
+ from PyQt6.QtGui import (
12
+ QIcon, QColor, QPixmap, QPainter, QPen, QImage, QPainterPath, QFont, QGuiApplication
13
+ )
14
+ from PyQt6.QtCore import Qt, QTimer, pyqtSignal, QPoint, QEvent, QPointF, QCoreApplication
15
+ from PyQt6.QtWidgets import (
16
+ QDialog, QVBoxLayout, QHBoxLayout, QGridLayout, QGroupBox, QLabel, QSlider,
17
+ QPushButton, QComboBox, QSizePolicy, QMessageBox, QColorDialog, QWidget,
18
+ QScrollArea, QScrollBar, QMdiSubWindow, QGraphicsScene, QGraphicsView,
19
+ QGraphicsTextItem, QTableWidget, QTableWidgetItem, QLineEdit, QToolButton,
20
+ QSpinBox, QDoubleSpinBox
21
+ )
22
+ from PyQt6.QtGui import QDoubleValidator, QIntValidator
23
+
24
+ from matplotlib.figure import Figure
25
+ from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
26
+
27
+ from astropy.io import fits
28
+ from astropy.stats import sigma_clipped_stats
29
+ from scipy.interpolate import griddata
30
+
31
+ import sep
32
+ from setiastro.saspro.widgets.themed_buttons import themed_toolbtn
33
+
34
+ # bring in your existing helpers/classes from the snippet you posted
35
+ # (we assume they live next to this file or already in pro/)
36
+ from .plate_solver import plate_solve_doc_inplace
37
+ from setiastro.saspro.imageops.stretch import stretch_mono_image, stretch_color_image
38
+ from astropy.wcs import WCS
39
+ from .plate_solver import _seed_header_from_meta, _solve_numpy_with_fallback
40
+ from astropy.wcs import WCS
41
+
42
+ def _header_from_meta(meta):
43
+ # Prefer real Header
44
+ hdr = _ensure_fits_header(meta.get("original_header"))
45
+ if hdr is not None:
46
+ return hdr
47
+
48
+ # Next try stored WCS header
49
+ wh = meta.get("wcs_header")
50
+ if isinstance(wh, fits.Header):
51
+ return wh
52
+ if isinstance(wh, dict):
53
+ try:
54
+ return fits.Header(wh)
55
+ except Exception:
56
+ pass
57
+
58
+ # Finally try astropy WCS object
59
+ w = meta.get("wcs")
60
+ if isinstance(w, WCS):
61
+ try:
62
+ return w.to_header(relax=True)
63
+ except Exception:
64
+ pass
65
+
66
+ return None
67
+
68
+
69
+ class PreviewPane(QWidget):
70
+ def __init__(self, parent=None):
71
+ super().__init__(parent)
72
+ self.zoom_factor = 1.0
73
+ self.is_autostretched = False
74
+ self._image_array = None
75
+ self.original_image = None # QImage
76
+ self.stretched_image = None # QImage
77
+ self._panning = False
78
+ self._pan_start = QPoint()
79
+ self._h_scroll_start = 0
80
+ self._v_scroll_start = 0
81
+
82
+ # the scrollable image area
83
+ self.image_label = QLabel()
84
+ self.image_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
85
+ self.scroll_area = QScrollArea()
86
+ self.scroll_area.setWidget(self.image_label)
87
+ self.scroll_area.setWidgetResizable(True)
88
+ self.scroll_area.setMinimumSize(450, 450)
89
+ self.scroll_area.viewport().installEventFilter(self)
90
+
91
+ # zoom controls
92
+ self.zoom_slider = QSlider(Qt.Orientation.Horizontal)
93
+ self.zoom_slider.setRange(1, 400)
94
+ self.zoom_slider.setValue(100)
95
+ self.zoom_slider.valueChanged.connect(self.on_zoom_changed)
96
+
97
+ self.zoom_in_btn = themed_toolbtn("zoom-in", self.tr("Zoom In"))
98
+ self.zoom_out_btn = themed_toolbtn("zoom-out", self.tr("Zoom Out"))
99
+ self.fit_btn = themed_toolbtn("zoom-fit-best", self.tr("Fit to Preview"))
100
+ self.zoom_in_btn.clicked .connect(lambda: self.adjust_zoom(10))
101
+ self.zoom_out_btn.clicked.connect(lambda: self.adjust_zoom(-10))
102
+ self.fit_btn.clicked .connect(self.fit_to_view)
103
+
104
+ self.stretch_btn = QPushButton(self.tr("AutoStretch"))
105
+ self.stretch_btn.clicked.connect(self.toggle_stretch)
106
+
107
+ zl = QHBoxLayout()
108
+ zl.addWidget(self.zoom_out_btn)
109
+ zl.addWidget(self.zoom_slider)
110
+ zl.addWidget(self.zoom_in_btn)
111
+ zl.addWidget(self.fit_btn)
112
+ zl.addWidget(self.stretch_btn)
113
+
114
+ layout = QVBoxLayout(self)
115
+ layout.addWidget(self.scroll_area, 1)
116
+ layout.addLayout(zl)
117
+
118
+ self.fit_to_view()
119
+
120
+ def load_qimage(self, img: QImage):
121
+ """
122
+ Call this to (re)load a fresh image.
123
+ We immediately convert it to a numpy array once
124
+ so we never have to touch the QImage bits again.
125
+ """
126
+ # keep a local copy of the QImage (for fast redisplay)
127
+ self.original_image = img.copy()
128
+
129
+ # one & only time we go QImage→numpy
130
+ self._image_array = self.qimage_to_numpy(self.original_image)
131
+
132
+ # reset any existing stretch state
133
+ self.stretched_image = None
134
+ self.is_autostretched = False
135
+ self.zoom_factor = 1.0
136
+ self.zoom_slider.setValue(100)
137
+
138
+ self._update_display()
139
+
140
+ def set_overlay(self, overlays):
141
+ """ Store and repaint overlays on top of the image. """
142
+ self._overlays = overlays
143
+ self._update_display()
144
+
145
+ def toggle_stretch(self):
146
+ if self._image_array is None:
147
+ return
148
+
149
+ self.is_autostretched = not self.is_autostretched
150
+
151
+ if self.is_autostretched:
152
+ # stretch the stored numpy array
153
+ arr = self._image_array.copy()
154
+ if arr.ndim == 2:
155
+ stretched = stretch_mono_image(
156
+ arr,
157
+ target_median=0.25,
158
+ normalize=True,
159
+ apply_curves=False
160
+ )
161
+ else:
162
+ stretched = stretch_color_image(
163
+ arr,
164
+ target_median=0.25,
165
+ linked=False,
166
+ normalize=True,
167
+ apply_curves=False
168
+ )
169
+
170
+ # convert back to a QImage for display
171
+ self.stretched_image = self.numpy_to_qimage(stretched).copy()
172
+ else:
173
+ # go back to the original QImage
174
+ self.stretched_image = self.original_image.copy()
175
+
176
+ self._update_display()
177
+
178
+ def qimage_to_numpy(self, qimg: QImage) -> np.ndarray:
179
+ """
180
+ Safely copy a QImage into a contiguous numpy array,
181
+ and return float32 data normalized to [0.0, 1.0].
182
+ Supports Grayscale8 and RGB888.
183
+ """
184
+ # force a copy & right format
185
+ if qimg.format() == QImage.Format.Format_Grayscale8:
186
+ img = qimg.convertToFormat(QImage.Format.Format_Grayscale8).copy()
187
+ w, h = img.width(), img.height()
188
+ ptr = img.bits()
189
+ ptr.setsize(h * w)
190
+ buf = ptr.asstring()
191
+ arr = np.frombuffer(buf, np.uint8).reshape((h, w))
192
+ else:
193
+ img = qimg.convertToFormat(QImage.Format.Format_RGB888).copy()
194
+ w, h = img.width(), img.height()
195
+ bpl = img.bytesPerLine()
196
+ ptr = img.bits()
197
+ ptr.setsize(h * bpl)
198
+ buf = ptr.asstring()
199
+ raw = np.frombuffer(buf, np.uint8).reshape((h, bpl))
200
+ raw = raw[:, : 3*w]
201
+ arr = raw.reshape((h, w, 3))
202
+
203
+ # **normalize to float32 [0..1]**
204
+ return (arr.astype(np.float32) / 255.0)
205
+
206
+ def numpy_to_qimage(self, arr: np.ndarray) -> QImage:
207
+ """
208
+ Convert a H×W or H×W×3 numpy array (float in [0..1] or uint8 in [0..255])
209
+ into a QImage (copying the buffer).
210
+ """
211
+ # If floating point, assume 0..1 and scale up:
212
+ if np.issubdtype(arr.dtype, np.floating):
213
+ arr = np.clip(arr * 255.0, 0, 255).astype(np.uint8)
214
+ # Otherwise convert any other integer type to uint8
215
+ elif arr.dtype != np.uint8:
216
+ arr = np.clip(arr, 0, 255).astype(np.uint8)
217
+
218
+ h, w = arr.shape[:2]
219
+ if arr.ndim == 2:
220
+ img = QImage(arr.data, w, h, w, QImage.Format.Format_Grayscale8)
221
+ return img.copy()
222
+ elif arr.ndim == 3 and arr.shape[2] == 3:
223
+ bytes_per_line = 3 * w
224
+ img = QImage(arr.data, w, h, bytes_per_line, QImage.Format.Format_RGB888)
225
+ return img.copy()
226
+ else:
227
+ raise ValueError(f"Cannot convert array of shape {arr.shape} to QImage")
228
+
229
+ def on_zoom_changed(self, val):
230
+ self.zoom_factor = val/100
231
+ self._update_display()
232
+
233
+ def adjust_zoom(self, delta):
234
+ v = self.zoom_slider.value() + delta
235
+ self.zoom_slider.setValue(min(max(v,1),400))
236
+
237
+ def fit_to_view(self):
238
+ if not self.original_image:
239
+ return
240
+ avail = self.scroll_area.viewport().size()
241
+ iw, ih = self.original_image.width(), self.original_image.height()
242
+ f = min(avail.width()/iw, avail.height()/ih)
243
+ self.zoom_factor = f
244
+ self.zoom_slider.setValue(int(f*100))
245
+ self._update_display()
246
+
247
+ def _update_display(self):
248
+ """
249
+ Chooses original vs stretched image and repaints.
250
+ """
251
+ img = self.stretched_image or self.original_image
252
+ if img is None:
253
+ return
254
+
255
+ pix = QPixmap.fromImage(self.stretched_image or self.original_image)
256
+ painter = QPainter(pix)
257
+ painter.setPen(QPen(Qt.GlobalColor.red, 2))
258
+ # draw any overlays
259
+ for ov in getattr(self, "_overlays", []):
260
+ x, y, p3, p4 = ov
261
+ # if p3 is an integer / we intended an ellipse
262
+ if isinstance(p3, (int,)) and isinstance(p4, (int, float)):
263
+ w = int(p3)
264
+ h = w
265
+ painter.drawEllipse(x, y, w, h)
266
+ painter.drawText(x, y, f"{p4:.2f}")
267
+ else:
268
+ # treat as vector overlay: (angle, length)
269
+ angle = float(p3)
270
+ length_um = float(p4)
271
+ # convert length from µm → pixels if necessary;
272
+ # here we assume overlays were built in pixels:
273
+ dx = math.cos(angle) * length_um
274
+ dy = -math.sin(angle) * length_um
275
+ x2 = x + dx
276
+ y2 = y + dy
277
+ painter.drawLine(int(x), int(y), int(x2), int(y2))
278
+ # optional: draw a simple arrowhead
279
+ # (two short lines at ±20° from the vector)
280
+ ah = 5 # arrow‐head pixel length
281
+ for sign in (+1, -1):
282
+ ang2 = angle + sign * math.radians(20)
283
+ ax = x2 - ah * math.cos(ang2)
284
+ ay = y2 + ah * math.sin(ang2)
285
+ painter.drawLine(int(x2), int(y2), int(ax), int(ay))
286
+ painter.end()
287
+ scaled = pix.scaled(
288
+ pix.size() * self.zoom_factor,
289
+ Qt.AspectRatioMode.KeepAspectRatio,
290
+ Qt.TransformationMode.SmoothTransformation
291
+ )
292
+ self.image_label.setPixmap(scaled)
293
+
294
+ def eventFilter(self, source, evt):
295
+ if source is self.scroll_area.viewport():
296
+ if evt.type() == QEvent.Type.MouseButtonPress and evt.button() == Qt.MouseButton.LeftButton:
297
+ self._panning = True
298
+ self._pan_start = evt.position().toPoint()
299
+ self._h_scroll_start = self.scroll_area.horizontalScrollBar().value()
300
+ self._v_scroll_start = self.scroll_area.verticalScrollBar().value()
301
+ self.scroll_area.viewport().setCursor(Qt.CursorShape.ClosedHandCursor)
302
+ return True
303
+
304
+ elif evt.type() == QEvent.Type.MouseMove and self._panning:
305
+ delta = evt.position().toPoint() - self._pan_start
306
+ self.scroll_area.horizontalScrollBar().setValue(self._h_scroll_start - delta.x())
307
+ self.scroll_area.verticalScrollBar().setValue(self._v_scroll_start - delta.y())
308
+ return True
309
+
310
+ elif evt.type() == QEvent.Type.MouseButtonRelease and evt.button() == Qt.MouseButton.LeftButton:
311
+ self._panning = False
312
+ self.scroll_area.viewport().setCursor(Qt.CursorShape.ArrowCursor)
313
+ return True
314
+
315
+ return super().eventFilter(source, evt)
316
+
317
+ def load_numpy(self, arr: np.ndarray):
318
+ """
319
+ Convenience wrapper: take an H×W or H×W×3 NumPy array (float in [0..1] or uint8),
320
+ convert it to a QImage and display.
321
+ """
322
+ # Convert to QImage
323
+ qimg = self.numpy_to_qimage(arr)
324
+ # Delegate to your existing loader
325
+ self.load_qimage(qimg)
326
+
327
+
328
+ def field_curvature_analysis(
329
+ img: np.ndarray,
330
+ grid: int,
331
+ panel: int,
332
+ pixel_scale: float,
333
+ snr_thresh: float = 5.0
334
+ ) -> Tuple[np.ndarray, List[Tuple[int,int,float,float]]]:
335
+ """
336
+ 1) Estimate background + detect stars via SEP.
337
+ 2) Compute per‐star FWHM (≈2*a), eccentricity, and orientation theta.
338
+ 3) Bin the FWHM into a grid×grid mosaic (median per cell) → FWHM_um heatmap.
339
+ 4) Normalize that heatmap to [0..1] for display.
340
+ 5) Build an overlay list of (x_pix,y_pix,angle_rad,elongation_um) for each star.
341
+ """
342
+ H, W = img.shape[:2]
343
+ # grayscale float32
344
+ if img.ndim == 3 and img.shape[2] == 3:
345
+ gray = (0.2126*img[...,0] + 0.7152*img[...,1] + 0.0722*img[...,2]).astype(np.float32)
346
+ else:
347
+ gray = img.astype(np.float32)
348
+
349
+ # background / stats
350
+ mean, med, std = sigma_clipped_stats(gray, sigma=3.0)
351
+ data = gray - med
352
+
353
+ # detect
354
+ objs = sep.extract(data, thresh=snr_thresh, err=std)
355
+ if objs is None or len(objs)==0:
356
+ # empty mosaic + no overlays
357
+ blank = np.zeros((H,W), dtype=float)
358
+ return blank, []
359
+
360
+ x, y = objs['x'], objs['y']
361
+ a, b, theta = objs['a'], objs['b'], objs['theta']
362
+
363
+ # FWHM ≈ 2 * a (in pixels) → µm
364
+ fwhm_um = 2.0 * a * pixel_scale
365
+
366
+ # eccentricity → elongation factor e = a/b - 1
367
+ e = np.clip(a / np.where(b>0, b, 1.0) - 1.0, 0.0, None)
368
+ elongation_um = e * pixel_scale
369
+
370
+ # --- build mosaic of median‐FWHM in each grid cell ---
371
+ cell_w, cell_h = W/grid, H/grid
372
+ fmap = np.zeros((H,W), dtype=float)
373
+ heat = np.full((grid, grid), np.nan, dtype=float)
374
+ for j in range(grid):
375
+ for i in range(grid):
376
+ mask = (
377
+ (x>= i*cell_w) & (x< (i+1)*cell_w) &
378
+ (y>= j*cell_h) & (y< (j+1)*cell_h)
379
+ )
380
+ if np.any(mask):
381
+ mval = np.median(fwhm_um[mask])
382
+ else:
383
+ mval = np.nan
384
+ heat[j,i] = mval
385
+ # fill that block
386
+ y0, y1 = int(j*cell_h), int((j+1)*cell_h)
387
+ x0, x1 = int(i*cell_w), int((i+1)*cell_w)
388
+ fmap[y0:y1, x0:x1] = mval if not np.isnan(mval) else 0.0
389
+
390
+ # replace empty with global median
391
+ med_heat = np.nanmedian(heat)
392
+ fmap = np.where(fmap==0, med_heat, fmap)
393
+
394
+ # normalize to [0..1]
395
+ mn, mx = fmap.min(), fmap.max()
396
+ if mx>mn:
397
+ norm = (fmap - mn) / (mx - mn)
398
+ else:
399
+ norm = np.zeros_like(fmap)
400
+
401
+ # --- build elongation‐arrow overlays ---
402
+ overlays: List[Tuple[int,int,float,float]] = []
403
+ for xi, yi, ang, el in zip(x, y, theta, elongation_um):
404
+ overlays.append((int(xi), int(yi), float(ang), float(el)))
405
+
406
+ return norm, overlays
407
+
408
+ def tilt_analysis(
409
+ img: np.ndarray,
410
+ pixel_size_um: float,
411
+ focal_length_mm: float,
412
+ aperture_mm: float,
413
+ sigma_clip: float = 2.0,
414
+ thresh_sigma: float = 5.0,
415
+ ) -> Tuple[np.ndarray, Tuple[float,float,float], Tuple[int,int]]:
416
+ """
417
+ Robust sensor‐tilt measurement via direct plane fit, with a thin‐lens defocus model.
418
+
419
+ 1) Convert to 2-D luminance if needed.
420
+ 2) Detect stars & measure half-light radius via SEP → rad (pixels).
421
+ 3) Compute blur diameter d_um = 2*a_px * pixel_size_um.
422
+ 4) Convert blur → defocus via thin‐lens: Δz_um = d_um * (focal_length_mm / aperture_mm).
423
+ 5) Fit plane Δz = a x + b y + c to all stars (sigma‐clipped).
424
+ 6) Return that best‐fit plane evaluated over every pixel, normalized 0–1 for display.
425
+ """
426
+ # 0) grayscale float32
427
+ if img.ndim == 3 and img.shape[2] == 3:
428
+ gray = (0.2126*img[...,0] + 0.7152*img[...,1] + 0.0722*img[...,2]).astype(np.float32)
429
+ else:
430
+ gray = img.astype(np.float32)
431
+ H, W = gray.shape
432
+
433
+ # 1) SEP star detection
434
+ data = np.ascontiguousarray(gray, dtype=np.float32)
435
+ bkg = sep.Background(data)
436
+ stars = sep.extract(data - bkg.back(),
437
+ thresh=thresh_sigma,
438
+ err=bkg.globalrms)
439
+ if stars is None or len(stars) < 10:
440
+ return np.zeros((H,W), dtype=float), (0.0,0.0,0.0), (H,W)
441
+
442
+ x = stars['x']
443
+ y = stars['y']
444
+ a_pix = stars['a'] # semi-major axis
445
+ flags = stars['flag'] if 'flag' in stars.dtype.names else np.zeros_like(a_pix, dtype=int)
446
+
447
+ # 2) map to defocus distance (µm) via thin-lens:
448
+ # blur diameter ≈ 2*a_pix * px_size_um
449
+ # Δz_um = blur_um * (focal_length_mm / aperture_mm)
450
+ blur_um = 2.0 * a_pix * pixel_size_um
451
+ f_number = focal_length_mm / aperture_mm
452
+ defocus_um = blur_um * f_number
453
+
454
+ # 3) initial least‐squares plane fit
455
+ A = np.vstack([x, y, np.ones_like(x)]).T # (N,3)
456
+ sol, *_ = np.linalg.lstsq(A, defocus_um, rcond=None)
457
+ a, b, c = sol
458
+
459
+ # 4) sigma‐clip outliers and re-fit
460
+ z_pred = A.dot(sol)
461
+ resid = defocus_um - z_pred
462
+ mask = np.abs(resid) < sigma_clip * np.std(resid)
463
+ if mask.sum() > 10:
464
+ sol, *_ = np.linalg.lstsq(A[mask], defocus_um[mask], rcond=None)
465
+ a, b, c = sol
466
+
467
+ # 5) build full‐frame plane
468
+ Y, X = np.mgrid[0:H, 0:W]
469
+ plane_full = a*X + b*Y + c
470
+
471
+ # 6) normalize to [0..1] for display
472
+ pmin, pmax = plane_full.min(), plane_full.max()
473
+ if pmax > pmin:
474
+ norm_plane = (plane_full - pmin) / (pmax - pmin)
475
+ else:
476
+ norm_plane = np.zeros_like(plane_full)
477
+
478
+ return norm_plane, (a, b, c), (H, W)
479
+
480
+ def focal_plane_curvature_overlay(img: np.ndarray, grid: int, panel: int):
481
+ """
482
+ Compute the best-fit sphere radius through each local panel,
483
+ return a list of QPainter-friendly overlay primitives,
484
+ e.g. [(x,y,radius,quality), …].
485
+ """
486
+ overlays = []
487
+ h, w = img.shape[:2]
488
+ xs = np.linspace(0, w-panel, grid, dtype=int)
489
+ ys = np.linspace(0, h-panel, grid, dtype=int)
490
+ for y in ys:
491
+ for x in xs:
492
+ patch = img[y:y+panel, x:x+panel]
493
+ # Fit a circle to the intensity → radius
494
+ radius = fit_circle_radius(patch)
495
+ overlays.append((x, y, panel, radius))
496
+ return overlays
497
+
498
+
499
+
500
+ def build_mosaic_numpy(
501
+ arr: np.ndarray,
502
+ grid: int,
503
+ panel: int,
504
+ sep: int = 4,
505
+ background: float = 0.0
506
+ ) -> np.ndarray:
507
+ """
508
+ Tile `arr` into a grid×grid mosaic of size `panel` each, separated by `sep` pixels.
509
+ If arr is 2D, result is 2D; if 3D (H×W×3), result is 3D.
510
+ """
511
+ h, w = arr.shape[:2]
512
+ out_h = grid * panel + (grid - 1) * sep
513
+ out_w = grid * panel + (grid - 1) * sep
514
+ if arr.ndim == 2:
515
+ mosaic = np.full((out_h, out_w), background, dtype=arr.dtype)
516
+ else:
517
+ c = arr.shape[2]
518
+ mosaic = np.full((out_h, out_w, c), background, dtype=arr.dtype)
519
+
520
+ # evenly spaced top-left corners
521
+ xs = [int((w - panel) * i / (grid - 1)) for i in range(grid)]
522
+ ys = [int((h - panel) * j / (grid - 1)) for j in range(grid)]
523
+
524
+ for row, y in enumerate(ys):
525
+ for col, x in enumerate(xs):
526
+ patch = arr[y : y + panel, x : x + panel]
527
+ dy = row * (panel + sep)
528
+ dx = col * (panel + sep)
529
+ mosaic[dy:dy + panel, dx:dx + panel, ...] = patch
530
+
531
+ return mosaic
532
+
533
+
534
+
535
+
536
+ def fit_circle_radius(patch: np.ndarray) -> float:
537
+ """
538
+ Very rough radius estimate by thresholding + edge points + circle fit.
539
+ Returns radius in pixels (caller scales to physical units).
540
+ """
541
+ # 1) threshold at ~50% max:
542
+ thr = patch.max() * 0.5
543
+ mask = patch > thr
544
+ ys, xs = np.nonzero(mask)
545
+ if len(xs) < 5:
546
+ return 0.0
547
+
548
+ # 2) algebraic circle fit (Taubin)
549
+ x = xs.astype(float)
550
+ y = ys.astype(float)
551
+ x_m = x.mean(); y_m = y.mean()
552
+ u = x - x_m; v = y - y_m
553
+ Suu = (u*u).sum(); Suv = (u*v).sum(); Svv = (v*v).sum()
554
+ Suuu = (u*u*u).sum(); Svvv = (v*v*v).sum()
555
+ Suvv = (u*v*v).sum(); Svuu = (v*u*u).sum()
556
+ # Solved system:
557
+ A = np.array([[Suu, Suv], [Suv, Svv]])
558
+ B = np.array([(Suuu + Suvv)/2.0, (Svvv + Svuu)/2.0])
559
+ try:
560
+ uc, vc = np.linalg.solve(A, B)
561
+ except np.linalg.LinAlgError:
562
+ return 0.0
563
+ radius = math.hypot(uc, vc)
564
+ return radius
565
+
566
+ def focal_plane_curvature_overlay(
567
+ img: np.ndarray,
568
+ grid: int,
569
+ panel: int,
570
+ pixel_size_um: Optional[float] = None
571
+ ) -> List[Tuple[int,int,int,float]]:
572
+ """
573
+ Divide `img` into grid×grid panels, estimate per-panel best-focus radius,
574
+ and return overlay tuples (x, y, panel, radius_um).
575
+ If pixel_size_um is given, radius is returned in microns; else in pixels.
576
+ """
577
+ overlays: List[Tuple[int,int,int,float]] = []
578
+ h, w = img.shape[:2]
579
+ xs = [int((w - panel) * i / (grid - 1)) for i in range(grid)]
580
+ ys = [int((h - panel) * j / (grid - 1)) for j in range(grid)]
581
+
582
+ for y in ys:
583
+ for x in xs:
584
+ patch = img[y : y + panel, x : x + panel]
585
+ r_px = fit_circle_radius(patch)
586
+ r = (r_px * pixel_size_um) if pixel_size_um else r_px
587
+ overlays.append((x, y, panel, r))
588
+
589
+ return overlays
590
+
591
+ # Import centralized widgets
592
+ from setiastro.saspro.widgets.spinboxes import CustomSpinBox, CustomDoubleSpinBox
593
+
594
+
595
+ class TiltDialog(QDialog):
596
+ def __init__(self,
597
+ title: str,
598
+ img: np.ndarray,
599
+ plane: Optional[Tuple[float,float,float]] = None,
600
+ img_shape: Optional[Tuple[int,int]] = None,
601
+ pixel_size_um: float = 1.0,
602
+ overlays: Optional[List[Tuple]] = None,
603
+ parent=None):
604
+ super().__init__(parent)
605
+ self.setWindowTitle(title)
606
+ self.pixel_size_um = pixel_size_um
607
+
608
+ # ––––– Create the view and load the image –––––
609
+ self.view = PreviewPane()
610
+ self.view.load_numpy(img)
611
+ if overlays:
612
+ self.view.set_overlay(overlays)
613
+
614
+ # ––––– Corner tilt table –––––
615
+ table = None
616
+ if plane and img_shape:
617
+ a, b, c = plane
618
+ H, W = img_shape
619
+ cx, cy = W/2, H/2
620
+ corners = {
621
+ "Top Left": (0, 0),
622
+ "Top Right": (W, 0),
623
+ "Bottom Left": (0, H),
624
+ "Bottom Right":(W, H),
625
+ }
626
+ rows = []
627
+ corner_deltas = []
628
+ for name,(x,y) in corners.items():
629
+ delta = a*(x - cx) + b*(y - cy)
630
+ corner_deltas.append(delta)
631
+
632
+ min_d, max_d = min(corner_deltas), max(corner_deltas)
633
+
634
+ # 2) now build a more meaningful label:
635
+ range_label = QLabel(self.tr("Tilt span: {0:.1f} µm … {1:.1f} µm").format(min_d, max_d))
636
+ for name, (x, y) in corners.items():
637
+ # how far above/below the center plane
638
+ delta = a*(x - cx) + b*(y - cy)
639
+ rows.append((name, f"{delta:.1f}"))
640
+
641
+ table = QTableWidget(len(rows), 2, self)
642
+ table.setHorizontalHeaderLabels([self.tr("Corner"), self.tr("Δ µm")])
643
+ # hide the vertical header
644
+ table.verticalHeader().setVisible(False)
645
+ for i, (name, val) in enumerate(rows):
646
+ table.setItem(i, 0, QTableWidgetItem(name))
647
+ table.setItem(i, 1, QTableWidgetItem(val))
648
+ table.resizeColumnsToContents()
649
+
650
+ # ––––– Layout everything –––––
651
+ layout = QVBoxLayout(self)
652
+ layout.addWidget(self.view, 1) # stretch = 1
653
+ layout.addWidget(range_label, 0) # stretch = 0
654
+ if table:
655
+ layout.addWidget(table, 0)
656
+ close_btn = QPushButton(self.tr("Close"), self)
657
+ close_btn.clicked.connect(self.accept)
658
+ layout.addWidget(close_btn, 0)
659
+
660
+ self.view.fit_to_view()
661
+
662
+ def compute_fwhm_heatmap_full(
663
+ img: np.ndarray,
664
+ pixel_scale: float,
665
+ thresh_sigma: float = 5.0
666
+ ) -> np.ndarray:
667
+ """
668
+ 1) Detect stars with SEP, measure fwhm_um = 2*a*pixel_scale
669
+ 2) Interpolate fwhm_um onto the full H×W grid with cubic+nearest
670
+ 3) Normalize to [0..1] and return that heatmap
671
+ """
672
+ gray = img.mean(axis=2).astype(np.float32) if img.ndim==3 else img.astype(np.float32)
673
+ H, W = gray.shape
674
+ data = np.ascontiguousarray(gray, dtype=np.float32)
675
+ bkg = sep.Background(data)
676
+ objs = sep.extract(data - bkg.back(), thresh=thresh_sigma, err=bkg.globalrms)
677
+ if objs is None or len(objs) < 5:
678
+ return np.zeros((H,W),dtype=float)
679
+
680
+ x = objs['x']; y = objs['y']
681
+ fwhm_um = 2.0 * objs['a'] * pixel_scale
682
+
683
+ # create interpolation grid
684
+ grid_x, grid_y = np.meshgrid(np.arange(W), np.arange(H))
685
+ points = np.vstack([x, y]).T
686
+
687
+ # first cubic, then nearest for NaNs
688
+ heat = griddata(points, fwhm_um, (grid_x, grid_y), method='cubic')
689
+ mask = np.isnan(heat)
690
+ if mask.any():
691
+ heat[mask] = griddata(points, fwhm_um, (grid_x, grid_y), method='nearest')[mask]
692
+
693
+ # normalize
694
+ mn, mx = heat.min(), heat.max()
695
+ return (heat - mn)/max(mx-mn,1e-9)
696
+
697
+
698
+
699
+ def fit_2d_poly(x, y, z, deg=2, sigma_clip=3.0, max_iter=3):
700
+ """
701
+ Fit z(x,y) = Σ_{i+j≤deg} c_{ij} x^i y^j
702
+ by linear least squares + sigma-clipping.
703
+ Returns the flattened coeff array.
704
+ """
705
+ # Build list of (i,j) exponents
706
+ exps = [(i, j) for total in range(deg+1)
707
+ for i in range(total+1)
708
+ for j in [total - i]]
709
+ # Design matrix
710
+ A = np.vstack([ (x**i)*(y**j) for (i,j) in exps ]).T # shape (N,len(exps))
711
+ mask = np.ones_like(z, bool)
712
+
713
+ for _ in range(max_iter):
714
+ sol, *_ = np.linalg.lstsq(A[mask], z[mask], rcond=None)
715
+ zfit = A.dot(sol)
716
+ resid = z - zfit
717
+ std = np.std(resid[mask])
718
+ newm = np.abs(resid) < sigma_clip*std
719
+ if newm.sum() == mask.sum():
720
+ break
721
+ mask = newm
722
+
723
+ return sol, exps
724
+
725
+ def eval_2d_poly(sol, exps, X, Y):
726
+ """
727
+ Evaluate the polynomial with coeffs sol and exponents exps
728
+ on a full grid X,Y.
729
+ """
730
+ Z = np.zeros_like(X, float)
731
+ for c,(i,j) in zip(sol, exps):
732
+ Z += c * (X**i) * (Y**j)
733
+ return Z
734
+
735
+ def compute_fwhm_surface(img, pixel_scale, thresh_sigma=5.0, deg=3):
736
+ # grayscale
737
+ gray = img.mean(axis=2).astype(np.float32) if img.ndim==3 else img.astype(np.float32)
738
+ H, W = gray.shape
739
+ data = np.ascontiguousarray(gray, np.float32)
740
+ bkg = sep.Background(data)
741
+ stars = sep.extract(data - bkg.back(), thresh=thresh_sigma, err=bkg.globalrms)
742
+ if stars is None or len(stars)<10:
743
+ return np.zeros((H,W), float)
744
+
745
+ x = stars['x']; y = stars['y']
746
+ fwhm_um = 2.0 * stars['a'] * pixel_scale
747
+
748
+ # 1) fit
749
+ sol, exps = fit_2d_poly(x, y, fwhm_um, deg=deg)
750
+
751
+ # 2) evaluate
752
+ Y, X = np.mgrid[0:H, 0:W]
753
+ surf = eval_2d_poly(sol, exps, X, Y)
754
+
755
+ # 3) normalize
756
+ mn, mx = surf.min(), surf.max()
757
+ heat = (surf - mn)/max(mx-mn,1e-9)
758
+ return heat, (mn, mx)
759
+
760
+
761
+ def compute_eccentricity_surface(
762
+ img: np.ndarray,
763
+ pixel_scale: float,
764
+ thresh_sigma: float = 5.0,
765
+ deg: int = 3
766
+ ) -> Tuple[np.ndarray, Tuple[float,float]]:
767
+ """
768
+ 1) SEP → x,y,a,b
769
+ 2) e = clip(1 - b/a)
770
+ 3) Fit e(x,y) with a 2D poly of degree 'deg' + sigma-clip
771
+ 4) Evaluate on full H×W grid, normalize to [0..1]
772
+ """
773
+ gray = img.mean(axis=2).astype(np.float32) if img.ndim==3 else img.astype(np.float32)
774
+ H, W = gray.shape
775
+ data = np.ascontiguousarray(gray, np.float32)
776
+ bkg = sep.Background(data)
777
+ stars = sep.extract(data - bkg.back(), thresh=thresh_sigma, err=bkg.globalrms)
778
+ if stars is None or len(stars)<6:
779
+ return np.zeros((H,W),dtype=float), (0.0, 0.0)
780
+
781
+ x = stars['x']; y = stars['y']
782
+ a = stars['a']; b = stars['b']
783
+ e = np.clip(1.0 - b/a, 0.0, 1.0)
784
+ e_min, e_max = float(e.min()), float(e.max())
785
+
786
+ # fit polynomial
787
+ sol, exps = fit_2d_poly(x, y, e, deg=deg)
788
+ Y, X = np.mgrid[0:H,0:W]
789
+ surf = eval_2d_poly(sol, exps, X, Y)
790
+
791
+ mn, mx = surf.min(), surf.max()
792
+ norm = (surf - mn)/max(mx-mn,1e-9)
793
+ return norm, (e_min, e_max)
794
+
795
+
796
+ def compute_orientation_surface(
797
+ img: np.ndarray,
798
+ thresh_sigma: float = 5.0,
799
+ deg: int = 1, # for pure tilt a plane is enough
800
+ sigma_clip: float = 3.0,
801
+ max_iter: int = 3
802
+ ) -> Tuple[np.ndarray, Tuple[float,float]]:
803
+ """
804
+ Fits a smooth orientation surface θ(x,y) via circular least squares.
805
+
806
+ Returns
807
+ -------
808
+ norm_hue : H×W array
809
+ Hue = (θ_fit + π/2)/π in [0..1], ready for display.
810
+ (h_min, h_max) :
811
+ min/max of the raw hue samples at star positions.
812
+ """
813
+ # → 1) make a 2D grayscale
814
+ gray = img.mean(axis=2).astype(np.float32) if img.ndim==3 else img.astype(np.float32)
815
+ H, W = gray.shape
816
+
817
+ # → 2) SEP detect
818
+ data = np.ascontiguousarray(gray, np.float32)
819
+ bkg = sep.Background(data)
820
+ stars = sep.extract(data - bkg.back(), thresh=thresh_sigma, err=bkg.globalrms)
821
+ if stars is None or len(stars) < 6:
822
+ return np.zeros((H, W), dtype=float), (0.0, 0.0)
823
+
824
+ x = stars['x']
825
+ y = stars['y']
826
+ theta = stars['theta'] # in radians
827
+
828
+ # → 3) form double‐angle sine/cosine
829
+ s = np.sin(2*theta)
830
+ c = np.cos(2*theta)
831
+
832
+ # compute raw hue range for legend
833
+ # compute **actual** θ range for legend (in radians)
834
+ theta_min, theta_max = float(theta.min()), float(theta.max())
835
+
836
+ # → 4) build design matrix for deg‐th 2D poly
837
+ exps = [(i,j) for total in range(deg+1)
838
+ for i in range(total+1)
839
+ for j in [total-i]]
840
+ A = np.vstack([ (x**i)*(y**j) for (i,j) in exps ]).T # shape (N, M)
841
+
842
+ # → 5) sigma‐clip loops on residual length
843
+ mask = np.ones_like(s, bool)
844
+ for _ in range(max_iter):
845
+ sol_s, *_ = np.linalg.lstsq(A[mask], s[mask], rcond=None)
846
+ sol_c, *_ = np.linalg.lstsq(A[mask], c[mask], rcond=None)
847
+ fit_s = A.dot(sol_s)
848
+ fit_c = A.dot(sol_c)
849
+ resid = np.hypot(s - fit_s, c - fit_c)
850
+ std = np.std(resid[mask])
851
+ newm = resid < sigma_clip*std
852
+ if newm.sum() == mask.sum():
853
+ break
854
+ mask = newm
855
+
856
+ # → 6) evaluate both polys on the full image grid
857
+ Y, X = np.mgrid[0:H, 0:W]
858
+ surf_s = sum(coeff*(X**i)*(Y**j) for coeff,(i,j) in zip(sol_s, exps))
859
+ surf_c = sum(coeff*(X**i)*(Y**j) for coeff,(i,j) in zip(sol_c, exps))
860
+
861
+ # → 7) recover the smooth θ_fit and map to hue [0..1]
862
+ theta_fit = 0.5 * np.arctan2(surf_s, surf_c) # in [−π/2..π/2]
863
+ hue = (theta_fit + np.pi/2) / np.pi # now [0..1]
864
+
865
+ return hue, (theta_min, theta_max)
866
+
867
+ class SurfaceDialog(QDialog):
868
+ def __init__(self, title, heatmap, vmin, vmax, units:str="", cmap="gray", parent=None):
869
+ super().__init__(parent)
870
+ self.setWindowTitle(title)
871
+
872
+ # image
873
+ # image (apply the chosen colormap if it’s a 2D heatmap,
874
+ # and load RGB directly if it’s already color)
875
+ from matplotlib import cm
876
+ import matplotlib.pyplot as plt
877
+
878
+ view = PreviewPane()
879
+ if heatmap.ndim == 2:
880
+ # 1) map to RGBA via colormap
881
+ cmap_obj = cm.get_cmap(cmap)
882
+ rgba = cmap_obj(heatmap) # shape H×W×4, floats 0–1
883
+ rgb = (rgba[...,:3] * 255).astype(np.uint8)
884
+ view.load_numpy(rgb)
885
+ else:
886
+ # assume already float32 [0..1] RGB or uint8 RGB
887
+ view.load_numpy(heatmap)
888
+ view.fit_to_view()
889
+
890
+ # colorbar pixmap
891
+ cb = self._make_colorbar(cmap, vmin, vmax, units)
892
+ lbl_cb = QLabel()
893
+ lbl_cb.setPixmap(cb)
894
+
895
+ # layout
896
+ h = QHBoxLayout()
897
+ h.addWidget(view, 1)
898
+ h.addWidget(lbl_cb, 0)
899
+
900
+ btn = QPushButton(self.tr("Close"))
901
+ btn.clicked.connect(self.accept)
902
+ lbl_span = QLabel(self.tr("Span: {0:.2f} … {1:.2f} {2}").format(vmin, vmax, units))
903
+
904
+ v = QVBoxLayout(self)
905
+ v.addLayout(h)
906
+ v.addWidget(lbl_span)
907
+ v.addWidget(btn)
908
+
909
+ def _make_colorbar(self, cmap_name, vmin, vmax, units):
910
+ # build a 256×20 gradient in RGBA
911
+ import numpy as np
912
+ import matplotlib.pyplot as plt
913
+ from matplotlib import cm
914
+ grad = np.linspace(0,1,256)[:,None]
915
+ bar = cm.get_cmap(cmap_name)(grad)
916
+ bar = (bar[:,:,:3]*255).astype(np.uint8)
917
+ # make a QImage
918
+ H,W,_ = bar.shape
919
+ img = QImage(bar.data, 1, 256, 3*1, QImage.Format.Format_RGB888)
920
+ # rotate to vertical
921
+ return QPixmap.fromImage(img.mirrored(False, True).scaled(20,256))
922
+
923
+ def distortion_vectors_sip(x_pix, y_pix, sip, pixel_size_um):
924
+ """
925
+ Evaluate the SIP Δ‐pixels at the given star positions,
926
+ return (dx_um, dy_um) and also the raw dx_pix,dy_pix arrays.
927
+ """
928
+ A = sip.a
929
+ B = sip.b
930
+ order = A.shape[0] - 1
931
+
932
+ # pull off CRPIX so u,v are relative to the SIP origin
933
+ crpix1, crpix2 = sip.forward_origin # equivalent to wcs.wcs.crpix
934
+ u = x_pix - crpix1
935
+ v = y_pix - crpix2
936
+
937
+ dx_pix = np.zeros_like(u)
938
+ dy_pix = np.zeros_like(u)
939
+
940
+ # vectorized polynomial evaluation
941
+ for i in range(order+1):
942
+ for j in range(order+1-i):
943
+ a_ij = A[i, j]
944
+ b_ij = B[i, j]
945
+ if a_ij:
946
+ dx_pix += a_ij * (u**i) * (v**j)
947
+ if b_ij:
948
+ dy_pix += b_ij * (u**i) * (v**j)
949
+
950
+ dx_um = dx_pix * pixel_size_um
951
+ dy_um = dy_pix * pixel_size_um
952
+
953
+ return dx_pix, dy_pix, dx_um, dy_um
954
+
955
+ def distortion_vectors(img: np.ndarray,
956
+ sip_meta: dict,
957
+ pixel_size_um: float):
958
+ """
959
+ 1) SEP detect stars → x_pix,y_pix
960
+ 2) extract A,B,crpix from sip_meta
961
+ 3) eval dx_pix,dy_pix → dx_um,dy_um
962
+ 4) return overlays
963
+ """
964
+ # 1) detect stars
965
+ gray = img.mean(-1).astype(np.float32) if img.ndim==3 else img.astype(np.float32)
966
+ data = np.ascontiguousarray(gray, np.float32)
967
+ bkg = sep.Background(data)
968
+ stars = sep.extract(data - bkg.back(),
969
+ thresh=5.0, err=bkg.globalrms)
970
+ if stars is None:
971
+ return []
972
+
973
+ x_pix = stars['x']; y_pix = stars['y']
974
+
975
+ # 2) pull SIP from meta (now robust to missing A_ORDER)
976
+ A, B, crpix1, crpix2 = extract_sip_from_meta(sip_meta)
977
+
978
+ # 3) vector‐polynomial evaluation
979
+ u = x_pix - crpix1
980
+ v = y_pix - crpix2
981
+ dx_pix = np.zeros_like(u)
982
+ dy_pix = np.zeros_like(u)
983
+ order = A.shape[0] - 1
984
+ for i in range(order+1):
985
+ for j in range(order+1-i):
986
+ a_ij = A[i, j]
987
+ b_ij = B[i, j]
988
+ if a_ij:
989
+ dx_pix += a_ij * (u**i) * (v**j)
990
+ if b_ij:
991
+ dy_pix += b_ij * (u**i) * (v**j)
992
+
993
+ # 4) to microns & pack
994
+ dx_um = dx_pix * pixel_size_um
995
+ dy_um = dy_pix * pixel_size_um
996
+
997
+ overlays = []
998
+ for x,y,dx,dy in zip(x_pix, y_pix, dx_um, dy_um):
999
+ ang = math.atan2(dy, dx)
1000
+ length = math.hypot(dx, dy)
1001
+ overlays.append((int(x), int(y), ang, length))
1002
+ return overlays
1003
+
1004
+ def eval_sip(A, B, u, v):
1005
+ """
1006
+ Vectorized SIP evaluation: given coefficient arrays A,B and
1007
+ coordinate offsets u=x-crpix1, v=y-crpix2, returns dx_pix, dy_pix.
1008
+ """
1009
+ dx = np.zeros_like(u)
1010
+ dy = np.zeros_like(u)
1011
+ order = A.shape[0]-1
1012
+ for i in range(order+1):
1013
+ for j in range(order+1-i):
1014
+ a = A[i, j]
1015
+ b = B[i, j]
1016
+ if a:
1017
+ dx += a * (u**i)*(v**j)
1018
+ if b:
1019
+ dy += b * (u**i)*(v**j)
1020
+ return dx, dy
1021
+
1022
+ def extract_sip_from_meta(sm: dict):
1023
+ """
1024
+ Given the metadata dict that ASTAP wrote into your slot,
1025
+ pull out the forward SIP polynomials A and B (and the reference pixel).
1026
+ We no longer rely on A_ORDER existing; we infer it from the A_i_j keys.
1027
+ """
1028
+ # 1) find all the A_i_j keys that actually made it into sm
1029
+ a_keys = [k for k in sm.keys() if re.match(r"A_\d+_\d+", k)]
1030
+ if not a_keys:
1031
+ raise ValueError("No SIP A_?_? coefficients found in metadata!")
1032
+
1033
+ # 2) parse out all the (i,j) pairs and infer the polynomial order as max(i+j)
1034
+ pairs = [tuple(map(int, k.split("_")[1:])) for k in a_keys]
1035
+ order = max(i+j for i,j in pairs)
1036
+
1037
+ # 3) allocate forward‐SIP coefficient arrays
1038
+ A = np.zeros((order+1, order+1), float)
1039
+ B = np.zeros((order+1, order+1), float)
1040
+
1041
+ for i, j in pairs:
1042
+ A[i, j] = float(sm[f"A_{i}_{j}"])
1043
+ B[i, j] = float(sm[f"B_{i}_{j}"])
1044
+
1045
+ # 4) pull the reference pixel
1046
+ crpix1 = float(sm["CRPIX1"])
1047
+ crpix2 = float(sm["CRPIX2"])
1048
+
1049
+ return A, B, crpix1, crpix2
1050
+
1051
+ class DistortionGridDialog(QDialog):
1052
+ def __init__(self,
1053
+ img: np.ndarray,
1054
+ sip_meta: dict,
1055
+ arcsec_per_pix: float,
1056
+ n_grid_lines: int = 10,
1057
+ amplify: float = 20.0,
1058
+ parent=None):
1059
+ super().__init__(parent)
1060
+ self.setWindowTitle(self.tr("Astrometric Distortion & Histogram"))
1061
+
1062
+ # — 1) detect stars —
1063
+ gray = img.mean(-1).astype(np.float32) if img.ndim==3 else img.astype(np.float32)
1064
+ data = np.ascontiguousarray(gray, np.float32)
1065
+ bkg = sep.Background(data)
1066
+ stars = sep.extract(data - bkg.back(), thresh=5.0, err=bkg.globalrms)
1067
+ if stars is None or len(stars) < 10:
1068
+ QMessageBox.warning(self, self.tr("Distortion"), self.tr("Not enough stars found."))
1069
+ self.reject()
1070
+ return
1071
+
1072
+ x_pix = stars['x']
1073
+ y_pix = stars['y']
1074
+
1075
+ # — 2) extract SIP A,B and reference pixel from metadata dict —
1076
+ A, B, crpix1, crpix2 = extract_sip_from_meta(sip_meta)
1077
+
1078
+ # — 4) per-star residuals in pixels → arc-sec —
1079
+ u_star = x_pix - crpix1
1080
+ v_star = y_pix - crpix2
1081
+ dx_star_pix, dy_star_pix = eval_sip(A, B, u_star, v_star)
1082
+ disp_star_pix = np.hypot(dx_star_pix, dy_star_pix)
1083
+ disp_star_arcsec = disp_star_pix * arcsec_per_pix
1084
+
1085
+ # — 5) full‐image warp maps (pixels) for drawing grid —
1086
+ H, W = data.shape
1087
+ YY, XX = np.mgrid[0:H, 0:W]
1088
+ U = XX - crpix1
1089
+ V = YY - crpix2
1090
+ DX_pix, DY_pix = eval_sip(A, B, U, V)
1091
+ DX = DX_pix * amplify
1092
+ DY = DY_pix * amplify
1093
+
1094
+ # — 6) build the distortion grid scene —
1095
+ scene = QGraphicsScene(self)
1096
+ scene.setBackgroundBrush(QColor(30,30,30))
1097
+ pen = QPen(QColor(255,100,100), 1)
1098
+ label_font = QFont("Arial", 12, QFont.Weight.Bold)
1099
+
1100
+ # title above the grid
1101
+ title = QLabel(self.tr("Astrometric Distortion Grid"))
1102
+ title.setFont(QFont("Arial", 16, QFont.Weight.Bold))
1103
+ title.setAlignment(Qt.AlignmentFlag.AlignCenter)
1104
+ title.setStyleSheet("color: white;")
1105
+
1106
+ # draw horizontal + vertical lines
1107
+ for i in range(n_grid_lines+1):
1108
+ y0 = i*(H-1)/n_grid_lines
1109
+ xs = np.linspace(0, W-1, 200)
1110
+ ys = np.full_like(xs, y0)
1111
+ xi = np.clip(xs.astype(int), 0, W-1)
1112
+ yi = np.clip(ys.astype(int), 0, H-1)
1113
+ warped = np.column_stack([ xs + DX[yi,xi], ys + DY[yi,xi] ])
1114
+ path = QPainterPath(QPointF(*warped[0]))
1115
+ for px,py in warped[1:]:
1116
+ path.lineTo(QPointF(px,py))
1117
+ scene.addPath(path, pen)
1118
+
1119
+ for j in range(n_grid_lines+1):
1120
+ x0 = j*(W-1)/n_grid_lines
1121
+ ys = np.linspace(0, H-1, 200)
1122
+ xs = np.full_like(ys, x0)
1123
+ xi = np.clip(xs.astype(int), 0, W-1)
1124
+ yi = np.clip(ys.astype(int), 0, H-1)
1125
+ warped = np.column_stack([ xs + DX[yi,xi], ys + DY[yi,xi] ])
1126
+ path = QPainterPath(QPointF(*warped[0]))
1127
+ for px,py in warped[1:]:
1128
+ path.lineTo(QPointF(px,py))
1129
+ scene.addPath(path, pen)
1130
+
1131
+ # annotate each grid‐intersection
1132
+ for i in range(n_grid_lines+1):
1133
+ for j in range(n_grid_lines+1):
1134
+ y0 = i*(H-1)/n_grid_lines
1135
+ x0 = j*(W-1)/n_grid_lines
1136
+ xi, yi = int(round(x0)), int(round(y0))
1137
+
1138
+ # local distortion in pixels → arcsec
1139
+ d_pix = math.hypot(DX_pix[yi, xi], DY_pix[yi, xi])
1140
+ d_arcsec = d_pix * arcsec_per_pix
1141
+
1142
+ px = x0 + DX[yi, xi]
1143
+ py = y0 + DY[yi, xi]
1144
+
1145
+ txt = QGraphicsTextItem(f"{d_arcsec:.1f}\"")
1146
+ txt.setFont(label_font)
1147
+ txt.setScale(5.0)
1148
+ txt.setDefaultTextColor(QColor(200,200,200))
1149
+ txt.setPos(px + 4, py + 4)
1150
+ scene.addItem(txt)
1151
+
1152
+ view = QGraphicsView(scene)
1153
+ view.setRenderHint(QPainter.RenderHint.Antialiasing)
1154
+ view.fitInView(scene.sceneRect(), Qt.AspectRatioMode.KeepAspectRatio)
1155
+
1156
+ # pack title + view vertically
1157
+ left_layout = QVBoxLayout()
1158
+ left_layout.addWidget(title)
1159
+ left_layout.addWidget(view, 1)
1160
+
1161
+ # — 7) histogram of per-star residuals (arcsec) —
1162
+ fig = Figure(figsize=(4,4))
1163
+ canvas = FigureCanvas(fig)
1164
+ ax = fig.add_subplot(111)
1165
+ ax.hist(disp_star_arcsec, bins=30, edgecolor='black')
1166
+ ax.set_xlabel(self.tr("Distortion (″)"))
1167
+ ax.set_ylabel(self.tr("Number of stars"))
1168
+ ax.set_title(self.tr("Residual histogram"))
1169
+ fig.tight_layout()
1170
+
1171
+ # side-by-side layout
1172
+ hl = QHBoxLayout()
1173
+ hl.addLayout(left_layout, 1)
1174
+ hl.addWidget(canvas, 1)
1175
+
1176
+ # close button
1177
+ btn = QPushButton(self.tr("Close"))
1178
+ btn.clicked.connect(self.accept)
1179
+
1180
+ # final
1181
+ v = QVBoxLayout(self)
1182
+ v.addLayout(hl)
1183
+ v.addWidget(btn, 0)
1184
+
1185
+ def make_header_from_xisf_meta(meta: dict) -> fits.Header:
1186
+ """
1187
+ meta is the dict you returned as original_header for XISF:
1188
+ {
1189
+ 'file_meta': ...,
1190
+ 'image_meta': ...,
1191
+ 'astrometry': {
1192
+ 'CD1_1', 'CD1_2', 'CD2_1', 'CD2_2',
1193
+ 'crpix1', 'crpix2',
1194
+ 'sip': {'order', 'A', 'B'}
1195
+ }
1196
+ }
1197
+ This builds a real fits.Header with WCS+SIP cards.
1198
+ """
1199
+ hdr = fits.Header()
1200
+ ast = meta['astrometry']
1201
+
1202
+ # WCS linear part
1203
+ hdr['CTYPE1'] = 'RA---TAN-SIP'
1204
+ hdr['CTYPE2'] = 'DEC--TAN-SIP'
1205
+ hdr['CRPIX1'] = ast['crpix1']
1206
+ hdr['CRPIX2'] = ast['crpix2']
1207
+ hdr['CD1_1'] = ast['CD1_1']
1208
+ hdr['CD1_2'] = ast['CD1_2']
1209
+ hdr['CD2_1'] = ast['CD2_1']
1210
+ hdr['CD2_2'] = ast['CD2_2']
1211
+
1212
+ # SIP coefficients
1213
+ sip = ast['sip']
1214
+ order = sip['order']
1215
+ hdr['A_ORDER'] = order
1216
+ hdr['B_ORDER'] = order
1217
+
1218
+ for i in range(order+1):
1219
+ for j in range(order+1-i):
1220
+ hdr[f'A_{i}_{j}'] = float(sip['A'][i,j])
1221
+ hdr[f'B_{i}_{j}'] = float(sip['B'][i,j])
1222
+
1223
+ # If you have file_meta FITSKeywords you can also copy those here:
1224
+ # for kw, vals in meta['file_meta'].get('FITSKeywords', {}).items():
1225
+ # for entry in vals:
1226
+ # hdr[kw] = entry['value']
1227
+
1228
+ return hdr
1229
+
1230
+ def plate_solve_current_image(image_manager, settings, parent=None):
1231
+ """
1232
+ Plate-solve the current slot image using the SASpro plate solver logic
1233
+ (ASTAP + Astrometry.net fallback) and update the slot's metadata in-place.
1234
+
1235
+ Returns the updated metadata dict for the current slot.
1236
+ """
1237
+ # 1) Grab pixel data + metadata from Image Peeker Pro
1238
+ arr, meta = image_manager.get_current_image_and_metadata()
1239
+ if meta is None:
1240
+ meta = {}
1241
+ elif not isinstance(meta, dict):
1242
+ meta = dict(meta)
1243
+
1244
+ # 2) Build the seed header from metadata (original_header / wcs / wcs_header)
1245
+ seed_h = _seed_header_from_meta(meta)
1246
+
1247
+ # 3) Pick a parent for UI/status if none is given
1248
+ if parent is None and hasattr(image_manager, "parent"):
1249
+ try:
1250
+ parent = image_manager.parent()
1251
+ except Exception:
1252
+ parent = None
1253
+
1254
+ # 4) Run the actual solve (ASTAP first, then astrometry.net if needed)
1255
+ ok, res = _solve_numpy_with_fallback(parent, settings, arr, seed_h)
1256
+ if not ok:
1257
+ # You can raise, return None, or bubble the error string.
1258
+ # Here we raise to make failures obvious.
1259
+ raise RuntimeError(f"Plate solve failed: {res}")
1260
+
1261
+ hdr = res # this is a real astropy.io.fits.Header
1262
+
1263
+ # 5) Store back into metadata
1264
+ meta["original_header"] = hdr
1265
+
1266
+ try:
1267
+ wcs_obj = WCS(hdr)
1268
+ meta["wcs"] = wcs_obj
1269
+ except Exception as e:
1270
+ print("Image Peeker: WCS build failed:", e)
1271
+
1272
+ # 6) Update image_manager’s internal metadata for this slot
1273
+ slot = image_manager.current_slot
1274
+ if hasattr(image_manager, "_metadata"):
1275
+ image_manager._metadata[slot] = meta
1276
+
1277
+ return meta
1278
+
1279
+
1280
+
1281
+ # ----------------------------- small utils -----------------------------------
1282
+
1283
+ def _ensure_fits_header(orig_hdr):
1284
+ if isinstance(orig_hdr, fits.Header):
1285
+ return orig_hdr
1286
+ if isinstance(orig_hdr, dict) and "astrometry" in orig_hdr:
1287
+ try:
1288
+ return make_header_from_xisf_meta(orig_hdr) # use local function
1289
+ except Exception:
1290
+ return None
1291
+ return None
1292
+ def _arcsec_per_pix_from_header(hdr: fits.Header, fallback_px_um: float|None=None, fallback_fl_mm: float|None=None):
1293
+ """Try CDELT-based scale; fallback to CD matrix; then pixel_size & focal length."""
1294
+ if hdr is None:
1295
+ if fallback_px_um and fallback_fl_mm:
1296
+ return 206.264806 * (fallback_px_um / fallback_fl_mm)
1297
+ return None
1298
+ try:
1299
+ return abs(float(hdr["CDELT1"])) * 3600.0
1300
+ except Exception:
1301
+ try:
1302
+ cd11 = float(hdr["CD1_1"])
1303
+ cd12 = float(hdr.get("CD1_2", 0.0))
1304
+ cd21 = float(hdr.get("CD2_1", 0.0))
1305
+ cd22 = float(hdr["CD2_2"])
1306
+ scale_deg = np.sqrt(abs(cd11 * cd22 - cd12 * cd21))
1307
+ return scale_deg * 3600.0
1308
+ except Exception:
1309
+ if fallback_px_um and fallback_fl_mm:
1310
+ return 206.264806 * (fallback_px_um / fallback_fl_mm)
1311
+ return None
1312
+
1313
+ class ImagePeekerDialogPro(QDialog):
1314
+ def __init__(self, parent, document, settings):
1315
+ super().__init__(parent)
1316
+ self.setWindowTitle(self.tr("Image Peeker"))
1317
+ self.setWindowFlag(Qt.WindowType.Window, True)
1318
+ self.setWindowModality(Qt.WindowModality.NonModal)
1319
+ self.setModal(False)
1320
+ self.document = self._coerce_doc(document) # <- ensure we hold a real doc
1321
+ self.settings = settings
1322
+ # status / progress line
1323
+ self.status_lbl = QLabel("")
1324
+ self.status_lbl.setStyleSheet("color:#bbb;")
1325
+
1326
+
1327
+ self.params = QGroupBox(self.tr("Grid parameters"))
1328
+ self.params.setMinimumWidth(180)
1329
+ self.params.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Preferred)
1330
+ gl = QGridLayout(self.params)
1331
+
1332
+ from PyQt6.QtWidgets import QSpinBox, QDoubleSpinBox
1333
+ self.grid_spin = QSpinBox(); self.grid_spin.setRange(2, 10); self.grid_spin.setValue(3)
1334
+ self.panel_slider = QSlider(Qt.Orientation.Horizontal); self.panel_slider.setRange(32, 512); self.panel_slider.setValue(256)
1335
+ self.panel_value_label = QLabel(str(self.panel_slider.value()))
1336
+ self.sep_slider = QSlider(Qt.Orientation.Horizontal); self.sep_slider.setRange(0, 50); self.sep_slider.setValue(4)
1337
+ self.sep_value_label = QLabel(str(self.sep_slider.value()))
1338
+
1339
+ self.pixel_size_input = QDoubleSpinBox(); self.pixel_size_input.setRange(0.01, 50.0); self.pixel_size_input.setSingleStep(0.1)
1340
+ self.focal_length_input = QDoubleSpinBox(); self.focal_length_input.setRange(10.0, 5000.0); self.focal_length_input.setSingleStep(10.0)
1341
+ self.aperture_input = QDoubleSpinBox(); self.aperture_input.setRange(1.0, 5000.0); self.aperture_input.setSingleStep(1.0)
1342
+
1343
+ px = self.settings.value("pixel_size_um", 4.8, type=float)
1344
+ fl = self.settings.value("focal_length_mm", 800.0, type=float)
1345
+ ap = self.settings.value("aperture_mm", 100.0, type=float)
1346
+ self.pixel_size_input.setValue(px); self.focal_length_input.setValue(fl); self.aperture_input.setValue(ap)
1347
+
1348
+ row = 0
1349
+ gl.addWidget(QLabel(self.tr("Grid size:")), row, 0); gl.addWidget(self.grid_spin, row, 1); row += 1
1350
+ gl.addWidget(QLabel(self.tr("Panel size:")), row, 0)
1351
+ pr = QHBoxLayout(); pr.addWidget(self.panel_slider, 1); pr.addWidget(self.panel_value_label)
1352
+ gl.addLayout(pr, row, 1); row += 1
1353
+ gl.addWidget(QLabel(self.tr("Separation:")), row, 0)
1354
+ sr = QHBoxLayout(); sr.addWidget(self.sep_slider, 1); sr.addWidget(self.sep_value_label)
1355
+ gl.addLayout(sr, row, 1); row += 1
1356
+ gl.addWidget(QLabel(self.tr("Pixel size (µm):")), row, 0); gl.addWidget(self.pixel_size_input, row, 1); row += 1
1357
+ gl.addWidget(QLabel(self.tr("Focal length (mm):")), row, 0); gl.addWidget(self.focal_length_input, row, 1); row += 1
1358
+ gl.addWidget(QLabel(self.tr("Aperture (mm):")), row, 0); gl.addWidget(self.aperture_input, row, 1); row += 1
1359
+
1360
+ # Right side
1361
+ from PyQt6.QtWidgets import QTabWidget
1362
+ self.preview_pane = PreviewPane()
1363
+ analysis_row = QHBoxLayout()
1364
+ analysis_row.addWidget(QLabel(self.tr("Analysis:")))
1365
+ self.analysis_combo = QComboBox()
1366
+ self.analysis_combo.addItem(self.tr("None"), "None")
1367
+ self.analysis_combo.addItem(self.tr("Tilt Analysis"), "Tilt Analysis")
1368
+ self.analysis_combo.addItem(self.tr("Focal Plane Analysis"), "Focal Plane Analysis")
1369
+ self.analysis_combo.addItem(self.tr("Astrometric Distortion Analysis"), "Astrometric Distortion Analysis")
1370
+ analysis_row.addWidget(self.analysis_combo); analysis_row.addStretch(1)
1371
+
1372
+ btns = QHBoxLayout(); btns.addStretch(1)
1373
+ ok_btn = QPushButton(self.tr("Save Settings && Exit")); cancel_btn = QPushButton(self.tr("Exit without Saving"))
1374
+ btns.addWidget(ok_btn); btns.addWidget(cancel_btn)
1375
+
1376
+ main = QHBoxLayout(self)
1377
+ main.addWidget(self.params)
1378
+ right = QVBoxLayout(); right.addLayout(analysis_row); right.addWidget(self.status_lbl, 0), right.addWidget(self.preview_pane, 1); right.addLayout(btns)
1379
+ main.addLayout(right, 1)
1380
+
1381
+ # Signals
1382
+ self.grid_spin.valueChanged.connect(self._refresh_mosaic)
1383
+ self.panel_slider.valueChanged.connect(lambda v: (self.panel_value_label.setText(str(v)), self._refresh_mosaic()))
1384
+ self.sep_slider.valueChanged.connect(lambda v: (self.sep_value_label.setText(str(v)), self._refresh_mosaic()))
1385
+ self.analysis_combo.currentTextChanged.connect(self._run_analysis)
1386
+ ok_btn.clicked.connect(self.accept); cancel_btn.clicked.connect(self.reject)
1387
+
1388
+ QTimer.singleShot(0, self._refresh_mosaic)
1389
+
1390
+ def _set_busy(self, on: bool, text: str = ""):
1391
+ if not text: text = self.tr("Processing…")
1392
+ self.status_lbl.setText(text if on else "")
1393
+ for w in (self.params, self.analysis_combo):
1394
+ w.setEnabled(not on)
1395
+ QGuiApplication.setOverrideCursor(Qt.CursorShape.WaitCursor) if on else QGuiApplication.restoreOverrideCursor()
1396
+ QCoreApplication.processEvents()
1397
+
1398
+ def _arr_and_meta(self):
1399
+ doc = self._coerce_doc(self.document)
1400
+ if doc is None or getattr(doc, "image", None) is None:
1401
+ return None, {}
1402
+ arr = np.asarray(doc.image, dtype=np.float32)
1403
+ meta = dict(getattr(doc, "metadata", {}) or {})
1404
+ return arr, meta
1405
+
1406
+ def accept(self):
1407
+ self.settings.setValue("pixel_size_um", self.pixel_size_input.value())
1408
+ self.settings.setValue("focal_length_mm", self.focal_length_input.value())
1409
+ self.settings.setValue("aperture_mm", self.aperture_input.value())
1410
+ super().accept()
1411
+
1412
+
1413
+ def _run_analysis(self, *_):
1414
+ mode_key = self.analysis_combo.currentData()
1415
+ mode_disp = self.analysis_combo.currentText()
1416
+ if mode_key == "None":
1417
+ self._set_busy(False, "")
1418
+ self._refresh_mosaic()
1419
+ return
1420
+ self._set_busy(True, self.tr("Running {0}…").format(mode_disp))
1421
+ QTimer.singleShot(0, lambda: self._run_analysis_dispatch(mode_key))
1422
+
1423
+ def _run_analysis_dispatch(self, mode: str):
1424
+ try:
1425
+ arr, meta = self._arr_and_meta()
1426
+ if arr is None or arr.size == 0:
1427
+ return
1428
+ ps_um = float(meta.get("pixel_size_um", self.pixel_size_input.value()))
1429
+ fl_mm = float(meta.get("focal_length_mm", self.focal_length_input.value()))
1430
+ ap_mm = float(meta.get("aperture_mm", self.aperture_input.value()))
1431
+ snr_th = float(meta.get("snr_threshold", 5.0))
1432
+
1433
+ if mode == "Tilt Analysis":
1434
+ norm_plane, (a,b,c), (H,W) = tilt_analysis(
1435
+ arr, pixel_size_um=ps_um, focal_length_mm=fl_mm, aperture_mm=ap_mm,
1436
+ sigma_clip=2.5, thresh_sigma=snr_th
1437
+ )
1438
+ TiltDialog(self.tr("Sensor Tilt (µm)"), norm_plane, (a,b,c), (H,W), ps_um, parent=self).show()
1439
+
1440
+ elif mode == "Focal Plane Analysis":
1441
+ fwhm_heat, (mn_f, mx_f) = compute_fwhm_surface(arr, ps_um, thresh_sigma=snr_th, deg=3)
1442
+ SurfaceDialog(self.tr("FWHM Heatmap"), fwhm_heat, mn_f, mx_f, "µm", "viridis", parent=self).show()
1443
+ ecc_heat, (mn_e, mx_e) = compute_eccentricity_surface(arr, ps_um, thresh_sigma=snr_th, deg=3)
1444
+ SurfaceDialog(self.tr("Eccentricity Map"), ecc_heat, mn_e, mx_e, "e = 1−b/a", "magma", parent=self).show()
1445
+ ori_heat, (mn_o, mx_o) = compute_orientation_surface(arr, thresh_sigma=snr_th, deg=3)
1446
+ SurfaceDialog(self.tr("Orientation Map"), ori_heat, mn_o, mx_o, "rad", "hsv", parent=self).show()
1447
+
1448
+ elif mode == "Astrometric Distortion Analysis":
1449
+ hdr = _header_from_meta(meta)
1450
+
1451
+ # If we truly have no WCS, plate-solve
1452
+ if hdr is None or not WCS(hdr, relax=True).has_celestial:
1453
+ ok, hdr_or_err = plate_solve_doc_inplace(
1454
+ parent=self, doc=self._coerce_doc(self.document), settings=self.settings
1455
+ )
1456
+ if not ok:
1457
+ QMessageBox.warning(self, self.tr("Plate Solve"), self.tr("ASTAP/Astrometry failed:\n{0}").format(hdr_or_err))
1458
+ return
1459
+
1460
+ # IMPORTANT: if solver returned a Header, store it
1461
+ if isinstance(hdr_or_err, fits.Header):
1462
+ doc = self._coerce_doc(self.document)
1463
+ if doc and isinstance(getattr(doc, "metadata", None), dict):
1464
+ doc.metadata["original_header"] = hdr_or_err
1465
+ doc.metadata["wcs_header"] = hdr_or_err
1466
+ try:
1467
+ doc.metadata["wcs"] = WCS(hdr_or_err, relax=True)
1468
+ except Exception:
1469
+ pass
1470
+
1471
+ arr, meta = self._arr_and_meta()
1472
+ hdr = _header_from_meta(meta)
1473
+
1474
+ # Now WCS exists, but do we have SIP?
1475
+ if hdr is None:
1476
+ QMessageBox.critical(self, self.tr("WCS Error"), self.tr("Plate solve did not produce a readable WCS header."))
1477
+ return
1478
+
1479
+ has_sip = any(k.startswith("A_") for k in hdr.keys()) and any(k.startswith("B_") for k in hdr.keys())
1480
+ if not has_sip:
1481
+ QMessageBox.warning(
1482
+ self, self.tr("No Distortion Model"),
1483
+ self.tr("This image has a valid WCS, but no SIP distortion terms (A_*, B_*).\n"
1484
+ "Astrometric distortion analysis requires a SIP-enabled solve.\n\n"
1485
+ "Re-solve with distortion fitting enabled in ASTAP.")
1486
+ )
1487
+ return
1488
+
1489
+ asp = _arcsec_per_pix_from_header(hdr, fallback_px_um=ps_um, fallback_fl_mm=fl_mm)
1490
+ if asp is None:
1491
+ QMessageBox.critical(self, self.tr("WCS Error"), self.tr("Cannot determine pixel scale."))
1492
+ return
1493
+
1494
+ DistortionGridDialog(
1495
+ img=np.clip(arr, 0, 1), sip_meta=hdr, arcsec_per_pix=float(asp),
1496
+ n_grid_lines=10, amplify=60.0, parent=self
1497
+ ).show()
1498
+
1499
+
1500
+ else:
1501
+ self._refresh_mosaic()
1502
+ finally:
1503
+ self._set_busy(False, "")
1504
+
1505
+ def _coerce_doc(self, obj):
1506
+ """Return a document object that has .image and .metadata, or None."""
1507
+ if obj is None:
1508
+ return None
1509
+ # If it already looks like a document
1510
+ if hasattr(obj, "image") and not isinstance(obj, QMdiSubWindow):
1511
+ return obj
1512
+ # If it's a subwindow, try its widget().document
1513
+ if isinstance(obj, QMdiSubWindow):
1514
+ w = obj.widget()
1515
+ return getattr(w, "document", None)
1516
+ # If it's a view-type wrapper
1517
+ if hasattr(obj, "document"):
1518
+ return getattr(obj, "document")
1519
+ return None
1520
+
1521
+
1522
+ def _on_panel_changed(self, v):
1523
+ self.panel_value_label.setText(str(v))
1524
+ self._refresh_mosaic()
1525
+
1526
+ def _on_sep_changed(self, v):
1527
+ self.sep_value_label.setText(str(v))
1528
+ self._refresh_mosaic()
1529
+
1530
+ def _update_sep_color_button(self):
1531
+ # show current color
1532
+ pix = QIcon().pixmap(16,16)
1533
+ pix.fill(self._sep_color)
1534
+ self.sep_color_btn.setIcon(QIcon(pix))
1535
+
1536
+ def _choose_sep_color(self):
1537
+ col = QColorDialog.getColor(self._sep_color, self, self.tr("Choose separation color"))
1538
+ if col.isValid():
1539
+ self._sep_color = col
1540
+ self._update_sep_color_button()
1541
+
1542
+ def _refresh_mosaic(self):
1543
+ arr, _ = self._arr_and_meta()
1544
+ if arr is None or arr.size == 0:
1545
+ return
1546
+ # ensure RGB for preview
1547
+ if arr.ndim == 2: arr = np.repeat(arr[...,None], 3, axis=2)
1548
+ qimg = self._to_qimage(np.clip(arr, 0, 1))
1549
+ n = max(2, int(self.grid_spin.value()))
1550
+ ps = int(self.panel_slider.value())
1551
+ sep = int(self.sep_slider.value())
1552
+ mosaic = self._build_mosaic(qimg, n, ps, sep, QColor(0,0,0))
1553
+ self.preview_pane.load_qimage(mosaic)
1554
+
1555
+ def _on_ok(self):
1556
+ # user clicked OK → generate & display the mosaic
1557
+ n = self.grid_spin.value
1558
+ panel_sz = self.panel_slider.value()
1559
+ sep = self.sep_slider.value()
1560
+ sep_col = self._sep_color
1561
+
1562
+ # fetch the currently loaded image (you’ll adapt to your image_manager API)
1563
+ img = self.image_manager.current_qimage()
1564
+ if img is None:
1565
+ QMessageBox.warning(self, self.tr("No image"), self.tr("No image loaded to peek at!"))
1566
+ return
1567
+
1568
+ mosaic = self._build_mosaic(img, n, panel_sz, sep, sep_col)
1569
+ self.preview.setPixmap(QPixmap.fromImage(mosaic))
1570
+ # keep dialog open so user can tweak parameters
1571
+
1572
+ def _build_mosaic(self, img, n, panel_sz, sep, sep_col):
1573
+ from PyQt6.QtGui import QImage
1574
+ W = n*panel_sz + (n-1)*sep; H = n*panel_sz + (n-1)*sep
1575
+ mosaic = QImage(W, H, img.format()); p = QPainter(mosaic)
1576
+ p.fillRect(0,0,W,H, sep_col)
1577
+ src_w, src_h = img.width(), img.height()
1578
+ xs = [int((src_w - panel_sz) * i / max(n-1, 1)) for i in range(n)]
1579
+ ys = [int((src_h - panel_sz) * j / max(n-1, 1)) for j in range(n)]
1580
+ for row, y in enumerate(ys):
1581
+ for col, x in enumerate(xs):
1582
+ patch = img.copy(x, y, panel_sz, panel_sz)
1583
+ dx = col * (panel_sz + sep); dy = row * (panel_sz + sep)
1584
+ p.drawImage(dx, dy, patch)
1585
+ p.end()
1586
+ return mosaic
1587
+
1588
+ def _to_qimage(self, arr: np.ndarray):
1589
+ # same as your _to_qimage in the snippet
1590
+ if arr.dtype.kind == "f":
1591
+ arr8 = np.clip(arr * 255, 0, 255).astype(np.uint8)
1592
+ elif arr.dtype != np.uint8:
1593
+ arr8 = arr.astype(np.uint8)
1594
+ else:
1595
+ arr8 = arr
1596
+ h, w = arr8.shape[:2]
1597
+ buf = arr8.tobytes(); self._last_qimage_buffer = buf
1598
+ from PyQt6.QtGui import QImage
1599
+ if arr8.ndim == 2:
1600
+ return QImage(buf, w, h, w, QImage.Format.Format_Grayscale8)
1601
+ elif arr8.ndim == 3 and arr8.shape[2] == 3:
1602
+ return QImage(buf, w, h, 3*w, QImage.Format.Format_RGB888)
1603
+ raise ValueError(f"Unsupported array shape {arr.shape}")
1604
+