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,1604 @@
|
|
|
1
|
+
# pro/image_peeker_pro.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import os
|
|
4
|
+
import math
|
|
5
|
+
import re
|
|
6
|
+
import tempfile
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
from typing import Optional, Tuple, List
|
|
10
|
+
|
|
11
|
+
from PyQt6.QtGui import (
|
|
12
|
+
QIcon, QColor, QPixmap, QPainter, QPen, QImage, QPainterPath, QFont, QGuiApplication
|
|
13
|
+
)
|
|
14
|
+
from PyQt6.QtCore import Qt, QTimer, pyqtSignal, QPoint, QEvent, QPointF, QCoreApplication
|
|
15
|
+
from PyQt6.QtWidgets import (
|
|
16
|
+
QDialog, QVBoxLayout, QHBoxLayout, QGridLayout, QGroupBox, QLabel, QSlider,
|
|
17
|
+
QPushButton, QComboBox, QSizePolicy, QMessageBox, QColorDialog, QWidget,
|
|
18
|
+
QScrollArea, QScrollBar, QMdiSubWindow, QGraphicsScene, QGraphicsView,
|
|
19
|
+
QGraphicsTextItem, QTableWidget, QTableWidgetItem, QLineEdit, QToolButton,
|
|
20
|
+
QSpinBox, QDoubleSpinBox
|
|
21
|
+
)
|
|
22
|
+
from PyQt6.QtGui import QDoubleValidator, QIntValidator
|
|
23
|
+
|
|
24
|
+
from matplotlib.figure import Figure
|
|
25
|
+
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
|
|
26
|
+
|
|
27
|
+
from astropy.io import fits
|
|
28
|
+
from astropy.stats import sigma_clipped_stats
|
|
29
|
+
from scipy.interpolate import griddata
|
|
30
|
+
|
|
31
|
+
import sep
|
|
32
|
+
from setiastro.saspro.widgets.themed_buttons import themed_toolbtn
|
|
33
|
+
|
|
34
|
+
# bring in your existing helpers/classes from the snippet you posted
|
|
35
|
+
# (we assume they live next to this file or already in pro/)
|
|
36
|
+
from .plate_solver import plate_solve_doc_inplace
|
|
37
|
+
from setiastro.saspro.imageops.stretch import stretch_mono_image, stretch_color_image
|
|
38
|
+
from astropy.wcs import WCS
|
|
39
|
+
from .plate_solver import _seed_header_from_meta, _solve_numpy_with_fallback
|
|
40
|
+
from astropy.wcs import WCS
|
|
41
|
+
|
|
42
|
+
def _header_from_meta(meta):
|
|
43
|
+
# Prefer real Header
|
|
44
|
+
hdr = _ensure_fits_header(meta.get("original_header"))
|
|
45
|
+
if hdr is not None:
|
|
46
|
+
return hdr
|
|
47
|
+
|
|
48
|
+
# Next try stored WCS header
|
|
49
|
+
wh = meta.get("wcs_header")
|
|
50
|
+
if isinstance(wh, fits.Header):
|
|
51
|
+
return wh
|
|
52
|
+
if isinstance(wh, dict):
|
|
53
|
+
try:
|
|
54
|
+
return fits.Header(wh)
|
|
55
|
+
except Exception:
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
# Finally try astropy WCS object
|
|
59
|
+
w = meta.get("wcs")
|
|
60
|
+
if isinstance(w, WCS):
|
|
61
|
+
try:
|
|
62
|
+
return w.to_header(relax=True)
|
|
63
|
+
except Exception:
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class PreviewPane(QWidget):
|
|
70
|
+
def __init__(self, parent=None):
|
|
71
|
+
super().__init__(parent)
|
|
72
|
+
self.zoom_factor = 1.0
|
|
73
|
+
self.is_autostretched = False
|
|
74
|
+
self._image_array = None
|
|
75
|
+
self.original_image = None # QImage
|
|
76
|
+
self.stretched_image = None # QImage
|
|
77
|
+
self._panning = False
|
|
78
|
+
self._pan_start = QPoint()
|
|
79
|
+
self._h_scroll_start = 0
|
|
80
|
+
self._v_scroll_start = 0
|
|
81
|
+
|
|
82
|
+
# the scrollable image area
|
|
83
|
+
self.image_label = QLabel()
|
|
84
|
+
self.image_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
85
|
+
self.scroll_area = QScrollArea()
|
|
86
|
+
self.scroll_area.setWidget(self.image_label)
|
|
87
|
+
self.scroll_area.setWidgetResizable(True)
|
|
88
|
+
self.scroll_area.setMinimumSize(450, 450)
|
|
89
|
+
self.scroll_area.viewport().installEventFilter(self)
|
|
90
|
+
|
|
91
|
+
# zoom controls
|
|
92
|
+
self.zoom_slider = QSlider(Qt.Orientation.Horizontal)
|
|
93
|
+
self.zoom_slider.setRange(1, 400)
|
|
94
|
+
self.zoom_slider.setValue(100)
|
|
95
|
+
self.zoom_slider.valueChanged.connect(self.on_zoom_changed)
|
|
96
|
+
|
|
97
|
+
self.zoom_in_btn = themed_toolbtn("zoom-in", self.tr("Zoom In"))
|
|
98
|
+
self.zoom_out_btn = themed_toolbtn("zoom-out", self.tr("Zoom Out"))
|
|
99
|
+
self.fit_btn = themed_toolbtn("zoom-fit-best", self.tr("Fit to Preview"))
|
|
100
|
+
self.zoom_in_btn.clicked .connect(lambda: self.adjust_zoom(10))
|
|
101
|
+
self.zoom_out_btn.clicked.connect(lambda: self.adjust_zoom(-10))
|
|
102
|
+
self.fit_btn.clicked .connect(self.fit_to_view)
|
|
103
|
+
|
|
104
|
+
self.stretch_btn = QPushButton(self.tr("AutoStretch"))
|
|
105
|
+
self.stretch_btn.clicked.connect(self.toggle_stretch)
|
|
106
|
+
|
|
107
|
+
zl = QHBoxLayout()
|
|
108
|
+
zl.addWidget(self.zoom_out_btn)
|
|
109
|
+
zl.addWidget(self.zoom_slider)
|
|
110
|
+
zl.addWidget(self.zoom_in_btn)
|
|
111
|
+
zl.addWidget(self.fit_btn)
|
|
112
|
+
zl.addWidget(self.stretch_btn)
|
|
113
|
+
|
|
114
|
+
layout = QVBoxLayout(self)
|
|
115
|
+
layout.addWidget(self.scroll_area, 1)
|
|
116
|
+
layout.addLayout(zl)
|
|
117
|
+
|
|
118
|
+
self.fit_to_view()
|
|
119
|
+
|
|
120
|
+
def load_qimage(self, img: QImage):
|
|
121
|
+
"""
|
|
122
|
+
Call this to (re)load a fresh image.
|
|
123
|
+
We immediately convert it to a numpy array once
|
|
124
|
+
so we never have to touch the QImage bits again.
|
|
125
|
+
"""
|
|
126
|
+
# keep a local copy of the QImage (for fast redisplay)
|
|
127
|
+
self.original_image = img.copy()
|
|
128
|
+
|
|
129
|
+
# one & only time we go QImage→numpy
|
|
130
|
+
self._image_array = self.qimage_to_numpy(self.original_image)
|
|
131
|
+
|
|
132
|
+
# reset any existing stretch state
|
|
133
|
+
self.stretched_image = None
|
|
134
|
+
self.is_autostretched = False
|
|
135
|
+
self.zoom_factor = 1.0
|
|
136
|
+
self.zoom_slider.setValue(100)
|
|
137
|
+
|
|
138
|
+
self._update_display()
|
|
139
|
+
|
|
140
|
+
def set_overlay(self, overlays):
|
|
141
|
+
""" Store and repaint overlays on top of the image. """
|
|
142
|
+
self._overlays = overlays
|
|
143
|
+
self._update_display()
|
|
144
|
+
|
|
145
|
+
def toggle_stretch(self):
|
|
146
|
+
if self._image_array is None:
|
|
147
|
+
return
|
|
148
|
+
|
|
149
|
+
self.is_autostretched = not self.is_autostretched
|
|
150
|
+
|
|
151
|
+
if self.is_autostretched:
|
|
152
|
+
# stretch the stored numpy array
|
|
153
|
+
arr = self._image_array.copy()
|
|
154
|
+
if arr.ndim == 2:
|
|
155
|
+
stretched = stretch_mono_image(
|
|
156
|
+
arr,
|
|
157
|
+
target_median=0.25,
|
|
158
|
+
normalize=True,
|
|
159
|
+
apply_curves=False
|
|
160
|
+
)
|
|
161
|
+
else:
|
|
162
|
+
stretched = stretch_color_image(
|
|
163
|
+
arr,
|
|
164
|
+
target_median=0.25,
|
|
165
|
+
linked=False,
|
|
166
|
+
normalize=True,
|
|
167
|
+
apply_curves=False
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
# convert back to a QImage for display
|
|
171
|
+
self.stretched_image = self.numpy_to_qimage(stretched).copy()
|
|
172
|
+
else:
|
|
173
|
+
# go back to the original QImage
|
|
174
|
+
self.stretched_image = self.original_image.copy()
|
|
175
|
+
|
|
176
|
+
self._update_display()
|
|
177
|
+
|
|
178
|
+
def qimage_to_numpy(self, qimg: QImage) -> np.ndarray:
|
|
179
|
+
"""
|
|
180
|
+
Safely copy a QImage into a contiguous numpy array,
|
|
181
|
+
and return float32 data normalized to [0.0, 1.0].
|
|
182
|
+
Supports Grayscale8 and RGB888.
|
|
183
|
+
"""
|
|
184
|
+
# force a copy & right format
|
|
185
|
+
if qimg.format() == QImage.Format.Format_Grayscale8:
|
|
186
|
+
img = qimg.convertToFormat(QImage.Format.Format_Grayscale8).copy()
|
|
187
|
+
w, h = img.width(), img.height()
|
|
188
|
+
ptr = img.bits()
|
|
189
|
+
ptr.setsize(h * w)
|
|
190
|
+
buf = ptr.asstring()
|
|
191
|
+
arr = np.frombuffer(buf, np.uint8).reshape((h, w))
|
|
192
|
+
else:
|
|
193
|
+
img = qimg.convertToFormat(QImage.Format.Format_RGB888).copy()
|
|
194
|
+
w, h = img.width(), img.height()
|
|
195
|
+
bpl = img.bytesPerLine()
|
|
196
|
+
ptr = img.bits()
|
|
197
|
+
ptr.setsize(h * bpl)
|
|
198
|
+
buf = ptr.asstring()
|
|
199
|
+
raw = np.frombuffer(buf, np.uint8).reshape((h, bpl))
|
|
200
|
+
raw = raw[:, : 3*w]
|
|
201
|
+
arr = raw.reshape((h, w, 3))
|
|
202
|
+
|
|
203
|
+
# **normalize to float32 [0..1]**
|
|
204
|
+
return (arr.astype(np.float32) / 255.0)
|
|
205
|
+
|
|
206
|
+
def numpy_to_qimage(self, arr: np.ndarray) -> QImage:
|
|
207
|
+
"""
|
|
208
|
+
Convert a H×W or H×W×3 numpy array (float in [0..1] or uint8 in [0..255])
|
|
209
|
+
into a QImage (copying the buffer).
|
|
210
|
+
"""
|
|
211
|
+
# If floating point, assume 0..1 and scale up:
|
|
212
|
+
if np.issubdtype(arr.dtype, np.floating):
|
|
213
|
+
arr = np.clip(arr * 255.0, 0, 255).astype(np.uint8)
|
|
214
|
+
# Otherwise convert any other integer type to uint8
|
|
215
|
+
elif arr.dtype != np.uint8:
|
|
216
|
+
arr = np.clip(arr, 0, 255).astype(np.uint8)
|
|
217
|
+
|
|
218
|
+
h, w = arr.shape[:2]
|
|
219
|
+
if arr.ndim == 2:
|
|
220
|
+
img = QImage(arr.data, w, h, w, QImage.Format.Format_Grayscale8)
|
|
221
|
+
return img.copy()
|
|
222
|
+
elif arr.ndim == 3 and arr.shape[2] == 3:
|
|
223
|
+
bytes_per_line = 3 * w
|
|
224
|
+
img = QImage(arr.data, w, h, bytes_per_line, QImage.Format.Format_RGB888)
|
|
225
|
+
return img.copy()
|
|
226
|
+
else:
|
|
227
|
+
raise ValueError(f"Cannot convert array of shape {arr.shape} to QImage")
|
|
228
|
+
|
|
229
|
+
def on_zoom_changed(self, val):
|
|
230
|
+
self.zoom_factor = val/100
|
|
231
|
+
self._update_display()
|
|
232
|
+
|
|
233
|
+
def adjust_zoom(self, delta):
|
|
234
|
+
v = self.zoom_slider.value() + delta
|
|
235
|
+
self.zoom_slider.setValue(min(max(v,1),400))
|
|
236
|
+
|
|
237
|
+
def fit_to_view(self):
|
|
238
|
+
if not self.original_image:
|
|
239
|
+
return
|
|
240
|
+
avail = self.scroll_area.viewport().size()
|
|
241
|
+
iw, ih = self.original_image.width(), self.original_image.height()
|
|
242
|
+
f = min(avail.width()/iw, avail.height()/ih)
|
|
243
|
+
self.zoom_factor = f
|
|
244
|
+
self.zoom_slider.setValue(int(f*100))
|
|
245
|
+
self._update_display()
|
|
246
|
+
|
|
247
|
+
def _update_display(self):
|
|
248
|
+
"""
|
|
249
|
+
Chooses original vs stretched image and repaints.
|
|
250
|
+
"""
|
|
251
|
+
img = self.stretched_image or self.original_image
|
|
252
|
+
if img is None:
|
|
253
|
+
return
|
|
254
|
+
|
|
255
|
+
pix = QPixmap.fromImage(self.stretched_image or self.original_image)
|
|
256
|
+
painter = QPainter(pix)
|
|
257
|
+
painter.setPen(QPen(Qt.GlobalColor.red, 2))
|
|
258
|
+
# draw any overlays
|
|
259
|
+
for ov in getattr(self, "_overlays", []):
|
|
260
|
+
x, y, p3, p4 = ov
|
|
261
|
+
# if p3 is an integer / we intended an ellipse
|
|
262
|
+
if isinstance(p3, (int,)) and isinstance(p4, (int, float)):
|
|
263
|
+
w = int(p3)
|
|
264
|
+
h = w
|
|
265
|
+
painter.drawEllipse(x, y, w, h)
|
|
266
|
+
painter.drawText(x, y, f"{p4:.2f}")
|
|
267
|
+
else:
|
|
268
|
+
# treat as vector overlay: (angle, length)
|
|
269
|
+
angle = float(p3)
|
|
270
|
+
length_um = float(p4)
|
|
271
|
+
# convert length from µm → pixels if necessary;
|
|
272
|
+
# here we assume overlays were built in pixels:
|
|
273
|
+
dx = math.cos(angle) * length_um
|
|
274
|
+
dy = -math.sin(angle) * length_um
|
|
275
|
+
x2 = x + dx
|
|
276
|
+
y2 = y + dy
|
|
277
|
+
painter.drawLine(int(x), int(y), int(x2), int(y2))
|
|
278
|
+
# optional: draw a simple arrowhead
|
|
279
|
+
# (two short lines at ±20° from the vector)
|
|
280
|
+
ah = 5 # arrow‐head pixel length
|
|
281
|
+
for sign in (+1, -1):
|
|
282
|
+
ang2 = angle + sign * math.radians(20)
|
|
283
|
+
ax = x2 - ah * math.cos(ang2)
|
|
284
|
+
ay = y2 + ah * math.sin(ang2)
|
|
285
|
+
painter.drawLine(int(x2), int(y2), int(ax), int(ay))
|
|
286
|
+
painter.end()
|
|
287
|
+
scaled = pix.scaled(
|
|
288
|
+
pix.size() * self.zoom_factor,
|
|
289
|
+
Qt.AspectRatioMode.KeepAspectRatio,
|
|
290
|
+
Qt.TransformationMode.SmoothTransformation
|
|
291
|
+
)
|
|
292
|
+
self.image_label.setPixmap(scaled)
|
|
293
|
+
|
|
294
|
+
def eventFilter(self, source, evt):
|
|
295
|
+
if source is self.scroll_area.viewport():
|
|
296
|
+
if evt.type() == QEvent.Type.MouseButtonPress and evt.button() == Qt.MouseButton.LeftButton:
|
|
297
|
+
self._panning = True
|
|
298
|
+
self._pan_start = evt.position().toPoint()
|
|
299
|
+
self._h_scroll_start = self.scroll_area.horizontalScrollBar().value()
|
|
300
|
+
self._v_scroll_start = self.scroll_area.verticalScrollBar().value()
|
|
301
|
+
self.scroll_area.viewport().setCursor(Qt.CursorShape.ClosedHandCursor)
|
|
302
|
+
return True
|
|
303
|
+
|
|
304
|
+
elif evt.type() == QEvent.Type.MouseMove and self._panning:
|
|
305
|
+
delta = evt.position().toPoint() - self._pan_start
|
|
306
|
+
self.scroll_area.horizontalScrollBar().setValue(self._h_scroll_start - delta.x())
|
|
307
|
+
self.scroll_area.verticalScrollBar().setValue(self._v_scroll_start - delta.y())
|
|
308
|
+
return True
|
|
309
|
+
|
|
310
|
+
elif evt.type() == QEvent.Type.MouseButtonRelease and evt.button() == Qt.MouseButton.LeftButton:
|
|
311
|
+
self._panning = False
|
|
312
|
+
self.scroll_area.viewport().setCursor(Qt.CursorShape.ArrowCursor)
|
|
313
|
+
return True
|
|
314
|
+
|
|
315
|
+
return super().eventFilter(source, evt)
|
|
316
|
+
|
|
317
|
+
def load_numpy(self, arr: np.ndarray):
|
|
318
|
+
"""
|
|
319
|
+
Convenience wrapper: take an H×W or H×W×3 NumPy array (float in [0..1] or uint8),
|
|
320
|
+
convert it to a QImage and display.
|
|
321
|
+
"""
|
|
322
|
+
# Convert to QImage
|
|
323
|
+
qimg = self.numpy_to_qimage(arr)
|
|
324
|
+
# Delegate to your existing loader
|
|
325
|
+
self.load_qimage(qimg)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def field_curvature_analysis(
|
|
329
|
+
img: np.ndarray,
|
|
330
|
+
grid: int,
|
|
331
|
+
panel: int,
|
|
332
|
+
pixel_scale: float,
|
|
333
|
+
snr_thresh: float = 5.0
|
|
334
|
+
) -> Tuple[np.ndarray, List[Tuple[int,int,float,float]]]:
|
|
335
|
+
"""
|
|
336
|
+
1) Estimate background + detect stars via SEP.
|
|
337
|
+
2) Compute per‐star FWHM (≈2*a), eccentricity, and orientation theta.
|
|
338
|
+
3) Bin the FWHM into a grid×grid mosaic (median per cell) → FWHM_um heatmap.
|
|
339
|
+
4) Normalize that heatmap to [0..1] for display.
|
|
340
|
+
5) Build an overlay list of (x_pix,y_pix,angle_rad,elongation_um) for each star.
|
|
341
|
+
"""
|
|
342
|
+
H, W = img.shape[:2]
|
|
343
|
+
# grayscale float32
|
|
344
|
+
if img.ndim == 3 and img.shape[2] == 3:
|
|
345
|
+
gray = (0.2126*img[...,0] + 0.7152*img[...,1] + 0.0722*img[...,2]).astype(np.float32)
|
|
346
|
+
else:
|
|
347
|
+
gray = img.astype(np.float32)
|
|
348
|
+
|
|
349
|
+
# background / stats
|
|
350
|
+
mean, med, std = sigma_clipped_stats(gray, sigma=3.0)
|
|
351
|
+
data = gray - med
|
|
352
|
+
|
|
353
|
+
# detect
|
|
354
|
+
objs = sep.extract(data, thresh=snr_thresh, err=std)
|
|
355
|
+
if objs is None or len(objs)==0:
|
|
356
|
+
# empty mosaic + no overlays
|
|
357
|
+
blank = np.zeros((H,W), dtype=float)
|
|
358
|
+
return blank, []
|
|
359
|
+
|
|
360
|
+
x, y = objs['x'], objs['y']
|
|
361
|
+
a, b, theta = objs['a'], objs['b'], objs['theta']
|
|
362
|
+
|
|
363
|
+
# FWHM ≈ 2 * a (in pixels) → µm
|
|
364
|
+
fwhm_um = 2.0 * a * pixel_scale
|
|
365
|
+
|
|
366
|
+
# eccentricity → elongation factor e = a/b - 1
|
|
367
|
+
e = np.clip(a / np.where(b>0, b, 1.0) - 1.0, 0.0, None)
|
|
368
|
+
elongation_um = e * pixel_scale
|
|
369
|
+
|
|
370
|
+
# --- build mosaic of median‐FWHM in each grid cell ---
|
|
371
|
+
cell_w, cell_h = W/grid, H/grid
|
|
372
|
+
fmap = np.zeros((H,W), dtype=float)
|
|
373
|
+
heat = np.full((grid, grid), np.nan, dtype=float)
|
|
374
|
+
for j in range(grid):
|
|
375
|
+
for i in range(grid):
|
|
376
|
+
mask = (
|
|
377
|
+
(x>= i*cell_w) & (x< (i+1)*cell_w) &
|
|
378
|
+
(y>= j*cell_h) & (y< (j+1)*cell_h)
|
|
379
|
+
)
|
|
380
|
+
if np.any(mask):
|
|
381
|
+
mval = np.median(fwhm_um[mask])
|
|
382
|
+
else:
|
|
383
|
+
mval = np.nan
|
|
384
|
+
heat[j,i] = mval
|
|
385
|
+
# fill that block
|
|
386
|
+
y0, y1 = int(j*cell_h), int((j+1)*cell_h)
|
|
387
|
+
x0, x1 = int(i*cell_w), int((i+1)*cell_w)
|
|
388
|
+
fmap[y0:y1, x0:x1] = mval if not np.isnan(mval) else 0.0
|
|
389
|
+
|
|
390
|
+
# replace empty with global median
|
|
391
|
+
med_heat = np.nanmedian(heat)
|
|
392
|
+
fmap = np.where(fmap==0, med_heat, fmap)
|
|
393
|
+
|
|
394
|
+
# normalize to [0..1]
|
|
395
|
+
mn, mx = fmap.min(), fmap.max()
|
|
396
|
+
if mx>mn:
|
|
397
|
+
norm = (fmap - mn) / (mx - mn)
|
|
398
|
+
else:
|
|
399
|
+
norm = np.zeros_like(fmap)
|
|
400
|
+
|
|
401
|
+
# --- build elongation‐arrow overlays ---
|
|
402
|
+
overlays: List[Tuple[int,int,float,float]] = []
|
|
403
|
+
for xi, yi, ang, el in zip(x, y, theta, elongation_um):
|
|
404
|
+
overlays.append((int(xi), int(yi), float(ang), float(el)))
|
|
405
|
+
|
|
406
|
+
return norm, overlays
|
|
407
|
+
|
|
408
|
+
def tilt_analysis(
|
|
409
|
+
img: np.ndarray,
|
|
410
|
+
pixel_size_um: float,
|
|
411
|
+
focal_length_mm: float,
|
|
412
|
+
aperture_mm: float,
|
|
413
|
+
sigma_clip: float = 2.0,
|
|
414
|
+
thresh_sigma: float = 5.0,
|
|
415
|
+
) -> Tuple[np.ndarray, Tuple[float,float,float], Tuple[int,int]]:
|
|
416
|
+
"""
|
|
417
|
+
Robust sensor‐tilt measurement via direct plane fit, with a thin‐lens defocus model.
|
|
418
|
+
|
|
419
|
+
1) Convert to 2-D luminance if needed.
|
|
420
|
+
2) Detect stars & measure half-light radius via SEP → rad (pixels).
|
|
421
|
+
3) Compute blur diameter d_um = 2*a_px * pixel_size_um.
|
|
422
|
+
4) Convert blur → defocus via thin‐lens: Δz_um = d_um * (focal_length_mm / aperture_mm).
|
|
423
|
+
5) Fit plane Δz = a x + b y + c to all stars (sigma‐clipped).
|
|
424
|
+
6) Return that best‐fit plane evaluated over every pixel, normalized 0–1 for display.
|
|
425
|
+
"""
|
|
426
|
+
# 0) grayscale float32
|
|
427
|
+
if img.ndim == 3 and img.shape[2] == 3:
|
|
428
|
+
gray = (0.2126*img[...,0] + 0.7152*img[...,1] + 0.0722*img[...,2]).astype(np.float32)
|
|
429
|
+
else:
|
|
430
|
+
gray = img.astype(np.float32)
|
|
431
|
+
H, W = gray.shape
|
|
432
|
+
|
|
433
|
+
# 1) SEP star detection
|
|
434
|
+
data = np.ascontiguousarray(gray, dtype=np.float32)
|
|
435
|
+
bkg = sep.Background(data)
|
|
436
|
+
stars = sep.extract(data - bkg.back(),
|
|
437
|
+
thresh=thresh_sigma,
|
|
438
|
+
err=bkg.globalrms)
|
|
439
|
+
if stars is None or len(stars) < 10:
|
|
440
|
+
return np.zeros((H,W), dtype=float), (0.0,0.0,0.0), (H,W)
|
|
441
|
+
|
|
442
|
+
x = stars['x']
|
|
443
|
+
y = stars['y']
|
|
444
|
+
a_pix = stars['a'] # semi-major axis
|
|
445
|
+
flags = stars['flag'] if 'flag' in stars.dtype.names else np.zeros_like(a_pix, dtype=int)
|
|
446
|
+
|
|
447
|
+
# 2) map to defocus distance (µm) via thin-lens:
|
|
448
|
+
# blur diameter ≈ 2*a_pix * px_size_um
|
|
449
|
+
# Δz_um = blur_um * (focal_length_mm / aperture_mm)
|
|
450
|
+
blur_um = 2.0 * a_pix * pixel_size_um
|
|
451
|
+
f_number = focal_length_mm / aperture_mm
|
|
452
|
+
defocus_um = blur_um * f_number
|
|
453
|
+
|
|
454
|
+
# 3) initial least‐squares plane fit
|
|
455
|
+
A = np.vstack([x, y, np.ones_like(x)]).T # (N,3)
|
|
456
|
+
sol, *_ = np.linalg.lstsq(A, defocus_um, rcond=None)
|
|
457
|
+
a, b, c = sol
|
|
458
|
+
|
|
459
|
+
# 4) sigma‐clip outliers and re-fit
|
|
460
|
+
z_pred = A.dot(sol)
|
|
461
|
+
resid = defocus_um - z_pred
|
|
462
|
+
mask = np.abs(resid) < sigma_clip * np.std(resid)
|
|
463
|
+
if mask.sum() > 10:
|
|
464
|
+
sol, *_ = np.linalg.lstsq(A[mask], defocus_um[mask], rcond=None)
|
|
465
|
+
a, b, c = sol
|
|
466
|
+
|
|
467
|
+
# 5) build full‐frame plane
|
|
468
|
+
Y, X = np.mgrid[0:H, 0:W]
|
|
469
|
+
plane_full = a*X + b*Y + c
|
|
470
|
+
|
|
471
|
+
# 6) normalize to [0..1] for display
|
|
472
|
+
pmin, pmax = plane_full.min(), plane_full.max()
|
|
473
|
+
if pmax > pmin:
|
|
474
|
+
norm_plane = (plane_full - pmin) / (pmax - pmin)
|
|
475
|
+
else:
|
|
476
|
+
norm_plane = np.zeros_like(plane_full)
|
|
477
|
+
|
|
478
|
+
return norm_plane, (a, b, c), (H, W)
|
|
479
|
+
|
|
480
|
+
def focal_plane_curvature_overlay(img: np.ndarray, grid: int, panel: int):
|
|
481
|
+
"""
|
|
482
|
+
Compute the best-fit sphere radius through each local panel,
|
|
483
|
+
return a list of QPainter-friendly overlay primitives,
|
|
484
|
+
e.g. [(x,y,radius,quality), …].
|
|
485
|
+
"""
|
|
486
|
+
overlays = []
|
|
487
|
+
h, w = img.shape[:2]
|
|
488
|
+
xs = np.linspace(0, w-panel, grid, dtype=int)
|
|
489
|
+
ys = np.linspace(0, h-panel, grid, dtype=int)
|
|
490
|
+
for y in ys:
|
|
491
|
+
for x in xs:
|
|
492
|
+
patch = img[y:y+panel, x:x+panel]
|
|
493
|
+
# Fit a circle to the intensity → radius
|
|
494
|
+
radius = fit_circle_radius(patch)
|
|
495
|
+
overlays.append((x, y, panel, radius))
|
|
496
|
+
return overlays
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def build_mosaic_numpy(
|
|
501
|
+
arr: np.ndarray,
|
|
502
|
+
grid: int,
|
|
503
|
+
panel: int,
|
|
504
|
+
sep: int = 4,
|
|
505
|
+
background: float = 0.0
|
|
506
|
+
) -> np.ndarray:
|
|
507
|
+
"""
|
|
508
|
+
Tile `arr` into a grid×grid mosaic of size `panel` each, separated by `sep` pixels.
|
|
509
|
+
If arr is 2D, result is 2D; if 3D (H×W×3), result is 3D.
|
|
510
|
+
"""
|
|
511
|
+
h, w = arr.shape[:2]
|
|
512
|
+
out_h = grid * panel + (grid - 1) * sep
|
|
513
|
+
out_w = grid * panel + (grid - 1) * sep
|
|
514
|
+
if arr.ndim == 2:
|
|
515
|
+
mosaic = np.full((out_h, out_w), background, dtype=arr.dtype)
|
|
516
|
+
else:
|
|
517
|
+
c = arr.shape[2]
|
|
518
|
+
mosaic = np.full((out_h, out_w, c), background, dtype=arr.dtype)
|
|
519
|
+
|
|
520
|
+
# evenly spaced top-left corners
|
|
521
|
+
xs = [int((w - panel) * i / (grid - 1)) for i in range(grid)]
|
|
522
|
+
ys = [int((h - panel) * j / (grid - 1)) for j in range(grid)]
|
|
523
|
+
|
|
524
|
+
for row, y in enumerate(ys):
|
|
525
|
+
for col, x in enumerate(xs):
|
|
526
|
+
patch = arr[y : y + panel, x : x + panel]
|
|
527
|
+
dy = row * (panel + sep)
|
|
528
|
+
dx = col * (panel + sep)
|
|
529
|
+
mosaic[dy:dy + panel, dx:dx + panel, ...] = patch
|
|
530
|
+
|
|
531
|
+
return mosaic
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def fit_circle_radius(patch: np.ndarray) -> float:
|
|
537
|
+
"""
|
|
538
|
+
Very rough radius estimate by thresholding + edge points + circle fit.
|
|
539
|
+
Returns radius in pixels (caller scales to physical units).
|
|
540
|
+
"""
|
|
541
|
+
# 1) threshold at ~50% max:
|
|
542
|
+
thr = patch.max() * 0.5
|
|
543
|
+
mask = patch > thr
|
|
544
|
+
ys, xs = np.nonzero(mask)
|
|
545
|
+
if len(xs) < 5:
|
|
546
|
+
return 0.0
|
|
547
|
+
|
|
548
|
+
# 2) algebraic circle fit (Taubin)
|
|
549
|
+
x = xs.astype(float)
|
|
550
|
+
y = ys.astype(float)
|
|
551
|
+
x_m = x.mean(); y_m = y.mean()
|
|
552
|
+
u = x - x_m; v = y - y_m
|
|
553
|
+
Suu = (u*u).sum(); Suv = (u*v).sum(); Svv = (v*v).sum()
|
|
554
|
+
Suuu = (u*u*u).sum(); Svvv = (v*v*v).sum()
|
|
555
|
+
Suvv = (u*v*v).sum(); Svuu = (v*u*u).sum()
|
|
556
|
+
# Solved system:
|
|
557
|
+
A = np.array([[Suu, Suv], [Suv, Svv]])
|
|
558
|
+
B = np.array([(Suuu + Suvv)/2.0, (Svvv + Svuu)/2.0])
|
|
559
|
+
try:
|
|
560
|
+
uc, vc = np.linalg.solve(A, B)
|
|
561
|
+
except np.linalg.LinAlgError:
|
|
562
|
+
return 0.0
|
|
563
|
+
radius = math.hypot(uc, vc)
|
|
564
|
+
return radius
|
|
565
|
+
|
|
566
|
+
def focal_plane_curvature_overlay(
|
|
567
|
+
img: np.ndarray,
|
|
568
|
+
grid: int,
|
|
569
|
+
panel: int,
|
|
570
|
+
pixel_size_um: Optional[float] = None
|
|
571
|
+
) -> List[Tuple[int,int,int,float]]:
|
|
572
|
+
"""
|
|
573
|
+
Divide `img` into grid×grid panels, estimate per-panel best-focus radius,
|
|
574
|
+
and return overlay tuples (x, y, panel, radius_um).
|
|
575
|
+
If pixel_size_um is given, radius is returned in microns; else in pixels.
|
|
576
|
+
"""
|
|
577
|
+
overlays: List[Tuple[int,int,int,float]] = []
|
|
578
|
+
h, w = img.shape[:2]
|
|
579
|
+
xs = [int((w - panel) * i / (grid - 1)) for i in range(grid)]
|
|
580
|
+
ys = [int((h - panel) * j / (grid - 1)) for j in range(grid)]
|
|
581
|
+
|
|
582
|
+
for y in ys:
|
|
583
|
+
for x in xs:
|
|
584
|
+
patch = img[y : y + panel, x : x + panel]
|
|
585
|
+
r_px = fit_circle_radius(patch)
|
|
586
|
+
r = (r_px * pixel_size_um) if pixel_size_um else r_px
|
|
587
|
+
overlays.append((x, y, panel, r))
|
|
588
|
+
|
|
589
|
+
return overlays
|
|
590
|
+
|
|
591
|
+
# Import centralized widgets
|
|
592
|
+
from setiastro.saspro.widgets.spinboxes import CustomSpinBox, CustomDoubleSpinBox
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
class TiltDialog(QDialog):
|
|
596
|
+
def __init__(self,
|
|
597
|
+
title: str,
|
|
598
|
+
img: np.ndarray,
|
|
599
|
+
plane: Optional[Tuple[float,float,float]] = None,
|
|
600
|
+
img_shape: Optional[Tuple[int,int]] = None,
|
|
601
|
+
pixel_size_um: float = 1.0,
|
|
602
|
+
overlays: Optional[List[Tuple]] = None,
|
|
603
|
+
parent=None):
|
|
604
|
+
super().__init__(parent)
|
|
605
|
+
self.setWindowTitle(title)
|
|
606
|
+
self.pixel_size_um = pixel_size_um
|
|
607
|
+
|
|
608
|
+
# ––––– Create the view and load the image –––––
|
|
609
|
+
self.view = PreviewPane()
|
|
610
|
+
self.view.load_numpy(img)
|
|
611
|
+
if overlays:
|
|
612
|
+
self.view.set_overlay(overlays)
|
|
613
|
+
|
|
614
|
+
# ––––– Corner tilt table –––––
|
|
615
|
+
table = None
|
|
616
|
+
if plane and img_shape:
|
|
617
|
+
a, b, c = plane
|
|
618
|
+
H, W = img_shape
|
|
619
|
+
cx, cy = W/2, H/2
|
|
620
|
+
corners = {
|
|
621
|
+
"Top Left": (0, 0),
|
|
622
|
+
"Top Right": (W, 0),
|
|
623
|
+
"Bottom Left": (0, H),
|
|
624
|
+
"Bottom Right":(W, H),
|
|
625
|
+
}
|
|
626
|
+
rows = []
|
|
627
|
+
corner_deltas = []
|
|
628
|
+
for name,(x,y) in corners.items():
|
|
629
|
+
delta = a*(x - cx) + b*(y - cy)
|
|
630
|
+
corner_deltas.append(delta)
|
|
631
|
+
|
|
632
|
+
min_d, max_d = min(corner_deltas), max(corner_deltas)
|
|
633
|
+
|
|
634
|
+
# 2) now build a more meaningful label:
|
|
635
|
+
range_label = QLabel(self.tr("Tilt span: {0:.1f} µm … {1:.1f} µm").format(min_d, max_d))
|
|
636
|
+
for name, (x, y) in corners.items():
|
|
637
|
+
# how far above/below the center plane
|
|
638
|
+
delta = a*(x - cx) + b*(y - cy)
|
|
639
|
+
rows.append((name, f"{delta:.1f}"))
|
|
640
|
+
|
|
641
|
+
table = QTableWidget(len(rows), 2, self)
|
|
642
|
+
table.setHorizontalHeaderLabels([self.tr("Corner"), self.tr("Δ µm")])
|
|
643
|
+
# hide the vertical header
|
|
644
|
+
table.verticalHeader().setVisible(False)
|
|
645
|
+
for i, (name, val) in enumerate(rows):
|
|
646
|
+
table.setItem(i, 0, QTableWidgetItem(name))
|
|
647
|
+
table.setItem(i, 1, QTableWidgetItem(val))
|
|
648
|
+
table.resizeColumnsToContents()
|
|
649
|
+
|
|
650
|
+
# ––––– Layout everything –––––
|
|
651
|
+
layout = QVBoxLayout(self)
|
|
652
|
+
layout.addWidget(self.view, 1) # stretch = 1
|
|
653
|
+
layout.addWidget(range_label, 0) # stretch = 0
|
|
654
|
+
if table:
|
|
655
|
+
layout.addWidget(table, 0)
|
|
656
|
+
close_btn = QPushButton(self.tr("Close"), self)
|
|
657
|
+
close_btn.clicked.connect(self.accept)
|
|
658
|
+
layout.addWidget(close_btn, 0)
|
|
659
|
+
|
|
660
|
+
self.view.fit_to_view()
|
|
661
|
+
|
|
662
|
+
def compute_fwhm_heatmap_full(
|
|
663
|
+
img: np.ndarray,
|
|
664
|
+
pixel_scale: float,
|
|
665
|
+
thresh_sigma: float = 5.0
|
|
666
|
+
) -> np.ndarray:
|
|
667
|
+
"""
|
|
668
|
+
1) Detect stars with SEP, measure fwhm_um = 2*a*pixel_scale
|
|
669
|
+
2) Interpolate fwhm_um onto the full H×W grid with cubic+nearest
|
|
670
|
+
3) Normalize to [0..1] and return that heatmap
|
|
671
|
+
"""
|
|
672
|
+
gray = img.mean(axis=2).astype(np.float32) if img.ndim==3 else img.astype(np.float32)
|
|
673
|
+
H, W = gray.shape
|
|
674
|
+
data = np.ascontiguousarray(gray, dtype=np.float32)
|
|
675
|
+
bkg = sep.Background(data)
|
|
676
|
+
objs = sep.extract(data - bkg.back(), thresh=thresh_sigma, err=bkg.globalrms)
|
|
677
|
+
if objs is None or len(objs) < 5:
|
|
678
|
+
return np.zeros((H,W),dtype=float)
|
|
679
|
+
|
|
680
|
+
x = objs['x']; y = objs['y']
|
|
681
|
+
fwhm_um = 2.0 * objs['a'] * pixel_scale
|
|
682
|
+
|
|
683
|
+
# create interpolation grid
|
|
684
|
+
grid_x, grid_y = np.meshgrid(np.arange(W), np.arange(H))
|
|
685
|
+
points = np.vstack([x, y]).T
|
|
686
|
+
|
|
687
|
+
# first cubic, then nearest for NaNs
|
|
688
|
+
heat = griddata(points, fwhm_um, (grid_x, grid_y), method='cubic')
|
|
689
|
+
mask = np.isnan(heat)
|
|
690
|
+
if mask.any():
|
|
691
|
+
heat[mask] = griddata(points, fwhm_um, (grid_x, grid_y), method='nearest')[mask]
|
|
692
|
+
|
|
693
|
+
# normalize
|
|
694
|
+
mn, mx = heat.min(), heat.max()
|
|
695
|
+
return (heat - mn)/max(mx-mn,1e-9)
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def fit_2d_poly(x, y, z, deg=2, sigma_clip=3.0, max_iter=3):
|
|
700
|
+
"""
|
|
701
|
+
Fit z(x,y) = Σ_{i+j≤deg} c_{ij} x^i y^j
|
|
702
|
+
by linear least squares + sigma-clipping.
|
|
703
|
+
Returns the flattened coeff array.
|
|
704
|
+
"""
|
|
705
|
+
# Build list of (i,j) exponents
|
|
706
|
+
exps = [(i, j) for total in range(deg+1)
|
|
707
|
+
for i in range(total+1)
|
|
708
|
+
for j in [total - i]]
|
|
709
|
+
# Design matrix
|
|
710
|
+
A = np.vstack([ (x**i)*(y**j) for (i,j) in exps ]).T # shape (N,len(exps))
|
|
711
|
+
mask = np.ones_like(z, bool)
|
|
712
|
+
|
|
713
|
+
for _ in range(max_iter):
|
|
714
|
+
sol, *_ = np.linalg.lstsq(A[mask], z[mask], rcond=None)
|
|
715
|
+
zfit = A.dot(sol)
|
|
716
|
+
resid = z - zfit
|
|
717
|
+
std = np.std(resid[mask])
|
|
718
|
+
newm = np.abs(resid) < sigma_clip*std
|
|
719
|
+
if newm.sum() == mask.sum():
|
|
720
|
+
break
|
|
721
|
+
mask = newm
|
|
722
|
+
|
|
723
|
+
return sol, exps
|
|
724
|
+
|
|
725
|
+
def eval_2d_poly(sol, exps, X, Y):
|
|
726
|
+
"""
|
|
727
|
+
Evaluate the polynomial with coeffs sol and exponents exps
|
|
728
|
+
on a full grid X,Y.
|
|
729
|
+
"""
|
|
730
|
+
Z = np.zeros_like(X, float)
|
|
731
|
+
for c,(i,j) in zip(sol, exps):
|
|
732
|
+
Z += c * (X**i) * (Y**j)
|
|
733
|
+
return Z
|
|
734
|
+
|
|
735
|
+
def compute_fwhm_surface(img, pixel_scale, thresh_sigma=5.0, deg=3):
|
|
736
|
+
# grayscale
|
|
737
|
+
gray = img.mean(axis=2).astype(np.float32) if img.ndim==3 else img.astype(np.float32)
|
|
738
|
+
H, W = gray.shape
|
|
739
|
+
data = np.ascontiguousarray(gray, np.float32)
|
|
740
|
+
bkg = sep.Background(data)
|
|
741
|
+
stars = sep.extract(data - bkg.back(), thresh=thresh_sigma, err=bkg.globalrms)
|
|
742
|
+
if stars is None or len(stars)<10:
|
|
743
|
+
return np.zeros((H,W), float)
|
|
744
|
+
|
|
745
|
+
x = stars['x']; y = stars['y']
|
|
746
|
+
fwhm_um = 2.0 * stars['a'] * pixel_scale
|
|
747
|
+
|
|
748
|
+
# 1) fit
|
|
749
|
+
sol, exps = fit_2d_poly(x, y, fwhm_um, deg=deg)
|
|
750
|
+
|
|
751
|
+
# 2) evaluate
|
|
752
|
+
Y, X = np.mgrid[0:H, 0:W]
|
|
753
|
+
surf = eval_2d_poly(sol, exps, X, Y)
|
|
754
|
+
|
|
755
|
+
# 3) normalize
|
|
756
|
+
mn, mx = surf.min(), surf.max()
|
|
757
|
+
heat = (surf - mn)/max(mx-mn,1e-9)
|
|
758
|
+
return heat, (mn, mx)
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
def compute_eccentricity_surface(
|
|
762
|
+
img: np.ndarray,
|
|
763
|
+
pixel_scale: float,
|
|
764
|
+
thresh_sigma: float = 5.0,
|
|
765
|
+
deg: int = 3
|
|
766
|
+
) -> Tuple[np.ndarray, Tuple[float,float]]:
|
|
767
|
+
"""
|
|
768
|
+
1) SEP → x,y,a,b
|
|
769
|
+
2) e = clip(1 - b/a)
|
|
770
|
+
3) Fit e(x,y) with a 2D poly of degree 'deg' + sigma-clip
|
|
771
|
+
4) Evaluate on full H×W grid, normalize to [0..1]
|
|
772
|
+
"""
|
|
773
|
+
gray = img.mean(axis=2).astype(np.float32) if img.ndim==3 else img.astype(np.float32)
|
|
774
|
+
H, W = gray.shape
|
|
775
|
+
data = np.ascontiguousarray(gray, np.float32)
|
|
776
|
+
bkg = sep.Background(data)
|
|
777
|
+
stars = sep.extract(data - bkg.back(), thresh=thresh_sigma, err=bkg.globalrms)
|
|
778
|
+
if stars is None or len(stars)<6:
|
|
779
|
+
return np.zeros((H,W),dtype=float), (0.0, 0.0)
|
|
780
|
+
|
|
781
|
+
x = stars['x']; y = stars['y']
|
|
782
|
+
a = stars['a']; b = stars['b']
|
|
783
|
+
e = np.clip(1.0 - b/a, 0.0, 1.0)
|
|
784
|
+
e_min, e_max = float(e.min()), float(e.max())
|
|
785
|
+
|
|
786
|
+
# fit polynomial
|
|
787
|
+
sol, exps = fit_2d_poly(x, y, e, deg=deg)
|
|
788
|
+
Y, X = np.mgrid[0:H,0:W]
|
|
789
|
+
surf = eval_2d_poly(sol, exps, X, Y)
|
|
790
|
+
|
|
791
|
+
mn, mx = surf.min(), surf.max()
|
|
792
|
+
norm = (surf - mn)/max(mx-mn,1e-9)
|
|
793
|
+
return norm, (e_min, e_max)
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
def compute_orientation_surface(
|
|
797
|
+
img: np.ndarray,
|
|
798
|
+
thresh_sigma: float = 5.0,
|
|
799
|
+
deg: int = 1, # for pure tilt a plane is enough
|
|
800
|
+
sigma_clip: float = 3.0,
|
|
801
|
+
max_iter: int = 3
|
|
802
|
+
) -> Tuple[np.ndarray, Tuple[float,float]]:
|
|
803
|
+
"""
|
|
804
|
+
Fits a smooth orientation surface θ(x,y) via circular least squares.
|
|
805
|
+
|
|
806
|
+
Returns
|
|
807
|
+
-------
|
|
808
|
+
norm_hue : H×W array
|
|
809
|
+
Hue = (θ_fit + π/2)/π in [0..1], ready for display.
|
|
810
|
+
(h_min, h_max) :
|
|
811
|
+
min/max of the raw hue samples at star positions.
|
|
812
|
+
"""
|
|
813
|
+
# → 1) make a 2D grayscale
|
|
814
|
+
gray = img.mean(axis=2).astype(np.float32) if img.ndim==3 else img.astype(np.float32)
|
|
815
|
+
H, W = gray.shape
|
|
816
|
+
|
|
817
|
+
# → 2) SEP detect
|
|
818
|
+
data = np.ascontiguousarray(gray, np.float32)
|
|
819
|
+
bkg = sep.Background(data)
|
|
820
|
+
stars = sep.extract(data - bkg.back(), thresh=thresh_sigma, err=bkg.globalrms)
|
|
821
|
+
if stars is None or len(stars) < 6:
|
|
822
|
+
return np.zeros((H, W), dtype=float), (0.0, 0.0)
|
|
823
|
+
|
|
824
|
+
x = stars['x']
|
|
825
|
+
y = stars['y']
|
|
826
|
+
theta = stars['theta'] # in radians
|
|
827
|
+
|
|
828
|
+
# → 3) form double‐angle sine/cosine
|
|
829
|
+
s = np.sin(2*theta)
|
|
830
|
+
c = np.cos(2*theta)
|
|
831
|
+
|
|
832
|
+
# compute raw hue range for legend
|
|
833
|
+
# compute **actual** θ range for legend (in radians)
|
|
834
|
+
theta_min, theta_max = float(theta.min()), float(theta.max())
|
|
835
|
+
|
|
836
|
+
# → 4) build design matrix for deg‐th 2D poly
|
|
837
|
+
exps = [(i,j) for total in range(deg+1)
|
|
838
|
+
for i in range(total+1)
|
|
839
|
+
for j in [total-i]]
|
|
840
|
+
A = np.vstack([ (x**i)*(y**j) for (i,j) in exps ]).T # shape (N, M)
|
|
841
|
+
|
|
842
|
+
# → 5) sigma‐clip loops on residual length
|
|
843
|
+
mask = np.ones_like(s, bool)
|
|
844
|
+
for _ in range(max_iter):
|
|
845
|
+
sol_s, *_ = np.linalg.lstsq(A[mask], s[mask], rcond=None)
|
|
846
|
+
sol_c, *_ = np.linalg.lstsq(A[mask], c[mask], rcond=None)
|
|
847
|
+
fit_s = A.dot(sol_s)
|
|
848
|
+
fit_c = A.dot(sol_c)
|
|
849
|
+
resid = np.hypot(s - fit_s, c - fit_c)
|
|
850
|
+
std = np.std(resid[mask])
|
|
851
|
+
newm = resid < sigma_clip*std
|
|
852
|
+
if newm.sum() == mask.sum():
|
|
853
|
+
break
|
|
854
|
+
mask = newm
|
|
855
|
+
|
|
856
|
+
# → 6) evaluate both polys on the full image grid
|
|
857
|
+
Y, X = np.mgrid[0:H, 0:W]
|
|
858
|
+
surf_s = sum(coeff*(X**i)*(Y**j) for coeff,(i,j) in zip(sol_s, exps))
|
|
859
|
+
surf_c = sum(coeff*(X**i)*(Y**j) for coeff,(i,j) in zip(sol_c, exps))
|
|
860
|
+
|
|
861
|
+
# → 7) recover the smooth θ_fit and map to hue [0..1]
|
|
862
|
+
theta_fit = 0.5 * np.arctan2(surf_s, surf_c) # in [−π/2..π/2]
|
|
863
|
+
hue = (theta_fit + np.pi/2) / np.pi # now [0..1]
|
|
864
|
+
|
|
865
|
+
return hue, (theta_min, theta_max)
|
|
866
|
+
|
|
867
|
+
class SurfaceDialog(QDialog):
|
|
868
|
+
def __init__(self, title, heatmap, vmin, vmax, units:str="", cmap="gray", parent=None):
|
|
869
|
+
super().__init__(parent)
|
|
870
|
+
self.setWindowTitle(title)
|
|
871
|
+
|
|
872
|
+
# image
|
|
873
|
+
# image (apply the chosen colormap if it’s a 2D heatmap,
|
|
874
|
+
# and load RGB directly if it’s already color)
|
|
875
|
+
from matplotlib import cm
|
|
876
|
+
import matplotlib.pyplot as plt
|
|
877
|
+
|
|
878
|
+
view = PreviewPane()
|
|
879
|
+
if heatmap.ndim == 2:
|
|
880
|
+
# 1) map to RGBA via colormap
|
|
881
|
+
cmap_obj = cm.get_cmap(cmap)
|
|
882
|
+
rgba = cmap_obj(heatmap) # shape H×W×4, floats 0–1
|
|
883
|
+
rgb = (rgba[...,:3] * 255).astype(np.uint8)
|
|
884
|
+
view.load_numpy(rgb)
|
|
885
|
+
else:
|
|
886
|
+
# assume already float32 [0..1] RGB or uint8 RGB
|
|
887
|
+
view.load_numpy(heatmap)
|
|
888
|
+
view.fit_to_view()
|
|
889
|
+
|
|
890
|
+
# colorbar pixmap
|
|
891
|
+
cb = self._make_colorbar(cmap, vmin, vmax, units)
|
|
892
|
+
lbl_cb = QLabel()
|
|
893
|
+
lbl_cb.setPixmap(cb)
|
|
894
|
+
|
|
895
|
+
# layout
|
|
896
|
+
h = QHBoxLayout()
|
|
897
|
+
h.addWidget(view, 1)
|
|
898
|
+
h.addWidget(lbl_cb, 0)
|
|
899
|
+
|
|
900
|
+
btn = QPushButton(self.tr("Close"))
|
|
901
|
+
btn.clicked.connect(self.accept)
|
|
902
|
+
lbl_span = QLabel(self.tr("Span: {0:.2f} … {1:.2f} {2}").format(vmin, vmax, units))
|
|
903
|
+
|
|
904
|
+
v = QVBoxLayout(self)
|
|
905
|
+
v.addLayout(h)
|
|
906
|
+
v.addWidget(lbl_span)
|
|
907
|
+
v.addWidget(btn)
|
|
908
|
+
|
|
909
|
+
def _make_colorbar(self, cmap_name, vmin, vmax, units):
|
|
910
|
+
# build a 256×20 gradient in RGBA
|
|
911
|
+
import numpy as np
|
|
912
|
+
import matplotlib.pyplot as plt
|
|
913
|
+
from matplotlib import cm
|
|
914
|
+
grad = np.linspace(0,1,256)[:,None]
|
|
915
|
+
bar = cm.get_cmap(cmap_name)(grad)
|
|
916
|
+
bar = (bar[:,:,:3]*255).astype(np.uint8)
|
|
917
|
+
# make a QImage
|
|
918
|
+
H,W,_ = bar.shape
|
|
919
|
+
img = QImage(bar.data, 1, 256, 3*1, QImage.Format.Format_RGB888)
|
|
920
|
+
# rotate to vertical
|
|
921
|
+
return QPixmap.fromImage(img.mirrored(False, True).scaled(20,256))
|
|
922
|
+
|
|
923
|
+
def distortion_vectors_sip(x_pix, y_pix, sip, pixel_size_um):
|
|
924
|
+
"""
|
|
925
|
+
Evaluate the SIP Δ‐pixels at the given star positions,
|
|
926
|
+
return (dx_um, dy_um) and also the raw dx_pix,dy_pix arrays.
|
|
927
|
+
"""
|
|
928
|
+
A = sip.a
|
|
929
|
+
B = sip.b
|
|
930
|
+
order = A.shape[0] - 1
|
|
931
|
+
|
|
932
|
+
# pull off CRPIX so u,v are relative to the SIP origin
|
|
933
|
+
crpix1, crpix2 = sip.forward_origin # equivalent to wcs.wcs.crpix
|
|
934
|
+
u = x_pix - crpix1
|
|
935
|
+
v = y_pix - crpix2
|
|
936
|
+
|
|
937
|
+
dx_pix = np.zeros_like(u)
|
|
938
|
+
dy_pix = np.zeros_like(u)
|
|
939
|
+
|
|
940
|
+
# vectorized polynomial evaluation
|
|
941
|
+
for i in range(order+1):
|
|
942
|
+
for j in range(order+1-i):
|
|
943
|
+
a_ij = A[i, j]
|
|
944
|
+
b_ij = B[i, j]
|
|
945
|
+
if a_ij:
|
|
946
|
+
dx_pix += a_ij * (u**i) * (v**j)
|
|
947
|
+
if b_ij:
|
|
948
|
+
dy_pix += b_ij * (u**i) * (v**j)
|
|
949
|
+
|
|
950
|
+
dx_um = dx_pix * pixel_size_um
|
|
951
|
+
dy_um = dy_pix * pixel_size_um
|
|
952
|
+
|
|
953
|
+
return dx_pix, dy_pix, dx_um, dy_um
|
|
954
|
+
|
|
955
|
+
def distortion_vectors(img: np.ndarray,
|
|
956
|
+
sip_meta: dict,
|
|
957
|
+
pixel_size_um: float):
|
|
958
|
+
"""
|
|
959
|
+
1) SEP detect stars → x_pix,y_pix
|
|
960
|
+
2) extract A,B,crpix from sip_meta
|
|
961
|
+
3) eval dx_pix,dy_pix → dx_um,dy_um
|
|
962
|
+
4) return overlays
|
|
963
|
+
"""
|
|
964
|
+
# 1) detect stars
|
|
965
|
+
gray = img.mean(-1).astype(np.float32) if img.ndim==3 else img.astype(np.float32)
|
|
966
|
+
data = np.ascontiguousarray(gray, np.float32)
|
|
967
|
+
bkg = sep.Background(data)
|
|
968
|
+
stars = sep.extract(data - bkg.back(),
|
|
969
|
+
thresh=5.0, err=bkg.globalrms)
|
|
970
|
+
if stars is None:
|
|
971
|
+
return []
|
|
972
|
+
|
|
973
|
+
x_pix = stars['x']; y_pix = stars['y']
|
|
974
|
+
|
|
975
|
+
# 2) pull SIP from meta (now robust to missing A_ORDER)
|
|
976
|
+
A, B, crpix1, crpix2 = extract_sip_from_meta(sip_meta)
|
|
977
|
+
|
|
978
|
+
# 3) vector‐polynomial evaluation
|
|
979
|
+
u = x_pix - crpix1
|
|
980
|
+
v = y_pix - crpix2
|
|
981
|
+
dx_pix = np.zeros_like(u)
|
|
982
|
+
dy_pix = np.zeros_like(u)
|
|
983
|
+
order = A.shape[0] - 1
|
|
984
|
+
for i in range(order+1):
|
|
985
|
+
for j in range(order+1-i):
|
|
986
|
+
a_ij = A[i, j]
|
|
987
|
+
b_ij = B[i, j]
|
|
988
|
+
if a_ij:
|
|
989
|
+
dx_pix += a_ij * (u**i) * (v**j)
|
|
990
|
+
if b_ij:
|
|
991
|
+
dy_pix += b_ij * (u**i) * (v**j)
|
|
992
|
+
|
|
993
|
+
# 4) to microns & pack
|
|
994
|
+
dx_um = dx_pix * pixel_size_um
|
|
995
|
+
dy_um = dy_pix * pixel_size_um
|
|
996
|
+
|
|
997
|
+
overlays = []
|
|
998
|
+
for x,y,dx,dy in zip(x_pix, y_pix, dx_um, dy_um):
|
|
999
|
+
ang = math.atan2(dy, dx)
|
|
1000
|
+
length = math.hypot(dx, dy)
|
|
1001
|
+
overlays.append((int(x), int(y), ang, length))
|
|
1002
|
+
return overlays
|
|
1003
|
+
|
|
1004
|
+
def eval_sip(A, B, u, v):
|
|
1005
|
+
"""
|
|
1006
|
+
Vectorized SIP evaluation: given coefficient arrays A,B and
|
|
1007
|
+
coordinate offsets u=x-crpix1, v=y-crpix2, returns dx_pix, dy_pix.
|
|
1008
|
+
"""
|
|
1009
|
+
dx = np.zeros_like(u)
|
|
1010
|
+
dy = np.zeros_like(u)
|
|
1011
|
+
order = A.shape[0]-1
|
|
1012
|
+
for i in range(order+1):
|
|
1013
|
+
for j in range(order+1-i):
|
|
1014
|
+
a = A[i, j]
|
|
1015
|
+
b = B[i, j]
|
|
1016
|
+
if a:
|
|
1017
|
+
dx += a * (u**i)*(v**j)
|
|
1018
|
+
if b:
|
|
1019
|
+
dy += b * (u**i)*(v**j)
|
|
1020
|
+
return dx, dy
|
|
1021
|
+
|
|
1022
|
+
def extract_sip_from_meta(sm: dict):
|
|
1023
|
+
"""
|
|
1024
|
+
Given the metadata dict that ASTAP wrote into your slot,
|
|
1025
|
+
pull out the forward SIP polynomials A and B (and the reference pixel).
|
|
1026
|
+
We no longer rely on A_ORDER existing; we infer it from the A_i_j keys.
|
|
1027
|
+
"""
|
|
1028
|
+
# 1) find all the A_i_j keys that actually made it into sm
|
|
1029
|
+
a_keys = [k for k in sm.keys() if re.match(r"A_\d+_\d+", k)]
|
|
1030
|
+
if not a_keys:
|
|
1031
|
+
raise ValueError("No SIP A_?_? coefficients found in metadata!")
|
|
1032
|
+
|
|
1033
|
+
# 2) parse out all the (i,j) pairs and infer the polynomial order as max(i+j)
|
|
1034
|
+
pairs = [tuple(map(int, k.split("_")[1:])) for k in a_keys]
|
|
1035
|
+
order = max(i+j for i,j in pairs)
|
|
1036
|
+
|
|
1037
|
+
# 3) allocate forward‐SIP coefficient arrays
|
|
1038
|
+
A = np.zeros((order+1, order+1), float)
|
|
1039
|
+
B = np.zeros((order+1, order+1), float)
|
|
1040
|
+
|
|
1041
|
+
for i, j in pairs:
|
|
1042
|
+
A[i, j] = float(sm[f"A_{i}_{j}"])
|
|
1043
|
+
B[i, j] = float(sm[f"B_{i}_{j}"])
|
|
1044
|
+
|
|
1045
|
+
# 4) pull the reference pixel
|
|
1046
|
+
crpix1 = float(sm["CRPIX1"])
|
|
1047
|
+
crpix2 = float(sm["CRPIX2"])
|
|
1048
|
+
|
|
1049
|
+
return A, B, crpix1, crpix2
|
|
1050
|
+
|
|
1051
|
+
class DistortionGridDialog(QDialog):
|
|
1052
|
+
def __init__(self,
|
|
1053
|
+
img: np.ndarray,
|
|
1054
|
+
sip_meta: dict,
|
|
1055
|
+
arcsec_per_pix: float,
|
|
1056
|
+
n_grid_lines: int = 10,
|
|
1057
|
+
amplify: float = 20.0,
|
|
1058
|
+
parent=None):
|
|
1059
|
+
super().__init__(parent)
|
|
1060
|
+
self.setWindowTitle(self.tr("Astrometric Distortion & Histogram"))
|
|
1061
|
+
|
|
1062
|
+
# — 1) detect stars —
|
|
1063
|
+
gray = img.mean(-1).astype(np.float32) if img.ndim==3 else img.astype(np.float32)
|
|
1064
|
+
data = np.ascontiguousarray(gray, np.float32)
|
|
1065
|
+
bkg = sep.Background(data)
|
|
1066
|
+
stars = sep.extract(data - bkg.back(), thresh=5.0, err=bkg.globalrms)
|
|
1067
|
+
if stars is None or len(stars) < 10:
|
|
1068
|
+
QMessageBox.warning(self, self.tr("Distortion"), self.tr("Not enough stars found."))
|
|
1069
|
+
self.reject()
|
|
1070
|
+
return
|
|
1071
|
+
|
|
1072
|
+
x_pix = stars['x']
|
|
1073
|
+
y_pix = stars['y']
|
|
1074
|
+
|
|
1075
|
+
# — 2) extract SIP A,B and reference pixel from metadata dict —
|
|
1076
|
+
A, B, crpix1, crpix2 = extract_sip_from_meta(sip_meta)
|
|
1077
|
+
|
|
1078
|
+
# — 4) per-star residuals in pixels → arc-sec —
|
|
1079
|
+
u_star = x_pix - crpix1
|
|
1080
|
+
v_star = y_pix - crpix2
|
|
1081
|
+
dx_star_pix, dy_star_pix = eval_sip(A, B, u_star, v_star)
|
|
1082
|
+
disp_star_pix = np.hypot(dx_star_pix, dy_star_pix)
|
|
1083
|
+
disp_star_arcsec = disp_star_pix * arcsec_per_pix
|
|
1084
|
+
|
|
1085
|
+
# — 5) full‐image warp maps (pixels) for drawing grid —
|
|
1086
|
+
H, W = data.shape
|
|
1087
|
+
YY, XX = np.mgrid[0:H, 0:W]
|
|
1088
|
+
U = XX - crpix1
|
|
1089
|
+
V = YY - crpix2
|
|
1090
|
+
DX_pix, DY_pix = eval_sip(A, B, U, V)
|
|
1091
|
+
DX = DX_pix * amplify
|
|
1092
|
+
DY = DY_pix * amplify
|
|
1093
|
+
|
|
1094
|
+
# — 6) build the distortion grid scene —
|
|
1095
|
+
scene = QGraphicsScene(self)
|
|
1096
|
+
scene.setBackgroundBrush(QColor(30,30,30))
|
|
1097
|
+
pen = QPen(QColor(255,100,100), 1)
|
|
1098
|
+
label_font = QFont("Arial", 12, QFont.Weight.Bold)
|
|
1099
|
+
|
|
1100
|
+
# title above the grid
|
|
1101
|
+
title = QLabel(self.tr("Astrometric Distortion Grid"))
|
|
1102
|
+
title.setFont(QFont("Arial", 16, QFont.Weight.Bold))
|
|
1103
|
+
title.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
1104
|
+
title.setStyleSheet("color: white;")
|
|
1105
|
+
|
|
1106
|
+
# draw horizontal + vertical lines
|
|
1107
|
+
for i in range(n_grid_lines+1):
|
|
1108
|
+
y0 = i*(H-1)/n_grid_lines
|
|
1109
|
+
xs = np.linspace(0, W-1, 200)
|
|
1110
|
+
ys = np.full_like(xs, y0)
|
|
1111
|
+
xi = np.clip(xs.astype(int), 0, W-1)
|
|
1112
|
+
yi = np.clip(ys.astype(int), 0, H-1)
|
|
1113
|
+
warped = np.column_stack([ xs + DX[yi,xi], ys + DY[yi,xi] ])
|
|
1114
|
+
path = QPainterPath(QPointF(*warped[0]))
|
|
1115
|
+
for px,py in warped[1:]:
|
|
1116
|
+
path.lineTo(QPointF(px,py))
|
|
1117
|
+
scene.addPath(path, pen)
|
|
1118
|
+
|
|
1119
|
+
for j in range(n_grid_lines+1):
|
|
1120
|
+
x0 = j*(W-1)/n_grid_lines
|
|
1121
|
+
ys = np.linspace(0, H-1, 200)
|
|
1122
|
+
xs = np.full_like(ys, x0)
|
|
1123
|
+
xi = np.clip(xs.astype(int), 0, W-1)
|
|
1124
|
+
yi = np.clip(ys.astype(int), 0, H-1)
|
|
1125
|
+
warped = np.column_stack([ xs + DX[yi,xi], ys + DY[yi,xi] ])
|
|
1126
|
+
path = QPainterPath(QPointF(*warped[0]))
|
|
1127
|
+
for px,py in warped[1:]:
|
|
1128
|
+
path.lineTo(QPointF(px,py))
|
|
1129
|
+
scene.addPath(path, pen)
|
|
1130
|
+
|
|
1131
|
+
# annotate each grid‐intersection
|
|
1132
|
+
for i in range(n_grid_lines+1):
|
|
1133
|
+
for j in range(n_grid_lines+1):
|
|
1134
|
+
y0 = i*(H-1)/n_grid_lines
|
|
1135
|
+
x0 = j*(W-1)/n_grid_lines
|
|
1136
|
+
xi, yi = int(round(x0)), int(round(y0))
|
|
1137
|
+
|
|
1138
|
+
# local distortion in pixels → arcsec
|
|
1139
|
+
d_pix = math.hypot(DX_pix[yi, xi], DY_pix[yi, xi])
|
|
1140
|
+
d_arcsec = d_pix * arcsec_per_pix
|
|
1141
|
+
|
|
1142
|
+
px = x0 + DX[yi, xi]
|
|
1143
|
+
py = y0 + DY[yi, xi]
|
|
1144
|
+
|
|
1145
|
+
txt = QGraphicsTextItem(f"{d_arcsec:.1f}\"")
|
|
1146
|
+
txt.setFont(label_font)
|
|
1147
|
+
txt.setScale(5.0)
|
|
1148
|
+
txt.setDefaultTextColor(QColor(200,200,200))
|
|
1149
|
+
txt.setPos(px + 4, py + 4)
|
|
1150
|
+
scene.addItem(txt)
|
|
1151
|
+
|
|
1152
|
+
view = QGraphicsView(scene)
|
|
1153
|
+
view.setRenderHint(QPainter.RenderHint.Antialiasing)
|
|
1154
|
+
view.fitInView(scene.sceneRect(), Qt.AspectRatioMode.KeepAspectRatio)
|
|
1155
|
+
|
|
1156
|
+
# pack title + view vertically
|
|
1157
|
+
left_layout = QVBoxLayout()
|
|
1158
|
+
left_layout.addWidget(title)
|
|
1159
|
+
left_layout.addWidget(view, 1)
|
|
1160
|
+
|
|
1161
|
+
# — 7) histogram of per-star residuals (arcsec) —
|
|
1162
|
+
fig = Figure(figsize=(4,4))
|
|
1163
|
+
canvas = FigureCanvas(fig)
|
|
1164
|
+
ax = fig.add_subplot(111)
|
|
1165
|
+
ax.hist(disp_star_arcsec, bins=30, edgecolor='black')
|
|
1166
|
+
ax.set_xlabel(self.tr("Distortion (″)"))
|
|
1167
|
+
ax.set_ylabel(self.tr("Number of stars"))
|
|
1168
|
+
ax.set_title(self.tr("Residual histogram"))
|
|
1169
|
+
fig.tight_layout()
|
|
1170
|
+
|
|
1171
|
+
# side-by-side layout
|
|
1172
|
+
hl = QHBoxLayout()
|
|
1173
|
+
hl.addLayout(left_layout, 1)
|
|
1174
|
+
hl.addWidget(canvas, 1)
|
|
1175
|
+
|
|
1176
|
+
# close button
|
|
1177
|
+
btn = QPushButton(self.tr("Close"))
|
|
1178
|
+
btn.clicked.connect(self.accept)
|
|
1179
|
+
|
|
1180
|
+
# final
|
|
1181
|
+
v = QVBoxLayout(self)
|
|
1182
|
+
v.addLayout(hl)
|
|
1183
|
+
v.addWidget(btn, 0)
|
|
1184
|
+
|
|
1185
|
+
def make_header_from_xisf_meta(meta: dict) -> fits.Header:
|
|
1186
|
+
"""
|
|
1187
|
+
meta is the dict you returned as original_header for XISF:
|
|
1188
|
+
{
|
|
1189
|
+
'file_meta': ...,
|
|
1190
|
+
'image_meta': ...,
|
|
1191
|
+
'astrometry': {
|
|
1192
|
+
'CD1_1', 'CD1_2', 'CD2_1', 'CD2_2',
|
|
1193
|
+
'crpix1', 'crpix2',
|
|
1194
|
+
'sip': {'order', 'A', 'B'}
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
This builds a real fits.Header with WCS+SIP cards.
|
|
1198
|
+
"""
|
|
1199
|
+
hdr = fits.Header()
|
|
1200
|
+
ast = meta['astrometry']
|
|
1201
|
+
|
|
1202
|
+
# WCS linear part
|
|
1203
|
+
hdr['CTYPE1'] = 'RA---TAN-SIP'
|
|
1204
|
+
hdr['CTYPE2'] = 'DEC--TAN-SIP'
|
|
1205
|
+
hdr['CRPIX1'] = ast['crpix1']
|
|
1206
|
+
hdr['CRPIX2'] = ast['crpix2']
|
|
1207
|
+
hdr['CD1_1'] = ast['CD1_1']
|
|
1208
|
+
hdr['CD1_2'] = ast['CD1_2']
|
|
1209
|
+
hdr['CD2_1'] = ast['CD2_1']
|
|
1210
|
+
hdr['CD2_2'] = ast['CD2_2']
|
|
1211
|
+
|
|
1212
|
+
# SIP coefficients
|
|
1213
|
+
sip = ast['sip']
|
|
1214
|
+
order = sip['order']
|
|
1215
|
+
hdr['A_ORDER'] = order
|
|
1216
|
+
hdr['B_ORDER'] = order
|
|
1217
|
+
|
|
1218
|
+
for i in range(order+1):
|
|
1219
|
+
for j in range(order+1-i):
|
|
1220
|
+
hdr[f'A_{i}_{j}'] = float(sip['A'][i,j])
|
|
1221
|
+
hdr[f'B_{i}_{j}'] = float(sip['B'][i,j])
|
|
1222
|
+
|
|
1223
|
+
# If you have file_meta FITSKeywords you can also copy those here:
|
|
1224
|
+
# for kw, vals in meta['file_meta'].get('FITSKeywords', {}).items():
|
|
1225
|
+
# for entry in vals:
|
|
1226
|
+
# hdr[kw] = entry['value']
|
|
1227
|
+
|
|
1228
|
+
return hdr
|
|
1229
|
+
|
|
1230
|
+
def plate_solve_current_image(image_manager, settings, parent=None):
|
|
1231
|
+
"""
|
|
1232
|
+
Plate-solve the current slot image using the SASpro plate solver logic
|
|
1233
|
+
(ASTAP + Astrometry.net fallback) and update the slot's metadata in-place.
|
|
1234
|
+
|
|
1235
|
+
Returns the updated metadata dict for the current slot.
|
|
1236
|
+
"""
|
|
1237
|
+
# 1) Grab pixel data + metadata from Image Peeker Pro
|
|
1238
|
+
arr, meta = image_manager.get_current_image_and_metadata()
|
|
1239
|
+
if meta is None:
|
|
1240
|
+
meta = {}
|
|
1241
|
+
elif not isinstance(meta, dict):
|
|
1242
|
+
meta = dict(meta)
|
|
1243
|
+
|
|
1244
|
+
# 2) Build the seed header from metadata (original_header / wcs / wcs_header)
|
|
1245
|
+
seed_h = _seed_header_from_meta(meta)
|
|
1246
|
+
|
|
1247
|
+
# 3) Pick a parent for UI/status if none is given
|
|
1248
|
+
if parent is None and hasattr(image_manager, "parent"):
|
|
1249
|
+
try:
|
|
1250
|
+
parent = image_manager.parent()
|
|
1251
|
+
except Exception:
|
|
1252
|
+
parent = None
|
|
1253
|
+
|
|
1254
|
+
# 4) Run the actual solve (ASTAP first, then astrometry.net if needed)
|
|
1255
|
+
ok, res = _solve_numpy_with_fallback(parent, settings, arr, seed_h)
|
|
1256
|
+
if not ok:
|
|
1257
|
+
# You can raise, return None, or bubble the error string.
|
|
1258
|
+
# Here we raise to make failures obvious.
|
|
1259
|
+
raise RuntimeError(f"Plate solve failed: {res}")
|
|
1260
|
+
|
|
1261
|
+
hdr = res # this is a real astropy.io.fits.Header
|
|
1262
|
+
|
|
1263
|
+
# 5) Store back into metadata
|
|
1264
|
+
meta["original_header"] = hdr
|
|
1265
|
+
|
|
1266
|
+
try:
|
|
1267
|
+
wcs_obj = WCS(hdr)
|
|
1268
|
+
meta["wcs"] = wcs_obj
|
|
1269
|
+
except Exception as e:
|
|
1270
|
+
print("Image Peeker: WCS build failed:", e)
|
|
1271
|
+
|
|
1272
|
+
# 6) Update image_manager’s internal metadata for this slot
|
|
1273
|
+
slot = image_manager.current_slot
|
|
1274
|
+
if hasattr(image_manager, "_metadata"):
|
|
1275
|
+
image_manager._metadata[slot] = meta
|
|
1276
|
+
|
|
1277
|
+
return meta
|
|
1278
|
+
|
|
1279
|
+
|
|
1280
|
+
|
|
1281
|
+
# ----------------------------- small utils -----------------------------------
|
|
1282
|
+
|
|
1283
|
+
def _ensure_fits_header(orig_hdr):
|
|
1284
|
+
if isinstance(orig_hdr, fits.Header):
|
|
1285
|
+
return orig_hdr
|
|
1286
|
+
if isinstance(orig_hdr, dict) and "astrometry" in orig_hdr:
|
|
1287
|
+
try:
|
|
1288
|
+
return make_header_from_xisf_meta(orig_hdr) # use local function
|
|
1289
|
+
except Exception:
|
|
1290
|
+
return None
|
|
1291
|
+
return None
|
|
1292
|
+
def _arcsec_per_pix_from_header(hdr: fits.Header, fallback_px_um: float|None=None, fallback_fl_mm: float|None=None):
|
|
1293
|
+
"""Try CDELT-based scale; fallback to CD matrix; then pixel_size & focal length."""
|
|
1294
|
+
if hdr is None:
|
|
1295
|
+
if fallback_px_um and fallback_fl_mm:
|
|
1296
|
+
return 206.264806 * (fallback_px_um / fallback_fl_mm)
|
|
1297
|
+
return None
|
|
1298
|
+
try:
|
|
1299
|
+
return abs(float(hdr["CDELT1"])) * 3600.0
|
|
1300
|
+
except Exception:
|
|
1301
|
+
try:
|
|
1302
|
+
cd11 = float(hdr["CD1_1"])
|
|
1303
|
+
cd12 = float(hdr.get("CD1_2", 0.0))
|
|
1304
|
+
cd21 = float(hdr.get("CD2_1", 0.0))
|
|
1305
|
+
cd22 = float(hdr["CD2_2"])
|
|
1306
|
+
scale_deg = np.sqrt(abs(cd11 * cd22 - cd12 * cd21))
|
|
1307
|
+
return scale_deg * 3600.0
|
|
1308
|
+
except Exception:
|
|
1309
|
+
if fallback_px_um and fallback_fl_mm:
|
|
1310
|
+
return 206.264806 * (fallback_px_um / fallback_fl_mm)
|
|
1311
|
+
return None
|
|
1312
|
+
|
|
1313
|
+
class ImagePeekerDialogPro(QDialog):
|
|
1314
|
+
def __init__(self, parent, document, settings):
|
|
1315
|
+
super().__init__(parent)
|
|
1316
|
+
self.setWindowTitle(self.tr("Image Peeker"))
|
|
1317
|
+
self.setWindowFlag(Qt.WindowType.Window, True)
|
|
1318
|
+
self.setWindowModality(Qt.WindowModality.NonModal)
|
|
1319
|
+
self.setModal(False)
|
|
1320
|
+
self.document = self._coerce_doc(document) # <- ensure we hold a real doc
|
|
1321
|
+
self.settings = settings
|
|
1322
|
+
# status / progress line
|
|
1323
|
+
self.status_lbl = QLabel("")
|
|
1324
|
+
self.status_lbl.setStyleSheet("color:#bbb;")
|
|
1325
|
+
|
|
1326
|
+
|
|
1327
|
+
self.params = QGroupBox(self.tr("Grid parameters"))
|
|
1328
|
+
self.params.setMinimumWidth(180)
|
|
1329
|
+
self.params.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Preferred)
|
|
1330
|
+
gl = QGridLayout(self.params)
|
|
1331
|
+
|
|
1332
|
+
from PyQt6.QtWidgets import QSpinBox, QDoubleSpinBox
|
|
1333
|
+
self.grid_spin = QSpinBox(); self.grid_spin.setRange(2, 10); self.grid_spin.setValue(3)
|
|
1334
|
+
self.panel_slider = QSlider(Qt.Orientation.Horizontal); self.panel_slider.setRange(32, 512); self.panel_slider.setValue(256)
|
|
1335
|
+
self.panel_value_label = QLabel(str(self.panel_slider.value()))
|
|
1336
|
+
self.sep_slider = QSlider(Qt.Orientation.Horizontal); self.sep_slider.setRange(0, 50); self.sep_slider.setValue(4)
|
|
1337
|
+
self.sep_value_label = QLabel(str(self.sep_slider.value()))
|
|
1338
|
+
|
|
1339
|
+
self.pixel_size_input = QDoubleSpinBox(); self.pixel_size_input.setRange(0.01, 50.0); self.pixel_size_input.setSingleStep(0.1)
|
|
1340
|
+
self.focal_length_input = QDoubleSpinBox(); self.focal_length_input.setRange(10.0, 5000.0); self.focal_length_input.setSingleStep(10.0)
|
|
1341
|
+
self.aperture_input = QDoubleSpinBox(); self.aperture_input.setRange(1.0, 5000.0); self.aperture_input.setSingleStep(1.0)
|
|
1342
|
+
|
|
1343
|
+
px = self.settings.value("pixel_size_um", 4.8, type=float)
|
|
1344
|
+
fl = self.settings.value("focal_length_mm", 800.0, type=float)
|
|
1345
|
+
ap = self.settings.value("aperture_mm", 100.0, type=float)
|
|
1346
|
+
self.pixel_size_input.setValue(px); self.focal_length_input.setValue(fl); self.aperture_input.setValue(ap)
|
|
1347
|
+
|
|
1348
|
+
row = 0
|
|
1349
|
+
gl.addWidget(QLabel(self.tr("Grid size:")), row, 0); gl.addWidget(self.grid_spin, row, 1); row += 1
|
|
1350
|
+
gl.addWidget(QLabel(self.tr("Panel size:")), row, 0)
|
|
1351
|
+
pr = QHBoxLayout(); pr.addWidget(self.panel_slider, 1); pr.addWidget(self.panel_value_label)
|
|
1352
|
+
gl.addLayout(pr, row, 1); row += 1
|
|
1353
|
+
gl.addWidget(QLabel(self.tr("Separation:")), row, 0)
|
|
1354
|
+
sr = QHBoxLayout(); sr.addWidget(self.sep_slider, 1); sr.addWidget(self.sep_value_label)
|
|
1355
|
+
gl.addLayout(sr, row, 1); row += 1
|
|
1356
|
+
gl.addWidget(QLabel(self.tr("Pixel size (µm):")), row, 0); gl.addWidget(self.pixel_size_input, row, 1); row += 1
|
|
1357
|
+
gl.addWidget(QLabel(self.tr("Focal length (mm):")), row, 0); gl.addWidget(self.focal_length_input, row, 1); row += 1
|
|
1358
|
+
gl.addWidget(QLabel(self.tr("Aperture (mm):")), row, 0); gl.addWidget(self.aperture_input, row, 1); row += 1
|
|
1359
|
+
|
|
1360
|
+
# Right side
|
|
1361
|
+
from PyQt6.QtWidgets import QTabWidget
|
|
1362
|
+
self.preview_pane = PreviewPane()
|
|
1363
|
+
analysis_row = QHBoxLayout()
|
|
1364
|
+
analysis_row.addWidget(QLabel(self.tr("Analysis:")))
|
|
1365
|
+
self.analysis_combo = QComboBox()
|
|
1366
|
+
self.analysis_combo.addItem(self.tr("None"), "None")
|
|
1367
|
+
self.analysis_combo.addItem(self.tr("Tilt Analysis"), "Tilt Analysis")
|
|
1368
|
+
self.analysis_combo.addItem(self.tr("Focal Plane Analysis"), "Focal Plane Analysis")
|
|
1369
|
+
self.analysis_combo.addItem(self.tr("Astrometric Distortion Analysis"), "Astrometric Distortion Analysis")
|
|
1370
|
+
analysis_row.addWidget(self.analysis_combo); analysis_row.addStretch(1)
|
|
1371
|
+
|
|
1372
|
+
btns = QHBoxLayout(); btns.addStretch(1)
|
|
1373
|
+
ok_btn = QPushButton(self.tr("Save Settings && Exit")); cancel_btn = QPushButton(self.tr("Exit without Saving"))
|
|
1374
|
+
btns.addWidget(ok_btn); btns.addWidget(cancel_btn)
|
|
1375
|
+
|
|
1376
|
+
main = QHBoxLayout(self)
|
|
1377
|
+
main.addWidget(self.params)
|
|
1378
|
+
right = QVBoxLayout(); right.addLayout(analysis_row); right.addWidget(self.status_lbl, 0), right.addWidget(self.preview_pane, 1); right.addLayout(btns)
|
|
1379
|
+
main.addLayout(right, 1)
|
|
1380
|
+
|
|
1381
|
+
# Signals
|
|
1382
|
+
self.grid_spin.valueChanged.connect(self._refresh_mosaic)
|
|
1383
|
+
self.panel_slider.valueChanged.connect(lambda v: (self.panel_value_label.setText(str(v)), self._refresh_mosaic()))
|
|
1384
|
+
self.sep_slider.valueChanged.connect(lambda v: (self.sep_value_label.setText(str(v)), self._refresh_mosaic()))
|
|
1385
|
+
self.analysis_combo.currentTextChanged.connect(self._run_analysis)
|
|
1386
|
+
ok_btn.clicked.connect(self.accept); cancel_btn.clicked.connect(self.reject)
|
|
1387
|
+
|
|
1388
|
+
QTimer.singleShot(0, self._refresh_mosaic)
|
|
1389
|
+
|
|
1390
|
+
def _set_busy(self, on: bool, text: str = ""):
|
|
1391
|
+
if not text: text = self.tr("Processing…")
|
|
1392
|
+
self.status_lbl.setText(text if on else "")
|
|
1393
|
+
for w in (self.params, self.analysis_combo):
|
|
1394
|
+
w.setEnabled(not on)
|
|
1395
|
+
QGuiApplication.setOverrideCursor(Qt.CursorShape.WaitCursor) if on else QGuiApplication.restoreOverrideCursor()
|
|
1396
|
+
QCoreApplication.processEvents()
|
|
1397
|
+
|
|
1398
|
+
def _arr_and_meta(self):
|
|
1399
|
+
doc = self._coerce_doc(self.document)
|
|
1400
|
+
if doc is None or getattr(doc, "image", None) is None:
|
|
1401
|
+
return None, {}
|
|
1402
|
+
arr = np.asarray(doc.image, dtype=np.float32)
|
|
1403
|
+
meta = dict(getattr(doc, "metadata", {}) or {})
|
|
1404
|
+
return arr, meta
|
|
1405
|
+
|
|
1406
|
+
def accept(self):
|
|
1407
|
+
self.settings.setValue("pixel_size_um", self.pixel_size_input.value())
|
|
1408
|
+
self.settings.setValue("focal_length_mm", self.focal_length_input.value())
|
|
1409
|
+
self.settings.setValue("aperture_mm", self.aperture_input.value())
|
|
1410
|
+
super().accept()
|
|
1411
|
+
|
|
1412
|
+
|
|
1413
|
+
def _run_analysis(self, *_):
|
|
1414
|
+
mode_key = self.analysis_combo.currentData()
|
|
1415
|
+
mode_disp = self.analysis_combo.currentText()
|
|
1416
|
+
if mode_key == "None":
|
|
1417
|
+
self._set_busy(False, "")
|
|
1418
|
+
self._refresh_mosaic()
|
|
1419
|
+
return
|
|
1420
|
+
self._set_busy(True, self.tr("Running {0}…").format(mode_disp))
|
|
1421
|
+
QTimer.singleShot(0, lambda: self._run_analysis_dispatch(mode_key))
|
|
1422
|
+
|
|
1423
|
+
def _run_analysis_dispatch(self, mode: str):
|
|
1424
|
+
try:
|
|
1425
|
+
arr, meta = self._arr_and_meta()
|
|
1426
|
+
if arr is None or arr.size == 0:
|
|
1427
|
+
return
|
|
1428
|
+
ps_um = float(meta.get("pixel_size_um", self.pixel_size_input.value()))
|
|
1429
|
+
fl_mm = float(meta.get("focal_length_mm", self.focal_length_input.value()))
|
|
1430
|
+
ap_mm = float(meta.get("aperture_mm", self.aperture_input.value()))
|
|
1431
|
+
snr_th = float(meta.get("snr_threshold", 5.0))
|
|
1432
|
+
|
|
1433
|
+
if mode == "Tilt Analysis":
|
|
1434
|
+
norm_plane, (a,b,c), (H,W) = tilt_analysis(
|
|
1435
|
+
arr, pixel_size_um=ps_um, focal_length_mm=fl_mm, aperture_mm=ap_mm,
|
|
1436
|
+
sigma_clip=2.5, thresh_sigma=snr_th
|
|
1437
|
+
)
|
|
1438
|
+
TiltDialog(self.tr("Sensor Tilt (µm)"), norm_plane, (a,b,c), (H,W), ps_um, parent=self).show()
|
|
1439
|
+
|
|
1440
|
+
elif mode == "Focal Plane Analysis":
|
|
1441
|
+
fwhm_heat, (mn_f, mx_f) = compute_fwhm_surface(arr, ps_um, thresh_sigma=snr_th, deg=3)
|
|
1442
|
+
SurfaceDialog(self.tr("FWHM Heatmap"), fwhm_heat, mn_f, mx_f, "µm", "viridis", parent=self).show()
|
|
1443
|
+
ecc_heat, (mn_e, mx_e) = compute_eccentricity_surface(arr, ps_um, thresh_sigma=snr_th, deg=3)
|
|
1444
|
+
SurfaceDialog(self.tr("Eccentricity Map"), ecc_heat, mn_e, mx_e, "e = 1−b/a", "magma", parent=self).show()
|
|
1445
|
+
ori_heat, (mn_o, mx_o) = compute_orientation_surface(arr, thresh_sigma=snr_th, deg=3)
|
|
1446
|
+
SurfaceDialog(self.tr("Orientation Map"), ori_heat, mn_o, mx_o, "rad", "hsv", parent=self).show()
|
|
1447
|
+
|
|
1448
|
+
elif mode == "Astrometric Distortion Analysis":
|
|
1449
|
+
hdr = _header_from_meta(meta)
|
|
1450
|
+
|
|
1451
|
+
# If we truly have no WCS, plate-solve
|
|
1452
|
+
if hdr is None or not WCS(hdr, relax=True).has_celestial:
|
|
1453
|
+
ok, hdr_or_err = plate_solve_doc_inplace(
|
|
1454
|
+
parent=self, doc=self._coerce_doc(self.document), settings=self.settings
|
|
1455
|
+
)
|
|
1456
|
+
if not ok:
|
|
1457
|
+
QMessageBox.warning(self, self.tr("Plate Solve"), self.tr("ASTAP/Astrometry failed:\n{0}").format(hdr_or_err))
|
|
1458
|
+
return
|
|
1459
|
+
|
|
1460
|
+
# IMPORTANT: if solver returned a Header, store it
|
|
1461
|
+
if isinstance(hdr_or_err, fits.Header):
|
|
1462
|
+
doc = self._coerce_doc(self.document)
|
|
1463
|
+
if doc and isinstance(getattr(doc, "metadata", None), dict):
|
|
1464
|
+
doc.metadata["original_header"] = hdr_or_err
|
|
1465
|
+
doc.metadata["wcs_header"] = hdr_or_err
|
|
1466
|
+
try:
|
|
1467
|
+
doc.metadata["wcs"] = WCS(hdr_or_err, relax=True)
|
|
1468
|
+
except Exception:
|
|
1469
|
+
pass
|
|
1470
|
+
|
|
1471
|
+
arr, meta = self._arr_and_meta()
|
|
1472
|
+
hdr = _header_from_meta(meta)
|
|
1473
|
+
|
|
1474
|
+
# Now WCS exists, but do we have SIP?
|
|
1475
|
+
if hdr is None:
|
|
1476
|
+
QMessageBox.critical(self, self.tr("WCS Error"), self.tr("Plate solve did not produce a readable WCS header."))
|
|
1477
|
+
return
|
|
1478
|
+
|
|
1479
|
+
has_sip = any(k.startswith("A_") for k in hdr.keys()) and any(k.startswith("B_") for k in hdr.keys())
|
|
1480
|
+
if not has_sip:
|
|
1481
|
+
QMessageBox.warning(
|
|
1482
|
+
self, self.tr("No Distortion Model"),
|
|
1483
|
+
self.tr("This image has a valid WCS, but no SIP distortion terms (A_*, B_*).\n"
|
|
1484
|
+
"Astrometric distortion analysis requires a SIP-enabled solve.\n\n"
|
|
1485
|
+
"Re-solve with distortion fitting enabled in ASTAP.")
|
|
1486
|
+
)
|
|
1487
|
+
return
|
|
1488
|
+
|
|
1489
|
+
asp = _arcsec_per_pix_from_header(hdr, fallback_px_um=ps_um, fallback_fl_mm=fl_mm)
|
|
1490
|
+
if asp is None:
|
|
1491
|
+
QMessageBox.critical(self, self.tr("WCS Error"), self.tr("Cannot determine pixel scale."))
|
|
1492
|
+
return
|
|
1493
|
+
|
|
1494
|
+
DistortionGridDialog(
|
|
1495
|
+
img=np.clip(arr, 0, 1), sip_meta=hdr, arcsec_per_pix=float(asp),
|
|
1496
|
+
n_grid_lines=10, amplify=60.0, parent=self
|
|
1497
|
+
).show()
|
|
1498
|
+
|
|
1499
|
+
|
|
1500
|
+
else:
|
|
1501
|
+
self._refresh_mosaic()
|
|
1502
|
+
finally:
|
|
1503
|
+
self._set_busy(False, "")
|
|
1504
|
+
|
|
1505
|
+
def _coerce_doc(self, obj):
|
|
1506
|
+
"""Return a document object that has .image and .metadata, or None."""
|
|
1507
|
+
if obj is None:
|
|
1508
|
+
return None
|
|
1509
|
+
# If it already looks like a document
|
|
1510
|
+
if hasattr(obj, "image") and not isinstance(obj, QMdiSubWindow):
|
|
1511
|
+
return obj
|
|
1512
|
+
# If it's a subwindow, try its widget().document
|
|
1513
|
+
if isinstance(obj, QMdiSubWindow):
|
|
1514
|
+
w = obj.widget()
|
|
1515
|
+
return getattr(w, "document", None)
|
|
1516
|
+
# If it's a view-type wrapper
|
|
1517
|
+
if hasattr(obj, "document"):
|
|
1518
|
+
return getattr(obj, "document")
|
|
1519
|
+
return None
|
|
1520
|
+
|
|
1521
|
+
|
|
1522
|
+
def _on_panel_changed(self, v):
|
|
1523
|
+
self.panel_value_label.setText(str(v))
|
|
1524
|
+
self._refresh_mosaic()
|
|
1525
|
+
|
|
1526
|
+
def _on_sep_changed(self, v):
|
|
1527
|
+
self.sep_value_label.setText(str(v))
|
|
1528
|
+
self._refresh_mosaic()
|
|
1529
|
+
|
|
1530
|
+
def _update_sep_color_button(self):
|
|
1531
|
+
# show current color
|
|
1532
|
+
pix = QIcon().pixmap(16,16)
|
|
1533
|
+
pix.fill(self._sep_color)
|
|
1534
|
+
self.sep_color_btn.setIcon(QIcon(pix))
|
|
1535
|
+
|
|
1536
|
+
def _choose_sep_color(self):
|
|
1537
|
+
col = QColorDialog.getColor(self._sep_color, self, self.tr("Choose separation color"))
|
|
1538
|
+
if col.isValid():
|
|
1539
|
+
self._sep_color = col
|
|
1540
|
+
self._update_sep_color_button()
|
|
1541
|
+
|
|
1542
|
+
def _refresh_mosaic(self):
|
|
1543
|
+
arr, _ = self._arr_and_meta()
|
|
1544
|
+
if arr is None or arr.size == 0:
|
|
1545
|
+
return
|
|
1546
|
+
# ensure RGB for preview
|
|
1547
|
+
if arr.ndim == 2: arr = np.repeat(arr[...,None], 3, axis=2)
|
|
1548
|
+
qimg = self._to_qimage(np.clip(arr, 0, 1))
|
|
1549
|
+
n = max(2, int(self.grid_spin.value()))
|
|
1550
|
+
ps = int(self.panel_slider.value())
|
|
1551
|
+
sep = int(self.sep_slider.value())
|
|
1552
|
+
mosaic = self._build_mosaic(qimg, n, ps, sep, QColor(0,0,0))
|
|
1553
|
+
self.preview_pane.load_qimage(mosaic)
|
|
1554
|
+
|
|
1555
|
+
def _on_ok(self):
|
|
1556
|
+
# user clicked OK → generate & display the mosaic
|
|
1557
|
+
n = self.grid_spin.value
|
|
1558
|
+
panel_sz = self.panel_slider.value()
|
|
1559
|
+
sep = self.sep_slider.value()
|
|
1560
|
+
sep_col = self._sep_color
|
|
1561
|
+
|
|
1562
|
+
# fetch the currently loaded image (you’ll adapt to your image_manager API)
|
|
1563
|
+
img = self.image_manager.current_qimage()
|
|
1564
|
+
if img is None:
|
|
1565
|
+
QMessageBox.warning(self, self.tr("No image"), self.tr("No image loaded to peek at!"))
|
|
1566
|
+
return
|
|
1567
|
+
|
|
1568
|
+
mosaic = self._build_mosaic(img, n, panel_sz, sep, sep_col)
|
|
1569
|
+
self.preview.setPixmap(QPixmap.fromImage(mosaic))
|
|
1570
|
+
# keep dialog open so user can tweak parameters
|
|
1571
|
+
|
|
1572
|
+
def _build_mosaic(self, img, n, panel_sz, sep, sep_col):
|
|
1573
|
+
from PyQt6.QtGui import QImage
|
|
1574
|
+
W = n*panel_sz + (n-1)*sep; H = n*panel_sz + (n-1)*sep
|
|
1575
|
+
mosaic = QImage(W, H, img.format()); p = QPainter(mosaic)
|
|
1576
|
+
p.fillRect(0,0,W,H, sep_col)
|
|
1577
|
+
src_w, src_h = img.width(), img.height()
|
|
1578
|
+
xs = [int((src_w - panel_sz) * i / max(n-1, 1)) for i in range(n)]
|
|
1579
|
+
ys = [int((src_h - panel_sz) * j / max(n-1, 1)) for j in range(n)]
|
|
1580
|
+
for row, y in enumerate(ys):
|
|
1581
|
+
for col, x in enumerate(xs):
|
|
1582
|
+
patch = img.copy(x, y, panel_sz, panel_sz)
|
|
1583
|
+
dx = col * (panel_sz + sep); dy = row * (panel_sz + sep)
|
|
1584
|
+
p.drawImage(dx, dy, patch)
|
|
1585
|
+
p.end()
|
|
1586
|
+
return mosaic
|
|
1587
|
+
|
|
1588
|
+
def _to_qimage(self, arr: np.ndarray):
|
|
1589
|
+
# same as your _to_qimage in the snippet
|
|
1590
|
+
if arr.dtype.kind == "f":
|
|
1591
|
+
arr8 = np.clip(arr * 255, 0, 255).astype(np.uint8)
|
|
1592
|
+
elif arr.dtype != np.uint8:
|
|
1593
|
+
arr8 = arr.astype(np.uint8)
|
|
1594
|
+
else:
|
|
1595
|
+
arr8 = arr
|
|
1596
|
+
h, w = arr8.shape[:2]
|
|
1597
|
+
buf = arr8.tobytes(); self._last_qimage_buffer = buf
|
|
1598
|
+
from PyQt6.QtGui import QImage
|
|
1599
|
+
if arr8.ndim == 2:
|
|
1600
|
+
return QImage(buf, w, h, w, QImage.Format.Format_Grayscale8)
|
|
1601
|
+
elif arr8.ndim == 3 and arr8.shape[2] == 3:
|
|
1602
|
+
return QImage(buf, w, h, 3*w, QImage.Format.Format_RGB888)
|
|
1603
|
+
raise ValueError(f"Unsupported array shape {arr.shape}")
|
|
1604
|
+
|