setiastrosuitepro 1.6.5.post3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- setiastro/__init__.py +2 -0
- setiastro/data/SASP_data.fits +0 -0
- setiastro/data/catalogs/List_of_Galaxies_with_Distances_Gly.csv +488 -0
- setiastro/data/catalogs/astrobin_filters.csv +890 -0
- setiastro/data/catalogs/astrobin_filters_page1_local.csv +51 -0
- setiastro/data/catalogs/cali2.csv +63 -0
- setiastro/data/catalogs/cali2color.csv +65 -0
- setiastro/data/catalogs/celestial_catalog - original.csv +16471 -0
- setiastro/data/catalogs/celestial_catalog.csv +24031 -0
- setiastro/data/catalogs/detected_stars.csv +24784 -0
- setiastro/data/catalogs/fits_header_data.csv +46 -0
- setiastro/data/catalogs/test.csv +8 -0
- setiastro/data/catalogs/updated_celestial_catalog.csv +16471 -0
- setiastro/images/Astro_Spikes.png +0 -0
- setiastro/images/Background_startup.jpg +0 -0
- setiastro/images/HRDiagram.png +0 -0
- setiastro/images/LExtract.png +0 -0
- setiastro/images/LInsert.png +0 -0
- setiastro/images/Oxygenation-atm-2.svg.png +0 -0
- setiastro/images/RGB080604.png +0 -0
- setiastro/images/abeicon.png +0 -0
- setiastro/images/aberration.png +0 -0
- setiastro/images/andromedatry.png +0 -0
- setiastro/images/andromedatry_satellited.png +0 -0
- setiastro/images/annotated.png +0 -0
- setiastro/images/aperture.png +0 -0
- setiastro/images/astrosuite.ico +0 -0
- setiastro/images/astrosuite.png +0 -0
- setiastro/images/astrosuitepro.icns +0 -0
- setiastro/images/astrosuitepro.ico +0 -0
- setiastro/images/astrosuitepro.png +0 -0
- setiastro/images/background.png +0 -0
- setiastro/images/background2.png +0 -0
- setiastro/images/benchmark.png +0 -0
- setiastro/images/big_moon_stabilizer_timeline.png +0 -0
- setiastro/images/big_moon_stabilizer_timeline_clean.png +0 -0
- setiastro/images/blaster.png +0 -0
- setiastro/images/blink.png +0 -0
- setiastro/images/clahe.png +0 -0
- setiastro/images/collage.png +0 -0
- setiastro/images/colorwheel.png +0 -0
- setiastro/images/contsub.png +0 -0
- setiastro/images/convo.png +0 -0
- setiastro/images/copyslot.png +0 -0
- setiastro/images/cosmic.png +0 -0
- setiastro/images/cosmicsat.png +0 -0
- setiastro/images/crop1.png +0 -0
- setiastro/images/cropicon.png +0 -0
- setiastro/images/curves.png +0 -0
- setiastro/images/cvs.png +0 -0
- setiastro/images/debayer.png +0 -0
- setiastro/images/denoise_cnn_custom.png +0 -0
- setiastro/images/denoise_cnn_graph.png +0 -0
- setiastro/images/disk.png +0 -0
- setiastro/images/dse.png +0 -0
- setiastro/images/exoicon.png +0 -0
- setiastro/images/eye.png +0 -0
- setiastro/images/fliphorizontal.png +0 -0
- setiastro/images/flipvertical.png +0 -0
- setiastro/images/font.png +0 -0
- setiastro/images/freqsep.png +0 -0
- setiastro/images/functionbundle.png +0 -0
- setiastro/images/graxpert.png +0 -0
- setiastro/images/green.png +0 -0
- setiastro/images/gridicon.png +0 -0
- setiastro/images/halo.png +0 -0
- setiastro/images/hdr.png +0 -0
- setiastro/images/histogram.png +0 -0
- setiastro/images/hubble.png +0 -0
- setiastro/images/imagecombine.png +0 -0
- setiastro/images/invert.png +0 -0
- setiastro/images/isophote.png +0 -0
- setiastro/images/isophote_demo_figure.png +0 -0
- setiastro/images/isophote_demo_image.png +0 -0
- setiastro/images/isophote_demo_model.png +0 -0
- setiastro/images/isophote_demo_residual.png +0 -0
- setiastro/images/jwstpupil.png +0 -0
- setiastro/images/linearfit.png +0 -0
- setiastro/images/livestacking.png +0 -0
- setiastro/images/mask.png +0 -0
- setiastro/images/maskapply.png +0 -0
- setiastro/images/maskcreate.png +0 -0
- setiastro/images/maskremove.png +0 -0
- setiastro/images/morpho.png +0 -0
- setiastro/images/mosaic.png +0 -0
- setiastro/images/multiscale_decomp.png +0 -0
- setiastro/images/nbtorgb.png +0 -0
- setiastro/images/neutral.png +0 -0
- setiastro/images/nuke.png +0 -0
- setiastro/images/openfile.png +0 -0
- setiastro/images/pedestal.png +0 -0
- setiastro/images/pen.png +0 -0
- setiastro/images/pixelmath.png +0 -0
- setiastro/images/platesolve.png +0 -0
- setiastro/images/ppp.png +0 -0
- setiastro/images/pro.png +0 -0
- setiastro/images/project.png +0 -0
- setiastro/images/psf.png +0 -0
- setiastro/images/redo.png +0 -0
- setiastro/images/redoicon.png +0 -0
- setiastro/images/rescale.png +0 -0
- setiastro/images/rgbalign.png +0 -0
- setiastro/images/rgbcombo.png +0 -0
- setiastro/images/rgbextract.png +0 -0
- setiastro/images/rotate180.png +0 -0
- setiastro/images/rotatearbitrary.png +0 -0
- setiastro/images/rotateclockwise.png +0 -0
- setiastro/images/rotatecounterclockwise.png +0 -0
- setiastro/images/satellite.png +0 -0
- setiastro/images/script.png +0 -0
- setiastro/images/selectivecolor.png +0 -0
- setiastro/images/simbad.png +0 -0
- setiastro/images/slot0.png +0 -0
- setiastro/images/slot1.png +0 -0
- setiastro/images/slot2.png +0 -0
- setiastro/images/slot3.png +0 -0
- setiastro/images/slot4.png +0 -0
- setiastro/images/slot5.png +0 -0
- setiastro/images/slot6.png +0 -0
- setiastro/images/slot7.png +0 -0
- setiastro/images/slot8.png +0 -0
- setiastro/images/slot9.png +0 -0
- setiastro/images/spcc.png +0 -0
- setiastro/images/spin_precession_vs_lunar_distance.png +0 -0
- setiastro/images/spinner.gif +0 -0
- setiastro/images/stacking.png +0 -0
- setiastro/images/staradd.png +0 -0
- setiastro/images/staralign.png +0 -0
- setiastro/images/starnet.png +0 -0
- setiastro/images/starregistration.png +0 -0
- setiastro/images/starspike.png +0 -0
- setiastro/images/starstretch.png +0 -0
- setiastro/images/statstretch.png +0 -0
- setiastro/images/supernova.png +0 -0
- setiastro/images/uhs.png +0 -0
- setiastro/images/undoicon.png +0 -0
- setiastro/images/upscale.png +0 -0
- setiastro/images/viewbundle.png +0 -0
- setiastro/images/whitebalance.png +0 -0
- setiastro/images/wimi_icon_256x256.png +0 -0
- setiastro/images/wimilogo.png +0 -0
- setiastro/images/wims.png +0 -0
- setiastro/images/wrench_icon.png +0 -0
- setiastro/images/xisfliberator.png +0 -0
- setiastro/qml/ResourceMonitor.qml +126 -0
- setiastro/saspro/__init__.py +20 -0
- setiastro/saspro/__main__.py +958 -0
- setiastro/saspro/_generated/__init__.py +7 -0
- setiastro/saspro/_generated/build_info.py +3 -0
- setiastro/saspro/abe.py +1346 -0
- setiastro/saspro/abe_preset.py +196 -0
- setiastro/saspro/aberration_ai.py +698 -0
- setiastro/saspro/aberration_ai_preset.py +224 -0
- setiastro/saspro/accel_installer.py +218 -0
- setiastro/saspro/accel_workers.py +30 -0
- setiastro/saspro/add_stars.py +624 -0
- setiastro/saspro/astrobin_exporter.py +1010 -0
- setiastro/saspro/astrospike.py +153 -0
- setiastro/saspro/astrospike_python.py +1841 -0
- setiastro/saspro/autostretch.py +198 -0
- setiastro/saspro/backgroundneutral.py +611 -0
- setiastro/saspro/batch_convert.py +328 -0
- setiastro/saspro/batch_renamer.py +522 -0
- setiastro/saspro/blemish_blaster.py +491 -0
- setiastro/saspro/blink_comparator_pro.py +3149 -0
- setiastro/saspro/bundles.py +61 -0
- setiastro/saspro/bundles_dock.py +114 -0
- setiastro/saspro/cheat_sheet.py +213 -0
- setiastro/saspro/clahe.py +368 -0
- setiastro/saspro/comet_stacking.py +1442 -0
- setiastro/saspro/common_tr.py +107 -0
- setiastro/saspro/config.py +38 -0
- setiastro/saspro/config_bootstrap.py +40 -0
- setiastro/saspro/config_manager.py +316 -0
- setiastro/saspro/continuum_subtract.py +1617 -0
- setiastro/saspro/convo.py +1400 -0
- setiastro/saspro/convo_preset.py +414 -0
- setiastro/saspro/copyastro.py +190 -0
- setiastro/saspro/cosmicclarity.py +1589 -0
- setiastro/saspro/cosmicclarity_preset.py +407 -0
- setiastro/saspro/crop_dialog_pro.py +983 -0
- setiastro/saspro/crop_preset.py +189 -0
- setiastro/saspro/curve_editor_pro.py +2562 -0
- setiastro/saspro/curves_preset.py +375 -0
- setiastro/saspro/debayer.py +673 -0
- setiastro/saspro/debug_utils.py +29 -0
- setiastro/saspro/dnd_mime.py +35 -0
- setiastro/saspro/doc_manager.py +2664 -0
- setiastro/saspro/exoplanet_detector.py +2166 -0
- setiastro/saspro/file_utils.py +284 -0
- setiastro/saspro/fitsmodifier.py +748 -0
- setiastro/saspro/fix_bom.py +32 -0
- setiastro/saspro/free_torch_memory.py +48 -0
- setiastro/saspro/frequency_separation.py +1349 -0
- setiastro/saspro/function_bundle.py +1596 -0
- setiastro/saspro/generate_translations.py +3092 -0
- setiastro/saspro/ghs_dialog_pro.py +663 -0
- setiastro/saspro/ghs_preset.py +284 -0
- setiastro/saspro/graxpert.py +637 -0
- setiastro/saspro/graxpert_preset.py +287 -0
- setiastro/saspro/gui/__init__.py +0 -0
- setiastro/saspro/gui/main_window.py +8792 -0
- setiastro/saspro/gui/mixins/__init__.py +33 -0
- setiastro/saspro/gui/mixins/dock_mixin.py +375 -0
- setiastro/saspro/gui/mixins/file_mixin.py +450 -0
- setiastro/saspro/gui/mixins/geometry_mixin.py +503 -0
- setiastro/saspro/gui/mixins/header_mixin.py +441 -0
- setiastro/saspro/gui/mixins/mask_mixin.py +421 -0
- setiastro/saspro/gui/mixins/menu_mixin.py +390 -0
- setiastro/saspro/gui/mixins/theme_mixin.py +367 -0
- setiastro/saspro/gui/mixins/toolbar_mixin.py +1619 -0
- setiastro/saspro/gui/mixins/update_mixin.py +323 -0
- setiastro/saspro/gui/mixins/view_mixin.py +435 -0
- setiastro/saspro/gui/statistics_dialog.py +47 -0
- setiastro/saspro/halobgon.py +488 -0
- setiastro/saspro/header_viewer.py +448 -0
- setiastro/saspro/headless_utils.py +88 -0
- setiastro/saspro/histogram.py +756 -0
- setiastro/saspro/history_explorer.py +941 -0
- setiastro/saspro/i18n.py +168 -0
- setiastro/saspro/image_combine.py +417 -0
- setiastro/saspro/image_peeker_pro.py +1604 -0
- setiastro/saspro/imageops/__init__.py +37 -0
- setiastro/saspro/imageops/mdi_snap.py +292 -0
- setiastro/saspro/imageops/scnr.py +36 -0
- setiastro/saspro/imageops/starbasedwhitebalance.py +210 -0
- setiastro/saspro/imageops/stretch.py +236 -0
- setiastro/saspro/isophote.py +1182 -0
- setiastro/saspro/layers.py +208 -0
- setiastro/saspro/layers_dock.py +714 -0
- setiastro/saspro/lazy_imports.py +193 -0
- setiastro/saspro/legacy/__init__.py +2 -0
- setiastro/saspro/legacy/image_manager.py +2360 -0
- setiastro/saspro/legacy/numba_utils.py +3676 -0
- setiastro/saspro/legacy/xisf.py +1213 -0
- setiastro/saspro/linear_fit.py +537 -0
- setiastro/saspro/live_stacking.py +1854 -0
- setiastro/saspro/log_bus.py +5 -0
- setiastro/saspro/logging_config.py +460 -0
- setiastro/saspro/luminancerecombine.py +510 -0
- setiastro/saspro/main_helpers.py +201 -0
- setiastro/saspro/mask_creation.py +1086 -0
- setiastro/saspro/masks_core.py +56 -0
- setiastro/saspro/mdi_widgets.py +353 -0
- setiastro/saspro/memory_utils.py +666 -0
- setiastro/saspro/metadata_patcher.py +75 -0
- setiastro/saspro/mfdeconv.py +3909 -0
- setiastro/saspro/mfdeconv_earlystop.py +71 -0
- setiastro/saspro/mfdeconvcudnn.py +3312 -0
- setiastro/saspro/mfdeconvsport.py +2459 -0
- setiastro/saspro/minorbodycatalog.py +567 -0
- setiastro/saspro/morphology.py +407 -0
- setiastro/saspro/multiscale_decomp.py +1747 -0
- setiastro/saspro/nbtorgb_stars.py +541 -0
- setiastro/saspro/numba_utils.py +3145 -0
- setiastro/saspro/numba_warmup.py +141 -0
- setiastro/saspro/ops/__init__.py +9 -0
- setiastro/saspro/ops/command_help_dialog.py +623 -0
- setiastro/saspro/ops/command_runner.py +217 -0
- setiastro/saspro/ops/commands.py +1594 -0
- setiastro/saspro/ops/script_editor.py +1105 -0
- setiastro/saspro/ops/scripts.py +1476 -0
- setiastro/saspro/ops/settings.py +637 -0
- setiastro/saspro/parallel_utils.py +554 -0
- setiastro/saspro/pedestal.py +121 -0
- setiastro/saspro/perfect_palette_picker.py +1105 -0
- setiastro/saspro/pipeline.py +110 -0
- setiastro/saspro/pixelmath.py +1604 -0
- setiastro/saspro/plate_solver.py +2445 -0
- setiastro/saspro/project_io.py +797 -0
- setiastro/saspro/psf_utils.py +136 -0
- setiastro/saspro/psf_viewer.py +549 -0
- setiastro/saspro/pyi_rthook_astroquery.py +95 -0
- setiastro/saspro/remove_green.py +331 -0
- setiastro/saspro/remove_stars.py +1599 -0
- setiastro/saspro/remove_stars_preset.py +446 -0
- setiastro/saspro/resources.py +503 -0
- setiastro/saspro/rgb_combination.py +208 -0
- setiastro/saspro/rgb_extract.py +19 -0
- setiastro/saspro/rgbalign.py +723 -0
- setiastro/saspro/runtime_imports.py +7 -0
- setiastro/saspro/runtime_torch.py +754 -0
- setiastro/saspro/save_options.py +73 -0
- setiastro/saspro/selective_color.py +1611 -0
- setiastro/saspro/sfcc.py +1472 -0
- setiastro/saspro/shortcuts.py +3116 -0
- setiastro/saspro/signature_insert.py +1102 -0
- setiastro/saspro/stacking_suite.py +19066 -0
- setiastro/saspro/star_alignment.py +7380 -0
- setiastro/saspro/star_alignment_preset.py +329 -0
- setiastro/saspro/star_metrics.py +49 -0
- setiastro/saspro/star_spikes.py +765 -0
- setiastro/saspro/star_stretch.py +507 -0
- setiastro/saspro/stat_stretch.py +538 -0
- setiastro/saspro/status_log_dock.py +78 -0
- setiastro/saspro/subwindow.py +3407 -0
- setiastro/saspro/supernovaasteroidhunter.py +1719 -0
- setiastro/saspro/swap_manager.py +134 -0
- setiastro/saspro/torch_backend.py +89 -0
- setiastro/saspro/torch_rejection.py +434 -0
- setiastro/saspro/translations/all_source_strings.json +4726 -0
- setiastro/saspro/translations/ar_translations.py +4096 -0
- setiastro/saspro/translations/de_translations.py +3728 -0
- setiastro/saspro/translations/es_translations.py +4169 -0
- setiastro/saspro/translations/fr_translations.py +4090 -0
- setiastro/saspro/translations/hi_translations.py +3803 -0
- setiastro/saspro/translations/integrate_translations.py +271 -0
- setiastro/saspro/translations/it_translations.py +4728 -0
- setiastro/saspro/translations/ja_translations.py +3834 -0
- setiastro/saspro/translations/pt_translations.py +3847 -0
- setiastro/saspro/translations/ru_translations.py +3082 -0
- setiastro/saspro/translations/saspro_ar.qm +0 -0
- setiastro/saspro/translations/saspro_ar.ts +16019 -0
- setiastro/saspro/translations/saspro_de.qm +0 -0
- setiastro/saspro/translations/saspro_de.ts +14548 -0
- setiastro/saspro/translations/saspro_es.qm +0 -0
- setiastro/saspro/translations/saspro_es.ts +16202 -0
- setiastro/saspro/translations/saspro_fr.qm +0 -0
- setiastro/saspro/translations/saspro_fr.ts +15870 -0
- setiastro/saspro/translations/saspro_hi.qm +0 -0
- setiastro/saspro/translations/saspro_hi.ts +14855 -0
- setiastro/saspro/translations/saspro_it.qm +0 -0
- setiastro/saspro/translations/saspro_it.ts +19046 -0
- setiastro/saspro/translations/saspro_ja.qm +0 -0
- setiastro/saspro/translations/saspro_ja.ts +14980 -0
- setiastro/saspro/translations/saspro_pt.qm +0 -0
- setiastro/saspro/translations/saspro_pt.ts +15024 -0
- setiastro/saspro/translations/saspro_ru.qm +0 -0
- setiastro/saspro/translations/saspro_ru.ts +11835 -0
- setiastro/saspro/translations/saspro_sw.qm +0 -0
- setiastro/saspro/translations/saspro_sw.ts +15237 -0
- setiastro/saspro/translations/saspro_uk.qm +0 -0
- setiastro/saspro/translations/saspro_uk.ts +15248 -0
- setiastro/saspro/translations/saspro_zh.qm +0 -0
- setiastro/saspro/translations/saspro_zh.ts +15289 -0
- setiastro/saspro/translations/sw_translations.py +3897 -0
- setiastro/saspro/translations/uk_translations.py +3929 -0
- setiastro/saspro/translations/zh_translations.py +3910 -0
- setiastro/saspro/versioning.py +77 -0
- setiastro/saspro/view_bundle.py +1558 -0
- setiastro/saspro/wavescale_hdr.py +645 -0
- setiastro/saspro/wavescale_hdr_preset.py +101 -0
- setiastro/saspro/wavescalede.py +680 -0
- setiastro/saspro/wavescalede_preset.py +230 -0
- setiastro/saspro/wcs_update.py +374 -0
- setiastro/saspro/whitebalance.py +513 -0
- setiastro/saspro/widgets/__init__.py +48 -0
- setiastro/saspro/widgets/common_utilities.py +306 -0
- setiastro/saspro/widgets/graphics_views.py +122 -0
- setiastro/saspro/widgets/image_utils.py +518 -0
- setiastro/saspro/widgets/minigame/game.js +991 -0
- setiastro/saspro/widgets/minigame/index.html +53 -0
- setiastro/saspro/widgets/minigame/style.css +241 -0
- setiastro/saspro/widgets/preview_dialogs.py +280 -0
- setiastro/saspro/widgets/resource_monitor.py +263 -0
- setiastro/saspro/widgets/spinboxes.py +290 -0
- setiastro/saspro/widgets/themed_buttons.py +13 -0
- setiastro/saspro/widgets/wavelet_utils.py +331 -0
- setiastro/saspro/wimi.py +7996 -0
- setiastro/saspro/wims.py +578 -0
- setiastro/saspro/window_shelf.py +185 -0
- setiastro/saspro/xisf.py +1213 -0
- setiastrosuitepro-1.6.5.post3.dist-info/METADATA +278 -0
- setiastrosuitepro-1.6.5.post3.dist-info/RECORD +368 -0
- setiastrosuitepro-1.6.5.post3.dist-info/WHEEL +4 -0
- setiastrosuitepro-1.6.5.post3.dist-info/entry_points.txt +6 -0
- setiastrosuitepro-1.6.5.post3.dist-info/licenses/LICENSE +674 -0
- setiastrosuitepro-1.6.5.post3.dist-info/licenses/license.txt +2580 -0
setiastro/saspro/sfcc.py
ADDED
|
@@ -0,0 +1,1472 @@
|
|
|
1
|
+
# sfcc.py
|
|
2
|
+
# SASpro Spectral Flux Color Calibration (SFCC) — "current view" integration
|
|
3
|
+
# - Expects a "view adapter" you provide that exposes:
|
|
4
|
+
# get_rgb_image() -> np.ndarray (H,W,3), uint8 or float32 in [0,1]
|
|
5
|
+
# get_metadata() -> dict (optional; may return {})
|
|
6
|
+
# get_header() -> astropy.io.fits.Header or dict (optional but needed for WCS features)
|
|
7
|
+
# set_rgb_image(img: np.ndarray, metadata: dict | None = None, step_name: str | None = None) -> None
|
|
8
|
+
# If your adapter names differ, tweak _get_img_meta/_get_header/_push_image below (they already try a few fallbacks).
|
|
9
|
+
#
|
|
10
|
+
# - Call open_sfcc(view_adapter, sasp_data_path) to show the dialog.
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import cv2
|
|
17
|
+
import math
|
|
18
|
+
import time
|
|
19
|
+
from datetime import datetime
|
|
20
|
+
from typing import List, Tuple, Optional
|
|
21
|
+
|
|
22
|
+
import numpy as np
|
|
23
|
+
import pandas as pd
|
|
24
|
+
|
|
25
|
+
# ── SciPy bits
|
|
26
|
+
from scipy.interpolate import RBFInterpolator, interp1d
|
|
27
|
+
from scipy.signal import medfilt
|
|
28
|
+
|
|
29
|
+
# ── Astropy / Astroquery
|
|
30
|
+
from astropy.io import fits
|
|
31
|
+
from astropy.wcs import WCS
|
|
32
|
+
import astropy.units as u
|
|
33
|
+
from astropy.coordinates import SkyCoord
|
|
34
|
+
from astroquery.simbad import Simbad
|
|
35
|
+
|
|
36
|
+
# ── SEP (Source Extractor)
|
|
37
|
+
import sep
|
|
38
|
+
|
|
39
|
+
# ── Matplotlib backend for Qt
|
|
40
|
+
from matplotlib.figure import Figure
|
|
41
|
+
from matplotlib import pyplot as plt
|
|
42
|
+
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
|
|
43
|
+
|
|
44
|
+
from PyQt6.QtCore import (Qt, QPoint, QRect, QMimeData, QSettings, QByteArray,
|
|
45
|
+
QDataStream, QIODevice, QEvent, QStandardPaths)
|
|
46
|
+
from PyQt6.QtGui import (QAction, QDrag, QIcon, QMouseEvent, QPixmap, QKeyEvent)
|
|
47
|
+
from PyQt6.QtWidgets import (QToolBar, QWidget, QToolButton, QMenu, QApplication, QVBoxLayout, QHBoxLayout, QComboBox, QGroupBox, QGridLayout, QDoubleSpinBox, QSpinBox,
|
|
48
|
+
QInputDialog, QMessageBox, QDialog, QFileDialog,
|
|
49
|
+
QFormLayout, QDialogButtonBox, QDoubleSpinBox, QCheckBox, QLabel, QRubberBand, QRadioButton, QMainWindow, QPushButton)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
53
|
+
# Utilities
|
|
54
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
# --- Debug/guards -----------------------------------------------------
|
|
57
|
+
def _debug_probe_channels(img: np.ndarray, label="input"):
|
|
58
|
+
assert img.ndim == 3 and img.shape[2] == 3, f"[SFCC] {label}: not RGB"
|
|
59
|
+
f = img.astype(np.float32) / (255.0 if img.dtype == np.uint8 else 1.0)
|
|
60
|
+
means = [float(f[...,i].mean()) for i in range(3)]
|
|
61
|
+
stds = [float(f[...,i].std()) for i in range(3)]
|
|
62
|
+
rg = float(np.corrcoef(f[...,0].ravel(), f[...,1].ravel())[0,1])
|
|
63
|
+
rb = float(np.corrcoef(f[...,0].ravel(), f[...,2].ravel())[0,1])
|
|
64
|
+
gb = float(np.corrcoef(f[...,1].ravel(), f[...,2].ravel())[0,1])
|
|
65
|
+
print(f"[SFCC] {label}: mean={means}, std={stds}, corr(R,G)={rg:.5f}, corr(R,B)={rb:.5f}, corr(G,B)={gb:.5f}")
|
|
66
|
+
return rg, rb, gb
|
|
67
|
+
|
|
68
|
+
def _maybe_bgr_to_rgb(img: np.ndarray) -> np.ndarray:
|
|
69
|
+
# Heuristic: if channel-2 is consistently brightest in highlights and ch-0 the dimmest → likely BGR.
|
|
70
|
+
f = img.astype(np.float32) / (255.0 if img.dtype == np.uint8 else 1.0)
|
|
71
|
+
lum = np.mean(f, axis=2)
|
|
72
|
+
thr = np.quantile(lum, 0.95)
|
|
73
|
+
m0 = f[...,0][lum >= thr].mean() if np.any(lum >= thr) else f[...,0].mean()
|
|
74
|
+
m1 = f[...,1][lum >= thr].mean() if np.any(lum >= thr) else f[...,1].mean()
|
|
75
|
+
m2 = f[...,2][lum >= thr].mean() if np.any(lum >= thr) else f[...,2].mean()
|
|
76
|
+
if (m2 > m1 >= m0) and (m2 - m0 > 0.02):
|
|
77
|
+
print("[SFCC] Heuristic suggests BGR input → converting to RGB")
|
|
78
|
+
return img[..., ::-1]
|
|
79
|
+
return img
|
|
80
|
+
|
|
81
|
+
def _ensure_angstrom(wl: np.ndarray) -> np.ndarray:
|
|
82
|
+
"""If wavelengths look like nm (≈300–1100), convert to Å."""
|
|
83
|
+
med = float(np.median(wl))
|
|
84
|
+
return wl * 10.0 if 250.0 <= med <= 2000.0 else wl
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def pickles_match_for_simbad(simbad_sp: str, available_extnames: List[str]) -> List[str]:
|
|
88
|
+
sp = simbad_sp.strip().upper()
|
|
89
|
+
if not sp:
|
|
90
|
+
return []
|
|
91
|
+
m = re.match(r"^([OBAFGKMLT])(\d?)(I{1,3}|IV|V)?", sp)
|
|
92
|
+
if not m:
|
|
93
|
+
return []
|
|
94
|
+
letter_class = m.group(1)
|
|
95
|
+
digit_part = m.group(2)
|
|
96
|
+
lum_part = m.group(3)
|
|
97
|
+
subclass = int(digit_part) if digit_part != "" else None
|
|
98
|
+
|
|
99
|
+
def parse_pickles_extname(ext: str):
|
|
100
|
+
ext = ext.strip().upper()
|
|
101
|
+
m2 = re.match(r"^([OBAFGKMLT])(\d+)(I{1,3}|IV|V)$", ext)
|
|
102
|
+
if not m2:
|
|
103
|
+
return None, None, None
|
|
104
|
+
return m2.group(1), int(m2.group(2)), m2.group(3)
|
|
105
|
+
|
|
106
|
+
parsed_templates = []
|
|
107
|
+
for ext in available_extnames:
|
|
108
|
+
l2, d2, L2 = parse_pickles_extname(ext)
|
|
109
|
+
if l2 is not None:
|
|
110
|
+
parsed_templates.append((ext, l2, d2, L2))
|
|
111
|
+
|
|
112
|
+
# Exact
|
|
113
|
+
if subclass is not None and lum_part is not None:
|
|
114
|
+
target = f"{letter_class}{subclass}{lum_part}"
|
|
115
|
+
if target in available_extnames:
|
|
116
|
+
return [target]
|
|
117
|
+
|
|
118
|
+
# Same letter (+same lum if we have it)
|
|
119
|
+
same_letter_and_lum = []
|
|
120
|
+
same_letter_any_lum = []
|
|
121
|
+
for (ext, l2, d2, L2) in parsed_templates:
|
|
122
|
+
if l2 != letter_class:
|
|
123
|
+
continue
|
|
124
|
+
if lum_part is not None and L2 == lum_part:
|
|
125
|
+
same_letter_and_lum.append((ext, d2))
|
|
126
|
+
else:
|
|
127
|
+
same_letter_any_lum.append((ext, d2))
|
|
128
|
+
|
|
129
|
+
def pick_nearest(candidates: List[Tuple[str, int]], target: int) -> List[str]:
|
|
130
|
+
if not candidates or target is None:
|
|
131
|
+
return []
|
|
132
|
+
arr = np.abs(np.array([d for _, d in candidates]) - target)
|
|
133
|
+
mind = np.min(arr)
|
|
134
|
+
return [candidates[i][0] for i in np.where(arr == mind)[0]]
|
|
135
|
+
|
|
136
|
+
if subclass is not None and lum_part is not None:
|
|
137
|
+
if same_letter_and_lum:
|
|
138
|
+
return pick_nearest(same_letter_and_lum, subclass)
|
|
139
|
+
if same_letter_any_lum:
|
|
140
|
+
return pick_nearest(same_letter_any_lum, subclass)
|
|
141
|
+
|
|
142
|
+
if subclass is not None and lum_part is None:
|
|
143
|
+
if same_letter_any_lum:
|
|
144
|
+
return pick_nearest(same_letter_any_lum, subclass)
|
|
145
|
+
|
|
146
|
+
if subclass is None and lum_part is None:
|
|
147
|
+
return sorted([ext for (ext, l2, _, _) in parsed_templates if l2 == letter_class])
|
|
148
|
+
|
|
149
|
+
if subclass is None and lum_part is not None:
|
|
150
|
+
cands = [ (ext, d2) for (ext, l2, d2, L2) in parsed_templates if l2 == letter_class and L2 == lum_part ]
|
|
151
|
+
if cands:
|
|
152
|
+
return sorted([ext for (ext, _) in cands])
|
|
153
|
+
return sorted([ext for (ext, l2, _, _) in parsed_templates if l2 == letter_class])
|
|
154
|
+
|
|
155
|
+
return []
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def compute_gradient_map(sources, delta_flux, shape, method="poly2"):
|
|
159
|
+
H, W = shape
|
|
160
|
+
xs, ys = sources[:, 0], sources[:, 1]
|
|
161
|
+
|
|
162
|
+
if method == "poly2":
|
|
163
|
+
A = np.vstack([np.ones_like(xs), xs, ys, xs**2, xs*ys, ys**2]).T
|
|
164
|
+
coeffs, *_ = np.linalg.lstsq(A, delta_flux, rcond=None)
|
|
165
|
+
YY, XX = np.mgrid[0:H, 0:W]
|
|
166
|
+
return (coeffs[0] + coeffs[1]*XX + coeffs[2]*YY
|
|
167
|
+
+ coeffs[3]*XX**2 + coeffs[4]*XX*YY + coeffs[5]*YY**2)
|
|
168
|
+
|
|
169
|
+
elif method == "poly3":
|
|
170
|
+
A = np.vstack([
|
|
171
|
+
np.ones_like(xs), xs, ys,
|
|
172
|
+
xs**2, xs*ys, ys**2,
|
|
173
|
+
xs**3, xs**2*ys, xs*ys**2, ys**3
|
|
174
|
+
]).T
|
|
175
|
+
coeffs, *_ = np.linalg.lstsq(A, delta_flux, rcond=None)
|
|
176
|
+
YY, XX = np.mgrid[0:H, 0:W]
|
|
177
|
+
return (coeffs[0] + coeffs[1]*XX + coeffs[2]*YY
|
|
178
|
+
+ coeffs[3]*XX**2 + coeffs[4]*XX*YY + coeffs[5]*YY**2
|
|
179
|
+
+ coeffs[6]*XX**3 + coeffs[7]*XX**2*YY + coeffs[8]*XX*YY**2 + coeffs[9]*YY**3)
|
|
180
|
+
|
|
181
|
+
elif method == "rbf":
|
|
182
|
+
pts = np.vstack([xs, ys]).T
|
|
183
|
+
rbfi = RBFInterpolator(pts, delta_flux, kernel="thin_plate_spline", smoothing=1.0)
|
|
184
|
+
YY, XX = np.mgrid[0:H, 0:W]
|
|
185
|
+
grid_pts = np.vstack([XX.ravel(), YY.ravel()]).T
|
|
186
|
+
return rbfi(grid_pts).reshape(H, W)
|
|
187
|
+
|
|
188
|
+
else:
|
|
189
|
+
raise ValueError("method must be one of 'poly2','poly3','rbf'")
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
193
|
+
# Simple responses viewer (unchanged core logic; useful for diagnostics)
|
|
194
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
195
|
+
class SaspViewer(QMainWindow):
|
|
196
|
+
def __init__(self, sasp_data_path: str, user_custom_path: str):
|
|
197
|
+
super().__init__()
|
|
198
|
+
self.setWindowTitle(self.tr("SASP Viewer (Pickles + RGB Responses)"))
|
|
199
|
+
|
|
200
|
+
self.base_hdul = fits.open(sasp_data_path, mode="readonly", memmap=False)
|
|
201
|
+
self.custom_hdul = fits.open(user_custom_path, mode="readonly", memmap=False)
|
|
202
|
+
|
|
203
|
+
self.pickles_templates = []
|
|
204
|
+
self.filter_list = []
|
|
205
|
+
self.sensor_list = []
|
|
206
|
+
for hdul in (self.custom_hdul, self.base_hdul):
|
|
207
|
+
for hdu in hdul:
|
|
208
|
+
if not isinstance(hdu, fits.BinTableHDU): continue
|
|
209
|
+
c = hdu.header.get("CTYPE","").upper()
|
|
210
|
+
e = hdu.header.get("EXTNAME","")
|
|
211
|
+
if c == "SED": self.pickles_templates.append(e)
|
|
212
|
+
elif c == "FILTER": self.filter_list.append(e)
|
|
213
|
+
elif c == "SENSOR": self.sensor_list.append(e)
|
|
214
|
+
|
|
215
|
+
for lst in (self.pickles_templates, self.filter_list, self.sensor_list):
|
|
216
|
+
lst.sort()
|
|
217
|
+
self.rgb_filter_choices = ["(None)"] + self.filter_list
|
|
218
|
+
|
|
219
|
+
central = QWidget(); self.setCentralWidget(central)
|
|
220
|
+
vbox = QVBoxLayout(); central.setLayout(vbox)
|
|
221
|
+
|
|
222
|
+
row = QHBoxLayout(); vbox.addLayout(row)
|
|
223
|
+
row.addWidget(QLabel(self.tr("Star Template:")))
|
|
224
|
+
self.star_combo = QComboBox(); self.star_combo.addItems(self.pickles_templates); row.addWidget(self.star_combo)
|
|
225
|
+
row.addWidget(QLabel(self.tr("R-Filter:")))
|
|
226
|
+
self.r_filter_combo = QComboBox(); self.r_filter_combo.addItems(self.rgb_filter_choices); row.addWidget(self.r_filter_combo)
|
|
227
|
+
row.addWidget(QLabel(self.tr("G-Filter:")))
|
|
228
|
+
self.g_filter_combo = QComboBox(); self.g_filter_combo.addItems(self.rgb_filter_choices); row.addWidget(self.g_filter_combo)
|
|
229
|
+
row.addWidget(QLabel(self.tr("B-Filter:")))
|
|
230
|
+
self.b_filter_combo = QComboBox(); self.b_filter_combo.addItems(self.rgb_filter_choices); row.addWidget(self.b_filter_combo)
|
|
231
|
+
|
|
232
|
+
row2 = QHBoxLayout(); vbox.addLayout(row2)
|
|
233
|
+
row2.addWidget(QLabel(self.tr("LP/Cut Filter1:")))
|
|
234
|
+
self.lp_filter_combo = QComboBox(); self.lp_filter_combo.addItems(self.rgb_filter_choices); row2.addWidget(self.lp_filter_combo)
|
|
235
|
+
row2.addWidget(QLabel(self.tr("LP/Cut Filter2:")))
|
|
236
|
+
self.lp_filter_combo2 = QComboBox(); self.lp_filter_combo2.addItems(self.rgb_filter_choices); row2.addWidget(self.lp_filter_combo2)
|
|
237
|
+
row2.addSpacing(20); row2.addWidget(QLabel(self.tr("Sensor (QE):")))
|
|
238
|
+
self.sens_combo = QComboBox(); self.sens_combo.addItems(self.sensor_list); row2.addWidget(self.sens_combo)
|
|
239
|
+
|
|
240
|
+
self.plot_btn = QPushButton(self.tr("Plot")); self.plot_btn.clicked.connect(self.update_plot); row.addWidget(self.plot_btn)
|
|
241
|
+
|
|
242
|
+
self.figure = Figure(figsize=(9, 6)); self.canvas = FigureCanvas(self.figure); vbox.addWidget(self.canvas)
|
|
243
|
+
self.update_plot()
|
|
244
|
+
|
|
245
|
+
def closeEvent(self, event):
|
|
246
|
+
self.base_hdul.close(); self.custom_hdul.close()
|
|
247
|
+
super().closeEvent(event)
|
|
248
|
+
|
|
249
|
+
def load_any(self, extname, field):
|
|
250
|
+
for hdul in (self.custom_hdul, self.base_hdul):
|
|
251
|
+
if extname in hdul:
|
|
252
|
+
return hdul[extname].data[field].astype(float)
|
|
253
|
+
raise KeyError(f"Extension '{extname}' not found")
|
|
254
|
+
|
|
255
|
+
def update_plot(self):
|
|
256
|
+
star_ext = self.star_combo.currentText()
|
|
257
|
+
r_filt = self.r_filter_combo.currentText()
|
|
258
|
+
g_filt = self.g_filter_combo.currentText()
|
|
259
|
+
b_filt = self.b_filter_combo.currentText()
|
|
260
|
+
sens_ext = self.sens_combo.currentText()
|
|
261
|
+
lp_ext1 = self.lp_filter_combo.currentText()
|
|
262
|
+
lp_ext2 = self.lp_filter_combo2.currentText()
|
|
263
|
+
|
|
264
|
+
wl_star = self.load_any(star_ext, "WAVELENGTH")
|
|
265
|
+
fl_star = self.load_any(star_ext, "FLUX")
|
|
266
|
+
wl_sens = self.load_any(sens_ext, "WAVELENGTH")
|
|
267
|
+
qe_sens = self.load_any(sens_ext, "THROUGHPUT")
|
|
268
|
+
|
|
269
|
+
wl_min, wl_max = 1150.0, 10620.0
|
|
270
|
+
common_wl = np.arange(wl_min, wl_max + 1.0, 1.0)
|
|
271
|
+
|
|
272
|
+
sed_interp = interp1d(wl_star, fl_star, kind="linear", bounds_error=False, fill_value=0.0)
|
|
273
|
+
sens_interp = interp1d(wl_sens, qe_sens, kind="linear", bounds_error=False, fill_value=0.0)
|
|
274
|
+
fl_common = sed_interp(common_wl)
|
|
275
|
+
sens_common = sens_interp(common_wl)
|
|
276
|
+
|
|
277
|
+
rgb_data = {}
|
|
278
|
+
for color, filt_name in (("red", r_filt), ("green", g_filt), ("blue", b_filt)):
|
|
279
|
+
if filt_name == "(None)":
|
|
280
|
+
rgb_data[color] = None; continue
|
|
281
|
+
|
|
282
|
+
wl_filt = self.load_any(filt_name, "WAVELENGTH")
|
|
283
|
+
tr_filt = self.load_any(filt_name, "THROUGHPUT")
|
|
284
|
+
filt_common = interp1d(wl_filt, tr_filt, bounds_error=False, fill_value=0.0)(common_wl)
|
|
285
|
+
|
|
286
|
+
def lp_curve(ext):
|
|
287
|
+
if ext == "(None)": return np.ones_like(common_wl)
|
|
288
|
+
wl_lp = self.load_any(ext, "WAVELENGTH"); tr_lp = self.load_any(ext, "THROUGHPUT")
|
|
289
|
+
return interp1d(wl_lp, tr_lp, bounds_error=False, fill_value=0.0)(common_wl)
|
|
290
|
+
|
|
291
|
+
T_LP = lp_curve(lp_ext1) * lp_curve(lp_ext2)
|
|
292
|
+
T_sys = filt_common * sens_common * T_LP
|
|
293
|
+
resp = fl_common * T_sys
|
|
294
|
+
|
|
295
|
+
rgb_data[color] = {"filter_name": filt_name, "T_sys": T_sys, "response": resp}
|
|
296
|
+
|
|
297
|
+
mag_texts = []
|
|
298
|
+
if "A0V" in self.pickles_templates:
|
|
299
|
+
wl_veg = self.load_any("A0V", "WAVELENGTH")
|
|
300
|
+
fl_veg = self.load_any("A0V", "FLUX")
|
|
301
|
+
fl_veg_c = interp1d(wl_veg, fl_veg, kind="linear", bounds_error=False, fill_value=0.0)(common_wl)
|
|
302
|
+
for color in ("red","green","blue"):
|
|
303
|
+
data = rgb_data[color]
|
|
304
|
+
if data is not None:
|
|
305
|
+
S_star = np.trapezoid(data["response"], x=common_wl)
|
|
306
|
+
S_veg = np.trapezoid(fl_veg_c * data["T_sys"], x=common_wl)
|
|
307
|
+
if S_veg>0 and S_star>0:
|
|
308
|
+
mag = -2.5 * np.log10(S_star / S_veg)
|
|
309
|
+
mag_texts.append(f"{color[0].upper()}→{data['filter_name']}: {mag:.2f}")
|
|
310
|
+
else:
|
|
311
|
+
mag_texts.append(f"{color[0].upper()}→{data['filter_name']}: N/A")
|
|
312
|
+
title_text = " | ".join(mag_texts) if mag_texts else self.tr("No channels selected")
|
|
313
|
+
|
|
314
|
+
self.figure.clf()
|
|
315
|
+
ax1 = self.figure.add_subplot(111)
|
|
316
|
+
ax1.plot(common_wl, fl_common, color="black", linewidth=1, label=f"{star_ext} SED")
|
|
317
|
+
for color, data in rgb_data.items():
|
|
318
|
+
if data is not None:
|
|
319
|
+
ax1.plot(common_wl, data["response"], color="gold", linewidth=1.5, label=self.tr("{0} Response").format(color.upper()))
|
|
320
|
+
ax1.set_xlim(wl_min, wl_max); ax1.set_xlabel(self.tr("Wavelength (Å)"))
|
|
321
|
+
ax1.set_ylabel(self.tr("Flux (erg s⁻¹ cm⁻² Å⁻¹)"), color="black"); ax1.tick_params(axis="y", labelcolor="black")
|
|
322
|
+
|
|
323
|
+
ax2 = ax1.twinx()
|
|
324
|
+
ax2.set_ylabel(self.tr("Relative Throughput"), color="red"); ax2.tick_params(axis="y", labelcolor="red"); ax2.set_ylim(0.0, 1.0)
|
|
325
|
+
if rgb_data["red"] is not None: ax2.plot(common_wl, rgb_data["red"]["T_sys"], color="red", linestyle="--", linewidth=1, label=self.tr("R filter×QE"))
|
|
326
|
+
if rgb_data["green"] is not None: ax2.plot(common_wl, rgb_data["green"]["T_sys"], color="green", linestyle="--", linewidth=1, label=self.tr("G filter×QE"))
|
|
327
|
+
if rgb_data["blue"] is not None: ax2.plot(common_wl, rgb_data["blue"]["T_sys"], color="blue", linestyle="--", linewidth=1, label=self.tr("B filter×QE"))
|
|
328
|
+
|
|
329
|
+
ax1.grid(True, which="both", linestyle="--", alpha=0.3); self.figure.suptitle(title_text, fontsize=10)
|
|
330
|
+
lines1, labels1 = ax1.get_legend_handles_labels(); lines2, labels2 = ax2.get_legend_handles_labels()
|
|
331
|
+
ax1.legend(lines1 + lines2, labels1 + labels2, loc="upper right")
|
|
332
|
+
self.canvas.draw()
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
336
|
+
# SFCC Dialog (rewired for "current view")
|
|
337
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
338
|
+
class SFCCDialog(QDialog):
|
|
339
|
+
"""
|
|
340
|
+
Spectral Flux Color Calibration dialog, adapted for SASpro's current view.
|
|
341
|
+
Pass a 'view' adapter providing:
|
|
342
|
+
- get_rgb_image(), set_rgb_image(...)
|
|
343
|
+
- get_metadata() [optional]
|
|
344
|
+
- get_header() [preferred for WCS; else we look in metadata]
|
|
345
|
+
"""
|
|
346
|
+
def __init__(self, doc_manager, sasp_data_path, parent=None):
|
|
347
|
+
super().__init__(parent)
|
|
348
|
+
self.setWindowTitle(self.tr("Spectral Flux Color Calibration"))
|
|
349
|
+
self.setWindowFlag(Qt.WindowType.Window, True)
|
|
350
|
+
self.setWindowModality(Qt.WindowModality.NonModal)
|
|
351
|
+
self.setModal(False)
|
|
352
|
+
self.setMinimumSize(800, 600)
|
|
353
|
+
|
|
354
|
+
self.doc_manager = doc_manager
|
|
355
|
+
self.sasp_data_path = sasp_data_path
|
|
356
|
+
self.user_custom_path = self._ensure_user_custom_fits()
|
|
357
|
+
self.current_image = None
|
|
358
|
+
self.current_header = None
|
|
359
|
+
self.orientation_label = QLabel(self.tr("Orientation: N/A"))
|
|
360
|
+
self.sasp_viewer_window = None
|
|
361
|
+
self.main_win = parent
|
|
362
|
+
|
|
363
|
+
# user custom file init … (unchanged)
|
|
364
|
+
# ...
|
|
365
|
+
self._reload_hdu_lists()
|
|
366
|
+
self.star_list = []
|
|
367
|
+
self._build_ui()
|
|
368
|
+
self.load_settings()
|
|
369
|
+
|
|
370
|
+
# persist combobox choices
|
|
371
|
+
self.r_filter_combo.currentIndexChanged.connect(self.save_r_filter_setting)
|
|
372
|
+
self.g_filter_combo.currentIndexChanged.connect(self.save_g_filter_setting)
|
|
373
|
+
self.b_filter_combo.currentIndexChanged.connect(self.save_b_filter_setting)
|
|
374
|
+
self.lp_filter_combo.currentIndexChanged.connect(self.save_lp_setting)
|
|
375
|
+
self.lp_filter_combo2.currentIndexChanged.connect(self.save_lp2_setting)
|
|
376
|
+
self.sens_combo.currentIndexChanged.connect(self.save_sensor_setting)
|
|
377
|
+
self.star_combo.currentIndexChanged.connect(self.save_star_setting)
|
|
378
|
+
|
|
379
|
+
self.grad_method = "poly3"
|
|
380
|
+
self.grad_method_combo.currentTextChanged.connect(lambda m: setattr(self, "grad_method", m))
|
|
381
|
+
|
|
382
|
+
# ── View plumbing ───────────────────────────────────────────────────
|
|
383
|
+
def _get_active_image_and_header(self):
|
|
384
|
+
doc = self.doc_manager.get_active_document()
|
|
385
|
+
if doc is None:
|
|
386
|
+
return None, None, None
|
|
387
|
+
|
|
388
|
+
img = doc.image
|
|
389
|
+
meta = doc.metadata or {}
|
|
390
|
+
|
|
391
|
+
# Prefer the normalized WCS header if present, then fall back
|
|
392
|
+
hdr = (
|
|
393
|
+
meta.get("wcs_header") or
|
|
394
|
+
meta.get("original_header") or
|
|
395
|
+
meta.get("header")
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
return img, hdr, meta
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _get_img_meta(self) -> Tuple[Optional[np.ndarray], dict]:
|
|
402
|
+
"""Try a few common shapes to obtain image + metadata from the view."""
|
|
403
|
+
meta = {}
|
|
404
|
+
img = None
|
|
405
|
+
if hasattr(self.view, "get_image_and_metadata"):
|
|
406
|
+
try:
|
|
407
|
+
img, meta = self.view.get_image_and_metadata()
|
|
408
|
+
except Exception:
|
|
409
|
+
pass
|
|
410
|
+
if img is None and hasattr(self.view, "get_rgb_image"):
|
|
411
|
+
img = self.view.get_rgb_image()
|
|
412
|
+
if not meta and hasattr(self.view, "get_metadata"):
|
|
413
|
+
try:
|
|
414
|
+
meta = self.view.get_metadata() or {}
|
|
415
|
+
except Exception:
|
|
416
|
+
meta = {}
|
|
417
|
+
return img, (meta or {})
|
|
418
|
+
|
|
419
|
+
def _get_header(self):
|
|
420
|
+
header = None
|
|
421
|
+
if hasattr(self.view, "get_header"):
|
|
422
|
+
try:
|
|
423
|
+
header = self.view.get_header()
|
|
424
|
+
except Exception:
|
|
425
|
+
header = None
|
|
426
|
+
if header is None:
|
|
427
|
+
# fall back to metadata
|
|
428
|
+
_, meta = self._get_img_meta()
|
|
429
|
+
header = meta.get("original_header") or meta.get("header")
|
|
430
|
+
return header
|
|
431
|
+
|
|
432
|
+
def _push_image(self, img: np.ndarray, meta: Optional[dict], step_name: str):
|
|
433
|
+
"""Send image back to the same current view."""
|
|
434
|
+
if hasattr(self.view, "set_rgb_image"):
|
|
435
|
+
self.view.set_rgb_image(img, meta or {}, step_name)
|
|
436
|
+
elif hasattr(self.view, "set_image"):
|
|
437
|
+
self.view.set_image(img, meta or {}, step_name=step_name)
|
|
438
|
+
elif hasattr(self.view, "update_image"):
|
|
439
|
+
self.view.update_image(img, meta or {}, step_name=step_name)
|
|
440
|
+
else:
|
|
441
|
+
# As a last resort, try attribute assignment (for custom apps)
|
|
442
|
+
if hasattr(self.view, "image"):
|
|
443
|
+
self.view.image = img
|
|
444
|
+
if hasattr(self.view, "metadata"):
|
|
445
|
+
self.view.metadata = meta or {}
|
|
446
|
+
|
|
447
|
+
# ── File prep ───────────────────────────────────────────────────────
|
|
448
|
+
|
|
449
|
+
def _ensure_user_custom_fits(self) -> str:
|
|
450
|
+
app_data = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.AppDataLocation)
|
|
451
|
+
os.makedirs(app_data, exist_ok=True)
|
|
452
|
+
path = os.path.join(app_data, "usercustomcurves.fits")
|
|
453
|
+
if not os.path.exists(path):
|
|
454
|
+
fits.HDUList([fits.PrimaryHDU()]).writeto(path)
|
|
455
|
+
return path
|
|
456
|
+
|
|
457
|
+
# ── UI ──────────────────────────────────────────────────────────────
|
|
458
|
+
|
|
459
|
+
def _build_ui(self):
|
|
460
|
+
layout = QVBoxLayout(self)
|
|
461
|
+
|
|
462
|
+
row1 = QHBoxLayout(); layout.addLayout(row1)
|
|
463
|
+
self.fetch_stars_btn = QPushButton(self.tr("Step 1: Fetch Stars from Current View"))
|
|
464
|
+
f = self.fetch_stars_btn.font(); f.setBold(True); self.fetch_stars_btn.setFont(f)
|
|
465
|
+
self.fetch_stars_btn.clicked.connect(self.fetch_stars)
|
|
466
|
+
row1.addWidget(self.fetch_stars_btn)
|
|
467
|
+
|
|
468
|
+
self.open_sasp_btn = QPushButton(self.tr("Open SASP Viewer"))
|
|
469
|
+
self.open_sasp_btn.clicked.connect(self.open_sasp_viewer)
|
|
470
|
+
row1.addWidget(self.open_sasp_btn)
|
|
471
|
+
|
|
472
|
+
row1.addSpacing(20)
|
|
473
|
+
row1.addWidget(QLabel(self.tr("Select White Reference:")))
|
|
474
|
+
self.star_combo = QComboBox()
|
|
475
|
+
self.star_combo.addItem(self.tr("Vega (A0V)"), userData="A0V")
|
|
476
|
+
for sed in getattr(self, "sed_list", []):
|
|
477
|
+
if sed.upper() == "A0V": continue
|
|
478
|
+
self.star_combo.addItem(sed, userData=sed)
|
|
479
|
+
row1.addWidget(self.star_combo)
|
|
480
|
+
idx_g2v = self.star_combo.findData("G2V")
|
|
481
|
+
if idx_g2v >= 0: self.star_combo.setCurrentIndex(idx_g2v)
|
|
482
|
+
|
|
483
|
+
row2 = QHBoxLayout(); layout.addLayout(row2)
|
|
484
|
+
row2.addWidget(QLabel(self.tr("R Filter:")))
|
|
485
|
+
self.r_filter_combo = QComboBox(); self.r_filter_combo.addItem("(None)"); self.r_filter_combo.addItems(self.filter_list); row2.addWidget(self.r_filter_combo)
|
|
486
|
+
row2.addSpacing(20); row2.addWidget(QLabel(self.tr("G Filter:")))
|
|
487
|
+
self.g_filter_combo = QComboBox(); self.g_filter_combo.addItem("(None)"); self.g_filter_combo.addItems(self.filter_list); row2.addWidget(self.g_filter_combo)
|
|
488
|
+
row2.addSpacing(20); row2.addWidget(QLabel(self.tr("B Filter:")))
|
|
489
|
+
self.b_filter_combo = QComboBox(); self.b_filter_combo.addItem("(None)"); self.b_filter_combo.addItems(self.filter_list); row2.addWidget(self.b_filter_combo)
|
|
490
|
+
|
|
491
|
+
row3 = QHBoxLayout(); layout.addLayout(row3)
|
|
492
|
+
row3.addStretch()
|
|
493
|
+
row3.addWidget(QLabel(self.tr("Sensor (QE):")))
|
|
494
|
+
self.sens_combo = QComboBox(); self.sens_combo.addItem("(None)"); self.sens_combo.addItems(self.sensor_list); row3.addWidget(self.sens_combo)
|
|
495
|
+
row3.addSpacing(20); row3.addWidget(QLabel(self.tr("LP/Cut Filter1:")))
|
|
496
|
+
self.lp_filter_combo = QComboBox(); self.lp_filter_combo.addItem("(None)"); self.lp_filter_combo.addItems(self.filter_list); row3.addWidget(self.lp_filter_combo)
|
|
497
|
+
row3.addSpacing(20); row3.addWidget(QLabel(self.tr("LP/Cut Filter2:")))
|
|
498
|
+
self.lp_filter_combo2 = QComboBox(); self.lp_filter_combo2.addItem("(None)"); self.lp_filter_combo2.addItems(self.filter_list); row3.addWidget(self.lp_filter_combo2)
|
|
499
|
+
row3.addStretch()
|
|
500
|
+
|
|
501
|
+
row4 = QHBoxLayout(); layout.addLayout(row4)
|
|
502
|
+
self.run_spcc_btn = QPushButton(self.tr("Step 2: Run Color Calibration"))
|
|
503
|
+
f2 = self.run_spcc_btn.font(); f2.setBold(True); self.run_spcc_btn.setFont(f2)
|
|
504
|
+
self.run_spcc_btn.clicked.connect(self.run_spcc)
|
|
505
|
+
row4.addWidget(self.run_spcc_btn)
|
|
506
|
+
|
|
507
|
+
self.neutralize_chk = QCheckBox(self.tr("Background Neutralization")); self.neutralize_chk.setChecked(True); row4.addWidget(self.neutralize_chk)
|
|
508
|
+
|
|
509
|
+
self.run_grad_btn = QPushButton(self.tr("Run Gradient Extraction (Beta)"))
|
|
510
|
+
f3 = self.run_grad_btn.font(); f3.setBold(True); self.run_grad_btn.setFont(f3)
|
|
511
|
+
self.run_grad_btn.clicked.connect(self.run_gradient_extraction)
|
|
512
|
+
row4.addWidget(self.run_grad_btn)
|
|
513
|
+
|
|
514
|
+
self.grad_method_combo = QComboBox(); self.grad_method_combo.addItems(["poly2","poly3","rbf"]); self.grad_method_combo.setCurrentText("poly3")
|
|
515
|
+
row4.addWidget(self.grad_method_combo)
|
|
516
|
+
|
|
517
|
+
row4.addSpacing(15)
|
|
518
|
+
row4.addWidget(QLabel(self.tr("Star detect σ:")))
|
|
519
|
+
self.sep_thr_spin = QSpinBox()
|
|
520
|
+
self.sep_thr_spin.setRange(2, 50) # should be enough
|
|
521
|
+
self.sep_thr_spin.setValue(5) # our current hardcoded value
|
|
522
|
+
self.sep_thr_spin.valueChanged.connect(self.save_sep_threshold_setting)
|
|
523
|
+
row4.addWidget(self.sep_thr_spin)
|
|
524
|
+
|
|
525
|
+
row4.addStretch()
|
|
526
|
+
self.add_curve_btn = QPushButton(self.tr("Add Custom Filter/Sensor Curve…"))
|
|
527
|
+
self.add_curve_btn.clicked.connect(self.add_custom_curve); row4.addWidget(self.add_curve_btn)
|
|
528
|
+
self.remove_curve_btn = QPushButton(self.tr("Remove Filter/Sensor Curve…"))
|
|
529
|
+
self.remove_curve_btn.clicked.connect(self.remove_custom_curve); row4.addWidget(self.remove_curve_btn)
|
|
530
|
+
row4.addStretch()
|
|
531
|
+
self.close_btn = QPushButton(self.tr("Close")); self.close_btn.clicked.connect(self.close); row4.addWidget(self.close_btn)
|
|
532
|
+
|
|
533
|
+
self.count_label = QLabel(""); layout.addWidget(self.count_label)
|
|
534
|
+
|
|
535
|
+
self.figure = Figure(figsize=(6, 4)); self.canvas = FigureCanvas(self.figure); self.canvas.setVisible(False); layout.addWidget(self.canvas, stretch=1)
|
|
536
|
+
self.reset_btn = QPushButton(self.tr("Reset View/Close")); self.reset_btn.clicked.connect(self.close); layout.addWidget(self.reset_btn)
|
|
537
|
+
|
|
538
|
+
# hide gradient controls by default (enable if you like)
|
|
539
|
+
self.run_grad_btn.hide(); self.grad_method_combo.hide()
|
|
540
|
+
layout.addWidget(self.orientation_label)
|
|
541
|
+
|
|
542
|
+
# ── Settings helpers ────────────────────────────────────────────────
|
|
543
|
+
|
|
544
|
+
def _reload_hdu_lists(self):
|
|
545
|
+
self.sed_list = []
|
|
546
|
+
with fits.open(self.sasp_data_path, mode="readonly", memmap=False) as base:
|
|
547
|
+
for hdu in base:
|
|
548
|
+
if isinstance(hdu, fits.BinTableHDU) and hdu.header.get("CTYPE","").upper()=="SED":
|
|
549
|
+
self.sed_list.append(hdu.header["EXTNAME"])
|
|
550
|
+
|
|
551
|
+
self.filter_list = []; self.sensor_list = []
|
|
552
|
+
for path in (self.sasp_data_path, self.user_custom_path):
|
|
553
|
+
with fits.open(path, mode="readonly", memmap=False) as hdul:
|
|
554
|
+
for hdu in hdul:
|
|
555
|
+
if not isinstance(hdu, fits.BinTableHDU): continue
|
|
556
|
+
c = hdu.header.get("CTYPE","").upper(); e = hdu.header.get("EXTNAME","")
|
|
557
|
+
if c=="FILTER": self.filter_list.append(e)
|
|
558
|
+
elif c=="SENSOR": self.sensor_list.append(e)
|
|
559
|
+
self.sed_list.sort(); self.filter_list.sort(); self.sensor_list.sort()
|
|
560
|
+
|
|
561
|
+
def load_settings(self):
|
|
562
|
+
s = QSettings()
|
|
563
|
+
def apply(cb, key):
|
|
564
|
+
val = s.value(key, "")
|
|
565
|
+
if val:
|
|
566
|
+
idx = cb.findText(val)
|
|
567
|
+
if idx != -1:
|
|
568
|
+
cb.setCurrentIndex(idx)
|
|
569
|
+
|
|
570
|
+
# existing stuff...
|
|
571
|
+
saved_star = QSettings().value("SFCC/WhiteReference", "")
|
|
572
|
+
if saved_star:
|
|
573
|
+
idx = self.star_combo.findText(saved_star)
|
|
574
|
+
if idx != -1:
|
|
575
|
+
self.star_combo.setCurrentIndex(idx)
|
|
576
|
+
|
|
577
|
+
apply(self.r_filter_combo, "SFCC/RFilter")
|
|
578
|
+
apply(self.g_filter_combo, "SFCC/GFilter")
|
|
579
|
+
apply(self.b_filter_combo, "SFCC/BFilter")
|
|
580
|
+
apply(self.sens_combo, "SFCC/Sensor")
|
|
581
|
+
apply(self.lp_filter_combo, "SFCC/LPFilter")
|
|
582
|
+
apply(self.lp_filter_combo2, "SFCC/LPFilter2")
|
|
583
|
+
|
|
584
|
+
# 👇 NEW: load SEP/star-detect threshold
|
|
585
|
+
sep_thr = int(s.value("SFCC/SEPThreshold", 5))
|
|
586
|
+
if hasattr(self, "sep_thr_spin"):
|
|
587
|
+
self.sep_thr_spin.setValue(sep_thr)
|
|
588
|
+
def save_sep_threshold_setting(self, v: int):
|
|
589
|
+
QSettings().setValue("SFCC/SEPThreshold", int(v))
|
|
590
|
+
|
|
591
|
+
def save_lp_setting(self, _): QSettings().setValue("SFCC/LPFilter", self.lp_filter_combo.currentText())
|
|
592
|
+
def save_lp2_setting(self, _): QSettings().setValue("SFCC/LPFilter2", self.lp_filter_combo2.currentText())
|
|
593
|
+
def save_star_setting(self, _): QSettings().setValue("SFCC/WhiteReference", self.star_combo.currentText())
|
|
594
|
+
def save_r_filter_setting(self, _): QSettings().setValue("SFCC/RFilter", self.r_filter_combo.currentText())
|
|
595
|
+
def save_g_filter_setting(self, _): QSettings().setValue("SFCC/GFilter", self.g_filter_combo.currentText())
|
|
596
|
+
def save_b_filter_setting(self, _): QSettings().setValue("SFCC/BFilter", self.b_filter_combo.currentText())
|
|
597
|
+
def save_sensor_setting(self, _): QSettings().setValue("SFCC/Sensor", self.sens_combo.currentText())
|
|
598
|
+
|
|
599
|
+
# ── Curve utilities ─────────────────────────────────────────────────
|
|
600
|
+
|
|
601
|
+
def interpolate_bad_points(self, wl, tr):
|
|
602
|
+
tr = tr.copy()
|
|
603
|
+
bad = (tr < 0.0) | (tr > 1.0)
|
|
604
|
+
good = ~bad
|
|
605
|
+
if not np.any(bad): return tr, np.array([], dtype=int)
|
|
606
|
+
if np.sum(good) < 2: raise RuntimeError("Not enough valid points to interpolate anomalies.")
|
|
607
|
+
tr_corr = tr.copy()
|
|
608
|
+
tr_corr[bad] = np.interp(wl[bad], wl[good], tr[good])
|
|
609
|
+
return tr_corr, np.where(bad)[0]
|
|
610
|
+
|
|
611
|
+
def smooth_curve(self, tr, window_size=5):
|
|
612
|
+
return medfilt(tr, kernel_size=window_size)
|
|
613
|
+
|
|
614
|
+
def get_calibration_points(self, rgb_img: np.ndarray):
|
|
615
|
+
print("\nClick three calibration points: BL (λmin,0), BR (λmax,0), TL (λmin,1)")
|
|
616
|
+
fig, ax = plt.subplots(figsize=(8, 5)); ax.imshow(rgb_img); ax.set_title(self.tr("Click 3 points, then close"))
|
|
617
|
+
pts = plt.ginput(3, timeout=-1); plt.close(fig)
|
|
618
|
+
if len(pts) != 3: raise RuntimeError(self.tr("Need exactly three clicks for calibration."))
|
|
619
|
+
return pts[0], pts[1], pts[2]
|
|
620
|
+
|
|
621
|
+
def build_transforms(self, px_bl, py_bl, px_br, py_br, px_tl, py_tl, λ_min, λ_max, resp_min, resp_max):
|
|
622
|
+
nm_per_px = (λ_max - λ_min) / (px_br - px_bl)
|
|
623
|
+
resp_per_px = (resp_max - resp_min) / (py_bl - py_tl)
|
|
624
|
+
def px_to_λ(px): return λ_min + (px - px_bl) * nm_per_px
|
|
625
|
+
def py_to_resp(py): return resp_max - (py - py_tl) * resp_per_px
|
|
626
|
+
return px_to_λ, py_to_resp
|
|
627
|
+
|
|
628
|
+
def extract_curve(self, gray_img, λ_mapper, resp_mapper, λ_min, λ_max, threshold=50):
|
|
629
|
+
H, W = gray_img.shape
|
|
630
|
+
data = []
|
|
631
|
+
for px in range(W):
|
|
632
|
+
col = gray_img[:, px]
|
|
633
|
+
py_min = int(np.argmin(col)); val_min = int(col[py_min])
|
|
634
|
+
if val_min < threshold:
|
|
635
|
+
lam = λ_mapper(px)
|
|
636
|
+
if λ_min <= lam <= λ_max:
|
|
637
|
+
data.append((lam, resp_mapper(py_min)))
|
|
638
|
+
if not data:
|
|
639
|
+
raise RuntimeError("No dark pixels found; raise threshold or adjust clicks.")
|
|
640
|
+
df = (pd.DataFrame(data, columns=["wavelength_nm", "response"])
|
|
641
|
+
.sort_values("wavelength_nm").reset_index(drop=True))
|
|
642
|
+
df = df[(df["wavelength_nm"] >= λ_min) & (df["wavelength_nm"] <= λ_max)].copy()
|
|
643
|
+
if df["wavelength_nm"].iloc[0] > λ_min:
|
|
644
|
+
df = pd.concat([pd.DataFrame([[λ_min, 0.0]], columns=["wavelength_nm", "response"]), df], ignore_index=True)
|
|
645
|
+
if df["wavelength_nm"].iloc[-1] < λ_max:
|
|
646
|
+
df = pd.concat([df, pd.DataFrame([[λ_max, 0.0]], columns=["wavelength_nm", "response"])], ignore_index=True)
|
|
647
|
+
return df.sort_values("wavelength_nm").reset_index(drop=True)
|
|
648
|
+
|
|
649
|
+
def _query_name_channel(self):
|
|
650
|
+
name_str, ok1 = QInputDialog.getText(self, self.tr("Curve Name"), self.tr("Enter curve name (EXTNAME):"))
|
|
651
|
+
if not (ok1 and name_str.strip()): return False, None, None
|
|
652
|
+
extname = name_str.strip().upper().replace(" ", "_")
|
|
653
|
+
ch_str, ok2 = QInputDialog.getText(self, self.tr("Channel"), self.tr("Enter channel (R,G,B or Q for sensor):"))
|
|
654
|
+
if not (ok2 and ch_str.strip()): return False, None, None
|
|
655
|
+
return True, extname, ch_str.strip().upper()
|
|
656
|
+
|
|
657
|
+
def _append_curve_hdu(self, wl_ang, tr_final, extname, ctype, origin):
|
|
658
|
+
col_wl = fits.Column(name="WAVELENGTH", format="E", unit="Angstrom", array=wl_ang.astype(np.float32))
|
|
659
|
+
col_tr = fits.Column(name="THROUGHPUT", format="E", unit="REL", array=tr_final.astype(np.float32))
|
|
660
|
+
new_hdu = fits.BinTableHDU.from_columns([col_wl, col_tr])
|
|
661
|
+
new_hdu.header["EXTNAME"] = extname
|
|
662
|
+
new_hdu.header["CTYPE"] = ctype
|
|
663
|
+
new_hdu.header["ORIGIN"] = origin
|
|
664
|
+
with fits.open(self.user_custom_path, mode="update", memmap=False) as hdul:
|
|
665
|
+
hdul.append(new_hdu); hdul.flush()
|
|
666
|
+
|
|
667
|
+
def add_custom_curve(self):
|
|
668
|
+
msg = QMessageBox(self); msg.setWindowTitle(self.tr("Add Custom Curve")); msg.setText(self.tr("Choose how to add the curve:"))
|
|
669
|
+
csv_btn = msg.addButton(self.tr("Import CSV"), QMessageBox.ButtonRole.AcceptRole)
|
|
670
|
+
img_btn = msg.addButton(self.tr("Digitize Image"), QMessageBox.ButtonRole.AcceptRole)
|
|
671
|
+
cancel_btn = msg.addButton(QMessageBox.StandardButton.Cancel)
|
|
672
|
+
msg.exec()
|
|
673
|
+
if msg.clickedButton() == csv_btn: self._import_curve_from_csv()
|
|
674
|
+
elif msg.clickedButton() == img_btn: self._digitize_curve_from_image()
|
|
675
|
+
|
|
676
|
+
def _import_curve_from_csv(self):
|
|
677
|
+
csv_path, _ = QFileDialog.getOpenFileName(self, self.tr("Select 2-column CSV (λ_nm, response)"), "", "CSV Files (*.csv);;All Files (*)")
|
|
678
|
+
if not csv_path: return
|
|
679
|
+
try:
|
|
680
|
+
df = (pd.read_csv(csv_path, comment="#", header=None).iloc[:, :2].dropna())
|
|
681
|
+
df.columns = ["wavelength_nm","response"]
|
|
682
|
+
wl_nm = df["wavelength_nm"].astype(float).to_numpy(); tp = df["response"].astype(float).to_numpy()
|
|
683
|
+
except ValueError:
|
|
684
|
+
try:
|
|
685
|
+
df = (pd.read_csv(csv_path, comment="#", header=0).iloc[:, :2].dropna())
|
|
686
|
+
df.columns = ["wavelength_nm","response"]
|
|
687
|
+
wl_nm = df["wavelength_nm"].astype(float).to_numpy(); tp = df["response"].astype(float).to_numpy()
|
|
688
|
+
except Exception as e2:
|
|
689
|
+
QMessageBox.critical(self, self.tr("CSV Error"), self.tr("Could not read CSV:\n{0}").format(e2)); return
|
|
690
|
+
except Exception as e:
|
|
691
|
+
QMessageBox.critical(self, self.tr("CSV Error"), self.tr("Could not read CSV:\n{0}").format(e)); return
|
|
692
|
+
|
|
693
|
+
ok, extname_base, channel_val = self._query_name_channel()
|
|
694
|
+
if not ok: return
|
|
695
|
+
wl_ang = (wl_nm * 10.0).astype(np.float32); tr_final = tp.astype(np.float32)
|
|
696
|
+
self._append_curve_hdu(wl_ang, tr_final, extname_base, "SENSOR" if channel_val=="Q" else "FILTER", f"CSV:{os.path.basename(csv_path)}")
|
|
697
|
+
self._reload_hdu_lists(); self.refresh_filter_sensor_lists()
|
|
698
|
+
QMessageBox.information(self, self.tr("Done"), self.tr("CSV curve '{0}' added.").format(extname_base))
|
|
699
|
+
|
|
700
|
+
def _digitize_curve_from_image(self):
|
|
701
|
+
img_path_str, _ = QFileDialog.getOpenFileName(self, self.tr("Select Curve Image to Digitize"), "", "Images (*.png *.jpg *.jpeg *.bmp);;All Files (*)")
|
|
702
|
+
if not img_path_str: return
|
|
703
|
+
img_filename = os.path.basename(img_path_str)
|
|
704
|
+
try:
|
|
705
|
+
bgr = cv2.imread(img_path_str)
|
|
706
|
+
if bgr is None: raise RuntimeError(f"cv2.imread returned None for '{img_path_str}'")
|
|
707
|
+
rgb_img = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB); gray_img = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY)
|
|
708
|
+
except Exception as e:
|
|
709
|
+
QMessageBox.critical(self, self.tr("Error"), self.tr("Could not load image:\n{0}").format(e)); return
|
|
710
|
+
|
|
711
|
+
try:
|
|
712
|
+
(px_bl, py_bl), (px_br, py_br), (px_tl, py_tl) = self.get_calibration_points(rgb_img)
|
|
713
|
+
except Exception as e:
|
|
714
|
+
QMessageBox.critical(self, self.tr("Digitization Error"), str(e)); return
|
|
715
|
+
|
|
716
|
+
λ_min_str, ok1 = QInputDialog.getText(self, self.tr("λ_min"), self.tr("Enter λ_min (in nm):"))
|
|
717
|
+
λ_max_str, ok2 = QInputDialog.getText(self, self.tr("λ_max"), self.tr("Enter λ_max (in nm):"))
|
|
718
|
+
if not (ok1 and ok2 and λ_min_str.strip() and λ_max_str.strip()): return
|
|
719
|
+
try:
|
|
720
|
+
λ_min = float(λ_min_str); λ_max = float(λ_max_str)
|
|
721
|
+
except ValueError:
|
|
722
|
+
QMessageBox.critical(self, self.tr("Input Error"), self.tr("λ_min and λ_max must be numbers.")); return
|
|
723
|
+
|
|
724
|
+
ok, extname_base, channel_val = self._query_name_channel()
|
|
725
|
+
if not ok: return
|
|
726
|
+
|
|
727
|
+
px_to_λ, py_to_resp = self.build_transforms(px_bl, py_bl, px_br, py_br, px_tl, py_tl, λ_min, λ_max, 0.0, 1.0)
|
|
728
|
+
try:
|
|
729
|
+
df_curve = self.extract_curve(gray_img, px_to_λ, py_to_resp, λ_min, λ_max, threshold=50)
|
|
730
|
+
except Exception as e:
|
|
731
|
+
QMessageBox.critical(self, self.tr("Extraction Error"), str(e)); return
|
|
732
|
+
|
|
733
|
+
df_curve["wl_int"] = df_curve["wavelength_nm"].round().astype(int)
|
|
734
|
+
grp = (df_curve.groupby("wl_int")["response"].median().reset_index().sort_values("wl_int"))
|
|
735
|
+
wl = grp["wl_int"].to_numpy(dtype=int); tr = grp["response"].to_numpy(dtype=float)
|
|
736
|
+
|
|
737
|
+
try:
|
|
738
|
+
tr_corr, _ = self.interpolate_bad_points(wl, tr)
|
|
739
|
+
except Exception as e:
|
|
740
|
+
QMessageBox.critical(self, self.tr("Interpolation Error"), str(e)); return
|
|
741
|
+
|
|
742
|
+
tr_smoothed = self.smooth_curve(tr_corr, window_size=5)
|
|
743
|
+
wl_ang = (wl.astype(float) * 10.0).astype(np.float32); tr_final = tr_smoothed.astype(np.float32)
|
|
744
|
+
self._append_curve_hdu(wl_ang, tr_final, extname_base, "SENSOR" if channel_val=="Q" else "FILTER", f"UserDefined:{img_filename}")
|
|
745
|
+
self._reload_hdu_lists(); self.refresh_filter_sensor_lists()
|
|
746
|
+
QMessageBox.information(self, self.tr("Done"), self.tr("Added curve '{0}'.").format(extname_base))
|
|
747
|
+
|
|
748
|
+
def remove_custom_curve(self):
|
|
749
|
+
all_curves = self.filter_list + self.sensor_list
|
|
750
|
+
if not all_curves:
|
|
751
|
+
QMessageBox.information(self, self.tr("Remove Curve"), self.tr("No custom curves to remove.")); return
|
|
752
|
+
curve, ok = QInputDialog.getItem(self, self.tr("Remove Curve"), self.tr("Select a FILTER or SENSOR curve to delete:"), all_curves, 0, False)
|
|
753
|
+
if not ok or not curve: return
|
|
754
|
+
reply = QMessageBox.question(self, self.tr("Confirm Deletion"), self.tr("Delete '{0}'?").format(curve), QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
|
755
|
+
if reply != QMessageBox.StandardButton.Yes: return
|
|
756
|
+
|
|
757
|
+
temp_path = self.user_custom_path + ".tmp"
|
|
758
|
+
try:
|
|
759
|
+
with fits.open(self.user_custom_path, memmap=False) as old_hdul:
|
|
760
|
+
new_hdus = []
|
|
761
|
+
for hdu in old_hdul:
|
|
762
|
+
if hdu is old_hdul[0]:
|
|
763
|
+
new_hdus.append(hdu.copy())
|
|
764
|
+
else:
|
|
765
|
+
if hdu.header.get("EXTNAME") != curve:
|
|
766
|
+
new_hdus.append(hdu.copy())
|
|
767
|
+
fits.HDUList(new_hdus).writeto(temp_path, overwrite=True)
|
|
768
|
+
os.replace(temp_path, self.user_custom_path)
|
|
769
|
+
except Exception as e:
|
|
770
|
+
if os.path.exists(temp_path): os.remove(temp_path)
|
|
771
|
+
QMessageBox.critical(self, "Write Error", f"Could not remove curve:\n{e}"); return
|
|
772
|
+
|
|
773
|
+
self._reload_hdu_lists(); self.refresh_filter_sensor_lists()
|
|
774
|
+
QMessageBox.information(self, self.tr("Removed"), self.tr("Deleted curve '{0}'.").format(curve))
|
|
775
|
+
|
|
776
|
+
def refresh_filter_sensor_lists(self):
|
|
777
|
+
self._reload_hdu_lists()
|
|
778
|
+
current_r = self.r_filter_combo.currentText()
|
|
779
|
+
current_g = self.g_filter_combo.currentText()
|
|
780
|
+
current_b = self.b_filter_combo.currentText()
|
|
781
|
+
current_s = self.sens_combo.currentText()
|
|
782
|
+
current_lp = self.lp_filter_combo.currentText()
|
|
783
|
+
current_lp2 = self.lp_filter_combo2.currentText()
|
|
784
|
+
|
|
785
|
+
for cb, lst, prev in [
|
|
786
|
+
(self.r_filter_combo, self.filter_list, current_r),
|
|
787
|
+
(self.g_filter_combo, self.filter_list, current_g),
|
|
788
|
+
(self.b_filter_combo, self.filter_list, current_b),
|
|
789
|
+
]:
|
|
790
|
+
cb.clear(); cb.addItem("(None)"); cb.addItems(lst)
|
|
791
|
+
idx = cb.findText(prev); cb.setCurrentIndex(idx if idx != -1 else 0)
|
|
792
|
+
|
|
793
|
+
for cb, prev in [(self.lp_filter_combo, current_lp), (self.lp_filter_combo2, current_lp2)]:
|
|
794
|
+
cb.clear(); cb.addItem("(None)"); cb.addItems(self.filter_list)
|
|
795
|
+
idx = cb.findText(prev); cb.setCurrentIndex(idx if idx != -1 else 0)
|
|
796
|
+
|
|
797
|
+
self.sens_combo.clear(); self.sens_combo.addItem("(None)"); self.sens_combo.addItems(self.sensor_list)
|
|
798
|
+
idx = self.sens_combo.findText(current_s); self.sens_combo.setCurrentIndex(idx if idx != -1 else 0)
|
|
799
|
+
|
|
800
|
+
# ── WCS utilities ──────────────────────────────────────────────────
|
|
801
|
+
def initialize_wcs_from_header(self, header):
|
|
802
|
+
if header is None:
|
|
803
|
+
print("No FITS header available; cannot build WCS.")
|
|
804
|
+
return
|
|
805
|
+
try:
|
|
806
|
+
hdr = header.copy()
|
|
807
|
+
|
|
808
|
+
# --- normalize deprecated keywords ---
|
|
809
|
+
if "RADECSYS" in hdr and "RADESYS" not in hdr:
|
|
810
|
+
radesys_val = str(hdr["RADECSYS"]).strip()
|
|
811
|
+
hdr["RADESYS"] = radesys_val
|
|
812
|
+
try:
|
|
813
|
+
del hdr["RADECSYS"]
|
|
814
|
+
except Exception:
|
|
815
|
+
pass
|
|
816
|
+
|
|
817
|
+
alt_letters = {
|
|
818
|
+
k[-1]
|
|
819
|
+
for k in hdr.keys()
|
|
820
|
+
if re.match(r"^CTYPE[12][A-Z]$", k)
|
|
821
|
+
}
|
|
822
|
+
for a in alt_letters:
|
|
823
|
+
key = f"RADESYS{a}"
|
|
824
|
+
if key not in hdr:
|
|
825
|
+
hdr[key] = radesys_val
|
|
826
|
+
|
|
827
|
+
if "EPOCH" in hdr and "EQUINOX" not in hdr:
|
|
828
|
+
hdr["EQUINOX"] = hdr["EPOCH"]
|
|
829
|
+
try:
|
|
830
|
+
del hdr["EPOCH"]
|
|
831
|
+
except Exception:
|
|
832
|
+
pass
|
|
833
|
+
|
|
834
|
+
# IMPORTANT: use the normalized hdr, not the original header
|
|
835
|
+
self.wcs = WCS(hdr, naxis=2, relax=True)
|
|
836
|
+
|
|
837
|
+
psm = self.wcs.pixel_scale_matrix
|
|
838
|
+
self.pixscale = np.hypot(psm[0, 0], psm[1, 0]) * 3600.0
|
|
839
|
+
self.center_ra, self.center_dec = self.wcs.wcs.crval
|
|
840
|
+
self.wcs_header = self.wcs.to_header(relax=True)
|
|
841
|
+
|
|
842
|
+
# Orientation from normalized header
|
|
843
|
+
if "CROTA2" in hdr:
|
|
844
|
+
try:
|
|
845
|
+
self.orientation = float(hdr["CROTA2"])
|
|
846
|
+
except Exception:
|
|
847
|
+
self.orientation = None
|
|
848
|
+
else:
|
|
849
|
+
self.orientation = self.calculate_orientation(hdr)
|
|
850
|
+
|
|
851
|
+
if self.orientation is not None:
|
|
852
|
+
self.orientation_label.setText(f"Orientation: {self.orientation:.2f}°")
|
|
853
|
+
else:
|
|
854
|
+
self.orientation_label.setText("Orientation: N/A")
|
|
855
|
+
|
|
856
|
+
except Exception as e:
|
|
857
|
+
print("WCS initialization error:\n", e)
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
def calculate_orientation(self, header):
|
|
861
|
+
try:
|
|
862
|
+
cd1_1 = float(header.get("CD1_1", 0.0))
|
|
863
|
+
cd1_2 = float(header.get("CD1_2", 0.0))
|
|
864
|
+
return math.degrees(math.atan2(cd1_2, cd1_1))
|
|
865
|
+
except Exception:
|
|
866
|
+
return None
|
|
867
|
+
|
|
868
|
+
def calculate_ra_dec_from_pixel(self, x, y):
|
|
869
|
+
if not hasattr(self, "wcs"): return None, None
|
|
870
|
+
return self.wcs.all_pix2world(x, y, 0)
|
|
871
|
+
|
|
872
|
+
# ── Background neutralization ───────────────────────────────────────
|
|
873
|
+
|
|
874
|
+
def _neutralize_background(self, rgb_img: np.ndarray, patch_size: int = 50) -> np.ndarray:
|
|
875
|
+
img = rgb_img.copy()
|
|
876
|
+
h, w = img.shape[:2]
|
|
877
|
+
ph, pw = h // patch_size, w // patch_size
|
|
878
|
+
min_sum, best_med = np.inf, None
|
|
879
|
+
for i in range(patch_size):
|
|
880
|
+
for j in range(patch_size):
|
|
881
|
+
y0, x0 = i * ph, j * pw
|
|
882
|
+
patch = img[y0:min(y0+ph, h), x0:min(x0+pw, w), :]
|
|
883
|
+
med = np.median(patch, axis=(0, 1))
|
|
884
|
+
s = med.sum()
|
|
885
|
+
if s < min_sum:
|
|
886
|
+
min_sum, best_med = s, med
|
|
887
|
+
if best_med is None:
|
|
888
|
+
return img
|
|
889
|
+
target = float(best_med.mean()); eps = 1e-8
|
|
890
|
+
for c in range(3):
|
|
891
|
+
diff = float(best_med[c] - target)
|
|
892
|
+
if abs(diff) < eps: continue
|
|
893
|
+
img[..., c] = np.clip((img[..., c] - diff) / (1.0 - diff), 0.0, 1.0)
|
|
894
|
+
return img
|
|
895
|
+
|
|
896
|
+
# ── SIMBAD/Star fetch ──────────────────────────────────────────────
|
|
897
|
+
|
|
898
|
+
def fetch_stars(self):
|
|
899
|
+
# 0) Grab current image + header from the active document
|
|
900
|
+
img, hdr, _meta = self._get_active_image_and_header()
|
|
901
|
+
self.current_image = img
|
|
902
|
+
self.current_header = hdr
|
|
903
|
+
|
|
904
|
+
if self.current_header is None or self.current_image is None:
|
|
905
|
+
QMessageBox.warning(self, "No Plate Solution",
|
|
906
|
+
"Please plate-solve the active document first.")
|
|
907
|
+
return
|
|
908
|
+
|
|
909
|
+
# Pickles templates list (once)
|
|
910
|
+
if not hasattr(self, "pickles_templates"):
|
|
911
|
+
self.pickles_templates = []
|
|
912
|
+
for p in (self.user_custom_path, self.sasp_data_path):
|
|
913
|
+
try:
|
|
914
|
+
with fits.open(p) as hd:
|
|
915
|
+
for hdu in hd:
|
|
916
|
+
if (isinstance(hdu, fits.BinTableHDU)
|
|
917
|
+
and hdu.header.get("CTYPE", "").upper() == "SED"):
|
|
918
|
+
extname = hdu.header.get("EXTNAME", None)
|
|
919
|
+
if extname and extname not in self.pickles_templates:
|
|
920
|
+
self.pickles_templates.append(extname)
|
|
921
|
+
except Exception as e:
|
|
922
|
+
print(f"[fetch_stars] Could not load Pickles templates from {p}: {e}")
|
|
923
|
+
|
|
924
|
+
# Build WCS
|
|
925
|
+
try:
|
|
926
|
+
self.initialize_wcs_from_header(self.current_header)
|
|
927
|
+
except Exception:
|
|
928
|
+
QMessageBox.critical(self, "WCS Error", "Could not build a 2D WCS from header."); return
|
|
929
|
+
|
|
930
|
+
H, W = self.current_image.shape[:2]
|
|
931
|
+
pix = np.array([[W/2, H/2], [0,0], [W,0], [0,H], [W,H]])
|
|
932
|
+
try:
|
|
933
|
+
sky = self.wcs.all_pix2world(pix, 0)
|
|
934
|
+
except Exception as e:
|
|
935
|
+
QMessageBox.critical(self, "WCS Conversion Error", str(e)); return
|
|
936
|
+
center_sky = SkyCoord(ra=sky[0,0]*u.deg, dec=sky[0,1]*u.deg, frame="icrs")
|
|
937
|
+
corners_sky = SkyCoord(ra=sky[1:,0]*u.deg, dec=sky[1:,1]*u.deg, frame="icrs")
|
|
938
|
+
radius_deg = center_sky.separation(corners_sky).max().deg
|
|
939
|
+
|
|
940
|
+
# Simbad fields
|
|
941
|
+
Simbad.reset_votable_fields()
|
|
942
|
+
for attempt in range(1, 6):
|
|
943
|
+
try:
|
|
944
|
+
Simbad.add_votable_fields('sp', 'flux(B)', 'flux(V)', 'flux(R)')
|
|
945
|
+
break
|
|
946
|
+
except Exception:
|
|
947
|
+
QApplication.processEvents()
|
|
948
|
+
time.sleep(1.2)
|
|
949
|
+
Simbad.ROW_LIMIT = 10000
|
|
950
|
+
|
|
951
|
+
for attempt in range(1, 6):
|
|
952
|
+
try:
|
|
953
|
+
result = Simbad.query_region(center_sky, radius=radius_deg * u.deg)
|
|
954
|
+
break
|
|
955
|
+
except Exception as e:
|
|
956
|
+
self.count_label.setText(f"Attempt {attempt}/5 to query SIMBAD…")
|
|
957
|
+
QApplication.processEvents(); time.sleep(1.2)
|
|
958
|
+
result = None
|
|
959
|
+
if result is None or len(result) == 0:
|
|
960
|
+
QMessageBox.information(self, "No Stars", "SIMBAD returned zero objects in that region.")
|
|
961
|
+
self.star_list = []; self.star_combo.clear(); self.star_combo.addItem("Vega (A0V)", userData="A0V"); return
|
|
962
|
+
|
|
963
|
+
def infer_letter(bv):
|
|
964
|
+
if bv is None or (isinstance(bv, float) and np.isnan(bv)): return None
|
|
965
|
+
if bv < 0.00: return "B"
|
|
966
|
+
elif bv < 0.30: return "A"
|
|
967
|
+
elif bv < 0.58: return "F"
|
|
968
|
+
elif bv < 0.81: return "G"
|
|
969
|
+
elif bv < 1.40: return "K"
|
|
970
|
+
elif bv > 1.40: return "M"
|
|
971
|
+
else: return "U"
|
|
972
|
+
|
|
973
|
+
self.star_list = []; templates_for_hist = []
|
|
974
|
+
for row in result:
|
|
975
|
+
raw_sp = row['sp_type']
|
|
976
|
+
bmag, vmag, rmag = row['B'], row['V'], row['R']
|
|
977
|
+
ra_deg, dec_deg = float(row['ra']), float(row['dec'])
|
|
978
|
+
try:
|
|
979
|
+
sc = SkyCoord(ra=ra_deg*u.deg, dec=dec_deg*u.deg, frame="icrs")
|
|
980
|
+
except Exception:
|
|
981
|
+
continue
|
|
982
|
+
|
|
983
|
+
def _unmask_num(x):
|
|
984
|
+
try:
|
|
985
|
+
if x is None or np.ma.isMaskedArray(x) and np.ma.is_masked(x):
|
|
986
|
+
return None
|
|
987
|
+
return float(x)
|
|
988
|
+
except Exception:
|
|
989
|
+
return None
|
|
990
|
+
|
|
991
|
+
# inside your SIMBAD row loop:
|
|
992
|
+
bmag = _unmask_num(row['B'])
|
|
993
|
+
vmag = _unmask_num(row['V'])
|
|
994
|
+
|
|
995
|
+
sp_clean = None
|
|
996
|
+
if raw_sp and str(raw_sp).strip():
|
|
997
|
+
sp = str(raw_sp).strip().upper()
|
|
998
|
+
if not (sp.startswith("SN") or sp.startswith("KA")):
|
|
999
|
+
sp_clean = sp
|
|
1000
|
+
elif bmag is not None and vmag is not None:
|
|
1001
|
+
bv = bmag - vmag
|
|
1002
|
+
sp_clean = infer_letter(bv)
|
|
1003
|
+
if not sp_clean: continue
|
|
1004
|
+
|
|
1005
|
+
match_list = pickles_match_for_simbad(sp_clean, self.pickles_templates)
|
|
1006
|
+
best_template = match_list[0] if match_list else None
|
|
1007
|
+
xpix, ypix = self.wcs.all_world2pix(sc.ra.deg, sc.dec.deg, 0)
|
|
1008
|
+
if 0 <= xpix < W and 0 <= ypix < H:
|
|
1009
|
+
self.star_list.append({
|
|
1010
|
+
"ra": sc.ra.deg, "dec": sc.dec.deg, "sp_clean": sp_clean,
|
|
1011
|
+
"pickles_match": best_template, "x": xpix, "y": ypix,
|
|
1012
|
+
"Bmag": float(bmag) if bmag else None,
|
|
1013
|
+
"Vmag": float(vmag) if vmag else None,
|
|
1014
|
+
"Rmag": float(rmag) if rmag else None,
|
|
1015
|
+
})
|
|
1016
|
+
if best_template is not None: templates_for_hist.append(best_template)
|
|
1017
|
+
|
|
1018
|
+
self.figure.clf()
|
|
1019
|
+
if templates_for_hist:
|
|
1020
|
+
uniq, cnt = np.unique(templates_for_hist, return_counts=True)
|
|
1021
|
+
types_str = ", ".join(uniq)
|
|
1022
|
+
self.count_label.setText(f"Found {len(templates_for_hist)} stars; templates: {types_str}")
|
|
1023
|
+
ax = self.figure.add_subplot(111)
|
|
1024
|
+
ax.bar(uniq, cnt, edgecolor="black")
|
|
1025
|
+
ax.set_xlabel("Spectral Type"); ax.set_ylabel("Count"); ax.set_title("Spectral Distribution")
|
|
1026
|
+
ax.tick_params(axis='x', rotation=90); ax.grid(axis="y", linestyle="--", alpha=0.3)
|
|
1027
|
+
self.canvas.setVisible(True); self.canvas.draw()
|
|
1028
|
+
else:
|
|
1029
|
+
self.count_label.setText("Found 0 stars with Pickles matches.")
|
|
1030
|
+
self.canvas.setVisible(False); self.canvas.draw()
|
|
1031
|
+
|
|
1032
|
+
# ── Core SFCC ───────────────────────────────────────────────────────
|
|
1033
|
+
|
|
1034
|
+
def run_spcc(self):
|
|
1035
|
+
ref_sed_name = self.star_combo.currentData()
|
|
1036
|
+
r_filt = self.r_filter_combo.currentText()
|
|
1037
|
+
g_filt = self.g_filter_combo.currentText()
|
|
1038
|
+
b_filt = self.b_filter_combo.currentText()
|
|
1039
|
+
sens_name = self.sens_combo.currentText()
|
|
1040
|
+
lp_filt = self.lp_filter_combo.currentText()
|
|
1041
|
+
lp_filt2 = self.lp_filter_combo2.currentText()
|
|
1042
|
+
|
|
1043
|
+
if not ref_sed_name:
|
|
1044
|
+
QMessageBox.warning(self, "Error", "Select a reference spectral type (e.g. A0V)."); return
|
|
1045
|
+
if r_filt == "(None)" and g_filt == "(None)" and b_filt == "(None)":
|
|
1046
|
+
QMessageBox.warning(self, "Error", "Pick at least one of R, G or B filters."); return
|
|
1047
|
+
if sens_name == "(None)":
|
|
1048
|
+
QMessageBox.warning(self, "Error", "Select a sensor QE curve."); return
|
|
1049
|
+
|
|
1050
|
+
# -- Step 1A: get active image as float32 in [0..1]
|
|
1051
|
+
doc = self.doc_manager.get_active_document()
|
|
1052
|
+
if doc is None or doc.image is None:
|
|
1053
|
+
QMessageBox.critical(self, "Error", "No active document.")
|
|
1054
|
+
return
|
|
1055
|
+
|
|
1056
|
+
img = doc.image
|
|
1057
|
+
H, W = img.shape[:2]
|
|
1058
|
+
if img.ndim != 3 or img.shape[2] != 3:
|
|
1059
|
+
QMessageBox.critical(self, "Error", "Active document must be RGB (3 channels).")
|
|
1060
|
+
return
|
|
1061
|
+
|
|
1062
|
+
if img.dtype == np.uint8:
|
|
1063
|
+
base = img.astype(np.float32) / 255.0
|
|
1064
|
+
else:
|
|
1065
|
+
base = img.astype(np.float32, copy=True)
|
|
1066
|
+
|
|
1067
|
+
# pedestal removal
|
|
1068
|
+
base = np.clip(base - np.min(base, axis=(0,1)), 0.0, None)
|
|
1069
|
+
# light neutralization
|
|
1070
|
+
base = self._neutralize_background(base, patch_size=10)
|
|
1071
|
+
|
|
1072
|
+
# SEP on grayscale
|
|
1073
|
+
gray = np.mean(base, axis=2)
|
|
1074
|
+
|
|
1075
|
+
bkg = sep.Background(gray)
|
|
1076
|
+
data_sub = gray - bkg.back()
|
|
1077
|
+
err = bkg.globalrms
|
|
1078
|
+
|
|
1079
|
+
# 👇 get user threshold (default 5.0)
|
|
1080
|
+
if hasattr(self, "sep_thr_spin"):
|
|
1081
|
+
sep_sigma = float(self.sep_thr_spin.value())
|
|
1082
|
+
else:
|
|
1083
|
+
sep_sigma = 5.0
|
|
1084
|
+
self.count_label.setText(f"Detecting stars (SEP σ={sep_sigma:.1f})…"); QApplication.processEvents()
|
|
1085
|
+
sources = sep.extract(data_sub, sep_sigma, err=err)
|
|
1086
|
+
|
|
1087
|
+
MAX_SOURCES = 300_000
|
|
1088
|
+
if sources.size > MAX_SOURCES:
|
|
1089
|
+
QMessageBox.warning(
|
|
1090
|
+
self,
|
|
1091
|
+
"Too many detections",
|
|
1092
|
+
f"SEP found {sources.size:,} sources with σ={sep_sigma:.1f}.\n"
|
|
1093
|
+
f"Increase the threshold and rerun SFCC."
|
|
1094
|
+
)
|
|
1095
|
+
return
|
|
1096
|
+
|
|
1097
|
+
if sources.size == 0:
|
|
1098
|
+
QMessageBox.critical(self, "SEP Error", "SEP found no sources."); return
|
|
1099
|
+
r_fluxrad, _ = sep.flux_radius(gray, sources["x"], sources["y"], 2.0*sources["a"], 0.5, normflux=sources["flux"], subpix=5)
|
|
1100
|
+
mask = (r_fluxrad > .2) & (r_fluxrad <= 10); sources = sources[mask]
|
|
1101
|
+
if sources.size == 0:
|
|
1102
|
+
QMessageBox.critical(self, "SEP Error", "All SEP detections rejected by radius filter."); return
|
|
1103
|
+
|
|
1104
|
+
if not getattr(self, "star_list", None):
|
|
1105
|
+
QMessageBox.warning(self, "Error", "Fetch Stars (with WCS) before running SFCC."); return
|
|
1106
|
+
|
|
1107
|
+
raw_matches = []
|
|
1108
|
+
for i, star in enumerate(self.star_list):
|
|
1109
|
+
dx = sources["x"] - star["x"]; dy = sources["y"] - star["y"]
|
|
1110
|
+
j = np.argmin(dx*dx + dy*dy)
|
|
1111
|
+
if (dx[j]**2 + dy[j]**2) < 3.0**2:
|
|
1112
|
+
xi, yi = int(round(sources["x"][j])), int(round(sources["y"][j]))
|
|
1113
|
+
if 0 <= xi < W and 0 <= yi < H:
|
|
1114
|
+
raw_matches.append({"sim_index": i, "template": star.get("pickles_match") or star["sp_clean"], "x_pix": xi, "y_pix": yi})
|
|
1115
|
+
if not raw_matches:
|
|
1116
|
+
QMessageBox.warning(self, "No Matches", "No SIMBAD star matched to SEP detections."); return
|
|
1117
|
+
|
|
1118
|
+
wl_min, wl_max = 3000, 11000
|
|
1119
|
+
wl_grid = np.arange(wl_min, wl_max+1)
|
|
1120
|
+
|
|
1121
|
+
def load_curve(ext):
|
|
1122
|
+
for p in (self.user_custom_path, self.sasp_data_path):
|
|
1123
|
+
with fits.open(p) as hd:
|
|
1124
|
+
if ext in hd:
|
|
1125
|
+
d = hd[ext].data
|
|
1126
|
+
wl = _ensure_angstrom(d["WAVELENGTH"].astype(float))
|
|
1127
|
+
tp = d["THROUGHPUT"].astype(float)
|
|
1128
|
+
return wl, tp
|
|
1129
|
+
raise KeyError(f"Curve '{ext}' not found")
|
|
1130
|
+
|
|
1131
|
+
def load_sed(ext):
|
|
1132
|
+
for p in (self.user_custom_path, self.sasp_data_path):
|
|
1133
|
+
with fits.open(p) as hd:
|
|
1134
|
+
if ext in hd:
|
|
1135
|
+
d = hd[ext].data
|
|
1136
|
+
wl = _ensure_angstrom(d["WAVELENGTH"].astype(float))
|
|
1137
|
+
fl = d["FLUX"].astype(float)
|
|
1138
|
+
return wl, fl
|
|
1139
|
+
raise KeyError(f"SED '{ext}' not found")
|
|
1140
|
+
|
|
1141
|
+
interp = lambda wl_o, tp_o: np.interp(wl_grid, wl_o, tp_o, left=0., right=0.)
|
|
1142
|
+
T_R = interp(*load_curve(r_filt)) if r_filt!="(None)" else np.ones_like(wl_grid)
|
|
1143
|
+
T_G = interp(*load_curve(g_filt)) if g_filt!="(None)" else np.ones_like(wl_grid)
|
|
1144
|
+
T_B = interp(*load_curve(b_filt)) if b_filt!="(None)" else np.ones_like(wl_grid)
|
|
1145
|
+
QE = interp(*load_curve(sens_name)) if sens_name!="(None)" else np.ones_like(wl_grid)
|
|
1146
|
+
LP1 = interp(*load_curve(lp_filt)) if lp_filt != "(None)" else np.ones_like(wl_grid)
|
|
1147
|
+
LP2 = interp(*load_curve(lp_filt2)) if lp_filt2!= "(None)" else np.ones_like(wl_grid)
|
|
1148
|
+
LP = LP1 * LP2
|
|
1149
|
+
T_sys_R, T_sys_G, T_sys_B = T_R*QE*LP, T_G*QE*LP, T_B*QE*LP
|
|
1150
|
+
|
|
1151
|
+
wl_ref, fl_ref = load_sed(ref_sed_name)
|
|
1152
|
+
fr_i = np.interp(wl_grid, wl_ref, fl_ref, left=0., right=0.)
|
|
1153
|
+
S_ref_R = np.trapezoid(fr_i * T_sys_R, x=wl_grid)
|
|
1154
|
+
S_ref_G = np.trapezoid(fr_i * T_sys_G, x=wl_grid)
|
|
1155
|
+
S_ref_B = np.trapezoid(fr_i * T_sys_B, x=wl_grid)
|
|
1156
|
+
|
|
1157
|
+
diag_meas_RG, diag_exp_RG = [], []
|
|
1158
|
+
diag_meas_BG, diag_exp_BG = [], []
|
|
1159
|
+
enriched = []
|
|
1160
|
+
|
|
1161
|
+
# --- Optimization: Pre-calculate integrals for unique templates ---
|
|
1162
|
+
unique_simbad_types = set(m["template"] for m in raw_matches)
|
|
1163
|
+
|
|
1164
|
+
# Map simbad_type -> pickles_template_name
|
|
1165
|
+
simbad_to_pickles = {}
|
|
1166
|
+
pickles_templates_needed = set()
|
|
1167
|
+
|
|
1168
|
+
for sp in unique_simbad_types:
|
|
1169
|
+
cands = pickles_match_for_simbad(sp, getattr(self, "pickles_templates", []))
|
|
1170
|
+
if cands:
|
|
1171
|
+
pickles_name = cands[0]
|
|
1172
|
+
simbad_to_pickles[sp] = pickles_name
|
|
1173
|
+
pickles_templates_needed.add(pickles_name)
|
|
1174
|
+
|
|
1175
|
+
# Pre-calc integrals for each unique Pickles template
|
|
1176
|
+
# Cache structure: template_name -> (S_sr, S_sg, S_sb)
|
|
1177
|
+
template_integrals = {}
|
|
1178
|
+
|
|
1179
|
+
# Cache for load_sed to avoid re-reading even across different calls if desired,
|
|
1180
|
+
# but here we just optimize the loop.
|
|
1181
|
+
|
|
1182
|
+
for pname in pickles_templates_needed:
|
|
1183
|
+
try:
|
|
1184
|
+
wl_s, fl_s = load_sed(pname)
|
|
1185
|
+
fs_i = np.interp(wl_grid, wl_s, fl_s, left=0., right=0.)
|
|
1186
|
+
|
|
1187
|
+
S_sr = np.trapezoid(fs_i * T_sys_R, x=wl_grid)
|
|
1188
|
+
S_sg = np.trapezoid(fs_i * T_sys_G, x=wl_grid)
|
|
1189
|
+
S_sb = np.trapezoid(fs_i * T_sys_B, x=wl_grid)
|
|
1190
|
+
|
|
1191
|
+
template_integrals[pname] = (S_sr, S_sg, S_sb)
|
|
1192
|
+
except Exception as e:
|
|
1193
|
+
print(f"[SFCC] Warning: failed to load/integrate template {pname}: {e}")
|
|
1194
|
+
|
|
1195
|
+
# --- Main Match Loop ---
|
|
1196
|
+
for m in raw_matches:
|
|
1197
|
+
xi, yi, sp = m["x_pix"], m["y_pix"], m["template"]
|
|
1198
|
+
Rm = float(base[yi, xi, 0]); Gm = float(base[yi, xi, 1]); Bm = float(base[yi, xi, 2])
|
|
1199
|
+
if Gm <= 0: continue
|
|
1200
|
+
|
|
1201
|
+
# 1. Resolve Simbad -> Pickles
|
|
1202
|
+
pname = simbad_to_pickles.get(sp)
|
|
1203
|
+
if not pname: continue
|
|
1204
|
+
|
|
1205
|
+
# 2. Retrieve pre-calced integrals
|
|
1206
|
+
integrals = template_integrals.get(pname)
|
|
1207
|
+
if not integrals: continue
|
|
1208
|
+
|
|
1209
|
+
S_sr, S_sg, S_sb = integrals
|
|
1210
|
+
|
|
1211
|
+
if S_sg <= 0: continue
|
|
1212
|
+
|
|
1213
|
+
exp_RG = S_sr / S_sg; exp_BG = S_sb / S_sg
|
|
1214
|
+
meas_RG = Rm / Gm; meas_BG = Bm / Gm
|
|
1215
|
+
|
|
1216
|
+
diag_meas_RG.append(meas_RG); diag_exp_RG.append(exp_RG)
|
|
1217
|
+
diag_meas_BG.append(meas_BG); diag_exp_BG.append(exp_BG)
|
|
1218
|
+
|
|
1219
|
+
enriched.append({
|
|
1220
|
+
**m, "R_meas": Rm, "G_meas": Gm, "B_meas": Bm,
|
|
1221
|
+
"S_star_R": S_sr, "S_star_G": S_sg, "S_star_B": S_sb,
|
|
1222
|
+
"exp_RG": exp_RG, "exp_BG": exp_BG
|
|
1223
|
+
})
|
|
1224
|
+
|
|
1225
|
+
self._last_matched = enriched
|
|
1226
|
+
diag_meas_RG = np.array(diag_meas_RG); diag_exp_RG = np.array(diag_exp_RG)
|
|
1227
|
+
diag_meas_BG = np.array(diag_meas_BG); diag_exp_BG = np.array(diag_exp_BG)
|
|
1228
|
+
if diag_meas_RG.size == 0 or diag_meas_BG.size == 0:
|
|
1229
|
+
QMessageBox.information(self, "No Valid Stars", "No stars with valid measured vs expected ratios."); return
|
|
1230
|
+
n_stars = diag_meas_RG.size
|
|
1231
|
+
|
|
1232
|
+
def rms_frac(pred, exp): return np.sqrt(np.mean(((pred/exp) - 1.0) ** 2))
|
|
1233
|
+
slope_only = lambda x, m: m*x
|
|
1234
|
+
affine = lambda x, m, b: m*x + b
|
|
1235
|
+
quad = lambda x, a, b, c: a*x**2 + b*x + c
|
|
1236
|
+
|
|
1237
|
+
denR = np.sum(diag_meas_RG**2); denB = np.sum(diag_meas_BG**2)
|
|
1238
|
+
mR_s = (np.sum(diag_meas_RG * diag_exp_RG) / denR) if denR > 0 else 1.0
|
|
1239
|
+
mB_s = (np.sum(diag_meas_BG * diag_exp_BG) / denB) if denB > 0 else 1.0
|
|
1240
|
+
rms_s = rms_frac(slope_only(diag_meas_RG, mR_s), diag_exp_RG) + rms_frac(slope_only(diag_meas_BG, mB_s), diag_exp_BG)
|
|
1241
|
+
|
|
1242
|
+
mR_a, bR_a = np.linalg.lstsq(np.vstack([diag_meas_RG, np.ones_like(diag_meas_RG)]).T, diag_exp_RG, rcond=None)[0]
|
|
1243
|
+
mB_a, bB_a = np.linalg.lstsq(np.vstack([diag_meas_BG, np.ones_like(diag_meas_BG)]).T, diag_exp_BG, rcond=None)[0]
|
|
1244
|
+
rms_a = rms_frac(affine(diag_meas_RG, mR_a, bR_a), diag_exp_RG) + rms_frac(affine(diag_meas_BG, mB_a, bB_a), diag_exp_BG)
|
|
1245
|
+
|
|
1246
|
+
aR_q, bR_q, cR_q = np.polyfit(diag_meas_RG, diag_exp_RG, 2)
|
|
1247
|
+
aB_q, bB_q, cB_q = np.polyfit(diag_meas_BG, diag_exp_BG, 2)
|
|
1248
|
+
rms_q = rms_frac(quad(diag_meas_RG, aR_q, bR_q, cR_q), diag_exp_RG) + rms_frac(quad(diag_meas_BG, aB_q, bB_q, cB_q), diag_exp_BG)
|
|
1249
|
+
|
|
1250
|
+
idx = np.argmin([rms_s, rms_a, rms_q])
|
|
1251
|
+
if idx == 0: coeff_R, coeff_B, model_choice = (0, mR_s, 0), (0, mB_s, 0), "slope-only"
|
|
1252
|
+
elif idx == 1: coeff_R, coeff_B, model_choice = (0, mR_a, bR_a), (0, mB_a, bB_a), "affine"
|
|
1253
|
+
else: coeff_R, coeff_B, model_choice = (aR_q, bR_q, cR_q), (aB_q, bB_q, cB_q), "quadratic"
|
|
1254
|
+
|
|
1255
|
+
poly = lambda c, x: c[0]*x**2 + c[1]*x + c[2]
|
|
1256
|
+
self.figure.clf()
|
|
1257
|
+
#ax1 = self.figure.add_subplot(1, 3, 1); bins=20
|
|
1258
|
+
#ax1.hist(diag_meas_RG, bins=bins, alpha=.65, label="meas R/G", color="firebrick", edgecolor="black")
|
|
1259
|
+
#ax1.hist(diag_exp_RG, bins=bins, alpha=.55, label="exp R/G", color="salmon", edgecolor="black")
|
|
1260
|
+
#ax1.hist(diag_meas_BG, bins=bins, alpha=.65, label="meas B/G", color="royalblue", edgecolor="black")
|
|
1261
|
+
#ax1.hist(diag_exp_BG, bins=bins, alpha=.55, label="exp B/G", color="lightskyblue", edgecolor="black")
|
|
1262
|
+
#ax1.set_xlabel("Ratio (band / G)"); ax1.set_ylabel("Count"); ax1.set_title("Measured vs expected"); ax1.legend(fontsize=7, frameon=False)
|
|
1263
|
+
|
|
1264
|
+
res0_RG = (diag_meas_RG / diag_exp_RG) - 1.0
|
|
1265
|
+
res0_BG = (diag_meas_BG / diag_exp_BG) - 1.0
|
|
1266
|
+
res1_RG = (poly(coeff_R, diag_meas_RG) / diag_exp_RG) - 1.0
|
|
1267
|
+
res1_BG = (poly(coeff_B, diag_meas_BG) / diag_exp_BG) - 1.0
|
|
1268
|
+
|
|
1269
|
+
ymin = np.min(np.concatenate([res0_RG, res0_BG])); ymax = np.max(np.concatenate([res0_RG, res0_BG]))
|
|
1270
|
+
pad = 0.05 * (ymax - ymin) if ymax > ymin else 0.02; y_lim = (ymin - pad, ymax + pad)
|
|
1271
|
+
def shade(ax, yvals, color):
|
|
1272
|
+
q1, q3 = np.percentile(yvals, [25,75]); ax.axhspan(q1, q3, color=color, alpha=.10, zorder=0)
|
|
1273
|
+
|
|
1274
|
+
ax2 = self.figure.add_subplot(1, 2, 1)
|
|
1275
|
+
ax2.axhline(0, color="0.65", ls="--", lw=1); shade(ax2, res0_RG, "firebrick"); shade(ax2, res0_BG, "royalblue")
|
|
1276
|
+
ax2.scatter(diag_exp_RG, res0_RG, c="firebrick", marker="o", alpha=.7, label="R/G residual")
|
|
1277
|
+
ax2.scatter(diag_exp_BG, res0_BG, c="royalblue", marker="s", alpha=.7, label="B/G residual")
|
|
1278
|
+
ax2.set_ylim(*y_lim); ax2.set_xlabel("Expected (band/G)"); ax2.set_ylabel("Frac residual (meas/exp − 1)")
|
|
1279
|
+
ax2.set_title("Residuals • BEFORE"); ax2.legend(frameon=False, fontsize=7, loc="lower right")
|
|
1280
|
+
|
|
1281
|
+
ax3 = self.figure.add_subplot(1, 2, 2)
|
|
1282
|
+
ax3.axhline(0, color="0.65", ls="--", lw=1); shade(ax3, res1_RG, "firebrick"); shade(ax3, res1_BG, "royalblue")
|
|
1283
|
+
ax3.scatter(diag_exp_RG, res1_RG, c="firebrick", marker="o", alpha=.7)
|
|
1284
|
+
ax3.scatter(diag_exp_BG, res1_BG, c="royalblue", marker="s", alpha=.7)
|
|
1285
|
+
ax3.set_ylim(*y_lim); ax3.set_xlabel("Expected (band/G)"); ax3.set_ylabel("Frac residual (corrected/exp − 1)")
|
|
1286
|
+
ax3.set_title("Residuals • AFTER")
|
|
1287
|
+
self.canvas.setVisible(True); self.figure.tight_layout(w_pad=2.); self.canvas.draw()
|
|
1288
|
+
|
|
1289
|
+
self.count_label.setText("Applying SFCC color scales to image…"); QApplication.processEvents()
|
|
1290
|
+
if img.dtype == np.uint8: img_float = img.astype(np.float32) / 255.0
|
|
1291
|
+
else: img_float = img.astype(np.float32)
|
|
1292
|
+
|
|
1293
|
+
RG = img_float[..., 0] / np.maximum(img_float[..., 1], 1e-8)
|
|
1294
|
+
BG = img_float[..., 2] / np.maximum(img_float[..., 1], 1e-8)
|
|
1295
|
+
aR, bR, cR = coeff_R; aB, bB, cB = coeff_B
|
|
1296
|
+
RG_corr = aR*RG**2 + bR*RG + cR
|
|
1297
|
+
BG_corr = aB*BG**2 + bB*BG + cB
|
|
1298
|
+
calibrated = img_float.copy()
|
|
1299
|
+
calibrated[..., 0] = RG_corr * img_float[..., 1]
|
|
1300
|
+
calibrated[..., 2] = BG_corr * img_float[..., 1]
|
|
1301
|
+
calibrated = np.clip(calibrated, 0, 1)
|
|
1302
|
+
|
|
1303
|
+
if self.neutralize_chk.isChecked():
|
|
1304
|
+
calibrated = self._neutralize_background(calibrated, patch_size=10)
|
|
1305
|
+
|
|
1306
|
+
if img.dtype == np.uint8:
|
|
1307
|
+
calibrated = (np.clip(calibrated, 0, 1) * 255.0).astype(np.uint8)
|
|
1308
|
+
else:
|
|
1309
|
+
calibrated = np.clip(calibrated, 0, 1).astype(np.float32)
|
|
1310
|
+
|
|
1311
|
+
new_meta = dict(doc.metadata or {})
|
|
1312
|
+
new_meta.update({
|
|
1313
|
+
"SFCC_applied": True,
|
|
1314
|
+
"SFCC_timestamp": datetime.now().isoformat(),
|
|
1315
|
+
"SFCC_model": model_choice,
|
|
1316
|
+
"SFCC_coeff_R": [float(v) for v in coeff_R],
|
|
1317
|
+
"SFCC_coeff_B": [float(v) for v in coeff_B],
|
|
1318
|
+
})
|
|
1319
|
+
|
|
1320
|
+
self.doc_manager.update_active_document(
|
|
1321
|
+
calibrated,
|
|
1322
|
+
metadata=new_meta,
|
|
1323
|
+
step_name="SFCC Calibrated",
|
|
1324
|
+
doc=doc, # 👈 pin to the document we started from
|
|
1325
|
+
)
|
|
1326
|
+
|
|
1327
|
+
self.count_label.setText(f"Applied SFCC color calibration using {n_stars} stars")
|
|
1328
|
+
QApplication.processEvents()
|
|
1329
|
+
|
|
1330
|
+
def pretty(coeff): return coeff[0] + coeff[1] + coeff[2]
|
|
1331
|
+
ratio_R, ratio_B = pretty(coeff_R), pretty(coeff_B)
|
|
1332
|
+
QMessageBox.information(self, "SFCC Complete",
|
|
1333
|
+
f"Applied SFCC using {n_stars} stars\n"
|
|
1334
|
+
f"Model: {model_choice}\n"
|
|
1335
|
+
f"R ratio @ x=1: {ratio_R:.4f}\n"
|
|
1336
|
+
f"B ratio @ x=1: {ratio_B:.4f}\n"
|
|
1337
|
+
f"Background neutralisation: {'ON' if self.neutralize_chk.isChecked() else 'OFF'}")
|
|
1338
|
+
|
|
1339
|
+
self.current_image = calibrated # keep for gradient step
|
|
1340
|
+
|
|
1341
|
+
# ── Chromatic gradient (optional) ──────────────────────────────────
|
|
1342
|
+
|
|
1343
|
+
def run_gradient_extraction(self):
|
|
1344
|
+
if not getattr(self, "_last_matched", None):
|
|
1345
|
+
QMessageBox.warning(self, "No Star Matches", "Run colour calibration first.")
|
|
1346
|
+
return
|
|
1347
|
+
|
|
1348
|
+
doc = self.doc_manager.get_active_document()
|
|
1349
|
+
if doc is None or doc.image is None:
|
|
1350
|
+
QMessageBox.critical(self, "Error", "No active document.")
|
|
1351
|
+
return
|
|
1352
|
+
|
|
1353
|
+
img = doc.image
|
|
1354
|
+
if img.ndim != 3 or img.shape[2] != 3:
|
|
1355
|
+
QMessageBox.critical(self, "Error", "Active document must be RGB.")
|
|
1356
|
+
return
|
|
1357
|
+
|
|
1358
|
+
is_u8 = (img.dtype == np.uint8)
|
|
1359
|
+
img_f = img.astype(np.float32) / (255.0 if is_u8 else 1.0)
|
|
1360
|
+
H, W = img_f.shape[:2]
|
|
1361
|
+
|
|
1362
|
+
# Need star diagnostics from SPCC
|
|
1363
|
+
if not hasattr(self, "_last_matched") or not self._last_matched:
|
|
1364
|
+
QMessageBox.warning(self, "No Star Matches", "Run color calibration first."); return
|
|
1365
|
+
|
|
1366
|
+
down_fact = 4
|
|
1367
|
+
Hs, Ws = H // down_fact, W // down_fact
|
|
1368
|
+
small = cv2.resize(img_f, (Ws, Hs), interpolation=cv2.INTER_AREA)
|
|
1369
|
+
|
|
1370
|
+
pts, dRG, dBG = [], [], []
|
|
1371
|
+
eps, box = 1e-8, 3
|
|
1372
|
+
for st in self._last_matched:
|
|
1373
|
+
xs_full, ys_full = st["x_pix"], st["y_pix"]
|
|
1374
|
+
xs, ys = xs_full / down_fact, ys_full / down_fact
|
|
1375
|
+
xs_c, ys_c = int(round(xs)), int(round(ys))
|
|
1376
|
+
if not (0 <= xs_c < Ws and 0 <= ys_c < Hs): continue
|
|
1377
|
+
xsl = slice(max(0, xs_c-box), min(Ws, xs_c+box+1))
|
|
1378
|
+
ysl = slice(max(0, ys_c-box), min(Hs, ys_c+box+1))
|
|
1379
|
+
Rm = np.median(small[ysl, xsl, 0]); Gm = np.median(small[ysl, xsl, 1]); Bm = np.median(small[ysl, xsl, 2])
|
|
1380
|
+
if Gm <= 0: continue
|
|
1381
|
+
meas_RG = Rm / Gm; meas_BG = Bm / Gm
|
|
1382
|
+
exp_RG, exp_BG = st["exp_RG"], st["exp_BG"]
|
|
1383
|
+
if exp_RG is None or exp_BG is None: continue
|
|
1384
|
+
dm_RG = -2.5 * np.log10((meas_RG+eps)/(exp_RG+eps))
|
|
1385
|
+
dm_BG = -2.5 * np.log10((meas_BG+eps)/(exp_BG+eps))
|
|
1386
|
+
pts.append([xs, ys]); dRG.append(dm_RG); dBG.append(dm_BG)
|
|
1387
|
+
|
|
1388
|
+
pts = np.asarray(pts); dRG = np.asarray(dRG); dBG = np.asarray(dBG)
|
|
1389
|
+
if pts.shape[0] < 5:
|
|
1390
|
+
QMessageBox.warning(self, "Too Few Stars", "Need ≥5 stars after clipping."); return
|
|
1391
|
+
|
|
1392
|
+
def sclip(arr, p, s=2.5):
|
|
1393
|
+
m, sd = np.median(arr), np.std(arr); keep = np.abs(arr-m) < s*sd
|
|
1394
|
+
return p[keep], arr[keep]
|
|
1395
|
+
|
|
1396
|
+
ptsRG, dRG = sclip(dRG, pts); ptsBG, dBG = sclip(dBG, pts)
|
|
1397
|
+
|
|
1398
|
+
mode = getattr(self, "grad_method", "poly2")
|
|
1399
|
+
bgRG_s = compute_gradient_map(ptsRG, dRG, (Hs, Ws), method=mode)
|
|
1400
|
+
bgBG_s = compute_gradient_map(ptsBG, dBG, (Hs, Ws), method=mode)
|
|
1401
|
+
|
|
1402
|
+
for bg in (bgRG_s, bgBG_s):
|
|
1403
|
+
bg -= np.median(bg)
|
|
1404
|
+
peak = np.max(np.abs(bg))
|
|
1405
|
+
if peak > 0.2: bg *= 0.2/peak
|
|
1406
|
+
|
|
1407
|
+
bgRG = cv2.resize(bgRG_s, (W, H), interpolation=cv2.INTER_CUBIC)
|
|
1408
|
+
bgBG = cv2.resize(bgBG_s, (W, H), interpolation=cv2.INTER_CUBIC)
|
|
1409
|
+
|
|
1410
|
+
scale_R = 10**(-0.4*bgRG); scale_B = 10**(-0.4*bgBG)
|
|
1411
|
+
|
|
1412
|
+
self.figure.clf()
|
|
1413
|
+
for i,(surf,lbl) in enumerate(((bgRG,"Δm R/G"),(bgBG,"Δm B/G"))):
|
|
1414
|
+
ax = self.figure.add_subplot(1,2,i+1)
|
|
1415
|
+
im = ax.imshow(surf, origin="lower", cmap="RdBu")
|
|
1416
|
+
ax.set_title(lbl); self.figure.colorbar(im, ax=ax)
|
|
1417
|
+
self.canvas.setVisible(True); self.figure.tight_layout(); self.canvas.draw()
|
|
1418
|
+
|
|
1419
|
+
corrected = img_f.copy()
|
|
1420
|
+
corrected[...,0] = np.clip(corrected[...,0] / scale_R, 0, 1.0)
|
|
1421
|
+
corrected[...,2] = np.clip(corrected[...,2] / scale_B, 0, 1.0)
|
|
1422
|
+
corrected = np.clip(corrected, 0, 1)
|
|
1423
|
+
if is_u8:
|
|
1424
|
+
corrected = (corrected * 255.0).astype(np.uint8)
|
|
1425
|
+
else:
|
|
1426
|
+
corrected = corrected.astype(np.float32)
|
|
1427
|
+
|
|
1428
|
+
new_meta = dict(doc.metadata or {})
|
|
1429
|
+
new_meta["ColourGradRemoved"] = True
|
|
1430
|
+
|
|
1431
|
+
self.doc_manager.update_active_document(
|
|
1432
|
+
corrected,
|
|
1433
|
+
metadata=new_meta,
|
|
1434
|
+
step_name="Colour-Gradient (star spectra, ¼-res fit)",
|
|
1435
|
+
doc=doc, # 👈 same idea
|
|
1436
|
+
)
|
|
1437
|
+
self.count_label.setText("Chromatic gradient removed ✓")
|
|
1438
|
+
QApplication.processEvents()
|
|
1439
|
+
|
|
1440
|
+
# ── Viewer, close ──────────────────────────────────────────────────
|
|
1441
|
+
|
|
1442
|
+
def open_sasp_viewer(self):
|
|
1443
|
+
if self.sasp_viewer_window is not None:
|
|
1444
|
+
if self.sasp_viewer_window.isVisible():
|
|
1445
|
+
self.sasp_viewer_window.raise_()
|
|
1446
|
+
else:
|
|
1447
|
+
self.sasp_viewer_window.show()
|
|
1448
|
+
return
|
|
1449
|
+
|
|
1450
|
+
self.sasp_viewer_window = SaspViewer(
|
|
1451
|
+
sasp_data_path=self.sasp_data_path,
|
|
1452
|
+
user_custom_path=self.user_custom_path
|
|
1453
|
+
)
|
|
1454
|
+
self.sasp_viewer_window.show()
|
|
1455
|
+
self.sasp_viewer_window.destroyed.connect(self._on_sasp_closed)
|
|
1456
|
+
|
|
1457
|
+
def _on_sasp_closed(self, _=None):
|
|
1458
|
+
# Called when the SaspViewer window is destroyed
|
|
1459
|
+
self.sasp_viewer_window = None
|
|
1460
|
+
|
|
1461
|
+
def closeEvent(self, event):
|
|
1462
|
+
super().closeEvent(event)
|
|
1463
|
+
|
|
1464
|
+
|
|
1465
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
1466
|
+
# Helper to open the dialog from your app
|
|
1467
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
1468
|
+
|
|
1469
|
+
def open_sfcc(doc_manager, sasp_data_path: str, parent=None) -> SFCCDialog:
|
|
1470
|
+
dlg = SFCCDialog(doc_manager=doc_manager, sasp_data_path=sasp_data_path, parent=parent)
|
|
1471
|
+
dlg.show()
|
|
1472
|
+
return dlg
|