setiastrosuitepro 1.6.7__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of setiastrosuitepro might be problematic. Click here for more details.

Files changed (394) 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/acv_icon.png +0 -0
  24. setiastro/images/andromedatry.png +0 -0
  25. setiastro/images/andromedatry_satellited.png +0 -0
  26. setiastro/images/annotated.png +0 -0
  27. setiastro/images/aperture.png +0 -0
  28. setiastro/images/astrosuite.ico +0 -0
  29. setiastro/images/astrosuite.png +0 -0
  30. setiastro/images/astrosuitepro.icns +0 -0
  31. setiastro/images/astrosuitepro.ico +0 -0
  32. setiastro/images/astrosuitepro.png +0 -0
  33. setiastro/images/background.png +0 -0
  34. setiastro/images/background2.png +0 -0
  35. setiastro/images/benchmark.png +0 -0
  36. setiastro/images/big_moon_stabilizer_timeline.png +0 -0
  37. setiastro/images/big_moon_stabilizer_timeline_clean.png +0 -0
  38. setiastro/images/blaster.png +0 -0
  39. setiastro/images/blink.png +0 -0
  40. setiastro/images/clahe.png +0 -0
  41. setiastro/images/collage.png +0 -0
  42. setiastro/images/colorwheel.png +0 -0
  43. setiastro/images/contsub.png +0 -0
  44. setiastro/images/convo.png +0 -0
  45. setiastro/images/copyslot.png +0 -0
  46. setiastro/images/cosmic.png +0 -0
  47. setiastro/images/cosmicsat.png +0 -0
  48. setiastro/images/crop1.png +0 -0
  49. setiastro/images/cropicon.png +0 -0
  50. setiastro/images/curves.png +0 -0
  51. setiastro/images/cvs.png +0 -0
  52. setiastro/images/debayer.png +0 -0
  53. setiastro/images/denoise_cnn_custom.png +0 -0
  54. setiastro/images/denoise_cnn_graph.png +0 -0
  55. setiastro/images/disk.png +0 -0
  56. setiastro/images/dse.png +0 -0
  57. setiastro/images/exoicon.png +0 -0
  58. setiastro/images/eye.png +0 -0
  59. setiastro/images/first_quarter.png +0 -0
  60. setiastro/images/fliphorizontal.png +0 -0
  61. setiastro/images/flipvertical.png +0 -0
  62. setiastro/images/font.png +0 -0
  63. setiastro/images/freqsep.png +0 -0
  64. setiastro/images/full_moon.png +0 -0
  65. setiastro/images/functionbundle.png +0 -0
  66. setiastro/images/graxpert.png +0 -0
  67. setiastro/images/green.png +0 -0
  68. setiastro/images/gridicon.png +0 -0
  69. setiastro/images/halo.png +0 -0
  70. setiastro/images/hdr.png +0 -0
  71. setiastro/images/histogram.png +0 -0
  72. setiastro/images/hubble.png +0 -0
  73. setiastro/images/imagecombine.png +0 -0
  74. setiastro/images/invert.png +0 -0
  75. setiastro/images/isophote.png +0 -0
  76. setiastro/images/isophote_demo_figure.png +0 -0
  77. setiastro/images/isophote_demo_image.png +0 -0
  78. setiastro/images/isophote_demo_model.png +0 -0
  79. setiastro/images/isophote_demo_residual.png +0 -0
  80. setiastro/images/jwstpupil.png +0 -0
  81. setiastro/images/last_quarter.png +0 -0
  82. setiastro/images/linearfit.png +0 -0
  83. setiastro/images/livestacking.png +0 -0
  84. setiastro/images/mask.png +0 -0
  85. setiastro/images/maskapply.png +0 -0
  86. setiastro/images/maskcreate.png +0 -0
  87. setiastro/images/maskremove.png +0 -0
  88. setiastro/images/morpho.png +0 -0
  89. setiastro/images/mosaic.png +0 -0
  90. setiastro/images/multiscale_decomp.png +0 -0
  91. setiastro/images/nbtorgb.png +0 -0
  92. setiastro/images/neutral.png +0 -0
  93. setiastro/images/new_moon.png +0 -0
  94. setiastro/images/nuke.png +0 -0
  95. setiastro/images/openfile.png +0 -0
  96. setiastro/images/pedestal.png +0 -0
  97. setiastro/images/pen.png +0 -0
  98. setiastro/images/pixelmath.png +0 -0
  99. setiastro/images/platesolve.png +0 -0
  100. setiastro/images/ppp.png +0 -0
  101. setiastro/images/pro.png +0 -0
  102. setiastro/images/project.png +0 -0
  103. setiastro/images/psf.png +0 -0
  104. setiastro/images/redo.png +0 -0
  105. setiastro/images/redoicon.png +0 -0
  106. setiastro/images/rescale.png +0 -0
  107. setiastro/images/rgbalign.png +0 -0
  108. setiastro/images/rgbcombo.png +0 -0
  109. setiastro/images/rgbextract.png +0 -0
  110. setiastro/images/rotate180.png +0 -0
  111. setiastro/images/rotatearbitrary.png +0 -0
  112. setiastro/images/rotateclockwise.png +0 -0
  113. setiastro/images/rotatecounterclockwise.png +0 -0
  114. setiastro/images/satellite.png +0 -0
  115. setiastro/images/script.png +0 -0
  116. setiastro/images/selectivecolor.png +0 -0
  117. setiastro/images/simbad.png +0 -0
  118. setiastro/images/slot0.png +0 -0
  119. setiastro/images/slot1.png +0 -0
  120. setiastro/images/slot2.png +0 -0
  121. setiastro/images/slot3.png +0 -0
  122. setiastro/images/slot4.png +0 -0
  123. setiastro/images/slot5.png +0 -0
  124. setiastro/images/slot6.png +0 -0
  125. setiastro/images/slot7.png +0 -0
  126. setiastro/images/slot8.png +0 -0
  127. setiastro/images/slot9.png +0 -0
  128. setiastro/images/spcc.png +0 -0
  129. setiastro/images/spin_precession_vs_lunar_distance.png +0 -0
  130. setiastro/images/spinner.gif +0 -0
  131. setiastro/images/stacking.png +0 -0
  132. setiastro/images/staradd.png +0 -0
  133. setiastro/images/staralign.png +0 -0
  134. setiastro/images/starnet.png +0 -0
  135. setiastro/images/starregistration.png +0 -0
  136. setiastro/images/starspike.png +0 -0
  137. setiastro/images/starstretch.png +0 -0
  138. setiastro/images/statstretch.png +0 -0
  139. setiastro/images/supernova.png +0 -0
  140. setiastro/images/uhs.png +0 -0
  141. setiastro/images/undoicon.png +0 -0
  142. setiastro/images/upscale.png +0 -0
  143. setiastro/images/viewbundle.png +0 -0
  144. setiastro/images/waning_crescent_1.png +0 -0
  145. setiastro/images/waning_crescent_2.png +0 -0
  146. setiastro/images/waning_crescent_3.png +0 -0
  147. setiastro/images/waning_crescent_4.png +0 -0
  148. setiastro/images/waning_crescent_5.png +0 -0
  149. setiastro/images/waning_gibbous_1.png +0 -0
  150. setiastro/images/waning_gibbous_2.png +0 -0
  151. setiastro/images/waning_gibbous_3.png +0 -0
  152. setiastro/images/waning_gibbous_4.png +0 -0
  153. setiastro/images/waning_gibbous_5.png +0 -0
  154. setiastro/images/waxing_crescent_1.png +0 -0
  155. setiastro/images/waxing_crescent_2.png +0 -0
  156. setiastro/images/waxing_crescent_3.png +0 -0
  157. setiastro/images/waxing_crescent_4.png +0 -0
  158. setiastro/images/waxing_crescent_5.png +0 -0
  159. setiastro/images/waxing_gibbous_1.png +0 -0
  160. setiastro/images/waxing_gibbous_2.png +0 -0
  161. setiastro/images/waxing_gibbous_3.png +0 -0
  162. setiastro/images/waxing_gibbous_4.png +0 -0
  163. setiastro/images/waxing_gibbous_5.png +0 -0
  164. setiastro/images/whitebalance.png +0 -0
  165. setiastro/images/wimi_icon_256x256.png +0 -0
  166. setiastro/images/wimilogo.png +0 -0
  167. setiastro/images/wims.png +0 -0
  168. setiastro/images/wrench_icon.png +0 -0
  169. setiastro/images/xisfliberator.png +0 -0
  170. setiastro/qml/ResourceMonitor.qml +128 -0
  171. setiastro/saspro/__init__.py +20 -0
  172. setiastro/saspro/__main__.py +964 -0
  173. setiastro/saspro/_generated/__init__.py +7 -0
  174. setiastro/saspro/_generated/build_info.py +3 -0
  175. setiastro/saspro/abe.py +1379 -0
  176. setiastro/saspro/abe_preset.py +196 -0
  177. setiastro/saspro/aberration_ai.py +910 -0
  178. setiastro/saspro/aberration_ai_preset.py +224 -0
  179. setiastro/saspro/accel_installer.py +218 -0
  180. setiastro/saspro/accel_workers.py +30 -0
  181. setiastro/saspro/acv_exporter.py +379 -0
  182. setiastro/saspro/add_stars.py +627 -0
  183. setiastro/saspro/astrobin_exporter.py +1010 -0
  184. setiastro/saspro/astrospike.py +153 -0
  185. setiastro/saspro/astrospike_python.py +1841 -0
  186. setiastro/saspro/autostretch.py +198 -0
  187. setiastro/saspro/backgroundneutral.py +639 -0
  188. setiastro/saspro/batch_convert.py +328 -0
  189. setiastro/saspro/batch_renamer.py +522 -0
  190. setiastro/saspro/blemish_blaster.py +494 -0
  191. setiastro/saspro/blink_comparator_pro.py +3149 -0
  192. setiastro/saspro/bundles.py +61 -0
  193. setiastro/saspro/bundles_dock.py +114 -0
  194. setiastro/saspro/cheat_sheet.py +213 -0
  195. setiastro/saspro/clahe.py +371 -0
  196. setiastro/saspro/comet_stacking.py +1442 -0
  197. setiastro/saspro/common_tr.py +107 -0
  198. setiastro/saspro/config.py +38 -0
  199. setiastro/saspro/config_bootstrap.py +40 -0
  200. setiastro/saspro/config_manager.py +316 -0
  201. setiastro/saspro/continuum_subtract.py +1620 -0
  202. setiastro/saspro/convo.py +1403 -0
  203. setiastro/saspro/convo_preset.py +414 -0
  204. setiastro/saspro/copyastro.py +190 -0
  205. setiastro/saspro/cosmicclarity.py +1593 -0
  206. setiastro/saspro/cosmicclarity_preset.py +407 -0
  207. setiastro/saspro/crop_dialog_pro.py +1005 -0
  208. setiastro/saspro/crop_preset.py +189 -0
  209. setiastro/saspro/curve_editor_pro.py +2608 -0
  210. setiastro/saspro/curves_preset.py +375 -0
  211. setiastro/saspro/debayer.py +673 -0
  212. setiastro/saspro/debug_utils.py +29 -0
  213. setiastro/saspro/dnd_mime.py +35 -0
  214. setiastro/saspro/doc_manager.py +2727 -0
  215. setiastro/saspro/exoplanet_detector.py +2258 -0
  216. setiastro/saspro/file_utils.py +284 -0
  217. setiastro/saspro/fitsmodifier.py +748 -0
  218. setiastro/saspro/fix_bom.py +32 -0
  219. setiastro/saspro/free_torch_memory.py +48 -0
  220. setiastro/saspro/frequency_separation.py +1352 -0
  221. setiastro/saspro/function_bundle.py +1596 -0
  222. setiastro/saspro/generate_translations.py +3092 -0
  223. setiastro/saspro/ghs_dialog_pro.py +728 -0
  224. setiastro/saspro/ghs_preset.py +284 -0
  225. setiastro/saspro/graxpert.py +638 -0
  226. setiastro/saspro/graxpert_preset.py +287 -0
  227. setiastro/saspro/gui/__init__.py +0 -0
  228. setiastro/saspro/gui/main_window.py +8928 -0
  229. setiastro/saspro/gui/mixins/__init__.py +33 -0
  230. setiastro/saspro/gui/mixins/dock_mixin.py +375 -0
  231. setiastro/saspro/gui/mixins/file_mixin.py +450 -0
  232. setiastro/saspro/gui/mixins/geometry_mixin.py +503 -0
  233. setiastro/saspro/gui/mixins/header_mixin.py +441 -0
  234. setiastro/saspro/gui/mixins/mask_mixin.py +421 -0
  235. setiastro/saspro/gui/mixins/menu_mixin.py +391 -0
  236. setiastro/saspro/gui/mixins/theme_mixin.py +367 -0
  237. setiastro/saspro/gui/mixins/toolbar_mixin.py +1824 -0
  238. setiastro/saspro/gui/mixins/update_mixin.py +323 -0
  239. setiastro/saspro/gui/mixins/view_mixin.py +477 -0
  240. setiastro/saspro/gui/statistics_dialog.py +47 -0
  241. setiastro/saspro/halobgon.py +492 -0
  242. setiastro/saspro/header_viewer.py +448 -0
  243. setiastro/saspro/headless_utils.py +88 -0
  244. setiastro/saspro/histogram.py +760 -0
  245. setiastro/saspro/history_explorer.py +941 -0
  246. setiastro/saspro/i18n.py +168 -0
  247. setiastro/saspro/image_combine.py +421 -0
  248. setiastro/saspro/image_peeker_pro.py +1608 -0
  249. setiastro/saspro/imageops/__init__.py +37 -0
  250. setiastro/saspro/imageops/mdi_snap.py +292 -0
  251. setiastro/saspro/imageops/scnr.py +36 -0
  252. setiastro/saspro/imageops/starbasedwhitebalance.py +210 -0
  253. setiastro/saspro/imageops/stretch.py +236 -0
  254. setiastro/saspro/isophote.py +1186 -0
  255. setiastro/saspro/layers.py +208 -0
  256. setiastro/saspro/layers_dock.py +714 -0
  257. setiastro/saspro/lazy_imports.py +193 -0
  258. setiastro/saspro/legacy/__init__.py +2 -0
  259. setiastro/saspro/legacy/image_manager.py +2360 -0
  260. setiastro/saspro/legacy/numba_utils.py +3676 -0
  261. setiastro/saspro/legacy/xisf.py +1213 -0
  262. setiastro/saspro/linear_fit.py +537 -0
  263. setiastro/saspro/live_stacking.py +1854 -0
  264. setiastro/saspro/log_bus.py +5 -0
  265. setiastro/saspro/logging_config.py +460 -0
  266. setiastro/saspro/luminancerecombine.py +510 -0
  267. setiastro/saspro/main_helpers.py +201 -0
  268. setiastro/saspro/mask_creation.py +1090 -0
  269. setiastro/saspro/masks_core.py +56 -0
  270. setiastro/saspro/mdi_widgets.py +353 -0
  271. setiastro/saspro/memory_utils.py +666 -0
  272. setiastro/saspro/metadata_patcher.py +75 -0
  273. setiastro/saspro/mfdeconv.py +3909 -0
  274. setiastro/saspro/mfdeconv_earlystop.py +71 -0
  275. setiastro/saspro/mfdeconvcudnn.py +3312 -0
  276. setiastro/saspro/mfdeconvsport.py +2459 -0
  277. setiastro/saspro/minorbodycatalog.py +567 -0
  278. setiastro/saspro/morphology.py +411 -0
  279. setiastro/saspro/multiscale_decomp.py +1751 -0
  280. setiastro/saspro/nbtorgb_stars.py +541 -0
  281. setiastro/saspro/numba_utils.py +3145 -0
  282. setiastro/saspro/numba_warmup.py +141 -0
  283. setiastro/saspro/ops/__init__.py +9 -0
  284. setiastro/saspro/ops/command_help_dialog.py +623 -0
  285. setiastro/saspro/ops/command_runner.py +217 -0
  286. setiastro/saspro/ops/commands.py +1594 -0
  287. setiastro/saspro/ops/script_editor.py +1105 -0
  288. setiastro/saspro/ops/scripts.py +1476 -0
  289. setiastro/saspro/ops/settings.py +637 -0
  290. setiastro/saspro/parallel_utils.py +554 -0
  291. setiastro/saspro/pedestal.py +121 -0
  292. setiastro/saspro/perfect_palette_picker.py +1105 -0
  293. setiastro/saspro/pipeline.py +110 -0
  294. setiastro/saspro/pixelmath.py +1604 -0
  295. setiastro/saspro/plate_solver.py +2480 -0
  296. setiastro/saspro/project_io.py +797 -0
  297. setiastro/saspro/psf_utils.py +136 -0
  298. setiastro/saspro/psf_viewer.py +631 -0
  299. setiastro/saspro/pyi_rthook_astroquery.py +95 -0
  300. setiastro/saspro/remove_green.py +331 -0
  301. setiastro/saspro/remove_stars.py +1599 -0
  302. setiastro/saspro/remove_stars_preset.py +446 -0
  303. setiastro/saspro/resources.py +570 -0
  304. setiastro/saspro/rgb_combination.py +208 -0
  305. setiastro/saspro/rgb_extract.py +19 -0
  306. setiastro/saspro/rgbalign.py +727 -0
  307. setiastro/saspro/runtime_imports.py +7 -0
  308. setiastro/saspro/runtime_torch.py +754 -0
  309. setiastro/saspro/save_options.py +73 -0
  310. setiastro/saspro/selective_color.py +1614 -0
  311. setiastro/saspro/sfcc.py +1530 -0
  312. setiastro/saspro/shortcuts.py +3125 -0
  313. setiastro/saspro/signature_insert.py +1106 -0
  314. setiastro/saspro/stacking_suite.py +19069 -0
  315. setiastro/saspro/star_alignment.py +7383 -0
  316. setiastro/saspro/star_alignment_preset.py +329 -0
  317. setiastro/saspro/star_metrics.py +49 -0
  318. setiastro/saspro/star_spikes.py +769 -0
  319. setiastro/saspro/star_stretch.py +542 -0
  320. setiastro/saspro/stat_stretch.py +554 -0
  321. setiastro/saspro/status_log_dock.py +78 -0
  322. setiastro/saspro/subwindow.py +3523 -0
  323. setiastro/saspro/supernovaasteroidhunter.py +1719 -0
  324. setiastro/saspro/swap_manager.py +134 -0
  325. setiastro/saspro/torch_backend.py +89 -0
  326. setiastro/saspro/torch_rejection.py +434 -0
  327. setiastro/saspro/translations/all_source_strings.json +4726 -0
  328. setiastro/saspro/translations/ar_translations.py +4096 -0
  329. setiastro/saspro/translations/de_translations.py +3728 -0
  330. setiastro/saspro/translations/es_translations.py +4169 -0
  331. setiastro/saspro/translations/fr_translations.py +4090 -0
  332. setiastro/saspro/translations/hi_translations.py +3803 -0
  333. setiastro/saspro/translations/integrate_translations.py +271 -0
  334. setiastro/saspro/translations/it_translations.py +4728 -0
  335. setiastro/saspro/translations/ja_translations.py +3834 -0
  336. setiastro/saspro/translations/pt_translations.py +3847 -0
  337. setiastro/saspro/translations/ru_translations.py +3082 -0
  338. setiastro/saspro/translations/saspro_ar.qm +0 -0
  339. setiastro/saspro/translations/saspro_ar.ts +16019 -0
  340. setiastro/saspro/translations/saspro_de.qm +0 -0
  341. setiastro/saspro/translations/saspro_de.ts +14548 -0
  342. setiastro/saspro/translations/saspro_es.qm +0 -0
  343. setiastro/saspro/translations/saspro_es.ts +16202 -0
  344. setiastro/saspro/translations/saspro_fr.qm +0 -0
  345. setiastro/saspro/translations/saspro_fr.ts +15870 -0
  346. setiastro/saspro/translations/saspro_hi.qm +0 -0
  347. setiastro/saspro/translations/saspro_hi.ts +14855 -0
  348. setiastro/saspro/translations/saspro_it.qm +0 -0
  349. setiastro/saspro/translations/saspro_it.ts +19046 -0
  350. setiastro/saspro/translations/saspro_ja.qm +0 -0
  351. setiastro/saspro/translations/saspro_ja.ts +14980 -0
  352. setiastro/saspro/translations/saspro_pt.qm +0 -0
  353. setiastro/saspro/translations/saspro_pt.ts +15024 -0
  354. setiastro/saspro/translations/saspro_ru.qm +0 -0
  355. setiastro/saspro/translations/saspro_ru.ts +11835 -0
  356. setiastro/saspro/translations/saspro_sw.qm +0 -0
  357. setiastro/saspro/translations/saspro_sw.ts +15237 -0
  358. setiastro/saspro/translations/saspro_uk.qm +0 -0
  359. setiastro/saspro/translations/saspro_uk.ts +15248 -0
  360. setiastro/saspro/translations/saspro_zh.qm +0 -0
  361. setiastro/saspro/translations/saspro_zh.ts +15289 -0
  362. setiastro/saspro/translations/sw_translations.py +3897 -0
  363. setiastro/saspro/translations/uk_translations.py +3929 -0
  364. setiastro/saspro/translations/zh_translations.py +3910 -0
  365. setiastro/saspro/versioning.py +77 -0
  366. setiastro/saspro/view_bundle.py +1558 -0
  367. setiastro/saspro/wavescale_hdr.py +648 -0
  368. setiastro/saspro/wavescale_hdr_preset.py +101 -0
  369. setiastro/saspro/wavescalede.py +683 -0
  370. setiastro/saspro/wavescalede_preset.py +230 -0
  371. setiastro/saspro/wcs_update.py +374 -0
  372. setiastro/saspro/whitebalance.py +540 -0
  373. setiastro/saspro/widgets/__init__.py +48 -0
  374. setiastro/saspro/widgets/common_utilities.py +306 -0
  375. setiastro/saspro/widgets/graphics_views.py +122 -0
  376. setiastro/saspro/widgets/image_utils.py +518 -0
  377. setiastro/saspro/widgets/minigame/game.js +991 -0
  378. setiastro/saspro/widgets/minigame/index.html +53 -0
  379. setiastro/saspro/widgets/minigame/style.css +241 -0
  380. setiastro/saspro/widgets/preview_dialogs.py +280 -0
  381. setiastro/saspro/widgets/resource_monitor.py +313 -0
  382. setiastro/saspro/widgets/spinboxes.py +290 -0
  383. setiastro/saspro/widgets/themed_buttons.py +13 -0
  384. setiastro/saspro/widgets/wavelet_utils.py +331 -0
  385. setiastro/saspro/wimi.py +7367 -0
  386. setiastro/saspro/wims.py +588 -0
  387. setiastro/saspro/window_shelf.py +185 -0
  388. setiastro/saspro/xisf.py +1213 -0
  389. setiastrosuitepro-1.6.7.dist-info/METADATA +279 -0
  390. setiastrosuitepro-1.6.7.dist-info/RECORD +394 -0
  391. setiastrosuitepro-1.6.7.dist-info/WHEEL +4 -0
  392. setiastrosuitepro-1.6.7.dist-info/entry_points.txt +6 -0
  393. setiastrosuitepro-1.6.7.dist-info/licenses/LICENSE +674 -0
  394. setiastrosuitepro-1.6.7.dist-info/licenses/license.txt +2580 -0
@@ -0,0 +1,1005 @@
1
+ # pro/crop_dialog_pro.py
2
+ from __future__ import annotations
3
+
4
+ import math
5
+ import numpy as np
6
+ import cv2
7
+ from typing import Optional
8
+
9
+ from PyQt6.QtCore import Qt, QEvent, QPointF, QRectF, pyqtSignal, QPoint, QTimer
10
+ from PyQt6.QtGui import QPixmap, QImage, QPen, QBrush, QColor
11
+ from PyQt6.QtWidgets import (
12
+ QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QComboBox, QToolButton,
13
+ QMessageBox, QGraphicsScene, QGraphicsView, QGraphicsRectItem, QGraphicsEllipseItem,
14
+ QGraphicsItem, QGraphicsPixmapItem, QSpinBox
15
+ )
16
+
17
+ from setiastro.saspro.wcs_update import update_wcs_after_crop
18
+ from setiastro.saspro.widgets.themed_buttons import themed_toolbtn
19
+
20
+ # -------- util: Siril-style preview stretch (non-destructive) ----------
21
+ def siril_style_autostretch(image: np.ndarray, sigma: float = 3.0) -> np.ndarray:
22
+ def stretch_channel(c):
23
+ med = np.median(c); mad = np.median(np.abs(c - med))
24
+ mad_std = mad * 1.4826
25
+ mn, mx = float(c.min()), float(c.max())
26
+ bp = max(mn, med - sigma * mad_std)
27
+ wp = min(mx, med + sigma * mad_std)
28
+ if wp - bp <= 1e-8: return np.zeros_like(c)
29
+ out = (c - bp) / (wp - bp)
30
+ return np.clip(out, 0, 1)
31
+
32
+ if image.ndim == 2:
33
+ return stretch_channel(image)
34
+ if image.ndim == 3 and image.shape[2] == 3:
35
+ return np.stack([stretch_channel(image[..., i]) for i in range(3)], axis=-1)
36
+ raise ValueError("Unsupported image format for autostretch.")
37
+
38
+ HANDLE_SIZE = 8 # screen pixels (handles stay constant size)
39
+ EDGE_GRAB_PX = 12 # screen-pixel tolerance for grabbing edges when zoomed out
40
+
41
+
42
+
43
+ class ResizableRotatableRectItem(QGraphicsRectItem):
44
+ def __init__(self, rect: QRectF, parent=None):
45
+ super().__init__(rect, parent)
46
+ pen = QPen(Qt.GlobalColor.green, 2); pen.setCosmetic(True)
47
+ self.setPen(pen)
48
+ self.setBrush(QBrush(Qt.BrushStyle.NoBrush))
49
+ self.setFlags(
50
+ QGraphicsItem.GraphicsItemFlag.ItemIsSelectable |
51
+ QGraphicsItem.GraphicsItemFlag.ItemIsMovable |
52
+ QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges
53
+ )
54
+ self.setAcceptHoverEvents(True)
55
+ self._fixed_ar: Optional[float] = None
56
+ self._handles: dict[str, QGraphicsEllipseItem] = {}
57
+ self._active: Optional[str] = None
58
+ self._rotating = False
59
+ self._angle0 = 0.0
60
+ self._pivot_scene = QPointF()
61
+
62
+ self._grab_pad = 20 # ← extra hit slop in screen px
63
+ self._edge_pad_px = EDGE_GRAB_PX
64
+ self.setZValue(100) # ← keep above pixmap
65
+
66
+ self._mk_handles()
67
+ self.setTransformOriginPoint(self.rect().center())
68
+
69
+ def setFixedAspectRatio(self, ratio: Optional[float]):
70
+ self._fixed_ar = ratio
71
+
72
+ def _scene_tolerance(self, px: float) -> float:
73
+ """Convert a pixel tolerance into scene/item units using the first view."""
74
+ sc = self.scene()
75
+ if not sc:
76
+ return float(px)
77
+ views = sc.views()
78
+ if not views:
79
+ return float(px)
80
+ v = views[0]
81
+ p0 = v.mapToScene(QPoint(0, 0))
82
+ p1 = v.mapToScene(QPoint(int(px), 0))
83
+ dx = p1.x() - p0.x()
84
+ dy = p1.y() - p0.y()
85
+ return math.hypot(dx, dy)
86
+
87
+ def _edge_under_cursor(self, scene_pos: QPointF) -> Optional[str]:
88
+ """
89
+ Return 'l', 'r', 't', or 'b' if the pointer is near an edge (within px-tolerance),
90
+ else None. Works at any zoom/rotation.
91
+ """
92
+ tol = self._scene_tolerance(self._edge_pad_px)
93
+ r = self.rect()
94
+ p = self.mapFromScene(scene_pos) # local coords (rotation handled)
95
+
96
+ # Distance to each edge in item units
97
+ d = {
98
+ "l": abs(p.x() - r.left()),
99
+ "r": abs(p.x() - r.right()),
100
+ "t": abs(p.y() - r.top()),
101
+ "b": abs(p.y() - r.bottom()),
102
+ }
103
+ m = min(d.values())
104
+ if m > tol:
105
+ return None
106
+
107
+ # Must also be within the span of the opposite axis (with tolerance)
108
+ if d["l"] == m or d["r"] == m:
109
+ if (r.top() - tol) <= p.y() <= (r.bottom() + tol):
110
+ return "l" if d["l"] <= d["r"] else "r"
111
+ else: # top/bottom
112
+ if (r.left() - tol) <= p.x() <= (r.right() + tol):
113
+ return "t" if d["t"] <= d["b"] else "b"
114
+
115
+ return None
116
+
117
+
118
+ def _mk_handles(self):
119
+ pen = QPen(Qt.GlobalColor.green, 2); pen.setCosmetic(True)
120
+ brush = QBrush(Qt.GlobalColor.white)
121
+ for name in ("tl", "tr", "br", "bl"):
122
+ h = QGraphicsEllipseItem(0, 0, HANDLE_SIZE, HANDLE_SIZE, self)
123
+ h.setPen(pen); h.setBrush(brush)
124
+ h.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsMovable, False)
125
+ h.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIgnoresTransformations, True) # constant-size on screen
126
+ h.setAcceptedMouseButtons(Qt.MouseButton.NoButton) # ← let parent receive mouse events
127
+ h.setAcceptHoverEvents(False)
128
+ h.setZValue(self.zValue() + 1)
129
+ self._handles[name] = h
130
+ self._sync_handles()
131
+
132
+ def _handle_hit(self, h: QGraphicsEllipseItem, scene_pos: QPointF) -> bool:
133
+ """
134
+ True if scene_pos is within the handle ellipse *plus* padding.
135
+ Because the handle ignores view transforms, this padding is in screen px.
136
+ """
137
+ p = h.mapFromScene(scene_pos)
138
+ r = h.rect().adjusted(-self._grab_pad, -self._grab_pad, self._grab_pad, self._grab_pad)
139
+ return r.contains(p)
140
+
141
+ def _sync_handles(self):
142
+ r = self.rect(); s = HANDLE_SIZE
143
+ pos = {
144
+ "tl": QPointF(r.left()-s/2, r.top()-s/2),
145
+ "tr": QPointF(r.right()-s/2, r.top()-s/2),
146
+ "br": QPointF(r.right()-s/2, r.bottom()-s/2),
147
+ "bl": QPointF(r.left()-s/2, r.bottom()-s/2),
148
+ }
149
+ for k, it in self._handles.items():
150
+ it.setPos(pos[k])
151
+
152
+ def hoverMoveEvent(self, e):
153
+ # Corner handles take priority
154
+ for k, h in self._handles.items():
155
+ if self._handle_hit(h, e.scenePos()):
156
+ self.setCursor({
157
+ "tl": Qt.CursorShape.SizeFDiagCursor,
158
+ "br": Qt.CursorShape.SizeFDiagCursor,
159
+ "tr": Qt.CursorShape.SizeBDiagCursor,
160
+ "bl": Qt.CursorShape.SizeBDiagCursor,
161
+ }.get(k, Qt.CursorShape.ArrowCursor))
162
+ return
163
+
164
+ # Edges next
165
+ edge = self._edge_under_cursor(e.scenePos())
166
+ if edge:
167
+ self.setCursor(
168
+ Qt.CursorShape.SizeHorCursor if edge in ("l", "r")
169
+ else Qt.CursorShape.SizeVerCursor
170
+ )
171
+ return
172
+
173
+ # Otherwise move
174
+ self.setCursor(Qt.CursorShape.SizeAllCursor)
175
+ super().hoverMoveEvent(e)
176
+
177
+ def mousePressEvent(self, e):
178
+ if e.modifiers() == Qt.KeyboardModifier.ShiftModifier:
179
+ self._rotating = True
180
+ self._pivot_scene = self.mapToScene(self.rect().center())
181
+ v0 = e.scenePos() - self._pivot_scene
182
+ self._angle_ref = math.degrees(math.atan2(v0.y(), v0.x()))
183
+ self._angle0 = self.rotation()
184
+ e.accept(); return
185
+
186
+ # padded corner hit
187
+ for k, h in self._handles.items():
188
+ if self._handle_hit(h, e.scenePos()):
189
+ self._active = k
190
+ e.accept(); return
191
+
192
+ # edge hit
193
+ edge = self._edge_under_cursor(e.scenePos())
194
+ if edge:
195
+ self._active = edge
196
+ e.accept(); return
197
+
198
+ super().mousePressEvent(e)
199
+
200
+ def mouseMoveEvent(self, e):
201
+ if self._rotating:
202
+ v = e.scenePos() - self._pivot_scene
203
+ ang = math.degrees(math.atan2(v.y(), v.x()))
204
+ self.setRotation(self._angle0 + (ang - self._angle_ref))
205
+ e.accept(); return
206
+ if self._active:
207
+ self._resize_via_handle(e.scenePos()); e.accept(); return
208
+ super().mouseMoveEvent(e)
209
+
210
+ def mouseReleaseEvent(self, e):
211
+ self._rotating = False; self._active = None
212
+ super().mouseReleaseEvent(e)
213
+
214
+ def itemChange(self, change, value):
215
+ if change in (
216
+ QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged,
217
+ QGraphicsItem.GraphicsItemChange.ItemRotationHasChanged,
218
+ QGraphicsItem.GraphicsItemChange.ItemTransformHasChanged,
219
+ ):
220
+ self._sync_handles()
221
+ return super().itemChange(change, value)
222
+
223
+ def _resize_via_handle(self, scene_pt: QPointF):
224
+ r = self.rect()
225
+ p = self.mapFromScene(scene_pt)
226
+
227
+ # Corners
228
+ if self._active == "tl": r.setTopLeft(p)
229
+ elif self._active == "tr": r.setTopRight(p)
230
+ elif self._active == "br": r.setBottomRight(p)
231
+ elif self._active == "bl": r.setBottomLeft(p)
232
+ # Edges
233
+ elif self._active == "l": r.setLeft(p.x())
234
+ elif self._active == "r": r.setRight(p.x())
235
+ elif self._active == "t": r.setTop(p.y())
236
+ elif self._active == "b": r.setBottom(p.y())
237
+
238
+ # Aspect ratio maintenance
239
+ if self._fixed_ar:
240
+ r = r.normalized()
241
+ cx, cy = r.center().x(), r.center().y()
242
+ if self._active in ("l", "r"): # horizontal resize → adjust height
243
+ w = r.width()
244
+ h = w / self._fixed_ar
245
+ r.setTop(cy - h/2); r.setBottom(cy + h/2)
246
+ elif self._active in ("t", "b"): # vertical resize → adjust width
247
+ h = r.height()
248
+ w = h * self._fixed_ar
249
+ r.setLeft(cx - w/2); r.setRight(cx + w/2)
250
+ else: # corner behaves like before
251
+ w = r.width(); h = w / self._fixed_ar
252
+ if self._active in ("tl", "tr"):
253
+ r.setTop(r.bottom() - h)
254
+ else:
255
+ r.setBottom(r.top() + h)
256
+
257
+ r = r.normalized()
258
+ self.setRect(r)
259
+ self._sync_handles()
260
+
261
+
262
+ class CropDialogPro(QDialog):
263
+ """SASpro crop/rotate dialog working on a Document."""
264
+ crop_applied = pyqtSignal(np.ndarray)
265
+
266
+ # persistent “Load Previous”
267
+ _prev_rect: Optional[QRectF] = None
268
+ _prev_angle: float = 0.0
269
+ _prev_pos: QPointF = QPointF()
270
+
271
+ def __init__(self, parent, document):
272
+ super().__init__(parent)
273
+ self.setWindowTitle(self.tr("Crop Tool"))
274
+ self.setWindowFlag(Qt.WindowType.Window, True)
275
+ self.setWindowModality(Qt.WindowModality.NonModal)
276
+ self.setModal(False)
277
+ self._main = parent
278
+ self.doc = document
279
+
280
+ self._follow_conn = False
281
+ if hasattr(self._main, "currentDocumentChanged"):
282
+ try:
283
+ self._main.currentDocumentChanged.connect(self._on_active_doc_changed)
284
+ self._follow_conn = True
285
+ except Exception:
286
+ self._follow_conn = False
287
+
288
+ self.finished.connect(self._cleanup_connections)
289
+ try:
290
+ self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
291
+ except Exception:
292
+ pass # older PyQt6 versions
293
+ self._rect_item: Optional[ResizableRotatableRectItem] = None
294
+ self._pix_item: Optional[QGraphicsPixmapItem] = None
295
+ self._drawing = False
296
+ self._origin = QPointF()
297
+ self._autostretch_on = True
298
+
299
+ # ---------- layout ----------
300
+ main = QVBoxLayout(self)
301
+
302
+ info = QLabel(self.tr(
303
+ "• Click–drag to draw a crop\n"
304
+ "• Drag corner handles to resize\n"
305
+ "• Shift + drag on box to rotate"
306
+ )); info.setStyleSheet("color: gray; font-style: italic;")
307
+ main.addWidget(info)
308
+
309
+ # aspect row
310
+ row = QHBoxLayout()
311
+ row.addStretch(1)
312
+ row.addWidget(QLabel(self.tr("Aspect Ratio:")))
313
+ self.cmb_ar = QComboBox()
314
+ self.cmb_ar.addItems([
315
+ self.tr("Free"), self.tr("Original"),
316
+ "1:1",
317
+ "3:2", "2:3",
318
+ "4:3", "3:4",
319
+ "4:5", "5:4",
320
+ "16:9", "9:16",
321
+ "21:9", "9:21",
322
+ "2:1", "1:2",
323
+ "3:5", "5:3",
324
+ ])
325
+ row.addWidget(self.cmb_ar)
326
+ row.addStretch(1)
327
+ main.addLayout(row)
328
+
329
+ # typed margins (pixels): Top, Right, Bottom, Left
330
+ margins_row = QHBoxLayout()
331
+ margins_row.addStretch(1)
332
+ margins_row.addWidget(QLabel(self.tr("Margins (px):")))
333
+ self.sb_top = QSpinBox(); self.sb_top.setSuffix(" px")
334
+ self.sb_right = QSpinBox(); self.sb_right.setSuffix(" px")
335
+ self.sb_bottom = QSpinBox(); self.sb_bottom.setSuffix(" px")
336
+ self.sb_left = QSpinBox(); self.sb_left.setSuffix(" px")
337
+
338
+ # reasonable wide ranges; clamped on apply anyway
339
+ for sb in (self.sb_top, self.sb_bottom, self.sb_left, self.sb_right):
340
+ sb.setRange(0, 1_000_000)
341
+
342
+ # labels inline for clarity
343
+ margins_row.addWidget(QLabel(self.tr("Top")))
344
+ margins_row.addWidget(self.sb_top)
345
+ margins_row.addSpacing(8)
346
+ margins_row.addWidget(QLabel(self.tr("Right")))
347
+ margins_row.addWidget(self.sb_right)
348
+ margins_row.addSpacing(8)
349
+ margins_row.addWidget(QLabel(self.tr("Bottom")))
350
+ margins_row.addWidget(self.sb_bottom)
351
+ margins_row.addSpacing(8)
352
+ margins_row.addWidget(QLabel(self.tr("Left")))
353
+ margins_row.addWidget(self.sb_left)
354
+ margins_row.addStretch(1)
355
+ main.addLayout(margins_row)
356
+
357
+ # live-apply: when any value changes, update the selection rectangle
358
+ self._suppress_margin_sync = False
359
+ def _on_margin_changed(_):
360
+ if self._suppress_margin_sync:
361
+ return
362
+ self._apply_margin_inputs()
363
+ for sb in (self.sb_top, self.sb_right, self.sb_bottom, self.sb_left):
364
+ sb.valueChanged.connect(_on_margin_changed)
365
+
366
+ # graphics view
367
+ self.scene = QGraphicsScene(self)
368
+ self.view = QGraphicsView(self.scene)
369
+ self.view.setRenderHints(self.view.renderHints())
370
+ self.view.setDragMode(QGraphicsView.DragMode.NoDrag)
371
+ self.view.viewport().installEventFilter(self)
372
+ main.addWidget(self.view, 1)
373
+
374
+ self._zoom = 1.0 # manual zoom factor
375
+ self._fit_mode = True # start in Fit-to-View mode
376
+
377
+ # nicer zoom behavior
378
+ self.view.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
379
+ self.view.setResizeAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
380
+ self.view.setDragMode(QGraphicsView.DragMode.ScrollHandDrag) # pan with mouse-drag
381
+
382
+ zoom_row = QHBoxLayout()
383
+ zoom_row.addStretch(1)
384
+
385
+ self.btn_zoom_out = themed_toolbtn("zoom-out", self.tr("Zoom Out"))
386
+ self.btn_zoom_in = themed_toolbtn("zoom-in", self.tr("Zoom In"))
387
+ self.btn_zoom_100 = themed_toolbtn("zoom-original", self.tr("Zoom 100%"))
388
+ self.btn_zoom_fit = themed_toolbtn("zoom-fit-best", self.tr("Fit to View"))
389
+
390
+ for b in (self.btn_zoom_out, self.btn_zoom_in, self.btn_zoom_100, self.btn_zoom_fit):
391
+ zoom_row.addWidget(b)
392
+
393
+ zoom_row.addStretch(1)
394
+ main.addLayout(zoom_row)
395
+
396
+ dim_row = QHBoxLayout()
397
+ dim_row.addStretch(1)
398
+ self.lbl_dims = QLabel(self.tr("Selection: —"))
399
+ self.lbl_dims.setStyleSheet("color: gray;")
400
+ dim_row.addWidget(self.lbl_dims)
401
+ dim_row.addStretch(1)
402
+ main.addLayout(dim_row)
403
+
404
+ # wire zoom buttons
405
+ self.btn_zoom_in.clicked.connect(lambda: self._zoom_by(1.25))
406
+ self.btn_zoom_out.clicked.connect(lambda: self._zoom_by(1/1.25))
407
+ self.btn_zoom_100.clicked.connect(self._zoom_reset_100)
408
+ self.btn_zoom_fit.clicked.connect(self._fit_view)
409
+
410
+ # buttons
411
+ btn_row = QHBoxLayout()
412
+ self.btn_autostretch = QPushButton(self.tr("Toggle Autostretch"))
413
+ self.btn_prev = QPushButton(self.tr("Load Previous Crop"))
414
+ self.btn_apply = QPushButton(self.tr("Apply"))
415
+ self.btn_batch = QPushButton(self.tr("Batch Crop (all open)"))
416
+ self.btn_close = QToolButton(); self.btn_close.setText(self.tr("Close"))
417
+ for b in (self.btn_autostretch, self.btn_prev, self.btn_apply, self.btn_batch, self.btn_close):
418
+ btn_row.addWidget(b)
419
+ main.addLayout(btn_row)
420
+
421
+ # wire
422
+ self.cmb_ar.currentTextChanged.connect(self._on_ar_changed)
423
+ self.btn_autostretch.clicked.connect(self._toggle_autostretch)
424
+ self.btn_prev.clicked.connect(self._load_previous)
425
+ self.btn_apply.clicked.connect(self._apply_one)
426
+ self.btn_batch.clicked.connect(self._apply_batch)
427
+ self.btn_close.clicked.connect(self.close)
428
+
429
+ # seed image
430
+ self._load_from_doc()
431
+ self._update_margin_spin_ranges()
432
+ self.resize(1000, 720)
433
+ self._deferred_fit()
434
+
435
+ def _deferred_fit(self):
436
+ if self._fit_mode:
437
+ QTimer.singleShot(0, self._fit_view)
438
+
439
+ def showEvent(self, ev):
440
+ super().showEvent(ev)
441
+ self._deferred_fit() # ensure fit after the first real layout
442
+
443
+ # ---------- image plumbing ----------
444
+ def _quad_is_axis_aligned(self, pts: np.ndarray, tol: float = 1e-2) -> bool:
445
+ """
446
+ pts: (4,2) in image pixel coords, order: TL, TR, BR, BL
447
+ Returns True if edges are parallel to axes within tolerance.
448
+ """
449
+ if pts.shape != (4, 2):
450
+ return False
451
+ xL, xR = (pts[0,0] + pts[3,0]) * 0.5, (pts[1,0] + pts[2,0]) * 0.5
452
+ yT, yB = (pts[0,1] + pts[1,1]) * 0.5, (pts[2,1] + pts[3,1]) * 0.5
453
+ # vertical edges nearly vertical, horizontal edges nearly horizontal
454
+ # Check that each edge's "other" dimension differs by very little.
455
+ left_dx = abs(pts[0,0] - pts[3,0])
456
+ right_dx = abs(pts[1,0] - pts[2,0])
457
+ top_dy = abs(pts[0,1] - pts[1,1])
458
+ bot_dy = abs(pts[2,1] - pts[3,1])
459
+
460
+ return (left_dx < tol and right_dx < tol and top_dy < tol and bot_dy < tol)
461
+
462
+ def _int_bounds_from_quad(self, pts: np.ndarray, W: int, H: int) -> tuple[int,int,int,int] | None:
463
+ """
464
+ pts: (4,2) image-space corners. Returns (x0, x1, y0, y1) clamped to image
465
+ using floor/ceil so we keep all intended pixels.
466
+ """
467
+ if pts.size != 8:
468
+ return None
469
+ xs = pts[:,0]; ys = pts[:,1]
470
+ # inclusive-exclusive slice bounds
471
+ x0 = int(np.floor(xs.min() + 1e-6))
472
+ y0 = int(np.floor(ys.min() + 1e-6))
473
+ x1 = int(np.ceil (xs.max() - 1e-6))
474
+ y1 = int(np.ceil (ys.max() - 1e-6))
475
+ # clamp
476
+ x0 = max(0, min(W, x0)); x1 = max(0, min(W, x1))
477
+ y0 = max(0, min(H, y0)); y1 = max(0, min(H, y1))
478
+ if x1 <= x0 or y1 <= y0:
479
+ return None
480
+ return x0, x1, y0, y1
481
+
482
+
483
+ def _img01_from_doc(self) -> np.ndarray:
484
+ arr = np.asarray(self.doc.image)
485
+ if arr.dtype.kind in "ui":
486
+ arr = arr.astype(np.float32) / np.iinfo(self.doc.image.dtype).max
487
+ else:
488
+ arr = arr.astype(np.float32, copy=False)
489
+ # ⬇️ Treat mono with a trailing channel as true mono
490
+ if arr.ndim == 3 and arr.shape[2] == 1:
491
+ arr = arr[..., 0]
492
+ return np.clip(arr, 0.0, 1.0)
493
+
494
+ def _on_active_doc_changed(self, doc):
495
+ """Called when user clicks a different image window."""
496
+ if doc is None or getattr(doc, "image", None) is None:
497
+ return
498
+ self.doc = doc
499
+ self._rect_item = None
500
+ self._load_from_doc()
501
+
502
+ def _load_from_doc(self):
503
+ self._full01 = self._img01_from_doc()
504
+ self._orig_h, self._orig_w = self._full01.shape[:2]
505
+ self._preview01 = self._full01 if not self._autostretch_on else siril_style_autostretch(self._full01)
506
+
507
+ self.scene.clear()
508
+ q = self._to_qimage(self._preview01)
509
+ pm = QPixmap.fromImage(q)
510
+ self._pix_item = QGraphicsPixmapItem(pm)
511
+ self._pix_item.setZValue(-1)
512
+ self.scene.addItem(self._pix_item)
513
+ self._apply_zoom_transform()
514
+ self._deferred_fit()
515
+ self._set_dim_label_none()
516
+
517
+ def resizeEvent(self, ev):
518
+ super().resizeEvent(ev)
519
+ if self._fit_mode:
520
+ self._apply_zoom_transform()
521
+
522
+ # ---------- selection dimensions label ----------
523
+
524
+ def _set_dim_label_none(self):
525
+ if hasattr(self, "lbl_dims"):
526
+ self.lbl_dims.setText(self.tr("Selection: —"))
527
+
528
+ def _update_dim_label_from_corners(self, corners_scene):
529
+ """
530
+ corners_scene: iterable of 4 QPointF in order TL, TR, BR, BL (scene coords).
531
+ Computes width/height in *image pixels* and updates the label.
532
+ """
533
+ if not hasattr(self, "lbl_dims") or not corners_scene or not self._pix_item:
534
+ self._set_dim_label_none()
535
+ return
536
+
537
+ w_img, h_img = self._orig_w, self._orig_h
538
+ src = np.array(
539
+ [self._scene_to_img_pixels(p, w_img, h_img) for p in corners_scene],
540
+ dtype=np.float32,
541
+ )
542
+
543
+ # same convention as _apply_one(): width = |TR-TL|, height = |BL-TL|
544
+ width = float(np.linalg.norm(src[1] - src[0]))
545
+ height = float(np.linalg.norm(src[3] - src[0]))
546
+
547
+ self.lbl_dims.setText(
548
+ self.tr("Selection: {0}×{1} px").format(int(round(height)), int(round(width)))
549
+ )
550
+
551
+ def _update_dim_label_from_rect_item(self):
552
+ """Update label from the current finalized rect item."""
553
+ if not self._rect_item:
554
+ self._set_dim_label_none()
555
+ return
556
+ corners = self._corners_scene() # uses mapToScene on the item
557
+ self._update_dim_label_from_corners(corners)
558
+
559
+
560
+ @staticmethod
561
+ def _to_qimage(img01: np.ndarray) -> QImage:
562
+ # Ensure shapes we expect
563
+ if img01.ndim == 3 and img01.shape[2] == 1:
564
+ img01 = img01[..., 0]
565
+
566
+ if img01.ndim == 2:
567
+ buf = np.ascontiguousarray((img01 * 255).astype(np.uint8))
568
+ h, w = buf.shape
569
+ bpl = buf.strides[0] # == w for contiguous grayscale
570
+ return QImage(buf.tobytes(), w, h, bpl, QImage.Format.Format_Grayscale8)
571
+
572
+ if img01.ndim == 3 and img01.shape[2] == 3:
573
+ buf = np.ascontiguousarray((img01 * 255).astype(np.uint8))
574
+ h, w, _ = buf.shape
575
+ bpl = buf.strides[0] # == 3*w for contiguous RGB
576
+ return QImage(buf.tobytes(), w, h, bpl, QImage.Format.Format_RGB888)
577
+
578
+ raise ValueError(f"Unsupported image shape for preview: {img01.shape}")
579
+
580
+ # ---------- aspect ratio ----------
581
+ def _on_ar_changed(self, txt: str):
582
+ if not self._rect_item: return
583
+ if txt == "Free":
584
+ ar = None
585
+ elif txt == "Original":
586
+ ar = self._orig_w / self._orig_h
587
+ else:
588
+ a, b = map(float, txt.split(":")); ar = a / b
589
+ self._rect_item.setFixedAspectRatio(ar)
590
+ if ar is not None:
591
+ r = self._rect_item.rect()
592
+ w = r.width(); h = w / ar
593
+ c = r.center()
594
+ nr = QRectF(c.x()-w/2, c.y()-h/2, w, h)
595
+ self._rect_item.setRect(nr)
596
+ self._rect_item.setTransformOriginPoint(nr.center())
597
+
598
+ # ---------- drawing / interaction ----------
599
+ def eventFilter(self, src, e):
600
+ if src is self.view.viewport():
601
+ if e.type() == QEvent.Type.Wheel and (e.modifiers() & Qt.KeyboardModifier.ControlModifier):
602
+ delta = e.angleDelta().y()
603
+ self._zoom_by(1.25 if delta > 0 else 1/1.25)
604
+ return True
605
+ if e.type() in (QEvent.Type.MouseButtonPress, QEvent.Type.MouseMove, QEvent.Type.MouseButtonRelease):
606
+ scene_pt = self.view.mapToScene(e.pos())
607
+
608
+ # ⬇️ New: if we already have a rect, keep dims updated on mouse move
609
+ if e.type() == QEvent.Type.MouseMove and self._rect_item is not None:
610
+ self._update_dim_label_from_rect_item()
611
+
612
+ if self._rect_item is None:
613
+ if e.type() == QEvent.Type.MouseButtonPress and e.button() == Qt.MouseButton.LeftButton:
614
+ self._drawing = True; self._origin = scene_pt; return True
615
+
616
+ if e.type() == QEvent.Type.MouseMove and self._drawing:
617
+ r = QRectF(self._origin, scene_pt).normalized()
618
+ r = self._apply_ar_to_rect(r, live=True, scene_pt=scene_pt)
619
+ self._draw_live_rect(r)
620
+
621
+ # ⬇️ live dims from the temporary rect (axis-aligned TL,TR,BR,BL)
622
+ corners = [r.topLeft(), r.topRight(), r.bottomRight(), r.bottomLeft()]
623
+ self._update_dim_label_from_corners(corners)
624
+ return True
625
+
626
+ if e.type() == QEvent.Type.MouseButtonRelease and e.button() == Qt.MouseButton.LeftButton and self._drawing:
627
+ self._drawing = False
628
+ r = QRectF(self._origin, scene_pt).normalized()
629
+ r = self._apply_ar_to_rect(r, live=False, scene_pt=scene_pt)
630
+ self._clear_live_rect()
631
+ self._rect_item = ResizableRotatableRectItem(r)
632
+ self._rect_item.setZValue(10)
633
+ self._rect_item.setFixedAspectRatio(self._current_ar_value())
634
+ self.scene.addItem(self._rect_item)
635
+
636
+ # remember for “Load Previous”
637
+ CropDialogPro._prev_rect = QRectF(r)
638
+ CropDialogPro._prev_angle = self._rect_item.rotation()
639
+ CropDialogPro._prev_pos = self._rect_item.pos()
640
+
641
+ # ⬇️ finalized selection dims
642
+ self._update_dim_label_from_rect_item()
643
+ return True
644
+
645
+ return False
646
+ return super().eventFilter(src, e)
647
+
648
+
649
+ def _apply_zoom_transform(self):
650
+ if not self._pix_item:
651
+ return
652
+ if self._fit_mode:
653
+ rect = self._pix_item.mapRectToScene(self._pix_item.boundingRect())
654
+ self.view.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorViewCenter)
655
+ r = rect.adjusted(-1, -1, 1, 1) # 1px breathing room
656
+ self.view.fitInView(r, Qt.AspectRatioMode.KeepAspectRatio)
657
+ self.view.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
658
+ else:
659
+ self.view.resetTransform()
660
+ self.view.scale(self._zoom, self._zoom)
661
+
662
+ def _fit_view(self):
663
+ self._fit_mode = True
664
+ self._apply_zoom_transform()
665
+
666
+ def _zoom_reset_100(self):
667
+ self._fit_mode = False
668
+ self._zoom = 1.0
669
+ self._apply_zoom_transform()
670
+
671
+ def _zoom_by(self, factor: float):
672
+ self._fit_mode = False
673
+ # clamp zoom
674
+ newz = min(16.0, max(0.05, self._zoom * float(factor)))
675
+ if abs(newz - self._zoom) < 1e-4:
676
+ return
677
+ self._zoom = newz
678
+ self._apply_zoom_transform()
679
+
680
+ # ---------- typed margins helpers ----------
681
+ def _update_margin_spin_ranges(self):
682
+ """Limit typed margins to image dimensions (pixels)."""
683
+ h, w = int(self._orig_h), int(self._orig_w)
684
+ # Individual margins can be up to the full dimension; final rect is clamped.
685
+ self.sb_top.setRange(0, max(0, h))
686
+ self.sb_bottom.setRange(0, max(0, h))
687
+ self.sb_left.setRange(0, max(0, w))
688
+ self.sb_right.setRange(0, max(0, w))
689
+
690
+ def _apply_margin_inputs(self):
691
+ """Create/adjust the selection rect from typed margins (pixels)."""
692
+ t = int(self.sb_top.value())
693
+ r = int(self.sb_right.value())
694
+ b = int(self.sb_bottom.value())
695
+ l = int(self.sb_left.value())
696
+ self._set_rect_from_margins(t, r, b, l)
697
+
698
+ def _set_rect_from_margins(self, top: int, right: int, bottom: int, left: int):
699
+ """Set an axis-aligned crop selection equal to image bounds minus margins."""
700
+ w_img, h_img = float(self._orig_w), float(self._orig_h)
701
+ # clamp to image
702
+ left = max(0, min(int(left), int(w_img)))
703
+ right = max(0, min(int(right), int(w_img)))
704
+ top = max(0, min(int(top), int(h_img)))
705
+ bottom = max(0, min(int(bottom), int(h_img)))
706
+
707
+ x = float(left)
708
+ y = float(top)
709
+ w = max(1.0, w_img - (left + right))
710
+ h = max(1.0, h_img - (top + bottom))
711
+
712
+ r = QRectF(x, y, w, h)
713
+
714
+ # create or update the selection; force axis-aligned (rotation = 0)
715
+ if self._rect_item is None:
716
+ self._rect_item = ResizableRotatableRectItem(r)
717
+ self._rect_item.setZValue(10)
718
+ self.scene.addItem(self._rect_item)
719
+ else:
720
+ self._rect_item.setRotation(0.0)
721
+ self._rect_item.setPos(QPointF(0, 0))
722
+ self._rect_item.setRect(r)
723
+
724
+ self._rect_item.setTransformOriginPoint(r.center())
725
+ self._update_dim_label_from_rect_item()
726
+
727
+
728
+ def _current_ar_value(self) -> Optional[float]:
729
+ txt = self.cmb_ar.currentText()
730
+ if txt == self.tr("Free"): return None
731
+ if txt == self.tr("Original"): return self._orig_w / self._orig_h
732
+ a, b = map(float, txt.split(":")); return a / b
733
+
734
+ def _apply_ar_to_rect(self, r: QRectF, live: bool, scene_pt: QPointF) -> QRectF:
735
+ ar = self._current_ar_value()
736
+ if ar is None:
737
+ return r
738
+
739
+ # Calculate height from width using current aspect ratio
740
+ w = r.width()
741
+ h = w / ar
742
+
743
+ # Anchor to the click origin, adjust height based on drag direction
744
+ if scene_pt.y() < self._origin.y():
745
+ r.setTop(r.bottom() - h)
746
+ else:
747
+ r.setBottom(r.top() + h)
748
+
749
+ return r.normalized()
750
+
751
+ def _draw_live_rect(self, r: QRectF):
752
+ if hasattr(self, "_live_rect") and self._live_rect:
753
+ self.scene.removeItem(self._live_rect)
754
+ pen = QPen(QColor(0,255,0), 2, Qt.PenStyle.DashLine); pen.setCosmetic(True)
755
+ self._live_rect = self.scene.addRect(r, pen)
756
+
757
+ def _clear_live_rect(self):
758
+ if hasattr(self, "_live_rect") and self._live_rect:
759
+ self.scene.removeItem(self._live_rect); self._live_rect = None
760
+
761
+ # ---------- preview toggles ----------
762
+ def _toggle_autostretch(self):
763
+ self._autostretch_on = not self._autostretch_on
764
+ saved = self._snapshot_rect_state()
765
+ self._load_from_doc()
766
+ self._restore_rect_state(saved)
767
+ self._deferred_fit()
768
+
769
+ def _snapshot_rect_state(self):
770
+ if not self._rect_item: return None
771
+ return (QRectF(self._rect_item.rect()),
772
+ float(self._rect_item.rotation()),
773
+ QPointF(self._rect_item.pos()))
774
+
775
+ def _restore_rect_state(self, state):
776
+ if not state: return
777
+ r, ang, pos = state
778
+ self._rect_item = ResizableRotatableRectItem(r)
779
+ self._rect_item.setZValue(10)
780
+ self._rect_item.setFixedAspectRatio(self._current_ar_value())
781
+ self._rect_item.setRotation(ang)
782
+ self._rect_item.setPos(pos)
783
+ self._rect_item.setTransformOriginPoint(r.center())
784
+ self.scene.addItem(self._rect_item)
785
+ self._update_dim_label_from_rect_item()
786
+
787
+
788
+ def _load_previous(self):
789
+ if CropDialogPro._prev_rect is None:
790
+ QMessageBox.information(self, self.tr("No Previous"), self.tr("No previous crop stored."))
791
+ return
792
+ if self._rect_item:
793
+ self.scene.removeItem(self._rect_item)
794
+ r = QRectF(CropDialogPro._prev_rect)
795
+ self._rect_item = ResizableRotatableRectItem(r)
796
+ self._rect_item.setZValue(10)
797
+ self._rect_item.setFixedAspectRatio(self._current_ar_value())
798
+ self._rect_item.setRotation(CropDialogPro._prev_angle)
799
+ self._rect_item.setPos(CropDialogPro._prev_pos)
800
+ self._rect_item.setTransformOriginPoint(r.center())
801
+ self.scene.addItem(self._rect_item)
802
+ self._update_dim_label_from_rect_item()
803
+
804
+ # ---------- apply ----------
805
+ def _corners_scene(self):
806
+ rl = self._rect_item.rect()
807
+ loc = [rl.topLeft(), rl.topRight(), rl.bottomRight(), rl.bottomLeft()]
808
+ return [self._rect_item.mapToScene(p) for p in loc]
809
+
810
+ def _scene_to_img_pixels(self, pt_scene: QPointF, w_img: int, h_img: int):
811
+ pm = self._pix_item.pixmap()
812
+ sx, sy = w_img / pm.width(), h_img / pm.height()
813
+ return np.array([pt_scene.x() * sx, pt_scene.y() * sy], dtype=np.float32)
814
+
815
+ def _apply_one(self):
816
+ if not self._rect_item:
817
+ QMessageBox.warning(self, self.tr("No Selection"), self.tr("Draw & finalize a crop first."))
818
+ return
819
+
820
+ corners = self._corners_scene()
821
+ w_img, h_img = self._orig_w, self._orig_h
822
+ src = np.array([self._scene_to_img_pixels(p, w_img, h_img) for p in corners], dtype=np.float32)
823
+
824
+ width = np.linalg.norm(src[1] - src[0])
825
+ height = np.linalg.norm(src[3] - src[0])
826
+ dst = np.array([[0,0],[width,0],[width,height],[0,height]], dtype=np.float32)
827
+
828
+ # ---- Axis-aligned? → exact slice; else → rotate with Lanczos ----
829
+ H_img, W_img = self._orig_h, self._orig_w
830
+ axis_aligned = self._quad_is_axis_aligned(src)
831
+
832
+ if axis_aligned:
833
+ # Pixel-perfect slice
834
+ bounds = self._int_bounds_from_quad(src, W_img, H_img)
835
+ if bounds is None:
836
+ QMessageBox.critical(self, self.tr("Apply failed"), self.tr("Invalid crop bounds."))
837
+ return
838
+ x0, x1, y0, y1 = bounds
839
+ out = self._full01[y0:y1, x0:x1].copy()
840
+
841
+ # Build a pure-translation H so WCS update remains correct
842
+ M = np.array([[1.0, 0.0, -float(x0)],
843
+ [0.0, 1.0, -float(y0)],
844
+ [0.0, 0.0, 1.0]], dtype=np.float32)
845
+ w_out, h_out = (x1 - x0), (y1 - y0)
846
+
847
+ else:
848
+ # Rotated/keystoned selection → perspective crop with sharp filter
849
+ M = cv2.getPerspectiveTransform(src, dst)
850
+ w_out = int(round(width))
851
+ h_out = int(round(height))
852
+ if w_out <= 0 or h_out <= 0:
853
+ QMessageBox.critical(self, self.tr("Apply failed"), self.tr("Invalid crop size."))
854
+ return
855
+
856
+ out = cv2.warpPerspective(
857
+ self._full01, M, (w_out, h_out),
858
+ flags=cv2.INTER_LANCZOS4 # crisper rotation, no resizing implied
859
+ )
860
+
861
+ # ---- WCS & bookkeeping (unchanged) ----
862
+ new_meta = dict(self.doc.metadata or {})
863
+ try:
864
+ if update_wcs_after_crop is not None:
865
+ new_meta = update_wcs_after_crop(new_meta, M_src_to_dst=M, out_w=w_out, out_h=h_out)
866
+ except Exception:
867
+ pass
868
+
869
+ CropDialogPro._prev_rect = QRectF(self._rect_item.rect())
870
+ CropDialogPro._prev_angle = float(self._rect_item.rotation())
871
+ CropDialogPro._prev_pos = QPointF(self._rect_item.pos())
872
+
873
+ try:
874
+ self.doc.apply_edit(out.copy(), metadata={**new_meta, "step_name": "Crop"}, step_name="Crop")
875
+ self._maybe_notify_wcs_update(new_meta)
876
+ self.crop_applied.emit(out)
877
+ self.close()
878
+ except Exception as e:
879
+ QMessageBox.critical(self, self.tr("Apply failed"), str(e))
880
+
881
+ def _apply_batch(self):
882
+ if not self._rect_item:
883
+ QMessageBox.warning(self, self.tr("No Selection"), self.tr("Draw & finalize a crop first."))
884
+ return
885
+
886
+ # Normalize the crop polygon to THIS image size
887
+ corners = self._corners_scene()
888
+ src_this = np.array([self._scene_to_img_pixels(p, self._orig_w, self._orig_h) for p in corners], dtype=np.float32)
889
+ norm = src_this / np.array([self._orig_w, self._orig_h], dtype=np.float32)
890
+
891
+ # Collect all open documents from the MDI
892
+ win = self.parent()
893
+ subs = getattr(win, "mdi", None).subWindowList() if hasattr(win, "mdi") else []
894
+ docs = []
895
+ for sw in subs:
896
+ vw = sw.widget()
897
+ d = getattr(vw, "document", None)
898
+ if d is not None:
899
+ docs.append(d)
900
+
901
+ if not docs:
902
+ QMessageBox.information(self, self.tr("No Images"), self.tr("No open images to crop."))
903
+ return
904
+
905
+ ok = QMessageBox.question(
906
+ self, self.tr("Confirm Batch"),
907
+ self.tr("Apply this crop to {0} open image(s)?").format(len(docs)),
908
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
909
+ QMessageBox.StandardButton.No
910
+ )
911
+ if ok != QMessageBox.StandardButton.Yes:
912
+ return
913
+
914
+ last_cropped = None
915
+ for d in docs:
916
+ img = np.asarray(d.image)
917
+ if img.dtype.kind in "ui":
918
+ src01 = img.astype(np.float32) / np.iinfo(d.image.dtype).max
919
+ else:
920
+ src01 = img.astype(np.float32, copy=False)
921
+
922
+ h, w = src01.shape[:2]
923
+ src_pts = norm * np.array([w, h], dtype=np.float32) # (4,2)
924
+
925
+ axis_aligned = self._quad_is_axis_aligned(src_pts)
926
+
927
+ if axis_aligned:
928
+ b = self._int_bounds_from_quad(src_pts, w, h)
929
+ if b is None:
930
+ continue
931
+ x0, x1, y0, y1 = b
932
+ cropped = src01[y0:y1, x0:x1].copy()
933
+ w_out, h_out = (x1 - x0), (y1 - y0)
934
+ M = np.array([[1.0, 0.0, -float(x0)],
935
+ [0.0, 1.0, -float(y0)],
936
+ [0.0, 0.0, 1.0]], dtype=np.float32)
937
+ else:
938
+ w_out = int(round(np.linalg.norm(src_pts[1] - src_pts[0])))
939
+ h_out = int(round(np.linalg.norm(src_pts[3] - src_pts[0])))
940
+ if w_out <= 0 or h_out <= 0:
941
+ continue
942
+ dst = np.array([[0,0],[w_out,0],[w_out,h_out],[0,h_out]], dtype=np.float32)
943
+ M = cv2.getPerspectiveTransform(src_pts.astype(np.float32), dst)
944
+ cropped = cv2.warpPerspective(src01, M, (w_out, h_out), flags=cv2.INTER_LANCZOS4)
945
+
946
+ # WCS update per doc
947
+ meta_this = dict(d.metadata or {})
948
+ try:
949
+ if update_wcs_after_crop is not None:
950
+ meta_this = update_wcs_after_crop(meta_this, M_src_to_dst=M, out_w=w_out, out_h=h_out)
951
+ except Exception:
952
+ pass
953
+
954
+ try:
955
+ d.apply_edit(cropped.copy(), metadata={**meta_this, "step_name":"Crop"}, step_name="Crop")
956
+ last_cropped = cropped
957
+ except Exception:
958
+ pass
959
+
960
+ QMessageBox.information(self, self.tr("Batch Crop"), self.tr("Applied crop to all open images. Any Astrometric Solutions has been updated."))
961
+ if last_cropped is not None:
962
+ self.crop_applied.emit(last_cropped)
963
+ self.close()
964
+
965
+ def _maybe_notify_wcs_update(self, meta: dict, batch_note: str | None = None):
966
+ dbg = (meta or {}).get("__wcs_debug__")
967
+ if not dbg:
968
+ return
969
+ try:
970
+ before = dbg.get("before", {})
971
+ after = dbg.get("after", {})
972
+ fit = dbg.get("fit", {})
973
+ b_ra, b_dec = before.get("crval_deg", (float("nan"), float("nan")))
974
+ a_ra, a_dec = after.get("crval_deg", (float("nan"), float("nan")))
975
+ rms = fit.get("rms_arcsec", float("nan"))
976
+ p95 = fit.get("p95_arcsec", float("nan"))
977
+ sip = after.get("sip_degree")
978
+ size = after.get("size")
979
+ sip_txt = f"TAN-SIP (deg={sip})" if sip is not None else "TAN"
980
+ size_txt = f"{size[0]}×{size[1]}" if size else "?"
981
+ extra = f"\n{batch_note}" if batch_note else ""
982
+ msg = (
983
+ self.tr("Astrometric solution updated ✔️\n\n") +
984
+ self.tr("Model: {0} Image: {1}\n").format(sip_txt, size_txt) +
985
+ self.tr("CRVAL: ({0:.6f}, {1:.6f}) → ({2:.6f}, {3:.6f})\n").format(b_ra, b_dec, a_ra, a_dec) +
986
+ self.tr("Fit residuals: RMS {0:.3f}\" (p95 {1:.3f}\")").format(rms, p95) +
987
+ f"{extra}"
988
+ )
989
+ QMessageBox.information(self, self.tr("WCS Updated"), msg)
990
+ except Exception:
991
+ # Be quiet if formatting fails
992
+ pass
993
+
994
+ def _cleanup_connections(self):
995
+ try:
996
+ if self._follow_conn and hasattr(self._main, "currentDocumentChanged"):
997
+ self._main.currentDocumentChanged.disconnect(self._on_active_doc_changed)
998
+ except Exception:
999
+ pass
1000
+ self._follow_conn = False
1001
+
1002
+
1003
+ def closeEvent(self, ev):
1004
+ self._cleanup_connections()
1005
+ super().closeEvent(ev)