setiastrosuitepro 1.6.2.post1__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 (367) 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/rotateclockwise.png +0 -0
  107. setiastro/images/rotatecounterclockwise.png +0 -0
  108. setiastro/images/satellite.png +0 -0
  109. setiastro/images/script.png +0 -0
  110. setiastro/images/selectivecolor.png +0 -0
  111. setiastro/images/simbad.png +0 -0
  112. setiastro/images/slot0.png +0 -0
  113. setiastro/images/slot1.png +0 -0
  114. setiastro/images/slot2.png +0 -0
  115. setiastro/images/slot3.png +0 -0
  116. setiastro/images/slot4.png +0 -0
  117. setiastro/images/slot5.png +0 -0
  118. setiastro/images/slot6.png +0 -0
  119. setiastro/images/slot7.png +0 -0
  120. setiastro/images/slot8.png +0 -0
  121. setiastro/images/slot9.png +0 -0
  122. setiastro/images/spcc.png +0 -0
  123. setiastro/images/spin_precession_vs_lunar_distance.png +0 -0
  124. setiastro/images/spinner.gif +0 -0
  125. setiastro/images/stacking.png +0 -0
  126. setiastro/images/staradd.png +0 -0
  127. setiastro/images/staralign.png +0 -0
  128. setiastro/images/starnet.png +0 -0
  129. setiastro/images/starregistration.png +0 -0
  130. setiastro/images/starspike.png +0 -0
  131. setiastro/images/starstretch.png +0 -0
  132. setiastro/images/statstretch.png +0 -0
  133. setiastro/images/supernova.png +0 -0
  134. setiastro/images/uhs.png +0 -0
  135. setiastro/images/undoicon.png +0 -0
  136. setiastro/images/upscale.png +0 -0
  137. setiastro/images/viewbundle.png +0 -0
  138. setiastro/images/whitebalance.png +0 -0
  139. setiastro/images/wimi_icon_256x256.png +0 -0
  140. setiastro/images/wimilogo.png +0 -0
  141. setiastro/images/wims.png +0 -0
  142. setiastro/images/wrench_icon.png +0 -0
  143. setiastro/images/xisfliberator.png +0 -0
  144. setiastro/qml/ResourceMonitor.qml +126 -0
  145. setiastro/saspro/__init__.py +20 -0
  146. setiastro/saspro/__main__.py +945 -0
  147. setiastro/saspro/_generated/__init__.py +7 -0
  148. setiastro/saspro/_generated/build_info.py +3 -0
  149. setiastro/saspro/abe.py +1346 -0
  150. setiastro/saspro/abe_preset.py +196 -0
  151. setiastro/saspro/aberration_ai.py +694 -0
  152. setiastro/saspro/aberration_ai_preset.py +224 -0
  153. setiastro/saspro/accel_installer.py +218 -0
  154. setiastro/saspro/accel_workers.py +30 -0
  155. setiastro/saspro/add_stars.py +624 -0
  156. setiastro/saspro/astrobin_exporter.py +1010 -0
  157. setiastro/saspro/astrospike.py +153 -0
  158. setiastro/saspro/astrospike_python.py +1841 -0
  159. setiastro/saspro/autostretch.py +198 -0
  160. setiastro/saspro/backgroundneutral.py +602 -0
  161. setiastro/saspro/batch_convert.py +328 -0
  162. setiastro/saspro/batch_renamer.py +522 -0
  163. setiastro/saspro/blemish_blaster.py +491 -0
  164. setiastro/saspro/blink_comparator_pro.py +2926 -0
  165. setiastro/saspro/bundles.py +61 -0
  166. setiastro/saspro/bundles_dock.py +114 -0
  167. setiastro/saspro/cheat_sheet.py +213 -0
  168. setiastro/saspro/clahe.py +368 -0
  169. setiastro/saspro/comet_stacking.py +1442 -0
  170. setiastro/saspro/common_tr.py +107 -0
  171. setiastro/saspro/config.py +38 -0
  172. setiastro/saspro/config_bootstrap.py +40 -0
  173. setiastro/saspro/config_manager.py +316 -0
  174. setiastro/saspro/continuum_subtract.py +1617 -0
  175. setiastro/saspro/convo.py +1400 -0
  176. setiastro/saspro/convo_preset.py +414 -0
  177. setiastro/saspro/copyastro.py +190 -0
  178. setiastro/saspro/cosmicclarity.py +1589 -0
  179. setiastro/saspro/cosmicclarity_preset.py +407 -0
  180. setiastro/saspro/crop_dialog_pro.py +973 -0
  181. setiastro/saspro/crop_preset.py +189 -0
  182. setiastro/saspro/curve_editor_pro.py +2562 -0
  183. setiastro/saspro/curves_preset.py +375 -0
  184. setiastro/saspro/debayer.py +673 -0
  185. setiastro/saspro/debug_utils.py +29 -0
  186. setiastro/saspro/dnd_mime.py +35 -0
  187. setiastro/saspro/doc_manager.py +2664 -0
  188. setiastro/saspro/exoplanet_detector.py +2166 -0
  189. setiastro/saspro/file_utils.py +284 -0
  190. setiastro/saspro/fitsmodifier.py +748 -0
  191. setiastro/saspro/fix_bom.py +32 -0
  192. setiastro/saspro/free_torch_memory.py +48 -0
  193. setiastro/saspro/frequency_separation.py +1349 -0
  194. setiastro/saspro/function_bundle.py +1596 -0
  195. setiastro/saspro/generate_translations.py +3092 -0
  196. setiastro/saspro/ghs_dialog_pro.py +663 -0
  197. setiastro/saspro/ghs_preset.py +284 -0
  198. setiastro/saspro/graxpert.py +637 -0
  199. setiastro/saspro/graxpert_preset.py +287 -0
  200. setiastro/saspro/gui/__init__.py +0 -0
  201. setiastro/saspro/gui/main_window.py +8810 -0
  202. setiastro/saspro/gui/mixins/__init__.py +33 -0
  203. setiastro/saspro/gui/mixins/dock_mixin.py +362 -0
  204. setiastro/saspro/gui/mixins/file_mixin.py +450 -0
  205. setiastro/saspro/gui/mixins/geometry_mixin.py +403 -0
  206. setiastro/saspro/gui/mixins/header_mixin.py +441 -0
  207. setiastro/saspro/gui/mixins/mask_mixin.py +421 -0
  208. setiastro/saspro/gui/mixins/menu_mixin.py +389 -0
  209. setiastro/saspro/gui/mixins/theme_mixin.py +367 -0
  210. setiastro/saspro/gui/mixins/toolbar_mixin.py +1457 -0
  211. setiastro/saspro/gui/mixins/update_mixin.py +309 -0
  212. setiastro/saspro/gui/mixins/view_mixin.py +435 -0
  213. setiastro/saspro/gui/statistics_dialog.py +47 -0
  214. setiastro/saspro/halobgon.py +488 -0
  215. setiastro/saspro/header_viewer.py +448 -0
  216. setiastro/saspro/headless_utils.py +88 -0
  217. setiastro/saspro/histogram.py +756 -0
  218. setiastro/saspro/history_explorer.py +941 -0
  219. setiastro/saspro/i18n.py +168 -0
  220. setiastro/saspro/image_combine.py +417 -0
  221. setiastro/saspro/image_peeker_pro.py +1604 -0
  222. setiastro/saspro/imageops/__init__.py +37 -0
  223. setiastro/saspro/imageops/mdi_snap.py +292 -0
  224. setiastro/saspro/imageops/scnr.py +36 -0
  225. setiastro/saspro/imageops/starbasedwhitebalance.py +210 -0
  226. setiastro/saspro/imageops/stretch.py +236 -0
  227. setiastro/saspro/isophote.py +1182 -0
  228. setiastro/saspro/layers.py +208 -0
  229. setiastro/saspro/layers_dock.py +714 -0
  230. setiastro/saspro/lazy_imports.py +193 -0
  231. setiastro/saspro/legacy/__init__.py +2 -0
  232. setiastro/saspro/legacy/image_manager.py +2226 -0
  233. setiastro/saspro/legacy/numba_utils.py +3676 -0
  234. setiastro/saspro/legacy/xisf.py +1071 -0
  235. setiastro/saspro/linear_fit.py +537 -0
  236. setiastro/saspro/live_stacking.py +1841 -0
  237. setiastro/saspro/log_bus.py +5 -0
  238. setiastro/saspro/logging_config.py +460 -0
  239. setiastro/saspro/luminancerecombine.py +309 -0
  240. setiastro/saspro/main_helpers.py +201 -0
  241. setiastro/saspro/mask_creation.py +931 -0
  242. setiastro/saspro/masks_core.py +56 -0
  243. setiastro/saspro/mdi_widgets.py +353 -0
  244. setiastro/saspro/memory_utils.py +666 -0
  245. setiastro/saspro/metadata_patcher.py +75 -0
  246. setiastro/saspro/mfdeconv.py +3831 -0
  247. setiastro/saspro/mfdeconv_earlystop.py +71 -0
  248. setiastro/saspro/mfdeconvcudnn.py +3263 -0
  249. setiastro/saspro/mfdeconvsport.py +2382 -0
  250. setiastro/saspro/minorbodycatalog.py +567 -0
  251. setiastro/saspro/morphology.py +407 -0
  252. setiastro/saspro/multiscale_decomp.py +1293 -0
  253. setiastro/saspro/nbtorgb_stars.py +541 -0
  254. setiastro/saspro/numba_utils.py +3145 -0
  255. setiastro/saspro/numba_warmup.py +141 -0
  256. setiastro/saspro/ops/__init__.py +9 -0
  257. setiastro/saspro/ops/command_help_dialog.py +623 -0
  258. setiastro/saspro/ops/command_runner.py +217 -0
  259. setiastro/saspro/ops/commands.py +1594 -0
  260. setiastro/saspro/ops/script_editor.py +1102 -0
  261. setiastro/saspro/ops/scripts.py +1473 -0
  262. setiastro/saspro/ops/settings.py +637 -0
  263. setiastro/saspro/parallel_utils.py +554 -0
  264. setiastro/saspro/pedestal.py +121 -0
  265. setiastro/saspro/perfect_palette_picker.py +1071 -0
  266. setiastro/saspro/pipeline.py +110 -0
  267. setiastro/saspro/pixelmath.py +1604 -0
  268. setiastro/saspro/plate_solver.py +2445 -0
  269. setiastro/saspro/project_io.py +797 -0
  270. setiastro/saspro/psf_utils.py +136 -0
  271. setiastro/saspro/psf_viewer.py +549 -0
  272. setiastro/saspro/pyi_rthook_astroquery.py +95 -0
  273. setiastro/saspro/remove_green.py +331 -0
  274. setiastro/saspro/remove_stars.py +1599 -0
  275. setiastro/saspro/remove_stars_preset.py +404 -0
  276. setiastro/saspro/resources.py +501 -0
  277. setiastro/saspro/rgb_combination.py +208 -0
  278. setiastro/saspro/rgb_extract.py +19 -0
  279. setiastro/saspro/rgbalign.py +723 -0
  280. setiastro/saspro/runtime_imports.py +7 -0
  281. setiastro/saspro/runtime_torch.py +754 -0
  282. setiastro/saspro/save_options.py +73 -0
  283. setiastro/saspro/selective_color.py +1552 -0
  284. setiastro/saspro/sfcc.py +1472 -0
  285. setiastro/saspro/shortcuts.py +3043 -0
  286. setiastro/saspro/signature_insert.py +1102 -0
  287. setiastro/saspro/stacking_suite.py +18470 -0
  288. setiastro/saspro/star_alignment.py +7435 -0
  289. setiastro/saspro/star_alignment_preset.py +329 -0
  290. setiastro/saspro/star_metrics.py +49 -0
  291. setiastro/saspro/star_spikes.py +765 -0
  292. setiastro/saspro/star_stretch.py +507 -0
  293. setiastro/saspro/stat_stretch.py +538 -0
  294. setiastro/saspro/status_log_dock.py +78 -0
  295. setiastro/saspro/subwindow.py +3328 -0
  296. setiastro/saspro/supernovaasteroidhunter.py +1719 -0
  297. setiastro/saspro/swap_manager.py +99 -0
  298. setiastro/saspro/torch_backend.py +89 -0
  299. setiastro/saspro/torch_rejection.py +434 -0
  300. setiastro/saspro/translations/all_source_strings.json +3654 -0
  301. setiastro/saspro/translations/ar_translations.py +3865 -0
  302. setiastro/saspro/translations/de_translations.py +3749 -0
  303. setiastro/saspro/translations/es_translations.py +3939 -0
  304. setiastro/saspro/translations/fr_translations.py +3858 -0
  305. setiastro/saspro/translations/hi_translations.py +3571 -0
  306. setiastro/saspro/translations/integrate_translations.py +270 -0
  307. setiastro/saspro/translations/it_translations.py +3678 -0
  308. setiastro/saspro/translations/ja_translations.py +3601 -0
  309. setiastro/saspro/translations/pt_translations.py +3869 -0
  310. setiastro/saspro/translations/ru_translations.py +2848 -0
  311. setiastro/saspro/translations/saspro_ar.qm +0 -0
  312. setiastro/saspro/translations/saspro_ar.ts +255 -0
  313. setiastro/saspro/translations/saspro_de.qm +0 -0
  314. setiastro/saspro/translations/saspro_de.ts +253 -0
  315. setiastro/saspro/translations/saspro_es.qm +0 -0
  316. setiastro/saspro/translations/saspro_es.ts +12520 -0
  317. setiastro/saspro/translations/saspro_fr.qm +0 -0
  318. setiastro/saspro/translations/saspro_fr.ts +12514 -0
  319. setiastro/saspro/translations/saspro_hi.qm +0 -0
  320. setiastro/saspro/translations/saspro_hi.ts +257 -0
  321. setiastro/saspro/translations/saspro_it.qm +0 -0
  322. setiastro/saspro/translations/saspro_it.ts +12520 -0
  323. setiastro/saspro/translations/saspro_ja.qm +0 -0
  324. setiastro/saspro/translations/saspro_ja.ts +257 -0
  325. setiastro/saspro/translations/saspro_pt.qm +0 -0
  326. setiastro/saspro/translations/saspro_pt.ts +257 -0
  327. setiastro/saspro/translations/saspro_ru.qm +0 -0
  328. setiastro/saspro/translations/saspro_ru.ts +237 -0
  329. setiastro/saspro/translations/saspro_sw.qm +0 -0
  330. setiastro/saspro/translations/saspro_sw.ts +257 -0
  331. setiastro/saspro/translations/saspro_uk.qm +0 -0
  332. setiastro/saspro/translations/saspro_uk.ts +10771 -0
  333. setiastro/saspro/translations/saspro_zh.qm +0 -0
  334. setiastro/saspro/translations/saspro_zh.ts +12520 -0
  335. setiastro/saspro/translations/sw_translations.py +3671 -0
  336. setiastro/saspro/translations/uk_translations.py +3700 -0
  337. setiastro/saspro/translations/zh_translations.py +3675 -0
  338. setiastro/saspro/versioning.py +77 -0
  339. setiastro/saspro/view_bundle.py +1558 -0
  340. setiastro/saspro/wavescale_hdr.py +645 -0
  341. setiastro/saspro/wavescale_hdr_preset.py +101 -0
  342. setiastro/saspro/wavescalede.py +680 -0
  343. setiastro/saspro/wavescalede_preset.py +230 -0
  344. setiastro/saspro/wcs_update.py +374 -0
  345. setiastro/saspro/whitebalance.py +492 -0
  346. setiastro/saspro/widgets/__init__.py +48 -0
  347. setiastro/saspro/widgets/common_utilities.py +306 -0
  348. setiastro/saspro/widgets/graphics_views.py +122 -0
  349. setiastro/saspro/widgets/image_utils.py +518 -0
  350. setiastro/saspro/widgets/minigame/game.js +986 -0
  351. setiastro/saspro/widgets/minigame/index.html +53 -0
  352. setiastro/saspro/widgets/minigame/style.css +241 -0
  353. setiastro/saspro/widgets/preview_dialogs.py +280 -0
  354. setiastro/saspro/widgets/resource_monitor.py +237 -0
  355. setiastro/saspro/widgets/spinboxes.py +275 -0
  356. setiastro/saspro/widgets/themed_buttons.py +13 -0
  357. setiastro/saspro/widgets/wavelet_utils.py +331 -0
  358. setiastro/saspro/wimi.py +7996 -0
  359. setiastro/saspro/wims.py +578 -0
  360. setiastro/saspro/window_shelf.py +185 -0
  361. setiastro/saspro/xisf.py +1123 -0
  362. setiastrosuitepro-1.6.2.post1.dist-info/METADATA +278 -0
  363. setiastrosuitepro-1.6.2.post1.dist-info/RECORD +367 -0
  364. setiastrosuitepro-1.6.2.post1.dist-info/WHEEL +4 -0
  365. setiastrosuitepro-1.6.2.post1.dist-info/entry_points.txt +6 -0
  366. setiastrosuitepro-1.6.2.post1.dist-info/licenses/LICENSE +674 -0
  367. setiastrosuitepro-1.6.2.post1.dist-info/licenses/license.txt +2580 -0
@@ -0,0 +1,973 @@
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([self.tr("Free"), self.tr("Original"), "1:1", "16:9", "9:16", "4:3"])
306
+ row.addWidget(self.cmb_ar)
307
+ row.addStretch(1)
308
+ main.addLayout(row)
309
+
310
+ # typed margins (pixels): Top, Right, Bottom, Left
311
+ margins_row = QHBoxLayout()
312
+ margins_row.addStretch(1)
313
+ margins_row.addWidget(QLabel(self.tr("Margins (px):")))
314
+ self.sb_top = QSpinBox(); self.sb_top.setSuffix(" px")
315
+ self.sb_right = QSpinBox(); self.sb_right.setSuffix(" px")
316
+ self.sb_bottom = QSpinBox(); self.sb_bottom.setSuffix(" px")
317
+ self.sb_left = QSpinBox(); self.sb_left.setSuffix(" px")
318
+
319
+ # reasonable wide ranges; clamped on apply anyway
320
+ for sb in (self.sb_top, self.sb_bottom, self.sb_left, self.sb_right):
321
+ sb.setRange(0, 1_000_000)
322
+
323
+ # labels inline for clarity
324
+ margins_row.addWidget(QLabel(self.tr("Top")))
325
+ margins_row.addWidget(self.sb_top)
326
+ margins_row.addSpacing(8)
327
+ margins_row.addWidget(QLabel(self.tr("Right")))
328
+ margins_row.addWidget(self.sb_right)
329
+ margins_row.addSpacing(8)
330
+ margins_row.addWidget(QLabel(self.tr("Bottom")))
331
+ margins_row.addWidget(self.sb_bottom)
332
+ margins_row.addSpacing(8)
333
+ margins_row.addWidget(QLabel(self.tr("Left")))
334
+ margins_row.addWidget(self.sb_left)
335
+ margins_row.addStretch(1)
336
+ main.addLayout(margins_row)
337
+
338
+ # live-apply: when any value changes, update the selection rectangle
339
+ self._suppress_margin_sync = False
340
+ def _on_margin_changed(_):
341
+ if self._suppress_margin_sync:
342
+ return
343
+ self._apply_margin_inputs()
344
+ for sb in (self.sb_top, self.sb_right, self.sb_bottom, self.sb_left):
345
+ sb.valueChanged.connect(_on_margin_changed)
346
+
347
+ # graphics view
348
+ self.scene = QGraphicsScene(self)
349
+ self.view = QGraphicsView(self.scene)
350
+ self.view.setRenderHints(self.view.renderHints())
351
+ self.view.setDragMode(QGraphicsView.DragMode.NoDrag)
352
+ self.view.viewport().installEventFilter(self)
353
+ main.addWidget(self.view, 1)
354
+
355
+ self._zoom = 1.0 # manual zoom factor
356
+ self._fit_mode = True # start in Fit-to-View mode
357
+
358
+ # nicer zoom behavior
359
+ self.view.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
360
+ self.view.setResizeAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
361
+ self.view.setDragMode(QGraphicsView.DragMode.ScrollHandDrag) # pan with mouse-drag
362
+
363
+ zoom_row = QHBoxLayout()
364
+ zoom_row.addStretch(1)
365
+
366
+ self.btn_zoom_out = themed_toolbtn("zoom-out", self.tr("Zoom Out"))
367
+ self.btn_zoom_in = themed_toolbtn("zoom-in", self.tr("Zoom In"))
368
+ self.btn_zoom_100 = themed_toolbtn("zoom-original", self.tr("Zoom 100%"))
369
+ self.btn_zoom_fit = themed_toolbtn("zoom-fit-best", self.tr("Fit to View"))
370
+
371
+ for b in (self.btn_zoom_out, self.btn_zoom_in, self.btn_zoom_100, self.btn_zoom_fit):
372
+ zoom_row.addWidget(b)
373
+
374
+ zoom_row.addStretch(1)
375
+ main.addLayout(zoom_row)
376
+
377
+ dim_row = QHBoxLayout()
378
+ dim_row.addStretch(1)
379
+ self.lbl_dims = QLabel(self.tr("Selection: —"))
380
+ self.lbl_dims.setStyleSheet("color: gray;")
381
+ dim_row.addWidget(self.lbl_dims)
382
+ dim_row.addStretch(1)
383
+ main.addLayout(dim_row)
384
+
385
+ # wire zoom buttons
386
+ self.btn_zoom_in.clicked.connect(lambda: self._zoom_by(1.25))
387
+ self.btn_zoom_out.clicked.connect(lambda: self._zoom_by(1/1.25))
388
+ self.btn_zoom_100.clicked.connect(self._zoom_reset_100)
389
+ self.btn_zoom_fit.clicked.connect(self._fit_view)
390
+
391
+ # buttons
392
+ btn_row = QHBoxLayout()
393
+ self.btn_autostretch = QPushButton(self.tr("Toggle Autostretch"))
394
+ self.btn_prev = QPushButton(self.tr("Load Previous Crop"))
395
+ self.btn_apply = QPushButton(self.tr("Apply"))
396
+ self.btn_batch = QPushButton(self.tr("Batch Crop (all open)"))
397
+ self.btn_close = QToolButton(); self.btn_close.setText(self.tr("Close"))
398
+ for b in (self.btn_autostretch, self.btn_prev, self.btn_apply, self.btn_batch, self.btn_close):
399
+ btn_row.addWidget(b)
400
+ main.addLayout(btn_row)
401
+
402
+ # wire
403
+ self.cmb_ar.currentTextChanged.connect(self._on_ar_changed)
404
+ self.btn_autostretch.clicked.connect(self._toggle_autostretch)
405
+ self.btn_prev.clicked.connect(self._load_previous)
406
+ self.btn_apply.clicked.connect(self._apply_one)
407
+ self.btn_batch.clicked.connect(self._apply_batch)
408
+ self.btn_close.clicked.connect(self.accept)
409
+
410
+ # seed image
411
+ self._load_from_doc()
412
+ self._update_margin_spin_ranges()
413
+ self.resize(1000, 720)
414
+ self._deferred_fit()
415
+
416
+ def _deferred_fit(self):
417
+ if self._fit_mode:
418
+ QTimer.singleShot(0, self._fit_view)
419
+
420
+ def showEvent(self, ev):
421
+ super().showEvent(ev)
422
+ self._deferred_fit() # ensure fit after the first real layout
423
+
424
+ # ---------- image plumbing ----------
425
+ def _quad_is_axis_aligned(self, pts: np.ndarray, tol: float = 1e-2) -> bool:
426
+ """
427
+ pts: (4,2) in image pixel coords, order: TL, TR, BR, BL
428
+ Returns True if edges are parallel to axes within tolerance.
429
+ """
430
+ if pts.shape != (4, 2):
431
+ return False
432
+ xL, xR = (pts[0,0] + pts[3,0]) * 0.5, (pts[1,0] + pts[2,0]) * 0.5
433
+ yT, yB = (pts[0,1] + pts[1,1]) * 0.5, (pts[2,1] + pts[3,1]) * 0.5
434
+ # vertical edges nearly vertical, horizontal edges nearly horizontal
435
+ # Check that each edge's "other" dimension differs by very little.
436
+ left_dx = abs(pts[0,0] - pts[3,0])
437
+ right_dx = abs(pts[1,0] - pts[2,0])
438
+ top_dy = abs(pts[0,1] - pts[1,1])
439
+ bot_dy = abs(pts[2,1] - pts[3,1])
440
+
441
+ return (left_dx < tol and right_dx < tol and top_dy < tol and bot_dy < tol)
442
+
443
+ def _int_bounds_from_quad(self, pts: np.ndarray, W: int, H: int) -> tuple[int,int,int,int] | None:
444
+ """
445
+ pts: (4,2) image-space corners. Returns (x0, x1, y0, y1) clamped to image
446
+ using floor/ceil so we keep all intended pixels.
447
+ """
448
+ if pts.size != 8:
449
+ return None
450
+ xs = pts[:,0]; ys = pts[:,1]
451
+ # inclusive-exclusive slice bounds
452
+ x0 = int(np.floor(xs.min() + 1e-6))
453
+ y0 = int(np.floor(ys.min() + 1e-6))
454
+ x1 = int(np.ceil (xs.max() - 1e-6))
455
+ y1 = int(np.ceil (ys.max() - 1e-6))
456
+ # clamp
457
+ x0 = max(0, min(W, x0)); x1 = max(0, min(W, x1))
458
+ y0 = max(0, min(H, y0)); y1 = max(0, min(H, y1))
459
+ if x1 <= x0 or y1 <= y0:
460
+ return None
461
+ return x0, x1, y0, y1
462
+
463
+
464
+ def _img01_from_doc(self) -> np.ndarray:
465
+ arr = np.asarray(self.doc.image)
466
+ if arr.dtype.kind in "ui":
467
+ arr = arr.astype(np.float32) / np.iinfo(self.doc.image.dtype).max
468
+ else:
469
+ arr = arr.astype(np.float32, copy=False)
470
+ # ⬇️ Treat mono with a trailing channel as true mono
471
+ if arr.ndim == 3 and arr.shape[2] == 1:
472
+ arr = arr[..., 0]
473
+ return np.clip(arr, 0.0, 1.0)
474
+
475
+ def _on_active_doc_changed(self, doc):
476
+ """Called when user clicks a different image window."""
477
+ if doc is None or getattr(doc, "image", None) is None:
478
+ return
479
+ self.doc = doc
480
+ self._rect_item = None
481
+ self._load_from_doc()
482
+
483
+ def _load_from_doc(self):
484
+ self._full01 = self._img01_from_doc()
485
+ self._orig_h, self._orig_w = self._full01.shape[:2]
486
+ self._preview01 = self._full01 if not self._autostretch_on else siril_style_autostretch(self._full01)
487
+
488
+ self.scene.clear()
489
+ q = self._to_qimage(self._preview01)
490
+ pm = QPixmap.fromImage(q)
491
+ self._pix_item = QGraphicsPixmapItem(pm)
492
+ self._pix_item.setZValue(-1)
493
+ self.scene.addItem(self._pix_item)
494
+ self._apply_zoom_transform()
495
+ self._deferred_fit()
496
+ self._set_dim_label_none()
497
+
498
+ def resizeEvent(self, ev):
499
+ super().resizeEvent(ev)
500
+ if self._fit_mode:
501
+ self._apply_zoom_transform()
502
+
503
+ # ---------- selection dimensions label ----------
504
+
505
+ def _set_dim_label_none(self):
506
+ if hasattr(self, "lbl_dims"):
507
+ self.lbl_dims.setText(self.tr("Selection: —"))
508
+
509
+ def _update_dim_label_from_corners(self, corners_scene):
510
+ """
511
+ corners_scene: iterable of 4 QPointF in order TL, TR, BR, BL (scene coords).
512
+ Computes width/height in *image pixels* and updates the label.
513
+ """
514
+ if not hasattr(self, "lbl_dims") or not corners_scene or not self._pix_item:
515
+ self._set_dim_label_none()
516
+ return
517
+
518
+ w_img, h_img = self._orig_w, self._orig_h
519
+ src = np.array(
520
+ [self._scene_to_img_pixels(p, w_img, h_img) for p in corners_scene],
521
+ dtype=np.float32,
522
+ )
523
+
524
+ # same convention as _apply_one(): width = |TR-TL|, height = |BL-TL|
525
+ width = float(np.linalg.norm(src[1] - src[0]))
526
+ height = float(np.linalg.norm(src[3] - src[0]))
527
+
528
+ self.lbl_dims.setText(
529
+ self.tr("Selection: {0}×{1} px").format(int(round(height)), int(round(width)))
530
+ )
531
+
532
+ def _update_dim_label_from_rect_item(self):
533
+ """Update label from the current finalized rect item."""
534
+ if not self._rect_item:
535
+ self._set_dim_label_none()
536
+ return
537
+ corners = self._corners_scene() # uses mapToScene on the item
538
+ self._update_dim_label_from_corners(corners)
539
+
540
+
541
+ @staticmethod
542
+ def _to_qimage(img01: np.ndarray) -> QImage:
543
+ # Ensure shapes we expect
544
+ if img01.ndim == 3 and img01.shape[2] == 1:
545
+ img01 = img01[..., 0]
546
+
547
+ if img01.ndim == 2:
548
+ buf = np.ascontiguousarray((img01 * 255).astype(np.uint8))
549
+ h, w = buf.shape
550
+ bpl = buf.strides[0] # == w for contiguous grayscale
551
+ return QImage(buf.tobytes(), w, h, bpl, QImage.Format.Format_Grayscale8)
552
+
553
+ if img01.ndim == 3 and img01.shape[2] == 3:
554
+ buf = np.ascontiguousarray((img01 * 255).astype(np.uint8))
555
+ h, w, _ = buf.shape
556
+ bpl = buf.strides[0] # == 3*w for contiguous RGB
557
+ return QImage(buf.tobytes(), w, h, bpl, QImage.Format.Format_RGB888)
558
+
559
+ raise ValueError(f"Unsupported image shape for preview: {img01.shape}")
560
+
561
+ # ---------- aspect ratio ----------
562
+ def _on_ar_changed(self, txt: str):
563
+ if not self._rect_item: return
564
+ if txt == "Free":
565
+ ar = None
566
+ elif txt == "Original":
567
+ ar = self._orig_w / self._orig_h
568
+ else:
569
+ a, b = map(float, txt.split(":")); ar = a / b
570
+ self._rect_item.setFixedAspectRatio(ar)
571
+ if ar is not None:
572
+ r = self._rect_item.rect()
573
+ w = r.width(); h = w / ar
574
+ c = r.center()
575
+ nr = QRectF(c.x()-w/2, c.y()-h/2, w, h)
576
+ self._rect_item.setRect(nr)
577
+ self._rect_item.setTransformOriginPoint(nr.center())
578
+
579
+ # ---------- drawing / interaction ----------
580
+ def eventFilter(self, src, e):
581
+ if src is self.view.viewport():
582
+ if e.type() == QEvent.Type.Wheel and (e.modifiers() & Qt.KeyboardModifier.ControlModifier):
583
+ delta = e.angleDelta().y()
584
+ self._zoom_by(1.25 if delta > 0 else 1/1.25)
585
+ return True
586
+ if e.type() in (QEvent.Type.MouseButtonPress, QEvent.Type.MouseMove, QEvent.Type.MouseButtonRelease):
587
+ scene_pt = self.view.mapToScene(e.pos())
588
+
589
+ # ⬇️ New: if we already have a rect, keep dims updated on mouse move
590
+ if e.type() == QEvent.Type.MouseMove and self._rect_item is not None:
591
+ self._update_dim_label_from_rect_item()
592
+
593
+ if self._rect_item is None:
594
+ if e.type() == QEvent.Type.MouseButtonPress and e.button() == Qt.MouseButton.LeftButton:
595
+ self._drawing = True; self._origin = scene_pt; return True
596
+
597
+ if e.type() == QEvent.Type.MouseMove and self._drawing:
598
+ r = QRectF(self._origin, scene_pt).normalized()
599
+ r = self._apply_ar_to_rect(r, live=True, scene_pt=scene_pt)
600
+ self._draw_live_rect(r)
601
+
602
+ # ⬇️ live dims from the temporary rect (axis-aligned TL,TR,BR,BL)
603
+ corners = [r.topLeft(), r.topRight(), r.bottomRight(), r.bottomLeft()]
604
+ self._update_dim_label_from_corners(corners)
605
+ return True
606
+
607
+ if e.type() == QEvent.Type.MouseButtonRelease and e.button() == Qt.MouseButton.LeftButton and self._drawing:
608
+ self._drawing = False
609
+ r = QRectF(self._origin, scene_pt).normalized()
610
+ r = self._apply_ar_to_rect(r, live=False, scene_pt=scene_pt)
611
+ self._clear_live_rect()
612
+ self._rect_item = ResizableRotatableRectItem(r)
613
+ self._rect_item.setZValue(10)
614
+ self._rect_item.setFixedAspectRatio(self._current_ar_value())
615
+ self.scene.addItem(self._rect_item)
616
+
617
+ # remember for “Load Previous”
618
+ CropDialogPro._prev_rect = QRectF(r)
619
+ CropDialogPro._prev_angle = self._rect_item.rotation()
620
+ CropDialogPro._prev_pos = self._rect_item.pos()
621
+
622
+ # ⬇️ finalized selection dims
623
+ self._update_dim_label_from_rect_item()
624
+ return True
625
+
626
+ return False
627
+ return super().eventFilter(src, e)
628
+
629
+
630
+ def _apply_zoom_transform(self):
631
+ if not self._pix_item:
632
+ return
633
+ if self._fit_mode:
634
+ rect = self._pix_item.mapRectToScene(self._pix_item.boundingRect())
635
+ self.view.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorViewCenter)
636
+ r = rect.adjusted(-1, -1, 1, 1) # 1px breathing room
637
+ self.view.fitInView(r, Qt.AspectRatioMode.KeepAspectRatio)
638
+ self.view.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
639
+ else:
640
+ self.view.resetTransform()
641
+ self.view.scale(self._zoom, self._zoom)
642
+
643
+ def _fit_view(self):
644
+ self._fit_mode = True
645
+ self._apply_zoom_transform()
646
+
647
+ def _zoom_reset_100(self):
648
+ self._fit_mode = False
649
+ self._zoom = 1.0
650
+ self._apply_zoom_transform()
651
+
652
+ def _zoom_by(self, factor: float):
653
+ self._fit_mode = False
654
+ # clamp zoom
655
+ newz = min(16.0, max(0.05, self._zoom * float(factor)))
656
+ if abs(newz - self._zoom) < 1e-4:
657
+ return
658
+ self._zoom = newz
659
+ self._apply_zoom_transform()
660
+
661
+ # ---------- typed margins helpers ----------
662
+ def _update_margin_spin_ranges(self):
663
+ """Limit typed margins to image dimensions (pixels)."""
664
+ h, w = int(self._orig_h), int(self._orig_w)
665
+ # Individual margins can be up to the full dimension; final rect is clamped.
666
+ self.sb_top.setRange(0, max(0, h))
667
+ self.sb_bottom.setRange(0, max(0, h))
668
+ self.sb_left.setRange(0, max(0, w))
669
+ self.sb_right.setRange(0, max(0, w))
670
+
671
+ def _apply_margin_inputs(self):
672
+ """Create/adjust the selection rect from typed margins (pixels)."""
673
+ t = int(self.sb_top.value())
674
+ r = int(self.sb_right.value())
675
+ b = int(self.sb_bottom.value())
676
+ l = int(self.sb_left.value())
677
+ self._set_rect_from_margins(t, r, b, l)
678
+
679
+ def _set_rect_from_margins(self, top: int, right: int, bottom: int, left: int):
680
+ """Set an axis-aligned crop selection equal to image bounds minus margins."""
681
+ w_img, h_img = float(self._orig_w), float(self._orig_h)
682
+ # clamp to image
683
+ left = max(0, min(int(left), int(w_img)))
684
+ right = max(0, min(int(right), int(w_img)))
685
+ top = max(0, min(int(top), int(h_img)))
686
+ bottom = max(0, min(int(bottom), int(h_img)))
687
+
688
+ x = float(left)
689
+ y = float(top)
690
+ w = max(1.0, w_img - (left + right))
691
+ h = max(1.0, h_img - (top + bottom))
692
+
693
+ r = QRectF(x, y, w, h)
694
+
695
+ # create or update the selection; force axis-aligned (rotation = 0)
696
+ if self._rect_item is None:
697
+ self._rect_item = ResizableRotatableRectItem(r)
698
+ self._rect_item.setZValue(10)
699
+ self.scene.addItem(self._rect_item)
700
+ else:
701
+ self._rect_item.setRotation(0.0)
702
+ self._rect_item.setPos(QPointF(0, 0))
703
+ self._rect_item.setRect(r)
704
+
705
+ self._rect_item.setTransformOriginPoint(r.center())
706
+ self._update_dim_label_from_rect_item()
707
+
708
+
709
+ def _current_ar_value(self) -> Optional[float]:
710
+ txt = self.cmb_ar.currentText()
711
+ if txt == self.tr("Free"): return None
712
+ if txt == self.tr("Original"): return self._orig_w / self._orig_h
713
+ a, b = map(float, txt.split(":")); return a / b
714
+
715
+ def _apply_ar_to_rect(self, r: QRectF, live: bool, scene_pt: QPointF) -> QRectF:
716
+ ar = self._current_ar_value()
717
+ if ar is None:
718
+ return r
719
+
720
+ # Calculate height from width using current aspect ratio
721
+ w = r.width()
722
+ h = w / ar
723
+
724
+ # Anchor to the click origin, adjust height based on drag direction
725
+ if scene_pt.y() < self._origin.y():
726
+ r.setTop(r.bottom() - h)
727
+ else:
728
+ r.setBottom(r.top() + h)
729
+
730
+ return r.normalized()
731
+
732
+ def _draw_live_rect(self, r: QRectF):
733
+ if hasattr(self, "_live_rect") and self._live_rect:
734
+ self.scene.removeItem(self._live_rect)
735
+ pen = QPen(QColor(0,255,0), 2, Qt.PenStyle.DashLine); pen.setCosmetic(True)
736
+ self._live_rect = self.scene.addRect(r, pen)
737
+
738
+ def _clear_live_rect(self):
739
+ if hasattr(self, "_live_rect") and self._live_rect:
740
+ self.scene.removeItem(self._live_rect); self._live_rect = None
741
+
742
+ # ---------- preview toggles ----------
743
+ def _toggle_autostretch(self):
744
+ self._autostretch_on = not self._autostretch_on
745
+ saved = self._snapshot_rect_state()
746
+ self._load_from_doc()
747
+ self._restore_rect_state(saved)
748
+ self._deferred_fit()
749
+
750
+ def _snapshot_rect_state(self):
751
+ if not self._rect_item: return None
752
+ return (QRectF(self._rect_item.rect()),
753
+ float(self._rect_item.rotation()),
754
+ QPointF(self._rect_item.pos()))
755
+
756
+ def _restore_rect_state(self, state):
757
+ if not state: return
758
+ r, ang, pos = state
759
+ self._rect_item = ResizableRotatableRectItem(r)
760
+ self._rect_item.setZValue(10)
761
+ self._rect_item.setFixedAspectRatio(self._current_ar_value())
762
+ self._rect_item.setRotation(ang)
763
+ self._rect_item.setPos(pos)
764
+ self._rect_item.setTransformOriginPoint(r.center())
765
+ self.scene.addItem(self._rect_item)
766
+ self._update_dim_label_from_rect_item()
767
+
768
+
769
+ def _load_previous(self):
770
+ if CropDialogPro._prev_rect is None:
771
+ QMessageBox.information(self, self.tr("No Previous"), self.tr("No previous crop stored."))
772
+ return
773
+ if self._rect_item:
774
+ self.scene.removeItem(self._rect_item)
775
+ r = QRectF(CropDialogPro._prev_rect)
776
+ self._rect_item = ResizableRotatableRectItem(r)
777
+ self._rect_item.setZValue(10)
778
+ self._rect_item.setFixedAspectRatio(self._current_ar_value())
779
+ self._rect_item.setRotation(CropDialogPro._prev_angle)
780
+ self._rect_item.setPos(CropDialogPro._prev_pos)
781
+ self._rect_item.setTransformOriginPoint(r.center())
782
+ self.scene.addItem(self._rect_item)
783
+ self._update_dim_label_from_rect_item()
784
+
785
+ # ---------- apply ----------
786
+ def _corners_scene(self):
787
+ rl = self._rect_item.rect()
788
+ loc = [rl.topLeft(), rl.topRight(), rl.bottomRight(), rl.bottomLeft()]
789
+ return [self._rect_item.mapToScene(p) for p in loc]
790
+
791
+ def _scene_to_img_pixels(self, pt_scene: QPointF, w_img: int, h_img: int):
792
+ pm = self._pix_item.pixmap()
793
+ sx, sy = w_img / pm.width(), h_img / pm.height()
794
+ return np.array([pt_scene.x() * sx, pt_scene.y() * sy], dtype=np.float32)
795
+
796
+ def _apply_one(self):
797
+ if not self._rect_item:
798
+ QMessageBox.warning(self, self.tr("No Selection"), self.tr("Draw & finalize a crop first."))
799
+ return
800
+
801
+ corners = self._corners_scene()
802
+ w_img, h_img = self._orig_w, self._orig_h
803
+ src = np.array([self._scene_to_img_pixels(p, w_img, h_img) for p in corners], dtype=np.float32)
804
+
805
+ width = np.linalg.norm(src[1] - src[0])
806
+ height = np.linalg.norm(src[3] - src[0])
807
+ dst = np.array([[0,0],[width,0],[width,height],[0,height]], dtype=np.float32)
808
+
809
+ # ---- Axis-aligned? → exact slice; else → rotate with Lanczos ----
810
+ H_img, W_img = self._orig_h, self._orig_w
811
+ axis_aligned = self._quad_is_axis_aligned(src)
812
+
813
+ if axis_aligned:
814
+ # Pixel-perfect slice
815
+ bounds = self._int_bounds_from_quad(src, W_img, H_img)
816
+ if bounds is None:
817
+ QMessageBox.critical(self, self.tr("Apply failed"), self.tr("Invalid crop bounds."))
818
+ return
819
+ x0, x1, y0, y1 = bounds
820
+ out = self._full01[y0:y1, x0:x1].copy()
821
+
822
+ # Build a pure-translation H so WCS update remains correct
823
+ M = np.array([[1.0, 0.0, -float(x0)],
824
+ [0.0, 1.0, -float(y0)],
825
+ [0.0, 0.0, 1.0]], dtype=np.float32)
826
+ w_out, h_out = (x1 - x0), (y1 - y0)
827
+
828
+ else:
829
+ # Rotated/keystoned selection → perspective crop with sharp filter
830
+ M = cv2.getPerspectiveTransform(src, dst)
831
+ w_out = int(round(width))
832
+ h_out = int(round(height))
833
+ if w_out <= 0 or h_out <= 0:
834
+ QMessageBox.critical(self, self.tr("Apply failed"), self.tr("Invalid crop size."))
835
+ return
836
+
837
+ out = cv2.warpPerspective(
838
+ self._full01, M, (w_out, h_out),
839
+ flags=cv2.INTER_LANCZOS4 # crisper rotation, no resizing implied
840
+ )
841
+
842
+ # ---- WCS & bookkeeping (unchanged) ----
843
+ new_meta = dict(self.doc.metadata or {})
844
+ try:
845
+ if update_wcs_after_crop is not None:
846
+ new_meta = update_wcs_after_crop(new_meta, M_src_to_dst=M, out_w=w_out, out_h=h_out)
847
+ except Exception:
848
+ pass
849
+
850
+ CropDialogPro._prev_rect = QRectF(self._rect_item.rect())
851
+ CropDialogPro._prev_angle = float(self._rect_item.rotation())
852
+ CropDialogPro._prev_pos = QPointF(self._rect_item.pos())
853
+
854
+ try:
855
+ self.doc.apply_edit(out.copy(), metadata={**new_meta, "step_name": "Crop"}, step_name="Crop")
856
+ self._maybe_notify_wcs_update(new_meta)
857
+ self.crop_applied.emit(out)
858
+ self.accept()
859
+ except Exception as e:
860
+ QMessageBox.critical(self, self.tr("Apply failed"), str(e))
861
+
862
+ def _apply_batch(self):
863
+ if not self._rect_item:
864
+ QMessageBox.warning(self, self.tr("No Selection"), self.tr("Draw & finalize a crop first."))
865
+ return
866
+
867
+ # Normalize the crop polygon to THIS image size
868
+ corners = self._corners_scene()
869
+ src_this = np.array([self._scene_to_img_pixels(p, self._orig_w, self._orig_h) for p in corners], dtype=np.float32)
870
+ norm = src_this / np.array([self._orig_w, self._orig_h], dtype=np.float32)
871
+
872
+ # Collect all open documents from the MDI
873
+ win = self.parent()
874
+ subs = getattr(win, "mdi", None).subWindowList() if hasattr(win, "mdi") else []
875
+ docs = []
876
+ for sw in subs:
877
+ vw = sw.widget()
878
+ d = getattr(vw, "document", None)
879
+ if d is not None:
880
+ docs.append(d)
881
+
882
+ if not docs:
883
+ QMessageBox.information(self, self.tr("No Images"), self.tr("No open images to crop."))
884
+ return
885
+
886
+ ok = QMessageBox.question(
887
+ self, self.tr("Confirm Batch"),
888
+ self.tr("Apply this crop to {0} open image(s)?").format(len(docs)),
889
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
890
+ QMessageBox.StandardButton.No
891
+ )
892
+ if ok != QMessageBox.StandardButton.Yes:
893
+ return
894
+
895
+ last_cropped = None
896
+ for d in docs:
897
+ img = np.asarray(d.image)
898
+ if img.dtype.kind in "ui":
899
+ src01 = img.astype(np.float32) / np.iinfo(d.image.dtype).max
900
+ else:
901
+ src01 = img.astype(np.float32, copy=False)
902
+
903
+ h, w = src01.shape[:2]
904
+ src_pts = norm * np.array([w, h], dtype=np.float32) # (4,2)
905
+
906
+ axis_aligned = self._quad_is_axis_aligned(src_pts)
907
+
908
+ if axis_aligned:
909
+ b = self._int_bounds_from_quad(src_pts, w, h)
910
+ if b is None:
911
+ continue
912
+ x0, x1, y0, y1 = b
913
+ cropped = src01[y0:y1, x0:x1].copy()
914
+ w_out, h_out = (x1 - x0), (y1 - y0)
915
+ M = np.array([[1.0, 0.0, -float(x0)],
916
+ [0.0, 1.0, -float(y0)],
917
+ [0.0, 0.0, 1.0]], dtype=np.float32)
918
+ else:
919
+ w_out = int(round(np.linalg.norm(src_pts[1] - src_pts[0])))
920
+ h_out = int(round(np.linalg.norm(src_pts[3] - src_pts[0])))
921
+ if w_out <= 0 or h_out <= 0:
922
+ continue
923
+ dst = np.array([[0,0],[w_out,0],[w_out,h_out],[0,h_out]], dtype=np.float32)
924
+ M = cv2.getPerspectiveTransform(src_pts.astype(np.float32), dst)
925
+ cropped = cv2.warpPerspective(src01, M, (w_out, h_out), flags=cv2.INTER_LANCZOS4)
926
+
927
+ # WCS update per doc
928
+ meta_this = dict(d.metadata or {})
929
+ try:
930
+ if update_wcs_after_crop is not None:
931
+ meta_this = update_wcs_after_crop(meta_this, M_src_to_dst=M, out_w=w_out, out_h=h_out)
932
+ except Exception:
933
+ pass
934
+
935
+ try:
936
+ d.apply_edit(cropped.copy(), metadata={**meta_this, "step_name":"Crop"}, step_name="Crop")
937
+ last_cropped = cropped
938
+ except Exception:
939
+ pass
940
+
941
+ QMessageBox.information(self, self.tr("Batch Crop"), self.tr("Applied crop to all open images. Any Astrometric Solutions has been updated."))
942
+ if last_cropped is not None:
943
+ self.crop_applied.emit(last_cropped)
944
+ self.accept()
945
+
946
+ def _maybe_notify_wcs_update(self, meta: dict, batch_note: str | None = None):
947
+ dbg = (meta or {}).get("__wcs_debug__")
948
+ if not dbg:
949
+ return
950
+ try:
951
+ before = dbg.get("before", {})
952
+ after = dbg.get("after", {})
953
+ fit = dbg.get("fit", {})
954
+ b_ra, b_dec = before.get("crval_deg", (float("nan"), float("nan")))
955
+ a_ra, a_dec = after.get("crval_deg", (float("nan"), float("nan")))
956
+ rms = fit.get("rms_arcsec", float("nan"))
957
+ p95 = fit.get("p95_arcsec", float("nan"))
958
+ sip = after.get("sip_degree")
959
+ size = after.get("size")
960
+ sip_txt = f"TAN-SIP (deg={sip})" if sip is not None else "TAN"
961
+ size_txt = f"{size[0]}×{size[1]}" if size else "?"
962
+ extra = f"\n{batch_note}" if batch_note else ""
963
+ msg = (
964
+ self.tr("Astrometric solution updated ✔️\n\n") +
965
+ self.tr("Model: {0} Image: {1}\n").format(sip_txt, size_txt) +
966
+ self.tr("CRVAL: ({0:.6f}, {1:.6f}) → ({2:.6f}, {3:.6f})\n").format(b_ra, b_dec, a_ra, a_dec) +
967
+ self.tr("Fit residuals: RMS {0:.3f}\" (p95 {1:.3f}\")").format(rms, p95) +
968
+ f"{extra}"
969
+ )
970
+ QMessageBox.information(self, self.tr("WCS Updated"), msg)
971
+ except Exception:
972
+ # Be quiet if formatting fails
973
+ pass