setiastrosuitepro 1.6.7__py3-none-any.whl

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

Potentially problematic release.


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

Files changed (394) 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/acv_icon.png +0 -0
  24. setiastro/images/andromedatry.png +0 -0
  25. setiastro/images/andromedatry_satellited.png +0 -0
  26. setiastro/images/annotated.png +0 -0
  27. setiastro/images/aperture.png +0 -0
  28. setiastro/images/astrosuite.ico +0 -0
  29. setiastro/images/astrosuite.png +0 -0
  30. setiastro/images/astrosuitepro.icns +0 -0
  31. setiastro/images/astrosuitepro.ico +0 -0
  32. setiastro/images/astrosuitepro.png +0 -0
  33. setiastro/images/background.png +0 -0
  34. setiastro/images/background2.png +0 -0
  35. setiastro/images/benchmark.png +0 -0
  36. setiastro/images/big_moon_stabilizer_timeline.png +0 -0
  37. setiastro/images/big_moon_stabilizer_timeline_clean.png +0 -0
  38. setiastro/images/blaster.png +0 -0
  39. setiastro/images/blink.png +0 -0
  40. setiastro/images/clahe.png +0 -0
  41. setiastro/images/collage.png +0 -0
  42. setiastro/images/colorwheel.png +0 -0
  43. setiastro/images/contsub.png +0 -0
  44. setiastro/images/convo.png +0 -0
  45. setiastro/images/copyslot.png +0 -0
  46. setiastro/images/cosmic.png +0 -0
  47. setiastro/images/cosmicsat.png +0 -0
  48. setiastro/images/crop1.png +0 -0
  49. setiastro/images/cropicon.png +0 -0
  50. setiastro/images/curves.png +0 -0
  51. setiastro/images/cvs.png +0 -0
  52. setiastro/images/debayer.png +0 -0
  53. setiastro/images/denoise_cnn_custom.png +0 -0
  54. setiastro/images/denoise_cnn_graph.png +0 -0
  55. setiastro/images/disk.png +0 -0
  56. setiastro/images/dse.png +0 -0
  57. setiastro/images/exoicon.png +0 -0
  58. setiastro/images/eye.png +0 -0
  59. setiastro/images/first_quarter.png +0 -0
  60. setiastro/images/fliphorizontal.png +0 -0
  61. setiastro/images/flipvertical.png +0 -0
  62. setiastro/images/font.png +0 -0
  63. setiastro/images/freqsep.png +0 -0
  64. setiastro/images/full_moon.png +0 -0
  65. setiastro/images/functionbundle.png +0 -0
  66. setiastro/images/graxpert.png +0 -0
  67. setiastro/images/green.png +0 -0
  68. setiastro/images/gridicon.png +0 -0
  69. setiastro/images/halo.png +0 -0
  70. setiastro/images/hdr.png +0 -0
  71. setiastro/images/histogram.png +0 -0
  72. setiastro/images/hubble.png +0 -0
  73. setiastro/images/imagecombine.png +0 -0
  74. setiastro/images/invert.png +0 -0
  75. setiastro/images/isophote.png +0 -0
  76. setiastro/images/isophote_demo_figure.png +0 -0
  77. setiastro/images/isophote_demo_image.png +0 -0
  78. setiastro/images/isophote_demo_model.png +0 -0
  79. setiastro/images/isophote_demo_residual.png +0 -0
  80. setiastro/images/jwstpupil.png +0 -0
  81. setiastro/images/last_quarter.png +0 -0
  82. setiastro/images/linearfit.png +0 -0
  83. setiastro/images/livestacking.png +0 -0
  84. setiastro/images/mask.png +0 -0
  85. setiastro/images/maskapply.png +0 -0
  86. setiastro/images/maskcreate.png +0 -0
  87. setiastro/images/maskremove.png +0 -0
  88. setiastro/images/morpho.png +0 -0
  89. setiastro/images/mosaic.png +0 -0
  90. setiastro/images/multiscale_decomp.png +0 -0
  91. setiastro/images/nbtorgb.png +0 -0
  92. setiastro/images/neutral.png +0 -0
  93. setiastro/images/new_moon.png +0 -0
  94. setiastro/images/nuke.png +0 -0
  95. setiastro/images/openfile.png +0 -0
  96. setiastro/images/pedestal.png +0 -0
  97. setiastro/images/pen.png +0 -0
  98. setiastro/images/pixelmath.png +0 -0
  99. setiastro/images/platesolve.png +0 -0
  100. setiastro/images/ppp.png +0 -0
  101. setiastro/images/pro.png +0 -0
  102. setiastro/images/project.png +0 -0
  103. setiastro/images/psf.png +0 -0
  104. setiastro/images/redo.png +0 -0
  105. setiastro/images/redoicon.png +0 -0
  106. setiastro/images/rescale.png +0 -0
  107. setiastro/images/rgbalign.png +0 -0
  108. setiastro/images/rgbcombo.png +0 -0
  109. setiastro/images/rgbextract.png +0 -0
  110. setiastro/images/rotate180.png +0 -0
  111. setiastro/images/rotatearbitrary.png +0 -0
  112. setiastro/images/rotateclockwise.png +0 -0
  113. setiastro/images/rotatecounterclockwise.png +0 -0
  114. setiastro/images/satellite.png +0 -0
  115. setiastro/images/script.png +0 -0
  116. setiastro/images/selectivecolor.png +0 -0
  117. setiastro/images/simbad.png +0 -0
  118. setiastro/images/slot0.png +0 -0
  119. setiastro/images/slot1.png +0 -0
  120. setiastro/images/slot2.png +0 -0
  121. setiastro/images/slot3.png +0 -0
  122. setiastro/images/slot4.png +0 -0
  123. setiastro/images/slot5.png +0 -0
  124. setiastro/images/slot6.png +0 -0
  125. setiastro/images/slot7.png +0 -0
  126. setiastro/images/slot8.png +0 -0
  127. setiastro/images/slot9.png +0 -0
  128. setiastro/images/spcc.png +0 -0
  129. setiastro/images/spin_precession_vs_lunar_distance.png +0 -0
  130. setiastro/images/spinner.gif +0 -0
  131. setiastro/images/stacking.png +0 -0
  132. setiastro/images/staradd.png +0 -0
  133. setiastro/images/staralign.png +0 -0
  134. setiastro/images/starnet.png +0 -0
  135. setiastro/images/starregistration.png +0 -0
  136. setiastro/images/starspike.png +0 -0
  137. setiastro/images/starstretch.png +0 -0
  138. setiastro/images/statstretch.png +0 -0
  139. setiastro/images/supernova.png +0 -0
  140. setiastro/images/uhs.png +0 -0
  141. setiastro/images/undoicon.png +0 -0
  142. setiastro/images/upscale.png +0 -0
  143. setiastro/images/viewbundle.png +0 -0
  144. setiastro/images/waning_crescent_1.png +0 -0
  145. setiastro/images/waning_crescent_2.png +0 -0
  146. setiastro/images/waning_crescent_3.png +0 -0
  147. setiastro/images/waning_crescent_4.png +0 -0
  148. setiastro/images/waning_crescent_5.png +0 -0
  149. setiastro/images/waning_gibbous_1.png +0 -0
  150. setiastro/images/waning_gibbous_2.png +0 -0
  151. setiastro/images/waning_gibbous_3.png +0 -0
  152. setiastro/images/waning_gibbous_4.png +0 -0
  153. setiastro/images/waning_gibbous_5.png +0 -0
  154. setiastro/images/waxing_crescent_1.png +0 -0
  155. setiastro/images/waxing_crescent_2.png +0 -0
  156. setiastro/images/waxing_crescent_3.png +0 -0
  157. setiastro/images/waxing_crescent_4.png +0 -0
  158. setiastro/images/waxing_crescent_5.png +0 -0
  159. setiastro/images/waxing_gibbous_1.png +0 -0
  160. setiastro/images/waxing_gibbous_2.png +0 -0
  161. setiastro/images/waxing_gibbous_3.png +0 -0
  162. setiastro/images/waxing_gibbous_4.png +0 -0
  163. setiastro/images/waxing_gibbous_5.png +0 -0
  164. setiastro/images/whitebalance.png +0 -0
  165. setiastro/images/wimi_icon_256x256.png +0 -0
  166. setiastro/images/wimilogo.png +0 -0
  167. setiastro/images/wims.png +0 -0
  168. setiastro/images/wrench_icon.png +0 -0
  169. setiastro/images/xisfliberator.png +0 -0
  170. setiastro/qml/ResourceMonitor.qml +128 -0
  171. setiastro/saspro/__init__.py +20 -0
  172. setiastro/saspro/__main__.py +964 -0
  173. setiastro/saspro/_generated/__init__.py +7 -0
  174. setiastro/saspro/_generated/build_info.py +3 -0
  175. setiastro/saspro/abe.py +1379 -0
  176. setiastro/saspro/abe_preset.py +196 -0
  177. setiastro/saspro/aberration_ai.py +910 -0
  178. setiastro/saspro/aberration_ai_preset.py +224 -0
  179. setiastro/saspro/accel_installer.py +218 -0
  180. setiastro/saspro/accel_workers.py +30 -0
  181. setiastro/saspro/acv_exporter.py +379 -0
  182. setiastro/saspro/add_stars.py +627 -0
  183. setiastro/saspro/astrobin_exporter.py +1010 -0
  184. setiastro/saspro/astrospike.py +153 -0
  185. setiastro/saspro/astrospike_python.py +1841 -0
  186. setiastro/saspro/autostretch.py +198 -0
  187. setiastro/saspro/backgroundneutral.py +639 -0
  188. setiastro/saspro/batch_convert.py +328 -0
  189. setiastro/saspro/batch_renamer.py +522 -0
  190. setiastro/saspro/blemish_blaster.py +494 -0
  191. setiastro/saspro/blink_comparator_pro.py +3149 -0
  192. setiastro/saspro/bundles.py +61 -0
  193. setiastro/saspro/bundles_dock.py +114 -0
  194. setiastro/saspro/cheat_sheet.py +213 -0
  195. setiastro/saspro/clahe.py +371 -0
  196. setiastro/saspro/comet_stacking.py +1442 -0
  197. setiastro/saspro/common_tr.py +107 -0
  198. setiastro/saspro/config.py +38 -0
  199. setiastro/saspro/config_bootstrap.py +40 -0
  200. setiastro/saspro/config_manager.py +316 -0
  201. setiastro/saspro/continuum_subtract.py +1620 -0
  202. setiastro/saspro/convo.py +1403 -0
  203. setiastro/saspro/convo_preset.py +414 -0
  204. setiastro/saspro/copyastro.py +190 -0
  205. setiastro/saspro/cosmicclarity.py +1593 -0
  206. setiastro/saspro/cosmicclarity_preset.py +407 -0
  207. setiastro/saspro/crop_dialog_pro.py +1005 -0
  208. setiastro/saspro/crop_preset.py +189 -0
  209. setiastro/saspro/curve_editor_pro.py +2608 -0
  210. setiastro/saspro/curves_preset.py +375 -0
  211. setiastro/saspro/debayer.py +673 -0
  212. setiastro/saspro/debug_utils.py +29 -0
  213. setiastro/saspro/dnd_mime.py +35 -0
  214. setiastro/saspro/doc_manager.py +2727 -0
  215. setiastro/saspro/exoplanet_detector.py +2258 -0
  216. setiastro/saspro/file_utils.py +284 -0
  217. setiastro/saspro/fitsmodifier.py +748 -0
  218. setiastro/saspro/fix_bom.py +32 -0
  219. setiastro/saspro/free_torch_memory.py +48 -0
  220. setiastro/saspro/frequency_separation.py +1352 -0
  221. setiastro/saspro/function_bundle.py +1596 -0
  222. setiastro/saspro/generate_translations.py +3092 -0
  223. setiastro/saspro/ghs_dialog_pro.py +728 -0
  224. setiastro/saspro/ghs_preset.py +284 -0
  225. setiastro/saspro/graxpert.py +638 -0
  226. setiastro/saspro/graxpert_preset.py +287 -0
  227. setiastro/saspro/gui/__init__.py +0 -0
  228. setiastro/saspro/gui/main_window.py +8928 -0
  229. setiastro/saspro/gui/mixins/__init__.py +33 -0
  230. setiastro/saspro/gui/mixins/dock_mixin.py +375 -0
  231. setiastro/saspro/gui/mixins/file_mixin.py +450 -0
  232. setiastro/saspro/gui/mixins/geometry_mixin.py +503 -0
  233. setiastro/saspro/gui/mixins/header_mixin.py +441 -0
  234. setiastro/saspro/gui/mixins/mask_mixin.py +421 -0
  235. setiastro/saspro/gui/mixins/menu_mixin.py +391 -0
  236. setiastro/saspro/gui/mixins/theme_mixin.py +367 -0
  237. setiastro/saspro/gui/mixins/toolbar_mixin.py +1824 -0
  238. setiastro/saspro/gui/mixins/update_mixin.py +323 -0
  239. setiastro/saspro/gui/mixins/view_mixin.py +477 -0
  240. setiastro/saspro/gui/statistics_dialog.py +47 -0
  241. setiastro/saspro/halobgon.py +492 -0
  242. setiastro/saspro/header_viewer.py +448 -0
  243. setiastro/saspro/headless_utils.py +88 -0
  244. setiastro/saspro/histogram.py +760 -0
  245. setiastro/saspro/history_explorer.py +941 -0
  246. setiastro/saspro/i18n.py +168 -0
  247. setiastro/saspro/image_combine.py +421 -0
  248. setiastro/saspro/image_peeker_pro.py +1608 -0
  249. setiastro/saspro/imageops/__init__.py +37 -0
  250. setiastro/saspro/imageops/mdi_snap.py +292 -0
  251. setiastro/saspro/imageops/scnr.py +36 -0
  252. setiastro/saspro/imageops/starbasedwhitebalance.py +210 -0
  253. setiastro/saspro/imageops/stretch.py +236 -0
  254. setiastro/saspro/isophote.py +1186 -0
  255. setiastro/saspro/layers.py +208 -0
  256. setiastro/saspro/layers_dock.py +714 -0
  257. setiastro/saspro/lazy_imports.py +193 -0
  258. setiastro/saspro/legacy/__init__.py +2 -0
  259. setiastro/saspro/legacy/image_manager.py +2360 -0
  260. setiastro/saspro/legacy/numba_utils.py +3676 -0
  261. setiastro/saspro/legacy/xisf.py +1213 -0
  262. setiastro/saspro/linear_fit.py +537 -0
  263. setiastro/saspro/live_stacking.py +1854 -0
  264. setiastro/saspro/log_bus.py +5 -0
  265. setiastro/saspro/logging_config.py +460 -0
  266. setiastro/saspro/luminancerecombine.py +510 -0
  267. setiastro/saspro/main_helpers.py +201 -0
  268. setiastro/saspro/mask_creation.py +1090 -0
  269. setiastro/saspro/masks_core.py +56 -0
  270. setiastro/saspro/mdi_widgets.py +353 -0
  271. setiastro/saspro/memory_utils.py +666 -0
  272. setiastro/saspro/metadata_patcher.py +75 -0
  273. setiastro/saspro/mfdeconv.py +3909 -0
  274. setiastro/saspro/mfdeconv_earlystop.py +71 -0
  275. setiastro/saspro/mfdeconvcudnn.py +3312 -0
  276. setiastro/saspro/mfdeconvsport.py +2459 -0
  277. setiastro/saspro/minorbodycatalog.py +567 -0
  278. setiastro/saspro/morphology.py +411 -0
  279. setiastro/saspro/multiscale_decomp.py +1751 -0
  280. setiastro/saspro/nbtorgb_stars.py +541 -0
  281. setiastro/saspro/numba_utils.py +3145 -0
  282. setiastro/saspro/numba_warmup.py +141 -0
  283. setiastro/saspro/ops/__init__.py +9 -0
  284. setiastro/saspro/ops/command_help_dialog.py +623 -0
  285. setiastro/saspro/ops/command_runner.py +217 -0
  286. setiastro/saspro/ops/commands.py +1594 -0
  287. setiastro/saspro/ops/script_editor.py +1105 -0
  288. setiastro/saspro/ops/scripts.py +1476 -0
  289. setiastro/saspro/ops/settings.py +637 -0
  290. setiastro/saspro/parallel_utils.py +554 -0
  291. setiastro/saspro/pedestal.py +121 -0
  292. setiastro/saspro/perfect_palette_picker.py +1105 -0
  293. setiastro/saspro/pipeline.py +110 -0
  294. setiastro/saspro/pixelmath.py +1604 -0
  295. setiastro/saspro/plate_solver.py +2480 -0
  296. setiastro/saspro/project_io.py +797 -0
  297. setiastro/saspro/psf_utils.py +136 -0
  298. setiastro/saspro/psf_viewer.py +631 -0
  299. setiastro/saspro/pyi_rthook_astroquery.py +95 -0
  300. setiastro/saspro/remove_green.py +331 -0
  301. setiastro/saspro/remove_stars.py +1599 -0
  302. setiastro/saspro/remove_stars_preset.py +446 -0
  303. setiastro/saspro/resources.py +570 -0
  304. setiastro/saspro/rgb_combination.py +208 -0
  305. setiastro/saspro/rgb_extract.py +19 -0
  306. setiastro/saspro/rgbalign.py +727 -0
  307. setiastro/saspro/runtime_imports.py +7 -0
  308. setiastro/saspro/runtime_torch.py +754 -0
  309. setiastro/saspro/save_options.py +73 -0
  310. setiastro/saspro/selective_color.py +1614 -0
  311. setiastro/saspro/sfcc.py +1530 -0
  312. setiastro/saspro/shortcuts.py +3125 -0
  313. setiastro/saspro/signature_insert.py +1106 -0
  314. setiastro/saspro/stacking_suite.py +19069 -0
  315. setiastro/saspro/star_alignment.py +7383 -0
  316. setiastro/saspro/star_alignment_preset.py +329 -0
  317. setiastro/saspro/star_metrics.py +49 -0
  318. setiastro/saspro/star_spikes.py +769 -0
  319. setiastro/saspro/star_stretch.py +542 -0
  320. setiastro/saspro/stat_stretch.py +554 -0
  321. setiastro/saspro/status_log_dock.py +78 -0
  322. setiastro/saspro/subwindow.py +3523 -0
  323. setiastro/saspro/supernovaasteroidhunter.py +1719 -0
  324. setiastro/saspro/swap_manager.py +134 -0
  325. setiastro/saspro/torch_backend.py +89 -0
  326. setiastro/saspro/torch_rejection.py +434 -0
  327. setiastro/saspro/translations/all_source_strings.json +4726 -0
  328. setiastro/saspro/translations/ar_translations.py +4096 -0
  329. setiastro/saspro/translations/de_translations.py +3728 -0
  330. setiastro/saspro/translations/es_translations.py +4169 -0
  331. setiastro/saspro/translations/fr_translations.py +4090 -0
  332. setiastro/saspro/translations/hi_translations.py +3803 -0
  333. setiastro/saspro/translations/integrate_translations.py +271 -0
  334. setiastro/saspro/translations/it_translations.py +4728 -0
  335. setiastro/saspro/translations/ja_translations.py +3834 -0
  336. setiastro/saspro/translations/pt_translations.py +3847 -0
  337. setiastro/saspro/translations/ru_translations.py +3082 -0
  338. setiastro/saspro/translations/saspro_ar.qm +0 -0
  339. setiastro/saspro/translations/saspro_ar.ts +16019 -0
  340. setiastro/saspro/translations/saspro_de.qm +0 -0
  341. setiastro/saspro/translations/saspro_de.ts +14548 -0
  342. setiastro/saspro/translations/saspro_es.qm +0 -0
  343. setiastro/saspro/translations/saspro_es.ts +16202 -0
  344. setiastro/saspro/translations/saspro_fr.qm +0 -0
  345. setiastro/saspro/translations/saspro_fr.ts +15870 -0
  346. setiastro/saspro/translations/saspro_hi.qm +0 -0
  347. setiastro/saspro/translations/saspro_hi.ts +14855 -0
  348. setiastro/saspro/translations/saspro_it.qm +0 -0
  349. setiastro/saspro/translations/saspro_it.ts +19046 -0
  350. setiastro/saspro/translations/saspro_ja.qm +0 -0
  351. setiastro/saspro/translations/saspro_ja.ts +14980 -0
  352. setiastro/saspro/translations/saspro_pt.qm +0 -0
  353. setiastro/saspro/translations/saspro_pt.ts +15024 -0
  354. setiastro/saspro/translations/saspro_ru.qm +0 -0
  355. setiastro/saspro/translations/saspro_ru.ts +11835 -0
  356. setiastro/saspro/translations/saspro_sw.qm +0 -0
  357. setiastro/saspro/translations/saspro_sw.ts +15237 -0
  358. setiastro/saspro/translations/saspro_uk.qm +0 -0
  359. setiastro/saspro/translations/saspro_uk.ts +15248 -0
  360. setiastro/saspro/translations/saspro_zh.qm +0 -0
  361. setiastro/saspro/translations/saspro_zh.ts +15289 -0
  362. setiastro/saspro/translations/sw_translations.py +3897 -0
  363. setiastro/saspro/translations/uk_translations.py +3929 -0
  364. setiastro/saspro/translations/zh_translations.py +3910 -0
  365. setiastro/saspro/versioning.py +77 -0
  366. setiastro/saspro/view_bundle.py +1558 -0
  367. setiastro/saspro/wavescale_hdr.py +648 -0
  368. setiastro/saspro/wavescale_hdr_preset.py +101 -0
  369. setiastro/saspro/wavescalede.py +683 -0
  370. setiastro/saspro/wavescalede_preset.py +230 -0
  371. setiastro/saspro/wcs_update.py +374 -0
  372. setiastro/saspro/whitebalance.py +540 -0
  373. setiastro/saspro/widgets/__init__.py +48 -0
  374. setiastro/saspro/widgets/common_utilities.py +306 -0
  375. setiastro/saspro/widgets/graphics_views.py +122 -0
  376. setiastro/saspro/widgets/image_utils.py +518 -0
  377. setiastro/saspro/widgets/minigame/game.js +991 -0
  378. setiastro/saspro/widgets/minigame/index.html +53 -0
  379. setiastro/saspro/widgets/minigame/style.css +241 -0
  380. setiastro/saspro/widgets/preview_dialogs.py +280 -0
  381. setiastro/saspro/widgets/resource_monitor.py +313 -0
  382. setiastro/saspro/widgets/spinboxes.py +290 -0
  383. setiastro/saspro/widgets/themed_buttons.py +13 -0
  384. setiastro/saspro/widgets/wavelet_utils.py +331 -0
  385. setiastro/saspro/wimi.py +7367 -0
  386. setiastro/saspro/wims.py +588 -0
  387. setiastro/saspro/window_shelf.py +185 -0
  388. setiastro/saspro/xisf.py +1213 -0
  389. setiastrosuitepro-1.6.7.dist-info/METADATA +279 -0
  390. setiastrosuitepro-1.6.7.dist-info/RECORD +394 -0
  391. setiastrosuitepro-1.6.7.dist-info/WHEEL +4 -0
  392. setiastrosuitepro-1.6.7.dist-info/entry_points.txt +6 -0
  393. setiastrosuitepro-1.6.7.dist-info/licenses/LICENSE +674 -0
  394. setiastrosuitepro-1.6.7.dist-info/licenses/license.txt +2580 -0
@@ -0,0 +1,1476 @@
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
+ def load_script_from_path(self, path: Path) -> ScriptEntry | None:
682
+ scripts_root = get_scripts_dir()
683
+ return self._load_one_script(path, scripts_root)
684
+
685
+ def _load_one_script(self, path: Path, scripts_root: Path) -> ScriptEntry | None:
686
+ """
687
+ Load a single user script from disk.
688
+
689
+ - Creates a unique module name based on mtime so reload picks up changes.
690
+ - Imports module.
691
+ - Determines stable script_id (prefer SCRIPT_ID in module, else persisted id).
692
+ - Pulls metadata: SCRIPT_NAME/GROUP/SHORTCUT.
693
+ Group defaults to relative folder under scripts_root.
694
+ """
695
+ # Unique module name so reloading actually re-imports
696
+ try:
697
+ mtime_ns = path.stat().st_mtime_ns
698
+ except Exception:
699
+ mtime_ns = 0
700
+ module_name = f"saspro_user_script_{path.stem}_{mtime_ns}"
701
+
702
+ spec = importlib.util.spec_from_file_location(module_name, path)
703
+ if not spec or not spec.loader:
704
+ return None
705
+
706
+ mod = importlib.util.module_from_spec(spec)
707
+
708
+ # Import module first (so SCRIPT_ID / metadata exists)
709
+ try:
710
+ spec.loader.exec_module(mod) # type: ignore
711
+ except Exception:
712
+ self._log(f"[Scripts] Error importing {path.name}:\n{traceback.format_exc()}")
713
+ return None
714
+
715
+ # ---- entrypoint: allow run(ctx) OR main(ctx) ----
716
+ run_func = getattr(mod, "run", None)
717
+ if not callable(run_func):
718
+ run_func = getattr(mod, "main", None)
719
+
720
+ if not callable(run_func):
721
+ self._log(f"[Scripts] {path.name} has no run(ctx) or main(ctx); skipping.")
722
+ return None
723
+
724
+ # ---- helper: allow CAPS or lowercase ----
725
+ def _pick(*names, default=None):
726
+ for n in names:
727
+ if hasattr(mod, n):
728
+ return getattr(mod, n)
729
+ return default
730
+
731
+ name = _pick("SCRIPT_NAME", "script_name", default=path.stem)
732
+
733
+ # Prefer explicit group; else derive group from relative folder
734
+ group = _pick("SCRIPT_GROUP", "script_group", default=None)
735
+ if group is None or not str(group).strip():
736
+ try:
737
+ rel_parent = path.parent.relative_to(scripts_root)
738
+ group = "" if str(rel_parent) in ("", ".") else rel_parent.as_posix()
739
+ except Exception:
740
+ group = ""
741
+
742
+ shortcut = _pick("SCRIPT_SHORTCUT", "script_shortcut", default=None)
743
+
744
+ # Stable script id (prefer explicit SCRIPT_ID; else persisted by rel-path)
745
+ script_id = self._script_id_for_path(path, scripts_root, mod)
746
+
747
+ entry = ScriptEntry(
748
+ script_id=str(script_id),
749
+ path=path,
750
+ name=str(name),
751
+ group=str(group or ""),
752
+ shortcut=str(shortcut) if shortcut else None,
753
+ module=mod,
754
+ run=run_func,
755
+ )
756
+ return entry
757
+
758
+
759
+
760
+ # ---- menu wiring ----
761
+ def rebuild_menu(self, menu_scripts):
762
+ """
763
+ Clears and rebuilds the Scripts menu from registry.
764
+
765
+ Expects base actions already created on app window:
766
+ act_script_editor, act_open_scripts_folder, act_reload_scripts, act_create_sample_script
767
+ (optionally) act_open_user_scripts_github, act_open_scripts_discord
768
+
769
+ Integrates scripts into ShortcutManager using command ids:
770
+ "script:<script_id>"
771
+
772
+ Also adds "Pin Script to Canvas" submenu to create desktop shortcuts for scripts.
773
+ """
774
+ from typing import Any
775
+ from PyQt6.QtCore import Qt, QPoint
776
+ from PyQt6.QtGui import QAction, QCursor
777
+
778
+ menu_scripts.clear()
779
+
780
+ # --- fixed top actions ---
781
+ if getattr(self.app, "act_script_editor", None):
782
+ menu_scripts.addAction(self.app.act_script_editor)
783
+ menu_scripts.addSeparator()
784
+
785
+ if getattr(self.app, "act_open_user_scripts_github", None):
786
+ menu_scripts.addAction(self.app.act_open_user_scripts_github)
787
+ if getattr(self.app, "act_open_scripts_discord", None):
788
+ menu_scripts.addAction(self.app.act_open_scripts_discord)
789
+
790
+ menu_scripts.addSeparator()
791
+
792
+ if getattr(self.app, "act_open_scripts_folder", None):
793
+ menu_scripts.addAction(self.app.act_open_scripts_folder)
794
+ if getattr(self.app, "act_reload_scripts", None):
795
+ menu_scripts.addAction(self.app.act_reload_scripts)
796
+ if getattr(self.app, "act_create_sample_script", None):
797
+ menu_scripts.addAction(self.app.act_create_sample_script)
798
+
799
+ menu_scripts.addSeparator()
800
+
801
+ # ShortcutManager (optional)
802
+ sc = getattr(self.app, "shortcuts", None)
803
+ can_register = callable(getattr(sc, "register_action", None))
804
+ can_add_sc = callable(getattr(sc, "add_shortcut", None))
805
+
806
+ # Helper: pin a command id to canvas at cursor pos
807
+ def _pin_to_canvas(cmdid: str):
808
+ if not (sc and can_add_sc):
809
+ return
810
+ mdi = getattr(self.app, "mdi", None)
811
+ if mdi is None:
812
+ return
813
+ vp = mdi.viewport()
814
+ if vp is None:
815
+ return
816
+
817
+ pos = vp.mapFromGlobal(QCursor.pos())
818
+ if not vp.rect().contains(pos):
819
+ pos = vp.rect().center()
820
+
821
+ try:
822
+ sc.add_shortcut(cmdid, QPoint(int(pos.x()), int(pos.y())))
823
+ except Exception:
824
+ pass
825
+
826
+ # "Pin Script to Canvas" submenu (grouped)
827
+ pin_root = menu_scripts.addMenu("Pin Script to Canvas")
828
+ pin_group_menus: dict[str, Any] = {}
829
+
830
+ # group -> submenu for run items
831
+ group_menus: dict[str, Any] = {}
832
+
833
+ for entry in self.registry:
834
+ script_id = getattr(entry, "script_id", None)
835
+ if not script_id:
836
+ # If a script entry has no id, we can still show it in the menu,
837
+ # but it can't be pinned/registered reliably.
838
+ cmdid = None
839
+ else:
840
+ cmdid = f"script:{script_id}"
841
+
842
+ group = (entry.group or "").strip()
843
+
844
+ # ---- RUN menu placement ----
845
+ if group:
846
+ run_sub = group_menus.get(group)
847
+ if run_sub is None:
848
+ run_sub = menu_scripts.addMenu(group)
849
+ group_menus[group] = run_sub
850
+ target_menu = run_sub
851
+ else:
852
+ target_menu = menu_scripts
853
+
854
+ from PyQt6.QtGui import QIcon
855
+ from setiastro.saspro.resources import get_icons
856
+
857
+ icons = get_icons()
858
+
859
+ act = QAction(entry.name, self.app)
860
+ act.setIcon(QIcon(icons.SCRIPT)) # NEW
861
+
862
+ # IMPORTANT: make shortcuts/global binds work regardless of focus
863
+ act.setShortcutContext(Qt.ShortcutContext.ApplicationShortcut)
864
+
865
+ # store command_id on the action if we have one
866
+ if cmdid:
867
+ act.setProperty("command_id", cmdid)
868
+
869
+ # Default shortcut from script metadata (optional)
870
+ if getattr(entry, "shortcut", None):
871
+ try:
872
+ act.setShortcut(entry.shortcut)
873
+ except Exception:
874
+ pass
875
+
876
+ # Register with ShortcutManager so persisted overrides can apply
877
+ if cmdid and can_register:
878
+ try:
879
+ sc.register_action(cmdid, act)
880
+ except Exception:
881
+ pass
882
+
883
+ act.triggered.connect(lambda _=False, e=entry: self.run_entry(e))
884
+ target_menu.addAction(act)
885
+
886
+ # ---- PIN menu placement ----
887
+ # Only add to pin menu if we have a stable cmdid
888
+ if cmdid:
889
+ if group:
890
+ pin_sub = pin_group_menus.get(group)
891
+ if pin_sub is None:
892
+ pin_sub = pin_root.addMenu(group)
893
+ pin_group_menus[group] = pin_sub
894
+ pin_menu = pin_sub
895
+ else:
896
+ pin_menu = pin_root
897
+
898
+ act_pin = QAction(entry.name, self.app)
899
+ act_pin.setIcon(QIcon(icons.SCRIPT)) # NEW
900
+ act_pin.triggered.connect(lambda _=False, c=cmdid: _pin_to_canvas(c))
901
+ pin_menu.addAction(act_pin)
902
+
903
+ # If there are no pinnable scripts, disable the pin root nicely
904
+ if pin_root.actions() == []:
905
+ a = pin_root.addAction("No scripts to pin")
906
+ a.setEnabled(False)
907
+
908
+
909
+ def _script_command_id(self, entry: ScriptEntry, *, on_base: bool = False) -> str:
910
+ # Keep it stable and unique. Path is perfect because scripts are per-user.
911
+ p = entry.path.as_posix()
912
+ return f"script:{'base:' if on_base else ''}{p}"
913
+
914
+ def _pin_command_to_canvas(self, command_id: str):
915
+ mgr = getattr(self.app, "shortcuts", None)
916
+ mdi = getattr(self.app, "mdi", None)
917
+ if mgr is None or mdi is None:
918
+ return
919
+
920
+ vp = mdi.viewport()
921
+ pos = vp.mapFromGlobal(QCursor.pos())
922
+ if not vp.rect().contains(pos):
923
+ pos = vp.rect().center()
924
+
925
+ mgr.add_shortcut(command_id, pos)
926
+
927
+ # ---- running ----
928
+ def run_entry(self, entry: ScriptEntry, *, on_base: bool = False):
929
+ ctx = ScriptContext(self.app, on_base=on_base)
930
+ try:
931
+ self._log(f"[Scripts] Running '{entry.name}' ({entry.path.name}) on_base={on_base}")
932
+ entry.run(ctx) # type: ignore
933
+ self._log(f"[Scripts] Finished '{entry.name}'")
934
+ except Exception as e:
935
+ tb = traceback.format_exc()
936
+ self._log(f"[Scripts] ERROR in '{entry.name}':\n{tb}")
937
+ try:
938
+ QMessageBox.critical(self.app, "Script Error",
939
+ f"{entry.name} failed:\n\n{e}")
940
+ except Exception:
941
+ pass
942
+
943
+
944
+ # ---- convenience actions ----
945
+ def open_scripts_folder(self):
946
+ folder = get_scripts_dir()
947
+ try:
948
+ QDesktopServices.openUrl(QUrl.fromLocalFile(str(folder)))
949
+ except Exception:
950
+ # OS fallback
951
+ try:
952
+ if sys.platform.startswith("win"):
953
+ os.startfile(folder) # type: ignore
954
+ elif sys.platform == "darwin":
955
+ os.system(f'open "{folder}"')
956
+ else:
957
+ os.system(f'xdg-open "{folder}"')
958
+ except Exception:
959
+ self._log(f"[Scripts] Couldn't open scripts folder: {folder}")
960
+
961
+ def create_sample_script(self):
962
+ folder = get_scripts_dir()
963
+
964
+ samples: dict[str, str] = {}
965
+
966
+ # ------------------------------------------------------------------
967
+ # 1) sample_invert.py (existing)
968
+ # ------------------------------------------------------------------
969
+ samples["sample_invert.py"] = """\
970
+ # Sample SASpro script
971
+ # Put scripts in this folder; they appear in Scripts menu.
972
+ # Required entrypoint:
973
+ # def run(ctx):
974
+ # ...
975
+
976
+ SCRIPT_NAME = "Invert Image (Sample)"
977
+ SCRIPT_GROUP = "Samples"
978
+
979
+ import numpy as np
980
+
981
+ def run(ctx):
982
+ img = ctx.get_image()
983
+ if img is None:
984
+ ctx.log("No active image.")
985
+ return
986
+
987
+ ctx.log(f"Inverting image... shape={img.shape}, dtype={img.dtype}")
988
+
989
+ f = img.astype(np.float32)
990
+ mx = float(np.nanmax(f)) if f.size else 1.0
991
+ if mx > 1.0:
992
+ f = f / mx
993
+ f = np.clip(f, 0.0, 1.0)
994
+
995
+ out = 1.0 - f
996
+ ctx.set_image(out, step_name="Invert via Script")
997
+ ctx.log("Done.")
998
+ """
999
+
1000
+ # ------------------------------------------------------------------
1001
+ # 2) sample_star_preview_ui.py (SEP demo)
1002
+ # ------------------------------------------------------------------
1003
+ samples["sample_star_preview_ui.py"] = """\
1004
+ from __future__ import annotations
1005
+
1006
+ # =========================
1007
+ # SASpro Script Metadata
1008
+ # =========================
1009
+ SCRIPT_NAME = "Star Preview UI (SEP Demo)"
1010
+ SCRIPT_GROUP = "Samples"
1011
+ SCRIPT_SHORTCUT = "" # optional
1012
+
1013
+ # -------------------------
1014
+ # Star Preview UI sample
1015
+ # -------------------------
1016
+
1017
+ import numpy as np
1018
+
1019
+ from PyQt6.QtCore import Qt, QTimer
1020
+ from PyQt6.QtGui import QImage, QPixmap
1021
+ from PyQt6.QtWidgets import (
1022
+ QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
1023
+ QSlider, QCheckBox, QMessageBox, QApplication, QWidget
1024
+ )
1025
+
1026
+ # your libs already bundled in SASpro
1027
+ from setiastro.saspro.imageops.stretch import stretch_color_image, stretch_mono_image
1028
+ from setiastro.saspro.imageops.starbasedwhitebalance import apply_star_based_white_balance
1029
+
1030
+ # (optional) for applying result back to active doc
1031
+ from setiastro.saspro.whitebalance import apply_white_balance_to_doc
1032
+
1033
+ # Shared utilities
1034
+ from setiastro.saspro.widgets.image_utils import to_float01 as _to_float01
1035
+
1036
+
1037
+ class StarPreviewDialog(QDialog):
1038
+ \"""
1039
+ Sample script UI:
1040
+ - Shows active image (auto-updates when subwindow changes)
1041
+ - Runs SEP detection + ellipse overlay
1042
+ - Zoom controls + Fit/1:1
1043
+ - Demo Apply WB to active image
1044
+ \"""
1045
+ def __init__(self, ctx, parent: QWidget | None = None):
1046
+ super().__init__(parent)
1047
+ self.ctx = ctx
1048
+ self.setWindowTitle("Sample Script: Star Preview UI")
1049
+ self.resize(980, 640)
1050
+
1051
+ self._zoom = 1.0
1052
+ self._img01: np.ndarray | None = None
1053
+ self._overlay01: np.ndarray | None = None
1054
+
1055
+ self._build_ui()
1056
+ self._wire()
1057
+
1058
+ # debounce for slider/checkbox
1059
+ self._debounce = QTimer(self)
1060
+ self._debounce.setSingleShot(True)
1061
+ self._debounce.setInterval(500)
1062
+ self._debounce.timeout.connect(self._rebuild_overlay)
1063
+
1064
+ # watch active base doc so preview isn't blank
1065
+ try:
1066
+ dm = getattr(self.ctx.app, "doc_manager", None)
1067
+ if dm is not None and hasattr(dm, "activeBaseChanged"):
1068
+ dm.activeBaseChanged.connect(lambda _=None: self._load_active_image())
1069
+ except Exception:
1070
+ pass
1071
+
1072
+ # initial load
1073
+ QTimer.singleShot(0, self._load_active_image)
1074
+
1075
+ # ---------------- UI ----------------
1076
+ def _build_ui(self):
1077
+ root = QVBoxLayout(self)
1078
+
1079
+ self.preview = QLabel("No active image.")
1080
+ self.preview.setAlignment(Qt.AlignmentFlag.AlignCenter)
1081
+ self.preview.setStyleSheet("border: 1px solid #333; background:#1f1f1f;")
1082
+ self.preview.setMinimumSize(720, 420)
1083
+ root.addWidget(self.preview, stretch=1)
1084
+
1085
+ # Zoom bar
1086
+ zrow = QHBoxLayout()
1087
+ self.btn_zoom_in = QPushButton("Zoom +")
1088
+ self.btn_zoom_out = QPushButton("Zoom −")
1089
+ self.btn_fit = QPushButton("Fit")
1090
+ self.btn_1to1 = QPushButton("1:1")
1091
+ zrow.addWidget(self.btn_zoom_in)
1092
+ zrow.addWidget(self.btn_zoom_out)
1093
+ zrow.addWidget(self.btn_fit)
1094
+ zrow.addWidget(self.btn_1to1)
1095
+ zrow.addStretch(1)
1096
+ root.addLayout(zrow)
1097
+
1098
+ # SEP controls
1099
+ ctrl = QHBoxLayout()
1100
+ ctrl.addWidget(QLabel("SEP threshold (σ):"))
1101
+ self.thr_slider = QSlider(Qt.Orientation.Horizontal)
1102
+ self.thr_slider.setRange(1, 100)
1103
+ self.thr_slider.setValue(50)
1104
+ self.thr_slider.setTickInterval(10)
1105
+ self.thr_slider.setTickPosition(QSlider.TickPosition.TicksBelow)
1106
+ ctrl.addWidget(self.thr_slider, stretch=1)
1107
+
1108
+ self.thr_label = QLabel("50")
1109
+ self.thr_label.setFixedWidth(30)
1110
+ ctrl.addWidget(self.thr_label)
1111
+
1112
+ self.chk_autostretch = QCheckBox("Autostretch preview")
1113
+ self.chk_autostretch.setChecked(True)
1114
+ ctrl.addWidget(self.chk_autostretch)
1115
+
1116
+ root.addLayout(ctrl)
1117
+
1118
+ # bottom buttons
1119
+ brow = QHBoxLayout()
1120
+ brow.addStretch(1)
1121
+ self.btn_apply_demo = QPushButton("Apply WB to Active Image (demo)")
1122
+ self.btn_close = QPushButton("Close")
1123
+ brow.addWidget(self.btn_apply_demo)
1124
+ brow.addWidget(self.btn_close)
1125
+ root.addLayout(brow)
1126
+
1127
+ def _wire(self):
1128
+ self.btn_close.clicked.connect(self.reject)
1129
+
1130
+ self.btn_zoom_in.clicked.connect(lambda: self._set_zoom(self._zoom * 1.25))
1131
+ self.btn_zoom_out.clicked.connect(lambda: self._set_zoom(self._zoom / 1.25))
1132
+ self.btn_fit.clicked.connect(self._zoom_fit)
1133
+ self.btn_1to1.clicked.connect(lambda: self._set_zoom(1.0))
1134
+
1135
+ self.thr_slider.valueChanged.connect(self._on_thr_changed)
1136
+ self.chk_autostretch.toggled.connect(lambda _=None: self._debounce.start())
1137
+
1138
+ self.btn_apply_demo.clicked.connect(self._apply_demo_wb)
1139
+
1140
+ # ------------- Active image -------------
1141
+ def _load_active_image(self):
1142
+ try:
1143
+ doc = self.ctx.active_document()
1144
+ except Exception:
1145
+ doc = None
1146
+
1147
+ if doc is None or getattr(doc, "image", None) is None:
1148
+ self._img01 = None
1149
+ self._overlay01 = None
1150
+ self.preview.setText("No active image.")
1151
+ self.preview.setPixmap(QPixmap())
1152
+ return
1153
+
1154
+ img = _to_float01(np.asarray(doc.image))
1155
+ self._img01 = img
1156
+ self._zoom_fit()
1157
+ self._rebuild_overlay()
1158
+
1159
+ # ------------- SEP overlay -------------
1160
+ def _on_thr_changed(self, v: int):
1161
+ self.thr_label.setText(str(v))
1162
+ self._debounce.start()
1163
+
1164
+ def _rebuild_overlay(self):
1165
+ if self._img01 is None:
1166
+ return
1167
+ try:
1168
+ thr = float(self.thr_slider.value())
1169
+ auto = bool(self.chk_autostretch.isChecked())
1170
+
1171
+ img = self._img01
1172
+ # if mono, make a fake RGB for visualization / SEP expects gray anyway
1173
+ if img.ndim == 2:
1174
+ rgb = np.repeat(img[..., None], 3, axis=2)
1175
+ elif img.ndim == 3 and img.shape[2] == 1:
1176
+ rgb = np.repeat(img, 3, axis=2)
1177
+ else:
1178
+ rgb = img
1179
+
1180
+ # Use your WB star detector just for overlay
1181
+ # (balanced output ignored; we only want overlay + count)
1182
+ _balanced, count, overlay = apply_star_based_white_balance(
1183
+ rgb, threshold=thr, autostretch=auto,
1184
+ reuse_cached_sources=False, return_star_colors=False
1185
+ )
1186
+
1187
+ self._overlay01 = overlay
1188
+ self._render_pixmap()
1189
+ self.setWindowTitle(f"Sample Script: Star Preview UI — {count} stars")
1190
+
1191
+ except Exception as e:
1192
+ self._overlay01 = None
1193
+ self.preview.setText(f"Star detection failed:\\n{e}")
1194
+
1195
+ # ------------- Rendering / zoom -------------
1196
+ def _render_pixmap(self):
1197
+ if self._overlay01 is None:
1198
+ return
1199
+ ov = np.clip(self._overlay01, 0, 1)
1200
+ h, w, c = ov.shape
1201
+ qimg = QImage((ov * 255).astype(np.uint8).data, w, h, 3*w, QImage.Format.Format_RGB888)
1202
+ pm = QPixmap.fromImage(qimg)
1203
+
1204
+ # apply zoom
1205
+ zw = int(pm.width() * self._zoom)
1206
+ zh = int(pm.height() * self._zoom)
1207
+ pmz = pm.scaled(zw, zh, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
1208
+ self.preview.setPixmap(pmz)
1209
+
1210
+ def _set_zoom(self, z: float):
1211
+ self._zoom = float(np.clip(z, 0.05, 20.0))
1212
+ self._render_pixmap()
1213
+
1214
+ def _zoom_fit(self):
1215
+ if self._overlay01 is None and self._img01 is None:
1216
+ return
1217
+ # fit based on raw image size
1218
+ base = self._overlay01 if self._overlay01 is not None else self._img01
1219
+ h, w = base.shape[:2]
1220
+ vw = max(1, self.preview.width())
1221
+ vh = max(1, self.preview.height())
1222
+ self._zoom = min(vw / w, vh / h)
1223
+ self._render_pixmap()
1224
+
1225
+ # ------------- Demo apply -------------
1226
+ def _apply_demo_wb(self):
1227
+ try:
1228
+ doc = self.ctx.active_document()
1229
+ if doc is None:
1230
+ raise RuntimeError("No active document.")
1231
+ # Reuse your headless preset WB as an example of applying edits
1232
+ preset = {"mode": "star", "threshold": float(self.thr_slider.value())}
1233
+ apply_white_balance_to_doc(doc, preset)
1234
+ QMessageBox.information(self, "Demo", "White Balance applied to active image.")
1235
+ # refresh preview after edit
1236
+ self._load_active_image()
1237
+ except Exception as e:
1238
+ QMessageBox.critical(self, "Demo", f"Failed to apply WB:\\n{e}")
1239
+
1240
+
1241
+ def run(ctx):
1242
+ \"""
1243
+ SASpro entry point.
1244
+ \"""
1245
+ w = StarPreviewDialog(ctx, parent=ctx.app)
1246
+ w.exec()
1247
+ """
1248
+
1249
+ # ------------------------------------------------------------------
1250
+ # 3) sample_average_two_docs_ui.py (NEW)
1251
+ # ------------------------------------------------------------------
1252
+ samples["sample_average_two_docs_ui.py"] = """\
1253
+ # Sample SASpro script
1254
+ # UI with two dropdowns listing open views by their CURRENT window titles.
1255
+ # Averages the two selected documents and opens a new document.
1256
+
1257
+ from __future__ import annotations
1258
+
1259
+ SCRIPT_NAME = "Average Two Documents (UI Sample)"
1260
+ SCRIPT_GROUP = "Samples"
1261
+
1262
+ import numpy as np
1263
+
1264
+ from PyQt6.QtWidgets import (
1265
+ QDialog, QVBoxLayout, QHBoxLayout, QLabel, QComboBox,
1266
+ QPushButton, QMessageBox
1267
+ )
1268
+
1269
+
1270
+ class AverageTwoDocsDialog(QDialog):
1271
+ def __init__(self, ctx):
1272
+ super().__init__(parent=ctx.app)
1273
+ self.ctx = ctx
1274
+ self.setWindowTitle("Average Two Documents")
1275
+ self.resize(520, 180)
1276
+
1277
+ self._title_to_doc = {}
1278
+
1279
+ root = QVBoxLayout(self)
1280
+
1281
+ # Row A
1282
+ row_a = QHBoxLayout()
1283
+ row_a.addWidget(QLabel("Document A:"))
1284
+ self.combo_a = QComboBox()
1285
+ row_a.addWidget(self.combo_a, 1)
1286
+ root.addLayout(row_a)
1287
+
1288
+ # Row B
1289
+ row_b = QHBoxLayout()
1290
+ row_b.addWidget(QLabel("Document B:"))
1291
+ self.combo_b = QComboBox()
1292
+ row_b.addWidget(self.combo_b, 1)
1293
+ root.addLayout(row_b)
1294
+
1295
+ # Buttons
1296
+ brow = QHBoxLayout()
1297
+ self.btn_refresh = QPushButton("Refresh List")
1298
+ self.btn_avg = QPushButton("Average → New Doc")
1299
+ self.btn_close = QPushButton("Close")
1300
+ brow.addStretch(1)
1301
+ brow.addWidget(self.btn_refresh)
1302
+ brow.addWidget(self.btn_avg)
1303
+ brow.addWidget(self.btn_close)
1304
+ root.addLayout(brow)
1305
+
1306
+ self.btn_refresh.clicked.connect(self._populate)
1307
+ self.btn_avg.clicked.connect(self._do_average)
1308
+ self.btn_close.clicked.connect(self.reject)
1309
+
1310
+ self._populate()
1311
+
1312
+ def _populate(self):
1313
+ self.combo_a.clear()
1314
+ self.combo_b.clear()
1315
+ self._title_to_doc.clear()
1316
+
1317
+ try:
1318
+ views = self.ctx.list_image_views()
1319
+ except Exception:
1320
+ views = []
1321
+
1322
+ for title, doc in views:
1323
+ # if duplicate names exist, disambiguate slightly
1324
+ key = title
1325
+ if key in self._title_to_doc:
1326
+ # add uid or a counter suffix
1327
+ try:
1328
+ uid = getattr(doc, "uid", "")[:6]
1329
+ key = f"{title} [{uid}]"
1330
+ except Exception:
1331
+ n = 2
1332
+ while f"{title} ({n})" in self._title_to_doc:
1333
+ n += 1
1334
+ key = f"{title} ({n})"
1335
+
1336
+ self._title_to_doc[key] = doc
1337
+ self.combo_a.addItem(key)
1338
+ self.combo_b.addItem(key)
1339
+
1340
+ if self.combo_a.count() == 0:
1341
+ self.combo_a.addItem("<no image views>")
1342
+ self.combo_b.addItem("<no image views>")
1343
+ self.btn_avg.setEnabled(False)
1344
+ else:
1345
+ self.btn_avg.setEnabled(True)
1346
+
1347
+ def _do_average(self):
1348
+ key_a = self.combo_a.currentText()
1349
+ key_b = self.combo_b.currentText()
1350
+
1351
+ doc_a = self._title_to_doc.get(key_a)
1352
+ doc_b = self._title_to_doc.get(key_b)
1353
+
1354
+ if doc_a is None or doc_b is None:
1355
+ QMessageBox.warning(self, "Average", "Please select two valid documents.")
1356
+ return
1357
+
1358
+ img_a = getattr(doc_a, "image", None)
1359
+ img_b = getattr(doc_b, "image", None)
1360
+
1361
+ if img_a is None or img_b is None:
1362
+ QMessageBox.warning(self, "Average", "One of the selected documents has no image.")
1363
+ return
1364
+
1365
+ a = np.asarray(img_a, dtype=np.float32)
1366
+ b = np.asarray(img_b, dtype=np.float32)
1367
+
1368
+ # reconcile mono/color
1369
+ if a.ndim == 2:
1370
+ a = a[..., None]
1371
+ if b.ndim == 2:
1372
+ b = b[..., None]
1373
+ if a.shape[2] == 1 and b.shape[2] == 3:
1374
+ a = np.repeat(a, 3, axis=2)
1375
+ if b.shape[2] == 1 and a.shape[2] == 3:
1376
+ b = np.repeat(b, 3, axis=2)
1377
+
1378
+ if a.shape != b.shape:
1379
+ QMessageBox.warning(
1380
+ self, "Average",
1381
+ f"Shape mismatch:\\nA: {a.shape}\\nB: {b.shape}\\n\\n"
1382
+ "For this sample, images must match exactly."
1383
+ )
1384
+ return
1385
+
1386
+ out = 0.5 * (a + b)
1387
+
1388
+ # name the new doc based on view titles
1389
+ new_name = f"Average({key_a}, {key_b})"
1390
+
1391
+ try:
1392
+ self.ctx.open_new_document(out, metadata={}, name=new_name)
1393
+ QMessageBox.information(self, "Average", f"Created new document:\\n{new_name}")
1394
+ except Exception as e:
1395
+ QMessageBox.critical(self, "Average", f"Failed to create new doc:\\n{e}")
1396
+
1397
+
1398
+ def run(ctx):
1399
+ dlg = AverageTwoDocsDialog(ctx)
1400
+ dlg.exec()
1401
+ """
1402
+
1403
+ created = []
1404
+ skipped = []
1405
+
1406
+ for fname, text in samples.items():
1407
+ path = folder / fname
1408
+ if path.exists():
1409
+ skipped.append(fname)
1410
+ continue
1411
+ try:
1412
+ path.write_text(text, encoding="utf-8")
1413
+ created.append(fname)
1414
+ self._log(f"[Scripts] Wrote sample script: {path}")
1415
+ except Exception:
1416
+ self._log(f"[Scripts] Failed to write {fname}:\n{traceback.format_exc()}")
1417
+
1418
+ # user message
1419
+ try:
1420
+ if created and not skipped:
1421
+ QMessageBox.information(
1422
+ self.app, "Sample Scripts Created",
1423
+ "Created sample scripts:\n\n" + "\n".join(created) +
1424
+ "\n\nReload Scripts to see them."
1425
+ )
1426
+ elif created and skipped:
1427
+ QMessageBox.information(
1428
+ self.app, "Sample Scripts Created",
1429
+ "Created:\n" + "\n".join(created) +
1430
+ "\n\nAlready existed:\n" + "\n".join(skipped) +
1431
+ "\n\nReload Scripts to see new ones."
1432
+ )
1433
+ else:
1434
+ QMessageBox.information(
1435
+ self.app, "Sample Scripts",
1436
+ "All sample scripts already exist:\n\n" + "\n".join(skipped)
1437
+ )
1438
+ except Exception:
1439
+ pass
1440
+
1441
+ self._log(f"[Scripts] Failed to write sample script:\n{traceback.format_exc()}")
1442
+
1443
+
1444
+ def _script_id_for_path(self, path: Path, scripts_root: Path, mod=None) -> str:
1445
+ """
1446
+ Determine a stable script_id.
1447
+
1448
+ Priority:
1449
+ 1) SCRIPT_ID / script_id defined in the script (best; survives renames/moves)
1450
+ 2) Persisted id in QSettings keyed by *relative path inside scripts_root*
1451
+ (stable across machines if folder structure is same)
1452
+
1453
+ NOTE: We intentionally DO NOT key by absolute path.
1454
+ """
1455
+ # 1) Prefer explicit SCRIPT_ID in the script file (best)
1456
+ if mod is not None:
1457
+ sid = getattr(mod, "SCRIPT_ID", None) or getattr(mod, "script_id", None)
1458
+ if isinstance(sid, str) and sid.strip():
1459
+ return sid.strip()
1460
+
1461
+ # 2) Persist per-relative-path (not absolute)
1462
+ try:
1463
+ rel = path.relative_to(scripts_root).as_posix()
1464
+ except Exception:
1465
+ rel = path.as_posix()
1466
+
1467
+ s = QSettings()
1468
+ key = f"Scripts/ids_rel/{rel}"
1469
+ sid = s.value(key, "", type=str) or ""
1470
+ if sid:
1471
+ return sid
1472
+
1473
+ sid = uuid.uuid4().hex
1474
+ s.setValue(key, sid)
1475
+ s.sync()
1476
+ return sid