setiastrosuitepro 1.6.2.post1__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 (367) 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/Background_startup.jpg +0 -0
  16. setiastro/images/HRDiagram.png +0 -0
  17. setiastro/images/LExtract.png +0 -0
  18. setiastro/images/LInsert.png +0 -0
  19. setiastro/images/Oxygenation-atm-2.svg.png +0 -0
  20. setiastro/images/RGB080604.png +0 -0
  21. setiastro/images/abeicon.png +0 -0
  22. setiastro/images/aberration.png +0 -0
  23. setiastro/images/andromedatry.png +0 -0
  24. setiastro/images/andromedatry_satellited.png +0 -0
  25. setiastro/images/annotated.png +0 -0
  26. setiastro/images/aperture.png +0 -0
  27. setiastro/images/astrosuite.ico +0 -0
  28. setiastro/images/astrosuite.png +0 -0
  29. setiastro/images/astrosuitepro.icns +0 -0
  30. setiastro/images/astrosuitepro.ico +0 -0
  31. setiastro/images/astrosuitepro.png +0 -0
  32. setiastro/images/background.png +0 -0
  33. setiastro/images/background2.png +0 -0
  34. setiastro/images/benchmark.png +0 -0
  35. setiastro/images/big_moon_stabilizer_timeline.png +0 -0
  36. setiastro/images/big_moon_stabilizer_timeline_clean.png +0 -0
  37. setiastro/images/blaster.png +0 -0
  38. setiastro/images/blink.png +0 -0
  39. setiastro/images/clahe.png +0 -0
  40. setiastro/images/collage.png +0 -0
  41. setiastro/images/colorwheel.png +0 -0
  42. setiastro/images/contsub.png +0 -0
  43. setiastro/images/convo.png +0 -0
  44. setiastro/images/copyslot.png +0 -0
  45. setiastro/images/cosmic.png +0 -0
  46. setiastro/images/cosmicsat.png +0 -0
  47. setiastro/images/crop1.png +0 -0
  48. setiastro/images/cropicon.png +0 -0
  49. setiastro/images/curves.png +0 -0
  50. setiastro/images/cvs.png +0 -0
  51. setiastro/images/debayer.png +0 -0
  52. setiastro/images/denoise_cnn_custom.png +0 -0
  53. setiastro/images/denoise_cnn_graph.png +0 -0
  54. setiastro/images/disk.png +0 -0
  55. setiastro/images/dse.png +0 -0
  56. setiastro/images/exoicon.png +0 -0
  57. setiastro/images/eye.png +0 -0
  58. setiastro/images/fliphorizontal.png +0 -0
  59. setiastro/images/flipvertical.png +0 -0
  60. setiastro/images/font.png +0 -0
  61. setiastro/images/freqsep.png +0 -0
  62. setiastro/images/functionbundle.png +0 -0
  63. setiastro/images/graxpert.png +0 -0
  64. setiastro/images/green.png +0 -0
  65. setiastro/images/gridicon.png +0 -0
  66. setiastro/images/halo.png +0 -0
  67. setiastro/images/hdr.png +0 -0
  68. setiastro/images/histogram.png +0 -0
  69. setiastro/images/hubble.png +0 -0
  70. setiastro/images/imagecombine.png +0 -0
  71. setiastro/images/invert.png +0 -0
  72. setiastro/images/isophote.png +0 -0
  73. setiastro/images/isophote_demo_figure.png +0 -0
  74. setiastro/images/isophote_demo_image.png +0 -0
  75. setiastro/images/isophote_demo_model.png +0 -0
  76. setiastro/images/isophote_demo_residual.png +0 -0
  77. setiastro/images/jwstpupil.png +0 -0
  78. setiastro/images/linearfit.png +0 -0
  79. setiastro/images/livestacking.png +0 -0
  80. setiastro/images/mask.png +0 -0
  81. setiastro/images/maskapply.png +0 -0
  82. setiastro/images/maskcreate.png +0 -0
  83. setiastro/images/maskremove.png +0 -0
  84. setiastro/images/morpho.png +0 -0
  85. setiastro/images/mosaic.png +0 -0
  86. setiastro/images/multiscale_decomp.png +0 -0
  87. setiastro/images/nbtorgb.png +0 -0
  88. setiastro/images/neutral.png +0 -0
  89. setiastro/images/nuke.png +0 -0
  90. setiastro/images/openfile.png +0 -0
  91. setiastro/images/pedestal.png +0 -0
  92. setiastro/images/pen.png +0 -0
  93. setiastro/images/pixelmath.png +0 -0
  94. setiastro/images/platesolve.png +0 -0
  95. setiastro/images/ppp.png +0 -0
  96. setiastro/images/pro.png +0 -0
  97. setiastro/images/project.png +0 -0
  98. setiastro/images/psf.png +0 -0
  99. setiastro/images/redo.png +0 -0
  100. setiastro/images/redoicon.png +0 -0
  101. setiastro/images/rescale.png +0 -0
  102. setiastro/images/rgbalign.png +0 -0
  103. setiastro/images/rgbcombo.png +0 -0
  104. setiastro/images/rgbextract.png +0 -0
  105. setiastro/images/rotate180.png +0 -0
  106. setiastro/images/rotateclockwise.png +0 -0
  107. setiastro/images/rotatecounterclockwise.png +0 -0
  108. setiastro/images/satellite.png +0 -0
  109. setiastro/images/script.png +0 -0
  110. setiastro/images/selectivecolor.png +0 -0
  111. setiastro/images/simbad.png +0 -0
  112. setiastro/images/slot0.png +0 -0
  113. setiastro/images/slot1.png +0 -0
  114. setiastro/images/slot2.png +0 -0
  115. setiastro/images/slot3.png +0 -0
  116. setiastro/images/slot4.png +0 -0
  117. setiastro/images/slot5.png +0 -0
  118. setiastro/images/slot6.png +0 -0
  119. setiastro/images/slot7.png +0 -0
  120. setiastro/images/slot8.png +0 -0
  121. setiastro/images/slot9.png +0 -0
  122. setiastro/images/spcc.png +0 -0
  123. setiastro/images/spin_precession_vs_lunar_distance.png +0 -0
  124. setiastro/images/spinner.gif +0 -0
  125. setiastro/images/stacking.png +0 -0
  126. setiastro/images/staradd.png +0 -0
  127. setiastro/images/staralign.png +0 -0
  128. setiastro/images/starnet.png +0 -0
  129. setiastro/images/starregistration.png +0 -0
  130. setiastro/images/starspike.png +0 -0
  131. setiastro/images/starstretch.png +0 -0
  132. setiastro/images/statstretch.png +0 -0
  133. setiastro/images/supernova.png +0 -0
  134. setiastro/images/uhs.png +0 -0
  135. setiastro/images/undoicon.png +0 -0
  136. setiastro/images/upscale.png +0 -0
  137. setiastro/images/viewbundle.png +0 -0
  138. setiastro/images/whitebalance.png +0 -0
  139. setiastro/images/wimi_icon_256x256.png +0 -0
  140. setiastro/images/wimilogo.png +0 -0
  141. setiastro/images/wims.png +0 -0
  142. setiastro/images/wrench_icon.png +0 -0
  143. setiastro/images/xisfliberator.png +0 -0
  144. setiastro/qml/ResourceMonitor.qml +126 -0
  145. setiastro/saspro/__init__.py +20 -0
  146. setiastro/saspro/__main__.py +945 -0
  147. setiastro/saspro/_generated/__init__.py +7 -0
  148. setiastro/saspro/_generated/build_info.py +3 -0
  149. setiastro/saspro/abe.py +1346 -0
  150. setiastro/saspro/abe_preset.py +196 -0
  151. setiastro/saspro/aberration_ai.py +694 -0
  152. setiastro/saspro/aberration_ai_preset.py +224 -0
  153. setiastro/saspro/accel_installer.py +218 -0
  154. setiastro/saspro/accel_workers.py +30 -0
  155. setiastro/saspro/add_stars.py +624 -0
  156. setiastro/saspro/astrobin_exporter.py +1010 -0
  157. setiastro/saspro/astrospike.py +153 -0
  158. setiastro/saspro/astrospike_python.py +1841 -0
  159. setiastro/saspro/autostretch.py +198 -0
  160. setiastro/saspro/backgroundneutral.py +602 -0
  161. setiastro/saspro/batch_convert.py +328 -0
  162. setiastro/saspro/batch_renamer.py +522 -0
  163. setiastro/saspro/blemish_blaster.py +491 -0
  164. setiastro/saspro/blink_comparator_pro.py +2926 -0
  165. setiastro/saspro/bundles.py +61 -0
  166. setiastro/saspro/bundles_dock.py +114 -0
  167. setiastro/saspro/cheat_sheet.py +213 -0
  168. setiastro/saspro/clahe.py +368 -0
  169. setiastro/saspro/comet_stacking.py +1442 -0
  170. setiastro/saspro/common_tr.py +107 -0
  171. setiastro/saspro/config.py +38 -0
  172. setiastro/saspro/config_bootstrap.py +40 -0
  173. setiastro/saspro/config_manager.py +316 -0
  174. setiastro/saspro/continuum_subtract.py +1617 -0
  175. setiastro/saspro/convo.py +1400 -0
  176. setiastro/saspro/convo_preset.py +414 -0
  177. setiastro/saspro/copyastro.py +190 -0
  178. setiastro/saspro/cosmicclarity.py +1589 -0
  179. setiastro/saspro/cosmicclarity_preset.py +407 -0
  180. setiastro/saspro/crop_dialog_pro.py +973 -0
  181. setiastro/saspro/crop_preset.py +189 -0
  182. setiastro/saspro/curve_editor_pro.py +2562 -0
  183. setiastro/saspro/curves_preset.py +375 -0
  184. setiastro/saspro/debayer.py +673 -0
  185. setiastro/saspro/debug_utils.py +29 -0
  186. setiastro/saspro/dnd_mime.py +35 -0
  187. setiastro/saspro/doc_manager.py +2664 -0
  188. setiastro/saspro/exoplanet_detector.py +2166 -0
  189. setiastro/saspro/file_utils.py +284 -0
  190. setiastro/saspro/fitsmodifier.py +748 -0
  191. setiastro/saspro/fix_bom.py +32 -0
  192. setiastro/saspro/free_torch_memory.py +48 -0
  193. setiastro/saspro/frequency_separation.py +1349 -0
  194. setiastro/saspro/function_bundle.py +1596 -0
  195. setiastro/saspro/generate_translations.py +3092 -0
  196. setiastro/saspro/ghs_dialog_pro.py +663 -0
  197. setiastro/saspro/ghs_preset.py +284 -0
  198. setiastro/saspro/graxpert.py +637 -0
  199. setiastro/saspro/graxpert_preset.py +287 -0
  200. setiastro/saspro/gui/__init__.py +0 -0
  201. setiastro/saspro/gui/main_window.py +8810 -0
  202. setiastro/saspro/gui/mixins/__init__.py +33 -0
  203. setiastro/saspro/gui/mixins/dock_mixin.py +362 -0
  204. setiastro/saspro/gui/mixins/file_mixin.py +450 -0
  205. setiastro/saspro/gui/mixins/geometry_mixin.py +403 -0
  206. setiastro/saspro/gui/mixins/header_mixin.py +441 -0
  207. setiastro/saspro/gui/mixins/mask_mixin.py +421 -0
  208. setiastro/saspro/gui/mixins/menu_mixin.py +389 -0
  209. setiastro/saspro/gui/mixins/theme_mixin.py +367 -0
  210. setiastro/saspro/gui/mixins/toolbar_mixin.py +1457 -0
  211. setiastro/saspro/gui/mixins/update_mixin.py +309 -0
  212. setiastro/saspro/gui/mixins/view_mixin.py +435 -0
  213. setiastro/saspro/gui/statistics_dialog.py +47 -0
  214. setiastro/saspro/halobgon.py +488 -0
  215. setiastro/saspro/header_viewer.py +448 -0
  216. setiastro/saspro/headless_utils.py +88 -0
  217. setiastro/saspro/histogram.py +756 -0
  218. setiastro/saspro/history_explorer.py +941 -0
  219. setiastro/saspro/i18n.py +168 -0
  220. setiastro/saspro/image_combine.py +417 -0
  221. setiastro/saspro/image_peeker_pro.py +1604 -0
  222. setiastro/saspro/imageops/__init__.py +37 -0
  223. setiastro/saspro/imageops/mdi_snap.py +292 -0
  224. setiastro/saspro/imageops/scnr.py +36 -0
  225. setiastro/saspro/imageops/starbasedwhitebalance.py +210 -0
  226. setiastro/saspro/imageops/stretch.py +236 -0
  227. setiastro/saspro/isophote.py +1182 -0
  228. setiastro/saspro/layers.py +208 -0
  229. setiastro/saspro/layers_dock.py +714 -0
  230. setiastro/saspro/lazy_imports.py +193 -0
  231. setiastro/saspro/legacy/__init__.py +2 -0
  232. setiastro/saspro/legacy/image_manager.py +2226 -0
  233. setiastro/saspro/legacy/numba_utils.py +3676 -0
  234. setiastro/saspro/legacy/xisf.py +1071 -0
  235. setiastro/saspro/linear_fit.py +537 -0
  236. setiastro/saspro/live_stacking.py +1841 -0
  237. setiastro/saspro/log_bus.py +5 -0
  238. setiastro/saspro/logging_config.py +460 -0
  239. setiastro/saspro/luminancerecombine.py +309 -0
  240. setiastro/saspro/main_helpers.py +201 -0
  241. setiastro/saspro/mask_creation.py +931 -0
  242. setiastro/saspro/masks_core.py +56 -0
  243. setiastro/saspro/mdi_widgets.py +353 -0
  244. setiastro/saspro/memory_utils.py +666 -0
  245. setiastro/saspro/metadata_patcher.py +75 -0
  246. setiastro/saspro/mfdeconv.py +3831 -0
  247. setiastro/saspro/mfdeconv_earlystop.py +71 -0
  248. setiastro/saspro/mfdeconvcudnn.py +3263 -0
  249. setiastro/saspro/mfdeconvsport.py +2382 -0
  250. setiastro/saspro/minorbodycatalog.py +567 -0
  251. setiastro/saspro/morphology.py +407 -0
  252. setiastro/saspro/multiscale_decomp.py +1293 -0
  253. setiastro/saspro/nbtorgb_stars.py +541 -0
  254. setiastro/saspro/numba_utils.py +3145 -0
  255. setiastro/saspro/numba_warmup.py +141 -0
  256. setiastro/saspro/ops/__init__.py +9 -0
  257. setiastro/saspro/ops/command_help_dialog.py +623 -0
  258. setiastro/saspro/ops/command_runner.py +217 -0
  259. setiastro/saspro/ops/commands.py +1594 -0
  260. setiastro/saspro/ops/script_editor.py +1102 -0
  261. setiastro/saspro/ops/scripts.py +1473 -0
  262. setiastro/saspro/ops/settings.py +637 -0
  263. setiastro/saspro/parallel_utils.py +554 -0
  264. setiastro/saspro/pedestal.py +121 -0
  265. setiastro/saspro/perfect_palette_picker.py +1071 -0
  266. setiastro/saspro/pipeline.py +110 -0
  267. setiastro/saspro/pixelmath.py +1604 -0
  268. setiastro/saspro/plate_solver.py +2445 -0
  269. setiastro/saspro/project_io.py +797 -0
  270. setiastro/saspro/psf_utils.py +136 -0
  271. setiastro/saspro/psf_viewer.py +549 -0
  272. setiastro/saspro/pyi_rthook_astroquery.py +95 -0
  273. setiastro/saspro/remove_green.py +331 -0
  274. setiastro/saspro/remove_stars.py +1599 -0
  275. setiastro/saspro/remove_stars_preset.py +404 -0
  276. setiastro/saspro/resources.py +501 -0
  277. setiastro/saspro/rgb_combination.py +208 -0
  278. setiastro/saspro/rgb_extract.py +19 -0
  279. setiastro/saspro/rgbalign.py +723 -0
  280. setiastro/saspro/runtime_imports.py +7 -0
  281. setiastro/saspro/runtime_torch.py +754 -0
  282. setiastro/saspro/save_options.py +73 -0
  283. setiastro/saspro/selective_color.py +1552 -0
  284. setiastro/saspro/sfcc.py +1472 -0
  285. setiastro/saspro/shortcuts.py +3043 -0
  286. setiastro/saspro/signature_insert.py +1102 -0
  287. setiastro/saspro/stacking_suite.py +18470 -0
  288. setiastro/saspro/star_alignment.py +7435 -0
  289. setiastro/saspro/star_alignment_preset.py +329 -0
  290. setiastro/saspro/star_metrics.py +49 -0
  291. setiastro/saspro/star_spikes.py +765 -0
  292. setiastro/saspro/star_stretch.py +507 -0
  293. setiastro/saspro/stat_stretch.py +538 -0
  294. setiastro/saspro/status_log_dock.py +78 -0
  295. setiastro/saspro/subwindow.py +3328 -0
  296. setiastro/saspro/supernovaasteroidhunter.py +1719 -0
  297. setiastro/saspro/swap_manager.py +99 -0
  298. setiastro/saspro/torch_backend.py +89 -0
  299. setiastro/saspro/torch_rejection.py +434 -0
  300. setiastro/saspro/translations/all_source_strings.json +3654 -0
  301. setiastro/saspro/translations/ar_translations.py +3865 -0
  302. setiastro/saspro/translations/de_translations.py +3749 -0
  303. setiastro/saspro/translations/es_translations.py +3939 -0
  304. setiastro/saspro/translations/fr_translations.py +3858 -0
  305. setiastro/saspro/translations/hi_translations.py +3571 -0
  306. setiastro/saspro/translations/integrate_translations.py +270 -0
  307. setiastro/saspro/translations/it_translations.py +3678 -0
  308. setiastro/saspro/translations/ja_translations.py +3601 -0
  309. setiastro/saspro/translations/pt_translations.py +3869 -0
  310. setiastro/saspro/translations/ru_translations.py +2848 -0
  311. setiastro/saspro/translations/saspro_ar.qm +0 -0
  312. setiastro/saspro/translations/saspro_ar.ts +255 -0
  313. setiastro/saspro/translations/saspro_de.qm +0 -0
  314. setiastro/saspro/translations/saspro_de.ts +253 -0
  315. setiastro/saspro/translations/saspro_es.qm +0 -0
  316. setiastro/saspro/translations/saspro_es.ts +12520 -0
  317. setiastro/saspro/translations/saspro_fr.qm +0 -0
  318. setiastro/saspro/translations/saspro_fr.ts +12514 -0
  319. setiastro/saspro/translations/saspro_hi.qm +0 -0
  320. setiastro/saspro/translations/saspro_hi.ts +257 -0
  321. setiastro/saspro/translations/saspro_it.qm +0 -0
  322. setiastro/saspro/translations/saspro_it.ts +12520 -0
  323. setiastro/saspro/translations/saspro_ja.qm +0 -0
  324. setiastro/saspro/translations/saspro_ja.ts +257 -0
  325. setiastro/saspro/translations/saspro_pt.qm +0 -0
  326. setiastro/saspro/translations/saspro_pt.ts +257 -0
  327. setiastro/saspro/translations/saspro_ru.qm +0 -0
  328. setiastro/saspro/translations/saspro_ru.ts +237 -0
  329. setiastro/saspro/translations/saspro_sw.qm +0 -0
  330. setiastro/saspro/translations/saspro_sw.ts +257 -0
  331. setiastro/saspro/translations/saspro_uk.qm +0 -0
  332. setiastro/saspro/translations/saspro_uk.ts +10771 -0
  333. setiastro/saspro/translations/saspro_zh.qm +0 -0
  334. setiastro/saspro/translations/saspro_zh.ts +12520 -0
  335. setiastro/saspro/translations/sw_translations.py +3671 -0
  336. setiastro/saspro/translations/uk_translations.py +3700 -0
  337. setiastro/saspro/translations/zh_translations.py +3675 -0
  338. setiastro/saspro/versioning.py +77 -0
  339. setiastro/saspro/view_bundle.py +1558 -0
  340. setiastro/saspro/wavescale_hdr.py +645 -0
  341. setiastro/saspro/wavescale_hdr_preset.py +101 -0
  342. setiastro/saspro/wavescalede.py +680 -0
  343. setiastro/saspro/wavescalede_preset.py +230 -0
  344. setiastro/saspro/wcs_update.py +374 -0
  345. setiastro/saspro/whitebalance.py +492 -0
  346. setiastro/saspro/widgets/__init__.py +48 -0
  347. setiastro/saspro/widgets/common_utilities.py +306 -0
  348. setiastro/saspro/widgets/graphics_views.py +122 -0
  349. setiastro/saspro/widgets/image_utils.py +518 -0
  350. setiastro/saspro/widgets/minigame/game.js +986 -0
  351. setiastro/saspro/widgets/minigame/index.html +53 -0
  352. setiastro/saspro/widgets/minigame/style.css +241 -0
  353. setiastro/saspro/widgets/preview_dialogs.py +280 -0
  354. setiastro/saspro/widgets/resource_monitor.py +237 -0
  355. setiastro/saspro/widgets/spinboxes.py +275 -0
  356. setiastro/saspro/widgets/themed_buttons.py +13 -0
  357. setiastro/saspro/widgets/wavelet_utils.py +331 -0
  358. setiastro/saspro/wimi.py +7996 -0
  359. setiastro/saspro/wims.py +578 -0
  360. setiastro/saspro/window_shelf.py +185 -0
  361. setiastro/saspro/xisf.py +1123 -0
  362. setiastrosuitepro-1.6.2.post1.dist-info/METADATA +278 -0
  363. setiastrosuitepro-1.6.2.post1.dist-info/RECORD +367 -0
  364. setiastrosuitepro-1.6.2.post1.dist-info/WHEEL +4 -0
  365. setiastrosuitepro-1.6.2.post1.dist-info/entry_points.txt +6 -0
  366. setiastrosuitepro-1.6.2.post1.dist-info/licenses/LICENSE +674 -0
  367. setiastrosuitepro-1.6.2.post1.dist-info/licenses/license.txt +2580 -0
@@ -0,0 +1,1473 @@
1
+ # ops/scripts.py
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import sys
6
+ import uuid
7
+ import traceback
8
+ import importlib.util
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import Callable, Optional, Any
12
+ import numpy as np
13
+ from PyQt6.QtCore import QStandardPaths, QObject, QSettings, Qt
14
+ from PyQt6.QtGui import QAction, QDesktopServices, QCursor
15
+ from PyQt6.QtWidgets import QMessageBox
16
+ from PyQt6.QtCore import QUrl
17
+
18
+ from setiastro.saspro.ops.command_runner import run_command as _run_command
19
+
20
+ # -----------------------------------------------------------------------------
21
+ # Scripts folder (FIXED ROOT: SASpro/scripts)
22
+ # -----------------------------------------------------------------------------
23
+ def get_scripts_dir() -> Path:
24
+ """
25
+ Per-user scripts folder, pinned to a stable 'SASpro/scripts' root.
26
+
27
+ Windows: %LOCALAPPDATA%/SASpro/scripts
28
+ macOS: ~/Library/Application Support/SASpro/scripts
29
+ Linux: ~/.local/share/SASpro/scripts (or $XDG_DATA_HOME)
30
+
31
+ This intentionally does NOT use Qt's AppLocalDataLocation so it won't
32
+ land under SetiAstro/Seti Astro Suite Pro.
33
+ """
34
+ # Windows
35
+ if sys.platform.startswith("win"):
36
+ base = os.getenv("LOCALAPPDATA")
37
+ if base:
38
+ root = Path(base)
39
+ else:
40
+ root = Path.home() / "AppData" / "Local"
41
+ scripts = root / "SASpro" / "scripts"
42
+
43
+ # macOS
44
+ elif sys.platform == "darwin":
45
+ root = Path.home() / "Library" / "Application Support"
46
+ scripts = root / "SASpro" / "scripts"
47
+
48
+ # Linux / other
49
+ else:
50
+ root = Path(os.getenv("XDG_DATA_HOME", Path.home() / ".local" / "share"))
51
+ scripts = root / "SASpro" / "scripts"
52
+
53
+ scripts.mkdir(parents=True, exist_ok=True)
54
+ return scripts
55
+
56
+ def migrate_old_scripts_if_needed():
57
+ """
58
+ One-time best-effort migration from the old Qt-derived folder into
59
+ the new SASpro/scripts folder.
60
+
61
+ Safe: only copies *.py that don't already exist in new location.
62
+ """
63
+ try:
64
+ new_dir = get_scripts_dir()
65
+
66
+ old_dirs: list[Path] = []
67
+
68
+ if sys.platform.startswith("win"):
69
+ old_dirs.append(
70
+ Path.home() / "AppData" / "Local" / "SetiAstro" / "Seti Astro Suite Pro" / "scripts"
71
+ )
72
+ elif sys.platform == "darwin":
73
+ old_dirs.append(
74
+ Path.home() / "Library" / "Application Support" / "SetiAstro" / "Seti Astro Suite Pro" / "scripts"
75
+ )
76
+ else:
77
+ old_dirs.append(
78
+ Path.home() / ".local" / "share" / "SetiAstro" / "Seti Astro Suite Pro" / "scripts"
79
+ )
80
+
81
+ for old in old_dirs:
82
+ if not old.exists() or not old.is_dir():
83
+ continue
84
+ for p in old.glob("*.py"):
85
+ dest = new_dir / p.name
86
+ if not dest.exists():
87
+ try:
88
+ dest.write_text(p.read_text(encoding="utf-8"), encoding="utf-8")
89
+ except Exception:
90
+ # fallback binary copy if encoding chokes
91
+ import shutil
92
+ shutil.copy2(p, dest)
93
+
94
+ except Exception:
95
+ pass
96
+
97
+
98
+ # -----------------------------------------------------------------------------
99
+ # Script context exposed to user scripts
100
+ # -----------------------------------------------------------------------------
101
+ class ScriptContext:
102
+ """
103
+ Minimal, stable API for user scripts.
104
+ Add helpers over time; try not to break existing ones.
105
+ """
106
+ def __init__(self, app_window, *, on_base: bool = False):
107
+ self.app = app_window
108
+ self._on_base = bool(on_base)
109
+
110
+ def main_window(self):
111
+ """Return the main SASpro window (stable helper for scripts)."""
112
+ return self.app
113
+
114
+ # ---- logging ----
115
+ def log(self, msg: str):
116
+ try:
117
+ self.app._log(f"[Script] {msg}")
118
+ except Exception:
119
+ print("[Script]", msg)
120
+
121
+ # ------------------------------------------------------------------
122
+ # File-based image I/O (canonical SASpro routes)
123
+ # ------------------------------------------------------------------
124
+ def load_image(self, filename: str, *, return_metadata: bool = False,
125
+ max_retries: int = 3, wait_seconds: int = 3):
126
+ """
127
+ Load an image from disk using SASpro's canonical loader.
128
+
129
+ This does NOT open or register a document or subwindow.
130
+ It is purely file I/O.
131
+
132
+ Returns:
133
+ If return_metadata=False (default):
134
+ (img, original_header, bit_depth, is_mono)
135
+ If return_metadata=True:
136
+ whatever legacy.image_manager.load_image returns in metadata mode
137
+ (typically includes image_meta/file_meta)
138
+ """
139
+ from setiastro.saspro.legacy import image_manager # canonical route
140
+ return image_manager.load_image(
141
+ filename,
142
+ max_retries=max_retries,
143
+ wait_seconds=wait_seconds,
144
+ return_metadata=bool(return_metadata),
145
+ )
146
+
147
+ def save_image(self, img_array, filename: str, *,
148
+ original_format: str | None = None,
149
+ bit_depth=None,
150
+ original_header=None,
151
+ is_mono: bool = False,
152
+ image_meta=None,
153
+ file_meta=None):
154
+ """
155
+ Save an image to disk using SASpro's canonical saver.
156
+
157
+ This does NOT require an open document.
158
+ It writes exactly through legacy.image_manager.save_image.
159
+
160
+ Args:
161
+ img_array: numpy array (mono or RGB). Any dtype accepted; saver handles it.
162
+ filename: output path
163
+ original_format: e.g. "fits", "tiff", "png". If None, inferred from suffix.
164
+ bit_depth/original_header/is_mono/image_meta/file_meta:
165
+ passed through to legacy saver.
166
+
167
+ Returns:
168
+ Whatever legacy.image_manager.save_image returns (often None or success flag).
169
+ """
170
+ from setiastro.saspro.legacy import image_manager # canonical route
171
+ from pathlib import Path
172
+
173
+ p = Path(filename)
174
+ fmt = original_format
175
+ if fmt is None or not str(fmt).strip():
176
+ # infer from extension (".fits", ".fit", ".fz", ".tif", ".tiff", ".png", etc.)
177
+ ext = p.suffix.lower().lstrip(".")
178
+ if ext in ("fit", "fits", "fz", "fits.gz", "fit.gz"):
179
+ fmt = "fits"
180
+ elif ext in ("tif", "tiff"):
181
+ fmt = "tiff"
182
+ else:
183
+ fmt = ext # png/jpg/x
184
+
185
+ return image_manager.save_image(
186
+ img_array,
187
+ str(p),
188
+ fmt,
189
+ bit_depth=bit_depth,
190
+ original_header=original_header,
191
+ is_mono=bool(is_mono),
192
+ image_meta=image_meta,
193
+ file_meta=file_meta,
194
+ )
195
+
196
+ # Friendly aliases (optional, but nice UX)
197
+ open_image = load_image
198
+ write_image = save_image
199
+
200
+ # ---- active view/doc access ----
201
+ def active_subwindow(self):
202
+ try:
203
+ return self.app.mdi.activeSubWindow()
204
+ except Exception:
205
+ return None
206
+
207
+ def active_view(self):
208
+ sw = self.active_subwindow()
209
+ return sw.widget() if sw else None
210
+
211
+ def base_document(self):
212
+ sw = self.active_subwindow()
213
+ if sw and hasattr(self.app, "_target_doc_from_subwindow"):
214
+ try:
215
+ return self.app._target_doc_from_subwindow(sw)
216
+ except Exception:
217
+ pass
218
+ return self.active_document(fallback_to_base=False)
219
+
220
+ def _docman(self):
221
+ return getattr(self.app, "doc_manager", None)
222
+
223
+ def active_document(self):
224
+ """
225
+ Normal run:
226
+ - return DocManager.get_active_document() so Preview tabs yield _RoiViewDocument.
227
+ Run-on-base:
228
+ - force base doc even if Preview is active.
229
+ """
230
+ dm = self._docman()
231
+
232
+ if dm and hasattr(dm, "get_active_document"):
233
+ if self._on_base:
234
+ # focused base is sticky and ignores ROI wrappers
235
+ base = None
236
+ try:
237
+ base = dm.get_focused_base_document()
238
+ except Exception:
239
+ base = None
240
+ return base or self.base_document()
241
+
242
+ # normal run: ROI-aware
243
+ try:
244
+ return dm.get_active_document()
245
+ except Exception:
246
+ pass
247
+
248
+ # fallback (should rarely happen)
249
+ view = self.active_view()
250
+ return getattr(view, "document", None) if view else None
251
+
252
+ def get_image(self):
253
+ doc = self.active_document()
254
+ return getattr(doc, "image", None) if doc else None
255
+
256
+ def set_image(self, img, step_name: str = "Script"):
257
+ dm = self._docman()
258
+ if dm is None:
259
+ raise RuntimeError("DocManager not available.")
260
+
261
+ img = np.asarray(img)
262
+ if img.dtype != np.float32:
263
+ img = img.astype(np.float32, copy=False)
264
+
265
+ if self._on_base:
266
+ # ✅ Bypass ROI branch: write to base doc directly
267
+ base_doc = None
268
+ try:
269
+ base_doc = dm.get_focused_base_document()
270
+ except Exception:
271
+ base_doc = None
272
+ base_doc = base_doc or self.base_document()
273
+
274
+ if base_doc is None:
275
+ raise RuntimeError("No base document to update.")
276
+
277
+ base_doc.apply_edit(img, metadata={}, step_name=step_name)
278
+
279
+ # force full repaint, including any preview
280
+ try:
281
+ dm.imageRegionUpdated.emit(base_doc, None)
282
+ except Exception:
283
+ pass
284
+
285
+ # if a preview is active, ask it to repaint too
286
+ try:
287
+ roi = dm._active_preview_roi() # returns (x,y,w,h) or None
288
+ if roi:
289
+ dm.previewRepaintRequested.emit(base_doc, roi)
290
+ except Exception:
291
+ pass
292
+ return
293
+
294
+ # ✅ Normal run: let DocManager decide (ROI preview vs full)
295
+ dm.update_active_document(img, metadata={}, step_name=step_name)
296
+
297
+ # ---- convenience wrappers into main window ----
298
+ def run_command(self, command_id: str, preset=None, **kwargs):
299
+ return _run_command(self, command_id, preset, **kwargs)
300
+
301
+ def is_frozen(self) -> bool:
302
+ return bool(getattr(sys, "frozen", False))
303
+
304
+ # ------------------------------------------------------------------
305
+ # View / document lookup helpers for scripts
306
+ # ------------------------------------------------------------------
307
+ def _iter_open_subwindows(self):
308
+ """Yield (subwindow, widget) for all open MDI subwindows."""
309
+ try:
310
+ mdi = getattr(self.app, "mdi", None)
311
+ if mdi is None:
312
+ return
313
+ for sw in mdi.subWindowList():
314
+ try:
315
+ w = sw.widget()
316
+ except Exception:
317
+ w = None
318
+ if w is not None:
319
+ yield sw, w
320
+ except Exception:
321
+ return
322
+
323
+ def _base_doc_for_widget(self, w):
324
+ """
325
+ Best-effort unwrap:
326
+ - ImageSubWindow.base_document / _base_document / document
327
+ - LiveViewDocument -> underlying base (_base)
328
+ - ROI wrapper -> parent
329
+ """
330
+ doc = (
331
+ getattr(w, "base_document", None)
332
+ or getattr(w, "_base_document", None)
333
+ or getattr(w, "document", None)
334
+ )
335
+ if doc is None:
336
+ return None
337
+
338
+ # LiveViewDocument exposes _base
339
+ base = getattr(doc, "_base", None)
340
+ if base is not None:
341
+ doc = base
342
+
343
+ # ROI wrapper -> parent base
344
+ parent = getattr(doc, "_parent_doc", None)
345
+ if parent is not None:
346
+ doc = parent
347
+
348
+ return doc
349
+
350
+ def list_views(self):
351
+ """
352
+ Return list of open views with stable info.
353
+ Each item:
354
+ {
355
+ "title": <window title>,
356
+ "name": <doc display name>,
357
+ "uid": <doc uid or None>,
358
+ "file_path": <metadata file_path or ''>,
359
+ "is_active": bool
360
+ }
361
+ """
362
+ out = []
363
+ active_sw = None
364
+ try:
365
+ active_sw = self.active_subwindow()
366
+ except Exception:
367
+ active_sw = None
368
+
369
+ for sw, w in self._iter_open_subwindows():
370
+ base_doc = self._base_doc_for_widget(w)
371
+ if base_doc is None:
372
+ continue
373
+
374
+ # titles / names
375
+ try:
376
+ title = str(sw.windowTitle() or "")
377
+ except Exception:
378
+ title = ""
379
+ try:
380
+ name = str(base_doc.display_name())
381
+ except Exception:
382
+ name = str(getattr(base_doc, "metadata", {}).get("display_name", title) or title)
383
+
384
+ uid = getattr(base_doc, "uid", None)
385
+ file_path = ""
386
+ try:
387
+ file_path = str(getattr(base_doc, "metadata", {}).get("file_path", "") or "")
388
+ except Exception:
389
+ pass
390
+
391
+ out.append({
392
+ "title": title,
393
+ "name": name,
394
+ "uid": uid,
395
+ "file_path": file_path,
396
+ "is_active": (sw is active_sw),
397
+ })
398
+ return out
399
+
400
+ def list_view_names(self):
401
+ """Convenience: return a list of human-visible names for open views."""
402
+ return [v["name"] or v["title"] for v in self.list_views()]
403
+
404
+ def get_document(self, view_name_or_uid: str, *, prefer_title: bool = False):
405
+ """
406
+ Look up an open document by:
407
+ - display name (doc.display_name())
408
+ - or window title (subwindow.windowTitle())
409
+ - or uid (exact)
410
+ Matching is case-insensitive for names/titles.
411
+ Returns base ImageDocument (never a ROI wrapper).
412
+ """
413
+ if not view_name_or_uid:
414
+ return None
415
+
416
+ key = str(view_name_or_uid).strip()
417
+ key_low = key.lower()
418
+
419
+ for sw, w in self._iter_open_subwindows():
420
+ base_doc = self._base_doc_for_widget(w)
421
+ if base_doc is None:
422
+ continue
423
+
424
+ uid = getattr(base_doc, "uid", None)
425
+ if uid is not None and str(uid) == key:
426
+ return base_doc
427
+
428
+ # compare names/titles
429
+ try:
430
+ doc_name = str(base_doc.display_name() or "").strip()
431
+ except Exception:
432
+ doc_name = str(getattr(base_doc, "metadata", {}).get("display_name", "") or "").strip()
433
+
434
+ try:
435
+ title = str(sw.windowTitle() or "").strip()
436
+ except Exception:
437
+ title = ""
438
+
439
+ if prefer_title:
440
+ if title and title.lower() == key_low:
441
+ return base_doc
442
+ if doc_name and doc_name.lower() == key_low:
443
+ return base_doc
444
+ else:
445
+ if doc_name and doc_name.lower() == key_low:
446
+ return base_doc
447
+ if title and title.lower() == key_low:
448
+ return base_doc
449
+
450
+ return None
451
+
452
+ def get_image_for(self, view_name_or_uid: str):
453
+ """Get image ndarray for a named/uid view (base doc)."""
454
+ doc = self.get_document(view_name_or_uid)
455
+ return getattr(doc, "image", None) if doc else None
456
+
457
+ def set_image_for(self, view_name_or_uid: str, img, step_name: str = "Script"):
458
+ """
459
+ Set image on a named/uid view (base doc), with undo + repaint.
460
+ This updates the full doc, not an ROI preview.
461
+ """
462
+ dm = self._docman()
463
+ if dm is None:
464
+ raise RuntimeError("DocManager not available.")
465
+
466
+ doc = self.get_document(view_name_or_uid)
467
+ if doc is None:
468
+ raise RuntimeError(f"No open view matches '{view_name_or_uid}'")
469
+
470
+ arr = np.asarray(img)
471
+ if arr.dtype != np.float32:
472
+ arr = arr.astype(np.float32, copy=False)
473
+
474
+ # Apply edit to that doc directly (full-image semantics)
475
+ doc.apply_edit(arr, metadata={}, step_name=step_name)
476
+
477
+ # Clear/invalidate any ROI caches for this base doc so previews don't stale
478
+ try:
479
+ dm._invalidate_roi_cache(doc, None)
480
+ except Exception:
481
+ pass
482
+
483
+ # Repaint any views showing this doc
484
+ try:
485
+ dm.imageRegionUpdated.emit(doc, None)
486
+ except Exception:
487
+ pass
488
+
489
+ def activate_view(self, view_name_or_uid: str) -> bool:
490
+ """
491
+ Bring a view to front by name/title/uid.
492
+ Returns True if activated.
493
+ """
494
+ key = str(view_name_or_uid).strip().lower()
495
+ mdi = getattr(self.app, "mdi", None)
496
+ if mdi is None:
497
+ return False
498
+
499
+ for sw, w in self._iter_open_subwindows():
500
+ base_doc = self._base_doc_for_widget(w)
501
+ if base_doc is None:
502
+ continue
503
+
504
+ uid = getattr(base_doc, "uid", None)
505
+ try:
506
+ doc_name = str(base_doc.display_name() or "").strip().lower()
507
+ except Exception:
508
+ doc_name = ""
509
+ try:
510
+ title = str(sw.windowTitle() or "").strip().lower()
511
+ except Exception:
512
+ title = ""
513
+
514
+ if (uid is not None and str(uid) == view_name_or_uid) or doc_name == key or title == key:
515
+ try:
516
+ mdi.setActiveSubWindow(sw)
517
+ except Exception:
518
+ pass
519
+ try:
520
+ sw.show()
521
+ sw.raise_()
522
+ except Exception:
523
+ pass
524
+ return True
525
+ return False
526
+
527
+ # ---- view enumeration / lookup by user-visible view name ----
528
+ def list_image_views(self):
529
+ """
530
+ Return a list of (view_title, doc) for all open image subwindows.
531
+ The title is the current MDI window title (what the user renamed it to).
532
+ """
533
+ out = []
534
+ mdi = getattr(self.app, "mdi", None)
535
+ if mdi is None:
536
+ return out
537
+
538
+ try:
539
+ subwins = mdi.subWindowList()
540
+ except Exception:
541
+ subwins = []
542
+
543
+ for sw in subwins:
544
+ try:
545
+ w = sw.widget()
546
+ except Exception:
547
+ continue
548
+
549
+ doc = (
550
+ getattr(w, "document", None)
551
+ or getattr(w, "base_document", None)
552
+ or getattr(w, "_base_document", None)
553
+ )
554
+ if doc is None or getattr(doc, "image", None) is None:
555
+ continue
556
+
557
+ try:
558
+ title = sw.windowTitle() or ""
559
+ except Exception:
560
+ title = ""
561
+
562
+ if not title:
563
+ # fallback to doc display name if window title missing
564
+ try:
565
+ title = doc.display_name()
566
+ except Exception:
567
+ title = "Untitled"
568
+
569
+ out.append((title, doc))
570
+
571
+ return out
572
+
573
+ def get_document_by_view_name(self, name: str):
574
+ """
575
+ Find the first open image doc whose *view title* matches name.
576
+ Matching is case-insensitive; exact match preferred, else unique prefix.
577
+ """
578
+ name_l = (name or "").strip().lower()
579
+ if not name_l:
580
+ return None
581
+
582
+ views = self.list_image_views()
583
+
584
+ # exact match
585
+ for title, doc in views:
586
+ if title.strip().lower() == name_l:
587
+ return doc
588
+
589
+ # unique prefix match
590
+ pref = [(t, d) for (t, d) in views if t.strip().lower().startswith(name_l)]
591
+ if len(pref) == 1:
592
+ return pref[0][1]
593
+
594
+ return None
595
+
596
+ def get_image_by_view_name(self, name: str):
597
+ doc = self.get_document_by_view_name(name)
598
+ return getattr(doc, "image", None) if doc else None
599
+
600
+ def open_new_document(self, img, metadata=None, name: str | None = None):
601
+ """
602
+ Convenience for scripts: create/register a new ImageDocument from an array.
603
+ """
604
+ dm = self._docman()
605
+ if dm is None:
606
+ raise RuntimeError("DocManager not available.")
607
+ return dm.open_array(np.asarray(img, dtype=np.float32), metadata=metadata, title=name)
608
+
609
+
610
+ # -----------------------------------------------------------------------------
611
+ # Script registry entries
612
+ # -----------------------------------------------------------------------------
613
+ @dataclass
614
+ class ScriptEntry:
615
+ script_id: str # NEW
616
+ path: Path
617
+ name: str
618
+ group: str = ""
619
+ shortcut: Optional[str] = None # default shortcut from script
620
+ module: Any = None
621
+ run: Optional[Callable[[ScriptContext], None]] = None
622
+
623
+
624
+
625
+ # -----------------------------------------------------------------------------
626
+ # Script manager
627
+ # -----------------------------------------------------------------------------
628
+ class ScriptManager(QObject):
629
+ """
630
+ Owns script discovery/loading and menu binding.
631
+ Main window delegates to this.
632
+ """
633
+ def __init__(self, app_window):
634
+ super().__init__(app_window)
635
+ self.app = app_window
636
+ self.registry: list[ScriptEntry] = []
637
+
638
+ # ---- internal log ----
639
+ def _log(self, msg: str):
640
+ try:
641
+ self.app._log(msg)
642
+ except Exception:
643
+ print(msg)
644
+
645
+ # ---- loading ----
646
+ def load_registry(self):
647
+ """
648
+ Discover scripts recursively under SASpro/scripts, load them, and build registry.
649
+ Skips __pycache__, hidden/underscore-prefixed files, and __init__.py.
650
+ """
651
+ migrate_old_scripts_if_needed()
652
+ scripts_dir = get_scripts_dir()
653
+ self.registry = []
654
+
655
+ try:
656
+ candidates = sorted(scripts_dir.rglob("*.py"))
657
+ except Exception:
658
+ candidates = []
659
+
660
+ for path in candidates:
661
+ # Skip pycache anywhere in path
662
+ parts_l = {p.lower() for p in path.parts}
663
+ if "__pycache__" in parts_l:
664
+ continue
665
+
666
+ # Skip hidden/private python files and package init
667
+ if path.name == "__init__.py":
668
+ continue
669
+ if path.name.startswith((".", "_")):
670
+ continue
671
+
672
+ try:
673
+ entry = self._load_one_script(path, scripts_dir)
674
+ if entry:
675
+ self.registry.append(entry)
676
+ except Exception:
677
+ self._log(f"[Scripts] Failed to load {path.name}:\n{traceback.format_exc()}")
678
+
679
+ self._log(f"[Scripts] Loaded {len(self.registry)} script(s) from {scripts_dir}")
680
+
681
+
682
+ def _load_one_script(self, path: Path, scripts_root: Path) -> ScriptEntry | None:
683
+ """
684
+ Load a single user script from disk.
685
+
686
+ - Creates a unique module name based on mtime so reload picks up changes.
687
+ - Imports module.
688
+ - Determines stable script_id (prefer SCRIPT_ID in module, else persisted id).
689
+ - Pulls metadata: SCRIPT_NAME/GROUP/SHORTCUT.
690
+ Group defaults to relative folder under scripts_root.
691
+ """
692
+ # Unique module name so reloading actually re-imports
693
+ try:
694
+ mtime_ns = path.stat().st_mtime_ns
695
+ except Exception:
696
+ mtime_ns = 0
697
+ module_name = f"saspro_user_script_{path.stem}_{mtime_ns}"
698
+
699
+ spec = importlib.util.spec_from_file_location(module_name, path)
700
+ if not spec or not spec.loader:
701
+ return None
702
+
703
+ mod = importlib.util.module_from_spec(spec)
704
+
705
+ # Import module first (so SCRIPT_ID / metadata exists)
706
+ try:
707
+ spec.loader.exec_module(mod) # type: ignore
708
+ except Exception:
709
+ self._log(f"[Scripts] Error importing {path.name}:\n{traceback.format_exc()}")
710
+ return None
711
+
712
+ # ---- entrypoint: allow run(ctx) OR main(ctx) ----
713
+ run_func = getattr(mod, "run", None)
714
+ if not callable(run_func):
715
+ run_func = getattr(mod, "main", None)
716
+
717
+ if not callable(run_func):
718
+ self._log(f"[Scripts] {path.name} has no run(ctx) or main(ctx); skipping.")
719
+ return None
720
+
721
+ # ---- helper: allow CAPS or lowercase ----
722
+ def _pick(*names, default=None):
723
+ for n in names:
724
+ if hasattr(mod, n):
725
+ return getattr(mod, n)
726
+ return default
727
+
728
+ name = _pick("SCRIPT_NAME", "script_name", default=path.stem)
729
+
730
+ # Prefer explicit group; else derive group from relative folder
731
+ group = _pick("SCRIPT_GROUP", "script_group", default=None)
732
+ if group is None or not str(group).strip():
733
+ try:
734
+ rel_parent = path.parent.relative_to(scripts_root)
735
+ group = "" if str(rel_parent) in ("", ".") else rel_parent.as_posix()
736
+ except Exception:
737
+ group = ""
738
+
739
+ shortcut = _pick("SCRIPT_SHORTCUT", "script_shortcut", default=None)
740
+
741
+ # Stable script id (prefer explicit SCRIPT_ID; else persisted by rel-path)
742
+ script_id = self._script_id_for_path(path, scripts_root, mod)
743
+
744
+ entry = ScriptEntry(
745
+ script_id=str(script_id),
746
+ path=path,
747
+ name=str(name),
748
+ group=str(group or ""),
749
+ shortcut=str(shortcut) if shortcut else None,
750
+ module=mod,
751
+ run=run_func,
752
+ )
753
+ return entry
754
+
755
+
756
+
757
+ # ---- menu wiring ----
758
+ def rebuild_menu(self, menu_scripts):
759
+ """
760
+ Clears and rebuilds the Scripts menu from registry.
761
+
762
+ Expects base actions already created on app window:
763
+ act_script_editor, act_open_scripts_folder, act_reload_scripts, act_create_sample_script
764
+ (optionally) act_open_user_scripts_github, act_open_scripts_discord
765
+
766
+ Integrates scripts into ShortcutManager using command ids:
767
+ "script:<script_id>"
768
+
769
+ Also adds "Pin Script to Canvas" submenu to create desktop shortcuts for scripts.
770
+ """
771
+ from typing import Any
772
+ from PyQt6.QtCore import Qt, QPoint
773
+ from PyQt6.QtGui import QAction, QCursor
774
+
775
+ menu_scripts.clear()
776
+
777
+ # --- fixed top actions ---
778
+ if getattr(self.app, "act_script_editor", None):
779
+ menu_scripts.addAction(self.app.act_script_editor)
780
+ menu_scripts.addSeparator()
781
+
782
+ if getattr(self.app, "act_open_user_scripts_github", None):
783
+ menu_scripts.addAction(self.app.act_open_user_scripts_github)
784
+ if getattr(self.app, "act_open_scripts_discord", None):
785
+ menu_scripts.addAction(self.app.act_open_scripts_discord)
786
+
787
+ menu_scripts.addSeparator()
788
+
789
+ if getattr(self.app, "act_open_scripts_folder", None):
790
+ menu_scripts.addAction(self.app.act_open_scripts_folder)
791
+ if getattr(self.app, "act_reload_scripts", None):
792
+ menu_scripts.addAction(self.app.act_reload_scripts)
793
+ if getattr(self.app, "act_create_sample_script", None):
794
+ menu_scripts.addAction(self.app.act_create_sample_script)
795
+
796
+ menu_scripts.addSeparator()
797
+
798
+ # ShortcutManager (optional)
799
+ sc = getattr(self.app, "shortcuts", None)
800
+ can_register = callable(getattr(sc, "register_action", None))
801
+ can_add_sc = callable(getattr(sc, "add_shortcut", None))
802
+
803
+ # Helper: pin a command id to canvas at cursor pos
804
+ def _pin_to_canvas(cmdid: str):
805
+ if not (sc and can_add_sc):
806
+ return
807
+ mdi = getattr(self.app, "mdi", None)
808
+ if mdi is None:
809
+ return
810
+ vp = mdi.viewport()
811
+ if vp is None:
812
+ return
813
+
814
+ pos = vp.mapFromGlobal(QCursor.pos())
815
+ if not vp.rect().contains(pos):
816
+ pos = vp.rect().center()
817
+
818
+ try:
819
+ sc.add_shortcut(cmdid, QPoint(int(pos.x()), int(pos.y())))
820
+ except Exception:
821
+ pass
822
+
823
+ # "Pin Script to Canvas" submenu (grouped)
824
+ pin_root = menu_scripts.addMenu("Pin Script to Canvas")
825
+ pin_group_menus: dict[str, Any] = {}
826
+
827
+ # group -> submenu for run items
828
+ group_menus: dict[str, Any] = {}
829
+
830
+ for entry in self.registry:
831
+ script_id = getattr(entry, "script_id", None)
832
+ if not script_id:
833
+ # If a script entry has no id, we can still show it in the menu,
834
+ # but it can't be pinned/registered reliably.
835
+ cmdid = None
836
+ else:
837
+ cmdid = f"script:{script_id}"
838
+
839
+ group = (entry.group or "").strip()
840
+
841
+ # ---- RUN menu placement ----
842
+ if group:
843
+ run_sub = group_menus.get(group)
844
+ if run_sub is None:
845
+ run_sub = menu_scripts.addMenu(group)
846
+ group_menus[group] = run_sub
847
+ target_menu = run_sub
848
+ else:
849
+ target_menu = menu_scripts
850
+
851
+ from PyQt6.QtGui import QIcon
852
+ from setiastro.saspro.resources import get_icons
853
+
854
+ icons = get_icons()
855
+
856
+ act = QAction(entry.name, self.app)
857
+ act.setIcon(QIcon(icons.SCRIPT)) # NEW
858
+
859
+ # IMPORTANT: make shortcuts/global binds work regardless of focus
860
+ act.setShortcutContext(Qt.ShortcutContext.ApplicationShortcut)
861
+
862
+ # store command_id on the action if we have one
863
+ if cmdid:
864
+ act.setProperty("command_id", cmdid)
865
+
866
+ # Default shortcut from script metadata (optional)
867
+ if getattr(entry, "shortcut", None):
868
+ try:
869
+ act.setShortcut(entry.shortcut)
870
+ except Exception:
871
+ pass
872
+
873
+ # Register with ShortcutManager so persisted overrides can apply
874
+ if cmdid and can_register:
875
+ try:
876
+ sc.register_action(cmdid, act)
877
+ except Exception:
878
+ pass
879
+
880
+ act.triggered.connect(lambda _=False, e=entry: self.run_entry(e))
881
+ target_menu.addAction(act)
882
+
883
+ # ---- PIN menu placement ----
884
+ # Only add to pin menu if we have a stable cmdid
885
+ if cmdid:
886
+ if group:
887
+ pin_sub = pin_group_menus.get(group)
888
+ if pin_sub is None:
889
+ pin_sub = pin_root.addMenu(group)
890
+ pin_group_menus[group] = pin_sub
891
+ pin_menu = pin_sub
892
+ else:
893
+ pin_menu = pin_root
894
+
895
+ act_pin = QAction(entry.name, self.app)
896
+ act_pin.setIcon(QIcon(icons.SCRIPT)) # NEW
897
+ act_pin.triggered.connect(lambda _=False, c=cmdid: _pin_to_canvas(c))
898
+ pin_menu.addAction(act_pin)
899
+
900
+ # If there are no pinnable scripts, disable the pin root nicely
901
+ if pin_root.actions() == []:
902
+ a = pin_root.addAction("No scripts to pin")
903
+ a.setEnabled(False)
904
+
905
+
906
+ def _script_command_id(self, entry: ScriptEntry, *, on_base: bool = False) -> str:
907
+ # Keep it stable and unique. Path is perfect because scripts are per-user.
908
+ p = entry.path.as_posix()
909
+ return f"script:{'base:' if on_base else ''}{p}"
910
+
911
+ def _pin_command_to_canvas(self, command_id: str):
912
+ mgr = getattr(self.app, "shortcuts", None)
913
+ mdi = getattr(self.app, "mdi", None)
914
+ if mgr is None or mdi is None:
915
+ return
916
+
917
+ vp = mdi.viewport()
918
+ pos = vp.mapFromGlobal(QCursor.pos())
919
+ if not vp.rect().contains(pos):
920
+ pos = vp.rect().center()
921
+
922
+ mgr.add_shortcut(command_id, pos)
923
+
924
+ # ---- running ----
925
+ def run_entry(self, entry: ScriptEntry, *, on_base: bool = False):
926
+ ctx = ScriptContext(self.app, on_base=on_base)
927
+ try:
928
+ self._log(f"[Scripts] Running '{entry.name}' ({entry.path.name}) on_base={on_base}")
929
+ entry.run(ctx) # type: ignore
930
+ self._log(f"[Scripts] Finished '{entry.name}'")
931
+ except Exception as e:
932
+ tb = traceback.format_exc()
933
+ self._log(f"[Scripts] ERROR in '{entry.name}':\n{tb}")
934
+ try:
935
+ QMessageBox.critical(self.app, "Script Error",
936
+ f"{entry.name} failed:\n\n{e}")
937
+ except Exception:
938
+ pass
939
+
940
+
941
+ # ---- convenience actions ----
942
+ def open_scripts_folder(self):
943
+ folder = get_scripts_dir()
944
+ try:
945
+ QDesktopServices.openUrl(QUrl.fromLocalFile(str(folder)))
946
+ except Exception:
947
+ # OS fallback
948
+ try:
949
+ if sys.platform.startswith("win"):
950
+ os.startfile(folder) # type: ignore
951
+ elif sys.platform == "darwin":
952
+ os.system(f'open "{folder}"')
953
+ else:
954
+ os.system(f'xdg-open "{folder}"')
955
+ except Exception:
956
+ self._log(f"[Scripts] Couldn't open scripts folder: {folder}")
957
+
958
+ def create_sample_script(self):
959
+ folder = get_scripts_dir()
960
+
961
+ samples: dict[str, str] = {}
962
+
963
+ # ------------------------------------------------------------------
964
+ # 1) sample_invert.py (existing)
965
+ # ------------------------------------------------------------------
966
+ samples["sample_invert.py"] = """\
967
+ # Sample SASpro script
968
+ # Put scripts in this folder; they appear in Scripts menu.
969
+ # Required entrypoint:
970
+ # def run(ctx):
971
+ # ...
972
+
973
+ SCRIPT_NAME = "Invert Image (Sample)"
974
+ SCRIPT_GROUP = "Samples"
975
+
976
+ import numpy as np
977
+
978
+ def run(ctx):
979
+ img = ctx.get_image()
980
+ if img is None:
981
+ ctx.log("No active image.")
982
+ return
983
+
984
+ ctx.log(f"Inverting image... shape={img.shape}, dtype={img.dtype}")
985
+
986
+ f = img.astype(np.float32)
987
+ mx = float(np.nanmax(f)) if f.size else 1.0
988
+ if mx > 1.0:
989
+ f = f / mx
990
+ f = np.clip(f, 0.0, 1.0)
991
+
992
+ out = 1.0 - f
993
+ ctx.set_image(out, step_name="Invert via Script")
994
+ ctx.log("Done.")
995
+ """
996
+
997
+ # ------------------------------------------------------------------
998
+ # 2) sample_star_preview_ui.py (SEP demo)
999
+ # ------------------------------------------------------------------
1000
+ samples["sample_star_preview_ui.py"] = """\
1001
+ from __future__ import annotations
1002
+
1003
+ # =========================
1004
+ # SASpro Script Metadata
1005
+ # =========================
1006
+ SCRIPT_NAME = "Star Preview UI (SEP Demo)"
1007
+ SCRIPT_GROUP = "Samples"
1008
+ SCRIPT_SHORTCUT = "" # optional
1009
+
1010
+ # -------------------------
1011
+ # Star Preview UI sample
1012
+ # -------------------------
1013
+
1014
+ import numpy as np
1015
+
1016
+ from PyQt6.QtCore import Qt, QTimer
1017
+ from PyQt6.QtGui import QImage, QPixmap
1018
+ from PyQt6.QtWidgets import (
1019
+ QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
1020
+ QSlider, QCheckBox, QMessageBox, QApplication, QWidget
1021
+ )
1022
+
1023
+ # your libs already bundled in SASpro
1024
+ from setiastro.saspro.imageops.stretch import stretch_color_image, stretch_mono_image
1025
+ from setiastro.saspro.imageops.starbasedwhitebalance import apply_star_based_white_balance
1026
+
1027
+ # (optional) for applying result back to active doc
1028
+ from setiastro.saspro.whitebalance import apply_white_balance_to_doc
1029
+
1030
+ # Shared utilities
1031
+ from setiastro.saspro.widgets.image_utils import to_float01 as _to_float01
1032
+
1033
+
1034
+ class StarPreviewDialog(QDialog):
1035
+ \"""
1036
+ Sample script UI:
1037
+ - Shows active image (auto-updates when subwindow changes)
1038
+ - Runs SEP detection + ellipse overlay
1039
+ - Zoom controls + Fit/1:1
1040
+ - Demo Apply WB to active image
1041
+ \"""
1042
+ def __init__(self, ctx, parent: QWidget | None = None):
1043
+ super().__init__(parent)
1044
+ self.ctx = ctx
1045
+ self.setWindowTitle("Sample Script: Star Preview UI")
1046
+ self.resize(980, 640)
1047
+
1048
+ self._zoom = 1.0
1049
+ self._img01: np.ndarray | None = None
1050
+ self._overlay01: np.ndarray | None = None
1051
+
1052
+ self._build_ui()
1053
+ self._wire()
1054
+
1055
+ # debounce for slider/checkbox
1056
+ self._debounce = QTimer(self)
1057
+ self._debounce.setSingleShot(True)
1058
+ self._debounce.setInterval(500)
1059
+ self._debounce.timeout.connect(self._rebuild_overlay)
1060
+
1061
+ # watch active base doc so preview isn't blank
1062
+ try:
1063
+ dm = getattr(self.ctx.app, "doc_manager", None)
1064
+ if dm is not None and hasattr(dm, "activeBaseChanged"):
1065
+ dm.activeBaseChanged.connect(lambda _=None: self._load_active_image())
1066
+ except Exception:
1067
+ pass
1068
+
1069
+ # initial load
1070
+ QTimer.singleShot(0, self._load_active_image)
1071
+
1072
+ # ---------------- UI ----------------
1073
+ def _build_ui(self):
1074
+ root = QVBoxLayout(self)
1075
+
1076
+ self.preview = QLabel("No active image.")
1077
+ self.preview.setAlignment(Qt.AlignmentFlag.AlignCenter)
1078
+ self.preview.setStyleSheet("border: 1px solid #333; background:#1f1f1f;")
1079
+ self.preview.setMinimumSize(720, 420)
1080
+ root.addWidget(self.preview, stretch=1)
1081
+
1082
+ # Zoom bar
1083
+ zrow = QHBoxLayout()
1084
+ self.btn_zoom_in = QPushButton("Zoom +")
1085
+ self.btn_zoom_out = QPushButton("Zoom −")
1086
+ self.btn_fit = QPushButton("Fit")
1087
+ self.btn_1to1 = QPushButton("1:1")
1088
+ zrow.addWidget(self.btn_zoom_in)
1089
+ zrow.addWidget(self.btn_zoom_out)
1090
+ zrow.addWidget(self.btn_fit)
1091
+ zrow.addWidget(self.btn_1to1)
1092
+ zrow.addStretch(1)
1093
+ root.addLayout(zrow)
1094
+
1095
+ # SEP controls
1096
+ ctrl = QHBoxLayout()
1097
+ ctrl.addWidget(QLabel("SEP threshold (σ):"))
1098
+ self.thr_slider = QSlider(Qt.Orientation.Horizontal)
1099
+ self.thr_slider.setRange(1, 100)
1100
+ self.thr_slider.setValue(50)
1101
+ self.thr_slider.setTickInterval(10)
1102
+ self.thr_slider.setTickPosition(QSlider.TickPosition.TicksBelow)
1103
+ ctrl.addWidget(self.thr_slider, stretch=1)
1104
+
1105
+ self.thr_label = QLabel("50")
1106
+ self.thr_label.setFixedWidth(30)
1107
+ ctrl.addWidget(self.thr_label)
1108
+
1109
+ self.chk_autostretch = QCheckBox("Autostretch preview")
1110
+ self.chk_autostretch.setChecked(True)
1111
+ ctrl.addWidget(self.chk_autostretch)
1112
+
1113
+ root.addLayout(ctrl)
1114
+
1115
+ # bottom buttons
1116
+ brow = QHBoxLayout()
1117
+ brow.addStretch(1)
1118
+ self.btn_apply_demo = QPushButton("Apply WB to Active Image (demo)")
1119
+ self.btn_close = QPushButton("Close")
1120
+ brow.addWidget(self.btn_apply_demo)
1121
+ brow.addWidget(self.btn_close)
1122
+ root.addLayout(brow)
1123
+
1124
+ def _wire(self):
1125
+ self.btn_close.clicked.connect(self.reject)
1126
+
1127
+ self.btn_zoom_in.clicked.connect(lambda: self._set_zoom(self._zoom * 1.25))
1128
+ self.btn_zoom_out.clicked.connect(lambda: self._set_zoom(self._zoom / 1.25))
1129
+ self.btn_fit.clicked.connect(self._zoom_fit)
1130
+ self.btn_1to1.clicked.connect(lambda: self._set_zoom(1.0))
1131
+
1132
+ self.thr_slider.valueChanged.connect(self._on_thr_changed)
1133
+ self.chk_autostretch.toggled.connect(lambda _=None: self._debounce.start())
1134
+
1135
+ self.btn_apply_demo.clicked.connect(self._apply_demo_wb)
1136
+
1137
+ # ------------- Active image -------------
1138
+ def _load_active_image(self):
1139
+ try:
1140
+ doc = self.ctx.active_document()
1141
+ except Exception:
1142
+ doc = None
1143
+
1144
+ if doc is None or getattr(doc, "image", None) is None:
1145
+ self._img01 = None
1146
+ self._overlay01 = None
1147
+ self.preview.setText("No active image.")
1148
+ self.preview.setPixmap(QPixmap())
1149
+ return
1150
+
1151
+ img = _to_float01(np.asarray(doc.image))
1152
+ self._img01 = img
1153
+ self._zoom_fit()
1154
+ self._rebuild_overlay()
1155
+
1156
+ # ------------- SEP overlay -------------
1157
+ def _on_thr_changed(self, v: int):
1158
+ self.thr_label.setText(str(v))
1159
+ self._debounce.start()
1160
+
1161
+ def _rebuild_overlay(self):
1162
+ if self._img01 is None:
1163
+ return
1164
+ try:
1165
+ thr = float(self.thr_slider.value())
1166
+ auto = bool(self.chk_autostretch.isChecked())
1167
+
1168
+ img = self._img01
1169
+ # if mono, make a fake RGB for visualization / SEP expects gray anyway
1170
+ if img.ndim == 2:
1171
+ rgb = np.repeat(img[..., None], 3, axis=2)
1172
+ elif img.ndim == 3 and img.shape[2] == 1:
1173
+ rgb = np.repeat(img, 3, axis=2)
1174
+ else:
1175
+ rgb = img
1176
+
1177
+ # Use your WB star detector just for overlay
1178
+ # (balanced output ignored; we only want overlay + count)
1179
+ _balanced, count, overlay = apply_star_based_white_balance(
1180
+ rgb, threshold=thr, autostretch=auto,
1181
+ reuse_cached_sources=False, return_star_colors=False
1182
+ )
1183
+
1184
+ self._overlay01 = overlay
1185
+ self._render_pixmap()
1186
+ self.setWindowTitle(f"Sample Script: Star Preview UI — {count} stars")
1187
+
1188
+ except Exception as e:
1189
+ self._overlay01 = None
1190
+ self.preview.setText(f"Star detection failed:\\n{e}")
1191
+
1192
+ # ------------- Rendering / zoom -------------
1193
+ def _render_pixmap(self):
1194
+ if self._overlay01 is None:
1195
+ return
1196
+ ov = np.clip(self._overlay01, 0, 1)
1197
+ h, w, c = ov.shape
1198
+ qimg = QImage((ov * 255).astype(np.uint8).data, w, h, 3*w, QImage.Format.Format_RGB888)
1199
+ pm = QPixmap.fromImage(qimg)
1200
+
1201
+ # apply zoom
1202
+ zw = int(pm.width() * self._zoom)
1203
+ zh = int(pm.height() * self._zoom)
1204
+ pmz = pm.scaled(zw, zh, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
1205
+ self.preview.setPixmap(pmz)
1206
+
1207
+ def _set_zoom(self, z: float):
1208
+ self._zoom = float(np.clip(z, 0.05, 20.0))
1209
+ self._render_pixmap()
1210
+
1211
+ def _zoom_fit(self):
1212
+ if self._overlay01 is None and self._img01 is None:
1213
+ return
1214
+ # fit based on raw image size
1215
+ base = self._overlay01 if self._overlay01 is not None else self._img01
1216
+ h, w = base.shape[:2]
1217
+ vw = max(1, self.preview.width())
1218
+ vh = max(1, self.preview.height())
1219
+ self._zoom = min(vw / w, vh / h)
1220
+ self._render_pixmap()
1221
+
1222
+ # ------------- Demo apply -------------
1223
+ def _apply_demo_wb(self):
1224
+ try:
1225
+ doc = self.ctx.active_document()
1226
+ if doc is None:
1227
+ raise RuntimeError("No active document.")
1228
+ # Reuse your headless preset WB as an example of applying edits
1229
+ preset = {"mode": "star", "threshold": float(self.thr_slider.value())}
1230
+ apply_white_balance_to_doc(doc, preset)
1231
+ QMessageBox.information(self, "Demo", "White Balance applied to active image.")
1232
+ # refresh preview after edit
1233
+ self._load_active_image()
1234
+ except Exception as e:
1235
+ QMessageBox.critical(self, "Demo", f"Failed to apply WB:\\n{e}")
1236
+
1237
+
1238
+ def run(ctx):
1239
+ \"""
1240
+ SASpro entry point.
1241
+ \"""
1242
+ w = StarPreviewDialog(ctx, parent=ctx.app)
1243
+ w.exec()
1244
+ """
1245
+
1246
+ # ------------------------------------------------------------------
1247
+ # 3) sample_average_two_docs_ui.py (NEW)
1248
+ # ------------------------------------------------------------------
1249
+ samples["sample_average_two_docs_ui.py"] = """\
1250
+ # Sample SASpro script
1251
+ # UI with two dropdowns listing open views by their CURRENT window titles.
1252
+ # Averages the two selected documents and opens a new document.
1253
+
1254
+ from __future__ import annotations
1255
+
1256
+ SCRIPT_NAME = "Average Two Documents (UI Sample)"
1257
+ SCRIPT_GROUP = "Samples"
1258
+
1259
+ import numpy as np
1260
+
1261
+ from PyQt6.QtWidgets import (
1262
+ QDialog, QVBoxLayout, QHBoxLayout, QLabel, QComboBox,
1263
+ QPushButton, QMessageBox
1264
+ )
1265
+
1266
+
1267
+ class AverageTwoDocsDialog(QDialog):
1268
+ def __init__(self, ctx):
1269
+ super().__init__(parent=ctx.app)
1270
+ self.ctx = ctx
1271
+ self.setWindowTitle("Average Two Documents")
1272
+ self.resize(520, 180)
1273
+
1274
+ self._title_to_doc = {}
1275
+
1276
+ root = QVBoxLayout(self)
1277
+
1278
+ # Row A
1279
+ row_a = QHBoxLayout()
1280
+ row_a.addWidget(QLabel("Document A:"))
1281
+ self.combo_a = QComboBox()
1282
+ row_a.addWidget(self.combo_a, 1)
1283
+ root.addLayout(row_a)
1284
+
1285
+ # Row B
1286
+ row_b = QHBoxLayout()
1287
+ row_b.addWidget(QLabel("Document B:"))
1288
+ self.combo_b = QComboBox()
1289
+ row_b.addWidget(self.combo_b, 1)
1290
+ root.addLayout(row_b)
1291
+
1292
+ # Buttons
1293
+ brow = QHBoxLayout()
1294
+ self.btn_refresh = QPushButton("Refresh List")
1295
+ self.btn_avg = QPushButton("Average → New Doc")
1296
+ self.btn_close = QPushButton("Close")
1297
+ brow.addStretch(1)
1298
+ brow.addWidget(self.btn_refresh)
1299
+ brow.addWidget(self.btn_avg)
1300
+ brow.addWidget(self.btn_close)
1301
+ root.addLayout(brow)
1302
+
1303
+ self.btn_refresh.clicked.connect(self._populate)
1304
+ self.btn_avg.clicked.connect(self._do_average)
1305
+ self.btn_close.clicked.connect(self.reject)
1306
+
1307
+ self._populate()
1308
+
1309
+ def _populate(self):
1310
+ self.combo_a.clear()
1311
+ self.combo_b.clear()
1312
+ self._title_to_doc.clear()
1313
+
1314
+ try:
1315
+ views = self.ctx.list_image_views()
1316
+ except Exception:
1317
+ views = []
1318
+
1319
+ for title, doc in views:
1320
+ # if duplicate names exist, disambiguate slightly
1321
+ key = title
1322
+ if key in self._title_to_doc:
1323
+ # add uid or a counter suffix
1324
+ try:
1325
+ uid = getattr(doc, "uid", "")[:6]
1326
+ key = f"{title} [{uid}]"
1327
+ except Exception:
1328
+ n = 2
1329
+ while f"{title} ({n})" in self._title_to_doc:
1330
+ n += 1
1331
+ key = f"{title} ({n})"
1332
+
1333
+ self._title_to_doc[key] = doc
1334
+ self.combo_a.addItem(key)
1335
+ self.combo_b.addItem(key)
1336
+
1337
+ if self.combo_a.count() == 0:
1338
+ self.combo_a.addItem("<no image views>")
1339
+ self.combo_b.addItem("<no image views>")
1340
+ self.btn_avg.setEnabled(False)
1341
+ else:
1342
+ self.btn_avg.setEnabled(True)
1343
+
1344
+ def _do_average(self):
1345
+ key_a = self.combo_a.currentText()
1346
+ key_b = self.combo_b.currentText()
1347
+
1348
+ doc_a = self._title_to_doc.get(key_a)
1349
+ doc_b = self._title_to_doc.get(key_b)
1350
+
1351
+ if doc_a is None or doc_b is None:
1352
+ QMessageBox.warning(self, "Average", "Please select two valid documents.")
1353
+ return
1354
+
1355
+ img_a = getattr(doc_a, "image", None)
1356
+ img_b = getattr(doc_b, "image", None)
1357
+
1358
+ if img_a is None or img_b is None:
1359
+ QMessageBox.warning(self, "Average", "One of the selected documents has no image.")
1360
+ return
1361
+
1362
+ a = np.asarray(img_a, dtype=np.float32)
1363
+ b = np.asarray(img_b, dtype=np.float32)
1364
+
1365
+ # reconcile mono/color
1366
+ if a.ndim == 2:
1367
+ a = a[..., None]
1368
+ if b.ndim == 2:
1369
+ b = b[..., None]
1370
+ if a.shape[2] == 1 and b.shape[2] == 3:
1371
+ a = np.repeat(a, 3, axis=2)
1372
+ if b.shape[2] == 1 and a.shape[2] == 3:
1373
+ b = np.repeat(b, 3, axis=2)
1374
+
1375
+ if a.shape != b.shape:
1376
+ QMessageBox.warning(
1377
+ self, "Average",
1378
+ f"Shape mismatch:\\nA: {a.shape}\\nB: {b.shape}\\n\\n"
1379
+ "For this sample, images must match exactly."
1380
+ )
1381
+ return
1382
+
1383
+ out = 0.5 * (a + b)
1384
+
1385
+ # name the new doc based on view titles
1386
+ new_name = f"Average({key_a}, {key_b})"
1387
+
1388
+ try:
1389
+ self.ctx.open_new_document(out, metadata={}, name=new_name)
1390
+ QMessageBox.information(self, "Average", f"Created new document:\\n{new_name}")
1391
+ except Exception as e:
1392
+ QMessageBox.critical(self, "Average", f"Failed to create new doc:\\n{e}")
1393
+
1394
+
1395
+ def run(ctx):
1396
+ dlg = AverageTwoDocsDialog(ctx)
1397
+ dlg.exec()
1398
+ """
1399
+
1400
+ created = []
1401
+ skipped = []
1402
+
1403
+ for fname, text in samples.items():
1404
+ path = folder / fname
1405
+ if path.exists():
1406
+ skipped.append(fname)
1407
+ continue
1408
+ try:
1409
+ path.write_text(text, encoding="utf-8")
1410
+ created.append(fname)
1411
+ self._log(f"[Scripts] Wrote sample script: {path}")
1412
+ except Exception:
1413
+ self._log(f"[Scripts] Failed to write {fname}:\n{traceback.format_exc()}")
1414
+
1415
+ # user message
1416
+ try:
1417
+ if created and not skipped:
1418
+ QMessageBox.information(
1419
+ self.app, "Sample Scripts Created",
1420
+ "Created sample scripts:\n\n" + "\n".join(created) +
1421
+ "\n\nReload Scripts to see them."
1422
+ )
1423
+ elif created and skipped:
1424
+ QMessageBox.information(
1425
+ self.app, "Sample Scripts Created",
1426
+ "Created:\n" + "\n".join(created) +
1427
+ "\n\nAlready existed:\n" + "\n".join(skipped) +
1428
+ "\n\nReload Scripts to see new ones."
1429
+ )
1430
+ else:
1431
+ QMessageBox.information(
1432
+ self.app, "Sample Scripts",
1433
+ "All sample scripts already exist:\n\n" + "\n".join(skipped)
1434
+ )
1435
+ except Exception:
1436
+ pass
1437
+
1438
+ self._log(f"[Scripts] Failed to write sample script:\n{traceback.format_exc()}")
1439
+
1440
+
1441
+ def _script_id_for_path(self, path: Path, scripts_root: Path, mod=None) -> str:
1442
+ """
1443
+ Determine a stable script_id.
1444
+
1445
+ Priority:
1446
+ 1) SCRIPT_ID / script_id defined in the script (best; survives renames/moves)
1447
+ 2) Persisted id in QSettings keyed by *relative path inside scripts_root*
1448
+ (stable across machines if folder structure is same)
1449
+
1450
+ NOTE: We intentionally DO NOT key by absolute path.
1451
+ """
1452
+ # 1) Prefer explicit SCRIPT_ID in the script file (best)
1453
+ if mod is not None:
1454
+ sid = getattr(mod, "SCRIPT_ID", None) or getattr(mod, "script_id", None)
1455
+ if isinstance(sid, str) and sid.strip():
1456
+ return sid.strip()
1457
+
1458
+ # 2) Persist per-relative-path (not absolute)
1459
+ try:
1460
+ rel = path.relative_to(scripts_root).as_posix()
1461
+ except Exception:
1462
+ rel = path.as_posix()
1463
+
1464
+ s = QSettings()
1465
+ key = f"Scripts/ids_rel/{rel}"
1466
+ sid = s.value(key, "", type=str) or ""
1467
+ if sid:
1468
+ return sid
1469
+
1470
+ sid = uuid.uuid4().hex
1471
+ s.setValue(key, sid)
1472
+ s.sync()
1473
+ return sid