setiastrosuitepro 1.6.1__py3-none-any.whl

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

Potentially problematic release.


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

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