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,2445 @@
|
|
|
1
|
+
# pro/plate_solver.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import math
|
|
7
|
+
import tempfile
|
|
8
|
+
from typing import Tuple, Dict, Any, Optional
|
|
9
|
+
from functools import lru_cache
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
import json
|
|
13
|
+
import time
|
|
14
|
+
import requests
|
|
15
|
+
from astropy.io import fits
|
|
16
|
+
from astropy.io.fits import Header
|
|
17
|
+
from astropy.wcs import WCS
|
|
18
|
+
|
|
19
|
+
from PyQt6.QtCore import QProcess, QTimer, QEventLoop, Qt, QCoreApplication
|
|
20
|
+
from PyQt6.QtGui import QIcon
|
|
21
|
+
from PyQt6.QtWidgets import (
|
|
22
|
+
QDialog, QLabel, QPushButton, QVBoxLayout, QHBoxLayout,
|
|
23
|
+
QFileDialog, QComboBox, QStackedWidget, QWidget, QMessageBox,
|
|
24
|
+
QLineEdit, QTextEdit, QApplication, QProgressBar
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# === our I/O & stretch — migrate from SASv2 ===
|
|
28
|
+
from setiastro.saspro.legacy.image_manager import load_image, save_image # <<<< IMPORTANT
|
|
29
|
+
try:
|
|
30
|
+
from setiastro.saspro.imageops.stretch import stretch_mono_image, stretch_color_image
|
|
31
|
+
except Exception:
|
|
32
|
+
stretch_mono_image = None
|
|
33
|
+
stretch_color_image = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
_NONFITS_META_KEYS = {
|
|
37
|
+
"FILE_PATH",
|
|
38
|
+
"FITS_HEADER",
|
|
39
|
+
"BIT_DEPTH",
|
|
40
|
+
"WCS_HEADER",
|
|
41
|
+
"__HEADER_SNAPSHOT__",
|
|
42
|
+
"ORIGINAL_HEADER",
|
|
43
|
+
"PRE_SOLVE_HEADER",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
def _strip_nonfits_meta_keys_from_header(h: Header | None) -> Header:
|
|
47
|
+
"""
|
|
48
|
+
Return a copy of the header with all of our internal, non-FITS metadata
|
|
49
|
+
keys removed. This prevents HIERARCH warnings and WCS failures on keys
|
|
50
|
+
like FILE_PATH with very long values.
|
|
51
|
+
"""
|
|
52
|
+
if not isinstance(h, Header):
|
|
53
|
+
return Header()
|
|
54
|
+
|
|
55
|
+
out = h.copy()
|
|
56
|
+
for k in list(out.keys()):
|
|
57
|
+
if k.upper() in _NONFITS_META_KEYS:
|
|
58
|
+
try:
|
|
59
|
+
out.remove(k)
|
|
60
|
+
except Exception:
|
|
61
|
+
pass
|
|
62
|
+
return out
|
|
63
|
+
|
|
64
|
+
# --- Lightweight, modeless status popup for headless runs ---
|
|
65
|
+
_STATUS_POPUP = None # module-level singleton
|
|
66
|
+
|
|
67
|
+
class _SolveStatusPopup(QDialog):
|
|
68
|
+
def __init__(self, parent=None):
|
|
69
|
+
super().__init__(parent, Qt.WindowType.Tool)
|
|
70
|
+
self.setObjectName("plate_solve_status_popup")
|
|
71
|
+
self.setWindowTitle(self.tr("Plate Solving"))
|
|
72
|
+
self.setWindowModality(Qt.WindowModality.NonModal)
|
|
73
|
+
self.setWindowFlag(Qt.WindowType.WindowStaysOnTopHint, True)
|
|
74
|
+
self.setMinimumWidth(420)
|
|
75
|
+
|
|
76
|
+
lay = QVBoxLayout(self)
|
|
77
|
+
lay.setContentsMargins(12, 12, 12, 12)
|
|
78
|
+
lay.setSpacing(10)
|
|
79
|
+
|
|
80
|
+
self.label = QLabel(self.tr("Starting…"), self)
|
|
81
|
+
self.label.setWordWrap(True)
|
|
82
|
+
lay.addWidget(self.label)
|
|
83
|
+
|
|
84
|
+
self.bar = QProgressBar(self)
|
|
85
|
+
self.bar.setRange(0, 0) # indeterminate
|
|
86
|
+
lay.addWidget(self.bar)
|
|
87
|
+
|
|
88
|
+
row = QHBoxLayout()
|
|
89
|
+
row.addStretch(1)
|
|
90
|
+
hide_btn = QPushButton(self.tr("Hide"), self)
|
|
91
|
+
hide_btn.clicked.connect(self.hide)
|
|
92
|
+
row.addWidget(hide_btn)
|
|
93
|
+
lay.addLayout(row)
|
|
94
|
+
|
|
95
|
+
def update_text(self, text: str):
|
|
96
|
+
self.label.setText(text or "")
|
|
97
|
+
self.label.repaint() # quick visual feedback
|
|
98
|
+
QApplication.processEvents()
|
|
99
|
+
|
|
100
|
+
def _status_popup_open(parent, text: str = ""):
|
|
101
|
+
"""Show (or create) the singleton status popup for headless runs."""
|
|
102
|
+
global _STATUS_POPUP
|
|
103
|
+
if _STATUS_POPUP is None:
|
|
104
|
+
host = parent if isinstance(parent, QWidget) else QApplication.activeWindow()
|
|
105
|
+
_STATUS_POPUP = _SolveStatusPopup(host)
|
|
106
|
+
if text:
|
|
107
|
+
_STATUS_POPUP.update_text(text)
|
|
108
|
+
_STATUS_POPUP.show()
|
|
109
|
+
_STATUS_POPUP.raise_()
|
|
110
|
+
QApplication.processEvents()
|
|
111
|
+
return _STATUS_POPUP
|
|
112
|
+
|
|
113
|
+
def _status_popup_update(text: str):
|
|
114
|
+
global _STATUS_POPUP
|
|
115
|
+
if _STATUS_POPUP is not None:
|
|
116
|
+
_STATUS_POPUP.update_text(text)
|
|
117
|
+
|
|
118
|
+
def _status_popup_close():
|
|
119
|
+
"""Hide (but do not destroy) the singleton status popup if it exists."""
|
|
120
|
+
global _STATUS_POPUP
|
|
121
|
+
try:
|
|
122
|
+
if _STATUS_POPUP is not None:
|
|
123
|
+
_STATUS_POPUP.hide()
|
|
124
|
+
# keep instance for reuse (fast re-open)
|
|
125
|
+
except Exception:
|
|
126
|
+
# Completely safe to ignore; worst case the popup was already gone.
|
|
127
|
+
pass
|
|
128
|
+
|
|
129
|
+
def _sleep_ui(ms: int):
|
|
130
|
+
"""Non-blocking sleep that keeps the UI responsive."""
|
|
131
|
+
loop = QEventLoop()
|
|
132
|
+
QTimer.singleShot(ms, loop.quit)
|
|
133
|
+
loop.exec()
|
|
134
|
+
|
|
135
|
+
def _with_events():
|
|
136
|
+
"""Yield to the UI event loop briefly."""
|
|
137
|
+
QApplication.processEvents()
|
|
138
|
+
|
|
139
|
+
def _set_status_ui(parent, text: str):
|
|
140
|
+
"""
|
|
141
|
+
Update dialog/main-window status or batch log; if neither exists (headless),
|
|
142
|
+
show/update a small modeless popup. Always pumps events for responsiveness.
|
|
143
|
+
"""
|
|
144
|
+
try:
|
|
145
|
+
updated_any = False
|
|
146
|
+
|
|
147
|
+
def _do():
|
|
148
|
+
nonlocal updated_any
|
|
149
|
+
target = None
|
|
150
|
+
# Dialog status label?
|
|
151
|
+
if hasattr(parent, "status") and isinstance(getattr(parent, "status"), QLabel):
|
|
152
|
+
target = parent.status
|
|
153
|
+
# Named child fallback
|
|
154
|
+
if target is None and hasattr(parent, "findChild"):
|
|
155
|
+
target = parent.findChild(QLabel, "status_label")
|
|
156
|
+
if target is not None:
|
|
157
|
+
target.setText(text)
|
|
158
|
+
updated_any = True
|
|
159
|
+
|
|
160
|
+
# Batch log?
|
|
161
|
+
logw = getattr(parent, "log", None)
|
|
162
|
+
if logw and hasattr(logw, "append"):
|
|
163
|
+
tr_status = QCoreApplication.translate("PlateSolver", "Status:")
|
|
164
|
+
if text and (text.startswith("Status:") or text.startswith(tr_status) or text.startswith("▶") or text.startswith("✔") or text.startswith("❌")):
|
|
165
|
+
logw.append(text)
|
|
166
|
+
updated_any = True
|
|
167
|
+
|
|
168
|
+
# If we couldn't update any inline widget, use the headless popup.
|
|
169
|
+
if not updated_any:
|
|
170
|
+
_status_popup_open(parent, text)
|
|
171
|
+
else:
|
|
172
|
+
# If inline widgets exist and popup is visible, keep it quiet.
|
|
173
|
+
_status_popup_update(text)
|
|
174
|
+
|
|
175
|
+
QApplication.processEvents()
|
|
176
|
+
|
|
177
|
+
if isinstance(parent, QWidget):
|
|
178
|
+
QTimer.singleShot(0, _do)
|
|
179
|
+
else:
|
|
180
|
+
_do()
|
|
181
|
+
except Exception:
|
|
182
|
+
# Last-resort popup if even the above failed
|
|
183
|
+
try:
|
|
184
|
+
_status_popup_open(parent, text)
|
|
185
|
+
except Exception:
|
|
186
|
+
pass
|
|
187
|
+
|
|
188
|
+
def _wait_process(proc: QProcess, timeout_ms: int, parent=None) -> bool:
|
|
189
|
+
"""
|
|
190
|
+
Incrementally wait for a QProcess while pumping UI events so the dialog stays responsive.
|
|
191
|
+
Returns True if the process finished with NormalExit, else False.
|
|
192
|
+
"""
|
|
193
|
+
deadline = time.monotonic() + (timeout_ms / 1000.0)
|
|
194
|
+
step_ms = 100
|
|
195
|
+
|
|
196
|
+
while time.monotonic() < deadline:
|
|
197
|
+
if proc.state() == QProcess.ProcessState.NotRunning:
|
|
198
|
+
break
|
|
199
|
+
_sleep_ui(step_ms)
|
|
200
|
+
|
|
201
|
+
if proc.state() != QProcess.ProcessState.NotRunning:
|
|
202
|
+
# Timed out: try to stop the process cleanly, then force kill.
|
|
203
|
+
try:
|
|
204
|
+
proc.terminate()
|
|
205
|
+
if not proc.waitForFinished(2000):
|
|
206
|
+
proc.kill()
|
|
207
|
+
proc.waitForFinished(2000)
|
|
208
|
+
except Exception:
|
|
209
|
+
pass
|
|
210
|
+
_set_status_ui(parent, QCoreApplication.translate("PlateSolver", "Status: process timed out."))
|
|
211
|
+
return False
|
|
212
|
+
|
|
213
|
+
if proc.exitStatus() != QProcess.ExitStatus.NormalExit:
|
|
214
|
+
_set_status_ui(parent, QCoreApplication.translate("PlateSolver", "Status: process did not exit normally."))
|
|
215
|
+
return False
|
|
216
|
+
|
|
217
|
+
return True
|
|
218
|
+
|
|
219
|
+
# --- astrometry.net config (web API) ---
|
|
220
|
+
ASTROMETRY_API_URL_DEFAULT = "https://nova.astrometry.net/api/"
|
|
221
|
+
|
|
222
|
+
def _get_astrometry_api_url(settings) -> str:
|
|
223
|
+
return (settings.value("astrometry/server_url", "", type=str) or ASTROMETRY_API_URL_DEFAULT).rstrip("/") + "/"
|
|
224
|
+
|
|
225
|
+
def _get_solvefield_exe(settings) -> str:
|
|
226
|
+
# Support both SASpro-style and legacy keys
|
|
227
|
+
cand = [
|
|
228
|
+
settings.value("paths/solve_field", "", type=str) or "",
|
|
229
|
+
settings.value("astrometry/solvefield_path", "", type=str) or "",
|
|
230
|
+
]
|
|
231
|
+
for p in cand:
|
|
232
|
+
if p and os.path.exists(p):
|
|
233
|
+
return p
|
|
234
|
+
return cand[0] # may be empty (used to decide web vs. local)
|
|
235
|
+
|
|
236
|
+
def _get_astrometry_api_key(settings) -> str:
|
|
237
|
+
return settings.value("astrometry/api_key", "", type=str) or ""
|
|
238
|
+
|
|
239
|
+
def _set_astrometry_api_key(settings, key: str):
|
|
240
|
+
settings.setValue("astrometry/api_key", key or "")
|
|
241
|
+
|
|
242
|
+
def _wcs_header_from_astrometry_calib(calib: dict, image_shape: tuple[int, ...]) -> Header:
|
|
243
|
+
"""
|
|
244
|
+
calib: dict with keys 'ra','dec','pixscale'(arcsec/px),'orientation'(deg, +CCW).
|
|
245
|
+
image_shape: (H, W) or (H, W, C). CRPIX is image center (1-based vs 0-based—astropy expects pixel coordinates in "fits" sense; mid-frame is fine).
|
|
246
|
+
"""
|
|
247
|
+
H = int(image_shape[0]); W = int(image_shape[1])
|
|
248
|
+
h = Header()
|
|
249
|
+
h["CTYPE1"] = "RA---TAN"
|
|
250
|
+
h["CTYPE2"] = "DEC--TAN"
|
|
251
|
+
h["CRPIX1"] = W / 2.0
|
|
252
|
+
h["CRPIX2"] = H / 2.0
|
|
253
|
+
h["CRVAL1"] = float(calib["ra"])
|
|
254
|
+
h["CRVAL2"] = float(calib["dec"])
|
|
255
|
+
scale_deg = float(calib["pixscale"]) / 3600.0 # deg/px
|
|
256
|
+
theta = math.radians(float(calib.get("orientation", 0.0)))
|
|
257
|
+
# note: same sign convention as your SASv2 builder
|
|
258
|
+
h["CD1_1"] = -scale_deg * math.cos(theta)
|
|
259
|
+
h["CD1_2"] = scale_deg * math.sin(theta)
|
|
260
|
+
h["CD2_1"] = -scale_deg * math.sin(theta)
|
|
261
|
+
h["CD2_2"] = -scale_deg * math.cos(theta)
|
|
262
|
+
h["RADECSYS"] = "ICRS"
|
|
263
|
+
h["WCSAXES"] = 2
|
|
264
|
+
return h
|
|
265
|
+
|
|
266
|
+
# If you already ship 'requests', this is simplest:
|
|
267
|
+
|
|
268
|
+
# ---- Seed controls (persisted in QSettings) ----
|
|
269
|
+
def _get_seed_mode(settings) -> str:
|
|
270
|
+
# "auto" (from header), "manual" (use user values), "none" (blind)
|
|
271
|
+
return (settings.value("astap/seed_mode", "auto", type=str) or "auto").lower()
|
|
272
|
+
|
|
273
|
+
def _set_seed_mode(settings, mode: str):
|
|
274
|
+
settings.setValue("astap/seed_mode", (mode or "auto").lower())
|
|
275
|
+
|
|
276
|
+
def _get_manual_ra(settings) -> str:
|
|
277
|
+
# store raw string so user can type hh:mm:ss or degrees
|
|
278
|
+
return settings.value("astap/manual_ra", "", type=str) or ""
|
|
279
|
+
|
|
280
|
+
def _get_manual_dec(settings) -> str:
|
|
281
|
+
return settings.value("astap/manual_dec", "", type=str) or ""
|
|
282
|
+
|
|
283
|
+
def _get_manual_scale(settings) -> float | None:
|
|
284
|
+
try:
|
|
285
|
+
v = settings.value("astap/manual_scale_arcsec", "", type=str)
|
|
286
|
+
return float(v) if v not in (None, "",) else None
|
|
287
|
+
except Exception:
|
|
288
|
+
return None
|
|
289
|
+
|
|
290
|
+
@lru_cache(maxsize=256)
|
|
291
|
+
def _parse_ra_input_to_deg(s: str) -> float | None:
|
|
292
|
+
"""Parse RA input string to degrees. Cached for repeated lookups."""
|
|
293
|
+
s = (s or "").strip()
|
|
294
|
+
if not s: return None
|
|
295
|
+
# allow plain degrees if > 24 or contains "deg"
|
|
296
|
+
try:
|
|
297
|
+
if re.search(r"[a-zA-Z]", s) is None and ":" not in s and " " not in s:
|
|
298
|
+
x = float(s)
|
|
299
|
+
return x if x > 24.0 else x * 15.0
|
|
300
|
+
except Exception:
|
|
301
|
+
pass
|
|
302
|
+
parts = re.split(r"[:\s]+", s)
|
|
303
|
+
try:
|
|
304
|
+
if len(parts) >= 3:
|
|
305
|
+
hh, mm, ss = float(parts[0]), float(parts[1]), float(parts[2])
|
|
306
|
+
elif len(parts) == 2:
|
|
307
|
+
hh, mm, ss = float(parts[0]), float(parts[1]), 0.0
|
|
308
|
+
else:
|
|
309
|
+
hh, mm, ss = float(parts[0]), 0.0, 0.0
|
|
310
|
+
return (abs(hh) + mm/60.0 + ss/3600.0) * 15.0
|
|
311
|
+
except Exception:
|
|
312
|
+
return None
|
|
313
|
+
|
|
314
|
+
@lru_cache(maxsize=256)
|
|
315
|
+
def _parse_dec_input_to_deg(s: str) -> float | None:
|
|
316
|
+
"""Parse DEC input string to degrees. Cached for repeated lookups."""
|
|
317
|
+
s = (s or "").strip()
|
|
318
|
+
if not s: return None
|
|
319
|
+
sign = -1.0 if s.startswith("-") else 1.0
|
|
320
|
+
s = s.lstrip("+-")
|
|
321
|
+
parts = re.split(r"[:\s]+", s)
|
|
322
|
+
try:
|
|
323
|
+
if len(parts) >= 3:
|
|
324
|
+
dd, mm, ss = float(parts[0]), float(parts[1]), float(parts[2])
|
|
325
|
+
elif len(parts) == 2:
|
|
326
|
+
dd, mm, ss = float(parts[0]), float(parts[1]), 0.0
|
|
327
|
+
else:
|
|
328
|
+
return sign * float(parts[0])
|
|
329
|
+
return sign * (abs(dd) + mm/60.0 + ss/3600.0)
|
|
330
|
+
except Exception:
|
|
331
|
+
return None
|
|
332
|
+
|
|
333
|
+
def _set_manual_seed(settings, ra: str, dec: str, scale_arcsec: float | None):
|
|
334
|
+
settings.setValue("astap/manual_ra", ra or "")
|
|
335
|
+
settings.setValue("astap/manual_dec", dec or "")
|
|
336
|
+
if scale_arcsec is None:
|
|
337
|
+
settings.setValue("astap/manual_scale_arcsec", "")
|
|
338
|
+
else:
|
|
339
|
+
settings.setValue("astap/manual_scale_arcsec", str(float(scale_arcsec)))
|
|
340
|
+
|
|
341
|
+
def _astrometry_api_request(method: str, url: str, *, data=None, files=None,
|
|
342
|
+
timeout=(10, 60),
|
|
343
|
+
max_retries: int = 5,
|
|
344
|
+
parent=None,
|
|
345
|
+
stage: str = "") -> dict | None:
|
|
346
|
+
"""
|
|
347
|
+
Robust request with retries, exponential backoff + jitter.
|
|
348
|
+
"""
|
|
349
|
+
if requests is None:
|
|
350
|
+
print("Requests not available for astrometry.net API.")
|
|
351
|
+
return None
|
|
352
|
+
|
|
353
|
+
import random
|
|
354
|
+
import requests as _rq
|
|
355
|
+
for attempt in range(1, max_retries + 1):
|
|
356
|
+
try:
|
|
357
|
+
if method.upper() == "POST":
|
|
358
|
+
# ✅ IMPORTANT: rewind any file handles before each attempt,
|
|
359
|
+
# because requests consumes them.
|
|
360
|
+
if files:
|
|
361
|
+
try:
|
|
362
|
+
for v in files.values():
|
|
363
|
+
if hasattr(v, "seek"):
|
|
364
|
+
v.seek(0)
|
|
365
|
+
except Exception:
|
|
366
|
+
pass
|
|
367
|
+
|
|
368
|
+
r = requests.post(url, data=data, files=files, timeout=timeout)
|
|
369
|
+
else:
|
|
370
|
+
r = requests.get(url, timeout=timeout)
|
|
371
|
+
|
|
372
|
+
if r.status_code == 200:
|
|
373
|
+
try:
|
|
374
|
+
return r.json()
|
|
375
|
+
except Exception:
|
|
376
|
+
return None
|
|
377
|
+
|
|
378
|
+
if r.status_code in (429, 500, 502, 503, 504):
|
|
379
|
+
raise _rq.RequestException(f"HTTP {r.status_code}")
|
|
380
|
+
else:
|
|
381
|
+
print(f"Astrometry API HTTP {r.status_code} (no retry).")
|
|
382
|
+
return None
|
|
383
|
+
|
|
384
|
+
except (_rq.Timeout, _rq.ConnectionError, _rq.RequestException) as e:
|
|
385
|
+
print(f"Astrometry API request error ({stage}): {e}")
|
|
386
|
+
if attempt >= max_retries:
|
|
387
|
+
break
|
|
388
|
+
delay = min(8.0, 0.5 * (2 ** (attempt - 1))) + random.random() * 0.2
|
|
389
|
+
msg = QCoreApplication.translate("PlateSolver", "Status: {0} retry {1}/{2}…").format(stage or 'request', attempt, max_retries)
|
|
390
|
+
_set_status_ui(parent, msg)
|
|
391
|
+
_sleep_ui(int(delay * 1000))
|
|
392
|
+
return None
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
# ---------------------------------------------------------------------
|
|
396
|
+
# Utilities (headers, parsing, normalization)
|
|
397
|
+
# ---------------------------------------------------------------------
|
|
398
|
+
|
|
399
|
+
def _parse_astap_wcs_file(wcs_path: str) -> Dict[str, Any]:
|
|
400
|
+
"""
|
|
401
|
+
Robustly load the .wcs file using astropy (instead of line parsing).
|
|
402
|
+
Returns a dictionary of key → value.
|
|
403
|
+
"""
|
|
404
|
+
if not wcs_path or not os.path.exists(wcs_path):
|
|
405
|
+
return {}
|
|
406
|
+
|
|
407
|
+
try:
|
|
408
|
+
header = fits.getheader(wcs_path)
|
|
409
|
+
return dict(header)
|
|
410
|
+
except Exception as e:
|
|
411
|
+
print(f"[ASTAP] Failed to parse .wcs with astropy: {e}")
|
|
412
|
+
return {}
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _get_astap_exe(settings) -> str:
|
|
416
|
+
# Support both SASpro and SASv2 keys.
|
|
417
|
+
cand = [
|
|
418
|
+
settings.value("paths/astap", "", type=str) or "",
|
|
419
|
+
settings.value("astap/exe_path", "", type=str) or "",
|
|
420
|
+
]
|
|
421
|
+
for p in cand:
|
|
422
|
+
if p and os.path.exists(p):
|
|
423
|
+
return p
|
|
424
|
+
return cand[0] # return first even if missing so we can error nicely
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _as_header(hdr_like: Any) -> Header | None:
|
|
428
|
+
"""
|
|
429
|
+
Try to coerce whatever we have in metadata to a proper astropy Header.
|
|
430
|
+
Accepts: fits.Header, dict, flattened string blobs (best effort).
|
|
431
|
+
"""
|
|
432
|
+
if hdr_like is None:
|
|
433
|
+
return None
|
|
434
|
+
if isinstance(hdr_like, Header):
|
|
435
|
+
return hdr_like
|
|
436
|
+
|
|
437
|
+
# 1) flattened single string? try hard to parse
|
|
438
|
+
if isinstance(hdr_like, str):
|
|
439
|
+
h = _parse_header_blob_to_header(hdr_like)
|
|
440
|
+
return h if len(h.keys()) else None
|
|
441
|
+
|
|
442
|
+
# 2) dict-ish
|
|
443
|
+
try:
|
|
444
|
+
d = dict(hdr_like)
|
|
445
|
+
h = Header()
|
|
446
|
+
int_keys = {"A_ORDER", "B_ORDER", "AP_ORDER", "BP_ORDER", "WCSAXES", "NAXIS", "NAXIS1", "NAXIS2", "NAXIS3"}
|
|
447
|
+
for k, v in d.items():
|
|
448
|
+
K = str(k).upper()
|
|
449
|
+
|
|
450
|
+
# 🚫 Never promote our internal metadata keys to FITS cards
|
|
451
|
+
if K in _NONFITS_META_KEYS:
|
|
452
|
+
continue
|
|
453
|
+
|
|
454
|
+
try:
|
|
455
|
+
if K in int_keys:
|
|
456
|
+
h[K] = int(float(str(v).strip().split()[0]))
|
|
457
|
+
elif re.match(r"^(?:A|B|AP|BP)_\d+_\d+$", K) or \
|
|
458
|
+
re.match(r"^(?:CRPIX|CRVAL|CDELT|CD|PC|CROTA|LATPOLE|LONPOLE|EQUINOX)\d?_?\d*$", K):
|
|
459
|
+
h[K] = float(str(v).strip().split()[0])
|
|
460
|
+
elif K.startswith("CTYPE") or K.startswith("CUNIT") or K in {"RADECSYS"}:
|
|
461
|
+
h[K] = str(v).strip().strip("'\"")
|
|
462
|
+
else:
|
|
463
|
+
h[K] = v
|
|
464
|
+
except Exception:
|
|
465
|
+
pass
|
|
466
|
+
|
|
467
|
+
# SIP order parity
|
|
468
|
+
if "A_ORDER" in h and "B_ORDER" not in h:
|
|
469
|
+
h["B_ORDER"] = int(h["A_ORDER"])
|
|
470
|
+
if "B_ORDER" in h and "A_ORDER" not in h:
|
|
471
|
+
h["A_ORDER"] = int(h["B_ORDER"])
|
|
472
|
+
return h
|
|
473
|
+
except Exception:
|
|
474
|
+
return None
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _parse_header_blob_to_header(blob: str) -> Header:
|
|
478
|
+
"""
|
|
479
|
+
Turn a flattened header blob into a real fits.Header.
|
|
480
|
+
Handles 80-char concatenated cards or KEY=VAL regex fallback.
|
|
481
|
+
"""
|
|
482
|
+
s = (blob or "").strip()
|
|
483
|
+
h = fits.Header()
|
|
484
|
+
|
|
485
|
+
# A) 80-char card chunking (if truly concatenated FITS cards)
|
|
486
|
+
if len(s) >= 80 and len(s) % 80 == 0:
|
|
487
|
+
cards = [s[i:i+80] for i in range(0, len(s), 80)]
|
|
488
|
+
for line in cards:
|
|
489
|
+
try:
|
|
490
|
+
card = fits.Card.fromstring(line)
|
|
491
|
+
if card.keyword not in ("COMMENT", "HISTORY", "END", ""):
|
|
492
|
+
h.append(card)
|
|
493
|
+
except Exception:
|
|
494
|
+
pass
|
|
495
|
+
if len(h.keys()):
|
|
496
|
+
return h
|
|
497
|
+
|
|
498
|
+
# B) Fallback regex KEY = value … next KEY
|
|
499
|
+
pattern = r"([A-Z0-9_]+)\s*=\s*([^=]*?)(?=\s{2,}[A-Z0-9_]+\s*=|$)"
|
|
500
|
+
for m in re.finditer(pattern, s):
|
|
501
|
+
key = m.group(1).strip().upper()
|
|
502
|
+
vraw = m.group(2).strip()
|
|
503
|
+
if vraw.startswith("'") and vraw.endswith("'"):
|
|
504
|
+
val = vraw[1:-1].strip()
|
|
505
|
+
else:
|
|
506
|
+
try:
|
|
507
|
+
if re.fullmatch(r"[+-]?\d+", vraw): val = int(vraw)
|
|
508
|
+
else: val = float(vraw)
|
|
509
|
+
except Exception:
|
|
510
|
+
val = vraw
|
|
511
|
+
try: h[key] = val
|
|
512
|
+
except Exception as e:
|
|
513
|
+
import logging
|
|
514
|
+
logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
|
|
515
|
+
|
|
516
|
+
if "A_ORDER" in h and "B_ORDER" not in h:
|
|
517
|
+
h["B_ORDER"] = int(h["A_ORDER"])
|
|
518
|
+
if "B_ORDER" in h and "A_ORDER" not in h:
|
|
519
|
+
h["A_ORDER"] = int(h["B_ORDER"])
|
|
520
|
+
|
|
521
|
+
return h
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def _strip_wcs_keys(h: Header) -> Header:
|
|
525
|
+
"""Return a copy without WCS/SIP keys (so ASTAP can write fresh)."""
|
|
526
|
+
h = h.copy()
|
|
527
|
+
for key in list(h.keys()):
|
|
528
|
+
ku = key.upper()
|
|
529
|
+
for prefix in (
|
|
530
|
+
"CRPIX", "CRVAL", "CDELT", "CROTA",
|
|
531
|
+
"CD1_", "CD2_", "PC", "CTYPE", "CUNIT",
|
|
532
|
+
"WCSAXES", "LATPOLE", "LONPOLE", "EQUINOX",
|
|
533
|
+
"PV1_", "PV2_", "SIP",
|
|
534
|
+
"A_ORDER", "B_ORDER", "AP_ORDER", "BP_ORDER",
|
|
535
|
+
"A_", "B_", "AP_", "BP_", "PLTSOLVD"
|
|
536
|
+
):
|
|
537
|
+
if ku.startswith(prefix):
|
|
538
|
+
h.pop(key, None)
|
|
539
|
+
break
|
|
540
|
+
return h
|
|
541
|
+
|
|
542
|
+
def _minimal_header_for_gray2d(h: int, w: int) -> Header:
|
|
543
|
+
hdu = Header()
|
|
544
|
+
hdu["SIMPLE"] = True
|
|
545
|
+
hdu["BITPIX"] = -32
|
|
546
|
+
hdu["NAXIS"] = 2
|
|
547
|
+
hdu["NAXIS1"] = int(w)
|
|
548
|
+
hdu["NAXIS2"] = int(h)
|
|
549
|
+
hdu["BZERO"] = 0.0
|
|
550
|
+
hdu["BSCALE"] = 1.0
|
|
551
|
+
hdu.add_comment("Temp FITS written for ASTAP solve.")
|
|
552
|
+
return hdu
|
|
553
|
+
|
|
554
|
+
def _minimal_header_for(img: np.ndarray, is_mono: bool) -> Header:
|
|
555
|
+
H = int(img.shape[0]) if img.ndim >= 2 else 1
|
|
556
|
+
W = int(img.shape[1]) if img.ndim >= 2 else 1
|
|
557
|
+
C = int(img.shape[2]) if (img.ndim == 3) else 1
|
|
558
|
+
h = Header()
|
|
559
|
+
h["SIMPLE"] = True
|
|
560
|
+
h["BITPIX"] = -32
|
|
561
|
+
h["NAXIS"] = 2 if is_mono else 3
|
|
562
|
+
h["NAXIS1"] = W
|
|
563
|
+
h["NAXIS2"] = H
|
|
564
|
+
if not is_mono:
|
|
565
|
+
h["NAXIS3"] = C
|
|
566
|
+
h["BZERO"] = 0.0
|
|
567
|
+
h["BSCALE"] = 1.0
|
|
568
|
+
h.add_comment("Temp FITS written for ASTAP solve.")
|
|
569
|
+
return h
|
|
570
|
+
|
|
571
|
+
def _write_temp_fit_web_16bit(gray2d_unit: np.ndarray) -> str:
|
|
572
|
+
"""
|
|
573
|
+
Write full-res mono FITS as 16-bit unsigned for web upload.
|
|
574
|
+
gray2d_unit must be float32 in [0,1].
|
|
575
|
+
Returns path to temp .fits.
|
|
576
|
+
"""
|
|
577
|
+
import os
|
|
578
|
+
import tempfile
|
|
579
|
+
import numpy as np
|
|
580
|
+
from astropy.io import fits
|
|
581
|
+
from astropy.io.fits import Header
|
|
582
|
+
|
|
583
|
+
if gray2d_unit.ndim != 2:
|
|
584
|
+
raise ValueError("Expected 2-D grayscale array for web FITS.")
|
|
585
|
+
|
|
586
|
+
g = np.clip(gray2d_unit.astype(np.float32), 0.0, 1.0)
|
|
587
|
+
u16 = (g * 65535.0 + 0.5).astype(np.uint16)
|
|
588
|
+
|
|
589
|
+
H, W = u16.shape
|
|
590
|
+
hdr = Header()
|
|
591
|
+
hdr["SIMPLE"] = True
|
|
592
|
+
hdr["BITPIX"] = 16
|
|
593
|
+
hdr["NAXIS"] = 2
|
|
594
|
+
hdr["NAXIS1"] = int(W)
|
|
595
|
+
hdr["NAXIS2"] = int(H)
|
|
596
|
+
hdr.add_comment("Temp FITS (16-bit) written for Astrometry.net upload.")
|
|
597
|
+
|
|
598
|
+
tmp = tempfile.NamedTemporaryFile(suffix=".fits", delete=False)
|
|
599
|
+
tmp_path = tmp.name
|
|
600
|
+
tmp.close()
|
|
601
|
+
|
|
602
|
+
fits.PrimaryHDU(u16, header=hdr).writeto(tmp_path, overwrite=True, output_verify="silentfix")
|
|
603
|
+
|
|
604
|
+
try:
|
|
605
|
+
print(f"[tempfits-web] Saved 16-bit FITS to: {tmp_path} (size={os.path.getsize(tmp_path)} bytes)")
|
|
606
|
+
except Exception:
|
|
607
|
+
pass
|
|
608
|
+
|
|
609
|
+
return tmp_path
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def _astrometry_download_wcs_file(settings, job_id: int, parent=None) -> Header | None:
|
|
613
|
+
"""
|
|
614
|
+
Download the solved WCS FITS from astrometry.net.
|
|
615
|
+
This includes SIP terms when present.
|
|
616
|
+
Returns fits.Header or None.
|
|
617
|
+
"""
|
|
618
|
+
import os
|
|
619
|
+
import tempfile
|
|
620
|
+
from astropy.io import fits
|
|
621
|
+
from astropy.io.fits import Header
|
|
622
|
+
|
|
623
|
+
base_site = _get_astrometry_api_url(settings).split("/api/")[0].rstrip("/") + "/"
|
|
624
|
+
url = base_site + f"wcs_file/{int(job_id)}"
|
|
625
|
+
|
|
626
|
+
_set_status_ui(parent, QCoreApplication.translate("PlateSolver", "Status: Downloading WCS file (with SIP) from Astrometry.net…"))
|
|
627
|
+
try:
|
|
628
|
+
r = requests.get(url, timeout=(10, 60))
|
|
629
|
+
if r.status_code != 200 or len(r.content) < 2000:
|
|
630
|
+
print(f"[Astrometry] WCS download failed HTTP {r.status_code}, bytes={len(r.content)}")
|
|
631
|
+
return None
|
|
632
|
+
|
|
633
|
+
tmp = tempfile.NamedTemporaryFile(suffix=".wcs.fits", delete=False)
|
|
634
|
+
tmp_path = tmp.name
|
|
635
|
+
tmp.write(r.content)
|
|
636
|
+
tmp.close()
|
|
637
|
+
|
|
638
|
+
try:
|
|
639
|
+
hdr = fits.getheader(tmp_path)
|
|
640
|
+
h2 = Header()
|
|
641
|
+
for k, v in dict(hdr).items():
|
|
642
|
+
if k not in ("COMMENT", "HISTORY", "END"):
|
|
643
|
+
h2[k] = v
|
|
644
|
+
return h2
|
|
645
|
+
finally:
|
|
646
|
+
try: os.remove(tmp_path)
|
|
647
|
+
except Exception as e:
|
|
648
|
+
import logging
|
|
649
|
+
logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
|
|
650
|
+
|
|
651
|
+
except Exception as e:
|
|
652
|
+
print("[Astrometry] WCS download exception:", e)
|
|
653
|
+
return None
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def _float01(arr: np.ndarray) -> np.ndarray:
|
|
657
|
+
a = np.asarray(arr)
|
|
658
|
+
if a.dtype.kind in "ui":
|
|
659
|
+
info = np.iinfo(a.dtype)
|
|
660
|
+
if info.max == 0: return a.astype(np.float32)
|
|
661
|
+
return (a.astype(np.float32) / float(info.max))
|
|
662
|
+
return np.clip(a.astype(np.float32), 0.0, 1.0)
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def _normalize_for_astap(img: np.ndarray) -> np.ndarray:
|
|
666
|
+
"""
|
|
667
|
+
Use migrated stretch functions when available.
|
|
668
|
+
Returns float32 in [0,1], 2D for mono or 3D for color.
|
|
669
|
+
Guaranteed to return something usable even if stretch funcs fail.
|
|
670
|
+
"""
|
|
671
|
+
f01 = _float01(img)
|
|
672
|
+
|
|
673
|
+
# Mono
|
|
674
|
+
if f01.ndim == 2 or (f01.ndim == 3 and f01.shape[2] == 1):
|
|
675
|
+
if stretch_mono_image is not None:
|
|
676
|
+
try:
|
|
677
|
+
print("DEBUG stretching mono")
|
|
678
|
+
out = stretch_mono_image(f01, 0.1, False)
|
|
679
|
+
return np.clip(out.astype(np.float32), 0.0, 1.0)
|
|
680
|
+
except Exception as e:
|
|
681
|
+
print("DEBUG mono stretch failed, fallback:", e)
|
|
682
|
+
return np.clip(f01.astype(np.float32), 0.0, 1.0)
|
|
683
|
+
|
|
684
|
+
# Color
|
|
685
|
+
if stretch_color_image is not None:
|
|
686
|
+
try:
|
|
687
|
+
print("DEBUG stretching color")
|
|
688
|
+
out = stretch_color_image(f01, 0.1, False, False)
|
|
689
|
+
return np.clip(out.astype(np.float32), 0.0, 1.0)
|
|
690
|
+
except Exception as e:
|
|
691
|
+
print("DEBUG color stretch failed, fallback:", e)
|
|
692
|
+
|
|
693
|
+
return np.clip(f01.astype(np.float32), 0.0, 1.0)
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def _first_float(v):
|
|
699
|
+
if v is None: return None
|
|
700
|
+
if isinstance(v, (int, float)): return float(v)
|
|
701
|
+
s = str(v)
|
|
702
|
+
m = re.search(r"[+-]?\d*\.?\d+(?:[eE][+-]?\d+)?", s)
|
|
703
|
+
return float(m.group(0)) if m else None
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def _first_int(v):
|
|
707
|
+
if v is None: return None
|
|
708
|
+
if isinstance(v, int): return v
|
|
709
|
+
if isinstance(v, float): return int(round(v))
|
|
710
|
+
s = str(v)
|
|
711
|
+
m = re.search(r"[+-]?\d+", s)
|
|
712
|
+
return int(m.group(0)) if m else None
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def _parse_ra_deg(h: Header) -> float | None:
|
|
716
|
+
ra = _first_float(h.get("CRVAL1"))
|
|
717
|
+
if ra is not None: return ra
|
|
718
|
+
ra = _first_float(h.get("RA"))
|
|
719
|
+
if ra is not None and 0.0 <= ra < 360.0: return ra
|
|
720
|
+
for key in ("OBJCTRA", "RA"):
|
|
721
|
+
s = h.get(key);
|
|
722
|
+
if not s: continue
|
|
723
|
+
s = str(s).strip()
|
|
724
|
+
parts = re.split(r"[:\s]+", s)
|
|
725
|
+
try:
|
|
726
|
+
if len(parts) >= 3:
|
|
727
|
+
hh, mm, ss = float(parts[0]), float(parts[1]), float(parts[2])
|
|
728
|
+
elif len(parts) == 2:
|
|
729
|
+
hh, mm, ss = float(parts[0]), float(parts[1]), 0.0
|
|
730
|
+
else:
|
|
731
|
+
x = float(parts[0]);
|
|
732
|
+
return x if x > 24 else x*15.0
|
|
733
|
+
return (abs(hh) + mm/60.0 + ss/3600.0) * 15.0
|
|
734
|
+
except Exception:
|
|
735
|
+
pass
|
|
736
|
+
return None
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def _parse_dec_deg(h: Header) -> float | None:
|
|
740
|
+
dec = _first_float(h.get("CRVAL2"))
|
|
741
|
+
if dec is not None: return dec
|
|
742
|
+
dec = _first_float(h.get("DEC"))
|
|
743
|
+
if dec is not None and -90 <= dec <= 90: return dec
|
|
744
|
+
for key in ("OBJCTDEC","DEC"):
|
|
745
|
+
s = h.get(key);
|
|
746
|
+
if not s: continue
|
|
747
|
+
s = str(s).strip()
|
|
748
|
+
sign = -1.0 if s.startswith("-") else 1.0
|
|
749
|
+
s = s.lstrip("+-")
|
|
750
|
+
parts = re.split(r"[:\s]+", s)
|
|
751
|
+
try:
|
|
752
|
+
if len(parts) >= 3:
|
|
753
|
+
dd, mm, ss = float(parts[0]), float(parts[1]), float(parts[2])
|
|
754
|
+
elif len(parts) == 2:
|
|
755
|
+
dd, mm = float(parts[0]), float(parts[1]); ss = 0.0
|
|
756
|
+
else:
|
|
757
|
+
return sign * float(parts[0])
|
|
758
|
+
return sign * (abs(dd) + mm/60.0 + ss/3600.0)
|
|
759
|
+
except Exception:
|
|
760
|
+
pass
|
|
761
|
+
return None
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def _compute_scale_arcsec_per_pix(h: Header) -> float | None:
|
|
765
|
+
"""
|
|
766
|
+
Try to compute pixel scale from WCS / instrument metadata.
|
|
767
|
+
If the result is obviously insane, return None so we can fall back
|
|
768
|
+
to RA/Dec-only seeding.
|
|
769
|
+
"""
|
|
770
|
+
def _sanity(val: float | None) -> float | None:
|
|
771
|
+
if val is None or not np.isfinite(val) or val <= 0:
|
|
772
|
+
return None
|
|
773
|
+
# Typical imaging: ~0.1"–100"/px. Allow up to ~1000"/px for very wide,
|
|
774
|
+
# but anything beyond that is almost certainly bogus.
|
|
775
|
+
if val > 1000.0:
|
|
776
|
+
return None
|
|
777
|
+
return float(val)
|
|
778
|
+
|
|
779
|
+
cd11 = _first_float(h.get("CD1_1"))
|
|
780
|
+
cd21 = _first_float(h.get("CD2_1"))
|
|
781
|
+
cdelt1 = _first_float(h.get("CDELT1"))
|
|
782
|
+
cdelt2 = _first_float(h.get("CDELT2"))
|
|
783
|
+
|
|
784
|
+
# 1) CD matrix
|
|
785
|
+
if cd11 is not None or cd21 is not None:
|
|
786
|
+
cd11 = cd11 or 0.0
|
|
787
|
+
cd21 = cd21 or 0.0
|
|
788
|
+
val = ((cd11**2 + cd21**2)**0.5) * 3600.0
|
|
789
|
+
val = _sanity(val)
|
|
790
|
+
if val is not None:
|
|
791
|
+
return val
|
|
792
|
+
|
|
793
|
+
# 2) CDELT
|
|
794
|
+
if cdelt1 is not None or cdelt2 is not None:
|
|
795
|
+
cdelt1 = cdelt1 or 0.0
|
|
796
|
+
cdelt2 = cdelt2 or 0.0
|
|
797
|
+
val = ((cdelt1**2 + cdelt2**2)**0.5) * 3600.0
|
|
798
|
+
val = _sanity(val)
|
|
799
|
+
if val is not None:
|
|
800
|
+
return val
|
|
801
|
+
|
|
802
|
+
# 3) Pixel size + focal length
|
|
803
|
+
px_um_x = _first_float(h.get("XPIXSZ"))
|
|
804
|
+
px_um_y = _first_float(h.get("YPIXSZ"))
|
|
805
|
+
focal_mm = _first_float(h.get("FOCALLEN"))
|
|
806
|
+
if focal_mm and (px_um_x or px_um_y):
|
|
807
|
+
px_um = px_um_x if (px_um_x and not px_um_y) else px_um_y if (px_um_y and not px_um_x) else None
|
|
808
|
+
if px_um is None:
|
|
809
|
+
px_um = (px_um_x + px_um_y) / 2.0
|
|
810
|
+
bx = _first_int(h.get("XBINNING")) or _first_int(h.get("XBIN")) or 1
|
|
811
|
+
by = _first_int(h.get("YBINNING")) or _first_int(h.get("YBIN")) or 1
|
|
812
|
+
bin_factor = (bx + by) / 2.0
|
|
813
|
+
px_um_eff = px_um * bin_factor
|
|
814
|
+
val = 206.264806 * px_um_eff / float(focal_mm)
|
|
815
|
+
val = _sanity(val)
|
|
816
|
+
if val is not None:
|
|
817
|
+
return val
|
|
818
|
+
|
|
819
|
+
return None
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
def _build_astap_seed_with_overrides(settings, header: Header | None, image: np.ndarray) -> tuple[list[str], str, float | None]:
|
|
823
|
+
"""
|
|
824
|
+
Decide seed based on seed_mode:
|
|
825
|
+
- auto: derive from header (existing logic)
|
|
826
|
+
- manual: use user-provided RA/Dec/Scale
|
|
827
|
+
- none: return [], "blind"
|
|
828
|
+
Returns: (args, dbg, scale_arcsec)
|
|
829
|
+
"""
|
|
830
|
+
mode = _get_seed_mode(settings)
|
|
831
|
+
|
|
832
|
+
if mode == "none":
|
|
833
|
+
return [], "seed disabled (blind)", None
|
|
834
|
+
|
|
835
|
+
if mode == "manual":
|
|
836
|
+
ra_s = _get_manual_ra(settings)
|
|
837
|
+
dec_s = _get_manual_dec(settings)
|
|
838
|
+
scl = _get_manual_scale(settings)
|
|
839
|
+
ra_deg = _parse_ra_input_to_deg(ra_s)
|
|
840
|
+
dec_deg = _parse_dec_input_to_deg(dec_s)
|
|
841
|
+
dbg = []
|
|
842
|
+
if ra_deg is None: dbg.append("RA?")
|
|
843
|
+
if dec_deg is None: dbg.append("Dec?")
|
|
844
|
+
if scl is None or not np.isfinite(scl) or scl <= 0: dbg.append("scale?")
|
|
845
|
+
if dbg:
|
|
846
|
+
return [], "manual seed invalid: " + ", ".join(dbg), None
|
|
847
|
+
ra_h = ra_deg / 15.0
|
|
848
|
+
spd = dec_deg + 90.0
|
|
849
|
+
args = ["-ra", f"{ra_h:.6f}", "-spd", f"{spd:.6f}", "-scale", f"{scl:.3f}"]
|
|
850
|
+
return args, f"manual RA={ra_h:.6f}h | SPD={spd:.6f}° | scale={scl:.3f}\"/px", float(scl)
|
|
851
|
+
|
|
852
|
+
# auto (default): from header
|
|
853
|
+
if isinstance(header, Header):
|
|
854
|
+
args, dbg = _build_astap_seed(header)
|
|
855
|
+
scl = None
|
|
856
|
+
if args:
|
|
857
|
+
try:
|
|
858
|
+
if "-scale" in args:
|
|
859
|
+
scl = float(args[args.index("-scale")+1])
|
|
860
|
+
except Exception:
|
|
861
|
+
scl = None
|
|
862
|
+
return args, "auto: " + dbg, scl
|
|
863
|
+
|
|
864
|
+
return [], "no header available for auto seed", None
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
def _build_astap_seed(h: Header) -> Tuple[list[str], str]:
|
|
868
|
+
"""
|
|
869
|
+
Build ASTAP seed args from a header.
|
|
870
|
+
RA/Dec are REQUIRED. Scale is OPTIONAL and sanity-checked.
|
|
871
|
+
"""
|
|
872
|
+
dbg = []
|
|
873
|
+
ra_deg = _parse_ra_deg(h)
|
|
874
|
+
dec_deg = _parse_dec_deg(h)
|
|
875
|
+
|
|
876
|
+
if ra_deg is None:
|
|
877
|
+
dbg.append("RA unknown")
|
|
878
|
+
if dec_deg is None:
|
|
879
|
+
dbg.append("Dec unknown")
|
|
880
|
+
|
|
881
|
+
# If we don't have RA/Dec, there's nothing useful to seed.
|
|
882
|
+
if ra_deg is None or dec_deg is None:
|
|
883
|
+
return [], " / ".join(dbg) if dbg else "RA/Dec unknown"
|
|
884
|
+
|
|
885
|
+
# Scale is now optional
|
|
886
|
+
scale = _estimate_scale_arcsec_from_header(h)
|
|
887
|
+
if scale is None:
|
|
888
|
+
dbg.append("scale unknown")
|
|
889
|
+
|
|
890
|
+
ra_h = ra_deg / 15.0
|
|
891
|
+
spd = dec_deg + 90.0
|
|
892
|
+
|
|
893
|
+
args = ["-ra", f"{ra_h:.6f}", "-spd", f"{spd:.6f}"]
|
|
894
|
+
if scale is not None:
|
|
895
|
+
args += ["-scale", f"{scale:.3f}"]
|
|
896
|
+
|
|
897
|
+
dbg_str = f"RA={ra_h:.6f} h | SPD={spd:.6f}°"
|
|
898
|
+
if scale is not None:
|
|
899
|
+
dbg_str += f" | scale={scale:.3f}\"/px"
|
|
900
|
+
else:
|
|
901
|
+
dbg_str += " | scale unknown"
|
|
902
|
+
|
|
903
|
+
return args, dbg_str
|
|
904
|
+
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
def _astrometry_login(settings, parent=None) -> str | None:
|
|
908
|
+
_set_status_ui(parent, QCoreApplication.translate("PlateSolver", "Status: Logging in to Astrometry.net…"))
|
|
909
|
+
api_key = _get_astrometry_api_key(settings)
|
|
910
|
+
if not api_key:
|
|
911
|
+
from PyQt6.QtWidgets import QInputDialog
|
|
912
|
+
key, ok = QInputDialog.getText(None, QCoreApplication.translate("PlateSolver", "Astrometry.net API Key"), QCoreApplication.translate("PlateSolver", "Enter your Astrometry.net API key:"))
|
|
913
|
+
if not ok or not key:
|
|
914
|
+
_set_status_ui(parent, QCoreApplication.translate("PlateSolver", "Status: Login canceled (no API key)."))
|
|
915
|
+
return None
|
|
916
|
+
_set_astrometry_api_key(settings, key)
|
|
917
|
+
api_key = key
|
|
918
|
+
|
|
919
|
+
base = _get_astrometry_api_url(settings)
|
|
920
|
+
resp = _astrometry_api_request(
|
|
921
|
+
"POST", base + "login",
|
|
922
|
+
data={'request-json': json.dumps({"apikey": api_key})},
|
|
923
|
+
parent=parent, stage="login"
|
|
924
|
+
)
|
|
925
|
+
if resp and resp.get("status") == "success":
|
|
926
|
+
_set_status_ui(parent, QCoreApplication.translate("PlateSolver", "Status: Login successful."))
|
|
927
|
+
return resp.get("session")
|
|
928
|
+
_set_status_ui(parent, QCoreApplication.translate("PlateSolver", "Status: Login failed."))
|
|
929
|
+
return None
|
|
930
|
+
|
|
931
|
+
def _astrometry_upload(settings, session: str, image_path: str, parent=None) -> int | None:
|
|
932
|
+
_set_status_ui(parent, QCoreApplication.translate("PlateSolver", "Status: Uploading image to Astrometry.net…"))
|
|
933
|
+
base = _get_astrometry_api_url(settings)
|
|
934
|
+
|
|
935
|
+
try:
|
|
936
|
+
sz = os.path.getsize(image_path)
|
|
937
|
+
if sz < 1024: # fits headers alone are ~2880 bytes
|
|
938
|
+
print(f"[Astrometry] temp FITS too small ({sz} bytes): {image_path}")
|
|
939
|
+
_set_status_ui(parent, QCoreApplication.translate("PlateSolver", "Status: Upload failed (temp FITS empty)."))
|
|
940
|
+
return None
|
|
941
|
+
except Exception:
|
|
942
|
+
pass
|
|
943
|
+
|
|
944
|
+
try:
|
|
945
|
+
with open(image_path, "rb") as f:
|
|
946
|
+
files = {"file": f}
|
|
947
|
+
data = {'request-json': json.dumps({
|
|
948
|
+
"publicly_visible": "y",
|
|
949
|
+
"allow_modifications": "d",
|
|
950
|
+
"session": session,
|
|
951
|
+
"allow_commercial_use": "d"
|
|
952
|
+
})}
|
|
953
|
+
resp = _astrometry_api_request(
|
|
954
|
+
"POST", base + "upload",
|
|
955
|
+
data=data, files=files,
|
|
956
|
+
timeout=(15, 180),
|
|
957
|
+
parent=parent, stage="upload"
|
|
958
|
+
)
|
|
959
|
+
if resp and resp.get("status") == "success":
|
|
960
|
+
_set_status_ui(parent, QCoreApplication.translate("PlateSolver", "Status: Upload complete."))
|
|
961
|
+
return int(resp["subid"])
|
|
962
|
+
except Exception as e:
|
|
963
|
+
print("Upload error:", e)
|
|
964
|
+
|
|
965
|
+
_set_status_ui(parent, QCoreApplication.translate("PlateSolver", "Status: Upload failed."))
|
|
966
|
+
return None
|
|
967
|
+
|
|
968
|
+
|
|
969
|
+
|
|
970
|
+
def _solve_with_local_solvefield(parent, settings, tmp_fit_path: str) -> tuple[bool, Header | str]:
|
|
971
|
+
solvefield = _get_solvefield_exe(settings)
|
|
972
|
+
if not solvefield or not os.path.exists(solvefield):
|
|
973
|
+
return False, QCoreApplication.translate("PlateSolver", "solve-field not configured.")
|
|
974
|
+
|
|
975
|
+
args = [
|
|
976
|
+
"--overwrite",
|
|
977
|
+
"--no-remove-lines",
|
|
978
|
+
"--cpulimit", "300",
|
|
979
|
+
"--downsample", "2",
|
|
980
|
+
"--write-wcs", "wcs",
|
|
981
|
+
tmp_fit_path
|
|
982
|
+
]
|
|
983
|
+
_set_status_ui(parent, QCoreApplication.translate("PlateSolver", "Status: Running local solve-field…"))
|
|
984
|
+
print("Running solve-field:", solvefield, " ".join(args))
|
|
985
|
+
p = QProcess(parent)
|
|
986
|
+
p.start(solvefield, args)
|
|
987
|
+
if not p.waitForStarted(5000):
|
|
988
|
+
_set_status_ui(parent, QCoreApplication.translate("PlateSolver", "Status: solve-field failed to start."))
|
|
989
|
+
return False, f"Failed to start solve-field: {p.errorString()}"
|
|
990
|
+
|
|
991
|
+
if not _wait_process(p, 300000, parent=parent):
|
|
992
|
+
_set_status_ui(parent, QCoreApplication.translate("PlateSolver", "Status: solve-field timed out."))
|
|
993
|
+
return False, "solve-field timed out."
|
|
994
|
+
|
|
995
|
+
if p.exitCode() != 0:
|
|
996
|
+
out = bytes(p.readAllStandardOutput()).decode(errors="ignore")
|
|
997
|
+
err = bytes(p.readAllStandardError()).decode(errors="ignore")
|
|
998
|
+
_set_status_ui(parent, QCoreApplication.translate("PlateSolver", "Status: solve-field failed."))
|
|
999
|
+
print("solve-field failed.\nSTDOUT:\n", out, "\nSTDERR:\n", err)
|
|
1000
|
+
return False, "solve-field returned non-zero exit."
|
|
1001
|
+
|
|
1002
|
+
wcs_path = os.path.splitext(tmp_fit_path)[0] + ".wcs"
|
|
1003
|
+
new_path = os.path.splitext(tmp_fit_path)[0] + ".new"
|
|
1004
|
+
|
|
1005
|
+
if os.path.exists(wcs_path):
|
|
1006
|
+
d = _parse_astap_wcs_file(wcs_path)
|
|
1007
|
+
if d:
|
|
1008
|
+
d = _ensure_ctypes(_coerce_wcs_numbers(d))
|
|
1009
|
+
return True, Header({k: v for k, v in d.items()})
|
|
1010
|
+
|
|
1011
|
+
if os.path.exists(new_path):
|
|
1012
|
+
try:
|
|
1013
|
+
with fits.open(new_path, memmap=False) as hdul:
|
|
1014
|
+
h = Header()
|
|
1015
|
+
for k, v in dict(hdul[0].header).items():
|
|
1016
|
+
if k not in ("COMMENT","HISTORY","END"):
|
|
1017
|
+
h[k] = v
|
|
1018
|
+
return True, h
|
|
1019
|
+
except Exception as e:
|
|
1020
|
+
print("Failed reading .new FITS:", e)
|
|
1021
|
+
|
|
1022
|
+
return False, "solve-field produced no WCS."
|
|
1023
|
+
|
|
1024
|
+
|
|
1025
|
+
def _astrometry_poll_job(settings, subid: int, *, max_wait_s=900, parent=None) -> int | None:
|
|
1026
|
+
_set_status_ui(parent, QCoreApplication.translate("PlateSolver", "Status: Waiting for job assignment…"))
|
|
1027
|
+
base = _get_astrometry_api_url(settings)
|
|
1028
|
+
t0 = time.time()
|
|
1029
|
+
while time.time() - t0 < max_wait_s:
|
|
1030
|
+
resp = _astrometry_api_request("GET", base + f"submissions/{subid}",
|
|
1031
|
+
parent=parent, stage="poll job")
|
|
1032
|
+
if resp:
|
|
1033
|
+
jobs = resp.get("jobs", [])
|
|
1034
|
+
if jobs and jobs[0] is not None:
|
|
1035
|
+
_set_status_ui(parent, QCoreApplication.translate("PlateSolver", "Status: Job assigned (ID {0}).").format(jobs[0]))
|
|
1036
|
+
try: return int(jobs[0])
|
|
1037
|
+
except Exception as e:
|
|
1038
|
+
import logging
|
|
1039
|
+
logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
|
|
1040
|
+
_sleep_ui(1000)
|
|
1041
|
+
return None
|
|
1042
|
+
|
|
1043
|
+
def _astrometry_poll_calib(settings, job_id: int, *, max_wait_s=900, parent=None) -> dict | None:
|
|
1044
|
+
_set_status_ui(parent, QCoreApplication.translate("PlateSolver", "Status: Waiting for solution…"))
|
|
1045
|
+
base = _get_astrometry_api_url(settings)
|
|
1046
|
+
t0 = time.time()
|
|
1047
|
+
while time.time() - t0 < max_wait_s:
|
|
1048
|
+
resp = _astrometry_api_request("GET", base + f"jobs/{job_id}/calibration/",
|
|
1049
|
+
parent=parent, stage="poll calib")
|
|
1050
|
+
if resp and all(k in resp for k in ("ra","dec","pixscale")):
|
|
1051
|
+
_set_status_ui(parent, QCoreApplication.translate("PlateSolver", "Status: Solution received."))
|
|
1052
|
+
return resp
|
|
1053
|
+
_sleep_ui(1500)
|
|
1054
|
+
return None
|
|
1055
|
+
|
|
1056
|
+
# ---- ASTAP seed controls ----
|
|
1057
|
+
# modes for radius: "auto" -> -r 0, "value" -> -r <user>, default "auto"
|
|
1058
|
+
def _get_astap_radius_mode(settings) -> str:
|
|
1059
|
+
return (settings.value("astap/seed_radius_mode", "auto", type=str) or "auto").lower()
|
|
1060
|
+
|
|
1061
|
+
def _get_astap_radius_value(settings) -> float:
|
|
1062
|
+
try:
|
|
1063
|
+
return float(settings.value("astap/seed_radius_value", 5.0, type=float))
|
|
1064
|
+
except Exception:
|
|
1065
|
+
return 5.0
|
|
1066
|
+
|
|
1067
|
+
# modes for fov: "auto" -> -fov 0, "compute" -> use computed FOV, "value" -> user number; default "compute"
|
|
1068
|
+
def _get_astap_fov_mode(settings) -> str:
|
|
1069
|
+
return (settings.value("astap/seed_fov_mode", "compute", type=str) or "compute").lower()
|
|
1070
|
+
|
|
1071
|
+
def _get_astap_fov_value(settings) -> float:
|
|
1072
|
+
try:
|
|
1073
|
+
return float(settings.value("astap/seed_fov_value", 0.0, type=float))
|
|
1074
|
+
except Exception:
|
|
1075
|
+
return 0.0
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
def _read_header_from_fits(path: str) -> Dict[str, Any]:
|
|
1079
|
+
with fits.open(path, memmap=False) as hdul:
|
|
1080
|
+
d = dict(hdul[0].header)
|
|
1081
|
+
d.pop("COMMENT", None); d.pop("HISTORY", None); d.pop("END", None)
|
|
1082
|
+
return d
|
|
1083
|
+
|
|
1084
|
+
|
|
1085
|
+
def _header_from_text_block(s: str) -> Header:
|
|
1086
|
+
"""Parse ASTAP .wcs or flattened blocks into a proper Header."""
|
|
1087
|
+
h = Header()
|
|
1088
|
+
if not s: return h
|
|
1089
|
+
lines = s.splitlines()
|
|
1090
|
+
if len(lines) <= 1:
|
|
1091
|
+
# single blob: split on KEY=
|
|
1092
|
+
lines = re.split(r"(?=(?:^|\s{2,})([A-Za-z][A-Za-z0-9_]+)\s*=)", s)
|
|
1093
|
+
lines = ["".join(lines[i:i+2]).strip() for i in range(1, len(lines), 2)]
|
|
1094
|
+
card_re = re.compile(r"^\s*([A-Za-z][A-Za-z0-9_]+)\s*=\s*(.*)$")
|
|
1095
|
+
num_re = re.compile(r"^[+-]?\d*\.?\d+(?:[eE][+-]?\d+)?$")
|
|
1096
|
+
for raw in lines:
|
|
1097
|
+
raw = raw.strip()
|
|
1098
|
+
if not raw or raw.upper().startswith(("COMMENT","HISTORY","END")):
|
|
1099
|
+
continue
|
|
1100
|
+
m = card_re.match(raw)
|
|
1101
|
+
if not m: continue
|
|
1102
|
+
key, rest = m.group(1).upper(), m.group(2).strip()
|
|
1103
|
+
if " /" in rest:
|
|
1104
|
+
val_str = rest.split(" /", 1)[0].strip()
|
|
1105
|
+
else:
|
|
1106
|
+
val_str = rest
|
|
1107
|
+
if (len(val_str) >= 2) and ((val_str[0] == "'" and val_str[-1] == "'") or (val_str[0] == '"' and val_str[-1] == '"')):
|
|
1108
|
+
val = val_str[1:-1].strip()
|
|
1109
|
+
else:
|
|
1110
|
+
try:
|
|
1111
|
+
if num_re.match(val_str):
|
|
1112
|
+
val = float(val_str)
|
|
1113
|
+
if re.match(r"^[+-]?\d+$", val_str): val = int(val)
|
|
1114
|
+
else:
|
|
1115
|
+
val = val_str
|
|
1116
|
+
except Exception:
|
|
1117
|
+
val = val_str
|
|
1118
|
+
try: h[key] = val
|
|
1119
|
+
except Exception as e:
|
|
1120
|
+
import logging
|
|
1121
|
+
logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
|
|
1122
|
+
if "A_ORDER" in h and "B_ORDER" not in h:
|
|
1123
|
+
h["B_ORDER"] = int(h["A_ORDER"])
|
|
1124
|
+
if "B_ORDER" in h and "A_ORDER" not in h:
|
|
1125
|
+
h["A_ORDER"] = int(h["B_ORDER"])
|
|
1126
|
+
return h
|
|
1127
|
+
|
|
1128
|
+
def _coerce_wcs_numbers(d: dict[str, Any]) -> dict[str, Any]:
|
|
1129
|
+
"""
|
|
1130
|
+
Convert values for common WCS/SIP keys to int/float where appropriate.
|
|
1131
|
+
Mirrors SASv2 logic.
|
|
1132
|
+
"""
|
|
1133
|
+
numeric_keys = {
|
|
1134
|
+
"CRPIX1", "CRPIX2", "CRVAL1", "CRVAL2", "CDELT1", "CDELT2",
|
|
1135
|
+
"CD1_1", "CD1_2", "CD2_1", "CD2_2", "CROTA1", "CROTA2",
|
|
1136
|
+
"EQUINOX", "WCSAXES", "A_ORDER", "B_ORDER", "AP_ORDER", "BP_ORDER",
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
out = {}
|
|
1140
|
+
for k, v in d.items():
|
|
1141
|
+
key = k.upper()
|
|
1142
|
+
try:
|
|
1143
|
+
if key in numeric_keys or re.match(r"^(A|B|AP|BP)_\d+_\d+$", key):
|
|
1144
|
+
if isinstance(v, str):
|
|
1145
|
+
val = float(v.strip())
|
|
1146
|
+
if val.is_integer(): val = int(val)
|
|
1147
|
+
else:
|
|
1148
|
+
val = v
|
|
1149
|
+
out[key] = val
|
|
1150
|
+
else:
|
|
1151
|
+
out[key] = v
|
|
1152
|
+
except Exception:
|
|
1153
|
+
out[key] = v
|
|
1154
|
+
return out
|
|
1155
|
+
|
|
1156
|
+
|
|
1157
|
+
def _ensure_ctypes(d: dict[str, Any]) -> dict[str, Any]:
|
|
1158
|
+
"""
|
|
1159
|
+
Ensure CTYPE1/2 exist and are proper strings. Fallback to TAN if missing.
|
|
1160
|
+
"""
|
|
1161
|
+
if "CTYPE1" not in d:
|
|
1162
|
+
d["CTYPE1"] = "RA---TAN"
|
|
1163
|
+
if "CTYPE2" not in d:
|
|
1164
|
+
d["CTYPE2"] = "DEC--TAN"
|
|
1165
|
+
d["CTYPE1"] = str(d["CTYPE1"]).strip()
|
|
1166
|
+
d["CTYPE2"] = str(d["CTYPE2"]).strip()
|
|
1167
|
+
return d
|
|
1168
|
+
|
|
1169
|
+
def _merge_wcs_into_base_header(base_header: Header | None, wcs_header: Header | None) -> Header:
|
|
1170
|
+
"""
|
|
1171
|
+
Merge a WCS/SIP solution into a base acquisition header.
|
|
1172
|
+
|
|
1173
|
+
- base_header: original FITS header with OBJECT, EXPTIME, GAIN, etc.
|
|
1174
|
+
- wcs_header: header containing CRPIX/CRVAL/CD/SIP/etc. from ASTAP or Astrometry.
|
|
1175
|
+
|
|
1176
|
+
Non-WCS cards in base_header are preserved.
|
|
1177
|
+
WCS/SIP/PLTSOLVD/etc. from wcs_header override any existing ones.
|
|
1178
|
+
"""
|
|
1179
|
+
if not isinstance(base_header, Header):
|
|
1180
|
+
base_header = Header()
|
|
1181
|
+
# Always strip our internal meta keys from the acquisition header
|
|
1182
|
+
base_header = _strip_nonfits_meta_keys_from_header(base_header)
|
|
1183
|
+
|
|
1184
|
+
if not isinstance(wcs_header, Header):
|
|
1185
|
+
# nothing special to merge; just normalize the base and return it.
|
|
1186
|
+
d0 = _ensure_ctypes(_coerce_wcs_numbers(dict(base_header)))
|
|
1187
|
+
out = Header()
|
|
1188
|
+
for k, v in d0.items():
|
|
1189
|
+
try:
|
|
1190
|
+
out[k] = v
|
|
1191
|
+
except Exception:
|
|
1192
|
+
pass
|
|
1193
|
+
return out
|
|
1194
|
+
|
|
1195
|
+
# Start from a copy of the acquisition header (drop COMMENT/HISTORY from it)
|
|
1196
|
+
base = base_header.copy()
|
|
1197
|
+
|
|
1198
|
+
|
|
1199
|
+
# Start from a copy of the acquisition header (drop COMMENT/HISTORY from it)
|
|
1200
|
+
base = base_header.copy()
|
|
1201
|
+
for k in ("COMMENT", "HISTORY", "END"):
|
|
1202
|
+
if k in base:
|
|
1203
|
+
base.remove(k)
|
|
1204
|
+
|
|
1205
|
+
merged = dict(base)
|
|
1206
|
+
|
|
1207
|
+
# Only import *WCS-ish* keys from the solver, not things like BITPIX/NAXIS.
|
|
1208
|
+
wcs_prefixes = (
|
|
1209
|
+
"CRPIX", "CRVAL", "CDELT", "CD1_", "CD2_", "PC",
|
|
1210
|
+
"CTYPE", "CUNIT", "PV1_", "PV2_", "A_", "B_", "AP_", "BP_"
|
|
1211
|
+
)
|
|
1212
|
+
wcs_extras = {
|
|
1213
|
+
"WCSAXES", "LATPOLE", "LONPOLE", "EQUINOX",
|
|
1214
|
+
"PLTSOLVD", "WARNING", "RADESYS", "RADECSYS", "RADECSYS"
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
for key, val in wcs_header.items():
|
|
1218
|
+
ku = key.upper()
|
|
1219
|
+
if ku.startswith(wcs_prefixes) or ku in wcs_extras:
|
|
1220
|
+
merged[ku] = val
|
|
1221
|
+
|
|
1222
|
+
# Coerce numeric types and ensure CTYPEs.
|
|
1223
|
+
merged = _ensure_ctypes(_coerce_wcs_numbers(merged))
|
|
1224
|
+
|
|
1225
|
+
# Ensure TAN-SIP if SIP terms exist.
|
|
1226
|
+
try:
|
|
1227
|
+
sip_present = any(re.match(r"^(A|B|AP|BP)_\d+_\d+$", k) for k in merged.keys())
|
|
1228
|
+
if sip_present:
|
|
1229
|
+
c1 = str(merged.get("CTYPE1", "RA---TAN"))
|
|
1230
|
+
c2 = str(merged.get("CTYPE2", "DEC--TAN"))
|
|
1231
|
+
if not c1.endswith("-SIP"):
|
|
1232
|
+
merged["CTYPE1"] = "RA---TAN-SIP"
|
|
1233
|
+
if not c2.endswith("-SIP"):
|
|
1234
|
+
merged["CTYPE2"] = "DEC--TAN-SIP"
|
|
1235
|
+
except Exception:
|
|
1236
|
+
pass
|
|
1237
|
+
|
|
1238
|
+
# CROTA from CD if missing.
|
|
1239
|
+
try:
|
|
1240
|
+
if ("CROTA1" not in merged or "CROTA2" not in merged) and \
|
|
1241
|
+
("CD1_1" in merged and "CD1_2" in merged):
|
|
1242
|
+
rot = math.degrees(math.atan2(float(merged["CD1_2"]), float(merged["CD1_1"])))
|
|
1243
|
+
merged["CROTA1"] = rot
|
|
1244
|
+
merged["CROTA2"] = rot
|
|
1245
|
+
except Exception:
|
|
1246
|
+
pass
|
|
1247
|
+
|
|
1248
|
+
out = Header()
|
|
1249
|
+
for k, v in merged.items():
|
|
1250
|
+
try:
|
|
1251
|
+
out[k] = v
|
|
1252
|
+
except Exception:
|
|
1253
|
+
# Skip weird/invalid keys silently
|
|
1254
|
+
pass
|
|
1255
|
+
return out
|
|
1256
|
+
|
|
1257
|
+
|
|
1258
|
+
def _build_header_from_astap_outputs(
|
|
1259
|
+
tmp_fits: str,
|
|
1260
|
+
sidecar_wcs: Optional[str],
|
|
1261
|
+
base_header: Header | None
|
|
1262
|
+
) -> Header:
|
|
1263
|
+
"""
|
|
1264
|
+
Build final header as: base_header (acquisition) + WCS/SIP from .wcs.
|
|
1265
|
+
"""
|
|
1266
|
+
_debug_dump_header("ASTAP: BASE_HEADER ARG INTO _build_header_from_astap_outputs", base_header)
|
|
1267
|
+
"""
|
|
1268
|
+
Build final header as: base_header (acquisition) + WCS/SIP from .wcs.
|
|
1269
|
+
|
|
1270
|
+
tmp_fits is only used as a last-resort source if base_header is None.
|
|
1271
|
+
"""
|
|
1272
|
+
# 1) Determine base header (acquisition)
|
|
1273
|
+
if isinstance(base_header, Header):
|
|
1274
|
+
base_hdr = base_header
|
|
1275
|
+
else:
|
|
1276
|
+
# Fallback: read whatever ASTAP wrote into the temp FITS.
|
|
1277
|
+
base_dict: Dict[str, Any] = {}
|
|
1278
|
+
try:
|
|
1279
|
+
with fits.open(tmp_fits, memmap=False) as hdul:
|
|
1280
|
+
base_dict = dict(hdul[0].header)
|
|
1281
|
+
for k in ("COMMENT", "HISTORY", "END"):
|
|
1282
|
+
base_dict.pop(k, None)
|
|
1283
|
+
except Exception as e:
|
|
1284
|
+
print("Failed reading temp FITS header:", e)
|
|
1285
|
+
base_hdr = Header()
|
|
1286
|
+
for k, v in base_dict.items():
|
|
1287
|
+
try:
|
|
1288
|
+
base_hdr[k] = v
|
|
1289
|
+
except Exception:
|
|
1290
|
+
pass
|
|
1291
|
+
_debug_dump_header("ASTAP: BASE_HDR (acquisition header after fallback)", base_hdr)
|
|
1292
|
+
# 2) Load WCS from sidecar
|
|
1293
|
+
wcs_hdr = Header()
|
|
1294
|
+
if sidecar_wcs and os.path.exists(sidecar_wcs):
|
|
1295
|
+
try:
|
|
1296
|
+
wcs_dict = _parse_astap_wcs_file(sidecar_wcs)
|
|
1297
|
+
for k, v in wcs_dict.items():
|
|
1298
|
+
if k not in ("COMMENT", "HISTORY", "END"):
|
|
1299
|
+
try:
|
|
1300
|
+
wcs_hdr[k] = v
|
|
1301
|
+
except Exception:
|
|
1302
|
+
pass
|
|
1303
|
+
except Exception as e:
|
|
1304
|
+
print("Error parsing .wcs file:", e)
|
|
1305
|
+
_debug_dump_header("ASTAP: WCS_HDR FROM SIDECAR .WCS", wcs_hdr)
|
|
1306
|
+
# 3) Merge WCS into base acquisition header (base wins for non-WCS keys)
|
|
1307
|
+
final_hdr = _merge_wcs_into_base_header(base_hdr, wcs_hdr)
|
|
1308
|
+
|
|
1309
|
+
_debug_dump_header("ASTAP: FINAL MERGED HEADER (base_hdr + wcs_hdr)", final_hdr)
|
|
1310
|
+
|
|
1311
|
+
|
|
1312
|
+
return final_hdr
|
|
1313
|
+
|
|
1314
|
+
|
|
1315
|
+
def _write_temp_fit_via_save_image(gray2d: np.ndarray, _header: Header | None) -> tuple[str, str]:
|
|
1316
|
+
"""
|
|
1317
|
+
Write a 2-D mono float32 FITS using legacy.save_image(), return (fit_path, sidecar_wcs_path).
|
|
1318
|
+
|
|
1319
|
+
NOTE: We intentionally ignore the incoming header's axis cards and
|
|
1320
|
+
build a clean 2-axis header to avoid 'NAXISj out of range' errors.
|
|
1321
|
+
"""
|
|
1322
|
+
# ensure 2-D float32 in [0,1]
|
|
1323
|
+
if gray2d.ndim != 2:
|
|
1324
|
+
raise ValueError("Expected a 2-D grayscale array for ASTAP temp FITS.")
|
|
1325
|
+
g = np.clip(gray2d.astype(np.float32), 0.0, 1.0)
|
|
1326
|
+
|
|
1327
|
+
H, W = int(g.shape[0]), int(g.shape[1])
|
|
1328
|
+
|
|
1329
|
+
# Build a *fresh* 2-axis header (no NAXIS3, no old WCS)
|
|
1330
|
+
clean_header = Header()
|
|
1331
|
+
clean_header["SIMPLE"] = True
|
|
1332
|
+
clean_header["BITPIX"] = -32
|
|
1333
|
+
clean_header["NAXIS"] = 2
|
|
1334
|
+
clean_header["NAXIS1"] = W
|
|
1335
|
+
clean_header["NAXIS2"] = H
|
|
1336
|
+
clean_header["BZERO"] = 0.0
|
|
1337
|
+
clean_header["BSCALE"] = 1.0
|
|
1338
|
+
clean_header.add_comment("Temp FITS written for ASTAP solve (mono 2-D).")
|
|
1339
|
+
|
|
1340
|
+
# Write using legacy.save_image (forces a valid 2-axis primary HDU)
|
|
1341
|
+
tmp = tempfile.NamedTemporaryFile(suffix=".fit", delete=False)
|
|
1342
|
+
tmp_path = tmp.name
|
|
1343
|
+
tmp.close()
|
|
1344
|
+
|
|
1345
|
+
save_image(
|
|
1346
|
+
img_array=g,
|
|
1347
|
+
filename=tmp_path,
|
|
1348
|
+
original_format="fit", # (our stack expects 'fit')
|
|
1349
|
+
bit_depth="32-bit floating point",
|
|
1350
|
+
original_header=clean_header,
|
|
1351
|
+
is_mono=True # <-- important: keep it 2-D/mono
|
|
1352
|
+
)
|
|
1353
|
+
|
|
1354
|
+
# Resolve the actual path in case save_image normalized the extension
|
|
1355
|
+
base, _ = os.path.splitext(tmp_path)
|
|
1356
|
+
candidates = [tmp_path, base + ".fit", base + ".fits", base + ".FIT", base + ".FITS"]
|
|
1357
|
+
fit_path = next((p for p in candidates if os.path.exists(p)), tmp_path)
|
|
1358
|
+
|
|
1359
|
+
print(f"Saved FITS image to: {fit_path}")
|
|
1360
|
+
return fit_path, os.path.splitext(fit_path)[0] + ".wcs"
|
|
1361
|
+
|
|
1362
|
+
def _solve_numpy_with_astrometry(
|
|
1363
|
+
parent,
|
|
1364
|
+
settings,
|
|
1365
|
+
image: np.ndarray,
|
|
1366
|
+
base_header: Header | None
|
|
1367
|
+
) -> tuple[bool, Header | str]:
|
|
1368
|
+
"""
|
|
1369
|
+
Try local solve-field first; if unavailable/failed, try astrometry.net web API.
|
|
1370
|
+
|
|
1371
|
+
WEB MODE:
|
|
1372
|
+
- keep ORIGINAL dimensions (no downsample)
|
|
1373
|
+
- stretch to non-linear for star detectability
|
|
1374
|
+
- quantize to 16-bit unsigned FITS to reduce upload size
|
|
1375
|
+
- prefer solved WCS file from astrometry.net (includes SIP)
|
|
1376
|
+
"""
|
|
1377
|
+
import os
|
|
1378
|
+
import numpy as np
|
|
1379
|
+
from astropy.io.fits import Header
|
|
1380
|
+
|
|
1381
|
+
# Build full-res mono in [0,1], but NON-LINEAR (stretched) for detectability
|
|
1382
|
+
norm_full = _normalize_for_astap(image) # float32 [0,1], mono/color
|
|
1383
|
+
gray_full = _to_gray2d_unit(norm_full) # 2D float32 [0,1]
|
|
1384
|
+
Hfull, Wfull = int(gray_full.shape[0]), int(gray_full.shape[1])
|
|
1385
|
+
|
|
1386
|
+
# Always write a full-res temp for LOCAL solve-field (float32)
|
|
1387
|
+
tmp_fit_full, _unused_sidecar = _write_temp_fit_via_save_image(gray_full, None)
|
|
1388
|
+
|
|
1389
|
+
try:
|
|
1390
|
+
# 1) local solve-field path (full-res float FITS)
|
|
1391
|
+
ok, res = _solve_with_local_solvefield(parent, settings, tmp_fit_full)
|
|
1392
|
+
if ok:
|
|
1393
|
+
hdr = res if isinstance(res, Header) else None
|
|
1394
|
+
if hdr is not None:
|
|
1395
|
+
d = _ensure_ctypes(_coerce_wcs_numbers(dict(hdr)))
|
|
1396
|
+
if any(re.match(r"^(A|B|AP|BP)_\d+_\d+$", k) for k in d.keys()):
|
|
1397
|
+
if not str(d.get("CTYPE1","RA---TAN")).endswith("-SIP"):
|
|
1398
|
+
d["CTYPE1"] = "RA---TAN-SIP"
|
|
1399
|
+
if not str(d.get("CTYPE2","DEC--TAN")).endswith("-SIP"):
|
|
1400
|
+
d["CTYPE2"] = "DEC--TAN-SIP"
|
|
1401
|
+
hh = Header()
|
|
1402
|
+
for k, v in d.items():
|
|
1403
|
+
try: hh[k] = v
|
|
1404
|
+
except Exception as e:
|
|
1405
|
+
import logging
|
|
1406
|
+
logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
|
|
1407
|
+
return True, hh
|
|
1408
|
+
return False, QCoreApplication.translate("PlateSolver", "solve-field returned no header.")
|
|
1409
|
+
|
|
1410
|
+
# 2) web API fallback (full-res, 16-bit upload)
|
|
1411
|
+
if requests is None:
|
|
1412
|
+
return False, QCoreApplication.translate("PlateSolver", "requests not available for astrometry.net API.")
|
|
1413
|
+
|
|
1414
|
+
_set_status_ui(parent, QCoreApplication.translate("PlateSolver", "Status: Preparing full-res 16-bit FITS for web solve…"))
|
|
1415
|
+
|
|
1416
|
+
tmp_fit_web = _write_temp_fit_web_16bit(gray_full)
|
|
1417
|
+
|
|
1418
|
+
# Verify web temp file isn't empty
|
|
1419
|
+
try:
|
|
1420
|
+
sz = os.path.getsize(tmp_fit_web)
|
|
1421
|
+
if sz < 3000:
|
|
1422
|
+
return False, QCoreApplication.translate("PlateSolver", "Temp FITS for web upload is empty/tiny ({0} bytes).").format(sz)
|
|
1423
|
+
except Exception:
|
|
1424
|
+
pass
|
|
1425
|
+
|
|
1426
|
+
session = _astrometry_login(settings, parent=parent)
|
|
1427
|
+
if not session:
|
|
1428
|
+
return False, QCoreApplication.translate("PlateSolver", "Astrometry.net login failed.")
|
|
1429
|
+
|
|
1430
|
+
subid = _astrometry_upload(settings, session, tmp_fit_web, parent=parent)
|
|
1431
|
+
if not subid:
|
|
1432
|
+
return False, QCoreApplication.translate("PlateSolver", "Astrometry.net upload failed.")
|
|
1433
|
+
|
|
1434
|
+
job_id = _astrometry_poll_job(settings, subid, parent=parent)
|
|
1435
|
+
if not job_id:
|
|
1436
|
+
return False, QCoreApplication.translate("PlateSolver", "Astrometry.net job ID not received in time.")
|
|
1437
|
+
|
|
1438
|
+
# Prefer full WCS file (includes SIP)
|
|
1439
|
+
hdr_wcs = _astrometry_download_wcs_file(settings, job_id, parent=parent)
|
|
1440
|
+
|
|
1441
|
+
if hdr_wcs is None:
|
|
1442
|
+
# fallback to calibration (no SIP)
|
|
1443
|
+
calib = _astrometry_poll_calib(settings, job_id, parent=parent)
|
|
1444
|
+
if not calib:
|
|
1445
|
+
return False, QCoreApplication.translate("PlateSolver", "Astrometry.net calibration not received in time.")
|
|
1446
|
+
|
|
1447
|
+
_set_status_ui(parent, QCoreApplication.translate("PlateSolver", "Status: Building WCS header from calibration…"))
|
|
1448
|
+
hdr_wcs = _wcs_header_from_astrometry_calib(calib, (Hfull, Wfull))
|
|
1449
|
+
|
|
1450
|
+
# Coerce & ensure TAN-SIP if SIP terms exist
|
|
1451
|
+
d = _ensure_ctypes(_coerce_wcs_numbers(dict(hdr_wcs)))
|
|
1452
|
+
if any(re.match(r"^(A|B|AP|BP)_\d+_\d+$", k) for k in d.keys()):
|
|
1453
|
+
if not str(d.get("CTYPE1","RA---TAN")).endswith("-SIP"):
|
|
1454
|
+
d["CTYPE1"] = "RA---TAN-SIP"
|
|
1455
|
+
if not str(d.get("CTYPE2","DEC--TAN")).endswith("-SIP"):
|
|
1456
|
+
d["CTYPE2"] = "DEC--TAN-SIP"
|
|
1457
|
+
|
|
1458
|
+
# Build a WCS-only Header from d
|
|
1459
|
+
wcs_hdr = Header()
|
|
1460
|
+
for k, v in d.items():
|
|
1461
|
+
try:
|
|
1462
|
+
wcs_hdr[k] = v
|
|
1463
|
+
except Exception:
|
|
1464
|
+
pass
|
|
1465
|
+
|
|
1466
|
+
# Merge with acquisition header (base_header)
|
|
1467
|
+
merged = _merge_wcs_into_base_header(base_header, wcs_hdr)
|
|
1468
|
+
|
|
1469
|
+
# clean temp web file ...
|
|
1470
|
+
try:
|
|
1471
|
+
if os.path.exists(tmp_fit_web):
|
|
1472
|
+
os.remove(tmp_fit_web)
|
|
1473
|
+
except Exception:
|
|
1474
|
+
pass
|
|
1475
|
+
|
|
1476
|
+
return True, merged
|
|
1477
|
+
|
|
1478
|
+
finally:
|
|
1479
|
+
# clean temp + solve-field byproducts next to tmp_fit_full
|
|
1480
|
+
try:
|
|
1481
|
+
base = os.path.splitext(tmp_fit_full)[0]
|
|
1482
|
+
for ext in (".fit",".fits",".wcs",".axy",".corr",".rdls",".solved",".new",".match",".ngc",".png",".ppm",".xyls"):
|
|
1483
|
+
p = base + ext
|
|
1484
|
+
if os.path.exists(p):
|
|
1485
|
+
os.remove(p)
|
|
1486
|
+
except Exception:
|
|
1487
|
+
pass
|
|
1488
|
+
|
|
1489
|
+
|
|
1490
|
+
def _solve_numpy_with_fallback(parent, settings, image: np.ndarray, seed_header: Header | None) -> tuple[bool, Header | str]:
|
|
1491
|
+
# Try ASTAP first
|
|
1492
|
+
_set_status_ui(parent, QCoreApplication.translate("PlateSolver", "Status: Solving with ASTAP…"))
|
|
1493
|
+
ok, res = _solve_numpy_with_astap(parent, settings, image, seed_header)
|
|
1494
|
+
if ok:
|
|
1495
|
+
_set_status_ui(parent, QCoreApplication.translate("PlateSolver", "Status: Solved with ASTAP."))
|
|
1496
|
+
return True, res
|
|
1497
|
+
|
|
1498
|
+
# ASTAP failed → tell the user and fall back
|
|
1499
|
+
err_msg = str(res) if res is not None else "unknown error"
|
|
1500
|
+
print("ASTAP failed:", err_msg)
|
|
1501
|
+
_set_status_ui(parent, QCoreApplication.translate("PlateSolver", "Status: ASTAP failed ({0}). Falling back to Astrometry.net…").format(err_msg))
|
|
1502
|
+
QApplication.processEvents()
|
|
1503
|
+
|
|
1504
|
+
# Fallback: astrometry.net (local solve-field first, then web API inside)
|
|
1505
|
+
ok2, res2 = _solve_numpy_with_astrometry(parent, settings, image, seed_header)
|
|
1506
|
+
if ok2:
|
|
1507
|
+
_set_status_ui(parent, QCoreApplication.translate("PlateSolver", "Status: Solved via Astrometry.net."))
|
|
1508
|
+
else:
|
|
1509
|
+
_set_status_ui(parent, QCoreApplication.translate("PlateSolver", "Status: Astrometry.net failed ({0}).").format(res2))
|
|
1510
|
+
|
|
1511
|
+
return ok2, res2
|
|
1512
|
+
|
|
1513
|
+
|
|
1514
|
+
def _save_temp_fits_via_save_image(norm_img: np.ndarray, clean_header: Header, is_mono: bool) -> str:
|
|
1515
|
+
"""
|
|
1516
|
+
Legacy helper used elsewhere. Make sure header axes match the data we write.
|
|
1517
|
+
If is_mono=True we force a 2-D primary HDU; otherwise we allow 3-D (H,W,C).
|
|
1518
|
+
"""
|
|
1519
|
+
hdr = Header()
|
|
1520
|
+
# sanitize header/axes
|
|
1521
|
+
if is_mono:
|
|
1522
|
+
# force 2-axis
|
|
1523
|
+
if norm_img.ndim != 2:
|
|
1524
|
+
raise ValueError("Expected 2-D array for mono temp FITS.")
|
|
1525
|
+
H, W = int(norm_img.shape[0]), int(norm_img.shape[1])
|
|
1526
|
+
hdr["SIMPLE"] = True
|
|
1527
|
+
hdr["BITPIX"] = -32
|
|
1528
|
+
hdr["NAXIS"] = 2
|
|
1529
|
+
hdr["NAXIS1"] = W
|
|
1530
|
+
hdr["NAXIS2"] = H
|
|
1531
|
+
else:
|
|
1532
|
+
# allow color (H, W, C)
|
|
1533
|
+
if norm_img.ndim != 3 or norm_img.shape[2] < 3:
|
|
1534
|
+
raise ValueError("Expected 3-D array (H,W,C) for color temp FITS.")
|
|
1535
|
+
H, W, C = int(norm_img.shape[0]), int(norm_img.shape[1]), int(norm_img.shape[2])
|
|
1536
|
+
hdr["SIMPLE"] = True
|
|
1537
|
+
hdr["BITPIX"] = -32
|
|
1538
|
+
hdr["NAXIS"] = 3
|
|
1539
|
+
hdr["NAXIS1"] = W
|
|
1540
|
+
hdr["NAXIS2"] = H
|
|
1541
|
+
hdr["NAXIS3"] = C
|
|
1542
|
+
|
|
1543
|
+
hdr["BZERO"] = 0.0
|
|
1544
|
+
hdr["BSCALE"] = 1.0
|
|
1545
|
+
hdr.add_comment("Temp FITS written for ASTAP solve.")
|
|
1546
|
+
|
|
1547
|
+
# write
|
|
1548
|
+
tmp = tempfile.NamedTemporaryFile(suffix=".fit", delete=False)
|
|
1549
|
+
tmp_path = tmp.name
|
|
1550
|
+
tmp.close()
|
|
1551
|
+
|
|
1552
|
+
save_image(
|
|
1553
|
+
img_array=np.clip(norm_img.astype(np.float32), 0.0, 1.0),
|
|
1554
|
+
filename=tmp_path,
|
|
1555
|
+
original_format="fit",
|
|
1556
|
+
bit_depth="32-bit floating point",
|
|
1557
|
+
original_header=hdr,
|
|
1558
|
+
is_mono=is_mono
|
|
1559
|
+
)
|
|
1560
|
+
|
|
1561
|
+
return tmp_path
|
|
1562
|
+
|
|
1563
|
+
|
|
1564
|
+
|
|
1565
|
+
def _active_doc_from_parent(parent) -> object | None:
|
|
1566
|
+
"""Try your helpers to get the active document."""
|
|
1567
|
+
if hasattr(parent, "_active_doc"):
|
|
1568
|
+
try:
|
|
1569
|
+
return parent._active_doc()
|
|
1570
|
+
except Exception:
|
|
1571
|
+
pass
|
|
1572
|
+
sw = getattr(parent, "mdi", None)
|
|
1573
|
+
if sw and hasattr(sw, "activeSubWindow"):
|
|
1574
|
+
asw = sw.activeSubWindow()
|
|
1575
|
+
if asw:
|
|
1576
|
+
w = asw.widget()
|
|
1577
|
+
return getattr(w, "document", None)
|
|
1578
|
+
return None
|
|
1579
|
+
|
|
1580
|
+
def _to_gray(arr: np.ndarray) -> np.ndarray:
|
|
1581
|
+
"""Always produce a 2-D grayscale float32 in [0,1]."""
|
|
1582
|
+
a = np.asarray(arr)
|
|
1583
|
+
# normalize to 0..1 first
|
|
1584
|
+
if a.dtype.kind in "ui":
|
|
1585
|
+
info = np.iinfo(a.dtype)
|
|
1586
|
+
a = a.astype(np.float32) / max(float(info.max), 1.0)
|
|
1587
|
+
else:
|
|
1588
|
+
a = np.clip(a.astype(np.float32), 0.0, 1.0)
|
|
1589
|
+
|
|
1590
|
+
if a.ndim == 2:
|
|
1591
|
+
return a
|
|
1592
|
+
if a.ndim == 3:
|
|
1593
|
+
if a.shape[2] >= 3:
|
|
1594
|
+
return (0.2126*a[...,0] + 0.7152*a[...,1] + 0.0722*a[...,2]).astype(np.float32)
|
|
1595
|
+
return a[...,0].astype(np.float32)
|
|
1596
|
+
# anything else, just flatten safely
|
|
1597
|
+
return a.reshape(a.shape[0], -1).astype(np.float32)
|
|
1598
|
+
|
|
1599
|
+
def _to_gray2d_unit(arr: np.ndarray) -> np.ndarray:
|
|
1600
|
+
"""
|
|
1601
|
+
Return a 2-D float32 array in [0,1].
|
|
1602
|
+
"""
|
|
1603
|
+
a = np.asarray(arr)
|
|
1604
|
+
if a.dtype.kind in "ui":
|
|
1605
|
+
info = np.iinfo(a.dtype)
|
|
1606
|
+
a = a.astype(np.float32) / max(float(info.max), 1.0)
|
|
1607
|
+
else:
|
|
1608
|
+
a = np.clip(a.astype(np.float32), 0.0, 1.0)
|
|
1609
|
+
|
|
1610
|
+
if a.ndim == 2:
|
|
1611
|
+
return a
|
|
1612
|
+
if a.ndim == 3:
|
|
1613
|
+
# perceptual luminance → 2-D
|
|
1614
|
+
if a.shape[2] >= 3:
|
|
1615
|
+
return (0.2126*a[...,0] + 0.7152*a[...,1] + 0.0722*a[...,2]).astype(np.float32)
|
|
1616
|
+
return a[...,0].astype(np.float32)
|
|
1617
|
+
# last resort: collapse to (H, W)
|
|
1618
|
+
return a.reshape(a.shape[0], -1).astype(np.float32)
|
|
1619
|
+
|
|
1620
|
+
|
|
1621
|
+
# ---------------------------------------------------------------------
|
|
1622
|
+
# Core ASTAP solving for a numpy image + seed header
|
|
1623
|
+
# ---------------------------------------------------------------------
|
|
1624
|
+
|
|
1625
|
+
def _solve_numpy_with_astap(parent, settings, image: np.ndarray, seed_header: Header | None) -> Tuple[bool, Header | str]:
|
|
1626
|
+
"""
|
|
1627
|
+
Normalize → write temp mono FITS → run ASTAP → return the EXACT FITS header ASTAP wrote.
|
|
1628
|
+
"""
|
|
1629
|
+
astap_exe = _get_astap_exe(settings)
|
|
1630
|
+
if not astap_exe or not os.path.exists(astap_exe):
|
|
1631
|
+
return False, QCoreApplication.translate("PlateSolver", "ASTAP path is not set (see Preferences) or file not found.")
|
|
1632
|
+
|
|
1633
|
+
# normalize and force 2-D luminance in [0,1]
|
|
1634
|
+
norm = _normalize_for_astap(image)
|
|
1635
|
+
#gray = _to_gray2d_unit(image)
|
|
1636
|
+
gray = _to_gray2d_unit(norm)
|
|
1637
|
+
|
|
1638
|
+
# build a clean temp header (strip old WCS but KEEP acquisition keys)
|
|
1639
|
+
if isinstance(seed_header, Header):
|
|
1640
|
+
clean_for_temp = _strip_wcs_keys(seed_header)
|
|
1641
|
+
base_for_merge = clean_for_temp # acquisition info lives here
|
|
1642
|
+
_debug_dump_header("ASTAP: CLEAN_FOR_TEMP (seed_header with WCS stripped)", clean_for_temp)
|
|
1643
|
+
_debug_dump_header("ASTAP: BASE_FOR_MERGE (acquisition header we expect to preserve)", base_for_merge)
|
|
1644
|
+
else:
|
|
1645
|
+
clean_for_temp = _minimal_header_for_gray2d(*gray.shape)
|
|
1646
|
+
base_for_merge = None
|
|
1647
|
+
_debug_dump_header("ASTAP: CLEAN_FOR_TEMP (minimal header, no seed)", clean_for_temp)
|
|
1648
|
+
|
|
1649
|
+
tmp_fit, sidecar_wcs = _write_temp_fit_via_save_image(gray, clean_for_temp)
|
|
1650
|
+
print(f"[ASTAP] Temp FITS: {tmp_fit}, sidecar WCS: {sidecar_wcs}")
|
|
1651
|
+
|
|
1652
|
+
# seed if possible; otherwise blind
|
|
1653
|
+
seed_args: list[str] = []
|
|
1654
|
+
scale_arcsec = None
|
|
1655
|
+
try:
|
|
1656
|
+
seed_args, dbg, scale_arcsec = _build_astap_seed_with_overrides(settings, seed_header, gray)
|
|
1657
|
+
if seed_args:
|
|
1658
|
+
# radius & fov modes (already implemented)
|
|
1659
|
+
radius_mode = _get_astap_radius_mode(settings) # "auto" or "value"
|
|
1660
|
+
fov_mode = _get_astap_fov_mode(settings) # "auto", "compute", "value"
|
|
1661
|
+
|
|
1662
|
+
# radius
|
|
1663
|
+
if radius_mode == "auto":
|
|
1664
|
+
r_arg = ["-r", "0"] # ASTAP auto
|
|
1665
|
+
r_dbg = "r=auto(0)"
|
|
1666
|
+
else:
|
|
1667
|
+
r_val = max(0.0, float(_get_astap_radius_value(settings)))
|
|
1668
|
+
r_arg = ["-r", f"{r_val:.3f}"]
|
|
1669
|
+
r_dbg = f"r={r_val:.3f}°"
|
|
1670
|
+
|
|
1671
|
+
# fov
|
|
1672
|
+
if fov_mode == "auto":
|
|
1673
|
+
fov_arg = ["-fov", "0"]
|
|
1674
|
+
f_dbg = "fov=auto(0)"
|
|
1675
|
+
elif fov_mode == "value":
|
|
1676
|
+
fv = max(0.0, float(_get_astap_fov_value(settings)))
|
|
1677
|
+
fov_arg = ["-fov", f"{fv:.4f}"]
|
|
1678
|
+
f_dbg = f"fov={fv:.4f}°"
|
|
1679
|
+
else: # "compute"
|
|
1680
|
+
fv = _compute_fov_deg(gray, scale_arcsec) or 0.0
|
|
1681
|
+
fov_arg = ["-fov", f"{fv:.4f}"]
|
|
1682
|
+
f_dbg = f"fov(computed)={fv:.4f}°"
|
|
1683
|
+
|
|
1684
|
+
seed_args = seed_args + r_arg + fov_arg
|
|
1685
|
+
print("ASTAP seed:", dbg, "|", r_dbg, "|", f_dbg)
|
|
1686
|
+
else:
|
|
1687
|
+
print("Seed disabled/invalid → blind:", dbg)
|
|
1688
|
+
except Exception as e:
|
|
1689
|
+
print("Seed build error:", e)
|
|
1690
|
+
|
|
1691
|
+
if not seed_args:
|
|
1692
|
+
seed_args = ["-r", "179", "-fov", "0", "-z", "0"]
|
|
1693
|
+
print("ASTAP BLIND: using arguments:", " ".join(seed_args))
|
|
1694
|
+
|
|
1695
|
+
args = ["-f", tmp_fit] + seed_args + ["-wcs", "-sip"]
|
|
1696
|
+
print("Running ASTAP with:", " ".join([astap_exe] + args))
|
|
1697
|
+
|
|
1698
|
+
proc = QProcess(parent)
|
|
1699
|
+
proc.start(astap_exe, args)
|
|
1700
|
+
if not proc.waitForStarted(5000):
|
|
1701
|
+
_set_status_ui(parent, QCoreApplication.translate("PlateSolver", "Status: ASTAP failed to start."))
|
|
1702
|
+
return False, QCoreApplication.translate("PlateSolver", "Failed to start ASTAP: {0}").format(proc.errorString())
|
|
1703
|
+
|
|
1704
|
+
_set_status_ui(parent, QCoreApplication.translate("PlateSolver", "Status: ASTAP solving…"))
|
|
1705
|
+
if not _wait_process(proc, 300000, parent=parent):
|
|
1706
|
+
_set_status_ui(parent, QCoreApplication.translate("PlateSolver", "Status: ASTAP timed out."))
|
|
1707
|
+
return False, QCoreApplication.translate("PlateSolver", "ASTAP timed out.")
|
|
1708
|
+
|
|
1709
|
+
if proc.exitCode() != 0:
|
|
1710
|
+
out = bytes(proc.readAllStandardOutput()).decode(errors="ignore")
|
|
1711
|
+
err = bytes(proc.readAllStandardError()).decode(errors="ignore")
|
|
1712
|
+
print("ASTAP failed.\nSTDOUT:\n", out, "\nSTDERR:\n", err)
|
|
1713
|
+
try: os.remove(tmp_fit)
|
|
1714
|
+
except Exception as e:
|
|
1715
|
+
import logging
|
|
1716
|
+
logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
|
|
1717
|
+
try:
|
|
1718
|
+
if os.path.exists(sidecar_wcs): os.remove(sidecar_wcs)
|
|
1719
|
+
except Exception as e:
|
|
1720
|
+
import logging
|
|
1721
|
+
logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
|
|
1722
|
+
return False, QCoreApplication.translate("PlateSolver", "ASTAP returned a non-zero exit code.")
|
|
1723
|
+
|
|
1724
|
+
# >>> THIS is the key change: read the header **directly** from the FITS ASTAP wrote
|
|
1725
|
+
try:
|
|
1726
|
+
# Use acquisition header as base + WCS from .wcs
|
|
1727
|
+
hdr = _build_header_from_astap_outputs(tmp_fit, sidecar_wcs, base_for_merge)
|
|
1728
|
+
finally:
|
|
1729
|
+
try: os.remove(tmp_fit)
|
|
1730
|
+
except Exception as e:
|
|
1731
|
+
import logging
|
|
1732
|
+
logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
|
|
1733
|
+
try:
|
|
1734
|
+
if os.path.exists(sidecar_wcs): os.remove(sidecar_wcs)
|
|
1735
|
+
except Exception as e:
|
|
1736
|
+
import logging
|
|
1737
|
+
logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
|
|
1738
|
+
|
|
1739
|
+
# return a REAL fits.Header (no blobs/strings/dicts)
|
|
1740
|
+
return True, hdr
|
|
1741
|
+
|
|
1742
|
+
|
|
1743
|
+
|
|
1744
|
+
# ---------------------------------------------------------------------
|
|
1745
|
+
# Solve active doc in-place
|
|
1746
|
+
# ---------------------------------------------------------------------
|
|
1747
|
+
|
|
1748
|
+
# --- Debug helpers ---------------------------------------------------
|
|
1749
|
+
DEBUG_PLATESOLVE_HEADERS = False # set False to silence all header dumps
|
|
1750
|
+
|
|
1751
|
+
|
|
1752
|
+
def _debug_dump_header(label: str, hdr: Header | None):
|
|
1753
|
+
"""Print a full FITS Header to the console for debugging."""
|
|
1754
|
+
if not DEBUG_PLATESOLVE_HEADERS:
|
|
1755
|
+
return
|
|
1756
|
+
|
|
1757
|
+
print(f"\n===== {label} =====")
|
|
1758
|
+
if hdr is None:
|
|
1759
|
+
print(" (None)")
|
|
1760
|
+
elif isinstance(hdr, Header):
|
|
1761
|
+
print(f" (#cards = {len(hdr)})")
|
|
1762
|
+
for k, v in hdr.items():
|
|
1763
|
+
print(f" {k:8s} = {v!r}")
|
|
1764
|
+
else:
|
|
1765
|
+
print(f" (not a Header: {type(hdr)!r})")
|
|
1766
|
+
print("========================================\n")
|
|
1767
|
+
|
|
1768
|
+
def _debug_dump_meta(label: str, meta: dict):
|
|
1769
|
+
if not DEBUG_PLATESOLVE_HEADERS:
|
|
1770
|
+
return
|
|
1771
|
+
print(f"\n===== {label} (meta keys) =====")
|
|
1772
|
+
for k in sorted(meta.keys()):
|
|
1773
|
+
v = meta[k]
|
|
1774
|
+
print(f" {k}: {type(v).__name__}")
|
|
1775
|
+
print("================================\n")
|
|
1776
|
+
|
|
1777
|
+
|
|
1778
|
+
|
|
1779
|
+
def plate_solve_doc_inplace(parent, doc, settings) -> Tuple[bool, Header | str]:
|
|
1780
|
+
img = getattr(doc, "image", None)
|
|
1781
|
+
if img is None:
|
|
1782
|
+
return False, QCoreApplication.translate("PlateSolver", "Active document has no image data.")
|
|
1783
|
+
|
|
1784
|
+
# Make sure metadata is a dict we can mutate
|
|
1785
|
+
meta = getattr(doc, "metadata", {}) or {}
|
|
1786
|
+
if not isinstance(meta, dict):
|
|
1787
|
+
try:
|
|
1788
|
+
meta = dict(meta)
|
|
1789
|
+
except Exception:
|
|
1790
|
+
meta = {}
|
|
1791
|
+
|
|
1792
|
+
_debug_dump_meta("META BEFORE SOLVE", meta)
|
|
1793
|
+
_debug_dump_header("META['original_header'] BEFORE SOLVE", meta.get("original_header"))
|
|
1794
|
+
|
|
1795
|
+
seed_h = _seed_header_from_meta(meta)
|
|
1796
|
+
_debug_dump_header("SEED HEADER FROM META (seed_h)", seed_h)
|
|
1797
|
+
|
|
1798
|
+
# Keep a copy of acquisition header (no WCS) for merge
|
|
1799
|
+
# Prefer the true acquisition header if we have it, otherwise fall back.
|
|
1800
|
+
raw_acq = meta.get("original_header") or meta.get("fits_header")
|
|
1801
|
+
|
|
1802
|
+
acq_base: Header | None = None
|
|
1803
|
+
if isinstance(raw_acq, Header):
|
|
1804
|
+
# Use the original acquisition header (OBJECT, EXPTIME, GAIN, etc.)
|
|
1805
|
+
acq_base = _strip_wcs_keys(raw_acq.copy())
|
|
1806
|
+
_debug_dump_header("ACQ_BASE (original/fits header with WCS stripped)", acq_base)
|
|
1807
|
+
elif isinstance(seed_h, Header):
|
|
1808
|
+
# Fallback: use the seed header as our acquisition base
|
|
1809
|
+
acq_base = _strip_wcs_keys(seed_h.copy())
|
|
1810
|
+
_debug_dump_header("ACQ_BASE (seed_h with WCS stripped)", acq_base)
|
|
1811
|
+
else:
|
|
1812
|
+
acq_base = None
|
|
1813
|
+
_debug_dump_header("ACQ_BASE (none available)", None)
|
|
1814
|
+
|
|
1815
|
+
# Better debug: use our new scale estimator
|
|
1816
|
+
try:
|
|
1817
|
+
if isinstance(seed_h, Header):
|
|
1818
|
+
ra = seed_h.get("CRVAL1", None)
|
|
1819
|
+
dec = seed_h.get("CRVAL2", None)
|
|
1820
|
+
scale = _estimate_scale_arcsec_from_header(seed_h)
|
|
1821
|
+
print(f"[PlateSolve seed] CRVAL1={ra}, CRVAL2={dec}, scale≈{scale} \"/px")
|
|
1822
|
+
else:
|
|
1823
|
+
print("[PlateSolve seed] No valid seed header available.")
|
|
1824
|
+
except Exception as e:
|
|
1825
|
+
print("Seed: debug print failed:", e)
|
|
1826
|
+
|
|
1827
|
+
# Determine if we have inline status/log widgets; if not, show the popup.
|
|
1828
|
+
headless = not (
|
|
1829
|
+
(hasattr(parent, "status") and isinstance(getattr(parent, "status"), QLabel)) or
|
|
1830
|
+
(hasattr(parent, "log") and hasattr(getattr(parent, "log"), "append")) or
|
|
1831
|
+
(hasattr(parent, "findChild") and parent.findChild(QLabel, "status_label") is not None)
|
|
1832
|
+
)
|
|
1833
|
+
if headless:
|
|
1834
|
+
_status_popup_open(parent, QCoreApplication.translate("PlateSolver", "Status: Preparing plate solve…"))
|
|
1835
|
+
|
|
1836
|
+
try:
|
|
1837
|
+
ok, res = _solve_numpy_with_fallback(parent, settings, img, seed_h)
|
|
1838
|
+
if not ok:
|
|
1839
|
+
return False, res
|
|
1840
|
+
|
|
1841
|
+
hdr: Header = res
|
|
1842
|
+
_debug_dump_header("SOLVER RAW HEADER (from _solve_numpy_with_fallback)", hdr)
|
|
1843
|
+
|
|
1844
|
+
# Final header = acquisition + new WCS (solver)
|
|
1845
|
+
if isinstance(acq_base, Header) and isinstance(hdr, Header):
|
|
1846
|
+
hdr_final = _merge_wcs_into_base_header(acq_base, hdr)
|
|
1847
|
+
else:
|
|
1848
|
+
hdr_final = hdr if isinstance(hdr, Header) else Header()
|
|
1849
|
+
|
|
1850
|
+
_debug_dump_header("FINAL MERGED HEADER (hdr_final)", hdr_final)
|
|
1851
|
+
# 🔹 NEW: stash pre-solve header ONCE so we never lose it
|
|
1852
|
+
try:
|
|
1853
|
+
if "original_header" in meta and "pre_solve_header" not in meta:
|
|
1854
|
+
old = meta["original_header"]
|
|
1855
|
+
if isinstance(old, Header):
|
|
1856
|
+
meta["pre_solve_header"] = old.copy()
|
|
1857
|
+
except Exception as e:
|
|
1858
|
+
print("plate_solve_doc_inplace: failed to stash pre_solve_header:", e)
|
|
1859
|
+
|
|
1860
|
+
# 🔹 Ensure doc.metadata is our updated dict
|
|
1861
|
+
doc.metadata = meta
|
|
1862
|
+
|
|
1863
|
+
# Store merged header as the current "original_header"
|
|
1864
|
+
doc.metadata["original_header"] = hdr_final
|
|
1865
|
+
_debug_dump_header("DOC.METADATA['original_header'] AFTER SOLVE", doc.metadata.get("original_header"))
|
|
1866
|
+
|
|
1867
|
+
|
|
1868
|
+
# Build WCS object from the same header we just stored
|
|
1869
|
+
try:
|
|
1870
|
+
wcs_obj = WCS(hdr_final)
|
|
1871
|
+
doc.metadata["wcs"] = wcs_obj
|
|
1872
|
+
except Exception as e:
|
|
1873
|
+
print("WCS build FAILED:", e)
|
|
1874
|
+
|
|
1875
|
+
# Notify UI
|
|
1876
|
+
if hasattr(doc, "changed"):
|
|
1877
|
+
try: doc.changed.emit()
|
|
1878
|
+
except Exception as e:
|
|
1879
|
+
import logging
|
|
1880
|
+
logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
|
|
1881
|
+
|
|
1882
|
+
if hasattr(parent, "header_viewer") and hasattr(parent.header_viewer, "set_document"):
|
|
1883
|
+
QTimer.singleShot(0, lambda: parent.header_viewer.set_document(doc))
|
|
1884
|
+
if hasattr(parent, "_refresh_header_viewer"):
|
|
1885
|
+
QTimer.singleShot(0, lambda: parent._refresh_header_viewer(doc))
|
|
1886
|
+
if hasattr(parent, "currentDocumentChanged"):
|
|
1887
|
+
QTimer.singleShot(0, lambda: parent.currentDocumentChanged.emit(doc))
|
|
1888
|
+
|
|
1889
|
+
_set_status_ui(parent, QCoreApplication.translate("PlateSolver", "Status: Plate solve completed."))
|
|
1890
|
+
_status_popup_close()
|
|
1891
|
+
return True, hdr
|
|
1892
|
+
finally:
|
|
1893
|
+
_status_popup_close()
|
|
1894
|
+
|
|
1895
|
+
|
|
1896
|
+
|
|
1897
|
+
def _estimate_scale_arcsec_from_header(hdr: Header) -> float | None:
|
|
1898
|
+
"""
|
|
1899
|
+
Estimate pixel scale in arcsec/pixel from a FITS Header.
|
|
1900
|
+
Tries WCS, then CD matrix, then PC*CDELT, then PIXSCALE-style keys.
|
|
1901
|
+
Returns None if we can't get a sane value.
|
|
1902
|
+
"""
|
|
1903
|
+
# Always work on a copy with our internal meta keys stripped
|
|
1904
|
+
hdr = _strip_nonfits_meta_keys_from_header(hdr)
|
|
1905
|
+
|
|
1906
|
+
# 1) Try astropy WCS, which handles CD vs PC*CDELT automatically
|
|
1907
|
+
try:
|
|
1908
|
+
w = WCS(hdr)
|
|
1909
|
+
from astropy.wcs.utils import proj_plane_pixel_scales
|
|
1910
|
+
scales_deg = proj_plane_pixel_scales(w) # degrees/pixel
|
|
1911
|
+
if scales_deg is not None and len(scales_deg) >= 2:
|
|
1912
|
+
s_deg = float(np.mean(scales_deg[:2]))
|
|
1913
|
+
scale = s_deg * 3600.0 # arcsec/pixel
|
|
1914
|
+
if 0 < scale < 10000:
|
|
1915
|
+
return scale
|
|
1916
|
+
except Exception as e:
|
|
1917
|
+
print("Seed: WCS->scale via proj_plane_pixel_scales failed:", e)
|
|
1918
|
+
|
|
1919
|
+
# 2) Try CD matrix directly
|
|
1920
|
+
cd11 = hdr.get("CD1_1")
|
|
1921
|
+
cd21 = hdr.get("CD2_1")
|
|
1922
|
+
try:
|
|
1923
|
+
if cd11 is not None or cd21 is not None:
|
|
1924
|
+
cd11 = float(cd11 or 0.0)
|
|
1925
|
+
cd21 = float(cd21 or 0.0)
|
|
1926
|
+
s_deg = (cd11 * cd11 + cd21 * cd21) ** 0.5
|
|
1927
|
+
scale = s_deg * 3600.0
|
|
1928
|
+
if 0 < scale < 10000:
|
|
1929
|
+
return scale
|
|
1930
|
+
except Exception as e:
|
|
1931
|
+
print("Seed: CD-based scale failed:", e)
|
|
1932
|
+
|
|
1933
|
+
# 3) Try PC * CDELT fallback
|
|
1934
|
+
try:
|
|
1935
|
+
cdelt1 = hdr.get("CDELT1")
|
|
1936
|
+
cdelt2 = hdr.get("CDELT2")
|
|
1937
|
+
pc11 = hdr.get("PC1_1")
|
|
1938
|
+
pc21 = hdr.get("PC2_1")
|
|
1939
|
+
if cdelt1 is not None and pc11 is not None:
|
|
1940
|
+
cd11 = float(cdelt1) * float(pc11)
|
|
1941
|
+
else:
|
|
1942
|
+
cd11 = None
|
|
1943
|
+
if cdelt2 is not None and pc21 is not None:
|
|
1944
|
+
cd21 = float(cdelt2) * float(pc21)
|
|
1945
|
+
else:
|
|
1946
|
+
cd21 = None
|
|
1947
|
+
|
|
1948
|
+
if cd11 is not None or cd21 is not None:
|
|
1949
|
+
s_deg = ( (cd11 or 0.0)**2 + (cd21 or 0.0)**2 ) ** 0.5
|
|
1950
|
+
scale = s_deg * 3600.0
|
|
1951
|
+
if 0 < scale < 10000:
|
|
1952
|
+
return scale
|
|
1953
|
+
except Exception as e:
|
|
1954
|
+
print("Seed: PC*CDELT-based scale failed:", e)
|
|
1955
|
+
|
|
1956
|
+
# 4) Fallback on explicit pixscale-like keywords, if present
|
|
1957
|
+
for key in ("PIXSCALE", "SECPIX"):
|
|
1958
|
+
if key in hdr:
|
|
1959
|
+
try:
|
|
1960
|
+
scale = float(hdr[key])
|
|
1961
|
+
if 0 < scale < 10000:
|
|
1962
|
+
return scale
|
|
1963
|
+
except Exception:
|
|
1964
|
+
pass
|
|
1965
|
+
|
|
1966
|
+
# If we get here, we couldn't find a sane scale
|
|
1967
|
+
return None
|
|
1968
|
+
|
|
1969
|
+
def _seed_header_from_meta(meta: dict) -> Header:
|
|
1970
|
+
"""
|
|
1971
|
+
Build the header used for ASTAP seeding from doc.metadata.
|
|
1972
|
+
|
|
1973
|
+
Priority:
|
|
1974
|
+
1. original_header (if present)
|
|
1975
|
+
2. meta as a dict
|
|
1976
|
+
Then merge in any WCS info from:
|
|
1977
|
+
- meta['wcs_header'] (Header or string)
|
|
1978
|
+
- meta['wcs'] (WCS object)
|
|
1979
|
+
"""
|
|
1980
|
+
# Base: original FITS header if present, otherwise treat meta dict as header
|
|
1981
|
+
base_src = meta.get("original_header") or meta.get("fits_header") or meta
|
|
1982
|
+
base = _as_header(base_src)
|
|
1983
|
+
|
|
1984
|
+
wcs_hdr: Header | None = None
|
|
1985
|
+
|
|
1986
|
+
# 1) Use explicit wcs_header if present
|
|
1987
|
+
raw_wcs = meta.get("wcs_header")
|
|
1988
|
+
if isinstance(raw_wcs, Header):
|
|
1989
|
+
wcs_hdr = raw_wcs
|
|
1990
|
+
elif isinstance(raw_wcs, str):
|
|
1991
|
+
# This is your case: stored as Header.tostring()
|
|
1992
|
+
try:
|
|
1993
|
+
# In real metadata this likely has newlines; sep='\n' handles that.
|
|
1994
|
+
wcs_hdr = fits.Header.fromstring(raw_wcs, sep='\n')
|
|
1995
|
+
except Exception as e:
|
|
1996
|
+
print("Seed: failed to parse wcs_header string:", e)
|
|
1997
|
+
|
|
1998
|
+
# 2) Fallback: derive from WCS object if we still don't have a header
|
|
1999
|
+
if wcs_hdr is None:
|
|
2000
|
+
wcs_obj = meta.get("wcs")
|
|
2001
|
+
if isinstance(wcs_obj, WCS):
|
|
2002
|
+
try:
|
|
2003
|
+
wcs_hdr = wcs_obj.to_header(relax=True)
|
|
2004
|
+
except Exception as e:
|
|
2005
|
+
print("Seed: failed to derive WCS header from WCS object:", e)
|
|
2006
|
+
|
|
2007
|
+
# 3) Merge WCS header into base header, with WCS keys winning
|
|
2008
|
+
if wcs_hdr is not None:
|
|
2009
|
+
if not isinstance(base, Header):
|
|
2010
|
+
base = Header()
|
|
2011
|
+
else:
|
|
2012
|
+
base = base.copy()
|
|
2013
|
+
for k, v in wcs_hdr.items():
|
|
2014
|
+
try:
|
|
2015
|
+
base[k] = v
|
|
2016
|
+
except Exception:
|
|
2017
|
+
pass
|
|
2018
|
+
|
|
2019
|
+
return _strip_nonfits_meta_keys_from_header(base)
|
|
2020
|
+
|
|
2021
|
+
|
|
2022
|
+
def _compute_fov_deg(image: np.ndarray, arcsec_per_px: float | None) -> float | None:
|
|
2023
|
+
if arcsec_per_px is None or not np.isfinite(arcsec_per_px) or arcsec_per_px <= 0:
|
|
2024
|
+
return None
|
|
2025
|
+
H = int(image.shape[0]) if image.ndim >= 2 else 0
|
|
2026
|
+
if H <= 0:
|
|
2027
|
+
return None
|
|
2028
|
+
return (H * arcsec_per_px) / 3600.0 # vertical FOV in degrees
|
|
2029
|
+
|
|
2030
|
+
def plate_solve_active_document(parent, settings) -> tuple[bool, Header | str]:
|
|
2031
|
+
"""
|
|
2032
|
+
Convenience wrapper:
|
|
2033
|
+
- Finds the active document from the given parent (main window, ImagePeeker, etc.)
|
|
2034
|
+
- Calls plate_solve_doc_inplace(...)
|
|
2035
|
+
|
|
2036
|
+
Returns (ok, Header | error_message).
|
|
2037
|
+
"""
|
|
2038
|
+
doc = _active_doc_from_parent(parent)
|
|
2039
|
+
if doc is None:
|
|
2040
|
+
return False, QCoreApplication.translate("PlateSolver", "No active document to plate-solve.")
|
|
2041
|
+
|
|
2042
|
+
return plate_solve_doc_inplace(parent, doc, settings)
|
|
2043
|
+
|
|
2044
|
+
# ---------------------------------------------------------------------
|
|
2045
|
+
# Dialog UI with Active/File and Batch modes
|
|
2046
|
+
# ---------------------------------------------------------------------
|
|
2047
|
+
|
|
2048
|
+
class PlateSolverDialog(QDialog):
|
|
2049
|
+
"""
|
|
2050
|
+
Plate-solve either:
|
|
2051
|
+
- Active View (default)
|
|
2052
|
+
- Single File (via load_image/save_image)
|
|
2053
|
+
- Batch (directory → directory)
|
|
2054
|
+
Uses settings key: 'paths/astap' or 'astap/exe_path' for ASTAP executable.
|
|
2055
|
+
"""
|
|
2056
|
+
def __init__(self, settings, parent=None, icon: QIcon | None = None):
|
|
2057
|
+
super().__init__(parent)
|
|
2058
|
+
self.settings = settings
|
|
2059
|
+
self.setWindowTitle(self.tr("Plate Solver"))
|
|
2060
|
+
self.setMinimumWidth(560)
|
|
2061
|
+
self.setWindowFlag(Qt.WindowType.Window, True)
|
|
2062
|
+
self.setWindowModality(Qt.WindowModality.NonModal)
|
|
2063
|
+
self.setModal(False)
|
|
2064
|
+
#self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
|
|
2065
|
+
|
|
2066
|
+
# ---------------- Main containers ----------------
|
|
2067
|
+
main = QVBoxLayout(self)
|
|
2068
|
+
main.setContentsMargins(10, 10, 10, 10)
|
|
2069
|
+
main.setSpacing(10)
|
|
2070
|
+
|
|
2071
|
+
# ---- Top row: Mode selector ----
|
|
2072
|
+
top = QHBoxLayout()
|
|
2073
|
+
top.addWidget(QLabel(self.tr("Mode:"), self))
|
|
2074
|
+
self.mode_combo = QComboBox(self)
|
|
2075
|
+
self.mode_combo.addItem(self.tr("Active View"), "Active View")
|
|
2076
|
+
self.mode_combo.addItem(self.tr("File"), "File")
|
|
2077
|
+
self.mode_combo.addItem(self.tr("Batch"), "Batch")
|
|
2078
|
+
top.addWidget(self.mode_combo, 1)
|
|
2079
|
+
top.addStretch(1)
|
|
2080
|
+
main.addLayout(top)
|
|
2081
|
+
|
|
2082
|
+
# ---- Seeding group (shared) ----
|
|
2083
|
+
from PyQt6.QtWidgets import QGroupBox, QFormLayout
|
|
2084
|
+
seed_box = QGroupBox(self.tr("Seeding & Constraints"), self)
|
|
2085
|
+
seed_form = QFormLayout(seed_box)
|
|
2086
|
+
seed_form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow)
|
|
2087
|
+
seed_form.setHorizontalSpacing(8)
|
|
2088
|
+
seed_form.setVerticalSpacing(6)
|
|
2089
|
+
|
|
2090
|
+
# Seed mode
|
|
2091
|
+
self.cb_seed_mode = QComboBox(seed_box)
|
|
2092
|
+
self.cb_seed_mode.addItem(self.tr("Auto (from header)"), "Auto (from header)")
|
|
2093
|
+
self.cb_seed_mode.addItem(self.tr("Manual"), "Manual")
|
|
2094
|
+
self.cb_seed_mode.addItem(self.tr("None (blind)"), "None (blind)")
|
|
2095
|
+
seed_form.addRow(self.tr("Seed mode:"), self.cb_seed_mode)
|
|
2096
|
+
|
|
2097
|
+
# Manual RA/Dec/Scale row
|
|
2098
|
+
manual_row = QHBoxLayout()
|
|
2099
|
+
self.le_ra = QLineEdit(seed_box); self.le_ra.setPlaceholderText(self.tr("RA (e.g. 22:32:14 or 338.1385)"))
|
|
2100
|
+
self.le_dec = QLineEdit(seed_box); self.le_dec.setPlaceholderText(self.tr("Dec (e.g. +40:42:43 or 40.7123)"))
|
|
2101
|
+
self.le_scale = QLineEdit(seed_box); self.le_scale.setPlaceholderText(self.tr('Scale [" / px] (e.g. 1.46)'))
|
|
2102
|
+
manual_row.addWidget(self.le_ra, 1)
|
|
2103
|
+
manual_row.addWidget(self.le_dec, 1)
|
|
2104
|
+
manual_row.addWidget(self.le_scale, 1)
|
|
2105
|
+
seed_form.addRow(self.tr("Manual RA/Dec/Scale:"), manual_row)
|
|
2106
|
+
|
|
2107
|
+
# Search radius (-r)
|
|
2108
|
+
rad_row = QHBoxLayout()
|
|
2109
|
+
self.cb_radius_mode = QComboBox(seed_box)
|
|
2110
|
+
self.cb_radius_mode.addItem(self.tr("Auto (-r 0)"), "Auto (-r 0)")
|
|
2111
|
+
self.cb_radius_mode.addItem(self.tr("Value (deg)"), "Value (deg)")
|
|
2112
|
+
self.le_radius_val = QLineEdit(seed_box); self.le_radius_val.setPlaceholderText(self.tr("e.g. 5.0"))
|
|
2113
|
+
self.le_radius_val.setFixedWidth(120)
|
|
2114
|
+
rad_row.addWidget(self.cb_radius_mode)
|
|
2115
|
+
rad_row.addWidget(self.le_radius_val)
|
|
2116
|
+
rad_row.addStretch(1)
|
|
2117
|
+
seed_form.addRow(self.tr("Search radius:"), rad_row)
|
|
2118
|
+
|
|
2119
|
+
# FOV (-fov)
|
|
2120
|
+
fov_row = QHBoxLayout()
|
|
2121
|
+
self.cb_fov_mode = QComboBox(seed_box)
|
|
2122
|
+
self.cb_fov_mode.addItem(self.tr("Compute from scale"), "Compute from scale")
|
|
2123
|
+
self.cb_fov_mode.addItem(self.tr("Auto (-fov 0)"), "Auto (-fov 0)")
|
|
2124
|
+
self.cb_fov_mode.addItem(self.tr("Value (deg)"), "Value (deg)")
|
|
2125
|
+
self.le_fov_val = QLineEdit(seed_box); self.le_fov_val.setPlaceholderText(self.tr("e.g. 1.80"))
|
|
2126
|
+
self.le_fov_val.setFixedWidth(120)
|
|
2127
|
+
fov_row.addWidget(self.cb_fov_mode)
|
|
2128
|
+
fov_row.addWidget(self.le_fov_val)
|
|
2129
|
+
fov_row.addStretch(1)
|
|
2130
|
+
seed_form.addRow(self.tr("FOV:"), fov_row)
|
|
2131
|
+
|
|
2132
|
+
# Tooltips
|
|
2133
|
+
self.cb_seed_mode.setToolTip(self.tr("Use FITS header, your manual RA/Dec/scale, or blind solve."))
|
|
2134
|
+
self.le_scale.setToolTip(self.tr('Pixel scale in arcseconds/pixel (e.g., 1.46).'))
|
|
2135
|
+
self.cb_radius_mode.setToolTip(self.tr("ASTAP -r. Auto lets ASTAP choose; Value forces a cone radius."))
|
|
2136
|
+
self.cb_fov_mode.setToolTip(self.tr("ASTAP -fov. Compute uses image height × scale; Auto lets ASTAP infer."))
|
|
2137
|
+
|
|
2138
|
+
main.addWidget(seed_box)
|
|
2139
|
+
|
|
2140
|
+
# ---------------- Stacked pages ----------------
|
|
2141
|
+
self.stack = QStackedWidget(self)
|
|
2142
|
+
main.addWidget(self.stack, 1)
|
|
2143
|
+
|
|
2144
|
+
# Page 0: Active View
|
|
2145
|
+
p0 = QWidget(self); l0 = QVBoxLayout(p0)
|
|
2146
|
+
l0.addWidget(QLabel(self.tr("Solve the currently active image view."), p0))
|
|
2147
|
+
l0.addStretch(1)
|
|
2148
|
+
self.stack.addWidget(p0)
|
|
2149
|
+
|
|
2150
|
+
# Page 1: File picker
|
|
2151
|
+
p1 = QWidget(self); l1 = QVBoxLayout(p1)
|
|
2152
|
+
file_row = QHBoxLayout()
|
|
2153
|
+
self.le_path = QLineEdit(p1); self.le_path.setPlaceholderText(self.tr("Choose an image…"))
|
|
2154
|
+
btn_browse = QPushButton(self.tr("Browse…"), p1)
|
|
2155
|
+
file_row.addWidget(self.le_path, 1); file_row.addWidget(btn_browse)
|
|
2156
|
+
l1.addLayout(file_row); l1.addStretch(1)
|
|
2157
|
+
self.stack.addWidget(p1)
|
|
2158
|
+
|
|
2159
|
+
# Page 2: Batch
|
|
2160
|
+
p2 = QWidget(self); l2 = QVBoxLayout(p2)
|
|
2161
|
+
in_row = QHBoxLayout(); out_row = QHBoxLayout()
|
|
2162
|
+
self.le_in = QLineEdit(p2); self.le_in.setPlaceholderText(self.tr("Input directory"))
|
|
2163
|
+
self.le_out = QLineEdit(p2); self.le_out.setPlaceholderText(self.tr("Output directory"))
|
|
2164
|
+
b_in = QPushButton(self.tr("Browse Input…"), p2)
|
|
2165
|
+
b_out = QPushButton(self.tr("Browse Output…"), p2)
|
|
2166
|
+
in_row.addWidget(self.le_in, 1); in_row.addWidget(b_in)
|
|
2167
|
+
out_row.addWidget(self.le_out, 1); out_row.addWidget(b_out)
|
|
2168
|
+
self.log = QTextEdit(p2); self.log.setReadOnly(True); self.log.setMinimumHeight(160)
|
|
2169
|
+
l2.addLayout(in_row); l2.addLayout(out_row); l2.addWidget(QLabel(self.tr("Status:"), p2)); l2.addWidget(self.log, 1)
|
|
2170
|
+
self.stack.addWidget(p2)
|
|
2171
|
+
|
|
2172
|
+
# ---------------- Status + buttons ----------------
|
|
2173
|
+
self.status = QLabel("", self)
|
|
2174
|
+
self.status.setMinimumHeight(20)
|
|
2175
|
+
main.addWidget(self.status)
|
|
2176
|
+
|
|
2177
|
+
btn_row = QHBoxLayout()
|
|
2178
|
+
btn_row.addStretch(1)
|
|
2179
|
+
self.btn_go = QPushButton(self.tr("Start"), self)
|
|
2180
|
+
self.btn_close = QPushButton(self.tr("Close"), self)
|
|
2181
|
+
btn_row.addWidget(self.btn_go)
|
|
2182
|
+
btn_row.addWidget(self.btn_close)
|
|
2183
|
+
main.addLayout(btn_row)
|
|
2184
|
+
|
|
2185
|
+
# ---------------- Connections ----------------
|
|
2186
|
+
self.mode_combo.currentIndexChanged.connect(self.stack.setCurrentIndex)
|
|
2187
|
+
btn_browse.clicked.connect(self._browse_file)
|
|
2188
|
+
b_in.clicked.connect(self._browse_in)
|
|
2189
|
+
b_out.clicked.connect(self._browse_out)
|
|
2190
|
+
self.btn_go.clicked.connect(self._run)
|
|
2191
|
+
self.btn_close.clicked.connect(self.close)
|
|
2192
|
+
|
|
2193
|
+
# ---------------- Load settings & init UI ----------------
|
|
2194
|
+
mode_map = {"auto": 0, "manual": 1, "none": 2}
|
|
2195
|
+
self.cb_seed_mode.setCurrentIndex(mode_map.get(_get_seed_mode(self.settings), 0))
|
|
2196
|
+
self.le_ra.setText(_get_manual_ra(self.settings))
|
|
2197
|
+
self.le_dec.setText(_get_manual_dec(self.settings))
|
|
2198
|
+
scl = _get_manual_scale(self.settings)
|
|
2199
|
+
self.le_scale.setText("" if scl is None else str(scl))
|
|
2200
|
+
|
|
2201
|
+
self.cb_radius_mode.setCurrentIndex(0 if _get_astap_radius_mode(self.settings) == "auto" else 1)
|
|
2202
|
+
self.le_radius_val.setText(str(_get_astap_radius_value(self.settings)))
|
|
2203
|
+
|
|
2204
|
+
fov_mode = _get_astap_fov_mode(self.settings)
|
|
2205
|
+
self.cb_fov_mode.setCurrentIndex(1 if fov_mode == "auto" else (2 if fov_mode == "value" else 0))
|
|
2206
|
+
self.le_fov_val.setText(str(_get_astap_fov_value(self.settings)))
|
|
2207
|
+
|
|
2208
|
+
def _update_visibility():
|
|
2209
|
+
manual = (self.cb_seed_mode.currentIndex() == 1)
|
|
2210
|
+
self.le_ra.setEnabled(manual)
|
|
2211
|
+
self.le_dec.setEnabled(manual)
|
|
2212
|
+
self.le_scale.setEnabled(manual)
|
|
2213
|
+
self.le_radius_val.setEnabled(self.cb_radius_mode.currentIndex() == 1)
|
|
2214
|
+
self.le_fov_val.setEnabled(self.cb_fov_mode.currentIndex() == 2)
|
|
2215
|
+
|
|
2216
|
+
self.cb_seed_mode.currentIndexChanged.connect(_update_visibility)
|
|
2217
|
+
self.cb_radius_mode.currentIndexChanged.connect(_update_visibility)
|
|
2218
|
+
self.cb_fov_mode.currentIndexChanged.connect(_update_visibility)
|
|
2219
|
+
_update_visibility()
|
|
2220
|
+
|
|
2221
|
+
if icon:
|
|
2222
|
+
self.setWindowIcon(icon)
|
|
2223
|
+
|
|
2224
|
+
self.status.setObjectName("status_label")
|
|
2225
|
+
# if batch page exists:
|
|
2226
|
+
self.log.setObjectName("batch_log")
|
|
2227
|
+
|
|
2228
|
+
# ---------- file/batch pickers ----------
|
|
2229
|
+
def _browse_file(self):
|
|
2230
|
+
f, _ = QFileDialog.getOpenFileName(
|
|
2231
|
+
self, self.tr("Choose Image"),
|
|
2232
|
+
"", self.tr("Images (*.fits *.fit *.xisf *.tif *.tiff *.png *.jpg *.jpeg);;All files (*)")
|
|
2233
|
+
)
|
|
2234
|
+
if f:
|
|
2235
|
+
self.le_path.setText(f)
|
|
2236
|
+
|
|
2237
|
+
def _browse_in(self):
|
|
2238
|
+
d = QFileDialog.getExistingDirectory(self, self.tr("Choose input directory"))
|
|
2239
|
+
if d: self.le_in.setText(d)
|
|
2240
|
+
|
|
2241
|
+
def _browse_out(self):
|
|
2242
|
+
d = QFileDialog.getExistingDirectory(self, self.tr("Choose output directory"))
|
|
2243
|
+
if d: self.le_out.setText(d)
|
|
2244
|
+
|
|
2245
|
+
# ---------- actions ----------
|
|
2246
|
+
def _run(self):
|
|
2247
|
+
astap_exe = _get_astap_exe(self.settings)
|
|
2248
|
+
if not astap_exe or not os.path.exists(astap_exe):
|
|
2249
|
+
self.status.setText(self.tr("ASTAP path missing. Set Preferences → ASTAP executable."))
|
|
2250
|
+
QMessageBox.warning(self, self.tr("Plate Solver"), self.tr("ASTAP path missing.\nSet it in Preferences → ASTAP executable."))
|
|
2251
|
+
return
|
|
2252
|
+
|
|
2253
|
+
idx = self.cb_seed_mode.currentIndex()
|
|
2254
|
+
_set_seed_mode(self.settings, "auto" if idx == 0 else ("manual" if idx == 1 else "none"))
|
|
2255
|
+
# manual values
|
|
2256
|
+
try:
|
|
2257
|
+
manual_scale = float(self.le_scale.text().strip()) if self.le_scale.text().strip() else None
|
|
2258
|
+
except Exception:
|
|
2259
|
+
manual_scale = None
|
|
2260
|
+
_set_manual_seed(self.settings, self.le_ra.text().strip(), self.le_dec.text().strip(), manual_scale)
|
|
2261
|
+
# radius
|
|
2262
|
+
self.settings.setValue("astap/seed_radius_mode", "auto" if self.cb_radius_mode.currentIndex()==0 else "value")
|
|
2263
|
+
try:
|
|
2264
|
+
self.settings.setValue("astap/seed_radius_value", float(self.le_radius_val.text().strip()))
|
|
2265
|
+
except Exception:
|
|
2266
|
+
pass
|
|
2267
|
+
# fov
|
|
2268
|
+
self.settings.setValue("astap/seed_fov_mode",
|
|
2269
|
+
"compute" if self.cb_fov_mode.currentIndex()==0 else ("auto" if self.cb_fov_mode.currentIndex()==1 else "value"))
|
|
2270
|
+
try:
|
|
2271
|
+
self.settings.setValue("astap/seed_fov_value", float(self.le_fov_val.text().strip()))
|
|
2272
|
+
except Exception:
|
|
2273
|
+
pass
|
|
2274
|
+
|
|
2275
|
+
mode = self.stack.currentIndex()
|
|
2276
|
+
if mode == 0:
|
|
2277
|
+
# Active view
|
|
2278
|
+
doc = _active_doc_from_parent(self.parent())
|
|
2279
|
+
if not doc:
|
|
2280
|
+
QMessageBox.information(self, self.tr("Plate Solver"), self.tr("No active image view."))
|
|
2281
|
+
return
|
|
2282
|
+
ok, res = plate_solve_doc_inplace(self, doc, self.settings)
|
|
2283
|
+
if ok:
|
|
2284
|
+
self.status.setText(self.tr("Solved with ASTAP (WCS + SIP applied to active doc)."))
|
|
2285
|
+
QTimer.singleShot(0, self.accept) # close when done
|
|
2286
|
+
else:
|
|
2287
|
+
self.status.setText(str(res))
|
|
2288
|
+
elif mode == 1:
|
|
2289
|
+
# Single file
|
|
2290
|
+
path = self.le_path.text().strip()
|
|
2291
|
+
if not path:
|
|
2292
|
+
QMessageBox.information(self, self.tr("Plate Solver"), self.tr("Choose a file to solve."))
|
|
2293
|
+
return
|
|
2294
|
+
if not os.path.exists(path):
|
|
2295
|
+
QMessageBox.warning(self, self.tr("Plate Solver"), self.tr("Selected file does not exist."))
|
|
2296
|
+
return
|
|
2297
|
+
self._solve_file(path)
|
|
2298
|
+
else:
|
|
2299
|
+
self._run_batch()
|
|
2300
|
+
|
|
2301
|
+
def _solve_file(self, path: str):
|
|
2302
|
+
# Load using legacy.load_image()
|
|
2303
|
+
try:
|
|
2304
|
+
image_data, original_header, bit_depth, is_mono = load_image(path)
|
|
2305
|
+
except Exception as e:
|
|
2306
|
+
QMessageBox.warning(self, self.tr("Plate Solver"), self.tr("Cannot read image:\n{0}").format(e))
|
|
2307
|
+
return
|
|
2308
|
+
if image_data is None:
|
|
2309
|
+
QMessageBox.warning(self, self.tr("Plate Solver"), self.tr("Unsupported or unreadable image."))
|
|
2310
|
+
return
|
|
2311
|
+
|
|
2312
|
+
# Seed header from original_header
|
|
2313
|
+
seed_h = _as_header(original_header) if isinstance(original_header, (dict, Header)) else None
|
|
2314
|
+
|
|
2315
|
+
# Acquisition base for final merge (strip old WCS)
|
|
2316
|
+
acq_base: Header | None = None
|
|
2317
|
+
if isinstance(seed_h, Header):
|
|
2318
|
+
acq_base = _strip_wcs_keys(seed_h)
|
|
2319
|
+
|
|
2320
|
+
# Solve
|
|
2321
|
+
ok, res = _solve_numpy_with_fallback(self, self.settings, image_data, seed_h)
|
|
2322
|
+
if not ok:
|
|
2323
|
+
self.status.setText(str(res))
|
|
2324
|
+
return
|
|
2325
|
+
solver_hdr: Header = res
|
|
2326
|
+
|
|
2327
|
+
# Merge solver WCS into acquisition header
|
|
2328
|
+
if isinstance(acq_base, Header) and isinstance(solver_hdr, Header):
|
|
2329
|
+
hdr_final = _merge_wcs_into_base_header(acq_base, solver_hdr)
|
|
2330
|
+
else:
|
|
2331
|
+
hdr_final = solver_hdr if isinstance(solver_hdr, Header) else Header()
|
|
2332
|
+
|
|
2333
|
+
# Save-as using legacy.save_image() with ORIGINAL pixels (not normalized)
|
|
2334
|
+
save_path, _ = QFileDialog.getSaveFileName(
|
|
2335
|
+
self,
|
|
2336
|
+
self.tr("Save Plate-Solved FITS"),
|
|
2337
|
+
"",
|
|
2338
|
+
self.tr("FITS files (*.fits *.fit)")
|
|
2339
|
+
)
|
|
2340
|
+
if save_path:
|
|
2341
|
+
try:
|
|
2342
|
+
# never persist 'file_path' inside FITS
|
|
2343
|
+
h2 = Header()
|
|
2344
|
+
for k in hdr_final.keys():
|
|
2345
|
+
if k.upper() != "FILE_PATH":
|
|
2346
|
+
h2[k] = hdr_final[k]
|
|
2347
|
+
|
|
2348
|
+
save_image(
|
|
2349
|
+
img_array=image_data,
|
|
2350
|
+
filename=save_path,
|
|
2351
|
+
original_format="fit",
|
|
2352
|
+
bit_depth="32-bit floating point",
|
|
2353
|
+
original_header=h2,
|
|
2354
|
+
is_mono=is_mono
|
|
2355
|
+
)
|
|
2356
|
+
self.status.setText(self.tr("Solved FITS saved:\n{0}").format(save_path))
|
|
2357
|
+
QTimer.singleShot(0, self.accept)
|
|
2358
|
+
except Exception as e:
|
|
2359
|
+
QMessageBox.critical(self, self.tr("Save Error"), self.tr("Failed to save: {0}").format(e))
|
|
2360
|
+
else:
|
|
2361
|
+
self.status.setText(self.tr("Solved (not saved)."))
|
|
2362
|
+
|
|
2363
|
+
|
|
2364
|
+
def _run_batch(self):
|
|
2365
|
+
in_dir = self.le_in.text().strip()
|
|
2366
|
+
out_dir = self.le_out.text().strip()
|
|
2367
|
+
if not in_dir or not os.path.isdir(in_dir):
|
|
2368
|
+
QMessageBox.warning(self, self.tr("Batch"), self.tr("Please choose a valid input directory."))
|
|
2369
|
+
return
|
|
2370
|
+
if not out_dir or not os.path.isdir(out_dir):
|
|
2371
|
+
QMessageBox.warning(self, self.tr("Batch"), self.tr("Please choose a valid output directory."))
|
|
2372
|
+
return
|
|
2373
|
+
|
|
2374
|
+
exts = {".xisf", ".fits", ".fit", ".tif", ".tiff", ".png", ".jpg", ".jpeg"}
|
|
2375
|
+
files = [
|
|
2376
|
+
os.path.join(in_dir, f)
|
|
2377
|
+
for f in os.listdir(in_dir)
|
|
2378
|
+
if os.path.splitext(f)[1].lower() in exts
|
|
2379
|
+
]
|
|
2380
|
+
if not files:
|
|
2381
|
+
QMessageBox.information(self, self.tr("Batch"), self.tr("No acceptable image files found."))
|
|
2382
|
+
return
|
|
2383
|
+
|
|
2384
|
+
self.log.clear()
|
|
2385
|
+
self.log.append(self.tr("Found {0} files. Starting batch…").format(len(files)))
|
|
2386
|
+
QApplication.processEvents()
|
|
2387
|
+
|
|
2388
|
+
for path in files:
|
|
2389
|
+
base = os.path.splitext(os.path.basename(path))[0]
|
|
2390
|
+
out = os.path.join(out_dir, base + "_plate_solved.fits")
|
|
2391
|
+
self.log.append(f"▶ {path}") # Symbol, no need to translate
|
|
2392
|
+
QApplication.processEvents()
|
|
2393
|
+
|
|
2394
|
+
try:
|
|
2395
|
+
# Load using legacy.load_image()
|
|
2396
|
+
image_data, original_header, bit_depth, is_mono = load_image(path)
|
|
2397
|
+
if image_data is None:
|
|
2398
|
+
self.log.append(self.tr(" ❌ Failed to load"))
|
|
2399
|
+
continue
|
|
2400
|
+
|
|
2401
|
+
# Seed header from original_header
|
|
2402
|
+
seed_h = _as_header(original_header) if isinstance(original_header, (dict, Header)) else None
|
|
2403
|
+
|
|
2404
|
+
# Acquisition base for final merge (strip old WCS)
|
|
2405
|
+
acq_base: Header | None = None
|
|
2406
|
+
if isinstance(seed_h, Header):
|
|
2407
|
+
acq_base = _strip_wcs_keys(seed_h)
|
|
2408
|
+
|
|
2409
|
+
# Solve
|
|
2410
|
+
ok, res = _solve_numpy_with_fallback(self, self.settings, image_data, seed_h)
|
|
2411
|
+
if not ok:
|
|
2412
|
+
self.log.append(f" ❌ {res}")
|
|
2413
|
+
continue
|
|
2414
|
+
hdr: Header = res
|
|
2415
|
+
|
|
2416
|
+
# Merge solver WCS into acquisition header
|
|
2417
|
+
if isinstance(acq_base, Header) and isinstance(hdr, Header):
|
|
2418
|
+
hdr_final = _merge_wcs_into_base_header(acq_base, hdr)
|
|
2419
|
+
else:
|
|
2420
|
+
hdr_final = hdr if isinstance(hdr, Header) else Header()
|
|
2421
|
+
|
|
2422
|
+
# Build header to save (and strip FILE_PATH)
|
|
2423
|
+
h2 = Header()
|
|
2424
|
+
for k in hdr_final.keys():
|
|
2425
|
+
if k.upper() != "FILE_PATH":
|
|
2426
|
+
h2[k] = hdr_final[k]
|
|
2427
|
+
|
|
2428
|
+
# Save using original pixels
|
|
2429
|
+
save_image(
|
|
2430
|
+
img_array=image_data,
|
|
2431
|
+
filename=out,
|
|
2432
|
+
original_format="fit",
|
|
2433
|
+
bit_depth="32-bit floating point",
|
|
2434
|
+
original_header=h2,
|
|
2435
|
+
is_mono=is_mono
|
|
2436
|
+
)
|
|
2437
|
+
self.log.append(self.tr(" ✔ saved: ") + out)
|
|
2438
|
+
|
|
2439
|
+
except Exception as e:
|
|
2440
|
+
self.log.append(self.tr(" ❌ error: ") + str(e))
|
|
2441
|
+
|
|
2442
|
+
QApplication.processEvents()
|
|
2443
|
+
|
|
2444
|
+
self.log.append(self.tr("Batch plate solving completed."))
|
|
2445
|
+
|