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,1716 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import cv2
|
|
3
|
+
import numpy as np
|
|
4
|
+
from PyQt6.QtWidgets import (
|
|
5
|
+
QWidget, QVBoxLayout, QLabel, QHBoxLayout, QLineEdit, QPushButton, QFileDialog,
|
|
6
|
+
QListWidget, QSlider, QCheckBox, QMessageBox, QTextEdit, QDialog, QApplication,
|
|
7
|
+
QTreeWidget, QTreeWidgetItem, QGraphicsView, QGraphicsScene, QGraphicsPixmapItem, QGridLayout,
|
|
8
|
+
QToolBar, QSizePolicy, QSpinBox, QDoubleSpinBox, QProgressBar
|
|
9
|
+
)
|
|
10
|
+
from PyQt6.QtGui import QImage, QPixmap, QIcon, QPainter, QAction, QTransform, QCursor
|
|
11
|
+
from PyQt6.QtCore import Qt, pyqtSignal, QRectF, QPointF, QTimer, QThread, QObject
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
import tempfile
|
|
16
|
+
|
|
17
|
+
from astropy.wcs import WCS
|
|
18
|
+
from astropy.time import Time
|
|
19
|
+
from astropy import units as u
|
|
20
|
+
from astropy.io import fits
|
|
21
|
+
from astropy.io.fits import Header
|
|
22
|
+
|
|
23
|
+
from setiastro.saspro.legacy.image_manager import load_image, save_image
|
|
24
|
+
from setiastro.saspro.legacy.numba_utils import bulk_cosmetic_correction_numba
|
|
25
|
+
from setiastro.saspro.imageops.stretch import stretch_mono_image, stretch_color_image
|
|
26
|
+
from setiastro.saspro.star_alignment import PolyGradientRemoval
|
|
27
|
+
from pro import minorbodycatalog as mbc
|
|
28
|
+
from setiastro.saspro.plate_solver import PlateSolverDialog as PlateSolver
|
|
29
|
+
from setiastro.saspro.widgets.themed_buttons import themed_toolbtn
|
|
30
|
+
|
|
31
|
+
from setiastro.saspro.plate_solver import (
|
|
32
|
+
_solve_numpy_with_fallback,
|
|
33
|
+
_as_header,
|
|
34
|
+
_strip_wcs_keys,
|
|
35
|
+
_merge_wcs_into_base_header,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
def _xisf_kw_value(xisf_meta: dict, key: str, default=None):
|
|
39
|
+
"""
|
|
40
|
+
Return the first 'value' for FITSKeywords[key] from a XISF meta dict.
|
|
41
|
+
|
|
42
|
+
xisf_meta: the dict stored in doc.metadata["xisf_meta"]
|
|
43
|
+
"""
|
|
44
|
+
if not xisf_meta:
|
|
45
|
+
return default
|
|
46
|
+
|
|
47
|
+
fk = xisf_meta.get("FITSKeywords", {})
|
|
48
|
+
if key not in fk:
|
|
49
|
+
return default
|
|
50
|
+
|
|
51
|
+
entry = fk[key]
|
|
52
|
+
# In your sample, it's a list of {"value": "...", "comment": "..."}
|
|
53
|
+
if isinstance(entry, list) and entry:
|
|
54
|
+
v = entry[0].get("value", default)
|
|
55
|
+
elif isinstance(entry, dict):
|
|
56
|
+
v = entry.get("value", default)
|
|
57
|
+
else:
|
|
58
|
+
v = entry
|
|
59
|
+
return v
|
|
60
|
+
|
|
61
|
+
def ensure_jd_from_xisf_meta(meta: dict) -> None:
|
|
62
|
+
"""
|
|
63
|
+
If this document came from a XISF and we haven't stored a JD yet,
|
|
64
|
+
derive JD / MJD from XISF FITSKeywords (DATE-OBS + EXPOSURE).
|
|
65
|
+
|
|
66
|
+
Safe no-op if anything is missing.
|
|
67
|
+
"""
|
|
68
|
+
# Already have it? Don't overwrite.
|
|
69
|
+
if "jd" in meta and np.isfinite(meta["jd"]):
|
|
70
|
+
return
|
|
71
|
+
|
|
72
|
+
xisf_meta = meta.get("xisf_meta")
|
|
73
|
+
if not isinstance(xisf_meta, dict):
|
|
74
|
+
return
|
|
75
|
+
|
|
76
|
+
# 1) Get UTC observation timestamp and exposure
|
|
77
|
+
date_obs = _xisf_kw_value(xisf_meta, "DATE-OBS")
|
|
78
|
+
if not date_obs:
|
|
79
|
+
# Optional fallback to local time if you *really* want:
|
|
80
|
+
# date_obs = _xisf_kw_value(xisf_meta, "DATE-LOC")
|
|
81
|
+
return
|
|
82
|
+
|
|
83
|
+
exp_str = (_xisf_kw_value(xisf_meta, "EXPOSURE") or
|
|
84
|
+
_xisf_kw_value(xisf_meta, "EXPTIME"))
|
|
85
|
+
exposure = None
|
|
86
|
+
if exp_str is not None:
|
|
87
|
+
try:
|
|
88
|
+
exposure = float(exp_str)
|
|
89
|
+
except Exception:
|
|
90
|
+
exposure = None
|
|
91
|
+
|
|
92
|
+
# 2) Parse the date string → Time
|
|
93
|
+
# SGP / PI are emitting ISO8601 with fractional seconds: 2024-04-22T06:58:08.4217144
|
|
94
|
+
try:
|
|
95
|
+
t = Time(date_obs, format="isot", scale="utc")
|
|
96
|
+
except Exception:
|
|
97
|
+
# Last-resort: let astropy guess; if that fails, bail out
|
|
98
|
+
try:
|
|
99
|
+
t = Time(date_obs, scale="utc")
|
|
100
|
+
except Exception:
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
# 3) Move to mid-exposure if we know the exposure length
|
|
104
|
+
if exposure and exposure > 0:
|
|
105
|
+
t = t + 0.5 * exposure * u.s
|
|
106
|
+
|
|
107
|
+
# 4) Store JD/MJD for later minor-body prediction
|
|
108
|
+
meta["jd"] = float(t.jd)
|
|
109
|
+
meta["mjd"] = float(t.mjd)
|
|
110
|
+
# Optional: keep a cleaned-up timestamp string too
|
|
111
|
+
meta.setdefault("date_obs", t.isot)
|
|
112
|
+
|
|
113
|
+
def _numpy_to_qimage(img: np.ndarray) -> QImage:
|
|
114
|
+
"""
|
|
115
|
+
Accepts:
|
|
116
|
+
- float32/float64 in [0..1], mono or RGB
|
|
117
|
+
- uint8 mono/RGB
|
|
118
|
+
Returns QImage (RGB888 or Grayscale8).
|
|
119
|
+
"""
|
|
120
|
+
if img is None:
|
|
121
|
+
return QImage()
|
|
122
|
+
|
|
123
|
+
# Normalize dtype
|
|
124
|
+
if img.dtype != np.uint8:
|
|
125
|
+
img = (np.clip(img, 0, 1) * 255.0).astype(np.uint8)
|
|
126
|
+
|
|
127
|
+
if img.ndim == 2:
|
|
128
|
+
h, w = img.shape
|
|
129
|
+
return QImage(img.data, w, h, img.strides[0], QImage.Format.Format_Grayscale8)
|
|
130
|
+
elif img.ndim == 3:
|
|
131
|
+
h, w, c = img.shape
|
|
132
|
+
if c == 3:
|
|
133
|
+
# assume RGB
|
|
134
|
+
return QImage(img.data, w, h, img.strides[0], QImage.Format.Format_RGB888)
|
|
135
|
+
elif c == 4:
|
|
136
|
+
return QImage(img.data, w, h, img.strides[0], QImage.Format.Format_RGBA8888)
|
|
137
|
+
else:
|
|
138
|
+
# collapse/expand as needed
|
|
139
|
+
if c == 1:
|
|
140
|
+
img = np.repeat(img, 3, axis=2)
|
|
141
|
+
h, w, _ = img.shape
|
|
142
|
+
return QImage(img.data, w, h, img.strides[0], QImage.Format.Format_RGB888)
|
|
143
|
+
# fallback empty
|
|
144
|
+
return QImage()
|
|
145
|
+
|
|
146
|
+
class MinorBodyWorker(QObject):
|
|
147
|
+
"""
|
|
148
|
+
Runs the heavy minor-body prediction in a background thread.
|
|
149
|
+
Does NOT touch any widgets directly.
|
|
150
|
+
"""
|
|
151
|
+
finished = pyqtSignal(list, str) # (bodies, error_message or "")
|
|
152
|
+
progress = pyqtSignal(int, str) # (percent, message)
|
|
153
|
+
|
|
154
|
+
def __init__(self, owner, jd_for_calc: float):
|
|
155
|
+
super().__init__()
|
|
156
|
+
self._owner = owner # SupernovaAsteroidHunterDialog
|
|
157
|
+
self._jd = jd_for_calc
|
|
158
|
+
|
|
159
|
+
def run(self):
|
|
160
|
+
try:
|
|
161
|
+
# Kick off with a low percentage
|
|
162
|
+
self.progress.emit(0, self.tr("Minor-body search: preparing catalog query..."))
|
|
163
|
+
bodies = self._owner._get_predicted_minor_bodies_for_field(
|
|
164
|
+
H_ast_max=self._owner.minor_H_ast_max,
|
|
165
|
+
H_com_max=self._owner.minor_H_com_max,
|
|
166
|
+
jd=self._jd,
|
|
167
|
+
progress_cb=self.progress.emit, # pass our signal as callback
|
|
168
|
+
)
|
|
169
|
+
if bodies is None:
|
|
170
|
+
bodies = []
|
|
171
|
+
self.finished.emit(bodies, "")
|
|
172
|
+
except Exception as e:
|
|
173
|
+
self.finished.emit([], str(e))
|
|
174
|
+
|
|
175
|
+
class ZoomableImageView(QGraphicsView):
|
|
176
|
+
zoomChanged = pyqtSignal(float) # emits current scale (1.0 = 100%)
|
|
177
|
+
|
|
178
|
+
def __init__(self, parent=None):
|
|
179
|
+
super().__init__(parent)
|
|
180
|
+
self.setScene(QGraphicsScene(self))
|
|
181
|
+
self._pix = QGraphicsPixmapItem()
|
|
182
|
+
self.scene().addItem(self._pix)
|
|
183
|
+
self.setRenderHints(QPainter.RenderHint.Antialiasing | QPainter.RenderHint.SmoothPixmapTransform)
|
|
184
|
+
self.setDragMode(QGraphicsView.DragMode.NoDrag)
|
|
185
|
+
self.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
|
|
186
|
+
self.setResizeAnchor(QGraphicsView.ViewportAnchor.AnchorViewCenter)
|
|
187
|
+
self._fit_mode = False
|
|
188
|
+
self._scale = 1.0
|
|
189
|
+
|
|
190
|
+
def set_image(self, np_img_rgb_or_gray_uint8_or_float):
|
|
191
|
+
qimg = _numpy_to_qimage(np_img_rgb_or_gray_uint8_or_float)
|
|
192
|
+
pix = QPixmap.fromImage(qimg)
|
|
193
|
+
self._pix.setPixmap(pix)
|
|
194
|
+
self.scene().setSceneRect(QRectF(pix.rect()))
|
|
195
|
+
self.reset_view()
|
|
196
|
+
|
|
197
|
+
def reset_view(self):
|
|
198
|
+
self._fit_mode = False
|
|
199
|
+
self._scale = 1.0
|
|
200
|
+
self.setTransform(QTransform())
|
|
201
|
+
self.centerOn(self._pix)
|
|
202
|
+
self.zoomChanged.emit(self._scale)
|
|
203
|
+
|
|
204
|
+
def fit_to_view(self):
|
|
205
|
+
if self._pix.pixmap().isNull():
|
|
206
|
+
return
|
|
207
|
+
self._fit_mode = True
|
|
208
|
+
self.setTransform(QTransform())
|
|
209
|
+
self.fitInView(self._pix, Qt.AspectRatioMode.KeepAspectRatio)
|
|
210
|
+
# derive scale from transform.m11
|
|
211
|
+
self._scale = self.transform().m11()
|
|
212
|
+
self.zoomChanged.emit(self._scale)
|
|
213
|
+
|
|
214
|
+
def set_1to1(self):
|
|
215
|
+
self._fit_mode = False
|
|
216
|
+
self.setTransform(QTransform())
|
|
217
|
+
self._scale = 1.0
|
|
218
|
+
self.zoomChanged.emit(self._scale)
|
|
219
|
+
|
|
220
|
+
def zoom(self, factor: float, anchor_pos: QPointF | None = None):
|
|
221
|
+
if self._pix.pixmap().isNull():
|
|
222
|
+
return
|
|
223
|
+
self._fit_mode = False
|
|
224
|
+
# clamp
|
|
225
|
+
new_scale = self._scale * factor
|
|
226
|
+
new_scale = max(0.05, min(32.0, new_scale))
|
|
227
|
+
factor = new_scale / self._scale
|
|
228
|
+
if abs(factor - 1.0) < 1e-6:
|
|
229
|
+
return
|
|
230
|
+
|
|
231
|
+
# zoom around cursor
|
|
232
|
+
if anchor_pos is not None:
|
|
233
|
+
self.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
|
|
234
|
+
else:
|
|
235
|
+
self.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorViewCenter)
|
|
236
|
+
|
|
237
|
+
self.scale(factor, factor)
|
|
238
|
+
self._scale = new_scale
|
|
239
|
+
self.zoomChanged.emit(self._scale)
|
|
240
|
+
|
|
241
|
+
# --- input handling ---
|
|
242
|
+
def wheelEvent(self, event):
|
|
243
|
+
if event.modifiers() & Qt.KeyboardModifier.ControlModifier:
|
|
244
|
+
delta = event.angleDelta().y()
|
|
245
|
+
step = 1.25 if delta > 0 else 0.8
|
|
246
|
+
self.zoom(step, anchor_pos=event.position())
|
|
247
|
+
event.accept()
|
|
248
|
+
else:
|
|
249
|
+
super().wheelEvent(event)
|
|
250
|
+
|
|
251
|
+
def mousePressEvent(self, event):
|
|
252
|
+
if event.button() == Qt.MouseButton.LeftButton:
|
|
253
|
+
self.setDragMode(QGraphicsView.DragMode.ScrollHandDrag)
|
|
254
|
+
self.viewport().setCursor(QCursor(Qt.CursorShape.ClosedHandCursor))
|
|
255
|
+
super().mousePressEvent(event)
|
|
256
|
+
|
|
257
|
+
def mouseReleaseEvent(self, event):
|
|
258
|
+
super().mouseReleaseEvent(event)
|
|
259
|
+
self.setDragMode(QGraphicsView.DragMode.NoDrag)
|
|
260
|
+
self.viewport().unsetCursor()
|
|
261
|
+
|
|
262
|
+
def resizeEvent(self, event):
|
|
263
|
+
super().resizeEvent(event)
|
|
264
|
+
if self._fit_mode and not self._pix.pixmap().isNull():
|
|
265
|
+
# keep image fitted when the window is resized
|
|
266
|
+
# (doesn't steal state if user switched to manual zoom)
|
|
267
|
+
self.fit_to_view()
|
|
268
|
+
|
|
269
|
+
class ImagePreviewWindow(QDialog):
|
|
270
|
+
pushed = pyqtSignal(object, str) # (numpy_image, title)
|
|
271
|
+
minorBodySearchRequested = pyqtSignal() # emitted when user clicks MB button
|
|
272
|
+
|
|
273
|
+
def __init__(
|
|
274
|
+
self,
|
|
275
|
+
np_img_rgb_or_gray,
|
|
276
|
+
title="Preview",
|
|
277
|
+
parent=None,
|
|
278
|
+
icon: QIcon | None = None,
|
|
279
|
+
source_path: str | None = None,
|
|
280
|
+
):
|
|
281
|
+
super().__init__(parent)
|
|
282
|
+
self.setWindowTitle(title)
|
|
283
|
+
if icon:
|
|
284
|
+
self.setWindowIcon(icon)
|
|
285
|
+
|
|
286
|
+
# This is the anomaly-marked image we want to push
|
|
287
|
+
self._original = np_img_rgb_or_gray
|
|
288
|
+
# Remember where it came from so we can re-load metadata
|
|
289
|
+
self._source_path = source_path
|
|
290
|
+
|
|
291
|
+
lay = QVBoxLayout(self)
|
|
292
|
+
|
|
293
|
+
# toolbar
|
|
294
|
+
tb = QToolBar(self)
|
|
295
|
+
self.act_fit = QAction(self.tr("Fit"), self)
|
|
296
|
+
self.act_1to1 = QAction(self.tr("1:1"), self)
|
|
297
|
+
self.act_zoom_in = QAction(self.tr("Zoom In"), self)
|
|
298
|
+
self.act_zoom_out = QAction(self.tr("Zoom Out"), self)
|
|
299
|
+
self.act_push = QAction(self.tr("Push to New View"), self)
|
|
300
|
+
# self.act_minor = QAction("Check Catalogued Minor Bodies in Field", self)
|
|
301
|
+
|
|
302
|
+
self.act_zoom_in.setShortcut("Ctrl++")
|
|
303
|
+
self.act_zoom_out.setShortcut("Ctrl+-")
|
|
304
|
+
self.act_fit.setShortcut("F")
|
|
305
|
+
self.act_1to1.setShortcut("1")
|
|
306
|
+
|
|
307
|
+
tb.addAction(self.act_fit)
|
|
308
|
+
tb.addAction(self.act_1to1)
|
|
309
|
+
tb.addSeparator()
|
|
310
|
+
tb.addAction(self.act_zoom_in)
|
|
311
|
+
tb.addAction(self.act_zoom_out)
|
|
312
|
+
tb.addSeparator()
|
|
313
|
+
tb.addAction(self.act_push)
|
|
314
|
+
# tb.addSeparator()
|
|
315
|
+
# tb.addAction(self.act_minor)
|
|
316
|
+
|
|
317
|
+
spacer = QWidget()
|
|
318
|
+
spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
|
319
|
+
tb.addWidget(spacer)
|
|
320
|
+
self._zoom_label = QLabel("100%")
|
|
321
|
+
tb.addWidget(self._zoom_label)
|
|
322
|
+
|
|
323
|
+
lay.addWidget(tb)
|
|
324
|
+
|
|
325
|
+
self.view = ZoomableImageView(self)
|
|
326
|
+
lay.addWidget(self.view)
|
|
327
|
+
self.view.set_image(np_img_rgb_or_gray)
|
|
328
|
+
self.view.zoomChanged.connect(self._on_zoom_changed)
|
|
329
|
+
|
|
330
|
+
self.act_fit.triggered.connect(self.view.fit_to_view)
|
|
331
|
+
self.act_1to1.triggered.connect(self.view.set_1to1)
|
|
332
|
+
self.act_zoom_in.triggered.connect(lambda: self.view.zoom(1.25))
|
|
333
|
+
self.act_zoom_out.triggered.connect(lambda: self.view.zoom(0.8))
|
|
334
|
+
self.act_push.triggered.connect(self._on_push)
|
|
335
|
+
# self.act_minor.triggered.connect(self._on_minor_body_search)
|
|
336
|
+
|
|
337
|
+
self.view.fit_to_view()
|
|
338
|
+
self.resize(900, 700)
|
|
339
|
+
|
|
340
|
+
def _on_zoom_changed(self, s: float):
|
|
341
|
+
self._zoom_label.setText(f"{round(s*100)}%")
|
|
342
|
+
|
|
343
|
+
def _on_push(self):
|
|
344
|
+
# Emit the anomaly-marked image
|
|
345
|
+
self.pushed.emit(self._original, self.windowTitle())
|
|
346
|
+
QMessageBox.information(self, self.tr("Pushed"), self.tr("New View Created."))
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _on_minor_body_search(self):
|
|
350
|
+
# Just emit a signal; the parent dialog will handle the heavy lifting.
|
|
351
|
+
self.minorBodySearchRequested.emit()
|
|
352
|
+
|
|
353
|
+
def showEvent(self, e):
|
|
354
|
+
super().showEvent(e)
|
|
355
|
+
# Defer one tick so the view has its final size
|
|
356
|
+
QTimer.singleShot(0, self.view.fit_to_view)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
class SupernovaAsteroidHunterDialog(QDialog):
|
|
360
|
+
def __init__(self, parent=None, settings=None,
|
|
361
|
+
image_manager=None, doc_manager=None,
|
|
362
|
+
supernova_path=None, wrench_path=None, spinner_path=None):
|
|
363
|
+
super().__init__(parent)
|
|
364
|
+
self.setWindowTitle(self.tr("Supernova / Asteroid Hunter"))
|
|
365
|
+
if supernova_path:
|
|
366
|
+
self.setWindowIcon(QIcon(supernova_path))
|
|
367
|
+
# keep icon path for previews
|
|
368
|
+
self.supernova_path = supernova_path
|
|
369
|
+
|
|
370
|
+
self.settings = settings
|
|
371
|
+
self.image_manager = image_manager
|
|
372
|
+
self.doc_manager = doc_manager
|
|
373
|
+
|
|
374
|
+
# one layout for the dialog
|
|
375
|
+
self.setLayout(QVBoxLayout())
|
|
376
|
+
|
|
377
|
+
# state
|
|
378
|
+
self.parameters = {
|
|
379
|
+
"referenceImagePath": "",
|
|
380
|
+
"searchImagePaths": [],
|
|
381
|
+
"threshold": 0.10
|
|
382
|
+
}
|
|
383
|
+
self.preprocessed_reference = None
|
|
384
|
+
self.preprocessed_search = []
|
|
385
|
+
self.anomalyData = []
|
|
386
|
+
|
|
387
|
+
# WCS / timing / minor bodies
|
|
388
|
+
self.ref_header = None
|
|
389
|
+
self.ref_wcs = None
|
|
390
|
+
self.ref_jd = None
|
|
391
|
+
self.ref_site = None # you can fill this from settings later
|
|
392
|
+
self.predicted_minor_bodies = None
|
|
393
|
+
|
|
394
|
+
# default H limits for minor bodies (you can later expose via UI)
|
|
395
|
+
self.minor_H_ast_max = 20.0
|
|
396
|
+
self.minor_H_com_max = 15.0
|
|
397
|
+
self.minor_ast_max_count = 50000
|
|
398
|
+
self.minor_com_max_count = 5000
|
|
399
|
+
self.minor_time_offset_hours = 0.0
|
|
400
|
+
self.initUI()
|
|
401
|
+
self.resize(900, 700)
|
|
402
|
+
|
|
403
|
+
def initUI(self):
|
|
404
|
+
layout = self.layout()
|
|
405
|
+
|
|
406
|
+
# Instruction Label
|
|
407
|
+
instructions = QLabel(self.tr(
|
|
408
|
+
"Select the reference image and search images. "
|
|
409
|
+
"Then click Process to hunt for anomalies."
|
|
410
|
+
))
|
|
411
|
+
layout.addWidget(instructions)
|
|
412
|
+
|
|
413
|
+
# --- Reference Image Selection ---
|
|
414
|
+
ref_layout = QHBoxLayout()
|
|
415
|
+
self.ref_line_edit = QLineEdit(self)
|
|
416
|
+
self.ref_line_edit.setPlaceholderText(self.tr("No reference image selected"))
|
|
417
|
+
self.ref_button = QPushButton(self.tr("Select Reference Image"), self)
|
|
418
|
+
self.ref_button.clicked.connect(self.selectReferenceImage)
|
|
419
|
+
ref_layout.addWidget(self.ref_line_edit)
|
|
420
|
+
ref_layout.addWidget(self.ref_button)
|
|
421
|
+
layout.addLayout(ref_layout)
|
|
422
|
+
|
|
423
|
+
# --- Search Images Selection ---
|
|
424
|
+
search_layout = QHBoxLayout()
|
|
425
|
+
self.search_list = QListWidget(self)
|
|
426
|
+
self.search_button = QPushButton(self.tr("Select Search Images"), self)
|
|
427
|
+
self.search_button.clicked.connect(self.selectSearchImages)
|
|
428
|
+
search_layout.addWidget(self.search_list)
|
|
429
|
+
search_layout.addWidget(self.search_button)
|
|
430
|
+
layout.addLayout(search_layout)
|
|
431
|
+
|
|
432
|
+
# --- Cosmetic Correction Checkbox ---
|
|
433
|
+
self.cosmetic_checkbox = QCheckBox(
|
|
434
|
+
self.tr("Apply Cosmetic Correction before Preprocessing"), self
|
|
435
|
+
)
|
|
436
|
+
layout.addWidget(self.cosmetic_checkbox)
|
|
437
|
+
|
|
438
|
+
# --- Threshold Slider ---
|
|
439
|
+
thresh_layout = QHBoxLayout()
|
|
440
|
+
self.thresh_label = QLabel(self.tr("Anomaly Detection Threshold: 0.10"), self)
|
|
441
|
+
self.thresh_slider = QSlider(Qt.Orientation.Horizontal, self)
|
|
442
|
+
self.thresh_slider.setMinimum(1)
|
|
443
|
+
self.thresh_slider.setMaximum(50) # Represents 0.01 to 0.50
|
|
444
|
+
self.thresh_slider.setValue(10) # 10 => 0.10 threshold
|
|
445
|
+
self.thresh_slider.valueChanged.connect(self.updateThreshold)
|
|
446
|
+
thresh_layout.addWidget(self.thresh_label)
|
|
447
|
+
thresh_layout.addWidget(self.thresh_slider)
|
|
448
|
+
layout.addLayout(thresh_layout)
|
|
449
|
+
|
|
450
|
+
# --- Process Button ---
|
|
451
|
+
self.process_button = QPushButton(
|
|
452
|
+
self.tr("Process (Cosmetic Correction, Preprocess, and Search)"), self
|
|
453
|
+
)
|
|
454
|
+
self.process_button.clicked.connect(self.process)
|
|
455
|
+
layout.addWidget(self.process_button)
|
|
456
|
+
|
|
457
|
+
# --- Progress Labels ---
|
|
458
|
+
self.preprocess_progress_label = QLabel(self.tr("Preprocessing progress: 0 / 0"), self)
|
|
459
|
+
self.search_progress_label = QLabel(self.tr("Processing progress: 0 / 0"), self)
|
|
460
|
+
layout.addWidget(self.preprocess_progress_label)
|
|
461
|
+
layout.addWidget(self.search_progress_label)
|
|
462
|
+
|
|
463
|
+
# -- Status label --
|
|
464
|
+
self.status_label = QLabel(self.tr("Status: Idle"), self)
|
|
465
|
+
layout.addWidget(self.status_label)
|
|
466
|
+
|
|
467
|
+
# Minor-body progress bar (hidden by default)
|
|
468
|
+
self.minor_progress = QProgressBar(self)
|
|
469
|
+
self.minor_progress.setRange(0, 100)
|
|
470
|
+
self.minor_progress.setValue(0)
|
|
471
|
+
self.minor_progress.setVisible(False)
|
|
472
|
+
layout.addWidget(self.minor_progress)
|
|
473
|
+
|
|
474
|
+
# --- New Instance Button ---
|
|
475
|
+
self.new_instance_button = QPushButton(self.tr("New Instance"), self)
|
|
476
|
+
self.new_instance_button.clicked.connect(self.newInstance)
|
|
477
|
+
layout.addWidget(self.new_instance_button)
|
|
478
|
+
|
|
479
|
+
self.setLayout(layout)
|
|
480
|
+
self.setWindowTitle("Supernova/Asteroid Hunter")
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def updateThreshold(self, value):
|
|
485
|
+
threshold = value / 100.0 # e.g. slider value 10 becomes 0.10
|
|
486
|
+
self.parameters["threshold"] = threshold
|
|
487
|
+
self.thresh_label.setText(self.tr("Anomaly Detection Threshold: {0:.2f}").format(threshold))
|
|
488
|
+
|
|
489
|
+
def selectReferenceImage(self):
|
|
490
|
+
file_path, _ = QFileDialog.getOpenFileName(self, self.tr("Select Reference Image"), "",
|
|
491
|
+
self.tr("Images (*.png *.tif *.tiff *.fits *.fit *.xisf)"))
|
|
492
|
+
if file_path:
|
|
493
|
+
self.parameters["referenceImagePath"] = file_path
|
|
494
|
+
self.ref_line_edit.setText(os.path.basename(file_path))
|
|
495
|
+
|
|
496
|
+
def selectSearchImages(self):
|
|
497
|
+
file_paths, _ = QFileDialog.getOpenFileNames(self, self.tr("Select Search Images"), "",
|
|
498
|
+
self.tr("Images (*.png *.tif *.tiff *.fits *.fit *.xisf)"))
|
|
499
|
+
if file_paths:
|
|
500
|
+
self.parameters["searchImagePaths"] = file_paths
|
|
501
|
+
self.search_list.clear()
|
|
502
|
+
for path in file_paths:
|
|
503
|
+
self.search_list.addItem(os.path.basename(path))
|
|
504
|
+
|
|
505
|
+
def process(self):
|
|
506
|
+
self.status_label.setText(self.tr("Process started..."))
|
|
507
|
+
QApplication.processEvents()
|
|
508
|
+
|
|
509
|
+
# If cosmetic correction is enabled, run it first
|
|
510
|
+
if self.cosmetic_checkbox.isChecked():
|
|
511
|
+
self.status_label.setText(self.tr("Running Cosmetic Correction..."))
|
|
512
|
+
QApplication.processEvents()
|
|
513
|
+
self.runCosmeticCorrectionIfNeeded()
|
|
514
|
+
|
|
515
|
+
self.status_label.setText(self.tr("Preprocessing images..."))
|
|
516
|
+
QApplication.processEvents()
|
|
517
|
+
self.preprocessImages()
|
|
518
|
+
|
|
519
|
+
self.status_label.setText(self.tr("Analyzing anomalies..."))
|
|
520
|
+
QApplication.processEvents()
|
|
521
|
+
self.runSearch()
|
|
522
|
+
|
|
523
|
+
self.status_label.setText(self.tr("Process complete."))
|
|
524
|
+
QApplication.processEvents()
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def runCosmeticCorrectionIfNeeded(self):
|
|
528
|
+
"""
|
|
529
|
+
Runs cosmetic correction on each search image...
|
|
530
|
+
"""
|
|
531
|
+
# Dictionary to hold corrected images
|
|
532
|
+
self.cosmetic_images = {}
|
|
533
|
+
|
|
534
|
+
for idx, image_path in enumerate(self.parameters["searchImagePaths"]):
|
|
535
|
+
try:
|
|
536
|
+
# Update status label to show which image is being handled
|
|
537
|
+
self.status_label.setText(self.tr("Cosmetic Correction: {0}/{1} => {2}").format(
|
|
538
|
+
idx+1, len(self.parameters['searchImagePaths']), os.path.basename(image_path)
|
|
539
|
+
))
|
|
540
|
+
QApplication.processEvents()
|
|
541
|
+
|
|
542
|
+
img, header, bit_depth, is_mono = load_image(image_path)
|
|
543
|
+
if img is None:
|
|
544
|
+
print(f"Unable to load image: {image_path}")
|
|
545
|
+
continue
|
|
546
|
+
|
|
547
|
+
# Numba correction
|
|
548
|
+
corrected = bulk_cosmetic_correction_numba(
|
|
549
|
+
img,
|
|
550
|
+
hot_sigma=5.0,
|
|
551
|
+
cold_sigma=5.0,
|
|
552
|
+
window_size=3
|
|
553
|
+
)
|
|
554
|
+
self.cosmetic_images[image_path] = corrected
|
|
555
|
+
print(f"Cosmetic correction (Numba) applied to: {image_path}")
|
|
556
|
+
|
|
557
|
+
except Exception as e:
|
|
558
|
+
print(f"Error in cosmetic correction for {image_path}: {e}")
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def preprocessImages(self):
|
|
562
|
+
# Update status label for reference image
|
|
563
|
+
self.status_label.setText(self.tr("Preprocessing reference image..."))
|
|
564
|
+
print("[Preprocessing] Preprocessing reference image...")
|
|
565
|
+
QApplication.processEvents()
|
|
566
|
+
|
|
567
|
+
ref_path = self.parameters["referenceImagePath"]
|
|
568
|
+
if not ref_path:
|
|
569
|
+
QMessageBox.warning(self, self.tr("Error"), self.tr("No reference image selected."))
|
|
570
|
+
return
|
|
571
|
+
|
|
572
|
+
try:
|
|
573
|
+
# --- Load reference with metadata so we can grab header / XISF info ---
|
|
574
|
+
ref_res = load_image(ref_path, return_metadata=True)
|
|
575
|
+
if not ref_res or ref_res[0] is None:
|
|
576
|
+
raise ValueError("load_image() returned no data for reference image.")
|
|
577
|
+
|
|
578
|
+
ref_img, header, bit_depth, is_mono, meta = ref_res
|
|
579
|
+
|
|
580
|
+
# Prefer synthesized FITS header from meta if present
|
|
581
|
+
self.ref_header = meta.get("fits_header", header) if isinstance(meta, dict) else header
|
|
582
|
+
|
|
583
|
+
# Try to build WCS directly from header (if it already has one).
|
|
584
|
+
try:
|
|
585
|
+
self.ref_wcs = WCS(self.ref_header)
|
|
586
|
+
except Exception:
|
|
587
|
+
self.ref_wcs = None
|
|
588
|
+
|
|
589
|
+
# --- Derive mid-exposure JD ---
|
|
590
|
+
self.ref_jd = None
|
|
591
|
+
|
|
592
|
+
# 1) XISF-aware path: use FITSKeywords (DATE-OBS + EXPOSURE/EXPTIME)
|
|
593
|
+
if isinstance(meta, dict):
|
|
594
|
+
ensure_jd_from_xisf_meta(meta)
|
|
595
|
+
jd_val = meta.get("jd", None)
|
|
596
|
+
if jd_val is not None:
|
|
597
|
+
self.ref_jd = float(jd_val)
|
|
598
|
+
|
|
599
|
+
# 2) FITS-style fallback from header (for non-XISF, or if XISF path failed)
|
|
600
|
+
if self.ref_jd is None and isinstance(self.ref_header, (dict, Header)):
|
|
601
|
+
try:
|
|
602
|
+
date_obs = self.ref_header.get("DATE-OBS")
|
|
603
|
+
exptime = float(
|
|
604
|
+
self.ref_header.get("EXPTIME", self.ref_header.get("EXPOSURE", 0.0))
|
|
605
|
+
)
|
|
606
|
+
if date_obs:
|
|
607
|
+
t = Time(str(date_obs), scale="utc")
|
|
608
|
+
# mid-exposure
|
|
609
|
+
t_mid = t + (exptime / 2.0) * u.s
|
|
610
|
+
self.ref_jd = float(t_mid.tt.jd)
|
|
611
|
+
except Exception:
|
|
612
|
+
self.ref_jd = None
|
|
613
|
+
|
|
614
|
+
print(f"[Preprocessing] ref JD={self.ref_jd!r}")
|
|
615
|
+
print("[Preprocessing] (Minor-body prediction is now manual only.)")
|
|
616
|
+
|
|
617
|
+
# --- Background neutralization + ABE + stretch for reference ---
|
|
618
|
+
debug_prefix_ref = os.path.splitext(ref_path)[0] + "_debug_ref"
|
|
619
|
+
|
|
620
|
+
self.status_label.setText(
|
|
621
|
+
"Applying background neutralization & ABE on reference..."
|
|
622
|
+
)
|
|
623
|
+
QApplication.processEvents()
|
|
624
|
+
|
|
625
|
+
ref_processed = self.preprocessImage(ref_img, debug_prefix=debug_prefix_ref)
|
|
626
|
+
self.preprocessed_reference = ref_processed
|
|
627
|
+
self.preprocess_progress_label.setText(
|
|
628
|
+
self.tr("Preprocessing reference image... Done.")
|
|
629
|
+
)
|
|
630
|
+
|
|
631
|
+
except Exception as e:
|
|
632
|
+
QMessageBox.critical(self, self.tr("Error"), self.tr("Failed to preprocess reference image: {0}").format(e))
|
|
633
|
+
return
|
|
634
|
+
|
|
635
|
+
# --- Preprocess search images ---
|
|
636
|
+
self.preprocessed_search = []
|
|
637
|
+
search_paths = self.parameters["searchImagePaths"]
|
|
638
|
+
total = len(search_paths)
|
|
639
|
+
|
|
640
|
+
for i, path in enumerate(search_paths):
|
|
641
|
+
try:
|
|
642
|
+
self.status_label.setText(
|
|
643
|
+
self.tr("Preprocessing search image {0}/{1} => {2}").format(
|
|
644
|
+
i+1, total, os.path.basename(path)
|
|
645
|
+
)
|
|
646
|
+
)
|
|
647
|
+
QApplication.processEvents()
|
|
648
|
+
|
|
649
|
+
debug_prefix_search = os.path.splitext(path)[0] + f"_debug_search_{i+1}"
|
|
650
|
+
|
|
651
|
+
if hasattr(self, "cosmetic_images") and path in self.cosmetic_images:
|
|
652
|
+
img = self.cosmetic_images[path]
|
|
653
|
+
else:
|
|
654
|
+
img, header, bit_depth, is_mono = load_image(path)
|
|
655
|
+
|
|
656
|
+
processed = self.preprocessImage(img, debug_prefix=debug_prefix_search)
|
|
657
|
+
self.preprocessed_search.append({"path": path, "image": processed})
|
|
658
|
+
|
|
659
|
+
self.preprocess_progress_label.setText(
|
|
660
|
+
self.tr("Preprocessing image {0} of {1}... Done.").format(i+1, total)
|
|
661
|
+
)
|
|
662
|
+
QApplication.processEvents()
|
|
663
|
+
|
|
664
|
+
except Exception as e:
|
|
665
|
+
print(f"Failed to preprocess {path}: {e}")
|
|
666
|
+
|
|
667
|
+
self.status_label.setText(self.tr("All search images preprocessed."))
|
|
668
|
+
QApplication.processEvents()
|
|
669
|
+
|
|
670
|
+
def _ensure_wcs(self, ref_path: str):
|
|
671
|
+
"""
|
|
672
|
+
Ensure we have a WCS (and, if possible, JD) for the reference frame.
|
|
673
|
+
This does NOT do any minor-body catalog work.
|
|
674
|
+
"""
|
|
675
|
+
# If we already have a WCS and header, don't re-solve.
|
|
676
|
+
if self.ref_wcs is not None and self.ref_header is not None:
|
|
677
|
+
return
|
|
678
|
+
|
|
679
|
+
try:
|
|
680
|
+
image_data, original_header, bit_depth, is_mono = load_image(ref_path)
|
|
681
|
+
except Exception as e:
|
|
682
|
+
print(f"[SupernovaHunter] Failed to load reference image for plate solve: {e}")
|
|
683
|
+
self.ref_wcs = None
|
|
684
|
+
return
|
|
685
|
+
|
|
686
|
+
if image_data is None:
|
|
687
|
+
print("[SupernovaHunter] Reference image is unsupported or unreadable for plate solve.")
|
|
688
|
+
self.ref_wcs = None
|
|
689
|
+
return
|
|
690
|
+
|
|
691
|
+
# Seed header from original_header (dict/Header/etc.)
|
|
692
|
+
seed_h = _as_header(original_header) if isinstance(original_header, (dict, Header)) else None
|
|
693
|
+
|
|
694
|
+
# Acquisition base for merge (strip any existing WCS)
|
|
695
|
+
acq_base: Header | None = None
|
|
696
|
+
if isinstance(seed_h, Header):
|
|
697
|
+
acq_base = _strip_wcs_keys(seed_h)
|
|
698
|
+
|
|
699
|
+
# Run the same solver core used by PlateSolverDialog
|
|
700
|
+
ok, res = _solve_numpy_with_fallback(self, self.settings, image_data, seed_h)
|
|
701
|
+
if not ok:
|
|
702
|
+
print(f"[SupernovaHunter] Plate solve failed for {ref_path}: {res}")
|
|
703
|
+
self.ref_wcs = None
|
|
704
|
+
return
|
|
705
|
+
|
|
706
|
+
solver_hdr: Header = res if isinstance(res, Header) else Header()
|
|
707
|
+
|
|
708
|
+
# Merge solver WCS into acquisition header
|
|
709
|
+
if isinstance(acq_base, Header) and isinstance(solver_hdr, Header):
|
|
710
|
+
hdr_final = _merge_wcs_into_base_header(acq_base, solver_hdr)
|
|
711
|
+
else:
|
|
712
|
+
hdr_final = solver_hdr
|
|
713
|
+
|
|
714
|
+
self.ref_header = hdr_final
|
|
715
|
+
try:
|
|
716
|
+
self.ref_wcs = WCS(hdr_final)
|
|
717
|
+
except Exception as e:
|
|
718
|
+
print("[SupernovaHunter] WCS build failed after plate solve:", e)
|
|
719
|
+
self.ref_wcs = None
|
|
720
|
+
|
|
721
|
+
# If we still lack JD, try to derive it from the header
|
|
722
|
+
if self.ref_jd is None and isinstance(self.ref_header, Header):
|
|
723
|
+
try:
|
|
724
|
+
date_obs = self.ref_header.get("DATE-OBS")
|
|
725
|
+
exptime = float(
|
|
726
|
+
self.ref_header.get("EXPTIME", self.ref_header.get("EXPOSURE", 0.0))
|
|
727
|
+
)
|
|
728
|
+
if date_obs:
|
|
729
|
+
t = Time(str(date_obs), scale="utc")
|
|
730
|
+
t_mid = t + (exptime / 2.0) * u.s
|
|
731
|
+
self.ref_jd = float(t_mid.tt.jd)
|
|
732
|
+
except Exception:
|
|
733
|
+
pass
|
|
734
|
+
|
|
735
|
+
def _prompt_minor_body_limits(self) -> bool:
|
|
736
|
+
"""
|
|
737
|
+
Modal dialog to configure minor-body search limits.
|
|
738
|
+
|
|
739
|
+
Returns True if the user pressed OK (and updates self.* attributes),
|
|
740
|
+
False if they cancelled.
|
|
741
|
+
"""
|
|
742
|
+
dlg = QDialog(self)
|
|
743
|
+
dlg.setWindowTitle(self.tr("Minor-body Search Limits"))
|
|
744
|
+
layout = QVBoxLayout(dlg)
|
|
745
|
+
|
|
746
|
+
row_layout = QGridLayout()
|
|
747
|
+
layout.addLayout(row_layout)
|
|
748
|
+
|
|
749
|
+
# Defaults / existing values
|
|
750
|
+
ast_H_default = getattr(self, "minor_H_ast_max", 9.0)
|
|
751
|
+
com_H_default = getattr(self, "minor_H_com_max", 10.0)
|
|
752
|
+
ast_max_default = getattr(self, "minor_ast_max_count", 5000)
|
|
753
|
+
com_max_default = getattr(self, "minor_com_max_count", 1000)
|
|
754
|
+
|
|
755
|
+
# Time offset in *hours* now; if old days-based attr exists, convert.
|
|
756
|
+
if hasattr(self, "minor_time_offset_hours"):
|
|
757
|
+
dt_default = float(self.minor_time_offset_hours)
|
|
758
|
+
else:
|
|
759
|
+
dt_default = float(getattr(self, "minor_time_offset_days", 0.0)) * 24.0
|
|
760
|
+
|
|
761
|
+
# Row 0: Asteroids
|
|
762
|
+
row_layout.addWidget(QLabel("Asteroid H ≤"), 0, 0)
|
|
763
|
+
ast_H_spin = QDoubleSpinBox(dlg)
|
|
764
|
+
ast_H_spin.setDecimals(1)
|
|
765
|
+
ast_H_spin.setRange(-5.0, 40.0)
|
|
766
|
+
ast_H_spin.setSingleStep(0.1)
|
|
767
|
+
ast_H_spin.setValue(ast_H_default)
|
|
768
|
+
row_layout.addWidget(ast_H_spin, 0, 1)
|
|
769
|
+
|
|
770
|
+
row_layout.addWidget(QLabel("Max asteroid"), 0, 2)
|
|
771
|
+
ast_max_spin = QSpinBox(dlg)
|
|
772
|
+
ast_max_spin.setRange(1, 2000000)
|
|
773
|
+
ast_max_spin.setValue(ast_max_default)
|
|
774
|
+
row_layout.addWidget(ast_max_spin, 0, 3)
|
|
775
|
+
|
|
776
|
+
# Row 1: Comets
|
|
777
|
+
row_layout.addWidget(QLabel("Comet H ≤"), 1, 0)
|
|
778
|
+
com_H_spin = QDoubleSpinBox(dlg)
|
|
779
|
+
com_H_spin.setDecimals(1)
|
|
780
|
+
com_H_spin.setRange(-5.0, 40.0)
|
|
781
|
+
com_H_spin.setSingleStep(0.1)
|
|
782
|
+
com_H_spin.setValue(com_H_default)
|
|
783
|
+
row_layout.addWidget(com_H_spin, 1, 1)
|
|
784
|
+
|
|
785
|
+
row_layout.addWidget(QLabel("Max comet"), 1, 2)
|
|
786
|
+
com_max_spin = QSpinBox(dlg)
|
|
787
|
+
com_max_spin.setRange(1, 200000)
|
|
788
|
+
com_max_spin.setValue(com_max_default)
|
|
789
|
+
row_layout.addWidget(com_max_spin, 1, 3)
|
|
790
|
+
|
|
791
|
+
# Row 2: Time offset (hours)
|
|
792
|
+
row_layout.addWidget(QLabel("Time offset (hours)"), 2, 0)
|
|
793
|
+
dt_spin = QDoubleSpinBox(dlg)
|
|
794
|
+
dt_spin.setDecimals(1)
|
|
795
|
+
dt_spin.setRange(-72.0, 72.0) # ±3 days in hours
|
|
796
|
+
dt_spin.setSingleStep(1.0)
|
|
797
|
+
dt_spin.setValue(dt_default)
|
|
798
|
+
row_layout.addWidget(dt_spin, 2, 1, 1, 3)
|
|
799
|
+
|
|
800
|
+
# Buttons
|
|
801
|
+
btn_row = QHBoxLayout()
|
|
802
|
+
layout.addLayout(btn_row)
|
|
803
|
+
btn_row.addStretch(1)
|
|
804
|
+
ok_btn = QPushButton("OK", dlg)
|
|
805
|
+
cancel_btn = QPushButton("Cancel", dlg)
|
|
806
|
+
btn_row.addWidget(ok_btn)
|
|
807
|
+
btn_row.addWidget(cancel_btn)
|
|
808
|
+
|
|
809
|
+
def on_ok():
|
|
810
|
+
self.minor_H_ast_max = float(ast_H_spin.value())
|
|
811
|
+
self.minor_H_com_max = float(com_H_spin.value())
|
|
812
|
+
self.minor_ast_max_count = int(ast_max_spin.value())
|
|
813
|
+
self.minor_com_max_count = int(com_max_spin.value())
|
|
814
|
+
hours = float(dt_spin.value())
|
|
815
|
+
self.minor_time_offset_hours = hours
|
|
816
|
+
# backward compat if anything still reads the old name:
|
|
817
|
+
self.minor_time_offset_days = hours / 24.0
|
|
818
|
+
dlg.accept()
|
|
819
|
+
|
|
820
|
+
def on_cancel():
|
|
821
|
+
dlg.reject()
|
|
822
|
+
|
|
823
|
+
ok_btn.clicked.connect(on_ok)
|
|
824
|
+
cancel_btn.clicked.connect(on_cancel)
|
|
825
|
+
|
|
826
|
+
return dlg.exec() == QDialog.DialogCode.Accepted
|
|
827
|
+
|
|
828
|
+
def _on_minor_body_progress(self, pct: int, msg: str):
|
|
829
|
+
self.status_label.setText(msg)
|
|
830
|
+
if hasattr(self, "minor_progress"):
|
|
831
|
+
self.minor_progress.setVisible(True)
|
|
832
|
+
self.minor_progress.setValue(int(pct))
|
|
833
|
+
QApplication.processEvents()
|
|
834
|
+
|
|
835
|
+
def _on_minor_body_finished(self, bodies: list, error: str):
|
|
836
|
+
if hasattr(self, "minor_progress"):
|
|
837
|
+
# show as done, then hide
|
|
838
|
+
self.minor_progress.setValue(100 if not error else 0)
|
|
839
|
+
self.minor_progress.setVisible(False)
|
|
840
|
+
if error:
|
|
841
|
+
print("[MinorBodies] prediction failed:", error)
|
|
842
|
+
QMessageBox.critical(
|
|
843
|
+
self,
|
|
844
|
+
self.tr("Minor-body Search"),
|
|
845
|
+
self.tr("Minor-body prediction failed:\n{0}").format(error)
|
|
846
|
+
)
|
|
847
|
+
self.status_label.setText(self.tr("Minor-body search failed."))
|
|
848
|
+
return
|
|
849
|
+
|
|
850
|
+
self.predicted_minor_bodies = bodies or []
|
|
851
|
+
|
|
852
|
+
if not self.predicted_minor_bodies:
|
|
853
|
+
self.status_label.setText(
|
|
854
|
+
self.tr("Minor-body search complete: no catalogued objects in this field "
|
|
855
|
+
"for the current magnitude limits.")
|
|
856
|
+
)
|
|
857
|
+
QMessageBox.information(
|
|
858
|
+
self,
|
|
859
|
+
self.tr("Minor-body Search"),
|
|
860
|
+
self.tr("No catalogued minor bodies (within the configured magnitude limits) "
|
|
861
|
+
"were found in this field.")
|
|
862
|
+
)
|
|
863
|
+
return
|
|
864
|
+
|
|
865
|
+
self.status_label.setText(
|
|
866
|
+
self.tr("Minor-body search complete: {0} objects in field.").format(len(self.predicted_minor_bodies))
|
|
867
|
+
)
|
|
868
|
+
QApplication.processEvents()
|
|
869
|
+
|
|
870
|
+
# Now cross-match on the UI thread if we already have anomalies
|
|
871
|
+
try:
|
|
872
|
+
if self.anomalyData:
|
|
873
|
+
print(f"[MinorBodies] cross-matching anomalies to "
|
|
874
|
+
f"{len(self.predicted_minor_bodies)} predicted bodies...")
|
|
875
|
+
self._match_anomalies_to_minor_bodies(
|
|
876
|
+
self.predicted_minor_bodies,
|
|
877
|
+
search_radius_arcsec=60.0
|
|
878
|
+
)
|
|
879
|
+
self.showDetailedResultsDialog(self.anomalyData)
|
|
880
|
+
else:
|
|
881
|
+
QMessageBox.information(
|
|
882
|
+
self,
|
|
883
|
+
self.tr("Minor-body Search"),
|
|
884
|
+
self.tr("Minor bodies in field have been computed.\n\n"
|
|
885
|
+
"Run the anomaly search (Process) to cross-match detections "
|
|
886
|
+
"against the predicted objects.")
|
|
887
|
+
)
|
|
888
|
+
except Exception as e:
|
|
889
|
+
print("[MinorBodies] cross-match failed:", e)
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
def runMinorBodySearch(self):
|
|
893
|
+
"""
|
|
894
|
+
Optional, slow step:
|
|
895
|
+
- Ensure we have WCS + JD for the reference frame (plate-solve if needed).
|
|
896
|
+
- Ask the user for H limits / max counts.
|
|
897
|
+
- Query the minor-body catalog and compute predicted objects in the FOV.
|
|
898
|
+
- Cross-match with existing anomalies (if any) and refresh the summary dialog.
|
|
899
|
+
"""
|
|
900
|
+
ref_path = self.parameters.get("referenceImagePath") or ""
|
|
901
|
+
if not ref_path:
|
|
902
|
+
QMessageBox.warning(
|
|
903
|
+
self,
|
|
904
|
+
self.tr("Minor-body Search"),
|
|
905
|
+
self.tr("No reference image selected.\n\n"
|
|
906
|
+
"Please select a reference image and run Process first.")
|
|
907
|
+
)
|
|
908
|
+
return
|
|
909
|
+
|
|
910
|
+
if self.preprocessed_reference is None:
|
|
911
|
+
QMessageBox.warning(
|
|
912
|
+
self,
|
|
913
|
+
self.tr("Minor-body Search"),
|
|
914
|
+
self.tr("Reference image has not been preprocessed yet.\n\n"
|
|
915
|
+
"Please click 'Process' before running the minor-body search.")
|
|
916
|
+
)
|
|
917
|
+
return
|
|
918
|
+
|
|
919
|
+
if self.settings is None:
|
|
920
|
+
QMessageBox.warning(
|
|
921
|
+
self,
|
|
922
|
+
self.tr("Minor-body Search"),
|
|
923
|
+
self.tr("Settings object is not available; cannot locate the minor-body database path.")
|
|
924
|
+
)
|
|
925
|
+
return
|
|
926
|
+
|
|
927
|
+
# Configure limits (H, max counts, time offset)
|
|
928
|
+
if not self._prompt_minor_body_limits():
|
|
929
|
+
# user cancelled
|
|
930
|
+
return
|
|
931
|
+
|
|
932
|
+
# Step 1: Ensure WCS (plate-solve if necessary)
|
|
933
|
+
self.status_label.setText("Minor-body search: solving plate / ensuring WCS...")
|
|
934
|
+
QApplication.processEvents()
|
|
935
|
+
|
|
936
|
+
self._ensure_wcs(ref_path)
|
|
937
|
+
|
|
938
|
+
if self.ref_wcs is None:
|
|
939
|
+
QMessageBox.warning(
|
|
940
|
+
self,
|
|
941
|
+
"Minor-body Search",
|
|
942
|
+
"No valid WCS (astrometric solution) is available for the reference image.\n\n"
|
|
943
|
+
"Minor-body prediction requires a solved WCS."
|
|
944
|
+
)
|
|
945
|
+
self.status_label.setText("Minor-body search aborted: no WCS.")
|
|
946
|
+
return
|
|
947
|
+
|
|
948
|
+
# Ensure we have JD (time of observation) for ephemerides
|
|
949
|
+
if self.ref_jd is None:
|
|
950
|
+
QMessageBox.warning(
|
|
951
|
+
self,
|
|
952
|
+
"Minor-body Search",
|
|
953
|
+
"No valid observation time (JD) is available for the reference image.\n\n"
|
|
954
|
+
"Minor-body prediction requires DATE-OBS/EXPTIME or equivalent."
|
|
955
|
+
)
|
|
956
|
+
self.status_label.setText("Minor-body search aborted: no JD.")
|
|
957
|
+
return
|
|
958
|
+
|
|
959
|
+
# Optional observatory site
|
|
960
|
+
try:
|
|
961
|
+
print("[MinorBodies] fetching observatory site from settings...")
|
|
962
|
+
lat = self.settings.value("site/latitude_deg", None, type=float)
|
|
963
|
+
lon = self.settings.value("site/longitude_deg", None, type=float)
|
|
964
|
+
elev = self.settings.value("site/elevation_m", 0.0, type=float)
|
|
965
|
+
if lat is not None and lon is not None:
|
|
966
|
+
self.ref_site = (lat, lon, elev)
|
|
967
|
+
else:
|
|
968
|
+
self.ref_site = None
|
|
969
|
+
except Exception as e:
|
|
970
|
+
print("[MinorBodies] failed to fetch observatory site from settings:", e)
|
|
971
|
+
self.ref_site = None
|
|
972
|
+
|
|
973
|
+
# JD adjusted by time offset (hours → days)
|
|
974
|
+
offset_hours = getattr(self, "minor_time_offset_hours", 0.0)
|
|
975
|
+
jd_for_calc = self.ref_jd + (offset_hours / 24.0)
|
|
976
|
+
|
|
977
|
+
# Kick off the heavy catalog + ephemeris work in a background thread
|
|
978
|
+
self.status_label.setText(
|
|
979
|
+
"Minor-body search: starting background catalog query..."
|
|
980
|
+
)
|
|
981
|
+
QApplication.processEvents()
|
|
982
|
+
if hasattr(self, "minor_progress"):
|
|
983
|
+
self.minor_progress.setVisible(True)
|
|
984
|
+
self.minor_progress.setValue(0)
|
|
985
|
+
|
|
986
|
+
self._mb_thread = QThread(self)
|
|
987
|
+
self._mb_worker = MinorBodyWorker(self, jd_for_calc)
|
|
988
|
+
self._mb_worker.moveToThread(self._mb_thread)
|
|
989
|
+
|
|
990
|
+
# Wire up thread lifecycle
|
|
991
|
+
self._mb_thread.started.connect(self._mb_worker.run)
|
|
992
|
+
self._mb_worker.progress.connect(self._on_minor_body_progress)
|
|
993
|
+
self._mb_worker.finished.connect(self._on_minor_body_finished)
|
|
994
|
+
self._mb_worker.finished.connect(self._mb_thread.quit)
|
|
995
|
+
self._mb_worker.finished.connect(self._mb_worker.deleteLater)
|
|
996
|
+
self._mb_thread.finished.connect(self._mb_thread.deleteLater)
|
|
997
|
+
|
|
998
|
+
self._mb_thread.start()
|
|
999
|
+
|
|
1000
|
+
def _get_predicted_minor_bodies_for_field(
|
|
1001
|
+
self,
|
|
1002
|
+
H_ast_max: float,
|
|
1003
|
+
H_com_max: float,
|
|
1004
|
+
jd: float | None = None,
|
|
1005
|
+
progress_cb=None,
|
|
1006
|
+
):
|
|
1007
|
+
"""
|
|
1008
|
+
Return a list of predicted minor bodies in the current ref image FOV
|
|
1009
|
+
at 'jd' (or self.ref_jd if jd is None), with pixel coords.
|
|
1010
|
+
"""
|
|
1011
|
+
# Need WCS and an image
|
|
1012
|
+
if self.ref_wcs is None or self.preprocessed_reference is None:
|
|
1013
|
+
return []
|
|
1014
|
+
|
|
1015
|
+
def emit(pct, msg):
|
|
1016
|
+
if progress_cb is not None:
|
|
1017
|
+
try:
|
|
1018
|
+
progress_cb(int(pct), msg)
|
|
1019
|
+
except TypeError:
|
|
1020
|
+
# fallback if callback only wants a message
|
|
1021
|
+
progress_cb(msg)
|
|
1022
|
+
|
|
1023
|
+
|
|
1024
|
+
# Resolve JD: explicit first, then self.ref_jd
|
|
1025
|
+
if jd is None:
|
|
1026
|
+
jd = self.ref_jd
|
|
1027
|
+
if jd is None:
|
|
1028
|
+
return []
|
|
1029
|
+
|
|
1030
|
+
if self.settings is None:
|
|
1031
|
+
print("[MinorBodies] settings object is None; cannot resolve DB path.")
|
|
1032
|
+
return []
|
|
1033
|
+
|
|
1034
|
+
# Per-type max counts with safe defaults
|
|
1035
|
+
ast_limit = getattr(self, "minor_ast_max_count", 50000)
|
|
1036
|
+
com_limit = getattr(self, "minor_com_max_count", 5000)
|
|
1037
|
+
|
|
1038
|
+
# 1) open DB (reuse WIMI’s ensure logic)
|
|
1039
|
+
emit(5, "Minor-body search: opening minor-body database...")
|
|
1040
|
+
try:
|
|
1041
|
+
data_dir = Path(
|
|
1042
|
+
self.settings.value("wimi/minorbody_data_dir", "", type=str)
|
|
1043
|
+
or os.path.join(os.path.expanduser("~"), ".saspro_minor_bodies")
|
|
1044
|
+
)
|
|
1045
|
+
db_path, manifest = mbc.ensure_minor_body_db(data_dir)
|
|
1046
|
+
catalog = mbc.MinorBodyCatalog(db_path)
|
|
1047
|
+
except Exception as e:
|
|
1048
|
+
print("[MinorBodies] could not open DB:", e)
|
|
1049
|
+
emit(100, "Minor-body search: failed to open database.")
|
|
1050
|
+
return []
|
|
1051
|
+
|
|
1052
|
+
try:
|
|
1053
|
+
emit(20, "Minor-body search: selecting bright asteroids/comets...")
|
|
1054
|
+
ast_df = catalog.get_bright_asteroids(H_max=H_ast_max, limit=ast_limit)
|
|
1055
|
+
com_df = catalog.get_bright_comets(H_max=H_com_max, limit=com_limit)
|
|
1056
|
+
|
|
1057
|
+
emit(40, "Minor-body search: computing asteroid positions...")
|
|
1058
|
+
ast_pos = catalog.compute_positions_skyfield(
|
|
1059
|
+
ast_df,
|
|
1060
|
+
jd,
|
|
1061
|
+
topocentric=self.ref_site,
|
|
1062
|
+
debug=False,
|
|
1063
|
+
)
|
|
1064
|
+
emit(60, "Minor-body search: computing comet positions...")
|
|
1065
|
+
com_pos = catalog.compute_positions_skyfield(
|
|
1066
|
+
com_df,
|
|
1067
|
+
jd,
|
|
1068
|
+
topocentric=self.ref_site,
|
|
1069
|
+
debug=False,
|
|
1070
|
+
)
|
|
1071
|
+
|
|
1072
|
+
emit(80, "Minor-body search: projecting onto image pixels...")
|
|
1073
|
+
|
|
1074
|
+
# 4) map RA/Dec -> pixel with ref WCS, and drop those outside FOV
|
|
1075
|
+
h, w = self.preprocessed_reference.shape[:2]
|
|
1076
|
+
bodies = []
|
|
1077
|
+
for src, kind, df in (
|
|
1078
|
+
(ast_pos, "asteroid", ast_df),
|
|
1079
|
+
(com_pos, "comet", com_df),
|
|
1080
|
+
):
|
|
1081
|
+
df_by_name = {row["designation"]: row for _, row in df.iterrows()}
|
|
1082
|
+
for row in src:
|
|
1083
|
+
ra = row["ra_deg"]
|
|
1084
|
+
dec = row["dec_deg"]
|
|
1085
|
+
x, y = self.ref_wcs.world_to_pixel_values(ra, dec)
|
|
1086
|
+
if 0 <= x < w and 0 <= y < h:
|
|
1087
|
+
base = df_by_name.get(row["designation"], {})
|
|
1088
|
+
bodies.append({
|
|
1089
|
+
"designation": row["designation"],
|
|
1090
|
+
"kind": kind,
|
|
1091
|
+
"ra_deg": ra,
|
|
1092
|
+
"dec_deg": dec,
|
|
1093
|
+
"x": float(x),
|
|
1094
|
+
"y": float(y),
|
|
1095
|
+
"H": float(base.get("magnitude_H", np.nan)),
|
|
1096
|
+
"distance_au": row.get("distance_au", np.nan),
|
|
1097
|
+
})
|
|
1098
|
+
emit(100, "Minor-body search: finished computing positions.")
|
|
1099
|
+
return bodies
|
|
1100
|
+
finally:
|
|
1101
|
+
try:
|
|
1102
|
+
catalog.close()
|
|
1103
|
+
except Exception:
|
|
1104
|
+
pass
|
|
1105
|
+
|
|
1106
|
+
|
|
1107
|
+
def preprocessImage(self, img, debug_prefix=None):
|
|
1108
|
+
"""
|
|
1109
|
+
Runs the full preprocessing chain on a single image:
|
|
1110
|
+
1. Background Neutralization
|
|
1111
|
+
2. Automatic Background Extraction (ABE)
|
|
1112
|
+
3. Pixel-math stretching
|
|
1113
|
+
|
|
1114
|
+
Optionally saves debug images if debug_prefix is provided.
|
|
1115
|
+
"""
|
|
1116
|
+
|
|
1117
|
+
|
|
1118
|
+
# --- Step 1: Background Neutralization ---
|
|
1119
|
+
if img.ndim == 3 and img.shape[2] == 3:
|
|
1120
|
+
h, w, _ = img.shape
|
|
1121
|
+
sample_x = int(w * 0.45)
|
|
1122
|
+
sample_y = int(h * 0.45)
|
|
1123
|
+
sample_w = max(1, int(w * 0.1))
|
|
1124
|
+
sample_h = max(1, int(h * 0.1))
|
|
1125
|
+
sample_region = img[sample_y:sample_y+sample_h, sample_x:sample_x+sample_w, :]
|
|
1126
|
+
medians = np.median(sample_region, axis=(0, 1))
|
|
1127
|
+
average_median = np.mean(medians)
|
|
1128
|
+
neutralized = img.copy()
|
|
1129
|
+
for c in range(3):
|
|
1130
|
+
diff = medians[c] - average_median
|
|
1131
|
+
numerator = neutralized[:, :, c] - diff
|
|
1132
|
+
denominator = 1.0 - diff
|
|
1133
|
+
if abs(denominator) < 1e-8:
|
|
1134
|
+
denominator = 1e-8
|
|
1135
|
+
neutralized[:, :, c] = np.clip(numerator / denominator, 0, 1)
|
|
1136
|
+
else:
|
|
1137
|
+
neutralized = img
|
|
1138
|
+
|
|
1139
|
+
|
|
1140
|
+
# --- Step 2: Automatic Background Extraction (ABE) ---
|
|
1141
|
+
pgr = PolyGradientRemoval(
|
|
1142
|
+
neutralized,
|
|
1143
|
+
poly_degree=2, # or pass in a user choice
|
|
1144
|
+
downsample_scale=4,
|
|
1145
|
+
num_sample_points=100
|
|
1146
|
+
)
|
|
1147
|
+
abe = pgr.process() # returns final polynomial-corrected image in original domain
|
|
1148
|
+
|
|
1149
|
+
|
|
1150
|
+
# --- Step 3: Pixel Math Stretch ---
|
|
1151
|
+
stretched = self.pixel_math_stretch(abe)
|
|
1152
|
+
|
|
1153
|
+
return stretched
|
|
1154
|
+
|
|
1155
|
+
|
|
1156
|
+
|
|
1157
|
+
def pixel_math_stretch(self, image):
|
|
1158
|
+
"""
|
|
1159
|
+
Replaces the old pixel math stretch logic by using the existing
|
|
1160
|
+
stretch_mono_image or stretch_color_image methods.
|
|
1161
|
+
"""
|
|
1162
|
+
# Choose a target median (the default you’ve used elsewhere is often 0.25)
|
|
1163
|
+
target_median = 0.25
|
|
1164
|
+
|
|
1165
|
+
# Check if the image is mono or color
|
|
1166
|
+
if image.ndim == 2 or (image.ndim == 3 and image.shape[2] == 1):
|
|
1167
|
+
# Treat it as mono
|
|
1168
|
+
stretched = stretch_mono_image(
|
|
1169
|
+
image.squeeze(), # squeeze in case it's (H,W,1)
|
|
1170
|
+
target_median=target_median,
|
|
1171
|
+
normalize=False, # Adjust if you want normalization
|
|
1172
|
+
apply_curves=False,
|
|
1173
|
+
curves_boost=0.0
|
|
1174
|
+
)
|
|
1175
|
+
# If it was (H,W,1), replicate to 3 channels (optional)
|
|
1176
|
+
# or just keep it mono if you prefer
|
|
1177
|
+
# For now, replicate to 3 channels:
|
|
1178
|
+
stretched = np.stack([stretched]*3, axis=-1)
|
|
1179
|
+
else:
|
|
1180
|
+
# Full-color image
|
|
1181
|
+
stretched = stretch_color_image(
|
|
1182
|
+
image,
|
|
1183
|
+
target_median=target_median,
|
|
1184
|
+
linked=False, # or False if you want per-channel stretches
|
|
1185
|
+
normalize=False,
|
|
1186
|
+
apply_curves=False,
|
|
1187
|
+
curves_boost=0.0
|
|
1188
|
+
)
|
|
1189
|
+
|
|
1190
|
+
return np.clip(stretched, 0, 1)
|
|
1191
|
+
|
|
1192
|
+
def runSearch(self):
|
|
1193
|
+
if self.preprocessed_reference is None:
|
|
1194
|
+
QMessageBox.warning(self, "Error", "Reference image not preprocessed.")
|
|
1195
|
+
return
|
|
1196
|
+
if not self.preprocessed_search:
|
|
1197
|
+
QMessageBox.warning(self, "Error", "No search images preprocessed.")
|
|
1198
|
+
return
|
|
1199
|
+
|
|
1200
|
+
ref_gray = self.to_grayscale(self.preprocessed_reference)
|
|
1201
|
+
|
|
1202
|
+
self.anomalyData = []
|
|
1203
|
+
total = len(self.preprocessed_search)
|
|
1204
|
+
for i, search_dict in enumerate(self.preprocessed_search):
|
|
1205
|
+
search_img = search_dict["image"]
|
|
1206
|
+
search_gray = self.to_grayscale(search_img)
|
|
1207
|
+
|
|
1208
|
+
diff_img = self.subtractImagesOnce(search_gray, ref_gray)
|
|
1209
|
+
anomalies = self.detectAnomaliesConnected(
|
|
1210
|
+
diff_img,
|
|
1211
|
+
threshold=self.parameters["threshold"],
|
|
1212
|
+
)
|
|
1213
|
+
|
|
1214
|
+
self.anomalyData.append({
|
|
1215
|
+
"imageName": os.path.basename(search_dict["path"]),
|
|
1216
|
+
"anomalyCount": len(anomalies),
|
|
1217
|
+
"anomalies": anomalies,
|
|
1218
|
+
})
|
|
1219
|
+
|
|
1220
|
+
self.search_progress_label.setText(f"Processing image {i+1} of {total}...")
|
|
1221
|
+
QApplication.processEvents()
|
|
1222
|
+
|
|
1223
|
+
self.search_progress_label.setText("Search for anomalies complete.")
|
|
1224
|
+
|
|
1225
|
+
# Minor-body cross-match (optional)
|
|
1226
|
+
try:
|
|
1227
|
+
bodies = getattr(self, "predicted_minor_bodies", None)
|
|
1228
|
+
if bodies:
|
|
1229
|
+
print(f"[MinorBodies] cross-matching anomalies to {len(bodies)} predicted bodies...")
|
|
1230
|
+
self._match_anomalies_to_minor_bodies(bodies, search_radius_arcsec=60.0)
|
|
1231
|
+
except Exception as e:
|
|
1232
|
+
print("[MinorBodies] cross-match failed:", e)
|
|
1233
|
+
|
|
1234
|
+
# Show text-based summary & tree
|
|
1235
|
+
self.showDetailedResultsDialog(self.anomalyData)
|
|
1236
|
+
self.showAnomalyListDialog()
|
|
1237
|
+
|
|
1238
|
+
def showAnomalyListDialog(self):
|
|
1239
|
+
"""
|
|
1240
|
+
Build a QDialog with a QTreeWidget listing each image and its anomaly count.
|
|
1241
|
+
Double-clicking an item will open a non-modal preview.
|
|
1242
|
+
"""
|
|
1243
|
+
if not self.anomalyData:
|
|
1244
|
+
QMessageBox.information(self, "Info", "No anomalies or no images processed.")
|
|
1245
|
+
return
|
|
1246
|
+
|
|
1247
|
+
dialog = QDialog(self)
|
|
1248
|
+
dialog.setWindowTitle("Anomaly Results")
|
|
1249
|
+
|
|
1250
|
+
layout = QVBoxLayout(dialog)
|
|
1251
|
+
|
|
1252
|
+
self.anomaly_tree = QTreeWidget(dialog)
|
|
1253
|
+
self.anomaly_tree.setColumnCount(2)
|
|
1254
|
+
self.anomaly_tree.setHeaderLabels(["Image", "Anomaly Count"])
|
|
1255
|
+
layout.addWidget(self.anomaly_tree)
|
|
1256
|
+
|
|
1257
|
+
# Populate the tree
|
|
1258
|
+
for i, data in enumerate(self.anomalyData):
|
|
1259
|
+
item = QTreeWidgetItem([
|
|
1260
|
+
data["imageName"],
|
|
1261
|
+
str(data["anomalyCount"])
|
|
1262
|
+
])
|
|
1263
|
+
# Store an index or reference so we know which image to open
|
|
1264
|
+
item.setData(0, Qt.ItemDataRole.UserRole, i)
|
|
1265
|
+
self.anomaly_tree.addTopLevelItem(item)
|
|
1266
|
+
|
|
1267
|
+
# Connect double-click
|
|
1268
|
+
self.anomaly_tree.itemDoubleClicked.connect(self.onAnomalyItemDoubleClicked)
|
|
1269
|
+
|
|
1270
|
+
dialog.setLayout(layout)
|
|
1271
|
+
dialog.resize(300, 200)
|
|
1272
|
+
dialog.show() # non-modal, so the user can keep using the main window
|
|
1273
|
+
|
|
1274
|
+
def onAnomalyItemDoubleClicked(self, item, column):
|
|
1275
|
+
idx = item.data(0, Qt.ItemDataRole.UserRole)
|
|
1276
|
+
if idx is None:
|
|
1277
|
+
return
|
|
1278
|
+
|
|
1279
|
+
anomalies = self.anomalyData[idx]["anomalies"]
|
|
1280
|
+
image_name = self.anomalyData[idx]["imageName"]
|
|
1281
|
+
|
|
1282
|
+
entry = self.preprocessed_search[idx]
|
|
1283
|
+
search_img = entry["image"] # stretched float [0..1]
|
|
1284
|
+
source_path = entry["path"] # original file path
|
|
1285
|
+
|
|
1286
|
+
# Show zoomable preview with overlays, remembering which file it came from
|
|
1287
|
+
self.showAnomaliesOnImage(
|
|
1288
|
+
search_img,
|
|
1289
|
+
anomalies,
|
|
1290
|
+
window_title=f"Anomalies in {image_name}",
|
|
1291
|
+
source_path=source_path,
|
|
1292
|
+
)
|
|
1293
|
+
|
|
1294
|
+
|
|
1295
|
+
def _match_anomalies_to_minor_bodies(self, bodies, search_radius_arcsec=20.0):
|
|
1296
|
+
"""
|
|
1297
|
+
For each anomaly, compute center pixel and find
|
|
1298
|
+
all predicted minor bodies within search_radius_arcsec.
|
|
1299
|
+
|
|
1300
|
+
Adds:
|
|
1301
|
+
- anomaly["matched_bodies"] = [body, ...]
|
|
1302
|
+
- anomaly["matched_body"] = closest body or None
|
|
1303
|
+
"""
|
|
1304
|
+
if self.ref_wcs is None or not bodies:
|
|
1305
|
+
return
|
|
1306
|
+
|
|
1307
|
+
# search radius in pixels — crude average plate scale from WCS
|
|
1308
|
+
try:
|
|
1309
|
+
cd = self.ref_wcs.pixel_scale_matrix # 2x2
|
|
1310
|
+
from numpy.linalg import det
|
|
1311
|
+
deg_per_pix = np.sqrt(abs(det(cd)))
|
|
1312
|
+
arcsec_per_pix = deg_per_pix * 3600.0
|
|
1313
|
+
except Exception:
|
|
1314
|
+
arcsec_per_pix = 1.0 # fallback
|
|
1315
|
+
|
|
1316
|
+
pix_radius = search_radius_arcsec / arcsec_per_pix
|
|
1317
|
+
|
|
1318
|
+
for entry in self.anomalyData:
|
|
1319
|
+
for anomaly in entry["anomalies"]:
|
|
1320
|
+
cx = 0.5 * (anomaly["minX"] + anomaly["maxX"])
|
|
1321
|
+
cy = 0.5 * (anomaly["minY"] + anomaly["maxY"])
|
|
1322
|
+
|
|
1323
|
+
matches = []
|
|
1324
|
+
for body in bodies:
|
|
1325
|
+
dx = body["x"] - cx
|
|
1326
|
+
dy = body["y"] - cy
|
|
1327
|
+
r_pix = np.hypot(dx, dy)
|
|
1328
|
+
if r_pix <= pix_radius:
|
|
1329
|
+
matches.append((r_pix, body))
|
|
1330
|
+
|
|
1331
|
+
if matches:
|
|
1332
|
+
matches.sort(key=lambda t: t[0])
|
|
1333
|
+
anomaly["matched_body"] = matches[0][1]
|
|
1334
|
+
anomaly["matched_bodies"] = [b for _, b in matches]
|
|
1335
|
+
else:
|
|
1336
|
+
anomaly["matched_body"] = None
|
|
1337
|
+
anomaly["matched_bodies"] = []
|
|
1338
|
+
|
|
1339
|
+
|
|
1340
|
+
def draw_bounding_boxes_on_stretched(self,
|
|
1341
|
+
stretched_image: np.ndarray,
|
|
1342
|
+
anomalies: list
|
|
1343
|
+
) -> np.ndarray:
|
|
1344
|
+
"""
|
|
1345
|
+
1) Convert 'stretched_image' [0..1] -> [0..255] 8-bit color
|
|
1346
|
+
2) Draw red rectangles for each anomaly in 'anomalies'.
|
|
1347
|
+
Each anomaly is assumed to have keys: minX, minY, maxX, maxY
|
|
1348
|
+
3) Return the 8-bit color image (H,W,3).
|
|
1349
|
+
"""
|
|
1350
|
+
# Ensure 3 channels
|
|
1351
|
+
if stretched_image.ndim == 2:
|
|
1352
|
+
stretched_3ch = np.stack([stretched_image]*3, axis=-1)
|
|
1353
|
+
elif stretched_image.ndim == 3 and stretched_image.shape[2] == 1:
|
|
1354
|
+
stretched_3ch = np.concatenate([stretched_image]*3, axis=2)
|
|
1355
|
+
else:
|
|
1356
|
+
stretched_3ch = stretched_image
|
|
1357
|
+
|
|
1358
|
+
# Convert float [0..1] => uint8 [0..255]
|
|
1359
|
+
img_bgr = (stretched_3ch * 255).clip(0,255).astype(np.uint8)
|
|
1360
|
+
|
|
1361
|
+
# Define the margin
|
|
1362
|
+
margin = 15
|
|
1363
|
+
|
|
1364
|
+
# Draw red boxes in BGR color = (0, 0, 255)
|
|
1365
|
+
for anomaly in anomalies:
|
|
1366
|
+
x1, y1 = anomaly["minX"], anomaly["minY"]
|
|
1367
|
+
x2, y2 = anomaly["maxX"], anomaly["maxY"]
|
|
1368
|
+
|
|
1369
|
+
# Expand the bounding box by a 10-pixel margin
|
|
1370
|
+
x1_exp = x1 - margin
|
|
1371
|
+
y1_exp = y1 - margin
|
|
1372
|
+
x2_exp = x2 + margin
|
|
1373
|
+
y2_exp = y2 + margin
|
|
1374
|
+
cv2.rectangle(img_bgr, (x1_exp, y1_exp), (x2_exp, y2_exp), color=(0, 0, 255), thickness=5)
|
|
1375
|
+
|
|
1376
|
+
return img_bgr
|
|
1377
|
+
|
|
1378
|
+
|
|
1379
|
+
def subtractImagesOnce(self, search_img, ref_img, debug_prefix=None):
|
|
1380
|
+
result = search_img - ref_img
|
|
1381
|
+
result = np.clip(result, 0, 1) # apply the clip
|
|
1382
|
+
return result
|
|
1383
|
+
|
|
1384
|
+
def debug_save_image(self, image, prefix="debug", step_name="step", ext=".tif"):
|
|
1385
|
+
"""
|
|
1386
|
+
Saves 'image' to disk for debugging.
|
|
1387
|
+
- 'prefix' can be a directory path or prefix for your debug images.
|
|
1388
|
+
- 'step_name' is appended to the filename to indicate which step.
|
|
1389
|
+
- 'ext' could be '.tif', '.png', or another format you support.
|
|
1390
|
+
|
|
1391
|
+
This example uses your 'save_image' function from earlier or can
|
|
1392
|
+
directly use tiff.imwrite or similar.
|
|
1393
|
+
"""
|
|
1394
|
+
|
|
1395
|
+
# Ensure the image is float32 in [0..1] before saving
|
|
1396
|
+
image = image.astype(np.float32, copy=False)
|
|
1397
|
+
|
|
1398
|
+
# Build debug filename
|
|
1399
|
+
filename = f"{prefix}_{step_name}{ext}"
|
|
1400
|
+
|
|
1401
|
+
# E.g., if you have a global 'save_image' function:
|
|
1402
|
+
save_image(
|
|
1403
|
+
image,
|
|
1404
|
+
filename,
|
|
1405
|
+
original_format="tif", # or "png", "fits", etc.
|
|
1406
|
+
bit_depth="16-bit"
|
|
1407
|
+
)
|
|
1408
|
+
print(f"[DEBUG] Saved {step_name} => {filename}")
|
|
1409
|
+
|
|
1410
|
+
def to_grayscale(self, image):
|
|
1411
|
+
"""
|
|
1412
|
+
Converts an image to grayscale by averaging channels if needed.
|
|
1413
|
+
If the image is already 2D, return it as is.
|
|
1414
|
+
"""
|
|
1415
|
+
if image.ndim == 2:
|
|
1416
|
+
# Already grayscale
|
|
1417
|
+
return image
|
|
1418
|
+
elif image.ndim == 3 and image.shape[2] == 3:
|
|
1419
|
+
# Average the three channels
|
|
1420
|
+
return np.mean(image, axis=2)
|
|
1421
|
+
elif image.ndim == 3 and image.shape[2] == 1:
|
|
1422
|
+
# Squeeze out that single channel
|
|
1423
|
+
return image[:, :, 0]
|
|
1424
|
+
else:
|
|
1425
|
+
raise ValueError(f"Unsupported image shape for grayscale: {image.shape}")
|
|
1426
|
+
|
|
1427
|
+
def detectAnomaliesConnected(self, diff_img: np.ndarray, threshold: float = 0.1):
|
|
1428
|
+
"""
|
|
1429
|
+
1) Build mask = diff_img > threshold.
|
|
1430
|
+
2) Optionally skip 5% border by zeroing out that region in the mask.
|
|
1431
|
+
3) connectedComponentsWithStats => bounding boxes.
|
|
1432
|
+
4) Filter by min_area, etc.
|
|
1433
|
+
5) Return a list of anomalies, each with minX, minY, maxX, maxY, area.
|
|
1434
|
+
"""
|
|
1435
|
+
h, w = diff_img.shape
|
|
1436
|
+
|
|
1437
|
+
# 1) Create the mask
|
|
1438
|
+
mask = (diff_img > threshold).astype(np.uint8)
|
|
1439
|
+
|
|
1440
|
+
# 2) Skip 5% border (optional)
|
|
1441
|
+
border_x = int(0.05 * w)
|
|
1442
|
+
border_y = int(0.05 * h)
|
|
1443
|
+
mask[:border_y, :] = 0
|
|
1444
|
+
mask[h - border_y:, :] = 0
|
|
1445
|
+
mask[:, :border_x] = 0
|
|
1446
|
+
mask[:, w - border_x:] = 0
|
|
1447
|
+
|
|
1448
|
+
# 3) connectedComponentsWithStats => label each region
|
|
1449
|
+
# connectivity=8 => 8-way adjacency
|
|
1450
|
+
num_labels, labels, stats, centroids = cv2.connectedComponentsWithStats(mask, connectivity=8)
|
|
1451
|
+
|
|
1452
|
+
# stats[i] = [x, y, width, height, area], for i in [1..num_labels-1]
|
|
1453
|
+
# label_id=0 => background
|
|
1454
|
+
|
|
1455
|
+
anomalies = []
|
|
1456
|
+
for label_id in range(1, num_labels):
|
|
1457
|
+
x, y, width_, height_, area_ = stats[label_id]
|
|
1458
|
+
|
|
1459
|
+
# bounding box corners
|
|
1460
|
+
minX = x
|
|
1461
|
+
minY = y
|
|
1462
|
+
maxX = x + width_ - 1
|
|
1463
|
+
maxY = y + height_ - 1
|
|
1464
|
+
|
|
1465
|
+
# 4) Filter out tiny or huge areas if you want:
|
|
1466
|
+
# e.g., skip anything <4x4 => area<16
|
|
1467
|
+
if area_ < 25:
|
|
1468
|
+
continue
|
|
1469
|
+
# e.g., skip bounding boxes bigger than 40 in either dimension if you want
|
|
1470
|
+
if width_ > 200 or height_ > 200:
|
|
1471
|
+
continue
|
|
1472
|
+
|
|
1473
|
+
anomalies.append({
|
|
1474
|
+
"minX": minX,
|
|
1475
|
+
"minY": minY,
|
|
1476
|
+
"maxX": maxX,
|
|
1477
|
+
"maxY": maxY,
|
|
1478
|
+
"area": area_
|
|
1479
|
+
})
|
|
1480
|
+
|
|
1481
|
+
return anomalies
|
|
1482
|
+
|
|
1483
|
+
|
|
1484
|
+
def showDetailedResultsDialog(self, anomalyData):
|
|
1485
|
+
dialog = QDialog(self)
|
|
1486
|
+
dialog.setWindowTitle("Anomaly Detection Results")
|
|
1487
|
+
layout = QVBoxLayout(dialog)
|
|
1488
|
+
text_edit = QTextEdit(dialog)
|
|
1489
|
+
text_edit.setReadOnly(True)
|
|
1490
|
+
result_text = "Detailed Anomaly Results:\n\n"
|
|
1491
|
+
|
|
1492
|
+
for data in anomalyData:
|
|
1493
|
+
result_text += f"Image: {data['imageName']}\nAnomalies: {data['anomalyCount']}\n"
|
|
1494
|
+
for group in data["anomalies"]:
|
|
1495
|
+
result_text += (
|
|
1496
|
+
f" Group Bounding Box: "
|
|
1497
|
+
f"Top-Left ({group['minX']}, {group['minY']}), "
|
|
1498
|
+
f"Bottom-Right ({group['maxX']}, {group['maxY']})\n"
|
|
1499
|
+
)
|
|
1500
|
+
mbs = group.get("matched_bodies") or []
|
|
1501
|
+
if mbs:
|
|
1502
|
+
result_text += " → Candidate matches:\n"
|
|
1503
|
+
for mb in mbs:
|
|
1504
|
+
H_str = (
|
|
1505
|
+
f"{mb['H']:.1f}"
|
|
1506
|
+
if np.isfinite(mb.get("H", np.nan))
|
|
1507
|
+
else "?"
|
|
1508
|
+
)
|
|
1509
|
+
result_text += (
|
|
1510
|
+
f" - {mb['kind']} {mb['designation']} "
|
|
1511
|
+
f"(H={H_str})\n"
|
|
1512
|
+
)
|
|
1513
|
+
# if no matches, leave as a pure candidate box
|
|
1514
|
+
result_text += "\n"
|
|
1515
|
+
|
|
1516
|
+
text_edit.setText(result_text)
|
|
1517
|
+
layout.addWidget(text_edit)
|
|
1518
|
+
dialog.setLayout(layout)
|
|
1519
|
+
dialog.show()
|
|
1520
|
+
|
|
1521
|
+
|
|
1522
|
+
def showAnomaliesOnImage(
|
|
1523
|
+
self,
|
|
1524
|
+
image: np.ndarray,
|
|
1525
|
+
anomalies: list,
|
|
1526
|
+
window_title: str = "Anomalies",
|
|
1527
|
+
source_path: str | None = None,
|
|
1528
|
+
):
|
|
1529
|
+
"""
|
|
1530
|
+
Shows a zoomable, pannable preview. CTRL+wheel zoom, buttons for fit/1:1.
|
|
1531
|
+
Pushing emits a signal you can wire to your main UI.
|
|
1532
|
+
"""
|
|
1533
|
+
# Ensure 3-ch so we can draw boxes
|
|
1534
|
+
if image.ndim == 2:
|
|
1535
|
+
img3 = np.stack([image]*3, axis=-1)
|
|
1536
|
+
elif image.ndim == 3 and image.shape[2] == 1:
|
|
1537
|
+
img3 = np.concatenate([image]*3, axis=2)
|
|
1538
|
+
else:
|
|
1539
|
+
img3 = image
|
|
1540
|
+
|
|
1541
|
+
# Make a copy in uint8 RGB for overlays
|
|
1542
|
+
if img3.dtype != np.uint8:
|
|
1543
|
+
img_u8 = (np.clip(img3, 0, 1) * 255).astype(np.uint8)
|
|
1544
|
+
else:
|
|
1545
|
+
img_u8 = img3.copy()
|
|
1546
|
+
|
|
1547
|
+
margin = 10
|
|
1548
|
+
h, w = img_u8.shape[:2]
|
|
1549
|
+
for a in anomalies:
|
|
1550
|
+
x1, y1, x2, y2 = a["minX"], a["minY"], a["maxX"], a["maxY"]
|
|
1551
|
+
x1 = max(0, x1 - margin); y1 = max(0, y1 - margin)
|
|
1552
|
+
x2 = min(w - 1, x2 + margin); y2 = min(h - 1, y2 + margin)
|
|
1553
|
+
|
|
1554
|
+
mbs = a.get("matched_bodies") or []
|
|
1555
|
+
if mbs:
|
|
1556
|
+
# anomalies with known bodies -> green box
|
|
1557
|
+
color = (0, 255, 0)
|
|
1558
|
+
else:
|
|
1559
|
+
# pure candidates -> red box
|
|
1560
|
+
color = (255, 0, 0)
|
|
1561
|
+
|
|
1562
|
+
cv2.rectangle(img_u8, (x1, y1), (x2, y2), color=color, thickness=5)
|
|
1563
|
+
|
|
1564
|
+
# NEW: overlay all predicted minor bodies as circles
|
|
1565
|
+
bodies = getattr(self, "predicted_minor_bodies", None)
|
|
1566
|
+
if bodies:
|
|
1567
|
+
for body in bodies:
|
|
1568
|
+
x = int(round(body["x"]))
|
|
1569
|
+
y = int(round(body["y"]))
|
|
1570
|
+
if 0 <= x < w and 0 <= y < h:
|
|
1571
|
+
# yellow circle so it stands out from red/green boxes
|
|
1572
|
+
cv2.circle(img_u8, (x, y), 8, (255, 255, 0), thickness=2)
|
|
1573
|
+
|
|
1574
|
+
|
|
1575
|
+
# Launch preview window
|
|
1576
|
+
icon = None
|
|
1577
|
+
try:
|
|
1578
|
+
if hasattr(self, "supernova_path") and self.supernova_path:
|
|
1579
|
+
icon = QIcon(self.supernova_path)
|
|
1580
|
+
except Exception:
|
|
1581
|
+
pass
|
|
1582
|
+
|
|
1583
|
+
prev = ImagePreviewWindow(
|
|
1584
|
+
img_u8, # anomaly-marked display image
|
|
1585
|
+
title=window_title,
|
|
1586
|
+
parent=self,
|
|
1587
|
+
icon=icon,
|
|
1588
|
+
source_path=source_path, # original file path
|
|
1589
|
+
)
|
|
1590
|
+
prev.pushed.connect(self._handle_preview_push)
|
|
1591
|
+
prev.minorBodySearchRequested.connect(self._on_preview_minor_body_search)
|
|
1592
|
+
prev.show() # non-modal
|
|
1593
|
+
|
|
1594
|
+
def _on_preview_minor_body_search(self):
|
|
1595
|
+
"""
|
|
1596
|
+
Called when the user clicks 'Check Catalogued Minor Bodies in Field'
|
|
1597
|
+
on any anomaly preview window.
|
|
1598
|
+
"""
|
|
1599
|
+
self.runMinorBodySearch()
|
|
1600
|
+
|
|
1601
|
+
|
|
1602
|
+
def _handle_preview_push(self, np_img, title: str):
|
|
1603
|
+
"""
|
|
1604
|
+
Take the anomaly preview (np_img) and push it into SASpro as a *new*
|
|
1605
|
+
document by reusing *all* metadata/header information returned by
|
|
1606
|
+
load_image() for the source file, and only swapping the image array.
|
|
1607
|
+
"""
|
|
1608
|
+
if not self.doc_manager:
|
|
1609
|
+
QMessageBox.warning(
|
|
1610
|
+
self,
|
|
1611
|
+
"No DocManager",
|
|
1612
|
+
"No document manager is available to push the preview."
|
|
1613
|
+
)
|
|
1614
|
+
return
|
|
1615
|
+
|
|
1616
|
+
# Which preview window emitted the signal? Grab its source_path.
|
|
1617
|
+
src_path = None
|
|
1618
|
+
sender = self.sender()
|
|
1619
|
+
if isinstance(sender, ImagePreviewWindow):
|
|
1620
|
+
src_path = getattr(sender, "_source_path", None)
|
|
1621
|
+
|
|
1622
|
+
if not src_path:
|
|
1623
|
+
QMessageBox.warning(
|
|
1624
|
+
self,
|
|
1625
|
+
"No Source File",
|
|
1626
|
+
"Could not determine the original file for this preview.\n"
|
|
1627
|
+
"Push to New View requires the original image path."
|
|
1628
|
+
)
|
|
1629
|
+
return
|
|
1630
|
+
|
|
1631
|
+
# Re-load the ORIGINAL file so we get the full tuple:
|
|
1632
|
+
# image, original_header, bit_depth, is_mono, meta
|
|
1633
|
+
try:
|
|
1634
|
+
res = load_image(src_path, return_metadata=True)
|
|
1635
|
+
except Exception as e:
|
|
1636
|
+
QMessageBox.critical(
|
|
1637
|
+
self,
|
|
1638
|
+
"Load Error",
|
|
1639
|
+
f"Failed to load original image:\n{e}"
|
|
1640
|
+
)
|
|
1641
|
+
return
|
|
1642
|
+
|
|
1643
|
+
if not res or res[0] is None:
|
|
1644
|
+
QMessageBox.critical(
|
|
1645
|
+
self,
|
|
1646
|
+
"Load Error",
|
|
1647
|
+
"Could not read original image data from disk."
|
|
1648
|
+
)
|
|
1649
|
+
return
|
|
1650
|
+
|
|
1651
|
+
orig_img, original_header, bit_depth, is_mono, meta = res
|
|
1652
|
+
|
|
1653
|
+
# Ensure meta is a dict we can stuff things into
|
|
1654
|
+
if not isinstance(meta, dict):
|
|
1655
|
+
meta = {}
|
|
1656
|
+
|
|
1657
|
+
# Keep ALL of the original pieces:
|
|
1658
|
+
# - store the original header explicitly if not already present
|
|
1659
|
+
meta.setdefault("fits_header", original_header)
|
|
1660
|
+
meta.setdefault("original_header", original_header)
|
|
1661
|
+
meta.setdefault("bit_depth", bit_depth)
|
|
1662
|
+
meta.setdefault("is_mono", is_mono)
|
|
1663
|
+
meta.setdefault("source_path", src_path)
|
|
1664
|
+
|
|
1665
|
+
# Give the new doc a nice display name
|
|
1666
|
+
meta["display_name"] = title
|
|
1667
|
+
|
|
1668
|
+
# Our preview image (with boxes). Normalize to float32 [0,1].
|
|
1669
|
+
img = np.asarray(np_img, copy=False)
|
|
1670
|
+
if img.dtype != np.float32:
|
|
1671
|
+
img = img.astype(np.float32, copy=False)
|
|
1672
|
+
|
|
1673
|
+
# If it looks like 0–255 data, rescale to 0–1
|
|
1674
|
+
if img.max() > 1.01 or img.min() < -0.01:
|
|
1675
|
+
img = np.clip(img, 0, 255) / 255.0
|
|
1676
|
+
|
|
1677
|
+
# Finally: create the new document using the preview pixels
|
|
1678
|
+
# but with *all* original metadata/header intact.
|
|
1679
|
+
self.doc_manager.create_document(
|
|
1680
|
+
image=img,
|
|
1681
|
+
metadata=meta,
|
|
1682
|
+
name=title,
|
|
1683
|
+
)
|
|
1684
|
+
|
|
1685
|
+
|
|
1686
|
+
def newInstance(self):
|
|
1687
|
+
# Reset parameters and UI elements for a new run
|
|
1688
|
+
self.parameters = {
|
|
1689
|
+
"referenceImagePath": "",
|
|
1690
|
+
"searchImagePaths": [],
|
|
1691
|
+
"threshold": 0.10
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
self.ref_line_edit.clear()
|
|
1695
|
+
self.search_list.clear()
|
|
1696
|
+
self.cosmetic_checkbox.setChecked(False)
|
|
1697
|
+
self.thresh_slider.setValue(10)
|
|
1698
|
+
|
|
1699
|
+
self.preprocess_progress_label.setText("Preprocessing progress: 0 / 0")
|
|
1700
|
+
self.search_progress_label.setText("Processing progress: 0 / 0")
|
|
1701
|
+
self.status_label.setText("Status: Idle")
|
|
1702
|
+
|
|
1703
|
+
# Image + results state
|
|
1704
|
+
self.preprocessed_reference = None
|
|
1705
|
+
self.preprocessed_search = []
|
|
1706
|
+
self.anomalyData = []
|
|
1707
|
+
|
|
1708
|
+
# WCS / timing / minor-body state
|
|
1709
|
+
self.ref_header = None
|
|
1710
|
+
self.ref_wcs = None
|
|
1711
|
+
self.ref_jd = None
|
|
1712
|
+
self.ref_site = None
|
|
1713
|
+
self.predicted_minor_bodies = None
|
|
1714
|
+
|
|
1715
|
+
QMessageBox.information(self, "New Instance", "Reset for a new instance.")
|
|
1716
|
+
|