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,1625 @@
|
|
|
1
|
+
# pro/remove_stars.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import os
|
|
4
|
+
import platform
|
|
5
|
+
import shutil
|
|
6
|
+
import stat
|
|
7
|
+
import tempfile
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from PyQt6.QtCore import QThread, pyqtSignal
|
|
11
|
+
from PyQt6.QtWidgets import (
|
|
12
|
+
QInputDialog, QMessageBox, QFileDialog,
|
|
13
|
+
QDialog, QVBoxLayout, QTextEdit, QPushButton,
|
|
14
|
+
QLabel, QComboBox, QCheckBox, QSpinBox, QFormLayout, QDialogButtonBox, QWidget, QHBoxLayout
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
# use your legacy I/O functions (as requested)
|
|
18
|
+
from setiastro.saspro.legacy.image_manager import save_image, load_image
|
|
19
|
+
import glob
|
|
20
|
+
try:
|
|
21
|
+
import cv2
|
|
22
|
+
except Exception:
|
|
23
|
+
cv2 = None
|
|
24
|
+
|
|
25
|
+
# Shared utilities
|
|
26
|
+
from setiastro.saspro.widgets.image_utils import extract_mask_from_document as _active_mask_array_from_doc
|
|
27
|
+
|
|
28
|
+
_MAD_NORM = 1.4826
|
|
29
|
+
|
|
30
|
+
# --------- deterministic, invertible stretch used for StarNet ----------
|
|
31
|
+
# ---------- Siril-like MTF (linked) pre-stretch for StarNet ----------
|
|
32
|
+
def _robust_peak_sigma(gray: np.ndarray) -> tuple[float, float]:
|
|
33
|
+
gray = gray.astype(np.float32, copy=False)
|
|
34
|
+
med = float(np.median(gray))
|
|
35
|
+
mad = float(np.median(np.abs(gray - med)))
|
|
36
|
+
sigma = 1.4826 * mad if mad > 0 else float(gray.std())
|
|
37
|
+
# optional: refine "peak" as histogram mode around median
|
|
38
|
+
try:
|
|
39
|
+
hist, edges = np.histogram(gray, bins=2048, range=(gray.min(), gray.max()))
|
|
40
|
+
peak = float(0.5 * (edges[np.argmax(hist)] + edges[np.argmax(hist)+1]))
|
|
41
|
+
except Exception:
|
|
42
|
+
peak = med
|
|
43
|
+
return peak, max(sigma, 1e-8)
|
|
44
|
+
|
|
45
|
+
def _mtf_apply(x: np.ndarray, shadows: float, midtones: float, highlights: float) -> np.ndarray:
|
|
46
|
+
# x in [0, +], returns [0..1]ish given s,h
|
|
47
|
+
s, m, h = float(shadows), float(midtones), float(highlights)
|
|
48
|
+
denom = max(h - s, 1e-8)
|
|
49
|
+
xp = (x - s) / denom
|
|
50
|
+
# clamp xp to avoid crazy values
|
|
51
|
+
xp = np.clip(xp, 0.0, 1.0)
|
|
52
|
+
num = (m - 1.0) * xp
|
|
53
|
+
den = ((2.0 * m - 1.0) * xp) - m
|
|
54
|
+
y = np.divide(num, den, out=np.zeros_like(xp, dtype=np.float32), where=np.abs(den) > 1e-12)
|
|
55
|
+
return np.clip(y, 0.0, 1.0).astype(np.float32, copy=False)
|
|
56
|
+
|
|
57
|
+
def _mtf_inverse(y: np.ndarray, shadows: float, midtones: float, highlights: float) -> np.ndarray:
|
|
58
|
+
"""
|
|
59
|
+
Pseudoinverse of MTF, matching Siril's MTF_pseudoinverse() implementation.
|
|
60
|
+
|
|
61
|
+
C reference:
|
|
62
|
+
|
|
63
|
+
float MTF_pseudoinverse(float y, struct mtf_params params) {
|
|
64
|
+
return ((((params.shadows + params.highlights) * params.midtones
|
|
65
|
+
- params.shadows) * y - params.shadows * params.midtones
|
|
66
|
+
+ params.shadows)
|
|
67
|
+
/ ((2 * params.midtones - 1) * y - params.midtones + 1));
|
|
68
|
+
}
|
|
69
|
+
"""
|
|
70
|
+
s = float(shadows)
|
|
71
|
+
m = float(midtones)
|
|
72
|
+
h = float(highlights)
|
|
73
|
+
|
|
74
|
+
yp = np.clip(y.astype(np.float32, copy=False), 0.0, 1.0)
|
|
75
|
+
|
|
76
|
+
num = (((s + h) * m - s) * yp - s * m + s)
|
|
77
|
+
den = (2.0 * m - 1.0) * yp - m + 1.0
|
|
78
|
+
|
|
79
|
+
x = np.divide(
|
|
80
|
+
num,
|
|
81
|
+
den,
|
|
82
|
+
out=np.full_like(yp, s, dtype=np.float32), # fallback ~shadows if denom≈0
|
|
83
|
+
where=np.abs(den) > 1e-12
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# Clamp back into [s, h] and then [0,1] for safety
|
|
87
|
+
x = np.clip(x, s, h)
|
|
88
|
+
return np.clip(x, 0.0, 1.0).astype(np.float32, copy=False)
|
|
89
|
+
|
|
90
|
+
def _mtf_params_linked(img_rgb01: np.ndarray, shadowclip_sigma: float = -2.8, targetbg: float = 0.25):
|
|
91
|
+
"""
|
|
92
|
+
Compute linked (single) MTF parameters for RGB image in [0..1].
|
|
93
|
+
Returns dict(s=..., m=..., h=...).
|
|
94
|
+
"""
|
|
95
|
+
# luminance proxy for stats
|
|
96
|
+
if img_rgb01.ndim == 2:
|
|
97
|
+
gray = img_rgb01
|
|
98
|
+
else:
|
|
99
|
+
gray = img_rgb01.mean(axis=2)
|
|
100
|
+
peak, sigma = _robust_peak_sigma(gray)
|
|
101
|
+
s = peak + shadowclip_sigma * sigma
|
|
102
|
+
# keep [0..1) with margin
|
|
103
|
+
s = float(np.clip(s, gray.min(), max(gray.max() - 1e-6, 0.0)))
|
|
104
|
+
h = 1.0 # Siril effectively normalizes to <=1 before 16-bit TIFF
|
|
105
|
+
# solve for midtones m so that mtf(xp(peak)) = targetbg
|
|
106
|
+
x = (peak - s) / max(h - s, 1e-8)
|
|
107
|
+
x = float(np.clip(x, 1e-6, 1.0 - 1e-6))
|
|
108
|
+
y = float(np.clip(targetbg, 1e-6, 1.0 - 1e-6))
|
|
109
|
+
denom = (2.0 * y * x) - y - x
|
|
110
|
+
m = (x * (y - 1.0)) / denom if abs(denom) > 1e-12 else 0.5
|
|
111
|
+
m = float(np.clip(m, 1e-4, 1.0 - 1e-4))
|
|
112
|
+
return {"s": s, "m": m, "h": h}
|
|
113
|
+
|
|
114
|
+
def _apply_mtf_linked_rgb(img_rgb01: np.ndarray, p: dict) -> np.ndarray:
|
|
115
|
+
if img_rgb01.ndim == 2:
|
|
116
|
+
img_rgb01 = np.stack([img_rgb01]*3, axis=-1)
|
|
117
|
+
y = np.empty_like(img_rgb01, dtype=np.float32)
|
|
118
|
+
for c in range(3):
|
|
119
|
+
y[..., c] = _mtf_apply(img_rgb01[..., c], p["s"], p["m"], p["h"])
|
|
120
|
+
return np.clip(y, 0.0, 1.0)
|
|
121
|
+
|
|
122
|
+
def _invert_mtf_linked_rgb(img_rgb01: np.ndarray, p: dict) -> np.ndarray:
|
|
123
|
+
y = np.empty_like(img_rgb01, dtype=np.float32)
|
|
124
|
+
for c in range(3):
|
|
125
|
+
y[..., c] = _mtf_inverse(img_rgb01[..., c], p["s"], p["m"], p["h"])
|
|
126
|
+
return y
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _mtf_params_unlinked(img_rgb01: np.ndarray,
|
|
130
|
+
shadows_clipping: float = -2.8,
|
|
131
|
+
targetbg: float = 0.25) -> dict:
|
|
132
|
+
"""
|
|
133
|
+
Siril-style per-channel MTF parameter estimation, matching
|
|
134
|
+
find_unlinked_midtones_balance_default() / find_unlinked_midtones_balance().
|
|
135
|
+
|
|
136
|
+
Works on float32 data assumed in [0,1].
|
|
137
|
+
Returns dict with arrays: {'s': (C,), 'm': (C,), 'h': (C,)}.
|
|
138
|
+
"""
|
|
139
|
+
x = np.asarray(img_rgb01, dtype=np.float32)
|
|
140
|
+
# Force 3 channels internally (Siril expects 1 or 3; we always give it 3 here)
|
|
141
|
+
if x.ndim == 2:
|
|
142
|
+
x = np.stack([x] * 3, axis=-1)
|
|
143
|
+
elif x.ndim == 3 and x.shape[2] == 1:
|
|
144
|
+
x = np.repeat(x, 3, axis=2)
|
|
145
|
+
|
|
146
|
+
C = x.shape[2]
|
|
147
|
+
s = np.zeros(C, dtype=np.float32)
|
|
148
|
+
m = np.zeros(C, dtype=np.float32)
|
|
149
|
+
h = np.zeros(C, dtype=np.float32)
|
|
150
|
+
|
|
151
|
+
med = np.zeros(C, dtype=np.float32)
|
|
152
|
+
mad = np.zeros(C, dtype=np.float32)
|
|
153
|
+
inverted_flags = np.zeros(C, dtype=bool)
|
|
154
|
+
|
|
155
|
+
# --- stats per channel (Siril: median / normValue, mad / normValue * MAD_NORM) ---
|
|
156
|
+
# Here normValue == 1.0 because we're already in [0,1]
|
|
157
|
+
for c in range(C):
|
|
158
|
+
ch = x[..., c].astype(np.float32, copy=False)
|
|
159
|
+
med_c = float(np.median(ch))
|
|
160
|
+
mad_raw = float(np.median(np.abs(ch - med_c)))
|
|
161
|
+
mad_c = mad_raw * _MAD_NORM
|
|
162
|
+
if mad_c == 0.0:
|
|
163
|
+
mad_c = 0.001
|
|
164
|
+
|
|
165
|
+
med[c] = med_c
|
|
166
|
+
mad[c] = mad_c
|
|
167
|
+
if med_c > 0.5:
|
|
168
|
+
inverted_flags[c] = True
|
|
169
|
+
|
|
170
|
+
inverted_channels = int(inverted_flags.sum())
|
|
171
|
+
|
|
172
|
+
# --- Main branch (non-inverted dominant) ---
|
|
173
|
+
if inverted_channels < C:
|
|
174
|
+
for c in range(C):
|
|
175
|
+
median = float(med[c])
|
|
176
|
+
mad_c = float(mad[c])
|
|
177
|
+
|
|
178
|
+
c0 = median + shadows_clipping * mad_c
|
|
179
|
+
if c0 < 0.0:
|
|
180
|
+
c0 = 0.0
|
|
181
|
+
# Siril: m2 = median - c0; midtones = MTF(m2, target_bg, 0,1)
|
|
182
|
+
m2 = median - c0
|
|
183
|
+
mid = float(_mtf_scalar(m2, targetbg, 0.0, 1.0))
|
|
184
|
+
|
|
185
|
+
s[c] = c0
|
|
186
|
+
m[c] = mid
|
|
187
|
+
h[c] = 1.0
|
|
188
|
+
|
|
189
|
+
# --- Inverted channel branch ---
|
|
190
|
+
else:
|
|
191
|
+
for c in range(C):
|
|
192
|
+
median = float(med[c])
|
|
193
|
+
mad_c = float(mad[c])
|
|
194
|
+
|
|
195
|
+
c1 = median - shadows_clipping * mad_c
|
|
196
|
+
if c1 > 1.0:
|
|
197
|
+
c1 = 1.0
|
|
198
|
+
m2 = c1 - median
|
|
199
|
+
mid = 1.0 - float(_mtf_scalar(m2, targetbg, 0.0, 1.0))
|
|
200
|
+
|
|
201
|
+
s[c] = 0.0
|
|
202
|
+
m[c] = mid
|
|
203
|
+
h[c] = c1
|
|
204
|
+
|
|
205
|
+
return {"s": s, "m": m, "h": h}
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _apply_mtf_unlinked_rgb(img_rgb01: np.ndarray, p: dict) -> np.ndarray:
|
|
209
|
+
"""
|
|
210
|
+
Apply per-channel MTF exactly. p from _mtf_params_unlinked.
|
|
211
|
+
"""
|
|
212
|
+
x = np.asarray(img_rgb01, dtype=np.float32)
|
|
213
|
+
if x.ndim == 2:
|
|
214
|
+
x = np.stack([x]*3, axis=-1)
|
|
215
|
+
elif x.ndim == 3 and x.shape[2] == 1:
|
|
216
|
+
x = np.repeat(x, 3, axis=2)
|
|
217
|
+
|
|
218
|
+
out = np.empty_like(x, dtype=np.float32)
|
|
219
|
+
for c in range(x.shape[2]):
|
|
220
|
+
out[..., c] = _mtf_apply(x[..., c], float(p["s"][c]), float(p["m"][c]), float(p["h"][c]))
|
|
221
|
+
return np.clip(out, 0.0, 1.0)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _invert_mtf_unlinked_rgb(img_rgb01: np.ndarray, p: dict) -> np.ndarray:
|
|
225
|
+
"""
|
|
226
|
+
Exact analytic inverse per channel (uses same s/m/h arrays).
|
|
227
|
+
"""
|
|
228
|
+
y = np.asarray(img_rgb01, dtype=np.float32)
|
|
229
|
+
if y.ndim == 2:
|
|
230
|
+
y = np.stack([y]*3, axis=-1)
|
|
231
|
+
elif y.ndim == 3 and y.shape[2] == 1:
|
|
232
|
+
y = np.repeat(y, 3, axis=2)
|
|
233
|
+
|
|
234
|
+
out = np.empty_like(y, dtype=np.float32)
|
|
235
|
+
for c in range(y.shape[2]):
|
|
236
|
+
out[..., c] = _mtf_inverse(y[..., c], float(p["s"][c]), float(p["m"][c]), float(p["h"][c]))
|
|
237
|
+
return np.clip(out, 0.0, 1.0)
|
|
238
|
+
|
|
239
|
+
def _stat_stretch_rgb(img: np.ndarray,
|
|
240
|
+
lo_pct: float = 0.25,
|
|
241
|
+
hi_pct: float = 99.75) -> tuple[np.ndarray, dict]:
|
|
242
|
+
"""
|
|
243
|
+
Make sure img is RGB float32 in [0,1], stretch each channel to [0,1]
|
|
244
|
+
using percentiles. Returns (stretched_img, params) where params can be
|
|
245
|
+
fed to _stat_unstretch_rgb() to invert exactly.
|
|
246
|
+
"""
|
|
247
|
+
was_single = (img.ndim == 2) or (img.ndim == 3 and img.shape[2] == 1)
|
|
248
|
+
if was_single:
|
|
249
|
+
img = np.stack([img] * 3, axis=-1)
|
|
250
|
+
|
|
251
|
+
x = img.astype(np.float32, copy=False)
|
|
252
|
+
out = np.empty_like(x, dtype=np.float32)
|
|
253
|
+
lo_vals, hi_vals = [], []
|
|
254
|
+
|
|
255
|
+
for c in range(3):
|
|
256
|
+
ch = x[..., c]
|
|
257
|
+
lo = float(np.percentile(ch, lo_pct))
|
|
258
|
+
hi = float(np.percentile(ch, hi_pct))
|
|
259
|
+
if not np.isfinite(lo): lo = 0.0
|
|
260
|
+
if not np.isfinite(hi): hi = 1.0
|
|
261
|
+
if hi - lo < 1e-6:
|
|
262
|
+
hi = lo + 1e-6
|
|
263
|
+
lo_vals.append(lo); hi_vals.append(hi)
|
|
264
|
+
out[..., c] = (ch - lo) / (hi - lo)
|
|
265
|
+
|
|
266
|
+
out = np.clip(out, 0.0, 1.0)
|
|
267
|
+
params = {"lo": lo_vals, "hi": hi_vals, "was_single": was_single}
|
|
268
|
+
return out, params
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _stat_unstretch_rgb(img: np.ndarray, params: dict) -> np.ndarray:
|
|
272
|
+
"""
|
|
273
|
+
Inverse of _stat_stretch_rgb. Expects img RGB float32 [0,1].
|
|
274
|
+
"""
|
|
275
|
+
lo = np.asarray(params["lo"], dtype=np.float32)
|
|
276
|
+
hi = np.asarray(params["hi"], dtype=np.float32)
|
|
277
|
+
out = img.astype(np.float32, copy=True)
|
|
278
|
+
for c in range(3):
|
|
279
|
+
out[..., c] = out[..., c] * (hi[c] - lo[c]) + lo[c]
|
|
280
|
+
out = np.clip(out, 0.0, 1.0)
|
|
281
|
+
if params.get("was_single", False):
|
|
282
|
+
out = out.mean(axis=2, keepdims=False) # back to single channel if needed
|
|
283
|
+
# StarNet needs RGB during processing; we keep RGB after removal for consistency.
|
|
284
|
+
# If you want to return mono to the doc when the source was mono, do it at the very end.
|
|
285
|
+
out = np.stack([out] * 3, axis=-1)
|
|
286
|
+
return out
|
|
287
|
+
|
|
288
|
+
def _mtf_scalar(x: float, m: float, lo: float = 0.0, hi: float = 1.0) -> float:
|
|
289
|
+
"""
|
|
290
|
+
Scalar midtones transfer function matching the PixInsight / Siril spec.
|
|
291
|
+
|
|
292
|
+
For x in [lo, hi], rescale to [0,1] and apply:
|
|
293
|
+
|
|
294
|
+
M(x; m) = (m - 1) * xp / ((2*m - 1)*xp - m)
|
|
295
|
+
|
|
296
|
+
with the special cases x<=lo -> 0, x>=hi -> 1.
|
|
297
|
+
"""
|
|
298
|
+
# clamp to the input domain
|
|
299
|
+
if x <= lo:
|
|
300
|
+
return 0.0
|
|
301
|
+
if x >= hi:
|
|
302
|
+
return 1.0
|
|
303
|
+
|
|
304
|
+
denom_range = hi - lo
|
|
305
|
+
if abs(denom_range) < 1e-12:
|
|
306
|
+
return 0.0
|
|
307
|
+
|
|
308
|
+
xp = (x - lo) / denom_range # normalized x in [0,1]
|
|
309
|
+
|
|
310
|
+
num = (m - 1.0) * xp
|
|
311
|
+
den = (2.0 * m - 1.0) * xp - m
|
|
312
|
+
|
|
313
|
+
if abs(den) < 1e-12:
|
|
314
|
+
# the spec says M(m; m) = 0.5, but if we ever hit this numerically
|
|
315
|
+
# just return 0.5 as a safe fallback
|
|
316
|
+
return 0.5
|
|
317
|
+
|
|
318
|
+
y = num / den
|
|
319
|
+
# clamp to [0,1] as PI/Siril do
|
|
320
|
+
if y < 0.0:
|
|
321
|
+
y = 0.0
|
|
322
|
+
elif y > 1.0:
|
|
323
|
+
y = 1.0
|
|
324
|
+
return float(y)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
# ------------------------------------------------------------
|
|
328
|
+
# Settings helper
|
|
329
|
+
# ------------------------------------------------------------
|
|
330
|
+
def _get_setting_any(settings, keys: tuple[str, ...], default: str = "") -> str:
|
|
331
|
+
if not settings:
|
|
332
|
+
return default
|
|
333
|
+
for k in keys:
|
|
334
|
+
try:
|
|
335
|
+
v = settings.value(k, "", type=str)
|
|
336
|
+
except Exception:
|
|
337
|
+
v = settings.value(k, "")
|
|
338
|
+
if isinstance(v, str) and v.strip():
|
|
339
|
+
return v.strip()
|
|
340
|
+
return default
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
# ================== HEADLESS, ARRAY-IN → STARLESS-ARRAY-OUT ==================
|
|
344
|
+
|
|
345
|
+
def starnet_starless_from_array(arr_rgb01: np.ndarray, settings, *, tmp_prefix="comet") -> np.ndarray:
|
|
346
|
+
"""
|
|
347
|
+
Siril-style MTF round-trip for 32-bit data:
|
|
348
|
+
|
|
349
|
+
1) Normalize to [0,1] (preserving overall scale separately)
|
|
350
|
+
2) Compute unlinked MTF params per channel (Siril auto-stretch)
|
|
351
|
+
3) Apply unlinked MTF -> 16-bit TIFF for StarNet
|
|
352
|
+
4) StarNet -> read starless 16-bit TIFF
|
|
353
|
+
5) Apply per-channel MTF pseudoinverse with SAME params
|
|
354
|
+
6) Restore original scale if >1.0
|
|
355
|
+
"""
|
|
356
|
+
import os
|
|
357
|
+
import platform
|
|
358
|
+
import subprocess
|
|
359
|
+
import numpy as np
|
|
360
|
+
|
|
361
|
+
# save_image / load_image / _get_setting_any assumed available
|
|
362
|
+
arr = np.asarray(arr_rgb01, dtype=np.float32)
|
|
363
|
+
was_single = (arr.ndim == 2) or (arr.ndim == 3 and arr.shape[2] == 1)
|
|
364
|
+
|
|
365
|
+
exe = _get_setting_any(settings, ("starnet/exe_path", "paths/starnet"), "")
|
|
366
|
+
if not exe or not os.path.exists(exe):
|
|
367
|
+
raise RuntimeError("StarNet executable not configured (settings 'paths/starnet').")
|
|
368
|
+
|
|
369
|
+
workdir = os.path.dirname(exe) or os.getcwd()
|
|
370
|
+
in_path = os.path.join(workdir, f"{tmp_prefix}_in.tif")
|
|
371
|
+
out_path = os.path.join(workdir, f"{tmp_prefix}_out.tif")
|
|
372
|
+
|
|
373
|
+
# --- Normalize input shape and safe values ---
|
|
374
|
+
x = arr
|
|
375
|
+
if x.ndim == 2:
|
|
376
|
+
x = np.stack([x] * 3, axis=-1)
|
|
377
|
+
elif x.ndim == 3 and x.shape[2] == 1:
|
|
378
|
+
x = np.repeat(x, 3, axis=2)
|
|
379
|
+
x = np.nan_to_num(x, nan=0.0, posinf=0.0, neginf=0.0).astype(np.float32, copy=False)
|
|
380
|
+
|
|
381
|
+
# Preserve original numeric scale if users pass >1.0
|
|
382
|
+
xmax = float(np.max(x)) if x.size else 1.0
|
|
383
|
+
scale_factor = xmax if xmax > 1.01 else 1.0
|
|
384
|
+
xin = (x / scale_factor) if scale_factor > 1.0 else x
|
|
385
|
+
xin = np.clip(xin, 0.0, 1.0)
|
|
386
|
+
|
|
387
|
+
# --- Siril-style unlinked MTF params + pre-stretch ---
|
|
388
|
+
mtf_params = _mtf_params_unlinked(xin, shadows_clipping=-2.8, targetbg=0.25)
|
|
389
|
+
x_for_starnet = _apply_mtf_unlinked_rgb(xin, mtf_params).astype(np.float32, copy=False)
|
|
390
|
+
|
|
391
|
+
# --- Write 16-bit TIFF for StarNet ---
|
|
392
|
+
save_image(
|
|
393
|
+
x_for_starnet, in_path,
|
|
394
|
+
original_format="tif", bit_depth="16-bit",
|
|
395
|
+
original_header=None, is_mono=False, image_meta=None, file_meta=None
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
# --- Run StarNet ---
|
|
399
|
+
exe_name = os.path.basename(exe).lower()
|
|
400
|
+
if platform.system() in ("Windows", "Linux"):
|
|
401
|
+
cmd = [exe, in_path, out_path, "256"]
|
|
402
|
+
else:
|
|
403
|
+
cmd = [exe, "--input", in_path, "--output", out_path] if "starnet2" in exe_name else [exe, in_path, out_path]
|
|
404
|
+
|
|
405
|
+
rc = subprocess.call(cmd, cwd=workdir)
|
|
406
|
+
if rc != 0 or not os.path.exists(out_path):
|
|
407
|
+
_safe_rm(in_path); _safe_rm(out_path)
|
|
408
|
+
raise RuntimeError(f"StarNet failed rc={rc}")
|
|
409
|
+
|
|
410
|
+
starless_s, _, _, _ = load_image(out_path)
|
|
411
|
+
_safe_rm(in_path); _safe_rm(out_path)
|
|
412
|
+
|
|
413
|
+
if starless_s.ndim == 2:
|
|
414
|
+
starless_s = np.stack([starless_s] * 3, axis=-1)
|
|
415
|
+
elif starless_s.ndim == 3 and starless_s.shape[2] == 1:
|
|
416
|
+
starless_s = np.repeat(starless_s, 3, axis=2)
|
|
417
|
+
starless_s = np.clip(starless_s.astype(np.float32, copy=False), 0.0, 1.0)
|
|
418
|
+
|
|
419
|
+
# --- Apply Siril-style pseudoinverse MTF with SAME params ---
|
|
420
|
+
starless_lin01 = _invert_mtf_unlinked_rgb(starless_s, mtf_params)
|
|
421
|
+
|
|
422
|
+
# Restore original scale if we normalized earlier
|
|
423
|
+
if scale_factor > 1.0:
|
|
424
|
+
starless_lin01 *= scale_factor
|
|
425
|
+
|
|
426
|
+
result = np.clip(starless_lin01, 0.0, 1.0).astype(np.float32, copy=False)
|
|
427
|
+
|
|
428
|
+
# If the source was mono, return mono
|
|
429
|
+
if was_single and result.ndim == 3:
|
|
430
|
+
result = result.mean(axis=2)
|
|
431
|
+
|
|
432
|
+
return result
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def darkstar_starless_from_array(arr_rgb01: np.ndarray, settings, *, tmp_prefix="comet",
|
|
436
|
+
disable_gpu=False, mode="unscreen", stride=512) -> np.ndarray:
|
|
437
|
+
"""
|
|
438
|
+
Save arr -> run DarkStar -> load starless -> return starless RGB float32 [0..1].
|
|
439
|
+
"""
|
|
440
|
+
exe, base = _resolve_darkstar_exe(type("dummy", (), {"settings": settings}) )
|
|
441
|
+
if not exe or not base:
|
|
442
|
+
raise RuntimeError("Cosmic Clarity DarkStar executable not configured.")
|
|
443
|
+
arr = np.asarray(arr_rgb01, dtype=np.float32)
|
|
444
|
+
was_single = (arr.ndim == 2) or (arr.ndim == 3 and arr.shape[2] == 1)
|
|
445
|
+
input_dir = os.path.join(base, "input")
|
|
446
|
+
output_dir = os.path.join(base, "output")
|
|
447
|
+
os.makedirs(input_dir, exist_ok=True)
|
|
448
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
449
|
+
_purge_darkstar_io(base, prefix=None, clear_input=True, clear_output=True)
|
|
450
|
+
|
|
451
|
+
in_path = os.path.join(input_dir, f"{tmp_prefix}_in.tif")
|
|
452
|
+
save_image(
|
|
453
|
+
arr, in_path,
|
|
454
|
+
original_format="tif", bit_depth="32-bit floating point",
|
|
455
|
+
original_header=None, is_mono=was_single, image_meta=None, file_meta=None
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
args = []
|
|
459
|
+
if disable_gpu: args.append("--disable_gpu")
|
|
460
|
+
args += ["--star_removal_mode", mode, "--chunk_size", str(int(stride))]
|
|
461
|
+
import subprocess
|
|
462
|
+
rc = subprocess.call([exe] + args, cwd=output_dir)
|
|
463
|
+
if rc != 0:
|
|
464
|
+
_safe_rm(in_path); raise RuntimeError(f"DarkStar failed rc={rc}")
|
|
465
|
+
|
|
466
|
+
starless_path = os.path.join(output_dir, "imagetoremovestars_starless.tif")
|
|
467
|
+
starless, _, _, _ = load_image(starless_path)
|
|
468
|
+
if starless is None:
|
|
469
|
+
_safe_rm(in_path); raise RuntimeError("DarkStar produced no starless image.")
|
|
470
|
+
if starless.ndim == 2 or (starless.ndim == 3 and starless.shape[2] == 1):
|
|
471
|
+
starless = np.stack([starless] * 3, axis=-1)
|
|
472
|
+
starless = np.clip(starless.astype(np.float32, copy=False), 0.0, 1.0)
|
|
473
|
+
|
|
474
|
+
# If the source was mono, collapse back to single channel
|
|
475
|
+
if was_single and starless.ndim == 3:
|
|
476
|
+
starless = starless.mean(axis=2)
|
|
477
|
+
|
|
478
|
+
# cleanup typical outputs
|
|
479
|
+
_purge_darkstar_io(base, prefix="imagetoremovestars", clear_input=True, clear_output=True)
|
|
480
|
+
return starless
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
# ------------------------------------------------------------
|
|
484
|
+
# Public entry
|
|
485
|
+
# ------------------------------------------------------------
|
|
486
|
+
def remove_stars(main, target_doc=None):
|
|
487
|
+
# block interactive UI during/just-after a headless preset run
|
|
488
|
+
if getattr(main, "_remove_stars_headless_running", False):
|
|
489
|
+
return
|
|
490
|
+
if getattr(main, "_remove_stars_guard", False):
|
|
491
|
+
return
|
|
492
|
+
|
|
493
|
+
tool, ok = QInputDialog.getItem(
|
|
494
|
+
main, "Select Star Removal Tool", "Choose a tool:",
|
|
495
|
+
["StarNet", "CosmicClarityDarkStar"], 0, False
|
|
496
|
+
)
|
|
497
|
+
if not ok:
|
|
498
|
+
return
|
|
499
|
+
|
|
500
|
+
# explicit doc wins; otherwise fall back to _active_doc
|
|
501
|
+
doc = target_doc
|
|
502
|
+
if doc is None:
|
|
503
|
+
doc = getattr(main, "_active_doc", None)
|
|
504
|
+
if callable(doc):
|
|
505
|
+
doc = doc()
|
|
506
|
+
|
|
507
|
+
if doc is None or getattr(doc, "image", None) is None:
|
|
508
|
+
QMessageBox.warning(main, "No Image", "Please load an image before removing stars.")
|
|
509
|
+
return
|
|
510
|
+
|
|
511
|
+
if tool == "CosmicClarityDarkStar":
|
|
512
|
+
_run_darkstar(main, doc)
|
|
513
|
+
else:
|
|
514
|
+
_run_starnet(main, doc)
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _first_nonzero_bp_per_channel(img3: np.ndarray) -> np.ndarray:
|
|
520
|
+
"""Per-channel minimum positive sample (0 if none)."""
|
|
521
|
+
bps = np.zeros(3, dtype=np.float32)
|
|
522
|
+
for c in range(3):
|
|
523
|
+
ch = img3[..., c].reshape(-1)
|
|
524
|
+
pos = ch[ch > 0.0]
|
|
525
|
+
bps[c] = float(pos.min()) if pos.size else 0.0
|
|
526
|
+
return bps
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _prepare_statstretch_input_for_starnet(img_rgb01: np.ndarray) -> tuple[np.ndarray, dict]:
|
|
530
|
+
"""
|
|
531
|
+
Build the input to StarNet using your statistical stretch flow:
|
|
532
|
+
• record per-channel first-nonzero blackpoints
|
|
533
|
+
• subtract pedestals
|
|
534
|
+
• record per-channel medians
|
|
535
|
+
• unlinked statistical stretch to target 0.25
|
|
536
|
+
Returns: (stretched_for_starnet_01, meta_dict)
|
|
537
|
+
"""
|
|
538
|
+
import numpy as np
|
|
539
|
+
from setiastro.saspro.imageops.stretch import stretch_color_image
|
|
540
|
+
|
|
541
|
+
x = np.asarray(img_rgb01, dtype=np.float32)
|
|
542
|
+
if x.ndim == 2:
|
|
543
|
+
x = np.stack([x]*3, axis=-1)
|
|
544
|
+
elif x.ndim == 3 and x.shape[2] == 1:
|
|
545
|
+
x = np.repeat(x, 3, axis=2)
|
|
546
|
+
x = np.nan_to_num(x, nan=0.0, posinf=0.0, neginf=0.0)
|
|
547
|
+
x = np.clip(x, 0.0, 1.0)
|
|
548
|
+
|
|
549
|
+
# per-channel pedestal
|
|
550
|
+
bp = _first_nonzero_bp_per_channel(x)
|
|
551
|
+
xin_ped = np.clip(x - bp.reshape((1, 1, 3)), 0.0, 1.0)
|
|
552
|
+
|
|
553
|
+
# per-channel medians (after pedestal removal)
|
|
554
|
+
m0 = np.array([float(np.median(xin_ped[..., c])) for c in range(3)], dtype=np.float32)
|
|
555
|
+
|
|
556
|
+
# unlinked stat-stretch to 0.25
|
|
557
|
+
x_for_starnet = stretch_color_image(
|
|
558
|
+
xin_ped, target_median=0.25, linked=False,
|
|
559
|
+
normalize=False, apply_curves=False, curves_boost=0.0
|
|
560
|
+
).astype(np.float32, copy=False)
|
|
561
|
+
|
|
562
|
+
meta = {
|
|
563
|
+
"statstretch": True,
|
|
564
|
+
"bp": bp, # pedestals we subtracted (in 0..1 domain)
|
|
565
|
+
"m0": m0, # per-channel original medians (post-pedestal)
|
|
566
|
+
}
|
|
567
|
+
return x_for_starnet, meta
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def _inverse_statstretch_from_starless(starless_s01: np.ndarray, meta: dict) -> np.ndarray:
|
|
571
|
+
"""
|
|
572
|
+
Inverse of the stat-stretch prep:
|
|
573
|
+
• per-channel stretch back to each original median m0[c]
|
|
574
|
+
• add back the saved pedestal bp[c]
|
|
575
|
+
Returns starless in 0..1 domain (float32).
|
|
576
|
+
"""
|
|
577
|
+
import numpy as np
|
|
578
|
+
from setiastro.saspro.imageops.stretch import stretch_mono_image
|
|
579
|
+
|
|
580
|
+
s = np.asarray(starless_s01, dtype=np.float32)
|
|
581
|
+
if s.ndim == 2:
|
|
582
|
+
s = np.stack([s]*3, axis=-1)
|
|
583
|
+
elif s.ndim == 3 and s.shape[2] == 1:
|
|
584
|
+
s = np.repeat(s, 3, axis=2)
|
|
585
|
+
s = np.clip(s, 0.0, 1.0)
|
|
586
|
+
|
|
587
|
+
bp = np.asarray(meta.get("bp"), dtype=np.float32).reshape((1, 1, 3))
|
|
588
|
+
m0 = np.asarray(meta.get("m0"), dtype=np.float32)
|
|
589
|
+
|
|
590
|
+
out = np.empty_like(s, dtype=np.float32)
|
|
591
|
+
for c in range(3):
|
|
592
|
+
out[..., c] = stretch_mono_image(
|
|
593
|
+
s[..., c], target_median=float(m0[c]),
|
|
594
|
+
normalize=False, apply_curves=False, curves_boost=0.0
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
out = out + bp
|
|
598
|
+
return np.clip(out, 0.0, 1.0).astype(np.float32, copy=False)
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
# ------------------------------------------------------------
|
|
602
|
+
# StarNet (SASv2-like: 16-bit TIFF in StarNet folder)
|
|
603
|
+
# ------------------------------------------------------------
|
|
604
|
+
def _run_starnet(main, doc):
|
|
605
|
+
import os
|
|
606
|
+
import platform
|
|
607
|
+
import numpy as np
|
|
608
|
+
from PyQt6.QtWidgets import QFileDialog, QMessageBox
|
|
609
|
+
|
|
610
|
+
# --- Resolve StarNet exe, persist in settings
|
|
611
|
+
exe = _get_setting_any(getattr(main, "settings", None),
|
|
612
|
+
("starnet/exe_path", "paths/starnet"), "")
|
|
613
|
+
if not exe or not os.path.exists(exe):
|
|
614
|
+
exe_path, _ = QFileDialog.getOpenFileName(main, "Select StarNet Executable", "", "Executable Files (*)")
|
|
615
|
+
if not exe_path:
|
|
616
|
+
return
|
|
617
|
+
exe = exe_path
|
|
618
|
+
s = getattr(main, "settings", None)
|
|
619
|
+
if s:
|
|
620
|
+
s.setValue("starnet/exe_path", exe)
|
|
621
|
+
s.setValue("paths/starnet", exe)
|
|
622
|
+
|
|
623
|
+
if platform.system() in ("Darwin", "Linux"):
|
|
624
|
+
_ensure_exec_bit(exe)
|
|
625
|
+
|
|
626
|
+
sysname = platform.system()
|
|
627
|
+
if sysname not in ("Windows", "Darwin", "Linux"):
|
|
628
|
+
QMessageBox.critical(main, "Unsupported OS",
|
|
629
|
+
f"The current operating system '{sysname}' is not supported.")
|
|
630
|
+
return
|
|
631
|
+
|
|
632
|
+
# --- Ask linearity (SASv2 behavior)
|
|
633
|
+
reply = QMessageBox.question(
|
|
634
|
+
main, "Image Linearity", "Is the current image linear?",
|
|
635
|
+
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
|
636
|
+
QMessageBox.StandardButton.No
|
|
637
|
+
)
|
|
638
|
+
is_linear = (reply == QMessageBox.StandardButton.Yes)
|
|
639
|
+
did_stretch = is_linear
|
|
640
|
+
try:
|
|
641
|
+
main._last_remove_stars_params = {
|
|
642
|
+
"engine": "StarNet",
|
|
643
|
+
"is_linear": bool(is_linear),
|
|
644
|
+
"did_stretch": bool(did_stretch),
|
|
645
|
+
"label": "Remove Stars (StarNet)",
|
|
646
|
+
}
|
|
647
|
+
except Exception:
|
|
648
|
+
pass
|
|
649
|
+
# 🔁 Record headless command for Replay Last
|
|
650
|
+
try:
|
|
651
|
+
main._last_headless_command = {
|
|
652
|
+
"command_id": "remove_stars",
|
|
653
|
+
"preset": {
|
|
654
|
+
"tool": "starnet",
|
|
655
|
+
"linear": bool(is_linear),
|
|
656
|
+
},
|
|
657
|
+
}
|
|
658
|
+
if hasattr(main, "_log"):
|
|
659
|
+
main._log(
|
|
660
|
+
f"[Replay] Recorded remove_stars (StarNet, linear="
|
|
661
|
+
f"{'yes' if is_linear else 'no'})"
|
|
662
|
+
)
|
|
663
|
+
except Exception:
|
|
664
|
+
pass
|
|
665
|
+
# --- Ensure RGB float32 in safe range
|
|
666
|
+
src = np.asarray(doc.image)
|
|
667
|
+
if src.ndim == 2:
|
|
668
|
+
processing_image = np.stack([src]*3, axis=-1)
|
|
669
|
+
elif src.ndim == 3 and src.shape[2] == 1:
|
|
670
|
+
processing_image = np.repeat(src, 3, axis=2)
|
|
671
|
+
else:
|
|
672
|
+
processing_image = src
|
|
673
|
+
processing_image = np.nan_to_num(processing_image.astype(np.float32, copy=False),
|
|
674
|
+
nan=0.0, posinf=0.0, neginf=0.0)
|
|
675
|
+
|
|
676
|
+
# --- Scale normalization if >1.0 (same reason as before: 16-bit export safety)
|
|
677
|
+
scale_factor = float(np.max(processing_image))
|
|
678
|
+
if scale_factor > 1.0:
|
|
679
|
+
processing_norm = processing_image / scale_factor
|
|
680
|
+
else:
|
|
681
|
+
processing_norm = processing_image
|
|
682
|
+
|
|
683
|
+
# --- Build input/output paths
|
|
684
|
+
starnet_dir = os.path.dirname(exe) or os.getcwd()
|
|
685
|
+
input_image_path = os.path.join(starnet_dir, "imagetoremovestars.tif")
|
|
686
|
+
output_image_path = os.path.join(starnet_dir, "starless.tif")
|
|
687
|
+
|
|
688
|
+
# --- Prepare input for StarNet (Siril-style MTF pre-stretch for linear data) ---
|
|
689
|
+
img_for_starnet = processing_norm
|
|
690
|
+
if is_linear:
|
|
691
|
+
# Siril-style unlinked MTF params from linear normalized image
|
|
692
|
+
mtf_params = _mtf_params_unlinked(processing_norm, shadows_clipping=-2.8, targetbg=0.25)
|
|
693
|
+
img_for_starnet = _apply_mtf_unlinked_rgb(processing_norm, mtf_params)
|
|
694
|
+
|
|
695
|
+
# 🔐 Stash EXACT params for inverse step later
|
|
696
|
+
try:
|
|
697
|
+
setattr(main, "_starnet_stat_meta", {
|
|
698
|
+
"scheme": "siril_mtf",
|
|
699
|
+
"s": np.asarray(mtf_params["s"], dtype=np.float32),
|
|
700
|
+
"m": np.asarray(mtf_params["m"], dtype=np.float32),
|
|
701
|
+
"h": np.asarray(mtf_params["h"], dtype=np.float32),
|
|
702
|
+
"scale": float(scale_factor),
|
|
703
|
+
})
|
|
704
|
+
except Exception:
|
|
705
|
+
pass
|
|
706
|
+
else:
|
|
707
|
+
# non-linear: do not try to invert any pre-stretch later
|
|
708
|
+
if hasattr(main, "_starnet_stat_meta"):
|
|
709
|
+
delattr(main, "_starnet_stat_meta")
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
# --- Write TIFF for StarNet
|
|
713
|
+
try:
|
|
714
|
+
save_image(img_for_starnet, input_image_path,
|
|
715
|
+
original_format="tif", bit_depth="16-bit",
|
|
716
|
+
original_header=None, is_mono=False, image_meta=None, file_meta=None)
|
|
717
|
+
except Exception as e:
|
|
718
|
+
QMessageBox.critical(main, "StarNet", f"Failed to write input TIFF:\n{e}")
|
|
719
|
+
return
|
|
720
|
+
|
|
721
|
+
# --- Launch StarNet in a worker (keeps your progress dialog)
|
|
722
|
+
exe_name = os.path.basename(exe).lower()
|
|
723
|
+
if sysname in ("Windows", "Linux"):
|
|
724
|
+
command = [exe, input_image_path, output_image_path, "256"]
|
|
725
|
+
else: # macOS
|
|
726
|
+
if "starnet2" in exe_name:
|
|
727
|
+
command = [exe, "--input", input_image_path, "--output", output_image_path]
|
|
728
|
+
else:
|
|
729
|
+
command = [exe, input_image_path, output_image_path]
|
|
730
|
+
|
|
731
|
+
dlg = _ProcDialog(main, title="StarNet Progress")
|
|
732
|
+
thr = _ProcThread(command, cwd=starnet_dir)
|
|
733
|
+
thr.output_signal.connect(dlg.append_text)
|
|
734
|
+
|
|
735
|
+
# Capture everything we need in the closure for finish handler
|
|
736
|
+
thr.finished_signal.connect(
|
|
737
|
+
lambda rc, ds=did_stretch: _on_starnet_finished(
|
|
738
|
+
main, doc, rc, dlg, input_image_path, output_image_path, ds
|
|
739
|
+
)
|
|
740
|
+
)
|
|
741
|
+
dlg.cancel_button.clicked.connect(thr.cancel)
|
|
742
|
+
|
|
743
|
+
dlg.show()
|
|
744
|
+
thr.start()
|
|
745
|
+
dlg.exec()
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def _on_starnet_finished(main, doc, return_code, dialog, input_path, output_path, did_stretch):
|
|
749
|
+
import os
|
|
750
|
+
import numpy as np
|
|
751
|
+
from PyQt6.QtWidgets import QMessageBox
|
|
752
|
+
from setiastro.saspro.imageops.stretch import stretch_mono_image # used for statistical inverse
|
|
753
|
+
|
|
754
|
+
def _first_nonzero_bp_per_channel(img3: np.ndarray) -> np.ndarray:
|
|
755
|
+
bps = np.zeros(3, dtype=np.float32)
|
|
756
|
+
for c in range(3):
|
|
757
|
+
ch = img3[..., c].reshape(-1)
|
|
758
|
+
pos = ch[ch > 0.0]
|
|
759
|
+
bps[c] = float(pos.min()) if pos.size else 0.0
|
|
760
|
+
return bps
|
|
761
|
+
|
|
762
|
+
dialog.append_text(f"\nProcess finished with return code {return_code}.\n")
|
|
763
|
+
if return_code != 0:
|
|
764
|
+
QMessageBox.critical(main, "StarNet Error", f"StarNet failed with return code {return_code}.")
|
|
765
|
+
_safe_rm(input_path); _safe_rm(output_path)
|
|
766
|
+
dialog.close()
|
|
767
|
+
return
|
|
768
|
+
|
|
769
|
+
if not os.path.exists(output_path):
|
|
770
|
+
QMessageBox.critical(main, "StarNet Error", "Starless image was not created.")
|
|
771
|
+
_safe_rm(input_path)
|
|
772
|
+
dialog.close()
|
|
773
|
+
return
|
|
774
|
+
|
|
775
|
+
dialog.append_text(f"Starless image found at {output_path}. Loading image...\n")
|
|
776
|
+
starless_rgb, _, _, _ = load_image(output_path)
|
|
777
|
+
_safe_rm(input_path); _safe_rm(output_path)
|
|
778
|
+
|
|
779
|
+
if starless_rgb is None:
|
|
780
|
+
QMessageBox.critical(main, "StarNet Error", "Failed to load starless image.")
|
|
781
|
+
dialog.close()
|
|
782
|
+
return
|
|
783
|
+
|
|
784
|
+
# ensure 3ch float32 in [0..1]
|
|
785
|
+
if starless_rgb.ndim == 2:
|
|
786
|
+
starless_rgb = np.stack([starless_rgb] * 3, axis=-1)
|
|
787
|
+
elif starless_rgb.ndim == 3 and starless_rgb.shape[2] == 1:
|
|
788
|
+
starless_rgb = np.repeat(starless_rgb, 3, axis=2)
|
|
789
|
+
starless_rgb = np.clip(starless_rgb.astype(np.float32, copy=False), 0.0, 1.0)
|
|
790
|
+
|
|
791
|
+
# original image (from the doc) as 3ch float32, track if it was mono
|
|
792
|
+
orig = np.asarray(doc.image)
|
|
793
|
+
if orig.ndim == 2:
|
|
794
|
+
original_rgb = np.stack([orig] * 3, axis=-1)
|
|
795
|
+
orig_was_mono = True
|
|
796
|
+
elif orig.ndim == 3 and orig.shape[2] == 1:
|
|
797
|
+
original_rgb = np.repeat(orig, 3, axis=2)
|
|
798
|
+
orig_was_mono = True
|
|
799
|
+
else:
|
|
800
|
+
original_rgb = orig
|
|
801
|
+
orig_was_mono = False
|
|
802
|
+
original_rgb = original_rgb.astype(np.float32, copy=False)
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
# ---- Inversion back to the document’s domain ----
|
|
806
|
+
if did_stretch:
|
|
807
|
+
# Prefer the new Siril-style MTF meta if present
|
|
808
|
+
meta = getattr(main, "_starnet_stat_meta", None)
|
|
809
|
+
mtf_params_legacy = getattr(main, "_starnet_last_mtf_params", None)
|
|
810
|
+
|
|
811
|
+
if isinstance(meta, dict) and meta.get("scheme") == "siril_mtf":
|
|
812
|
+
dialog.append_text("Unstretching (Siril-style MTF pseudoinverse)...\n")
|
|
813
|
+
try:
|
|
814
|
+
s_vec = np.asarray(meta.get("s"), dtype=np.float32)
|
|
815
|
+
m_vec = np.asarray(meta.get("m"), dtype=np.float32)
|
|
816
|
+
h_vec = np.asarray(meta.get("h"), dtype=np.float32)
|
|
817
|
+
scale_factor = float(meta.get("scale", 1.0))
|
|
818
|
+
|
|
819
|
+
p = {"s": s_vec, "m": m_vec, "h": h_vec}
|
|
820
|
+
inv = _invert_mtf_unlinked_rgb(starless_rgb, p)
|
|
821
|
+
|
|
822
|
+
if scale_factor > 1.0:
|
|
823
|
+
inv = inv * scale_factor
|
|
824
|
+
|
|
825
|
+
starless_rgb = np.clip(inv, 0.0, 1.0)
|
|
826
|
+
except Exception as e:
|
|
827
|
+
dialog.append_text(f"⚠️ Siril-style MTF inverse failed: {e}\n")
|
|
828
|
+
|
|
829
|
+
elif isinstance(meta, dict) and meta.get("scheme") == "statstretch":
|
|
830
|
+
# Back-compat: statistical round-trip with bp/m0
|
|
831
|
+
dialog.append_text("Unstretching (statistical inverse w/ original BP/M0)...\n")
|
|
832
|
+
|
|
833
|
+
bp_vec = np.asarray(meta.get("bp"), dtype=np.float32)
|
|
834
|
+
m0_vec = np.asarray(meta.get("m0"), dtype=np.float32)
|
|
835
|
+
scale_factor = float(meta.get("scale", 1.0))
|
|
836
|
+
|
|
837
|
+
inv = np.empty_like(starless_rgb, dtype=np.float32)
|
|
838
|
+
for c in range(3):
|
|
839
|
+
inv[..., c] = stretch_mono_image(
|
|
840
|
+
starless_rgb[..., c],
|
|
841
|
+
target_median=float(m0_vec[c]),
|
|
842
|
+
normalize=False, apply_curves=False, curves_boost=0.0
|
|
843
|
+
)
|
|
844
|
+
|
|
845
|
+
inv += bp_vec.reshape((1, 1, 3))
|
|
846
|
+
inv = np.clip(inv, 0.0, 1.0)
|
|
847
|
+
if scale_factor > 1.0:
|
|
848
|
+
inv *= scale_factor
|
|
849
|
+
starless_rgb = np.clip(inv, 0.0, 1.0)
|
|
850
|
+
|
|
851
|
+
elif mtf_params_legacy:
|
|
852
|
+
# Very old MTF path (linked, single triple) – keep for safety
|
|
853
|
+
dialog.append_text("Unstretching (legacy MTF inverse)...\n")
|
|
854
|
+
try:
|
|
855
|
+
starless_rgb = _invert_mtf_linked_rgb(starless_rgb, mtf_params_legacy)
|
|
856
|
+
sc = float(mtf_params_legacy.get("scale", 1.0))
|
|
857
|
+
if sc > 1.0:
|
|
858
|
+
starless_rgb = starless_rgb * sc
|
|
859
|
+
except Exception as e:
|
|
860
|
+
dialog.append_text(f"⚠️ Legacy MTF inverse failed: {e}\n")
|
|
861
|
+
starless_rgb = np.clip(starless_rgb, 0.0, 1.0)
|
|
862
|
+
|
|
863
|
+
# Clean up stashed meta so it can't leak to future ops
|
|
864
|
+
try:
|
|
865
|
+
if hasattr(main, "_starnet_stat_meta"):
|
|
866
|
+
delattr(main, "_starnet_stat_meta")
|
|
867
|
+
except Exception:
|
|
868
|
+
pass
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
|
|
872
|
+
# ---- Stars-Only = original − starless (linear-domain diff) ----
|
|
873
|
+
dialog.append_text("Generating stars-only image...\n")
|
|
874
|
+
stars_only = np.clip(original_rgb - starless_rgb, 0.0, 1.0)
|
|
875
|
+
|
|
876
|
+
# apply active mask (doc-based)
|
|
877
|
+
m3 = _active_mask3_from_doc(doc, stars_only.shape[1], stars_only.shape[0])
|
|
878
|
+
if m3 is not None:
|
|
879
|
+
stars_only *= m3
|
|
880
|
+
dialog.append_text("✅ Applied active mask to the stars-only image.\n")
|
|
881
|
+
else:
|
|
882
|
+
dialog.append_text("ℹ️ No active mask for stars-only; skipping.\n")
|
|
883
|
+
|
|
884
|
+
# If the original doc was mono, return a mono stars-only image
|
|
885
|
+
if orig_was_mono:
|
|
886
|
+
stars_to_push = stars_only.mean(axis=2).astype(np.float32, copy=False)
|
|
887
|
+
else:
|
|
888
|
+
stars_to_push = stars_only
|
|
889
|
+
|
|
890
|
+
# push Stars-Only as new document with suffix _stars
|
|
891
|
+
_push_as_new_doc(main, doc, stars_to_push, title_suffix="_stars", source="Stars-Only (StarNet)")
|
|
892
|
+
dialog.append_text("Stars-only image pushed.\n")
|
|
893
|
+
|
|
894
|
+
# mask-blend starless with original using active mask, then overwrite current view
|
|
895
|
+
dialog.append_text("Preparing to update current view with starless (mask-blend)...\n")
|
|
896
|
+
final_starless = _mask_blend_with_doc_mask(doc, starless_rgb, original_rgb)
|
|
897
|
+
|
|
898
|
+
# If the original doc was mono, collapse back to single-channel
|
|
899
|
+
if orig_was_mono:
|
|
900
|
+
final_to_apply = final_starless.mean(axis=2).astype(np.float32, copy=False)
|
|
901
|
+
else:
|
|
902
|
+
final_to_apply = final_starless.astype(np.float32, copy=False)
|
|
903
|
+
|
|
904
|
+
try:
|
|
905
|
+
meta = {
|
|
906
|
+
"step_name": "Stars Removed",
|
|
907
|
+
"bit_depth": "32-bit floating point",
|
|
908
|
+
"is_mono": bool(orig_was_mono),
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
# 🔹 Attach replay-last metadata
|
|
912
|
+
rp = getattr(main, "_last_remove_stars_params", None)
|
|
913
|
+
if isinstance(rp, dict):
|
|
914
|
+
replay_params = dict(rp) # shallow copy so we don't mutate the stored one
|
|
915
|
+
else:
|
|
916
|
+
replay_params = {
|
|
917
|
+
"engine": "StarNet",
|
|
918
|
+
"is_linear": bool(did_stretch),
|
|
919
|
+
"did_stretch": bool(did_stretch),
|
|
920
|
+
"label": "Remove Stars (StarNet)",
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
replay_params.setdefault("engine", "StarNet")
|
|
924
|
+
replay_params.setdefault("label", "Remove Stars (StarNet)")
|
|
925
|
+
|
|
926
|
+
meta["replay_last"] = {
|
|
927
|
+
"op": "remove_stars",
|
|
928
|
+
"params": replay_params,
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
# Clean up the stash so it can't leak to the next unrelated op
|
|
932
|
+
try:
|
|
933
|
+
if hasattr(main, "_last_remove_stars_params"):
|
|
934
|
+
delattr(main, "_last_remove_stars_params")
|
|
935
|
+
except Exception:
|
|
936
|
+
pass
|
|
937
|
+
|
|
938
|
+
doc.apply_edit(
|
|
939
|
+
final_to_apply,
|
|
940
|
+
metadata=meta,
|
|
941
|
+
step_name="Stars Removed"
|
|
942
|
+
)
|
|
943
|
+
if hasattr(main, "_log"):
|
|
944
|
+
main._log("Stars Removed (StarNet)")
|
|
945
|
+
except Exception as e:
|
|
946
|
+
QMessageBox.critical(main, "StarNet Error", f"Failed to apply starless result:\n{e}")
|
|
947
|
+
|
|
948
|
+
dialog.append_text("Temporary files cleaned up.\n")
|
|
949
|
+
dialog.close()
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
|
|
953
|
+
# ------------------------------------------------------------
|
|
954
|
+
# CosmicClarityDarkStar
|
|
955
|
+
# ------------------------------------------------------------
|
|
956
|
+
def _run_darkstar(main, doc):
|
|
957
|
+
exe, base = _resolve_darkstar_exe(main)
|
|
958
|
+
if not exe or not base:
|
|
959
|
+
QMessageBox.critical(main, "Cosmic Clarity Folder Error",
|
|
960
|
+
"Cosmic Clarity Dark Star executable not set.")
|
|
961
|
+
return
|
|
962
|
+
|
|
963
|
+
# --- Input/output folders per SASv2 ---
|
|
964
|
+
input_dir = os.path.join(base, "input")
|
|
965
|
+
output_dir = os.path.join(base, "output")
|
|
966
|
+
os.makedirs(input_dir, exist_ok=True)
|
|
967
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
968
|
+
_purge_darkstar_io(base, prefix=None, clear_input=True, clear_output=True)
|
|
969
|
+
|
|
970
|
+
# --- Config dialog (same as before) ---
|
|
971
|
+
cfg = DarkStarConfigDialog(main)
|
|
972
|
+
if not cfg.exec():
|
|
973
|
+
return
|
|
974
|
+
params = cfg.get_values()
|
|
975
|
+
disable_gpu = params["disable_gpu"]
|
|
976
|
+
mode = params["mode"] # "unscreen" or "additive"
|
|
977
|
+
show_extracted_stars = params["show_extracted_stars"]
|
|
978
|
+
stride = params["stride"] # 64..1024, default 512
|
|
979
|
+
|
|
980
|
+
# 🔹 Ask if image is linear (so we know whether to MTF-prestretch)
|
|
981
|
+
reply = QMessageBox.question(
|
|
982
|
+
main, "Image Linearity", "Is the current image linear?",
|
|
983
|
+
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
|
984
|
+
QMessageBox.StandardButton.Yes
|
|
985
|
+
)
|
|
986
|
+
is_linear = (reply == QMessageBox.StandardButton.Yes)
|
|
987
|
+
did_prestretch = is_linear
|
|
988
|
+
|
|
989
|
+
# 🔹 Stash parameters for replay-last
|
|
990
|
+
try:
|
|
991
|
+
main._last_remove_stars_params = {
|
|
992
|
+
"engine": "CosmicClarityDarkStar",
|
|
993
|
+
"disable_gpu": bool(disable_gpu),
|
|
994
|
+
"mode": mode,
|
|
995
|
+
"show_extracted_stars": bool(show_extracted_stars),
|
|
996
|
+
"stride": int(stride),
|
|
997
|
+
"is_linear": bool(is_linear),
|
|
998
|
+
"did_prestretch": bool(did_prestretch),
|
|
999
|
+
"label": "Remove Stars (DarkStar)",
|
|
1000
|
+
}
|
|
1001
|
+
except Exception:
|
|
1002
|
+
pass
|
|
1003
|
+
|
|
1004
|
+
# 🔁 Record headless command for Replay Last
|
|
1005
|
+
try:
|
|
1006
|
+
main._last_headless_command = {
|
|
1007
|
+
"command_id": "remove_stars",
|
|
1008
|
+
"preset": {
|
|
1009
|
+
"tool": "darkstar",
|
|
1010
|
+
"disable_gpu": bool(disable_gpu),
|
|
1011
|
+
"mode": mode,
|
|
1012
|
+
"show_extracted_stars": bool(show_extracted_stars),
|
|
1013
|
+
"stride": int(stride),
|
|
1014
|
+
"is_linear": bool(is_linear),
|
|
1015
|
+
"did_prestretch": bool(did_prestretch),
|
|
1016
|
+
},
|
|
1017
|
+
}
|
|
1018
|
+
if hasattr(main, "_log"):
|
|
1019
|
+
main._log(
|
|
1020
|
+
"[Replay] Recorded remove_stars (DarkStar, "
|
|
1021
|
+
f"mode={mode}, stride={int(stride)}, "
|
|
1022
|
+
f"gpu={'off' if disable_gpu else 'on'}, "
|
|
1023
|
+
f"stars={'on' if show_extracted_stars else 'off'}, "
|
|
1024
|
+
f"linear={'yes' if is_linear else 'no'})"
|
|
1025
|
+
)
|
|
1026
|
+
except Exception:
|
|
1027
|
+
pass
|
|
1028
|
+
|
|
1029
|
+
# --- Build processing image (RGB float32, normalized) ---
|
|
1030
|
+
src = np.asarray(doc.image)
|
|
1031
|
+
if src.ndim == 2:
|
|
1032
|
+
processing_image = np.stack([src] * 3, axis=-1)
|
|
1033
|
+
elif src.ndim == 3 and src.shape[2] == 1:
|
|
1034
|
+
processing_image = np.repeat(src, 3, axis=2)
|
|
1035
|
+
else:
|
|
1036
|
+
processing_image = src
|
|
1037
|
+
|
|
1038
|
+
processing_image = np.nan_to_num(
|
|
1039
|
+
processing_image.astype(np.float32, copy=False),
|
|
1040
|
+
nan=0.0, posinf=0.0, neginf=0.0
|
|
1041
|
+
)
|
|
1042
|
+
|
|
1043
|
+
scale_factor = float(np.max(processing_image)) if processing_image.size else 1.0
|
|
1044
|
+
if scale_factor > 1.0:
|
|
1045
|
+
processing_norm = processing_image / scale_factor
|
|
1046
|
+
else:
|
|
1047
|
+
processing_norm = processing_image
|
|
1048
|
+
processing_norm = np.clip(processing_norm, 0.0, 1.0)
|
|
1049
|
+
|
|
1050
|
+
# --- Optional Siril-style MTF pre-stretch for linear data ---
|
|
1051
|
+
img_for_darkstar = processing_norm
|
|
1052
|
+
if is_linear:
|
|
1053
|
+
try:
|
|
1054
|
+
mtf_params = _mtf_params_unlinked(
|
|
1055
|
+
processing_norm,
|
|
1056
|
+
shadows_clipping=-2.8,
|
|
1057
|
+
targetbg=0.25
|
|
1058
|
+
)
|
|
1059
|
+
img_for_darkstar = _apply_mtf_unlinked_rgb(processing_norm, mtf_params)
|
|
1060
|
+
|
|
1061
|
+
# 🔐 Stash EXACT params for inverse step later
|
|
1062
|
+
setattr(main, "_darkstar_mtf_meta", {
|
|
1063
|
+
"s": np.asarray(mtf_params["s"], dtype=np.float32),
|
|
1064
|
+
"m": np.asarray(mtf_params["m"], dtype=np.float32),
|
|
1065
|
+
"h": np.asarray(mtf_params["h"], dtype=np.float32),
|
|
1066
|
+
"scale": float(scale_factor),
|
|
1067
|
+
})
|
|
1068
|
+
if hasattr(main, "_log"):
|
|
1069
|
+
main._log("[DarkStar] Applying Siril-style MTF pre-stretch for linear image.")
|
|
1070
|
+
except Exception as e:
|
|
1071
|
+
# If anything goes wrong, fall back to un-stretched normalized image
|
|
1072
|
+
img_for_darkstar = processing_norm
|
|
1073
|
+
try:
|
|
1074
|
+
if hasattr(main, "_darkstar_mtf_meta"):
|
|
1075
|
+
delattr(main, "_darkstar_mtf_meta")
|
|
1076
|
+
except Exception:
|
|
1077
|
+
pass
|
|
1078
|
+
if hasattr(main, "_log"):
|
|
1079
|
+
main._log(f"[DarkStar] MTF pre-stretch failed, using normalized image only: {e}")
|
|
1080
|
+
else:
|
|
1081
|
+
# Non-linear: don't store any pre-stretch meta
|
|
1082
|
+
try:
|
|
1083
|
+
if hasattr(main, "_darkstar_mtf_meta"):
|
|
1084
|
+
delattr(main, "_darkstar_mtf_meta")
|
|
1085
|
+
except Exception:
|
|
1086
|
+
pass
|
|
1087
|
+
|
|
1088
|
+
# --- Save pre-stretched image as 32-bit float TIFF for DarkStar ---
|
|
1089
|
+
in_path = os.path.join(input_dir, "imagetoremovestars.tif")
|
|
1090
|
+
try:
|
|
1091
|
+
save_image(
|
|
1092
|
+
img_for_darkstar,
|
|
1093
|
+
in_path,
|
|
1094
|
+
original_format="tif",
|
|
1095
|
+
bit_depth="32-bit floating point",
|
|
1096
|
+
original_header=None,
|
|
1097
|
+
is_mono=False, # we always send RGB to DarkStar
|
|
1098
|
+
image_meta=None,
|
|
1099
|
+
file_meta=None
|
|
1100
|
+
)
|
|
1101
|
+
except Exception as e:
|
|
1102
|
+
QMessageBox.critical(main, "Cosmic Clarity", f"Failed to write input TIFF:\n{e}")
|
|
1103
|
+
return
|
|
1104
|
+
|
|
1105
|
+
# --- Build CLI exactly like SASv2 (using --chunk_size, not chunk_size) ---
|
|
1106
|
+
args = []
|
|
1107
|
+
if disable_gpu:
|
|
1108
|
+
args.append("--disable_gpu")
|
|
1109
|
+
args += ["--star_removal_mode", mode]
|
|
1110
|
+
if show_extracted_stars:
|
|
1111
|
+
args.append("--show_extracted_stars")
|
|
1112
|
+
args += ["--chunk_size", str(stride)]
|
|
1113
|
+
|
|
1114
|
+
command = [exe] + args
|
|
1115
|
+
|
|
1116
|
+
dlg = _ProcDialog(main, title="CosmicClarityDarkStar Progress")
|
|
1117
|
+
thr = _ProcThread(command, cwd=output_dir)
|
|
1118
|
+
thr.output_signal.connect(dlg.append_text)
|
|
1119
|
+
thr.finished_signal.connect(
|
|
1120
|
+
lambda rc, base=base, ds=did_prestretch: _on_darkstar_finished(
|
|
1121
|
+
main, doc, rc, dlg, in_path, output_dir, base, ds
|
|
1122
|
+
)
|
|
1123
|
+
)
|
|
1124
|
+
dlg.cancel_button.clicked.connect(thr.cancel)
|
|
1125
|
+
|
|
1126
|
+
dlg.show()
|
|
1127
|
+
thr.start()
|
|
1128
|
+
dlg.exec()
|
|
1129
|
+
|
|
1130
|
+
|
|
1131
|
+
|
|
1132
|
+
|
|
1133
|
+
def _resolve_darkstar_exe(main):
|
|
1134
|
+
"""
|
|
1135
|
+
Return (exe_path, base_folder) or (None, None) on cancel/error.
|
|
1136
|
+
Accepts either a folder (stored) or a direct executable path.
|
|
1137
|
+
Saves the folder back to QSettings under 'paths/cosmic_clarity'.
|
|
1138
|
+
"""
|
|
1139
|
+
settings = getattr(main, "settings", None)
|
|
1140
|
+
raw = _get_setting_any(settings, ("paths/cosmic_clarity", "cosmic_clarity_folder"), "")
|
|
1141
|
+
|
|
1142
|
+
def _platform_exe_name():
|
|
1143
|
+
return "setiastrocosmicclarity_darkstar.exe" if platform.system() == "Windows" \
|
|
1144
|
+
else "setiastrocosmicclarity_darkstar"
|
|
1145
|
+
|
|
1146
|
+
exe_name = _platform_exe_name()
|
|
1147
|
+
|
|
1148
|
+
exe_path = None
|
|
1149
|
+
base_folder = None
|
|
1150
|
+
|
|
1151
|
+
if raw:
|
|
1152
|
+
if os.path.isfile(raw):
|
|
1153
|
+
# user stored the executable path directly
|
|
1154
|
+
exe_path = raw
|
|
1155
|
+
base_folder = os.path.dirname(raw)
|
|
1156
|
+
elif os.path.isdir(raw):
|
|
1157
|
+
# user stored the parent folder
|
|
1158
|
+
base_folder = raw
|
|
1159
|
+
exe_path = os.path.join(base_folder, exe_name)
|
|
1160
|
+
|
|
1161
|
+
# if missing or invalid, let user pick the executable directly
|
|
1162
|
+
if not exe_path or not os.path.exists(exe_path):
|
|
1163
|
+
picked, _ = QFileDialog.getOpenFileName(main, "Select CosmicClarityDarkStar Executable", "", "Executable Files (*)")
|
|
1164
|
+
if not picked:
|
|
1165
|
+
return None, None
|
|
1166
|
+
exe_path = picked
|
|
1167
|
+
base_folder = os.path.dirname(picked)
|
|
1168
|
+
|
|
1169
|
+
# ensure exec bit on POSIX
|
|
1170
|
+
if platform.system() in ("Darwin", "Linux"):
|
|
1171
|
+
_ensure_exec_bit(exe_path)
|
|
1172
|
+
|
|
1173
|
+
# persist folder (not the exe) to the canonical key
|
|
1174
|
+
if settings:
|
|
1175
|
+
settings.setValue("paths/cosmic_clarity", base_folder)
|
|
1176
|
+
settings.sync()
|
|
1177
|
+
|
|
1178
|
+
return exe_path, base_folder
|
|
1179
|
+
|
|
1180
|
+
|
|
1181
|
+
def _on_darkstar_finished(main, doc, return_code, dialog, in_path, output_dir, base_folder, did_prestretch):
|
|
1182
|
+
dialog.append_text(f"\nProcess finished with return code {return_code}.\n")
|
|
1183
|
+
if return_code != 0:
|
|
1184
|
+
QMessageBox.critical(main, "CosmicClarityDarkStar Error",
|
|
1185
|
+
f"CosmicClarityDarkStar failed with return code {return_code}.")
|
|
1186
|
+
_safe_rm(in_path); dialog.close(); return
|
|
1187
|
+
|
|
1188
|
+
starless_path = os.path.join(output_dir, "imagetoremovestars_starless.tif")
|
|
1189
|
+
if not os.path.exists(starless_path):
|
|
1190
|
+
QMessageBox.critical(main, "CosmicClarityDarkStar Error", "Starless image was not created.")
|
|
1191
|
+
_safe_rm(in_path); dialog.close(); return
|
|
1192
|
+
|
|
1193
|
+
dialog.append_text(f"Loading starless image from {starless_path}...\n")
|
|
1194
|
+
starless, _, _, _ = load_image(starless_path)
|
|
1195
|
+
if starless is None:
|
|
1196
|
+
QMessageBox.critical(main, "CosmicClarityDarkStar Error", "Failed to load starless image.")
|
|
1197
|
+
_safe_rm(in_path); dialog.close(); return
|
|
1198
|
+
|
|
1199
|
+
if starless.ndim == 2 or (starless.ndim == 3 and starless.shape[2] == 1):
|
|
1200
|
+
starless_rgb = np.stack([starless] * 3, axis=-1)
|
|
1201
|
+
else:
|
|
1202
|
+
starless_rgb = starless
|
|
1203
|
+
starless_rgb = starless_rgb.astype(np.float32, copy=False)
|
|
1204
|
+
|
|
1205
|
+
src = np.asarray(doc.image)
|
|
1206
|
+
if src.ndim == 2:
|
|
1207
|
+
original_rgb = np.stack([src] * 3, axis=-1)
|
|
1208
|
+
orig_was_mono = True
|
|
1209
|
+
elif src.ndim == 3 and src.shape[2] == 1:
|
|
1210
|
+
original_rgb = np.repeat(src, 3, axis=2)
|
|
1211
|
+
orig_was_mono = True
|
|
1212
|
+
else:
|
|
1213
|
+
original_rgb = src
|
|
1214
|
+
orig_was_mono = False
|
|
1215
|
+
original_rgb = original_rgb.astype(np.float32, copy=False)
|
|
1216
|
+
|
|
1217
|
+
# --- Undo the MTF pre-stretch (if we did one) ---
|
|
1218
|
+
if did_prestretch:
|
|
1219
|
+
meta = getattr(main, "_darkstar_mtf_meta", None)
|
|
1220
|
+
if isinstance(meta, dict):
|
|
1221
|
+
dialog.append_text("Unstretching starless result (DarkStar MTF inverse)...\n")
|
|
1222
|
+
try:
|
|
1223
|
+
s_vec = np.asarray(meta.get("s"), dtype=np.float32)
|
|
1224
|
+
m_vec = np.asarray(meta.get("m"), dtype=np.float32)
|
|
1225
|
+
h_vec = np.asarray(meta.get("h"), dtype=np.float32)
|
|
1226
|
+
scale = float(meta.get("scale", 1.0))
|
|
1227
|
+
|
|
1228
|
+
p = {"s": s_vec, "m": m_vec, "h": h_vec}
|
|
1229
|
+
inv = _invert_mtf_unlinked_rgb(starless_rgb, p)
|
|
1230
|
+
|
|
1231
|
+
if scale > 1.0:
|
|
1232
|
+
inv *= scale
|
|
1233
|
+
|
|
1234
|
+
starless_rgb = np.clip(inv, 0.0, 1.0)
|
|
1235
|
+
except Exception as e:
|
|
1236
|
+
dialog.append_text(f"⚠️ DarkStar MTF inverse failed: {e}\n")
|
|
1237
|
+
|
|
1238
|
+
# Clean up pre-stretch meta so it can't leak into another op
|
|
1239
|
+
try:
|
|
1240
|
+
if hasattr(main, "_darkstar_mtf_meta"):
|
|
1241
|
+
delattr(main, "_darkstar_mtf_meta")
|
|
1242
|
+
except Exception:
|
|
1243
|
+
pass
|
|
1244
|
+
|
|
1245
|
+
# --- stars-only optional push (as before) ---
|
|
1246
|
+
stars_path = os.path.join(output_dir, "imagetoremovestars_stars_only.tif")
|
|
1247
|
+
if os.path.exists(stars_path):
|
|
1248
|
+
dialog.append_text(f"Loading stars-only image from {stars_path}...\n")
|
|
1249
|
+
stars_only, _, _, _ = load_image(stars_path)
|
|
1250
|
+
if stars_only is not None:
|
|
1251
|
+
if stars_only.ndim == 2 or (stars_only.ndim == 3 and stars_only.shape[2] == 1):
|
|
1252
|
+
stars_only = np.stack([stars_only] * 3, axis=-1)
|
|
1253
|
+
stars_only = stars_only.astype(np.float32, copy=False)
|
|
1254
|
+
m3 = _active_mask3_from_doc(doc, stars_only.shape[1], stars_only.shape[0])
|
|
1255
|
+
if m3 is not None:
|
|
1256
|
+
stars_only *= m3
|
|
1257
|
+
dialog.append_text("✅ Applied active mask to stars-only image.\n")
|
|
1258
|
+
else:
|
|
1259
|
+
dialog.append_text("ℹ️ Mask not active for stars-only; skipping.\n")
|
|
1260
|
+
|
|
1261
|
+
# If the original doc was mono, collapse stars-only back to single channel
|
|
1262
|
+
if orig_was_mono:
|
|
1263
|
+
stars_to_push = stars_only.mean(axis=2).astype(np.float32, copy=False)
|
|
1264
|
+
else:
|
|
1265
|
+
stars_to_push = stars_only
|
|
1266
|
+
|
|
1267
|
+
_push_as_new_doc(main, doc, stars_to_push, title_suffix="_stars", source="Stars-Only (DarkStar)")
|
|
1268
|
+
else:
|
|
1269
|
+
dialog.append_text("Failed to load stars-only image.\n")
|
|
1270
|
+
else:
|
|
1271
|
+
dialog.append_text("No stars-only image generated.\n")
|
|
1272
|
+
|
|
1273
|
+
# --- Mask-blend starless → overwrite current doc (in original domain) ---
|
|
1274
|
+
dialog.append_text("Mask-blending starless image before update...\n")
|
|
1275
|
+
final_starless = _mask_blend_with_doc_mask(doc, starless_rgb, original_rgb)
|
|
1276
|
+
|
|
1277
|
+
# If the original doc was mono, collapse back to single-channel
|
|
1278
|
+
if orig_was_mono:
|
|
1279
|
+
final_to_apply = final_starless.mean(axis=2).astype(np.float32, copy=False)
|
|
1280
|
+
else:
|
|
1281
|
+
final_to_apply = final_starless.astype(np.float32, copy=False)
|
|
1282
|
+
|
|
1283
|
+
try:
|
|
1284
|
+
meta = {
|
|
1285
|
+
"step_name": "Stars Removed",
|
|
1286
|
+
"bit_depth": "32-bit floating point",
|
|
1287
|
+
"is_mono": bool(orig_was_mono),
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
# 🔹 Attach replay-last metadata
|
|
1291
|
+
rp = getattr(main, "_last_remove_stars_params", None)
|
|
1292
|
+
if isinstance(rp, dict):
|
|
1293
|
+
replay_params = dict(rp)
|
|
1294
|
+
else:
|
|
1295
|
+
replay_params = {
|
|
1296
|
+
"engine": "CosmicClarityDarkStar",
|
|
1297
|
+
"label": "Remove Stars (DarkStar)",
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
replay_params.setdefault("engine", "CosmicClarityDarkStar")
|
|
1301
|
+
replay_params.setdefault("label", "Remove Stars (DarkStar)")
|
|
1302
|
+
|
|
1303
|
+
meta["replay_last"] = {
|
|
1304
|
+
"op": "remove_stars",
|
|
1305
|
+
"params": replay_params,
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
# Clean up stash
|
|
1309
|
+
try:
|
|
1310
|
+
if hasattr(main, "_last_remove_stars_params"):
|
|
1311
|
+
delattr(main, "_last_remove_stars_params")
|
|
1312
|
+
except Exception:
|
|
1313
|
+
pass
|
|
1314
|
+
|
|
1315
|
+
doc.apply_edit(
|
|
1316
|
+
final_to_apply,
|
|
1317
|
+
metadata=meta,
|
|
1318
|
+
step_name="Stars Removed"
|
|
1319
|
+
)
|
|
1320
|
+
if hasattr(main, "_log"):
|
|
1321
|
+
main._log("Stars Removed (DarkStar)")
|
|
1322
|
+
except Exception as e:
|
|
1323
|
+
QMessageBox.critical(main, "CosmicClarityDarkStar", f"Failed to apply result:\n{e}")
|
|
1324
|
+
|
|
1325
|
+
# --- cleanup ---
|
|
1326
|
+
try:
|
|
1327
|
+
_safe_rm(in_path)
|
|
1328
|
+
_safe_rm(starless_path)
|
|
1329
|
+
_safe_rm(os.path.join(output_dir, "imagetoremovestars_stars_only.tif"))
|
|
1330
|
+
|
|
1331
|
+
# 🔸 Final sweep: nuke any imagetoremovestars* leftovers in both dirs
|
|
1332
|
+
base_folder = os.path.dirname(output_dir) # <-- derive CC base from output_dir
|
|
1333
|
+
_purge_darkstar_io(base_folder, prefix="imagetoremovestars", clear_input=True, clear_output=True)
|
|
1334
|
+
|
|
1335
|
+
dialog.append_text("Temporary files cleaned up.\n")
|
|
1336
|
+
except Exception as e:
|
|
1337
|
+
dialog.append_text(f"Cleanup error: {e}\n")
|
|
1338
|
+
|
|
1339
|
+
dialog.close()
|
|
1340
|
+
|
|
1341
|
+
|
|
1342
|
+
# ------------------------------------------------------------
|
|
1343
|
+
# Mask helpers (doc-centric)
|
|
1344
|
+
# ------------------------------------------------------------
|
|
1345
|
+
# _active_mask_array_from_doc is now imported from setiastro.saspro.widgets.image_utils
|
|
1346
|
+
|
|
1347
|
+
|
|
1348
|
+
def _active_mask3_from_doc(doc, w, h) -> np.ndarray | None:
|
|
1349
|
+
"""Return 3-channel mask resized to (h,w) if a doc-level mask exists; else None."""
|
|
1350
|
+
m = _active_mask_array_from_doc(doc)
|
|
1351
|
+
if m is None:
|
|
1352
|
+
return None
|
|
1353
|
+
if m.shape != (h, w):
|
|
1354
|
+
if cv2 is not None:
|
|
1355
|
+
m = cv2.resize(m, (w, h), interpolation=cv2.INTER_NEAREST)
|
|
1356
|
+
else:
|
|
1357
|
+
yi = (np.linspace(0, m.shape[0] - 1, h)).astype(np.int32)
|
|
1358
|
+
xi = (np.linspace(0, m.shape[1] - 1, w)).astype(np.int32)
|
|
1359
|
+
m = m[yi][:, xi]
|
|
1360
|
+
return np.repeat(m[:, :, None], 3, axis=2).astype(np.float32, copy=False)
|
|
1361
|
+
|
|
1362
|
+
|
|
1363
|
+
def _mask_blend_with_doc_mask(doc, starless_rgb: np.ndarray, original_rgb: np.ndarray) -> np.ndarray:
|
|
1364
|
+
"""Blend using mask from doc if present: result = starless*m + original*(1-m)."""
|
|
1365
|
+
m = _active_mask_array_from_doc(doc)
|
|
1366
|
+
if m is None:
|
|
1367
|
+
return starless_rgb
|
|
1368
|
+
h, w = starless_rgb.shape[:2]
|
|
1369
|
+
if m.shape != (h, w):
|
|
1370
|
+
if cv2 is not None:
|
|
1371
|
+
m = cv2.resize(m, (w, h), interpolation=cv2.INTER_NEAREST)
|
|
1372
|
+
else:
|
|
1373
|
+
yi = (np.linspace(0, m.shape[0] - 1, h)).astype(np.int32)
|
|
1374
|
+
xi = (np.linspace(0, m.shape[1] - 1, w)).astype(np.int32)
|
|
1375
|
+
m = m[yi][:, xi]
|
|
1376
|
+
m3 = np.repeat(m[:, :, None], 3, axis=2)
|
|
1377
|
+
return np.clip(starless_rgb * m3 + original_rgb * (1.0 - m3), 0.0, 1.0).astype(np.float32, copy=False)
|
|
1378
|
+
|
|
1379
|
+
|
|
1380
|
+
def _derive_view_base_title(main, doc) -> str:
|
|
1381
|
+
"""
|
|
1382
|
+
Prefer the active view's title (respecting per-view rename/override),
|
|
1383
|
+
fallback to the document display name, then to doc.name, and finally 'Image'.
|
|
1384
|
+
Also strips any decorations (mask glyph, 'Active View:' prefix) if available.
|
|
1385
|
+
"""
|
|
1386
|
+
# 1) Ask main for a subwindow for this document, if it exposes a helper
|
|
1387
|
+
try:
|
|
1388
|
+
if hasattr(main, "_subwindow_for_document"):
|
|
1389
|
+
sw = main._subwindow_for_document(doc)
|
|
1390
|
+
if sw:
|
|
1391
|
+
w = sw.widget() if hasattr(sw, "widget") else sw
|
|
1392
|
+
# Preferred: view's effective title (includes per-view override)
|
|
1393
|
+
if hasattr(w, "_effective_title"):
|
|
1394
|
+
t = w._effective_title() or ""
|
|
1395
|
+
else:
|
|
1396
|
+
t = sw.windowTitle() if hasattr(sw, "windowTitle") else ""
|
|
1397
|
+
if hasattr(w, "_strip_decorations"):
|
|
1398
|
+
t, _ = w._strip_decorations(t)
|
|
1399
|
+
if t.strip():
|
|
1400
|
+
return t.strip()
|
|
1401
|
+
except Exception:
|
|
1402
|
+
pass
|
|
1403
|
+
|
|
1404
|
+
# 2) Try scanning MDI for a subwindow whose widget holds this document
|
|
1405
|
+
try:
|
|
1406
|
+
mdi = (getattr(main, "mdi_area", None)
|
|
1407
|
+
or getattr(main, "mdiArea", None)
|
|
1408
|
+
or getattr(main, "mdi", None))
|
|
1409
|
+
if mdi and hasattr(mdi, "subWindowList"):
|
|
1410
|
+
for sw in mdi.subWindowList():
|
|
1411
|
+
w = sw.widget()
|
|
1412
|
+
if getattr(w, "document", None) is doc:
|
|
1413
|
+
t = sw.windowTitle() if hasattr(sw, "windowTitle") else ""
|
|
1414
|
+
if hasattr(w, "_strip_decorations"):
|
|
1415
|
+
t, _ = w._strip_decorations(t)
|
|
1416
|
+
if t.strip():
|
|
1417
|
+
return t.strip()
|
|
1418
|
+
except Exception:
|
|
1419
|
+
pass
|
|
1420
|
+
|
|
1421
|
+
# 3) Fallback to document's display name (then name, then generic)
|
|
1422
|
+
try:
|
|
1423
|
+
if hasattr(doc, "display_name"):
|
|
1424
|
+
t = doc.display_name()
|
|
1425
|
+
if t and t.strip():
|
|
1426
|
+
return t.strip()
|
|
1427
|
+
except Exception:
|
|
1428
|
+
pass
|
|
1429
|
+
return (getattr(doc, "name", "") or "Image").strip()
|
|
1430
|
+
|
|
1431
|
+
|
|
1432
|
+
# ------------------------------------------------------------
|
|
1433
|
+
# New document helper
|
|
1434
|
+
# ------------------------------------------------------------
|
|
1435
|
+
def _push_as_new_doc(main, doc, arr: np.ndarray, title_suffix="_stars", source="Stars-Only"):
|
|
1436
|
+
dm = getattr(main, "docman", None)
|
|
1437
|
+
if not dm or not hasattr(dm, "open_array"):
|
|
1438
|
+
return
|
|
1439
|
+
try:
|
|
1440
|
+
# Use the current view's title if available (respects per-view rename)
|
|
1441
|
+
base = _derive_view_base_title(main, doc)
|
|
1442
|
+
|
|
1443
|
+
# avoid double-suffix if user already named it with the suffix
|
|
1444
|
+
if title_suffix and base.endswith(title_suffix):
|
|
1445
|
+
title = base
|
|
1446
|
+
else:
|
|
1447
|
+
title = f"{base}{title_suffix}"
|
|
1448
|
+
|
|
1449
|
+
meta = {
|
|
1450
|
+
"bit_depth": "32-bit floating point",
|
|
1451
|
+
"is_mono": (arr.ndim == 2),
|
|
1452
|
+
"source": source,
|
|
1453
|
+
}
|
|
1454
|
+
newdoc = dm.open_array(arr.astype(np.float32, copy=False), metadata=meta, title=title)
|
|
1455
|
+
if hasattr(main, "_spawn_subwindow_for"):
|
|
1456
|
+
main._spawn_subwindow_for(newdoc)
|
|
1457
|
+
except Exception:
|
|
1458
|
+
pass
|
|
1459
|
+
|
|
1460
|
+
|
|
1461
|
+
|
|
1462
|
+
# ------------------------------------------------------------
|
|
1463
|
+
# Utilities
|
|
1464
|
+
# ------------------------------------------------------------
|
|
1465
|
+
def _ensure_exec_bit(path: str):
|
|
1466
|
+
if platform.system() == "Windows":
|
|
1467
|
+
return
|
|
1468
|
+
try:
|
|
1469
|
+
st = os.stat(path)
|
|
1470
|
+
os.chmod(path, st.st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
1471
|
+
except Exception:
|
|
1472
|
+
pass
|
|
1473
|
+
|
|
1474
|
+
|
|
1475
|
+
def _safe_rm(p):
|
|
1476
|
+
try:
|
|
1477
|
+
if p and os.path.exists(p):
|
|
1478
|
+
os.remove(p)
|
|
1479
|
+
except Exception:
|
|
1480
|
+
pass
|
|
1481
|
+
|
|
1482
|
+
def _safe_rm_globs(patterns: list[str]):
|
|
1483
|
+
for pat in patterns:
|
|
1484
|
+
try:
|
|
1485
|
+
for fp in glob.glob(pat):
|
|
1486
|
+
_safe_rm(fp)
|
|
1487
|
+
except Exception:
|
|
1488
|
+
pass
|
|
1489
|
+
|
|
1490
|
+
def _purge_darkstar_io(base_folder: str, *, prefix: str | None = None, clear_input=True, clear_output=True):
|
|
1491
|
+
"""Delete old image-like files from CC DarkStar input/output."""
|
|
1492
|
+
try:
|
|
1493
|
+
inp = os.path.join(base_folder, "input")
|
|
1494
|
+
out = os.path.join(base_folder, "output")
|
|
1495
|
+
if clear_input and os.path.isdir(inp):
|
|
1496
|
+
for fn in os.listdir(inp):
|
|
1497
|
+
fp = os.path.join(inp, fn)
|
|
1498
|
+
if os.path.isfile(fp) and (prefix is None or fn.startswith(prefix)):
|
|
1499
|
+
_safe_rm(fp)
|
|
1500
|
+
if clear_output and os.path.isdir(out):
|
|
1501
|
+
for fn in os.listdir(out):
|
|
1502
|
+
fp = os.path.join(out, fn)
|
|
1503
|
+
if os.path.isfile(fp) and (prefix is None or fn.startswith(prefix)):
|
|
1504
|
+
_safe_rm(fp)
|
|
1505
|
+
except Exception:
|
|
1506
|
+
pass
|
|
1507
|
+
|
|
1508
|
+
|
|
1509
|
+
# ------------------------------------------------------------
|
|
1510
|
+
# Proc runner & dialog (merged stdout/stderr)
|
|
1511
|
+
# ------------------------------------------------------------
|
|
1512
|
+
class _ProcThread(QThread):
|
|
1513
|
+
output_signal = pyqtSignal(str)
|
|
1514
|
+
finished_signal = pyqtSignal(int)
|
|
1515
|
+
|
|
1516
|
+
def __init__(self, command: list[str], cwd: str | None = None, parent=None):
|
|
1517
|
+
super().__init__(parent)
|
|
1518
|
+
self.command = command
|
|
1519
|
+
self.cwd = cwd
|
|
1520
|
+
self.process = None
|
|
1521
|
+
|
|
1522
|
+
def cancel(self):
|
|
1523
|
+
"""Request the subprocess to stop."""
|
|
1524
|
+
if self.process:
|
|
1525
|
+
try:
|
|
1526
|
+
self.process.kill()
|
|
1527
|
+
except Exception:
|
|
1528
|
+
pass
|
|
1529
|
+
|
|
1530
|
+
|
|
1531
|
+
def run(self):
|
|
1532
|
+
import subprocess
|
|
1533
|
+
import os
|
|
1534
|
+
env = os.environ.copy()
|
|
1535
|
+
for k in ("PYTHONHOME","PYTHONPATH","DYLD_LIBRARY_PATH","DYLD_FALLBACK_LIBRARY_PATH","PYTHONEXECUTABLE"):
|
|
1536
|
+
env.pop(k, None)
|
|
1537
|
+
rc = -1
|
|
1538
|
+
try:
|
|
1539
|
+
self.process = subprocess.Popen(
|
|
1540
|
+
self.command, cwd=self.cwd,
|
|
1541
|
+
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
|
1542
|
+
universal_newlines=True, text=True, start_new_session=True, env=env
|
|
1543
|
+
)
|
|
1544
|
+
for line in iter(self.process.stdout.readline, ""):
|
|
1545
|
+
if not line: break
|
|
1546
|
+
self.output_signal.emit(line.rstrip())
|
|
1547
|
+
try:
|
|
1548
|
+
self.process.stdout.close()
|
|
1549
|
+
except Exception:
|
|
1550
|
+
pass
|
|
1551
|
+
rc = self.process.wait()
|
|
1552
|
+
except Exception as e:
|
|
1553
|
+
self.output_signal.emit(str(e))
|
|
1554
|
+
rc = -1
|
|
1555
|
+
finally:
|
|
1556
|
+
self.process = None
|
|
1557
|
+
self.finished_signal.emit(rc)
|
|
1558
|
+
|
|
1559
|
+
|
|
1560
|
+
class _ProcDialog(QDialog):
|
|
1561
|
+
def __init__(self, parent, title="Process"):
|
|
1562
|
+
super().__init__(parent)
|
|
1563
|
+
self.setWindowTitle(title)
|
|
1564
|
+
self.setMinimumSize(600, 420)
|
|
1565
|
+
lay = QVBoxLayout(self)
|
|
1566
|
+
self.text = QTextEdit(self); self.text.setReadOnly(True)
|
|
1567
|
+
lay.addWidget(self.text)
|
|
1568
|
+
self.cancel_button = QPushButton("Cancel", self)
|
|
1569
|
+
lay.addWidget(self.cancel_button)
|
|
1570
|
+
|
|
1571
|
+
def append_text(self, s: str):
|
|
1572
|
+
try:
|
|
1573
|
+
self.text.append(s)
|
|
1574
|
+
except Exception:
|
|
1575
|
+
pass
|
|
1576
|
+
|
|
1577
|
+
|
|
1578
|
+
class DarkStarConfigDialog(QDialog):
|
|
1579
|
+
"""
|
|
1580
|
+
SASv2-style config UI:
|
|
1581
|
+
- Disable GPU: Yes/No (default No)
|
|
1582
|
+
- Star Removal Mode: unscreen | additive (default unscreen)
|
|
1583
|
+
- Show Extracted Stars: Yes/No (default No)
|
|
1584
|
+
- Stride (powers of 2): 64,128,256,512,1024 (default 512)
|
|
1585
|
+
"""
|
|
1586
|
+
def __init__(self, parent=None):
|
|
1587
|
+
super().__init__(parent)
|
|
1588
|
+
self.setWindowTitle("CosmicClarity Dark Star Settings")
|
|
1589
|
+
|
|
1590
|
+
self.chk_disable_gpu = QCheckBox("Disable GPU")
|
|
1591
|
+
self.chk_disable_gpu.setChecked(False) # default No (unchecked)
|
|
1592
|
+
|
|
1593
|
+
self.cmb_mode = QComboBox()
|
|
1594
|
+
self.cmb_mode.addItems(["unscreen", "additive"])
|
|
1595
|
+
self.cmb_mode.setCurrentText("unscreen")
|
|
1596
|
+
|
|
1597
|
+
self.chk_show_stars = QCheckBox("Show Extracted Stars")
|
|
1598
|
+
self.chk_show_stars.setChecked(True)
|
|
1599
|
+
|
|
1600
|
+
self.cmb_stride = QComboBox()
|
|
1601
|
+
for v in (64, 128, 256, 512, 1024):
|
|
1602
|
+
self.cmb_stride.addItem(str(v), v)
|
|
1603
|
+
self.cmb_stride.setCurrentText("512") # default 512
|
|
1604
|
+
|
|
1605
|
+
form = QFormLayout()
|
|
1606
|
+
form.addRow("Star Removal Mode:", self.cmb_mode)
|
|
1607
|
+
form.addRow("Stride (power of two):", self.cmb_stride)
|
|
1608
|
+
form.addRow("", self.chk_disable_gpu)
|
|
1609
|
+
form.addRow("", self.chk_show_stars)
|
|
1610
|
+
|
|
1611
|
+
btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, parent=self)
|
|
1612
|
+
btns.accepted.connect(self.accept)
|
|
1613
|
+
btns.rejected.connect(self.reject)
|
|
1614
|
+
|
|
1615
|
+
layout = QVBoxLayout(self)
|
|
1616
|
+
layout.addLayout(form)
|
|
1617
|
+
layout.addWidget(btns)
|
|
1618
|
+
|
|
1619
|
+
def get_values(self):
|
|
1620
|
+
return {
|
|
1621
|
+
"disable_gpu": self.chk_disable_gpu.isChecked(),
|
|
1622
|
+
"mode": self.cmb_mode.currentText(),
|
|
1623
|
+
"show_extracted_stars": self.chk_show_stars.isChecked(),
|
|
1624
|
+
"stride": int(self.cmb_stride.currentData()),
|
|
1625
|
+
}
|