setiastrosuitepro 1.6.2.post1__py3-none-any.whl

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

Potentially problematic release.


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

Files changed (367) hide show
  1. setiastro/__init__.py +2 -0
  2. setiastro/data/SASP_data.fits +0 -0
  3. setiastro/data/catalogs/List_of_Galaxies_with_Distances_Gly.csv +488 -0
  4. setiastro/data/catalogs/astrobin_filters.csv +890 -0
  5. setiastro/data/catalogs/astrobin_filters_page1_local.csv +51 -0
  6. setiastro/data/catalogs/cali2.csv +63 -0
  7. setiastro/data/catalogs/cali2color.csv +65 -0
  8. setiastro/data/catalogs/celestial_catalog - original.csv +16471 -0
  9. setiastro/data/catalogs/celestial_catalog.csv +24031 -0
  10. setiastro/data/catalogs/detected_stars.csv +24784 -0
  11. setiastro/data/catalogs/fits_header_data.csv +46 -0
  12. setiastro/data/catalogs/test.csv +8 -0
  13. setiastro/data/catalogs/updated_celestial_catalog.csv +16471 -0
  14. setiastro/images/Astro_Spikes.png +0 -0
  15. setiastro/images/Background_startup.jpg +0 -0
  16. setiastro/images/HRDiagram.png +0 -0
  17. setiastro/images/LExtract.png +0 -0
  18. setiastro/images/LInsert.png +0 -0
  19. setiastro/images/Oxygenation-atm-2.svg.png +0 -0
  20. setiastro/images/RGB080604.png +0 -0
  21. setiastro/images/abeicon.png +0 -0
  22. setiastro/images/aberration.png +0 -0
  23. setiastro/images/andromedatry.png +0 -0
  24. setiastro/images/andromedatry_satellited.png +0 -0
  25. setiastro/images/annotated.png +0 -0
  26. setiastro/images/aperture.png +0 -0
  27. setiastro/images/astrosuite.ico +0 -0
  28. setiastro/images/astrosuite.png +0 -0
  29. setiastro/images/astrosuitepro.icns +0 -0
  30. setiastro/images/astrosuitepro.ico +0 -0
  31. setiastro/images/astrosuitepro.png +0 -0
  32. setiastro/images/background.png +0 -0
  33. setiastro/images/background2.png +0 -0
  34. setiastro/images/benchmark.png +0 -0
  35. setiastro/images/big_moon_stabilizer_timeline.png +0 -0
  36. setiastro/images/big_moon_stabilizer_timeline_clean.png +0 -0
  37. setiastro/images/blaster.png +0 -0
  38. setiastro/images/blink.png +0 -0
  39. setiastro/images/clahe.png +0 -0
  40. setiastro/images/collage.png +0 -0
  41. setiastro/images/colorwheel.png +0 -0
  42. setiastro/images/contsub.png +0 -0
  43. setiastro/images/convo.png +0 -0
  44. setiastro/images/copyslot.png +0 -0
  45. setiastro/images/cosmic.png +0 -0
  46. setiastro/images/cosmicsat.png +0 -0
  47. setiastro/images/crop1.png +0 -0
  48. setiastro/images/cropicon.png +0 -0
  49. setiastro/images/curves.png +0 -0
  50. setiastro/images/cvs.png +0 -0
  51. setiastro/images/debayer.png +0 -0
  52. setiastro/images/denoise_cnn_custom.png +0 -0
  53. setiastro/images/denoise_cnn_graph.png +0 -0
  54. setiastro/images/disk.png +0 -0
  55. setiastro/images/dse.png +0 -0
  56. setiastro/images/exoicon.png +0 -0
  57. setiastro/images/eye.png +0 -0
  58. setiastro/images/fliphorizontal.png +0 -0
  59. setiastro/images/flipvertical.png +0 -0
  60. setiastro/images/font.png +0 -0
  61. setiastro/images/freqsep.png +0 -0
  62. setiastro/images/functionbundle.png +0 -0
  63. setiastro/images/graxpert.png +0 -0
  64. setiastro/images/green.png +0 -0
  65. setiastro/images/gridicon.png +0 -0
  66. setiastro/images/halo.png +0 -0
  67. setiastro/images/hdr.png +0 -0
  68. setiastro/images/histogram.png +0 -0
  69. setiastro/images/hubble.png +0 -0
  70. setiastro/images/imagecombine.png +0 -0
  71. setiastro/images/invert.png +0 -0
  72. setiastro/images/isophote.png +0 -0
  73. setiastro/images/isophote_demo_figure.png +0 -0
  74. setiastro/images/isophote_demo_image.png +0 -0
  75. setiastro/images/isophote_demo_model.png +0 -0
  76. setiastro/images/isophote_demo_residual.png +0 -0
  77. setiastro/images/jwstpupil.png +0 -0
  78. setiastro/images/linearfit.png +0 -0
  79. setiastro/images/livestacking.png +0 -0
  80. setiastro/images/mask.png +0 -0
  81. setiastro/images/maskapply.png +0 -0
  82. setiastro/images/maskcreate.png +0 -0
  83. setiastro/images/maskremove.png +0 -0
  84. setiastro/images/morpho.png +0 -0
  85. setiastro/images/mosaic.png +0 -0
  86. setiastro/images/multiscale_decomp.png +0 -0
  87. setiastro/images/nbtorgb.png +0 -0
  88. setiastro/images/neutral.png +0 -0
  89. setiastro/images/nuke.png +0 -0
  90. setiastro/images/openfile.png +0 -0
  91. setiastro/images/pedestal.png +0 -0
  92. setiastro/images/pen.png +0 -0
  93. setiastro/images/pixelmath.png +0 -0
  94. setiastro/images/platesolve.png +0 -0
  95. setiastro/images/ppp.png +0 -0
  96. setiastro/images/pro.png +0 -0
  97. setiastro/images/project.png +0 -0
  98. setiastro/images/psf.png +0 -0
  99. setiastro/images/redo.png +0 -0
  100. setiastro/images/redoicon.png +0 -0
  101. setiastro/images/rescale.png +0 -0
  102. setiastro/images/rgbalign.png +0 -0
  103. setiastro/images/rgbcombo.png +0 -0
  104. setiastro/images/rgbextract.png +0 -0
  105. setiastro/images/rotate180.png +0 -0
  106. setiastro/images/rotateclockwise.png +0 -0
  107. setiastro/images/rotatecounterclockwise.png +0 -0
  108. setiastro/images/satellite.png +0 -0
  109. setiastro/images/script.png +0 -0
  110. setiastro/images/selectivecolor.png +0 -0
  111. setiastro/images/simbad.png +0 -0
  112. setiastro/images/slot0.png +0 -0
  113. setiastro/images/slot1.png +0 -0
  114. setiastro/images/slot2.png +0 -0
  115. setiastro/images/slot3.png +0 -0
  116. setiastro/images/slot4.png +0 -0
  117. setiastro/images/slot5.png +0 -0
  118. setiastro/images/slot6.png +0 -0
  119. setiastro/images/slot7.png +0 -0
  120. setiastro/images/slot8.png +0 -0
  121. setiastro/images/slot9.png +0 -0
  122. setiastro/images/spcc.png +0 -0
  123. setiastro/images/spin_precession_vs_lunar_distance.png +0 -0
  124. setiastro/images/spinner.gif +0 -0
  125. setiastro/images/stacking.png +0 -0
  126. setiastro/images/staradd.png +0 -0
  127. setiastro/images/staralign.png +0 -0
  128. setiastro/images/starnet.png +0 -0
  129. setiastro/images/starregistration.png +0 -0
  130. setiastro/images/starspike.png +0 -0
  131. setiastro/images/starstretch.png +0 -0
  132. setiastro/images/statstretch.png +0 -0
  133. setiastro/images/supernova.png +0 -0
  134. setiastro/images/uhs.png +0 -0
  135. setiastro/images/undoicon.png +0 -0
  136. setiastro/images/upscale.png +0 -0
  137. setiastro/images/viewbundle.png +0 -0
  138. setiastro/images/whitebalance.png +0 -0
  139. setiastro/images/wimi_icon_256x256.png +0 -0
  140. setiastro/images/wimilogo.png +0 -0
  141. setiastro/images/wims.png +0 -0
  142. setiastro/images/wrench_icon.png +0 -0
  143. setiastro/images/xisfliberator.png +0 -0
  144. setiastro/qml/ResourceMonitor.qml +126 -0
  145. setiastro/saspro/__init__.py +20 -0
  146. setiastro/saspro/__main__.py +945 -0
  147. setiastro/saspro/_generated/__init__.py +7 -0
  148. setiastro/saspro/_generated/build_info.py +3 -0
  149. setiastro/saspro/abe.py +1346 -0
  150. setiastro/saspro/abe_preset.py +196 -0
  151. setiastro/saspro/aberration_ai.py +694 -0
  152. setiastro/saspro/aberration_ai_preset.py +224 -0
  153. setiastro/saspro/accel_installer.py +218 -0
  154. setiastro/saspro/accel_workers.py +30 -0
  155. setiastro/saspro/add_stars.py +624 -0
  156. setiastro/saspro/astrobin_exporter.py +1010 -0
  157. setiastro/saspro/astrospike.py +153 -0
  158. setiastro/saspro/astrospike_python.py +1841 -0
  159. setiastro/saspro/autostretch.py +198 -0
  160. setiastro/saspro/backgroundneutral.py +602 -0
  161. setiastro/saspro/batch_convert.py +328 -0
  162. setiastro/saspro/batch_renamer.py +522 -0
  163. setiastro/saspro/blemish_blaster.py +491 -0
  164. setiastro/saspro/blink_comparator_pro.py +2926 -0
  165. setiastro/saspro/bundles.py +61 -0
  166. setiastro/saspro/bundles_dock.py +114 -0
  167. setiastro/saspro/cheat_sheet.py +213 -0
  168. setiastro/saspro/clahe.py +368 -0
  169. setiastro/saspro/comet_stacking.py +1442 -0
  170. setiastro/saspro/common_tr.py +107 -0
  171. setiastro/saspro/config.py +38 -0
  172. setiastro/saspro/config_bootstrap.py +40 -0
  173. setiastro/saspro/config_manager.py +316 -0
  174. setiastro/saspro/continuum_subtract.py +1617 -0
  175. setiastro/saspro/convo.py +1400 -0
  176. setiastro/saspro/convo_preset.py +414 -0
  177. setiastro/saspro/copyastro.py +190 -0
  178. setiastro/saspro/cosmicclarity.py +1589 -0
  179. setiastro/saspro/cosmicclarity_preset.py +407 -0
  180. setiastro/saspro/crop_dialog_pro.py +973 -0
  181. setiastro/saspro/crop_preset.py +189 -0
  182. setiastro/saspro/curve_editor_pro.py +2562 -0
  183. setiastro/saspro/curves_preset.py +375 -0
  184. setiastro/saspro/debayer.py +673 -0
  185. setiastro/saspro/debug_utils.py +29 -0
  186. setiastro/saspro/dnd_mime.py +35 -0
  187. setiastro/saspro/doc_manager.py +2664 -0
  188. setiastro/saspro/exoplanet_detector.py +2166 -0
  189. setiastro/saspro/file_utils.py +284 -0
  190. setiastro/saspro/fitsmodifier.py +748 -0
  191. setiastro/saspro/fix_bom.py +32 -0
  192. setiastro/saspro/free_torch_memory.py +48 -0
  193. setiastro/saspro/frequency_separation.py +1349 -0
  194. setiastro/saspro/function_bundle.py +1596 -0
  195. setiastro/saspro/generate_translations.py +3092 -0
  196. setiastro/saspro/ghs_dialog_pro.py +663 -0
  197. setiastro/saspro/ghs_preset.py +284 -0
  198. setiastro/saspro/graxpert.py +637 -0
  199. setiastro/saspro/graxpert_preset.py +287 -0
  200. setiastro/saspro/gui/__init__.py +0 -0
  201. setiastro/saspro/gui/main_window.py +8810 -0
  202. setiastro/saspro/gui/mixins/__init__.py +33 -0
  203. setiastro/saspro/gui/mixins/dock_mixin.py +362 -0
  204. setiastro/saspro/gui/mixins/file_mixin.py +450 -0
  205. setiastro/saspro/gui/mixins/geometry_mixin.py +403 -0
  206. setiastro/saspro/gui/mixins/header_mixin.py +441 -0
  207. setiastro/saspro/gui/mixins/mask_mixin.py +421 -0
  208. setiastro/saspro/gui/mixins/menu_mixin.py +389 -0
  209. setiastro/saspro/gui/mixins/theme_mixin.py +367 -0
  210. setiastro/saspro/gui/mixins/toolbar_mixin.py +1457 -0
  211. setiastro/saspro/gui/mixins/update_mixin.py +309 -0
  212. setiastro/saspro/gui/mixins/view_mixin.py +435 -0
  213. setiastro/saspro/gui/statistics_dialog.py +47 -0
  214. setiastro/saspro/halobgon.py +488 -0
  215. setiastro/saspro/header_viewer.py +448 -0
  216. setiastro/saspro/headless_utils.py +88 -0
  217. setiastro/saspro/histogram.py +756 -0
  218. setiastro/saspro/history_explorer.py +941 -0
  219. setiastro/saspro/i18n.py +168 -0
  220. setiastro/saspro/image_combine.py +417 -0
  221. setiastro/saspro/image_peeker_pro.py +1604 -0
  222. setiastro/saspro/imageops/__init__.py +37 -0
  223. setiastro/saspro/imageops/mdi_snap.py +292 -0
  224. setiastro/saspro/imageops/scnr.py +36 -0
  225. setiastro/saspro/imageops/starbasedwhitebalance.py +210 -0
  226. setiastro/saspro/imageops/stretch.py +236 -0
  227. setiastro/saspro/isophote.py +1182 -0
  228. setiastro/saspro/layers.py +208 -0
  229. setiastro/saspro/layers_dock.py +714 -0
  230. setiastro/saspro/lazy_imports.py +193 -0
  231. setiastro/saspro/legacy/__init__.py +2 -0
  232. setiastro/saspro/legacy/image_manager.py +2226 -0
  233. setiastro/saspro/legacy/numba_utils.py +3676 -0
  234. setiastro/saspro/legacy/xisf.py +1071 -0
  235. setiastro/saspro/linear_fit.py +537 -0
  236. setiastro/saspro/live_stacking.py +1841 -0
  237. setiastro/saspro/log_bus.py +5 -0
  238. setiastro/saspro/logging_config.py +460 -0
  239. setiastro/saspro/luminancerecombine.py +309 -0
  240. setiastro/saspro/main_helpers.py +201 -0
  241. setiastro/saspro/mask_creation.py +931 -0
  242. setiastro/saspro/masks_core.py +56 -0
  243. setiastro/saspro/mdi_widgets.py +353 -0
  244. setiastro/saspro/memory_utils.py +666 -0
  245. setiastro/saspro/metadata_patcher.py +75 -0
  246. setiastro/saspro/mfdeconv.py +3831 -0
  247. setiastro/saspro/mfdeconv_earlystop.py +71 -0
  248. setiastro/saspro/mfdeconvcudnn.py +3263 -0
  249. setiastro/saspro/mfdeconvsport.py +2382 -0
  250. setiastro/saspro/minorbodycatalog.py +567 -0
  251. setiastro/saspro/morphology.py +407 -0
  252. setiastro/saspro/multiscale_decomp.py +1293 -0
  253. setiastro/saspro/nbtorgb_stars.py +541 -0
  254. setiastro/saspro/numba_utils.py +3145 -0
  255. setiastro/saspro/numba_warmup.py +141 -0
  256. setiastro/saspro/ops/__init__.py +9 -0
  257. setiastro/saspro/ops/command_help_dialog.py +623 -0
  258. setiastro/saspro/ops/command_runner.py +217 -0
  259. setiastro/saspro/ops/commands.py +1594 -0
  260. setiastro/saspro/ops/script_editor.py +1102 -0
  261. setiastro/saspro/ops/scripts.py +1473 -0
  262. setiastro/saspro/ops/settings.py +637 -0
  263. setiastro/saspro/parallel_utils.py +554 -0
  264. setiastro/saspro/pedestal.py +121 -0
  265. setiastro/saspro/perfect_palette_picker.py +1071 -0
  266. setiastro/saspro/pipeline.py +110 -0
  267. setiastro/saspro/pixelmath.py +1604 -0
  268. setiastro/saspro/plate_solver.py +2445 -0
  269. setiastro/saspro/project_io.py +797 -0
  270. setiastro/saspro/psf_utils.py +136 -0
  271. setiastro/saspro/psf_viewer.py +549 -0
  272. setiastro/saspro/pyi_rthook_astroquery.py +95 -0
  273. setiastro/saspro/remove_green.py +331 -0
  274. setiastro/saspro/remove_stars.py +1599 -0
  275. setiastro/saspro/remove_stars_preset.py +404 -0
  276. setiastro/saspro/resources.py +501 -0
  277. setiastro/saspro/rgb_combination.py +208 -0
  278. setiastro/saspro/rgb_extract.py +19 -0
  279. setiastro/saspro/rgbalign.py +723 -0
  280. setiastro/saspro/runtime_imports.py +7 -0
  281. setiastro/saspro/runtime_torch.py +754 -0
  282. setiastro/saspro/save_options.py +73 -0
  283. setiastro/saspro/selective_color.py +1552 -0
  284. setiastro/saspro/sfcc.py +1472 -0
  285. setiastro/saspro/shortcuts.py +3043 -0
  286. setiastro/saspro/signature_insert.py +1102 -0
  287. setiastro/saspro/stacking_suite.py +18470 -0
  288. setiastro/saspro/star_alignment.py +7435 -0
  289. setiastro/saspro/star_alignment_preset.py +329 -0
  290. setiastro/saspro/star_metrics.py +49 -0
  291. setiastro/saspro/star_spikes.py +765 -0
  292. setiastro/saspro/star_stretch.py +507 -0
  293. setiastro/saspro/stat_stretch.py +538 -0
  294. setiastro/saspro/status_log_dock.py +78 -0
  295. setiastro/saspro/subwindow.py +3328 -0
  296. setiastro/saspro/supernovaasteroidhunter.py +1719 -0
  297. setiastro/saspro/swap_manager.py +99 -0
  298. setiastro/saspro/torch_backend.py +89 -0
  299. setiastro/saspro/torch_rejection.py +434 -0
  300. setiastro/saspro/translations/all_source_strings.json +3654 -0
  301. setiastro/saspro/translations/ar_translations.py +3865 -0
  302. setiastro/saspro/translations/de_translations.py +3749 -0
  303. setiastro/saspro/translations/es_translations.py +3939 -0
  304. setiastro/saspro/translations/fr_translations.py +3858 -0
  305. setiastro/saspro/translations/hi_translations.py +3571 -0
  306. setiastro/saspro/translations/integrate_translations.py +270 -0
  307. setiastro/saspro/translations/it_translations.py +3678 -0
  308. setiastro/saspro/translations/ja_translations.py +3601 -0
  309. setiastro/saspro/translations/pt_translations.py +3869 -0
  310. setiastro/saspro/translations/ru_translations.py +2848 -0
  311. setiastro/saspro/translations/saspro_ar.qm +0 -0
  312. setiastro/saspro/translations/saspro_ar.ts +255 -0
  313. setiastro/saspro/translations/saspro_de.qm +0 -0
  314. setiastro/saspro/translations/saspro_de.ts +253 -0
  315. setiastro/saspro/translations/saspro_es.qm +0 -0
  316. setiastro/saspro/translations/saspro_es.ts +12520 -0
  317. setiastro/saspro/translations/saspro_fr.qm +0 -0
  318. setiastro/saspro/translations/saspro_fr.ts +12514 -0
  319. setiastro/saspro/translations/saspro_hi.qm +0 -0
  320. setiastro/saspro/translations/saspro_hi.ts +257 -0
  321. setiastro/saspro/translations/saspro_it.qm +0 -0
  322. setiastro/saspro/translations/saspro_it.ts +12520 -0
  323. setiastro/saspro/translations/saspro_ja.qm +0 -0
  324. setiastro/saspro/translations/saspro_ja.ts +257 -0
  325. setiastro/saspro/translations/saspro_pt.qm +0 -0
  326. setiastro/saspro/translations/saspro_pt.ts +257 -0
  327. setiastro/saspro/translations/saspro_ru.qm +0 -0
  328. setiastro/saspro/translations/saspro_ru.ts +237 -0
  329. setiastro/saspro/translations/saspro_sw.qm +0 -0
  330. setiastro/saspro/translations/saspro_sw.ts +257 -0
  331. setiastro/saspro/translations/saspro_uk.qm +0 -0
  332. setiastro/saspro/translations/saspro_uk.ts +10771 -0
  333. setiastro/saspro/translations/saspro_zh.qm +0 -0
  334. setiastro/saspro/translations/saspro_zh.ts +12520 -0
  335. setiastro/saspro/translations/sw_translations.py +3671 -0
  336. setiastro/saspro/translations/uk_translations.py +3700 -0
  337. setiastro/saspro/translations/zh_translations.py +3675 -0
  338. setiastro/saspro/versioning.py +77 -0
  339. setiastro/saspro/view_bundle.py +1558 -0
  340. setiastro/saspro/wavescale_hdr.py +645 -0
  341. setiastro/saspro/wavescale_hdr_preset.py +101 -0
  342. setiastro/saspro/wavescalede.py +680 -0
  343. setiastro/saspro/wavescalede_preset.py +230 -0
  344. setiastro/saspro/wcs_update.py +374 -0
  345. setiastro/saspro/whitebalance.py +492 -0
  346. setiastro/saspro/widgets/__init__.py +48 -0
  347. setiastro/saspro/widgets/common_utilities.py +306 -0
  348. setiastro/saspro/widgets/graphics_views.py +122 -0
  349. setiastro/saspro/widgets/image_utils.py +518 -0
  350. setiastro/saspro/widgets/minigame/game.js +986 -0
  351. setiastro/saspro/widgets/minigame/index.html +53 -0
  352. setiastro/saspro/widgets/minigame/style.css +241 -0
  353. setiastro/saspro/widgets/preview_dialogs.py +280 -0
  354. setiastro/saspro/widgets/resource_monitor.py +237 -0
  355. setiastro/saspro/widgets/spinboxes.py +275 -0
  356. setiastro/saspro/widgets/themed_buttons.py +13 -0
  357. setiastro/saspro/widgets/wavelet_utils.py +331 -0
  358. setiastro/saspro/wimi.py +7996 -0
  359. setiastro/saspro/wims.py +578 -0
  360. setiastro/saspro/window_shelf.py +185 -0
  361. setiastro/saspro/xisf.py +1123 -0
  362. setiastrosuitepro-1.6.2.post1.dist-info/METADATA +278 -0
  363. setiastrosuitepro-1.6.2.post1.dist-info/RECORD +367 -0
  364. setiastrosuitepro-1.6.2.post1.dist-info/WHEEL +4 -0
  365. setiastrosuitepro-1.6.2.post1.dist-info/entry_points.txt +6 -0
  366. setiastrosuitepro-1.6.2.post1.dist-info/licenses/LICENSE +674 -0
  367. setiastrosuitepro-1.6.2.post1.dist-info/licenses/license.txt +2580 -0
@@ -0,0 +1,1071 @@
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
+
231
+ # stretched cache (per input name → stretched array)
232
+ self._stretched: dict[str, np.ndarray] = {}
233
+
234
+ self.final = None
235
+ self.current_palette = None
236
+ self._thumb_base_pm: dict[str, QPixmap] = {} # palette name -> base pixmap (image only)
237
+ self._selected_name: str | None = None
238
+
239
+ # thumbs
240
+ self._thumb_buttons: dict[str, QPushButton] = {}
241
+
242
+ self._base_pm: QPixmap | None = None
243
+ self._zoom = 1.0
244
+ self._min_zoom = 0.05
245
+ self._max_zoom = 6.0
246
+ self._panning = False
247
+ self._pan_last: QPoint | None = None
248
+
249
+ self._build_ui()
250
+
251
+ # ---------------- UI ----------------
252
+ def _build_ui(self):
253
+ root = QHBoxLayout(self)
254
+
255
+ # -------- left controls
256
+ left = QVBoxLayout()
257
+ left_host = QWidget(self); left_host.setLayout(left); left_host.setFixedWidth(300)
258
+
259
+ left.addWidget(QLabel("<b>Load channels</b>"))
260
+
261
+ # Load buttons + status labels
262
+ self.btn_ha = QPushButton("Load Ha…"); self.btn_ha.clicked.connect(lambda: self._load_channel("Ha"))
263
+ self.btn_oiii = QPushButton("Load OIII…"); self.btn_oiii.clicked.connect(lambda: self._load_channel("OIII"))
264
+ self.btn_sii = QPushButton("Load SII…"); self.btn_sii.clicked.connect(lambda: self._load_channel("SII"))
265
+ self.btn_osc1 = QPushButton("Load OSC1 (Ha/OIII)…"); self.btn_osc1.clicked.connect(lambda: self._load_channel("OSC1"))
266
+ self.btn_osc2 = QPushButton("Load OSC2 (SII/OIII)…"); self.btn_osc2.clicked.connect(lambda: self._load_channel("OSC2"))
267
+
268
+ self.lbl_ha = QLabel("No Ha loaded.")
269
+ self.lbl_oiii = QLabel("No OIII loaded.")
270
+ self.lbl_sii = QLabel("No SII loaded.")
271
+ self.lbl_osc1 = QLabel("No OSC1 loaded.")
272
+ self.lbl_osc2 = QLabel("No OSC2 loaded.")
273
+ for lab in (self.lbl_ha, self.lbl_oiii, self.lbl_sii, self.lbl_osc1, self.lbl_osc2):
274
+ lab.setWordWrap(True); lab.setStyleSheet("color:#888; margin-left:8px;")
275
+
276
+ for btn, lab in (
277
+ (self.btn_ha, self.lbl_ha),
278
+ (self.btn_oiii, self.lbl_oiii),
279
+ (self.btn_sii, self.lbl_sii),
280
+ (self.btn_osc1, self.lbl_osc1),
281
+ (self.btn_osc2, self.lbl_osc2),
282
+ ):
283
+ left.addWidget(btn); left.addWidget(lab)
284
+
285
+ # Linear toggle (stretch BEFORE palette build)
286
+ self.chk_linear = QCheckBox("Linear input (apply statistical stretch before build)")
287
+ self.chk_linear.setChecked(True)
288
+ self.chk_linear.stateChanged.connect(self._rebuild_stretch_cache_for_all)
289
+ left.addSpacing(6); left.addWidget(self.chk_linear)
290
+
291
+ # Actions
292
+ self.btn_clear = QPushButton("Clear Loaded Channels")
293
+ self.btn_clear.clicked.connect(self._clear_channels)
294
+ left.addWidget(self.btn_clear)
295
+
296
+ self.btn_create = QPushButton("Create Palettes")
297
+ self.btn_create.clicked.connect(self._create_palettes)
298
+ left.addWidget(self.btn_create)
299
+
300
+ self.btn_push = QPushButton("Push Final to New View")
301
+ self.btn_push.clicked.connect(self._push_final)
302
+ left.addWidget(self.btn_push)
303
+
304
+ left.addStretch(1)
305
+ root.addWidget(left_host, 0)
306
+
307
+ # -------- right: preview + fixed-size 4×3 grid
308
+ right = QVBoxLayout()
309
+
310
+ # zoom toolbar
311
+ # zoom toolbar (themed)
312
+ tools = QHBoxLayout()
313
+ self.btn_zoom_in = themed_toolbtn("zoom-in", "Zoom In")
314
+ self.btn_zoom_out = themed_toolbtn("zoom-out", "Zoom Out")
315
+ self.btn_fit = themed_toolbtn("zoom-fit-best", "Fit to Preview")
316
+
317
+ self.btn_zoom_in.clicked.connect(lambda: self._zoom_at(1.25))
318
+ self.btn_zoom_out.clicked.connect(lambda: self._zoom_at(0.8))
319
+ self.btn_fit.clicked.connect(self._fit_to_preview)
320
+
321
+ tools.addStretch(1)
322
+ tools.addWidget(self.btn_zoom_out)
323
+ tools.addWidget(self.btn_zoom_in)
324
+ tools.addWidget(self.btn_fit)
325
+ tools.addStretch(1)
326
+ right.addLayout(tools)
327
+
328
+
329
+ # main preview (expands)
330
+ self.scroll = QScrollArea(self); self.scroll.setWidgetResizable(True)
331
+ self.scroll.setAlignment(Qt.AlignmentFlag.AlignCenter)
332
+ self.preview = QLabel(alignment=Qt.AlignmentFlag.AlignCenter)
333
+ self.scroll.setWidget(self.preview)
334
+ self.preview.setMouseTracking(True)
335
+ self.preview.installEventFilter(self)
336
+ self.scroll.viewport().installEventFilter(self)
337
+ self.scroll.installEventFilter(self)
338
+ self.scroll.horizontalScrollBar().installEventFilter(self) # NEW
339
+ self.scroll.verticalScrollBar().installEventFilter(self) # NEW
340
+ right.addWidget(self.scroll, 1)
341
+
342
+ # fixed-size grid
343
+ self.grid = QGridLayout()
344
+ self.grid.setHorizontalSpacing(8); self.grid.setVerticalSpacing(8)
345
+ self.grid.setContentsMargins(8, 8, 8, 8)
346
+
347
+ self.thumb_size = QSize(220, 110)
348
+ btn_w = self.thumb_size.width() + 2
349
+ btn_h = self.thumb_size.height() + 2
350
+ cols, rows = 4, 3
351
+
352
+ for idx, name in enumerate(self.PALETTES):
353
+ r, c = divmod(idx, cols)
354
+ b = QPushButton("") # we draw the text onto the icon itself
355
+ b.setToolTip(name)
356
+ b.setIconSize(self.thumb_size)
357
+ b.setFixedSize(btn_w, btn_h)
358
+ b.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
359
+ b.clicked.connect(lambda _=None, n=name: self._on_palette_clicked(n))
360
+ b.setStyleSheet("QPushButton{background:#222;border:1px solid #333;} QPushButton:hover{border-color:#555;}")
361
+ self._thumb_buttons[name] = b
362
+ self.grid.addWidget(b, r, c)
363
+
364
+ grid_host = QWidget(self); grid_host.setLayout(self.grid)
365
+ hspacing = self.grid.horizontalSpacing(); vspacing = self.grid.verticalSpacing()
366
+ m = self.grid.contentsMargins()
367
+ grid_w = cols*btn_w + (cols-1)*hspacing + m.left() + m.right()
368
+ grid_h = rows*btn_h + (rows-1)*vspacing + m.top() + m.bottom()
369
+ grid_host.setFixedSize(grid_w, grid_h)
370
+ grid_host.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
371
+ right.addWidget(grid_host, 0, alignment=Qt.AlignmentFlag.AlignHCenter)
372
+
373
+ self.status = QLabel(""); right.addWidget(self.status, 0)
374
+
375
+ right_host = QWidget(self); right_host.setLayout(right)
376
+ root.addWidget(right_host, 1)
377
+
378
+ self.setLayout(root)
379
+ self.setMinimumSize(left_host.width() + grid_w + 48, max(560, grid_h + 200))
380
+
381
+ def _resize_to(self, arr: np.ndarray | None, size: tuple[int, int]) -> np.ndarray | None:
382
+ """Resize np array to (w,h). Keeps dtype/scale. Uses INTER_AREA for downsizing."""
383
+ if arr is None:
384
+ return None
385
+ w, h = size
386
+ if arr.ndim == 2:
387
+ src_h, src_w = arr.shape
388
+ else:
389
+ src_h, src_w = arr.shape[:2]
390
+ if (src_w, src_h) == (w, h):
391
+ return arr
392
+ if cv2 is None:
393
+ # ultra-simple fallback: nearest; OK for thumbs if OpenCV isn't present
394
+ if arr.ndim == 2:
395
+ return np.array(Image.fromarray((arr*255).astype(np.uint8)).resize((w, h))).astype(np.float32) / 255.0
396
+ return np.array(Image.fromarray((arr*255).astype(np.uint8)).resize((w, h))).astype(np.float32) / 255.0
397
+ interp = cv2.INTER_AREA if (w < src_w or h < src_h) else cv2.INTER_LINEAR
398
+ if arr.ndim == 2:
399
+ return cv2.resize(arr, (w, h), interpolation=interp)
400
+ return cv2.resize(arr, (w, h), interpolation=interp)
401
+
402
+ def _capture_view_state(self):
403
+ """Capture current view center in base-image coordinates + zoom."""
404
+ if self._base_pm is None:
405
+ return None
406
+ vp = self.scroll.viewport()
407
+ hbar = self.scroll.horizontalScrollBar()
408
+ vbar = self.scroll.verticalScrollBar()
409
+
410
+ # center of viewport in viewport coords
411
+ anchor_vp = QPoint(vp.width() // 2, vp.height() // 2)
412
+
413
+ # convert to label coords (scaled image coords)
414
+ anchor_lbl = self.preview.mapFrom(vp, anchor_vp)
415
+
416
+ # scaled -> base image coords
417
+ base_x = anchor_lbl.x() / max(self._zoom, 1e-6)
418
+ base_y = anchor_lbl.y() / max(self._zoom, 1e-6)
419
+
420
+ pm = self._base_pm.size()
421
+ fx = 0.5 if pm.width() <= 0 else (base_x / pm.width())
422
+ fy = 0.5 if pm.height() <= 0 else (base_y / pm.height())
423
+
424
+ return {"zoom": float(self._zoom), "fx": float(fx), "fy": float(fy)}
425
+
426
+ def _restore_view_state(self, state):
427
+ """Restore zoom and pan using stored base-image fractions."""
428
+ if not state or self._base_pm is None:
429
+ return
430
+
431
+ # restore zoom first
432
+ self._zoom = max(self._min_zoom, min(self._max_zoom, float(state["zoom"])))
433
+ self._update_preview_pixmap()
434
+
435
+ # now restore center point
436
+ pm = self._base_pm.size()
437
+ fx = float(state.get("fx", 0.5))
438
+ fy = float(state.get("fy", 0.5))
439
+
440
+ base_x = fx * pm.width()
441
+ base_y = fy * pm.height()
442
+
443
+ # base -> scaled label coords
444
+ lbl_x = int(base_x * self._zoom)
445
+ lbl_y = int(base_y * self._zoom)
446
+
447
+ vp = self.scroll.viewport()
448
+ anchor_vp = QPoint(vp.width() // 2, vp.height() // 2)
449
+
450
+ hbar = self.scroll.horizontalScrollBar()
451
+ vbar = self.scroll.verticalScrollBar()
452
+ hbar.setValue(max(hbar.minimum(), min(hbar.maximum(), lbl_x - anchor_vp.x())))
453
+ vbar.setValue(max(vbar.minimum(), min(vbar.maximum(), lbl_y - anchor_vp.y())))
454
+
455
+ # ---------- status helpers ----------
456
+ def _set_status_label(self, which: str, text: str | None):
457
+ lab = getattr(self, f"lbl_{which.lower()}")
458
+ if text:
459
+ lab.setText(text)
460
+ lab.setStyleSheet("color:#2a7; font-weight:600; margin-left:8px;")
461
+ else:
462
+ lab.setText(f"No {which} loaded.")
463
+ lab.setStyleSheet("color:#888; margin-left:8px;")
464
+
465
+ # ------------- load by view/file -------------
466
+ def _load_channel(self, which: str):
467
+ src, ok = QInputDialog.getItem(
468
+ self, f"Load {which}", "Source:", ["From View", "From File"], 0, False
469
+ )
470
+ if not ok:
471
+ return
472
+
473
+ if src == "From View":
474
+ out = self._load_from_view(which)
475
+ else:
476
+ out = self._load_from_file(which)
477
+ if out is None:
478
+ return
479
+
480
+ img, header, bit_depth, is_mono, path, label = out
481
+
482
+ # NB channels → mono; OSC → RGB
483
+ if which in ("Ha","OIII","SII"):
484
+ if img.ndim == 3:
485
+ img = img[:, :, 0]
486
+ else:
487
+ if img.ndim == 2:
488
+ img = np.stack([img]*3, axis=-1)
489
+
490
+ # store raw, normalized
491
+ setattr(self, which.lower(), self._as_float01(img))
492
+ self._set_status_label(which, label)
493
+ self.status.setText(f"{which} loaded ({'mono' if img.ndim==2 else 'RGB'}) shape={img.shape}")
494
+
495
+ # build/clear stretched cache for this input
496
+ self._cache_stretch(which)
497
+
498
+ if self.current_palette is None:
499
+ self.current_palette = "SHO"
500
+
501
+ def _load_from_view(self, which):
502
+ views = self._list_open_views()
503
+ if not views:
504
+ QMessageBox.warning(self, "No Views", "No open image views were found.")
505
+ return None
506
+
507
+ labels = [lab for lab, _ in views]
508
+ choice, ok = QInputDialog.getItem(
509
+ self, f"Select View for {which}", "Choose a view (by name):", labels, 0, False
510
+ )
511
+ if not ok or not choice:
512
+ return None
513
+
514
+ sw = dict(views)[choice]
515
+ doc = getattr(sw, "document", None)
516
+ if doc is None or getattr(doc, "image", None) is None:
517
+ QMessageBox.warning(self, "Empty View", "Selected view has no image.")
518
+ return None
519
+
520
+ img = doc.image
521
+ meta = getattr(doc, "metadata", {}) or {}
522
+ header = meta.get("original_header", None)
523
+ bit_depth = meta.get("bit_depth", "Unknown")
524
+ is_mono = (img.ndim == 2) or (img.ndim == 3 and img.shape[2] == 1)
525
+ path = meta.get("file_path", None)
526
+ return img, header, bit_depth, is_mono, path, f"From View: {choice}"
527
+
528
+ def _load_from_file(self, which):
529
+ filt = "Images (*.png *.tif *.tiff *.fits *.fit *.xisf)"
530
+ path, _ = QFileDialog.getOpenFileName(self, f"Select {which} File", "", filt)
531
+ if not path:
532
+ return None
533
+ img, header, bit_depth, is_mono = legacy_load_image(path)
534
+ if img is None:
535
+ QMessageBox.critical(self, "Load Error", f"Could not load {os.path.basename(path)}")
536
+ return None
537
+ label = f"From File: {os.path.basename(path)}"
538
+ return img, header, bit_depth, is_mono, path, label
539
+
540
+ def showEvent(self, e):
541
+ super().showEvent(e)
542
+ QTimer.singleShot(0, self._center_scrollbars)
543
+
544
+ # ------------- build/caches -------------
545
+ def _cache_stretch(self, which: str):
546
+ """Compute and cache stretched version of a just-loaded input (if linear checked)."""
547
+ arr = getattr(self, which.lower())
548
+ if arr is None:
549
+ self._stretched.pop(which, None); return
550
+ if not self.chk_linear.isChecked():
551
+ self._stretched.pop(which, None); return
552
+ self._stretched[which] = self._stretch_input(arr)
553
+
554
+ def _rebuild_stretch_cache_for_all(self, _state: int):
555
+ """Rebuild (or clear) stretched cache for all loaded inputs when checkbox toggles."""
556
+ for which in ("Ha","OIII","SII","OSC1","OSC2"):
557
+ self._cache_stretch(which)
558
+
559
+ def _render_thumb(self, name: str):
560
+ base = self._thumb_base_pm.get(name)
561
+ if base is None:
562
+ return
563
+ pm = base.copy()
564
+
565
+ p = QPainter(pm)
566
+ p.setRenderHint(QPainter.RenderHint.Antialiasing)
567
+
568
+ font = QFont("Helvetica", 10, QFont.Weight.DemiBold)
569
+ p.setFont(font)
570
+ fm = QFontMetrics(font)
571
+
572
+ pad = 6
573
+ strip_h = fm.height() + pad * 2
574
+ strip = pm.rect().adjusted(0, pm.height() - strip_h, 0, 0)
575
+
576
+ # translucent bottom strip
577
+ p.fillRect(strip, QColor(0, 0, 0, 160))
578
+ color = QColor(102, 255, 102) if self._selected_name == name else QColor(255, 255, 255)
579
+ p.setPen(QPen(color))
580
+ p.drawText(strip, Qt.AlignmentFlag.AlignCenter, name)
581
+ p.end()
582
+
583
+ btn = self._thumb_buttons[name]
584
+ btn.setIcon(QIcon(pm))
585
+ btn.setIconSize(self.thumb_size) # <- ensures no clipping
586
+
587
+ # ------------- thumbnails -------------
588
+ def _create_palettes(self):
589
+ """
590
+ Build the 12 palette thumbnails from a **center crop of the stretched inputs**
591
+ and draw the palette name directly on each thumbnail. Names turn green when selected.
592
+ """
593
+ ha, oo, si = self._prepared_channels(for_thumbs=True)
594
+ if oo is None or (ha is None and si is None):
595
+ QMessageBox.warning(self, "Need Channels", "Load at least OIII + (Ha or SII).")
596
+ return
597
+
598
+ built = 0
599
+ for name in self.PALETTES:
600
+ r, g, b = self._map_channels_or_special(name, ha, oo, si)
601
+ if any(ch is None for ch in (r, g, b)):
602
+ self._thumb_base_pm.pop(name, None)
603
+ self._thumb_buttons[name].setIcon(QIcon())
604
+ continue
605
+
606
+ r = np.clip(np.nan_to_num(r), 0, 1)
607
+ g = np.clip(np.nan_to_num(g), 0, 1)
608
+ b = np.clip(np.nan_to_num(b), 0, 1)
609
+ rgb = np.stack([r, g, b], axis=2).astype(np.float32)
610
+
611
+ # scale the thumbnail to EXACTLY the button icon size first
612
+ pm = QPixmap.fromImage(self._to_qimage(rgb)).scaled(
613
+ self.thumb_size, Qt.AspectRatioMode.KeepAspectRatio,
614
+ Qt.TransformationMode.SmoothTransformation
615
+ )
616
+ self._thumb_base_pm[name] = pm
617
+ self._render_thumb(name)
618
+ built += 1
619
+
620
+ self.status.setText(f"Created {built} palette previews.")
621
+
622
+
623
+ def _on_palette_clicked(self, name: str):
624
+ self._selected_name = name
625
+ for n in self.PALETTES:
626
+ self._render_thumb(n)
627
+ self.current_palette = name
628
+ self._generate_for_palette(name)
629
+
630
+ # ------------- palette build helpers -------------
631
+ def _center_crop(self, img: np.ndarray, side: int) -> np.ndarray:
632
+ """Center-crop to a square of size 'side' (no upscaling)."""
633
+ h, w = img.shape[:2]; s = min(side, h, w)
634
+ y0 = (h - s) // 2; x0 = (w - s) // 2
635
+ return img[y0:y0+s, x0:x0+s] if img.ndim == 2 else img[y0:y0+s, x0:x0+s, :]
636
+
637
+ def _center_crop_all_to_side(self, side: int, *imgs):
638
+ """Center-crop all provided images to the same square side (no upscaling)."""
639
+ s = None
640
+ for im in imgs:
641
+ if im is None: continue
642
+ h, w = im.shape[:2]
643
+ s = min(side, h, w) if s is None else min(s, h, w, side)
644
+ if s is None: s = side
645
+ return [self._center_crop(im, s) if im is not None else None for im in imgs], s
646
+
647
+ def _prepared_channels(self, for_thumbs: bool = False):
648
+ """
649
+ Build Ha/OIII/SII bases from inputs. If 'Linear input' is checked,
650
+ **use stretched versions** (cached). Then optionally center-crop for thumbnails.
651
+ """
652
+ # choose raw vs stretched
653
+ def pick(name):
654
+ if self.chk_linear.isChecked() and (name in self._stretched):
655
+ return self._stretched[name]
656
+ return getattr(self, name.lower())
657
+
658
+ ha = pick("Ha")
659
+ oo = pick("OIII")
660
+ si = pick("SII")
661
+ o1 = pick("OSC1")
662
+ o2 = pick("OSC2")
663
+
664
+ # synthesize from stretched OSC first (stretch-before-crop)
665
+ if o1 is not None: # OSC1: R≈Ha, mean(G,B)≈OIII
666
+ h1 = o1[..., 0]
667
+ g1b1 = o1[..., 1:3].mean(axis=2)
668
+ ha = h1 if ha is None else 0.5*ha + 0.5*h1
669
+ oo = g1b1 if oo is None else 0.5*oo + 0.5*g1b1
670
+
671
+ if o2 is not None: # OSC2: R≈SII, mean(G,B)≈OIII
672
+ s2 = o2[..., 0]
673
+ g2b2 = o2[..., 1:3].mean(axis=2)
674
+ si = s2 if si is None else 0.5*si + 0.5*s2
675
+ oo = g2b2 if oo is None else 0.5*oo + 0.5*g2b2
676
+
677
+ # shapes must match for full-size
678
+ shapes = [x.shape for x in (ha, oo, si) if x is not None]
679
+ if len(shapes) and len(set(shapes)) > 1 and not for_thumbs:
680
+ QMessageBox.critical(self, "Size Mismatch", f"Channel sizes differ: {set(shapes)}")
681
+ return None, None, None
682
+
683
+ # thumbnails: crop AFTER stretch/synth
684
+ if for_thumbs:
685
+ # choose a reference (prefer OIII, then Ha, then SII)
686
+ ref = oo if oo is not None else (ha if ha is not None else si)
687
+ if ref is not None:
688
+ ref_h, ref_w = ref.shape[:2]
689
+
690
+ # 1) first, size-match all channels to the reference full frame
691
+ ha = self._resize_to(ha, (ref_w, ref_h)) if ha is not None else None
692
+ oo = self._resize_to(oo, (ref_w, ref_h)) if oo is not None else None
693
+ si = self._resize_to(si, (ref_w, ref_h)) if si is not None else None
694
+
695
+ # 2) then, make a 50% view of the full rectangle
696
+ half_w = max(1, int(ref_w * 0.5))
697
+ half_h = max(1, int(ref_h * 0.5))
698
+ ha = self._resize_to(ha, (half_w, half_h)) if ha is not None else None
699
+ oo = self._resize_to(oo, (half_w, half_h)) if oo is not None else None
700
+ si = self._resize_to(si, (half_w, half_h)) if si is not None else None
701
+
702
+ return ha, oo, si
703
+
704
+ def _generate_for_palette(self, pal: str):
705
+ ha, oo, si = self._prepared_channels()
706
+ if oo is None or (ha is None and si is None):
707
+ return
708
+
709
+ r,g,b = self._map_channels_or_special(pal, ha, oo, si)
710
+ if any(ch is None for ch in (r,g,b)):
711
+ QMessageBox.critical(self, "Palette Error", f"Could not build palette {pal}."); return
712
+
713
+ r = np.clip(np.nan_to_num(r), 0, 1)
714
+ g = np.clip(np.nan_to_num(g), 0, 1)
715
+ b = np.clip(np.nan_to_num(b), 0, 1)
716
+ rgb = np.stack([r,g,b], axis=2).astype(np.float32)
717
+
718
+ mx = float(rgb.max()) or 1.0
719
+ self.final = (rgb / mx).astype(np.float32)
720
+
721
+ # Fit only when there wasn't an existing preview yet
722
+ first = (self._base_pm is None)
723
+ self._set_preview_image(self._to_qimage(self.final), fit=first, preserve_view=True)
724
+ self.status.setText(f"Preview generated: {pal}")
725
+
726
+ def _set_preview_image(self, qimg: QImage, *, fit: bool = False, preserve_view: bool = True):
727
+ state = None
728
+ if preserve_view and (not fit) and (self._base_pm is not None):
729
+ state = self._capture_view_state()
730
+
731
+ self._base_pm = QPixmap.fromImage(qimg)
732
+
733
+ # If we’re fitting, ignore old zoom/pan.
734
+ if fit or state is None:
735
+ self._zoom = 1.0
736
+ self._update_preview_pixmap()
737
+ if fit:
738
+ QTimer.singleShot(0, self._fit_to_preview)
739
+ else:
740
+ QTimer.singleShot(0, self._center_scrollbars)
741
+ return
742
+
743
+ # restore prior zoom/pan
744
+ self._restore_view_state(state)
745
+
746
+
747
+ def _update_preview_pixmap(self):
748
+ if self._base_pm is None:
749
+ return
750
+ # explicit int size (QSize * float can crash on some PyQt6 builds)
751
+ base_sz = self._base_pm.size()
752
+ w = max(1, int(base_sz.width() * self._zoom))
753
+ h = max(1, int(base_sz.height() * self._zoom))
754
+ scaled = self._base_pm.scaled(
755
+ w, h,
756
+ Qt.AspectRatioMode.KeepAspectRatio,
757
+ Qt.TransformationMode.SmoothTransformation
758
+ )
759
+ self.preview.setPixmap(scaled)
760
+ self.preview.resize(scaled.size())
761
+
762
+ def _set_zoom(self, new_zoom: float):
763
+ self._zoom = max(self._min_zoom, min(self._max_zoom, new_zoom))
764
+ self._update_preview_pixmap()
765
+
766
+ def _zoom_at(self, factor: float = 1.25, anchor_vp: QPoint | None = None):
767
+ if self._base_pm is None:
768
+ return
769
+
770
+ vp = self.scroll.viewport()
771
+ if anchor_vp is None:
772
+ anchor_vp = QPoint(vp.width() // 2, vp.height() // 2) # view center
773
+
774
+ # label coords under the anchor *before* zoom
775
+ lbl_before = self.preview.mapFrom(vp, anchor_vp)
776
+
777
+ old_zoom = self._zoom
778
+ new_zoom = max(self._min_zoom, min(self._max_zoom, old_zoom * factor))
779
+ ratio = new_zoom / max(old_zoom, 1e-6)
780
+ if abs(ratio - 1.0) < 1e-6:
781
+ return
782
+
783
+ # apply zoom (updates label size & scrollbar ranges)
784
+ self._zoom = new_zoom
785
+ self._update_preview_pixmap()
786
+
787
+ # desired label coords *after* zoom
788
+ lbl_after_x = int(lbl_before.x() * ratio)
789
+ lbl_after_y = int(lbl_before.y() * ratio)
790
+
791
+ # move scrollbars so anchor_vp keeps the same content point
792
+ hbar = self.scroll.horizontalScrollBar()
793
+ vbar = self.scroll.verticalScrollBar()
794
+ hbar.setValue(max(hbar.minimum(), min(hbar.maximum(), lbl_after_x - anchor_vp.x())))
795
+ vbar.setValue(max(vbar.minimum(), min(vbar.maximum(), lbl_after_y - anchor_vp.y())))
796
+
797
+
798
+ def _fit_to_preview(self):
799
+ if self._base_pm is None:
800
+ return
801
+ vp = self.scroll.viewport().size()
802
+ pm = self._base_pm.size()
803
+ if pm.width() == 0 or pm.height() == 0:
804
+ return
805
+ k = min(vp.width() / pm.width(), vp.height() / pm.height())
806
+ self._set_zoom(max(self._min_zoom, min(self._max_zoom, k)))
807
+ self._center_scrollbars()
808
+
809
+ def _center_scrollbars(self):
810
+ # center the view on the image
811
+ h = self.scroll.horizontalScrollBar()
812
+ v = self.scroll.verticalScrollBar()
813
+ h.setValue((h.maximum() + h.minimum()) // 2)
814
+ v.setValue((v.maximum() + v.minimum()) // 2)
815
+
816
+ def _map_channels_or_special(self, name, ha, oo, si):
817
+ # substitution
818
+ if ha is None and si is not None: ha = si
819
+ if si is None and ha is not None: si = ha
820
+
821
+ basic = {
822
+ "SHO": (si, ha, oo),
823
+ "HOO": (ha, oo, oo),
824
+ "HSO": (ha, si, oo),
825
+ "HOS": (ha, oo, si),
826
+ "OSS": (oo, si, si),
827
+ "OHH": (oo, ha, ha),
828
+ "OSH": (oo, si, ha),
829
+ "OHS": (oo, ha, si),
830
+ "HSS": (ha, si, si),
831
+ }
832
+ if name in basic:
833
+ return basic[name]
834
+
835
+ try:
836
+ if name == "Realistic1":
837
+ r = (ha + si)/2 if (ha is not None and si is not None) else (ha if ha is not None else 0)
838
+ g = 0.3*(ha if ha is not None else 0) + 0.7*(oo if oo is not None else 0)
839
+ b = 0.9*(oo if oo is not None else 0) + 0.1*(ha if ha is not None else 0)
840
+ return r,g,b
841
+ if name == "Realistic2":
842
+ r = 0.7*(ha if ha is not None else 0) + 0.3*(si if si is not None else 0)
843
+ g = 0.3*(si if si is not None else 0) + 0.7*(oo if oo is not None else 0)
844
+ b = (oo if oo is not None else 0)
845
+ return r,g,b
846
+ if name == "Foraxx":
847
+ if ha is not None and oo is not None and si is None:
848
+ r = ha; b = oo
849
+ t = ha * oo
850
+ g = (t**(1 - t))*ha + (1 - (t**(1 - t)))*oo
851
+ return r,g,b
852
+ if ha is not None and oo is not None and si is not None:
853
+ t = np.clip(oo, 1e-6, 1.0)**(1 - np.clip(oo, 1e-6, 1.0))
854
+ r = t*si + (1 - t)*ha
855
+ t2 = ha * oo
856
+ g = (t2**(1 - t2))*ha + (1 - (t2**(1 - t2)))*oo
857
+ b = oo
858
+ return r,g,b
859
+ return basic["SHO"]
860
+ except Exception:
861
+ return basic.get("SHO", (ha, oo, si))
862
+
863
+ return basic.get("SHO", (ha, oo, si))
864
+
865
+ # ------------- push to new subwindow -------------
866
+ # ------------- push to new subwindow -------------
867
+ def _get_doc_manager(self):
868
+ """
869
+ Try several ways to get a DocManager:
870
+ 1) explicit doc_manager passed into PerfectPalettePicker
871
+ 2) main window's .docman or .doc_manager attribute
872
+ """
873
+ if self.doc_manager is not None:
874
+ return self.doc_manager
875
+
876
+ mw = self._find_main_window()
877
+ if mw is None:
878
+ return None
879
+
880
+ return getattr(mw, "docman", None) or getattr(mw, "doc_manager", None)
881
+
882
+ def _push_final(self):
883
+ if self.final is None:
884
+ QMessageBox.warning(self, "No Image", "Generate a palette first.")
885
+ return
886
+
887
+ # Use the SAME prepared channels the palette was built with
888
+ ha_prep, oo_prep, si_prep = self._prepared_channels()
889
+ if oo_prep is None or (ha_prep is None and si_prep is None):
890
+ QMessageBox.warning(self, "Need Channels", "Load at least OIII + (Ha or SII).")
891
+ return
892
+
893
+ dlg = PaletteAdjustDialog(
894
+ base_rgb = self.final, # fully formed palette
895
+ palette_name = self.current_palette or "SHO",
896
+ ha_src = ha_prep, # prepared (stretched/OSC-synth)
897
+ oiii_src = oo_prep,
898
+ sii_src = si_prep,
899
+ owner = self
900
+ )
901
+ adjusted = {"img": None}
902
+ dlg.adjusted_image.connect(lambda img: adjusted.__setitem__("img", img))
903
+ dlg.exec()
904
+
905
+ if adjusted["img"] is None:
906
+ return # user canceled
907
+
908
+ # Update preview with adjusted result and set as final
909
+ self.final = adjusted["img"]
910
+ self._set_preview_image(self._to_qimage(self.final))
911
+
912
+ title = self.current_palette or "Palette"
913
+
914
+ # ---- get DocManager the robust way ----
915
+ dm = self._get_doc_manager()
916
+
917
+ if dm is None:
918
+ # Fallback: open a simple viewer instead of erroring out
919
+ viewer = QDialog(self)
920
+ viewer.setWindowTitle(title)
921
+ vlayout = QVBoxLayout(viewer)
922
+ lbl = QLabel()
923
+ lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
924
+ lbl.setPixmap(QPixmap.fromImage(self._to_qimage(self.final)))
925
+ vlayout.addWidget(lbl)
926
+ viewer.resize(lbl.pixmap().size())
927
+ viewer.show()
928
+ # keep ref so it isn't GC'd
929
+ self._last_popup_viewer = viewer
930
+ self.status.setText("DocManager not found; opened palette in stand-alone viewer.")
931
+ return
932
+
933
+ # ---- normal SAS path: create a new document ----
934
+ try:
935
+ if hasattr(dm, "open_array"):
936
+ # many of your tools already use this signature
937
+ doc = dm.open_array(self.final, metadata={"is_mono": False}, title=title)
938
+ elif hasattr(dm, "create_document"):
939
+ doc = dm.create_document(image=self.final, metadata={"is_mono": False}, name=title)
940
+ else:
941
+ raise RuntimeError("DocManager lacks open_array/create_document")
942
+
943
+ # If DocManager or main window auto-spawns subwindows on new docs,
944
+ # this is all we need. If not, you can optionally keep the
945
+ # _spawn_subwindow_for hook here.
946
+ self.status.setText("Opened final palette in a new view.")
947
+ except Exception as e:
948
+ QMessageBox.critical(self, "Error", f"Failed to open new view:\n{e}")
949
+
950
+
951
+
952
+ # ------------- utilities -------------
953
+ def _clear_channels(self):
954
+ self.ha = self.oiii = self.sii = self.osc1 = self.osc2 = None
955
+ self._stretched.clear()
956
+ self.final = None
957
+ self.preview.clear()
958
+ for which in ("Ha","OIII","SII","OSC1","OSC2"):
959
+ self._set_status_label(which, None)
960
+ for name, b in self._thumb_buttons.items():
961
+ b.setIcon(QIcon())
962
+ self._thumb_base_pm.clear()
963
+ self._selected_name = None
964
+ for b in self._thumb_buttons.values():
965
+ b.setIcon(QIcon())
966
+ self.status.setText("Cleared all loaded channels.")
967
+
968
+ def _as_float01(self, arr):
969
+ a = np.asarray(arr)
970
+ if a.dtype == np.uint8: return a.astype(np.float32)/255.0
971
+ if a.dtype == np.uint16: return a.astype(np.float32)/65535.0
972
+ return np.clip(a.astype(np.float32), 0.0, 1.0)
973
+
974
+ def _stretch_input(self, img):
975
+ """Run statistical stretch on mono or color inputs (target_median=0.25)."""
976
+ if img.ndim == 2:
977
+ return np.clip(stretch_mono_image(img, target_median=0.25), 0.0, 1.0)
978
+ if img.ndim == 3 and img.shape[2] == 3:
979
+ return np.clip(stretch_color_image(img, target_median=0.25, linked=False), 0.0, 1.0)
980
+ if img.ndim == 3 and img.shape[2] == 1:
981
+ mono = img[...,0]
982
+ return np.clip(stretch_mono_image(mono, target_median=0.25), 0.0, 1.0)
983
+ return img
984
+
985
+ def _to_qimage(self, arr):
986
+ a = np.clip(arr, 0, 1)
987
+ if a.ndim == 2:
988
+ u = (a * 255).astype(np.uint8); h, w = u.shape
989
+ return QImage(u.data, w, h, w, QImage.Format.Format_Grayscale8).copy()
990
+ if a.ndim == 3 and a.shape[2] == 3:
991
+ u = (a * 255).astype(np.uint8); h, w, _ = u.shape
992
+ return QImage(u.data, w, h, w*3, QImage.Format.Format_RGB888).copy()
993
+ raise ValueError(f"Unexpected image shape: {a.shape}")
994
+
995
+ def _find_main_window(self):
996
+ w = self
997
+ from PyQt6.QtWidgets import QMainWindow, QApplication
998
+ while w is not None and not isinstance(w, QMainWindow):
999
+ w = w.parentWidget()
1000
+ if w: return w
1001
+ for tlw in QApplication.topLevelWidgets():
1002
+ if isinstance(tlw, QMainWindow):
1003
+ return tlw
1004
+ return None
1005
+
1006
+ def _list_open_views(self):
1007
+ mw = self._find_main_window()
1008
+ if not mw:
1009
+ return []
1010
+ try:
1011
+ from setiastro.saspro.subwindow import ImageSubWindow
1012
+ subs = mw.findChildren(ImageSubWindow)
1013
+ except Exception:
1014
+ subs = []
1015
+ out = []
1016
+ for sw in subs:
1017
+ title = getattr(sw, "view_title", None) or sw.windowTitle() or getattr(sw.document, "display_name", lambda: "Untitled")()
1018
+ out.append((str(title), sw))
1019
+ return out
1020
+
1021
+ def eventFilter(self, obj, ev):
1022
+ # Ctrl+wheel = zoom at mouse (no scrolling). Wheel without Ctrl = eaten.
1023
+ if ev.type() == QEvent.Type.Wheel and (
1024
+ obj is self.preview
1025
+ or obj is self.scroll
1026
+ or obj is self.scroll.viewport()
1027
+ or obj is self.scroll.horizontalScrollBar()
1028
+ or obj is self.scroll.verticalScrollBar()
1029
+ ):
1030
+ # always stop the wheel from scrolling
1031
+ ev.accept()
1032
+
1033
+ # Zoom only when Ctrl is held
1034
+ if ev.modifiers() & Qt.KeyboardModifier.ControlModifier:
1035
+ factor = 1.25 if ev.angleDelta().y() > 0 else 0.8
1036
+
1037
+ # Get mouse position in global screen coords and map into the viewport
1038
+ vp = self.scroll.viewport()
1039
+ anchor_vp = vp.mapFromGlobal(ev.globalPosition().toPoint())
1040
+
1041
+ # Clamp to viewport rect (robust if the event originated on scrollbars)
1042
+ r = vp.rect()
1043
+ if not r.contains(anchor_vp):
1044
+ anchor_vp.setX(max(r.left(), min(r.right(), anchor_vp.x())))
1045
+ anchor_vp.setY(max(r.top(), min(r.bottom(), anchor_vp.y())))
1046
+
1047
+ self._zoom_at(factor, anchor_vp)
1048
+ return True
1049
+ # click-drag pan on viewport
1050
+ if obj is self.scroll.viewport():
1051
+ if ev.type() == QEvent.Type.MouseButtonPress and ev.button() == Qt.MouseButton.LeftButton:
1052
+ self._panning = True
1053
+ self._pan_last = ev.position().toPoint()
1054
+ self.scroll.viewport().setCursor(QCursor(Qt.CursorShape.ClosedHandCursor))
1055
+ return True
1056
+ if ev.type() == QEvent.Type.MouseMove and self._panning:
1057
+ cur = ev.position().toPoint()
1058
+ delta = cur - (self._pan_last or cur)
1059
+ self._pan_last = cur
1060
+ h = self.scroll.horizontalScrollBar()
1061
+ v = self.scroll.verticalScrollBar()
1062
+ h.setValue(h.value() - delta.x())
1063
+ v.setValue(v.value() - delta.y())
1064
+ return True
1065
+ if ev.type() == QEvent.Type.MouseButtonRelease and ev.button() == Qt.MouseButton.LeftButton:
1066
+ self._panning = False
1067
+ self._pan_last = None
1068
+ self.scroll.viewport().setCursor(QCursor(Qt.CursorShape.ArrowCursor))
1069
+ return True
1070
+
1071
+ return super().eventFilter(obj, ev)