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