setiastrosuitepro 1.6.2.post1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of setiastrosuitepro might be problematic. Click here for more details.
- setiastro/__init__.py +2 -0
- setiastro/data/SASP_data.fits +0 -0
- setiastro/data/catalogs/List_of_Galaxies_with_Distances_Gly.csv +488 -0
- setiastro/data/catalogs/astrobin_filters.csv +890 -0
- setiastro/data/catalogs/astrobin_filters_page1_local.csv +51 -0
- setiastro/data/catalogs/cali2.csv +63 -0
- setiastro/data/catalogs/cali2color.csv +65 -0
- setiastro/data/catalogs/celestial_catalog - original.csv +16471 -0
- setiastro/data/catalogs/celestial_catalog.csv +24031 -0
- setiastro/data/catalogs/detected_stars.csv +24784 -0
- setiastro/data/catalogs/fits_header_data.csv +46 -0
- setiastro/data/catalogs/test.csv +8 -0
- setiastro/data/catalogs/updated_celestial_catalog.csv +16471 -0
- setiastro/images/Astro_Spikes.png +0 -0
- setiastro/images/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/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 +945 -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 +694 -0
- setiastro/saspro/aberration_ai_preset.py +224 -0
- setiastro/saspro/accel_installer.py +218 -0
- setiastro/saspro/accel_workers.py +30 -0
- setiastro/saspro/add_stars.py +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 +602 -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 +2926 -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 +973 -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 +8810 -0
- setiastro/saspro/gui/mixins/__init__.py +33 -0
- setiastro/saspro/gui/mixins/dock_mixin.py +362 -0
- setiastro/saspro/gui/mixins/file_mixin.py +450 -0
- setiastro/saspro/gui/mixins/geometry_mixin.py +403 -0
- setiastro/saspro/gui/mixins/header_mixin.py +441 -0
- setiastro/saspro/gui/mixins/mask_mixin.py +421 -0
- setiastro/saspro/gui/mixins/menu_mixin.py +389 -0
- setiastro/saspro/gui/mixins/theme_mixin.py +367 -0
- setiastro/saspro/gui/mixins/toolbar_mixin.py +1457 -0
- setiastro/saspro/gui/mixins/update_mixin.py +309 -0
- setiastro/saspro/gui/mixins/view_mixin.py +435 -0
- setiastro/saspro/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 +2226 -0
- setiastro/saspro/legacy/numba_utils.py +3676 -0
- setiastro/saspro/legacy/xisf.py +1071 -0
- setiastro/saspro/linear_fit.py +537 -0
- setiastro/saspro/live_stacking.py +1841 -0
- setiastro/saspro/log_bus.py +5 -0
- setiastro/saspro/logging_config.py +460 -0
- setiastro/saspro/luminancerecombine.py +309 -0
- setiastro/saspro/main_helpers.py +201 -0
- setiastro/saspro/mask_creation.py +931 -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 +3831 -0
- setiastro/saspro/mfdeconv_earlystop.py +71 -0
- setiastro/saspro/mfdeconvcudnn.py +3263 -0
- setiastro/saspro/mfdeconvsport.py +2382 -0
- setiastro/saspro/minorbodycatalog.py +567 -0
- setiastro/saspro/morphology.py +407 -0
- setiastro/saspro/multiscale_decomp.py +1293 -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 +1102 -0
- setiastro/saspro/ops/scripts.py +1473 -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 +1071 -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 +404 -0
- setiastro/saspro/resources.py +501 -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 +1552 -0
- setiastro/saspro/sfcc.py +1472 -0
- setiastro/saspro/shortcuts.py +3043 -0
- setiastro/saspro/signature_insert.py +1102 -0
- setiastro/saspro/stacking_suite.py +18470 -0
- setiastro/saspro/star_alignment.py +7435 -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 +3328 -0
- setiastro/saspro/supernovaasteroidhunter.py +1719 -0
- setiastro/saspro/swap_manager.py +99 -0
- setiastro/saspro/torch_backend.py +89 -0
- setiastro/saspro/torch_rejection.py +434 -0
- setiastro/saspro/translations/all_source_strings.json +3654 -0
- setiastro/saspro/translations/ar_translations.py +3865 -0
- setiastro/saspro/translations/de_translations.py +3749 -0
- setiastro/saspro/translations/es_translations.py +3939 -0
- setiastro/saspro/translations/fr_translations.py +3858 -0
- setiastro/saspro/translations/hi_translations.py +3571 -0
- setiastro/saspro/translations/integrate_translations.py +270 -0
- setiastro/saspro/translations/it_translations.py +3678 -0
- setiastro/saspro/translations/ja_translations.py +3601 -0
- setiastro/saspro/translations/pt_translations.py +3869 -0
- setiastro/saspro/translations/ru_translations.py +2848 -0
- setiastro/saspro/translations/saspro_ar.qm +0 -0
- setiastro/saspro/translations/saspro_ar.ts +255 -0
- setiastro/saspro/translations/saspro_de.qm +0 -0
- setiastro/saspro/translations/saspro_de.ts +253 -0
- setiastro/saspro/translations/saspro_es.qm +0 -0
- setiastro/saspro/translations/saspro_es.ts +12520 -0
- setiastro/saspro/translations/saspro_fr.qm +0 -0
- setiastro/saspro/translations/saspro_fr.ts +12514 -0
- setiastro/saspro/translations/saspro_hi.qm +0 -0
- setiastro/saspro/translations/saspro_hi.ts +257 -0
- setiastro/saspro/translations/saspro_it.qm +0 -0
- setiastro/saspro/translations/saspro_it.ts +12520 -0
- setiastro/saspro/translations/saspro_ja.qm +0 -0
- setiastro/saspro/translations/saspro_ja.ts +257 -0
- setiastro/saspro/translations/saspro_pt.qm +0 -0
- setiastro/saspro/translations/saspro_pt.ts +257 -0
- setiastro/saspro/translations/saspro_ru.qm +0 -0
- setiastro/saspro/translations/saspro_ru.ts +237 -0
- setiastro/saspro/translations/saspro_sw.qm +0 -0
- setiastro/saspro/translations/saspro_sw.ts +257 -0
- setiastro/saspro/translations/saspro_uk.qm +0 -0
- setiastro/saspro/translations/saspro_uk.ts +10771 -0
- setiastro/saspro/translations/saspro_zh.qm +0 -0
- setiastro/saspro/translations/saspro_zh.ts +12520 -0
- setiastro/saspro/translations/sw_translations.py +3671 -0
- setiastro/saspro/translations/uk_translations.py +3700 -0
- setiastro/saspro/translations/zh_translations.py +3675 -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 +492 -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 +986 -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 +237 -0
- setiastro/saspro/widgets/spinboxes.py +275 -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 +1123 -0
- setiastrosuitepro-1.6.2.post1.dist-info/METADATA +278 -0
- setiastrosuitepro-1.6.2.post1.dist-info/RECORD +367 -0
- setiastrosuitepro-1.6.2.post1.dist-info/WHEEL +4 -0
- setiastrosuitepro-1.6.2.post1.dist-info/entry_points.txt +6 -0
- setiastrosuitepro-1.6.2.post1.dist-info/licenses/LICENSE +674 -0
- setiastrosuitepro-1.6.2.post1.dist-info/licenses/license.txt +2580 -0
|
@@ -0,0 +1,2226 @@
|
|
|
1
|
+
#legacy.image_manager.py
|
|
2
|
+
# --- required imports for this module ---
|
|
3
|
+
import os
|
|
4
|
+
import time
|
|
5
|
+
import gzip
|
|
6
|
+
from io import BytesIO
|
|
7
|
+
from typing import Optional, Dict
|
|
8
|
+
import datetime
|
|
9
|
+
from datetime import timezone
|
|
10
|
+
import numpy as np
|
|
11
|
+
from PIL import Image
|
|
12
|
+
import tifffile as tiff
|
|
13
|
+
|
|
14
|
+
# add this near your other optional imports
|
|
15
|
+
from astropy.io import fits
|
|
16
|
+
try:
|
|
17
|
+
from astropy.io.fits.verify import VerifyError
|
|
18
|
+
except Exception:
|
|
19
|
+
# Fallback for older Astropy – we'll just treat it as a generic Exception
|
|
20
|
+
class VerifyError(Exception):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
def _drop_invalid_cards(header: fits.Header) -> fits.Header:
|
|
24
|
+
"""
|
|
25
|
+
Return a copy of the FITS header with any cards that raise VerifyError removed.
|
|
26
|
+
This prevents 'Unparsable card (FOO)' from blowing up later on .value access.
|
|
27
|
+
"""
|
|
28
|
+
if not isinstance(header, fits.Header):
|
|
29
|
+
return header
|
|
30
|
+
|
|
31
|
+
hdr = header.copy()
|
|
32
|
+
bad_keys = []
|
|
33
|
+
for card in list(hdr.cards):
|
|
34
|
+
try:
|
|
35
|
+
# Accessing .value is what triggers VerifyError for bad cards
|
|
36
|
+
_ = card.value
|
|
37
|
+
except VerifyError as e:
|
|
38
|
+
print(f"[ImageManager] Dropping invalid FITS card {card.keyword!r}: {e}")
|
|
39
|
+
bad_keys.append(card.keyword)
|
|
40
|
+
|
|
41
|
+
for key in bad_keys:
|
|
42
|
+
try:
|
|
43
|
+
del hdr[key]
|
|
44
|
+
except Exception:
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
return hdr
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
import rawpy
|
|
53
|
+
|
|
54
|
+
except Exception:
|
|
55
|
+
rawpy = None # optional; RAW loading will raise if it's None
|
|
56
|
+
|
|
57
|
+
from setiastro.saspro.xisf import XISF
|
|
58
|
+
|
|
59
|
+
from PyQt6.QtCore import QObject, pyqtSignal
|
|
60
|
+
|
|
61
|
+
def _looks_like_xisf_header(hdr) -> bool:
|
|
62
|
+
try:
|
|
63
|
+
if isinstance(hdr, (fits.Header, dict)):
|
|
64
|
+
for k in hdr.keys():
|
|
65
|
+
if isinstance(k, str) and k.startswith("XISF:"):
|
|
66
|
+
return True
|
|
67
|
+
except Exception:
|
|
68
|
+
pass
|
|
69
|
+
return False
|
|
70
|
+
|
|
71
|
+
def _iter_header_items(hdr):
|
|
72
|
+
"""Yield (key, value) safely from fits.Header or dict; else yield nothing."""
|
|
73
|
+
if isinstance(hdr, fits.Header):
|
|
74
|
+
# .items() is supported and yields (key, value)
|
|
75
|
+
for kv in hdr.items():
|
|
76
|
+
yield kv
|
|
77
|
+
elif isinstance(hdr, dict):
|
|
78
|
+
for kv in hdr.items():
|
|
79
|
+
yield kv
|
|
80
|
+
|
|
81
|
+
class ImageManager(QObject):
|
|
82
|
+
"""
|
|
83
|
+
Manages multiple image slots with associated metadata and supports undo/redo operations for each slot.
|
|
84
|
+
Emits a signal whenever an image or its metadata changes.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
# Signal emitted when an image or its metadata changes.
|
|
88
|
+
# Parameters:
|
|
89
|
+
# - slot (int): The slot number.
|
|
90
|
+
# - image (np.ndarray): The new image data.
|
|
91
|
+
# - metadata (dict): Associated metadata for the image.
|
|
92
|
+
image_changed = pyqtSignal(int, np.ndarray, dict)
|
|
93
|
+
current_slot_changed = pyqtSignal(int)
|
|
94
|
+
# Keys we always carry forward unless caller explicitly supplies a non-empty replacement
|
|
95
|
+
PRESERVE_META_KEYS = ("file_path", "FILE", "path", "fits_header", "header")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def __init__(self, max_slots=5, parent=None):
|
|
99
|
+
"""
|
|
100
|
+
Initializes the ImageManager with a specified number of slots.
|
|
101
|
+
|
|
102
|
+
:param max_slots: Maximum number of image slots to manage.
|
|
103
|
+
"""
|
|
104
|
+
super().__init__()
|
|
105
|
+
self.parent = parent
|
|
106
|
+
self.max_slots = max_slots
|
|
107
|
+
self._images = {i: None for i in range(max_slots)}
|
|
108
|
+
self._metadata = {i: {} for i in range(max_slots)}
|
|
109
|
+
self._undo_stacks = {i: [] for i in range(max_slots)}
|
|
110
|
+
self._redo_stacks = {i: [] for i in range(max_slots)}
|
|
111
|
+
self.current_slot = 0 # Default to the first slot
|
|
112
|
+
self.active_previews = {} # Track active preview windows by slot
|
|
113
|
+
self.mask_manager = MaskManager(max_slots) # Add a MaskManager
|
|
114
|
+
|
|
115
|
+
def _looks_like_path(self, v: object) -> bool:
|
|
116
|
+
if not isinstance(v, str):
|
|
117
|
+
return False
|
|
118
|
+
# treat as path if it has a separator or a known extension
|
|
119
|
+
ext_ok = v.lower().endswith((".fits", ".fit", ".fts", ".fz", ".fits.fz"))
|
|
120
|
+
return (os.path.sep in v) or ext_ok
|
|
121
|
+
|
|
122
|
+
def _attach_step_name(self, merged_meta: Dict, step_name: Optional[str]) -> Dict:
|
|
123
|
+
if step_name is not None and str(step_name).strip():
|
|
124
|
+
merged_meta["step_name"] = step_name.strip()
|
|
125
|
+
return merged_meta
|
|
126
|
+
|
|
127
|
+
def _merge_metadata(self, base: Optional[Dict], updates: Optional[Dict]) -> Dict:
|
|
128
|
+
out = (base or {}).copy()
|
|
129
|
+
if not updates:
|
|
130
|
+
return out
|
|
131
|
+
for k, v in updates.items():
|
|
132
|
+
if k in ("file_path", "FILE", "path"):
|
|
133
|
+
# Only accept if it looks like a real path; ignore labels like "Cropped Image"
|
|
134
|
+
if not self._looks_like_path(v):
|
|
135
|
+
continue
|
|
136
|
+
if k in ("fits_header", "header"):
|
|
137
|
+
# Don’t replace with None/blank
|
|
138
|
+
if v is None or (isinstance(v, str) and not v.strip()):
|
|
139
|
+
continue
|
|
140
|
+
out[k] = v
|
|
141
|
+
return out
|
|
142
|
+
|
|
143
|
+
def _emit_change(self, slot: int):
|
|
144
|
+
"""Centralized emitter to avoid passing None metadata to listeners."""
|
|
145
|
+
img = self._images[slot]
|
|
146
|
+
meta = self._metadata[slot]
|
|
147
|
+
self.image_changed.emit(slot, img, meta)
|
|
148
|
+
if self.parent and hasattr(self.parent, "update_undo_redo_action_labels"):
|
|
149
|
+
self.parent.update_undo_redo_action_labels()
|
|
150
|
+
if self.parent and hasattr(self.parent, "update_slot_toolbar_highlight"):
|
|
151
|
+
self.parent.update_slot_toolbar_highlight()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def get_current_image_and_metadata(self):
|
|
155
|
+
slot = self.current_slot
|
|
156
|
+
return self._images[slot], self._metadata[slot]
|
|
157
|
+
|
|
158
|
+
def rename_slot(self, slot: int, new_name: str):
|
|
159
|
+
"""Store a custom slot_name in metadata and emit an update."""
|
|
160
|
+
if 0 <= slot < self.max_slots:
|
|
161
|
+
self._metadata[slot]['slot_name'] = new_name
|
|
162
|
+
|
|
163
|
+
# explicitly check for None, avoid ambiguous truth-check on ndarray
|
|
164
|
+
existing = self._images[slot]
|
|
165
|
+
if existing is None:
|
|
166
|
+
img = np.zeros((1,1), dtype=np.uint8)
|
|
167
|
+
else:
|
|
168
|
+
img = existing
|
|
169
|
+
|
|
170
|
+
# re-emit image_changed so UI labels (menus/toolbars) can refresh
|
|
171
|
+
self.image_changed.emit(slot, img, self._metadata[slot])
|
|
172
|
+
else:
|
|
173
|
+
print(f"ImageManager: cannot rename slot {slot}, out of range")
|
|
174
|
+
|
|
175
|
+
def get_mask(self, slot=None):
|
|
176
|
+
"""
|
|
177
|
+
Retrieves the mask for the current or specified slot.
|
|
178
|
+
:param slot: Slot number. If None, uses current slot.
|
|
179
|
+
:return: Mask as numpy array or None.
|
|
180
|
+
"""
|
|
181
|
+
if slot is None:
|
|
182
|
+
slot = self.current_slot
|
|
183
|
+
return self.mask_manager.get_mask(slot)
|
|
184
|
+
|
|
185
|
+
def set_mask(self, mask, slot=None):
|
|
186
|
+
"""
|
|
187
|
+
Sets a mask for the current or specified slot.
|
|
188
|
+
:param mask: Numpy array representing the mask.
|
|
189
|
+
:param slot: Slot number. If None, uses current slot.
|
|
190
|
+
"""
|
|
191
|
+
if slot is None:
|
|
192
|
+
slot = self.current_slot
|
|
193
|
+
self.mask_manager.set_mask(slot, mask)
|
|
194
|
+
|
|
195
|
+
def clear_mask(self, slot=None):
|
|
196
|
+
"""
|
|
197
|
+
Clears the mask for the current or specified slot.
|
|
198
|
+
:param slot: Slot number. If None, uses current slot.
|
|
199
|
+
"""
|
|
200
|
+
if slot is None:
|
|
201
|
+
slot = self.current_slot
|
|
202
|
+
self.mask_manager.clear_mask(slot)
|
|
203
|
+
|
|
204
|
+
def set_current_slot(self, slot):
|
|
205
|
+
if 0 <= slot < self.max_slots:
|
|
206
|
+
self.current_slot = slot
|
|
207
|
+
self.current_slot_changed.emit(slot)
|
|
208
|
+
# Use a non-empty placeholder if the slot is empty
|
|
209
|
+
image_to_emit = self._images[slot] if self._images[slot] is not None and self._images[slot].size > 0 else np.zeros((1, 1), dtype=np.uint8)
|
|
210
|
+
self.image_changed.emit(slot, image_to_emit, self._metadata[slot])
|
|
211
|
+
print(f"ImageManager: Current slot set to {slot}.")
|
|
212
|
+
if self.parent and hasattr(self.parent, "update_slot_toolbar_highlight"):
|
|
213
|
+
self.parent.update_slot_toolbar_highlight()
|
|
214
|
+
else:
|
|
215
|
+
print(f"ImageManager: Slot {slot} is out of range.")
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def add_image(self, slot, image, metadata):
|
|
219
|
+
"""
|
|
220
|
+
Adds an image and its metadata to a specified slot.
|
|
221
|
+
|
|
222
|
+
:param slot: The slot number where the image will be added.
|
|
223
|
+
:param image: The image data (numpy array).
|
|
224
|
+
:param metadata: A dictionary containing metadata for the image.
|
|
225
|
+
"""
|
|
226
|
+
if 0 <= slot < self.max_slots:
|
|
227
|
+
self._images[slot] = image
|
|
228
|
+
self._metadata[slot] = metadata
|
|
229
|
+
# Clear undo/redo stacks when a new image is added
|
|
230
|
+
self._undo_stacks[slot].clear()
|
|
231
|
+
self._redo_stacks[slot].clear()
|
|
232
|
+
self.current_slot = slot
|
|
233
|
+
self.image_changed.emit(slot, image, metadata)
|
|
234
|
+
print(f"ImageManager: Image added to slot {slot} with metadata.")
|
|
235
|
+
else:
|
|
236
|
+
print(f"ImageManager: Slot {slot} is out of range. Max slots: {self.max_slots}")
|
|
237
|
+
if metadata is None:
|
|
238
|
+
metadata = {}
|
|
239
|
+
metadata.setdefault("step_name", "Loaded")
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def set_image(self, new_image, metadata, step_name=None):
|
|
243
|
+
slot = self.current_slot
|
|
244
|
+
if self._images[slot] is not None:
|
|
245
|
+
self._undo_stacks[slot].append(
|
|
246
|
+
(self._images[slot].copy(), self._metadata[slot].copy(), step_name or "Unnamed Step")
|
|
247
|
+
)
|
|
248
|
+
self._redo_stacks[slot].clear()
|
|
249
|
+
print(f"ImageManager: Previous image in slot {slot} pushed to undo stack.")
|
|
250
|
+
else:
|
|
251
|
+
print(f"ImageManager: No existing image in slot {slot} to push to undo stack.")
|
|
252
|
+
|
|
253
|
+
merged = self._merge_metadata(self._metadata[slot], metadata)
|
|
254
|
+
merged = self._attach_step_name(merged, step_name) # <-- add this
|
|
255
|
+
self._images[slot] = new_image
|
|
256
|
+
self._metadata[slot] = merged
|
|
257
|
+
self._emit_change(slot)
|
|
258
|
+
print(f"ImageManager: Image set for slot {slot} with merged metadata.")
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def set_image_for_slot(self, slot, new_image, metadata, step_name=None):
|
|
262
|
+
if slot < 0 or slot >= self.max_slots:
|
|
263
|
+
print(f"ImageManager: Slot {slot} is out of range. Max slots={self.max_slots}")
|
|
264
|
+
return
|
|
265
|
+
|
|
266
|
+
if self._images[slot] is not None:
|
|
267
|
+
self._undo_stacks[slot].append(
|
|
268
|
+
(self._images[slot].copy(), self._metadata[slot].copy(), step_name or "Unnamed Step")
|
|
269
|
+
)
|
|
270
|
+
self._redo_stacks[slot].clear()
|
|
271
|
+
print(f"ImageManager: Previous image in slot {slot} pushed to undo stack.")
|
|
272
|
+
else:
|
|
273
|
+
print(f"ImageManager: No existing image in slot {slot} to push to undo stack.")
|
|
274
|
+
|
|
275
|
+
merged = self._merge_metadata(self._metadata[slot], metadata)
|
|
276
|
+
merged = self._attach_step_name(merged, step_name)
|
|
277
|
+
self._images[slot] = new_image
|
|
278
|
+
self._metadata[slot] = merged
|
|
279
|
+
self.current_slot = slot
|
|
280
|
+
self._emit_change(slot)
|
|
281
|
+
print(f"ImageManager: Image set for slot {slot} with merged metadata.")
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
@property
|
|
285
|
+
def image(self):
|
|
286
|
+
return self._images[self.current_slot]
|
|
287
|
+
|
|
288
|
+
@image.setter
|
|
289
|
+
def image(self, new_image):
|
|
290
|
+
"""
|
|
291
|
+
Default image setter that stores undo as an unnamed step.
|
|
292
|
+
"""
|
|
293
|
+
self.set_image_with_step_name(new_image, self._metadata[self.current_slot], step_name="Unnamed Step")
|
|
294
|
+
|
|
295
|
+
def set_image_with_step_name(self, new_image, metadata, step_name="Unnamed Step"):
|
|
296
|
+
slot = self.current_slot
|
|
297
|
+
if self._images[slot] is not None:
|
|
298
|
+
self._undo_stacks[slot].append(
|
|
299
|
+
(self._images[slot].copy(), self._metadata[slot].copy(), step_name)
|
|
300
|
+
)
|
|
301
|
+
self._redo_stacks[slot].clear()
|
|
302
|
+
print(f"ImageManager: Previous image in slot {slot} pushed to undo stack (step: {step_name})")
|
|
303
|
+
else:
|
|
304
|
+
print(f"ImageManager: No existing image in slot {slot} to push to undo stack.")
|
|
305
|
+
|
|
306
|
+
merged = self._merge_metadata(self._metadata[slot], metadata)
|
|
307
|
+
merged = self._attach_step_name(merged, step_name)
|
|
308
|
+
self._images[slot] = new_image
|
|
309
|
+
self._metadata[slot] = merged
|
|
310
|
+
self._emit_change(slot)
|
|
311
|
+
print(f"ImageManager: Image set for slot {slot} via set_image_with_step_name (merged).")
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def get_slot_name(self, slot):
|
|
315
|
+
"""
|
|
316
|
+
Returns the display name for a given slot.
|
|
317
|
+
If a slot has been renamed (stored under "slot_name" in metadata), that name is returned.
|
|
318
|
+
Otherwise, it returns "Slot X" (using 1-indexed numbering for display).
|
|
319
|
+
"""
|
|
320
|
+
metadata = self._metadata.get(slot, {})
|
|
321
|
+
if 'slot_name' in metadata:
|
|
322
|
+
return metadata['slot_name']
|
|
323
|
+
else:
|
|
324
|
+
return f"Slot {slot}"
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def set_metadata(self, metadata):
|
|
328
|
+
slot = self.current_slot
|
|
329
|
+
if self._images[slot] is not None:
|
|
330
|
+
self._undo_stacks[slot].append(
|
|
331
|
+
(self._images[slot].copy(), self._metadata[slot].copy())
|
|
332
|
+
)
|
|
333
|
+
self._redo_stacks[slot].clear()
|
|
334
|
+
print(f"ImageManager: Previous metadata in slot {slot} pushed to undo stack.")
|
|
335
|
+
else:
|
|
336
|
+
print(f"ImageManager: No existing image in slot {slot} to set metadata.")
|
|
337
|
+
|
|
338
|
+
merged = self._merge_metadata(self._metadata[slot], metadata)
|
|
339
|
+
self._metadata[slot] = merged
|
|
340
|
+
self._emit_change(slot)
|
|
341
|
+
print(f"ImageManager: Metadata set for slot {slot} (merged).")
|
|
342
|
+
|
|
343
|
+
def update_image(self, updated_image, metadata=None, slot=None):
|
|
344
|
+
if slot is None:
|
|
345
|
+
slot = self.current_slot
|
|
346
|
+
|
|
347
|
+
self._images[slot] = updated_image
|
|
348
|
+
if metadata is not None:
|
|
349
|
+
merged = self._merge_metadata(self._metadata[slot], metadata)
|
|
350
|
+
self._metadata[slot] = merged
|
|
351
|
+
|
|
352
|
+
self._emit_change(slot)
|
|
353
|
+
|
|
354
|
+
def can_undo(self, slot=None):
|
|
355
|
+
"""
|
|
356
|
+
Determines if there are actions available to undo for the specified slot.
|
|
357
|
+
|
|
358
|
+
:param slot: (Optional) The slot number to check. If None, uses current_slot.
|
|
359
|
+
:return: True if undo is possible, False otherwise.
|
|
360
|
+
"""
|
|
361
|
+
if slot is None:
|
|
362
|
+
slot = self.current_slot
|
|
363
|
+
if 0 <= slot < self.max_slots:
|
|
364
|
+
return len(self._undo_stacks[slot]) > 0
|
|
365
|
+
else:
|
|
366
|
+
print(f"ImageManager: Slot {slot} is out of range. Cannot check can_undo.")
|
|
367
|
+
return False
|
|
368
|
+
|
|
369
|
+
def can_redo(self, slot=None):
|
|
370
|
+
"""
|
|
371
|
+
Determines if there are actions available to redo for the specified slot.
|
|
372
|
+
|
|
373
|
+
:param slot: (Optional) The slot number to check. If None, uses current_slot.
|
|
374
|
+
:return: True if redo is possible, False otherwise.
|
|
375
|
+
"""
|
|
376
|
+
if slot is None:
|
|
377
|
+
slot = self.current_slot
|
|
378
|
+
if 0 <= slot < self.max_slots:
|
|
379
|
+
return len(self._redo_stacks[slot]) > 0
|
|
380
|
+
else:
|
|
381
|
+
print(f"ImageManager: Slot {slot} is out of range. Cannot check can_redo.")
|
|
382
|
+
return False
|
|
383
|
+
|
|
384
|
+
def undo(self, slot=None):
|
|
385
|
+
if slot is None:
|
|
386
|
+
slot = self.current_slot
|
|
387
|
+
|
|
388
|
+
if 0 <= slot < self.max_slots and self.can_undo(slot):
|
|
389
|
+
self._redo_stacks[slot].append(
|
|
390
|
+
(self._images[slot].copy(), self._metadata[slot].copy(), "Redo of Previous Step")
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
popped = self._undo_stacks[slot].pop()
|
|
394
|
+
if len(popped) == 3:
|
|
395
|
+
prev_img, prev_meta, step_name = popped
|
|
396
|
+
else:
|
|
397
|
+
prev_img, prev_meta = popped
|
|
398
|
+
step_name = "Unnamed Undo Step"
|
|
399
|
+
|
|
400
|
+
self._images[slot] = prev_img
|
|
401
|
+
self._metadata[slot] = prev_meta
|
|
402
|
+
self.image_changed.emit(slot, prev_img, prev_meta)
|
|
403
|
+
|
|
404
|
+
print(f"ImageManager: Undo performed on slot {slot}: {step_name}")
|
|
405
|
+
return step_name
|
|
406
|
+
else:
|
|
407
|
+
print(f"ImageManager: Cannot perform undo on slot {slot}.")
|
|
408
|
+
return None
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def redo(self, slot=None):
|
|
413
|
+
if slot is None:
|
|
414
|
+
slot = self.current_slot
|
|
415
|
+
|
|
416
|
+
if 0 <= slot < self.max_slots and self.can_redo(slot):
|
|
417
|
+
self._undo_stacks[slot].append(
|
|
418
|
+
(self._images[slot].copy(), self._metadata[slot].copy(), "Undo of Redone Step")
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
popped = self._redo_stacks[slot].pop()
|
|
422
|
+
if len(popped) == 3:
|
|
423
|
+
redo_img, redo_meta, step_name = popped
|
|
424
|
+
else:
|
|
425
|
+
redo_img, redo_meta = popped
|
|
426
|
+
step_name = "Unnamed Redo Step"
|
|
427
|
+
|
|
428
|
+
self._images[slot] = redo_img
|
|
429
|
+
self._metadata[slot] = redo_meta
|
|
430
|
+
self.image_changed.emit(slot, redo_img, redo_meta)
|
|
431
|
+
|
|
432
|
+
print(f"ImageManager: Redo performed on slot {slot}: {step_name}")
|
|
433
|
+
return step_name
|
|
434
|
+
else:
|
|
435
|
+
print(f"ImageManager: Cannot perform redo on slot {slot}.")
|
|
436
|
+
return None
|
|
437
|
+
|
|
438
|
+
def get_history_image(self, slot: int, index: int):
|
|
439
|
+
"""
|
|
440
|
+
Get a specific image from the undo stack (not applied, just for preview).
|
|
441
|
+
:param slot: Slot number.
|
|
442
|
+
:param index: Index from the bottom (0 = oldest).
|
|
443
|
+
"""
|
|
444
|
+
if 0 <= slot < self.max_slots:
|
|
445
|
+
stack = self._undo_stacks[slot]
|
|
446
|
+
if 0 <= index < len(stack):
|
|
447
|
+
img, meta, _ = stack[index] if len(stack[index]) == 3 else (*stack[index], "Unnamed")
|
|
448
|
+
return img.copy(), meta.copy()
|
|
449
|
+
return None, None
|
|
450
|
+
|
|
451
|
+
def get_image_for_slot(self, slot: int) -> Optional[np.ndarray]:
|
|
452
|
+
"""Return the image stored in slot, or None if empty."""
|
|
453
|
+
return self._images.get(slot)
|
|
454
|
+
|
|
455
|
+
class MaskManager(QObject):
|
|
456
|
+
"""
|
|
457
|
+
Manages masks and tracks whether a mask is applied to the image.
|
|
458
|
+
"""
|
|
459
|
+
mask_changed = pyqtSignal(int, np.ndarray) # Signal to notify mask changes (slot, mask)
|
|
460
|
+
applied_mask_changed = pyqtSignal(int, np.ndarray) # Signal for applied mask updates
|
|
461
|
+
|
|
462
|
+
def __init__(self, max_slots=5):
|
|
463
|
+
super().__init__()
|
|
464
|
+
self.max_slots = max_slots
|
|
465
|
+
self._masks = {i: None for i in range(max_slots)} # Store masks for each slot
|
|
466
|
+
self.applied_mask_slot = None # Slot from which the mask is applied
|
|
467
|
+
self.applied_mask = None # Currently applied mask (numpy array)
|
|
468
|
+
|
|
469
|
+
def set_mask(self, slot, mask):
|
|
470
|
+
"""
|
|
471
|
+
Sets the mask for a specific slot.
|
|
472
|
+
"""
|
|
473
|
+
if 0 <= slot < self.max_slots:
|
|
474
|
+
self._masks[slot] = mask
|
|
475
|
+
self.mask_changed.emit(slot, mask)
|
|
476
|
+
|
|
477
|
+
def get_mask(self, slot):
|
|
478
|
+
"""
|
|
479
|
+
Retrieves the mask from a specific slot.
|
|
480
|
+
"""
|
|
481
|
+
return self._masks.get(slot, None)
|
|
482
|
+
|
|
483
|
+
def clear_applied_mask(self):
|
|
484
|
+
"""
|
|
485
|
+
Clears the currently applied mask and emits an empty mask.
|
|
486
|
+
"""
|
|
487
|
+
self.applied_mask_slot = None
|
|
488
|
+
self.applied_mask = None
|
|
489
|
+
|
|
490
|
+
# Emit an empty mask instead of None
|
|
491
|
+
empty_mask = np.zeros((1, 1), dtype=np.uint8)
|
|
492
|
+
self.applied_mask_changed.emit(-1, empty_mask) # Signal that no mask is applied
|
|
493
|
+
|
|
494
|
+
print("Applied mask cleared.")
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def apply_mask_from_slot(self, slot):
|
|
499
|
+
"""
|
|
500
|
+
Applies the mask from the specified slot.
|
|
501
|
+
"""
|
|
502
|
+
if slot in self._masks and self._masks[slot] is not None:
|
|
503
|
+
self.applied_mask_slot = slot
|
|
504
|
+
self.applied_mask = self._masks[slot]
|
|
505
|
+
self.applied_mask_changed.emit(slot, self.applied_mask)
|
|
506
|
+
print(f"Mask from slot {slot} applied.")
|
|
507
|
+
else:
|
|
508
|
+
print(f"Mask from slot {slot} cannot be applied (empty).")
|
|
509
|
+
|
|
510
|
+
def get_applied_mask(self):
|
|
511
|
+
"""
|
|
512
|
+
Retrieves the currently applied mask.
|
|
513
|
+
"""
|
|
514
|
+
return self.applied_mask
|
|
515
|
+
|
|
516
|
+
def get_applied_mask_slot(self):
|
|
517
|
+
"""
|
|
518
|
+
Retrieves the slot from which the currently applied mask originated.
|
|
519
|
+
"""
|
|
520
|
+
return self.applied_mask_slot
|
|
521
|
+
|
|
522
|
+
def _finalize_loaded_image(arr: np.ndarray) -> np.ndarray:
|
|
523
|
+
"""Ensure float32 [finite], C-contiguous for downstream Qt/Numba."""
|
|
524
|
+
if arr is None:
|
|
525
|
+
return None
|
|
526
|
+
# Replace NaN/Inf (can appear after BSCALE/BZERO math)
|
|
527
|
+
arr = np.nan_to_num(arr, nan=0.0, posinf=1.0, neginf=0.0)
|
|
528
|
+
# Force float32 + C-order (copies if needed; detaches from memmap)
|
|
529
|
+
return np.asarray(arr, dtype=np.float32, order="C")
|
|
530
|
+
|
|
531
|
+
def list_fits_extensions(path: str) -> dict:
|
|
532
|
+
"""
|
|
533
|
+
Return a dict {extname_or_index: {"index": i, "shape": shape, "dtype": dtype}} for all IMAGE HDUs.
|
|
534
|
+
extname_or_index prefers the HDU name (uppercased) when present, otherwise the numeric index.
|
|
535
|
+
"""
|
|
536
|
+
if path.lower().endswith(('.fits.gz', '.fit.gz')):
|
|
537
|
+
with gzip.open(path, 'rb') as f:
|
|
538
|
+
buf = BytesIO(f.read())
|
|
539
|
+
hdul = fits.open(buf, memmap=False)
|
|
540
|
+
else:
|
|
541
|
+
hdul = fits.open(path, memmap=False)
|
|
542
|
+
|
|
543
|
+
info = {}
|
|
544
|
+
with hdul as hdul:
|
|
545
|
+
for i, hdu in enumerate(hdul):
|
|
546
|
+
if getattr(hdu, 'data', None) is None:
|
|
547
|
+
continue
|
|
548
|
+
if not hasattr(hdu, 'data'):
|
|
549
|
+
continue
|
|
550
|
+
key = (hdu.name or str(i)).upper()
|
|
551
|
+
try:
|
|
552
|
+
shp = tuple(hdu.data.shape)
|
|
553
|
+
dt = hdu.data.dtype
|
|
554
|
+
info[key] = {"index": i, "shape": shp, "dtype": str(dt)}
|
|
555
|
+
except Exception:
|
|
556
|
+
pass
|
|
557
|
+
return info
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def load_fits_extension(path: str, key: str | int):
|
|
561
|
+
"""
|
|
562
|
+
Load a single IMAGE HDU (by extname or index) as float32 in [0..1] (like load_image does).
|
|
563
|
+
Returns (image: np.ndarray, header: fits.Header, bit_depth: str, is_mono: bool).
|
|
564
|
+
"""
|
|
565
|
+
if path.lower().endswith(('.fits.gz', '.fit.gz')):
|
|
566
|
+
with gzip.open(path, 'rb') as f:
|
|
567
|
+
buf = BytesIO(f.read())
|
|
568
|
+
hdul = fits.open(buf, memmap=False)
|
|
569
|
+
else:
|
|
570
|
+
hdul = fits.open(path, memmap=False)
|
|
571
|
+
|
|
572
|
+
with hdul as hdul:
|
|
573
|
+
# resolve key
|
|
574
|
+
if isinstance(key, str):
|
|
575
|
+
# find first matching extname (case-insensitive)
|
|
576
|
+
idx = None
|
|
577
|
+
for i, hdu in enumerate(hdul):
|
|
578
|
+
if (hdu.name or '').upper() == key.upper():
|
|
579
|
+
idx = i; break
|
|
580
|
+
if idx is None:
|
|
581
|
+
raise KeyError(f"Extension '{key}' not found in {path}")
|
|
582
|
+
else:
|
|
583
|
+
idx = int(key)
|
|
584
|
+
|
|
585
|
+
hdu = hdul[idx]
|
|
586
|
+
data = hdu.data
|
|
587
|
+
if data is None:
|
|
588
|
+
raise ValueError(f"HDU {key} has no image data")
|
|
589
|
+
|
|
590
|
+
# normalize like your load_image
|
|
591
|
+
import numpy as np
|
|
592
|
+
if data.dtype == np.uint8:
|
|
593
|
+
bit_depth = "8-bit"; img = data.astype(np.float32) / 255.0
|
|
594
|
+
elif data.dtype == np.uint16:
|
|
595
|
+
bit_depth = "16-bit"; img = data.astype(np.float32) / 65535.0
|
|
596
|
+
elif data.dtype == np.uint32:
|
|
597
|
+
bit_depth = "32-bit unsigned";
|
|
598
|
+
bzero = hdu.header.get('BZERO', 0); bscale = hdu.header.get('BSCALE', 1)
|
|
599
|
+
img = data.astype(np.float32) * bscale + bzero
|
|
600
|
+
elif data.dtype == np.int32:
|
|
601
|
+
bit_depth = "32-bit signed";
|
|
602
|
+
bzero = hdu.header.get('BZERO', 0); bscale = hdu.header.get('BSCALE', 1)
|
|
603
|
+
img = data.astype(np.float32) * bscale + bzero
|
|
604
|
+
elif data.dtype == np.float32:
|
|
605
|
+
bit_depth = "32-bit floating point"; img = np.array(data, dtype=np.float32, copy=True, order="C")
|
|
606
|
+
else:
|
|
607
|
+
raise ValueError(f"Unsupported FITS extension dtype: {data.dtype}")
|
|
608
|
+
|
|
609
|
+
img = np.squeeze(img)
|
|
610
|
+
if img.dtype == np.float32 and img.size and img.max() > 1.0:
|
|
611
|
+
img = img / float(img.max())
|
|
612
|
+
|
|
613
|
+
if img.ndim == 2:
|
|
614
|
+
is_mono = True
|
|
615
|
+
elif img.ndim == 3 and img.shape[0] == 3 and img.shape[1] > 1 and img.shape[2] > 1:
|
|
616
|
+
img = np.transpose(img, (1, 2, 0)); is_mono = False
|
|
617
|
+
elif img.ndim == 3 and img.shape[-1] == 3:
|
|
618
|
+
is_mono = False
|
|
619
|
+
else:
|
|
620
|
+
raise ValueError(f"Unsupported FITS ext dimensions: {img.shape}")
|
|
621
|
+
|
|
622
|
+
from .image_manager import _finalize_loaded_image # or adjust import if needed
|
|
623
|
+
img = _finalize_loaded_image(img)
|
|
624
|
+
return img, hdu.header, bit_depth, is_mono
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def _normalize_to_float(image_u16: np.ndarray) -> tuple[np.ndarray, str, bool]:
|
|
628
|
+
"""Normalize uint16/uint8 arrays to float32 [0,1] and detect mono."""
|
|
629
|
+
if image_u16.dtype == np.uint16:
|
|
630
|
+
bit_depth = "16-bit"
|
|
631
|
+
img = image_u16.astype(np.float32) / 65535.0
|
|
632
|
+
elif image_u16.dtype == np.uint8:
|
|
633
|
+
bit_depth = "8-bit"
|
|
634
|
+
img = image_u16.astype(np.float32) / 255.0
|
|
635
|
+
else:
|
|
636
|
+
bit_depth = str(image_u16.dtype)
|
|
637
|
+
img = image_u16.astype(np.float32)
|
|
638
|
+
mx = float(img.max()) if img.size else 1.0
|
|
639
|
+
if mx > 0:
|
|
640
|
+
img /= mx
|
|
641
|
+
is_mono = (img.ndim == 2) or (img.ndim == 3 and img.shape[2] == 1)
|
|
642
|
+
if img.ndim == 3 and img.shape[2] == 1:
|
|
643
|
+
img = img[:, :, 0]
|
|
644
|
+
return img, bit_depth, is_mono
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def _try_load_raw_with_rawpy(filename: str, allow_thumb_preview: bool = True, debug_thumb: bool = True):
|
|
648
|
+
"""
|
|
649
|
+
Open RAW with rawpy/LibRaw and return a normalized [0,1] Bayer mosaic (mono=True).
|
|
650
|
+
Fallbacks:
|
|
651
|
+
1) raw.raw_image_visible
|
|
652
|
+
2) raw.raw_image
|
|
653
|
+
3) raw.postprocess(...) → linear 16-bit RGB (no auto-bright), normalized to [0,1]
|
|
654
|
+
4) Embedded JPEG preview (8-bit)
|
|
655
|
+
Returns: (image, header, bit_depth, is_mono)
|
|
656
|
+
"""
|
|
657
|
+
if rawpy is None:
|
|
658
|
+
raise RuntimeError("rawpy not installed")
|
|
659
|
+
|
|
660
|
+
def _normalize_bayer(arr: np.ndarray, raw) -> tuple[np.ndarray, fits.Header, str, bool]:
|
|
661
|
+
arr = arr.astype(np.float32, copy=False)
|
|
662
|
+
blk = float(np.mean(getattr(raw, "black_level_per_channel", [0, 0, 0, 0])))
|
|
663
|
+
wht = float(getattr(raw, "white_level", max(1.0, float(arr.max()))))
|
|
664
|
+
arr = np.clip(arr - blk, 0, None)
|
|
665
|
+
scale = max(1.0, wht - blk)
|
|
666
|
+
arr /= scale
|
|
667
|
+
|
|
668
|
+
hdr = fits.Header()
|
|
669
|
+
# Fill from raw.metadata first
|
|
670
|
+
hdr = _fill_hdr_from_raw_metadata(raw, hdr)
|
|
671
|
+
|
|
672
|
+
# Optional extra bits you already had:
|
|
673
|
+
try:
|
|
674
|
+
if getattr(raw, "camera_whitebalance", None) is not None:
|
|
675
|
+
hdr["CAMWB0"] = float(raw.camera_whitebalance[0])
|
|
676
|
+
except Exception:
|
|
677
|
+
pass
|
|
678
|
+
|
|
679
|
+
for key, attr in (("EXPTIME", "shutter"),
|
|
680
|
+
("ISO", "iso_speed"),
|
|
681
|
+
("FOCAL", "focal_len"),
|
|
682
|
+
("TIMESTAMP", "timestamp")):
|
|
683
|
+
if hasattr(raw, attr) and key not in hdr:
|
|
684
|
+
hdr[key] = getattr(raw, attr)
|
|
685
|
+
|
|
686
|
+
try:
|
|
687
|
+
cfa = getattr(raw, "raw_colors_visible", None)
|
|
688
|
+
if cfa is not None:
|
|
689
|
+
mapping = {0: "R", 1: "G", 2: "B"}
|
|
690
|
+
desc = "".join(mapping.get(int(v), "?") for v in cfa.flatten()[:4])
|
|
691
|
+
hdr["CFA"] = desc
|
|
692
|
+
except Exception:
|
|
693
|
+
pass
|
|
694
|
+
|
|
695
|
+
return arr, hdr, "16-bit", True # Bayer mosaic → mono=True
|
|
696
|
+
|
|
697
|
+
# Attempt 1: visible mosaic
|
|
698
|
+
try:
|
|
699
|
+
with rawpy.imread(filename) as raw:
|
|
700
|
+
bayer = raw.raw_image_visible
|
|
701
|
+
if bayer is None:
|
|
702
|
+
raise RuntimeError("raw_image_visible is None")
|
|
703
|
+
return _normalize_bayer(bayer, raw)
|
|
704
|
+
except Exception as e1:
|
|
705
|
+
print(f"[rawpy] full decode (visible) failed: {e1}")
|
|
706
|
+
|
|
707
|
+
# Attempt 2: full raw mosaic (no explicit unpack)
|
|
708
|
+
try:
|
|
709
|
+
with rawpy.imread(filename) as raw:
|
|
710
|
+
bayer = getattr(raw, "raw_image", None)
|
|
711
|
+
if bayer is None:
|
|
712
|
+
raise RuntimeError("raw_image is None")
|
|
713
|
+
return _normalize_bayer(bayer, raw)
|
|
714
|
+
except Exception as e2:
|
|
715
|
+
print(f"[rawpy] second pass (raw_image) failed: {e2}")
|
|
716
|
+
|
|
717
|
+
# Attempt 3: safe demosaic (linear, no auto-bright) → RGB float32 [0,1]
|
|
718
|
+
try:
|
|
719
|
+
with rawpy.imread(filename) as raw:
|
|
720
|
+
rgb16 = raw.postprocess(
|
|
721
|
+
output_bps=16,
|
|
722
|
+
gamma=(1, 1), # keep linear
|
|
723
|
+
no_auto_bright=True, # avoid LibRaw “lift”
|
|
724
|
+
use_camera_wb=False, # neutral; you can set True if desired
|
|
725
|
+
output_color=rawpy.ColorSpace.raw,
|
|
726
|
+
user_flip=0,
|
|
727
|
+
)
|
|
728
|
+
img = rgb16.astype(np.float32) / 65535.0 # HxWx3
|
|
729
|
+
|
|
730
|
+
hdr = fits.Header()
|
|
731
|
+
hdr = _fill_hdr_from_raw_metadata(raw, hdr)
|
|
732
|
+
hdr["RAW_DEM"] = (True, "LibRaw postprocess; linear, no auto-bright, RAW color")
|
|
733
|
+
|
|
734
|
+
return img, hdr, "16-bit demosaiced", False
|
|
735
|
+
except Exception as e3:
|
|
736
|
+
print(f"[rawpy] postprocess fallback failed: {e3}")
|
|
737
|
+
|
|
738
|
+
# Attempt 4: embedded JPEG preview
|
|
739
|
+
if allow_thumb_preview:
|
|
740
|
+
try:
|
|
741
|
+
with rawpy.imread(filename) as raw2:
|
|
742
|
+
th = raw2.extract_thumb()
|
|
743
|
+
if debug_thumb:
|
|
744
|
+
kind = getattr(th.format, "name", str(th.format))
|
|
745
|
+
print(f"[rawpy] extract_thumb: kind={kind}, bytes={len(th.data)}")
|
|
746
|
+
from io import BytesIO as _BytesIO
|
|
747
|
+
pil = Image.open(_BytesIO(th.data))
|
|
748
|
+
if pil.mode not in ("RGB", "L"):
|
|
749
|
+
pil = pil.convert("RGB")
|
|
750
|
+
img = np.array(pil, dtype=np.float32) / 255.0
|
|
751
|
+
is_mono = (img.ndim == 2)
|
|
752
|
+
|
|
753
|
+
hdr = fits.Header()
|
|
754
|
+
hdr = _fill_hdr_from_raw_metadata(raw2, hdr)
|
|
755
|
+
hdr["RAW_PREV"] = (True, "Embedded JPEG preview (no linear RAW data)")
|
|
756
|
+
|
|
757
|
+
return img, hdr, "8-bit preview (JPEG from RAW)", is_mono
|
|
758
|
+
except Exception as e4:
|
|
759
|
+
print(f"[rawpy] extract_thumb failed: {e4}")
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
raise RuntimeError("RAW decode failed (rawpy).")
|
|
763
|
+
|
|
764
|
+
import os
|
|
765
|
+
import datetime
|
|
766
|
+
|
|
767
|
+
import exifread
|
|
768
|
+
|
|
769
|
+
RAW_EXTS = ('.cr2', '.cr3', '.nef', '.arw', '.dng', '.raf', '.orf', '.rw2', '.pef')
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
def _is_raw_file(path: str) -> bool:
|
|
773
|
+
return path.lower().endswith(RAW_EXTS)
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def _parse_fraction_or_float(val) -> float | None:
|
|
777
|
+
"""
|
|
778
|
+
Accepts things like '1/125', '0.008', 8, or exifread Ratio objects.
|
|
779
|
+
Returns float seconds or None.
|
|
780
|
+
"""
|
|
781
|
+
s = str(val).strip()
|
|
782
|
+
if not s:
|
|
783
|
+
return None
|
|
784
|
+
try:
|
|
785
|
+
# exifread often gives a single Ratio or list of one Ratio
|
|
786
|
+
if hasattr(val, "num") and hasattr(val, "den"):
|
|
787
|
+
return float(val.num) / float(val.den)
|
|
788
|
+
if isinstance(val, (list, tuple)) and val and hasattr(val[0], "num"):
|
|
789
|
+
r = val[0]
|
|
790
|
+
return float(r.num) / float(r.den)
|
|
791
|
+
|
|
792
|
+
if '/' in s:
|
|
793
|
+
num, den = s.split('/', 1)
|
|
794
|
+
return float(num) / float(den)
|
|
795
|
+
return float(s)
|
|
796
|
+
except Exception:
|
|
797
|
+
return None
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
def _parse_exif_datetime(dt_str: str) -> str | None:
|
|
801
|
+
"""
|
|
802
|
+
EXIF typically: 'YYYY:MM:DD HH:MM:SS'.
|
|
803
|
+
Returns ISO-like 'YYYY-MM-DDTHH:MM:SS' or None.
|
|
804
|
+
"""
|
|
805
|
+
s = str(dt_str).strip()
|
|
806
|
+
if not s:
|
|
807
|
+
return None
|
|
808
|
+
|
|
809
|
+
# exifread sometimes formats as "YYYY:MM:DD HH:MM:SS"
|
|
810
|
+
try:
|
|
811
|
+
date_part, time_part = s.split(' ', 1)
|
|
812
|
+
y, m, d = date_part.split(':', 2)
|
|
813
|
+
return f"{int(y):04d}-{int(m):02d}-{int(d):02d}T{time_part}"
|
|
814
|
+
except Exception:
|
|
815
|
+
return None
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
def _ensure_minimal_header(header, file_path: str) -> fits.Header:
|
|
819
|
+
"""
|
|
820
|
+
Guarantee we have a FITS Header. For non-FITS sources (TIFF/PNG/JPG/etc),
|
|
821
|
+
synthesize a basic header and fill DATE-OBS from file mtime if missing.
|
|
822
|
+
"""
|
|
823
|
+
if header is None:
|
|
824
|
+
header = fits.Header()
|
|
825
|
+
header["SIMPLE"] = True
|
|
826
|
+
header["BITPIX"] = 16
|
|
827
|
+
header["CREATOR"] = "SetiAstroSuite"
|
|
828
|
+
|
|
829
|
+
# Try to provide DATE-OBS if not present
|
|
830
|
+
if "DATE-OBS" not in header:
|
|
831
|
+
try:
|
|
832
|
+
ts = os.path.getmtime(file_path)
|
|
833
|
+
dt = datetime.datetime.fromtimestamp(ts, tz=datetime.timezone.utc)
|
|
834
|
+
header["DATE-OBS"] = (
|
|
835
|
+
dt.isoformat(timespec="seconds"),
|
|
836
|
+
"File modification time (UTC) used as DATE-OBS"
|
|
837
|
+
)
|
|
838
|
+
except Exception:
|
|
839
|
+
pass
|
|
840
|
+
|
|
841
|
+
return header
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
def _enrich_header_from_exif(header: fits.Header, file_path: str) -> fits.Header:
|
|
845
|
+
"""
|
|
846
|
+
Merge EXIF metadata from a RAW file into an existing header without
|
|
847
|
+
blowing away other keys. Only fills keys that are missing.
|
|
848
|
+
"""
|
|
849
|
+
header = header.copy() if header is not None else fits.Header()
|
|
850
|
+
header.setdefault("SIMPLE", True)
|
|
851
|
+
header.setdefault("BITPIX", 16)
|
|
852
|
+
header.setdefault("CREATOR", "SetiAstroSuite")
|
|
853
|
+
|
|
854
|
+
try:
|
|
855
|
+
with open(file_path, "rb") as f:
|
|
856
|
+
tags = exifread.process_file(f, details=False)
|
|
857
|
+
except Exception:
|
|
858
|
+
# Can't read EXIF → just return what we have
|
|
859
|
+
return header
|
|
860
|
+
|
|
861
|
+
def get_tag(*names):
|
|
862
|
+
for n in names:
|
|
863
|
+
t = tags.get(n)
|
|
864
|
+
if t is not None:
|
|
865
|
+
return t
|
|
866
|
+
return None
|
|
867
|
+
|
|
868
|
+
# Exposure time
|
|
869
|
+
exptime_tag = get_tag("EXIF ExposureTime", "EXIF ShutterSpeedValue")
|
|
870
|
+
if exptime_tag and "EXPTIME" not in header:
|
|
871
|
+
val = _parse_fraction_or_float(exptime_tag.values)
|
|
872
|
+
if val is not None:
|
|
873
|
+
header["EXPTIME"] = (float(val), "Exposure time (s) from EXIF")
|
|
874
|
+
|
|
875
|
+
# ISO
|
|
876
|
+
iso_tag = get_tag("EXIF ISOSpeedRatings", "EXIF PhotographicSensitivity")
|
|
877
|
+
if iso_tag and "ISO" not in header:
|
|
878
|
+
try:
|
|
879
|
+
header["ISO"] = (int(str(iso_tag.values)), "ISO from EXIF")
|
|
880
|
+
except Exception:
|
|
881
|
+
header["ISO"] = (str(iso_tag.values), "ISO from EXIF")
|
|
882
|
+
|
|
883
|
+
# Date/time
|
|
884
|
+
date_tag = get_tag(
|
|
885
|
+
"EXIF DateTimeOriginal",
|
|
886
|
+
"EXIF DateTimeDigitized",
|
|
887
|
+
"Image DateTime",
|
|
888
|
+
)
|
|
889
|
+
if date_tag and "DATE-OBS" not in header:
|
|
890
|
+
dt = _parse_exif_datetime(date_tag.values)
|
|
891
|
+
if dt:
|
|
892
|
+
header["DATE-OBS"] = (dt, "Start of exposure (camera local time)")
|
|
893
|
+
|
|
894
|
+
# Aperture
|
|
895
|
+
fnum_tag = get_tag("EXIF FNumber")
|
|
896
|
+
if fnum_tag and "FNUMBER" not in header:
|
|
897
|
+
val = _parse_fraction_or_float(fnum_tag.values)
|
|
898
|
+
if val is not None:
|
|
899
|
+
header["FNUMBER"] = (float(val), "F-number (aperture)")
|
|
900
|
+
|
|
901
|
+
# Focal length
|
|
902
|
+
fl_tag = get_tag("EXIF FocalLength")
|
|
903
|
+
if fl_tag and "FOCALLEN" not in header:
|
|
904
|
+
val = _parse_fraction_or_float(fl_tag.values)
|
|
905
|
+
if val is not None:
|
|
906
|
+
header["FOCALLEN"] = (float(val), "Focal length (mm)")
|
|
907
|
+
|
|
908
|
+
# Camera make/model
|
|
909
|
+
make_tag = get_tag("Image Make")
|
|
910
|
+
model_tag = get_tag("Image Model")
|
|
911
|
+
cam_parts = []
|
|
912
|
+
if make_tag:
|
|
913
|
+
cam_parts.append(str(make_tag.values).strip())
|
|
914
|
+
if model_tag:
|
|
915
|
+
cam_parts.append(str(model_tag.values).strip())
|
|
916
|
+
camera_str = " ".join(p for p in cam_parts if p)
|
|
917
|
+
if camera_str:
|
|
918
|
+
header.setdefault("INSTRUME", camera_str) # instrument / camera
|
|
919
|
+
header.setdefault("CAMERA", camera_str) # custom keyword
|
|
920
|
+
|
|
921
|
+
return header
|
|
922
|
+
|
|
923
|
+
def _fill_hdr_from_raw_metadata(raw, hdr: fits.Header | None = None) -> fits.Header:
|
|
924
|
+
"""
|
|
925
|
+
Merge LibRaw/rawpy metadata into hdr (EXPTIME, ISO, FNUMBER, FOCALLEN, camera, DATE-OBS).
|
|
926
|
+
Does NOT overwrite existing keys.
|
|
927
|
+
"""
|
|
928
|
+
if hdr is None:
|
|
929
|
+
hdr = fits.Header()
|
|
930
|
+
|
|
931
|
+
try:
|
|
932
|
+
m = raw.metadata
|
|
933
|
+
except Exception:
|
|
934
|
+
return hdr
|
|
935
|
+
|
|
936
|
+
# Exposure time (seconds)
|
|
937
|
+
if hasattr(m, "exposure") and m.exposure is not None and "EXPTIME" not in hdr:
|
|
938
|
+
try:
|
|
939
|
+
hdr["EXPTIME"] = (float(m.exposure), "Exposure time (s) from RAW metadata")
|
|
940
|
+
except Exception:
|
|
941
|
+
pass
|
|
942
|
+
|
|
943
|
+
# ISO
|
|
944
|
+
if hasattr(m, "iso") and m.iso is not None and "ISO" not in hdr:
|
|
945
|
+
try:
|
|
946
|
+
hdr["ISO"] = (int(m.iso), "ISO from RAW metadata")
|
|
947
|
+
except Exception:
|
|
948
|
+
hdr["ISO"] = (str(m.iso), "ISO from RAW metadata")
|
|
949
|
+
|
|
950
|
+
# Aperture
|
|
951
|
+
if hasattr(m, "aperture") and m.aperture is not None and "FNUMBER" not in hdr:
|
|
952
|
+
try:
|
|
953
|
+
hdr["FNUMBER"] = (float(m.aperture), "F-number (aperture) from RAW metadata")
|
|
954
|
+
except Exception:
|
|
955
|
+
pass
|
|
956
|
+
|
|
957
|
+
# Focal length (mm)
|
|
958
|
+
if hasattr(m, "focal_len") and m.focal_len is not None and "FOCALLEN" not in hdr:
|
|
959
|
+
try:
|
|
960
|
+
hdr["FOCALLEN"] = (float(m.focal_len), "Focal length (mm) from RAW metadata")
|
|
961
|
+
except Exception:
|
|
962
|
+
pass
|
|
963
|
+
|
|
964
|
+
# Camera make/model
|
|
965
|
+
make = getattr(m, "make", None)
|
|
966
|
+
model = getattr(m, "model", None)
|
|
967
|
+
cam_parts = []
|
|
968
|
+
if make:
|
|
969
|
+
cam_parts.append(str(make).strip())
|
|
970
|
+
if model:
|
|
971
|
+
cam_parts.append(str(model).strip())
|
|
972
|
+
camera_str = " ".join(p for p in cam_parts if p)
|
|
973
|
+
if camera_str:
|
|
974
|
+
hdr.setdefault("INSTRUME", camera_str)
|
|
975
|
+
hdr.setdefault("CAMERA", camera_str)
|
|
976
|
+
|
|
977
|
+
# Timestamp → DATE-OBS in UTC
|
|
978
|
+
if hasattr(m, "timestamp") and m.timestamp and "DATE-OBS" not in hdr:
|
|
979
|
+
try:
|
|
980
|
+
dt = datetime.datetime.fromtimestamp(m.timestamp, tz=datetime.timezone.utc)
|
|
981
|
+
hdr["DATE-OBS"] = (dt.isoformat(timespec="seconds"), "RAW timestamp (UTC)")
|
|
982
|
+
except Exception:
|
|
983
|
+
pass
|
|
984
|
+
|
|
985
|
+
return hdr
|
|
986
|
+
|
|
987
|
+
from astropy.wcs import WCS
|
|
988
|
+
|
|
989
|
+
def attach_wcs_to_metadata(meta: dict, hdr: fits.Header | dict | None) -> dict:
|
|
990
|
+
"""
|
|
991
|
+
If hdr contains WCS, create an astropy.wcs.WCS and stash in metadata.
|
|
992
|
+
"""
|
|
993
|
+
if not hdr or meta is None:
|
|
994
|
+
return meta or {}
|
|
995
|
+
|
|
996
|
+
if meta.get("wcs") is not None:
|
|
997
|
+
return meta # already present
|
|
998
|
+
|
|
999
|
+
try:
|
|
1000
|
+
fhdr = hdr if isinstance(hdr, fits.Header) else fits.Header(hdr)
|
|
1001
|
+
|
|
1002
|
+
# 🔹 Drop problematic long-string cards that upset astropy.wcs
|
|
1003
|
+
# FILE_PATH is the one we saw erroring, but you can add more here if needed.
|
|
1004
|
+
if "FILE_PATH" in fhdr:
|
|
1005
|
+
val = str(fhdr["FILE_PATH"])
|
|
1006
|
+
if len(val) > 68: # FITS cards max 80 chars, ~68 for value
|
|
1007
|
+
print(f"⚠️ Dropping FILE_PATH from WCS header build (too long: {len(val)} chars)")
|
|
1008
|
+
del fhdr["FILE_PATH"]
|
|
1009
|
+
|
|
1010
|
+
# Optional: also run through our invalid-card stripper
|
|
1011
|
+
fhdr = _drop_invalid_cards(fhdr)
|
|
1012
|
+
|
|
1013
|
+
# --- Quick sanity: no basic WCS → bail quietly ---
|
|
1014
|
+
core_keys = ("CTYPE1", "CTYPE2", "CRVAL1", "CRVAL2")
|
|
1015
|
+
if not all(k in fhdr for k in core_keys):
|
|
1016
|
+
return meta
|
|
1017
|
+
|
|
1018
|
+
# --- Attempt 1: basic WCS ---
|
|
1019
|
+
try:
|
|
1020
|
+
w = WCS(fhdr, relax=True)
|
|
1021
|
+
except Exception as e1:
|
|
1022
|
+
print(f"⚠️ WCS(fhdr, relax=True) failed: {e1}")
|
|
1023
|
+
print("⚠️ Retrying WCS with naxis=2 (ignore extra axis).")
|
|
1024
|
+
try:
|
|
1025
|
+
w = WCS(fhdr, relax=True, naxis=2)
|
|
1026
|
+
except Exception as e2:
|
|
1027
|
+
print(f"⚠️ WCS(..., naxis=2) failed: {e2}")
|
|
1028
|
+
print("⚠️ Retrying WCS with naxis=2 after stripping SIP terms.")
|
|
1029
|
+
try:
|
|
1030
|
+
fhdr2 = fhdr.copy()
|
|
1031
|
+
for k in list(fhdr2.keys()):
|
|
1032
|
+
if k.startswith(("A_", "B_", "AP_", "BP_", "A_ORDER", "B_ORDER")):
|
|
1033
|
+
del fhdr2[k]
|
|
1034
|
+
w = WCS(fhdr2, relax=True, naxis=2)
|
|
1035
|
+
except Exception as e3:
|
|
1036
|
+
print(f"⚠️ WCS(..., naxis=2) after SIP-strip failed: {e3}")
|
|
1037
|
+
raise e1 # re-raise original
|
|
1038
|
+
|
|
1039
|
+
if getattr(w, "has_celestial", False):
|
|
1040
|
+
meta["wcs"] = w
|
|
1041
|
+
meta["wcs_header"] = w.to_header(relax=True)
|
|
1042
|
+
meta["wcsaxes"] = int(getattr(w, "naxis", getattr(w.wcs, "naxis", 2)))
|
|
1043
|
+
print(f"🔷 Attached astropy WCS into metadata (naxis={meta['wcsaxes']})")
|
|
1044
|
+
else:
|
|
1045
|
+
print("⚠️ WCS parsed but has no celestial axes; not attaching.")
|
|
1046
|
+
|
|
1047
|
+
except Exception as e:
|
|
1048
|
+
print(f"⚠️ Failed to build WCS from header: {e}")
|
|
1049
|
+
|
|
1050
|
+
return meta
|
|
1051
|
+
|
|
1052
|
+
|
|
1053
|
+
def load_image(filename, max_retries=3, wait_seconds=3, return_metadata: bool = False):
|
|
1054
|
+
"""
|
|
1055
|
+
Loads an image from the specified filename with support for various formats.
|
|
1056
|
+
If a "buffer is too small for requested array" error occurs, it retries loading after waiting.
|
|
1057
|
+
|
|
1058
|
+
Parameters:
|
|
1059
|
+
filename (str): Path to the image file.
|
|
1060
|
+
max_retries (int): Number of times to retry on specific buffer error.
|
|
1061
|
+
wait_seconds (int): Seconds to wait before retrying.
|
|
1062
|
+
|
|
1063
|
+
Returns:
|
|
1064
|
+
tuple: (image, original_header, bit_depth, is_mono) or (None, None, None, None) on failure.
|
|
1065
|
+
"""
|
|
1066
|
+
attempt = 0
|
|
1067
|
+
while attempt <= max_retries:
|
|
1068
|
+
try:
|
|
1069
|
+
image = None # Ensure 'image' is explicitly declared
|
|
1070
|
+
bit_depth = None
|
|
1071
|
+
is_mono = False
|
|
1072
|
+
original_header = None
|
|
1073
|
+
|
|
1074
|
+
# --- Unified FITS handling ---
|
|
1075
|
+
if filename.lower().endswith(('.fits', '.fit', '.fits.gz', '.fit.gz', '.fz', '.fz')):
|
|
1076
|
+
# Use get_valid_header to retrieve the header and extension index.
|
|
1077
|
+
original_header, ext_index = get_valid_header(filename)
|
|
1078
|
+
|
|
1079
|
+
|
|
1080
|
+
# Open the file appropriately.
|
|
1081
|
+
if filename.lower().endswith(('.fits.gz', '.fit.gz')):
|
|
1082
|
+
print(f"Loading compressed FITS file: {filename}")
|
|
1083
|
+
with gzip.open(filename, 'rb') as f:
|
|
1084
|
+
file_content = f.read()
|
|
1085
|
+
hdul = fits.open(BytesIO(file_content))
|
|
1086
|
+
else:
|
|
1087
|
+
if filename.lower().endswith(('.fz', '.fz')):
|
|
1088
|
+
print(f"Loading Rice-compressed FITS file: {filename}")
|
|
1089
|
+
else:
|
|
1090
|
+
print(f"Loading FITS file: {filename}")
|
|
1091
|
+
hdul = fits.open(filename)
|
|
1092
|
+
|
|
1093
|
+
with hdul as hdul:
|
|
1094
|
+
# Retrieve image data from the extension indicated by get_valid_header.
|
|
1095
|
+
image_data = hdul[ext_index].data
|
|
1096
|
+
if image_data is None:
|
|
1097
|
+
raise ValueError(f"No image data found in FITS file in extension {ext_index}.")
|
|
1098
|
+
|
|
1099
|
+
# Ensure native byte order
|
|
1100
|
+
if image_data.dtype.byteorder not in ('=', '|'):
|
|
1101
|
+
image_data = image_data.astype(image_data.dtype.newbyteorder('='))
|
|
1102
|
+
|
|
1103
|
+
# ---------------------------------------------------------------------
|
|
1104
|
+
# 1) Detect bit depth and convert to float32
|
|
1105
|
+
# ---------------------------------------------------------------------
|
|
1106
|
+
if image_data.dtype == np.uint8:
|
|
1107
|
+
bit_depth = "8-bit"
|
|
1108
|
+
print("Identified 8-bit FITS image.")
|
|
1109
|
+
image = image_data.astype(np.float32) / 255.0
|
|
1110
|
+
|
|
1111
|
+
elif image_data.dtype == np.uint16:
|
|
1112
|
+
bit_depth = "16-bit"
|
|
1113
|
+
print("Identified 16-bit FITS image.")
|
|
1114
|
+
image = image_data.astype(np.float32) / 65535.0
|
|
1115
|
+
|
|
1116
|
+
elif image_data.dtype == np.int16:
|
|
1117
|
+
bit_depth = "16-bit signed"
|
|
1118
|
+
print("Identified 16-bit signed FITS image.")
|
|
1119
|
+
bzero = original_header.get('BZERO', 0)
|
|
1120
|
+
bscale = original_header.get('BSCALE', 1)
|
|
1121
|
+
data = image_data.astype(np.float32) * float(bscale) + float(bzero)
|
|
1122
|
+
|
|
1123
|
+
if bzero != 0 or bscale != 1:
|
|
1124
|
+
image = np.clip(data / 65535.0, 0.0, 1.0)
|
|
1125
|
+
else:
|
|
1126
|
+
dmin = float(data.min())
|
|
1127
|
+
dmax = float(data.max())
|
|
1128
|
+
if dmax > dmin:
|
|
1129
|
+
image = (data - dmin) / (dmax - dmin)
|
|
1130
|
+
else:
|
|
1131
|
+
image = np.zeros_like(data, dtype=np.float32)
|
|
1132
|
+
|
|
1133
|
+
elif image_data.dtype == np.int8:
|
|
1134
|
+
bit_depth = "8-bit signed"
|
|
1135
|
+
print("Identified 8-bit signed FITS image.")
|
|
1136
|
+
# Use BSCALE/BZERO if present, else generic normalize
|
|
1137
|
+
bzero = original_header.get('BZERO', 0)
|
|
1138
|
+
bscale = original_header.get('BSCALE', 1)
|
|
1139
|
+
data = image_data.astype(np.float32) * float(bscale) + float(bzero)
|
|
1140
|
+
dmin = float(data.min())
|
|
1141
|
+
dmax = float(data.max())
|
|
1142
|
+
if dmax > dmin:
|
|
1143
|
+
image = (data - dmin) / (dmax - dmin)
|
|
1144
|
+
else:
|
|
1145
|
+
image = np.zeros_like(data, dtype=np.float32)
|
|
1146
|
+
|
|
1147
|
+
elif image_data.dtype == np.int32:
|
|
1148
|
+
bit_depth = "32-bit signed"
|
|
1149
|
+
print("Identified 32-bit signed FITS image.")
|
|
1150
|
+
bzero = float(original_header.get('BZERO', 0))
|
|
1151
|
+
bscale = float(original_header.get('BSCALE', 1))
|
|
1152
|
+
|
|
1153
|
+
# Rebuild physical values
|
|
1154
|
+
data = image_data.astype(np.float32) * bscale + bzero
|
|
1155
|
+
|
|
1156
|
+
# Normalize to [0,1] for the viewer / pipeline
|
|
1157
|
+
dmin = float(data.min())
|
|
1158
|
+
dmax = float(data.max())
|
|
1159
|
+
if dmax > dmin:
|
|
1160
|
+
image = (data - dmin) / (dmax - dmin)
|
|
1161
|
+
else:
|
|
1162
|
+
image = np.zeros_like(data, dtype=np.float32)
|
|
1163
|
+
|
|
1164
|
+
|
|
1165
|
+
elif image_data.dtype == np.uint32:
|
|
1166
|
+
bit_depth = "32-bit unsigned"
|
|
1167
|
+
print("Identified 32-bit unsigned FITS image.")
|
|
1168
|
+
|
|
1169
|
+
bzero = float(original_header.get('BZERO', 0))
|
|
1170
|
+
bscale = float(original_header.get('BSCALE', 1))
|
|
1171
|
+
|
|
1172
|
+
if bzero == 0.0 and bscale == 1.0:
|
|
1173
|
+
# Literal 0..2^32-1 data → map directly to [0,1]
|
|
1174
|
+
image = image_data.astype(np.float32) / 4294967295.0
|
|
1175
|
+
else:
|
|
1176
|
+
# Non-trivial BSCALE/BZERO: reconstruct physical values, then normalize
|
|
1177
|
+
data = image_data.astype(np.float32) * bscale + bzero
|
|
1178
|
+
dmin = float(data.min())
|
|
1179
|
+
dmax = float(data.max())
|
|
1180
|
+
if dmax > dmin:
|
|
1181
|
+
image = (data - dmin) / (dmax - dmin)
|
|
1182
|
+
else:
|
|
1183
|
+
image = np.zeros_like(data, dtype=np.float32)
|
|
1184
|
+
|
|
1185
|
+
|
|
1186
|
+
elif image_data.dtype == np.float32:
|
|
1187
|
+
bit_depth = "32-bit floating point"
|
|
1188
|
+
print("Identified 32-bit floating point FITS image.")
|
|
1189
|
+
image = np.array(image_data, dtype=np.float32, copy=True, order="C")
|
|
1190
|
+
|
|
1191
|
+
elif image_data.dtype == np.float64:
|
|
1192
|
+
bit_depth = "64-bit floating point"
|
|
1193
|
+
print("Identified 64-bit floating point FITS image.")
|
|
1194
|
+
# Keep dynamic range as-is, just cast down to float32
|
|
1195
|
+
image = image_data.astype(np.float32, copy=True)
|
|
1196
|
+
|
|
1197
|
+
else:
|
|
1198
|
+
raise ValueError(f"Unsupported FITS data type: {image_data.dtype}")
|
|
1199
|
+
|
|
1200
|
+
|
|
1201
|
+
# ---------------------------------------------------------------------
|
|
1202
|
+
# 2) Squeeze out any singleton dimensions (fix weird NAXIS combos)
|
|
1203
|
+
# ---------------------------------------------------------------------
|
|
1204
|
+
image = np.squeeze(image)
|
|
1205
|
+
|
|
1206
|
+
#if image.dtype == np.float32:
|
|
1207
|
+
# max_val = image.max()
|
|
1208
|
+
# if max_val > 1.0:
|
|
1209
|
+
# print(f"Detected float image with max value {max_val:.3f} > 1.0; rescales to [0,1]")
|
|
1210
|
+
# image = image / max_val
|
|
1211
|
+
# ---------------------------------------------------------------------
|
|
1212
|
+
# 3) Interpret final shape to decide if mono or color
|
|
1213
|
+
# ---------------------------------------------------------------------
|
|
1214
|
+
if image.ndim == 2:
|
|
1215
|
+
is_mono = True
|
|
1216
|
+
elif image.ndim == 3:
|
|
1217
|
+
if image.shape[0] == 3 and image.shape[1] > 1 and image.shape[2] > 1:
|
|
1218
|
+
image = np.transpose(image, (1, 2, 0))
|
|
1219
|
+
is_mono = False
|
|
1220
|
+
elif image.shape[-1] == 3:
|
|
1221
|
+
is_mono = False
|
|
1222
|
+
else:
|
|
1223
|
+
raise ValueError(f"Unsupported 3D shape after squeeze: {image.shape}")
|
|
1224
|
+
else:
|
|
1225
|
+
raise ValueError(f"Unsupported FITS dimensions after squeeze: {image.shape}")
|
|
1226
|
+
|
|
1227
|
+
print(f"Loaded FITS image: shape={image.shape}, bit depth={bit_depth}, mono={is_mono}")
|
|
1228
|
+
image = _finalize_loaded_image(image)
|
|
1229
|
+
|
|
1230
|
+
# NEW: build metadata + attach WCS
|
|
1231
|
+
meta = {
|
|
1232
|
+
"file_path": filename,
|
|
1233
|
+
"fits_header": original_header,
|
|
1234
|
+
"bit_depth": bit_depth,
|
|
1235
|
+
"mono": is_mono,
|
|
1236
|
+
}
|
|
1237
|
+
meta = attach_wcs_to_metadata(meta, original_header)
|
|
1238
|
+
|
|
1239
|
+
if return_metadata:
|
|
1240
|
+
return image, original_header, bit_depth, is_mono, meta
|
|
1241
|
+
return image, original_header, bit_depth, is_mono
|
|
1242
|
+
|
|
1243
|
+
|
|
1244
|
+
elif filename.lower().endswith(('.tiff', '.tif')):
|
|
1245
|
+
print(f"Loading TIFF file: {filename}")
|
|
1246
|
+
image_data = tiff.imread(filename)
|
|
1247
|
+
print(f"Loaded TIFF image with dtype: {image_data.dtype}")
|
|
1248
|
+
|
|
1249
|
+
if image_data.dtype == np.uint8:
|
|
1250
|
+
bit_depth = "8-bit"
|
|
1251
|
+
image = image_data.astype(np.float32) / 255.0
|
|
1252
|
+
|
|
1253
|
+
elif image_data.dtype == np.uint16:
|
|
1254
|
+
bit_depth = "16-bit"
|
|
1255
|
+
image = image_data.astype(np.float32) / 65535.0
|
|
1256
|
+
|
|
1257
|
+
elif image_data.dtype == np.int16:
|
|
1258
|
+
bit_depth = "16-bit signed"
|
|
1259
|
+
print("Detected 16-bit signed TIFF image.")
|
|
1260
|
+
data = image_data.astype(np.float32)
|
|
1261
|
+
dmin = float(data.min())
|
|
1262
|
+
dmax = float(data.max())
|
|
1263
|
+
if dmax > dmin:
|
|
1264
|
+
image = (data - dmin) / (dmax - dmin)
|
|
1265
|
+
else:
|
|
1266
|
+
image = np.zeros_like(data, dtype=np.float32)
|
|
1267
|
+
|
|
1268
|
+
elif image_data.dtype == np.uint32:
|
|
1269
|
+
bit_depth = "32-bit unsigned"
|
|
1270
|
+
image = image_data.astype(np.float32) / 4294967295.0
|
|
1271
|
+
|
|
1272
|
+
elif image_data.dtype == np.int32:
|
|
1273
|
+
bit_depth = "32-bit signed"
|
|
1274
|
+
print("Detected 32-bit signed TIFF image.")
|
|
1275
|
+
data = image_data.astype(np.float32)
|
|
1276
|
+
dmin = float(data.min())
|
|
1277
|
+
dmax = float(data.max())
|
|
1278
|
+
if dmax > dmin:
|
|
1279
|
+
image = (data - dmin) / (dmax - dmin)
|
|
1280
|
+
else:
|
|
1281
|
+
image = np.zeros_like(data, dtype=np.float32)
|
|
1282
|
+
|
|
1283
|
+
elif image_data.dtype == np.float32:
|
|
1284
|
+
bit_depth = "32-bit floating point"
|
|
1285
|
+
image = image_data.astype(np.float32)
|
|
1286
|
+
|
|
1287
|
+
elif image_data.dtype == np.float64:
|
|
1288
|
+
bit_depth = "64-bit floating point"
|
|
1289
|
+
image = image_data.astype(np.float32)
|
|
1290
|
+
|
|
1291
|
+
elif np.issubdtype(image_data.dtype, np.integer):
|
|
1292
|
+
# Generic integer fallback (int8, etc.)
|
|
1293
|
+
info = np.iinfo(image_data.dtype)
|
|
1294
|
+
bit_depth = f"{info.bits}-bit signed"
|
|
1295
|
+
print(f"Generic int TIFF; normalizing by [{info.min}, {info.max}]")
|
|
1296
|
+
data = image_data.astype(np.float32)
|
|
1297
|
+
# shift to [0, max-min] then normalize
|
|
1298
|
+
data -= info.min
|
|
1299
|
+
image = data / float(info.max - info.min)
|
|
1300
|
+
|
|
1301
|
+
else:
|
|
1302
|
+
raise ValueError("Unsupported TIFF format!")
|
|
1303
|
+
|
|
1304
|
+
|
|
1305
|
+
#if image.dtype == np.float32:
|
|
1306
|
+
# max_val = image.max()
|
|
1307
|
+
# if max_val > 1.0:
|
|
1308
|
+
# print(f"Detected float image with max value {max_val:.3f} > 1.0; rescales to [0,1]")
|
|
1309
|
+
# image = image / max_val
|
|
1310
|
+
|
|
1311
|
+
# Handle mono or RGB TIFFs
|
|
1312
|
+
if image_data.ndim == 2: # Mono
|
|
1313
|
+
is_mono = True
|
|
1314
|
+
elif image_data.ndim == 3 and image_data.shape[2] == 3: # RGB
|
|
1315
|
+
is_mono = False
|
|
1316
|
+
else:
|
|
1317
|
+
raise ValueError("Unsupported TIFF image dimensions!")
|
|
1318
|
+
|
|
1319
|
+
elif filename.lower().endswith('.xisf'):
|
|
1320
|
+
print(f"Loading XISF file: {filename}")
|
|
1321
|
+
xisf = XISF(filename)
|
|
1322
|
+
|
|
1323
|
+
# Read image data (assuming the first image in the XISF file)
|
|
1324
|
+
image_data = xisf.read_image(0) # Adjust the index if multiple images are present
|
|
1325
|
+
|
|
1326
|
+
# Retrieve metadata
|
|
1327
|
+
image_meta = xisf.get_images_metadata()[0] # Assuming single image
|
|
1328
|
+
file_meta = xisf.get_file_metadata()
|
|
1329
|
+
|
|
1330
|
+
|
|
1331
|
+
# Here we check the maximum pixel value to determine bit depth
|
|
1332
|
+
# --- Detect the bit depth by dtype ---
|
|
1333
|
+
if image_data.dtype == np.uint8:
|
|
1334
|
+
bit_depth = "8-bit"
|
|
1335
|
+
print("Debug: Detected 8-bit dtype. Normalizing by 255.")
|
|
1336
|
+
image = image_data.astype(np.float32) / 255.0
|
|
1337
|
+
|
|
1338
|
+
elif image_data.dtype == np.uint16:
|
|
1339
|
+
bit_depth = "16-bit"
|
|
1340
|
+
print("Debug: Detected 16-bit dtype. Normalizing by 65535.")
|
|
1341
|
+
image = image_data.astype(np.float32) / 65535.0
|
|
1342
|
+
|
|
1343
|
+
elif image_data.dtype == np.uint32:
|
|
1344
|
+
bit_depth = "32-bit unsigned"
|
|
1345
|
+
print("Debug: Detected 32-bit unsigned dtype. Normalizing by 4294967295.")
|
|
1346
|
+
image = image_data.astype(np.float32) / 4294967295.0
|
|
1347
|
+
|
|
1348
|
+
elif image_data.dtype == np.float32 or image_data.dtype == np.float64:
|
|
1349
|
+
bit_depth = "32-bit floating point"
|
|
1350
|
+
print("Debug: Detected float dtype. Casting to float32 (no normalization).")
|
|
1351
|
+
image = image_data.astype(np.float32)
|
|
1352
|
+
|
|
1353
|
+
else:
|
|
1354
|
+
raise ValueError(f"Unsupported XISF data type: {image_data.dtype}")
|
|
1355
|
+
|
|
1356
|
+
# Handle mono or RGB XISF
|
|
1357
|
+
if image_data.ndim == 2:
|
|
1358
|
+
# We know it's mono. Already normalized in `image`.
|
|
1359
|
+
is_mono = True
|
|
1360
|
+
# If you really want to store it in an RGB shape:
|
|
1361
|
+
#image = np.stack([image] * 3, axis=-1)
|
|
1362
|
+
|
|
1363
|
+
elif image_data.ndim == 3 and image_data.shape[2] == 1:
|
|
1364
|
+
# It's mono with shape (H, W, 1)
|
|
1365
|
+
is_mono = True
|
|
1366
|
+
# Squeeze the normalized image, not the original image_data
|
|
1367
|
+
image = np.squeeze(image, axis=2)
|
|
1368
|
+
# If you want an RGB shape, you can do:
|
|
1369
|
+
#image = np.stack([image] * 3, axis=-1)
|
|
1370
|
+
|
|
1371
|
+
elif image_data.ndim == 3 and image_data.shape[2] == 3:
|
|
1372
|
+
is_mono = False
|
|
1373
|
+
# We already stored the normalized float32 data in `image`.
|
|
1374
|
+
# So no change needed if it’s already shape (H, W, 3).
|
|
1375
|
+
|
|
1376
|
+
else:
|
|
1377
|
+
raise ValueError("Unsupported XISF image dimensions!")
|
|
1378
|
+
|
|
1379
|
+
# ─── Build FITS header from PixInsight XISFProperties ─────────────────
|
|
1380
|
+
# ─── Build FITS header from XISFProperties, then fallback to FITSKeywords & Pixel‐Scale ─────────────────
|
|
1381
|
+
props = image_meta.get('XISFProperties', {})
|
|
1382
|
+
def _dump_astrometric_keys(props, image_meta, file_meta):
|
|
1383
|
+
print("🔎 [XISF] XISFProperties AstrometricSolution-related keys:")
|
|
1384
|
+
for k in sorted(props.keys()):
|
|
1385
|
+
if "AstrometricSolution" in k or "SplineWorldTransformation" in k or "SIP" in k:
|
|
1386
|
+
print(" ", k)
|
|
1387
|
+
|
|
1388
|
+
def _dump_fk(meta, tag):
|
|
1389
|
+
fk = meta.get("FITSKeywords", {})
|
|
1390
|
+
if not fk:
|
|
1391
|
+
print(f"🔎 [XISF] No FITSKeywords in {tag}")
|
|
1392
|
+
return
|
|
1393
|
+
sip_keys = [k for k in fk.keys() if k.startswith(("A_", "B_", "AP_", "BP_", "A_ORDER", "B_ORDER"))]
|
|
1394
|
+
print(f"🔎 [XISF] FITSKeywords SIP-ish keys in {tag}: {sorted(sip_keys)}")
|
|
1395
|
+
|
|
1396
|
+
_dump_fk(image_meta, "image_meta")
|
|
1397
|
+
_dump_fk(file_meta, "file_meta")
|
|
1398
|
+
#_dump_astrometric_keys(props, image_meta, file_meta)
|
|
1399
|
+
hdr = fits.Header()
|
|
1400
|
+
_filled = set()
|
|
1401
|
+
|
|
1402
|
+
# 1) PixInsight astrometric solution
|
|
1403
|
+
try:
|
|
1404
|
+
im0, im1 = props['PCL:AstrometricSolution:ReferenceImageCoordinates']['value']
|
|
1405
|
+
w0, w1 = props['PCL:AstrometricSolution:ReferenceCelestialCoordinates']['value']
|
|
1406
|
+
hdr['CRPIX1'], hdr['CRPIX2'] = float(im0), float(im1)
|
|
1407
|
+
hdr['CRVAL1'], hdr['CRVAL2'] = float(w0), float(w1)
|
|
1408
|
+
hdr['CTYPE1'], hdr['CTYPE2'] = 'RA---TAN-SIP','DEC--TAN-SIP'
|
|
1409
|
+
_filled |= {'CRPIX1','CRPIX2','CRVAL1','CRVAL2','CTYPE1','CTYPE2'}
|
|
1410
|
+
print("🔷 Injected CRPIX/CRVAL from XISFProperties")
|
|
1411
|
+
except KeyError:
|
|
1412
|
+
print("⚠️ Missing reference coords in XISFProperties")
|
|
1413
|
+
|
|
1414
|
+
# 2) CD matrix
|
|
1415
|
+
try:
|
|
1416
|
+
lin = np.asarray(props['PCL:AstrometricSolution:LinearTransformationMatrix']['value'], float)
|
|
1417
|
+
hdr['CD1_1'], hdr['CD1_2'] = lin[0,0], lin[0,1]
|
|
1418
|
+
hdr['CD2_1'], hdr['CD2_2'] = lin[1,0], lin[1,1]
|
|
1419
|
+
_filled |= {'CD1_1','CD1_2','CD2_1','CD2_2'}
|
|
1420
|
+
print("🔷 Injected CD matrix from XISFProperties")
|
|
1421
|
+
except KeyError:
|
|
1422
|
+
print("⚠️ Missing CD matrix in XISFProperties")
|
|
1423
|
+
|
|
1424
|
+
# 3) SIP polynomial fitting (CORRECTED for PI ImageToNative grids)
|
|
1425
|
+
def _try_inject_sip_from_fitskeywords(hdr, image_meta, file_meta):
|
|
1426
|
+
"""If PI already wrote SIP in FITSKeywords, pull it in verbatim."""
|
|
1427
|
+
def _lookup_kw(key):
|
|
1428
|
+
for meta in (image_meta, file_meta):
|
|
1429
|
+
fk = meta.get("FITSKeywords", {})
|
|
1430
|
+
if key in fk and fk[key]:
|
|
1431
|
+
return fk[key][0].get("value")
|
|
1432
|
+
return None
|
|
1433
|
+
|
|
1434
|
+
a_order = _lookup_kw("A_ORDER")
|
|
1435
|
+
b_order = _lookup_kw("B_ORDER")
|
|
1436
|
+
if a_order is None or b_order is None:
|
|
1437
|
+
return False
|
|
1438
|
+
|
|
1439
|
+
try:
|
|
1440
|
+
a_order = int(a_order); b_order = int(b_order)
|
|
1441
|
+
except Exception:
|
|
1442
|
+
return False
|
|
1443
|
+
|
|
1444
|
+
hdr["A_ORDER"] = a_order
|
|
1445
|
+
hdr["B_ORDER"] = b_order
|
|
1446
|
+
|
|
1447
|
+
# pull all A_i_j / B_i_j that exist in FITSKeywords
|
|
1448
|
+
for order_key, prefix in (("A_ORDER", "A_"), ("B_ORDER", "B_")):
|
|
1449
|
+
o = int(hdr[order_key])
|
|
1450
|
+
for i in range(o + 1):
|
|
1451
|
+
for j in range(o + 1 - i):
|
|
1452
|
+
if i == 0 and j == 0:
|
|
1453
|
+
continue
|
|
1454
|
+
k = f"{prefix}{i}_{j}"
|
|
1455
|
+
v = _lookup_kw(k)
|
|
1456
|
+
if v is not None:
|
|
1457
|
+
try:
|
|
1458
|
+
hdr[k] = float(v)
|
|
1459
|
+
except Exception:
|
|
1460
|
+
pass
|
|
1461
|
+
|
|
1462
|
+
# if CTYPE isn't SIP already, make it SIP
|
|
1463
|
+
hdr.setdefault("CTYPE1", "RA---TAN-SIP")
|
|
1464
|
+
hdr.setdefault("CTYPE2", "DEC--TAN-SIP")
|
|
1465
|
+
|
|
1466
|
+
print(f"🔷 Injected SIP directly from FITSKeywords (A/B order {a_order})")
|
|
1467
|
+
return True
|
|
1468
|
+
# 3a) First try to import SIP directly if PI already gave it to us
|
|
1469
|
+
if _try_inject_sip_from_fitskeywords(hdr, image_meta, file_meta):
|
|
1470
|
+
_filled |= {"A_ORDER", "B_ORDER"} | {k for k in hdr.keys() if k.startswith(("A_", "B_"))}
|
|
1471
|
+
else:
|
|
1472
|
+
try:
|
|
1473
|
+
def _find_image_to_native_grid(props):
|
|
1474
|
+
"""
|
|
1475
|
+
Return a dict-like pg with keys GridX/GridY/Delta/Rect in the same shape
|
|
1476
|
+
your SIP fitter expects.
|
|
1477
|
+
|
|
1478
|
+
PI can store this either as:
|
|
1479
|
+
A) one nested property:
|
|
1480
|
+
...:PointGridInterpolation:ImageToNative -> dict with GridX/GridY/etc
|
|
1481
|
+
B) separate leaf properties:
|
|
1482
|
+
...:ImageToNative:GridX, :GridY, :Delta, :Rect
|
|
1483
|
+
"""
|
|
1484
|
+
base = "PCL:AstrometricSolution:SplineWorldTransformation:PointGridInterpolation:ImageToNative"
|
|
1485
|
+
|
|
1486
|
+
# Case A: full nested block exists
|
|
1487
|
+
if base in props:
|
|
1488
|
+
return props[base]
|
|
1489
|
+
|
|
1490
|
+
# Case B: leaf keys exist — rebuild a pseudo-block
|
|
1491
|
+
gx_key = base + ":GridX"
|
|
1492
|
+
gy_key = base + ":GridY"
|
|
1493
|
+
if gx_key in props and gy_key in props:
|
|
1494
|
+
pg = {
|
|
1495
|
+
"GridX": props[gx_key],
|
|
1496
|
+
"GridY": props[gy_key],
|
|
1497
|
+
"Delta": props.get(base + ":Delta", {"value": 1.0}),
|
|
1498
|
+
"Rect": props.get(base + ":Rect", {"value": None}),
|
|
1499
|
+
}
|
|
1500
|
+
return pg
|
|
1501
|
+
|
|
1502
|
+
return None
|
|
1503
|
+
|
|
1504
|
+
pg = _find_image_to_native_grid(props)
|
|
1505
|
+
if pg is None:
|
|
1506
|
+
raise KeyError("No ImageToNative grid found")
|
|
1507
|
+
gx = np.asarray(pg['GridX']['value'], dtype=float)
|
|
1508
|
+
gy = np.asarray(pg['GridY']['value'], dtype=float)
|
|
1509
|
+
delta = float(pg.get('Delta', {}).get('value', 1.0))
|
|
1510
|
+
rect = np.asarray(pg.get('Rect', {}).get('value', [0,0,gx.shape[1]*delta, gx.shape[0]*delta]), dtype=float)
|
|
1511
|
+
x0, y0 = rect[0], rect[1]
|
|
1512
|
+
|
|
1513
|
+
# grid gives native-plane coords (deg) at sampled pixels
|
|
1514
|
+
# build pixel coord for each grid sample
|
|
1515
|
+
rows, cols = gx.shape
|
|
1516
|
+
xs = x0 + np.arange(cols, dtype=float) * delta
|
|
1517
|
+
ys = y0 + np.arange(rows, dtype=float) * delta
|
|
1518
|
+
Xs, Ys = np.meshgrid(xs, ys)
|
|
1519
|
+
|
|
1520
|
+
# u,v relative to CRPIX for SIP basis
|
|
1521
|
+
crpix1, crpix2 = float(hdr['CRPIX1']), float(hdr['CRPIX2'])
|
|
1522
|
+
u = (Xs - crpix1).ravel()
|
|
1523
|
+
v = (Ys - crpix2).ravel()
|
|
1524
|
+
|
|
1525
|
+
# linear native-plane coords from CD
|
|
1526
|
+
CD = np.array([[hdr['CD1_1'], hdr['CD1_2']],
|
|
1527
|
+
[hdr['CD2_1'], hdr['CD2_2']]], dtype=float)
|
|
1528
|
+
duv = np.vstack([u, v]) # 2×N
|
|
1529
|
+
native_lin = CD @ duv # deg residuals predicted by linear model
|
|
1530
|
+
native_true = np.vstack([gx.ravel(), gy.ravel()]) # deg native coords from PI grids
|
|
1531
|
+
|
|
1532
|
+
# residual in native plane (deg)
|
|
1533
|
+
d_native = native_true - native_lin # 2×N in degrees
|
|
1534
|
+
|
|
1535
|
+
# convert residual degrees back to pixel residuals (dp) using inv(CD)
|
|
1536
|
+
try:
|
|
1537
|
+
invCD = np.linalg.inv(CD)
|
|
1538
|
+
except np.linalg.LinAlgError:
|
|
1539
|
+
invCD = np.linalg.pinv(CD)
|
|
1540
|
+
d_pix = invCD @ d_native # 2×N in pixels
|
|
1541
|
+
dx_pix = d_pix[0]
|
|
1542
|
+
dy_pix = d_pix[1]
|
|
1543
|
+
|
|
1544
|
+
# robust mask to avoid NaNs/infs
|
|
1545
|
+
m = np.isfinite(u) & np.isfinite(v) & np.isfinite(dx_pix) & np.isfinite(dy_pix)
|
|
1546
|
+
u = u[m]; v = v[m]; dx_pix = dx_pix[m]; dy_pix = dy_pix[m]
|
|
1547
|
+
|
|
1548
|
+
def fit_sip_pixels(u, v, dx, dy, order):
|
|
1549
|
+
terms = [(i,j) for i in range(order+1) for j in range(order+1-i) if (i,j)!=(0,0)]
|
|
1550
|
+
M = np.vstack([(u**i)*(v**j) for (i,j) in terms]).T
|
|
1551
|
+
a, *_ = np.linalg.lstsq(M, dx, rcond=None)
|
|
1552
|
+
b, *_ = np.linalg.lstsq(M, dy, rcond=None)
|
|
1553
|
+
rms = np.hypot(dx - M.dot(a), dy - M.dot(b)).std()
|
|
1554
|
+
return a, b, terms, rms
|
|
1555
|
+
|
|
1556
|
+
# cap order hard to avoid overfit; PI splines can be complex
|
|
1557
|
+
best = {'order':None, 'rms':np.inf}
|
|
1558
|
+
|
|
1559
|
+
for order in (2,3,4): # <=4 is plenty for real optics
|
|
1560
|
+
a, b, terms, rms = fit_sip_pixels(u, v, dx_pix, dy_pix, order)
|
|
1561
|
+
if rms < best['rms']:
|
|
1562
|
+
best.update(order=order, a=a, b=b, terms=terms, rms=rms)
|
|
1563
|
+
|
|
1564
|
+
o = best['order']
|
|
1565
|
+
hdr['A_ORDER'] = o; hdr['B_ORDER'] = o
|
|
1566
|
+
_filled |= {'A_ORDER','B_ORDER'}
|
|
1567
|
+
|
|
1568
|
+
for (i,j), coef in zip(best['terms'], best['a']):
|
|
1569
|
+
hdr[f'A_{i}_{j}'] = float(coef); _filled.add(f'A_{i}_{j}')
|
|
1570
|
+
for (i,j), coef in zip(best['terms'], best['b']):
|
|
1571
|
+
hdr[f'B_{i}_{j}'] = float(coef); _filled.add(f'B_{i}_{j}')
|
|
1572
|
+
|
|
1573
|
+
print(f"🔷 Injected SIP order {o} (from PI native grids), rms={best['rms']:.4g}px")
|
|
1574
|
+
|
|
1575
|
+
except KeyError:
|
|
1576
|
+
print("⚠️ No PI ImageToNative grid; skipping SIP")
|
|
1577
|
+
except Exception as e:
|
|
1578
|
+
print(f"⚠️ SIP fit failed; skipping SIP. Reason: {e}")
|
|
1579
|
+
|
|
1580
|
+
|
|
1581
|
+
|
|
1582
|
+
# Helper: look in FITSKeywords dicts
|
|
1583
|
+
def _lookup_kw(key):
|
|
1584
|
+
for meta in (image_meta, file_meta):
|
|
1585
|
+
fk = meta.get('FITSKeywords',{})
|
|
1586
|
+
if key in fk and fk[key]:
|
|
1587
|
+
return fk[key][0]['value']
|
|
1588
|
+
return None
|
|
1589
|
+
|
|
1590
|
+
# 4) Fallback WCS/CD from FITSKeywords
|
|
1591
|
+
for key in ('CRPIX1','CRPIX2','CRVAL1','CRVAL2','CTYPE1','CTYPE2',
|
|
1592
|
+
'CD1_1','CD1_2','CD2_1','CD2_2'):
|
|
1593
|
+
if key not in hdr:
|
|
1594
|
+
v = _lookup_kw(key)
|
|
1595
|
+
if v is not None:
|
|
1596
|
+
hdr[key] = v
|
|
1597
|
+
_filled.add(key)
|
|
1598
|
+
print(f"🔷 Injected {key} from FITSKeywords")
|
|
1599
|
+
|
|
1600
|
+
# 5) Generic RA/DEC fallback
|
|
1601
|
+
if 'CRVAL1' not in hdr or 'CRVAL2' not in hdr:
|
|
1602
|
+
for ra_kw, dec_kw in (('RA','DEC'),('OBJCTRA','OBJCTDEC')):
|
|
1603
|
+
ra = _lookup_kw(ra_kw); dec = _lookup_kw(dec_kw)
|
|
1604
|
+
if ra and dec:
|
|
1605
|
+
try:
|
|
1606
|
+
ra_deg = float(ra); dec_deg = float(dec)
|
|
1607
|
+
except ValueError:
|
|
1608
|
+
from astropy.coordinates import Angle
|
|
1609
|
+
ra_deg = Angle(str(ra), unit='hourangle').degree
|
|
1610
|
+
dec_deg = Angle(str(dec), unit='deg').degree
|
|
1611
|
+
hdr['CRVAL1'], hdr['CRVAL2'] = ra_deg, dec_deg
|
|
1612
|
+
hdr.setdefault('CTYPE1','RA---TAN'); hdr.setdefault('CTYPE2','DEC--TAN')
|
|
1613
|
+
print(f"🔷 Fallback CRVAL from {ra_kw}/{dec_kw}")
|
|
1614
|
+
break
|
|
1615
|
+
|
|
1616
|
+
# 6) Pixel‐scale fallback → inject CDELT if no CD or CDELT
|
|
1617
|
+
if not any(k in hdr for k in ('CD1_1','CDELT1')):
|
|
1618
|
+
pix_arcsec = None
|
|
1619
|
+
for kw in ('PIXSCALE','SCALE'):
|
|
1620
|
+
val = _lookup_kw(kw)
|
|
1621
|
+
if val:
|
|
1622
|
+
pix_arcsec = float(val); break
|
|
1623
|
+
if pix_arcsec is None:
|
|
1624
|
+
xpsz = _lookup_kw('XPIXSZ'); foc = _lookup_kw('FOCALLEN')
|
|
1625
|
+
if xpsz and foc:
|
|
1626
|
+
pix_arcsec = float(xpsz)*1e-3/float(foc)*206265
|
|
1627
|
+
if pix_arcsec:
|
|
1628
|
+
degpix = pix_arcsec / 3600.0
|
|
1629
|
+
hdr['CDELT1'], hdr['CDELT2'] = -degpix, degpix
|
|
1630
|
+
print(f"🔷 Injected pixel scale {pix_arcsec:.3f}\"/px → CDELT={degpix:.6f}°")
|
|
1631
|
+
|
|
1632
|
+
# 7) Copy any remaining simple FITSKeywords
|
|
1633
|
+
for kw, vals in file_meta.get('FITSKeywords',{}).items():
|
|
1634
|
+
if kw in hdr: continue
|
|
1635
|
+
v = vals[0].get('value')
|
|
1636
|
+
if isinstance(v, (int,float,str)):
|
|
1637
|
+
hdr[kw] = v
|
|
1638
|
+
|
|
1639
|
+
# 8) Binning
|
|
1640
|
+
bx = int(_lookup_kw('XBINNING') or 1)
|
|
1641
|
+
by = int(_lookup_kw('YBINNING') or bx)
|
|
1642
|
+
if bx!=by: print(f"⚠️ Unequal binning {bx}×{by}, averaging")
|
|
1643
|
+
hdr['XBINNING'], hdr['YBINNING'] = bx, by
|
|
1644
|
+
|
|
1645
|
+
original_header = hdr
|
|
1646
|
+
print(f"Loaded XISF header with keys: {_filled}")
|
|
1647
|
+
image = _finalize_loaded_image(image)
|
|
1648
|
+
|
|
1649
|
+
# NEW: build metadata + attach WCS
|
|
1650
|
+
meta = {
|
|
1651
|
+
"file_path": filename,
|
|
1652
|
+
"fits_header": original_header, # your synthesized FITS header
|
|
1653
|
+
"bit_depth": bit_depth,
|
|
1654
|
+
"mono": is_mono,
|
|
1655
|
+
"xisf_meta": image_meta, # optional, handy for debugging later
|
|
1656
|
+
}
|
|
1657
|
+
meta = attach_wcs_to_metadata(meta, original_header)
|
|
1658
|
+
|
|
1659
|
+
if return_metadata:
|
|
1660
|
+
return image, original_header, bit_depth, is_mono, meta
|
|
1661
|
+
return image, original_header, bit_depth, is_mono
|
|
1662
|
+
|
|
1663
|
+
elif filename.lower().endswith(('.cr2', '.cr3', '.nef', '.arw', '.dng', '.raf', '.orf', '.rw2', '.pef')):
|
|
1664
|
+
print(f"Loading RAW file: {filename}")
|
|
1665
|
+
|
|
1666
|
+
try:
|
|
1667
|
+
image, original_header, bit_depth, is_mono = _try_load_raw_with_rawpy(
|
|
1668
|
+
filename,
|
|
1669
|
+
allow_thumb_preview=True, # keep your current behavior
|
|
1670
|
+
debug_thumb=True
|
|
1671
|
+
)
|
|
1672
|
+
|
|
1673
|
+
if original_header is None:
|
|
1674
|
+
original_header = fits.Header()
|
|
1675
|
+
|
|
1676
|
+
# 🔹 Fold in EXIF, but only for keys missing from raw metadata
|
|
1677
|
+
original_header = _enrich_header_from_exif(original_header, filename)
|
|
1678
|
+
|
|
1679
|
+
# If preview path returned a minimal header, that's fine—upstream UI will message it
|
|
1680
|
+
if "preview" in str(bit_depth).lower():
|
|
1681
|
+
print("RAW decode failed; using embedded JPEG preview (non-linear, 8-bit).")
|
|
1682
|
+
|
|
1683
|
+
image = _finalize_loaded_image(image)
|
|
1684
|
+
return image, original_header, bit_depth, is_mono
|
|
1685
|
+
|
|
1686
|
+
except Exception as e_raw:
|
|
1687
|
+
print(f"rawpy failed: {e_raw}")
|
|
1688
|
+
raise
|
|
1689
|
+
|
|
1690
|
+
|
|
1691
|
+
|
|
1692
|
+
elif filename.lower().endswith('.png'):
|
|
1693
|
+
print(f"Loading PNG file: {filename}")
|
|
1694
|
+
img = Image.open(filename)
|
|
1695
|
+
|
|
1696
|
+
# Convert unsupported modes to RGB
|
|
1697
|
+
if img.mode not in ('L', 'RGB'):
|
|
1698
|
+
print(f"Unsupported PNG mode: {img.mode}, converting to RGB")
|
|
1699
|
+
img = img.convert("RGB")
|
|
1700
|
+
|
|
1701
|
+
# Convert image to numpy array and normalize pixel values to [0, 1]
|
|
1702
|
+
image = np.array(img, dtype=np.float32) / 255.0
|
|
1703
|
+
bit_depth = "8-bit"
|
|
1704
|
+
|
|
1705
|
+
# Determine if the image is grayscale or RGB
|
|
1706
|
+
if len(image.shape) == 2: # Grayscale image
|
|
1707
|
+
is_mono = True
|
|
1708
|
+
elif len(image.shape) == 3 and image.shape[2] == 3: # RGB image
|
|
1709
|
+
is_mono = False
|
|
1710
|
+
else:
|
|
1711
|
+
raise ValueError(f"Unsupported PNG dimensions: {image.shape}")
|
|
1712
|
+
|
|
1713
|
+
print(f"Loaded PNG image: shape={image.shape}, bit depth={bit_depth}, mono={is_mono}")
|
|
1714
|
+
|
|
1715
|
+
elif filename.lower().endswith(('.jpg', '.jpeg')):
|
|
1716
|
+
print(f"Loading JPG file: {filename}")
|
|
1717
|
+
img = Image.open(filename)
|
|
1718
|
+
if img.mode == 'L': # Grayscale
|
|
1719
|
+
is_mono = True
|
|
1720
|
+
image = np.array(img, dtype=np.float32) / 255.0
|
|
1721
|
+
bit_depth = "8-bit"
|
|
1722
|
+
elif img.mode == 'RGB': # RGB
|
|
1723
|
+
is_mono = False
|
|
1724
|
+
image = np.array(img, dtype=np.float32) / 255.0
|
|
1725
|
+
bit_depth = "8-bit"
|
|
1726
|
+
else:
|
|
1727
|
+
raise ValueError("Unsupported JPG format!")
|
|
1728
|
+
|
|
1729
|
+
else:
|
|
1730
|
+
raise ValueError("Unsupported file format!")
|
|
1731
|
+
|
|
1732
|
+
print(f"Loaded image: shape={image.shape}, bit depth={bit_depth}, mono={is_mono}")
|
|
1733
|
+
image = _finalize_loaded_image(image)
|
|
1734
|
+
return image, original_header, bit_depth, is_mono
|
|
1735
|
+
|
|
1736
|
+
except Exception as e:
|
|
1737
|
+
error_message = str(e)
|
|
1738
|
+
if "buffer is too small for requested array" in error_message.lower():
|
|
1739
|
+
if attempt < max_retries:
|
|
1740
|
+
attempt += 1
|
|
1741
|
+
print(f"Error reading image {filename}: {e}")
|
|
1742
|
+
print(f"Retrying in {wait_seconds} seconds... (Attempt {attempt}/{max_retries})")
|
|
1743
|
+
time.sleep(wait_seconds)
|
|
1744
|
+
continue # Retry loading the image
|
|
1745
|
+
else:
|
|
1746
|
+
print(f"Error reading image {filename} after {max_retries} retries: {e}")
|
|
1747
|
+
else:
|
|
1748
|
+
print(f"Error reading image {filename}: {e}")
|
|
1749
|
+
return None, None, None, None
|
|
1750
|
+
|
|
1751
|
+
def get_valid_header(file_path):
|
|
1752
|
+
"""
|
|
1753
|
+
Opens the FITS file (handling compressed files as needed), finds the first HDU
|
|
1754
|
+
with image data, and then searches through all HDUs for additional keywords (e.g. BAYERPAT).
|
|
1755
|
+
Returns a composite header (a copy of the image HDU header updated with extra keywords)
|
|
1756
|
+
and the extension index of the image data.
|
|
1757
|
+
"""
|
|
1758
|
+
# Open file appropriately for compressed files
|
|
1759
|
+
if file_path.lower().endswith(('.fits.gz', '.fit.gz')):
|
|
1760
|
+
|
|
1761
|
+
with gzip.open(file_path, 'rb') as f:
|
|
1762
|
+
file_content = f.read()
|
|
1763
|
+
hdul = fits.open(BytesIO(file_content))
|
|
1764
|
+
else:
|
|
1765
|
+
|
|
1766
|
+
hdul = fits.open(file_path)
|
|
1767
|
+
|
|
1768
|
+
with hdul as hdul:
|
|
1769
|
+
image_hdu = None
|
|
1770
|
+
image_index = None
|
|
1771
|
+
# First, find the HDU that contains image data
|
|
1772
|
+
for i, hdu in enumerate(hdul):
|
|
1773
|
+
|
|
1774
|
+
if hdu.data is not None:
|
|
1775
|
+
image_hdu = hdu
|
|
1776
|
+
image_index = i
|
|
1777
|
+
|
|
1778
|
+
break
|
|
1779
|
+
if image_hdu is None:
|
|
1780
|
+
raise ValueError("No image data found in FITS file.")
|
|
1781
|
+
|
|
1782
|
+
# Start with a copy of the image HDU header
|
|
1783
|
+
composite_header = image_hdu.header.copy()
|
|
1784
|
+
# Drop any cards that will raise VerifyError later (e.g. broken TELESCOP)
|
|
1785
|
+
composite_header = _drop_invalid_cards(composite_header)
|
|
1786
|
+
|
|
1787
|
+
|
|
1788
|
+
# Now search all HDUs for extra keywords (e.g. BAYERPAT)
|
|
1789
|
+
for i, hdu in enumerate(hdul):
|
|
1790
|
+
if 'BAYERPAT' in hdu.header:
|
|
1791
|
+
composite_header['BAYERPAT'] = hdu.header['BAYERPAT']
|
|
1792
|
+
|
|
1793
|
+
break
|
|
1794
|
+
|
|
1795
|
+
return composite_header, image_index
|
|
1796
|
+
|
|
1797
|
+
def get_bayer_header(file_path):
|
|
1798
|
+
"""
|
|
1799
|
+
Iterates through all HDUs in the FITS file (handling compressed files if needed)
|
|
1800
|
+
to find a header that contains the 'BAYERPAT' keyword.
|
|
1801
|
+
Returns the header if found, otherwise None.
|
|
1802
|
+
"""
|
|
1803
|
+
|
|
1804
|
+
|
|
1805
|
+
try:
|
|
1806
|
+
# Check for compressed files first.
|
|
1807
|
+
if file_path.lower().endswith(('.fits.gz', '.fit.gz')):
|
|
1808
|
+
with gzip.open(file_path, 'rb') as f:
|
|
1809
|
+
file_content = f.read()
|
|
1810
|
+
hdul = fits.open(BytesIO(file_content))
|
|
1811
|
+
else:
|
|
1812
|
+
hdul = fits.open(file_path)
|
|
1813
|
+
with hdul as hdul:
|
|
1814
|
+
for hdu in hdul:
|
|
1815
|
+
if 'BAYERPAT' in hdu.header:
|
|
1816
|
+
return hdu.header
|
|
1817
|
+
except Exception as e:
|
|
1818
|
+
print(f"Error in get_bayer_header: {e}")
|
|
1819
|
+
return None
|
|
1820
|
+
|
|
1821
|
+
|
|
1822
|
+
_BIT_DEPTH_STRS = {
|
|
1823
|
+
"8-bit", "16-bit", "32-bit unsigned", "32-bit floating point"
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
def _normalize_format(fmt: str) -> str:
|
|
1827
|
+
"""Normalize an input format/extension (with or without leading dot)."""
|
|
1828
|
+
f = (fmt or "").lower().lstrip(".")
|
|
1829
|
+
if f == "jpeg": f = "jpg"
|
|
1830
|
+
if f == "tiff": f = "tif"
|
|
1831
|
+
return f
|
|
1832
|
+
|
|
1833
|
+
def _is_header_obj(h) -> bool:
|
|
1834
|
+
"""True if h looks like a FITS header-ish object."""
|
|
1835
|
+
return isinstance(h, (fits.Header, dict))
|
|
1836
|
+
|
|
1837
|
+
def _looks_like_xisf_header(hdr) -> bool:
|
|
1838
|
+
"""Detects XISF-origin metadata safely without assuming .keys() exists."""
|
|
1839
|
+
try:
|
|
1840
|
+
if isinstance(hdr, fits.Header):
|
|
1841
|
+
# fits.Header supports .keys() and iteration
|
|
1842
|
+
for k in hdr.keys():
|
|
1843
|
+
if isinstance(k, str) and k.startswith("XISF:"):
|
|
1844
|
+
return True
|
|
1845
|
+
elif isinstance(hdr, dict):
|
|
1846
|
+
for k in hdr.keys():
|
|
1847
|
+
if isinstance(k, str) and k.startswith("XISF:"):
|
|
1848
|
+
return True
|
|
1849
|
+
except Exception:
|
|
1850
|
+
pass
|
|
1851
|
+
return False
|
|
1852
|
+
|
|
1853
|
+
def _has_xisf_props(meta) -> bool:
|
|
1854
|
+
"""True if meta appears to contain XISFProperties (dict or list-of-dicts)."""
|
|
1855
|
+
try:
|
|
1856
|
+
if isinstance(meta, dict):
|
|
1857
|
+
return "XISFProperties" in meta
|
|
1858
|
+
if isinstance(meta, list) and meta and isinstance(meta[0], dict):
|
|
1859
|
+
return "XISFProperties" in meta[0]
|
|
1860
|
+
except Exception:
|
|
1861
|
+
pass
|
|
1862
|
+
return False
|
|
1863
|
+
|
|
1864
|
+
import logging
|
|
1865
|
+
|
|
1866
|
+
log = logging.getLogger(__name__)
|
|
1867
|
+
|
|
1868
|
+
def save_image(img_array,
|
|
1869
|
+
filename,
|
|
1870
|
+
original_format,
|
|
1871
|
+
bit_depth=None,
|
|
1872
|
+
original_header=None,
|
|
1873
|
+
is_mono=False,
|
|
1874
|
+
image_meta=None,
|
|
1875
|
+
file_meta=None,
|
|
1876
|
+
wcs_header=None): # 🔥 NEW
|
|
1877
|
+
"""
|
|
1878
|
+
Save an image array to a file in the specified format and bit depth.
|
|
1879
|
+
- Robust to mis-ordered positional args (header/bit_depth swap).
|
|
1880
|
+
- Never calls .keys() on a non-mapping.
|
|
1881
|
+
- FITS always written as float32; header is sanitized or synthesized.
|
|
1882
|
+
"""
|
|
1883
|
+
# 🔊 Debug what we got
|
|
1884
|
+
if isinstance(original_header, fits.Header):
|
|
1885
|
+
log.debug(
|
|
1886
|
+
"[legacy_save_image] original_header: fits.Header with %d cards, first few:",
|
|
1887
|
+
len(original_header)
|
|
1888
|
+
)
|
|
1889
|
+
for i, card in enumerate(original_header.cards):
|
|
1890
|
+
if i >= 20:
|
|
1891
|
+
log.debug("[legacy_save_image] ... (truncated)")
|
|
1892
|
+
break
|
|
1893
|
+
log.debug("[legacy_save_image] %-10s = %r", card.keyword, card.value)
|
|
1894
|
+
else:
|
|
1895
|
+
log.debug(
|
|
1896
|
+
"[legacy_save_image] original_header is %r, wcs_header is %r",
|
|
1897
|
+
type(original_header), type(wcs_header),
|
|
1898
|
+
)
|
|
1899
|
+
|
|
1900
|
+
# --- Fix for accidental positional arg swap: (header <-> bit_depth) -----
|
|
1901
|
+
if isinstance(original_header, str) and original_header in _BIT_DEPTH_STRS and _is_header_obj(bit_depth):
|
|
1902
|
+
original_header, bit_depth = bit_depth, original_header
|
|
1903
|
+
|
|
1904
|
+
# Normalize format and extension
|
|
1905
|
+
fmt = _normalize_format(original_format)
|
|
1906
|
+
base, _ = os.path.splitext(filename)
|
|
1907
|
+
out_ext = "jpg" if fmt == "jpg" else ("tif" if fmt == "tif" else fmt)
|
|
1908
|
+
if not filename.lower().endswith(f".{out_ext}"):
|
|
1909
|
+
filename = f"{base}.{out_ext}"
|
|
1910
|
+
|
|
1911
|
+
# Ensure correct byte order for numpy data
|
|
1912
|
+
img_array = ensure_native_byte_order(img_array)
|
|
1913
|
+
|
|
1914
|
+
# Detect XISF origin (safely)
|
|
1915
|
+
is_xisf = _looks_like_xisf_header(original_header) or _has_xisf_props(image_meta)
|
|
1916
|
+
|
|
1917
|
+
try:
|
|
1918
|
+
# ---------------------------------------------------------------------
|
|
1919
|
+
# PNG/JPG — always write 8-bit preview-style data
|
|
1920
|
+
# ---------------------------------------------------------------------
|
|
1921
|
+
if fmt == "png":
|
|
1922
|
+
img = Image.fromarray((np.clip(img_array, 0, 1) * 255).astype(np.uint8))
|
|
1923
|
+
img.save(filename)
|
|
1924
|
+
print(f"Saved 8-bit PNG image to: {filename}")
|
|
1925
|
+
return
|
|
1926
|
+
|
|
1927
|
+
if fmt == "jpg":
|
|
1928
|
+
img = Image.fromarray((np.clip(img_array, 0, 1) * 255).astype(np.uint8))
|
|
1929
|
+
# You can pass quality=95, subsampling=0 if you want
|
|
1930
|
+
img.save(filename)
|
|
1931
|
+
print(f"Saved 8-bit JPG image to: {filename}")
|
|
1932
|
+
return
|
|
1933
|
+
|
|
1934
|
+
# ---------------------------------------------------------------------
|
|
1935
|
+
# TIFF — honor bit depth (fallback to 32-bit floating point)
|
|
1936
|
+
# ---------------------------------------------------------------------
|
|
1937
|
+
if fmt in ("tif",):
|
|
1938
|
+
bd = bit_depth or "32-bit floating point"
|
|
1939
|
+
if bd == "8-bit":
|
|
1940
|
+
tiff.imwrite(filename, (np.clip(img_array, 0, 1) * 255).astype(np.uint8))
|
|
1941
|
+
elif bd == "16-bit":
|
|
1942
|
+
tiff.imwrite(filename, (np.clip(img_array, 0, 1) * 65535).astype(np.uint16))
|
|
1943
|
+
elif bd == "32-bit unsigned":
|
|
1944
|
+
tiff.imwrite(filename, (np.clip(img_array, 0, 1) * 4294967295).astype(np.uint32))
|
|
1945
|
+
elif bd == "32-bit floating point":
|
|
1946
|
+
tiff.imwrite(filename, img_array.astype(np.float32))
|
|
1947
|
+
else:
|
|
1948
|
+
raise ValueError(f"Unsupported bit depth for TIFF: {bd}")
|
|
1949
|
+
print(f"Saved {bd} TIFF image to: {filename}")
|
|
1950
|
+
return
|
|
1951
|
+
|
|
1952
|
+
# ---------------------------------------------------------------------
|
|
1953
|
+
# FITS — honor bit_depth like TIFF (8/16/32U/32f)
|
|
1954
|
+
# ---------------------------------------------------------------------
|
|
1955
|
+
if fmt in ("fit", "fits"):
|
|
1956
|
+
# Helper to build minimal valid header
|
|
1957
|
+
def _minimal_fits_header(h: int, w: int, is_rgb: bool) -> fits.Header:
|
|
1958
|
+
hdr = fits.Header()
|
|
1959
|
+
hdr["SIMPLE"] = True
|
|
1960
|
+
hdr["BITPIX"] = -32 # will be overridden below if needed
|
|
1961
|
+
hdr["NAXIS"] = 3 if is_rgb else 2
|
|
1962
|
+
hdr["NAXIS1"] = w
|
|
1963
|
+
hdr["NAXIS2"] = h
|
|
1964
|
+
if is_rgb:
|
|
1965
|
+
hdr["NAXIS3"] = 3
|
|
1966
|
+
hdr["BSCALE"] = 1.0
|
|
1967
|
+
hdr["BZERO"] = 0.0
|
|
1968
|
+
hdr["CREATOR"] = "Seti Astro Suite Pro"
|
|
1969
|
+
hdr.add_history("Written by Seti Astro Suite Pro")
|
|
1970
|
+
return hdr
|
|
1971
|
+
|
|
1972
|
+
h, w = img_array.shape[:2]
|
|
1973
|
+
is_rgb = (img_array.ndim == 3 and img_array.shape[2] == 3)
|
|
1974
|
+
|
|
1975
|
+
# Build base header (same as before)
|
|
1976
|
+
if is_xisf:
|
|
1977
|
+
fits_header = fits.Header()
|
|
1978
|
+
props = None
|
|
1979
|
+
if isinstance(image_meta, dict):
|
|
1980
|
+
props = image_meta.get("XISFProperties")
|
|
1981
|
+
elif isinstance(image_meta, list) and image_meta and isinstance(image_meta[0], dict):
|
|
1982
|
+
props = image_meta[0].get("XISFProperties")
|
|
1983
|
+
if isinstance(props, dict):
|
|
1984
|
+
try:
|
|
1985
|
+
if "PCL:AstrometricSolution:ReferenceCoordinates" in props:
|
|
1986
|
+
ra, dec = props["PCL:AstrometricSolution:ReferenceCoordinates"]["value"]
|
|
1987
|
+
fits_header["CRVAL1"] = ra
|
|
1988
|
+
fits_header["CRVAL2"] = dec
|
|
1989
|
+
if "PCL:AstrometricSolution:ReferenceLocation" in props:
|
|
1990
|
+
cx, cy = props["PCL:AstrometricSolution:ReferenceLocation"]["value"]
|
|
1991
|
+
fits_header["CRPIX1"] = cx
|
|
1992
|
+
fits_header["CRPIX2"] = cy
|
|
1993
|
+
if "PCL:AstrometricSolution:PixelSize" in props:
|
|
1994
|
+
px = props["PCL:AstrometricSolution:PixelSize"]["value"]
|
|
1995
|
+
fits_header["CDELT1"] = -px / 3600.0
|
|
1996
|
+
fits_header["CDELT2"] = px / 3600.0
|
|
1997
|
+
if "PCL:AstrometricSolution:LinearTransformationMatrix" in props:
|
|
1998
|
+
m = props["PCL:AstrometricSolution:LinearTransformationMatrix"]["value"]
|
|
1999
|
+
fits_header["CD1_1"] = m[0][0]; fits_header["CD1_2"] = m[0][1]
|
|
2000
|
+
fits_header["CD2_1"] = m[1][0]; fits_header["CD2_2"] = m[1][1]
|
|
2001
|
+
except Exception:
|
|
2002
|
+
pass
|
|
2003
|
+
fits_header.setdefault("CTYPE1", "RA---TAN")
|
|
2004
|
+
fits_header.setdefault("CTYPE2", "DEC--TAN")
|
|
2005
|
+
|
|
2006
|
+
elif _is_header_obj(original_header):
|
|
2007
|
+
# Clean up invalid cards
|
|
2008
|
+
if isinstance(original_header, fits.Header):
|
|
2009
|
+
safe_header = _drop_invalid_cards(original_header)
|
|
2010
|
+
src_items = safe_header.items()
|
|
2011
|
+
else:
|
|
2012
|
+
safe_header = original_header
|
|
2013
|
+
src_items = safe_header.items()
|
|
2014
|
+
|
|
2015
|
+
fits_header = fits.Header()
|
|
2016
|
+
for key, value in src_items:
|
|
2017
|
+
if isinstance(key, str) and key.startswith("XISF:"):
|
|
2018
|
+
continue
|
|
2019
|
+
if key in ("RANGE_LOW", "RANGE_HIGH"):
|
|
2020
|
+
continue
|
|
2021
|
+
if isinstance(value, dict) and "value" in value:
|
|
2022
|
+
value = value["value"]
|
|
2023
|
+
try:
|
|
2024
|
+
fits_header[key] = value
|
|
2025
|
+
except Exception:
|
|
2026
|
+
pass
|
|
2027
|
+
else:
|
|
2028
|
+
fits_header = _minimal_fits_header(h, w, is_rgb)
|
|
2029
|
+
|
|
2030
|
+
# 🔥 Merge explicit WCS header from metadata, if present
|
|
2031
|
+
from astropy.io import fits as _fits_mod
|
|
2032
|
+
if isinstance(wcs_header, _fits_mod.Header):
|
|
2033
|
+
for key, value in wcs_header.items():
|
|
2034
|
+
if key in ("SIMPLE", "BITPIX", "NAXIS", "NAXIS1", "NAXIS2",
|
|
2035
|
+
"NAXIS3", "BSCALE", "BZERO", "EXTEND", "END"):
|
|
2036
|
+
continue
|
|
2037
|
+
try:
|
|
2038
|
+
fits_header[key] = value
|
|
2039
|
+
except Exception:
|
|
2040
|
+
pass
|
|
2041
|
+
|
|
2042
|
+
# --- Shape + base data (float), then quantize based on bit_depth ---
|
|
2043
|
+
if is_rgb:
|
|
2044
|
+
base_data = np.transpose(img_array, (2, 0, 1)) # (3, H, W)
|
|
2045
|
+
fits_header["NAXIS"] = 3
|
|
2046
|
+
fits_header["NAXIS1"] = w
|
|
2047
|
+
fits_header["NAXIS2"] = h
|
|
2048
|
+
fits_header["NAXIS3"] = 3
|
|
2049
|
+
else:
|
|
2050
|
+
if img_array.ndim == 3 and img_array.shape[2] == 1:
|
|
2051
|
+
base_data = img_array[:, :, 0]
|
|
2052
|
+
else:
|
|
2053
|
+
base_data = img_array
|
|
2054
|
+
fits_header["NAXIS"] = 2
|
|
2055
|
+
fits_header["NAXIS1"] = w
|
|
2056
|
+
fits_header["NAXIS2"] = h
|
|
2057
|
+
fits_header.pop("NAXIS3", None)
|
|
2058
|
+
|
|
2059
|
+
bd = (bit_depth or "32-bit floating point").lower()
|
|
2060
|
+
|
|
2061
|
+
if bd == "8-bit":
|
|
2062
|
+
data_to_write = (np.clip(base_data, 0, 1) * 255).astype(np.uint8)
|
|
2063
|
+
fits_header["BITPIX"] = 8
|
|
2064
|
+
elif bd == "16-bit":
|
|
2065
|
+
data_to_write = (np.clip(base_data, 0, 1) * 65535).astype(np.uint16)
|
|
2066
|
+
fits_header["BITPIX"] = 16
|
|
2067
|
+
elif bd == "32-bit unsigned":
|
|
2068
|
+
data_to_write = (np.clip(base_data, 0, 1) * 4294967295).astype(np.uint32)
|
|
2069
|
+
fits_header["BITPIX"] = 32
|
|
2070
|
+
else:
|
|
2071
|
+
# default / 32-bit float
|
|
2072
|
+
data_to_write = base_data.astype(np.float32)
|
|
2073
|
+
fits_header["BITPIX"] = -32
|
|
2074
|
+
|
|
2075
|
+
# Linear scaling for all these
|
|
2076
|
+
fits_header["BSCALE"] = 1.0
|
|
2077
|
+
fits_header["BZERO"] = 0.0
|
|
2078
|
+
|
|
2079
|
+
# --- Write with the same robust path you already had ---
|
|
2080
|
+
hdu = fits.PrimaryHDU(data_to_write, header=fits_header)
|
|
2081
|
+
|
|
2082
|
+
try:
|
|
2083
|
+
hdu.writeto(filename, overwrite=True)
|
|
2084
|
+
except VerifyError as ve:
|
|
2085
|
+
print(f"FITS header verify error while saving {filename}: {ve}")
|
|
2086
|
+
print("Attempting header auto-fix via hdu.verify('fix') and manual cleanup...")
|
|
2087
|
+
try:
|
|
2088
|
+
hdu.verify('fix')
|
|
2089
|
+
except Exception as ve2:
|
|
2090
|
+
print(f"hdu.verify('fix') raised: {ve2}")
|
|
2091
|
+
|
|
2092
|
+
bad_keys = []
|
|
2093
|
+
for card in list(hdu.header.cards):
|
|
2094
|
+
try:
|
|
2095
|
+
_ = str(card)
|
|
2096
|
+
except Exception:
|
|
2097
|
+
bad_keys.append(card.keyword)
|
|
2098
|
+
for key in bad_keys:
|
|
2099
|
+
try:
|
|
2100
|
+
del hdu.header[key]
|
|
2101
|
+
print(f"Dropped invalid FITS header card {key!r}")
|
|
2102
|
+
except Exception:
|
|
2103
|
+
pass
|
|
2104
|
+
|
|
2105
|
+
try:
|
|
2106
|
+
hdu.writeto(filename, overwrite=True)
|
|
2107
|
+
except VerifyError as ve3:
|
|
2108
|
+
print(f"Still failing after cleanup: {ve3}")
|
|
2109
|
+
print("Falling back to minimal FITS header (dropping all original cards).")
|
|
2110
|
+
clean_header = _minimal_fits_header(h, w, is_rgb)
|
|
2111
|
+
hdu2 = fits.PrimaryHDU(data_to_write.astype(np.float32), header=clean_header)
|
|
2112
|
+
hdu2.writeto(filename, overwrite=True)
|
|
2113
|
+
|
|
2114
|
+
print(f"Saved FITS image to: {filename}")
|
|
2115
|
+
return
|
|
2116
|
+
# ---------------------------------------------------------------------
|
|
2117
|
+
# RAW inputs — not writable; convert to FITS (float32)
|
|
2118
|
+
# ---------------------------------------------------------------------
|
|
2119
|
+
if fmt in ("cr2", "nef", "arw", "dng", "orf", "rw2", "pef"):
|
|
2120
|
+
print("RAW formats are not writable. Saving as FITS instead.")
|
|
2121
|
+
filename = f"{base}.fits"
|
|
2122
|
+
|
|
2123
|
+
fits_header = fits.Header()
|
|
2124
|
+
if _is_header_obj(original_header):
|
|
2125
|
+
src_items = (original_header.items()
|
|
2126
|
+
if isinstance(original_header, fits.Header)
|
|
2127
|
+
else original_header.items())
|
|
2128
|
+
for k, v in src_items:
|
|
2129
|
+
try:
|
|
2130
|
+
fits_header[k] = v
|
|
2131
|
+
except Exception:
|
|
2132
|
+
pass
|
|
2133
|
+
|
|
2134
|
+
fits_header["BSCALE"] = 1.0
|
|
2135
|
+
fits_header["BZERO"] = 0.0
|
|
2136
|
+
fits_header["BITPIX"] = -32
|
|
2137
|
+
|
|
2138
|
+
if is_mono:
|
|
2139
|
+
data = (img_array[:, :, 0] if (img_array.ndim == 3 and img_array.shape[2] == 1) else img_array)
|
|
2140
|
+
img_array_fits = data.astype(np.float32)
|
|
2141
|
+
fits_header["NAXIS"] = 2
|
|
2142
|
+
fits_header["NAXIS1"] = img_array.shape[1]
|
|
2143
|
+
fits_header["NAXIS2"] = img_array.shape[0]
|
|
2144
|
+
fits_header.pop("NAXIS3", None)
|
|
2145
|
+
else:
|
|
2146
|
+
img_array_transposed = np.transpose(img_array, (2, 0, 1)) # (C,H,W)
|
|
2147
|
+
img_array_fits = img_array_transposed.astype(np.float32)
|
|
2148
|
+
fits_header["NAXIS"] = 3
|
|
2149
|
+
fits_header["NAXIS1"] = img_array_transposed.shape[2]
|
|
2150
|
+
fits_header["NAXIS2"] = img_array_transposed.shape[1]
|
|
2151
|
+
fits_header["NAXIS3"] = img_array_transposed.shape[0]
|
|
2152
|
+
|
|
2153
|
+
hdu = fits.PrimaryHDU(img_array_fits, header=fits_header)
|
|
2154
|
+
hdu.writeto(filename, overwrite=True)
|
|
2155
|
+
print(f"RAW processed and saved as FITS to: {filename}")
|
|
2156
|
+
return
|
|
2157
|
+
|
|
2158
|
+
# ---------------------------------------------------------------------
|
|
2159
|
+
# XISF — use XISF.write; manage metadata shapes
|
|
2160
|
+
# ---------------------------------------------------------------------
|
|
2161
|
+
if fmt == "xisf":
|
|
2162
|
+
print(f"Original image shape: {img_array.shape}, dtype: {img_array.dtype}")
|
|
2163
|
+
print(f"Bit depth: {bit_depth}")
|
|
2164
|
+
|
|
2165
|
+
bd = bit_depth or "32-bit floating point"
|
|
2166
|
+
if bd == "16-bit":
|
|
2167
|
+
processed_image = (np.clip(img_array, 0, 1) * 65535).astype(np.uint16)
|
|
2168
|
+
elif bd == "32-bit unsigned":
|
|
2169
|
+
processed_image = (np.clip(img_array, 0, 1) * 4294967295).astype(np.uint32)
|
|
2170
|
+
else:
|
|
2171
|
+
processed_image = img_array.astype(np.float32)
|
|
2172
|
+
|
|
2173
|
+
# Normalize metadata shape hints
|
|
2174
|
+
if is_mono:
|
|
2175
|
+
if processed_image.ndim == 3 and processed_image.shape[2] > 1:
|
|
2176
|
+
processed_image = processed_image[:, :, 0]
|
|
2177
|
+
if processed_image.ndim == 2:
|
|
2178
|
+
processed_image = processed_image[:, :, np.newaxis] # H, W, 1
|
|
2179
|
+
|
|
2180
|
+
if not isinstance(image_meta, list):
|
|
2181
|
+
image_meta = [{}]
|
|
2182
|
+
image_meta[0].setdefault("geometry", (processed_image.shape[1], processed_image.shape[0], 1))
|
|
2183
|
+
image_meta[0]["colorSpace"] = "Gray"
|
|
2184
|
+
else:
|
|
2185
|
+
if not isinstance(image_meta, list):
|
|
2186
|
+
image_meta = [{}]
|
|
2187
|
+
ch = processed_image.shape[2] if processed_image.ndim == 3 else 1
|
|
2188
|
+
image_meta[0].setdefault("geometry", (processed_image.shape[1], processed_image.shape[0], ch))
|
|
2189
|
+
image_meta[0]["colorSpace"] = "RGB" if ch >= 3 else "Gray"
|
|
2190
|
+
|
|
2191
|
+
if file_meta is None:
|
|
2192
|
+
file_meta = {}
|
|
2193
|
+
|
|
2194
|
+
print(f"Processed image shape for XISF: {processed_image.shape}, dtype: {processed_image.dtype}")
|
|
2195
|
+
|
|
2196
|
+
XISF.write(
|
|
2197
|
+
filename,
|
|
2198
|
+
processed_image,
|
|
2199
|
+
creator_app="Seti Astro Cosmic Clarity",
|
|
2200
|
+
image_metadata=image_meta[0],
|
|
2201
|
+
xisf_metadata=file_meta,
|
|
2202
|
+
shuffle=True
|
|
2203
|
+
)
|
|
2204
|
+
print(f"Saved {bd} XISF image to: {filename}")
|
|
2205
|
+
return
|
|
2206
|
+
|
|
2207
|
+
# ---------------------------------------------------------------------
|
|
2208
|
+
# Unknown format
|
|
2209
|
+
# ---------------------------------------------------------------------
|
|
2210
|
+
raise ValueError(f"Unsupported file format: {original_format!r}")
|
|
2211
|
+
|
|
2212
|
+
except Exception as e:
|
|
2213
|
+
print(f"Error saving image to {filename}: {e}")
|
|
2214
|
+
raise
|
|
2215
|
+
|
|
2216
|
+
|
|
2217
|
+
def ensure_native_byte_order(array):
|
|
2218
|
+
"""
|
|
2219
|
+
Ensures that the array is in the native byte order.
|
|
2220
|
+
If the array is in a non-native byte order, it will convert it.
|
|
2221
|
+
"""
|
|
2222
|
+
if array.dtype.byteorder == '=': # Already in native byte order
|
|
2223
|
+
return array
|
|
2224
|
+
elif array.dtype.byteorder in ('<', '>'): # Non-native byte order
|
|
2225
|
+
return array.byteswap().view(array.dtype.newbyteorder('='))
|
|
2226
|
+
return array
|