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,1604 @@
1
+ # pro/pixelmath.py
2
+ from __future__ import annotations
3
+ import os
4
+ import re
5
+ import json
6
+ import numpy as np
7
+
8
+ from PyQt6.QtCore import Qt, QTimer, QPointF
9
+ from PyQt6.QtGui import QIcon, QCursor, QImage, QPixmap, QTransform, QActionGroup
10
+ from PyQt6.QtWidgets import (
11
+ QDialog, QVBoxLayout, QHBoxLayout, QGroupBox, QGridLayout, QLabel,
12
+ QPushButton, QPlainTextEdit, QComboBox, QDialogButtonBox, QRadioButton, QApplication, QSplitter,
13
+ QTabWidget, QWidget, QMessageBox, QMenu, QScrollArea, QButtonGroup, QListWidget, QListWidgetItem, QGraphicsView, QGraphicsScene, QGraphicsPixmapItem, QToolButton
14
+ )
15
+
16
+ from setiastro.saspro.autostretch import autostretch
17
+
18
+ # Import shared utilities
19
+ from setiastro.saspro.widgets.image_utils import nearest_resize_2d as _nearest_resize_2d
20
+ from setiastro.saspro.widgets.image_utils import float_to_qimage_rgb8 as _float_to_qimage_rgb8
21
+ from setiastro.saspro.widgets.themed_buttons import themed_toolbtn
22
+
23
+ # ---- Optional accelerators from setiastro.saspro.legacy.numba_utils -------------------------
24
+ try:
25
+ from setiastro.saspro.legacy.numba_utils import fast_mad as _fast_mad
26
+ except Exception:
27
+ _fast_mad = None
28
+
29
+ # _float_to_qimage_rgb8 imported from setiastro.saspro.widgets.image_utils
30
+
31
+
32
+ # =============================================================================
33
+ # PixelImage wrapper (vector ops, indexing, ^ as exponent, ~ as invert)
34
+ # =============================================================================
35
+ class PixelImage:
36
+ """
37
+ Lightweight wrapper to enable intuitive pixel math:
38
+ • Supports per-channel indexing: img[0], img[1], img[2] → (H,W) planes
39
+ • Broadcasts (H,W) ⇄ (H,W,3) for +,-,*,/, power, and comparisons
40
+ • ~img means (1 - img)
41
+ """
42
+ __array_priority__ = 10_000 # ensure numpy uses our dunder ops
43
+
44
+ def __init__(self, array: np.ndarray):
45
+ self.array = np.asarray(array, dtype=np.float32)
46
+
47
+ # ---- channel indexing ----
48
+ def __getitem__(self, ch):
49
+ a = self.array
50
+ if a.ndim < 3:
51
+ raise ValueError("This image has no channel dimension to index.")
52
+ if not (0 <= ch < a.shape[2]):
53
+ raise IndexError(f"Channel index {ch} out of range for shape {a.shape}")
54
+ return PixelImage(a[..., ch])
55
+
56
+ # ---- shape coercion (H,W) ⇄ (H,W,3) ----
57
+ @staticmethod
58
+ def _coerce(a, b):
59
+ a = np.asarray(a, dtype=np.float32)
60
+ b = np.asarray(b, dtype=np.float32)
61
+ if a.ndim == 3 and b.ndim == 2:
62
+ # Broadcast b to (H,W,1) virtual view; numpy ufuncs handle (H,W,3) vs (H,W,1) automatically
63
+ b = b[..., None]
64
+ elif a.ndim == 2 and b.ndim == 3:
65
+ a = a[..., None]
66
+ return a, b
67
+
68
+ # ---- binary arithmetic helpers ----
69
+ def _bin(self, other, op):
70
+ a = self.array
71
+ b = other.array if isinstance(other, PixelImage) else other
72
+ a, b = self._coerce(a, b)
73
+ return PixelImage(op(a, b))
74
+
75
+ # ---- comparisons with coercion (return ndarray masks) ----
76
+ def _cmp(self, other, op):
77
+ a = self.array
78
+ b = other.array if isinstance(other, PixelImage) else other
79
+ a, b = self._coerce(a, b)
80
+ return op(a, b)
81
+
82
+ # ---- arithmetic ----
83
+ __add__ = lambda self, o: self._bin(o, np.add)
84
+ __radd__ = __add__
85
+ __sub__ = lambda self, o: self._bin(o, np.subtract)
86
+ __mul__ = lambda self, o: self._bin(o, np.multiply)
87
+ __rmul__ = __mul__
88
+ __truediv__ = lambda self, o: self._bin(o, np.divide)
89
+
90
+ def __rsub__(self, o):
91
+ a, b = self._coerce(o.array if isinstance(o, PixelImage) else o, self.array)
92
+ return PixelImage(np.subtract(a, b))
93
+
94
+ def __rtruediv__(self, o):
95
+ a, b = self._coerce(o.array if isinstance(o, PixelImage) else o, self.array)
96
+ return PixelImage(np.divide(a, b))
97
+
98
+ # power ** and ^
99
+ def __pow__(self, o):
100
+ a = self.array; b = o.array if isinstance(o, PixelImage) else o
101
+ a, b = self._coerce(a, b)
102
+ return PixelImage(np.power(a, b))
103
+
104
+ def __rpow__(self, o):
105
+ a = o.array if isinstance(o, PixelImage) else o; b = self.array
106
+ a, b = self._coerce(a, b)
107
+ return PixelImage(np.power(a, b))
108
+
109
+ # keep ^ as alias for power for convenience
110
+ def __xor__(self, o):
111
+ return self.__pow__(o)
112
+
113
+ def __rxor__(self, o):
114
+ return self.__rpow__(o)
115
+
116
+ # invert (~img) → 1 - img
117
+ def __invert__(self):
118
+ return PixelImage(1.0 - self.array)
119
+
120
+ # ---- comparisons (return boolean ndarray) ----
121
+ __lt__ = lambda self, o: self._cmp(o, np.less)
122
+ __le__ = lambda self, o: self._cmp(o, np.less_equal)
123
+ __eq__ = lambda self, o: self._cmp(o, np.equal)
124
+ __ne__ = lambda self, o: self._cmp(o, np.not_equal)
125
+ __gt__ = lambda self, o: self._cmp(o, np.greater)
126
+ __ge__ = lambda self, o: self._cmp(o, np.greater_equal)
127
+
128
+ def __repr__(self):
129
+ return f"PixelImage(shape={self.array.shape}, dtype={self.array.dtype})"
130
+
131
+
132
+
133
+ # =============================================================================
134
+ # Helpers
135
+ # =============================================================================
136
+ _ID_RX = re.compile(r'[^0-9a-zA-Z_]+')
137
+ def _sanitize_ident(name: str) -> str:
138
+ s = _ID_RX.sub('_', str(name)).strip('_')
139
+ if not s: s = "view"
140
+ if s[0].isdigit(): s = "_" + s
141
+ return s
142
+
143
+ def _as_rgb(arr: np.ndarray) -> np.ndarray:
144
+ a = np.asarray(arr, dtype=np.float32)
145
+ a = np.clip(a, 0.0, 1.0)
146
+ if a.ndim == 2:
147
+ a = np.repeat(a[..., None], 3, axis=2)
148
+ elif a.ndim == 3 and a.shape[2] == 1:
149
+ a = np.repeat(a, 3, axis=2)
150
+ return a
151
+
152
+ # _nearest_resize_2d imported from setiastro.saspro.widgets.image_utils
153
+
154
+ def _get_doc_active_mask_2d(doc, H: int, W: int) -> np.ndarray | None:
155
+ """
156
+ Returns the active mask as a 2-D float32 array in [0..1], resized to (H,W).
157
+ """
158
+ if doc is None:
159
+ return None
160
+ mid = getattr(doc, "active_mask_id", None)
161
+ if not mid:
162
+ return None
163
+ masks = getattr(doc, "masks", {}) or {}
164
+ layer = masks.get(mid)
165
+ if layer is None:
166
+ return None
167
+
168
+ # Extract data robustly without using `or` on arrays
169
+ data = None
170
+ # object-style
171
+ for attr in ("data", "mask", "image", "array"):
172
+ if hasattr(layer, attr):
173
+ val = getattr(layer, attr)
174
+ if val is not None:
175
+ data = val
176
+ break
177
+ # dict-style
178
+ if data is None and isinstance(layer, dict):
179
+ for key in ("data", "mask", "image", "array"):
180
+ if key in layer and layer[key] is not None:
181
+ data = layer[key]
182
+ break
183
+ # ndarray
184
+ if data is None and isinstance(layer, np.ndarray):
185
+ data = layer
186
+ if data is None:
187
+ return None
188
+
189
+ m = np.asarray(data)
190
+ if m.ndim == 3: # collapse RGB(A) → gray
191
+ m = m.mean(axis=2)
192
+ m = m.astype(np.float32, copy=False)
193
+
194
+ # normalize to [0..1]
195
+ if m.max(initial=0.0) > 1.0:
196
+ m /= float(m.max())
197
+
198
+ m = np.clip(m, 0.0, 1.0)
199
+ return _nearest_resize_2d(m, H, W)
200
+
201
+ def _mask_for_ref(doc, ref_like: np.ndarray) -> np.ndarray | None:
202
+ """
203
+ Returns a mask shaped for `ref_like`:
204
+ - 2-D for mono ref
205
+ - H×W×C (broadcast) for color ref
206
+ """
207
+ ref = np.asarray(ref_like)
208
+ H, W = ref.shape[:2]
209
+ m2d = _get_doc_active_mask_2d(doc, H, W)
210
+ if m2d is None:
211
+ return None
212
+ if ref.ndim == 3:
213
+ return np.repeat(m2d[:, :, None], ref.shape[2], axis=2)
214
+ return m2d
215
+
216
+ def _blend_masked(base: np.ndarray, out: np.ndarray, m: np.ndarray) -> np.ndarray:
217
+ base = _as_rgb(base) # (H,W,3)
218
+ out = _as_rgb(out) # (H,W,3)
219
+ m = np.asarray(m, dtype=np.float32)
220
+ m = np.clip(m, 0.0, 1.0)
221
+
222
+ # Allow 2-D or 3-D masks
223
+ if m.ndim == 2:
224
+ m = m[..., None] # (H,W,1)
225
+ elif m.ndim == 3 and m.shape[2] not in (1, 3):
226
+ raise ValueError("Mask must be 2-D or have 1 or 3 channels.")
227
+
228
+ return np.clip(base * (1.0 - m) + out * m, 0.0, 1.0)
229
+
230
+
231
+ # =============================================================================
232
+ # Headless apply
233
+ # =============================================================================
234
+ def apply_pixel_math_to_doc(parent, doc, preset: dict | None):
235
+ if doc is None or getattr(doc, "image", None) is None:
236
+ raise RuntimeError("Document has no image.")
237
+ expr = (preset or {}).get("expr", "").strip()
238
+ ev = _Evaluator(parent, doc)
239
+ if expr:
240
+ out = ev.eval_single(expr)
241
+ else:
242
+ r = (preset or {}).get("expr_r", "").strip()
243
+ g = (preset or {}).get("expr_g", "").strip()
244
+ b = (preset or {}).get("expr_b", "").strip()
245
+ if not (r or g or b):
246
+ raise RuntimeError("Pixel Math preset empty.")
247
+ out = ev.eval_rgb(r, g, b, default_channels=(0, 1, 2))
248
+
249
+ out = np.clip(out, 0.0, 1.0).astype(np.float32, copy=False)
250
+ if hasattr(doc, "set_image"):
251
+ doc.set_image(out, step_name="Pixel Math")
252
+ elif hasattr(doc, "apply_numpy"):
253
+ doc.apply_numpy(out, step_name="Pixel Math")
254
+ else:
255
+ doc.image = out
256
+
257
+ # =============================================================================
258
+ # Evaluator
259
+ # =============================================================================
260
+ class _Evaluator:
261
+ def __init__(self, parent, doc):
262
+ self.parent = parent
263
+ self.doc = doc
264
+ self._build_namespace()
265
+
266
+ def _build_namespace(self):
267
+ self.ns = {
268
+ "np": np,
269
+ # existing:
270
+ "med": self._med, "mean": self._mean, "min": self._min, "max": self._max,
271
+ "std": self._std, "mad": self._mad, "log": self._log, "iff": self._iff, "mtf": self._mtf,
272
+ # new math helpers:
273
+ "clamp": self._clamp,
274
+ "rescale": self._rescale,
275
+ "gamma": self._gamma,
276
+ "pow_safe": self._pow_safe,
277
+ "absf": self._absf,
278
+ "expf": self._expf,
279
+ "sqrtf": self._sqrtf,
280
+ "arcsin": self._arcsin,
281
+ "sigmoid": self._sigmoid,
282
+ "smoothstep": self._smoothstep,
283
+ "lerp": self._lerp, "mix": self._lerp,
284
+ # stats / normalization:
285
+ "percentile": self._percentile,
286
+ "normalize01": self._normalize01,
287
+ "zscore": self._zscore,
288
+ # channels & color:
289
+ "ch": self._ch,
290
+ "luma": self._luma,
291
+ "compose": self._compose,
292
+ # mask helpers:
293
+ "mask": self._mask_fn,
294
+ "apply_mask": self._apply_mask_fn,
295
+ # optional filters (cv2-backed):
296
+ "boxblur": self._boxblur,
297
+ "gauss": self._gauss,
298
+ "median": self._median,
299
+ "unsharp": self._unsharp,
300
+ # constants:
301
+ "pi": float(np.pi), "e": float(np.e), "EPS": 1e-8,
302
+ }
303
+
304
+ cur = np.asarray(self.doc.image, dtype=np.float32)
305
+ self._img_shape = cur.shape
306
+ self.ns["img"] = PixelImage(_as_rgb(cur))
307
+
308
+ H, W = cur.shape[:2]
309
+ C = 1 if cur.ndim == 2 else cur.shape[2]
310
+ self.ns["H"], self.ns["W"], self.ns["C"] = int(H), int(W), int(C)
311
+ self.ns["shape"] = (int(H), int(W), int(C))
312
+
313
+ # Normalized coordinate grids (2-D, float32)
314
+ xx = np.linspace(0.0, 1.0, W, dtype=np.float32)
315
+ yy = np.linspace(0.0, 1.0, H, dtype=np.float32)
316
+ X, Y = np.meshgrid(xx, yy)
317
+ self.ns["X"] = X
318
+ self.ns["Y"] = Y
319
+
320
+ # map: raw title → ident (existing)
321
+ self.title_map = []
322
+ open_docs = []
323
+ if hasattr(self.parent, "_subwindow_docs"):
324
+ open_docs = list(self.parent._subwindow_docs())
325
+ else:
326
+ open_docs = [(getattr(self.doc, "display_name", lambda: "view")(), self.doc)]
327
+
328
+ used = set(self.ns.keys())
329
+ for raw_title, d in open_docs:
330
+ ident = _sanitize_ident(raw_title or "view")
331
+ base, i = ident, 2
332
+ while ident in used:
333
+ ident = f"{base}_{i}"; i += 1
334
+ used.add(ident)
335
+ arr = getattr(d, "image", None)
336
+ if arr is None:
337
+ continue
338
+ self.ns[ident] = PixelImage(np.asarray(arr, dtype=np.float32)) # keep native 2D/3D
339
+ self.title_map.append((str(raw_title), ident))
340
+
341
+ # -------- expression rewriting: allow raw window titles in user code
342
+ def _rewrite_names(self, expr: str) -> str:
343
+ if not expr: return expr
344
+ out = expr
345
+ for raw, ident in self.title_map:
346
+ # raw title
347
+ pat = re.compile(rf'(?<![\w]){re.escape(raw)}(?![\w])')
348
+ out = pat.sub(ident, out)
349
+ # basename without extension
350
+ base = os.path.splitext(raw)[0]
351
+ if base and base != raw:
352
+ pat2 = re.compile(rf'(?<![\w]){re.escape(base)}(?![\w])')
353
+ out = pat2.sub(ident, out)
354
+ return out
355
+
356
+ # -------- functions
357
+ def _med(self, x):
358
+ a = x.array if isinstance(x, PixelImage) else np.asarray(x)
359
+ if a.ndim == 2:
360
+ v = np.median(a); out = np.full_like(a, v)
361
+ else:
362
+ v = np.median(a, axis=(0, 1)); out = np.tile(v, (*a.shape[:2], 1))
363
+ return PixelImage(out) if isinstance(x, PixelImage) else out
364
+
365
+ def _mean(self, x):
366
+ a = x.array if isinstance(x, PixelImage) else np.asarray(x)
367
+ if a.ndim == 2:
368
+ v = np.mean(a); out = np.full_like(a, v)
369
+ else:
370
+ v = np.mean(a, axis=(0, 1)); out = np.tile(v, (*a.shape[:2], 1))
371
+ return PixelImage(out) if isinstance(x, PixelImage) else out
372
+
373
+ def _min(self, x):
374
+ a = x.array if isinstance(x, PixelImage) else np.asarray(x)
375
+ if a.ndim == 2:
376
+ v = np.min(a); out = np.full_like(a, v)
377
+ else:
378
+ v = np.min(a, axis=(0, 1)); out = np.tile(v, (*a.shape[:2], 1))
379
+ return PixelImage(out) if isinstance(x, PixelImage) else out
380
+
381
+ def _max(self, x):
382
+ a = x.array if isinstance(x, PixelImage) else np.asarray(x)
383
+ if a.ndim == 2:
384
+ v = np.max(a); out = np.full_like(a, v)
385
+ else:
386
+ v = np.max(a, axis=(0, 1)); out = np.tile(v, (*a.shape[:2], 1))
387
+ return PixelImage(out) if isinstance(x, PixelImage) else out
388
+
389
+ def _std(self, x):
390
+ a = x.array if isinstance(x, PixelImage) else np.asarray(x)
391
+ if a.ndim == 2:
392
+ v = np.std(a); out = np.full_like(a, v)
393
+ else:
394
+ v = np.std(a, axis=(0, 1)); out = np.tile(v, (*a.shape[:2], 1))
395
+ return PixelImage(out) if isinstance(x, PixelImage) else out
396
+
397
+ def _mad(self, x):
398
+ a = x.array if isinstance(x, PixelImage) else np.asarray(x)
399
+ if a.ndim == 2:
400
+ if _fast_mad is not None:
401
+ v = float(_fast_mad(a))
402
+ else:
403
+ m = np.median(a); v = np.median(np.abs(a - m))
404
+ out = np.full_like(a, v)
405
+ else:
406
+ out = np.empty_like(a)
407
+ for c in range(a.shape[2]):
408
+ ch = a[..., c]
409
+ if _fast_mad is not None:
410
+ v = float(_fast_mad(ch))
411
+ else:
412
+ m = np.median(ch); v = np.median(np.abs(ch - m))
413
+ out[..., c] = v
414
+ return PixelImage(out) if isinstance(x, PixelImage) else out
415
+
416
+ def _log(self, x):
417
+ a = x.array if isinstance(x, PixelImage) else np.asarray(x)
418
+ with np.errstate(divide='ignore', invalid='ignore'):
419
+ y = np.log(np.clip(a, 1e-12, None))
420
+ return PixelImage(y) if isinstance(x, PixelImage) else y
421
+
422
+ def _iff(self, cond, a, b):
423
+ c = cond.array if isinstance(cond, PixelImage) else cond
424
+ av = a.array if isinstance(a, PixelImage) else a
425
+ bv = b.array if isinstance(b, PixelImage) else b
426
+ r = np.where(c, av, bv)
427
+ return PixelImage(r) if any(isinstance(z, PixelImage) for z in (cond, a, b)) else r
428
+
429
+ def _mtf(self, x, m):
430
+ a = x.array if isinstance(x, PixelImage) else np.asarray(x)
431
+ with np.errstate(divide='ignore', invalid='ignore'):
432
+ y = ((m - 1.0) * a) / (((2.0 * m - 1.0) * a) - m)
433
+ y = np.nan_to_num(y, nan=0.0, posinf=1.0, neginf=0.0)
434
+ return PixelImage(y) if isinstance(x, PixelImage) else y
435
+
436
+ # ---- math helpers ----
437
+ def _clamp(self, x, lo=0.0, hi=1.0):
438
+ a = x.array if isinstance(x, PixelImage) else np.asarray(x, dtype=np.float32)
439
+ y = np.clip(a, float(lo), float(hi))
440
+ return PixelImage(y) if isinstance(x, PixelImage) else y
441
+
442
+ def _rescale(self, x, a, b, lo=0.0, hi=1.0):
443
+ a = np.asarray(x.array if isinstance(x, PixelImage) else x, dtype=np.float32)
444
+ src_lo, src_hi = float(a.min()), float(a.max())
445
+ if np.isfinite(a).any():
446
+ src_lo, src_hi = float(a), float(b)
447
+ # avoid div-by-zero
448
+ denom = max(src_hi - src_lo, 1e-12)
449
+ y = (a - src_lo) / denom
450
+ y = y * (hi - lo) + lo
451
+ return PixelImage(y) if isinstance(x, PixelImage) else y
452
+
453
+ def _gamma(self, x, g):
454
+ a = x.array if isinstance(x, PixelImage) else np.asarray(x, dtype=np.float32)
455
+ y = np.power(np.clip(a, 0.0, 1.0), float(g))
456
+ return PixelImage(y) if isinstance(x, PixelImage) else y
457
+
458
+ def _pow_safe(self, x, p):
459
+ a = x.array if isinstance(x, PixelImage) else np.asarray(x, dtype=np.float32)
460
+ y = np.power(np.clip(a, 1e-8, None), float(p))
461
+ return PixelImage(y) if isinstance(x, PixelImage) else y
462
+
463
+ def _absf(self, x):
464
+ a = x.array if isinstance(x, PixelImage) else np.asarray(x, dtype=np.float32)
465
+ y = np.abs(a)
466
+ return PixelImage(y) if isinstance(x, PixelImage) else y
467
+
468
+ def _expf(self, x):
469
+ a = x.array if isinstance(x, PixelImage) else np.asarray(x, dtype=np.float32)
470
+ y = np.exp(a)
471
+ return PixelImage(y) if isinstance(x, PixelImage) else y
472
+
473
+ def _sqrtf(self, x):
474
+ a = x.array if isinstance(x, PixelImage) else np.asarray(x, dtype=np.float32)
475
+ y = np.sqrt(np.clip(a, 0.0, None))
476
+ return PixelImage(y) if isinstance(x, PixelImage) else y
477
+
478
+ def _arcsin(self, x):
479
+ a = x.array if isinstance(x, PixelImage) else np.asarray(x, dtype=np.float32)
480
+ y = np.arcsin(np.clip(a, -1.0, 1.0))
481
+ return PixelImage(y) if isinstance(x, PixelImage) else y
482
+
483
+
484
+ def _sigmoid(self, x, k=10.0, mid=0.5):
485
+ a = x.array if isinstance(x, PixelImage) else np.asarray(x, dtype=np.float32)
486
+ y = 1.0 / (1.0 + np.exp(-float(k) * (a - float(mid))))
487
+ return PixelImage(y) if isinstance(x, PixelImage) else y
488
+
489
+ def _smoothstep(self, e0, e1, x):
490
+ e0, e1 = float(e0), float(e1)
491
+ a = x.array if isinstance(x, PixelImage) else np.asarray(x, dtype=np.float32)
492
+ t = np.clip((a - e0) / max(e1 - e0, 1e-12), 0.0, 1.0)
493
+ y = t * t * (3 - 2 * t)
494
+ return PixelImage(y) if isinstance(x, PixelImage) else y
495
+
496
+ def _lerp(self, a, b, t):
497
+ av = a.array if isinstance(a, PixelImage) else np.asarray(a, dtype=np.float32)
498
+ bv = b.array if isinstance(b, PixelImage) else np.asarray(b, dtype=np.float32)
499
+ tv = t.array if isinstance(t, PixelImage) else np.asarray(t, dtype=np.float32)
500
+ y = av * (1.0 - tv) + bv * tv
501
+ return PixelImage(y) if any(isinstance(z, PixelImage) for z in (a, b, t)) else y
502
+
503
+ # ---- stats/normalization ----
504
+ def _percentile(self, x, p):
505
+ a = x.array if isinstance(x, PixelImage) else np.asarray(x, dtype=np.float32)
506
+ if a.ndim == 2:
507
+ v = np.percentile(a, float(p))
508
+ out = np.full_like(a, v)
509
+ else:
510
+ out = np.empty_like(a)
511
+ for c in range(a.shape[2]):
512
+ v = np.percentile(a[..., c], float(p))
513
+ out[..., c] = v
514
+ return PixelImage(out) if isinstance(x, PixelImage) else out
515
+
516
+ def _normalize01(self, x):
517
+ a = x.array if isinstance(x, PixelImage) else np.asarray(x, dtype=np.float32)
518
+ if a.ndim == 2:
519
+ lo, hi = float(a.min()), float(a.max())
520
+ out = (a - lo) / max(hi - lo, 1e-12)
521
+ else:
522
+ out = np.empty_like(a)
523
+ for c in range(a.shape[2]):
524
+ ch = a[..., c]
525
+ lo, hi = float(ch.min()), float(ch.max())
526
+ out[..., c] = (ch - lo) / max(hi - lo, 1e-12)
527
+ return PixelImage(np.clip(out, 0.0, 1.0)) if isinstance(x, PixelImage) else np.clip(out, 0.0, 1.0)
528
+
529
+ def _zscore(self, x):
530
+ a = x.array if isinstance(x, PixelImage) else np.asarray(x, dtype=np.float32)
531
+ if a.ndim == 2:
532
+ m, s = float(a.mean()), float(a.std())
533
+ out = (a - m) / max(s, 1e-12)
534
+ else:
535
+ out = np.empty_like(a)
536
+ for c in range(a.shape[2]):
537
+ ch = a[..., c]
538
+ m, s = float(ch.mean()), float(ch.std())
539
+ out[..., c] = (ch - m) / max(s, 1e-12)
540
+ return PixelImage(out) if isinstance(x, PixelImage) else out
541
+
542
+ # ---- channels & color ----
543
+ def _ch(self, x, i):
544
+ a = x.array if isinstance(x, PixelImage) else np.asarray(x, dtype=np.float32)
545
+ if a.ndim != 3: raise ValueError("ch(x,i) expects RGB image")
546
+ return a[..., int(i)]
547
+
548
+ def _luma(self, x):
549
+ a = x.array if isinstance(x, PixelImage) else np.asarray(x, dtype=np.float32)
550
+ if a.ndim == 2:
551
+ return a
552
+ y = 0.2126*a[...,0] + 0.7152*a[...,1] + 0.0722*a[...,2]
553
+ return y
554
+
555
+ def _compose(self, r, g, b):
556
+ R = r.array if isinstance(r, PixelImage) else np.asarray(r, dtype=np.float32)
557
+ G = g.array if isinstance(g, PixelImage) else np.asarray(g, dtype=np.float32)
558
+ B = b.array if isinstance(b, PixelImage) else np.asarray(b, dtype=np.float32)
559
+ if R.ndim != 2 or G.ndim != 2 or B.ndim != 2:
560
+ raise ValueError("compose(r,g,b) expects three 2-D planes")
561
+ return np.stack([R, G, B], axis=2)
562
+
563
+ # ---- mask helpers exposed to the user ----
564
+ def _mask_fn(self):
565
+ ref = _as_rgb(np.asarray(self.doc.image, dtype=np.float32))
566
+ m = _mask_for_ref(self.doc, ref)
567
+ if m is None:
568
+ m = np.zeros(ref.shape[:2], dtype=np.float32)
569
+ if m.ndim == 3:
570
+ m = m[...,0]
571
+ return m
572
+
573
+ def _apply_mask_fn(self, base, out, m):
574
+ basev = base.array if isinstance(base, PixelImage) else np.asarray(base, dtype=np.float32)
575
+ outv = out.array if isinstance(out, PixelImage) else np.asarray(out, dtype=np.float32)
576
+ mv = m.array if isinstance(m, PixelImage) else np.asarray(m, dtype=np.float32)
577
+ return _blend_masked(basev, outv, mv) # _blend_masked now handles 2-D or 3-D
578
+
579
+
580
+ # ---- tiny filters (cv2 optional) ----
581
+ def _apply_per_channel(self, a, fn):
582
+ if a.ndim == 2:
583
+ return fn(a)
584
+ out = np.empty_like(a)
585
+ for c in range(a.shape[2]):
586
+ out[..., c] = fn(a[..., c])
587
+ return out
588
+
589
+ def _boxblur(self, x, k=3):
590
+ a = x.array if isinstance(x, PixelImage) else np.asarray(x, dtype=np.float32)
591
+ try:
592
+ import cv2
593
+ k = int(max(1, k))
594
+ y = self._apply_per_channel(a, lambda ch: cv2.blur(ch, (k, k)))
595
+ except Exception:
596
+ # naive fallback
597
+ from math import floor
598
+ k = int(max(1, k))
599
+ r = k//2
600
+ y = a.copy()
601
+ # very simple and slow fallback; okay as last resort
602
+ for i in range(a.shape[0]):
603
+ i0, i1 = max(0, i-r), min(a.shape[0], i+r+1)
604
+ for j in range(a.shape[1]):
605
+ j0, j1 = max(0, j-r), min(a.shape[1], j+r+1)
606
+ y[i, j] = a[i0:i1, j0:j1].mean(axis=(0,1))
607
+ return PixelImage(y) if isinstance(x, PixelImage) else y
608
+
609
+ def _gauss(self, x, sigma=1.0):
610
+ a = x.array if isinstance(x, PixelImage) else np.asarray(x, dtype=np.float32)
611
+ try:
612
+ import cv2
613
+ s = float(sigma)
614
+ k = int(max(1, 2*int(3*s)+1))
615
+ y = self._apply_per_channel(a, lambda ch: cv2.GaussianBlur(ch, (k, k), s))
616
+ except Exception:
617
+ # approximate with box blur passes
618
+ y = self._boxblur(a, k=max(1, int(2*sigma)+1))
619
+ y = y.array if isinstance(y, PixelImage) else y
620
+ y = self._boxblur(PixelImage(y), k=max(1, int(2*sigma)+1))
621
+ y = y.array if isinstance(y, PixelImage) else y
622
+ return PixelImage(y) if isinstance(x, PixelImage) else y
623
+
624
+ def _median(self, x, k=3):
625
+ a = x.array if isinstance(x, PixelImage) else np.asarray(x, dtype=np.float32)
626
+ try:
627
+ import cv2
628
+ k = int(max(1, k)) | 1 # must be odd
629
+ y = self._apply_per_channel(a, lambda ch: cv2.medianBlur(ch, k))
630
+ except Exception:
631
+ # crude fallback: percentile in local box
632
+ y = self._boxblur(a, k=k) # not truly median, but better than nothing
633
+ y = y.array if isinstance(y, PixelImage) else y
634
+ return PixelImage(y) if isinstance(x, PixelImage) else y
635
+
636
+ def _unsharp(self, x, sigma=1.5, amount=1.0):
637
+ a = x.array if isinstance(x, PixelImage) else np.asarray(x, dtype=np.float32)
638
+ blur = self._gauss(PixelImage(a), sigma)
639
+ blur = blur.array if isinstance(blur, PixelImage) else blur
640
+ y = np.clip(a + float(amount) * (a - blur), 0.0, 1.0)
641
+ return PixelImage(y) if isinstance(x, PixelImage) else y
642
+
643
+
644
+ # -------- core eval
645
+ def _eval_multiline(self, expr: str):
646
+ lines = [ln for ln in (expr or "").splitlines() if ln.strip()]
647
+ if not lines:
648
+ return 0
649
+ scope = dict(self.ns)
650
+ for ln in lines[:-1]:
651
+ exec(ln, {"__builtins__": None}, scope)
652
+ return eval(lines[-1], {"__builtins__": None}, scope)
653
+
654
+ def eval_single(self, expr: str) -> np.ndarray:
655
+ expr = self._rewrite_names(expr)
656
+ r = self._eval_multiline(expr)
657
+ if isinstance(r, PixelImage):
658
+ r = r.array
659
+
660
+ ref = _as_rgb(np.asarray(self.doc.image, dtype=np.float32))
661
+ if np.isscalar(r):
662
+ r = np.full(ref.shape, float(r), dtype=np.float32)
663
+ r = _as_rgb(r.astype(np.float32, copy=False))
664
+
665
+ m = _mask_for_ref(self.doc, ref)
666
+ if m is not None:
667
+ r = _blend_masked(ref, r, m)
668
+ return r
669
+
670
+ def eval_rgb(self, er: str, eg: str, eb: str, default_channels=(0, 1, 2)) -> np.ndarray:
671
+ er, eg, eb = self._rewrite_names(er), self._rewrite_names(eg), self._rewrite_names(eb)
672
+ ref = _as_rgb(np.asarray(self.doc.image, dtype=np.float32))
673
+ H, W, _ = ref.shape
674
+
675
+ def one(e, ci: int):
676
+ if not e:
677
+ return 0
678
+ v = self._eval_multiline(e)
679
+ if isinstance(v, PixelImage):
680
+ v = v.array
681
+
682
+ # Scalars become (H,W) plane later, so handle that below
683
+ if np.isscalar(v):
684
+ return np.full((H, W), float(v), dtype=np.float32)
685
+
686
+ v = np.asarray(v, dtype=np.float32)
687
+
688
+ # NEW: if user returned a color (HxWx3) in per-channel slot, assume the tab's channel
689
+ if v.ndim == 3:
690
+ if v.shape[2] == 1:
691
+ v = v[..., 0]
692
+ else:
693
+ # auto-pick requested channel
694
+ v = v[..., int(ci)]
695
+
696
+ # At this point expect 2-D plane
697
+ if v.ndim != 2:
698
+ raise ValueError("Per-channel mode expects a 2-D result (or an RGB where the tab's channel can be taken).")
699
+ return v
700
+
701
+ R = one(er, default_channels[0])
702
+ G = one(eg, default_channels[1])
703
+ B = one(eb, default_channels[2])
704
+ out = np.stack([R, G, B], axis=2)
705
+
706
+ m = _mask_for_ref(self.doc, ref)
707
+ if m is not None:
708
+ out = _blend_masked(ref, out, m)
709
+ return out
710
+
711
+ class _PreviewView(QGraphicsView):
712
+ """QGraphicsView with left-drag panning and Ctrl+wheel zoom."""
713
+ def __init__(self, parent=None):
714
+ super().__init__(parent)
715
+ self.setDragMode(QGraphicsView.DragMode.ScrollHandDrag) # click & drag to pan
716
+ self.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
717
+ self.setResizeAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
718
+ self._zoom = 1.0
719
+ self._min_zoom = 0.05
720
+ self._max_zoom = 20.0
721
+
722
+ def wheelEvent(self, ev):
723
+ # Ctrl + wheel → zoom; otherwise, default scroll behavior
724
+ if ev.modifiers() & Qt.KeyboardModifier.ControlModifier:
725
+ angle = ev.angleDelta().y()
726
+ step = 1.25 if angle > 0 else 1/1.25
727
+ new_zoom = max(self._min_zoom, min(self._zoom * step, self._max_zoom))
728
+ step = new_zoom / self._zoom # clamp-aware step
729
+ self._zoom = new_zoom
730
+ self.scale(step, step)
731
+ ev.accept()
732
+ else:
733
+ super().wheelEvent(ev)
734
+
735
+ # helpers to keep external controls in sync
736
+ def zoom_reset(self):
737
+ self.resetTransform()
738
+ self._zoom = 1.0
739
+
740
+ def zoom_by(self, factor: float):
741
+ new_zoom = max(self._min_zoom, min(self._zoom * float(factor), self._max_zoom))
742
+ factor = new_zoom / self._zoom
743
+ self._zoom = new_zoom
744
+ self.scale(factor, factor)
745
+
746
+
747
+ # =============================================================================
748
+ # Dialog
749
+ # =============================================================================
750
+ class PixelMathDialogPro(QDialog):
751
+ """
752
+ Pixel Math with view-name variables.
753
+ • img → active view
754
+ • one variable per OPEN VIEW using the window title (sanitized).
755
+ • Output: Overwrite active OR Create new view
756
+ """
757
+ def __init__(self, parent, doc, icon: QIcon | None = None):
758
+ super().__init__(parent)
759
+ self.setWindowTitle(self.tr("Pixel Math"))
760
+ self.setWindowFlag(Qt.WindowType.Window, True)
761
+ self.setWindowModality(Qt.WindowModality.NonModal)
762
+ self.setModal(False)
763
+ if icon:
764
+ try:
765
+ self.setWindowIcon(icon)
766
+ except Exception:
767
+ pass
768
+
769
+ self.doc = doc
770
+ self.ev = _Evaluator(parent, doc)
771
+
772
+ self._load_autostretch_prefs()
773
+
774
+ # ──────────────────────────────────────────────────────────────────────────
775
+ # Root split layout: controls (left, scrollable) | preview (right, flexible)
776
+ # ──────────────────────────────────────────────────────────────────────────
777
+ root = QHBoxLayout(self)
778
+
779
+ # Left column (controls)
780
+ left_scroll = QScrollArea()
781
+ left_scroll.setWidgetResizable(True)
782
+ left_panel = QWidget()
783
+ left_col = QVBoxLayout(left_panel)
784
+ left_col.setContentsMargins(0, 0, 0, 0)
785
+ left_col.setSpacing(8)
786
+ left_scroll.setWidget(left_panel)
787
+
788
+ # Right column (preview)
789
+ right_panel = QWidget()
790
+ right_col = QVBoxLayout(right_panel)
791
+ right_col.setContentsMargins(0, 0, 0, 0)
792
+ right_col.setSpacing(8)
793
+
794
+ # Put them into a splitter so user can drag the boundary
795
+ splitter = QSplitter(Qt.Orientation.Horizontal, self)
796
+ splitter.setChildrenCollapsible(False)
797
+ splitter.addWidget(left_scroll)
798
+ splitter.addWidget(right_panel)
799
+ splitter.setStretchFactor(0, 0) # left: fixed-ish
800
+ splitter.setStretchFactor(1, 1) # right: grows
801
+
802
+ # Give the left side a reasonable minimum so it doesn't disappear
803
+ left_scroll.setMinimumWidth(260)
804
+
805
+ root.addWidget(splitter)
806
+ self._splitter = splitter # optional, if you ever want to tweak later
807
+
808
+
809
+ # ──────────────────────────────────────────────────────────────────────────
810
+ # Variables mapping (raw title → identifier)
811
+ # ──────────────────────────────────────────────────────────────────────────
812
+ vars_grp = QGroupBox(self.tr("Variables"))
813
+ vars_layout = QVBoxLayout(vars_grp)
814
+
815
+ self.vars_list = QListWidget()
816
+ self.vars_list.setSelectionMode(QListWidget.SelectionMode.SingleSelection)
817
+ self.vars_list.setAlternatingRowColors(True)
818
+ self.vars_list.setTextElideMode(Qt.TextElideMode.ElideRight)
819
+
820
+ def _shorten_title(raw_title: str, ident: str, max_chars: int = 48) -> tuple[str, str]:
821
+ """
822
+ Return (display_text, full_text).
823
+ display_text is shortened so the left panel doesn't explode.
824
+ full_text is put into the tooltip.
825
+ """
826
+ base = str(raw_title)
827
+ if len(base) > max_chars:
828
+ head = max_chars // 2 - 1
829
+ tail = max_chars - head - 1
830
+ base = base[:head] + "…" + base[-tail:]
831
+ disp = f"{base} → {ident}"
832
+ full = f"{raw_title} → {ident}"
833
+ return disp, full
834
+
835
+
836
+ # First item = active view
837
+ active_item = QListWidgetItem("img (active)")
838
+ active_item.setData(Qt.ItemDataRole.UserRole, "img") # ← stash the real name
839
+ active_item.setToolTip(self.tr("img (active)"))
840
+ self.vars_list.addItem(active_item)
841
+
842
+ # Other open views
843
+ for raw, ident in self.ev.title_map:
844
+ disp, full = _shorten_title(raw, ident)
845
+ it = QListWidgetItem(disp)
846
+ it.setData(Qt.ItemDataRole.UserRole, ident) # ← stash the ident
847
+ it.setToolTip(full)
848
+ self.vars_list.addItem(it)
849
+
850
+ # Comfortable height; scroll appears as needed
851
+ self.vars_list.setMinimumHeight(120)
852
+ self.vars_list.setMaximumHeight(180)
853
+
854
+ hint = QLabel(self.tr("Tip: double-click to insert the identifier at the cursor"))
855
+ hint.setStyleSheet("color: gray; font-size: 11px;")
856
+
857
+ vars_layout.addWidget(self.vars_list)
858
+ vars_layout.addWidget(hint)
859
+
860
+ def _insert_ident_into_current_editor(item: QListWidgetItem):
861
+ ident = item.data(Qt.ItemDataRole.UserRole) or item.text().split("→", 1)[-1].strip()
862
+ ed = self.ed_single if self.rb_single.isChecked() else (
863
+ self.ed_r if self.tabs.currentIndex()==0 else self.ed_g if self.tabs.currentIndex()==1 else self.ed_b
864
+ )
865
+ ed.setFocus()
866
+ ed.insertPlainText(str(ident))
867
+
868
+ self._on_var_dblclick = _insert_ident_into_current_editor
869
+ try:
870
+ self.vars_list.itemDoubleClicked.connect(self._on_var_dblclick, Qt.ConnectionType.UniqueConnection)
871
+ except TypeError:
872
+ # Already connected; ignore
873
+ pass
874
+
875
+ left_col.addWidget(vars_grp)
876
+
877
+ # ──────────────────────────────────────────────────────────────────────────
878
+ # Output group
879
+ # ──────────────────────────────────────────────────────────────────────────
880
+ out_grp = QGroupBox(self.tr("Output"))
881
+ out_row = QHBoxLayout(out_grp)
882
+ self.rb_out_overwrite = QRadioButton(self.tr("Overwrite active")); self.rb_out_overwrite.setChecked(True)
883
+ self.rb_out_new = QRadioButton(self.tr("Create new view"))
884
+ out_row.addWidget(self.rb_out_overwrite)
885
+ out_row.addWidget(self.rb_out_new)
886
+ out_row.addStretch(1)
887
+ left_col.addWidget(out_grp)
888
+
889
+ # ──────────────────────────────────────────────────────────────────────────
890
+ # Mode (single expression vs per-channel)
891
+ # ──────────────────────────────────────────────────────────────────────────
892
+ mode_row = QHBoxLayout()
893
+ self.rb_single = QRadioButton(self.tr("Single Expression")); self.rb_single.setChecked(True)
894
+ self.rb_sep = QRadioButton(self.tr("Separate (R / G / B)"))
895
+ mode_row.addWidget(self.rb_single)
896
+ mode_row.addWidget(self.rb_sep)
897
+ mode_row.addStretch(1)
898
+ left_col.addLayout(mode_row)
899
+
900
+ self.mode_group = QButtonGroup(self)
901
+ self.mode_group.setExclusive(True)
902
+ self.mode_group.addButton(self.rb_single)
903
+ self.mode_group.addButton(self.rb_sep)
904
+
905
+ # Editors
906
+ self.ed_single = QPlainTextEdit()
907
+ self.ed_single.setPlaceholderText(self.tr("e.g. (img + otherView) / 2"))
908
+ left_col.addWidget(self.ed_single)
909
+
910
+ self.tabs = QTabWidget(); self.tabs.setVisible(False)
911
+ self.ed_r, self.ed_g, self.ed_b = QPlainTextEdit(), QPlainTextEdit(), QPlainTextEdit()
912
+ for ed, name in ((self.ed_r, self.tr("Red")), (self.ed_g, self.tr("Green")), (self.ed_b, self.tr("Blue"))):
913
+ w = QWidget(); lay = QVBoxLayout(w); lay.addWidget(ed); self.tabs.addTab(w, name)
914
+ left_col.addWidget(self.tabs)
915
+
916
+ self.rb_single.toggled.connect(lambda on: self._mode(on))
917
+
918
+ glossary_btn = QPushButton(self.tr("Glossary…"))
919
+ glossary_btn.clicked.connect(self._open_glossary)
920
+ left_col.addWidget(glossary_btn)
921
+
922
+ # ──────────────────────────────────────────────────────────────────────────
923
+ # Preview (right side)
924
+ # ──────────────────────────────────────────────────────────────────────────
925
+ preview_grp = QGroupBox(self.tr("Preview"))
926
+ pv_lay = QVBoxLayout(preview_grp)
927
+
928
+ # Toolbar
929
+ tb = QHBoxLayout()
930
+ self.btn_preview = QPushButton(self.tr("Preview"))
931
+ self.btn_preview.setToolTip(self.tr("Compute Pixel Math and show the result here without committing."))
932
+
933
+ # NEW: Auto-stretch toggle with a dropdown menu
934
+ self.btn_autostretch = QToolButton()
935
+ self.btn_autostretch.setText(self.tr("Auto-stretch"))
936
+ self.btn_autostretch.setCheckable(True)
937
+ self.btn_autostretch.setChecked(self._as_enabled)
938
+ self.btn_autostretch.setPopupMode(QToolButton.ToolButtonPopupMode.MenuButtonPopup)
939
+
940
+ as_menu = QMenu(self)
941
+ act_toggle = as_menu.addAction(self.tr("Enable Auto-stretch"))
942
+ act_toggle.setCheckable(True); act_toggle.setChecked(self._as_enabled)
943
+ as_menu.addSeparator()
944
+
945
+ # Target presets
946
+ tgt_menu = as_menu.addMenu(self.tr("Target median"))
947
+ self._tgt_group = QActionGroup(self); self._tgt_group.setExclusive(True)
948
+ for label, val in ((self.tr("0.18 (soft)"), 0.18), (self.tr("0.25 (default)"), 0.25), (self.tr("0.35 (brighter)"), 0.35)):
949
+ a = tgt_menu.addAction(label)
950
+ a.setCheckable(True)
951
+ a.setChecked(abs(self._as_target - val) < 1e-6)
952
+ self._tgt_group.addAction(a)
953
+ a.triggered.connect(lambda _=False, v=val: self._set_as_target(v))
954
+
955
+ # Sigma presets
956
+ sig_menu = as_menu.addMenu(self.tr("Black-point sigma"))
957
+ self._sig_group = QActionGroup(self); self._sig_group.setExclusive(True)
958
+ for label, val in ((self.tr("σ=2.5"), 2.5), (self.tr("σ=3 (default)"), 3.0), (self.tr("σ=4 (deeper black)"), 4.0)):
959
+ a = sig_menu.addAction(label)
960
+ a.setCheckable(True)
961
+ a.setChecked(abs(self._as_sigma - val) < 1e-6)
962
+ self._sig_group.addAction(a)
963
+ a.triggered.connect(lambda _=False, v=val: self._set_as_sigma(v))
964
+
965
+ # Linked channels
966
+ act_linked = as_menu.addAction(self.tr("Linked channels (use luminance)"))
967
+ act_linked.setCheckable(True); act_linked.setChecked(self._as_linked)
968
+ as_menu.addSeparator()
969
+
970
+ # Output precision
971
+ act_16 = as_menu.addAction(self.tr("Use 16-bit stats"))
972
+ act_16.setCheckable(True); act_16.setChecked(self._as_16bit)
973
+
974
+ self.btn_autostretch.setMenu(as_menu)
975
+
976
+ # Keep toggle and menu in sync
977
+ def _apply_menu_state():
978
+ self._as_enabled = self.btn_autostretch.isChecked()
979
+ act_toggle.setChecked(self._as_enabled)
980
+ self._save_autostretch_prefs()
981
+ self._rerun_preview_if_any()
982
+
983
+ self.btn_autostretch.toggled.connect(_apply_menu_state)
984
+
985
+ act_toggle.toggled.connect(lambda on: (self.btn_autostretch.setChecked(on),
986
+ self._save_autostretch_prefs(),
987
+ self._rerun_preview_if_any()))
988
+
989
+ act_linked.toggled.connect(lambda on: (setattr(self, "_as_linked", on),
990
+ self._save_autostretch_prefs(),
991
+ self._rerun_preview_if_any()))
992
+
993
+ act_16.toggled.connect( lambda on: (setattr(self, "_as_16bit", on),
994
+ self._save_autostretch_prefs(),
995
+ self._rerun_preview_if_any()))
996
+
997
+ # tiny helpers for radio menus
998
+ def _refresh_target_checks():
999
+ for a in self._tgt_group.actions():
1000
+ txt = a.text() # "0.18 (soft)" / "0.25 (default)" / "0.35 (brighter)"
1001
+ v = 0.18 if txt.startswith("0.18") else 0.25 if txt.startswith("0.25") else 0.35
1002
+ a.setChecked(abs(self._as_target - v) < 1e-6)
1003
+
1004
+ def _refresh_sigma_checks():
1005
+ for a in self._sig_group.actions():
1006
+ # texts are "σ=2.5", "σ=3 (default)", "σ=4 (deeper black)"
1007
+ tail = a.text().split("=", 1)[-1].strip().split()[0] # "2.5" / "3" / "4"
1008
+ v = float(tail)
1009
+ a.setChecked(abs(self._as_sigma - v) < 1e-6)
1010
+
1011
+ # API used by the actions above
1012
+ def _set_target(v: float):
1013
+ self._as_target = float(v)
1014
+ self._save_autostretch_prefs()
1015
+ _refresh_target_checks()
1016
+ self._rerun_preview_if_any()
1017
+
1018
+ def _set_sigma(v: float):
1019
+ self._as_sigma = float(v)
1020
+ self._save_autostretch_prefs()
1021
+ _refresh_sigma_checks()
1022
+ self._rerun_preview_if_any()
1023
+
1024
+ self._set_as_target = _set_target
1025
+ self._set_as_sigma = _set_sigma
1026
+
1027
+ # existing zoom buttons
1028
+ self.btn_zoom_in = themed_toolbtn("zoom-in", self.tr("Zoom In"))
1029
+ self.btn_zoom_out = themed_toolbtn("zoom-out", self.tr("Zoom Out"))
1030
+ self.btn_zoom_1_1 = themed_toolbtn("zoom-original", self.tr("1:1"))
1031
+ self.btn_fit = themed_toolbtn("zoom-fit-best", self.tr("Fit to Preview"))
1032
+
1033
+ tb.addWidget(self.btn_preview)
1034
+ tb.addWidget(self.btn_autostretch) # ← NEW
1035
+ tb.addSpacing(12)
1036
+ tb.addWidget(self.btn_zoom_in); tb.addWidget(self.btn_zoom_out)
1037
+ tb.addWidget(self.btn_zoom_1_1); tb.addWidget(self.btn_fit)
1038
+ tb.addStretch(1)
1039
+ pv_lay.addLayout(tb)
1040
+
1041
+ # Graphics view
1042
+ self.preview_view = _PreviewView() # <-- was QGraphicsView()
1043
+ self.preview_view.setRenderHints(self.preview_view.renderHints())
1044
+ self.preview_scene = QGraphicsScene(self.preview_view)
1045
+ self.preview_view.setScene(self.preview_scene)
1046
+ self._preview_item: QGraphicsPixmapItem | None = None
1047
+ self._preview_zoom = 1.0 # keep if you like; the view tracks its own zoom too
1048
+ pv_lay.addWidget(self.preview_view, 1)
1049
+
1050
+ right_col.addWidget(preview_grp, 1)
1051
+
1052
+ # Wire up preview actions
1053
+ self.btn_preview.clicked.connect(self._do_preview)
1054
+ self.btn_zoom_in.clicked.connect(lambda: self._zoom_by(1.25))
1055
+ self.btn_zoom_out.clicked.connect(lambda: self._zoom_by(1/1.25))
1056
+ self.btn_zoom_1_1.clicked.connect(self._zoom_reset_1_1)
1057
+ self.btn_fit.clicked.connect(self._fit_to_view)
1058
+
1059
+ # ──────────────────────────────────────────────────────────────────────────
1060
+ # Examples (insertable templates)
1061
+ # ──────────────────────────────────────────────────────────────────────────
1062
+ ex_row = QHBoxLayout()
1063
+ ex_row.addWidget(QLabel(self.tr("Examples:")))
1064
+ self.cb_examples = QComboBox()
1065
+ self.cb_examples.addItem(self.tr("Insert example…"))
1066
+ for title, kind, payload in self._examples_list():
1067
+ self.cb_examples.addItem(title, (kind, payload))
1068
+ self.cb_examples.currentIndexChanged.connect(self._apply_example_from_combo)
1069
+ ex_row.addWidget(self.cb_examples, 1)
1070
+ left_col.addLayout(ex_row)
1071
+
1072
+ # ──────────────────────────────────────────────────────────────────────────
1073
+ # Favorites
1074
+ # ──────────────────────────────────────────────────────────────────────────
1075
+ fav_row = QHBoxLayout()
1076
+ self.cb_fav = QComboBox(); self.cb_fav.addItem(self.tr("Select a favorite expression"))
1077
+ self._load_favorites()
1078
+ self.cb_fav.currentTextChanged.connect(self._pick_favorite)
1079
+
1080
+ b_save = QPushButton(self.tr("Save as Favorite"))
1081
+ b_del = QPushButton(self.tr("Delete Favorite"))
1082
+
1083
+ b_save.clicked.connect(self._save_favorite)
1084
+ b_del.clicked.connect(self._delete_favorite)
1085
+
1086
+ fav_row.addWidget(self.cb_fav, 1)
1087
+ fav_row.addWidget(b_save)
1088
+ fav_row.addWidget(b_del)
1089
+ left_col.addLayout(fav_row)
1090
+
1091
+ def _fav_context_menu(point):
1092
+ if self.cb_fav.currentIndex() <= 0:
1093
+ return
1094
+ menu = QMenu(self)
1095
+ act_del = menu.addAction(self.tr("Delete this favorite"))
1096
+ act = menu.exec(self.cb_fav.mapToGlobal(point))
1097
+ if act == act_del:
1098
+ self._delete_favorite()
1099
+
1100
+ self.cb_fav.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
1101
+ self.cb_fav.customContextMenuRequested.connect(_fav_context_menu)
1102
+
1103
+ # ──────────────────────────────────────────────────────────────────────────
1104
+ # Buttons + Help (left)
1105
+ # ──────────────────────────────────────────────────────────────────────────
1106
+ btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, parent=self)
1107
+ btns.accepted.connect(self._apply)
1108
+ btns.rejected.connect(self.reject)
1109
+ b_help = btns.addButton(self.tr("Help"), QDialogButtonBox.ButtonRole.HelpRole)
1110
+ b_help.clicked.connect(self._help)
1111
+ left_col.addWidget(btns)
1112
+
1113
+ # Output group selection model
1114
+ self.out_group = QButtonGroup(self)
1115
+ self.out_group.setExclusive(True)
1116
+ self.out_group.addButton(self.rb_out_overwrite)
1117
+ self.out_group.addButton(self.rb_out_new)
1118
+
1119
+ # Initialize editor visibility
1120
+ QTimer.singleShot(0, lambda: self._mode(self.rb_single.isChecked()))
1121
+
1122
+ # A little wider to favor the preview
1123
+ self.resize(940, 700)
1124
+ QTimer.singleShot(0, lambda: self._splitter.setSizes([320, max(620, self.width() - 320)]))
1125
+
1126
+ # ─────────────── Auto-stretch prefs ───────────────
1127
+ def _as_settings(self):
1128
+ p = self.parent()
1129
+ return getattr(p, "settings", None)
1130
+
1131
+ def _load_autostretch_prefs(self):
1132
+ s = self._as_settings()
1133
+ self._as_enabled = False
1134
+ self._as_linked = True
1135
+ self._as_target = 0.25
1136
+ self._as_sigma = 3.0
1137
+ self._as_16bit = True
1138
+ if s:
1139
+ self._as_enabled = s.value("pixelmath/preview_autostretch", False, type=bool)
1140
+ self._as_linked = s.value("pixelmath/preview_as_linked", True, type=bool)
1141
+ self._as_target = s.value("pixelmath/preview_as_target", 0.25, type=float)
1142
+ self._as_sigma = s.value("pixelmath/preview_as_sigma", 3.0, type=float)
1143
+ self._as_16bit = s.value("pixelmath/preview_as_16bit", True, type=bool)
1144
+
1145
+ def _save_autostretch_prefs(self):
1146
+ s = self._as_settings()
1147
+ if not s: return
1148
+ s.setValue("pixelmath/preview_autostretch", self._as_enabled)
1149
+ s.setValue("pixelmath/preview_as_linked", self._as_linked)
1150
+ s.setValue("pixelmath/preview_as_target", float(self._as_target))
1151
+ s.setValue("pixelmath/preview_as_sigma", float(self._as_sigma))
1152
+ s.setValue("pixelmath/preview_as_16bit", self._as_16bit)
1153
+
1154
+ def _rerun_preview_if_any(self):
1155
+ # If we already showed something, recompute so user sees the change immediately
1156
+ if self._preview_item is not None:
1157
+ QTimer.singleShot(0, self._do_preview)
1158
+
1159
+
1160
+ # ---------- Preview helpers ------------------------------------------------
1161
+ def _do_preview(self):
1162
+ QApplication.setOverrideCursor(QCursor(Qt.CursorShape.WaitCursor))
1163
+ try:
1164
+ if self.rb_single.isChecked():
1165
+ out = self.ev.eval_single(self.ed_single.toPlainText().strip())
1166
+ else:
1167
+ out = self.ev.eval_rgb(
1168
+ self.ed_r.toPlainText().strip(),
1169
+ self.ed_g.toPlainText().strip(),
1170
+ self.ed_b.toPlainText().strip(),
1171
+ default_channels=(0, 1, 2)
1172
+ )
1173
+ out = np.clip(out, 0.0, 1.0).astype(np.float32, copy=False)
1174
+
1175
+ # NEW: optional auto-stretch (preview only)
1176
+ if getattr(self, "_as_enabled", False):
1177
+ out = autostretch(
1178
+ out,
1179
+ target_median=float(getattr(self, "_as_target", 0.25)),
1180
+ linked=bool(getattr(self, "_as_linked", True)),
1181
+ sigma=float(getattr(self, "_as_sigma", 3.0)),
1182
+ use_16bit=bool(getattr(self, "_as_16bit", True)),
1183
+ )
1184
+
1185
+ self._set_preview_image(out)
1186
+ if self._preview_item is not None and abs(self._preview_zoom - 1.0) < 1e-6:
1187
+ self._fit_to_view()
1188
+ except Exception as e:
1189
+ msg = str(e)
1190
+ if "name '" in msg and "' is not defined" in msg:
1191
+ msg += self.tr("\n\nTip: use the identifier listed in Variables (or the raw title; it’s auto-mapped).")
1192
+ QMessageBox.critical(self, self.tr("Pixel Math Preview"), self.tr("Failed:\n{0}").format(msg))
1193
+ finally:
1194
+ QApplication.restoreOverrideCursor()
1195
+
1196
+ def _set_preview_image(self, img: np.ndarray):
1197
+ """Render numpy float image into the preview scene, preserving zoom/pan."""
1198
+ qim = _float_to_qimage_rgb8(img)
1199
+ pm = QPixmap.fromImage(qim)
1200
+
1201
+ # --- capture current view state (before we clear/replace) ---
1202
+ view = self.preview_view
1203
+ old_transform = QTransform(view.transform())
1204
+ old_zoom = getattr(view, "_zoom", 1.0)
1205
+
1206
+ old_center_norm = None
1207
+ if self._preview_item is not None:
1208
+ # current center in scene coords → normalize to old image size
1209
+ center_scene = view.mapToScene(view.viewport().rect().center())
1210
+ old_pix = self._preview_item.pixmap()
1211
+ ow, oh = float(old_pix.width()), float(old_pix.height())
1212
+ if ow > 0 and oh > 0:
1213
+ old_center_norm = QPointF(center_scene.x() / ow, center_scene.y() / oh)
1214
+
1215
+ # --- replace scene content ---
1216
+ self.preview_scene.clear()
1217
+ self._preview_item = self.preview_scene.addPixmap(pm)
1218
+ self.preview_scene.setSceneRect(self._preview_item.boundingRect())
1219
+
1220
+ # --- restore transform and center ---
1221
+ # (don’t call zoom_reset / fit here—respect user’s current view)
1222
+ view.setTransform(old_transform)
1223
+ view._zoom = float(old_zoom)
1224
+ self._preview_zoom = float(old_zoom)
1225
+
1226
+ if old_center_norm is not None:
1227
+ nw, nh = float(pm.width()), float(pm.height())
1228
+ new_center = QPointF(old_center_norm.x() * nw, old_center_norm.y() * nh)
1229
+ view.centerOn(new_center)
1230
+
1231
+ self.preview_view.viewport().update()
1232
+
1233
+ def _zoom_by(self, factor: float):
1234
+ if self._preview_item is None:
1235
+ return
1236
+ self.preview_view.zoom_by(float(factor))
1237
+ # mirror into our logical tracker (optional)
1238
+ self._preview_zoom = self.preview_view._zoom
1239
+
1240
+ def _zoom_reset_1_1(self):
1241
+ if self._preview_item is None:
1242
+ return
1243
+ self.preview_view.zoom_reset()
1244
+ self._preview_zoom = 1.0
1245
+
1246
+ def _fit_to_view(self):
1247
+ if self._preview_item is None:
1248
+ return
1249
+ # Fit the item, then record logical zoom as 1.0 (we treat "fit" as baseline)
1250
+ self.preview_view.fitInView(self._preview_item, Qt.AspectRatioMode.KeepAspectRatio)
1251
+ self.preview_view._zoom = 1.0
1252
+ self._preview_zoom = 1.0
1253
+
1254
+ # ---------- examples -------------------------------------------------------
1255
+ def _examples_list(self):
1256
+ a = "img"
1257
+ others = [ident for (_, ident) in self.ev.title_map if ident != a]
1258
+ b = others[0] if others else a
1259
+ c = others[1] if len(others) > 1 else a
1260
+
1261
+ return [
1262
+ # --- existing basics ---
1263
+ (self.tr("Average two views"), "single", f"({a} + {b}) / 2"),
1264
+ (self.tr("Difference (A - B)"), "single", f"{a} - {b}"),
1265
+ (self.tr("Invert active"), "single", f"~{a}"),
1266
+ (self.tr("Subtract median (bias remove)"), "single", f"{a} - med({a})"),
1267
+ (self.tr("Zero-center by mean"), "single", f"{a} - mean({a})"),
1268
+ (self.tr("Min + Max combine"), "single", f"min({a}) + max({a})"),
1269
+ (self.tr("Log transform"), "single", f"log({a} + 1e-6)"),
1270
+ (self.tr("Midtones transform m=0.25"), "single", f"mtf({a}, 0.25)"),
1271
+ (self.tr("If darker than median → 0 else 1"), "single", f"iff({a} < med({a}), 0, 1)"),
1272
+
1273
+ (self.tr("Per-channel: swap R↔B"), "rgb", (f"{a}[2]", f"{a}[1]", f"{a}[0]")),
1274
+ (self.tr("Per-channel: avg A & B"), "rgb", (f"({a}[0]+{b}[0])/2", f"({a}[1]+{b}[1])/2", f"({a}[2]+{b}[2])/2")),
1275
+ (self.tr("Per-channel: build RGB from A,B,C"), "rgb", (f"{a}[0]", f"{b}[1]", f"{c}[2]")),
1276
+
1277
+ # --- new, single-expression tone/normalization ---
1278
+ (self.tr("Normalize to 0–1 (per-channel)"), "single", f"normalize01({a})"),
1279
+ (self.tr("Sigmoid contrast (k=12, mid=0.4)"), "single", f"sigmoid({a}, k=12, mid=0.4)"),
1280
+ (self.tr("Gamma 0.6 (brighten midtones)"), "single", f"gamma({a}, 0.6)"),
1281
+ (self.tr("Percentile stretch 0.5–99.5%"), "single",
1282
+ f"lo = percentile({a}, 0.5)\nhi = percentile({a}, 99.5)\nclamp(({a} - lo) / (hi - lo), 0, 1)"),
1283
+
1284
+ # --- blending & masking ---
1285
+ (self.tr("Blend A→B by horizontal gradient X"), "single", f"t = X\nlerp({a}, {b}, t)"),
1286
+ (self.tr("Apply active mask to blend A→B"), "single", f"m = mask()\napply_mask({a}, {b}, m)"),
1287
+
1288
+ # --- sharpening with mask (multiline) ---
1289
+ (self.tr("Masked unsharp (luma-based)"), "single",
1290
+ f"base = {a}\nsh = unsharp({a}, sigma=1.2, amount=0.8)\n"
1291
+ f"m = smoothstep(0.10, 0.60, luma({a}))\napply_mask(base, sh, m)"),
1292
+
1293
+ # --- view matching / calibration ---
1294
+ (self.tr("Match medians of A to B"), "single", f"{a} * (med({b}) / med({a}))"),
1295
+
1296
+ # --- small filters ---
1297
+ (self.tr("Gaussian blur σ=2"), "single", f"gauss({a}, sigma=2.0)"),
1298
+ (self.tr("Median filter k=3"), "single", f"median({a}, k=3)"),
1299
+
1300
+ # --- per-channel examples using new helpers ---
1301
+ (self.tr("Per-channel: luma to all channels"), "rgb", (f"luma({a})", f"luma({a})", f"luma({a})")),
1302
+ (self.tr("Per-channel: A’s R, B’s G, C’s B (normed)"), "rgb",
1303
+ (f"normalize01({a}[0])", f"normalize01({b}[1])", f"normalize01({c}[2])")),
1304
+ ]
1305
+
1306
+ def _function_glossary(self):
1307
+ # name -> (signature / template, short description)
1308
+ return {
1309
+ "clamp": ("clamp(x, lo=0, hi=1)", self.tr("Limit values to [lo..hi].")),
1310
+ "rescale": ("rescale(x, a, b, lo=0, hi=1)", self.tr("Map range [a..b] to [lo..hi].")),
1311
+ "gamma": ("gamma(x, g)", self.tr("Apply gamma curve.")),
1312
+ "pow_safe": ("pow_safe(x, p)", self.tr("Power with EPS floor.")),
1313
+ "absf": ("absf(x)", self.tr("Absolute value.")),
1314
+ "expf": ("expf(x)", self.tr("Exponential.")),
1315
+ "sqrtf": ("sqrtf(x)", self.tr("Square root (clamped to ≥0).")),
1316
+ "arcsin": ("arcsin(x)", self.tr("Inverse sine (radians), input clipped to [-1,1].")),
1317
+ "sigmoid": ("sigmoid(x, k=10, mid=0.5)", self.tr("S-shaped tone curve.")),
1318
+ "smoothstep": ("smoothstep(e0, e1, x)", self.tr("Cubic smooth ramp.")),
1319
+ "lerp/mix": ("lerp(a, b, t)", self.tr("Linear blend.")),
1320
+ "percentile": ("percentile(x, p)", self.tr("Per-channel percentile image.")),
1321
+ "normalize01": ("normalize01(x)", self.tr("Per-channel [0..1] normalization.")),
1322
+ "zscore": ("zscore(x)", self.tr("Per-channel (x-mean)/std.")),
1323
+ "ch": ("ch(x, i)", self.tr("Extract channel i (0/1/2) as 2-D.")),
1324
+ "luma": ("luma(x)", self.tr("Rec.709 luminance as 2-D.")),
1325
+ "compose": ("compose(R, G, B)", self.tr("Stack three planes to RGB.")),
1326
+ "mask": ("m = mask()", self.tr("Active mask (2-D, [0..1]).")),
1327
+ "apply_mask": ("apply_mask(base, out, m)", self.tr("Blend by mask.")),
1328
+ "boxblur": ("boxblur(x, k=3)", self.tr("Box blur (cv2 if available).")),
1329
+ "gauss": ("gauss(x, sigma=1.0)", self.tr("Gaussian blur.")),
1330
+ "median": ("median(x, k=3)", self.tr("Median filter (cv2 if avail).")),
1331
+ "unsharp": ("unsharp(x, sigma=1.5, amount=1.0)", self.tr("Unsharp mask.")),
1332
+ "mtf": ("mtf(x, m)", self.tr("Midtones transfer (existing).")),
1333
+ "iff": ("iff(cond, a, b)", self.tr("Conditional (existing).")),
1334
+ "X / Y": ("X, Y", self.tr("Normalized coordinates in [0..1].")),
1335
+ "H/W/C": ("H, W, C, shape", self.tr("Image dimensions.")),
1336
+ }
1337
+
1338
+ def _open_glossary(self):
1339
+ dlg = QDialog(self)
1340
+ dlg.setWindowTitle(self.tr("Pixel Math Glossary"))
1341
+ lay = QVBoxLayout(dlg)
1342
+
1343
+ info = QLabel(self.tr("Double-click to insert a template at the cursor."))
1344
+ info.setStyleSheet("color: gray;")
1345
+ lay.addWidget(info)
1346
+
1347
+ from PyQt6.QtWidgets import QLineEdit, QListWidget, QListWidgetItem, QHBoxLayout, QPushButton
1348
+ search = QLineEdit()
1349
+ search.setPlaceholderText(self.tr("Search…"))
1350
+ lay.addWidget(search)
1351
+
1352
+ lst = QListWidget()
1353
+ lst.setMinimumHeight(220)
1354
+ lay.addWidget(lst, 1)
1355
+
1356
+ # fill
1357
+ gl = self._function_glossary()
1358
+ def _refill():
1359
+ q = search.text().strip().lower()
1360
+ lst.clear()
1361
+ for name, (sig, desc) in gl.items():
1362
+ if not q or q in name.lower() or q in sig.lower() or q in desc.lower():
1363
+ item = QListWidgetItem(f"{sig} — {desc}")
1364
+ item.setData(Qt.ItemDataRole.UserRole, sig)
1365
+ lst.addItem(item)
1366
+ _refill()
1367
+
1368
+ def _insert_current():
1369
+ item = lst.currentItem()
1370
+ if not item: return
1371
+ sig = item.data(Qt.ItemDataRole.UserRole) or ""
1372
+ ed = self.ed_single if self.rb_single.isChecked() else (self.ed_r if self.tabs.currentIndex()==0 else self.ed_g if self.tabs.currentIndex()==1 else self.ed_b)
1373
+ ed.insertPlainText(sig)
1374
+
1375
+ lst.itemDoubleClicked.connect(lambda *_: (_insert_current(), None))
1376
+ search.textChanged.connect(lambda *_: _refill())
1377
+
1378
+ btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
1379
+ insert_btn = QPushButton(self.tr("Insert"))
1380
+ btns.addButton(insert_btn, QDialogButtonBox.ButtonRole.ApplyRole)
1381
+ insert_btn.clicked.connect(_insert_current)
1382
+ btns.rejected.connect(dlg.reject)
1383
+ lay.addWidget(btns)
1384
+
1385
+ dlg.resize(620, 400)
1386
+ dlg.exec()
1387
+
1388
+
1389
+ def _delete_favorite(self):
1390
+ text = self.cb_fav.currentText()
1391
+ if text == self.tr("Select a favorite expression"):
1392
+ return
1393
+ # Remove from in-memory list
1394
+ try:
1395
+ idx_in_list = self._favs.index(text)
1396
+ except ValueError:
1397
+ return
1398
+
1399
+ self._favs.pop(idx_in_list)
1400
+
1401
+ # Rebuild combo to keep indices clean
1402
+ self.cb_fav.blockSignals(True)
1403
+ self.cb_fav.clear()
1404
+ self.cb_fav.addItem(self.tr("Select a favorite expression"))
1405
+ for f in self._favs:
1406
+ self.cb_fav.addItem(f)
1407
+ self.cb_fav.setCurrentIndex(0)
1408
+ self.cb_fav.blockSignals(False)
1409
+
1410
+ # Persist
1411
+ s = self._settings()
1412
+ if s:
1413
+ s.setValue("pixelmath_favorites", json.dumps(self._favs))
1414
+
1415
+
1416
+ def _apply_example_from_combo(self, idx: int):
1417
+ if idx <= 0: # "Insert example…"
1418
+ return
1419
+ kind, payload = self.cb_examples.currentData()
1420
+ # Switch mode first, then inject text on the next event loop tick to avoid any race with toggled()
1421
+ if kind == "single":
1422
+ self.rb_single.setChecked(True)
1423
+ def set_text():
1424
+ self._mode(True)
1425
+ self.ed_single.setPlainText(str(payload))
1426
+ QTimer.singleShot(0, set_text)
1427
+ else:
1428
+ self.rb_sep.setChecked(True)
1429
+ def set_text_rgb():
1430
+ self._mode(False)
1431
+ r, g, b = payload
1432
+ self.ed_r.setPlainText(r)
1433
+ self.ed_g.setPlainText(g)
1434
+ self.ed_b.setPlainText(b)
1435
+ QTimer.singleShot(0, set_text_rgb)
1436
+ # reset the combo back to the prompt so it can be used repeatedly
1437
+ QTimer.singleShot(0, lambda: self.cb_examples.setCurrentIndex(0))
1438
+
1439
+ # ---------- favorites ------------------------------------------------------
1440
+ def _settings(self):
1441
+ p = self.parent(); return getattr(p, "settings", None)
1442
+
1443
+ def _load_favorites(self):
1444
+ self._favs = []
1445
+ s = self._settings()
1446
+ if s:
1447
+ raw = s.value("pixelmath_favorites", "", type=str) or ""
1448
+ try: self._favs = json.loads(raw) if raw else []
1449
+ except Exception: self._favs = []
1450
+ for f in self._favs: self.cb_fav.addItem(f)
1451
+
1452
+ def _save_favorite(self):
1453
+ if self.rb_single.isChecked():
1454
+ expr = self.ed_single.toPlainText().strip()
1455
+ else:
1456
+ expr = f"[R]{self.ed_r.toPlainText().strip()} | [G]{self.ed_g.toPlainText().strip()} | [B]{self.ed_b.toPlainText().strip()}"
1457
+ if not expr or expr in self._favs: return
1458
+ self._favs.append(expr); self.cb_fav.addItem(expr)
1459
+ s = self._settings()
1460
+ if s: s.setValue("pixelmath_favorites", json.dumps(self._favs))
1461
+
1462
+ def _pick_favorite(self, text):
1463
+ if text == self.tr("Select a favorite expression"): return
1464
+ if "[R]" in text or "[G]" in text or "[B]" in text:
1465
+ self.rb_sep.setChecked(True); self._mode(False)
1466
+ parts = {}
1467
+ for p in [t.strip() for t in text.split("|") if t.strip()]:
1468
+ parts[p[:3]] = p[3:].strip()
1469
+ self.ed_r.setPlainText(parts.get("[R]", "")); self.ed_g.setPlainText(parts.get("[G]", "")); self.ed_b.setPlainText(parts.get("[B]", ""))
1470
+ else:
1471
+ self.rb_single.setChecked(True); self._mode(True)
1472
+ self.ed_single.setPlainText(text)
1473
+
1474
+ # =============================================================================
1475
+ # New-view delivery helper (used by PixelMathDialogPro)
1476
+ # =============================================================================
1477
+
1478
+ @staticmethod
1479
+ def _deliver_new_view(parent, src_doc, img: np.ndarray, step_name: str = "Pixel Math"):
1480
+ dm = getattr(parent, "doc_manager", None)
1481
+ if dm is None:
1482
+ if hasattr(src_doc, "set_image"):
1483
+ src_doc.set_image(img, step_name=step_name)
1484
+ else:
1485
+ src_doc.image = img
1486
+ return src_doc
1487
+
1488
+ base = src_doc.display_name() if callable(getattr(src_doc, "display_name", None)) else getattr(src_doc, "display_name", "Untitled")
1489
+ base = base if isinstance(base, str) and base else "Untitled"
1490
+ new_title = f"{base} — {step_name}"
1491
+
1492
+ meta = dict(getattr(src_doc, "metadata", {}) or {})
1493
+ meta["step_name"] = step_name
1494
+
1495
+ new_doc = dm.open_array(np.asarray(img, dtype=np.float32), metadata=meta, title=new_title)
1496
+ if hasattr(parent, "_spawn_subwindow_for"):
1497
+ parent._spawn_subwindow_for(new_doc)
1498
+ return new_doc
1499
+
1500
+
1501
+ # ---------- UI helpers -----------------------------------------------------
1502
+ def _mode(self, single_on: bool):
1503
+ self.ed_single.setVisible(single_on)
1504
+ self.tabs.setVisible(not single_on)
1505
+
1506
+ def _help(self):
1507
+ gl = self._function_glossary()
1508
+ lines = [
1509
+ self.tr("Operators: + - * / ^(power) ~(invert)"),
1510
+ self.tr("Comparisons: <, == (use inside iff)"),
1511
+ "",
1512
+ self.tr("Variables:"),
1513
+ self.tr(" • img (active) and one per open view (by window title, auto-mapped)."),
1514
+ self.tr(" • Coordinates: X, Y in [0..1]."),
1515
+ self.tr(" • Sizes: H, W, C, shape."),
1516
+ "",
1517
+ self.tr("Per-channel indexing: view[0], view[1], view[2]."),
1518
+ self.tr("Multiline: last line is the result."),
1519
+ self.tr("Output: Overwrite active or Create new view."),
1520
+ "",
1521
+ self.tr("Functions:")
1522
+ ]
1523
+ # Pretty column-ish dump
1524
+ for name, (sig, desc) in gl.items():
1525
+ lines.append(f" {sig}\n {desc}")
1526
+ QMessageBox.information(self, self.tr("Pixel Math Help"), "\n".join(lines))
1527
+
1528
+ # ---------- Apply ----------------------------------------------------------
1529
+ # ---------- Apply ----------------------------------------------------------
1530
+ def _apply(self):
1531
+ try:
1532
+ # Capture expressions first so we can store them for replay
1533
+ if self.rb_single.isChecked():
1534
+ mode = "single"
1535
+ expr = self.ed_single.toPlainText().strip()
1536
+ expr_r = ""
1537
+ expr_g = ""
1538
+ expr_b = ""
1539
+ out = self.ev.eval_single(expr)
1540
+ else:
1541
+ mode = "rgb"
1542
+ expr = ""
1543
+ expr_r = self.ed_r.toPlainText().strip()
1544
+ expr_g = self.ed_g.toPlainText().strip()
1545
+ expr_b = self.ed_b.toPlainText().strip()
1546
+ out = self.ev.eval_rgb(
1547
+ expr_r,
1548
+ expr_g,
1549
+ expr_b,
1550
+ default_channels=(0, 1, 2)
1551
+ )
1552
+
1553
+ out = np.clip(out, 0.0, 1.0).astype(np.float32, copy=False)
1554
+
1555
+ # Output route
1556
+ if self.rb_out_new.isChecked():
1557
+ self._deliver_new_view(self.parent(), self.doc, out, "Pixel Math")
1558
+ else:
1559
+ if hasattr(self.doc, "set_image"):
1560
+ self.doc.set_image(out, step_name="Pixel Math")
1561
+ elif hasattr(self.doc, "apply_numpy"):
1562
+ self.doc.apply_numpy(out, step_name="Pixel Math")
1563
+ else:
1564
+ self.doc.image = out
1565
+
1566
+ # ── Register as last_headless_command for replay ──────────
1567
+ try:
1568
+ main = self.parent()
1569
+ if main is not None:
1570
+ preset = {
1571
+ "mode": mode,
1572
+ "expr": expr,
1573
+ "expr_r": expr_r,
1574
+ "expr_g": expr_g,
1575
+ "expr_b": expr_b,
1576
+ }
1577
+ payload = {
1578
+ "command_id": "pixel_math",
1579
+ "preset": dict(preset),
1580
+ }
1581
+ setattr(main, "_last_headless_command", payload)
1582
+
1583
+ # optional log
1584
+ try:
1585
+ if hasattr(main, "_log"):
1586
+ if mode == "single" and expr:
1587
+ desc = expr
1588
+ else:
1589
+ desc = f"R:{expr_r} G:{expr_g} B:{expr_b}"
1590
+ main._log(f"[Replay] Registered Pixel Math as last action → {desc}")
1591
+ except Exception:
1592
+ pass
1593
+ except Exception:
1594
+ # don't break apply if replay wiring fails
1595
+ pass
1596
+ # ───────────────────────────────────────────────────────────
1597
+
1598
+ self.accept()
1599
+ except Exception as e:
1600
+ msg = str(e)
1601
+ if "name '" in msg and "' is not defined" in msg:
1602
+ msg += self.tr("\n\nTip: use the identifier shown beside Variables (e.g. 'andromeda_png'), ")
1603
+ msg += self.tr("or just type the raw title; it will be auto-mapped.")
1604
+ QMessageBox.critical(self, "Pixel Math", f"Failed:\n{msg}")