setiastrosuitepro 1.6.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of setiastrosuitepro might be problematic. Click here for more details.
- setiastro/__init__.py +2 -0
- setiastro/data/SASP_data.fits +0 -0
- setiastro/data/catalogs/List_of_Galaxies_with_Distances_Gly.csv +488 -0
- setiastro/data/catalogs/astrobin_filters.csv +890 -0
- setiastro/data/catalogs/astrobin_filters_page1_local.csv +51 -0
- setiastro/data/catalogs/cali2.csv +63 -0
- setiastro/data/catalogs/cali2color.csv +65 -0
- setiastro/data/catalogs/celestial_catalog - original.csv +16471 -0
- setiastro/data/catalogs/celestial_catalog.csv +24031 -0
- setiastro/data/catalogs/detected_stars.csv +24784 -0
- setiastro/data/catalogs/fits_header_data.csv +46 -0
- setiastro/data/catalogs/test.csv +8 -0
- setiastro/data/catalogs/updated_celestial_catalog.csv +16471 -0
- setiastro/images/Astro_Spikes.png +0 -0
- setiastro/images/HRDiagram.png +0 -0
- setiastro/images/LExtract.png +0 -0
- setiastro/images/LInsert.png +0 -0
- setiastro/images/Oxygenation-atm-2.svg.png +0 -0
- setiastro/images/RGB080604.png +0 -0
- setiastro/images/abeicon.png +0 -0
- setiastro/images/aberration.png +0 -0
- setiastro/images/andromedatry.png +0 -0
- setiastro/images/andromedatry_satellited.png +0 -0
- setiastro/images/annotated.png +0 -0
- setiastro/images/aperture.png +0 -0
- setiastro/images/astrosuite.ico +0 -0
- setiastro/images/astrosuite.png +0 -0
- setiastro/images/astrosuitepro.icns +0 -0
- setiastro/images/astrosuitepro.ico +0 -0
- setiastro/images/astrosuitepro.png +0 -0
- setiastro/images/background.png +0 -0
- setiastro/images/background2.png +0 -0
- setiastro/images/benchmark.png +0 -0
- setiastro/images/big_moon_stabilizer_timeline.png +0 -0
- setiastro/images/big_moon_stabilizer_timeline_clean.png +0 -0
- setiastro/images/blaster.png +0 -0
- setiastro/images/blink.png +0 -0
- setiastro/images/clahe.png +0 -0
- setiastro/images/collage.png +0 -0
- setiastro/images/colorwheel.png +0 -0
- setiastro/images/contsub.png +0 -0
- setiastro/images/convo.png +0 -0
- setiastro/images/copyslot.png +0 -0
- setiastro/images/cosmic.png +0 -0
- setiastro/images/cosmicsat.png +0 -0
- setiastro/images/crop1.png +0 -0
- setiastro/images/cropicon.png +0 -0
- setiastro/images/curves.png +0 -0
- setiastro/images/cvs.png +0 -0
- setiastro/images/debayer.png +0 -0
- setiastro/images/denoise_cnn_custom.png +0 -0
- setiastro/images/denoise_cnn_graph.png +0 -0
- setiastro/images/disk.png +0 -0
- setiastro/images/dse.png +0 -0
- setiastro/images/exoicon.png +0 -0
- setiastro/images/eye.png +0 -0
- setiastro/images/fliphorizontal.png +0 -0
- setiastro/images/flipvertical.png +0 -0
- setiastro/images/font.png +0 -0
- setiastro/images/freqsep.png +0 -0
- setiastro/images/functionbundle.png +0 -0
- setiastro/images/graxpert.png +0 -0
- setiastro/images/green.png +0 -0
- setiastro/images/gridicon.png +0 -0
- setiastro/images/halo.png +0 -0
- setiastro/images/hdr.png +0 -0
- setiastro/images/histogram.png +0 -0
- setiastro/images/hubble.png +0 -0
- setiastro/images/imagecombine.png +0 -0
- setiastro/images/invert.png +0 -0
- setiastro/images/isophote.png +0 -0
- setiastro/images/isophote_demo_figure.png +0 -0
- setiastro/images/isophote_demo_image.png +0 -0
- setiastro/images/isophote_demo_model.png +0 -0
- setiastro/images/isophote_demo_residual.png +0 -0
- setiastro/images/jwstpupil.png +0 -0
- setiastro/images/linearfit.png +0 -0
- setiastro/images/livestacking.png +0 -0
- setiastro/images/mask.png +0 -0
- setiastro/images/maskapply.png +0 -0
- setiastro/images/maskcreate.png +0 -0
- setiastro/images/maskremove.png +0 -0
- setiastro/images/morpho.png +0 -0
- setiastro/images/mosaic.png +0 -0
- setiastro/images/multiscale_decomp.png +0 -0
- setiastro/images/nbtorgb.png +0 -0
- setiastro/images/neutral.png +0 -0
- setiastro/images/nuke.png +0 -0
- setiastro/images/openfile.png +0 -0
- setiastro/images/pedestal.png +0 -0
- setiastro/images/pen.png +0 -0
- setiastro/images/pixelmath.png +0 -0
- setiastro/images/platesolve.png +0 -0
- setiastro/images/ppp.png +0 -0
- setiastro/images/pro.png +0 -0
- setiastro/images/project.png +0 -0
- setiastro/images/psf.png +0 -0
- setiastro/images/redo.png +0 -0
- setiastro/images/redoicon.png +0 -0
- setiastro/images/rescale.png +0 -0
- setiastro/images/rgbalign.png +0 -0
- setiastro/images/rgbcombo.png +0 -0
- setiastro/images/rgbextract.png +0 -0
- setiastro/images/rotate180.png +0 -0
- setiastro/images/rotateclockwise.png +0 -0
- setiastro/images/rotatecounterclockwise.png +0 -0
- setiastro/images/satellite.png +0 -0
- setiastro/images/script.png +0 -0
- setiastro/images/selectivecolor.png +0 -0
- setiastro/images/simbad.png +0 -0
- setiastro/images/slot0.png +0 -0
- setiastro/images/slot1.png +0 -0
- setiastro/images/slot2.png +0 -0
- setiastro/images/slot3.png +0 -0
- setiastro/images/slot4.png +0 -0
- setiastro/images/slot5.png +0 -0
- setiastro/images/slot6.png +0 -0
- setiastro/images/slot7.png +0 -0
- setiastro/images/slot8.png +0 -0
- setiastro/images/slot9.png +0 -0
- setiastro/images/spcc.png +0 -0
- setiastro/images/spin_precession_vs_lunar_distance.png +0 -0
- setiastro/images/spinner.gif +0 -0
- setiastro/images/stacking.png +0 -0
- setiastro/images/staradd.png +0 -0
- setiastro/images/staralign.png +0 -0
- setiastro/images/starnet.png +0 -0
- setiastro/images/starregistration.png +0 -0
- setiastro/images/starspike.png +0 -0
- setiastro/images/starstretch.png +0 -0
- setiastro/images/statstretch.png +0 -0
- setiastro/images/supernova.png +0 -0
- setiastro/images/uhs.png +0 -0
- setiastro/images/undoicon.png +0 -0
- setiastro/images/upscale.png +0 -0
- setiastro/images/viewbundle.png +0 -0
- setiastro/images/whitebalance.png +0 -0
- setiastro/images/wimi_icon_256x256.png +0 -0
- setiastro/images/wimilogo.png +0 -0
- setiastro/images/wims.png +0 -0
- setiastro/images/wrench_icon.png +0 -0
- setiastro/images/xisfliberator.png +0 -0
- setiastro/saspro/__init__.py +20 -0
- setiastro/saspro/__main__.py +809 -0
- setiastro/saspro/_generated/__init__.py +7 -0
- setiastro/saspro/_generated/build_info.py +2 -0
- setiastro/saspro/abe.py +1295 -0
- setiastro/saspro/abe_preset.py +196 -0
- setiastro/saspro/aberration_ai.py +694 -0
- setiastro/saspro/aberration_ai_preset.py +224 -0
- setiastro/saspro/accel_installer.py +218 -0
- setiastro/saspro/accel_workers.py +30 -0
- setiastro/saspro/add_stars.py +621 -0
- setiastro/saspro/astrobin_exporter.py +1007 -0
- setiastro/saspro/astrospike.py +153 -0
- setiastro/saspro/astrospike_python.py +1839 -0
- setiastro/saspro/autostretch.py +196 -0
- setiastro/saspro/backgroundneutral.py +560 -0
- setiastro/saspro/batch_convert.py +325 -0
- setiastro/saspro/batch_renamer.py +519 -0
- setiastro/saspro/blemish_blaster.py +488 -0
- setiastro/saspro/blink_comparator_pro.py +2926 -0
- setiastro/saspro/bundles.py +61 -0
- setiastro/saspro/bundles_dock.py +114 -0
- setiastro/saspro/cheat_sheet.py +178 -0
- setiastro/saspro/clahe.py +342 -0
- setiastro/saspro/comet_stacking.py +1377 -0
- setiastro/saspro/common_tr.py +107 -0
- setiastro/saspro/config.py +38 -0
- setiastro/saspro/config_bootstrap.py +40 -0
- setiastro/saspro/config_manager.py +316 -0
- setiastro/saspro/continuum_subtract.py +1617 -0
- setiastro/saspro/convo.py +1397 -0
- setiastro/saspro/convo_preset.py +414 -0
- setiastro/saspro/copyastro.py +187 -0
- setiastro/saspro/cosmicclarity.py +1564 -0
- setiastro/saspro/cosmicclarity_preset.py +407 -0
- setiastro/saspro/crop_dialog_pro.py +956 -0
- setiastro/saspro/crop_preset.py +189 -0
- setiastro/saspro/curve_editor_pro.py +2544 -0
- setiastro/saspro/curves_preset.py +375 -0
- setiastro/saspro/debayer.py +670 -0
- setiastro/saspro/debug_utils.py +29 -0
- setiastro/saspro/dnd_mime.py +35 -0
- setiastro/saspro/doc_manager.py +2641 -0
- setiastro/saspro/exoplanet_detector.py +2166 -0
- setiastro/saspro/file_utils.py +284 -0
- setiastro/saspro/fitsmodifier.py +745 -0
- setiastro/saspro/fix_bom.py +32 -0
- setiastro/saspro/free_torch_memory.py +48 -0
- setiastro/saspro/frequency_separation.py +1343 -0
- setiastro/saspro/function_bundle.py +1594 -0
- setiastro/saspro/generate_translations.py +2378 -0
- setiastro/saspro/ghs_dialog_pro.py +660 -0
- setiastro/saspro/ghs_preset.py +284 -0
- setiastro/saspro/graxpert.py +634 -0
- setiastro/saspro/graxpert_preset.py +287 -0
- setiastro/saspro/gui/__init__.py +0 -0
- setiastro/saspro/gui/main_window.py +8567 -0
- setiastro/saspro/gui/mixins/__init__.py +33 -0
- setiastro/saspro/gui/mixins/dock_mixin.py +263 -0
- setiastro/saspro/gui/mixins/file_mixin.py +443 -0
- setiastro/saspro/gui/mixins/geometry_mixin.py +403 -0
- setiastro/saspro/gui/mixins/header_mixin.py +441 -0
- setiastro/saspro/gui/mixins/mask_mixin.py +421 -0
- setiastro/saspro/gui/mixins/menu_mixin.py +361 -0
- setiastro/saspro/gui/mixins/theme_mixin.py +367 -0
- setiastro/saspro/gui/mixins/toolbar_mixin.py +1457 -0
- setiastro/saspro/gui/mixins/update_mixin.py +309 -0
- setiastro/saspro/gui/mixins/view_mixin.py +435 -0
- setiastro/saspro/halobgon.py +462 -0
- setiastro/saspro/header_viewer.py +448 -0
- setiastro/saspro/headless_utils.py +88 -0
- setiastro/saspro/histogram.py +753 -0
- setiastro/saspro/history_explorer.py +939 -0
- setiastro/saspro/i18n.py +156 -0
- setiastro/saspro/image_combine.py +414 -0
- setiastro/saspro/image_peeker_pro.py +1601 -0
- setiastro/saspro/imageops/__init__.py +37 -0
- setiastro/saspro/imageops/mdi_snap.py +292 -0
- setiastro/saspro/imageops/scnr.py +36 -0
- setiastro/saspro/imageops/starbasedwhitebalance.py +210 -0
- setiastro/saspro/imageops/stretch.py +244 -0
- setiastro/saspro/isophote.py +1179 -0
- setiastro/saspro/layers.py +208 -0
- setiastro/saspro/layers_dock.py +714 -0
- setiastro/saspro/lazy_imports.py +193 -0
- setiastro/saspro/legacy/__init__.py +2 -0
- setiastro/saspro/legacy/image_manager.py +2226 -0
- setiastro/saspro/legacy/numba_utils.py +3659 -0
- setiastro/saspro/legacy/xisf.py +1071 -0
- setiastro/saspro/linear_fit.py +534 -0
- setiastro/saspro/live_stacking.py +1830 -0
- setiastro/saspro/log_bus.py +5 -0
- setiastro/saspro/logging_config.py +460 -0
- setiastro/saspro/luminancerecombine.py +309 -0
- setiastro/saspro/main_helpers.py +201 -0
- setiastro/saspro/mask_creation.py +928 -0
- setiastro/saspro/masks_core.py +56 -0
- setiastro/saspro/mdi_widgets.py +353 -0
- setiastro/saspro/memory_utils.py +666 -0
- setiastro/saspro/metadata_patcher.py +75 -0
- setiastro/saspro/mfdeconv.py +3826 -0
- setiastro/saspro/mfdeconv_earlystop.py +71 -0
- setiastro/saspro/mfdeconvcudnn.py +3263 -0
- setiastro/saspro/mfdeconvsport.py +2382 -0
- setiastro/saspro/minorbodycatalog.py +567 -0
- setiastro/saspro/morphology.py +382 -0
- setiastro/saspro/multiscale_decomp.py +1290 -0
- setiastro/saspro/nbtorgb_stars.py +531 -0
- setiastro/saspro/numba_utils.py +3044 -0
- setiastro/saspro/numba_warmup.py +141 -0
- setiastro/saspro/ops/__init__.py +9 -0
- setiastro/saspro/ops/command_help_dialog.py +623 -0
- setiastro/saspro/ops/command_runner.py +217 -0
- setiastro/saspro/ops/commands.py +1594 -0
- setiastro/saspro/ops/script_editor.py +1102 -0
- setiastro/saspro/ops/scripts.py +1413 -0
- setiastro/saspro/ops/settings.py +679 -0
- setiastro/saspro/parallel_utils.py +554 -0
- setiastro/saspro/pedestal.py +121 -0
- setiastro/saspro/perfect_palette_picker.py +1070 -0
- setiastro/saspro/pipeline.py +110 -0
- setiastro/saspro/pixelmath.py +1600 -0
- setiastro/saspro/plate_solver.py +2444 -0
- setiastro/saspro/project_io.py +797 -0
- setiastro/saspro/psf_utils.py +136 -0
- setiastro/saspro/psf_viewer.py +549 -0
- setiastro/saspro/pyi_rthook_astroquery.py +95 -0
- setiastro/saspro/remove_green.py +314 -0
- setiastro/saspro/remove_stars.py +1625 -0
- setiastro/saspro/remove_stars_preset.py +404 -0
- setiastro/saspro/resources.py +477 -0
- setiastro/saspro/rgb_combination.py +207 -0
- setiastro/saspro/rgb_extract.py +19 -0
- setiastro/saspro/rgbalign.py +723 -0
- setiastro/saspro/runtime_imports.py +7 -0
- setiastro/saspro/runtime_torch.py +754 -0
- setiastro/saspro/save_options.py +72 -0
- setiastro/saspro/selective_color.py +1552 -0
- setiastro/saspro/sfcc.py +1430 -0
- setiastro/saspro/shortcuts.py +3043 -0
- setiastro/saspro/signature_insert.py +1099 -0
- setiastro/saspro/stacking_suite.py +18181 -0
- setiastro/saspro/star_alignment.py +7420 -0
- setiastro/saspro/star_alignment_preset.py +329 -0
- setiastro/saspro/star_metrics.py +49 -0
- setiastro/saspro/star_spikes.py +681 -0
- setiastro/saspro/star_stretch.py +470 -0
- setiastro/saspro/stat_stretch.py +506 -0
- setiastro/saspro/status_log_dock.py +78 -0
- setiastro/saspro/subwindow.py +3267 -0
- setiastro/saspro/supernovaasteroidhunter.py +1716 -0
- setiastro/saspro/swap_manager.py +99 -0
- setiastro/saspro/torch_backend.py +89 -0
- setiastro/saspro/torch_rejection.py +434 -0
- setiastro/saspro/translations/de_translations.py +3733 -0
- setiastro/saspro/translations/es_translations.py +3923 -0
- setiastro/saspro/translations/fr_translations.py +3842 -0
- setiastro/saspro/translations/integrate_translations.py +234 -0
- setiastro/saspro/translations/it_translations.py +3662 -0
- setiastro/saspro/translations/ja_translations.py +3585 -0
- setiastro/saspro/translations/pt_translations.py +3853 -0
- setiastro/saspro/translations/saspro_de.qm +0 -0
- setiastro/saspro/translations/saspro_de.ts +253 -0
- setiastro/saspro/translations/saspro_es.qm +0 -0
- setiastro/saspro/translations/saspro_es.ts +12520 -0
- setiastro/saspro/translations/saspro_fr.qm +0 -0
- setiastro/saspro/translations/saspro_fr.ts +12514 -0
- setiastro/saspro/translations/saspro_it.qm +0 -0
- setiastro/saspro/translations/saspro_it.ts +12520 -0
- setiastro/saspro/translations/saspro_ja.qm +0 -0
- setiastro/saspro/translations/saspro_ja.ts +257 -0
- setiastro/saspro/translations/saspro_pt.qm +0 -0
- setiastro/saspro/translations/saspro_pt.ts +257 -0
- setiastro/saspro/translations/saspro_zh.qm +0 -0
- setiastro/saspro/translations/saspro_zh.ts +12520 -0
- setiastro/saspro/translations/zh_translations.py +3659 -0
- setiastro/saspro/versioning.py +71 -0
- setiastro/saspro/view_bundle.py +1555 -0
- setiastro/saspro/wavescale_hdr.py +624 -0
- setiastro/saspro/wavescale_hdr_preset.py +101 -0
- setiastro/saspro/wavescalede.py +658 -0
- setiastro/saspro/wavescalede_preset.py +230 -0
- setiastro/saspro/wcs_update.py +374 -0
- setiastro/saspro/whitebalance.py +456 -0
- setiastro/saspro/widgets/__init__.py +48 -0
- setiastro/saspro/widgets/common_utilities.py +306 -0
- setiastro/saspro/widgets/graphics_views.py +122 -0
- setiastro/saspro/widgets/image_utils.py +518 -0
- setiastro/saspro/widgets/preview_dialogs.py +280 -0
- setiastro/saspro/widgets/spinboxes.py +275 -0
- setiastro/saspro/widgets/themed_buttons.py +13 -0
- setiastro/saspro/widgets/wavelet_utils.py +299 -0
- setiastro/saspro/window_shelf.py +185 -0
- setiastro/saspro/xisf.py +1123 -0
- setiastrosuitepro-1.6.1.dist-info/METADATA +267 -0
- setiastrosuitepro-1.6.1.dist-info/RECORD +342 -0
- setiastrosuitepro-1.6.1.dist-info/WHEEL +4 -0
- setiastrosuitepro-1.6.1.dist-info/entry_points.txt +6 -0
- setiastrosuitepro-1.6.1.dist-info/licenses/LICENSE +674 -0
- setiastrosuitepro-1.6.1.dist-info/licenses/license.txt +2580 -0
|
@@ -0,0 +1,1617 @@
|
|
|
1
|
+
# pro/continuum_subtract.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import os
|
|
4
|
+
import numpy as np
|
|
5
|
+
import time # NEW
|
|
6
|
+
import glob # NEW
|
|
7
|
+
import subprocess # NEW
|
|
8
|
+
# Optional deps used by the processing threads
|
|
9
|
+
try:
|
|
10
|
+
import cv2
|
|
11
|
+
except Exception:
|
|
12
|
+
cv2 = None
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
import pywt
|
|
16
|
+
except Exception:
|
|
17
|
+
pywt = None
|
|
18
|
+
|
|
19
|
+
from PyQt6.QtCore import (
|
|
20
|
+
Qt, QSize, QPoint, QEvent, QThread, pyqtSignal, QTimer,
|
|
21
|
+
QCoreApplication
|
|
22
|
+
)
|
|
23
|
+
from PyQt6.QtWidgets import (
|
|
24
|
+
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
|
|
25
|
+
QGroupBox, QScrollArea, QDialog, QInputDialog, QFileDialog,
|
|
26
|
+
QMessageBox, QCheckBox, QApplication, QMainWindow, QCheckBox
|
|
27
|
+
)
|
|
28
|
+
from PyQt6.QtGui import (
|
|
29
|
+
QPixmap, QImage, QCursor, QWheelEvent
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# register QImage for cross-thread signals
|
|
33
|
+
#qRegisterMetaType(QImage)
|
|
34
|
+
|
|
35
|
+
from .doc_manager import ImageDocument # add this import
|
|
36
|
+
from setiastro.saspro.legacy.image_manager import load_image as legacy_load_image, save_image as legacy_save_image # CHANGED
|
|
37
|
+
from setiastro.saspro.imageops.stretch import stretch_mono_image, stretch_color_image
|
|
38
|
+
from setiastro.saspro.imageops.starbasedwhitebalance import apply_star_based_white_balance
|
|
39
|
+
from setiastro.saspro.legacy.numba_utils import apply_curves_numba
|
|
40
|
+
from setiastro.saspro.cosmicclarity_preset import _cosmic_root, _platform_exe_names
|
|
41
|
+
from setiastro.saspro.widgets.themed_buttons import themed_toolbtn
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def apply_curves_adjustment(image, target_median, curves_boost):
|
|
45
|
+
"""
|
|
46
|
+
Original signature unchanged, but now uses a Numba helper
|
|
47
|
+
to do the pixel-by-pixel interpolation.
|
|
48
|
+
|
|
49
|
+
'image' can be 2D (H,W) or 3D (H,W,3).
|
|
50
|
+
"""
|
|
51
|
+
# Build the curve array as before
|
|
52
|
+
curve = [
|
|
53
|
+
[0.0, 0.0],
|
|
54
|
+
[0.5 * target_median, 0.5 * target_median],
|
|
55
|
+
[target_median, target_median],
|
|
56
|
+
[
|
|
57
|
+
(1/4 * (1 - target_median) + target_median),
|
|
58
|
+
np.power((1/4 * (1 - target_median) + target_median), (1 - curves_boost))
|
|
59
|
+
],
|
|
60
|
+
[
|
|
61
|
+
(3/4 * (1 - target_median) + target_median),
|
|
62
|
+
np.power(np.power((3/4 * (1 - target_median) + target_median), (1 - curves_boost)), (1 - curves_boost))
|
|
63
|
+
],
|
|
64
|
+
[1.0, 1.0]
|
|
65
|
+
]
|
|
66
|
+
# Convert to arrays
|
|
67
|
+
xvals = np.array([p[0] for p in curve], dtype=np.float32)
|
|
68
|
+
yvals = np.array([p[1] for p in curve], dtype=np.float32)
|
|
69
|
+
|
|
70
|
+
# Ensure 'image' is float32
|
|
71
|
+
image_32 = image.astype(np.float32, copy=False)
|
|
72
|
+
|
|
73
|
+
# Now apply the piecewise linear function in Numba
|
|
74
|
+
adjusted_image = apply_curves_numba(image_32, xvals, yvals)
|
|
75
|
+
return adjusted_image
|
|
76
|
+
|
|
77
|
+
class ContinuumSubtractTab(QWidget):
|
|
78
|
+
def __init__(self, doc_manager, document=None, parent=None):
|
|
79
|
+
super().__init__(parent)
|
|
80
|
+
self.parent_window = parent
|
|
81
|
+
self.doc_manager = doc_manager
|
|
82
|
+
self.initUI()
|
|
83
|
+
self._threads = []
|
|
84
|
+
# — initialize every loadable image to None —
|
|
85
|
+
self.ha_image = None
|
|
86
|
+
self.sii_image = None
|
|
87
|
+
self.oiii_image = None
|
|
88
|
+
self.red_image = None
|
|
89
|
+
self.green_image = None
|
|
90
|
+
self.osc_image = None
|
|
91
|
+
# NEW: composite HaO3 / S2O3 "source" images (optional)
|
|
92
|
+
self.hao3_image = None
|
|
93
|
+
self.s2o3_image = None
|
|
94
|
+
self.hao3_starless_image = None
|
|
95
|
+
self.s2o3_starless_image = None
|
|
96
|
+
|
|
97
|
+
# NEW: OIII components extracted from composites (for averaging)
|
|
98
|
+
self._o3_from_hao3 = None
|
|
99
|
+
self._o3_from_s2o3 = None
|
|
100
|
+
self._o3_from_hao3_starless = None
|
|
101
|
+
self._o3_from_s2o3_starless = None
|
|
102
|
+
self.filename = None
|
|
103
|
+
self.is_mono = True
|
|
104
|
+
self.combined_image = None
|
|
105
|
+
self.processing_thread = None
|
|
106
|
+
self.original_header = None
|
|
107
|
+
self._clickable_images = {}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def initUI(self):
|
|
111
|
+
self.spinnerLabel = QLabel("") # starts empty
|
|
112
|
+
self.spinnerLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
113
|
+
self.spinnerLabel.setStyleSheet("color:#999; font-style:italic;")
|
|
114
|
+
self.spinnerLabel.hide()
|
|
115
|
+
|
|
116
|
+
# images (starless)
|
|
117
|
+
self.ha_starless_image = None
|
|
118
|
+
self.sii_starless_image = None
|
|
119
|
+
self.oiii_starless_image = None
|
|
120
|
+
self.red_starless_image = None
|
|
121
|
+
self.green_starless_image = None
|
|
122
|
+
self.osc_starless_image = None
|
|
123
|
+
|
|
124
|
+
# status
|
|
125
|
+
self.statusLabel = QLabel("")
|
|
126
|
+
self.statusLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
127
|
+
|
|
128
|
+
main_layout = QVBoxLayout() # overall vertical: columns + bottom row
|
|
129
|
+
columns_layout = QHBoxLayout() # holds the three groups
|
|
130
|
+
|
|
131
|
+
# — NB group —
|
|
132
|
+
nb_group = QGroupBox(self.tr("Narrowband Filters"))
|
|
133
|
+
nb_l = QVBoxLayout()
|
|
134
|
+
for name, attr in [("Ha","ha"), ("SII","sii"), ("OIII","oiii")]:
|
|
135
|
+
# starry
|
|
136
|
+
btn = QPushButton(f"Load {name}")
|
|
137
|
+
lbl = QLabel(f"No {name}")
|
|
138
|
+
setattr(self, f"{attr}Button", btn)
|
|
139
|
+
setattr(self, f"{attr}Label", lbl)
|
|
140
|
+
btn.clicked.connect(lambda _, n=name: self.loadImage(n))
|
|
141
|
+
nb_l.addWidget(btn); nb_l.addWidget(lbl)
|
|
142
|
+
|
|
143
|
+
# starless
|
|
144
|
+
btn_sl = QPushButton(f"Load {name} (Starless)")
|
|
145
|
+
lbl_sl = QLabel(f"No {name} (starless)")
|
|
146
|
+
setattr(self, f"{attr}StarlessButton", btn_sl)
|
|
147
|
+
setattr(self, f"{attr}StarlessLabel", lbl_sl)
|
|
148
|
+
btn_sl.clicked.connect(lambda _, n=f"{name} (Starless)": self.loadImage(n))
|
|
149
|
+
nb_l.addWidget(btn_sl); nb_l.addWidget(lbl_sl)
|
|
150
|
+
|
|
151
|
+
for name, attr in [("HaO3", "hao3"), ("S2O3", "s2o3")]:
|
|
152
|
+
# starry
|
|
153
|
+
btn = QPushButton(f"Load {name}")
|
|
154
|
+
lbl = QLabel(f"No {name}")
|
|
155
|
+
setattr(self, f"{attr}Button", btn)
|
|
156
|
+
setattr(self, f"{attr}Label", lbl)
|
|
157
|
+
btn.clicked.connect(lambda _, n=name: self.loadImage(n))
|
|
158
|
+
nb_l.addWidget(btn); nb_l.addWidget(lbl)
|
|
159
|
+
|
|
160
|
+
# starless
|
|
161
|
+
btn_sl = QPushButton(f"Load {name} (Starless)")
|
|
162
|
+
lbl_sl = QLabel(f"No {name} (starless)")
|
|
163
|
+
setattr(self, f"{attr}StarlessButton", btn_sl)
|
|
164
|
+
setattr(self, f"{attr}StarlessLabel", lbl_sl)
|
|
165
|
+
btn_sl.clicked.connect(lambda _, n=f"{name} (Starless)": self.loadImage(n))
|
|
166
|
+
nb_l.addWidget(btn_sl); nb_l.addWidget(lbl_sl)
|
|
167
|
+
|
|
168
|
+
# user controls
|
|
169
|
+
self.linear_output_checkbox = QCheckBox(self.tr("Output Linear Image Only"))
|
|
170
|
+
nb_l.addWidget(self.linear_output_checkbox)
|
|
171
|
+
self.denoise_checkbox = QCheckBox(self.tr("Denoise continuum result with Cosmic Clarity (0.9)")) # NEW
|
|
172
|
+
self.denoise_checkbox.setToolTip(
|
|
173
|
+
"Runs Cosmic Clarity denoise on the linear continuum-subtracted image "
|
|
174
|
+
"before any non-linear stretch."
|
|
175
|
+
) # NEW
|
|
176
|
+
self.denoise_checkbox.setChecked(True)
|
|
177
|
+
nb_l.addWidget(self.denoise_checkbox)
|
|
178
|
+
# ---- Advanced (collapsed) ----
|
|
179
|
+
# defaults used elsewhere
|
|
180
|
+
self.threshold_value = 5.0
|
|
181
|
+
self.q_factor = 0.80
|
|
182
|
+
self.summary_gamma = 0.6 # gamma < 1.0 brightens summary previews
|
|
183
|
+
|
|
184
|
+
# header row with toggle button
|
|
185
|
+
adv_hdr = QHBoxLayout()
|
|
186
|
+
self.advanced_btn = QPushButton("Advanced ▸")
|
|
187
|
+
self.advanced_btn.setCheckable(False)
|
|
188
|
+
self.advanced_btn.setFlat(True)
|
|
189
|
+
self.advanced_btn.clicked.connect(self._toggle_advanced)
|
|
190
|
+
adv_hdr.addWidget(self.advanced_btn, stretch=0)
|
|
191
|
+
adv_hdr.addStretch(1)
|
|
192
|
+
nb_l.addLayout(adv_hdr)
|
|
193
|
+
|
|
194
|
+
# panel that will be shown/hidden
|
|
195
|
+
self.advanced_panel = QWidget()
|
|
196
|
+
adv_l = QVBoxLayout(self.advanced_panel)
|
|
197
|
+
adv_l.setContentsMargins(12, 0, 0, 0) # small indent
|
|
198
|
+
|
|
199
|
+
# WB threshold control (UI)
|
|
200
|
+
thr_row = QHBoxLayout()
|
|
201
|
+
self.threshold_label = QLabel(f"WB star detect threshold: {self.threshold_value:.1f}")
|
|
202
|
+
self.threshold_btn = QPushButton("Change…")
|
|
203
|
+
self.threshold_btn.clicked.connect(self._change_threshold)
|
|
204
|
+
thr_row.addWidget(self.threshold_label)
|
|
205
|
+
thr_row.addWidget(self.threshold_btn)
|
|
206
|
+
adv_l.addLayout(thr_row)
|
|
207
|
+
|
|
208
|
+
# Q factor control (UI)
|
|
209
|
+
q_row = QHBoxLayout()
|
|
210
|
+
self.q_label = QLabel(f"Continuum Q factor: {self.q_factor:.2f}")
|
|
211
|
+
self.q_btn = QPushButton("Change…")
|
|
212
|
+
self.q_btn.clicked.connect(self._change_q)
|
|
213
|
+
q_row.addWidget(self.q_label)
|
|
214
|
+
q_row.addWidget(self.q_btn)
|
|
215
|
+
adv_l.addLayout(q_row)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
self.advanced_panel.setVisible(False) # start hidden
|
|
220
|
+
nb_l.addWidget(self.advanced_panel)
|
|
221
|
+
|
|
222
|
+
nb_l.addStretch(1)
|
|
223
|
+
|
|
224
|
+
self.clear_button = QPushButton(self.tr("Clear Loaded Images"))
|
|
225
|
+
self.clear_button.clicked.connect(self.clear_loaded_images)
|
|
226
|
+
nb_l.addWidget(self.clear_button)
|
|
227
|
+
nb_group.setLayout(nb_l)
|
|
228
|
+
|
|
229
|
+
# — Continuum group —
|
|
230
|
+
cont_group = QGroupBox(self.tr("Continuum Sources"))
|
|
231
|
+
cont_l = QVBoxLayout()
|
|
232
|
+
for name, attr in [("Red","red"), ("Green","green"), ("OSC","osc")]:
|
|
233
|
+
btn = QPushButton(f"Load {name}")
|
|
234
|
+
lbl = QLabel(f"No {name}")
|
|
235
|
+
setattr(self, f"{attr}Button", btn)
|
|
236
|
+
setattr(self, f"{attr}Label", lbl)
|
|
237
|
+
btn.clicked.connect(lambda _, n=name: self.loadImage(n))
|
|
238
|
+
cont_l.addWidget(btn); cont_l.addWidget(lbl)
|
|
239
|
+
|
|
240
|
+
btn_sl = QPushButton(f"Load {name} (Starless)")
|
|
241
|
+
lbl_sl = QLabel(f"No {name} (starless)")
|
|
242
|
+
setattr(self, f"{attr}StarlessButton", btn_sl)
|
|
243
|
+
setattr(self, f"{attr}StarlessLabel", lbl_sl)
|
|
244
|
+
btn_sl.clicked.connect(lambda _, n=f"{name} (Starless)": self.loadImage(n))
|
|
245
|
+
cont_l.addWidget(btn_sl); cont_l.addWidget(lbl_sl)
|
|
246
|
+
|
|
247
|
+
cont_l.addStretch(1)
|
|
248
|
+
cont_group.setLayout(cont_l)
|
|
249
|
+
|
|
250
|
+
# — White balance diagnostics —
|
|
251
|
+
wb_group = QGroupBox(self.tr("Star-Based WB"))
|
|
252
|
+
self.wb_l = QVBoxLayout()
|
|
253
|
+
self.wb_l.setAlignment(Qt.AlignmentFlag.AlignTop)
|
|
254
|
+
wb_group.setLayout(self.wb_l)
|
|
255
|
+
|
|
256
|
+
# put it in a scroll area so many entries won't overflow
|
|
257
|
+
wb_scroll = QScrollArea()
|
|
258
|
+
wb_scroll.setWidgetResizable(True)
|
|
259
|
+
wb_container = QWidget()
|
|
260
|
+
wb_container.setLayout(self.wb_l)
|
|
261
|
+
wb_scroll.setWidget(wb_container)
|
|
262
|
+
|
|
263
|
+
# assemble columns
|
|
264
|
+
columns_layout.addWidget(nb_group, 1)
|
|
265
|
+
columns_layout.addWidget(cont_group, 1)
|
|
266
|
+
columns_layout.addWidget(wb_scroll, 2)
|
|
267
|
+
|
|
268
|
+
# — Bottom row: Execute & status —
|
|
269
|
+
bottom_layout = QHBoxLayout()
|
|
270
|
+
self.execute_button = QPushButton(self.tr("Execute"))
|
|
271
|
+
self.execute_button.clicked.connect(self.startContinuumSubtraction)
|
|
272
|
+
bottom_layout.addWidget(self.execute_button, stretch=1)
|
|
273
|
+
bottom_layout.addWidget(self.spinnerLabel, stretch=1)
|
|
274
|
+
bottom_layout.addWidget(self.statusLabel, stretch=3)
|
|
275
|
+
|
|
276
|
+
# put it all together
|
|
277
|
+
main_layout.addLayout(columns_layout)
|
|
278
|
+
main_layout.addLayout(bottom_layout)
|
|
279
|
+
|
|
280
|
+
self.setLayout(main_layout)
|
|
281
|
+
self.installEventFilter(self)
|
|
282
|
+
|
|
283
|
+
def _toggle_advanced(self):
|
|
284
|
+
show = not self.advanced_panel.isVisible()
|
|
285
|
+
self.advanced_panel.setVisible(show)
|
|
286
|
+
self.advanced_btn.setText("Advanced ▾" if show else "Advanced ▸")
|
|
287
|
+
|
|
288
|
+
def _change_q(self):
|
|
289
|
+
val, ok = QInputDialog.getDouble(
|
|
290
|
+
self,
|
|
291
|
+
"Continuum Q Factor",
|
|
292
|
+
"Q (scale of broadband subtraction, typical 0.6–1.0):",
|
|
293
|
+
self.q_factor,
|
|
294
|
+
0.10, 2.00, 2 # min, max, decimals
|
|
295
|
+
)
|
|
296
|
+
if ok:
|
|
297
|
+
self.q_factor = float(val)
|
|
298
|
+
self.q_label.setText(f"Continuum Q factor: {self.q_factor:.2f}")
|
|
299
|
+
|
|
300
|
+
def _change_threshold(self):
|
|
301
|
+
val, ok = QInputDialog.getDouble(
|
|
302
|
+
self,
|
|
303
|
+
"WB Threshold",
|
|
304
|
+
"Sigma threshold for star detection:",
|
|
305
|
+
self.threshold_value,
|
|
306
|
+
0.5, 50.0, 1 # min, max, decimals
|
|
307
|
+
)
|
|
308
|
+
if ok:
|
|
309
|
+
self.threshold_value = float(val)
|
|
310
|
+
self.threshold_label.setText(f"WB star detect threshold: {self.threshold_value:.1f}")
|
|
311
|
+
|
|
312
|
+
def _main_window(self) -> QMainWindow | None:
|
|
313
|
+
# 1) explicit parent the tool may have been created with
|
|
314
|
+
mw = self.parent_window
|
|
315
|
+
if mw and hasattr(mw, "mdi"):
|
|
316
|
+
return mw
|
|
317
|
+
# 2) walk up the parent chain
|
|
318
|
+
p = self.parent()
|
|
319
|
+
while p is not None:
|
|
320
|
+
if hasattr(p, "mdi"):
|
|
321
|
+
return p # main window
|
|
322
|
+
p = p.parent()
|
|
323
|
+
# 3) search top-level widgets
|
|
324
|
+
for w in QApplication.topLevelWidgets():
|
|
325
|
+
if hasattr(w, "mdi"):
|
|
326
|
+
return w
|
|
327
|
+
return None
|
|
328
|
+
|
|
329
|
+
def _iter_open_docs(self):
|
|
330
|
+
"""Yield (doc, title) for all open subwindows."""
|
|
331
|
+
mw = self._main_window()
|
|
332
|
+
if not mw or not hasattr(mw, "mdi"):
|
|
333
|
+
return []
|
|
334
|
+
out = []
|
|
335
|
+
for sw in mw.mdi.subWindowList():
|
|
336
|
+
w = sw.widget()
|
|
337
|
+
d = getattr(w, "document", None)
|
|
338
|
+
if d is not None:
|
|
339
|
+
out.append((d, sw.windowTitle()))
|
|
340
|
+
return out
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def refresh(self):
|
|
344
|
+
if self.image_manager:
|
|
345
|
+
# You might have a way to retrieve the current image and metadata.
|
|
346
|
+
# For example, if your image_manager stores the current image,
|
|
347
|
+
# you could do something like:
|
|
348
|
+
return
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def clear_loaded_images(self):
|
|
353
|
+
for attr in (
|
|
354
|
+
"ha_image","sii_image","oiii_image","red_image","green_image","osc_image",
|
|
355
|
+
"ha_starless_image","sii_starless_image","oiii_starless_image",
|
|
356
|
+
"red_starless_image","green_starless_image","osc_starless_image",
|
|
357
|
+
# NEW composite attrs
|
|
358
|
+
"hao3_image","s2o3_image",
|
|
359
|
+
"hao3_starless_image","s2o3_starless_image"
|
|
360
|
+
):
|
|
361
|
+
setattr(self, attr, None)
|
|
362
|
+
|
|
363
|
+
# Reset NB labels
|
|
364
|
+
self.haLabel.setText("No Ha")
|
|
365
|
+
self.siiLabel.setText("No SII")
|
|
366
|
+
self.oiiiLabel.setText("No OIII")
|
|
367
|
+
# NEW composite labels
|
|
368
|
+
self.hao3Label.setText("No HaO3")
|
|
369
|
+
self.s2o3Label.setText("No S2O3")
|
|
370
|
+
|
|
371
|
+
# Reset continuum labels
|
|
372
|
+
self.redLabel.setText("No Red")
|
|
373
|
+
self.greenLabel.setText("No Green")
|
|
374
|
+
self.oscLabel.setText("No OSC")
|
|
375
|
+
|
|
376
|
+
self.haStarlessLabel.setText("No Ha (starless)")
|
|
377
|
+
self.siiStarlessLabel.setText("No SII (starless)")
|
|
378
|
+
self.oiiiStarlessLabel.setText("No OIII (starless)")
|
|
379
|
+
self.redStarlessLabel.setText("No Red (starless)")
|
|
380
|
+
self.greenStarlessLabel.setText("No Green (starless)")
|
|
381
|
+
self.oscStarlessLabel.setText("No OSC (starless)")
|
|
382
|
+
|
|
383
|
+
# NEW: clear OIII-from-composite caches
|
|
384
|
+
self._o3_from_hao3 = None
|
|
385
|
+
self._o3_from_s2o3 = None
|
|
386
|
+
self._o3_from_hao3_starless = None
|
|
387
|
+
self._o3_from_s2o3_starless = None
|
|
388
|
+
|
|
389
|
+
self.combined_image = None
|
|
390
|
+
self.statusLabel.setText("All loaded images cleared.")
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def loadImage(self, channel: str):
|
|
394
|
+
"""
|
|
395
|
+
Prompt the user to load either from file or from ImageManager slots,
|
|
396
|
+
for the given channel ("Ha", "SII", "OIII", "Red", "Green", "OSC").
|
|
397
|
+
"""
|
|
398
|
+
source, ok = QInputDialog.getItem(
|
|
399
|
+
self, f"Select {channel} Image Source", "Load image from:",
|
|
400
|
+
["From View", "From File"], editable=False
|
|
401
|
+
)
|
|
402
|
+
if not ok:
|
|
403
|
+
return
|
|
404
|
+
|
|
405
|
+
if source == "From File":
|
|
406
|
+
result = self.loadImageFromFile(channel)
|
|
407
|
+
else:
|
|
408
|
+
result = self.loadImageFromView(channel)
|
|
409
|
+
|
|
410
|
+
if not result:
|
|
411
|
+
return
|
|
412
|
+
|
|
413
|
+
image, header, bit_depth, is_mono, name_or_path = result
|
|
414
|
+
|
|
415
|
+
# Use view title if we got one; if it's a real path, show just the basename
|
|
416
|
+
label_text = str(name_or_path) if name_or_path else "From View"
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
try:
|
|
420
|
+
if isinstance(name_or_path, str) and os.path.isabs(name_or_path):
|
|
421
|
+
label_text = os.path.basename(name_or_path)
|
|
422
|
+
except Exception:
|
|
423
|
+
pass
|
|
424
|
+
|
|
425
|
+
is_starless = "(Starless)" in channel
|
|
426
|
+
base = channel.replace(" (Starless)", "")
|
|
427
|
+
|
|
428
|
+
if base == "Ha":
|
|
429
|
+
if is_starless:
|
|
430
|
+
self.ha_starless_image = image
|
|
431
|
+
self.haStarlessLabel.setText(label_text)
|
|
432
|
+
else:
|
|
433
|
+
self.ha_image = image
|
|
434
|
+
self.haLabel.setText(label_text)
|
|
435
|
+
|
|
436
|
+
elif base == "SII":
|
|
437
|
+
if is_starless:
|
|
438
|
+
self.sii_starless_image = image
|
|
439
|
+
self.siiStarlessLabel.setText(label_text)
|
|
440
|
+
else:
|
|
441
|
+
self.sii_image = image
|
|
442
|
+
self.siiLabel.setText(label_text)
|
|
443
|
+
|
|
444
|
+
elif base == "OIII":
|
|
445
|
+
if is_starless:
|
|
446
|
+
self.oiii_starless_image = image
|
|
447
|
+
self.oiiiStarlessLabel.setText(label_text)
|
|
448
|
+
else:
|
|
449
|
+
self.oiii_image = image
|
|
450
|
+
self.oiiiLabel.setText(label_text)
|
|
451
|
+
|
|
452
|
+
elif base == "Red":
|
|
453
|
+
if is_starless:
|
|
454
|
+
self.red_starless_image = image
|
|
455
|
+
self.redStarlessLabel.setText(label_text)
|
|
456
|
+
else:
|
|
457
|
+
self.red_image = image
|
|
458
|
+
self.redLabel.setText(label_text)
|
|
459
|
+
|
|
460
|
+
elif base == "Green":
|
|
461
|
+
if is_starless:
|
|
462
|
+
self.green_starless_image = image
|
|
463
|
+
self.greenStarlessLabel.setText(label_text)
|
|
464
|
+
else:
|
|
465
|
+
self.green_image = image
|
|
466
|
+
self.greenLabel.setText(label_text)
|
|
467
|
+
|
|
468
|
+
elif base == "OSC":
|
|
469
|
+
if is_starless:
|
|
470
|
+
self.osc_starless_image = image
|
|
471
|
+
self.oscStarlessLabel.setText(label_text)
|
|
472
|
+
else:
|
|
473
|
+
self.osc_image = image
|
|
474
|
+
self.oscLabel.setText(label_text)
|
|
475
|
+
|
|
476
|
+
# NEW: HaO3 composite → Ha (R), OIII (G)
|
|
477
|
+
elif base == "HaO3":
|
|
478
|
+
# keep the full composite for reference
|
|
479
|
+
if is_starless:
|
|
480
|
+
self.hao3_starless_image = image
|
|
481
|
+
self.hao3StarlessLabel.setText(label_text)
|
|
482
|
+
else:
|
|
483
|
+
self.hao3_image = image
|
|
484
|
+
self.hao3Label.setText(label_text)
|
|
485
|
+
|
|
486
|
+
if not (isinstance(image, np.ndarray) and image.ndim == 3 and image.shape[2] >= 2):
|
|
487
|
+
QMessageBox.warning(
|
|
488
|
+
self,
|
|
489
|
+
"HaO3 Load",
|
|
490
|
+
"HaO3 expects a 3-channel color image (R=Ha, G=OIII). "
|
|
491
|
+
"Loaded image is not 3-channel; cannot extract Ha/OIII."
|
|
492
|
+
)
|
|
493
|
+
return
|
|
494
|
+
|
|
495
|
+
img32 = image.astype(np.float32, copy=False)
|
|
496
|
+
ha_from_r = img32[..., 0]
|
|
497
|
+
o3_from_g = img32[..., 1]
|
|
498
|
+
|
|
499
|
+
if is_starless:
|
|
500
|
+
self.ha_starless_image = ha_from_r
|
|
501
|
+
self.haStarlessLabel.setText(label_text + " [R → Ha (starless)]")
|
|
502
|
+
self._o3_from_hao3_starless = o3_from_g
|
|
503
|
+
self._update_oiii_from_composites(starless=True)
|
|
504
|
+
else:
|
|
505
|
+
self.ha_image = ha_from_r
|
|
506
|
+
self.haLabel.setText(label_text + " [R → Ha]")
|
|
507
|
+
self._o3_from_hao3 = o3_from_g
|
|
508
|
+
self._update_oiii_from_composites(starless=False)
|
|
509
|
+
|
|
510
|
+
# NEW: S2O3 composite → SII (R), OIII (G)
|
|
511
|
+
elif base == "S2O3":
|
|
512
|
+
# keep the full composite for reference
|
|
513
|
+
if is_starless:
|
|
514
|
+
self.s2o3_starless_image = image
|
|
515
|
+
self.s2o3StarlessLabel.setText(label_text)
|
|
516
|
+
else:
|
|
517
|
+
self.s2o3_image = image
|
|
518
|
+
self.s2o3Label.setText(label_text)
|
|
519
|
+
|
|
520
|
+
if not (isinstance(image, np.ndarray) and image.ndim == 3 and image.shape[2] >= 2):
|
|
521
|
+
QMessageBox.warning(
|
|
522
|
+
self,
|
|
523
|
+
"S2O3 Load",
|
|
524
|
+
"S2O3 expects a 3-channel color image (R=SII, G=OIII). "
|
|
525
|
+
"Loaded image is not 3-channel; cannot extract SII/OIII."
|
|
526
|
+
)
|
|
527
|
+
return
|
|
528
|
+
|
|
529
|
+
img32 = image.astype(np.float32, copy=False)
|
|
530
|
+
s2_from_r = img32[..., 0]
|
|
531
|
+
o3_from_g = img32[..., 1]
|
|
532
|
+
|
|
533
|
+
if is_starless:
|
|
534
|
+
self.sii_starless_image = s2_from_r
|
|
535
|
+
self.siiStarlessLabel.setText(label_text + " [R → SII (starless)]")
|
|
536
|
+
self._o3_from_s2o3_starless = o3_from_g
|
|
537
|
+
self._update_oiii_from_composites(starless=True)
|
|
538
|
+
else:
|
|
539
|
+
self.sii_image = s2_from_r
|
|
540
|
+
self.siiLabel.setText(label_text + " [R → SII]")
|
|
541
|
+
self._o3_from_s2o3 = o3_from_g
|
|
542
|
+
self._update_oiii_from_composites(starless=False)
|
|
543
|
+
|
|
544
|
+
else:
|
|
545
|
+
QMessageBox.critical(self, "Error", f"Unknown channel '{channel}'.")
|
|
546
|
+
return
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
# Store header and mono-flag for later saving
|
|
550
|
+
self.original_header = header
|
|
551
|
+
self.is_mono = is_mono
|
|
552
|
+
|
|
553
|
+
# --- NEW: helper to combine OIII from HaO3 and S2O3 ---
|
|
554
|
+
def _update_oiii_from_composites(self, starless: bool):
|
|
555
|
+
"""
|
|
556
|
+
Average all available composite-derived green channels into a single OIII NB image.
|
|
557
|
+
Only averages HaO3+S2O3 (does NOT touch any manually loaded OIII).
|
|
558
|
+
"""
|
|
559
|
+
sources = []
|
|
560
|
+
labels = []
|
|
561
|
+
|
|
562
|
+
if starless:
|
|
563
|
+
if self._o3_from_hao3_starless is not None:
|
|
564
|
+
sources.append(self._o3_from_hao3_starless)
|
|
565
|
+
labels.append("HaO3")
|
|
566
|
+
if self._o3_from_s2o3_starless is not None:
|
|
567
|
+
sources.append(self._o3_from_s2o3_starless)
|
|
568
|
+
labels.append("S2O3")
|
|
569
|
+
else:
|
|
570
|
+
if self._o3_from_hao3 is not None:
|
|
571
|
+
sources.append(self._o3_from_hao3)
|
|
572
|
+
labels.append("HaO3")
|
|
573
|
+
if self._o3_from_s2o3 is not None:
|
|
574
|
+
sources.append(self._o3_from_s2o3)
|
|
575
|
+
labels.append("S2O3")
|
|
576
|
+
|
|
577
|
+
if not sources:
|
|
578
|
+
return
|
|
579
|
+
|
|
580
|
+
try:
|
|
581
|
+
combo = np.mean(np.stack(sources, axis=0), axis=0).astype(np.float32, copy=False)
|
|
582
|
+
except ValueError:
|
|
583
|
+
# shape mismatch – fall back to last one
|
|
584
|
+
combo = sources[-1]
|
|
585
|
+
|
|
586
|
+
if starless:
|
|
587
|
+
self.oiii_starless_image = combo
|
|
588
|
+
base_label = "OIII from " + "+".join(labels) + " (starless)"
|
|
589
|
+
self.oiiiStarlessLabel.setText(base_label)
|
|
590
|
+
else:
|
|
591
|
+
self.oiii_image = combo
|
|
592
|
+
base_label = "OIII from " + "+".join(labels)
|
|
593
|
+
self.oiiiLabel.setText(base_label)
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def _collect_open_documents(self):
|
|
597
|
+
# kept for compatibility with callers; returns only docs
|
|
598
|
+
return [d for d, _ in self._iter_open_docs()]
|
|
599
|
+
|
|
600
|
+
def _select_document_via_dropdown(self, title: str):
|
|
601
|
+
items = self._iter_open_docs()
|
|
602
|
+
if not items:
|
|
603
|
+
QMessageBox.information(self, f"Select View — {title}", "No open views/documents found.")
|
|
604
|
+
return None
|
|
605
|
+
|
|
606
|
+
# default to active view if present
|
|
607
|
+
mw = self._main_window()
|
|
608
|
+
active_doc = None
|
|
609
|
+
if mw and mw.mdi.activeSubWindow():
|
|
610
|
+
active_doc = getattr(mw.mdi.activeSubWindow().widget(), "document", None)
|
|
611
|
+
|
|
612
|
+
if len(items) == 1:
|
|
613
|
+
return items[0][0]
|
|
614
|
+
|
|
615
|
+
names = [t for _, t in items]
|
|
616
|
+
default_index = next((i for i, (d, _) in enumerate(items) if d is active_doc), 0)
|
|
617
|
+
|
|
618
|
+
choice, ok = QInputDialog.getItem(
|
|
619
|
+
self, f"Select View — {title}", "Choose:", names, default_index, False
|
|
620
|
+
)
|
|
621
|
+
if not ok:
|
|
622
|
+
return None
|
|
623
|
+
return items[names.index(choice)][0]
|
|
624
|
+
|
|
625
|
+
def _image_from_doc(self, doc):
|
|
626
|
+
"""(np.ndarray, header, bit_depth, is_mono, file_path) from an ImageDocument."""
|
|
627
|
+
arr = getattr(doc, "image", None)
|
|
628
|
+
if arr is None:
|
|
629
|
+
QMessageBox.warning(self, "No image", "Selected view has no image.")
|
|
630
|
+
return None
|
|
631
|
+
meta = getattr(doc, "metadata", {}) or {}
|
|
632
|
+
header = meta.get("original_header") or meta.get("fits_header") or meta.get("header")
|
|
633
|
+
bit_depth = meta.get("bit_depth", "Unknown")
|
|
634
|
+
is_mono = False
|
|
635
|
+
try:
|
|
636
|
+
import numpy as np
|
|
637
|
+
is_mono = isinstance(arr, np.ndarray) and (arr.ndim == 2 or (arr.ndim == 3 and arr.shape[2] == 1))
|
|
638
|
+
except Exception:
|
|
639
|
+
pass
|
|
640
|
+
return arr, header, bit_depth, is_mono, meta.get("file_path")
|
|
641
|
+
|
|
642
|
+
def loadImageFromView(self, channel: str):
|
|
643
|
+
doc = self._select_document_via_dropdown(channel)
|
|
644
|
+
if not doc:
|
|
645
|
+
return None
|
|
646
|
+
res = self._image_from_doc(doc)
|
|
647
|
+
if not res:
|
|
648
|
+
return None
|
|
649
|
+
|
|
650
|
+
img, header, bit_depth, is_mono, _ = res
|
|
651
|
+
|
|
652
|
+
# Build a human-friendly name for the label (view/subwindow title)
|
|
653
|
+
title = ""
|
|
654
|
+
try:
|
|
655
|
+
title = doc.display_name()
|
|
656
|
+
except Exception:
|
|
657
|
+
mw = self._main_window()
|
|
658
|
+
if mw and mw.mdi.activeSubWindow():
|
|
659
|
+
title = mw.mdi.activeSubWindow().windowTitle()
|
|
660
|
+
|
|
661
|
+
# Return with the "path" field set to the title so the caller can label it
|
|
662
|
+
return img, header, bit_depth, is_mono, title
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def loadImageFromFile(self, channel: str):
|
|
666
|
+
file_filter = "Images (*.png *.tif *.tiff *.fits *.fit *.xisf)"
|
|
667
|
+
path, _ = QFileDialog.getOpenFileName(self, f"Select {channel} Image", "", file_filter)
|
|
668
|
+
if not path:
|
|
669
|
+
return None
|
|
670
|
+
try:
|
|
671
|
+
image, header, bit_depth, is_mono = legacy_load_image(path) # ← use the alias
|
|
672
|
+
except Exception as e:
|
|
673
|
+
QMessageBox.critical(self, "Error", f"Failed to load {channel} image:\n{e}")
|
|
674
|
+
return None
|
|
675
|
+
return image, header, bit_depth, is_mono, path
|
|
676
|
+
|
|
677
|
+
def loadImageFromSlot(self, channel: str):
|
|
678
|
+
"""
|
|
679
|
+
Prompt the user to pick one of the ImageManager’s slots (using custom names if defined)
|
|
680
|
+
and load that image.
|
|
681
|
+
"""
|
|
682
|
+
if not self.image_manager:
|
|
683
|
+
QMessageBox.critical(self, "Error", "ImageManager is not initialized. Cannot load image from slot.")
|
|
684
|
+
return None
|
|
685
|
+
|
|
686
|
+
# Look up the main window’s custom slot names
|
|
687
|
+
main_win = getattr(self, "parent_window", None) or self.window()
|
|
688
|
+
slot_names = getattr(main_win, "slot_names", {})
|
|
689
|
+
|
|
690
|
+
# Build the list of display names (zero-based)
|
|
691
|
+
display_names = [
|
|
692
|
+
slot_names.get(i, f"Slot {i}")
|
|
693
|
+
for i in range(self.image_manager.max_slots)
|
|
694
|
+
]
|
|
695
|
+
|
|
696
|
+
# Ask the user to choose one
|
|
697
|
+
choice, ok = QInputDialog.getItem(
|
|
698
|
+
self,
|
|
699
|
+
f"Select Slot for {channel}",
|
|
700
|
+
"Choose a slot:",
|
|
701
|
+
display_names,
|
|
702
|
+
0,
|
|
703
|
+
False
|
|
704
|
+
)
|
|
705
|
+
if not ok or not choice:
|
|
706
|
+
return None
|
|
707
|
+
|
|
708
|
+
# Map back to the numeric index
|
|
709
|
+
idx = display_names.index(choice)
|
|
710
|
+
|
|
711
|
+
# Retrieve the image and metadata
|
|
712
|
+
img = self.image_manager._images.get(idx)
|
|
713
|
+
if img is None:
|
|
714
|
+
QMessageBox.warning(self, "Empty Slot", f"{choice} is empty.")
|
|
715
|
+
return None
|
|
716
|
+
|
|
717
|
+
meta = self.image_manager._metadata.get(idx, {})
|
|
718
|
+
return (
|
|
719
|
+
img,
|
|
720
|
+
meta.get("original_header"),
|
|
721
|
+
meta.get("bit_depth", "Unknown"),
|
|
722
|
+
meta.get("is_mono", False),
|
|
723
|
+
meta.get("file_path", None)
|
|
724
|
+
)
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def startContinuumSubtraction(self):
|
|
728
|
+
# STARRED (with stars) continuum channels
|
|
729
|
+
cont_red_starry = self.red_image if self.red_image is not None else (self.osc_image[..., 0] if self.osc_image is not None else None)
|
|
730
|
+
cont_green_starry = self.green_image if self.green_image is not None else (self.osc_image[..., 1] if self.osc_image is not None else None)
|
|
731
|
+
|
|
732
|
+
# STARLESS continuum channels
|
|
733
|
+
cont_red_starless = self.red_starless_image if self.red_starless_image is not None else (self.osc_starless_image[..., 0] if self.osc_starless_image is not None else None)
|
|
734
|
+
cont_green_starless = self.green_starless_image if self.green_starless_image is not None else (self.osc_starless_image[..., 1] if self.osc_starless_image is not None else None)
|
|
735
|
+
|
|
736
|
+
# Build tasks per NB filter
|
|
737
|
+
pairs = []
|
|
738
|
+
def add_pair(name, nb_starry, cont_starry, nb_starless, cont_starless):
|
|
739
|
+
has_starry = (nb_starry is not None and cont_starry is not None)
|
|
740
|
+
has_starless = (nb_starless is not None and cont_starless is not None)
|
|
741
|
+
if has_starry or has_starless:
|
|
742
|
+
pairs.append({
|
|
743
|
+
"name": name,
|
|
744
|
+
"nb": nb_starry,
|
|
745
|
+
"cont": cont_starry,
|
|
746
|
+
"nb_sl": nb_starless,
|
|
747
|
+
"cont_sl": cont_starless,
|
|
748
|
+
"starless_only": (has_starless and not has_starry),
|
|
749
|
+
})
|
|
750
|
+
|
|
751
|
+
add_pair("Ha", self.ha_image, cont_red_starry, self.ha_starless_image, cont_red_starless)
|
|
752
|
+
add_pair("SII", self.sii_image, cont_red_starry, self.sii_starless_image, cont_red_starless)
|
|
753
|
+
add_pair("OIII", self.oiii_image, cont_green_starry, self.oiii_starless_image, cont_green_starless)
|
|
754
|
+
|
|
755
|
+
if not pairs:
|
|
756
|
+
self.statusLabel.setText("Load at least one NB + matching continuum channel (or OSC).")
|
|
757
|
+
return
|
|
758
|
+
mw = self._main_window()
|
|
759
|
+
cosmic_root = _cosmic_root(mw) if mw is not None else "" # NEW
|
|
760
|
+
denoise_linear = self.denoise_checkbox.isChecked() # NEW
|
|
761
|
+
self.showSpinner()
|
|
762
|
+
self._threads = []
|
|
763
|
+
self._results = []
|
|
764
|
+
self._pushed_results = False
|
|
765
|
+
self._pending = 0
|
|
766
|
+
|
|
767
|
+
# How many result signals do we expect in total?
|
|
768
|
+
self._expected_results = sum(
|
|
769
|
+
(1 if p["nb"] is not None and p["cont"] is not None else 0) +
|
|
770
|
+
(1 if p["nb_sl"] is not None and p["cont_sl"] is not None else 0)
|
|
771
|
+
for p in pairs
|
|
772
|
+
)
|
|
773
|
+
|
|
774
|
+
for p in pairs:
|
|
775
|
+
t = ContinuumProcessingThread(
|
|
776
|
+
p["nb"], p["cont"], self.linear_output_checkbox.isChecked(),
|
|
777
|
+
starless_nb=p["nb_sl"], starless_cont=p["cont_sl"], starless_only=p["starless_only"],
|
|
778
|
+
threshold=self.threshold_value, summary_gamma=self.summary_gamma, q_factor=self.q_factor,
|
|
779
|
+
cosmic_root=cosmic_root, denoise_linear=denoise_linear # NEW
|
|
780
|
+
)
|
|
781
|
+
name = p["name"] # avoid late binding in lambdas
|
|
782
|
+
|
|
783
|
+
if p["nb"] is not None and p["cont"] is not None:
|
|
784
|
+
self._pending += 1
|
|
785
|
+
t.processing_complete.connect(
|
|
786
|
+
lambda img, stars, overlay, raw, after, n=f"{name} (starry)":
|
|
787
|
+
self._onOneResult(n, img, stars, overlay, raw, after)
|
|
788
|
+
)
|
|
789
|
+
|
|
790
|
+
if p["nb_sl"] is not None and p["cont_sl"] is not None:
|
|
791
|
+
self._pending += 1
|
|
792
|
+
t.processing_complete_starless.connect(
|
|
793
|
+
lambda img, stars, overlay, raw, after, n=f"{name} (starless)":
|
|
794
|
+
self._onOneResult(n, img, stars, overlay, raw, after)
|
|
795
|
+
)
|
|
796
|
+
|
|
797
|
+
t.status_update.connect(self.update_status_label)
|
|
798
|
+
self._threads.append(t)
|
|
799
|
+
t.start()
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def _onOneResult(self, filt, img, star_count, overlay_qimg, raw_pixels, after_pixels):
|
|
803
|
+
# stash for later slot‐pushing
|
|
804
|
+
self._results.append({
|
|
805
|
+
"filter": filt,
|
|
806
|
+
"image": img,
|
|
807
|
+
"stars": star_count,
|
|
808
|
+
"overlay": overlay_qimg,
|
|
809
|
+
"raw": raw_pixels,
|
|
810
|
+
"after": after_pixels
|
|
811
|
+
})
|
|
812
|
+
|
|
813
|
+
# ---------- thumbnails / diagnostics ----------
|
|
814
|
+
make_scatter = (
|
|
815
|
+
isinstance(raw_pixels, np.ndarray) and
|
|
816
|
+
raw_pixels.ndim == 2 and raw_pixels.shape[1] >= 2 and
|
|
817
|
+
raw_pixels.shape[0] >= 3 and
|
|
818
|
+
(cv2 is not None)
|
|
819
|
+
)
|
|
820
|
+
|
|
821
|
+
if make_scatter:
|
|
822
|
+
nb_flux = raw_pixels[:, 0].astype(np.float32, copy=False)
|
|
823
|
+
cont_flux = raw_pixels[:, 1].astype(np.float32, copy=False)
|
|
824
|
+
|
|
825
|
+
h, w = 200, 200
|
|
826
|
+
scatter_img = np.ones((h, w, 3), np.uint8) * 255
|
|
827
|
+
|
|
828
|
+
# 1) best-fit NB ≈ m·BB + c
|
|
829
|
+
try:
|
|
830
|
+
m, c = np.polyfit(cont_flux, nb_flux, 1)
|
|
831
|
+
x0f, y0f = 0.0, c
|
|
832
|
+
x1f, y1f = 1.0, m + c
|
|
833
|
+
y0f = float(np.clip(y0f, 0.0, 1.0))
|
|
834
|
+
y1f = float(np.clip(y1f, 0.0, 1.0))
|
|
835
|
+
x0 = int(x0f * (w - 1)); y0 = int((1 - y0f) * (h - 1))
|
|
836
|
+
x1 = int(x1f * (w - 1)); y1 = int((1 - y1f) * (h - 1))
|
|
837
|
+
cv2.line(scatter_img, (x0, y0), (x1, y1), (0, 0, 255), 2) # red line (BGR)
|
|
838
|
+
except Exception:
|
|
839
|
+
pass
|
|
840
|
+
|
|
841
|
+
# 2) points
|
|
842
|
+
xs = (np.clip(cont_flux, 0, 1) * (w - 1)).astype(int)
|
|
843
|
+
ys = ((1 - np.clip(nb_flux, 0, 1)) * (h - 1)).astype(int)
|
|
844
|
+
for x, y in zip(xs, ys):
|
|
845
|
+
if 0 <= x < w and 0 <= y < h:
|
|
846
|
+
cv2.circle(scatter_img, (x, y), 2, (255, 0, 0), -1) # blue points (BGR)
|
|
847
|
+
|
|
848
|
+
# axes
|
|
849
|
+
cv2.line(scatter_img, (0, h - 1), (w - 1, h - 1), (0, 0, 0), 1)
|
|
850
|
+
cv2.line(scatter_img, (0, 0), (0, h - 1), (0, 0, 0), 1)
|
|
851
|
+
|
|
852
|
+
# labels
|
|
853
|
+
font = cv2.FONT_HERSHEY_SIMPLEX
|
|
854
|
+
((tw, _), _) = cv2.getTextSize("BB Flux", font, 0.5, 1)
|
|
855
|
+
cv2.putText(scatter_img, "BB Flux", ((w - tw) // 2, h - 5), font, 0.5, (0, 0, 0), 1, cv2.LINE_AA)
|
|
856
|
+
for i, ch in enumerate("NB Flux"):
|
|
857
|
+
cv2.putText(scatter_img, ch, (2, 15 + i*15), font, 0.5, (0, 0, 0), 1, cv2.LINE_AA)
|
|
858
|
+
|
|
859
|
+
qscatter = QImage(scatter_img.data, w, h, 3*w, QImage.Format.Format_RGB888).copy()
|
|
860
|
+
scatter_pix = QPixmap.fromImage(qscatter)
|
|
861
|
+
|
|
862
|
+
# overlay thumbnail (always)
|
|
863
|
+
thumb_pix = QPixmap.fromImage(overlay_qimg).scaled(
|
|
864
|
+
200, 200,
|
|
865
|
+
Qt.AspectRatioMode.KeepAspectRatio,
|
|
866
|
+
Qt.TransformationMode.SmoothTransformation
|
|
867
|
+
)
|
|
868
|
+
|
|
869
|
+
# assemble entry row
|
|
870
|
+
entry = QWidget()
|
|
871
|
+
elay = QHBoxLayout(entry)
|
|
872
|
+
elay.addWidget(QLabel(f"{filt}: {star_count} stars"))
|
|
873
|
+
|
|
874
|
+
if make_scatter:
|
|
875
|
+
scatter_label = QLabel()
|
|
876
|
+
scatter_label.setPixmap(scatter_pix)
|
|
877
|
+
scatter_label.setCursor(Qt.CursorShape.PointingHandCursor)
|
|
878
|
+
elay.addWidget(scatter_label)
|
|
879
|
+
self._clickable_images[scatter_label] = scatter_pix
|
|
880
|
+
scatter_label.installEventFilter(self)
|
|
881
|
+
|
|
882
|
+
overlay_label = QLabel()
|
|
883
|
+
overlay_label.setPixmap(thumb_pix)
|
|
884
|
+
overlay_label.setCursor(Qt.CursorShape.PointingHandCursor)
|
|
885
|
+
elay.addWidget(overlay_label)
|
|
886
|
+
self._clickable_images[overlay_label] = QPixmap.fromImage(overlay_qimg)
|
|
887
|
+
overlay_label.installEventFilter(self)
|
|
888
|
+
|
|
889
|
+
elay.addStretch(1)
|
|
890
|
+
entry.setLayout(elay)
|
|
891
|
+
self.wb_l.addWidget(entry)
|
|
892
|
+
|
|
893
|
+
# ---------- call _pushResultsToDocs exactly once ----------
|
|
894
|
+
if (not getattr(self, "_pushed_results", False)
|
|
895
|
+
and len(self._results) == getattr(self, "_expected_results", 0)):
|
|
896
|
+
self._pushed_results = True
|
|
897
|
+
self.hideSpinner()
|
|
898
|
+
self._pushResultsToDocs(self._results)
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
def eventFilter(self, source, event):
|
|
902
|
+
# catch mouse releases on any of our clickable labels
|
|
903
|
+
if event.type() == QEvent.Type.MouseButtonRelease and source in self._clickable_images:
|
|
904
|
+
pix = self._clickable_images[source]
|
|
905
|
+
self._showEnlarged(pix)
|
|
906
|
+
return True
|
|
907
|
+
return super().eventFilter(source, event)
|
|
908
|
+
|
|
909
|
+
def _showEnlarged(self, pixmap: QPixmap):
|
|
910
|
+
"""
|
|
911
|
+
Detail View dialog with themed zoom controls, autostretch, and 'push to document'.
|
|
912
|
+
Uses ZoomableGraphicsView + QGraphicsScene (consistent with CLAHE).
|
|
913
|
+
"""
|
|
914
|
+
from PyQt6.QtCore import Qt
|
|
915
|
+
from PyQt6.QtGui import QImage, QPixmap
|
|
916
|
+
from PyQt6.QtWidgets import (
|
|
917
|
+
QDialog, QVBoxLayout, QHBoxLayout, QMessageBox,
|
|
918
|
+
QLabel, QPushButton, QGraphicsScene, QGraphicsPixmapItem
|
|
919
|
+
)
|
|
920
|
+
|
|
921
|
+
|
|
922
|
+
from setiastro.saspro.widgets.graphics_views import ZoomableGraphicsView
|
|
923
|
+
from setiastro.saspro.widgets.themed_buttons import themed_toolbtn
|
|
924
|
+
from setiastro.saspro.imageops.stretch import stretch_color_image, stretch_mono_image
|
|
925
|
+
|
|
926
|
+
# ---------- helpers ----------
|
|
927
|
+
def _float01_from_qimage(qimg: QImage) -> np.ndarray:
|
|
928
|
+
"""QImage -> float32 [0..1]. Handles Grayscale8 and RGB888; converts others to RGB888."""
|
|
929
|
+
if qimg is None or qimg.isNull():
|
|
930
|
+
return np.zeros((1, 1), dtype=np.float32)
|
|
931
|
+
|
|
932
|
+
fmt = qimg.format()
|
|
933
|
+
if fmt not in (QImage.Format.Format_Grayscale8, QImage.Format.Format_RGB888):
|
|
934
|
+
qimg = qimg.convertToFormat(QImage.Format.Format_RGB888)
|
|
935
|
+
fmt = QImage.Format.Format_RGB888
|
|
936
|
+
|
|
937
|
+
h = qimg.height()
|
|
938
|
+
w = qimg.width()
|
|
939
|
+
bpl = qimg.bytesPerLine()
|
|
940
|
+
|
|
941
|
+
ptr = qimg.bits()
|
|
942
|
+
ptr.setsize(h * bpl)
|
|
943
|
+
buf = np.frombuffer(ptr, dtype=np.uint8).reshape((h, bpl))
|
|
944
|
+
|
|
945
|
+
if fmt == QImage.Format.Format_Grayscale8:
|
|
946
|
+
return (buf[:, :w].astype(np.float32) / 255.0).clip(0.0, 1.0)
|
|
947
|
+
|
|
948
|
+
# RGB888
|
|
949
|
+
rgb = buf[:, :w * 3].reshape((h, w, 3)).astype(np.float32) / 255.0
|
|
950
|
+
return rgb.clip(0.0, 1.0)
|
|
951
|
+
|
|
952
|
+
def _qimage_from_float01(arr: np.ndarray) -> QImage:
|
|
953
|
+
"""float32 [0..1] -> QImage (RGB888 or Grayscale8), deep-copied."""
|
|
954
|
+
a = np.clip(np.asarray(arr, dtype=np.float32), 0.0, 1.0)
|
|
955
|
+
|
|
956
|
+
if a.ndim == 2:
|
|
957
|
+
u8 = (a * 255.0 + 0.5).astype(np.uint8, copy=False)
|
|
958
|
+
h, w = u8.shape
|
|
959
|
+
q = QImage(u8.data, w, h, w, QImage.Format.Format_Grayscale8)
|
|
960
|
+
return q.copy()
|
|
961
|
+
|
|
962
|
+
if a.ndim == 3 and a.shape[2] == 1:
|
|
963
|
+
return _qimage_from_float01(a[..., 0])
|
|
964
|
+
|
|
965
|
+
if a.ndim == 3 and a.shape[2] == 3:
|
|
966
|
+
u8 = (a * 255.0 + 0.5).astype(np.uint8, copy=False)
|
|
967
|
+
h, w, _ = u8.shape
|
|
968
|
+
q = QImage(u8.data, w, h, 3 * w, QImage.Format.Format_RGB888)
|
|
969
|
+
return q.copy()
|
|
970
|
+
|
|
971
|
+
# fallback
|
|
972
|
+
raise ValueError(f"Unexpected image shape: {a.shape}")
|
|
973
|
+
|
|
974
|
+
def _pixmap_from_float01(arr: np.ndarray) -> QPixmap:
|
|
975
|
+
return QPixmap.fromImage(_qimage_from_float01(arr))
|
|
976
|
+
|
|
977
|
+
# ---------- dialog ----------
|
|
978
|
+
dlg = QDialog(self)
|
|
979
|
+
dlg.setWindowTitle("Detail View")
|
|
980
|
+
dlg.resize(980, 820)
|
|
981
|
+
|
|
982
|
+
outer = QVBoxLayout(dlg)
|
|
983
|
+
|
|
984
|
+
# Convert input pixmap -> float01 working buffer
|
|
985
|
+
try:
|
|
986
|
+
base_qimg = pixmap.toImage()
|
|
987
|
+
current_arr = _float01_from_qimage(base_qimg)
|
|
988
|
+
except Exception:
|
|
989
|
+
current_arr = np.zeros((1, 1), dtype=np.float32)
|
|
990
|
+
|
|
991
|
+
# Ensure "display" is always RGB for the pixmap item (GraphicsView)
|
|
992
|
+
def _ensure_rgb(a: np.ndarray) -> np.ndarray:
|
|
993
|
+
a = np.asarray(a, dtype=np.float32)
|
|
994
|
+
if a.ndim == 2:
|
|
995
|
+
return np.stack([a, a, a], axis=-1)
|
|
996
|
+
if a.ndim == 3 and a.shape[2] == 1:
|
|
997
|
+
return np.repeat(a, 3, axis=2)
|
|
998
|
+
return a
|
|
999
|
+
|
|
1000
|
+
# Graphics view
|
|
1001
|
+
scene = QGraphicsScene(dlg)
|
|
1002
|
+
view = ZoomableGraphicsView(scene)
|
|
1003
|
+
view.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
1004
|
+
|
|
1005
|
+
pix_item = QGraphicsPixmapItem()
|
|
1006
|
+
scene.addItem(pix_item)
|
|
1007
|
+
outer.addWidget(view, stretch=1)
|
|
1008
|
+
|
|
1009
|
+
def _set_scene_from_arr(arr01: np.ndarray):
|
|
1010
|
+
rgb = _ensure_rgb(arr01)
|
|
1011
|
+
pm = _pixmap_from_float01(rgb)
|
|
1012
|
+
pix_item.setPixmap(pm)
|
|
1013
|
+
scene.setSceneRect(pix_item.boundingRect())
|
|
1014
|
+
|
|
1015
|
+
_set_scene_from_arr(current_arr)
|
|
1016
|
+
|
|
1017
|
+
# Toolbar row (themed zoom helper)
|
|
1018
|
+
row = QHBoxLayout()
|
|
1019
|
+
|
|
1020
|
+
btn_zoom_out = themed_toolbtn("zoom-out", "Zoom Out")
|
|
1021
|
+
btn_zoom_in = themed_toolbtn("zoom-in", "Zoom In")
|
|
1022
|
+
btn_zoom_1to1 = themed_toolbtn("zoom-original", "1:1 (100%)")
|
|
1023
|
+
btn_zoom_fit = themed_toolbtn("zoom-fit-best", "Fit to Preview")
|
|
1024
|
+
|
|
1025
|
+
btn_zoom_out.clicked.connect(view.zoom_out)
|
|
1026
|
+
btn_zoom_in.clicked.connect(view.zoom_in)
|
|
1027
|
+
btn_zoom_1to1.clicked.connect(view.one_to_one if hasattr(view, "one_to_one") else (lambda: None))
|
|
1028
|
+
btn_zoom_fit.clicked.connect(lambda: view.fit_to_item(pix_item))
|
|
1029
|
+
|
|
1030
|
+
row.addWidget(btn_zoom_out)
|
|
1031
|
+
row.addWidget(btn_zoom_in)
|
|
1032
|
+
row.addWidget(btn_zoom_1to1)
|
|
1033
|
+
row.addWidget(btn_zoom_fit)
|
|
1034
|
+
|
|
1035
|
+
row.addStretch(1)
|
|
1036
|
+
|
|
1037
|
+
btn_autostretch = QPushButton("Autostretch")
|
|
1038
|
+
row.addWidget(btn_autostretch)
|
|
1039
|
+
|
|
1040
|
+
btn_push = QPushButton("Push to New Document")
|
|
1041
|
+
row.addWidget(btn_push)
|
|
1042
|
+
|
|
1043
|
+
btn_close = QPushButton("Close")
|
|
1044
|
+
row.addWidget(btn_close)
|
|
1045
|
+
|
|
1046
|
+
outer.addLayout(row)
|
|
1047
|
+
|
|
1048
|
+
# Actions
|
|
1049
|
+
def _do_autostretch():
|
|
1050
|
+
nonlocal current_arr
|
|
1051
|
+
try:
|
|
1052
|
+
a = np.asarray(current_arr, dtype=np.float32)
|
|
1053
|
+
|
|
1054
|
+
# Autostretch in-place, respecting mono vs RGB
|
|
1055
|
+
if a.ndim == 2:
|
|
1056
|
+
stretched = stretch_mono_image(a, target_median=0.25)
|
|
1057
|
+
current_arr = np.clip(stretched, 0.0, 1.0).astype(np.float32, copy=False)
|
|
1058
|
+
elif a.ndim == 3 and a.shape[2] == 1:
|
|
1059
|
+
stretched = stretch_mono_image(a[..., 0], target_median=0.25)
|
|
1060
|
+
current_arr = np.clip(stretched, 0.0, 1.0).astype(np.float32, copy=False)
|
|
1061
|
+
else:
|
|
1062
|
+
stretched = stretch_color_image(a, target_median=0.25, linked=False)
|
|
1063
|
+
current_arr = np.clip(stretched, 0.0, 1.0).astype(np.float32, copy=False)
|
|
1064
|
+
|
|
1065
|
+
_set_scene_from_arr(current_arr)
|
|
1066
|
+
# keep current zoom, just refresh pixmap
|
|
1067
|
+
except Exception as e:
|
|
1068
|
+
QMessageBox.warning(dlg, "Detail View", f"Autostretch failed:\n{e}")
|
|
1069
|
+
|
|
1070
|
+
def _do_push_to_doc():
|
|
1071
|
+
dm = getattr(self, "doc_manager", None)
|
|
1072
|
+
mw = self._main_window() if hasattr(self, "_main_window") else None
|
|
1073
|
+
|
|
1074
|
+
if dm is None or mw is None or not hasattr(mw, "_spawn_subwindow_for"):
|
|
1075
|
+
QMessageBox.critical(dlg, "Detail View", "Cannot create document: missing DocManager or MainWindow.")
|
|
1076
|
+
return
|
|
1077
|
+
|
|
1078
|
+
try:
|
|
1079
|
+
img = np.asarray(current_arr, dtype=np.float32)
|
|
1080
|
+
|
|
1081
|
+
# Preserve mono where appropriate
|
|
1082
|
+
is_mono = (img.ndim == 2) or (img.ndim == 3 and img.shape[2] == 1)
|
|
1083
|
+
|
|
1084
|
+
counter = getattr(self, "_detail_doc_counter", 0) + 1
|
|
1085
|
+
self._detail_doc_counter = counter
|
|
1086
|
+
name = f"DetailView_{counter}"
|
|
1087
|
+
|
|
1088
|
+
meta = {
|
|
1089
|
+
"display_name": name,
|
|
1090
|
+
"file_path": name,
|
|
1091
|
+
"bit_depth": "32-bit floating point",
|
|
1092
|
+
"is_mono": bool(is_mono),
|
|
1093
|
+
"original_header": getattr(self, "original_header", None),
|
|
1094
|
+
"source": "Continuum Subtract — Detail View",
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
doc = dm.create_document(img, metadata=meta, name=name)
|
|
1098
|
+
mw._spawn_subwindow_for(doc)
|
|
1099
|
+
|
|
1100
|
+
try:
|
|
1101
|
+
if hasattr(self, "statusLabel") and self.statusLabel is not None:
|
|
1102
|
+
self.statusLabel.setText(f"Pushed detail view → '{name}'.")
|
|
1103
|
+
except Exception:
|
|
1104
|
+
pass
|
|
1105
|
+
|
|
1106
|
+
except Exception as e:
|
|
1107
|
+
QMessageBox.critical(dlg, "Detail View", f"Failed to create document:\n{e}")
|
|
1108
|
+
|
|
1109
|
+
btn_autostretch.clicked.connect(_do_autostretch)
|
|
1110
|
+
btn_push.clicked.connect(_do_push_to_doc)
|
|
1111
|
+
btn_close.clicked.connect(dlg.accept)
|
|
1112
|
+
|
|
1113
|
+
# Initial fit
|
|
1114
|
+
try:
|
|
1115
|
+
view.fit_to_item(pix_item)
|
|
1116
|
+
except Exception:
|
|
1117
|
+
pass
|
|
1118
|
+
|
|
1119
|
+
dlg.exec()
|
|
1120
|
+
|
|
1121
|
+
|
|
1122
|
+
def _onThreadFinished(self):
|
|
1123
|
+
self._pending -= 1
|
|
1124
|
+
if self._pending == 0:
|
|
1125
|
+
self.hideSpinner()
|
|
1126
|
+
self._pushResultsToDocs(self._results)
|
|
1127
|
+
|
|
1128
|
+
def _pushResultsToDocs(self, results):
|
|
1129
|
+
dm = getattr(self, "doc_manager", None)
|
|
1130
|
+
mw = self._main_window()
|
|
1131
|
+
if dm is None or mw is None or not hasattr(mw, "_spawn_subwindow_for"):
|
|
1132
|
+
QMessageBox.critical(self, "Continuum Subtract",
|
|
1133
|
+
"Cannot create documents: missing DocManager or MainWindow.")
|
|
1134
|
+
return
|
|
1135
|
+
|
|
1136
|
+
created = 0
|
|
1137
|
+
for entry in results:
|
|
1138
|
+
filt = entry["filter"]
|
|
1139
|
+
img = np.asarray(entry["image"], dtype=np.float32) # keep everything float32
|
|
1140
|
+
name = f"{filt}_ContSub"
|
|
1141
|
+
|
|
1142
|
+
meta = {
|
|
1143
|
+
"display_name": name, # nice title in the UI
|
|
1144
|
+
"file_path": name, # placeholder path until user saves
|
|
1145
|
+
"bit_depth": "32-bit floating point",
|
|
1146
|
+
"is_mono": (img.ndim == 2 or (img.ndim == 3 and img.shape[2] == 1)),
|
|
1147
|
+
"original_header": self.original_header,
|
|
1148
|
+
"source": "Continuum Subtract",
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
try:
|
|
1152
|
+
# create a proper ImageDocument and register it
|
|
1153
|
+
doc = dm.create_document(img, metadata=meta, name=name)
|
|
1154
|
+
# show it as an MDI subwindow
|
|
1155
|
+
mw._spawn_subwindow_for(doc)
|
|
1156
|
+
created += 1
|
|
1157
|
+
except Exception as e:
|
|
1158
|
+
QMessageBox.critical(self, "Continuum Subtract",
|
|
1159
|
+
f"Failed to create document '{name}':\n{e}")
|
|
1160
|
+
|
|
1161
|
+
self.statusLabel.setText(f"Created {created} document(s).")
|
|
1162
|
+
|
|
1163
|
+
|
|
1164
|
+
def _onThreadFinished(self):
|
|
1165
|
+
self._pending -= 1
|
|
1166
|
+
# do NOT push here if you already push in _onOneResult
|
|
1167
|
+
|
|
1168
|
+
def update_status_label(self, message):
|
|
1169
|
+
self.statusLabel.setText(message)
|
|
1170
|
+
|
|
1171
|
+
def showSpinner(self):
|
|
1172
|
+
self.spinnerLabel.setText("Processing…")
|
|
1173
|
+
self.spinnerLabel.show()
|
|
1174
|
+
if hasattr(self, "execute_button"):
|
|
1175
|
+
self.execute_button.setEnabled(False)
|
|
1176
|
+
|
|
1177
|
+
def hideSpinner(self):
|
|
1178
|
+
self.spinnerLabel.hide()
|
|
1179
|
+
self.spinnerLabel.clear()
|
|
1180
|
+
if hasattr(self, "execute_button"):
|
|
1181
|
+
self.execute_button.setEnabled(True)
|
|
1182
|
+
|
|
1183
|
+
|
|
1184
|
+
|
|
1185
|
+
class ContinuumProcessingThread(QThread):
|
|
1186
|
+
processing_complete = pyqtSignal(np.ndarray, int, QImage, np.ndarray, np.ndarray)
|
|
1187
|
+
processing_complete_starless = pyqtSignal(np.ndarray, int, QImage, np.ndarray, np.ndarray)
|
|
1188
|
+
status_update = pyqtSignal(str)
|
|
1189
|
+
|
|
1190
|
+
def __init__(self, nb_image, continuum_image, output_linear, *,
|
|
1191
|
+
starless_nb=None, starless_cont=None, starless_only=False,
|
|
1192
|
+
threshold: float = 5.0, summary_gamma: float = 0.6, q_factor: float = 0.8,
|
|
1193
|
+
cosmic_root: str = "", denoise_linear: bool = False): # NEW params
|
|
1194
|
+
super().__init__()
|
|
1195
|
+
self.nb_image = nb_image
|
|
1196
|
+
self.continuum_image = continuum_image
|
|
1197
|
+
self.output_linear = output_linear
|
|
1198
|
+
self.starless_nb = starless_nb
|
|
1199
|
+
self.starless_cont = starless_cont
|
|
1200
|
+
self.starless_only = starless_only
|
|
1201
|
+
self.background_reference = None
|
|
1202
|
+
self._recipe = None # learned from starry pass
|
|
1203
|
+
|
|
1204
|
+
# user knobs
|
|
1205
|
+
self.threshold = float(threshold)
|
|
1206
|
+
self.summary_gamma = float(summary_gamma)
|
|
1207
|
+
self.q_factor = float(q_factor)
|
|
1208
|
+
|
|
1209
|
+
# NEW: Cosmic Clarity integration
|
|
1210
|
+
self.cosmic_root = (cosmic_root or "").strip()
|
|
1211
|
+
self.denoise_linear = bool(denoise_linear)
|
|
1212
|
+
|
|
1213
|
+
# ---------- small helpers ----------
|
|
1214
|
+
@staticmethod
|
|
1215
|
+
def _to_mono(img):
|
|
1216
|
+
a = np.asarray(img)
|
|
1217
|
+
if a.ndim == 3:
|
|
1218
|
+
if a.shape[2] == 3:
|
|
1219
|
+
return a[..., 0] # use R channel for NB/cont slots when color
|
|
1220
|
+
if a.shape[2] == 1:
|
|
1221
|
+
return a[..., 0]
|
|
1222
|
+
return a
|
|
1223
|
+
|
|
1224
|
+
@staticmethod
|
|
1225
|
+
def _as_rgb(nb, cont):
|
|
1226
|
+
r = np.asarray(nb, dtype=np.float32)
|
|
1227
|
+
g = np.asarray(cont, dtype=np.float32)
|
|
1228
|
+
if r.ndim == 3: r = r[..., 0]
|
|
1229
|
+
if g.ndim == 3: g = g[..., 0]
|
|
1230
|
+
if r.dtype.kind in "ui":
|
|
1231
|
+
r = r / (255.0 if r.dtype == np.uint8 else 65535.0)
|
|
1232
|
+
if g.dtype.kind in "ui":
|
|
1233
|
+
g = g / (255.0 if g.dtype == np.uint8 else 65535.0)
|
|
1234
|
+
b = g
|
|
1235
|
+
return np.stack([r, g, b], axis=-1).astype(np.float32, copy=False)
|
|
1236
|
+
|
|
1237
|
+
@staticmethod
|
|
1238
|
+
def _fit_ab(x, y):
|
|
1239
|
+
x = x.reshape(-1).astype(np.float32)
|
|
1240
|
+
y = y.reshape(-1).astype(np.float32)
|
|
1241
|
+
N = min(x.size, 100_000)
|
|
1242
|
+
if x.size > N:
|
|
1243
|
+
idx = np.random.choice(x.size, N, replace=False)
|
|
1244
|
+
x = x[idx]; y = y[idx]
|
|
1245
|
+
A = np.vstack([x, np.ones_like(x)]).T
|
|
1246
|
+
a, b = np.linalg.lstsq(A, y, rcond=None)[0]
|
|
1247
|
+
return float(a), float(b)
|
|
1248
|
+
|
|
1249
|
+
@staticmethod
|
|
1250
|
+
def _qimage_from_uint8(rgb_uint8: np.ndarray) -> QImage:
|
|
1251
|
+
"""Create a deep-copied QImage from an HxWx3 uint8 array."""
|
|
1252
|
+
h, w = rgb_uint8.shape[:2]
|
|
1253
|
+
return QImage(rgb_uint8.data, w, h, 3*w, QImage.Format.Format_RGB888).copy()
|
|
1254
|
+
|
|
1255
|
+
@staticmethod
|
|
1256
|
+
def _nonlinear_finalize(lin_img: np.ndarray) -> np.ndarray:
|
|
1257
|
+
"""Stretch → subtract pedestal → curves, returned as float32 in [0,1]."""
|
|
1258
|
+
target_median = 0.25
|
|
1259
|
+
stretched = stretch_color_image(lin_img, target_median, True, False)
|
|
1260
|
+
final = stretched - 0.7 * np.median(stretched)
|
|
1261
|
+
final = np.clip(final, 0, 1)
|
|
1262
|
+
return apply_curves_adjustment(final, np.median(final), 0.5).astype(np.float32, copy=False)
|
|
1263
|
+
|
|
1264
|
+
# ---------- BG neutral: return pedestals (no in-place surprises) ----------
|
|
1265
|
+
def _compute_bg_pedestal(self, rgb):
|
|
1266
|
+
height, width, _ = rgb.shape
|
|
1267
|
+
num_boxes, box_size, iterations = 200, 25, 25
|
|
1268
|
+
|
|
1269
|
+
boxes = [(np.random.randint(0, height - box_size),
|
|
1270
|
+
np.random.randint(0, width - box_size)) for _ in range(num_boxes)]
|
|
1271
|
+
best = np.full(num_boxes, np.inf, dtype=np.float32)
|
|
1272
|
+
|
|
1273
|
+
for _ in range(iterations):
|
|
1274
|
+
for i, (y, x) in enumerate(boxes):
|
|
1275
|
+
if y + box_size <= height and x + box_size <= width:
|
|
1276
|
+
patch = rgb[y:y+box_size, x:x+box_size]
|
|
1277
|
+
med = np.median(patch) if patch.size else np.inf
|
|
1278
|
+
best[i] = min(best[i], med)
|
|
1279
|
+
sv = []
|
|
1280
|
+
for dy in (-1, 0, 1):
|
|
1281
|
+
for dx in (-1, 0, 1):
|
|
1282
|
+
yy, xx = y + dy*box_size, x + dx*box_size
|
|
1283
|
+
if 0 <= yy < height - box_size and 0 <= xx < width - box_size:
|
|
1284
|
+
p2 = rgb[yy:yy+box_size, xx:xx+box_size]
|
|
1285
|
+
if p2.size:
|
|
1286
|
+
sv.append(np.median(p2))
|
|
1287
|
+
if sv:
|
|
1288
|
+
k = int(np.argmin(sv))
|
|
1289
|
+
y += (k // 3 - 1) * box_size
|
|
1290
|
+
x += (k % 3 - 1) * box_size
|
|
1291
|
+
boxes[i] = (y, x)
|
|
1292
|
+
|
|
1293
|
+
# pick darkest
|
|
1294
|
+
darkest = np.inf; ref = None
|
|
1295
|
+
for y, x in boxes:
|
|
1296
|
+
if y + box_size <= height and x + box_size <= width:
|
|
1297
|
+
patch = rgb[y:y+box_size, x:x+box_size]
|
|
1298
|
+
med = np.median(patch) if patch.size else np.inf
|
|
1299
|
+
if med < darkest:
|
|
1300
|
+
darkest, ref = med, patch
|
|
1301
|
+
|
|
1302
|
+
ped = np.zeros(3, dtype=np.float32)
|
|
1303
|
+
if ref is not None:
|
|
1304
|
+
self.background_reference = np.median(ref.reshape(-1, 3), axis=0)
|
|
1305
|
+
chan_meds = np.median(rgb, axis=(0, 1))
|
|
1306
|
+
# pedestal to lift channels toward their own median
|
|
1307
|
+
ped = np.maximum(0.0, chan_meds - self.background_reference)
|
|
1308
|
+
|
|
1309
|
+
# specifically lift G/B if below R reference
|
|
1310
|
+
r_ref = float(self.background_reference[0])
|
|
1311
|
+
for ch in (1, 2):
|
|
1312
|
+
if self.background_reference[ch] < r_ref:
|
|
1313
|
+
ped[ch] += (r_ref - self.background_reference[ch])
|
|
1314
|
+
return ped
|
|
1315
|
+
|
|
1316
|
+
@staticmethod
|
|
1317
|
+
def _apply_pedestal(rgb, ped):
|
|
1318
|
+
return np.clip(rgb + ped.reshape(1,1,3), 0.0, 1.0)
|
|
1319
|
+
|
|
1320
|
+
@staticmethod
|
|
1321
|
+
def _normalize_red_to_green(rgb):
|
|
1322
|
+
r = rgb[...,0]; g = rgb[...,1]
|
|
1323
|
+
mad_r = float(np.mean(np.abs(r - np.mean(r))))
|
|
1324
|
+
mad_g = float(np.mean(np.abs(g - np.mean(g))))
|
|
1325
|
+
med_r = float(np.median(r))
|
|
1326
|
+
med_g = float(np.median(g))
|
|
1327
|
+
g_gain = (mad_g / max(mad_r, 1e-9))
|
|
1328
|
+
g_offs = (-g_gain * med_r + med_g)
|
|
1329
|
+
rgb2 = rgb.copy()
|
|
1330
|
+
rgb2[...,0] = np.clip(r * g_gain + g_offs, 0.0, 1.0)
|
|
1331
|
+
return rgb2, g_gain, g_offs
|
|
1332
|
+
|
|
1333
|
+
def _linear_subtract(self, rgb, Q, green_median):
|
|
1334
|
+
r = rgb[...,0]; g = rgb[...,1]
|
|
1335
|
+
return np.clip(r - Q * (g - green_median), 0.0, 1.0)
|
|
1336
|
+
|
|
1337
|
+
# ---------- main ----------
|
|
1338
|
+
def run(self):
|
|
1339
|
+
try:
|
|
1340
|
+
# STARLESS-ONLY early exit
|
|
1341
|
+
if (self.nb_image is None or self.continuum_image is None) and self.starless_only:
|
|
1342
|
+
self._run_starless_only()
|
|
1343
|
+
return
|
|
1344
|
+
|
|
1345
|
+
recipe = None
|
|
1346
|
+
|
|
1347
|
+
# ----- starry pass (learn recipe) -----
|
|
1348
|
+
if self.nb_image is not None and self.continuum_image is not None:
|
|
1349
|
+
rgb = self._as_rgb(self.nb_image, self.continuum_image)
|
|
1350
|
+
|
|
1351
|
+
self.status_update.emit("Performing background neutralization...")
|
|
1352
|
+
ped = self._compute_bg_pedestal(rgb)
|
|
1353
|
+
rgb = self._apply_pedestal(rgb, ped)
|
|
1354
|
+
|
|
1355
|
+
self.status_update.emit("Normalizing red to green…")
|
|
1356
|
+
rgb, g_gain, g_offs = self._normalize_red_to_green(rgb)
|
|
1357
|
+
|
|
1358
|
+
self.status_update.emit("Performing star-based white balance…")
|
|
1359
|
+
balanced_rgb, star_count, star_overlay, raw_star_pixels, after_star_pixels = \
|
|
1360
|
+
apply_star_based_white_balance(
|
|
1361
|
+
rgb, threshold=self.threshold, autostretch=False,
|
|
1362
|
+
reuse_cached_sources=True, return_star_colors=True
|
|
1363
|
+
)
|
|
1364
|
+
|
|
1365
|
+
# per-channel affine fit to reproduce WB later
|
|
1366
|
+
wb_a = np.zeros(3, np.float32)
|
|
1367
|
+
wb_b = np.zeros(3, np.float32)
|
|
1368
|
+
for c in range(3):
|
|
1369
|
+
a, b = self._fit_ab(rgb[..., c], balanced_rgb[..., c])
|
|
1370
|
+
wb_a[c], wb_b[c] = a, b
|
|
1371
|
+
|
|
1372
|
+
green_med = float(np.median(balanced_rgb[..., 1]))
|
|
1373
|
+
Q = self.q_factor
|
|
1374
|
+
linear_image = self._linear_subtract(balanced_rgb, Q, green_med)
|
|
1375
|
+
if self.denoise_linear and self.cosmic_root:
|
|
1376
|
+
self.status_update.emit("Denoising continuum-subtracted image (Cosmic Clarity)…")
|
|
1377
|
+
linear_image = self._denoise_linear_image(linear_image)
|
|
1378
|
+
# --- NEW: gamma brighten overlay for the summary ---
|
|
1379
|
+
g = max(self.summary_gamma, 1e-6)
|
|
1380
|
+
overlay_gamma = np.power(np.clip(star_overlay, 0.0, 1.0), g)
|
|
1381
|
+
overlay_uint8 = (overlay_gamma * 255).astype(np.uint8)
|
|
1382
|
+
qimg = self._qimage_from_uint8(overlay_uint8)
|
|
1383
|
+
|
|
1384
|
+
if self.output_linear:
|
|
1385
|
+
self.processing_complete.emit(
|
|
1386
|
+
np.clip(linear_image, 0, 1), int(star_count), qimg,
|
|
1387
|
+
np.array(raw_star_pixels), np.array(after_star_pixels)
|
|
1388
|
+
)
|
|
1389
|
+
else:
|
|
1390
|
+
self.status_update.emit("Linear → Non-linear stretch…")
|
|
1391
|
+
target_median = 0.25
|
|
1392
|
+
stretched = stretch_color_image(linear_image, target_median, True, False)
|
|
1393
|
+
final = stretched - 0.7 * np.median(stretched)
|
|
1394
|
+
final = np.clip(final, 0, 1)
|
|
1395
|
+
final = apply_curves_adjustment(final, np.median(final), 0.5)
|
|
1396
|
+
self.processing_complete.emit(
|
|
1397
|
+
final.astype(np.float32, copy=False),
|
|
1398
|
+
int(star_count), qimg,
|
|
1399
|
+
np.array(raw_star_pixels), np.array(after_star_pixels)
|
|
1400
|
+
)
|
|
1401
|
+
|
|
1402
|
+
# learned recipe + fit data (reused for starless)
|
|
1403
|
+
recipe = {
|
|
1404
|
+
"pedestal": ped,
|
|
1405
|
+
"rnorm_gain": g_gain,
|
|
1406
|
+
"rnorm_offs": g_offs,
|
|
1407
|
+
"wb_a": wb_a,
|
|
1408
|
+
"wb_b": wb_b,
|
|
1409
|
+
"Q": Q,
|
|
1410
|
+
"green_median": green_med,
|
|
1411
|
+
|
|
1412
|
+
# store raw overlay + star stats for reuse
|
|
1413
|
+
"overlay_uint8": overlay_uint8,
|
|
1414
|
+
"fit_star_count": int(star_count),
|
|
1415
|
+
"fit_raw": np.array(raw_star_pixels, copy=True),
|
|
1416
|
+
"fit_after": np.array(after_star_pixels, copy=True),
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
# ----- starless paired pass (apply recipe) -----
|
|
1420
|
+
if self.starless_nb is not None and self.starless_cont is not None:
|
|
1421
|
+
if recipe is not None:
|
|
1422
|
+
rgb = self._as_rgb(self.starless_nb, self.starless_cont)
|
|
1423
|
+
# apply starry recipe exactly
|
|
1424
|
+
rgb = self._apply_pedestal(rgb, recipe["pedestal"])
|
|
1425
|
+
r = rgb[..., 0]
|
|
1426
|
+
rgb[..., 0] = np.clip(r * recipe["rnorm_gain"] + recipe["rnorm_offs"], 0.0, 1.0)
|
|
1427
|
+
for c in range(3):
|
|
1428
|
+
rgb[..., c] = np.clip(rgb[..., c] * recipe["wb_a"][c] + recipe["wb_b"][c], 0.0, 1.0)
|
|
1429
|
+
|
|
1430
|
+
lin = self._linear_subtract(rgb, recipe["Q"], recipe["green_median"])
|
|
1431
|
+
if self.denoise_linear and self.cosmic_root:
|
|
1432
|
+
self.status_update.emit("Denoising starless continuum-subtracted image (Cosmic Clarity)…")
|
|
1433
|
+
lin = self._denoise_linear_image(lin)
|
|
1434
|
+
# reuse gamma-bright overlay & fit info from the starry pass
|
|
1435
|
+
# rebuild overlay & make fresh copies of arrays for the starless emit
|
|
1436
|
+
overlay_uint8 = np.array(recipe["overlay_uint8"], copy=True)
|
|
1437
|
+
fit_qimg = self._qimage_from_uint8(overlay_uint8)
|
|
1438
|
+
fit_count = int(recipe["fit_star_count"])
|
|
1439
|
+
fit_raw = np.array(recipe["fit_raw"], copy=True)
|
|
1440
|
+
fit_after = np.array(recipe["fit_after"], copy=True)
|
|
1441
|
+
|
|
1442
|
+
if self.output_linear:
|
|
1443
|
+
self.processing_complete_starless.emit(
|
|
1444
|
+
np.clip(lin, 0, 1), fit_count, fit_qimg, fit_raw, fit_after
|
|
1445
|
+
)
|
|
1446
|
+
else:
|
|
1447
|
+
self.status_update.emit("Linear → Non-linear stretch (starless)…")
|
|
1448
|
+
target_median = 0.25
|
|
1449
|
+
stretched = stretch_color_image(lin, target_median, True, False)
|
|
1450
|
+
final = stretched - 0.7 * np.median(stretched)
|
|
1451
|
+
final = np.clip(final, 0, 1)
|
|
1452
|
+
final = apply_curves_adjustment(final, np.median(final), 0.5)
|
|
1453
|
+
self.processing_complete_starless.emit(
|
|
1454
|
+
final.astype(np.float32, copy=False),
|
|
1455
|
+
fit_count, fit_qimg, fit_raw, fit_after
|
|
1456
|
+
)
|
|
1457
|
+
|
|
1458
|
+
elif self.starless_only:
|
|
1459
|
+
pass # handled in _run_starless_only
|
|
1460
|
+
except Exception as e:
|
|
1461
|
+
try:
|
|
1462
|
+
self.status_update.emit(f"Continuum subtraction failed: {e}")
|
|
1463
|
+
except Exception:
|
|
1464
|
+
pass
|
|
1465
|
+
|
|
1466
|
+
# ----- starless-only path (no WB; same math you had) -----
|
|
1467
|
+
def _run_starless_only(self):
|
|
1468
|
+
rgb = self._as_rgb(self.starless_nb, self.starless_cont)
|
|
1469
|
+
|
|
1470
|
+
self.status_update.emit("Performing background neutralization…")
|
|
1471
|
+
ped = self._compute_bg_pedestal(rgb)
|
|
1472
|
+
rgb = self._apply_pedestal(rgb, ped)
|
|
1473
|
+
|
|
1474
|
+
self.status_update.emit("Normalizing red to green…")
|
|
1475
|
+
rgb, _, _ = self._normalize_red_to_green(rgb)
|
|
1476
|
+
|
|
1477
|
+
green_med = float(np.median(rgb[..., 1]))
|
|
1478
|
+
lin = self._linear_subtract(rgb, 0.9, green_med)
|
|
1479
|
+
if self.denoise_linear and self.cosmic_root:
|
|
1480
|
+
self.status_update.emit("Denoising starless continuum-subtracted image (Cosmic Clarity)…")
|
|
1481
|
+
lin = self._denoise_linear_image(lin)
|
|
1482
|
+
# Blank overlay & empty star lists (no star detection in starless-only path)
|
|
1483
|
+
h, w = lin.shape[:2]
|
|
1484
|
+
blank = np.zeros((h, w, 3), np.uint8)
|
|
1485
|
+
qimg = self._qimage_from_uint8(blank)
|
|
1486
|
+
empty = np.empty((0, 2), float)
|
|
1487
|
+
|
|
1488
|
+
if self.output_linear:
|
|
1489
|
+
self.processing_complete_starless.emit(np.clip(lin, 0, 1), 0, qimg, empty, empty)
|
|
1490
|
+
return
|
|
1491
|
+
|
|
1492
|
+
self.status_update.emit("Linear → Non-linear stretch…")
|
|
1493
|
+
final = self._nonlinear_finalize(lin)
|
|
1494
|
+
self.processing_complete_starless.emit(final, 0, qimg, empty, empty)
|
|
1495
|
+
|
|
1496
|
+
def _denoise_linear_image(self, img: np.ndarray) -> np.ndarray:
|
|
1497
|
+
"""
|
|
1498
|
+
Run Cosmic Clarity denoise on a [0,1] float image and return the result.
|
|
1499
|
+
If anything fails (no path, no exe, timeout, etc.), returns the input.
|
|
1500
|
+
"""
|
|
1501
|
+
try:
|
|
1502
|
+
if img is None:
|
|
1503
|
+
return img
|
|
1504
|
+
|
|
1505
|
+
root = self.cosmic_root
|
|
1506
|
+
if not root:
|
|
1507
|
+
# No configured CC path; silently skip
|
|
1508
|
+
return img
|
|
1509
|
+
|
|
1510
|
+
exe_name = _platform_exe_names("denoise")
|
|
1511
|
+
if not exe_name:
|
|
1512
|
+
return img
|
|
1513
|
+
|
|
1514
|
+
exe = os.path.join(root, exe_name)
|
|
1515
|
+
if not os.path.exists(exe):
|
|
1516
|
+
# Executable missing; skip denoise
|
|
1517
|
+
return img
|
|
1518
|
+
|
|
1519
|
+
in_dir = os.path.join(root, "input")
|
|
1520
|
+
out_dir = os.path.join(root, "output")
|
|
1521
|
+
os.makedirs(in_dir, exist_ok=True)
|
|
1522
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
1523
|
+
|
|
1524
|
+
# Unique base name to avoid collisions with other threads
|
|
1525
|
+
base = f"contsub_{os.getpid()}_{id(self)}_{int(time.time() * 1000)}"
|
|
1526
|
+
in_path = os.path.join(in_dir, f"{base}.tif")
|
|
1527
|
+
|
|
1528
|
+
# Stage image as 32-bit float TIFF in [0,1]
|
|
1529
|
+
arr = np.clip(np.asarray(img, dtype=np.float32), 0.0, 1.0)
|
|
1530
|
+
is_mono = not (arr.ndim == 3 and arr.shape[2] == 3)
|
|
1531
|
+
legacy_save_image(
|
|
1532
|
+
arr,
|
|
1533
|
+
in_path,
|
|
1534
|
+
"tiff",
|
|
1535
|
+
"32-bit floating point",
|
|
1536
|
+
None, # no header needed
|
|
1537
|
+
is_mono
|
|
1538
|
+
)
|
|
1539
|
+
|
|
1540
|
+
# Build denoise args (fixed strength 0.9 for both luma + color, full mode)
|
|
1541
|
+
args = [
|
|
1542
|
+
"--denoise_strength", "0.90",
|
|
1543
|
+
"--color_denoise_strength", "0.90",
|
|
1544
|
+
"--denoise_mode", "full",
|
|
1545
|
+
]
|
|
1546
|
+
|
|
1547
|
+
proc = subprocess.Popen(
|
|
1548
|
+
[exe] + args,
|
|
1549
|
+
cwd=root,
|
|
1550
|
+
stdout=subprocess.PIPE,
|
|
1551
|
+
stderr=subprocess.STDOUT,
|
|
1552
|
+
text=True,
|
|
1553
|
+
universal_newlines=True,
|
|
1554
|
+
)
|
|
1555
|
+
|
|
1556
|
+
# Consume output (optional: could parse progress)
|
|
1557
|
+
if proc.stdout is not None:
|
|
1558
|
+
for line in proc.stdout:
|
|
1559
|
+
line = (line or "").strip()
|
|
1560
|
+
if not line:
|
|
1561
|
+
continue
|
|
1562
|
+
# We could parse "Progress:" here if desired.
|
|
1563
|
+
|
|
1564
|
+
rc = proc.wait()
|
|
1565
|
+
if rc != 0:
|
|
1566
|
+
return img
|
|
1567
|
+
|
|
1568
|
+
# Cosmic Clarity will create something like base_* in the output folder
|
|
1569
|
+
pattern = os.path.join(out_dir, f"{base}_*.*")
|
|
1570
|
+
out_path = self._wait_for_cc_output(pattern)
|
|
1571
|
+
if not out_path:
|
|
1572
|
+
return img
|
|
1573
|
+
|
|
1574
|
+
out_img, _, _, _ = legacy_load_image(out_path)
|
|
1575
|
+
if out_img is None:
|
|
1576
|
+
return img
|
|
1577
|
+
|
|
1578
|
+
result = np.clip(np.asarray(out_img, dtype=np.float32), 0.0, 1.0)
|
|
1579
|
+
|
|
1580
|
+
# Cleanup temp files
|
|
1581
|
+
for path in (in_path, out_path):
|
|
1582
|
+
try:
|
|
1583
|
+
if path and os.path.exists(path):
|
|
1584
|
+
os.remove(path)
|
|
1585
|
+
except Exception:
|
|
1586
|
+
pass
|
|
1587
|
+
|
|
1588
|
+
return result
|
|
1589
|
+
|
|
1590
|
+
except Exception as e:
|
|
1591
|
+
try:
|
|
1592
|
+
self.status_update.emit(f"Cosmic Clarity denoise failed: {e}")
|
|
1593
|
+
except Exception:
|
|
1594
|
+
pass
|
|
1595
|
+
return img
|
|
1596
|
+
|
|
1597
|
+
def _wait_for_cc_output(self, pattern: str, timeout: float = 1800.0, poll: float = 0.25) -> str:
|
|
1598
|
+
"""
|
|
1599
|
+
Wait for a CC output file matching glob `pattern`. Returns most recent path or "" on timeout.
|
|
1600
|
+
"""
|
|
1601
|
+
t0 = time.time()
|
|
1602
|
+
last = ""
|
|
1603
|
+
while time.time() - t0 < timeout:
|
|
1604
|
+
matches = glob.glob(pattern)
|
|
1605
|
+
if matches:
|
|
1606
|
+
try:
|
|
1607
|
+
matches.sort(key=lambda p: os.path.getmtime(p), reverse=True)
|
|
1608
|
+
except Exception:
|
|
1609
|
+
matches.sort()
|
|
1610
|
+
last = matches[0]
|
|
1611
|
+
try:
|
|
1612
|
+
if os.path.getsize(last) > 0:
|
|
1613
|
+
return last
|
|
1614
|
+
except Exception:
|
|
1615
|
+
return last
|
|
1616
|
+
time.sleep(poll)
|
|
1617
|
+
return ""
|