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.
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,1105 @@
1
+ # pro/perfect_palette_picker.py
2
+ from __future__ import annotations
3
+ import os
4
+ import numpy as np
5
+ from PIL import Image
6
+ import cv2
7
+ from PyQt6.QtCore import Qt, QSize, QEvent, QTimer, QPoint, pyqtSignal
8
+ from PyQt6.QtWidgets import (
9
+ QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QScrollArea,
10
+ QFileDialog, QInputDialog, QMessageBox, QGridLayout, QCheckBox, QSizePolicy, QDialog
11
+ )
12
+ from PyQt6.QtGui import QPixmap, QImage, QIcon, QPainter, QPen, QColor, QFont, QFontMetrics, QCursor
13
+
14
+ # legacy loader (same one DocManager uses)
15
+ from setiastro.saspro.legacy.image_manager import load_image as legacy_load_image
16
+
17
+ # your statistical stretch (mono + color) like SASv2
18
+ # (same signatures you use elsewhere)
19
+ from setiastro.saspro.imageops.stretch import stretch_mono_image, stretch_color_image
20
+ from setiastro.saspro.widgets.themed_buttons import themed_toolbtn
21
+
22
+ class PaletteAdjustDialog(QDialog):
23
+ adjusted_image = pyqtSignal(np.ndarray)
24
+
25
+ def __init__(self, base_rgb, palette_name, ha_src, oiii_src, sii_src, owner):
26
+ from PyQt6.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QScrollArea, QSlider
27
+ from PyQt6.QtCore import QTimer, Qt, QPoint, QEvent
28
+ super().__init__(owner)
29
+ self.setWindowTitle("Adjust Palette Intensities")
30
+ self.setWindowFlag(Qt.WindowType.Window, True)
31
+ self.setWindowModality(Qt.WindowModality.NonModal)
32
+ self.setModal(False)
33
+ #self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
34
+
35
+ self.base_rgb = base_rgb.astype(np.float32)
36
+ self.palette_name = palette_name
37
+ self.ha_src = ha_src
38
+ self.oiii_src = oiii_src
39
+ self.sii_src = sii_src
40
+ self.owner = owner
41
+
42
+ self.ha_factor = 1.0
43
+ self.oiii_factor = 1.0
44
+ self.sii_factor = 1.0
45
+
46
+ self._debounce = QTimer(self); self._debounce.setInterval(300); self._debounce.setSingleShot(True)
47
+ self._debounce.timeout.connect(self._update_preview)
48
+
49
+ self.zoom_factor = 1.0
50
+ self._dragging = False
51
+ self._last_pos = QPoint()
52
+
53
+ vlayout = QVBoxLayout(self)
54
+
55
+ # Zoom controls
56
+ zoom_layout = QHBoxLayout()
57
+
58
+ self.btn_zoom_in = themed_toolbtn("zoom-in", "Zoom In")
59
+ self.btn_zoom_out = themed_toolbtn("zoom-out", "Zoom Out")
60
+ self.btn_fit = themed_toolbtn("zoom-fit-best", "Fit to Preview")
61
+
62
+ self.btn_zoom_in.clicked.connect(lambda: self._change_zoom(1.25))
63
+ self.btn_zoom_out.clicked.connect(lambda: self._change_zoom(0.8))
64
+ self.btn_fit.clicked.connect(self._fit_to_preview)
65
+
66
+ zoom_layout.addStretch(1)
67
+ zoom_layout.addWidget(self.btn_zoom_out)
68
+ zoom_layout.addWidget(self.btn_zoom_in)
69
+ zoom_layout.addWidget(self.btn_fit)
70
+ zoom_layout.addStretch(1)
71
+
72
+ vlayout.addLayout(zoom_layout)
73
+
74
+ # Preview
75
+ self.preview_area = QScrollArea(self); self.preview_area.setWidgetResizable(True)
76
+ self.preview_label = QLabel(alignment=Qt.AlignmentFlag.AlignCenter)
77
+ self.preview_label.setCursor(Qt.CursorShape.OpenHandCursor)
78
+ self.preview_label.setMouseTracking(True)
79
+ self.preview_area.setWidget(self.preview_label)
80
+ self.preview_label.installEventFilter(self)
81
+ vlayout.addWidget(self.preview_area, stretch=1)
82
+
83
+ # Sliders
84
+ for name in ("Ha","OIII","SII"):
85
+ row = QHBoxLayout()
86
+ row.addWidget(QLabel(f"{name} Intensity:", self))
87
+ sl = QSlider(Qt.Orientation.Horizontal, self); sl.setRange(0,200); sl.setValue(100)
88
+ sl.valueChanged.connect(self._on_slider_change)
89
+ setattr(self, f"_{name.lower()}_slider", sl)
90
+ row.addWidget(sl)
91
+ vlayout.addLayout(row)
92
+
93
+ # Buttons
94
+ btns = QHBoxLayout(); btns.addStretch()
95
+ accept = QPushButton("Accept", self); accept.clicked.connect(self._on_accept)
96
+ reset = QPushButton("Reset", self); reset.clicked.connect(self._on_reset)
97
+ discard = QPushButton("Discard",self); discard.clicked.connect(self.reject)
98
+ btns.addWidget(accept); btns.addWidget(reset); btns.addWidget(discard)
99
+ vlayout.addLayout(btns)
100
+
101
+ self._update_preview()
102
+
103
+ def _on_slider_change(self, _):
104
+ self.ha_factor = self._ha_slider.value()/100.0
105
+ self.oiii_factor = self._oiii_slider.value()/100.0
106
+ self.sii_factor = self._sii_slider.value()/100.0
107
+ self._debounce.start()
108
+
109
+ def _update_preview(self):
110
+ ha = (self.ha_src * self.ha_factor) if self.ha_src is not None else None
111
+ oo = (self.oiii_src * self.oiii_factor) if self.oiii_src is not None else None
112
+ si = (self.sii_src * self.sii_factor) if self.sii_src is not None else None
113
+
114
+ r,g,b = self.owner._map_channels_or_special(self.palette_name, ha, oo, si)
115
+
116
+ # --- make sure channels match the base palette size ---
117
+ H, W = self.base_rgb.shape[:2]
118
+ def fit(ch):
119
+ if ch is None: return None
120
+ if ch.shape[:2] != (H, W):
121
+ return self.owner._resize_to(ch, (W, H))
122
+ return ch
123
+ r, g, b = fit(r), fit(g), fit(b)
124
+ # ------------------------------------------------------
125
+
126
+ img = np.zeros_like(self.base_rgb, dtype=np.float32)
127
+ if r is not None: img[...,0] = r
128
+ if g is not None: img[...,1] = g
129
+ if b is not None: img[...,2] = b
130
+ m = float(img.max()) or 1.0
131
+ img = np.clip(img/m, 0.0, 1.0)
132
+
133
+ qimg = self.owner._to_qimage(img)
134
+ self._base_pixmap = QPixmap.fromImage(qimg)
135
+ self._rescale_pixmap()
136
+
137
+ def _rescale_pixmap(self):
138
+ if not hasattr(self, "_base_pixmap"): return
139
+ w = int(self._base_pixmap.width() * self.zoom_factor)
140
+ h = int(self._base_pixmap.height() * self.zoom_factor)
141
+ scaled = self._base_pixmap.scaled(w, h, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
142
+ self._current_pixmap = scaled
143
+ self.preview_label.setPixmap(scaled)
144
+ self.preview_label.resize(scaled.size())
145
+
146
+ def _change_zoom(self, factor: float):
147
+ self.zoom_factor = max(0.1, min(10.0, self.zoom_factor * factor))
148
+ self._rescale_pixmap()
149
+
150
+ def _fit_to_preview(self):
151
+ if not hasattr(self, "_base_pixmap"):
152
+ return
153
+ vp = self.preview_area.viewport().size()
154
+ pm = self._base_pixmap.size()
155
+ if pm.width() <= 0 or pm.height() <= 0:
156
+ return
157
+ k = min(vp.width() / pm.width(), vp.height() / pm.height())
158
+ self.zoom_factor = max(0.1, min(10.0, k))
159
+ self._rescale_pixmap()
160
+
161
+
162
+ def _on_reset(self):
163
+ for s in (self._ha_slider, self._oiii_slider, self._sii_slider):
164
+ s.setValue(100)
165
+ self._on_slider_change(None)
166
+
167
+ def _on_accept(self):
168
+ ha = (self.ha_src * self.ha_factor) if self.ha_src is not None else None
169
+ oo = (self.oiii_src * self.oiii_factor) if self.oiii_src is not None else None
170
+ si = (self.sii_src * self.sii_factor) if self.sii_src is not None else None
171
+
172
+ r,g,b = self.owner._map_channels_or_special(self.palette_name, ha, oo, si)
173
+
174
+ # match base size
175
+ H, W = self.base_rgb.shape[:2]
176
+ def fit(ch):
177
+ if ch is None: return None
178
+ if ch.shape[:2] != (H, W):
179
+ return self.owner._resize_to(ch, (W, H))
180
+ return ch
181
+ r, g, b = fit(r), fit(g), fit(b)
182
+
183
+ final = np.zeros_like(self.base_rgb, dtype=np.float32)
184
+ if r is not None: final[...,0] = r
185
+ if g is not None: final[...,1] = g
186
+ if b is not None: final[...,2] = b
187
+
188
+ m = float(final.max()) or 1.0
189
+ final = np.clip(final/m, 0.0, 1.0)
190
+
191
+ self.adjusted_image.emit(final)
192
+ self.accept()
193
+
194
+ def eventFilter(self, obj, evt):
195
+ if obj is self.preview_label:
196
+ if evt.type() == QEvent.Type.MouseButtonPress and evt.button() == Qt.MouseButton.LeftButton:
197
+ self._dragging = True; self._last_pos = evt.pos()
198
+ self.preview_label.setCursor(Qt.CursorShape.ClosedHandCursor); return True
199
+ if evt.type() == QEvent.Type.MouseMove and self._dragging:
200
+ d = evt.pos() - self._last_pos; self._last_pos = evt.pos()
201
+ self.preview_area.horizontalScrollBar().setValue(self.preview_area.horizontalScrollBar().value() - d.x())
202
+ self.preview_area.verticalScrollBar().setValue(self.preview_area.verticalScrollBar().value() - d.y())
203
+ return True
204
+ if evt.type() == QEvent.Type.MouseButtonRelease and evt.button() == Qt.MouseButton.LeftButton:
205
+ self._dragging = False; self.preview_label.setCursor(Qt.CursorShape.OpenHandCursor); return True
206
+ if evt.type() == QEvent.Type.Wheel:
207
+ self._change_zoom(1.1 if evt.angleDelta().y() > 0 else 0.9); return True
208
+ return super().eventFilter(obj, evt)
209
+
210
+
211
+ class PerfectPalettePicker(QWidget):
212
+ THUMB_CROP = 512 # side length for thumbnail center crops
213
+ PALETTES = [
214
+ "SHO","HOO","HSO","HOS",
215
+ "OSS","OHH","OSH","OHS",
216
+ "HSS","Realistic1","Realistic2","Foraxx"
217
+ ]
218
+
219
+ def __init__(self, doc_manager=None, parent=None):
220
+ super().__init__(parent)
221
+ self.doc_manager = doc_manager
222
+ self.setWindowTitle("Perfect Palette Picker")
223
+
224
+ # raw channels (float32 ~[0..1])
225
+ self.ha = None
226
+ self.oiii = None
227
+ self.sii = None
228
+ self.osc1 = None
229
+ self.osc2 = None
230
+ self._dim_mismatch_accepted = False
231
+
232
+ # stretched cache (per input name → stretched array)
233
+ self._stretched: dict[str, np.ndarray] = {}
234
+
235
+ self.final = None
236
+ self.current_palette = None
237
+ self._thumb_base_pm: dict[str, QPixmap] = {} # palette name -> base pixmap (image only)
238
+ self._selected_name: str | None = None
239
+
240
+ # thumbs
241
+ self._thumb_buttons: dict[str, QPushButton] = {}
242
+
243
+ self._base_pm: QPixmap | None = None
244
+ self._zoom = 1.0
245
+ self._min_zoom = 0.05
246
+ self._max_zoom = 6.0
247
+ self._panning = False
248
+ self._pan_last: QPoint | None = None
249
+
250
+ self._build_ui()
251
+
252
+ # ---------------- UI ----------------
253
+ def _build_ui(self):
254
+ root = QHBoxLayout(self)
255
+
256
+ # -------- left controls
257
+ left = QVBoxLayout()
258
+ left_host = QWidget(self); left_host.setLayout(left); left_host.setFixedWidth(300)
259
+
260
+ left.addWidget(QLabel("<b>Load channels</b>"))
261
+
262
+ # Load buttons + status labels
263
+ self.btn_ha = QPushButton("Load Ha…"); self.btn_ha.clicked.connect(lambda: self._load_channel("Ha"))
264
+ self.btn_oiii = QPushButton("Load OIII…"); self.btn_oiii.clicked.connect(lambda: self._load_channel("OIII"))
265
+ self.btn_sii = QPushButton("Load SII…"); self.btn_sii.clicked.connect(lambda: self._load_channel("SII"))
266
+ self.btn_osc1 = QPushButton("Load OSC1 (Ha/OIII)…"); self.btn_osc1.clicked.connect(lambda: self._load_channel("OSC1"))
267
+ self.btn_osc2 = QPushButton("Load OSC2 (SII/OIII)…"); self.btn_osc2.clicked.connect(lambda: self._load_channel("OSC2"))
268
+
269
+ self.lbl_ha = QLabel("No Ha loaded.")
270
+ self.lbl_oiii = QLabel("No OIII loaded.")
271
+ self.lbl_sii = QLabel("No SII loaded.")
272
+ self.lbl_osc1 = QLabel("No OSC1 loaded.")
273
+ self.lbl_osc2 = QLabel("No OSC2 loaded.")
274
+ for lab in (self.lbl_ha, self.lbl_oiii, self.lbl_sii, self.lbl_osc1, self.lbl_osc2):
275
+ lab.setWordWrap(True); lab.setStyleSheet("color:#888; margin-left:8px;")
276
+
277
+ for btn, lab in (
278
+ (self.btn_ha, self.lbl_ha),
279
+ (self.btn_oiii, self.lbl_oiii),
280
+ (self.btn_sii, self.lbl_sii),
281
+ (self.btn_osc1, self.lbl_osc1),
282
+ (self.btn_osc2, self.lbl_osc2),
283
+ ):
284
+ left.addWidget(btn); left.addWidget(lab)
285
+
286
+ # Linear toggle (stretch BEFORE palette build)
287
+ self.chk_linear = QCheckBox("Linear input (apply statistical stretch before build)")
288
+ self.chk_linear.setChecked(True)
289
+ self.chk_linear.stateChanged.connect(self._rebuild_stretch_cache_for_all)
290
+ left.addSpacing(6); left.addWidget(self.chk_linear)
291
+
292
+ # Actions
293
+ self.btn_clear = QPushButton("Clear Loaded Channels")
294
+ self.btn_clear.clicked.connect(self._clear_channels)
295
+ left.addWidget(self.btn_clear)
296
+
297
+ self.btn_create = QPushButton("Create Palettes")
298
+ self.btn_create.clicked.connect(self._create_palettes)
299
+ left.addWidget(self.btn_create)
300
+
301
+ self.btn_push = QPushButton("Push Final to New View")
302
+ self.btn_push.clicked.connect(self._push_final)
303
+ left.addWidget(self.btn_push)
304
+
305
+ left.addStretch(1)
306
+ root.addWidget(left_host, 0)
307
+
308
+ # -------- right: preview + fixed-size 4×3 grid
309
+ right = QVBoxLayout()
310
+
311
+ # zoom toolbar
312
+ # zoom toolbar (themed)
313
+ tools = QHBoxLayout()
314
+ self.btn_zoom_in = themed_toolbtn("zoom-in", "Zoom In")
315
+ self.btn_zoom_out = themed_toolbtn("zoom-out", "Zoom Out")
316
+ self.btn_fit = themed_toolbtn("zoom-fit-best", "Fit to Preview")
317
+
318
+ self.btn_zoom_in.clicked.connect(lambda: self._zoom_at(1.25))
319
+ self.btn_zoom_out.clicked.connect(lambda: self._zoom_at(0.8))
320
+ self.btn_fit.clicked.connect(self._fit_to_preview)
321
+
322
+ tools.addStretch(1)
323
+ tools.addWidget(self.btn_zoom_out)
324
+ tools.addWidget(self.btn_zoom_in)
325
+ tools.addWidget(self.btn_fit)
326
+ tools.addStretch(1)
327
+ right.addLayout(tools)
328
+
329
+
330
+ # main preview (expands)
331
+ self.scroll = QScrollArea(self); self.scroll.setWidgetResizable(True)
332
+ self.scroll.setAlignment(Qt.AlignmentFlag.AlignCenter)
333
+ self.preview = QLabel(alignment=Qt.AlignmentFlag.AlignCenter)
334
+ self.scroll.setWidget(self.preview)
335
+ self.preview.setMouseTracking(True)
336
+ self.preview.installEventFilter(self)
337
+ self.scroll.viewport().installEventFilter(self)
338
+ self.scroll.installEventFilter(self)
339
+ self.scroll.horizontalScrollBar().installEventFilter(self) # NEW
340
+ self.scroll.verticalScrollBar().installEventFilter(self) # NEW
341
+ right.addWidget(self.scroll, 1)
342
+
343
+ # fixed-size grid
344
+ self.grid = QGridLayout()
345
+ self.grid.setHorizontalSpacing(8); self.grid.setVerticalSpacing(8)
346
+ self.grid.setContentsMargins(8, 8, 8, 8)
347
+
348
+ self.thumb_size = QSize(220, 110)
349
+ btn_w = self.thumb_size.width() + 2
350
+ btn_h = self.thumb_size.height() + 2
351
+ cols, rows = 4, 3
352
+
353
+ for idx, name in enumerate(self.PALETTES):
354
+ r, c = divmod(idx, cols)
355
+ b = QPushButton("") # we draw the text onto the icon itself
356
+ b.setToolTip(name)
357
+ b.setIconSize(self.thumb_size)
358
+ b.setFixedSize(btn_w, btn_h)
359
+ b.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
360
+ b.clicked.connect(lambda _=None, n=name: self._on_palette_clicked(n))
361
+ b.setStyleSheet("QPushButton{background:#222;border:1px solid #333;} QPushButton:hover{border-color:#555;}")
362
+ self._thumb_buttons[name] = b
363
+ self.grid.addWidget(b, r, c)
364
+
365
+ grid_host = QWidget(self); grid_host.setLayout(self.grid)
366
+ hspacing = self.grid.horizontalSpacing(); vspacing = self.grid.verticalSpacing()
367
+ m = self.grid.contentsMargins()
368
+ grid_w = cols*btn_w + (cols-1)*hspacing + m.left() + m.right()
369
+ grid_h = rows*btn_h + (rows-1)*vspacing + m.top() + m.bottom()
370
+ grid_host.setFixedSize(grid_w, grid_h)
371
+ grid_host.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
372
+ right.addWidget(grid_host, 0, alignment=Qt.AlignmentFlag.AlignHCenter)
373
+
374
+ self.status = QLabel(""); right.addWidget(self.status, 0)
375
+
376
+ right_host = QWidget(self); right_host.setLayout(right)
377
+ root.addWidget(right_host, 1)
378
+
379
+ self.setLayout(root)
380
+ self.setMinimumSize(left_host.width() + grid_w + 48, max(560, grid_h + 200))
381
+
382
+ def _resize_to(self, arr: np.ndarray | None, size: tuple[int, int]) -> np.ndarray | None:
383
+ """Resize np array to (w,h). Keeps dtype/scale. Uses INTER_AREA for downsizing."""
384
+ if arr is None:
385
+ return None
386
+ w, h = size
387
+ if arr.ndim == 2:
388
+ src_h, src_w = arr.shape
389
+ else:
390
+ src_h, src_w = arr.shape[:2]
391
+ if (src_w, src_h) == (w, h):
392
+ return arr
393
+ if cv2 is None:
394
+ # ultra-simple fallback: nearest; OK for thumbs if OpenCV isn't present
395
+ if arr.ndim == 2:
396
+ return np.array(Image.fromarray((arr*255).astype(np.uint8)).resize((w, h))).astype(np.float32) / 255.0
397
+ return np.array(Image.fromarray((arr*255).astype(np.uint8)).resize((w, h))).astype(np.float32) / 255.0
398
+ interp = cv2.INTER_AREA if (w < src_w or h < src_h) else cv2.INTER_LINEAR
399
+ if arr.ndim == 2:
400
+ return cv2.resize(arr, (w, h), interpolation=interp)
401
+ return cv2.resize(arr, (w, h), interpolation=interp)
402
+
403
+ def _capture_view_state(self):
404
+ """Capture current view center in base-image coordinates + zoom."""
405
+ if self._base_pm is None:
406
+ return None
407
+ vp = self.scroll.viewport()
408
+ hbar = self.scroll.horizontalScrollBar()
409
+ vbar = self.scroll.verticalScrollBar()
410
+
411
+ # center of viewport in viewport coords
412
+ anchor_vp = QPoint(vp.width() // 2, vp.height() // 2)
413
+
414
+ # convert to label coords (scaled image coords)
415
+ anchor_lbl = self.preview.mapFrom(vp, anchor_vp)
416
+
417
+ # scaled -> base image coords
418
+ base_x = anchor_lbl.x() / max(self._zoom, 1e-6)
419
+ base_y = anchor_lbl.y() / max(self._zoom, 1e-6)
420
+
421
+ pm = self._base_pm.size()
422
+ fx = 0.5 if pm.width() <= 0 else (base_x / pm.width())
423
+ fy = 0.5 if pm.height() <= 0 else (base_y / pm.height())
424
+
425
+ return {"zoom": float(self._zoom), "fx": float(fx), "fy": float(fy)}
426
+
427
+ def _restore_view_state(self, state):
428
+ """Restore zoom and pan using stored base-image fractions."""
429
+ if not state or self._base_pm is None:
430
+ return
431
+
432
+ # restore zoom first
433
+ self._zoom = max(self._min_zoom, min(self._max_zoom, float(state["zoom"])))
434
+ self._update_preview_pixmap()
435
+
436
+ # now restore center point
437
+ pm = self._base_pm.size()
438
+ fx = float(state.get("fx", 0.5))
439
+ fy = float(state.get("fy", 0.5))
440
+
441
+ base_x = fx * pm.width()
442
+ base_y = fy * pm.height()
443
+
444
+ # base -> scaled label coords
445
+ lbl_x = int(base_x * self._zoom)
446
+ lbl_y = int(base_y * self._zoom)
447
+
448
+ vp = self.scroll.viewport()
449
+ anchor_vp = QPoint(vp.width() // 2, vp.height() // 2)
450
+
451
+ hbar = self.scroll.horizontalScrollBar()
452
+ vbar = self.scroll.verticalScrollBar()
453
+ hbar.setValue(max(hbar.minimum(), min(hbar.maximum(), lbl_x - anchor_vp.x())))
454
+ vbar.setValue(max(vbar.minimum(), min(vbar.maximum(), lbl_y - anchor_vp.y())))
455
+
456
+ # ---------- status helpers ----------
457
+ def _set_status_label(self, which: str, text: str | None):
458
+ lab = getattr(self, f"lbl_{which.lower()}")
459
+ if text:
460
+ lab.setText(text)
461
+ lab.setStyleSheet("color:#2a7; font-weight:600; margin-left:8px;")
462
+ else:
463
+ lab.setText(f"No {which} loaded.")
464
+ lab.setStyleSheet("color:#888; margin-left:8px;")
465
+
466
+ # ------------- load by view/file -------------
467
+ def _load_channel(self, which: str):
468
+ src, ok = QInputDialog.getItem(
469
+ self, f"Load {which}", "Source:", ["From View", "From File"], 0, False
470
+ )
471
+ if not ok:
472
+ return
473
+
474
+ if src == "From View":
475
+ out = self._load_from_view(which)
476
+ else:
477
+ out = self._load_from_file(which)
478
+ if out is None:
479
+ return
480
+
481
+ img, header, bit_depth, is_mono, path, label = out
482
+
483
+ # NB channels → mono; OSC → RGB
484
+ if which in ("Ha","OIII","SII"):
485
+ if img.ndim == 3:
486
+ img = img[:, :, 0]
487
+ else:
488
+ if img.ndim == 2:
489
+ img = np.stack([img]*3, axis=-1)
490
+
491
+ # store raw, normalized
492
+ setattr(self, which.lower(), self._as_float01(img))
493
+ self._set_status_label(which, label)
494
+ self.status.setText(f"{which} loaded ({'mono' if img.ndim==2 else 'RGB'}) shape={img.shape}")
495
+
496
+ # build/clear stretched cache for this input
497
+ self._cache_stretch(which)
498
+
499
+ if self.current_palette is None:
500
+ self.current_palette = "SHO"
501
+
502
+ def _load_from_view(self, which):
503
+ views = self._list_open_views()
504
+ if not views:
505
+ QMessageBox.warning(self, "No Views", "No open image views were found.")
506
+ return None
507
+
508
+ labels = [lab for lab, _ in views]
509
+ choice, ok = QInputDialog.getItem(
510
+ self, f"Select View for {which}", "Choose a view (by name):", labels, 0, False
511
+ )
512
+ if not ok or not choice:
513
+ return None
514
+
515
+ sw = dict(views)[choice]
516
+ doc = getattr(sw, "document", None)
517
+ if doc is None or getattr(doc, "image", None) is None:
518
+ QMessageBox.warning(self, "Empty View", "Selected view has no image.")
519
+ return None
520
+
521
+ img = doc.image
522
+ meta = getattr(doc, "metadata", {}) or {}
523
+ header = meta.get("original_header", None)
524
+ bit_depth = meta.get("bit_depth", "Unknown")
525
+ is_mono = (img.ndim == 2) or (img.ndim == 3 and img.shape[2] == 1)
526
+ path = meta.get("file_path", None)
527
+ return img, header, bit_depth, is_mono, path, f"From View: {choice}"
528
+
529
+ def _load_from_file(self, which):
530
+ filt = "Images (*.png *.tif *.tiff *.fits *.fit *.xisf)"
531
+ path, _ = QFileDialog.getOpenFileName(self, f"Select {which} File", "", filt)
532
+ if not path:
533
+ return None
534
+ img, header, bit_depth, is_mono = legacy_load_image(path)
535
+ if img is None:
536
+ QMessageBox.critical(self, "Load Error", f"Could not load {os.path.basename(path)}")
537
+ return None
538
+ label = f"From File: {os.path.basename(path)}"
539
+ return img, header, bit_depth, is_mono, path, label
540
+
541
+ def showEvent(self, e):
542
+ super().showEvent(e)
543
+ QTimer.singleShot(0, self._center_scrollbars)
544
+
545
+ # ------------- build/caches -------------
546
+ def _cache_stretch(self, which: str):
547
+ """Compute and cache stretched version of a just-loaded input (if linear checked)."""
548
+ arr = getattr(self, which.lower())
549
+ if arr is None:
550
+ self._stretched.pop(which, None); return
551
+ if not self.chk_linear.isChecked():
552
+ self._stretched.pop(which, None); return
553
+ self._stretched[which] = self._stretch_input(arr)
554
+
555
+ def _rebuild_stretch_cache_for_all(self, _state: int):
556
+ """Rebuild (or clear) stretched cache for all loaded inputs when checkbox toggles."""
557
+ for which in ("Ha","OIII","SII","OSC1","OSC2"):
558
+ self._cache_stretch(which)
559
+
560
+ def _render_thumb(self, name: str):
561
+ base = self._thumb_base_pm.get(name)
562
+ if base is None:
563
+ return
564
+ pm = base.copy()
565
+
566
+ p = QPainter(pm)
567
+ p.setRenderHint(QPainter.RenderHint.Antialiasing)
568
+
569
+ font = QFont("Helvetica", 10, QFont.Weight.DemiBold)
570
+ p.setFont(font)
571
+ fm = QFontMetrics(font)
572
+
573
+ pad = 6
574
+ strip_h = fm.height() + pad * 2
575
+ strip = pm.rect().adjusted(0, pm.height() - strip_h, 0, 0)
576
+
577
+ # translucent bottom strip
578
+ p.fillRect(strip, QColor(0, 0, 0, 160))
579
+ color = QColor(102, 255, 102) if self._selected_name == name else QColor(255, 255, 255)
580
+ p.setPen(QPen(color))
581
+ p.drawText(strip, Qt.AlignmentFlag.AlignCenter, name)
582
+ p.end()
583
+
584
+ btn = self._thumb_buttons[name]
585
+ btn.setIcon(QIcon(pm))
586
+ btn.setIconSize(self.thumb_size) # <- ensures no clipping
587
+
588
+ # ------------- thumbnails -------------
589
+ def _create_palettes(self):
590
+ """
591
+ Build the 12 palette thumbnails from a **center crop of the stretched inputs**
592
+ and draw the palette name directly on each thumbnail. Names turn green when selected.
593
+ """
594
+ ha, oo, si = self._prepared_channels(for_thumbs=True)
595
+ if oo is None or (ha is None and si is None):
596
+ QMessageBox.warning(self, "Need Channels", "Load at least OIII + (Ha or SII).")
597
+ return
598
+
599
+ built = 0
600
+ for name in self.PALETTES:
601
+ r, g, b = self._map_channels_or_special(name, ha, oo, si)
602
+ if any(ch is None for ch in (r, g, b)):
603
+ self._thumb_base_pm.pop(name, None)
604
+ self._thumb_buttons[name].setIcon(QIcon())
605
+ continue
606
+
607
+ r = np.clip(np.nan_to_num(r), 0, 1)
608
+ g = np.clip(np.nan_to_num(g), 0, 1)
609
+ b = np.clip(np.nan_to_num(b), 0, 1)
610
+ rgb = np.stack([r, g, b], axis=2).astype(np.float32)
611
+
612
+ # scale the thumbnail to EXACTLY the button icon size first
613
+ pm = QPixmap.fromImage(self._to_qimage(rgb)).scaled(
614
+ self.thumb_size, Qt.AspectRatioMode.KeepAspectRatio,
615
+ Qt.TransformationMode.SmoothTransformation
616
+ )
617
+ self._thumb_base_pm[name] = pm
618
+ self._render_thumb(name)
619
+ built += 1
620
+
621
+ self.status.setText(f"Created {built} palette previews.")
622
+
623
+
624
+ def _on_palette_clicked(self, name: str):
625
+ self._selected_name = name
626
+ for n in self.PALETTES:
627
+ self._render_thumb(n)
628
+ self.current_palette = name
629
+ self._generate_for_palette(name)
630
+
631
+ # ------------- palette build helpers -------------
632
+ def _center_crop(self, img: np.ndarray, side: int) -> np.ndarray:
633
+ """Center-crop to a square of size 'side' (no upscaling)."""
634
+ h, w = img.shape[:2]; s = min(side, h, w)
635
+ y0 = (h - s) // 2; x0 = (w - s) // 2
636
+ return img[y0:y0+s, x0:x0+s] if img.ndim == 2 else img[y0:y0+s, x0:x0+s, :]
637
+
638
+ def _center_crop_all_to_side(self, side: int, *imgs):
639
+ """Center-crop all provided images to the same square side (no upscaling)."""
640
+ s = None
641
+ for im in imgs:
642
+ if im is None: continue
643
+ h, w = im.shape[:2]
644
+ s = min(side, h, w) if s is None else min(s, h, w, side)
645
+ if s is None: s = side
646
+ return [self._center_crop(im, s) if im is not None else None for im in imgs], s
647
+
648
+ def _prepared_channels(self, for_thumbs: bool = False):
649
+ """
650
+ Build Ha/OIII/SII bases from inputs. If 'Linear input' is checked,
651
+ **use stretched versions** (cached). Then optionally center-crop for thumbnails.
652
+ """
653
+ # choose raw vs stretched
654
+ def pick(name):
655
+ if self.chk_linear.isChecked() and (name in self._stretched):
656
+ return self._stretched[name]
657
+ return getattr(self, name.lower())
658
+
659
+ ha = pick("Ha")
660
+ oo = pick("OIII")
661
+ si = pick("SII")
662
+ o1 = pick("OSC1")
663
+ o2 = pick("OSC2")
664
+
665
+ # synthesize from stretched OSC first (stretch-before-crop)
666
+ if o1 is not None: # OSC1: R≈Ha, mean(G,B)≈OIII
667
+ h1 = o1[..., 0]
668
+ g1b1 = o1[..., 1:3].mean(axis=2)
669
+ ha = h1 if ha is None else 0.5*ha + 0.5*h1
670
+ oo = g1b1 if oo is None else 0.5*oo + 0.5*g1b1
671
+
672
+ if o2 is not None: # OSC2: R≈SII, mean(G,B)≈OIII
673
+ s2 = o2[..., 0]
674
+ g2b2 = o2[..., 1:3].mean(axis=2)
675
+ si = s2 if si is None else 0.5*si + 0.5*s2
676
+ oo = g2b2 if oo is None else 0.5*oo + 0.5*g2b2
677
+
678
+ # shapes must match for full-size
679
+ # shapes must match for full-size
680
+ shapes = [x.shape[:2] for x in (ha, oo, si) if x is not None]
681
+ if len(shapes) and len(set(shapes)) > 1 and not for_thumbs:
682
+ # pick a reference size (prefer Ha, then OIII, then SII)
683
+ ref = ha if ha is not None else (oo if oo is not None else si)
684
+ ref_name = "Ha" if ha is not None else ("OIII" if oo is not None else "SII")
685
+ ref_h, ref_w = ref.shape[:2]
686
+
687
+ # Only prompt once per session unless you want every time
688
+ if not self._dim_mismatch_accepted:
689
+ msg = (
690
+ "The loaded channels have different image dimensions.\n\n"
691
+ f"• Ha: {None if ha is None else ha.shape}\n"
692
+ f"• OIII: {None if oo is None else oo.shape}\n"
693
+ f"• SII: {None if si is None else si.shape}\n\n"
694
+ f"SASpro can resize (warp) the channels to match the reference frame:\n"
695
+ f"• Reference: {ref_name}\n"
696
+ f"• Target size: ({ref_w} × {ref_h})\n\n"
697
+ "Proceed and resize mismatched channels?"
698
+ )
699
+ ret = QMessageBox.question(
700
+ self,
701
+ "Channel Size Mismatch",
702
+ msg,
703
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
704
+ QMessageBox.StandardButton.Yes
705
+ )
706
+ if ret != QMessageBox.StandardButton.Yes:
707
+ return None, None, None
708
+
709
+ self._dim_mismatch_accepted = True
710
+
711
+ # resize to reference
712
+ ha = self._resize_to(ha, (ref_w, ref_h)) if ha is not None else None
713
+ oo = self._resize_to(oo, (ref_w, ref_h)) if oo is not None else None
714
+ si = self._resize_to(si, (ref_w, ref_h)) if si is not None else None
715
+
716
+ # thumbnails: crop AFTER stretch/synth
717
+ if for_thumbs:
718
+ # choose a reference (prefer OIII, then Ha, then SII)
719
+ ref = oo if oo is not None else (ha if ha is not None else si)
720
+ if ref is not None:
721
+ ref_h, ref_w = ref.shape[:2]
722
+
723
+ # 1) first, size-match all channels to the reference full frame
724
+ ha = self._resize_to(ha, (ref_w, ref_h)) if ha is not None else None
725
+ oo = self._resize_to(oo, (ref_w, ref_h)) if oo is not None else None
726
+ si = self._resize_to(si, (ref_w, ref_h)) if si is not None else None
727
+
728
+ # 2) then, make a 50% view of the full rectangle
729
+ half_w = max(1, int(ref_w * 0.5))
730
+ half_h = max(1, int(ref_h * 0.5))
731
+ ha = self._resize_to(ha, (half_w, half_h)) if ha is not None else None
732
+ oo = self._resize_to(oo, (half_w, half_h)) if oo is not None else None
733
+ si = self._resize_to(si, (half_w, half_h)) if si is not None else None
734
+
735
+ return ha, oo, si
736
+
737
+ def _generate_for_palette(self, pal: str):
738
+ ha, oo, si = self._prepared_channels()
739
+ if oo is None or (ha is None and si is None):
740
+ return
741
+
742
+ r,g,b = self._map_channels_or_special(pal, ha, oo, si)
743
+ if any(ch is None for ch in (r,g,b)):
744
+ QMessageBox.critical(self, "Palette Error", f"Could not build palette {pal}."); return
745
+
746
+ r = np.clip(np.nan_to_num(r), 0, 1)
747
+ g = np.clip(np.nan_to_num(g), 0, 1)
748
+ b = np.clip(np.nan_to_num(b), 0, 1)
749
+ rgb = np.stack([r,g,b], axis=2).astype(np.float32)
750
+
751
+ mx = float(rgb.max()) or 1.0
752
+ self.final = (rgb / mx).astype(np.float32)
753
+
754
+ # Fit only when there wasn't an existing preview yet
755
+ first = (self._base_pm is None)
756
+ self._set_preview_image(self._to_qimage(self.final), fit=first, preserve_view=True)
757
+ self.status.setText(f"Preview generated: {pal}")
758
+
759
+ def _set_preview_image(self, qimg: QImage, *, fit: bool = False, preserve_view: bool = True):
760
+ state = None
761
+ if preserve_view and (not fit) and (self._base_pm is not None):
762
+ state = self._capture_view_state()
763
+
764
+ self._base_pm = QPixmap.fromImage(qimg)
765
+
766
+ # If we’re fitting, ignore old zoom/pan.
767
+ if fit or state is None:
768
+ self._zoom = 1.0
769
+ self._update_preview_pixmap()
770
+ if fit:
771
+ QTimer.singleShot(0, self._fit_to_preview)
772
+ else:
773
+ QTimer.singleShot(0, self._center_scrollbars)
774
+ return
775
+
776
+ # restore prior zoom/pan
777
+ self._restore_view_state(state)
778
+
779
+
780
+ def _update_preview_pixmap(self):
781
+ if self._base_pm is None:
782
+ return
783
+ # explicit int size (QSize * float can crash on some PyQt6 builds)
784
+ base_sz = self._base_pm.size()
785
+ w = max(1, int(base_sz.width() * self._zoom))
786
+ h = max(1, int(base_sz.height() * self._zoom))
787
+ scaled = self._base_pm.scaled(
788
+ w, h,
789
+ Qt.AspectRatioMode.KeepAspectRatio,
790
+ Qt.TransformationMode.SmoothTransformation
791
+ )
792
+ self.preview.setPixmap(scaled)
793
+ self.preview.resize(scaled.size())
794
+
795
+ def _set_zoom(self, new_zoom: float):
796
+ self._zoom = max(self._min_zoom, min(self._max_zoom, new_zoom))
797
+ self._update_preview_pixmap()
798
+
799
+ def _zoom_at(self, factor: float = 1.25, anchor_vp: QPoint | None = None):
800
+ if self._base_pm is None:
801
+ return
802
+
803
+ vp = self.scroll.viewport()
804
+ if anchor_vp is None:
805
+ anchor_vp = QPoint(vp.width() // 2, vp.height() // 2) # view center
806
+
807
+ # label coords under the anchor *before* zoom
808
+ lbl_before = self.preview.mapFrom(vp, anchor_vp)
809
+
810
+ old_zoom = self._zoom
811
+ new_zoom = max(self._min_zoom, min(self._max_zoom, old_zoom * factor))
812
+ ratio = new_zoom / max(old_zoom, 1e-6)
813
+ if abs(ratio - 1.0) < 1e-6:
814
+ return
815
+
816
+ # apply zoom (updates label size & scrollbar ranges)
817
+ self._zoom = new_zoom
818
+ self._update_preview_pixmap()
819
+
820
+ # desired label coords *after* zoom
821
+ lbl_after_x = int(lbl_before.x() * ratio)
822
+ lbl_after_y = int(lbl_before.y() * ratio)
823
+
824
+ # move scrollbars so anchor_vp keeps the same content point
825
+ hbar = self.scroll.horizontalScrollBar()
826
+ vbar = self.scroll.verticalScrollBar()
827
+ hbar.setValue(max(hbar.minimum(), min(hbar.maximum(), lbl_after_x - anchor_vp.x())))
828
+ vbar.setValue(max(vbar.minimum(), min(vbar.maximum(), lbl_after_y - anchor_vp.y())))
829
+
830
+
831
+ def _fit_to_preview(self):
832
+ if self._base_pm is None:
833
+ return
834
+ vp = self.scroll.viewport().size()
835
+ pm = self._base_pm.size()
836
+ if pm.width() == 0 or pm.height() == 0:
837
+ return
838
+ k = min(vp.width() / pm.width(), vp.height() / pm.height())
839
+ self._set_zoom(max(self._min_zoom, min(self._max_zoom, k)))
840
+ self._center_scrollbars()
841
+
842
+ def _center_scrollbars(self):
843
+ # center the view on the image
844
+ h = self.scroll.horizontalScrollBar()
845
+ v = self.scroll.verticalScrollBar()
846
+ h.setValue((h.maximum() + h.minimum()) // 2)
847
+ v.setValue((v.maximum() + v.minimum()) // 2)
848
+
849
+ def _map_channels_or_special(self, name, ha, oo, si):
850
+ # substitution
851
+ if ha is None and si is not None: ha = si
852
+ if si is None and ha is not None: si = ha
853
+
854
+ basic = {
855
+ "SHO": (si, ha, oo),
856
+ "HOO": (ha, oo, oo),
857
+ "HSO": (ha, si, oo),
858
+ "HOS": (ha, oo, si),
859
+ "OSS": (oo, si, si),
860
+ "OHH": (oo, ha, ha),
861
+ "OSH": (oo, si, ha),
862
+ "OHS": (oo, ha, si),
863
+ "HSS": (ha, si, si),
864
+ }
865
+ if name in basic:
866
+ return basic[name]
867
+
868
+ try:
869
+ if name == "Realistic1":
870
+ r = (ha + si)/2 if (ha is not None and si is not None) else (ha if ha is not None else 0)
871
+ g = 0.3*(ha if ha is not None else 0) + 0.7*(oo if oo is not None else 0)
872
+ b = 0.9*(oo if oo is not None else 0) + 0.1*(ha if ha is not None else 0)
873
+ return r,g,b
874
+ if name == "Realistic2":
875
+ r = 0.7*(ha if ha is not None else 0) + 0.3*(si if si is not None else 0)
876
+ g = 0.3*(si if si is not None else 0) + 0.7*(oo if oo is not None else 0)
877
+ b = (oo if oo is not None else 0)
878
+ return r,g,b
879
+ if name == "Foraxx":
880
+ if ha is not None and oo is not None and si is None:
881
+ r = ha; b = oo
882
+ t = ha * oo
883
+ g = (t**(1 - t))*ha + (1 - (t**(1 - t)))*oo
884
+ return r,g,b
885
+ if ha is not None and oo is not None and si is not None:
886
+ t = np.clip(oo, 1e-6, 1.0)**(1 - np.clip(oo, 1e-6, 1.0))
887
+ r = t*si + (1 - t)*ha
888
+ t2 = ha * oo
889
+ g = (t2**(1 - t2))*ha + (1 - (t2**(1 - t2)))*oo
890
+ b = oo
891
+ return r,g,b
892
+ return basic["SHO"]
893
+ except Exception:
894
+ return basic.get("SHO", (ha, oo, si))
895
+
896
+ return basic.get("SHO", (ha, oo, si))
897
+
898
+ # ------------- push to new subwindow -------------
899
+ # ------------- push to new subwindow -------------
900
+ def _get_doc_manager(self):
901
+ """
902
+ Try several ways to get a DocManager:
903
+ 1) explicit doc_manager passed into PerfectPalettePicker
904
+ 2) main window's .docman or .doc_manager attribute
905
+ """
906
+ if self.doc_manager is not None:
907
+ return self.doc_manager
908
+
909
+ mw = self._find_main_window()
910
+ if mw is None:
911
+ return None
912
+
913
+ return getattr(mw, "docman", None) or getattr(mw, "doc_manager", None)
914
+
915
+ def _push_final(self):
916
+ if self.final is None:
917
+ QMessageBox.warning(self, "No Image", "Generate a palette first.")
918
+ return
919
+
920
+ # Use the SAME prepared channels the palette was built with
921
+ ha_prep, oo_prep, si_prep = self._prepared_channels()
922
+ if oo_prep is None or (ha_prep is None and si_prep is None):
923
+ QMessageBox.warning(self, "Need Channels", "Load at least OIII + (Ha or SII).")
924
+ return
925
+
926
+ dlg = PaletteAdjustDialog(
927
+ base_rgb = self.final, # fully formed palette
928
+ palette_name = self.current_palette or "SHO",
929
+ ha_src = ha_prep, # prepared (stretched/OSC-synth)
930
+ oiii_src = oo_prep,
931
+ sii_src = si_prep,
932
+ owner = self
933
+ )
934
+ adjusted = {"img": None}
935
+ dlg.adjusted_image.connect(lambda img: adjusted.__setitem__("img", img))
936
+ dlg.exec()
937
+
938
+ if adjusted["img"] is None:
939
+ return # user canceled
940
+
941
+ # Update preview with adjusted result and set as final
942
+ self.final = adjusted["img"]
943
+ self._set_preview_image(self._to_qimage(self.final))
944
+
945
+ title = self.current_palette or "Palette"
946
+
947
+ # ---- get DocManager the robust way ----
948
+ dm = self._get_doc_manager()
949
+
950
+ if dm is None:
951
+ # Fallback: open a simple viewer instead of erroring out
952
+ viewer = QDialog(self)
953
+ viewer.setWindowTitle(title)
954
+ vlayout = QVBoxLayout(viewer)
955
+ lbl = QLabel()
956
+ lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
957
+ lbl.setPixmap(QPixmap.fromImage(self._to_qimage(self.final)))
958
+ vlayout.addWidget(lbl)
959
+ viewer.resize(lbl.pixmap().size())
960
+ viewer.show()
961
+ # keep ref so it isn't GC'd
962
+ self._last_popup_viewer = viewer
963
+ self.status.setText("DocManager not found; opened palette in stand-alone viewer.")
964
+ return
965
+
966
+ # ---- normal SAS path: create a new document ----
967
+ try:
968
+ if hasattr(dm, "open_array"):
969
+ # many of your tools already use this signature
970
+ doc = dm.open_array(self.final, metadata={"is_mono": False}, title=title)
971
+ elif hasattr(dm, "create_document"):
972
+ doc = dm.create_document(image=self.final, metadata={"is_mono": False}, name=title)
973
+ else:
974
+ raise RuntimeError("DocManager lacks open_array/create_document")
975
+
976
+ # If DocManager or main window auto-spawns subwindows on new docs,
977
+ # this is all we need. If not, you can optionally keep the
978
+ # _spawn_subwindow_for hook here.
979
+ self.status.setText("Opened final palette in a new view.")
980
+ except Exception as e:
981
+ QMessageBox.critical(self, "Error", f"Failed to open new view:\n{e}")
982
+
983
+
984
+
985
+ # ------------- utilities -------------
986
+ def _clear_channels(self):
987
+ self.ha = self.oiii = self.sii = self.osc1 = self.osc2 = None
988
+ self._stretched.clear()
989
+ self._dim_mismatch_accepted = False
990
+ self.final = None
991
+ self.preview.clear()
992
+ for which in ("Ha","OIII","SII","OSC1","OSC2"):
993
+ self._set_status_label(which, None)
994
+ for name, b in self._thumb_buttons.items():
995
+ b.setIcon(QIcon())
996
+ self._thumb_base_pm.clear()
997
+ self._selected_name = None
998
+ for b in self._thumb_buttons.values():
999
+ b.setIcon(QIcon())
1000
+ self.status.setText("Cleared all loaded channels.")
1001
+
1002
+ def _as_float01(self, arr):
1003
+ a = np.asarray(arr)
1004
+ if a.dtype == np.uint8: return a.astype(np.float32)/255.0
1005
+ if a.dtype == np.uint16: return a.astype(np.float32)/65535.0
1006
+ return np.clip(a.astype(np.float32), 0.0, 1.0)
1007
+
1008
+ def _stretch_input(self, img):
1009
+ """Run statistical stretch on mono or color inputs (target_median=0.25)."""
1010
+ if img.ndim == 2:
1011
+ return np.clip(stretch_mono_image(img, target_median=0.25), 0.0, 1.0)
1012
+ if img.ndim == 3 and img.shape[2] == 3:
1013
+ return np.clip(stretch_color_image(img, target_median=0.25, linked=False), 0.0, 1.0)
1014
+ if img.ndim == 3 and img.shape[2] == 1:
1015
+ mono = img[...,0]
1016
+ return np.clip(stretch_mono_image(mono, target_median=0.25), 0.0, 1.0)
1017
+ return img
1018
+
1019
+ def _to_qimage(self, arr):
1020
+ a = np.clip(arr, 0, 1)
1021
+ if a.ndim == 2:
1022
+ u = (a * 255).astype(np.uint8); h, w = u.shape
1023
+ return QImage(u.data, w, h, w, QImage.Format.Format_Grayscale8).copy()
1024
+ if a.ndim == 3 and a.shape[2] == 3:
1025
+ u = (a * 255).astype(np.uint8); h, w, _ = u.shape
1026
+ return QImage(u.data, w, h, w*3, QImage.Format.Format_RGB888).copy()
1027
+ raise ValueError(f"Unexpected image shape: {a.shape}")
1028
+
1029
+ def _find_main_window(self):
1030
+ w = self
1031
+ from PyQt6.QtWidgets import QMainWindow, QApplication
1032
+ while w is not None and not isinstance(w, QMainWindow):
1033
+ w = w.parentWidget()
1034
+ if w: return w
1035
+ for tlw in QApplication.topLevelWidgets():
1036
+ if isinstance(tlw, QMainWindow):
1037
+ return tlw
1038
+ return None
1039
+
1040
+ def _list_open_views(self):
1041
+ mw = self._find_main_window()
1042
+ if not mw:
1043
+ return []
1044
+ try:
1045
+ from setiastro.saspro.subwindow import ImageSubWindow
1046
+ subs = mw.findChildren(ImageSubWindow)
1047
+ except Exception:
1048
+ subs = []
1049
+ out = []
1050
+ for sw in subs:
1051
+ title = getattr(sw, "view_title", None) or sw.windowTitle() or getattr(sw.document, "display_name", lambda: "Untitled")()
1052
+ out.append((str(title), sw))
1053
+ return out
1054
+
1055
+ def eventFilter(self, obj, ev):
1056
+ # Ctrl+wheel = zoom at mouse (no scrolling). Wheel without Ctrl = eaten.
1057
+ if ev.type() == QEvent.Type.Wheel and (
1058
+ obj is self.preview
1059
+ or obj is self.scroll
1060
+ or obj is self.scroll.viewport()
1061
+ or obj is self.scroll.horizontalScrollBar()
1062
+ or obj is self.scroll.verticalScrollBar()
1063
+ ):
1064
+ # always stop the wheel from scrolling
1065
+ ev.accept()
1066
+
1067
+ # Zoom only when Ctrl is held
1068
+ if ev.modifiers() & Qt.KeyboardModifier.ControlModifier:
1069
+ factor = 1.25 if ev.angleDelta().y() > 0 else 0.8
1070
+
1071
+ # Get mouse position in global screen coords and map into the viewport
1072
+ vp = self.scroll.viewport()
1073
+ anchor_vp = vp.mapFromGlobal(ev.globalPosition().toPoint())
1074
+
1075
+ # Clamp to viewport rect (robust if the event originated on scrollbars)
1076
+ r = vp.rect()
1077
+ if not r.contains(anchor_vp):
1078
+ anchor_vp.setX(max(r.left(), min(r.right(), anchor_vp.x())))
1079
+ anchor_vp.setY(max(r.top(), min(r.bottom(), anchor_vp.y())))
1080
+
1081
+ self._zoom_at(factor, anchor_vp)
1082
+ return True
1083
+ # click-drag pan on viewport
1084
+ if obj is self.scroll.viewport():
1085
+ if ev.type() == QEvent.Type.MouseButtonPress and ev.button() == Qt.MouseButton.LeftButton:
1086
+ self._panning = True
1087
+ self._pan_last = ev.position().toPoint()
1088
+ self.scroll.viewport().setCursor(QCursor(Qt.CursorShape.ClosedHandCursor))
1089
+ return True
1090
+ if ev.type() == QEvent.Type.MouseMove and self._panning:
1091
+ cur = ev.position().toPoint()
1092
+ delta = cur - (self._pan_last or cur)
1093
+ self._pan_last = cur
1094
+ h = self.scroll.horizontalScrollBar()
1095
+ v = self.scroll.verticalScrollBar()
1096
+ h.setValue(h.value() - delta.x())
1097
+ v.setValue(v.value() - delta.y())
1098
+ return True
1099
+ if ev.type() == QEvent.Type.MouseButtonRelease and ev.button() == Qt.MouseButton.LeftButton:
1100
+ self._panning = False
1101
+ self._pan_last = None
1102
+ self.scroll.viewport().setCursor(QCursor(Qt.CursorShape.ArrowCursor))
1103
+ return True
1104
+
1105
+ return super().eventFilter(obj, ev)