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.
- 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/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/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/saspro/__init__.py +20 -0
- setiastro/saspro/__main__.py +809 -0
- setiastro/saspro/_generated/__init__.py +7 -0
- setiastro/saspro/_generated/build_info.py +2 -0
- setiastro/saspro/abe.py +1295 -0
- setiastro/saspro/abe_preset.py +196 -0
- setiastro/saspro/aberration_ai.py +694 -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 +621 -0
- setiastro/saspro/astrobin_exporter.py +1007 -0
- setiastro/saspro/astrospike.py +153 -0
- setiastro/saspro/astrospike_python.py +1839 -0
- setiastro/saspro/autostretch.py +196 -0
- setiastro/saspro/backgroundneutral.py +560 -0
- setiastro/saspro/batch_convert.py +325 -0
- setiastro/saspro/batch_renamer.py +519 -0
- setiastro/saspro/blemish_blaster.py +488 -0
- setiastro/saspro/blink_comparator_pro.py +2926 -0
- setiastro/saspro/bundles.py +61 -0
- setiastro/saspro/bundles_dock.py +114 -0
- setiastro/saspro/cheat_sheet.py +178 -0
- setiastro/saspro/clahe.py +342 -0
- setiastro/saspro/comet_stacking.py +1377 -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 +1397 -0
- setiastro/saspro/convo_preset.py +414 -0
- setiastro/saspro/copyastro.py +187 -0
- setiastro/saspro/cosmicclarity.py +1564 -0
- setiastro/saspro/cosmicclarity_preset.py +407 -0
- setiastro/saspro/crop_dialog_pro.py +956 -0
- setiastro/saspro/crop_preset.py +189 -0
- setiastro/saspro/curve_editor_pro.py +2544 -0
- setiastro/saspro/curves_preset.py +375 -0
- setiastro/saspro/debayer.py +670 -0
- setiastro/saspro/debug_utils.py +29 -0
- setiastro/saspro/dnd_mime.py +35 -0
- setiastro/saspro/doc_manager.py +2641 -0
- setiastro/saspro/exoplanet_detector.py +2166 -0
- setiastro/saspro/file_utils.py +284 -0
- setiastro/saspro/fitsmodifier.py +745 -0
- setiastro/saspro/fix_bom.py +32 -0
- setiastro/saspro/free_torch_memory.py +48 -0
- setiastro/saspro/frequency_separation.py +1343 -0
- setiastro/saspro/function_bundle.py +1594 -0
- setiastro/saspro/generate_translations.py +2378 -0
- setiastro/saspro/ghs_dialog_pro.py +660 -0
- setiastro/saspro/ghs_preset.py +284 -0
- setiastro/saspro/graxpert.py +634 -0
- setiastro/saspro/graxpert_preset.py +287 -0
- setiastro/saspro/gui/__init__.py +0 -0
- setiastro/saspro/gui/main_window.py +8567 -0
- setiastro/saspro/gui/mixins/__init__.py +33 -0
- setiastro/saspro/gui/mixins/dock_mixin.py +263 -0
- setiastro/saspro/gui/mixins/file_mixin.py +443 -0
- setiastro/saspro/gui/mixins/geometry_mixin.py +403 -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 +361 -0
- setiastro/saspro/gui/mixins/theme_mixin.py +367 -0
- setiastro/saspro/gui/mixins/toolbar_mixin.py +1457 -0
- setiastro/saspro/gui/mixins/update_mixin.py +309 -0
- setiastro/saspro/gui/mixins/view_mixin.py +435 -0
- setiastro/saspro/halobgon.py +462 -0
- setiastro/saspro/header_viewer.py +448 -0
- setiastro/saspro/headless_utils.py +88 -0
- setiastro/saspro/histogram.py +753 -0
- setiastro/saspro/history_explorer.py +939 -0
- setiastro/saspro/i18n.py +156 -0
- setiastro/saspro/image_combine.py +414 -0
- setiastro/saspro/image_peeker_pro.py +1601 -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 +244 -0
- setiastro/saspro/isophote.py +1179 -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 +2226 -0
- setiastro/saspro/legacy/numba_utils.py +3659 -0
- setiastro/saspro/legacy/xisf.py +1071 -0
- setiastro/saspro/linear_fit.py +534 -0
- setiastro/saspro/live_stacking.py +1830 -0
- setiastro/saspro/log_bus.py +5 -0
- setiastro/saspro/logging_config.py +460 -0
- setiastro/saspro/luminancerecombine.py +309 -0
- setiastro/saspro/main_helpers.py +201 -0
- setiastro/saspro/mask_creation.py +928 -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 +3826 -0
- setiastro/saspro/mfdeconv_earlystop.py +71 -0
- setiastro/saspro/mfdeconvcudnn.py +3263 -0
- setiastro/saspro/mfdeconvsport.py +2382 -0
- setiastro/saspro/minorbodycatalog.py +567 -0
- setiastro/saspro/morphology.py +382 -0
- setiastro/saspro/multiscale_decomp.py +1290 -0
- setiastro/saspro/nbtorgb_stars.py +531 -0
- setiastro/saspro/numba_utils.py +3044 -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 +1102 -0
- setiastro/saspro/ops/scripts.py +1413 -0
- setiastro/saspro/ops/settings.py +679 -0
- setiastro/saspro/parallel_utils.py +554 -0
- setiastro/saspro/pedestal.py +121 -0
- setiastro/saspro/perfect_palette_picker.py +1070 -0
- setiastro/saspro/pipeline.py +110 -0
- setiastro/saspro/pixelmath.py +1600 -0
- setiastro/saspro/plate_solver.py +2444 -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 +314 -0
- setiastro/saspro/remove_stars.py +1625 -0
- setiastro/saspro/remove_stars_preset.py +404 -0
- setiastro/saspro/resources.py +477 -0
- setiastro/saspro/rgb_combination.py +207 -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 +72 -0
- setiastro/saspro/selective_color.py +1552 -0
- setiastro/saspro/sfcc.py +1430 -0
- setiastro/saspro/shortcuts.py +3043 -0
- setiastro/saspro/signature_insert.py +1099 -0
- setiastro/saspro/stacking_suite.py +18181 -0
- setiastro/saspro/star_alignment.py +7420 -0
- setiastro/saspro/star_alignment_preset.py +329 -0
- setiastro/saspro/star_metrics.py +49 -0
- setiastro/saspro/star_spikes.py +681 -0
- setiastro/saspro/star_stretch.py +470 -0
- setiastro/saspro/stat_stretch.py +506 -0
- setiastro/saspro/status_log_dock.py +78 -0
- setiastro/saspro/subwindow.py +3267 -0
- setiastro/saspro/supernovaasteroidhunter.py +1716 -0
- setiastro/saspro/swap_manager.py +99 -0
- setiastro/saspro/torch_backend.py +89 -0
- setiastro/saspro/torch_rejection.py +434 -0
- setiastro/saspro/translations/de_translations.py +3733 -0
- setiastro/saspro/translations/es_translations.py +3923 -0
- setiastro/saspro/translations/fr_translations.py +3842 -0
- setiastro/saspro/translations/integrate_translations.py +234 -0
- setiastro/saspro/translations/it_translations.py +3662 -0
- setiastro/saspro/translations/ja_translations.py +3585 -0
- setiastro/saspro/translations/pt_translations.py +3853 -0
- setiastro/saspro/translations/saspro_de.qm +0 -0
- setiastro/saspro/translations/saspro_de.ts +253 -0
- setiastro/saspro/translations/saspro_es.qm +0 -0
- setiastro/saspro/translations/saspro_es.ts +12520 -0
- setiastro/saspro/translations/saspro_fr.qm +0 -0
- setiastro/saspro/translations/saspro_fr.ts +12514 -0
- setiastro/saspro/translations/saspro_it.qm +0 -0
- setiastro/saspro/translations/saspro_it.ts +12520 -0
- setiastro/saspro/translations/saspro_ja.qm +0 -0
- setiastro/saspro/translations/saspro_ja.ts +257 -0
- setiastro/saspro/translations/saspro_pt.qm +0 -0
- setiastro/saspro/translations/saspro_pt.ts +257 -0
- setiastro/saspro/translations/saspro_zh.qm +0 -0
- setiastro/saspro/translations/saspro_zh.ts +12520 -0
- setiastro/saspro/translations/zh_translations.py +3659 -0
- setiastro/saspro/versioning.py +71 -0
- setiastro/saspro/view_bundle.py +1555 -0
- setiastro/saspro/wavescale_hdr.py +624 -0
- setiastro/saspro/wavescale_hdr_preset.py +101 -0
- setiastro/saspro/wavescalede.py +658 -0
- setiastro/saspro/wavescalede_preset.py +230 -0
- setiastro/saspro/wcs_update.py +374 -0
- setiastro/saspro/whitebalance.py +456 -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/preview_dialogs.py +280 -0
- setiastro/saspro/widgets/spinboxes.py +275 -0
- setiastro/saspro/widgets/themed_buttons.py +13 -0
- setiastro/saspro/widgets/wavelet_utils.py +299 -0
- setiastro/saspro/window_shelf.py +185 -0
- setiastro/saspro/xisf.py +1123 -0
- setiastrosuitepro-1.6.1.dist-info/METADATA +267 -0
- setiastrosuitepro-1.6.1.dist-info/RECORD +342 -0
- setiastrosuitepro-1.6.1.dist-info/WHEEL +4 -0
- setiastrosuitepro-1.6.1.dist-info/entry_points.txt +6 -0
- setiastrosuitepro-1.6.1.dist-info/licenses/LICENSE +674 -0
- setiastrosuitepro-1.6.1.dist-info/licenses/license.txt +2580 -0
setiastro/saspro/abe.py
ADDED
|
@@ -0,0 +1,1295 @@
|
|
|
1
|
+
# pro/abe.py — SASpro Automatic Background Extraction (ABE)
|
|
2
|
+
# -----------------------------------------------------------------------------
|
|
3
|
+
# This module migrates the SASv2 ABE functionality into SASpro with:
|
|
4
|
+
# • Polynomial background model (degree 1–6)
|
|
5
|
+
# • Optional RBF refinement stage (multiquadric) with smoothing
|
|
6
|
+
# • Smart sample-point generation (borders, corners, quartiles) with
|
|
7
|
+
# gradient-descent-to-dim-spot and bright-region avoidance
|
|
8
|
+
# • User-drawn exclusion polygons directly on the preview (image-space)
|
|
9
|
+
# • Non‑destructive preview, commit with undo, optional background doc
|
|
10
|
+
# • Mono and RGB float workflows (expects [0..1] float domain internally)
|
|
11
|
+
# -----------------------------------------------------------------------------
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
import cv2
|
|
19
|
+
except Exception: # pragma: no cover
|
|
20
|
+
cv2 = None
|
|
21
|
+
|
|
22
|
+
from PyQt6.QtCore import Qt, QSize, QEvent, QPointF, QTimer
|
|
23
|
+
from PyQt6.QtWidgets import (
|
|
24
|
+
QDialog, QVBoxLayout, QHBoxLayout, QFormLayout, QLabel, QSpinBox,
|
|
25
|
+
QCheckBox, QPushButton, QScrollArea, QWidget, QMessageBox, QComboBox,
|
|
26
|
+
QGroupBox, QApplication
|
|
27
|
+
)
|
|
28
|
+
from PyQt6.QtGui import QImage, QPixmap, QPainter, QColor, QPen
|
|
29
|
+
from PyQt6 import sip
|
|
30
|
+
|
|
31
|
+
from scipy.interpolate import Rbf
|
|
32
|
+
|
|
33
|
+
from .doc_manager import ImageDocument
|
|
34
|
+
from setiastro.saspro.legacy.numba_utils import build_poly_terms, evaluate_polynomial
|
|
35
|
+
from .autostretch import autostretch as hard_autostretch
|
|
36
|
+
from setiastro.saspro.widgets.themed_buttons import themed_toolbtn
|
|
37
|
+
|
|
38
|
+
# =============================================================================
|
|
39
|
+
# Headless ABE Core (poly + RBF)
|
|
40
|
+
# =============================================================================
|
|
41
|
+
|
|
42
|
+
def _downsample_area(img: np.ndarray, scale: int) -> np.ndarray:
|
|
43
|
+
if scale <= 1:
|
|
44
|
+
return img
|
|
45
|
+
if cv2 is None:
|
|
46
|
+
return img[::scale, ::scale] if img.ndim == 2 else img[::scale, ::scale, :]
|
|
47
|
+
h, w = img.shape[:2]
|
|
48
|
+
return cv2.resize(img, (max(1, w // scale), max(1, h // scale)), interpolation=cv2.INTER_AREA)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _upscale_bg(bg_small: np.ndarray, out_shape: tuple[int, int]) -> np.ndarray:
|
|
52
|
+
oh, ow = out_shape
|
|
53
|
+
if cv2 is None:
|
|
54
|
+
ys = (np.linspace(0, bg_small.shape[0] - 1, oh)).astype(int)
|
|
55
|
+
xs = (np.linspace(0, bg_small.shape[1] - 1, ow)).astype(int)
|
|
56
|
+
if bg_small.ndim == 2:
|
|
57
|
+
return bg_small[ys][:, xs]
|
|
58
|
+
return np.stack([bg_small[..., c][ys][:, xs] for c in range(bg_small.shape[2])], axis=-1)
|
|
59
|
+
if bg_small.ndim == 2:
|
|
60
|
+
return cv2.resize(bg_small, (ow, oh), interpolation=cv2.INTER_LANCZOS4).astype(np.float32)
|
|
61
|
+
return np.stack(
|
|
62
|
+
[cv2.resize(bg_small[..., c], (ow, oh), interpolation=cv2.INTER_LANCZOS4) for c in range(bg_small.shape[2])],
|
|
63
|
+
axis=-1
|
|
64
|
+
).astype(np.float32)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _fit_poly_on_small(small: np.ndarray, points: np.ndarray, degree: int, patch_size: int = 15) -> np.ndarray:
|
|
68
|
+
H, W = small.shape[:2]
|
|
69
|
+
half = patch_size // 2
|
|
70
|
+
pts = np.asarray(points, dtype=np.int32)
|
|
71
|
+
xs = np.clip(pts[:, 0], 0, W - 1)
|
|
72
|
+
ys = np.clip(pts[:, 1], 0, H - 1)
|
|
73
|
+
|
|
74
|
+
A = build_poly_terms(xs.astype(np.float32), ys.astype(np.float32), degree).astype(np.float32)
|
|
75
|
+
|
|
76
|
+
if small.ndim == 3 and small.shape[2] == 3:
|
|
77
|
+
bg_small = np.zeros_like(small, dtype=np.float32)
|
|
78
|
+
for c in range(3):
|
|
79
|
+
z = []
|
|
80
|
+
for x, y in zip(xs, ys):
|
|
81
|
+
x0, x1 = max(0, x - half), min(W, x + half + 1)
|
|
82
|
+
y0, y1 = max(0, y - half), min(H, y + half + 1)
|
|
83
|
+
z.append(np.median(small[y0:y1, x0:x1, c]))
|
|
84
|
+
z = np.asarray(z, dtype=np.float32)
|
|
85
|
+
coeffs, *_ = np.linalg.lstsq(A, z, rcond=None)
|
|
86
|
+
bg_small[..., c] = evaluate_polynomial(H, W, coeffs.astype(np.float32), degree)
|
|
87
|
+
return bg_small
|
|
88
|
+
else:
|
|
89
|
+
z = []
|
|
90
|
+
for x, y in zip(xs, ys):
|
|
91
|
+
x0, x1 = max(0, x - half), min(W, x + half + 1)
|
|
92
|
+
y0, y1 = max(0, y - half), min(H, y + half + 1)
|
|
93
|
+
z.append(np.median(small[y0:y1, x0:x1]))
|
|
94
|
+
z = np.asarray(z, dtype=np.float32)
|
|
95
|
+
coeffs, *_ = np.linalg.lstsq(A, z, rcond=None)
|
|
96
|
+
return evaluate_polynomial(H, W, coeffs.astype(np.float32), degree)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _divide_into_quartiles(image: np.ndarray):
|
|
100
|
+
h, w = image.shape[:2]
|
|
101
|
+
hh, ww = h // 2, w // 2
|
|
102
|
+
return {
|
|
103
|
+
"top_left": (slice(0, hh), slice(0, ww), (0, 0)),
|
|
104
|
+
"top_right": (slice(0, hh), slice(ww, w), (ww, 0)),
|
|
105
|
+
"bottom_left": (slice(hh, h), slice(0, ww), (0, hh)),
|
|
106
|
+
"bottom_right": (slice(hh, h), slice(ww, w), (ww, hh)),
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _exclude_bright_regions(gray: np.ndarray, exclusion_fraction: float = 0.5) -> np.ndarray:
|
|
111
|
+
flat = gray.ravel()
|
|
112
|
+
thresh = np.percentile(flat, 100 * (1 - exclusion_fraction))
|
|
113
|
+
return (gray < thresh)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _to_luminance(img: np.ndarray) -> np.ndarray:
|
|
117
|
+
if img.ndim == 2:
|
|
118
|
+
return img
|
|
119
|
+
return np.dot(img[..., :3], [0.2989, 0.5870, 0.1140]).astype(np.float32)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _gradient_descent_to_dim_spot(image: np.ndarray, x: int, y: int, max_iter: int = 500, patch_size: int = 15) -> tuple[int, int]:
|
|
123
|
+
half = patch_size // 2
|
|
124
|
+
lum = _to_luminance(image)
|
|
125
|
+
H, W = lum.shape
|
|
126
|
+
|
|
127
|
+
def patch_median(px: int, py: int) -> float:
|
|
128
|
+
x0, x1 = max(0, px - half), min(W, px + half + 1)
|
|
129
|
+
y0, y1 = max(0, py - half), min(H, py + half + 1)
|
|
130
|
+
return float(np.median(lum[y0:y1, x0:x1]))
|
|
131
|
+
|
|
132
|
+
cx, cy = int(np.clip(x, 0, W - 1)), int(np.clip(y, 0, H - 1))
|
|
133
|
+
for _ in range(max_iter):
|
|
134
|
+
cur = patch_median(cx, cy)
|
|
135
|
+
xs = range(max(0, cx - 1), min(W, cx + 2))
|
|
136
|
+
ys = range(max(0, cy - 1), min(H, cy + 2))
|
|
137
|
+
best = (cx, cy); best_val = cur
|
|
138
|
+
for nx in xs:
|
|
139
|
+
for ny in ys:
|
|
140
|
+
if nx == cx and ny == cy:
|
|
141
|
+
continue
|
|
142
|
+
val = patch_median(nx, ny)
|
|
143
|
+
if val < best_val:
|
|
144
|
+
best_val = val; best = (nx, ny)
|
|
145
|
+
if best == (cx, cy):
|
|
146
|
+
break
|
|
147
|
+
cx, cy = best
|
|
148
|
+
return cx, cy
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _generate_sample_points(image: np.ndarray, num_points: int = 100, exclusion_mask: np.ndarray | None = None, patch_size: int = 15) -> np.ndarray:
|
|
152
|
+
H, W = image.shape[:2]
|
|
153
|
+
pts: list[tuple[int, int]] = []
|
|
154
|
+
border = 10
|
|
155
|
+
|
|
156
|
+
def allowed(x: int, y: int) -> bool:
|
|
157
|
+
if exclusion_mask is None:
|
|
158
|
+
return True
|
|
159
|
+
return bool(exclusion_mask[min(max(0, y), H-1), min(max(0, x), W-1)])
|
|
160
|
+
|
|
161
|
+
# corners
|
|
162
|
+
corners = [(border, border), (W - border - 1, border), (border, H - border - 1), (W - border - 1, H - border - 1)]
|
|
163
|
+
for x, y in corners:
|
|
164
|
+
if not allowed(x, y):
|
|
165
|
+
continue
|
|
166
|
+
nx, ny = _gradient_descent_to_dim_spot(image, x, y, patch_size=patch_size)
|
|
167
|
+
if allowed(nx, ny):
|
|
168
|
+
pts.append((nx, ny))
|
|
169
|
+
|
|
170
|
+
# borders
|
|
171
|
+
xs = np.linspace(border, W - border - 1, 5, dtype=int)
|
|
172
|
+
ys = np.linspace(border, H - border - 1, 5, dtype=int)
|
|
173
|
+
for x in xs:
|
|
174
|
+
if allowed(x, border):
|
|
175
|
+
nx, ny = _gradient_descent_to_dim_spot(image, x, border, patch_size=patch_size)
|
|
176
|
+
if allowed(nx, ny):
|
|
177
|
+
pts.append((nx, ny))
|
|
178
|
+
if allowed(x, H - border - 1):
|
|
179
|
+
nx, ny = _gradient_descent_to_dim_spot(image, x, H - border - 1, patch_size=patch_size)
|
|
180
|
+
if allowed(nx, ny):
|
|
181
|
+
pts.append((nx, ny))
|
|
182
|
+
for y in ys:
|
|
183
|
+
if allowed(border, y):
|
|
184
|
+
nx, ny = _gradient_descent_to_dim_spot(image, border, y, patch_size=patch_size)
|
|
185
|
+
if allowed(nx, ny):
|
|
186
|
+
pts.append((nx, ny))
|
|
187
|
+
if allowed(W - border - 1, y):
|
|
188
|
+
nx, ny = _gradient_descent_to_dim_spot(image, W - border - 1, y, patch_size=patch_size)
|
|
189
|
+
if allowed(nx, ny):
|
|
190
|
+
pts.append((nx, ny))
|
|
191
|
+
|
|
192
|
+
# quartiles with bright-region avoidance and descent
|
|
193
|
+
quarts = _divide_into_quartiles(image)
|
|
194
|
+
for _, (yslc, xslc, (x0, y0)) in quarts.items():
|
|
195
|
+
sub = image[yslc, xslc]
|
|
196
|
+
gray = _to_luminance(sub)
|
|
197
|
+
bright_mask = _exclude_bright_regions(gray, exclusion_fraction=0.5)
|
|
198
|
+
if exclusion_mask is not None:
|
|
199
|
+
bright_mask &= exclusion_mask[yslc, xslc]
|
|
200
|
+
elig = np.argwhere(bright_mask)
|
|
201
|
+
if elig.size == 0:
|
|
202
|
+
continue
|
|
203
|
+
k = min(len(elig), max(1, num_points // 4))
|
|
204
|
+
sel = elig[np.random.choice(len(elig), k, replace=False)]
|
|
205
|
+
for (yy, xx) in sel:
|
|
206
|
+
gx, gy = x0 + int(xx), y0 + int(yy)
|
|
207
|
+
nx, ny = _gradient_descent_to_dim_spot(image, gx, gy, patch_size=patch_size)
|
|
208
|
+
if allowed(nx, ny):
|
|
209
|
+
pts.append((nx, ny))
|
|
210
|
+
|
|
211
|
+
if len(pts) == 0:
|
|
212
|
+
# fallback grid
|
|
213
|
+
grid = int(np.sqrt(max(9, num_points)))
|
|
214
|
+
xs = np.linspace(border, W - border - 1, grid, dtype=int)
|
|
215
|
+
ys = np.linspace(border, H - border - 1, grid, dtype=int)
|
|
216
|
+
pts = [(x, y) for y in ys for x in xs if allowed(x, y)]
|
|
217
|
+
return np.array(pts, dtype=np.int32)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _fit_rbf_on_small(small: np.ndarray, points: np.ndarray, smooth: float = 0.1, patch_size: int = 15) -> np.ndarray:
|
|
221
|
+
"""Match SASv2 exactly: float64 for RBF inputs, multiquadric, epsilon=1.0."""
|
|
222
|
+
H, W = small.shape[:2]
|
|
223
|
+
half = patch_size // 2
|
|
224
|
+
pts = np.asarray(points, dtype=np.int32)
|
|
225
|
+
xs = np.clip(pts[:, 0], 0, W - 1).astype(np.int64)
|
|
226
|
+
ys = np.clip(pts[:, 1], 0, H - 1).astype(np.int64)
|
|
227
|
+
|
|
228
|
+
# Evaluate on a float64 meshgrid (same as SASv2)
|
|
229
|
+
grid_x, grid_y = np.meshgrid(
|
|
230
|
+
np.arange(W, dtype=np.float64),
|
|
231
|
+
np.arange(H, dtype=np.float64),
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
def _median_patch(arr, x, y):
|
|
235
|
+
x0, x1 = max(0, x - half), min(W, x + half + 1)
|
|
236
|
+
y0, y1 = max(0, y - half), min(H, y + half + 1)
|
|
237
|
+
return float(np.median(arr[y0:y1, x0:x1]))
|
|
238
|
+
|
|
239
|
+
if small.ndim == 3 and small.shape[2] == 3:
|
|
240
|
+
bg_small = np.zeros((H, W, 3), dtype=np.float32)
|
|
241
|
+
for c in range(3):
|
|
242
|
+
z = np.array([_median_patch(small[..., c], int(x), int(y)) for x, y in zip(xs, ys)], dtype=np.float64)
|
|
243
|
+
rbf = Rbf(xs.astype(np.float64), ys.astype(np.float64), z,
|
|
244
|
+
function='multiquadric', smooth=float(smooth), epsilon=1.0)
|
|
245
|
+
bg_small[..., c] = rbf(grid_x, grid_y).astype(np.float32)
|
|
246
|
+
return bg_small
|
|
247
|
+
else:
|
|
248
|
+
z = np.array([_median_patch(small, int(x), int(y)) for x, y in zip(xs, ys)], dtype=np.float64)
|
|
249
|
+
rbf = Rbf(xs.astype(np.float64), ys.astype(np.float64), z,
|
|
250
|
+
function='multiquadric', smooth=float(smooth), epsilon=1.0)
|
|
251
|
+
return rbf(grid_x, grid_y).astype(np.float32)
|
|
252
|
+
|
|
253
|
+
def _legacy_stretch_unlinked(image: np.ndarray):
|
|
254
|
+
"""
|
|
255
|
+
SASv2 stretch domain used for modeling: per-channel min shift + unlinked rational
|
|
256
|
+
stretch to target median=0.25. Returns (stretched_rgb, state_dict).
|
|
257
|
+
"""
|
|
258
|
+
was_single = False
|
|
259
|
+
img = image
|
|
260
|
+
if img.ndim == 2 or (img.ndim == 3 and img.shape[2] == 1):
|
|
261
|
+
was_single = True
|
|
262
|
+
img = np.stack([img[..., 0] if img.ndim == 3 else img] * 3, axis=-1)
|
|
263
|
+
|
|
264
|
+
img = img.astype(np.float32, copy=True)
|
|
265
|
+
target_median = 0.25
|
|
266
|
+
|
|
267
|
+
ch_mins: list[float] = []
|
|
268
|
+
ch_meds: list[float] = []
|
|
269
|
+
out = img.copy()
|
|
270
|
+
|
|
271
|
+
for c in range(3):
|
|
272
|
+
m0 = float(np.min(out[..., c]))
|
|
273
|
+
ch_mins.append(m0)
|
|
274
|
+
out[..., c] -= m0
|
|
275
|
+
med = float(np.median(out[..., c]))
|
|
276
|
+
ch_meds.append(med)
|
|
277
|
+
if med != 0.0:
|
|
278
|
+
num = (med - 1.0) * target_median * out[..., c]
|
|
279
|
+
den = (med * (target_median + out[..., c] - 1.0) - target_median * out[..., c])
|
|
280
|
+
den = np.where(den == 0.0, 1e-6, den)
|
|
281
|
+
out[..., c] = num / den
|
|
282
|
+
|
|
283
|
+
out = np.clip(out, 0.0, 1.0)
|
|
284
|
+
return out, {"mins": ch_mins, "meds": ch_meds, "was_single": was_single}
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _legacy_unstretch_unlinked(image: np.ndarray, state: dict):
|
|
288
|
+
"""
|
|
289
|
+
Inverse of the SASv2 stretch above. Accepts mono or RGB; returns same ndim
|
|
290
|
+
as input, except if original was single-channel it returns mono.
|
|
291
|
+
"""
|
|
292
|
+
mins = state["mins"]; meds = state["meds"]; was_single = state["was_single"]
|
|
293
|
+
img = image.astype(np.float32, copy=True)
|
|
294
|
+
|
|
295
|
+
# Work as RGB internally
|
|
296
|
+
if img.ndim == 2:
|
|
297
|
+
img = np.stack([img] * 3, axis=-1)
|
|
298
|
+
if img.ndim == 3 and img.shape[2] == 1:
|
|
299
|
+
img = np.repeat(img, 3, axis=2)
|
|
300
|
+
|
|
301
|
+
for c in range(3):
|
|
302
|
+
ch_med = float(np.median(img[..., c]))
|
|
303
|
+
orig_med = float(meds[c])
|
|
304
|
+
if ch_med != 0.0 and orig_med != 0.0:
|
|
305
|
+
num = (ch_med - 1.0) * orig_med * img[..., c]
|
|
306
|
+
den = (ch_med * (orig_med + img[..., c] - 1.0) - orig_med * img[..., c])
|
|
307
|
+
den = np.where(den == 0.0, 1e-6, den)
|
|
308
|
+
img[..., c] = num / den
|
|
309
|
+
img[..., c] += float(mins[c])
|
|
310
|
+
|
|
311
|
+
img = np.clip(img, 0.0, 1.0)
|
|
312
|
+
if was_single:
|
|
313
|
+
# original was mono → return mono
|
|
314
|
+
return img[..., 0]
|
|
315
|
+
return img
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def abe_run(
|
|
319
|
+
image: np.ndarray,
|
|
320
|
+
degree: int = 2, # 0..6 (0 = skip polynomial)
|
|
321
|
+
num_samples: int = 100,
|
|
322
|
+
downsample: int = 4,
|
|
323
|
+
patch_size: int = 15,
|
|
324
|
+
use_rbf: bool = True,
|
|
325
|
+
rbf_smooth: float = 0.1, # numeric; UI can map 10 -> 0.10, 100 -> 1.0, etc.
|
|
326
|
+
exclusion_mask: np.ndarray | None = None,
|
|
327
|
+
return_background: bool = True,
|
|
328
|
+
progress_cb=None,
|
|
329
|
+
legacy_prestretch: bool = True, # <-- SASv2 parity switch
|
|
330
|
+
) -> tuple[np.ndarray, np.ndarray] | np.ndarray:
|
|
331
|
+
"""Two-stage ABE (poly + optional RBF) with SASv2-compatible pre/post stretch."""
|
|
332
|
+
if image is None:
|
|
333
|
+
raise ValueError("ABE: image is None")
|
|
334
|
+
|
|
335
|
+
img_src = np.asarray(image).astype(np.float32, copy=False)
|
|
336
|
+
mono = (img_src.ndim == 2) or (img_src.ndim == 3 and img_src.shape[2] == 1)
|
|
337
|
+
|
|
338
|
+
# Work in RGB internally (even for mono) so pre/post stretch matches SASv2 behavior
|
|
339
|
+
img_rgb = img_src if (img_src.ndim == 3 and img_src.shape[2] == 3) else np.stack(
|
|
340
|
+
[img_src.squeeze()] * 3, axis=-1
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
# --- SASv2 modeling domain (optional) ---------------------------------
|
|
344
|
+
stretch_state = None
|
|
345
|
+
if legacy_prestretch:
|
|
346
|
+
img_rgb, stretch_state = _legacy_stretch_unlinked(img_rgb)
|
|
347
|
+
|
|
348
|
+
# IMPORTANT: compute original median ONCE in the modeling domain
|
|
349
|
+
orig_med = float(np.median(img_rgb))
|
|
350
|
+
|
|
351
|
+
# downsample & mask (for fitting only)
|
|
352
|
+
if progress_cb: progress_cb("Downsampling image…")
|
|
353
|
+
small = _downsample_area(img_rgb, downsample)
|
|
354
|
+
mask_small = None
|
|
355
|
+
if exclusion_mask is not None:
|
|
356
|
+
if progress_cb: progress_cb("Downsampling exclusion mask…")
|
|
357
|
+
mask_small = _downsample_area(exclusion_mask.astype(np.float32), downsample) >= 0.5
|
|
358
|
+
|
|
359
|
+
# ---------- Polynomial stage (skip when degree == 0) ----------
|
|
360
|
+
if degree <= 0:
|
|
361
|
+
if progress_cb: progress_cb("Degree 0: skipping polynomial stage…")
|
|
362
|
+
after_poly = img_rgb.copy() # nothing removed yet
|
|
363
|
+
total_bg = np.zeros_like(img_rgb, dtype=np.float32)
|
|
364
|
+
else:
|
|
365
|
+
if progress_cb: progress_cb("Sampling points (poly stage)…")
|
|
366
|
+
pts = _generate_sample_points(small, num_points=num_samples,
|
|
367
|
+
exclusion_mask=mask_small, patch_size=patch_size)
|
|
368
|
+
|
|
369
|
+
if progress_cb: progress_cb(f"Fitting polynomial (degree {degree})…")
|
|
370
|
+
bg_poly_small = _fit_poly_on_small(small, pts, degree=degree, patch_size=patch_size)
|
|
371
|
+
|
|
372
|
+
if progress_cb: progress_cb("Upscaling polynomial background…")
|
|
373
|
+
bg_poly = _upscale_bg(bg_poly_small, img_rgb.shape[:2])
|
|
374
|
+
|
|
375
|
+
if progress_cb: progress_cb("Subtracting polynomial background & re-centering…")
|
|
376
|
+
after_poly = img_rgb - bg_poly
|
|
377
|
+
med_after = float(np.median(after_poly))
|
|
378
|
+
after_poly = np.clip(after_poly + (orig_med - med_after), 0.0, 1.0)
|
|
379
|
+
|
|
380
|
+
total_bg = bg_poly.astype(np.float32, copy=False)
|
|
381
|
+
|
|
382
|
+
# ---------- RBF refinement --------------------------------------------
|
|
383
|
+
if use_rbf:
|
|
384
|
+
if progress_cb: progress_cb("Downsampling for RBF stage…")
|
|
385
|
+
small_rbf = _downsample_area(after_poly, downsample)
|
|
386
|
+
|
|
387
|
+
if progress_cb: progress_cb("Sampling points (RBF stage)…")
|
|
388
|
+
pts_rbf = _generate_sample_points(small_rbf, num_points=num_samples,
|
|
389
|
+
exclusion_mask=mask_small, patch_size=patch_size)
|
|
390
|
+
|
|
391
|
+
if progress_cb: progress_cb(f"Fitting RBF (smooth={rbf_smooth:.3f})…")
|
|
392
|
+
bg_rbf_small = _fit_rbf_on_small(small_rbf, pts_rbf, smooth=rbf_smooth, patch_size=patch_size)
|
|
393
|
+
|
|
394
|
+
if progress_cb: progress_cb("Upscaling RBF background…")
|
|
395
|
+
bg_rbf = _upscale_bg(bg_rbf_small, img_rgb.shape[:2])
|
|
396
|
+
|
|
397
|
+
if progress_cb: progress_cb("Combining backgrounds & finalizing…")
|
|
398
|
+
total_bg = (total_bg + bg_rbf).astype(np.float32)
|
|
399
|
+
corrected = img_rgb - total_bg
|
|
400
|
+
med2 = float(np.median(corrected))
|
|
401
|
+
corrected = np.clip(corrected + (orig_med - med2), 0.0, 1.0)
|
|
402
|
+
else:
|
|
403
|
+
if progress_cb: progress_cb("Finalizing…")
|
|
404
|
+
corrected = after_poly
|
|
405
|
+
|
|
406
|
+
# --- Undo SASv2 modeling domain if used -------------------------------
|
|
407
|
+
if legacy_prestretch and stretch_state is not None:
|
|
408
|
+
if progress_cb: progress_cb("Unstretching to source domain…")
|
|
409
|
+
corrected = _legacy_unstretch_unlinked(corrected, stretch_state)
|
|
410
|
+
total_bg = _legacy_unstretch_unlinked(total_bg, stretch_state)
|
|
411
|
+
|
|
412
|
+
# Make sure types are float32
|
|
413
|
+
corrected = corrected.astype(np.float32, copy=False)
|
|
414
|
+
total_bg = total_bg.astype(np.float32, copy=False)
|
|
415
|
+
|
|
416
|
+
# If original was mono, squeeze to 2D
|
|
417
|
+
if mono:
|
|
418
|
+
if corrected.ndim == 3:
|
|
419
|
+
corrected = corrected[..., 0]
|
|
420
|
+
if total_bg.ndim == 3:
|
|
421
|
+
total_bg = total_bg[..., 0]
|
|
422
|
+
else:
|
|
423
|
+
# We stayed in RGB all along; if the source was mono, return mono
|
|
424
|
+
if mono:
|
|
425
|
+
corrected = corrected[..., 0]
|
|
426
|
+
total_bg = total_bg[..., 0]
|
|
427
|
+
|
|
428
|
+
if progress_cb: progress_cb("Ready")
|
|
429
|
+
if return_background:
|
|
430
|
+
return corrected.astype(np.float32, copy=False), total_bg.astype(np.float32, copy=False)
|
|
431
|
+
return corrected.astype(np.float32, copy=False)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def siril_style_autostretch(image: np.ndarray, sigma: float = 3.0) -> np.ndarray:
|
|
436
|
+
def stretch_channel(c):
|
|
437
|
+
med = np.median(c); mad = np.median(np.abs(c - med))
|
|
438
|
+
mad_std = mad * 1.4826
|
|
439
|
+
mn, mx = float(c.min()), float(c.max())
|
|
440
|
+
bp = max(mn, med - sigma * mad_std)
|
|
441
|
+
wp = min(mx, med + 0.5*sigma * mad_std)
|
|
442
|
+
if wp - bp <= 1e-8:
|
|
443
|
+
return np.zeros_like(c, dtype=np.float32)
|
|
444
|
+
out = (c - bp) / (wp - bp)
|
|
445
|
+
return np.clip(out, 0.0, 1.0).astype(np.float32)
|
|
446
|
+
|
|
447
|
+
if image.ndim == 2:
|
|
448
|
+
return stretch_channel(image.astype(np.float32, copy=False))
|
|
449
|
+
if image.ndim == 3 and image.shape[2] == 3:
|
|
450
|
+
return np.stack([stretch_channel(image[..., i].astype(np.float32, copy=False))
|
|
451
|
+
for i in range(3)], axis=-1)
|
|
452
|
+
raise ValueError("Unsupported image format for autostretch.")
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
# =============================================================================
|
|
459
|
+
# UI Dialog
|
|
460
|
+
# =============================================================================
|
|
461
|
+
|
|
462
|
+
def _asfloat32(x: np.ndarray) -> np.ndarray:
|
|
463
|
+
a = np.asarray(x) # zero-copy view when possible
|
|
464
|
+
return a if a.dtype == np.float32 else a.astype(np.float32, copy=False)
|
|
465
|
+
|
|
466
|
+
class ABEDialog(QDialog):
|
|
467
|
+
"""
|
|
468
|
+
Non-destructive preview with polygon exclusions and optional RBF stage.
|
|
469
|
+
Apply commits to the document image with undo. Optionally spawns a
|
|
470
|
+
background document containing the extracted gradient.
|
|
471
|
+
"""
|
|
472
|
+
def __init__(self, parent, document: ImageDocument):
|
|
473
|
+
super().__init__(parent)
|
|
474
|
+
self.setWindowTitle(self.tr("Automatic Background Extraction (ABE)"))
|
|
475
|
+
|
|
476
|
+
# IMPORTANT: avoid “attached modal sheet” behavior on some Linux WMs
|
|
477
|
+
self.setWindowFlag(Qt.WindowType.Window, True)
|
|
478
|
+
# keep it blocking if you want, but as a top-level window
|
|
479
|
+
self.setWindowModality(Qt.WindowModality.ApplicationModal)
|
|
480
|
+
self.setModal(False)
|
|
481
|
+
#self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
|
|
482
|
+
|
|
483
|
+
self.doc = document
|
|
484
|
+
|
|
485
|
+
self._preview_scale = 1.0
|
|
486
|
+
self._preview_qimg = None
|
|
487
|
+
self._last_preview = None # backing ndarray for QImage lifetime
|
|
488
|
+
self._overlay = None
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
# image-space polygons: list[list[QPointF]] in ORIGINAL IMAGE COORDS
|
|
492
|
+
self._polygons: list[list[QPointF]] = []
|
|
493
|
+
self._drawing_poly: list[QPointF] | None = None
|
|
494
|
+
self._panning = False
|
|
495
|
+
self._pan_last = None
|
|
496
|
+
self._preview_source_f01 = None
|
|
497
|
+
|
|
498
|
+
# ---------------- Controls ----------------
|
|
499
|
+
self.sp_degree = QSpinBox(); self.sp_degree.setRange(0, 6); self.sp_degree.setValue(2)
|
|
500
|
+
self.sp_samples = QSpinBox(); self.sp_samples.setRange(20, 10000); self.sp_samples.setSingleStep(20); self.sp_samples.setValue(120)
|
|
501
|
+
self.sp_down = QSpinBox(); self.sp_down.setRange(1, 32); self.sp_down.setValue(4)
|
|
502
|
+
self.sp_patch = QSpinBox(); self.sp_patch.setRange(5, 151); self.sp_patch.setSingleStep(2); self.sp_patch.setValue(15)
|
|
503
|
+
self.chk_use_rbf = QCheckBox(self.tr("Enable RBF refinement (after polynomial)")); self.chk_use_rbf.setChecked(True)
|
|
504
|
+
self.sp_rbf = QSpinBox(); self.sp_rbf.setRange(0, 1000); self.sp_rbf.setValue(100) # shown as ×0.01 below
|
|
505
|
+
self.chk_make_bg_doc = QCheckBox(self.tr("Create background document")); self.chk_make_bg_doc.setChecked(False)
|
|
506
|
+
self.chk_preview_bg = QCheckBox(self.tr("Preview background instead of corrected")); self.chk_preview_bg.setChecked(False)
|
|
507
|
+
|
|
508
|
+
# Preview area
|
|
509
|
+
self.preview_label = QLabel(alignment=Qt.AlignmentFlag.AlignCenter)
|
|
510
|
+
self.preview_label.setMinimumSize(QSize(480, 360))
|
|
511
|
+
self.preview_label.setScaledContents(False)
|
|
512
|
+
self.preview_scroll = QScrollArea()
|
|
513
|
+
self.preview_scroll.setWidgetResizable(False)
|
|
514
|
+
self.preview_scroll.setWidget(self.preview_label)
|
|
515
|
+
self.preview_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
|
516
|
+
self.preview_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
|
517
|
+
|
|
518
|
+
# Buttons
|
|
519
|
+
self.btn_preview = QPushButton(self.tr("Preview"))
|
|
520
|
+
self.btn_apply = QPushButton(self.tr("Apply"))
|
|
521
|
+
self.btn_close = QPushButton(self.tr("Close"))
|
|
522
|
+
self.btn_clear = QPushButton(self.tr("Clear Exclusions"))
|
|
523
|
+
self.btn_preview.clicked.connect(self._do_preview)
|
|
524
|
+
self.btn_apply.clicked.connect(self._do_apply)
|
|
525
|
+
self.btn_close.clicked.connect(self.close)
|
|
526
|
+
self.btn_clear.clicked.connect(self._clear_polys)
|
|
527
|
+
|
|
528
|
+
# Layout
|
|
529
|
+
params = QFormLayout()
|
|
530
|
+
params.addRow(self.tr("Polynomial degree:"), self.sp_degree)
|
|
531
|
+
params.addRow(self.tr("# sample points:"), self.sp_samples)
|
|
532
|
+
params.addRow(self.tr("Downsample factor:"), self.sp_down)
|
|
533
|
+
params.addRow(self.tr("Patch size (px):"), self.sp_patch)
|
|
534
|
+
|
|
535
|
+
rbf_box = QGroupBox(self.tr("RBF Refinement"))
|
|
536
|
+
rbf_form = QFormLayout()
|
|
537
|
+
rbf_form.addRow(self.chk_use_rbf)
|
|
538
|
+
rbf_form.addRow(self.tr("Smooth (x0.01):"), self.sp_rbf)
|
|
539
|
+
rbf_box.setLayout(rbf_form)
|
|
540
|
+
|
|
541
|
+
opts = QVBoxLayout()
|
|
542
|
+
opts.addLayout(params)
|
|
543
|
+
opts.addWidget(rbf_box)
|
|
544
|
+
opts.addWidget(self.chk_make_bg_doc)
|
|
545
|
+
opts.addWidget(self.chk_preview_bg)
|
|
546
|
+
row = QHBoxLayout(); row.addWidget(self.btn_preview); row.addWidget(self.btn_apply); row.addStretch(1)
|
|
547
|
+
opts.addLayout(row)
|
|
548
|
+
opts.addWidget(self.btn_clear)
|
|
549
|
+
opts.addStretch(1)
|
|
550
|
+
|
|
551
|
+
# ▼ New status label
|
|
552
|
+
self.status_label = QLabel("Ready")
|
|
553
|
+
self.status_label.setWordWrap(True)
|
|
554
|
+
opts.addWidget(self.status_label)
|
|
555
|
+
|
|
556
|
+
opts.addStretch(1)
|
|
557
|
+
|
|
558
|
+
# ⬇️ New right-side stack: toolbar row ABOVE the preview
|
|
559
|
+
right = QVBoxLayout()
|
|
560
|
+
right.addLayout(self._build_toolbar()) # Zoom In / Out / Fit / Autostretch
|
|
561
|
+
right.addWidget(self.preview_scroll, 1) # Preview below the buttons
|
|
562
|
+
|
|
563
|
+
main = QHBoxLayout(self)
|
|
564
|
+
main.addLayout(opts, 0) # Left controls
|
|
565
|
+
main.addLayout(right, 1) # Right: buttons above preview
|
|
566
|
+
|
|
567
|
+
self._base_pixmap = None # clean, scaled image with no overlays
|
|
568
|
+
self.preview_scroll.viewport().installEventFilter(self)
|
|
569
|
+
self.preview_label.installEventFilter(self)
|
|
570
|
+
self._install_zoom_filters()
|
|
571
|
+
self._populate_initial_preview()
|
|
572
|
+
self.sp_degree.valueChanged.connect(self._degree_changed)
|
|
573
|
+
|
|
574
|
+
QTimer.singleShot(0, self._post_init_fit_and_stretch)
|
|
575
|
+
|
|
576
|
+
def _post_init_fit_and_stretch(self) -> None:
|
|
577
|
+
# Only run if we have an image preview
|
|
578
|
+
if self._preview_qimg is None:
|
|
579
|
+
return
|
|
580
|
+
# Fit to the viewport
|
|
581
|
+
self.fit_to_preview()
|
|
582
|
+
# Turn autostretch ON if it's not already
|
|
583
|
+
if not getattr(self, "_autostretch_on", False):
|
|
584
|
+
self.autostretch_preview()
|
|
585
|
+
|
|
586
|
+
def _set_status(self, text: str) -> None:
|
|
587
|
+
self.status_label.setText(text)
|
|
588
|
+
QApplication.processEvents()
|
|
589
|
+
|
|
590
|
+
def _build_toolbar(self):
|
|
591
|
+
"""
|
|
592
|
+
Returns a QHBoxLayout with: Zoom In, Zoom Out, Fit, Autostretch.
|
|
593
|
+
Call: opts.addLayout(self._build_toolbar()) in __init__.
|
|
594
|
+
"""
|
|
595
|
+
bar = QHBoxLayout()
|
|
596
|
+
|
|
597
|
+
# QToolButtons with theme icons
|
|
598
|
+
self.btn_zoom_in = themed_toolbtn("zoom-in", "Zoom In")
|
|
599
|
+
self.btn_zoom_out = themed_toolbtn("zoom-out", "Zoom Out")
|
|
600
|
+
self.btn_fit = themed_toolbtn("zoom-fit-best", "Fit to Preview")
|
|
601
|
+
self.btn_autostr = themed_toolbtn("color-picker", "Autostretch") # pick your preferred icon
|
|
602
|
+
|
|
603
|
+
self.btn_zoom_in.clicked.connect(self.zoom_in)
|
|
604
|
+
self.btn_zoom_out.clicked.connect(self.zoom_out)
|
|
605
|
+
self.btn_fit.clicked.connect(self.fit_to_preview)
|
|
606
|
+
self.btn_autostr.clicked.connect(self.autostretch_preview)
|
|
607
|
+
|
|
608
|
+
bar.addWidget(self.btn_zoom_in)
|
|
609
|
+
bar.addWidget(self.btn_zoom_out)
|
|
610
|
+
bar.addWidget(self.btn_fit)
|
|
611
|
+
bar.addStretch(1)
|
|
612
|
+
bar.addWidget(self.btn_autostr)
|
|
613
|
+
return bar
|
|
614
|
+
|
|
615
|
+
# ----- data helpers -----
|
|
616
|
+
def _get_source_float(self) -> np.ndarray | None:
|
|
617
|
+
src = np.asarray(self.doc.image)
|
|
618
|
+
if src is None or src.size == 0:
|
|
619
|
+
return None
|
|
620
|
+
if np.issubdtype(src.dtype, np.integer):
|
|
621
|
+
scale = float(np.iinfo(src.dtype).max)
|
|
622
|
+
return (src.astype(np.float32) / scale).clip(0.0, 1.0)
|
|
623
|
+
# float path: do NOT normalize; just clip to [0,1] like Crop does upstream
|
|
624
|
+
return np.clip(src.astype(np.float32, copy=False), 0.0, 1.0)
|
|
625
|
+
|
|
626
|
+
# ----- preview/applier -----
|
|
627
|
+
def _run_abe(self, excl_mask: np.ndarray | None, progress=None):
|
|
628
|
+
imgf = self._get_source_float()
|
|
629
|
+
if imgf is None:
|
|
630
|
+
return None, None
|
|
631
|
+
deg = int(self.sp_degree.value())
|
|
632
|
+
npts = int(self.sp_samples.value())
|
|
633
|
+
dwn = int(self.sp_down.value())
|
|
634
|
+
patch = int(self.sp_patch.value())
|
|
635
|
+
use_rbf = bool(self.chk_use_rbf.isChecked())
|
|
636
|
+
rbf_smooth = float(self.sp_rbf.value()) * 0.01
|
|
637
|
+
|
|
638
|
+
return abe_run(
|
|
639
|
+
imgf,
|
|
640
|
+
degree=deg, num_samples=npts, downsample=dwn, patch_size=patch,
|
|
641
|
+
use_rbf=use_rbf, rbf_smooth=rbf_smooth,
|
|
642
|
+
exclusion_mask=excl_mask, return_background=True,
|
|
643
|
+
progress_cb=progress # ◀️ forward progress
|
|
644
|
+
)
|
|
645
|
+
|
|
646
|
+
def _degree_changed(self, v: int):
|
|
647
|
+
# Make it clear what 0 means, and default RBF on (can still be unchecked)
|
|
648
|
+
if v == 0:
|
|
649
|
+
self.chk_use_rbf.setChecked(True)
|
|
650
|
+
if hasattr(self, "_set_status"):
|
|
651
|
+
self._set_status("Polynomial disabled (degree 0) → RBF-only.")
|
|
652
|
+
else:
|
|
653
|
+
if hasattr(self, "_set_status"):
|
|
654
|
+
self._set_status("Ready")
|
|
655
|
+
|
|
656
|
+
def _populate_initial_preview(self):
|
|
657
|
+
src = self._get_source_float()
|
|
658
|
+
if src is not None:
|
|
659
|
+
self._set_preview_pixmap(np.clip(src, 0, 1))
|
|
660
|
+
|
|
661
|
+
def _do_preview(self):
|
|
662
|
+
try:
|
|
663
|
+
self._set_status("Building exclusion mask…")
|
|
664
|
+
excl = self._build_exclusion_mask()
|
|
665
|
+
|
|
666
|
+
self._set_status("Running ABE preview…")
|
|
667
|
+
corrected, bg = self._run_abe(excl, progress=self._set_status)
|
|
668
|
+
if corrected is None:
|
|
669
|
+
QMessageBox.information(self, "No image", "No image is loaded in the active document.")
|
|
670
|
+
self._set_status("Ready")
|
|
671
|
+
return
|
|
672
|
+
|
|
673
|
+
show = bg if self.chk_preview_bg.isChecked() else corrected
|
|
674
|
+
|
|
675
|
+
# ✅ If previewing the corrected image, honor the active mask
|
|
676
|
+
if not self.chk_preview_bg.isChecked():
|
|
677
|
+
srcf = self._get_source_float()
|
|
678
|
+
show = self._blend_with_mask_float(show, srcf)
|
|
679
|
+
|
|
680
|
+
self._set_status("Rendering preview…")
|
|
681
|
+
self._set_preview_pixmap(show)
|
|
682
|
+
self._set_status("Ready")
|
|
683
|
+
except Exception as e:
|
|
684
|
+
self._set_status("Error")
|
|
685
|
+
QMessageBox.warning(self, "Preview failed", str(e))
|
|
686
|
+
|
|
687
|
+
def _do_apply(self):
|
|
688
|
+
try:
|
|
689
|
+
self._set_status("Building exclusion mask…")
|
|
690
|
+
excl = self._build_exclusion_mask()
|
|
691
|
+
|
|
692
|
+
self._set_status("Running ABE (apply)…")
|
|
693
|
+
corrected, bg = self._run_abe(excl, progress=self._set_status)
|
|
694
|
+
if corrected is None:
|
|
695
|
+
QMessageBox.information(self, "No image", "No image is loaded in the active document.")
|
|
696
|
+
self._set_status("Ready")
|
|
697
|
+
return
|
|
698
|
+
|
|
699
|
+
# Preserve mono vs color shape w.r.t. source
|
|
700
|
+
out = corrected
|
|
701
|
+
if out.ndim == 3 and out.shape[2] == 3 and (self.doc.image.ndim == 2 or (self.doc.image.ndim == 3 and self.doc.image.shape[2] == 1)):
|
|
702
|
+
out = out[..., 0]
|
|
703
|
+
|
|
704
|
+
# ✅ Blend with active mask before committing
|
|
705
|
+
srcf = self._get_source_float()
|
|
706
|
+
out_masked = self._blend_with_mask_float(out, srcf)
|
|
707
|
+
|
|
708
|
+
# Build step name for undo stack
|
|
709
|
+
# Build step name + params for undo stack + Replay
|
|
710
|
+
deg = int(self.sp_degree.value())
|
|
711
|
+
npts = int(self.sp_samples.value())
|
|
712
|
+
dwn = int(self.sp_down.value())
|
|
713
|
+
patch = int(self.sp_patch.value())
|
|
714
|
+
use_rbf = bool(self.chk_use_rbf.isChecked())
|
|
715
|
+
rbf_smooth = float(self.sp_rbf.value()) * 0.01
|
|
716
|
+
make_bg_doc = bool(self.chk_make_bg_doc.isChecked())
|
|
717
|
+
|
|
718
|
+
step_name = (
|
|
719
|
+
f"ABE (deg={deg}, samples={npts}, ds={dwn}, patch={patch}, "
|
|
720
|
+
f"rbf={'on' if use_rbf else 'off'}, s={rbf_smooth:.3f})"
|
|
721
|
+
)
|
|
722
|
+
|
|
723
|
+
# Normalized preset params (same schema as abe_preset.apply_abe_via_preset)
|
|
724
|
+
params = {
|
|
725
|
+
"degree": deg,
|
|
726
|
+
"samples": npts,
|
|
727
|
+
"downsample": dwn,
|
|
728
|
+
"patch": patch,
|
|
729
|
+
"rbf": use_rbf,
|
|
730
|
+
"rbf_smooth": rbf_smooth,
|
|
731
|
+
"make_background_doc": make_bg_doc,
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
# 🔁 Remember this as the last headless-style command for Replay
|
|
735
|
+
mw = self.parent()
|
|
736
|
+
try:
|
|
737
|
+
remember = getattr(mw, "remember_last_headless_command", None)
|
|
738
|
+
if remember is None:
|
|
739
|
+
remember = getattr(mw, "_remember_last_headless_command", None)
|
|
740
|
+
if callable(remember):
|
|
741
|
+
remember("abe", params, description="Automatic Background Extraction")
|
|
742
|
+
try:
|
|
743
|
+
if hasattr(mw, "_log"):
|
|
744
|
+
mw._log(
|
|
745
|
+
f"[Replay] ABE UI apply stored: "
|
|
746
|
+
f"command_id='abe', preset_keys={list(params.keys())}"
|
|
747
|
+
)
|
|
748
|
+
except Exception:
|
|
749
|
+
pass
|
|
750
|
+
except Exception:
|
|
751
|
+
# don’t block the actual ABE apply if remembering fails
|
|
752
|
+
pass
|
|
753
|
+
|
|
754
|
+
# ✅ mask bookkeeping in metadata
|
|
755
|
+
_marr, mid, mname = self._active_mask_layer()
|
|
756
|
+
abe_meta = dict(params)
|
|
757
|
+
abe_meta["exclusion"] = "polygons" if excl is not None else "none"
|
|
758
|
+
|
|
759
|
+
meta = {
|
|
760
|
+
"step_name": "ABE",
|
|
761
|
+
"abe": abe_meta,
|
|
762
|
+
"masked": bool(mid),
|
|
763
|
+
"mask_id": mid,
|
|
764
|
+
"mask_name": mname,
|
|
765
|
+
"mask_blend": "m*out + (1-m)*src",
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
self._set_status("Committing edit…")
|
|
769
|
+
self.doc.apply_edit(
|
|
770
|
+
out_masked.astype(np.float32, copy=False),
|
|
771
|
+
step_name=step_name,
|
|
772
|
+
metadata=meta,
|
|
773
|
+
)
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
if self.chk_make_bg_doc.isChecked() and bg is not None:
|
|
777
|
+
self._set_status("Creating background document…")
|
|
778
|
+
mw = self.parent()
|
|
779
|
+
dm = getattr(mw, "docman", None)
|
|
780
|
+
if dm is not None:
|
|
781
|
+
base = os.path.splitext(self.doc.display_name())[0]
|
|
782
|
+
meta = {
|
|
783
|
+
"bit_depth": "32-bit floating point",
|
|
784
|
+
"is_mono": (bg.ndim == 2),
|
|
785
|
+
"source": "ABE background",
|
|
786
|
+
"original_header": self.doc.metadata.get("original_header"),
|
|
787
|
+
}
|
|
788
|
+
doc_bg = dm.open_array(bg.astype(np.float32, copy=False), metadata=meta, title=f"{base}_ABE_BG")
|
|
789
|
+
if hasattr(mw, "_spawn_subwindow_for"):
|
|
790
|
+
mw._spawn_subwindow_for(doc_bg)
|
|
791
|
+
|
|
792
|
+
# Preserve the current view's autostretch state: capture before/restore after
|
|
793
|
+
mw = self.parent()
|
|
794
|
+
prev_autostretch = False
|
|
795
|
+
view = None
|
|
796
|
+
try:
|
|
797
|
+
if hasattr(mw, "mdi") and mw.mdi.activeSubWindow():
|
|
798
|
+
view = mw.mdi.activeSubWindow().widget()
|
|
799
|
+
prev_autostretch = bool(getattr(view, "autostretch_enabled", False))
|
|
800
|
+
except Exception:
|
|
801
|
+
prev_autostretch = False
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
if hasattr(mw, "_log"):
|
|
805
|
+
mw._log(step_name)
|
|
806
|
+
|
|
807
|
+
# Restore autostretch state on the view (recompute display) so the
|
|
808
|
+
# user's display-stretch choice survives the edit.
|
|
809
|
+
try:
|
|
810
|
+
if view is not None and hasattr(view, "set_autostretch") and callable(view.set_autostretch):
|
|
811
|
+
view.set_autostretch(prev_autostretch)
|
|
812
|
+
except Exception:
|
|
813
|
+
pass
|
|
814
|
+
|
|
815
|
+
self._set_status("Done")
|
|
816
|
+
self.accept()
|
|
817
|
+
|
|
818
|
+
except Exception as e:
|
|
819
|
+
self._set_status("Error")
|
|
820
|
+
QMessageBox.critical(self, "Apply failed", str(e))
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
# ----- exclusion polygons & mask -----
|
|
824
|
+
def _clear_polys(self):
|
|
825
|
+
self._polygons.clear()
|
|
826
|
+
self._drawing_poly = None
|
|
827
|
+
# ✅ redraw from the clean base
|
|
828
|
+
self._redraw_overlay()
|
|
829
|
+
|
|
830
|
+
def _image_shape(self) -> tuple[int, int]:
|
|
831
|
+
src = np.asarray(self.doc.image)
|
|
832
|
+
if src.ndim == 2:
|
|
833
|
+
return src.shape[0], src.shape[1]
|
|
834
|
+
return src.shape[0], src.shape[1]
|
|
835
|
+
|
|
836
|
+
def _build_exclusion_mask(self) -> np.ndarray | None:
|
|
837
|
+
if not self._polygons:
|
|
838
|
+
return None
|
|
839
|
+
H, W = self._image_shape()
|
|
840
|
+
mask = np.ones((H, W), dtype=np.uint8)
|
|
841
|
+
if cv2 is None:
|
|
842
|
+
# very slow pure-numpy fallback: fill polygon by bounding-box rasterization
|
|
843
|
+
# (expect OpenCV to be available in SASpro)
|
|
844
|
+
for poly in self._polygons:
|
|
845
|
+
pts = np.array([[int(p.x()), int(p.y())] for p in poly], dtype=np.int32)
|
|
846
|
+
minx, maxx = np.clip([pts[:,0].min(), pts[:,0].max()], 0, W-1)
|
|
847
|
+
miny, maxy = np.clip([pts[:,1].min(), pts[:,1].max()], 0, H-1)
|
|
848
|
+
for y in range(miny, maxy+1):
|
|
849
|
+
for x in range(minx, maxx+1):
|
|
850
|
+
# winding test approx omitted -> treat as box (coarse)
|
|
851
|
+
mask[y, x] = 0
|
|
852
|
+
else:
|
|
853
|
+
polys = [np.array([[int(p.x()), int(p.y())] for p in poly], dtype=np.int32) for poly in self._polygons]
|
|
854
|
+
cv2.fillPoly(mask, polys, 0) # 0 = excluded
|
|
855
|
+
return mask.astype(bool)
|
|
856
|
+
|
|
857
|
+
# ----- preview rendering helpers -----
|
|
858
|
+
|
|
859
|
+
def _set_preview_pixmap(self, arr: np.ndarray):
|
|
860
|
+
if arr is None or arr.size == 0:
|
|
861
|
+
self.preview_label.clear(); self._overlay = None; self._preview_source_f01 = None
|
|
862
|
+
return
|
|
863
|
+
|
|
864
|
+
# keep the float source for autostretch toggling (no re-normalization)
|
|
865
|
+
a = _asfloat32(arr)
|
|
866
|
+
self._preview_source_f01 = a # ← no np.clip here
|
|
867
|
+
|
|
868
|
+
# show autostretched or raw; siril_style_autostretch() already clips its result
|
|
869
|
+
src_to_show = (hard_autostretch(self._preview_source_f01, target_median=0.5, sigma=2,
|
|
870
|
+
linked=False, use_16bit=True)
|
|
871
|
+
if getattr(self, "_autostretch_on", False) else self._preview_source_f01)
|
|
872
|
+
|
|
873
|
+
if src_to_show.ndim == 2 or (src_to_show.ndim == 3 and src_to_show.shape[2] == 1):
|
|
874
|
+
# MONO path — match Crop: use Grayscale8 QImage; keep 3-ch backing for rebuild
|
|
875
|
+
mono = src_to_show if src_to_show.ndim == 2 else src_to_show[..., 0]
|
|
876
|
+
buf8_mono = (mono * 255.0).astype(np.uint8) # ← no np.clip here
|
|
877
|
+
buf8_mono = np.ascontiguousarray(buf8_mono)
|
|
878
|
+
h, w = buf8_mono.shape
|
|
879
|
+
|
|
880
|
+
# for the toggle/rebuild code which expects 3-ch bytes
|
|
881
|
+
self._last_preview = np.ascontiguousarray(np.stack([buf8_mono]*3, axis=-1))
|
|
882
|
+
|
|
883
|
+
qimg = QImage(buf8_mono.data, w, h, w, QImage.Format.Format_Grayscale8)
|
|
884
|
+
else:
|
|
885
|
+
# RGB path
|
|
886
|
+
buf8 = (src_to_show * 255.0).astype(np.uint8) # ← no np.clip here
|
|
887
|
+
buf8 = np.ascontiguousarray(buf8)
|
|
888
|
+
h, w, _ = buf8.shape
|
|
889
|
+
self._last_preview = buf8
|
|
890
|
+
qimg = QImage(buf8.data, w, h, buf8.strides[0], QImage.Format.Format_RGB888)
|
|
891
|
+
|
|
892
|
+
self._preview_qimg = qimg
|
|
893
|
+
self._update_preview_scaled()
|
|
894
|
+
self._redraw_overlay()
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
def _update_preview_scaled(self):
|
|
898
|
+
if self._preview_qimg is None:
|
|
899
|
+
self.preview_label.clear()
|
|
900
|
+
return
|
|
901
|
+
|
|
902
|
+
sw = max(1, int(self._preview_qimg.width() * self._preview_scale))
|
|
903
|
+
sh = max(1, int(self._preview_qimg.height() * self._preview_scale))
|
|
904
|
+
|
|
905
|
+
scaled = self._preview_qimg.scaled(
|
|
906
|
+
sw, sh,
|
|
907
|
+
Qt.AspectRatioMode.KeepAspectRatio,
|
|
908
|
+
Qt.TransformationMode.SmoothTransformation
|
|
909
|
+
)
|
|
910
|
+
|
|
911
|
+
# ✅ store a clean base without overlays
|
|
912
|
+
self._base_pixmap = QPixmap.fromImage(scaled)
|
|
913
|
+
self.preview_label.setPixmap(self._base_pixmap)
|
|
914
|
+
self.preview_label.resize(self._base_pixmap.size())
|
|
915
|
+
|
|
916
|
+
def _redraw_overlay(self):
|
|
917
|
+
pm_base = self._base_pixmap or self.preview_label.pixmap()
|
|
918
|
+
if pm_base is None:
|
|
919
|
+
return
|
|
920
|
+
|
|
921
|
+
# start from a fresh copy of the clean base
|
|
922
|
+
composed = QPixmap(pm_base)
|
|
923
|
+
overlay = QPixmap(pm_base.size())
|
|
924
|
+
overlay.fill(Qt.GlobalColor.transparent)
|
|
925
|
+
|
|
926
|
+
painter = QPainter(overlay)
|
|
927
|
+
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
|
928
|
+
|
|
929
|
+
# map image-space polys to label-space
|
|
930
|
+
img_w = self._preview_qimg.width() if self._preview_qimg else 1
|
|
931
|
+
img_h = self._preview_qimg.height() if self._preview_qimg else 1
|
|
932
|
+
lab_w = self.preview_label.width()
|
|
933
|
+
lab_h = self.preview_label.height()
|
|
934
|
+
sx = lab_w / img_w
|
|
935
|
+
sy = lab_h / img_h
|
|
936
|
+
|
|
937
|
+
# finalized polygons (green, semi-transparent)
|
|
938
|
+
pen = QPen(QColor(0, 255, 0), 2)
|
|
939
|
+
brush = QColor(0, 255, 0, 60)
|
|
940
|
+
painter.setPen(pen)
|
|
941
|
+
painter.setBrush(brush)
|
|
942
|
+
for poly in self._polygons:
|
|
943
|
+
if len(poly) >= 3:
|
|
944
|
+
mapped = [QPointF(p.x() * sx, p.y() * sy) for p in poly]
|
|
945
|
+
painter.drawPolygon(*mapped)
|
|
946
|
+
|
|
947
|
+
# in-progress poly (red dashed)
|
|
948
|
+
if self._drawing_poly and len(self._drawing_poly) >= 2:
|
|
949
|
+
pen2 = QPen(QColor(255, 0, 0), 2, Qt.PenStyle.DashLine)
|
|
950
|
+
painter.setPen(pen2)
|
|
951
|
+
painter.setBrush(Qt.BrushStyle.NoBrush)
|
|
952
|
+
mapped = [QPointF(p.x() * sx, p.y() * sy) for p in self._drawing_poly]
|
|
953
|
+
painter.drawPolyline(*mapped)
|
|
954
|
+
|
|
955
|
+
painter.end()
|
|
956
|
+
|
|
957
|
+
p = QPainter(composed)
|
|
958
|
+
p.drawPixmap(0, 0, overlay)
|
|
959
|
+
p.end()
|
|
960
|
+
|
|
961
|
+
self.preview_label.setPixmap(composed)
|
|
962
|
+
|
|
963
|
+
# ----- zoom/pan + polygon drawing -----
|
|
964
|
+
def eventFilter(self, obj, ev):
|
|
965
|
+
# ---- Robust Ctrl+Wheel zoom handling (Qt6-friendly) ----
|
|
966
|
+
if ev.type() == QEvent.Type.Wheel and (
|
|
967
|
+
obj is self.preview_label
|
|
968
|
+
or obj is self.preview_scroll
|
|
969
|
+
or obj is self.preview_scroll.viewport()
|
|
970
|
+
or obj is self.preview_scroll.horizontalScrollBar()
|
|
971
|
+
or obj is self.preview_scroll.verticalScrollBar()
|
|
972
|
+
):
|
|
973
|
+
# always stop the wheel from scrolling
|
|
974
|
+
ev.accept()
|
|
975
|
+
|
|
976
|
+
# Zoom only when Ctrl is held
|
|
977
|
+
if ev.modifiers() & Qt.KeyboardModifier.ControlModifier:
|
|
978
|
+
factor = 1.25 if ev.angleDelta().y() > 0 else 0.8
|
|
979
|
+
|
|
980
|
+
# Anchor at the mouse position in the viewport (even if event came from a scrollbar)
|
|
981
|
+
vp = self.preview_scroll.viewport()
|
|
982
|
+
anchor_vp = vp.mapFromGlobal(ev.globalPosition().toPoint())
|
|
983
|
+
|
|
984
|
+
# Clamp to viewport rect (robust if the event originated on scrollbars)
|
|
985
|
+
r = vp.rect()
|
|
986
|
+
if not r.contains(anchor_vp):
|
|
987
|
+
anchor_vp.setX(max(r.left(), min(r.right(), anchor_vp.x())))
|
|
988
|
+
anchor_vp.setY(max(r.top(), min(r.bottom(), anchor_vp.y())))
|
|
989
|
+
|
|
990
|
+
self._zoom_at(factor, anchor_vp)
|
|
991
|
+
return True
|
|
992
|
+
|
|
993
|
+
# ---- Existing polygon drawing on the label ----
|
|
994
|
+
if obj is self.preview_label:
|
|
995
|
+
if ev.type() == QEvent.Type.MouseButtonPress:
|
|
996
|
+
if ev.buttons() & Qt.MouseButton.RightButton:
|
|
997
|
+
if self._drawing_poly and len(self._drawing_poly) >= 3:
|
|
998
|
+
self._polygons.append(self._drawing_poly)
|
|
999
|
+
self._drawing_poly = None
|
|
1000
|
+
self._redraw_overlay()
|
|
1001
|
+
return True
|
|
1002
|
+
if ev.buttons() & Qt.MouseButton.MiddleButton or (ev.buttons() & Qt.MouseButton.LeftButton and (ev.modifiers() & Qt.KeyboardModifier.ControlModifier)):
|
|
1003
|
+
self._panning = True
|
|
1004
|
+
self._pan_last = ev.position().toPoint()
|
|
1005
|
+
self.preview_label.setCursor(Qt.CursorShape.ClosedHandCursor)
|
|
1006
|
+
return True
|
|
1007
|
+
if ev.buttons() & Qt.MouseButton.LeftButton:
|
|
1008
|
+
img_pt = self._label_to_image_coords(ev.position())
|
|
1009
|
+
if img_pt is not None:
|
|
1010
|
+
if self._drawing_poly is None:
|
|
1011
|
+
self._drawing_poly = [img_pt]
|
|
1012
|
+
else:
|
|
1013
|
+
self._drawing_poly.append(img_pt)
|
|
1014
|
+
self._redraw_overlay()
|
|
1015
|
+
return True
|
|
1016
|
+
|
|
1017
|
+
elif ev.type() == QEvent.Type.MouseMove:
|
|
1018
|
+
if getattr(self, "_panning", False):
|
|
1019
|
+
pos = ev.position().toPoint()
|
|
1020
|
+
delta = pos - (self._pan_last or pos)
|
|
1021
|
+
self._pan_last = pos
|
|
1022
|
+
hsb = self.preview_scroll.horizontalScrollBar()
|
|
1023
|
+
vsb = self.preview_scroll.verticalScrollBar()
|
|
1024
|
+
hsb.setValue(hsb.value() - delta.x())
|
|
1025
|
+
vsb.setValue(vsb.value() - delta.y())
|
|
1026
|
+
return True
|
|
1027
|
+
if self._drawing_poly is not None and (ev.buttons() & Qt.MouseButton.LeftButton):
|
|
1028
|
+
img_pt = self._label_to_image_coords(ev.position())
|
|
1029
|
+
if img_pt is not None:
|
|
1030
|
+
self._drawing_poly.append(img_pt)
|
|
1031
|
+
self._redraw_overlay()
|
|
1032
|
+
return True
|
|
1033
|
+
|
|
1034
|
+
elif ev.type() == QEvent.Type.MouseButtonRelease:
|
|
1035
|
+
# finish panning
|
|
1036
|
+
if getattr(self, "_panning", False):
|
|
1037
|
+
self._panning = False
|
|
1038
|
+
self._pan_last = None
|
|
1039
|
+
self.preview_label.unsetCursor()
|
|
1040
|
+
return True
|
|
1041
|
+
|
|
1042
|
+
# Close polygon on LEFT mouse release
|
|
1043
|
+
if ev.button() == Qt.MouseButton.LeftButton and self._drawing_poly is not None:
|
|
1044
|
+
if len(self._drawing_poly) >= 3:
|
|
1045
|
+
self._polygons.append(self._drawing_poly)
|
|
1046
|
+
self._drawing_poly = None
|
|
1047
|
+
self._redraw_overlay()
|
|
1048
|
+
return True
|
|
1049
|
+
|
|
1050
|
+
return super().eventFilter(obj, ev)
|
|
1051
|
+
|
|
1052
|
+
|
|
1053
|
+
|
|
1054
|
+
|
|
1055
|
+
def _ensure_scale_state(self):
|
|
1056
|
+
# internal guard so _zoom_at can be called even if _scale hasn't been set
|
|
1057
|
+
if not hasattr(self, "_scale"):
|
|
1058
|
+
self._scale = float(self.view.transform().m11()) if not self.view.transform().isIdentity() else 1.0
|
|
1059
|
+
|
|
1060
|
+
def _zoom_at(self, factor: float, anchor_vp) -> None:
|
|
1061
|
+
"""
|
|
1062
|
+
Zoom the preview by 'factor', keeping the content point under 'anchor_vp'
|
|
1063
|
+
(a QPoint in viewport coordinates) stationary.
|
|
1064
|
+
"""
|
|
1065
|
+
old_scale = float(self._preview_scale)
|
|
1066
|
+
new_scale = max(0.05, min(old_scale * factor, 8.0))
|
|
1067
|
+
if abs(new_scale - old_scale) < 1e-6:
|
|
1068
|
+
return
|
|
1069
|
+
factor = new_scale / old_scale
|
|
1070
|
+
|
|
1071
|
+
# content coordinates (relative to the QLabel) under the cursor BEFORE scaling
|
|
1072
|
+
hsb = self.preview_scroll.horizontalScrollBar()
|
|
1073
|
+
vsb = self.preview_scroll.verticalScrollBar()
|
|
1074
|
+
old_x = hsb.value() + anchor_vp.x()
|
|
1075
|
+
old_y = vsb.value() + anchor_vp.y()
|
|
1076
|
+
|
|
1077
|
+
# apply scale
|
|
1078
|
+
self._preview_scale = new_scale
|
|
1079
|
+
self._update_preview_scaled()
|
|
1080
|
+
self._redraw_overlay()
|
|
1081
|
+
|
|
1082
|
+
# desired scroll so the same content point stays under the cursor
|
|
1083
|
+
new_x = int(old_x * factor - anchor_vp.x())
|
|
1084
|
+
new_y = int(old_y * factor - anchor_vp.y())
|
|
1085
|
+
|
|
1086
|
+
# clamp to valid range
|
|
1087
|
+
hsb.setValue(max(hsb.minimum(), min(new_x, hsb.maximum())))
|
|
1088
|
+
vsb.setValue(max(vsb.minimum(), min(new_y, vsb.maximum())))
|
|
1089
|
+
|
|
1090
|
+
|
|
1091
|
+
def zoom_in(self) -> None:
|
|
1092
|
+
vp = self.preview_scroll.viewport()
|
|
1093
|
+
self._zoom_at(1.25, vp.rect().center())
|
|
1094
|
+
|
|
1095
|
+
def zoom_out(self) -> None:
|
|
1096
|
+
vp = self.preview_scroll.viewport()
|
|
1097
|
+
self._zoom_at(0.8, vp.rect().center())
|
|
1098
|
+
|
|
1099
|
+
def fit_to_preview(self) -> None:
|
|
1100
|
+
"""Set scale so the image fits inside the viewport (keeps aspect)."""
|
|
1101
|
+
if self._preview_qimg is None:
|
|
1102
|
+
return
|
|
1103
|
+
vp = self.preview_scroll.viewport()
|
|
1104
|
+
vw, vh = max(1, vp.width()), max(1, vp.height())
|
|
1105
|
+
iw, ih = self._preview_qimg.width(), self._preview_qimg.height()
|
|
1106
|
+
if iw == 0 or ih == 0:
|
|
1107
|
+
return
|
|
1108
|
+
scale = min(vw / iw, vh / ih)
|
|
1109
|
+
self._preview_scale = max(0.05, min(scale, 8.0))
|
|
1110
|
+
self._update_preview_scaled()
|
|
1111
|
+
self._redraw_overlay()
|
|
1112
|
+
|
|
1113
|
+
# center after fit
|
|
1114
|
+
hsb = self.preview_scroll.horizontalScrollBar()
|
|
1115
|
+
vsb = self.preview_scroll.verticalScrollBar()
|
|
1116
|
+
hsb.setValue((hsb.maximum() - hsb.minimum()) // 2)
|
|
1117
|
+
vsb.setValue((vsb.maximum() - vsb.minimum()) // 2)
|
|
1118
|
+
|
|
1119
|
+
|
|
1120
|
+
|
|
1121
|
+
def _label_to_image_coords(self, posf) -> QPointF | None:
|
|
1122
|
+
if self._preview_qimg is None:
|
|
1123
|
+
return None
|
|
1124
|
+
img_w = self._preview_qimg.width(); img_h = self._preview_qimg.height()
|
|
1125
|
+
lab_w = self.preview_label.width(); lab_h = self.preview_label.height()
|
|
1126
|
+
sx = img_w / max(1.0, lab_w); sy = img_h / max(1.0, lab_h)
|
|
1127
|
+
x_img = float(posf.x()) * sx; y_img = float(posf.y()) * sy
|
|
1128
|
+
# clamp to image
|
|
1129
|
+
x_img = max(0.0, min(x_img, img_w - 1.0))
|
|
1130
|
+
y_img = max(0.0, min(y_img, img_h - 1.0))
|
|
1131
|
+
return QPointF(x_img, y_img)
|
|
1132
|
+
|
|
1133
|
+
def _install_zoom_filters(self):
|
|
1134
|
+
"""Install event filters so Ctrl+Wheel works even when the cursor is over scrollbars."""
|
|
1135
|
+
self.preview_scroll.installEventFilter(self)
|
|
1136
|
+
self.preview_scroll.viewport().installEventFilter(self)
|
|
1137
|
+
self.preview_scroll.horizontalScrollBar().installEventFilter(self)
|
|
1138
|
+
self.preview_scroll.verticalScrollBar().installEventFilter(self)
|
|
1139
|
+
self.preview_label.installEventFilter(self)
|
|
1140
|
+
|
|
1141
|
+
def _set_preview_from_float(self, arr: np.ndarray):
|
|
1142
|
+
if arr is None or arr.size == 0:
|
|
1143
|
+
return
|
|
1144
|
+
a = _asfloat32(arr)
|
|
1145
|
+
self._preview_source_f01 = a # ← no np.clip
|
|
1146
|
+
|
|
1147
|
+
src_to_show = (hard_autostretch(self._preview_source_f01, target_median=0.5, sigma=2,
|
|
1148
|
+
linked=False, use_16bit=True)
|
|
1149
|
+
if getattr(self, "_autostretch_on", False) else self._preview_source_f01)
|
|
1150
|
+
|
|
1151
|
+
if src_to_show.ndim == 2 or (src_to_show.ndim == 3 and src_to_show.shape[2] == 1):
|
|
1152
|
+
mono = src_to_show if src_to_show.ndim == 2 else src_to_show[..., 0]
|
|
1153
|
+
buf8_mono = (mono * 255.0).astype(np.uint8) # ← no np.clip
|
|
1154
|
+
buf8_mono = np.ascontiguousarray(buf8_mono)
|
|
1155
|
+
self._last_preview = np.ascontiguousarray(np.stack([buf8_mono]*3, axis=-1))
|
|
1156
|
+
h, w = buf8_mono.shape
|
|
1157
|
+
qimg = QImage(buf8_mono.data, w, h, w, QImage.Format.Format_Grayscale8)
|
|
1158
|
+
else:
|
|
1159
|
+
buf8 = (src_to_show * 255.0).astype(np.uint8) # ← no np.clip
|
|
1160
|
+
buf8 = np.ascontiguousarray(buf8)
|
|
1161
|
+
self._last_preview = buf8
|
|
1162
|
+
h, w, _ = buf8.shape
|
|
1163
|
+
qimg = QImage(buf8.data, w, h, buf8.strides[0], QImage.Format.Format_RGB888)
|
|
1164
|
+
|
|
1165
|
+
self._preview_qimg = qimg
|
|
1166
|
+
self._update_preview_scaled()
|
|
1167
|
+
self._redraw_overlay()
|
|
1168
|
+
|
|
1169
|
+
# --- mask helpers ---------------------------------------------------
|
|
1170
|
+
def _active_mask_layer(self):
|
|
1171
|
+
"""Return (mask_float01, mask_id, mask_name) or (None, None, None)."""
|
|
1172
|
+
mid = getattr(self.doc, "active_mask_id", None)
|
|
1173
|
+
if not mid: return None, None, None
|
|
1174
|
+
layer = getattr(self.doc, "masks", {}).get(mid)
|
|
1175
|
+
if layer is None: return None, None, None
|
|
1176
|
+
m = np.asarray(getattr(layer, "data", None))
|
|
1177
|
+
if m is None or m.size == 0: return None, None, None
|
|
1178
|
+
m = m.astype(np.float32, copy=False)
|
|
1179
|
+
if m.dtype.kind in "ui":
|
|
1180
|
+
m /= float(np.iinfo(m.dtype).max)
|
|
1181
|
+
else:
|
|
1182
|
+
mx = float(m.max()) if m.size else 1.0
|
|
1183
|
+
if mx > 1.0: m /= mx
|
|
1184
|
+
return np.clip(m, 0.0, 1.0), mid, getattr(layer, "name", "Mask")
|
|
1185
|
+
|
|
1186
|
+
def _resample_mask_if_needed(self, mask: np.ndarray, out_hw: tuple[int,int]) -> np.ndarray:
|
|
1187
|
+
"""Nearest-neighbor resize via integer indexing."""
|
|
1188
|
+
mh, mw = mask.shape[:2]
|
|
1189
|
+
th, tw = out_hw
|
|
1190
|
+
if (mh, mw) == (th, tw): return mask
|
|
1191
|
+
yi = np.linspace(0, mh - 1, th).astype(np.int32)
|
|
1192
|
+
xi = np.linspace(0, mw - 1, tw).astype(np.int32)
|
|
1193
|
+
return mask[yi][:, xi]
|
|
1194
|
+
|
|
1195
|
+
def _blend_with_mask_float(self, processed: np.ndarray, src: np.ndarray | None = None) -> np.ndarray:
|
|
1196
|
+
"""
|
|
1197
|
+
m*out + (1-m)*src in float [0..1], mono or RGB.
|
|
1198
|
+
If src is None, uses the current document image (float [0..1]).
|
|
1199
|
+
"""
|
|
1200
|
+
mask, _mid, _mname = self._active_mask_layer()
|
|
1201
|
+
if mask is None:
|
|
1202
|
+
return processed
|
|
1203
|
+
|
|
1204
|
+
out = processed.astype(np.float32, copy=False)
|
|
1205
|
+
if src is None:
|
|
1206
|
+
src = self._get_source_float()
|
|
1207
|
+
else:
|
|
1208
|
+
src = src.astype(np.float32, copy=False)
|
|
1209
|
+
|
|
1210
|
+
# match HxW
|
|
1211
|
+
m = self._resample_mask_if_needed(mask, out.shape[:2])
|
|
1212
|
+
|
|
1213
|
+
# channel reconcile
|
|
1214
|
+
if out.ndim == 2 and src.ndim == 3:
|
|
1215
|
+
out = out[..., None]
|
|
1216
|
+
if src.ndim == 2 and out.ndim == 3:
|
|
1217
|
+
src = src[..., None]
|
|
1218
|
+
|
|
1219
|
+
if out.ndim == 3 and out.shape[2] == 3 and m.ndim == 2:
|
|
1220
|
+
m = m[..., None]
|
|
1221
|
+
|
|
1222
|
+
blended = (m * out + (1.0 - m) * src).astype(np.float32, copy=False)
|
|
1223
|
+
# squeeze back to mono if we expanded
|
|
1224
|
+
if blended.ndim == 3 and blended.shape[2] == 1:
|
|
1225
|
+
blended = blended[..., 0]
|
|
1226
|
+
return np.clip(blended, 0.0, 1.0)
|
|
1227
|
+
|
|
1228
|
+
|
|
1229
|
+
def autostretch_preview(self, sigma: float = 3.0) -> None:
|
|
1230
|
+
"""
|
|
1231
|
+
Toggle Siril-style MAD autostretch on the *preview only* (non-destructive).
|
|
1232
|
+
First press applies; second press restores the original preview.
|
|
1233
|
+
Works from the float [0..1] preview source to avoid double-clipping.
|
|
1234
|
+
"""
|
|
1235
|
+
if self._preview_source_f01 is None and self._last_preview is None:
|
|
1236
|
+
return
|
|
1237
|
+
|
|
1238
|
+
# Lazy init toggle state
|
|
1239
|
+
if not hasattr(self, "_autostretch_on"):
|
|
1240
|
+
self._autostretch_on = False
|
|
1241
|
+
if not hasattr(self, "_orig_preview8"):
|
|
1242
|
+
self._orig_preview8 = None
|
|
1243
|
+
|
|
1244
|
+
def _rebuild_from_last():
|
|
1245
|
+
h, w = self._last_preview.shape[:2]
|
|
1246
|
+
ptr = sip.voidptr(self._last_preview.ctypes.data)
|
|
1247
|
+
qimg = QImage(ptr, w, h, self._last_preview.strides[0], QImage.Format.Format_RGB888)
|
|
1248
|
+
self._preview_qimg = qimg
|
|
1249
|
+
self._update_preview_scaled()
|
|
1250
|
+
self._redraw_overlay()
|
|
1251
|
+
|
|
1252
|
+
# Toggle OFF → restore original preview bytes
|
|
1253
|
+
if self._autostretch_on and self._orig_preview8 is not None:
|
|
1254
|
+
self._last_preview = np.ascontiguousarray(self._orig_preview8)
|
|
1255
|
+
_rebuild_from_last()
|
|
1256
|
+
self._autostretch_on = False
|
|
1257
|
+
if hasattr(self, "btn_autostr"):
|
|
1258
|
+
self.btn_autostr.setText("Autostretch")
|
|
1259
|
+
return
|
|
1260
|
+
|
|
1261
|
+
# Toggle ON → cache original and apply stretch from float source
|
|
1262
|
+
if self._last_preview is not None:
|
|
1263
|
+
self._orig_preview8 = np.ascontiguousarray(self._last_preview)
|
|
1264
|
+
|
|
1265
|
+
# Prefer float source (avoids 8-bit clipping); fall back to decoding _last_preview if needed
|
|
1266
|
+
arr = self._preview_source_f01 if self._preview_source_f01 is not None else (self._last_preview.astype(np.float32)/255.0)
|
|
1267
|
+
|
|
1268
|
+
stretched = hard_autostretch(arr, target_median=0.5, sigma=2, linked=False, use_16bit=True)
|
|
1269
|
+
|
|
1270
|
+
buf8 = (np.clip(stretched, 0.0, 1.0) * 255.0).astype(np.uint8)
|
|
1271
|
+
if buf8.ndim == 2:
|
|
1272
|
+
buf8 = np.stack([buf8] * 3, axis=-1)
|
|
1273
|
+
self._last_preview = np.ascontiguousarray(buf8)
|
|
1274
|
+
|
|
1275
|
+
_rebuild_from_last()
|
|
1276
|
+
self._autostretch_on = True
|
|
1277
|
+
if hasattr(self, "btn_autostr"):
|
|
1278
|
+
self.btn_autostr.setText("Autostretch (On)")
|
|
1279
|
+
|
|
1280
|
+
|
|
1281
|
+
def _apply_autostretch_inplace(self, sigma: float = 3.0):
|
|
1282
|
+
# Apply autostretch directly from current float preview source without toggling state.
|
|
1283
|
+
if self._preview_source_f01 is None:
|
|
1284
|
+
return
|
|
1285
|
+
stretched = hard_autostretch(self._preview_source_f01, target_median=0.5, sigma=2,
|
|
1286
|
+
linked=False, use_16bit=True)
|
|
1287
|
+
buf8 = (np.clip(stretched, 0.0, 1.0) * 255.0).astype(np.uint8)
|
|
1288
|
+
if buf8.ndim == 2:
|
|
1289
|
+
buf8 = np.stack([buf8] * 3, axis=-1)
|
|
1290
|
+
self._last_preview = np.ascontiguousarray(buf8)
|
|
1291
|
+
h, w = buf8.shape[:2]
|
|
1292
|
+
qimg = QImage(buf8.data, w, h, buf8.strides[0], QImage.Format.Format_RGB888)
|
|
1293
|
+
self._preview_qimg = qimg
|
|
1294
|
+
self._update_preview_scaled()
|
|
1295
|
+
self._redraw_overlay()
|