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