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,983 @@
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
+ # Connect to active document change signal
281
+ if hasattr(self._main, "currentDocumentChanged"):
282
+ self._main.currentDocumentChanged.connect(self._on_active_doc_changed)
283
+
284
+ self._rect_item: Optional[ResizableRotatableRectItem] = None
285
+ self._pix_item: Optional[QGraphicsPixmapItem] = None
286
+ self._drawing = False
287
+ self._origin = QPointF()
288
+ self._autostretch_on = True
289
+
290
+ # ---------- layout ----------
291
+ main = QVBoxLayout(self)
292
+
293
+ info = QLabel(self.tr(
294
+ "• Click–drag to draw a crop\n"
295
+ "• Drag corner handles to resize\n"
296
+ "• Shift + drag on box to rotate"
297
+ )); info.setStyleSheet("color: gray; font-style: italic;")
298
+ main.addWidget(info)
299
+
300
+ # aspect row
301
+ row = QHBoxLayout()
302
+ row.addStretch(1)
303
+ row.addWidget(QLabel(self.tr("Aspect Ratio:")))
304
+ self.cmb_ar = QComboBox()
305
+ self.cmb_ar.addItems([
306
+ self.tr("Free"), self.tr("Original"),
307
+ "1:1",
308
+ "3:2", "2:3",
309
+ "4:3", "3:4",
310
+ "4:5", "5:4",
311
+ "16:9", "9:16",
312
+ "21:9", "9:21",
313
+ "2:1", "1:2",
314
+ "3:5", "5:3",
315
+ ])
316
+ row.addWidget(self.cmb_ar)
317
+ row.addStretch(1)
318
+ main.addLayout(row)
319
+
320
+ # typed margins (pixels): Top, Right, Bottom, Left
321
+ margins_row = QHBoxLayout()
322
+ margins_row.addStretch(1)
323
+ margins_row.addWidget(QLabel(self.tr("Margins (px):")))
324
+ self.sb_top = QSpinBox(); self.sb_top.setSuffix(" px")
325
+ self.sb_right = QSpinBox(); self.sb_right.setSuffix(" px")
326
+ self.sb_bottom = QSpinBox(); self.sb_bottom.setSuffix(" px")
327
+ self.sb_left = QSpinBox(); self.sb_left.setSuffix(" px")
328
+
329
+ # reasonable wide ranges; clamped on apply anyway
330
+ for sb in (self.sb_top, self.sb_bottom, self.sb_left, self.sb_right):
331
+ sb.setRange(0, 1_000_000)
332
+
333
+ # labels inline for clarity
334
+ margins_row.addWidget(QLabel(self.tr("Top")))
335
+ margins_row.addWidget(self.sb_top)
336
+ margins_row.addSpacing(8)
337
+ margins_row.addWidget(QLabel(self.tr("Right")))
338
+ margins_row.addWidget(self.sb_right)
339
+ margins_row.addSpacing(8)
340
+ margins_row.addWidget(QLabel(self.tr("Bottom")))
341
+ margins_row.addWidget(self.sb_bottom)
342
+ margins_row.addSpacing(8)
343
+ margins_row.addWidget(QLabel(self.tr("Left")))
344
+ margins_row.addWidget(self.sb_left)
345
+ margins_row.addStretch(1)
346
+ main.addLayout(margins_row)
347
+
348
+ # live-apply: when any value changes, update the selection rectangle
349
+ self._suppress_margin_sync = False
350
+ def _on_margin_changed(_):
351
+ if self._suppress_margin_sync:
352
+ return
353
+ self._apply_margin_inputs()
354
+ for sb in (self.sb_top, self.sb_right, self.sb_bottom, self.sb_left):
355
+ sb.valueChanged.connect(_on_margin_changed)
356
+
357
+ # graphics view
358
+ self.scene = QGraphicsScene(self)
359
+ self.view = QGraphicsView(self.scene)
360
+ self.view.setRenderHints(self.view.renderHints())
361
+ self.view.setDragMode(QGraphicsView.DragMode.NoDrag)
362
+ self.view.viewport().installEventFilter(self)
363
+ main.addWidget(self.view, 1)
364
+
365
+ self._zoom = 1.0 # manual zoom factor
366
+ self._fit_mode = True # start in Fit-to-View mode
367
+
368
+ # nicer zoom behavior
369
+ self.view.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
370
+ self.view.setResizeAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
371
+ self.view.setDragMode(QGraphicsView.DragMode.ScrollHandDrag) # pan with mouse-drag
372
+
373
+ zoom_row = QHBoxLayout()
374
+ zoom_row.addStretch(1)
375
+
376
+ self.btn_zoom_out = themed_toolbtn("zoom-out", self.tr("Zoom Out"))
377
+ self.btn_zoom_in = themed_toolbtn("zoom-in", self.tr("Zoom In"))
378
+ self.btn_zoom_100 = themed_toolbtn("zoom-original", self.tr("Zoom 100%"))
379
+ self.btn_zoom_fit = themed_toolbtn("zoom-fit-best", self.tr("Fit to View"))
380
+
381
+ for b in (self.btn_zoom_out, self.btn_zoom_in, self.btn_zoom_100, self.btn_zoom_fit):
382
+ zoom_row.addWidget(b)
383
+
384
+ zoom_row.addStretch(1)
385
+ main.addLayout(zoom_row)
386
+
387
+ dim_row = QHBoxLayout()
388
+ dim_row.addStretch(1)
389
+ self.lbl_dims = QLabel(self.tr("Selection: —"))
390
+ self.lbl_dims.setStyleSheet("color: gray;")
391
+ dim_row.addWidget(self.lbl_dims)
392
+ dim_row.addStretch(1)
393
+ main.addLayout(dim_row)
394
+
395
+ # wire zoom buttons
396
+ self.btn_zoom_in.clicked.connect(lambda: self._zoom_by(1.25))
397
+ self.btn_zoom_out.clicked.connect(lambda: self._zoom_by(1/1.25))
398
+ self.btn_zoom_100.clicked.connect(self._zoom_reset_100)
399
+ self.btn_zoom_fit.clicked.connect(self._fit_view)
400
+
401
+ # buttons
402
+ btn_row = QHBoxLayout()
403
+ self.btn_autostretch = QPushButton(self.tr("Toggle Autostretch"))
404
+ self.btn_prev = QPushButton(self.tr("Load Previous Crop"))
405
+ self.btn_apply = QPushButton(self.tr("Apply"))
406
+ self.btn_batch = QPushButton(self.tr("Batch Crop (all open)"))
407
+ self.btn_close = QToolButton(); self.btn_close.setText(self.tr("Close"))
408
+ for b in (self.btn_autostretch, self.btn_prev, self.btn_apply, self.btn_batch, self.btn_close):
409
+ btn_row.addWidget(b)
410
+ main.addLayout(btn_row)
411
+
412
+ # wire
413
+ self.cmb_ar.currentTextChanged.connect(self._on_ar_changed)
414
+ self.btn_autostretch.clicked.connect(self._toggle_autostretch)
415
+ self.btn_prev.clicked.connect(self._load_previous)
416
+ self.btn_apply.clicked.connect(self._apply_one)
417
+ self.btn_batch.clicked.connect(self._apply_batch)
418
+ self.btn_close.clicked.connect(self.accept)
419
+
420
+ # seed image
421
+ self._load_from_doc()
422
+ self._update_margin_spin_ranges()
423
+ self.resize(1000, 720)
424
+ self._deferred_fit()
425
+
426
+ def _deferred_fit(self):
427
+ if self._fit_mode:
428
+ QTimer.singleShot(0, self._fit_view)
429
+
430
+ def showEvent(self, ev):
431
+ super().showEvent(ev)
432
+ self._deferred_fit() # ensure fit after the first real layout
433
+
434
+ # ---------- image plumbing ----------
435
+ def _quad_is_axis_aligned(self, pts: np.ndarray, tol: float = 1e-2) -> bool:
436
+ """
437
+ pts: (4,2) in image pixel coords, order: TL, TR, BR, BL
438
+ Returns True if edges are parallel to axes within tolerance.
439
+ """
440
+ if pts.shape != (4, 2):
441
+ return False
442
+ xL, xR = (pts[0,0] + pts[3,0]) * 0.5, (pts[1,0] + pts[2,0]) * 0.5
443
+ yT, yB = (pts[0,1] + pts[1,1]) * 0.5, (pts[2,1] + pts[3,1]) * 0.5
444
+ # vertical edges nearly vertical, horizontal edges nearly horizontal
445
+ # Check that each edge's "other" dimension differs by very little.
446
+ left_dx = abs(pts[0,0] - pts[3,0])
447
+ right_dx = abs(pts[1,0] - pts[2,0])
448
+ top_dy = abs(pts[0,1] - pts[1,1])
449
+ bot_dy = abs(pts[2,1] - pts[3,1])
450
+
451
+ return (left_dx < tol and right_dx < tol and top_dy < tol and bot_dy < tol)
452
+
453
+ def _int_bounds_from_quad(self, pts: np.ndarray, W: int, H: int) -> tuple[int,int,int,int] | None:
454
+ """
455
+ pts: (4,2) image-space corners. Returns (x0, x1, y0, y1) clamped to image
456
+ using floor/ceil so we keep all intended pixels.
457
+ """
458
+ if pts.size != 8:
459
+ return None
460
+ xs = pts[:,0]; ys = pts[:,1]
461
+ # inclusive-exclusive slice bounds
462
+ x0 = int(np.floor(xs.min() + 1e-6))
463
+ y0 = int(np.floor(ys.min() + 1e-6))
464
+ x1 = int(np.ceil (xs.max() - 1e-6))
465
+ y1 = int(np.ceil (ys.max() - 1e-6))
466
+ # clamp
467
+ x0 = max(0, min(W, x0)); x1 = max(0, min(W, x1))
468
+ y0 = max(0, min(H, y0)); y1 = max(0, min(H, y1))
469
+ if x1 <= x0 or y1 <= y0:
470
+ return None
471
+ return x0, x1, y0, y1
472
+
473
+
474
+ def _img01_from_doc(self) -> np.ndarray:
475
+ arr = np.asarray(self.doc.image)
476
+ if arr.dtype.kind in "ui":
477
+ arr = arr.astype(np.float32) / np.iinfo(self.doc.image.dtype).max
478
+ else:
479
+ arr = arr.astype(np.float32, copy=False)
480
+ # ⬇️ Treat mono with a trailing channel as true mono
481
+ if arr.ndim == 3 and arr.shape[2] == 1:
482
+ arr = arr[..., 0]
483
+ return np.clip(arr, 0.0, 1.0)
484
+
485
+ def _on_active_doc_changed(self, doc):
486
+ """Called when user clicks a different image window."""
487
+ if doc is None or getattr(doc, "image", None) is None:
488
+ return
489
+ self.doc = doc
490
+ self._rect_item = None
491
+ self._load_from_doc()
492
+
493
+ def _load_from_doc(self):
494
+ self._full01 = self._img01_from_doc()
495
+ self._orig_h, self._orig_w = self._full01.shape[:2]
496
+ self._preview01 = self._full01 if not self._autostretch_on else siril_style_autostretch(self._full01)
497
+
498
+ self.scene.clear()
499
+ q = self._to_qimage(self._preview01)
500
+ pm = QPixmap.fromImage(q)
501
+ self._pix_item = QGraphicsPixmapItem(pm)
502
+ self._pix_item.setZValue(-1)
503
+ self.scene.addItem(self._pix_item)
504
+ self._apply_zoom_transform()
505
+ self._deferred_fit()
506
+ self._set_dim_label_none()
507
+
508
+ def resizeEvent(self, ev):
509
+ super().resizeEvent(ev)
510
+ if self._fit_mode:
511
+ self._apply_zoom_transform()
512
+
513
+ # ---------- selection dimensions label ----------
514
+
515
+ def _set_dim_label_none(self):
516
+ if hasattr(self, "lbl_dims"):
517
+ self.lbl_dims.setText(self.tr("Selection: —"))
518
+
519
+ def _update_dim_label_from_corners(self, corners_scene):
520
+ """
521
+ corners_scene: iterable of 4 QPointF in order TL, TR, BR, BL (scene coords).
522
+ Computes width/height in *image pixels* and updates the label.
523
+ """
524
+ if not hasattr(self, "lbl_dims") or not corners_scene or not self._pix_item:
525
+ self._set_dim_label_none()
526
+ return
527
+
528
+ w_img, h_img = self._orig_w, self._orig_h
529
+ src = np.array(
530
+ [self._scene_to_img_pixels(p, w_img, h_img) for p in corners_scene],
531
+ dtype=np.float32,
532
+ )
533
+
534
+ # same convention as _apply_one(): width = |TR-TL|, height = |BL-TL|
535
+ width = float(np.linalg.norm(src[1] - src[0]))
536
+ height = float(np.linalg.norm(src[3] - src[0]))
537
+
538
+ self.lbl_dims.setText(
539
+ self.tr("Selection: {0}×{1} px").format(int(round(height)), int(round(width)))
540
+ )
541
+
542
+ def _update_dim_label_from_rect_item(self):
543
+ """Update label from the current finalized rect item."""
544
+ if not self._rect_item:
545
+ self._set_dim_label_none()
546
+ return
547
+ corners = self._corners_scene() # uses mapToScene on the item
548
+ self._update_dim_label_from_corners(corners)
549
+
550
+
551
+ @staticmethod
552
+ def _to_qimage(img01: np.ndarray) -> QImage:
553
+ # Ensure shapes we expect
554
+ if img01.ndim == 3 and img01.shape[2] == 1:
555
+ img01 = img01[..., 0]
556
+
557
+ if img01.ndim == 2:
558
+ buf = np.ascontiguousarray((img01 * 255).astype(np.uint8))
559
+ h, w = buf.shape
560
+ bpl = buf.strides[0] # == w for contiguous grayscale
561
+ return QImage(buf.tobytes(), w, h, bpl, QImage.Format.Format_Grayscale8)
562
+
563
+ if img01.ndim == 3 and img01.shape[2] == 3:
564
+ buf = np.ascontiguousarray((img01 * 255).astype(np.uint8))
565
+ h, w, _ = buf.shape
566
+ bpl = buf.strides[0] # == 3*w for contiguous RGB
567
+ return QImage(buf.tobytes(), w, h, bpl, QImage.Format.Format_RGB888)
568
+
569
+ raise ValueError(f"Unsupported image shape for preview: {img01.shape}")
570
+
571
+ # ---------- aspect ratio ----------
572
+ def _on_ar_changed(self, txt: str):
573
+ if not self._rect_item: return
574
+ if txt == "Free":
575
+ ar = None
576
+ elif txt == "Original":
577
+ ar = self._orig_w / self._orig_h
578
+ else:
579
+ a, b = map(float, txt.split(":")); ar = a / b
580
+ self._rect_item.setFixedAspectRatio(ar)
581
+ if ar is not None:
582
+ r = self._rect_item.rect()
583
+ w = r.width(); h = w / ar
584
+ c = r.center()
585
+ nr = QRectF(c.x()-w/2, c.y()-h/2, w, h)
586
+ self._rect_item.setRect(nr)
587
+ self._rect_item.setTransformOriginPoint(nr.center())
588
+
589
+ # ---------- drawing / interaction ----------
590
+ def eventFilter(self, src, e):
591
+ if src is self.view.viewport():
592
+ if e.type() == QEvent.Type.Wheel and (e.modifiers() & Qt.KeyboardModifier.ControlModifier):
593
+ delta = e.angleDelta().y()
594
+ self._zoom_by(1.25 if delta > 0 else 1/1.25)
595
+ return True
596
+ if e.type() in (QEvent.Type.MouseButtonPress, QEvent.Type.MouseMove, QEvent.Type.MouseButtonRelease):
597
+ scene_pt = self.view.mapToScene(e.pos())
598
+
599
+ # ⬇️ New: if we already have a rect, keep dims updated on mouse move
600
+ if e.type() == QEvent.Type.MouseMove and self._rect_item is not None:
601
+ self._update_dim_label_from_rect_item()
602
+
603
+ if self._rect_item is None:
604
+ if e.type() == QEvent.Type.MouseButtonPress and e.button() == Qt.MouseButton.LeftButton:
605
+ self._drawing = True; self._origin = scene_pt; return True
606
+
607
+ if e.type() == QEvent.Type.MouseMove and self._drawing:
608
+ r = QRectF(self._origin, scene_pt).normalized()
609
+ r = self._apply_ar_to_rect(r, live=True, scene_pt=scene_pt)
610
+ self._draw_live_rect(r)
611
+
612
+ # ⬇️ live dims from the temporary rect (axis-aligned TL,TR,BR,BL)
613
+ corners = [r.topLeft(), r.topRight(), r.bottomRight(), r.bottomLeft()]
614
+ self._update_dim_label_from_corners(corners)
615
+ return True
616
+
617
+ if e.type() == QEvent.Type.MouseButtonRelease and e.button() == Qt.MouseButton.LeftButton and self._drawing:
618
+ self._drawing = False
619
+ r = QRectF(self._origin, scene_pt).normalized()
620
+ r = self._apply_ar_to_rect(r, live=False, scene_pt=scene_pt)
621
+ self._clear_live_rect()
622
+ self._rect_item = ResizableRotatableRectItem(r)
623
+ self._rect_item.setZValue(10)
624
+ self._rect_item.setFixedAspectRatio(self._current_ar_value())
625
+ self.scene.addItem(self._rect_item)
626
+
627
+ # remember for “Load Previous”
628
+ CropDialogPro._prev_rect = QRectF(r)
629
+ CropDialogPro._prev_angle = self._rect_item.rotation()
630
+ CropDialogPro._prev_pos = self._rect_item.pos()
631
+
632
+ # ⬇️ finalized selection dims
633
+ self._update_dim_label_from_rect_item()
634
+ return True
635
+
636
+ return False
637
+ return super().eventFilter(src, e)
638
+
639
+
640
+ def _apply_zoom_transform(self):
641
+ if not self._pix_item:
642
+ return
643
+ if self._fit_mode:
644
+ rect = self._pix_item.mapRectToScene(self._pix_item.boundingRect())
645
+ self.view.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorViewCenter)
646
+ r = rect.adjusted(-1, -1, 1, 1) # 1px breathing room
647
+ self.view.fitInView(r, Qt.AspectRatioMode.KeepAspectRatio)
648
+ self.view.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
649
+ else:
650
+ self.view.resetTransform()
651
+ self.view.scale(self._zoom, self._zoom)
652
+
653
+ def _fit_view(self):
654
+ self._fit_mode = True
655
+ self._apply_zoom_transform()
656
+
657
+ def _zoom_reset_100(self):
658
+ self._fit_mode = False
659
+ self._zoom = 1.0
660
+ self._apply_zoom_transform()
661
+
662
+ def _zoom_by(self, factor: float):
663
+ self._fit_mode = False
664
+ # clamp zoom
665
+ newz = min(16.0, max(0.05, self._zoom * float(factor)))
666
+ if abs(newz - self._zoom) < 1e-4:
667
+ return
668
+ self._zoom = newz
669
+ self._apply_zoom_transform()
670
+
671
+ # ---------- typed margins helpers ----------
672
+ def _update_margin_spin_ranges(self):
673
+ """Limit typed margins to image dimensions (pixels)."""
674
+ h, w = int(self._orig_h), int(self._orig_w)
675
+ # Individual margins can be up to the full dimension; final rect is clamped.
676
+ self.sb_top.setRange(0, max(0, h))
677
+ self.sb_bottom.setRange(0, max(0, h))
678
+ self.sb_left.setRange(0, max(0, w))
679
+ self.sb_right.setRange(0, max(0, w))
680
+
681
+ def _apply_margin_inputs(self):
682
+ """Create/adjust the selection rect from typed margins (pixels)."""
683
+ t = int(self.sb_top.value())
684
+ r = int(self.sb_right.value())
685
+ b = int(self.sb_bottom.value())
686
+ l = int(self.sb_left.value())
687
+ self._set_rect_from_margins(t, r, b, l)
688
+
689
+ def _set_rect_from_margins(self, top: int, right: int, bottom: int, left: int):
690
+ """Set an axis-aligned crop selection equal to image bounds minus margins."""
691
+ w_img, h_img = float(self._orig_w), float(self._orig_h)
692
+ # clamp to image
693
+ left = max(0, min(int(left), int(w_img)))
694
+ right = max(0, min(int(right), int(w_img)))
695
+ top = max(0, min(int(top), int(h_img)))
696
+ bottom = max(0, min(int(bottom), int(h_img)))
697
+
698
+ x = float(left)
699
+ y = float(top)
700
+ w = max(1.0, w_img - (left + right))
701
+ h = max(1.0, h_img - (top + bottom))
702
+
703
+ r = QRectF(x, y, w, h)
704
+
705
+ # create or update the selection; force axis-aligned (rotation = 0)
706
+ if self._rect_item is None:
707
+ self._rect_item = ResizableRotatableRectItem(r)
708
+ self._rect_item.setZValue(10)
709
+ self.scene.addItem(self._rect_item)
710
+ else:
711
+ self._rect_item.setRotation(0.0)
712
+ self._rect_item.setPos(QPointF(0, 0))
713
+ self._rect_item.setRect(r)
714
+
715
+ self._rect_item.setTransformOriginPoint(r.center())
716
+ self._update_dim_label_from_rect_item()
717
+
718
+
719
+ def _current_ar_value(self) -> Optional[float]:
720
+ txt = self.cmb_ar.currentText()
721
+ if txt == self.tr("Free"): return None
722
+ if txt == self.tr("Original"): return self._orig_w / self._orig_h
723
+ a, b = map(float, txt.split(":")); return a / b
724
+
725
+ def _apply_ar_to_rect(self, r: QRectF, live: bool, scene_pt: QPointF) -> QRectF:
726
+ ar = self._current_ar_value()
727
+ if ar is None:
728
+ return r
729
+
730
+ # Calculate height from width using current aspect ratio
731
+ w = r.width()
732
+ h = w / ar
733
+
734
+ # Anchor to the click origin, adjust height based on drag direction
735
+ if scene_pt.y() < self._origin.y():
736
+ r.setTop(r.bottom() - h)
737
+ else:
738
+ r.setBottom(r.top() + h)
739
+
740
+ return r.normalized()
741
+
742
+ def _draw_live_rect(self, r: QRectF):
743
+ if hasattr(self, "_live_rect") and self._live_rect:
744
+ self.scene.removeItem(self._live_rect)
745
+ pen = QPen(QColor(0,255,0), 2, Qt.PenStyle.DashLine); pen.setCosmetic(True)
746
+ self._live_rect = self.scene.addRect(r, pen)
747
+
748
+ def _clear_live_rect(self):
749
+ if hasattr(self, "_live_rect") and self._live_rect:
750
+ self.scene.removeItem(self._live_rect); self._live_rect = None
751
+
752
+ # ---------- preview toggles ----------
753
+ def _toggle_autostretch(self):
754
+ self._autostretch_on = not self._autostretch_on
755
+ saved = self._snapshot_rect_state()
756
+ self._load_from_doc()
757
+ self._restore_rect_state(saved)
758
+ self._deferred_fit()
759
+
760
+ def _snapshot_rect_state(self):
761
+ if not self._rect_item: return None
762
+ return (QRectF(self._rect_item.rect()),
763
+ float(self._rect_item.rotation()),
764
+ QPointF(self._rect_item.pos()))
765
+
766
+ def _restore_rect_state(self, state):
767
+ if not state: return
768
+ r, ang, pos = state
769
+ self._rect_item = ResizableRotatableRectItem(r)
770
+ self._rect_item.setZValue(10)
771
+ self._rect_item.setFixedAspectRatio(self._current_ar_value())
772
+ self._rect_item.setRotation(ang)
773
+ self._rect_item.setPos(pos)
774
+ self._rect_item.setTransformOriginPoint(r.center())
775
+ self.scene.addItem(self._rect_item)
776
+ self._update_dim_label_from_rect_item()
777
+
778
+
779
+ def _load_previous(self):
780
+ if CropDialogPro._prev_rect is None:
781
+ QMessageBox.information(self, self.tr("No Previous"), self.tr("No previous crop stored."))
782
+ return
783
+ if self._rect_item:
784
+ self.scene.removeItem(self._rect_item)
785
+ r = QRectF(CropDialogPro._prev_rect)
786
+ self._rect_item = ResizableRotatableRectItem(r)
787
+ self._rect_item.setZValue(10)
788
+ self._rect_item.setFixedAspectRatio(self._current_ar_value())
789
+ self._rect_item.setRotation(CropDialogPro._prev_angle)
790
+ self._rect_item.setPos(CropDialogPro._prev_pos)
791
+ self._rect_item.setTransformOriginPoint(r.center())
792
+ self.scene.addItem(self._rect_item)
793
+ self._update_dim_label_from_rect_item()
794
+
795
+ # ---------- apply ----------
796
+ def _corners_scene(self):
797
+ rl = self._rect_item.rect()
798
+ loc = [rl.topLeft(), rl.topRight(), rl.bottomRight(), rl.bottomLeft()]
799
+ return [self._rect_item.mapToScene(p) for p in loc]
800
+
801
+ def _scene_to_img_pixels(self, pt_scene: QPointF, w_img: int, h_img: int):
802
+ pm = self._pix_item.pixmap()
803
+ sx, sy = w_img / pm.width(), h_img / pm.height()
804
+ return np.array([pt_scene.x() * sx, pt_scene.y() * sy], dtype=np.float32)
805
+
806
+ def _apply_one(self):
807
+ if not self._rect_item:
808
+ QMessageBox.warning(self, self.tr("No Selection"), self.tr("Draw & finalize a crop first."))
809
+ return
810
+
811
+ corners = self._corners_scene()
812
+ w_img, h_img = self._orig_w, self._orig_h
813
+ src = np.array([self._scene_to_img_pixels(p, w_img, h_img) for p in corners], dtype=np.float32)
814
+
815
+ width = np.linalg.norm(src[1] - src[0])
816
+ height = np.linalg.norm(src[3] - src[0])
817
+ dst = np.array([[0,0],[width,0],[width,height],[0,height]], dtype=np.float32)
818
+
819
+ # ---- Axis-aligned? → exact slice; else → rotate with Lanczos ----
820
+ H_img, W_img = self._orig_h, self._orig_w
821
+ axis_aligned = self._quad_is_axis_aligned(src)
822
+
823
+ if axis_aligned:
824
+ # Pixel-perfect slice
825
+ bounds = self._int_bounds_from_quad(src, W_img, H_img)
826
+ if bounds is None:
827
+ QMessageBox.critical(self, self.tr("Apply failed"), self.tr("Invalid crop bounds."))
828
+ return
829
+ x0, x1, y0, y1 = bounds
830
+ out = self._full01[y0:y1, x0:x1].copy()
831
+
832
+ # Build a pure-translation H so WCS update remains correct
833
+ M = np.array([[1.0, 0.0, -float(x0)],
834
+ [0.0, 1.0, -float(y0)],
835
+ [0.0, 0.0, 1.0]], dtype=np.float32)
836
+ w_out, h_out = (x1 - x0), (y1 - y0)
837
+
838
+ else:
839
+ # Rotated/keystoned selection → perspective crop with sharp filter
840
+ M = cv2.getPerspectiveTransform(src, dst)
841
+ w_out = int(round(width))
842
+ h_out = int(round(height))
843
+ if w_out <= 0 or h_out <= 0:
844
+ QMessageBox.critical(self, self.tr("Apply failed"), self.tr("Invalid crop size."))
845
+ return
846
+
847
+ out = cv2.warpPerspective(
848
+ self._full01, M, (w_out, h_out),
849
+ flags=cv2.INTER_LANCZOS4 # crisper rotation, no resizing implied
850
+ )
851
+
852
+ # ---- WCS & bookkeeping (unchanged) ----
853
+ new_meta = dict(self.doc.metadata or {})
854
+ try:
855
+ if update_wcs_after_crop is not None:
856
+ new_meta = update_wcs_after_crop(new_meta, M_src_to_dst=M, out_w=w_out, out_h=h_out)
857
+ except Exception:
858
+ pass
859
+
860
+ CropDialogPro._prev_rect = QRectF(self._rect_item.rect())
861
+ CropDialogPro._prev_angle = float(self._rect_item.rotation())
862
+ CropDialogPro._prev_pos = QPointF(self._rect_item.pos())
863
+
864
+ try:
865
+ self.doc.apply_edit(out.copy(), metadata={**new_meta, "step_name": "Crop"}, step_name="Crop")
866
+ self._maybe_notify_wcs_update(new_meta)
867
+ self.crop_applied.emit(out)
868
+ self.accept()
869
+ except Exception as e:
870
+ QMessageBox.critical(self, self.tr("Apply failed"), str(e))
871
+
872
+ def _apply_batch(self):
873
+ if not self._rect_item:
874
+ QMessageBox.warning(self, self.tr("No Selection"), self.tr("Draw & finalize a crop first."))
875
+ return
876
+
877
+ # Normalize the crop polygon to THIS image size
878
+ corners = self._corners_scene()
879
+ src_this = np.array([self._scene_to_img_pixels(p, self._orig_w, self._orig_h) for p in corners], dtype=np.float32)
880
+ norm = src_this / np.array([self._orig_w, self._orig_h], dtype=np.float32)
881
+
882
+ # Collect all open documents from the MDI
883
+ win = self.parent()
884
+ subs = getattr(win, "mdi", None).subWindowList() if hasattr(win, "mdi") else []
885
+ docs = []
886
+ for sw in subs:
887
+ vw = sw.widget()
888
+ d = getattr(vw, "document", None)
889
+ if d is not None:
890
+ docs.append(d)
891
+
892
+ if not docs:
893
+ QMessageBox.information(self, self.tr("No Images"), self.tr("No open images to crop."))
894
+ return
895
+
896
+ ok = QMessageBox.question(
897
+ self, self.tr("Confirm Batch"),
898
+ self.tr("Apply this crop to {0} open image(s)?").format(len(docs)),
899
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
900
+ QMessageBox.StandardButton.No
901
+ )
902
+ if ok != QMessageBox.StandardButton.Yes:
903
+ return
904
+
905
+ last_cropped = None
906
+ for d in docs:
907
+ img = np.asarray(d.image)
908
+ if img.dtype.kind in "ui":
909
+ src01 = img.astype(np.float32) / np.iinfo(d.image.dtype).max
910
+ else:
911
+ src01 = img.astype(np.float32, copy=False)
912
+
913
+ h, w = src01.shape[:2]
914
+ src_pts = norm * np.array([w, h], dtype=np.float32) # (4,2)
915
+
916
+ axis_aligned = self._quad_is_axis_aligned(src_pts)
917
+
918
+ if axis_aligned:
919
+ b = self._int_bounds_from_quad(src_pts, w, h)
920
+ if b is None:
921
+ continue
922
+ x0, x1, y0, y1 = b
923
+ cropped = src01[y0:y1, x0:x1].copy()
924
+ w_out, h_out = (x1 - x0), (y1 - y0)
925
+ M = np.array([[1.0, 0.0, -float(x0)],
926
+ [0.0, 1.0, -float(y0)],
927
+ [0.0, 0.0, 1.0]], dtype=np.float32)
928
+ else:
929
+ w_out = int(round(np.linalg.norm(src_pts[1] - src_pts[0])))
930
+ h_out = int(round(np.linalg.norm(src_pts[3] - src_pts[0])))
931
+ if w_out <= 0 or h_out <= 0:
932
+ continue
933
+ dst = np.array([[0,0],[w_out,0],[w_out,h_out],[0,h_out]], dtype=np.float32)
934
+ M = cv2.getPerspectiveTransform(src_pts.astype(np.float32), dst)
935
+ cropped = cv2.warpPerspective(src01, M, (w_out, h_out), flags=cv2.INTER_LANCZOS4)
936
+
937
+ # WCS update per doc
938
+ meta_this = dict(d.metadata or {})
939
+ try:
940
+ if update_wcs_after_crop is not None:
941
+ meta_this = update_wcs_after_crop(meta_this, M_src_to_dst=M, out_w=w_out, out_h=h_out)
942
+ except Exception:
943
+ pass
944
+
945
+ try:
946
+ d.apply_edit(cropped.copy(), metadata={**meta_this, "step_name":"Crop"}, step_name="Crop")
947
+ last_cropped = cropped
948
+ except Exception:
949
+ pass
950
+
951
+ QMessageBox.information(self, self.tr("Batch Crop"), self.tr("Applied crop to all open images. Any Astrometric Solutions has been updated."))
952
+ if last_cropped is not None:
953
+ self.crop_applied.emit(last_cropped)
954
+ self.accept()
955
+
956
+ def _maybe_notify_wcs_update(self, meta: dict, batch_note: str | None = None):
957
+ dbg = (meta or {}).get("__wcs_debug__")
958
+ if not dbg:
959
+ return
960
+ try:
961
+ before = dbg.get("before", {})
962
+ after = dbg.get("after", {})
963
+ fit = dbg.get("fit", {})
964
+ b_ra, b_dec = before.get("crval_deg", (float("nan"), float("nan")))
965
+ a_ra, a_dec = after.get("crval_deg", (float("nan"), float("nan")))
966
+ rms = fit.get("rms_arcsec", float("nan"))
967
+ p95 = fit.get("p95_arcsec", float("nan"))
968
+ sip = after.get("sip_degree")
969
+ size = after.get("size")
970
+ sip_txt = f"TAN-SIP (deg={sip})" if sip is not None else "TAN"
971
+ size_txt = f"{size[0]}×{size[1]}" if size else "?"
972
+ extra = f"\n{batch_note}" if batch_note else ""
973
+ msg = (
974
+ self.tr("Astrometric solution updated ✔️\n\n") +
975
+ self.tr("Model: {0} Image: {1}\n").format(sip_txt, size_txt) +
976
+ self.tr("CRVAL: ({0:.6f}, {1:.6f}) → ({2:.6f}, {3:.6f})\n").format(b_ra, b_dec, a_ra, a_dec) +
977
+ self.tr("Fit residuals: RMS {0:.3f}\" (p95 {1:.3f}\")").format(rms, p95) +
978
+ f"{extra}"
979
+ )
980
+ QMessageBox.information(self, self.tr("WCS Updated"), msg)
981
+ except Exception:
982
+ # Be quiet if formatting fails
983
+ pass