setiastrosuitepro 1.6.7__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of setiastrosuitepro might be problematic. Click here for more details.
- setiastro/__init__.py +2 -0
- setiastro/data/SASP_data.fits +0 -0
- setiastro/data/catalogs/List_of_Galaxies_with_Distances_Gly.csv +488 -0
- setiastro/data/catalogs/astrobin_filters.csv +890 -0
- setiastro/data/catalogs/astrobin_filters_page1_local.csv +51 -0
- setiastro/data/catalogs/cali2.csv +63 -0
- setiastro/data/catalogs/cali2color.csv +65 -0
- setiastro/data/catalogs/celestial_catalog - original.csv +16471 -0
- setiastro/data/catalogs/celestial_catalog.csv +24031 -0
- setiastro/data/catalogs/detected_stars.csv +24784 -0
- setiastro/data/catalogs/fits_header_data.csv +46 -0
- setiastro/data/catalogs/test.csv +8 -0
- setiastro/data/catalogs/updated_celestial_catalog.csv +16471 -0
- setiastro/images/Astro_Spikes.png +0 -0
- setiastro/images/Background_startup.jpg +0 -0
- setiastro/images/HRDiagram.png +0 -0
- setiastro/images/LExtract.png +0 -0
- setiastro/images/LInsert.png +0 -0
- setiastro/images/Oxygenation-atm-2.svg.png +0 -0
- setiastro/images/RGB080604.png +0 -0
- setiastro/images/abeicon.png +0 -0
- setiastro/images/aberration.png +0 -0
- setiastro/images/acv_icon.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/first_quarter.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/full_moon.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/last_quarter.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/new_moon.png +0 -0
- setiastro/images/nuke.png +0 -0
- setiastro/images/openfile.png +0 -0
- setiastro/images/pedestal.png +0 -0
- setiastro/images/pen.png +0 -0
- setiastro/images/pixelmath.png +0 -0
- setiastro/images/platesolve.png +0 -0
- setiastro/images/ppp.png +0 -0
- setiastro/images/pro.png +0 -0
- setiastro/images/project.png +0 -0
- setiastro/images/psf.png +0 -0
- setiastro/images/redo.png +0 -0
- setiastro/images/redoicon.png +0 -0
- setiastro/images/rescale.png +0 -0
- setiastro/images/rgbalign.png +0 -0
- setiastro/images/rgbcombo.png +0 -0
- setiastro/images/rgbextract.png +0 -0
- setiastro/images/rotate180.png +0 -0
- setiastro/images/rotatearbitrary.png +0 -0
- setiastro/images/rotateclockwise.png +0 -0
- setiastro/images/rotatecounterclockwise.png +0 -0
- setiastro/images/satellite.png +0 -0
- setiastro/images/script.png +0 -0
- setiastro/images/selectivecolor.png +0 -0
- setiastro/images/simbad.png +0 -0
- setiastro/images/slot0.png +0 -0
- setiastro/images/slot1.png +0 -0
- setiastro/images/slot2.png +0 -0
- setiastro/images/slot3.png +0 -0
- setiastro/images/slot4.png +0 -0
- setiastro/images/slot5.png +0 -0
- setiastro/images/slot6.png +0 -0
- setiastro/images/slot7.png +0 -0
- setiastro/images/slot8.png +0 -0
- setiastro/images/slot9.png +0 -0
- setiastro/images/spcc.png +0 -0
- setiastro/images/spin_precession_vs_lunar_distance.png +0 -0
- setiastro/images/spinner.gif +0 -0
- setiastro/images/stacking.png +0 -0
- setiastro/images/staradd.png +0 -0
- setiastro/images/staralign.png +0 -0
- setiastro/images/starnet.png +0 -0
- setiastro/images/starregistration.png +0 -0
- setiastro/images/starspike.png +0 -0
- setiastro/images/starstretch.png +0 -0
- setiastro/images/statstretch.png +0 -0
- setiastro/images/supernova.png +0 -0
- setiastro/images/uhs.png +0 -0
- setiastro/images/undoicon.png +0 -0
- setiastro/images/upscale.png +0 -0
- setiastro/images/viewbundle.png +0 -0
- setiastro/images/waning_crescent_1.png +0 -0
- setiastro/images/waning_crescent_2.png +0 -0
- setiastro/images/waning_crescent_3.png +0 -0
- setiastro/images/waning_crescent_4.png +0 -0
- setiastro/images/waning_crescent_5.png +0 -0
- setiastro/images/waning_gibbous_1.png +0 -0
- setiastro/images/waning_gibbous_2.png +0 -0
- setiastro/images/waning_gibbous_3.png +0 -0
- setiastro/images/waning_gibbous_4.png +0 -0
- setiastro/images/waning_gibbous_5.png +0 -0
- setiastro/images/waxing_crescent_1.png +0 -0
- setiastro/images/waxing_crescent_2.png +0 -0
- setiastro/images/waxing_crescent_3.png +0 -0
- setiastro/images/waxing_crescent_4.png +0 -0
- setiastro/images/waxing_crescent_5.png +0 -0
- setiastro/images/waxing_gibbous_1.png +0 -0
- setiastro/images/waxing_gibbous_2.png +0 -0
- setiastro/images/waxing_gibbous_3.png +0 -0
- setiastro/images/waxing_gibbous_4.png +0 -0
- setiastro/images/waxing_gibbous_5.png +0 -0
- setiastro/images/whitebalance.png +0 -0
- setiastro/images/wimi_icon_256x256.png +0 -0
- setiastro/images/wimilogo.png +0 -0
- setiastro/images/wims.png +0 -0
- setiastro/images/wrench_icon.png +0 -0
- setiastro/images/xisfliberator.png +0 -0
- setiastro/qml/ResourceMonitor.qml +128 -0
- setiastro/saspro/__init__.py +20 -0
- setiastro/saspro/__main__.py +964 -0
- setiastro/saspro/_generated/__init__.py +7 -0
- setiastro/saspro/_generated/build_info.py +3 -0
- setiastro/saspro/abe.py +1379 -0
- setiastro/saspro/abe_preset.py +196 -0
- setiastro/saspro/aberration_ai.py +910 -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/acv_exporter.py +379 -0
- setiastro/saspro/add_stars.py +627 -0
- setiastro/saspro/astrobin_exporter.py +1010 -0
- setiastro/saspro/astrospike.py +153 -0
- setiastro/saspro/astrospike_python.py +1841 -0
- setiastro/saspro/autostretch.py +198 -0
- setiastro/saspro/backgroundneutral.py +639 -0
- setiastro/saspro/batch_convert.py +328 -0
- setiastro/saspro/batch_renamer.py +522 -0
- setiastro/saspro/blemish_blaster.py +494 -0
- setiastro/saspro/blink_comparator_pro.py +3149 -0
- setiastro/saspro/bundles.py +61 -0
- setiastro/saspro/bundles_dock.py +114 -0
- setiastro/saspro/cheat_sheet.py +213 -0
- setiastro/saspro/clahe.py +371 -0
- setiastro/saspro/comet_stacking.py +1442 -0
- setiastro/saspro/common_tr.py +107 -0
- setiastro/saspro/config.py +38 -0
- setiastro/saspro/config_bootstrap.py +40 -0
- setiastro/saspro/config_manager.py +316 -0
- setiastro/saspro/continuum_subtract.py +1620 -0
- setiastro/saspro/convo.py +1403 -0
- setiastro/saspro/convo_preset.py +414 -0
- setiastro/saspro/copyastro.py +190 -0
- setiastro/saspro/cosmicclarity.py +1593 -0
- setiastro/saspro/cosmicclarity_preset.py +407 -0
- setiastro/saspro/crop_dialog_pro.py +1005 -0
- setiastro/saspro/crop_preset.py +189 -0
- setiastro/saspro/curve_editor_pro.py +2608 -0
- setiastro/saspro/curves_preset.py +375 -0
- setiastro/saspro/debayer.py +673 -0
- setiastro/saspro/debug_utils.py +29 -0
- setiastro/saspro/dnd_mime.py +35 -0
- setiastro/saspro/doc_manager.py +2727 -0
- setiastro/saspro/exoplanet_detector.py +2258 -0
- setiastro/saspro/file_utils.py +284 -0
- setiastro/saspro/fitsmodifier.py +748 -0
- setiastro/saspro/fix_bom.py +32 -0
- setiastro/saspro/free_torch_memory.py +48 -0
- setiastro/saspro/frequency_separation.py +1352 -0
- setiastro/saspro/function_bundle.py +1596 -0
- setiastro/saspro/generate_translations.py +3092 -0
- setiastro/saspro/ghs_dialog_pro.py +728 -0
- setiastro/saspro/ghs_preset.py +284 -0
- setiastro/saspro/graxpert.py +638 -0
- setiastro/saspro/graxpert_preset.py +287 -0
- setiastro/saspro/gui/__init__.py +0 -0
- setiastro/saspro/gui/main_window.py +8928 -0
- setiastro/saspro/gui/mixins/__init__.py +33 -0
- setiastro/saspro/gui/mixins/dock_mixin.py +375 -0
- setiastro/saspro/gui/mixins/file_mixin.py +450 -0
- setiastro/saspro/gui/mixins/geometry_mixin.py +503 -0
- setiastro/saspro/gui/mixins/header_mixin.py +441 -0
- setiastro/saspro/gui/mixins/mask_mixin.py +421 -0
- setiastro/saspro/gui/mixins/menu_mixin.py +391 -0
- setiastro/saspro/gui/mixins/theme_mixin.py +367 -0
- setiastro/saspro/gui/mixins/toolbar_mixin.py +1824 -0
- setiastro/saspro/gui/mixins/update_mixin.py +323 -0
- setiastro/saspro/gui/mixins/view_mixin.py +477 -0
- setiastro/saspro/gui/statistics_dialog.py +47 -0
- setiastro/saspro/halobgon.py +492 -0
- setiastro/saspro/header_viewer.py +448 -0
- setiastro/saspro/headless_utils.py +88 -0
- setiastro/saspro/histogram.py +760 -0
- setiastro/saspro/history_explorer.py +941 -0
- setiastro/saspro/i18n.py +168 -0
- setiastro/saspro/image_combine.py +421 -0
- setiastro/saspro/image_peeker_pro.py +1608 -0
- setiastro/saspro/imageops/__init__.py +37 -0
- setiastro/saspro/imageops/mdi_snap.py +292 -0
- setiastro/saspro/imageops/scnr.py +36 -0
- setiastro/saspro/imageops/starbasedwhitebalance.py +210 -0
- setiastro/saspro/imageops/stretch.py +236 -0
- setiastro/saspro/isophote.py +1186 -0
- setiastro/saspro/layers.py +208 -0
- setiastro/saspro/layers_dock.py +714 -0
- setiastro/saspro/lazy_imports.py +193 -0
- setiastro/saspro/legacy/__init__.py +2 -0
- setiastro/saspro/legacy/image_manager.py +2360 -0
- setiastro/saspro/legacy/numba_utils.py +3676 -0
- setiastro/saspro/legacy/xisf.py +1213 -0
- setiastro/saspro/linear_fit.py +537 -0
- setiastro/saspro/live_stacking.py +1854 -0
- setiastro/saspro/log_bus.py +5 -0
- setiastro/saspro/logging_config.py +460 -0
- setiastro/saspro/luminancerecombine.py +510 -0
- setiastro/saspro/main_helpers.py +201 -0
- setiastro/saspro/mask_creation.py +1090 -0
- setiastro/saspro/masks_core.py +56 -0
- setiastro/saspro/mdi_widgets.py +353 -0
- setiastro/saspro/memory_utils.py +666 -0
- setiastro/saspro/metadata_patcher.py +75 -0
- setiastro/saspro/mfdeconv.py +3909 -0
- setiastro/saspro/mfdeconv_earlystop.py +71 -0
- setiastro/saspro/mfdeconvcudnn.py +3312 -0
- setiastro/saspro/mfdeconvsport.py +2459 -0
- setiastro/saspro/minorbodycatalog.py +567 -0
- setiastro/saspro/morphology.py +411 -0
- setiastro/saspro/multiscale_decomp.py +1751 -0
- setiastro/saspro/nbtorgb_stars.py +541 -0
- setiastro/saspro/numba_utils.py +3145 -0
- setiastro/saspro/numba_warmup.py +141 -0
- setiastro/saspro/ops/__init__.py +9 -0
- setiastro/saspro/ops/command_help_dialog.py +623 -0
- setiastro/saspro/ops/command_runner.py +217 -0
- setiastro/saspro/ops/commands.py +1594 -0
- setiastro/saspro/ops/script_editor.py +1105 -0
- setiastro/saspro/ops/scripts.py +1476 -0
- setiastro/saspro/ops/settings.py +637 -0
- setiastro/saspro/parallel_utils.py +554 -0
- setiastro/saspro/pedestal.py +121 -0
- setiastro/saspro/perfect_palette_picker.py +1105 -0
- setiastro/saspro/pipeline.py +110 -0
- setiastro/saspro/pixelmath.py +1604 -0
- setiastro/saspro/plate_solver.py +2480 -0
- setiastro/saspro/project_io.py +797 -0
- setiastro/saspro/psf_utils.py +136 -0
- setiastro/saspro/psf_viewer.py +631 -0
- setiastro/saspro/pyi_rthook_astroquery.py +95 -0
- setiastro/saspro/remove_green.py +331 -0
- setiastro/saspro/remove_stars.py +1599 -0
- setiastro/saspro/remove_stars_preset.py +446 -0
- setiastro/saspro/resources.py +570 -0
- setiastro/saspro/rgb_combination.py +208 -0
- setiastro/saspro/rgb_extract.py +19 -0
- setiastro/saspro/rgbalign.py +727 -0
- setiastro/saspro/runtime_imports.py +7 -0
- setiastro/saspro/runtime_torch.py +754 -0
- setiastro/saspro/save_options.py +73 -0
- setiastro/saspro/selective_color.py +1614 -0
- setiastro/saspro/sfcc.py +1530 -0
- setiastro/saspro/shortcuts.py +3125 -0
- setiastro/saspro/signature_insert.py +1106 -0
- setiastro/saspro/stacking_suite.py +19069 -0
- setiastro/saspro/star_alignment.py +7383 -0
- setiastro/saspro/star_alignment_preset.py +329 -0
- setiastro/saspro/star_metrics.py +49 -0
- setiastro/saspro/star_spikes.py +769 -0
- setiastro/saspro/star_stretch.py +542 -0
- setiastro/saspro/stat_stretch.py +554 -0
- setiastro/saspro/status_log_dock.py +78 -0
- setiastro/saspro/subwindow.py +3523 -0
- setiastro/saspro/supernovaasteroidhunter.py +1719 -0
- setiastro/saspro/swap_manager.py +134 -0
- setiastro/saspro/torch_backend.py +89 -0
- setiastro/saspro/torch_rejection.py +434 -0
- setiastro/saspro/translations/all_source_strings.json +4726 -0
- setiastro/saspro/translations/ar_translations.py +4096 -0
- setiastro/saspro/translations/de_translations.py +3728 -0
- setiastro/saspro/translations/es_translations.py +4169 -0
- setiastro/saspro/translations/fr_translations.py +4090 -0
- setiastro/saspro/translations/hi_translations.py +3803 -0
- setiastro/saspro/translations/integrate_translations.py +271 -0
- setiastro/saspro/translations/it_translations.py +4728 -0
- setiastro/saspro/translations/ja_translations.py +3834 -0
- setiastro/saspro/translations/pt_translations.py +3847 -0
- setiastro/saspro/translations/ru_translations.py +3082 -0
- setiastro/saspro/translations/saspro_ar.qm +0 -0
- setiastro/saspro/translations/saspro_ar.ts +16019 -0
- setiastro/saspro/translations/saspro_de.qm +0 -0
- setiastro/saspro/translations/saspro_de.ts +14548 -0
- setiastro/saspro/translations/saspro_es.qm +0 -0
- setiastro/saspro/translations/saspro_es.ts +16202 -0
- setiastro/saspro/translations/saspro_fr.qm +0 -0
- setiastro/saspro/translations/saspro_fr.ts +15870 -0
- setiastro/saspro/translations/saspro_hi.qm +0 -0
- setiastro/saspro/translations/saspro_hi.ts +14855 -0
- setiastro/saspro/translations/saspro_it.qm +0 -0
- setiastro/saspro/translations/saspro_it.ts +19046 -0
- setiastro/saspro/translations/saspro_ja.qm +0 -0
- setiastro/saspro/translations/saspro_ja.ts +14980 -0
- setiastro/saspro/translations/saspro_pt.qm +0 -0
- setiastro/saspro/translations/saspro_pt.ts +15024 -0
- setiastro/saspro/translations/saspro_ru.qm +0 -0
- setiastro/saspro/translations/saspro_ru.ts +11835 -0
- setiastro/saspro/translations/saspro_sw.qm +0 -0
- setiastro/saspro/translations/saspro_sw.ts +15237 -0
- setiastro/saspro/translations/saspro_uk.qm +0 -0
- setiastro/saspro/translations/saspro_uk.ts +15248 -0
- setiastro/saspro/translations/saspro_zh.qm +0 -0
- setiastro/saspro/translations/saspro_zh.ts +15289 -0
- setiastro/saspro/translations/sw_translations.py +3897 -0
- setiastro/saspro/translations/uk_translations.py +3929 -0
- setiastro/saspro/translations/zh_translations.py +3910 -0
- setiastro/saspro/versioning.py +77 -0
- setiastro/saspro/view_bundle.py +1558 -0
- setiastro/saspro/wavescale_hdr.py +648 -0
- setiastro/saspro/wavescale_hdr_preset.py +101 -0
- setiastro/saspro/wavescalede.py +683 -0
- setiastro/saspro/wavescalede_preset.py +230 -0
- setiastro/saspro/wcs_update.py +374 -0
- setiastro/saspro/whitebalance.py +540 -0
- setiastro/saspro/widgets/__init__.py +48 -0
- setiastro/saspro/widgets/common_utilities.py +306 -0
- setiastro/saspro/widgets/graphics_views.py +122 -0
- setiastro/saspro/widgets/image_utils.py +518 -0
- setiastro/saspro/widgets/minigame/game.js +991 -0
- setiastro/saspro/widgets/minigame/index.html +53 -0
- setiastro/saspro/widgets/minigame/style.css +241 -0
- setiastro/saspro/widgets/preview_dialogs.py +280 -0
- setiastro/saspro/widgets/resource_monitor.py +313 -0
- setiastro/saspro/widgets/spinboxes.py +290 -0
- setiastro/saspro/widgets/themed_buttons.py +13 -0
- setiastro/saspro/widgets/wavelet_utils.py +331 -0
- setiastro/saspro/wimi.py +7367 -0
- setiastro/saspro/wims.py +588 -0
- setiastro/saspro/window_shelf.py +185 -0
- setiastro/saspro/xisf.py +1213 -0
- setiastrosuitepro-1.6.7.dist-info/METADATA +279 -0
- setiastrosuitepro-1.6.7.dist-info/RECORD +394 -0
- setiastrosuitepro-1.6.7.dist-info/WHEEL +4 -0
- setiastrosuitepro-1.6.7.dist-info/entry_points.txt +6 -0
- setiastrosuitepro-1.6.7.dist-info/licenses/LICENSE +674 -0
- setiastrosuitepro-1.6.7.dist-info/licenses/license.txt +2580 -0
|
@@ -0,0 +1,1841 @@
|
|
|
1
|
+
|
|
2
|
+
import numpy as np
|
|
3
|
+
import math
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import List, Tuple, Optional, Dict
|
|
6
|
+
from enum import Enum
|
|
7
|
+
|
|
8
|
+
# PyQt6 imports (available in SETI Astro)
|
|
9
|
+
from PyQt6.QtWidgets import (QApplication, QMainWindow, QWidget, QHBoxLayout,
|
|
10
|
+
QVBoxLayout, QPushButton, QFileDialog, QLabel, QSlider,
|
|
11
|
+
QScrollArea, QCheckBox, QGroupBox, QDialog, QSizePolicy)
|
|
12
|
+
from PyQt6.QtCore import Qt, QThread, pyqtSignal, QTimer, QPointF, QRectF
|
|
13
|
+
from PyQt6.QtGui import (QImage, QPixmap, QPainter, QColor, QPalette, QLinearGradient,
|
|
14
|
+
QRadialGradient, QBrush, QPen, QPaintEvent, QMouseEvent,
|
|
15
|
+
QWheelEvent, QPainterPath)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# =============================================================================
|
|
19
|
+
# DATA TYPES
|
|
20
|
+
# =============================================================================
|
|
21
|
+
from setiastro.saspro.widgets.themed_buttons import themed_toolbtn
|
|
22
|
+
@dataclass
|
|
23
|
+
class Color:
|
|
24
|
+
r: float
|
|
25
|
+
g: float
|
|
26
|
+
b: float
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class Star:
|
|
30
|
+
x: float
|
|
31
|
+
y: float
|
|
32
|
+
brightness: float
|
|
33
|
+
radius: float
|
|
34
|
+
color: Color
|
|
35
|
+
|
|
36
|
+
class ToolMode(Enum):
|
|
37
|
+
NONE = 'none'
|
|
38
|
+
ADD = 'add'
|
|
39
|
+
ERASE = 'erase'
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class SpikeConfig:
|
|
43
|
+
# Detection
|
|
44
|
+
threshold: int = 100
|
|
45
|
+
star_amount: float = 100.0
|
|
46
|
+
min_star_size: float = 0.0
|
|
47
|
+
max_star_size: float = 100.0
|
|
48
|
+
# Main Spikes
|
|
49
|
+
quantity: int = 4
|
|
50
|
+
length: float = 300.0
|
|
51
|
+
global_scale: float = 1.0
|
|
52
|
+
angle: float = 45.0
|
|
53
|
+
intensity: float = 1.0
|
|
54
|
+
spike_width: float = 1.0
|
|
55
|
+
sharpness: float = 0.5
|
|
56
|
+
# Appearance
|
|
57
|
+
color_saturation: float = 1.0
|
|
58
|
+
hue_shift: float = 0.0
|
|
59
|
+
# Secondary Spikes
|
|
60
|
+
secondary_intensity: float = 0.5
|
|
61
|
+
secondary_length: float = 120.0
|
|
62
|
+
secondary_offset: float = 45.0
|
|
63
|
+
# Soft Flare
|
|
64
|
+
soft_flare_intensity: float = 3.0
|
|
65
|
+
soft_flare_size: float = 15.0
|
|
66
|
+
# Halo
|
|
67
|
+
enable_halo: bool = False
|
|
68
|
+
halo_intensity: float = 0.5
|
|
69
|
+
halo_scale: float = 5.0
|
|
70
|
+
halo_width: float = 1.0
|
|
71
|
+
halo_blur: float = 0.5
|
|
72
|
+
halo_saturation: float = 1.0
|
|
73
|
+
# Rainbow
|
|
74
|
+
enable_rainbow: bool = False
|
|
75
|
+
rainbow_spikes: bool = True
|
|
76
|
+
rainbow_spike_intensity: float = 0.8
|
|
77
|
+
rainbow_spike_frequency: float = 1.0
|
|
78
|
+
rainbow_spike_length: float = 0.8
|
|
79
|
+
|
|
80
|
+
DEFAULT_CONFIG = SpikeConfig()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# =============================================================================
|
|
84
|
+
# STAR DETECTION
|
|
85
|
+
# =============================================================================
|
|
86
|
+
|
|
87
|
+
def map_threshold_to_internal(ui_threshold: int) -> float:
|
|
88
|
+
"""Maps UI threshold (1-100) to internal threshold (140-240)."""
|
|
89
|
+
return 140 + (ui_threshold - 1) * (240 - 140) / (100 - 1)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def find_local_peak(lum_data: np.ndarray, x: int, y: int, width: int, height: int) -> Tuple[int, int, float]:
|
|
93
|
+
"""Finds the local maximum brightness starting from (x, y)."""
|
|
94
|
+
curr_x, curr_y = x, y
|
|
95
|
+
curr_lum = lum_data[y, x]
|
|
96
|
+
|
|
97
|
+
for _ in range(20):
|
|
98
|
+
best_lum = curr_lum
|
|
99
|
+
best_x, best_y = curr_x, curr_y
|
|
100
|
+
changed = False
|
|
101
|
+
|
|
102
|
+
y_min = max(0, curr_y - 1)
|
|
103
|
+
y_max = min(height, curr_y + 2)
|
|
104
|
+
x_min = max(0, curr_x - 1)
|
|
105
|
+
x_max = min(width, curr_x + 2)
|
|
106
|
+
|
|
107
|
+
window = lum_data[y_min:y_max, x_min:x_max]
|
|
108
|
+
max_val = np.max(window)
|
|
109
|
+
|
|
110
|
+
if max_val > best_lum:
|
|
111
|
+
local_y, local_x = np.unravel_index(np.argmax(window), window.shape)
|
|
112
|
+
best_x = x_min + local_x
|
|
113
|
+
best_y = y_min + local_y
|
|
114
|
+
best_lum = max_val
|
|
115
|
+
changed = True
|
|
116
|
+
|
|
117
|
+
if not changed:
|
|
118
|
+
break
|
|
119
|
+
|
|
120
|
+
curr_x, curr_y = best_x, best_y
|
|
121
|
+
curr_lum = best_lum
|
|
122
|
+
|
|
123
|
+
return curr_x, curr_y, curr_lum
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def flood_fill_star(data: np.ndarray, lum_data: np.ndarray, width: int, height: int,
|
|
127
|
+
start_x: int, start_y: int, threshold: int, checked: np.ndarray) -> Optional[Star]:
|
|
128
|
+
"""Flood fill to determine star extent and properties."""
|
|
129
|
+
sum_x = 0.0
|
|
130
|
+
sum_y = 0.0
|
|
131
|
+
sum_lum = 0.0
|
|
132
|
+
|
|
133
|
+
sum_r = 0.0
|
|
134
|
+
sum_g = 0.0
|
|
135
|
+
sum_b = 0.0
|
|
136
|
+
sum_color_weight = 0.0
|
|
137
|
+
|
|
138
|
+
pixel_count = 0
|
|
139
|
+
max_lum = lum_data[start_y, start_x]
|
|
140
|
+
|
|
141
|
+
min_x, max_x = start_x, start_x
|
|
142
|
+
min_y, max_y = start_y, start_y
|
|
143
|
+
|
|
144
|
+
pixel_coords_x = []
|
|
145
|
+
pixel_coords_y = []
|
|
146
|
+
|
|
147
|
+
stack = [(start_x, start_y)]
|
|
148
|
+
max_pixels = int(1000 + (max_lum / 255.0) * 50000)
|
|
149
|
+
min_lum_ratio = 0.20
|
|
150
|
+
path_min_lum = max_lum
|
|
151
|
+
|
|
152
|
+
while stack and pixel_count < max_pixels:
|
|
153
|
+
cx, cy = stack.pop()
|
|
154
|
+
|
|
155
|
+
if cx < 0 or cx >= width or cy < 0 or cy >= height:
|
|
156
|
+
continue
|
|
157
|
+
|
|
158
|
+
if checked[cy, cx]:
|
|
159
|
+
continue
|
|
160
|
+
|
|
161
|
+
l = lum_data[cy, cx]
|
|
162
|
+
|
|
163
|
+
if l > threshold:
|
|
164
|
+
if max_lum > 0 and l < (max_lum * min_lum_ratio):
|
|
165
|
+
continue
|
|
166
|
+
|
|
167
|
+
checked[cy, cx] = True
|
|
168
|
+
|
|
169
|
+
if l < path_min_lum:
|
|
170
|
+
path_min_lum = l
|
|
171
|
+
|
|
172
|
+
min_x = min(min_x, cx)
|
|
173
|
+
max_x = max(max_x, cx)
|
|
174
|
+
min_y = min(min_y, cy)
|
|
175
|
+
max_y = max(max_y, cy)
|
|
176
|
+
|
|
177
|
+
pixel_coords_x.append(cx)
|
|
178
|
+
pixel_coords_y.append(cy)
|
|
179
|
+
|
|
180
|
+
sum_x += cx * l
|
|
181
|
+
sum_y += cy * l
|
|
182
|
+
sum_lum += l
|
|
183
|
+
|
|
184
|
+
pr = float(data[cy, cx, 0])
|
|
185
|
+
pg = float(data[cy, cx, 1])
|
|
186
|
+
pb = float(data[cy, cx, 2])
|
|
187
|
+
|
|
188
|
+
max_rgb = max(pr, pg, pb)
|
|
189
|
+
min_rgb = min(pr, pg, pb)
|
|
190
|
+
saturation = (max_rgb - min_rgb) / 255.0 if max_rgb > 0 else 0
|
|
191
|
+
|
|
192
|
+
if pr > 245 and pg > 245 and pb > 245:
|
|
193
|
+
color_weight = 0.01
|
|
194
|
+
else:
|
|
195
|
+
color_weight = (l / 255.0) + saturation * 2.0
|
|
196
|
+
|
|
197
|
+
sum_r += pr * color_weight
|
|
198
|
+
sum_g += pg * color_weight
|
|
199
|
+
sum_b += pb * color_weight
|
|
200
|
+
sum_color_weight += color_weight
|
|
201
|
+
|
|
202
|
+
pixel_count += 1
|
|
203
|
+
|
|
204
|
+
neighbors = [
|
|
205
|
+
(cx + 1, cy), (cx - 1, cy),
|
|
206
|
+
(cx, cy + 1), (cx, cy - 1)
|
|
207
|
+
]
|
|
208
|
+
|
|
209
|
+
for nx, ny in neighbors:
|
|
210
|
+
if 0 <= nx < width and 0 <= ny < height:
|
|
211
|
+
nl = lum_data[ny, nx]
|
|
212
|
+
valley_climb_tolerance = max(10, path_min_lum * 0.15)
|
|
213
|
+
if nl > path_min_lum + valley_climb_tolerance:
|
|
214
|
+
continue
|
|
215
|
+
stack.append((nx, ny))
|
|
216
|
+
|
|
217
|
+
if pixel_count == 0:
|
|
218
|
+
return None
|
|
219
|
+
|
|
220
|
+
# Shape analysis - reject irregular blobs
|
|
221
|
+
if pixel_count >= 10:
|
|
222
|
+
coords_x = np.array(pixel_coords_x, dtype=float)
|
|
223
|
+
coords_y = np.array(pixel_coords_y, dtype=float)
|
|
224
|
+
|
|
225
|
+
cx = np.mean(coords_x)
|
|
226
|
+
cy = np.mean(coords_y)
|
|
227
|
+
|
|
228
|
+
dx = coords_x - cx
|
|
229
|
+
dy = coords_y - cy
|
|
230
|
+
|
|
231
|
+
mu20 = np.mean(dx * dx)
|
|
232
|
+
mu02 = np.mean(dy * dy)
|
|
233
|
+
mu11 = np.mean(dx * dy)
|
|
234
|
+
|
|
235
|
+
trace = mu20 + mu02
|
|
236
|
+
det = mu20 * mu02 - mu11 * mu11
|
|
237
|
+
discriminant = trace * trace - 4 * det
|
|
238
|
+
|
|
239
|
+
if discriminant >= 0 and trace > 0:
|
|
240
|
+
sqrt_disc = math.sqrt(discriminant)
|
|
241
|
+
lambda1 = (trace + sqrt_disc) / 2.0
|
|
242
|
+
lambda2 = (trace - sqrt_disc) / 2.0
|
|
243
|
+
|
|
244
|
+
if lambda2 > 0:
|
|
245
|
+
axis_ratio = math.sqrt(lambda1 / lambda2)
|
|
246
|
+
if axis_ratio > 1.5:
|
|
247
|
+
return None
|
|
248
|
+
|
|
249
|
+
# Compactness check
|
|
250
|
+
bbox_width = max_x - min_x + 1
|
|
251
|
+
bbox_height = max_y - min_y + 1
|
|
252
|
+
bbox_area = bbox_width * bbox_height
|
|
253
|
+
|
|
254
|
+
aspect_ratio = max(bbox_width, bbox_height) / max(min(bbox_width, bbox_height), 1)
|
|
255
|
+
if aspect_ratio > 5.0:
|
|
256
|
+
return None
|
|
257
|
+
|
|
258
|
+
fill_ratio = pixel_count / max(bbox_area, 1)
|
|
259
|
+
if fill_ratio < 0.10 and pixel_count > 50:
|
|
260
|
+
return None
|
|
261
|
+
|
|
262
|
+
calculated_radius = math.sqrt(pixel_count / math.pi)
|
|
263
|
+
|
|
264
|
+
if sum_color_weight > 0:
|
|
265
|
+
avg_r = sum_r / sum_color_weight
|
|
266
|
+
avg_g = sum_g / sum_color_weight
|
|
267
|
+
avg_b = sum_b / sum_color_weight
|
|
268
|
+
else:
|
|
269
|
+
avg_r, avg_g, avg_b = 255, 255, 255
|
|
270
|
+
|
|
271
|
+
return Star(
|
|
272
|
+
x=sum_x / sum_lum,
|
|
273
|
+
y=sum_y / sum_lum,
|
|
274
|
+
brightness=max_lum / 255.0,
|
|
275
|
+
radius=calculated_radius,
|
|
276
|
+
color=Color(avg_r, avg_g, avg_b)
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def sample_halo_color(data: np.ndarray, width: int, height: int, star: Star) -> Color:
|
|
281
|
+
"""Sample color from the star's halo region."""
|
|
282
|
+
inner_radius = star.radius * 1.5
|
|
283
|
+
outer_radius = star.radius * 3.0
|
|
284
|
+
|
|
285
|
+
sum_r = 0.0
|
|
286
|
+
sum_g = 0.0
|
|
287
|
+
sum_b = 0.0
|
|
288
|
+
sample_count = 0
|
|
289
|
+
|
|
290
|
+
samples = 24
|
|
291
|
+
for i in range(samples):
|
|
292
|
+
angle = (i / samples) * math.pi * 2
|
|
293
|
+
radius = (inner_radius + outer_radius) / 2
|
|
294
|
+
|
|
295
|
+
x = int(round(star.x + math.cos(angle) * radius))
|
|
296
|
+
y = int(round(star.y + math.sin(angle) * radius))
|
|
297
|
+
|
|
298
|
+
if 0 <= x < width and 0 <= y < height:
|
|
299
|
+
sum_r += data[y, x, 0]
|
|
300
|
+
sum_g += data[y, x, 1]
|
|
301
|
+
sum_b += data[y, x, 2]
|
|
302
|
+
sample_count += 1
|
|
303
|
+
|
|
304
|
+
if sample_count == 0:
|
|
305
|
+
return Color(255, 255, 255)
|
|
306
|
+
|
|
307
|
+
return Color(
|
|
308
|
+
r=sum_r / sample_count,
|
|
309
|
+
g=sum_g / sample_count,
|
|
310
|
+
b=sum_b / sample_count
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def detect_stars(image_data: np.ndarray, threshold: int) -> List[Star]:
|
|
315
|
+
"""Detect stars in the image using peak finding and flood fill."""
|
|
316
|
+
height, width = image_data.shape[:2]
|
|
317
|
+
internal_threshold = map_threshold_to_internal(threshold)
|
|
318
|
+
|
|
319
|
+
# Convert to 0-255 range if needed
|
|
320
|
+
if image_data.dtype == np.float32 or image_data.dtype == np.float64:
|
|
321
|
+
if image_data.max() <= 1.0:
|
|
322
|
+
image_data_255 = (image_data * 255).astype(np.uint8)
|
|
323
|
+
else:
|
|
324
|
+
image_data_255 = image_data.astype(np.uint8)
|
|
325
|
+
else:
|
|
326
|
+
image_data_255 = image_data
|
|
327
|
+
|
|
328
|
+
# Calculate luminance
|
|
329
|
+
r = image_data_255[:, :, 0].astype(float)
|
|
330
|
+
g = image_data_255[:, :, 1].astype(float)
|
|
331
|
+
b = image_data_255[:, :, 2].astype(float)
|
|
332
|
+
lum = 0.2126 * r + 0.7152 * g + 0.0722 * b
|
|
333
|
+
|
|
334
|
+
stride = 4
|
|
335
|
+
lum_strided = lum[0:height:stride, 0:width:stride]
|
|
336
|
+
cy_indices, cx_indices = np.where(lum_strided > internal_threshold)
|
|
337
|
+
|
|
338
|
+
unique_peaks: Dict[Tuple[int, int], float] = {}
|
|
339
|
+
|
|
340
|
+
for i in range(len(cy_indices)):
|
|
341
|
+
y = cy_indices[i] * stride
|
|
342
|
+
x = cx_indices[i] * stride
|
|
343
|
+
|
|
344
|
+
px, py, plum = find_local_peak(lum, x, y, width, height)
|
|
345
|
+
|
|
346
|
+
if plum > internal_threshold:
|
|
347
|
+
unique_peaks[(px, py)] = plum
|
|
348
|
+
|
|
349
|
+
sorted_peaks = sorted(unique_peaks.items(), key=lambda item: item[1], reverse=True)
|
|
350
|
+
|
|
351
|
+
stars: List[Star] = []
|
|
352
|
+
checked = np.zeros((height, width), dtype=bool)
|
|
353
|
+
|
|
354
|
+
for (px, py), plum in sorted_peaks:
|
|
355
|
+
if checked[py, px]:
|
|
356
|
+
continue
|
|
357
|
+
|
|
358
|
+
star = flood_fill_star(image_data_255, lum, width, height, px, py, internal_threshold, checked)
|
|
359
|
+
if star:
|
|
360
|
+
stars.append(star)
|
|
361
|
+
|
|
362
|
+
# Merge overlapping stars
|
|
363
|
+
stars.sort(key=lambda s: s.radius, reverse=True)
|
|
364
|
+
merged_stars: List[Star] = []
|
|
365
|
+
|
|
366
|
+
for star in stars:
|
|
367
|
+
merged = False
|
|
368
|
+
for existing in merged_stars:
|
|
369
|
+
dx = star.x - existing.x
|
|
370
|
+
dy = star.y - existing.y
|
|
371
|
+
dist = math.sqrt(dx*dx + dy*dy)
|
|
372
|
+
|
|
373
|
+
brightness_ratio = star.brightness / max(existing.brightness, 0.01)
|
|
374
|
+
is_much_dimmer = brightness_ratio < 0.4
|
|
375
|
+
is_tiny = star.radius < 5
|
|
376
|
+
|
|
377
|
+
should_merge = False
|
|
378
|
+
|
|
379
|
+
if is_much_dimmer or is_tiny:
|
|
380
|
+
if dist < (existing.radius + star.radius) * 1.2:
|
|
381
|
+
should_merge = True
|
|
382
|
+
else:
|
|
383
|
+
if dist < (existing.radius + star.radius) * 0.25:
|
|
384
|
+
should_merge = True
|
|
385
|
+
|
|
386
|
+
if should_merge:
|
|
387
|
+
merged = True
|
|
388
|
+
break
|
|
389
|
+
|
|
390
|
+
if not merged:
|
|
391
|
+
merged_stars.append(star)
|
|
392
|
+
|
|
393
|
+
# Sample halo colors
|
|
394
|
+
for star in merged_stars:
|
|
395
|
+
star.color = sample_halo_color(image_data_255, width, height, star)
|
|
396
|
+
|
|
397
|
+
merged_stars.sort(key=lambda s: s.brightness * s.radius, reverse=True)
|
|
398
|
+
return merged_stars
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
# =============================================================================
|
|
402
|
+
# RENDERING
|
|
403
|
+
# =============================================================================
|
|
404
|
+
|
|
405
|
+
def hsl_to_rgb(h: float, s: float, l: float) -> Tuple[float, float, float]:
|
|
406
|
+
"""Convert HSL to RGB (all values 0-1)."""
|
|
407
|
+
if s == 0:
|
|
408
|
+
return l, l, l
|
|
409
|
+
|
|
410
|
+
def hue_to_rgb(p, q, t):
|
|
411
|
+
if t < 0:
|
|
412
|
+
t += 1
|
|
413
|
+
if t > 1:
|
|
414
|
+
t -= 1
|
|
415
|
+
if t < 1/6:
|
|
416
|
+
return p + (q - p) * 6 * t
|
|
417
|
+
if t < 1/2:
|
|
418
|
+
return q
|
|
419
|
+
if t < 2/3:
|
|
420
|
+
return p + (q - p) * (2/3 - t) * 6
|
|
421
|
+
return p
|
|
422
|
+
|
|
423
|
+
q = l * (1 + s) if l < 0.5 else l + s - l * s
|
|
424
|
+
p = 2 * l - q
|
|
425
|
+
|
|
426
|
+
r = hue_to_rgb(p, q, h + 1/3)
|
|
427
|
+
g = hue_to_rgb(p, q, h)
|
|
428
|
+
b = hue_to_rgb(p, q, h - 1/3)
|
|
429
|
+
|
|
430
|
+
return r, g, b
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def get_star_color(star: Star, hue_shift: float, saturation_input: float, alpha: float) -> Tuple[float, float, float, float]:
|
|
434
|
+
"""Calculate star color with saturation control. Returns (r, g, b, a) in 0-1 range."""
|
|
435
|
+
r, g, b = star.color.r / 255.0, star.color.g / 255.0, star.color.b / 255.0
|
|
436
|
+
|
|
437
|
+
# RGB to HSL
|
|
438
|
+
max_c = max(r, g, b)
|
|
439
|
+
min_c = min(r, g, b)
|
|
440
|
+
l = (max_c + min_c) / 2.0
|
|
441
|
+
h = 0.0
|
|
442
|
+
s = 0.0
|
|
443
|
+
|
|
444
|
+
if max_c != min_c:
|
|
445
|
+
d = max_c - min_c
|
|
446
|
+
s = d / (2.0 - max_c - min_c) if l > 0.5 else d / (max_c + min_c)
|
|
447
|
+
|
|
448
|
+
if max_c == r:
|
|
449
|
+
h = (g - b) / d + (6.0 if g < b else 0.0)
|
|
450
|
+
elif max_c == g:
|
|
451
|
+
h = (b - r) / d + 2.0
|
|
452
|
+
elif max_c == b:
|
|
453
|
+
h = (r - g) / d + 4.0
|
|
454
|
+
h /= 6.0
|
|
455
|
+
else:
|
|
456
|
+
h = ((star.x * 0.618 + star.y * 0.382) % 1.0)
|
|
457
|
+
|
|
458
|
+
new_h = (h * 360.0) + hue_shift
|
|
459
|
+
|
|
460
|
+
# Saturation logic
|
|
461
|
+
boosted_s = min(1.0, s * 16.0)
|
|
462
|
+
|
|
463
|
+
if saturation_input <= 1.0:
|
|
464
|
+
final_s = boosted_s * saturation_input
|
|
465
|
+
final_l = max(l, 0.65)
|
|
466
|
+
else:
|
|
467
|
+
hyper_factor = saturation_input - 1.0
|
|
468
|
+
final_s = boosted_s + (1.0 - boosted_s) * hyper_factor
|
|
469
|
+
base_l = max(l, 0.65)
|
|
470
|
+
target_l = 0.5
|
|
471
|
+
final_l = base_l + (target_l - base_l) * hyper_factor
|
|
472
|
+
|
|
473
|
+
final_s = max(0.0, min(1.0, final_s))
|
|
474
|
+
final_l = max(0.4, min(0.95, final_l))
|
|
475
|
+
final_h = ((new_h % 360.0) / 360.0)
|
|
476
|
+
|
|
477
|
+
r_out, g_out, b_out = hsl_to_rgb(final_h, final_s, final_l)
|
|
478
|
+
return (r_out, g_out, b_out, alpha)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def create_glow_sprite(size: int = 256) -> np.ndarray:
|
|
482
|
+
"""Create a radial gradient glow sprite."""
|
|
483
|
+
sprite = np.zeros((size, size, 4), dtype=np.float32)
|
|
484
|
+
center = size / 2
|
|
485
|
+
|
|
486
|
+
for y in range(size):
|
|
487
|
+
for x in range(size):
|
|
488
|
+
dx = x - center
|
|
489
|
+
dy = y - center
|
|
490
|
+
dist = math.sqrt(dx*dx + dy*dy) / center
|
|
491
|
+
|
|
492
|
+
if dist <= 1.0:
|
|
493
|
+
if dist <= 0.2:
|
|
494
|
+
alpha = 1.0 - (dist / 0.2) * 0.6
|
|
495
|
+
elif dist <= 0.6:
|
|
496
|
+
alpha = 0.4 - ((dist - 0.2) / 0.4) * 0.35
|
|
497
|
+
else:
|
|
498
|
+
alpha = 0.05 - ((dist - 0.6) / 0.4) * 0.05
|
|
499
|
+
|
|
500
|
+
alpha = max(0, alpha)
|
|
501
|
+
sprite[y, x] = [1.0, 1.0, 1.0, alpha]
|
|
502
|
+
|
|
503
|
+
return sprite
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def blend_screen(base: np.ndarray, overlay: np.ndarray, x: int, y: int, opacity: float = 1.0):
|
|
507
|
+
"""Apply screen blending mode for overlay at position (x, y)."""
|
|
508
|
+
h, w = overlay.shape[:2]
|
|
509
|
+
bh, bw = base.shape[:2]
|
|
510
|
+
|
|
511
|
+
# Calculate bounds
|
|
512
|
+
x1, y1 = max(0, x), max(0, y)
|
|
513
|
+
x2, y2 = min(bw, x + w), min(bh, y + h)
|
|
514
|
+
|
|
515
|
+
ox1, oy1 = x1 - x, y1 - y
|
|
516
|
+
ox2, oy2 = ox1 + (x2 - x1), oy1 + (y2 - y1)
|
|
517
|
+
|
|
518
|
+
if x2 <= x1 or y2 <= y1:
|
|
519
|
+
return
|
|
520
|
+
|
|
521
|
+
base_region = base[y1:y2, x1:x2]
|
|
522
|
+
overlay_region = overlay[oy1:oy2, ox1:ox2]
|
|
523
|
+
|
|
524
|
+
if overlay.shape[2] == 4:
|
|
525
|
+
alpha = overlay_region[:, :, 3:4] * opacity
|
|
526
|
+
overlay_rgb = overlay_region[:, :, :3]
|
|
527
|
+
else:
|
|
528
|
+
alpha = np.ones((overlay_region.shape[0], overlay_region.shape[1], 1)) * opacity
|
|
529
|
+
overlay_rgb = overlay_region
|
|
530
|
+
|
|
531
|
+
# Screen blend: 1 - (1-a)(1-b)
|
|
532
|
+
result = 1.0 - (1.0 - base_region) * (1.0 - overlay_rgb * alpha)
|
|
533
|
+
base[y1:y2, x1:x2] = result
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def draw_line_gradient(output: np.ndarray, x1: float, y1: float, x2: float, y2: float,
|
|
537
|
+
color_start: Tuple[float, float, float, float],
|
|
538
|
+
color_end: Tuple[float, float, float, float],
|
|
539
|
+
thickness: float, sharpness: float = 0.5):
|
|
540
|
+
"""Draw a gradient line with screen blending."""
|
|
541
|
+
height, width = output.shape[:2]
|
|
542
|
+
|
|
543
|
+
dx = x2 - x1
|
|
544
|
+
dy = y2 - y1
|
|
545
|
+
length = math.sqrt(dx*dx + dy*dy)
|
|
546
|
+
|
|
547
|
+
if length < 1:
|
|
548
|
+
return
|
|
549
|
+
|
|
550
|
+
# Normalize direction
|
|
551
|
+
dx /= length
|
|
552
|
+
dy /= length
|
|
553
|
+
|
|
554
|
+
# Perpendicular for thickness
|
|
555
|
+
px, py = -dy, dx
|
|
556
|
+
|
|
557
|
+
# Number of steps along the line
|
|
558
|
+
steps = int(length * 2)
|
|
559
|
+
|
|
560
|
+
for i in range(steps):
|
|
561
|
+
t = i / max(1, steps - 1)
|
|
562
|
+
|
|
563
|
+
# Position along line
|
|
564
|
+
lx = x1 + dx * length * t
|
|
565
|
+
ly = y1 + dy * length * t
|
|
566
|
+
|
|
567
|
+
# Interpolate color with sharpness
|
|
568
|
+
if t < sharpness:
|
|
569
|
+
color_t = t / sharpness if sharpness > 0 else 1
|
|
570
|
+
r = color_start[0] * (1 - color_t * 0.2)
|
|
571
|
+
g = color_start[1] * (1 - color_t * 0.2)
|
|
572
|
+
b = color_start[2] * (1 - color_t * 0.2)
|
|
573
|
+
a = color_start[3] * (1 - color_t * 0.2)
|
|
574
|
+
else:
|
|
575
|
+
fade_t = (t - sharpness) / (1 - sharpness) if sharpness < 1 else 0
|
|
576
|
+
r = color_start[0] * 0.8 * (1 - fade_t)
|
|
577
|
+
g = color_start[1] * 0.8 * (1 - fade_t)
|
|
578
|
+
b = color_start[2] * 0.8 * (1 - fade_t)
|
|
579
|
+
a = color_start[3] * 0.8 * (1 - fade_t)
|
|
580
|
+
|
|
581
|
+
# Draw across thickness
|
|
582
|
+
half_thick = thickness / 2
|
|
583
|
+
for ti in range(-int(half_thick), int(half_thick) + 1):
|
|
584
|
+
px_x = int(lx + px * ti)
|
|
585
|
+
px_y = int(ly + py * ti)
|
|
586
|
+
|
|
587
|
+
if 0 <= px_x < width and 0 <= px_y < height:
|
|
588
|
+
# Distance from center of line for anti-aliasing
|
|
589
|
+
thick_factor = 1.0 - abs(ti) / (half_thick + 1)
|
|
590
|
+
|
|
591
|
+
# Screen blend
|
|
592
|
+
final_a = a * thick_factor
|
|
593
|
+
if final_a > 0.001:
|
|
594
|
+
base = output[px_y, px_x]
|
|
595
|
+
overlay = np.array([r, g, b]) * final_a
|
|
596
|
+
output[px_y, px_x] = 1.0 - (1.0 - base) * (1.0 - overlay)
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def render_spikes(output: np.ndarray, stars: List[Star], config: SpikeConfig, ctx=None):
|
|
600
|
+
"""Render all spike effects onto the output image."""
|
|
601
|
+
height, width = output.shape[:2]
|
|
602
|
+
|
|
603
|
+
if not stars:
|
|
604
|
+
return
|
|
605
|
+
|
|
606
|
+
# Coerce quantity → safe integer (at least 1)
|
|
607
|
+
qty = max(1, int(round(config.quantity)))
|
|
608
|
+
|
|
609
|
+
# Apply quantity limit
|
|
610
|
+
limit = int(len(stars) * (config.star_amount / 100.0))
|
|
611
|
+
active_stars = stars[:limit]
|
|
612
|
+
|
|
613
|
+
# Apply min size filtering
|
|
614
|
+
if config.min_star_size > 0:
|
|
615
|
+
internal_min_size = config.min_star_size * 0.02
|
|
616
|
+
active_stars = [star for star in active_stars if star.radius >= internal_min_size]
|
|
617
|
+
|
|
618
|
+
# Apply max size filtering
|
|
619
|
+
internal_max_size = 96 + (config.max_star_size * 0.04)
|
|
620
|
+
|
|
621
|
+
if internal_max_size < 100 and len(active_stars) > 0:
|
|
622
|
+
sorted_by_size = sorted(active_stars, key=lambda s: s.radius, reverse=True)
|
|
623
|
+
removal_percentage = (100 - internal_max_size) / 100.0
|
|
624
|
+
num_to_remove = int(len(sorted_by_size) * removal_percentage)
|
|
625
|
+
|
|
626
|
+
if num_to_remove > 0:
|
|
627
|
+
stars_to_remove_ids = set(id(star) for star in sorted_by_size[:num_to_remove])
|
|
628
|
+
active_stars = [star for star in active_stars if id(star) not in stars_to_remove_ids]
|
|
629
|
+
|
|
630
|
+
if ctx:
|
|
631
|
+
ctx.log(f"Processing {len(active_stars)} stars...")
|
|
632
|
+
|
|
633
|
+
deg_to_rad = math.pi / 180.0
|
|
634
|
+
main_angle_rad = config.angle * deg_to_rad
|
|
635
|
+
sec_angle_rad = (config.angle + config.secondary_offset) * deg_to_rad
|
|
636
|
+
|
|
637
|
+
# Create glow sprite for soft flare
|
|
638
|
+
glow_sprite = create_glow_sprite(256)
|
|
639
|
+
|
|
640
|
+
# Render soft flare
|
|
641
|
+
if config.soft_flare_intensity > 0:
|
|
642
|
+
for star in active_stars:
|
|
643
|
+
glow_r = (star.radius * config.soft_flare_size * 0.4 + (star.radius * 2))
|
|
644
|
+
if glow_r > 2:
|
|
645
|
+
opacity = config.soft_flare_intensity * 0.8 * star.brightness
|
|
646
|
+
opacity = min(1.0, opacity)
|
|
647
|
+
|
|
648
|
+
# Resize glow sprite
|
|
649
|
+
draw_size = int(glow_r * 2)
|
|
650
|
+
if draw_size > 4:
|
|
651
|
+
# Simple resize using nearest neighbor
|
|
652
|
+
scale = draw_size / 256
|
|
653
|
+
resized_glow = np.zeros((draw_size, draw_size, 4), dtype=np.float32)
|
|
654
|
+
|
|
655
|
+
for y in range(draw_size):
|
|
656
|
+
for x in range(draw_size):
|
|
657
|
+
src_x = int(x / scale)
|
|
658
|
+
src_y = int(y / scale)
|
|
659
|
+
src_x = min(255, src_x)
|
|
660
|
+
src_y = min(255, src_y)
|
|
661
|
+
resized_glow[y, x] = glow_sprite[src_y, src_x]
|
|
662
|
+
|
|
663
|
+
# Apply star color tint
|
|
664
|
+
star_color = get_star_color(star, config.hue_shift, config.color_saturation, 1.0)
|
|
665
|
+
resized_glow[:, :, 0] *= star_color[0]
|
|
666
|
+
resized_glow[:, :, 1] *= star_color[1]
|
|
667
|
+
resized_glow[:, :, 2] *= star_color[2]
|
|
668
|
+
|
|
669
|
+
# Blend
|
|
670
|
+
x_pos = int(star.x - glow_r)
|
|
671
|
+
y_pos = int(star.y - glow_r)
|
|
672
|
+
blend_screen(output, resized_glow, x_pos, y_pos, opacity)
|
|
673
|
+
|
|
674
|
+
# Render spikes
|
|
675
|
+
for star in active_stars:
|
|
676
|
+
radius_factor = math.pow(star.radius, 1.2)
|
|
677
|
+
base_length = radius_factor * (config.length / 40.0) * config.global_scale
|
|
678
|
+
thickness = max(0.5, star.radius * config.spike_width * 0.15 * config.global_scale)
|
|
679
|
+
|
|
680
|
+
if base_length < 2:
|
|
681
|
+
continue
|
|
682
|
+
|
|
683
|
+
color = get_star_color(star, config.hue_shift, config.color_saturation, config.intensity)
|
|
684
|
+
sec_color = get_star_color(star, config.hue_shift, config.color_saturation, config.secondary_intensity)
|
|
685
|
+
|
|
686
|
+
# Main spikes
|
|
687
|
+
if config.intensity > 0:
|
|
688
|
+
for i in range(qty):
|
|
689
|
+
theta = main_angle_rad + (i * (math.pi * 2) / float(qty))
|
|
690
|
+
...
|
|
691
|
+
|
|
692
|
+
# Secondary spikes
|
|
693
|
+
if config.secondary_intensity > 0:
|
|
694
|
+
sec_len = base_length * (config.secondary_length / config.length)
|
|
695
|
+
for i in range(qty):
|
|
696
|
+
theta = sec_angle_rad + (i * (math.pi * 2) / float(qty))
|
|
697
|
+
cos_t = math.cos(theta)
|
|
698
|
+
sin_t = math.sin(theta)
|
|
699
|
+
|
|
700
|
+
start_x = star.x + cos_t * 1.0
|
|
701
|
+
start_y = star.y + sin_t * 1.0
|
|
702
|
+
end_x = star.x + cos_t * sec_len
|
|
703
|
+
end_y = star.y + sin_t * sec_len
|
|
704
|
+
|
|
705
|
+
draw_line_gradient(output, start_x, start_y, end_x, end_y,
|
|
706
|
+
sec_color, (0, 0, 0, 0), thickness * 0.6, config.sharpness)
|
|
707
|
+
|
|
708
|
+
# Halo
|
|
709
|
+
if config.enable_halo and config.halo_intensity > 0:
|
|
710
|
+
classification_score = star.radius * star.brightness
|
|
711
|
+
intensity_weight = math.pow(min(1.0, classification_score / 10.0), 2)
|
|
712
|
+
|
|
713
|
+
if intensity_weight > 0.01:
|
|
714
|
+
final_halo_intensity = config.halo_intensity * intensity_weight
|
|
715
|
+
halo_color = get_star_color(star, config.hue_shift, config.halo_saturation, final_halo_intensity)
|
|
716
|
+
|
|
717
|
+
r_halo = star.radius * config.halo_scale
|
|
718
|
+
if r_halo > 0.5:
|
|
719
|
+
# Draw halo ring
|
|
720
|
+
ring_width = r_halo * config.halo_width * 0.15
|
|
721
|
+
inner_r = max(0.5, r_halo - ring_width / 2)
|
|
722
|
+
outer_r = r_halo + ring_width / 2
|
|
723
|
+
|
|
724
|
+
for angle in np.linspace(0, 2 * math.pi, 72):
|
|
725
|
+
for r in np.linspace(inner_r, outer_r, max(1, int(ring_width))):
|
|
726
|
+
px = int(star.x + math.cos(angle) * r)
|
|
727
|
+
py = int(star.y + math.sin(angle) * r)
|
|
728
|
+
|
|
729
|
+
if 0 <= px < width and 0 <= py < height:
|
|
730
|
+
# Distance from ring center for falloff
|
|
731
|
+
ring_center = (inner_r + outer_r) / 2
|
|
732
|
+
dist_from_center = abs(r - ring_center) / (ring_width / 2 + 0.1)
|
|
733
|
+
falloff = max(0, 1 - dist_from_center)
|
|
734
|
+
falloff *= (1 - config.halo_blur * 0.5)
|
|
735
|
+
|
|
736
|
+
alpha = halo_color[3] * falloff
|
|
737
|
+
if alpha > 0.001:
|
|
738
|
+
overlay = np.array([halo_color[0], halo_color[1], halo_color[2]]) * alpha
|
|
739
|
+
output[py, px] = 1.0 - (1.0 - output[py, px]) * (1.0 - overlay)
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
# =============================================================================
|
|
743
|
+
# PyQt6 RENDERER (for GUI preview)
|
|
744
|
+
# =============================================================================
|
|
745
|
+
|
|
746
|
+
class Renderer:
|
|
747
|
+
"""PyQt6-based renderer for spike effects."""
|
|
748
|
+
|
|
749
|
+
def __init__(self):
|
|
750
|
+
self.glow_sprite = self._create_glow_sprite()
|
|
751
|
+
|
|
752
|
+
def _create_glow_sprite(self) -> QImage:
|
|
753
|
+
size = 256
|
|
754
|
+
image = QImage(size, size, QImage.Format.Format_ARGB32_Premultiplied)
|
|
755
|
+
image.fill(Qt.GlobalColor.transparent)
|
|
756
|
+
|
|
757
|
+
painter = QPainter(image)
|
|
758
|
+
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
|
759
|
+
|
|
760
|
+
half = size / 2
|
|
761
|
+
grad = QRadialGradient(half, half, half)
|
|
762
|
+
grad.setColorAt(0, QColor(255, 255, 255, 255))
|
|
763
|
+
grad.setColorAt(0.2, QColor(255, 255, 255, 100))
|
|
764
|
+
grad.setColorAt(0.6, QColor(255, 255, 255, 13))
|
|
765
|
+
grad.setColorAt(1, QColor(255, 255, 255, 0))
|
|
766
|
+
|
|
767
|
+
painter.setBrush(QBrush(grad))
|
|
768
|
+
painter.setPen(Qt.PenStyle.NoPen)
|
|
769
|
+
painter.drawRect(0, 0, size, size)
|
|
770
|
+
painter.end()
|
|
771
|
+
return image
|
|
772
|
+
|
|
773
|
+
def get_star_color(self, star: Star, hue_shift: float, saturation_input: float, alpha: float) -> QColor:
|
|
774
|
+
r, g, b = int(star.color.r), int(star.color.g), int(star.color.b)
|
|
775
|
+
r1, g1, b1 = r / 255.0, g / 255.0, b / 255.0
|
|
776
|
+
max_c = max(r1, g1, b1)
|
|
777
|
+
min_c = min(r1, g1, b1)
|
|
778
|
+
l = (max_c + min_c) / 2.0
|
|
779
|
+
h = 0.0
|
|
780
|
+
s = 0.0
|
|
781
|
+
|
|
782
|
+
if max_c != min_c:
|
|
783
|
+
d = max_c - min_c
|
|
784
|
+
s = d / (2.0 - max_c - min_c) if l > 0.5 else d / (max_c + min_c)
|
|
785
|
+
if max_c == r1:
|
|
786
|
+
h = (g1 - b1) / d + (6.0 if g1 < b1 else 0.0)
|
|
787
|
+
elif max_c == g1:
|
|
788
|
+
h = (b1 - r1) / d + 2.0
|
|
789
|
+
elif max_c == b1:
|
|
790
|
+
h = (r1 - g1) / d + 4.0
|
|
791
|
+
h /= 6.0
|
|
792
|
+
else:
|
|
793
|
+
h = ((star.x * 0.618 + star.y * 0.382) % 1.0)
|
|
794
|
+
|
|
795
|
+
new_h = (h * 360.0) + hue_shift
|
|
796
|
+
boosted_s = min(1.0, s * 16.0)
|
|
797
|
+
|
|
798
|
+
if saturation_input <= 1.0:
|
|
799
|
+
final_s = boosted_s * saturation_input
|
|
800
|
+
final_l = max(l, 0.65)
|
|
801
|
+
else:
|
|
802
|
+
hyper_factor = saturation_input - 1.0
|
|
803
|
+
final_s = boosted_s + (1.0 - boosted_s) * hyper_factor
|
|
804
|
+
base_l = max(l, 0.65)
|
|
805
|
+
final_l = base_l + (0.5 - base_l) * hyper_factor
|
|
806
|
+
|
|
807
|
+
final_s = max(0.0, min(1.0, final_s))
|
|
808
|
+
final_l = max(0.4, min(0.95, final_l))
|
|
809
|
+
final_h = (new_h % 360.0) / 360.0
|
|
810
|
+
|
|
811
|
+
return QColor.fromHslF(final_h, final_s, final_l, alpha)
|
|
812
|
+
|
|
813
|
+
def render(self, painter: QPainter, width: int, height: int, stars: List[Star], config: SpikeConfig):
|
|
814
|
+
if not stars:
|
|
815
|
+
return
|
|
816
|
+
|
|
817
|
+
limit = int(len(stars) * (config.star_amount / 100.0))
|
|
818
|
+
active_stars = stars[:limit]
|
|
819
|
+
|
|
820
|
+
if config.min_star_size > 0:
|
|
821
|
+
internal_min_size = config.min_star_size * 0.02
|
|
822
|
+
active_stars = [star for star in active_stars if star.radius >= internal_min_size]
|
|
823
|
+
|
|
824
|
+
internal_max_size = 96 + (config.max_star_size * 0.04)
|
|
825
|
+
if internal_max_size < 100 and len(active_stars) > 0:
|
|
826
|
+
sorted_by_size = sorted(active_stars, key=lambda s: s.radius, reverse=True)
|
|
827
|
+
removal_percentage = (100 - internal_max_size) / 100.0
|
|
828
|
+
num_to_remove = int(len(sorted_by_size) * removal_percentage)
|
|
829
|
+
if num_to_remove > 0:
|
|
830
|
+
stars_to_remove_ids = set(id(star) for star in sorted_by_size[:num_to_remove])
|
|
831
|
+
active_stars = [star for star in active_stars if id(star) not in stars_to_remove_ids]
|
|
832
|
+
|
|
833
|
+
painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Screen)
|
|
834
|
+
|
|
835
|
+
deg_to_rad = math.pi / 180.0
|
|
836
|
+
main_angle_rad = config.angle * deg_to_rad
|
|
837
|
+
sec_angle_rad = (config.angle + config.secondary_offset) * deg_to_rad
|
|
838
|
+
|
|
839
|
+
# Soft Flare
|
|
840
|
+
if config.soft_flare_intensity > 0:
|
|
841
|
+
for star in active_stars:
|
|
842
|
+
glow_r = (star.radius * config.soft_flare_size * 0.4 + (star.radius * 2))
|
|
843
|
+
if glow_r > 2:
|
|
844
|
+
draw_size = glow_r * 2
|
|
845
|
+
opacity = config.soft_flare_intensity * 0.8 * star.brightness
|
|
846
|
+
painter.setOpacity(min(1.0, opacity))
|
|
847
|
+
target_rect = QRectF(star.x - glow_r, star.y - glow_r, draw_size, draw_size)
|
|
848
|
+
painter.drawImage(target_rect, self.glow_sprite, QRectF(self.glow_sprite.rect()))
|
|
849
|
+
painter.setOpacity(1.0)
|
|
850
|
+
|
|
851
|
+
# Spikes
|
|
852
|
+
for star in active_stars:
|
|
853
|
+
radius_factor = math.pow(star.radius, 1.2)
|
|
854
|
+
base_length = radius_factor * (config.length / 40.0) * config.global_scale
|
|
855
|
+
thickness = max(0.5, star.radius * config.spike_width * 0.15 * config.global_scale)
|
|
856
|
+
|
|
857
|
+
if base_length < 2:
|
|
858
|
+
continue
|
|
859
|
+
|
|
860
|
+
color = self.get_star_color(star, config.hue_shift, config.color_saturation, config.intensity)
|
|
861
|
+
sec_color = self.get_star_color(star, config.hue_shift, config.color_saturation, config.secondary_intensity)
|
|
862
|
+
|
|
863
|
+
# Main Spikes
|
|
864
|
+
if config.intensity > 0:
|
|
865
|
+
rainbow_str = config.rainbow_spike_intensity if (config.enable_rainbow and config.rainbow_spikes) else 0
|
|
866
|
+
|
|
867
|
+
for i in range(int(config.quantity)):
|
|
868
|
+
theta = main_angle_rad + (i * (math.pi * 2) / config.quantity)
|
|
869
|
+
cos_t = math.cos(theta)
|
|
870
|
+
sin_t = math.sin(theta)
|
|
871
|
+
|
|
872
|
+
start_x = star.x + cos_t * 0.5
|
|
873
|
+
start_y = star.y + sin_t * 0.5
|
|
874
|
+
end_x = star.x + cos_t * base_length
|
|
875
|
+
end_y = star.y + sin_t * base_length
|
|
876
|
+
|
|
877
|
+
# 1. Standard Spike (dimmed if rainbow enabled)
|
|
878
|
+
if rainbow_str > 0:
|
|
879
|
+
painter.setOpacity(0.4)
|
|
880
|
+
|
|
881
|
+
grad = QLinearGradient(star.x, star.y, end_x, end_y)
|
|
882
|
+
grad.setColorAt(0, color)
|
|
883
|
+
fade_point = max(0.0, min(0.99, config.sharpness))
|
|
884
|
+
if fade_point > 0:
|
|
885
|
+
c_mid = QColor(color)
|
|
886
|
+
c_mid.setAlphaF(min(1.0, config.intensity * 0.8))
|
|
887
|
+
grad.setColorAt(fade_point, c_mid)
|
|
888
|
+
c_end = QColor(int(star.color.r), int(star.color.g), int(star.color.b), 0)
|
|
889
|
+
grad.setColorAt(1, c_end)
|
|
890
|
+
|
|
891
|
+
pen = QPen(QBrush(grad), thickness)
|
|
892
|
+
pen.setCapStyle(Qt.PenCapStyle.FlatCap)
|
|
893
|
+
painter.setPen(pen)
|
|
894
|
+
painter.drawLine(QPointF(start_x, start_y), QPointF(end_x, end_y))
|
|
895
|
+
|
|
896
|
+
if rainbow_str > 0:
|
|
897
|
+
painter.setOpacity(1.0)
|
|
898
|
+
|
|
899
|
+
# 2. Rainbow Overlay (IF ENABLED)
|
|
900
|
+
if rainbow_str > 0:
|
|
901
|
+
r_grad = QLinearGradient(star.x, star.y, end_x, end_y)
|
|
902
|
+
r_grad.setColorAt(0, color)
|
|
903
|
+
|
|
904
|
+
stops = 10
|
|
905
|
+
for s in range(1, stops + 1):
|
|
906
|
+
pos = s / stops
|
|
907
|
+
if pos > config.rainbow_spike_length:
|
|
908
|
+
break
|
|
909
|
+
|
|
910
|
+
hue = (pos * 360.0 * config.rainbow_spike_frequency) % 360.0
|
|
911
|
+
a = min(1.0, config.intensity * rainbow_str * 2.0) * (1.0 - pos)
|
|
912
|
+
c = QColor.fromHslF(hue / 360.0, 0.8, 0.6, min(1.0, a))
|
|
913
|
+
r_grad.setColorAt(pos, c)
|
|
914
|
+
|
|
915
|
+
r_grad.setColorAt(1, QColor(0, 0, 0, 0))
|
|
916
|
+
|
|
917
|
+
r_pen = QPen(QBrush(r_grad), thickness)
|
|
918
|
+
r_pen.setCapStyle(Qt.PenCapStyle.FlatCap)
|
|
919
|
+
painter.setPen(r_pen)
|
|
920
|
+
painter.drawLine(QPointF(start_x, start_y), QPointF(end_x, end_y))
|
|
921
|
+
|
|
922
|
+
# Secondary Spikes
|
|
923
|
+
if config.secondary_intensity > 0:
|
|
924
|
+
sec_len = base_length * (config.secondary_length / config.length)
|
|
925
|
+
for i in range(int(config.quantity)):
|
|
926
|
+
theta = sec_angle_rad + (i * (math.pi * 2) / config.quantity)
|
|
927
|
+
cos_t = math.cos(theta)
|
|
928
|
+
sin_t = math.sin(theta)
|
|
929
|
+
|
|
930
|
+
start_x = star.x + cos_t * 1.0
|
|
931
|
+
start_y = star.y + sin_t * 1.0
|
|
932
|
+
end_x = star.x + cos_t * sec_len
|
|
933
|
+
end_y = star.y + sin_t * sec_len
|
|
934
|
+
|
|
935
|
+
grad = QLinearGradient(star.x, star.y, end_x, end_y)
|
|
936
|
+
grad.setColorAt(0, sec_color)
|
|
937
|
+
grad.setColorAt(1, QColor(0, 0, 0, 0))
|
|
938
|
+
|
|
939
|
+
pen = QPen(QBrush(grad), thickness * 0.6)
|
|
940
|
+
pen.setCapStyle(Qt.PenCapStyle.FlatCap)
|
|
941
|
+
painter.setPen(pen)
|
|
942
|
+
painter.drawLine(QPointF(start_x, start_y), QPointF(end_x, end_y))
|
|
943
|
+
|
|
944
|
+
# Halo
|
|
945
|
+
if config.enable_halo and config.halo_intensity > 0:
|
|
946
|
+
classification_score = star.radius * star.brightness
|
|
947
|
+
intensity_weight = math.pow(min(1.0, classification_score / 10.0), 2)
|
|
948
|
+
|
|
949
|
+
if intensity_weight > 0.01:
|
|
950
|
+
final_halo_intensity = config.halo_intensity * intensity_weight
|
|
951
|
+
halo_color = self.get_star_color(star, config.hue_shift, config.halo_saturation, final_halo_intensity)
|
|
952
|
+
|
|
953
|
+
r_halo = star.radius * config.halo_scale
|
|
954
|
+
if r_halo > 0.5:
|
|
955
|
+
blur_expand = config.halo_blur * 20.0
|
|
956
|
+
relative_width = r_halo * (config.halo_width * 0.15)
|
|
957
|
+
inner_r = max(0.0, r_halo - relative_width/2.0)
|
|
958
|
+
outer_r = r_halo + relative_width/2.0
|
|
959
|
+
draw_outer = outer_r + blur_expand
|
|
960
|
+
|
|
961
|
+
grad = QRadialGradient(star.x, star.y, draw_outer)
|
|
962
|
+
stop_start = inner_r / draw_outer
|
|
963
|
+
stop_end = outer_r / draw_outer
|
|
964
|
+
|
|
965
|
+
grad.setColorAt(0, QColor(0,0,0,0))
|
|
966
|
+
grad.setColorAt(max(0, stop_start - 0.05), QColor(0,0,0,0))
|
|
967
|
+
grad.setColorAt((stop_start + stop_end)/2, halo_color)
|
|
968
|
+
grad.setColorAt(min(1, stop_end + 0.05), QColor(0,0,0,0))
|
|
969
|
+
grad.setColorAt(1, QColor(0,0,0,0))
|
|
970
|
+
|
|
971
|
+
painter.setBrush(QBrush(grad))
|
|
972
|
+
painter.setPen(Qt.PenStyle.NoPen)
|
|
973
|
+
painter.drawEllipse(QPointF(star.x, star.y), draw_outer, draw_outer)
|
|
974
|
+
|
|
975
|
+
|
|
976
|
+
# =============================================================================
|
|
977
|
+
# MAIN SCRIPT ENTRY POINT
|
|
978
|
+
# =============================================================================
|
|
979
|
+
|
|
980
|
+
def run(ctx):
|
|
981
|
+
"""Main entry point for SETI Astro script - Opens GUI interface."""
|
|
982
|
+
ctx.log("AstroSpike - Opening interface...")
|
|
983
|
+
|
|
984
|
+
# Get the active image
|
|
985
|
+
img = ctx.get_image()
|
|
986
|
+
if img is None:
|
|
987
|
+
ctx.log("Error: No active image found. Please open an image first.")
|
|
988
|
+
return
|
|
989
|
+
|
|
990
|
+
# Ensure image is float32 and in 0-1 range
|
|
991
|
+
if img.dtype != np.float32:
|
|
992
|
+
img = img.astype(np.float32)
|
|
993
|
+
|
|
994
|
+
if img.max() > 1.0:
|
|
995
|
+
img = img / 255.0
|
|
996
|
+
|
|
997
|
+
# Handle grayscale images
|
|
998
|
+
if len(img.shape) == 2:
|
|
999
|
+
img = np.stack([img, img, img], axis=-1)
|
|
1000
|
+
elif img.shape[2] == 1:
|
|
1001
|
+
img = np.concatenate([img, img, img], axis=-1)
|
|
1002
|
+
|
|
1003
|
+
# Ensure we have RGB (not RGBA)
|
|
1004
|
+
if img.shape[2] == 4:
|
|
1005
|
+
img = img[:, :, :3]
|
|
1006
|
+
|
|
1007
|
+
ctx.log(f"Image size: {img.shape[1]}x{img.shape[0]}")
|
|
1008
|
+
|
|
1009
|
+
# Convert to 0-255 uint8 for QImage
|
|
1010
|
+
img_255 = (np.clip(img, 0, 1) * 255).astype(np.uint8)
|
|
1011
|
+
|
|
1012
|
+
# Create and show the GUI window
|
|
1013
|
+
window = AstroSpikeWindow(img_255, img, ctx)
|
|
1014
|
+
window.exec() # Modal dialog
|
|
1015
|
+
|
|
1016
|
+
ctx.log("AstroSpike completed.")
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
# =============================================================================
|
|
1020
|
+
# UI CLASSES
|
|
1021
|
+
# =============================================================================
|
|
1022
|
+
|
|
1023
|
+
class StarDetectionThread(QThread):
|
|
1024
|
+
"""Thread for star detection to keep UI responsive."""
|
|
1025
|
+
stars_detected = pyqtSignal(list)
|
|
1026
|
+
|
|
1027
|
+
def __init__(self, image_data, threshold):
|
|
1028
|
+
super().__init__()
|
|
1029
|
+
self.image_data = image_data
|
|
1030
|
+
self.threshold = threshold
|
|
1031
|
+
|
|
1032
|
+
def run(self):
|
|
1033
|
+
stars = detect_stars(self.image_data, self.threshold)
|
|
1034
|
+
self.stars_detected.emit(stars)
|
|
1035
|
+
|
|
1036
|
+
|
|
1037
|
+
class AstroSpikeWindow(QDialog):
|
|
1038
|
+
"""Main AstroSpike GUI window."""
|
|
1039
|
+
|
|
1040
|
+
def __init__(self, image_data_255: np.ndarray, image_data_float: np.ndarray, ctx):
|
|
1041
|
+
super().__init__()
|
|
1042
|
+
self.setWindowTitle("AstroSpike - Star Diffraction Spikes")
|
|
1043
|
+
self.setWindowFlag(Qt.WindowType.Window, True)
|
|
1044
|
+
self.setWindowModality(Qt.WindowModality.NonModal)
|
|
1045
|
+
self.setModal(False)
|
|
1046
|
+
|
|
1047
|
+
self.ctx = ctx
|
|
1048
|
+
self.image_data = image_data_255 # uint8 for detection
|
|
1049
|
+
self.image_data_float = image_data_float # float for output
|
|
1050
|
+
self.config = DEFAULT_CONFIG
|
|
1051
|
+
self.thread = None
|
|
1052
|
+
|
|
1053
|
+
# Tool state
|
|
1054
|
+
self.tool_mode = ToolMode.NONE
|
|
1055
|
+
self.star_input_radius = 4.0
|
|
1056
|
+
self.eraser_input_size = 20.0
|
|
1057
|
+
|
|
1058
|
+
# History management
|
|
1059
|
+
self.history: List[List[Star]] = []
|
|
1060
|
+
self.history_index = -1
|
|
1061
|
+
|
|
1062
|
+
# Debounce timer
|
|
1063
|
+
self.detect_timer = QTimer()
|
|
1064
|
+
self.detect_timer.setSingleShot(True)
|
|
1065
|
+
self.detect_timer.setInterval(200)
|
|
1066
|
+
self.detect_timer.timeout.connect(self.detect_stars)
|
|
1067
|
+
|
|
1068
|
+
# Convert numpy to QImage
|
|
1069
|
+
height, width = self.image_data.shape[:2]
|
|
1070
|
+
bytes_per_line = 3 * width
|
|
1071
|
+
self.qimage = QImage(self.image_data.data, width, height, bytes_per_line, QImage.Format.Format_RGB888)
|
|
1072
|
+
|
|
1073
|
+
self._init_ui()
|
|
1074
|
+
self._apply_dark_theme()
|
|
1075
|
+
|
|
1076
|
+
# Auto-detect stars on open
|
|
1077
|
+
QTimer.singleShot(100, self.detect_stars)
|
|
1078
|
+
|
|
1079
|
+
def _apply_dark_theme(self):
|
|
1080
|
+
"""Apply dark theme to window."""
|
|
1081
|
+
dark_palette = QPalette()
|
|
1082
|
+
dark_palette.setColor(QPalette.ColorRole.Window, QColor(30, 30, 30))
|
|
1083
|
+
dark_palette.setColor(QPalette.ColorRole.WindowText, QColor(224, 224, 224))
|
|
1084
|
+
dark_palette.setColor(QPalette.ColorRole.Base, QColor(25, 25, 25))
|
|
1085
|
+
dark_palette.setColor(QPalette.ColorRole.AlternateBase, QColor(45, 45, 48))
|
|
1086
|
+
dark_palette.setColor(QPalette.ColorRole.Text, QColor(224, 224, 224))
|
|
1087
|
+
dark_palette.setColor(QPalette.ColorRole.Button, QColor(45, 45, 48))
|
|
1088
|
+
dark_palette.setColor(QPalette.ColorRole.ButtonText, QColor(224, 224, 224))
|
|
1089
|
+
dark_palette.setColor(QPalette.ColorRole.Highlight, QColor(0, 122, 204))
|
|
1090
|
+
dark_palette.setColor(QPalette.ColorRole.HighlightedText, QColor(255, 255, 255))
|
|
1091
|
+
self.setPalette(dark_palette)
|
|
1092
|
+
|
|
1093
|
+
def _init_ui(self):
|
|
1094
|
+
root_layout = QVBoxLayout(self)
|
|
1095
|
+
root_layout.setSizeConstraint(QVBoxLayout.SizeConstraint.SetNoConstraint)
|
|
1096
|
+
root_layout.setContentsMargins(0, 0, 0, 0)
|
|
1097
|
+
root_layout.setSpacing(0)
|
|
1098
|
+
|
|
1099
|
+
# Top Toolbar
|
|
1100
|
+
top_bar = QWidget()
|
|
1101
|
+
top_bar.setObjectName("topBar")
|
|
1102
|
+
top_bar.setFixedHeight(50)
|
|
1103
|
+
top_layout = QHBoxLayout(top_bar)
|
|
1104
|
+
top_layout.setContentsMargins(10, 0, 10, 0)
|
|
1105
|
+
top_layout.setSpacing(15)
|
|
1106
|
+
|
|
1107
|
+
# Apply to Document button
|
|
1108
|
+
btn_apply = QPushButton("✓ Apply to Document")
|
|
1109
|
+
btn_apply.setToolTip("Apply effect and close")
|
|
1110
|
+
btn_apply.clicked.connect(self.apply_to_document)
|
|
1111
|
+
btn_apply.setStyleSheet("background: #007acc; color: white; font-weight: bold; padding: 8px 16px;")
|
|
1112
|
+
|
|
1113
|
+
btn_save = QPushButton("💾 Save Image")
|
|
1114
|
+
btn_save.setToolTip("Save Image to File")
|
|
1115
|
+
btn_save.clicked.connect(self.save_image)
|
|
1116
|
+
|
|
1117
|
+
top_layout.addWidget(btn_apply)
|
|
1118
|
+
top_layout.addWidget(btn_save)
|
|
1119
|
+
|
|
1120
|
+
# Separator
|
|
1121
|
+
line1 = QWidget()
|
|
1122
|
+
line1.setFixedWidth(1)
|
|
1123
|
+
line1.setStyleSheet("background: #444;")
|
|
1124
|
+
top_layout.addWidget(line1)
|
|
1125
|
+
|
|
1126
|
+
# Undo/Redo
|
|
1127
|
+
self.btn_undo = QPushButton("↩️ Undo")
|
|
1128
|
+
self.btn_undo.clicked.connect(self.undo)
|
|
1129
|
+
self.btn_undo.setEnabled(False)
|
|
1130
|
+
|
|
1131
|
+
self.btn_redo = QPushButton("↪️ Redo")
|
|
1132
|
+
self.btn_redo.clicked.connect(self.redo)
|
|
1133
|
+
self.btn_redo.setEnabled(False)
|
|
1134
|
+
|
|
1135
|
+
top_layout.addWidget(self.btn_undo)
|
|
1136
|
+
top_layout.addWidget(self.btn_redo)
|
|
1137
|
+
|
|
1138
|
+
# Separator
|
|
1139
|
+
line2 = QWidget()
|
|
1140
|
+
line2.setFixedWidth(1)
|
|
1141
|
+
line2.setStyleSheet("background: #444;")
|
|
1142
|
+
top_layout.addWidget(line2)
|
|
1143
|
+
|
|
1144
|
+
# Tools
|
|
1145
|
+
lbl_tools = QLabel("Tools:")
|
|
1146
|
+
lbl_tools.setStyleSheet("color: #888; font-weight: bold;")
|
|
1147
|
+
top_layout.addWidget(lbl_tools)
|
|
1148
|
+
|
|
1149
|
+
self.btn_pan = QPushButton("✋ Pan")
|
|
1150
|
+
self.btn_pan.setCheckable(True)
|
|
1151
|
+
self.btn_pan.setChecked(True)
|
|
1152
|
+
self.btn_pan.clicked.connect(lambda: self.set_tool_mode(ToolMode.NONE))
|
|
1153
|
+
|
|
1154
|
+
self.btn_brush_add = QPushButton("⭐ Add Star")
|
|
1155
|
+
self.btn_brush_add.setCheckable(True)
|
|
1156
|
+
self.btn_brush_add.clicked.connect(lambda: self.set_tool_mode(ToolMode.ADD))
|
|
1157
|
+
|
|
1158
|
+
self.btn_eraser = QPushButton("🧹 Eraser")
|
|
1159
|
+
self.btn_eraser.setCheckable(True)
|
|
1160
|
+
self.btn_eraser.clicked.connect(lambda: self.set_tool_mode(ToolMode.ERASE))
|
|
1161
|
+
|
|
1162
|
+
top_layout.addWidget(self.btn_pan)
|
|
1163
|
+
top_layout.addWidget(self.btn_brush_add)
|
|
1164
|
+
top_layout.addWidget(self.btn_eraser)
|
|
1165
|
+
|
|
1166
|
+
# Tool Size Controls
|
|
1167
|
+
line_tool_sep = QWidget()
|
|
1168
|
+
line_tool_sep.setFixedWidth(1)
|
|
1169
|
+
line_tool_sep.setStyleSheet("background: #444;")
|
|
1170
|
+
top_layout.addWidget(line_tool_sep)
|
|
1171
|
+
|
|
1172
|
+
lbl_star_size = QLabel("Star Size:")
|
|
1173
|
+
lbl_star_size.setStyleSheet("color: #888;")
|
|
1174
|
+
top_layout.addWidget(lbl_star_size)
|
|
1175
|
+
|
|
1176
|
+
self.slider_star_size = QSlider(Qt.Orientation.Horizontal)
|
|
1177
|
+
self.slider_star_size.setRange(1, 50)
|
|
1178
|
+
self.slider_star_size.setValue(int(self.star_input_radius))
|
|
1179
|
+
self.slider_star_size.setFixedWidth(80)
|
|
1180
|
+
self.slider_star_size.valueChanged.connect(self.on_star_size_changed)
|
|
1181
|
+
top_layout.addWidget(self.slider_star_size)
|
|
1182
|
+
|
|
1183
|
+
self.lbl_star_size_val = QLabel(f"{self.star_input_radius:.0f}")
|
|
1184
|
+
self.lbl_star_size_val.setFixedWidth(25)
|
|
1185
|
+
top_layout.addWidget(self.lbl_star_size_val)
|
|
1186
|
+
|
|
1187
|
+
lbl_eraser_size = QLabel("Eraser:")
|
|
1188
|
+
lbl_eraser_size.setStyleSheet("color: #888;")
|
|
1189
|
+
top_layout.addWidget(lbl_eraser_size)
|
|
1190
|
+
|
|
1191
|
+
self.slider_eraser_size = QSlider(Qt.Orientation.Horizontal)
|
|
1192
|
+
self.slider_eraser_size.setRange(5, 100)
|
|
1193
|
+
self.slider_eraser_size.setValue(int(self.eraser_input_size))
|
|
1194
|
+
self.slider_eraser_size.setFixedWidth(80)
|
|
1195
|
+
self.slider_eraser_size.valueChanged.connect(self.on_eraser_size_changed)
|
|
1196
|
+
top_layout.addWidget(self.slider_eraser_size)
|
|
1197
|
+
|
|
1198
|
+
self.lbl_eraser_size_val = QLabel(f"{self.eraser_input_size:.0f}")
|
|
1199
|
+
self.lbl_eraser_size_val.setFixedWidth(25)
|
|
1200
|
+
top_layout.addWidget(self.lbl_eraser_size_val)
|
|
1201
|
+
|
|
1202
|
+
# Separator
|
|
1203
|
+
line3 = QWidget()
|
|
1204
|
+
line3.setFixedWidth(1)
|
|
1205
|
+
line3.setStyleSheet("background: #444;")
|
|
1206
|
+
top_layout.addWidget(line3)
|
|
1207
|
+
|
|
1208
|
+
# Zoom Controls (standardized)
|
|
1209
|
+
self.btn_zoom_in = themed_toolbtn("zoom-in", "Zoom In")
|
|
1210
|
+
self.btn_zoom_out = themed_toolbtn("zoom-out", "Zoom Out")
|
|
1211
|
+
self.btn_fit = themed_toolbtn("zoom-fit-best", "Fit to View")
|
|
1212
|
+
|
|
1213
|
+
top_layout.addWidget(self.btn_zoom_in)
|
|
1214
|
+
top_layout.addWidget(self.btn_zoom_out)
|
|
1215
|
+
top_layout.addWidget(self.btn_fit)
|
|
1216
|
+
|
|
1217
|
+
top_layout.addStretch()
|
|
1218
|
+
|
|
1219
|
+
self.status_label = QLabel("Ready")
|
|
1220
|
+
self.status_label.setStyleSheet("color: #aaa;")
|
|
1221
|
+
self.status_label.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred)
|
|
1222
|
+
top_layout.addWidget(self.status_label)
|
|
1223
|
+
|
|
1224
|
+
root_layout.addWidget(top_bar)
|
|
1225
|
+
|
|
1226
|
+
# Content Area
|
|
1227
|
+
content_area = QWidget()
|
|
1228
|
+
content_layout = QHBoxLayout(content_area)
|
|
1229
|
+
content_layout.setContentsMargins(0, 0, 0, 0)
|
|
1230
|
+
|
|
1231
|
+
# Canvas
|
|
1232
|
+
self.canvas = CanvasPreview()
|
|
1233
|
+
self.canvas.stars_updated.connect(self.on_stars_updated)
|
|
1234
|
+
self.canvas.set_image(self.qimage)
|
|
1235
|
+
self.canvas.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
|
1236
|
+
self.canvas.setMinimumSize(100, 100)
|
|
1237
|
+
content_layout.addWidget(self.canvas, stretch=1)
|
|
1238
|
+
|
|
1239
|
+
# Connect Zoom
|
|
1240
|
+
self.btn_zoom_in.clicked.connect(self.canvas.zoom_in)
|
|
1241
|
+
self.btn_zoom_out.clicked.connect(self.canvas.zoom_out)
|
|
1242
|
+
self.btn_fit.clicked.connect(self.canvas.fit_to_view)
|
|
1243
|
+
|
|
1244
|
+
# Controls Panel
|
|
1245
|
+
self.controls = ControlPanel(self.config)
|
|
1246
|
+
self.controls.setFixedWidth(340)
|
|
1247
|
+
self.controls.config_changed.connect(self.on_config_changed)
|
|
1248
|
+
self.controls.reset_requested.connect(self.reset_config)
|
|
1249
|
+
|
|
1250
|
+
controls_container = QWidget()
|
|
1251
|
+
controls_container.setObjectName("controlsContainer")
|
|
1252
|
+
controls_layout = QVBoxLayout(controls_container)
|
|
1253
|
+
controls_layout.setContentsMargins(0, 0, 0, 0)
|
|
1254
|
+
controls_layout.addWidget(self.controls)
|
|
1255
|
+
|
|
1256
|
+
content_layout.addWidget(controls_container)
|
|
1257
|
+
|
|
1258
|
+
# content_area deve espandersi verticalmente per riempire lo spazio disponibile
|
|
1259
|
+
root_layout.addWidget(content_area, stretch=1)
|
|
1260
|
+
|
|
1261
|
+
# Style
|
|
1262
|
+
self.setStyleSheet("""
|
|
1263
|
+
QDialog { background-color: #1e1e1e; }
|
|
1264
|
+
QWidget { color: #e0e0e0; font-family: 'Segoe UI', sans-serif; font-size: 13px; }
|
|
1265
|
+
#topBar { background-color: #252526; border-bottom: 1px solid #333; }
|
|
1266
|
+
#controlsContainer { background-color: #252526; border-left: 1px solid #333; }
|
|
1267
|
+
QPushButton {
|
|
1268
|
+
background-color: transparent; border: 1px solid transparent;
|
|
1269
|
+
padding: 6px 12px; border-radius: 4px; color: #ccc;
|
|
1270
|
+
}
|
|
1271
|
+
QPushButton:hover { background-color: #3e3e42; color: white; }
|
|
1272
|
+
QPushButton:pressed { background-color: #007acc; color: white; }
|
|
1273
|
+
QPushButton:checked { background-color: #007acc; color: white; border: 1px solid #005a9e; }
|
|
1274
|
+
QGroupBox {
|
|
1275
|
+
font-weight: bold; border: 1px solid #3e3e42; margin-top: 16px;
|
|
1276
|
+
padding-top: 16px; border-radius: 4px; background: #2d2d30;
|
|
1277
|
+
}
|
|
1278
|
+
QGroupBox::title {
|
|
1279
|
+
subcontrol-origin: margin; subcontrol-position: top left;
|
|
1280
|
+
left: 10px; top: 0px; padding: 0 5px; color: #007acc;
|
|
1281
|
+
}
|
|
1282
|
+
QSlider::groove:horizontal { border: 1px solid #3e3e42; height: 4px; background: #1e1e1e; margin: 2px 0; border-radius: 2px; }
|
|
1283
|
+
QSlider::handle:horizontal { background: #007acc; border: 1px solid #007acc; width: 14px; height: 14px; margin: -6px 0; border-radius: 7px; }
|
|
1284
|
+
QScrollArea { border: none; background: transparent; }
|
|
1285
|
+
QScrollBar:vertical { border: none; background: #1e1e1e; width: 10px; margin: 0; }
|
|
1286
|
+
QScrollBar::handle:vertical { background: #424242; min-height: 20px; border-radius: 5px; }
|
|
1287
|
+
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0px; }
|
|
1288
|
+
""")
|
|
1289
|
+
|
|
1290
|
+
def detect_stars(self):
|
|
1291
|
+
if self.image_data is None:
|
|
1292
|
+
return
|
|
1293
|
+
|
|
1294
|
+
if self.thread and self.thread.isRunning():
|
|
1295
|
+
self.detect_timer.start(100)
|
|
1296
|
+
return
|
|
1297
|
+
|
|
1298
|
+
self.status_label.setText("Detecting stars...")
|
|
1299
|
+
self.thread = StarDetectionThread(self.image_data, self.config.threshold)
|
|
1300
|
+
self.thread.stars_detected.connect(self.on_stars_detected)
|
|
1301
|
+
self.thread.start()
|
|
1302
|
+
|
|
1303
|
+
def on_stars_detected(self, stars):
|
|
1304
|
+
self.status_label.setText(f"Found {len(stars)} stars")
|
|
1305
|
+
self.canvas.set_stars(stars)
|
|
1306
|
+
self.canvas.set_config(self.config)
|
|
1307
|
+
self._reset_history(stars)
|
|
1308
|
+
|
|
1309
|
+
def on_config_changed(self, config):
|
|
1310
|
+
current_threshold = self.thread.threshold if self.thread else -1
|
|
1311
|
+
if config.threshold != current_threshold:
|
|
1312
|
+
self.detect_timer.start()
|
|
1313
|
+
self.config = config
|
|
1314
|
+
self.canvas.set_config(config)
|
|
1315
|
+
|
|
1316
|
+
def reset_config(self):
|
|
1317
|
+
self.config = SpikeConfig()
|
|
1318
|
+
self.controls.set_config(self.config)
|
|
1319
|
+
self.canvas.set_config(self.config)
|
|
1320
|
+
self.detect_stars()
|
|
1321
|
+
|
|
1322
|
+
def set_tool_mode(self, mode: ToolMode):
|
|
1323
|
+
self.tool_mode = mode
|
|
1324
|
+
self.canvas.set_tool_mode(mode)
|
|
1325
|
+
self.btn_pan.setChecked(mode == ToolMode.NONE)
|
|
1326
|
+
self.btn_brush_add.setChecked(mode == ToolMode.ADD)
|
|
1327
|
+
self.btn_eraser.setChecked(mode == ToolMode.ERASE)
|
|
1328
|
+
|
|
1329
|
+
def on_star_size_changed(self, value: int):
|
|
1330
|
+
self.star_input_radius = float(value)
|
|
1331
|
+
self.lbl_star_size_val.setText(f"{value}")
|
|
1332
|
+
self.canvas.set_star_input_radius(self.star_input_radius)
|
|
1333
|
+
|
|
1334
|
+
def on_eraser_size_changed(self, value: int):
|
|
1335
|
+
self.eraser_input_size = float(value)
|
|
1336
|
+
self.lbl_eraser_size_val.setText(f"{value}")
|
|
1337
|
+
self.canvas.set_eraser_input_size(self.eraser_input_size)
|
|
1338
|
+
|
|
1339
|
+
def on_stars_updated(self, new_stars: list, push_history: bool):
|
|
1340
|
+
self.canvas.stars = new_stars
|
|
1341
|
+
if push_history:
|
|
1342
|
+
self.history = self.history[:self.history_index + 1]
|
|
1343
|
+
self.history.append(list(new_stars))
|
|
1344
|
+
self.history_index += 1
|
|
1345
|
+
self._update_history_buttons()
|
|
1346
|
+
self.canvas.update()
|
|
1347
|
+
|
|
1348
|
+
def _update_history_buttons(self):
|
|
1349
|
+
self.btn_undo.setEnabled(self.history_index > 0)
|
|
1350
|
+
self.btn_redo.setEnabled(self.history_index < len(self.history) - 1)
|
|
1351
|
+
|
|
1352
|
+
def undo(self):
|
|
1353
|
+
if self.history_index > 0:
|
|
1354
|
+
self.history_index -= 1
|
|
1355
|
+
self.canvas.stars = list(self.history[self.history_index])
|
|
1356
|
+
self.canvas.update()
|
|
1357
|
+
self._update_history_buttons()
|
|
1358
|
+
|
|
1359
|
+
def redo(self):
|
|
1360
|
+
if self.history_index < len(self.history) - 1:
|
|
1361
|
+
self.history_index += 1
|
|
1362
|
+
self.canvas.stars = list(self.history[self.history_index])
|
|
1363
|
+
self.canvas.update()
|
|
1364
|
+
self._update_history_buttons()
|
|
1365
|
+
|
|
1366
|
+
def _reset_history(self, initial_stars: list):
|
|
1367
|
+
self.history = [list(initial_stars)]
|
|
1368
|
+
self.history_index = 0
|
|
1369
|
+
self._update_history_buttons()
|
|
1370
|
+
|
|
1371
|
+
def save_image(self):
|
|
1372
|
+
"""Save rendered image to file."""
|
|
1373
|
+
file_path, _ = QFileDialog.getSaveFileName(
|
|
1374
|
+
self, "Save Image", "astrospike_output.png",
|
|
1375
|
+
"PNG Images (*.png);;JPEG Images (*.jpg);;TIFF Images (*.tif)"
|
|
1376
|
+
)
|
|
1377
|
+
if file_path:
|
|
1378
|
+
final_image = self.qimage.copy()
|
|
1379
|
+
painter = QPainter(final_image)
|
|
1380
|
+
self.canvas.renderer.render(painter, final_image.width(), final_image.height(),
|
|
1381
|
+
self.canvas.stars, self.config)
|
|
1382
|
+
painter.end()
|
|
1383
|
+
final_image.save(file_path)
|
|
1384
|
+
self.status_label.setText(f"Saved to {file_path}")
|
|
1385
|
+
self.ctx.log(f"Saved image to {file_path}")
|
|
1386
|
+
|
|
1387
|
+
def apply_to_document(self):
|
|
1388
|
+
"""Apply the effect to the SETI Astro document and close."""
|
|
1389
|
+
self.status_label.setText("Applying to document...")
|
|
1390
|
+
|
|
1391
|
+
# Render to numpy array
|
|
1392
|
+
output = self.image_data_float.copy()
|
|
1393
|
+
render_spikes(output, self.canvas.stars, self.config)
|
|
1394
|
+
output = np.clip(output, 0.0, 1.0)
|
|
1395
|
+
|
|
1396
|
+
# Apply to document
|
|
1397
|
+
self.ctx.set_image(output, step_name="AstroSpike Effect")
|
|
1398
|
+
self.ctx.log(f"Applied AstroSpike effect with {len(self.canvas.stars)} stars")
|
|
1399
|
+
|
|
1400
|
+
self.accept() # Close dialog
|
|
1401
|
+
|
|
1402
|
+
def closeEvent(self, event):
|
|
1403
|
+
if self.thread and self.thread.isRunning():
|
|
1404
|
+
self.thread.terminate()
|
|
1405
|
+
self.thread.wait()
|
|
1406
|
+
event.accept()
|
|
1407
|
+
|
|
1408
|
+
|
|
1409
|
+
class SliderControl(QWidget):
|
|
1410
|
+
"""Slider control with label and value display."""
|
|
1411
|
+
value_changed = pyqtSignal(float)
|
|
1412
|
+
|
|
1413
|
+
def __init__(self, label: str, min_val: float, max_val: float, step: float, initial: float, unit: str = ""):
|
|
1414
|
+
super().__init__()
|
|
1415
|
+
self.min_val = min_val
|
|
1416
|
+
self.max_val = max_val
|
|
1417
|
+
self.step = step
|
|
1418
|
+
self.unit = unit
|
|
1419
|
+
|
|
1420
|
+
layout = QVBoxLayout(self)
|
|
1421
|
+
layout.setContentsMargins(0, 5, 0, 5)
|
|
1422
|
+
layout.setSpacing(2)
|
|
1423
|
+
|
|
1424
|
+
header = QHBoxLayout()
|
|
1425
|
+
self.label = QLabel(label)
|
|
1426
|
+
self.value_label = QLabel(f"{initial:.2f}{unit}")
|
|
1427
|
+
header.addWidget(self.label)
|
|
1428
|
+
header.addStretch()
|
|
1429
|
+
header.addWidget(self.value_label)
|
|
1430
|
+
|
|
1431
|
+
self.slider = QSlider(Qt.Orientation.Horizontal)
|
|
1432
|
+
self.slider.setRange(0, 1000)
|
|
1433
|
+
self.slider.setValue(self._float_to_int(initial))
|
|
1434
|
+
self.slider.valueChanged.connect(self._on_slider_change)
|
|
1435
|
+
|
|
1436
|
+
layout.addLayout(header)
|
|
1437
|
+
layout.addWidget(self.slider)
|
|
1438
|
+
|
|
1439
|
+
def _float_to_int(self, val: float) -> int:
|
|
1440
|
+
ratio = (val - self.min_val) / (self.max_val - self.min_val)
|
|
1441
|
+
return int(ratio * 1000)
|
|
1442
|
+
|
|
1443
|
+
def _int_to_float(self, val: int) -> float:
|
|
1444
|
+
ratio = val / 1000.0
|
|
1445
|
+
return self.min_val + ratio * (self.max_val - self.min_val)
|
|
1446
|
+
|
|
1447
|
+
def _on_slider_change(self, val: int):
|
|
1448
|
+
f_val = self._int_to_float(val)
|
|
1449
|
+
if self.step > 0:
|
|
1450
|
+
f_val = round(f_val / self.step) * self.step
|
|
1451
|
+
self.value_label.setText(f"{f_val:.2f}{self.unit}")
|
|
1452
|
+
self.value_changed.emit(f_val)
|
|
1453
|
+
|
|
1454
|
+
def set_value(self, val: float):
|
|
1455
|
+
self.slider.blockSignals(True)
|
|
1456
|
+
self.slider.setValue(self._float_to_int(val))
|
|
1457
|
+
self.value_label.setText(f"{val:.2f}{self.unit}")
|
|
1458
|
+
self.slider.blockSignals(False)
|
|
1459
|
+
|
|
1460
|
+
|
|
1461
|
+
class ControlPanel(QWidget):
|
|
1462
|
+
"""Control panel with all spike parameters."""
|
|
1463
|
+
config_changed = pyqtSignal(SpikeConfig)
|
|
1464
|
+
reset_requested = pyqtSignal()
|
|
1465
|
+
|
|
1466
|
+
def __init__(self, config: SpikeConfig):
|
|
1467
|
+
super().__init__()
|
|
1468
|
+
self.config = config
|
|
1469
|
+
self._init_ui()
|
|
1470
|
+
|
|
1471
|
+
def _init_ui(self):
|
|
1472
|
+
main_layout = QVBoxLayout(self)
|
|
1473
|
+
main_layout.setContentsMargins(0, 0, 0, 0)
|
|
1474
|
+
|
|
1475
|
+
header_layout = QHBoxLayout()
|
|
1476
|
+
header_layout.setContentsMargins(10, 10, 10, 0)
|
|
1477
|
+
|
|
1478
|
+
lbl_title = QLabel("PARAMETERS")
|
|
1479
|
+
lbl_title.setStyleSheet("font-weight: bold; color: #888; letter-spacing: 1px;")
|
|
1480
|
+
header_layout.addWidget(lbl_title)
|
|
1481
|
+
header_layout.addStretch()
|
|
1482
|
+
|
|
1483
|
+
btn_reset = QPushButton("↺ Reset")
|
|
1484
|
+
btn_reset.setStyleSheet("background: #333; border: 1px solid #555; padding: 4px 8px; font-size: 11px;")
|
|
1485
|
+
btn_reset.clicked.connect(self.reset_requested.emit)
|
|
1486
|
+
header_layout.addWidget(btn_reset)
|
|
1487
|
+
|
|
1488
|
+
main_layout.addLayout(header_layout)
|
|
1489
|
+
|
|
1490
|
+
self.scroll = QScrollArea()
|
|
1491
|
+
self.scroll.setWidgetResizable(True)
|
|
1492
|
+
self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
|
1493
|
+
|
|
1494
|
+
self.content = QWidget()
|
|
1495
|
+
self.layout = QVBoxLayout(self.content)
|
|
1496
|
+
self.layout.setSpacing(15)
|
|
1497
|
+
|
|
1498
|
+
self._build_controls()
|
|
1499
|
+
|
|
1500
|
+
self.scroll.setWidget(self.content)
|
|
1501
|
+
main_layout.addWidget(self.scroll)
|
|
1502
|
+
|
|
1503
|
+
def _build_controls(self):
|
|
1504
|
+
while self.layout.count():
|
|
1505
|
+
child = self.layout.takeAt(0)
|
|
1506
|
+
if child.widget():
|
|
1507
|
+
child.widget().deleteLater()
|
|
1508
|
+
|
|
1509
|
+
self._add_group("Detection", [
|
|
1510
|
+
("Threshold", 1, 100, 1, self.config.threshold, "threshold", ""),
|
|
1511
|
+
("Quantity Limit %", 0, 100, 1, self.config.star_amount, "star_amount", "%"),
|
|
1512
|
+
("Min Star Size", 0, 100, 1, self.config.min_star_size, "min_star_size", ""),
|
|
1513
|
+
("Max Star Size", 0, 100, 1, self.config.max_star_size, "max_star_size", "")
|
|
1514
|
+
])
|
|
1515
|
+
|
|
1516
|
+
self._add_group("Geometry", [
|
|
1517
|
+
("Global Scale", 0.2, 3.0, 0.1, self.config.global_scale, "global_scale", ""),
|
|
1518
|
+
("Points", 2, 8, 1, self.config.quantity, "quantity", ""),
|
|
1519
|
+
("Length", 10, 1500, 10, self.config.length, "length", ""),
|
|
1520
|
+
("Angle", 0, 180, 1, self.config.angle, "angle", "°"),
|
|
1521
|
+
("Thickness", 0.1, 5.0, 0.1, self.config.spike_width, "spike_width", "")
|
|
1522
|
+
])
|
|
1523
|
+
|
|
1524
|
+
self._add_group("Appearance", [
|
|
1525
|
+
("Intensity", 0, 1.0, 0.05, self.config.intensity, "intensity", ""),
|
|
1526
|
+
("Color Saturation", 0, 2.0, 0.05, self.config.color_saturation, "color_saturation", ""),
|
|
1527
|
+
("Hue Shift", -180, 180, 1, self.config.hue_shift, "hue_shift", "°")
|
|
1528
|
+
])
|
|
1529
|
+
|
|
1530
|
+
# Halo
|
|
1531
|
+
halo_group = QGroupBox("Star Halo / Rings")
|
|
1532
|
+
halo_layout = QVBoxLayout()
|
|
1533
|
+
|
|
1534
|
+
self.halo_check = QCheckBox("Enable Halo")
|
|
1535
|
+
self.halo_check.setChecked(self.config.enable_halo)
|
|
1536
|
+
self.halo_check.toggled.connect(lambda c: self._update_config("enable_halo", c))
|
|
1537
|
+
halo_layout.addWidget(self.halo_check)
|
|
1538
|
+
|
|
1539
|
+
self._add_slider(halo_layout, "Intensity", 0, 1.0, 0.05, self.config.halo_intensity, "halo_intensity", "")
|
|
1540
|
+
self._add_slider(halo_layout, "Radius", 0.1, 5.0, 0.1, self.config.halo_scale, "halo_scale", "")
|
|
1541
|
+
self._add_slider(halo_layout, "Width", 0.2, 10.0, 0.2, self.config.halo_width, "halo_width", "")
|
|
1542
|
+
self._add_slider(halo_layout, "Blur", 0, 10.0, 0.1, self.config.halo_blur, "halo_blur", "")
|
|
1543
|
+
self._add_slider(halo_layout, "Saturation", 0, 3.0, 0.1, self.config.halo_saturation, "halo_saturation", "")
|
|
1544
|
+
|
|
1545
|
+
halo_group.setLayout(halo_layout)
|
|
1546
|
+
self.layout.addWidget(halo_group)
|
|
1547
|
+
|
|
1548
|
+
self._add_group("Secondary Spikes", [
|
|
1549
|
+
("Intensity", 0, 1.0, 0.05, self.config.secondary_intensity, "secondary_intensity", ""),
|
|
1550
|
+
("Length", 0, 500, 10, self.config.secondary_length, "secondary_length", ""),
|
|
1551
|
+
("Offset Angle", 0, 90, 1, self.config.secondary_offset, "secondary_offset", "°")
|
|
1552
|
+
])
|
|
1553
|
+
|
|
1554
|
+
self._add_group("Soft Flare", [
|
|
1555
|
+
("Glow Intensity", 0, 3.0, 0.05, self.config.soft_flare_intensity, "soft_flare_intensity", ""),
|
|
1556
|
+
("Glow Size", 0, 200, 5, self.config.soft_flare_size, "soft_flare_size", "")
|
|
1557
|
+
])
|
|
1558
|
+
|
|
1559
|
+
# Spectral
|
|
1560
|
+
spectral_group = QGroupBox("Spectral Effects")
|
|
1561
|
+
spectral_layout = QVBoxLayout()
|
|
1562
|
+
|
|
1563
|
+
self.rainbow_check = QCheckBox("Enable Rainbow FX")
|
|
1564
|
+
self.rainbow_check.setChecked(self.config.enable_rainbow)
|
|
1565
|
+
self.rainbow_check.toggled.connect(lambda c: self._update_config("enable_rainbow", c))
|
|
1566
|
+
spectral_layout.addWidget(self.rainbow_check)
|
|
1567
|
+
|
|
1568
|
+
self._add_slider(spectral_layout, "Intensity", 0, 1.0, 0.05, self.config.rainbow_spike_intensity, "rainbow_spike_intensity", "")
|
|
1569
|
+
self._add_slider(spectral_layout, "Frequency", 0.1, 3.0, 0.1, self.config.rainbow_spike_frequency, "rainbow_spike_frequency", "")
|
|
1570
|
+
self._add_slider(spectral_layout, "Coverage", 0.1, 1.0, 0.1, self.config.rainbow_spike_length, "rainbow_spike_length", "")
|
|
1571
|
+
|
|
1572
|
+
spectral_group.setLayout(spectral_layout)
|
|
1573
|
+
self.layout.addWidget(spectral_group)
|
|
1574
|
+
|
|
1575
|
+
self.layout.addStretch()
|
|
1576
|
+
|
|
1577
|
+
def _add_group(self, title, sliders):
|
|
1578
|
+
group = QGroupBox(title)
|
|
1579
|
+
layout = QVBoxLayout()
|
|
1580
|
+
for label, min_v, max_v, step, init, key, unit in sliders:
|
|
1581
|
+
self._add_slider(layout, label, min_v, max_v, step, init, key, unit)
|
|
1582
|
+
group.setLayout(layout)
|
|
1583
|
+
self.layout.addWidget(group)
|
|
1584
|
+
|
|
1585
|
+
def _add_slider(self, layout, label, min_v, max_v, step, init, key, unit):
|
|
1586
|
+
slider = SliderControl(label, min_v, max_v, step, init, unit)
|
|
1587
|
+
slider.value_changed.connect(lambda v, k=key: self._update_config(k, v))
|
|
1588
|
+
layout.addWidget(slider)
|
|
1589
|
+
|
|
1590
|
+
def _update_config(self, key, value):
|
|
1591
|
+
setattr(self.config, key, value)
|
|
1592
|
+
self.config_changed.emit(self.config)
|
|
1593
|
+
|
|
1594
|
+
def set_config(self, config: SpikeConfig):
|
|
1595
|
+
self.config = config
|
|
1596
|
+
self._build_controls()
|
|
1597
|
+
|
|
1598
|
+
|
|
1599
|
+
class CanvasPreview(QWidget):
|
|
1600
|
+
"""Canvas widget for image preview with pan/zoom."""
|
|
1601
|
+
stars_updated = pyqtSignal(list, bool)
|
|
1602
|
+
|
|
1603
|
+
def __init__(self):
|
|
1604
|
+
super().__init__()
|
|
1605
|
+
self.setMouseTracking(True)
|
|
1606
|
+
self.image: Optional[QImage] = None
|
|
1607
|
+
self.stars: List[Star] = []
|
|
1608
|
+
self.config: Optional[SpikeConfig] = None
|
|
1609
|
+
self.renderer = Renderer()
|
|
1610
|
+
|
|
1611
|
+
self.scale = 1.0
|
|
1612
|
+
self.offset_x = 0.0
|
|
1613
|
+
self.offset_y = 0.0
|
|
1614
|
+
|
|
1615
|
+
self.is_dragging = False
|
|
1616
|
+
self.last_mouse_pos = QPointF()
|
|
1617
|
+
|
|
1618
|
+
self.tool_mode: ToolMode = ToolMode.NONE
|
|
1619
|
+
self.star_input_radius: float = 4.0
|
|
1620
|
+
self.eraser_input_size: float = 20.0
|
|
1621
|
+
self.cursor_pos = QPointF(-9999, -9999)
|
|
1622
|
+
self.is_erasing = False
|
|
1623
|
+
|
|
1624
|
+
def set_image(self, image: QImage):
|
|
1625
|
+
self.image = image
|
|
1626
|
+
self.fit_to_view()
|
|
1627
|
+
self.update()
|
|
1628
|
+
|
|
1629
|
+
def set_stars(self, stars: List[Star]):
|
|
1630
|
+
self.stars = stars
|
|
1631
|
+
self.update()
|
|
1632
|
+
|
|
1633
|
+
def set_config(self, config: SpikeConfig):
|
|
1634
|
+
self.config = config
|
|
1635
|
+
self.update()
|
|
1636
|
+
|
|
1637
|
+
def set_tool_mode(self, mode: ToolMode):
|
|
1638
|
+
self.tool_mode = mode
|
|
1639
|
+
if mode == ToolMode.NONE:
|
|
1640
|
+
self.setCursor(Qt.CursorShape.OpenHandCursor)
|
|
1641
|
+
else:
|
|
1642
|
+
self.setCursor(Qt.CursorShape.BlankCursor)
|
|
1643
|
+
self.update()
|
|
1644
|
+
|
|
1645
|
+
def set_star_input_radius(self, radius: float):
|
|
1646
|
+
self.star_input_radius = radius
|
|
1647
|
+
self.update()
|
|
1648
|
+
|
|
1649
|
+
def set_eraser_input_size(self, size: float):
|
|
1650
|
+
self.eraser_input_size = size
|
|
1651
|
+
self.update()
|
|
1652
|
+
|
|
1653
|
+
def fit_to_view(self):
|
|
1654
|
+
if not self.image:
|
|
1655
|
+
return
|
|
1656
|
+
w_ratio = self.width() / self.image.width()
|
|
1657
|
+
h_ratio = self.height() / self.image.height()
|
|
1658
|
+
self.scale = min(w_ratio, h_ratio) * 0.9
|
|
1659
|
+
self.center_image()
|
|
1660
|
+
|
|
1661
|
+
def zoom_in(self):
|
|
1662
|
+
self.scale *= 1.2
|
|
1663
|
+
self.center_image()
|
|
1664
|
+
|
|
1665
|
+
def zoom_out(self):
|
|
1666
|
+
self.scale /= 1.2
|
|
1667
|
+
self.center_image()
|
|
1668
|
+
|
|
1669
|
+
def center_image(self):
|
|
1670
|
+
if not self.image:
|
|
1671
|
+
return
|
|
1672
|
+
self.offset_x = (self.width() - self.image.width() * self.scale) / 2
|
|
1673
|
+
self.offset_y = (self.height() - self.image.height() * self.scale) / 2
|
|
1674
|
+
self.update()
|
|
1675
|
+
|
|
1676
|
+
def resizeEvent(self, event):
|
|
1677
|
+
if self.image:
|
|
1678
|
+
self.center_image()
|
|
1679
|
+
super().resizeEvent(event)
|
|
1680
|
+
|
|
1681
|
+
def _screen_to_image(self, screen_pos: QPointF) -> QPointF:
|
|
1682
|
+
img_x = (screen_pos.x() - self.offset_x) / self.scale
|
|
1683
|
+
img_y = (screen_pos.y() - self.offset_y) / self.scale
|
|
1684
|
+
return QPointF(img_x, img_y)
|
|
1685
|
+
|
|
1686
|
+
def paintEvent(self, event: QPaintEvent):
|
|
1687
|
+
painter = QPainter(self)
|
|
1688
|
+
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
|
1689
|
+
painter.fillRect(self.rect(), QColor(10, 10, 12))
|
|
1690
|
+
|
|
1691
|
+
if not self.image:
|
|
1692
|
+
painter.setPen(QColor(100, 100, 100))
|
|
1693
|
+
painter.drawText(self.rect(), Qt.AlignmentFlag.AlignCenter, "No Image Loaded")
|
|
1694
|
+
return
|
|
1695
|
+
|
|
1696
|
+
painter.save()
|
|
1697
|
+
painter.translate(self.offset_x, self.offset_y)
|
|
1698
|
+
painter.scale(self.scale, self.scale)
|
|
1699
|
+
|
|
1700
|
+
target_rect = QRectF(0, 0, self.image.width(), self.image.height())
|
|
1701
|
+
painter.drawImage(target_rect, self.image)
|
|
1702
|
+
|
|
1703
|
+
if self.config:
|
|
1704
|
+
self.renderer.render(painter, self.image.width(), self.image.height(), self.stars, self.config)
|
|
1705
|
+
|
|
1706
|
+
painter.restore()
|
|
1707
|
+
|
|
1708
|
+
if self.tool_mode != ToolMode.NONE and self.cursor_pos.x() > -9000:
|
|
1709
|
+
self._draw_cursor_preview(painter)
|
|
1710
|
+
|
|
1711
|
+
painter.setPen(QColor(200, 200, 200))
|
|
1712
|
+
mode_str = self.tool_mode.value.upper() if self.tool_mode else "NONE"
|
|
1713
|
+
painter.drawText(10, 20, f"Zoom: {self.scale*100:.0f}% | Stars: {len(self.stars)} | Tool: {mode_str}")
|
|
1714
|
+
|
|
1715
|
+
def _draw_cursor_preview(self, painter: QPainter):
|
|
1716
|
+
if self.tool_mode == ToolMode.ADD:
|
|
1717
|
+
preview_radius = self.star_input_radius * self.scale
|
|
1718
|
+
color = QColor(56, 189, 248, 150)
|
|
1719
|
+
border_color = QColor(56, 189, 248, 255)
|
|
1720
|
+
elif self.tool_mode == ToolMode.ERASE:
|
|
1721
|
+
preview_radius = self.eraser_input_size * self.scale
|
|
1722
|
+
color = QColor(248, 113, 113, 80)
|
|
1723
|
+
border_color = QColor(248, 113, 113, 200)
|
|
1724
|
+
else:
|
|
1725
|
+
return
|
|
1726
|
+
|
|
1727
|
+
preview_radius = max(4, preview_radius)
|
|
1728
|
+
painter.setBrush(QBrush(color))
|
|
1729
|
+
painter.setPen(QPen(border_color, 2))
|
|
1730
|
+
painter.drawEllipse(self.cursor_pos, preview_radius, preview_radius)
|
|
1731
|
+
|
|
1732
|
+
def mousePressEvent(self, event: QMouseEvent):
|
|
1733
|
+
if event.button() == Qt.MouseButton.LeftButton:
|
|
1734
|
+
if self.tool_mode == ToolMode.NONE:
|
|
1735
|
+
self.is_dragging = True
|
|
1736
|
+
self.last_mouse_pos = event.position()
|
|
1737
|
+
self.setCursor(Qt.CursorShape.ClosedHandCursor)
|
|
1738
|
+
elif self.tool_mode == ToolMode.ADD:
|
|
1739
|
+
self._add_star_at(event.position())
|
|
1740
|
+
elif self.tool_mode == ToolMode.ERASE:
|
|
1741
|
+
self.is_erasing = True
|
|
1742
|
+
self._erase_stars_at(event.position(), push_history=False)
|
|
1743
|
+
|
|
1744
|
+
def mouseMoveEvent(self, event: QMouseEvent):
|
|
1745
|
+
self.cursor_pos = event.position()
|
|
1746
|
+
|
|
1747
|
+
if self.is_dragging and self.tool_mode == ToolMode.NONE:
|
|
1748
|
+
delta = event.position() - self.last_mouse_pos
|
|
1749
|
+
self.offset_x += delta.x()
|
|
1750
|
+
self.offset_y += delta.y()
|
|
1751
|
+
self.last_mouse_pos = event.position()
|
|
1752
|
+
elif self.is_erasing and self.tool_mode == ToolMode.ERASE:
|
|
1753
|
+
self._erase_stars_at(event.position(), push_history=False)
|
|
1754
|
+
|
|
1755
|
+
self.update()
|
|
1756
|
+
|
|
1757
|
+
def mouseReleaseEvent(self, event: QMouseEvent):
|
|
1758
|
+
if event.button() == Qt.MouseButton.LeftButton:
|
|
1759
|
+
if self.is_dragging and self.tool_mode == ToolMode.NONE:
|
|
1760
|
+
self.is_dragging = False
|
|
1761
|
+
self.setCursor(Qt.CursorShape.OpenHandCursor)
|
|
1762
|
+
elif self.is_erasing and self.tool_mode == ToolMode.ERASE:
|
|
1763
|
+
self.is_erasing = False
|
|
1764
|
+
self.stars_updated.emit(list(self.stars), True)
|
|
1765
|
+
|
|
1766
|
+
def leaveEvent(self, event):
|
|
1767
|
+
self.cursor_pos = QPointF(-9999, -9999)
|
|
1768
|
+
self.update()
|
|
1769
|
+
super().leaveEvent(event)
|
|
1770
|
+
|
|
1771
|
+
def wheelEvent(self, event: QWheelEvent):
|
|
1772
|
+
zoom_sensitivity = 0.001
|
|
1773
|
+
delta = event.angleDelta().y() * zoom_sensitivity
|
|
1774
|
+
|
|
1775
|
+
old_scale = self.scale
|
|
1776
|
+
new_scale = max(0.05, min(20.0, self.scale * (1 + delta)))
|
|
1777
|
+
|
|
1778
|
+
mouse_pos = event.position()
|
|
1779
|
+
rel_x = (mouse_pos.x() - self.offset_x) / old_scale
|
|
1780
|
+
rel_y = (mouse_pos.y() - self.offset_y) / old_scale
|
|
1781
|
+
|
|
1782
|
+
self.offset_x = mouse_pos.x() - rel_x * new_scale
|
|
1783
|
+
self.offset_y = mouse_pos.y() - rel_y * new_scale
|
|
1784
|
+
self.scale = new_scale
|
|
1785
|
+
|
|
1786
|
+
self.update()
|
|
1787
|
+
|
|
1788
|
+
def _add_star_at(self, screen_pos: QPointF):
|
|
1789
|
+
if not self.image:
|
|
1790
|
+
return
|
|
1791
|
+
|
|
1792
|
+
img_pos = self._screen_to_image(screen_pos)
|
|
1793
|
+
|
|
1794
|
+
if img_pos.x() < 0 or img_pos.x() >= self.image.width():
|
|
1795
|
+
return
|
|
1796
|
+
if img_pos.y() < 0 or img_pos.y() >= self.image.height():
|
|
1797
|
+
return
|
|
1798
|
+
|
|
1799
|
+
new_star = Star(
|
|
1800
|
+
x=img_pos.x(),
|
|
1801
|
+
y=img_pos.y(),
|
|
1802
|
+
brightness=1.0,
|
|
1803
|
+
radius=self.star_input_radius,
|
|
1804
|
+
color=Color(255, 255, 255)
|
|
1805
|
+
)
|
|
1806
|
+
|
|
1807
|
+
new_stars = list(self.stars) + [new_star]
|
|
1808
|
+
self.stars = new_stars
|
|
1809
|
+
self.stars_updated.emit(new_stars, True)
|
|
1810
|
+
self.update()
|
|
1811
|
+
|
|
1812
|
+
def _erase_stars_at(self, screen_pos: QPointF, push_history: bool = False):
|
|
1813
|
+
if not self.image:
|
|
1814
|
+
return
|
|
1815
|
+
|
|
1816
|
+
img_pos = self._screen_to_image(screen_pos)
|
|
1817
|
+
erase_radius_sq = self.eraser_input_size * self.eraser_input_size
|
|
1818
|
+
|
|
1819
|
+
initial_count = len(self.stars)
|
|
1820
|
+
|
|
1821
|
+
filtered_stars = [
|
|
1822
|
+
star for star in self.stars
|
|
1823
|
+
if (star.x - img_pos.x()) ** 2 + (star.y - img_pos.y()) ** 2 > erase_radius_sq
|
|
1824
|
+
]
|
|
1825
|
+
|
|
1826
|
+
if len(filtered_stars) != initial_count:
|
|
1827
|
+
self.stars = filtered_stars
|
|
1828
|
+
self.stars_updated.emit(filtered_stars, push_history)
|
|
1829
|
+
self.update()
|
|
1830
|
+
|
|
1831
|
+
|
|
1832
|
+
if __name__ == "__main__":
|
|
1833
|
+
print("AstroSpike Script for SETI Astro")
|
|
1834
|
+
print("=" * 40)
|
|
1835
|
+
print("This script is designed to run within SETI Astro.")
|
|
1836
|
+
print("To use it:")
|
|
1837
|
+
print("1. Copy this file to your SETI Astro scripts folder")
|
|
1838
|
+
print("2. Open an image in SETI Astro")
|
|
1839
|
+
print("3. Run the script from the Scripts menu")
|
|
1840
|
+
print()
|
|
1841
|
+
print("Configuration options can be adjusted at the top of this file.")
|