setiastrosuitepro 1.6.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of setiastrosuitepro might be problematic. Click here for more details.

Files changed (342) hide show
  1. setiastro/__init__.py +2 -0
  2. setiastro/data/SASP_data.fits +0 -0
  3. setiastro/data/catalogs/List_of_Galaxies_with_Distances_Gly.csv +488 -0
  4. setiastro/data/catalogs/astrobin_filters.csv +890 -0
  5. setiastro/data/catalogs/astrobin_filters_page1_local.csv +51 -0
  6. setiastro/data/catalogs/cali2.csv +63 -0
  7. setiastro/data/catalogs/cali2color.csv +65 -0
  8. setiastro/data/catalogs/celestial_catalog - original.csv +16471 -0
  9. setiastro/data/catalogs/celestial_catalog.csv +24031 -0
  10. setiastro/data/catalogs/detected_stars.csv +24784 -0
  11. setiastro/data/catalogs/fits_header_data.csv +46 -0
  12. setiastro/data/catalogs/test.csv +8 -0
  13. setiastro/data/catalogs/updated_celestial_catalog.csv +16471 -0
  14. setiastro/images/Astro_Spikes.png +0 -0
  15. setiastro/images/HRDiagram.png +0 -0
  16. setiastro/images/LExtract.png +0 -0
  17. setiastro/images/LInsert.png +0 -0
  18. setiastro/images/Oxygenation-atm-2.svg.png +0 -0
  19. setiastro/images/RGB080604.png +0 -0
  20. setiastro/images/abeicon.png +0 -0
  21. setiastro/images/aberration.png +0 -0
  22. setiastro/images/andromedatry.png +0 -0
  23. setiastro/images/andromedatry_satellited.png +0 -0
  24. setiastro/images/annotated.png +0 -0
  25. setiastro/images/aperture.png +0 -0
  26. setiastro/images/astrosuite.ico +0 -0
  27. setiastro/images/astrosuite.png +0 -0
  28. setiastro/images/astrosuitepro.icns +0 -0
  29. setiastro/images/astrosuitepro.ico +0 -0
  30. setiastro/images/astrosuitepro.png +0 -0
  31. setiastro/images/background.png +0 -0
  32. setiastro/images/background2.png +0 -0
  33. setiastro/images/benchmark.png +0 -0
  34. setiastro/images/big_moon_stabilizer_timeline.png +0 -0
  35. setiastro/images/big_moon_stabilizer_timeline_clean.png +0 -0
  36. setiastro/images/blaster.png +0 -0
  37. setiastro/images/blink.png +0 -0
  38. setiastro/images/clahe.png +0 -0
  39. setiastro/images/collage.png +0 -0
  40. setiastro/images/colorwheel.png +0 -0
  41. setiastro/images/contsub.png +0 -0
  42. setiastro/images/convo.png +0 -0
  43. setiastro/images/copyslot.png +0 -0
  44. setiastro/images/cosmic.png +0 -0
  45. setiastro/images/cosmicsat.png +0 -0
  46. setiastro/images/crop1.png +0 -0
  47. setiastro/images/cropicon.png +0 -0
  48. setiastro/images/curves.png +0 -0
  49. setiastro/images/cvs.png +0 -0
  50. setiastro/images/debayer.png +0 -0
  51. setiastro/images/denoise_cnn_custom.png +0 -0
  52. setiastro/images/denoise_cnn_graph.png +0 -0
  53. setiastro/images/disk.png +0 -0
  54. setiastro/images/dse.png +0 -0
  55. setiastro/images/exoicon.png +0 -0
  56. setiastro/images/eye.png +0 -0
  57. setiastro/images/fliphorizontal.png +0 -0
  58. setiastro/images/flipvertical.png +0 -0
  59. setiastro/images/font.png +0 -0
  60. setiastro/images/freqsep.png +0 -0
  61. setiastro/images/functionbundle.png +0 -0
  62. setiastro/images/graxpert.png +0 -0
  63. setiastro/images/green.png +0 -0
  64. setiastro/images/gridicon.png +0 -0
  65. setiastro/images/halo.png +0 -0
  66. setiastro/images/hdr.png +0 -0
  67. setiastro/images/histogram.png +0 -0
  68. setiastro/images/hubble.png +0 -0
  69. setiastro/images/imagecombine.png +0 -0
  70. setiastro/images/invert.png +0 -0
  71. setiastro/images/isophote.png +0 -0
  72. setiastro/images/isophote_demo_figure.png +0 -0
  73. setiastro/images/isophote_demo_image.png +0 -0
  74. setiastro/images/isophote_demo_model.png +0 -0
  75. setiastro/images/isophote_demo_residual.png +0 -0
  76. setiastro/images/jwstpupil.png +0 -0
  77. setiastro/images/linearfit.png +0 -0
  78. setiastro/images/livestacking.png +0 -0
  79. setiastro/images/mask.png +0 -0
  80. setiastro/images/maskapply.png +0 -0
  81. setiastro/images/maskcreate.png +0 -0
  82. setiastro/images/maskremove.png +0 -0
  83. setiastro/images/morpho.png +0 -0
  84. setiastro/images/mosaic.png +0 -0
  85. setiastro/images/multiscale_decomp.png +0 -0
  86. setiastro/images/nbtorgb.png +0 -0
  87. setiastro/images/neutral.png +0 -0
  88. setiastro/images/nuke.png +0 -0
  89. setiastro/images/openfile.png +0 -0
  90. setiastro/images/pedestal.png +0 -0
  91. setiastro/images/pen.png +0 -0
  92. setiastro/images/pixelmath.png +0 -0
  93. setiastro/images/platesolve.png +0 -0
  94. setiastro/images/ppp.png +0 -0
  95. setiastro/images/pro.png +0 -0
  96. setiastro/images/project.png +0 -0
  97. setiastro/images/psf.png +0 -0
  98. setiastro/images/redo.png +0 -0
  99. setiastro/images/redoicon.png +0 -0
  100. setiastro/images/rescale.png +0 -0
  101. setiastro/images/rgbalign.png +0 -0
  102. setiastro/images/rgbcombo.png +0 -0
  103. setiastro/images/rgbextract.png +0 -0
  104. setiastro/images/rotate180.png +0 -0
  105. setiastro/images/rotateclockwise.png +0 -0
  106. setiastro/images/rotatecounterclockwise.png +0 -0
  107. setiastro/images/satellite.png +0 -0
  108. setiastro/images/script.png +0 -0
  109. setiastro/images/selectivecolor.png +0 -0
  110. setiastro/images/simbad.png +0 -0
  111. setiastro/images/slot0.png +0 -0
  112. setiastro/images/slot1.png +0 -0
  113. setiastro/images/slot2.png +0 -0
  114. setiastro/images/slot3.png +0 -0
  115. setiastro/images/slot4.png +0 -0
  116. setiastro/images/slot5.png +0 -0
  117. setiastro/images/slot6.png +0 -0
  118. setiastro/images/slot7.png +0 -0
  119. setiastro/images/slot8.png +0 -0
  120. setiastro/images/slot9.png +0 -0
  121. setiastro/images/spcc.png +0 -0
  122. setiastro/images/spin_precession_vs_lunar_distance.png +0 -0
  123. setiastro/images/spinner.gif +0 -0
  124. setiastro/images/stacking.png +0 -0
  125. setiastro/images/staradd.png +0 -0
  126. setiastro/images/staralign.png +0 -0
  127. setiastro/images/starnet.png +0 -0
  128. setiastro/images/starregistration.png +0 -0
  129. setiastro/images/starspike.png +0 -0
  130. setiastro/images/starstretch.png +0 -0
  131. setiastro/images/statstretch.png +0 -0
  132. setiastro/images/supernova.png +0 -0
  133. setiastro/images/uhs.png +0 -0
  134. setiastro/images/undoicon.png +0 -0
  135. setiastro/images/upscale.png +0 -0
  136. setiastro/images/viewbundle.png +0 -0
  137. setiastro/images/whitebalance.png +0 -0
  138. setiastro/images/wimi_icon_256x256.png +0 -0
  139. setiastro/images/wimilogo.png +0 -0
  140. setiastro/images/wims.png +0 -0
  141. setiastro/images/wrench_icon.png +0 -0
  142. setiastro/images/xisfliberator.png +0 -0
  143. setiastro/saspro/__init__.py +20 -0
  144. setiastro/saspro/__main__.py +809 -0
  145. setiastro/saspro/_generated/__init__.py +7 -0
  146. setiastro/saspro/_generated/build_info.py +2 -0
  147. setiastro/saspro/abe.py +1295 -0
  148. setiastro/saspro/abe_preset.py +196 -0
  149. setiastro/saspro/aberration_ai.py +694 -0
  150. setiastro/saspro/aberration_ai_preset.py +224 -0
  151. setiastro/saspro/accel_installer.py +218 -0
  152. setiastro/saspro/accel_workers.py +30 -0
  153. setiastro/saspro/add_stars.py +621 -0
  154. setiastro/saspro/astrobin_exporter.py +1007 -0
  155. setiastro/saspro/astrospike.py +153 -0
  156. setiastro/saspro/astrospike_python.py +1839 -0
  157. setiastro/saspro/autostretch.py +196 -0
  158. setiastro/saspro/backgroundneutral.py +560 -0
  159. setiastro/saspro/batch_convert.py +325 -0
  160. setiastro/saspro/batch_renamer.py +519 -0
  161. setiastro/saspro/blemish_blaster.py +488 -0
  162. setiastro/saspro/blink_comparator_pro.py +2926 -0
  163. setiastro/saspro/bundles.py +61 -0
  164. setiastro/saspro/bundles_dock.py +114 -0
  165. setiastro/saspro/cheat_sheet.py +178 -0
  166. setiastro/saspro/clahe.py +342 -0
  167. setiastro/saspro/comet_stacking.py +1377 -0
  168. setiastro/saspro/common_tr.py +107 -0
  169. setiastro/saspro/config.py +38 -0
  170. setiastro/saspro/config_bootstrap.py +40 -0
  171. setiastro/saspro/config_manager.py +316 -0
  172. setiastro/saspro/continuum_subtract.py +1617 -0
  173. setiastro/saspro/convo.py +1397 -0
  174. setiastro/saspro/convo_preset.py +414 -0
  175. setiastro/saspro/copyastro.py +187 -0
  176. setiastro/saspro/cosmicclarity.py +1564 -0
  177. setiastro/saspro/cosmicclarity_preset.py +407 -0
  178. setiastro/saspro/crop_dialog_pro.py +956 -0
  179. setiastro/saspro/crop_preset.py +189 -0
  180. setiastro/saspro/curve_editor_pro.py +2544 -0
  181. setiastro/saspro/curves_preset.py +375 -0
  182. setiastro/saspro/debayer.py +670 -0
  183. setiastro/saspro/debug_utils.py +29 -0
  184. setiastro/saspro/dnd_mime.py +35 -0
  185. setiastro/saspro/doc_manager.py +2641 -0
  186. setiastro/saspro/exoplanet_detector.py +2166 -0
  187. setiastro/saspro/file_utils.py +284 -0
  188. setiastro/saspro/fitsmodifier.py +745 -0
  189. setiastro/saspro/fix_bom.py +32 -0
  190. setiastro/saspro/free_torch_memory.py +48 -0
  191. setiastro/saspro/frequency_separation.py +1343 -0
  192. setiastro/saspro/function_bundle.py +1594 -0
  193. setiastro/saspro/generate_translations.py +2378 -0
  194. setiastro/saspro/ghs_dialog_pro.py +660 -0
  195. setiastro/saspro/ghs_preset.py +284 -0
  196. setiastro/saspro/graxpert.py +634 -0
  197. setiastro/saspro/graxpert_preset.py +287 -0
  198. setiastro/saspro/gui/__init__.py +0 -0
  199. setiastro/saspro/gui/main_window.py +8567 -0
  200. setiastro/saspro/gui/mixins/__init__.py +33 -0
  201. setiastro/saspro/gui/mixins/dock_mixin.py +263 -0
  202. setiastro/saspro/gui/mixins/file_mixin.py +443 -0
  203. setiastro/saspro/gui/mixins/geometry_mixin.py +403 -0
  204. setiastro/saspro/gui/mixins/header_mixin.py +441 -0
  205. setiastro/saspro/gui/mixins/mask_mixin.py +421 -0
  206. setiastro/saspro/gui/mixins/menu_mixin.py +361 -0
  207. setiastro/saspro/gui/mixins/theme_mixin.py +367 -0
  208. setiastro/saspro/gui/mixins/toolbar_mixin.py +1457 -0
  209. setiastro/saspro/gui/mixins/update_mixin.py +309 -0
  210. setiastro/saspro/gui/mixins/view_mixin.py +435 -0
  211. setiastro/saspro/halobgon.py +462 -0
  212. setiastro/saspro/header_viewer.py +448 -0
  213. setiastro/saspro/headless_utils.py +88 -0
  214. setiastro/saspro/histogram.py +753 -0
  215. setiastro/saspro/history_explorer.py +939 -0
  216. setiastro/saspro/i18n.py +156 -0
  217. setiastro/saspro/image_combine.py +414 -0
  218. setiastro/saspro/image_peeker_pro.py +1601 -0
  219. setiastro/saspro/imageops/__init__.py +37 -0
  220. setiastro/saspro/imageops/mdi_snap.py +292 -0
  221. setiastro/saspro/imageops/scnr.py +36 -0
  222. setiastro/saspro/imageops/starbasedwhitebalance.py +210 -0
  223. setiastro/saspro/imageops/stretch.py +244 -0
  224. setiastro/saspro/isophote.py +1179 -0
  225. setiastro/saspro/layers.py +208 -0
  226. setiastro/saspro/layers_dock.py +714 -0
  227. setiastro/saspro/lazy_imports.py +193 -0
  228. setiastro/saspro/legacy/__init__.py +2 -0
  229. setiastro/saspro/legacy/image_manager.py +2226 -0
  230. setiastro/saspro/legacy/numba_utils.py +3659 -0
  231. setiastro/saspro/legacy/xisf.py +1071 -0
  232. setiastro/saspro/linear_fit.py +534 -0
  233. setiastro/saspro/live_stacking.py +1830 -0
  234. setiastro/saspro/log_bus.py +5 -0
  235. setiastro/saspro/logging_config.py +460 -0
  236. setiastro/saspro/luminancerecombine.py +309 -0
  237. setiastro/saspro/main_helpers.py +201 -0
  238. setiastro/saspro/mask_creation.py +928 -0
  239. setiastro/saspro/masks_core.py +56 -0
  240. setiastro/saspro/mdi_widgets.py +353 -0
  241. setiastro/saspro/memory_utils.py +666 -0
  242. setiastro/saspro/metadata_patcher.py +75 -0
  243. setiastro/saspro/mfdeconv.py +3826 -0
  244. setiastro/saspro/mfdeconv_earlystop.py +71 -0
  245. setiastro/saspro/mfdeconvcudnn.py +3263 -0
  246. setiastro/saspro/mfdeconvsport.py +2382 -0
  247. setiastro/saspro/minorbodycatalog.py +567 -0
  248. setiastro/saspro/morphology.py +382 -0
  249. setiastro/saspro/multiscale_decomp.py +1290 -0
  250. setiastro/saspro/nbtorgb_stars.py +531 -0
  251. setiastro/saspro/numba_utils.py +3044 -0
  252. setiastro/saspro/numba_warmup.py +141 -0
  253. setiastro/saspro/ops/__init__.py +9 -0
  254. setiastro/saspro/ops/command_help_dialog.py +623 -0
  255. setiastro/saspro/ops/command_runner.py +217 -0
  256. setiastro/saspro/ops/commands.py +1594 -0
  257. setiastro/saspro/ops/script_editor.py +1102 -0
  258. setiastro/saspro/ops/scripts.py +1413 -0
  259. setiastro/saspro/ops/settings.py +679 -0
  260. setiastro/saspro/parallel_utils.py +554 -0
  261. setiastro/saspro/pedestal.py +121 -0
  262. setiastro/saspro/perfect_palette_picker.py +1070 -0
  263. setiastro/saspro/pipeline.py +110 -0
  264. setiastro/saspro/pixelmath.py +1600 -0
  265. setiastro/saspro/plate_solver.py +2444 -0
  266. setiastro/saspro/project_io.py +797 -0
  267. setiastro/saspro/psf_utils.py +136 -0
  268. setiastro/saspro/psf_viewer.py +549 -0
  269. setiastro/saspro/pyi_rthook_astroquery.py +95 -0
  270. setiastro/saspro/remove_green.py +314 -0
  271. setiastro/saspro/remove_stars.py +1625 -0
  272. setiastro/saspro/remove_stars_preset.py +404 -0
  273. setiastro/saspro/resources.py +477 -0
  274. setiastro/saspro/rgb_combination.py +207 -0
  275. setiastro/saspro/rgb_extract.py +19 -0
  276. setiastro/saspro/rgbalign.py +723 -0
  277. setiastro/saspro/runtime_imports.py +7 -0
  278. setiastro/saspro/runtime_torch.py +754 -0
  279. setiastro/saspro/save_options.py +72 -0
  280. setiastro/saspro/selective_color.py +1552 -0
  281. setiastro/saspro/sfcc.py +1430 -0
  282. setiastro/saspro/shortcuts.py +3043 -0
  283. setiastro/saspro/signature_insert.py +1099 -0
  284. setiastro/saspro/stacking_suite.py +18181 -0
  285. setiastro/saspro/star_alignment.py +7420 -0
  286. setiastro/saspro/star_alignment_preset.py +329 -0
  287. setiastro/saspro/star_metrics.py +49 -0
  288. setiastro/saspro/star_spikes.py +681 -0
  289. setiastro/saspro/star_stretch.py +470 -0
  290. setiastro/saspro/stat_stretch.py +506 -0
  291. setiastro/saspro/status_log_dock.py +78 -0
  292. setiastro/saspro/subwindow.py +3267 -0
  293. setiastro/saspro/supernovaasteroidhunter.py +1716 -0
  294. setiastro/saspro/swap_manager.py +99 -0
  295. setiastro/saspro/torch_backend.py +89 -0
  296. setiastro/saspro/torch_rejection.py +434 -0
  297. setiastro/saspro/translations/de_translations.py +3733 -0
  298. setiastro/saspro/translations/es_translations.py +3923 -0
  299. setiastro/saspro/translations/fr_translations.py +3842 -0
  300. setiastro/saspro/translations/integrate_translations.py +234 -0
  301. setiastro/saspro/translations/it_translations.py +3662 -0
  302. setiastro/saspro/translations/ja_translations.py +3585 -0
  303. setiastro/saspro/translations/pt_translations.py +3853 -0
  304. setiastro/saspro/translations/saspro_de.qm +0 -0
  305. setiastro/saspro/translations/saspro_de.ts +253 -0
  306. setiastro/saspro/translations/saspro_es.qm +0 -0
  307. setiastro/saspro/translations/saspro_es.ts +12520 -0
  308. setiastro/saspro/translations/saspro_fr.qm +0 -0
  309. setiastro/saspro/translations/saspro_fr.ts +12514 -0
  310. setiastro/saspro/translations/saspro_it.qm +0 -0
  311. setiastro/saspro/translations/saspro_it.ts +12520 -0
  312. setiastro/saspro/translations/saspro_ja.qm +0 -0
  313. setiastro/saspro/translations/saspro_ja.ts +257 -0
  314. setiastro/saspro/translations/saspro_pt.qm +0 -0
  315. setiastro/saspro/translations/saspro_pt.ts +257 -0
  316. setiastro/saspro/translations/saspro_zh.qm +0 -0
  317. setiastro/saspro/translations/saspro_zh.ts +12520 -0
  318. setiastro/saspro/translations/zh_translations.py +3659 -0
  319. setiastro/saspro/versioning.py +71 -0
  320. setiastro/saspro/view_bundle.py +1555 -0
  321. setiastro/saspro/wavescale_hdr.py +624 -0
  322. setiastro/saspro/wavescale_hdr_preset.py +101 -0
  323. setiastro/saspro/wavescalede.py +658 -0
  324. setiastro/saspro/wavescalede_preset.py +230 -0
  325. setiastro/saspro/wcs_update.py +374 -0
  326. setiastro/saspro/whitebalance.py +456 -0
  327. setiastro/saspro/widgets/__init__.py +48 -0
  328. setiastro/saspro/widgets/common_utilities.py +306 -0
  329. setiastro/saspro/widgets/graphics_views.py +122 -0
  330. setiastro/saspro/widgets/image_utils.py +518 -0
  331. setiastro/saspro/widgets/preview_dialogs.py +280 -0
  332. setiastro/saspro/widgets/spinboxes.py +275 -0
  333. setiastro/saspro/widgets/themed_buttons.py +13 -0
  334. setiastro/saspro/widgets/wavelet_utils.py +299 -0
  335. setiastro/saspro/window_shelf.py +185 -0
  336. setiastro/saspro/xisf.py +1123 -0
  337. setiastrosuitepro-1.6.1.dist-info/METADATA +267 -0
  338. setiastrosuitepro-1.6.1.dist-info/RECORD +342 -0
  339. setiastrosuitepro-1.6.1.dist-info/WHEEL +4 -0
  340. setiastrosuitepro-1.6.1.dist-info/entry_points.txt +6 -0
  341. setiastrosuitepro-1.6.1.dist-info/licenses/LICENSE +674 -0
  342. setiastrosuitepro-1.6.1.dist-info/licenses/license.txt +2580 -0
@@ -0,0 +1,1839 @@
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.setModal(True)
1044
+
1045
+ self.ctx = ctx
1046
+ self.image_data = image_data_255 # uint8 for detection
1047
+ self.image_data_float = image_data_float # float for output
1048
+ self.config = DEFAULT_CONFIG
1049
+ self.thread = None
1050
+
1051
+ # Tool state
1052
+ self.tool_mode = ToolMode.NONE
1053
+ self.star_input_radius = 4.0
1054
+ self.eraser_input_size = 20.0
1055
+
1056
+ # History management
1057
+ self.history: List[List[Star]] = []
1058
+ self.history_index = -1
1059
+
1060
+ # Debounce timer
1061
+ self.detect_timer = QTimer()
1062
+ self.detect_timer.setSingleShot(True)
1063
+ self.detect_timer.setInterval(200)
1064
+ self.detect_timer.timeout.connect(self.detect_stars)
1065
+
1066
+ # Convert numpy to QImage
1067
+ height, width = self.image_data.shape[:2]
1068
+ bytes_per_line = 3 * width
1069
+ self.qimage = QImage(self.image_data.data, width, height, bytes_per_line, QImage.Format.Format_RGB888)
1070
+
1071
+ self._init_ui()
1072
+ self._apply_dark_theme()
1073
+
1074
+ # Auto-detect stars on open
1075
+ QTimer.singleShot(100, self.detect_stars)
1076
+
1077
+ def _apply_dark_theme(self):
1078
+ """Apply dark theme to window."""
1079
+ dark_palette = QPalette()
1080
+ dark_palette.setColor(QPalette.ColorRole.Window, QColor(30, 30, 30))
1081
+ dark_palette.setColor(QPalette.ColorRole.WindowText, QColor(224, 224, 224))
1082
+ dark_palette.setColor(QPalette.ColorRole.Base, QColor(25, 25, 25))
1083
+ dark_palette.setColor(QPalette.ColorRole.AlternateBase, QColor(45, 45, 48))
1084
+ dark_palette.setColor(QPalette.ColorRole.Text, QColor(224, 224, 224))
1085
+ dark_palette.setColor(QPalette.ColorRole.Button, QColor(45, 45, 48))
1086
+ dark_palette.setColor(QPalette.ColorRole.ButtonText, QColor(224, 224, 224))
1087
+ dark_palette.setColor(QPalette.ColorRole.Highlight, QColor(0, 122, 204))
1088
+ dark_palette.setColor(QPalette.ColorRole.HighlightedText, QColor(255, 255, 255))
1089
+ self.setPalette(dark_palette)
1090
+
1091
+ def _init_ui(self):
1092
+ root_layout = QVBoxLayout(self)
1093
+ root_layout.setSizeConstraint(QVBoxLayout.SizeConstraint.SetNoConstraint)
1094
+ root_layout.setContentsMargins(0, 0, 0, 0)
1095
+ root_layout.setSpacing(0)
1096
+
1097
+ # Top Toolbar
1098
+ top_bar = QWidget()
1099
+ top_bar.setObjectName("topBar")
1100
+ top_bar.setFixedHeight(50)
1101
+ top_layout = QHBoxLayout(top_bar)
1102
+ top_layout.setContentsMargins(10, 0, 10, 0)
1103
+ top_layout.setSpacing(15)
1104
+
1105
+ # Apply to Document button
1106
+ btn_apply = QPushButton("✓ Apply to Document")
1107
+ btn_apply.setToolTip("Apply effect and close")
1108
+ btn_apply.clicked.connect(self.apply_to_document)
1109
+ btn_apply.setStyleSheet("background: #007acc; color: white; font-weight: bold; padding: 8px 16px;")
1110
+
1111
+ btn_save = QPushButton("💾 Save Image")
1112
+ btn_save.setToolTip("Save Image to File")
1113
+ btn_save.clicked.connect(self.save_image)
1114
+
1115
+ top_layout.addWidget(btn_apply)
1116
+ top_layout.addWidget(btn_save)
1117
+
1118
+ # Separator
1119
+ line1 = QWidget()
1120
+ line1.setFixedWidth(1)
1121
+ line1.setStyleSheet("background: #444;")
1122
+ top_layout.addWidget(line1)
1123
+
1124
+ # Undo/Redo
1125
+ self.btn_undo = QPushButton("↩️ Undo")
1126
+ self.btn_undo.clicked.connect(self.undo)
1127
+ self.btn_undo.setEnabled(False)
1128
+
1129
+ self.btn_redo = QPushButton("↪️ Redo")
1130
+ self.btn_redo.clicked.connect(self.redo)
1131
+ self.btn_redo.setEnabled(False)
1132
+
1133
+ top_layout.addWidget(self.btn_undo)
1134
+ top_layout.addWidget(self.btn_redo)
1135
+
1136
+ # Separator
1137
+ line2 = QWidget()
1138
+ line2.setFixedWidth(1)
1139
+ line2.setStyleSheet("background: #444;")
1140
+ top_layout.addWidget(line2)
1141
+
1142
+ # Tools
1143
+ lbl_tools = QLabel("Tools:")
1144
+ lbl_tools.setStyleSheet("color: #888; font-weight: bold;")
1145
+ top_layout.addWidget(lbl_tools)
1146
+
1147
+ self.btn_pan = QPushButton("✋ Pan")
1148
+ self.btn_pan.setCheckable(True)
1149
+ self.btn_pan.setChecked(True)
1150
+ self.btn_pan.clicked.connect(lambda: self.set_tool_mode(ToolMode.NONE))
1151
+
1152
+ self.btn_brush_add = QPushButton("⭐ Add Star")
1153
+ self.btn_brush_add.setCheckable(True)
1154
+ self.btn_brush_add.clicked.connect(lambda: self.set_tool_mode(ToolMode.ADD))
1155
+
1156
+ self.btn_eraser = QPushButton("🧹 Eraser")
1157
+ self.btn_eraser.setCheckable(True)
1158
+ self.btn_eraser.clicked.connect(lambda: self.set_tool_mode(ToolMode.ERASE))
1159
+
1160
+ top_layout.addWidget(self.btn_pan)
1161
+ top_layout.addWidget(self.btn_brush_add)
1162
+ top_layout.addWidget(self.btn_eraser)
1163
+
1164
+ # Tool Size Controls
1165
+ line_tool_sep = QWidget()
1166
+ line_tool_sep.setFixedWidth(1)
1167
+ line_tool_sep.setStyleSheet("background: #444;")
1168
+ top_layout.addWidget(line_tool_sep)
1169
+
1170
+ lbl_star_size = QLabel("Star Size:")
1171
+ lbl_star_size.setStyleSheet("color: #888;")
1172
+ top_layout.addWidget(lbl_star_size)
1173
+
1174
+ self.slider_star_size = QSlider(Qt.Orientation.Horizontal)
1175
+ self.slider_star_size.setRange(1, 50)
1176
+ self.slider_star_size.setValue(int(self.star_input_radius))
1177
+ self.slider_star_size.setFixedWidth(80)
1178
+ self.slider_star_size.valueChanged.connect(self.on_star_size_changed)
1179
+ top_layout.addWidget(self.slider_star_size)
1180
+
1181
+ self.lbl_star_size_val = QLabel(f"{self.star_input_radius:.0f}")
1182
+ self.lbl_star_size_val.setFixedWidth(25)
1183
+ top_layout.addWidget(self.lbl_star_size_val)
1184
+
1185
+ lbl_eraser_size = QLabel("Eraser:")
1186
+ lbl_eraser_size.setStyleSheet("color: #888;")
1187
+ top_layout.addWidget(lbl_eraser_size)
1188
+
1189
+ self.slider_eraser_size = QSlider(Qt.Orientation.Horizontal)
1190
+ self.slider_eraser_size.setRange(5, 100)
1191
+ self.slider_eraser_size.setValue(int(self.eraser_input_size))
1192
+ self.slider_eraser_size.setFixedWidth(80)
1193
+ self.slider_eraser_size.valueChanged.connect(self.on_eraser_size_changed)
1194
+ top_layout.addWidget(self.slider_eraser_size)
1195
+
1196
+ self.lbl_eraser_size_val = QLabel(f"{self.eraser_input_size:.0f}")
1197
+ self.lbl_eraser_size_val.setFixedWidth(25)
1198
+ top_layout.addWidget(self.lbl_eraser_size_val)
1199
+
1200
+ # Separator
1201
+ line3 = QWidget()
1202
+ line3.setFixedWidth(1)
1203
+ line3.setStyleSheet("background: #444;")
1204
+ top_layout.addWidget(line3)
1205
+
1206
+ # Zoom Controls (standardized)
1207
+ self.btn_zoom_in = themed_toolbtn("zoom-in", "Zoom In")
1208
+ self.btn_zoom_out = themed_toolbtn("zoom-out", "Zoom Out")
1209
+ self.btn_fit = themed_toolbtn("zoom-fit-best", "Fit to View")
1210
+
1211
+ top_layout.addWidget(self.btn_zoom_in)
1212
+ top_layout.addWidget(self.btn_zoom_out)
1213
+ top_layout.addWidget(self.btn_fit)
1214
+
1215
+ top_layout.addStretch()
1216
+
1217
+ self.status_label = QLabel("Ready")
1218
+ self.status_label.setStyleSheet("color: #aaa;")
1219
+ self.status_label.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred)
1220
+ top_layout.addWidget(self.status_label)
1221
+
1222
+ root_layout.addWidget(top_bar)
1223
+
1224
+ # Content Area
1225
+ content_area = QWidget()
1226
+ content_layout = QHBoxLayout(content_area)
1227
+ content_layout.setContentsMargins(0, 0, 0, 0)
1228
+
1229
+ # Canvas
1230
+ self.canvas = CanvasPreview()
1231
+ self.canvas.stars_updated.connect(self.on_stars_updated)
1232
+ self.canvas.set_image(self.qimage)
1233
+ self.canvas.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
1234
+ self.canvas.setMinimumSize(100, 100)
1235
+ content_layout.addWidget(self.canvas, stretch=1)
1236
+
1237
+ # Connect Zoom
1238
+ self.btn_zoom_in.clicked.connect(self.canvas.zoom_in)
1239
+ self.btn_zoom_out.clicked.connect(self.canvas.zoom_out)
1240
+ self.btn_fit.clicked.connect(self.canvas.fit_to_view)
1241
+
1242
+ # Controls Panel
1243
+ self.controls = ControlPanel(self.config)
1244
+ self.controls.setFixedWidth(340)
1245
+ self.controls.config_changed.connect(self.on_config_changed)
1246
+ self.controls.reset_requested.connect(self.reset_config)
1247
+
1248
+ controls_container = QWidget()
1249
+ controls_container.setObjectName("controlsContainer")
1250
+ controls_layout = QVBoxLayout(controls_container)
1251
+ controls_layout.setContentsMargins(0, 0, 0, 0)
1252
+ controls_layout.addWidget(self.controls)
1253
+
1254
+ content_layout.addWidget(controls_container)
1255
+
1256
+ # content_area deve espandersi verticalmente per riempire lo spazio disponibile
1257
+ root_layout.addWidget(content_area, stretch=1)
1258
+
1259
+ # Style
1260
+ self.setStyleSheet("""
1261
+ QDialog { background-color: #1e1e1e; }
1262
+ QWidget { color: #e0e0e0; font-family: 'Segoe UI', sans-serif; font-size: 13px; }
1263
+ #topBar { background-color: #252526; border-bottom: 1px solid #333; }
1264
+ #controlsContainer { background-color: #252526; border-left: 1px solid #333; }
1265
+ QPushButton {
1266
+ background-color: transparent; border: 1px solid transparent;
1267
+ padding: 6px 12px; border-radius: 4px; color: #ccc;
1268
+ }
1269
+ QPushButton:hover { background-color: #3e3e42; color: white; }
1270
+ QPushButton:pressed { background-color: #007acc; color: white; }
1271
+ QPushButton:checked { background-color: #007acc; color: white; border: 1px solid #005a9e; }
1272
+ QGroupBox {
1273
+ font-weight: bold; border: 1px solid #3e3e42; margin-top: 16px;
1274
+ padding-top: 16px; border-radius: 4px; background: #2d2d30;
1275
+ }
1276
+ QGroupBox::title {
1277
+ subcontrol-origin: margin; subcontrol-position: top left;
1278
+ left: 10px; top: 0px; padding: 0 5px; color: #007acc;
1279
+ }
1280
+ QSlider::groove:horizontal { border: 1px solid #3e3e42; height: 4px; background: #1e1e1e; margin: 2px 0; border-radius: 2px; }
1281
+ QSlider::handle:horizontal { background: #007acc; border: 1px solid #007acc; width: 14px; height: 14px; margin: -6px 0; border-radius: 7px; }
1282
+ QScrollArea { border: none; background: transparent; }
1283
+ QScrollBar:vertical { border: none; background: #1e1e1e; width: 10px; margin: 0; }
1284
+ QScrollBar::handle:vertical { background: #424242; min-height: 20px; border-radius: 5px; }
1285
+ QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0px; }
1286
+ """)
1287
+
1288
+ def detect_stars(self):
1289
+ if self.image_data is None:
1290
+ return
1291
+
1292
+ if self.thread and self.thread.isRunning():
1293
+ self.detect_timer.start(100)
1294
+ return
1295
+
1296
+ self.status_label.setText("Detecting stars...")
1297
+ self.thread = StarDetectionThread(self.image_data, self.config.threshold)
1298
+ self.thread.stars_detected.connect(self.on_stars_detected)
1299
+ self.thread.start()
1300
+
1301
+ def on_stars_detected(self, stars):
1302
+ self.status_label.setText(f"Found {len(stars)} stars")
1303
+ self.canvas.set_stars(stars)
1304
+ self.canvas.set_config(self.config)
1305
+ self._reset_history(stars)
1306
+
1307
+ def on_config_changed(self, config):
1308
+ current_threshold = self.thread.threshold if self.thread else -1
1309
+ if config.threshold != current_threshold:
1310
+ self.detect_timer.start()
1311
+ self.config = config
1312
+ self.canvas.set_config(config)
1313
+
1314
+ def reset_config(self):
1315
+ self.config = SpikeConfig()
1316
+ self.controls.set_config(self.config)
1317
+ self.canvas.set_config(self.config)
1318
+ self.detect_stars()
1319
+
1320
+ def set_tool_mode(self, mode: ToolMode):
1321
+ self.tool_mode = mode
1322
+ self.canvas.set_tool_mode(mode)
1323
+ self.btn_pan.setChecked(mode == ToolMode.NONE)
1324
+ self.btn_brush_add.setChecked(mode == ToolMode.ADD)
1325
+ self.btn_eraser.setChecked(mode == ToolMode.ERASE)
1326
+
1327
+ def on_star_size_changed(self, value: int):
1328
+ self.star_input_radius = float(value)
1329
+ self.lbl_star_size_val.setText(f"{value}")
1330
+ self.canvas.set_star_input_radius(self.star_input_radius)
1331
+
1332
+ def on_eraser_size_changed(self, value: int):
1333
+ self.eraser_input_size = float(value)
1334
+ self.lbl_eraser_size_val.setText(f"{value}")
1335
+ self.canvas.set_eraser_input_size(self.eraser_input_size)
1336
+
1337
+ def on_stars_updated(self, new_stars: list, push_history: bool):
1338
+ self.canvas.stars = new_stars
1339
+ if push_history:
1340
+ self.history = self.history[:self.history_index + 1]
1341
+ self.history.append(list(new_stars))
1342
+ self.history_index += 1
1343
+ self._update_history_buttons()
1344
+ self.canvas.update()
1345
+
1346
+ def _update_history_buttons(self):
1347
+ self.btn_undo.setEnabled(self.history_index > 0)
1348
+ self.btn_redo.setEnabled(self.history_index < len(self.history) - 1)
1349
+
1350
+ def undo(self):
1351
+ if self.history_index > 0:
1352
+ self.history_index -= 1
1353
+ self.canvas.stars = list(self.history[self.history_index])
1354
+ self.canvas.update()
1355
+ self._update_history_buttons()
1356
+
1357
+ def redo(self):
1358
+ if self.history_index < len(self.history) - 1:
1359
+ self.history_index += 1
1360
+ self.canvas.stars = list(self.history[self.history_index])
1361
+ self.canvas.update()
1362
+ self._update_history_buttons()
1363
+
1364
+ def _reset_history(self, initial_stars: list):
1365
+ self.history = [list(initial_stars)]
1366
+ self.history_index = 0
1367
+ self._update_history_buttons()
1368
+
1369
+ def save_image(self):
1370
+ """Save rendered image to file."""
1371
+ file_path, _ = QFileDialog.getSaveFileName(
1372
+ self, "Save Image", "astrospike_output.png",
1373
+ "PNG Images (*.png);;JPEG Images (*.jpg);;TIFF Images (*.tif)"
1374
+ )
1375
+ if file_path:
1376
+ final_image = self.qimage.copy()
1377
+ painter = QPainter(final_image)
1378
+ self.canvas.renderer.render(painter, final_image.width(), final_image.height(),
1379
+ self.canvas.stars, self.config)
1380
+ painter.end()
1381
+ final_image.save(file_path)
1382
+ self.status_label.setText(f"Saved to {file_path}")
1383
+ self.ctx.log(f"Saved image to {file_path}")
1384
+
1385
+ def apply_to_document(self):
1386
+ """Apply the effect to the SETI Astro document and close."""
1387
+ self.status_label.setText("Applying to document...")
1388
+
1389
+ # Render to numpy array
1390
+ output = self.image_data_float.copy()
1391
+ render_spikes(output, self.canvas.stars, self.config)
1392
+ output = np.clip(output, 0.0, 1.0)
1393
+
1394
+ # Apply to document
1395
+ self.ctx.set_image(output, step_name="AstroSpike Effect")
1396
+ self.ctx.log(f"Applied AstroSpike effect with {len(self.canvas.stars)} stars")
1397
+
1398
+ self.accept() # Close dialog
1399
+
1400
+ def closeEvent(self, event):
1401
+ if self.thread and self.thread.isRunning():
1402
+ self.thread.terminate()
1403
+ self.thread.wait()
1404
+ event.accept()
1405
+
1406
+
1407
+ class SliderControl(QWidget):
1408
+ """Slider control with label and value display."""
1409
+ value_changed = pyqtSignal(float)
1410
+
1411
+ def __init__(self, label: str, min_val: float, max_val: float, step: float, initial: float, unit: str = ""):
1412
+ super().__init__()
1413
+ self.min_val = min_val
1414
+ self.max_val = max_val
1415
+ self.step = step
1416
+ self.unit = unit
1417
+
1418
+ layout = QVBoxLayout(self)
1419
+ layout.setContentsMargins(0, 5, 0, 5)
1420
+ layout.setSpacing(2)
1421
+
1422
+ header = QHBoxLayout()
1423
+ self.label = QLabel(label)
1424
+ self.value_label = QLabel(f"{initial:.2f}{unit}")
1425
+ header.addWidget(self.label)
1426
+ header.addStretch()
1427
+ header.addWidget(self.value_label)
1428
+
1429
+ self.slider = QSlider(Qt.Orientation.Horizontal)
1430
+ self.slider.setRange(0, 1000)
1431
+ self.slider.setValue(self._float_to_int(initial))
1432
+ self.slider.valueChanged.connect(self._on_slider_change)
1433
+
1434
+ layout.addLayout(header)
1435
+ layout.addWidget(self.slider)
1436
+
1437
+ def _float_to_int(self, val: float) -> int:
1438
+ ratio = (val - self.min_val) / (self.max_val - self.min_val)
1439
+ return int(ratio * 1000)
1440
+
1441
+ def _int_to_float(self, val: int) -> float:
1442
+ ratio = val / 1000.0
1443
+ return self.min_val + ratio * (self.max_val - self.min_val)
1444
+
1445
+ def _on_slider_change(self, val: int):
1446
+ f_val = self._int_to_float(val)
1447
+ if self.step > 0:
1448
+ f_val = round(f_val / self.step) * self.step
1449
+ self.value_label.setText(f"{f_val:.2f}{self.unit}")
1450
+ self.value_changed.emit(f_val)
1451
+
1452
+ def set_value(self, val: float):
1453
+ self.slider.blockSignals(True)
1454
+ self.slider.setValue(self._float_to_int(val))
1455
+ self.value_label.setText(f"{val:.2f}{self.unit}")
1456
+ self.slider.blockSignals(False)
1457
+
1458
+
1459
+ class ControlPanel(QWidget):
1460
+ """Control panel with all spike parameters."""
1461
+ config_changed = pyqtSignal(SpikeConfig)
1462
+ reset_requested = pyqtSignal()
1463
+
1464
+ def __init__(self, config: SpikeConfig):
1465
+ super().__init__()
1466
+ self.config = config
1467
+ self._init_ui()
1468
+
1469
+ def _init_ui(self):
1470
+ main_layout = QVBoxLayout(self)
1471
+ main_layout.setContentsMargins(0, 0, 0, 0)
1472
+
1473
+ header_layout = QHBoxLayout()
1474
+ header_layout.setContentsMargins(10, 10, 10, 0)
1475
+
1476
+ lbl_title = QLabel("PARAMETERS")
1477
+ lbl_title.setStyleSheet("font-weight: bold; color: #888; letter-spacing: 1px;")
1478
+ header_layout.addWidget(lbl_title)
1479
+ header_layout.addStretch()
1480
+
1481
+ btn_reset = QPushButton("↺ Reset")
1482
+ btn_reset.setStyleSheet("background: #333; border: 1px solid #555; padding: 4px 8px; font-size: 11px;")
1483
+ btn_reset.clicked.connect(self.reset_requested.emit)
1484
+ header_layout.addWidget(btn_reset)
1485
+
1486
+ main_layout.addLayout(header_layout)
1487
+
1488
+ self.scroll = QScrollArea()
1489
+ self.scroll.setWidgetResizable(True)
1490
+ self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
1491
+
1492
+ self.content = QWidget()
1493
+ self.layout = QVBoxLayout(self.content)
1494
+ self.layout.setSpacing(15)
1495
+
1496
+ self._build_controls()
1497
+
1498
+ self.scroll.setWidget(self.content)
1499
+ main_layout.addWidget(self.scroll)
1500
+
1501
+ def _build_controls(self):
1502
+ while self.layout.count():
1503
+ child = self.layout.takeAt(0)
1504
+ if child.widget():
1505
+ child.widget().deleteLater()
1506
+
1507
+ self._add_group("Detection", [
1508
+ ("Threshold", 1, 100, 1, self.config.threshold, "threshold", ""),
1509
+ ("Quantity Limit %", 0, 100, 1, self.config.star_amount, "star_amount", "%"),
1510
+ ("Min Star Size", 0, 100, 1, self.config.min_star_size, "min_star_size", ""),
1511
+ ("Max Star Size", 0, 100, 1, self.config.max_star_size, "max_star_size", "")
1512
+ ])
1513
+
1514
+ self._add_group("Geometry", [
1515
+ ("Global Scale", 0.2, 3.0, 0.1, self.config.global_scale, "global_scale", ""),
1516
+ ("Points", 2, 8, 1, self.config.quantity, "quantity", ""),
1517
+ ("Length", 10, 1500, 10, self.config.length, "length", ""),
1518
+ ("Angle", 0, 180, 1, self.config.angle, "angle", "°"),
1519
+ ("Thickness", 0.1, 5.0, 0.1, self.config.spike_width, "spike_width", "")
1520
+ ])
1521
+
1522
+ self._add_group("Appearance", [
1523
+ ("Intensity", 0, 1.0, 0.05, self.config.intensity, "intensity", ""),
1524
+ ("Color Saturation", 0, 2.0, 0.05, self.config.color_saturation, "color_saturation", ""),
1525
+ ("Hue Shift", -180, 180, 1, self.config.hue_shift, "hue_shift", "°")
1526
+ ])
1527
+
1528
+ # Halo
1529
+ halo_group = QGroupBox("Star Halo / Rings")
1530
+ halo_layout = QVBoxLayout()
1531
+
1532
+ self.halo_check = QCheckBox("Enable Halo")
1533
+ self.halo_check.setChecked(self.config.enable_halo)
1534
+ self.halo_check.toggled.connect(lambda c: self._update_config("enable_halo", c))
1535
+ halo_layout.addWidget(self.halo_check)
1536
+
1537
+ self._add_slider(halo_layout, "Intensity", 0, 1.0, 0.05, self.config.halo_intensity, "halo_intensity", "")
1538
+ self._add_slider(halo_layout, "Radius", 0.1, 5.0, 0.1, self.config.halo_scale, "halo_scale", "")
1539
+ self._add_slider(halo_layout, "Width", 0.2, 10.0, 0.2, self.config.halo_width, "halo_width", "")
1540
+ self._add_slider(halo_layout, "Blur", 0, 10.0, 0.1, self.config.halo_blur, "halo_blur", "")
1541
+ self._add_slider(halo_layout, "Saturation", 0, 3.0, 0.1, self.config.halo_saturation, "halo_saturation", "")
1542
+
1543
+ halo_group.setLayout(halo_layout)
1544
+ self.layout.addWidget(halo_group)
1545
+
1546
+ self._add_group("Secondary Spikes", [
1547
+ ("Intensity", 0, 1.0, 0.05, self.config.secondary_intensity, "secondary_intensity", ""),
1548
+ ("Length", 0, 500, 10, self.config.secondary_length, "secondary_length", ""),
1549
+ ("Offset Angle", 0, 90, 1, self.config.secondary_offset, "secondary_offset", "°")
1550
+ ])
1551
+
1552
+ self._add_group("Soft Flare", [
1553
+ ("Glow Intensity", 0, 3.0, 0.05, self.config.soft_flare_intensity, "soft_flare_intensity", ""),
1554
+ ("Glow Size", 0, 200, 5, self.config.soft_flare_size, "soft_flare_size", "")
1555
+ ])
1556
+
1557
+ # Spectral
1558
+ spectral_group = QGroupBox("Spectral Effects")
1559
+ spectral_layout = QVBoxLayout()
1560
+
1561
+ self.rainbow_check = QCheckBox("Enable Rainbow FX")
1562
+ self.rainbow_check.setChecked(self.config.enable_rainbow)
1563
+ self.rainbow_check.toggled.connect(lambda c: self._update_config("enable_rainbow", c))
1564
+ spectral_layout.addWidget(self.rainbow_check)
1565
+
1566
+ self._add_slider(spectral_layout, "Intensity", 0, 1.0, 0.05, self.config.rainbow_spike_intensity, "rainbow_spike_intensity", "")
1567
+ self._add_slider(spectral_layout, "Frequency", 0.1, 3.0, 0.1, self.config.rainbow_spike_frequency, "rainbow_spike_frequency", "")
1568
+ self._add_slider(spectral_layout, "Coverage", 0.1, 1.0, 0.1, self.config.rainbow_spike_length, "rainbow_spike_length", "")
1569
+
1570
+ spectral_group.setLayout(spectral_layout)
1571
+ self.layout.addWidget(spectral_group)
1572
+
1573
+ self.layout.addStretch()
1574
+
1575
+ def _add_group(self, title, sliders):
1576
+ group = QGroupBox(title)
1577
+ layout = QVBoxLayout()
1578
+ for label, min_v, max_v, step, init, key, unit in sliders:
1579
+ self._add_slider(layout, label, min_v, max_v, step, init, key, unit)
1580
+ group.setLayout(layout)
1581
+ self.layout.addWidget(group)
1582
+
1583
+ def _add_slider(self, layout, label, min_v, max_v, step, init, key, unit):
1584
+ slider = SliderControl(label, min_v, max_v, step, init, unit)
1585
+ slider.value_changed.connect(lambda v, k=key: self._update_config(k, v))
1586
+ layout.addWidget(slider)
1587
+
1588
+ def _update_config(self, key, value):
1589
+ setattr(self.config, key, value)
1590
+ self.config_changed.emit(self.config)
1591
+
1592
+ def set_config(self, config: SpikeConfig):
1593
+ self.config = config
1594
+ self._build_controls()
1595
+
1596
+
1597
+ class CanvasPreview(QWidget):
1598
+ """Canvas widget for image preview with pan/zoom."""
1599
+ stars_updated = pyqtSignal(list, bool)
1600
+
1601
+ def __init__(self):
1602
+ super().__init__()
1603
+ self.setMouseTracking(True)
1604
+ self.image: Optional[QImage] = None
1605
+ self.stars: List[Star] = []
1606
+ self.config: Optional[SpikeConfig] = None
1607
+ self.renderer = Renderer()
1608
+
1609
+ self.scale = 1.0
1610
+ self.offset_x = 0.0
1611
+ self.offset_y = 0.0
1612
+
1613
+ self.is_dragging = False
1614
+ self.last_mouse_pos = QPointF()
1615
+
1616
+ self.tool_mode: ToolMode = ToolMode.NONE
1617
+ self.star_input_radius: float = 4.0
1618
+ self.eraser_input_size: float = 20.0
1619
+ self.cursor_pos = QPointF(-9999, -9999)
1620
+ self.is_erasing = False
1621
+
1622
+ def set_image(self, image: QImage):
1623
+ self.image = image
1624
+ self.fit_to_view()
1625
+ self.update()
1626
+
1627
+ def set_stars(self, stars: List[Star]):
1628
+ self.stars = stars
1629
+ self.update()
1630
+
1631
+ def set_config(self, config: SpikeConfig):
1632
+ self.config = config
1633
+ self.update()
1634
+
1635
+ def set_tool_mode(self, mode: ToolMode):
1636
+ self.tool_mode = mode
1637
+ if mode == ToolMode.NONE:
1638
+ self.setCursor(Qt.CursorShape.OpenHandCursor)
1639
+ else:
1640
+ self.setCursor(Qt.CursorShape.BlankCursor)
1641
+ self.update()
1642
+
1643
+ def set_star_input_radius(self, radius: float):
1644
+ self.star_input_radius = radius
1645
+ self.update()
1646
+
1647
+ def set_eraser_input_size(self, size: float):
1648
+ self.eraser_input_size = size
1649
+ self.update()
1650
+
1651
+ def fit_to_view(self):
1652
+ if not self.image:
1653
+ return
1654
+ w_ratio = self.width() / self.image.width()
1655
+ h_ratio = self.height() / self.image.height()
1656
+ self.scale = min(w_ratio, h_ratio) * 0.9
1657
+ self.center_image()
1658
+
1659
+ def zoom_in(self):
1660
+ self.scale *= 1.2
1661
+ self.center_image()
1662
+
1663
+ def zoom_out(self):
1664
+ self.scale /= 1.2
1665
+ self.center_image()
1666
+
1667
+ def center_image(self):
1668
+ if not self.image:
1669
+ return
1670
+ self.offset_x = (self.width() - self.image.width() * self.scale) / 2
1671
+ self.offset_y = (self.height() - self.image.height() * self.scale) / 2
1672
+ self.update()
1673
+
1674
+ def resizeEvent(self, event):
1675
+ if self.image:
1676
+ self.center_image()
1677
+ super().resizeEvent(event)
1678
+
1679
+ def _screen_to_image(self, screen_pos: QPointF) -> QPointF:
1680
+ img_x = (screen_pos.x() - self.offset_x) / self.scale
1681
+ img_y = (screen_pos.y() - self.offset_y) / self.scale
1682
+ return QPointF(img_x, img_y)
1683
+
1684
+ def paintEvent(self, event: QPaintEvent):
1685
+ painter = QPainter(self)
1686
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
1687
+ painter.fillRect(self.rect(), QColor(10, 10, 12))
1688
+
1689
+ if not self.image:
1690
+ painter.setPen(QColor(100, 100, 100))
1691
+ painter.drawText(self.rect(), Qt.AlignmentFlag.AlignCenter, "No Image Loaded")
1692
+ return
1693
+
1694
+ painter.save()
1695
+ painter.translate(self.offset_x, self.offset_y)
1696
+ painter.scale(self.scale, self.scale)
1697
+
1698
+ target_rect = QRectF(0, 0, self.image.width(), self.image.height())
1699
+ painter.drawImage(target_rect, self.image)
1700
+
1701
+ if self.config:
1702
+ self.renderer.render(painter, self.image.width(), self.image.height(), self.stars, self.config)
1703
+
1704
+ painter.restore()
1705
+
1706
+ if self.tool_mode != ToolMode.NONE and self.cursor_pos.x() > -9000:
1707
+ self._draw_cursor_preview(painter)
1708
+
1709
+ painter.setPen(QColor(200, 200, 200))
1710
+ mode_str = self.tool_mode.value.upper() if self.tool_mode else "NONE"
1711
+ painter.drawText(10, 20, f"Zoom: {self.scale*100:.0f}% | Stars: {len(self.stars)} | Tool: {mode_str}")
1712
+
1713
+ def _draw_cursor_preview(self, painter: QPainter):
1714
+ if self.tool_mode == ToolMode.ADD:
1715
+ preview_radius = self.star_input_radius * self.scale
1716
+ color = QColor(56, 189, 248, 150)
1717
+ border_color = QColor(56, 189, 248, 255)
1718
+ elif self.tool_mode == ToolMode.ERASE:
1719
+ preview_radius = self.eraser_input_size * self.scale
1720
+ color = QColor(248, 113, 113, 80)
1721
+ border_color = QColor(248, 113, 113, 200)
1722
+ else:
1723
+ return
1724
+
1725
+ preview_radius = max(4, preview_radius)
1726
+ painter.setBrush(QBrush(color))
1727
+ painter.setPen(QPen(border_color, 2))
1728
+ painter.drawEllipse(self.cursor_pos, preview_radius, preview_radius)
1729
+
1730
+ def mousePressEvent(self, event: QMouseEvent):
1731
+ if event.button() == Qt.MouseButton.LeftButton:
1732
+ if self.tool_mode == ToolMode.NONE:
1733
+ self.is_dragging = True
1734
+ self.last_mouse_pos = event.position()
1735
+ self.setCursor(Qt.CursorShape.ClosedHandCursor)
1736
+ elif self.tool_mode == ToolMode.ADD:
1737
+ self._add_star_at(event.position())
1738
+ elif self.tool_mode == ToolMode.ERASE:
1739
+ self.is_erasing = True
1740
+ self._erase_stars_at(event.position(), push_history=False)
1741
+
1742
+ def mouseMoveEvent(self, event: QMouseEvent):
1743
+ self.cursor_pos = event.position()
1744
+
1745
+ if self.is_dragging and self.tool_mode == ToolMode.NONE:
1746
+ delta = event.position() - self.last_mouse_pos
1747
+ self.offset_x += delta.x()
1748
+ self.offset_y += delta.y()
1749
+ self.last_mouse_pos = event.position()
1750
+ elif self.is_erasing and self.tool_mode == ToolMode.ERASE:
1751
+ self._erase_stars_at(event.position(), push_history=False)
1752
+
1753
+ self.update()
1754
+
1755
+ def mouseReleaseEvent(self, event: QMouseEvent):
1756
+ if event.button() == Qt.MouseButton.LeftButton:
1757
+ if self.is_dragging and self.tool_mode == ToolMode.NONE:
1758
+ self.is_dragging = False
1759
+ self.setCursor(Qt.CursorShape.OpenHandCursor)
1760
+ elif self.is_erasing and self.tool_mode == ToolMode.ERASE:
1761
+ self.is_erasing = False
1762
+ self.stars_updated.emit(list(self.stars), True)
1763
+
1764
+ def leaveEvent(self, event):
1765
+ self.cursor_pos = QPointF(-9999, -9999)
1766
+ self.update()
1767
+ super().leaveEvent(event)
1768
+
1769
+ def wheelEvent(self, event: QWheelEvent):
1770
+ zoom_sensitivity = 0.001
1771
+ delta = event.angleDelta().y() * zoom_sensitivity
1772
+
1773
+ old_scale = self.scale
1774
+ new_scale = max(0.05, min(20.0, self.scale * (1 + delta)))
1775
+
1776
+ mouse_pos = event.position()
1777
+ rel_x = (mouse_pos.x() - self.offset_x) / old_scale
1778
+ rel_y = (mouse_pos.y() - self.offset_y) / old_scale
1779
+
1780
+ self.offset_x = mouse_pos.x() - rel_x * new_scale
1781
+ self.offset_y = mouse_pos.y() - rel_y * new_scale
1782
+ self.scale = new_scale
1783
+
1784
+ self.update()
1785
+
1786
+ def _add_star_at(self, screen_pos: QPointF):
1787
+ if not self.image:
1788
+ return
1789
+
1790
+ img_pos = self._screen_to_image(screen_pos)
1791
+
1792
+ if img_pos.x() < 0 or img_pos.x() >= self.image.width():
1793
+ return
1794
+ if img_pos.y() < 0 or img_pos.y() >= self.image.height():
1795
+ return
1796
+
1797
+ new_star = Star(
1798
+ x=img_pos.x(),
1799
+ y=img_pos.y(),
1800
+ brightness=1.0,
1801
+ radius=self.star_input_radius,
1802
+ color=Color(255, 255, 255)
1803
+ )
1804
+
1805
+ new_stars = list(self.stars) + [new_star]
1806
+ self.stars = new_stars
1807
+ self.stars_updated.emit(new_stars, True)
1808
+ self.update()
1809
+
1810
+ def _erase_stars_at(self, screen_pos: QPointF, push_history: bool = False):
1811
+ if not self.image:
1812
+ return
1813
+
1814
+ img_pos = self._screen_to_image(screen_pos)
1815
+ erase_radius_sq = self.eraser_input_size * self.eraser_input_size
1816
+
1817
+ initial_count = len(self.stars)
1818
+
1819
+ filtered_stars = [
1820
+ star for star in self.stars
1821
+ if (star.x - img_pos.x()) ** 2 + (star.y - img_pos.y()) ** 2 > erase_radius_sq
1822
+ ]
1823
+
1824
+ if len(filtered_stars) != initial_count:
1825
+ self.stars = filtered_stars
1826
+ self.stars_updated.emit(filtered_stars, push_history)
1827
+ self.update()
1828
+
1829
+
1830
+ if __name__ == "__main__":
1831
+ print("AstroSpike Script for SETI Astro")
1832
+ print("=" * 40)
1833
+ print("This script is designed to run within SETI Astro.")
1834
+ print("To use it:")
1835
+ print("1. Copy this file to your SETI Astro scripts folder")
1836
+ print("2. Open an image in SETI Astro")
1837
+ print("3. Run the script from the Scripts menu")
1838
+ print()
1839
+ print("Configuration options can be adjusted at the top of this file.")