setiastrosuitepro 1.6.5.post3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (368) 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/rotatearbitrary.png +0 -0
  107. setiastro/images/rotateclockwise.png +0 -0
  108. setiastro/images/rotatecounterclockwise.png +0 -0
  109. setiastro/images/satellite.png +0 -0
  110. setiastro/images/script.png +0 -0
  111. setiastro/images/selectivecolor.png +0 -0
  112. setiastro/images/simbad.png +0 -0
  113. setiastro/images/slot0.png +0 -0
  114. setiastro/images/slot1.png +0 -0
  115. setiastro/images/slot2.png +0 -0
  116. setiastro/images/slot3.png +0 -0
  117. setiastro/images/slot4.png +0 -0
  118. setiastro/images/slot5.png +0 -0
  119. setiastro/images/slot6.png +0 -0
  120. setiastro/images/slot7.png +0 -0
  121. setiastro/images/slot8.png +0 -0
  122. setiastro/images/slot9.png +0 -0
  123. setiastro/images/spcc.png +0 -0
  124. setiastro/images/spin_precession_vs_lunar_distance.png +0 -0
  125. setiastro/images/spinner.gif +0 -0
  126. setiastro/images/stacking.png +0 -0
  127. setiastro/images/staradd.png +0 -0
  128. setiastro/images/staralign.png +0 -0
  129. setiastro/images/starnet.png +0 -0
  130. setiastro/images/starregistration.png +0 -0
  131. setiastro/images/starspike.png +0 -0
  132. setiastro/images/starstretch.png +0 -0
  133. setiastro/images/statstretch.png +0 -0
  134. setiastro/images/supernova.png +0 -0
  135. setiastro/images/uhs.png +0 -0
  136. setiastro/images/undoicon.png +0 -0
  137. setiastro/images/upscale.png +0 -0
  138. setiastro/images/viewbundle.png +0 -0
  139. setiastro/images/whitebalance.png +0 -0
  140. setiastro/images/wimi_icon_256x256.png +0 -0
  141. setiastro/images/wimilogo.png +0 -0
  142. setiastro/images/wims.png +0 -0
  143. setiastro/images/wrench_icon.png +0 -0
  144. setiastro/images/xisfliberator.png +0 -0
  145. setiastro/qml/ResourceMonitor.qml +126 -0
  146. setiastro/saspro/__init__.py +20 -0
  147. setiastro/saspro/__main__.py +958 -0
  148. setiastro/saspro/_generated/__init__.py +7 -0
  149. setiastro/saspro/_generated/build_info.py +3 -0
  150. setiastro/saspro/abe.py +1346 -0
  151. setiastro/saspro/abe_preset.py +196 -0
  152. setiastro/saspro/aberration_ai.py +698 -0
  153. setiastro/saspro/aberration_ai_preset.py +224 -0
  154. setiastro/saspro/accel_installer.py +218 -0
  155. setiastro/saspro/accel_workers.py +30 -0
  156. setiastro/saspro/add_stars.py +624 -0
  157. setiastro/saspro/astrobin_exporter.py +1010 -0
  158. setiastro/saspro/astrospike.py +153 -0
  159. setiastro/saspro/astrospike_python.py +1841 -0
  160. setiastro/saspro/autostretch.py +198 -0
  161. setiastro/saspro/backgroundneutral.py +611 -0
  162. setiastro/saspro/batch_convert.py +328 -0
  163. setiastro/saspro/batch_renamer.py +522 -0
  164. setiastro/saspro/blemish_blaster.py +491 -0
  165. setiastro/saspro/blink_comparator_pro.py +3149 -0
  166. setiastro/saspro/bundles.py +61 -0
  167. setiastro/saspro/bundles_dock.py +114 -0
  168. setiastro/saspro/cheat_sheet.py +213 -0
  169. setiastro/saspro/clahe.py +368 -0
  170. setiastro/saspro/comet_stacking.py +1442 -0
  171. setiastro/saspro/common_tr.py +107 -0
  172. setiastro/saspro/config.py +38 -0
  173. setiastro/saspro/config_bootstrap.py +40 -0
  174. setiastro/saspro/config_manager.py +316 -0
  175. setiastro/saspro/continuum_subtract.py +1617 -0
  176. setiastro/saspro/convo.py +1400 -0
  177. setiastro/saspro/convo_preset.py +414 -0
  178. setiastro/saspro/copyastro.py +190 -0
  179. setiastro/saspro/cosmicclarity.py +1589 -0
  180. setiastro/saspro/cosmicclarity_preset.py +407 -0
  181. setiastro/saspro/crop_dialog_pro.py +983 -0
  182. setiastro/saspro/crop_preset.py +189 -0
  183. setiastro/saspro/curve_editor_pro.py +2562 -0
  184. setiastro/saspro/curves_preset.py +375 -0
  185. setiastro/saspro/debayer.py +673 -0
  186. setiastro/saspro/debug_utils.py +29 -0
  187. setiastro/saspro/dnd_mime.py +35 -0
  188. setiastro/saspro/doc_manager.py +2664 -0
  189. setiastro/saspro/exoplanet_detector.py +2166 -0
  190. setiastro/saspro/file_utils.py +284 -0
  191. setiastro/saspro/fitsmodifier.py +748 -0
  192. setiastro/saspro/fix_bom.py +32 -0
  193. setiastro/saspro/free_torch_memory.py +48 -0
  194. setiastro/saspro/frequency_separation.py +1349 -0
  195. setiastro/saspro/function_bundle.py +1596 -0
  196. setiastro/saspro/generate_translations.py +3092 -0
  197. setiastro/saspro/ghs_dialog_pro.py +663 -0
  198. setiastro/saspro/ghs_preset.py +284 -0
  199. setiastro/saspro/graxpert.py +637 -0
  200. setiastro/saspro/graxpert_preset.py +287 -0
  201. setiastro/saspro/gui/__init__.py +0 -0
  202. setiastro/saspro/gui/main_window.py +8792 -0
  203. setiastro/saspro/gui/mixins/__init__.py +33 -0
  204. setiastro/saspro/gui/mixins/dock_mixin.py +375 -0
  205. setiastro/saspro/gui/mixins/file_mixin.py +450 -0
  206. setiastro/saspro/gui/mixins/geometry_mixin.py +503 -0
  207. setiastro/saspro/gui/mixins/header_mixin.py +441 -0
  208. setiastro/saspro/gui/mixins/mask_mixin.py +421 -0
  209. setiastro/saspro/gui/mixins/menu_mixin.py +390 -0
  210. setiastro/saspro/gui/mixins/theme_mixin.py +367 -0
  211. setiastro/saspro/gui/mixins/toolbar_mixin.py +1619 -0
  212. setiastro/saspro/gui/mixins/update_mixin.py +323 -0
  213. setiastro/saspro/gui/mixins/view_mixin.py +435 -0
  214. setiastro/saspro/gui/statistics_dialog.py +47 -0
  215. setiastro/saspro/halobgon.py +488 -0
  216. setiastro/saspro/header_viewer.py +448 -0
  217. setiastro/saspro/headless_utils.py +88 -0
  218. setiastro/saspro/histogram.py +756 -0
  219. setiastro/saspro/history_explorer.py +941 -0
  220. setiastro/saspro/i18n.py +168 -0
  221. setiastro/saspro/image_combine.py +417 -0
  222. setiastro/saspro/image_peeker_pro.py +1604 -0
  223. setiastro/saspro/imageops/__init__.py +37 -0
  224. setiastro/saspro/imageops/mdi_snap.py +292 -0
  225. setiastro/saspro/imageops/scnr.py +36 -0
  226. setiastro/saspro/imageops/starbasedwhitebalance.py +210 -0
  227. setiastro/saspro/imageops/stretch.py +236 -0
  228. setiastro/saspro/isophote.py +1182 -0
  229. setiastro/saspro/layers.py +208 -0
  230. setiastro/saspro/layers_dock.py +714 -0
  231. setiastro/saspro/lazy_imports.py +193 -0
  232. setiastro/saspro/legacy/__init__.py +2 -0
  233. setiastro/saspro/legacy/image_manager.py +2360 -0
  234. setiastro/saspro/legacy/numba_utils.py +3676 -0
  235. setiastro/saspro/legacy/xisf.py +1213 -0
  236. setiastro/saspro/linear_fit.py +537 -0
  237. setiastro/saspro/live_stacking.py +1854 -0
  238. setiastro/saspro/log_bus.py +5 -0
  239. setiastro/saspro/logging_config.py +460 -0
  240. setiastro/saspro/luminancerecombine.py +510 -0
  241. setiastro/saspro/main_helpers.py +201 -0
  242. setiastro/saspro/mask_creation.py +1086 -0
  243. setiastro/saspro/masks_core.py +56 -0
  244. setiastro/saspro/mdi_widgets.py +353 -0
  245. setiastro/saspro/memory_utils.py +666 -0
  246. setiastro/saspro/metadata_patcher.py +75 -0
  247. setiastro/saspro/mfdeconv.py +3909 -0
  248. setiastro/saspro/mfdeconv_earlystop.py +71 -0
  249. setiastro/saspro/mfdeconvcudnn.py +3312 -0
  250. setiastro/saspro/mfdeconvsport.py +2459 -0
  251. setiastro/saspro/minorbodycatalog.py +567 -0
  252. setiastro/saspro/morphology.py +407 -0
  253. setiastro/saspro/multiscale_decomp.py +1747 -0
  254. setiastro/saspro/nbtorgb_stars.py +541 -0
  255. setiastro/saspro/numba_utils.py +3145 -0
  256. setiastro/saspro/numba_warmup.py +141 -0
  257. setiastro/saspro/ops/__init__.py +9 -0
  258. setiastro/saspro/ops/command_help_dialog.py +623 -0
  259. setiastro/saspro/ops/command_runner.py +217 -0
  260. setiastro/saspro/ops/commands.py +1594 -0
  261. setiastro/saspro/ops/script_editor.py +1105 -0
  262. setiastro/saspro/ops/scripts.py +1476 -0
  263. setiastro/saspro/ops/settings.py +637 -0
  264. setiastro/saspro/parallel_utils.py +554 -0
  265. setiastro/saspro/pedestal.py +121 -0
  266. setiastro/saspro/perfect_palette_picker.py +1105 -0
  267. setiastro/saspro/pipeline.py +110 -0
  268. setiastro/saspro/pixelmath.py +1604 -0
  269. setiastro/saspro/plate_solver.py +2445 -0
  270. setiastro/saspro/project_io.py +797 -0
  271. setiastro/saspro/psf_utils.py +136 -0
  272. setiastro/saspro/psf_viewer.py +549 -0
  273. setiastro/saspro/pyi_rthook_astroquery.py +95 -0
  274. setiastro/saspro/remove_green.py +331 -0
  275. setiastro/saspro/remove_stars.py +1599 -0
  276. setiastro/saspro/remove_stars_preset.py +446 -0
  277. setiastro/saspro/resources.py +503 -0
  278. setiastro/saspro/rgb_combination.py +208 -0
  279. setiastro/saspro/rgb_extract.py +19 -0
  280. setiastro/saspro/rgbalign.py +723 -0
  281. setiastro/saspro/runtime_imports.py +7 -0
  282. setiastro/saspro/runtime_torch.py +754 -0
  283. setiastro/saspro/save_options.py +73 -0
  284. setiastro/saspro/selective_color.py +1611 -0
  285. setiastro/saspro/sfcc.py +1472 -0
  286. setiastro/saspro/shortcuts.py +3116 -0
  287. setiastro/saspro/signature_insert.py +1102 -0
  288. setiastro/saspro/stacking_suite.py +19066 -0
  289. setiastro/saspro/star_alignment.py +7380 -0
  290. setiastro/saspro/star_alignment_preset.py +329 -0
  291. setiastro/saspro/star_metrics.py +49 -0
  292. setiastro/saspro/star_spikes.py +765 -0
  293. setiastro/saspro/star_stretch.py +507 -0
  294. setiastro/saspro/stat_stretch.py +538 -0
  295. setiastro/saspro/status_log_dock.py +78 -0
  296. setiastro/saspro/subwindow.py +3407 -0
  297. setiastro/saspro/supernovaasteroidhunter.py +1719 -0
  298. setiastro/saspro/swap_manager.py +134 -0
  299. setiastro/saspro/torch_backend.py +89 -0
  300. setiastro/saspro/torch_rejection.py +434 -0
  301. setiastro/saspro/translations/all_source_strings.json +4726 -0
  302. setiastro/saspro/translations/ar_translations.py +4096 -0
  303. setiastro/saspro/translations/de_translations.py +3728 -0
  304. setiastro/saspro/translations/es_translations.py +4169 -0
  305. setiastro/saspro/translations/fr_translations.py +4090 -0
  306. setiastro/saspro/translations/hi_translations.py +3803 -0
  307. setiastro/saspro/translations/integrate_translations.py +271 -0
  308. setiastro/saspro/translations/it_translations.py +4728 -0
  309. setiastro/saspro/translations/ja_translations.py +3834 -0
  310. setiastro/saspro/translations/pt_translations.py +3847 -0
  311. setiastro/saspro/translations/ru_translations.py +3082 -0
  312. setiastro/saspro/translations/saspro_ar.qm +0 -0
  313. setiastro/saspro/translations/saspro_ar.ts +16019 -0
  314. setiastro/saspro/translations/saspro_de.qm +0 -0
  315. setiastro/saspro/translations/saspro_de.ts +14548 -0
  316. setiastro/saspro/translations/saspro_es.qm +0 -0
  317. setiastro/saspro/translations/saspro_es.ts +16202 -0
  318. setiastro/saspro/translations/saspro_fr.qm +0 -0
  319. setiastro/saspro/translations/saspro_fr.ts +15870 -0
  320. setiastro/saspro/translations/saspro_hi.qm +0 -0
  321. setiastro/saspro/translations/saspro_hi.ts +14855 -0
  322. setiastro/saspro/translations/saspro_it.qm +0 -0
  323. setiastro/saspro/translations/saspro_it.ts +19046 -0
  324. setiastro/saspro/translations/saspro_ja.qm +0 -0
  325. setiastro/saspro/translations/saspro_ja.ts +14980 -0
  326. setiastro/saspro/translations/saspro_pt.qm +0 -0
  327. setiastro/saspro/translations/saspro_pt.ts +15024 -0
  328. setiastro/saspro/translations/saspro_ru.qm +0 -0
  329. setiastro/saspro/translations/saspro_ru.ts +11835 -0
  330. setiastro/saspro/translations/saspro_sw.qm +0 -0
  331. setiastro/saspro/translations/saspro_sw.ts +15237 -0
  332. setiastro/saspro/translations/saspro_uk.qm +0 -0
  333. setiastro/saspro/translations/saspro_uk.ts +15248 -0
  334. setiastro/saspro/translations/saspro_zh.qm +0 -0
  335. setiastro/saspro/translations/saspro_zh.ts +15289 -0
  336. setiastro/saspro/translations/sw_translations.py +3897 -0
  337. setiastro/saspro/translations/uk_translations.py +3929 -0
  338. setiastro/saspro/translations/zh_translations.py +3910 -0
  339. setiastro/saspro/versioning.py +77 -0
  340. setiastro/saspro/view_bundle.py +1558 -0
  341. setiastro/saspro/wavescale_hdr.py +645 -0
  342. setiastro/saspro/wavescale_hdr_preset.py +101 -0
  343. setiastro/saspro/wavescalede.py +680 -0
  344. setiastro/saspro/wavescalede_preset.py +230 -0
  345. setiastro/saspro/wcs_update.py +374 -0
  346. setiastro/saspro/whitebalance.py +513 -0
  347. setiastro/saspro/widgets/__init__.py +48 -0
  348. setiastro/saspro/widgets/common_utilities.py +306 -0
  349. setiastro/saspro/widgets/graphics_views.py +122 -0
  350. setiastro/saspro/widgets/image_utils.py +518 -0
  351. setiastro/saspro/widgets/minigame/game.js +991 -0
  352. setiastro/saspro/widgets/minigame/index.html +53 -0
  353. setiastro/saspro/widgets/minigame/style.css +241 -0
  354. setiastro/saspro/widgets/preview_dialogs.py +280 -0
  355. setiastro/saspro/widgets/resource_monitor.py +263 -0
  356. setiastro/saspro/widgets/spinboxes.py +290 -0
  357. setiastro/saspro/widgets/themed_buttons.py +13 -0
  358. setiastro/saspro/widgets/wavelet_utils.py +331 -0
  359. setiastro/saspro/wimi.py +7996 -0
  360. setiastro/saspro/wims.py +578 -0
  361. setiastro/saspro/window_shelf.py +185 -0
  362. setiastro/saspro/xisf.py +1213 -0
  363. setiastrosuitepro-1.6.5.post3.dist-info/METADATA +278 -0
  364. setiastrosuitepro-1.6.5.post3.dist-info/RECORD +368 -0
  365. setiastrosuitepro-1.6.5.post3.dist-info/WHEEL +4 -0
  366. setiastrosuitepro-1.6.5.post3.dist-info/entry_points.txt +6 -0
  367. setiastrosuitepro-1.6.5.post3.dist-info/licenses/LICENSE +674 -0
  368. setiastrosuitepro-1.6.5.post3.dist-info/licenses/license.txt +2580 -0
@@ -0,0 +1,756 @@
1
+ # pro/histogram.py
2
+ from __future__ import annotations
3
+ import numpy as np
4
+
5
+ from PyQt6.QtCore import Qt, QSettings, QTimer, QEvent, pyqtSignal
6
+ from PyQt6.QtWidgets import (
7
+ QDialog, QVBoxLayout, QHBoxLayout, QLabel, QSlider, QPushButton, QScrollArea,
8
+ QTableWidget, QTableWidgetItem, QMessageBox, QToolButton, QInputDialog, QSplitter, QSizePolicy, QHeaderView
9
+ )
10
+ from PyQt6.QtGui import QPixmap, QPainter, QPen, QColor, QFont, QPalette
11
+
12
+ # Shared utilities
13
+ from setiastro.saspro.widgets.image_utils import to_float01 as _to_float01
14
+ from setiastro.saspro.widgets.themed_buttons import themed_toolbtn
15
+
16
+ def _to_float_preserve(img):
17
+ if img is None: return None
18
+ a = np.asarray(img)
19
+ return a.astype(np.float32, copy=False) if a.dtype != np.float32 else a
20
+
21
+
22
+
23
+ class HistogramDialog(QDialog):
24
+ """
25
+ Per-document histogram (non-modal).
26
+ - Connects to ImageDocument.changed and repaints automatically.
27
+ - Multiple dialogs can be open at once (each bound to one doc).
28
+ """
29
+ pivotPicked = pyqtSignal(float) # normalized [0..1] x position for GHS pivot
30
+ def __init__(self, parent, document):
31
+ super().__init__(parent)
32
+ self.setWindowTitle(self.tr("Histogram"))
33
+ self.setWindowFlag(Qt.WindowType.Window, True)
34
+ self.setWindowModality(Qt.WindowModality.NonModal)
35
+ self.setModal(False)
36
+ self.doc = document
37
+ self.image = _to_float_preserve(document.image)
38
+
39
+ self.zoom_factor = 1.0 # 1.0 = 100%
40
+ self.log_scale = False # log X
41
+ self.log_y = False # log Y
42
+ self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
43
+ self._eps_log = 1e-6 # first log bin edge (for labels)
44
+
45
+ # for mapping clicks → normalized x
46
+ self._click_mapping = None # dict or None
47
+ self.settings = QSettings()
48
+ self.sensor_max01 = 1.0
49
+ self.sensor_native_max = None # user ADU max (e.g., 65532)
50
+ self.native_theoretical_max = None
51
+
52
+ # histogram cache
53
+ self._bin_count = 512
54
+ self._bin_edges_lin = None # np.ndarray | None
55
+ self._bin_edges_log = None # np.ndarray | None
56
+ self._counts_lin = None # list[np.ndarray] | None
57
+ self._counts_log = None # list[np.ndarray] | None
58
+ self._is_color = False
59
+ self._eps_log = 1e-6 # first log bin edge (for labels)
60
+
61
+ self._load_sensor_max_setting()
62
+ self._build_ui()
63
+
64
+ # debounce timer for resize / splitter moves
65
+ self._resize_timer = QTimer(self)
66
+ self._resize_timer.setSingleShot(True)
67
+ self._resize_timer.setInterval(80) # ms; tweak if you want snappier/slower
68
+ self._resize_timer.timeout.connect(self._draw_histogram)
69
+
70
+ # prime histogram & stats from initial image
71
+ self._recompute_hist_cache()
72
+ self._update_stats()
73
+
74
+ # wire up to this specific document
75
+ self.doc.changed.connect(self._on_doc_changed)
76
+ # If the doc object goes away, close this dialog
77
+ self.doc.destroyed.connect(self.deleteLater)
78
+
79
+ self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
80
+ self._doc_conn = False
81
+ if getattr(self, "doc", None) is not None:
82
+ try:
83
+ self.doc.destroyed.connect(self._on_doc_destroyed)
84
+ self._doc_conn = True
85
+ except Exception:
86
+ pass
87
+
88
+ # Do the first draw once the widget has a real size
89
+ QTimer.singleShot(0, self._draw_histogram)
90
+
91
+
92
+
93
+ # ---------- UI ----------
94
+ def _build_ui(self):
95
+ # Make it start at a sensible size
96
+ self.setMinimumSize(800, 400)
97
+ self.resize(900, 500)
98
+
99
+ main_layout = QVBoxLayout(self)
100
+
101
+ # --- top area: splitter with histogram + stats ---
102
+ splitter = QSplitter(Qt.Orientation.Horizontal, self)
103
+
104
+ # left: scroll area + label for the pixmap
105
+ self.scroll_area = QScrollArea(self)
106
+ self.scroll_area.setWidgetResizable(True)
107
+ self.scroll_area.setSizePolicy(
108
+ QSizePolicy.Policy.Expanding,
109
+ QSizePolicy.Policy.Expanding,
110
+ )
111
+
112
+ self.hist_label = QLabel(self)
113
+ self.hist_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
114
+ self.scroll_area.setWidget(self.hist_label)
115
+ self.hist_label.installEventFilter(self)
116
+ self.hist_label.setToolTip(self.tr(
117
+ "Ctrl+Click on the histogram to send that intensity as the "
118
+ "pivot to Hyperbolic Stretch (if open)."
119
+ ))
120
+ self.scroll_area.viewport().installEventFilter(self)
121
+
122
+ splitter.addWidget(self.scroll_area)
123
+
124
+ # right: stats table
125
+ self.stats_table = QTableWidget(self)
126
+ self.stats_table.setRowCount(7)
127
+ self.stats_table.setColumnCount(1)
128
+ self.stats_table.setVerticalHeaderLabels([
129
+ self.tr("Min"), self.tr("Max"), self.tr("Median"), self.tr("StdDev"),
130
+ self.tr("MAD"), self.tr("Low Clipped"), self.tr("High Clipped")
131
+ ])
132
+
133
+ # Let it grow/shrink with the splitter
134
+ self.stats_table.setMinimumWidth(320)
135
+ self.stats_table.setSizePolicy(
136
+ QSizePolicy.Policy.Preferred, # <- was Fixed
137
+ QSizePolicy.Policy.Expanding,
138
+ )
139
+
140
+ # Make the columns use available width nicely
141
+ hdr = self.stats_table.horizontalHeader()
142
+ hdr.setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
143
+ # hdr.setStretchLastSection(True)
144
+ splitter.addWidget(self.stats_table)
145
+
146
+ # Give more space to histogram side by default
147
+ splitter.setStretchFactor(0, 3)
148
+ splitter.setStretchFactor(1, 1)
149
+ # Explicit initial sizes so it doesn't start with a tiny histogram
150
+ splitter.setSizes([650, 250])
151
+
152
+ QTimer.singleShot(0, self._adjust_stats_width)
153
+
154
+ main_layout.addWidget(splitter)
155
+
156
+ # --- controls row (unchanged except for being below splitter) ---
157
+ ctl = QHBoxLayout()
158
+ self.zoom_slider = QSlider(Qt.Orientation.Horizontal, self)
159
+ self.zoom_slider.setRange(50, 1000)
160
+ self.zoom_slider.setValue(100)
161
+ self.zoom_slider.setTickInterval(10)
162
+ self.zoom_slider.setTickPosition(QSlider.TickPosition.TicksBelow)
163
+ self.zoom_slider.valueChanged.connect(self._on_zoom_changed)
164
+
165
+ ctl.addWidget(QLabel(self.tr("Zoom:")))
166
+ ctl.addWidget(self.zoom_slider)
167
+
168
+ self.btn_logx = QPushButton(self.tr("Toggle Log X-Axis"), self)
169
+ self.btn_logx.setCheckable(True)
170
+ self.btn_logx.toggled.connect(self._toggle_log_x)
171
+ ctl.addWidget(self.btn_logx)
172
+
173
+ self.btn_logy = QPushButton(self.tr("Toggle Log Y-Axis"), self)
174
+ self.btn_logy.setCheckable(True)
175
+ self.btn_logy.toggled.connect(self._toggle_log_y)
176
+ ctl.addWidget(self.btn_logy)
177
+
178
+ self.btn_sensor_max = QToolButton(self)
179
+ self.btn_sensor_max.setText("?")
180
+ self.btn_sensor_max.setToolTip(self.tr(
181
+ "Set your camera's true saturation level for clipping warnings.\n"
182
+ "Tip: take an overexposed frame and see its max ADU."
183
+ ))
184
+ self.btn_sensor_max.clicked.connect(self._prompt_sensor_max)
185
+ ctl.addWidget(self.btn_sensor_max)
186
+
187
+ main_layout.addLayout(ctl)
188
+
189
+ btn_close = QPushButton(self.tr("Close"), self)
190
+ btn_close.clicked.connect(self.accept)
191
+ main_layout.addWidget(btn_close)
192
+
193
+ self.setLayout(main_layout)
194
+
195
+
196
+
197
+ # ---------- slots ----------
198
+ def _on_doc_changed(self):
199
+ self.image = _to_float_preserve(self.doc.image)
200
+ self._recompute_hist_cache()
201
+ self._update_stats()
202
+ self._draw_histogram()
203
+
204
+ def _on_zoom_changed(self, v: int):
205
+ self.zoom_factor = v / 100.0
206
+ self._draw_histogram()
207
+
208
+ def _toggle_log_x(self, on: bool):
209
+ self.log_scale = bool(on)
210
+ self._draw_histogram()
211
+
212
+ def _toggle_log_y(self, on: bool):
213
+ self.log_y = bool(on)
214
+ self._draw_histogram()
215
+
216
+ # ---------- drawing ----------
217
+ # ---------- drawing ----------
218
+ def _draw_histogram(self):
219
+ # nothing to draw yet
220
+ if self.image is None or self._bin_edges_lin is None:
221
+ self.hist_label.clear()
222
+ return
223
+
224
+ # use available size in the scroll area's viewport
225
+ if self.scroll_area is not None:
226
+ vp = self.scroll_area.viewport()
227
+ avail_w = max(200, vp.width())
228
+ avail_h = max(200, vp.height())
229
+ else:
230
+ avail_w = 512
231
+ avail_h = 300
232
+
233
+ base_width = avail_w
234
+ height = avail_h
235
+ width = int(base_width * self.zoom_factor)
236
+
237
+ # layout margins
238
+ left_margin = 32 # room for Y labels
239
+ top_margin = 12 # room so top ticks/text aren't clipped
240
+ bottom_margin = 24 # room for X labels
241
+ axis_y = height - bottom_margin
242
+ usable_h = max(1, axis_y - top_margin)
243
+ plot_width = max(1, width - left_margin)
244
+
245
+ # choose edges + raw counts from cache
246
+ if self.log_scale:
247
+ bin_edges = self._bin_edges_log
248
+ counts_list = self._counts_log
249
+ else:
250
+ bin_edges = self._bin_edges_lin
251
+ counts_list = self._counts_lin
252
+
253
+ if bin_edges is None or counts_list is None:
254
+ self.hist_label.clear()
255
+ return
256
+
257
+ bin_count = len(bin_edges) - 1
258
+
259
+ # precompute log range if needed
260
+ if self.log_scale:
261
+ # guard: avoid log10(<=0)
262
+ be0 = float(bin_edges[0])
263
+ if be0 <= 0:
264
+ be0 = self._eps_log
265
+ log_min = np.log10(be0)
266
+ log_max = 0.0
267
+ else:
268
+ log_min = None
269
+ log_max = None
270
+
271
+ # map X-domain edge → pixel X
272
+ def x_pos(edge: float) -> int:
273
+ if self.log_scale:
274
+ if edge <= 0:
275
+ edge = self._eps_log
276
+ if abs(log_max - log_min) < 1e-12:
277
+ return left_margin
278
+ return left_margin + int(
279
+ (np.log10(edge) - log_min) / (log_max - log_min) * plot_width
280
+ )
281
+ else:
282
+ return left_margin + int(edge * plot_width)
283
+
284
+ # --- convert counts → display values (linear or log Y) ---
285
+ vals_list: list[np.ndarray] = []
286
+ max_val = 0.0
287
+ for counts in counts_list:
288
+ if self.log_y:
289
+ vals = np.log10(counts + 1.0)
290
+ else:
291
+ vals = counts.astype(np.float32)
292
+ if vals.size:
293
+ max_val = max(max_val, float(vals.max()))
294
+ vals_list.append(vals)
295
+
296
+ if max_val <= 0:
297
+ max_val = 1.0
298
+
299
+ # theme colors
300
+ pal = self.window().palette() if self.window() else self.palette()
301
+ bg_color = pal.color(QPalette.ColorRole.Window)
302
+ text_color = pal.color(QPalette.ColorRole.Text)
303
+
304
+ if bg_color.lightness() < 128:
305
+ axis_color = QColor(210, 210, 210)
306
+ label_color = QColor(245, 245, 245)
307
+ else:
308
+ axis_color = QColor(40, 40, 40)
309
+ label_color = text_color
310
+
311
+ grid_color = QColor(axis_color)
312
+ grid_color.setAlpha(60)
313
+ grid_pen = QPen(grid_color)
314
+ grid_pen.setWidth(1)
315
+
316
+ pm = QPixmap(width, height)
317
+ pm.fill(bg_color)
318
+ p = QPainter(pm)
319
+ p.setRenderHint(QPainter.RenderHint.Antialiasing)
320
+
321
+ # helper: map normalized [0,1] → Y pixel (0 at bottom, 1 at top)
322
+ def y_pos(norm: float) -> int:
323
+ # norm in [0,1], map 0→axis_y, 1→top_margin
324
+ return int(top_margin + (1.0 - norm) * usable_h)
325
+
326
+ # ----- draw bars -----
327
+ if self._is_color:
328
+ colors = [
329
+ QColor(255, 0, 0, 140),
330
+ QColor(0, 180, 0, 140),
331
+ QColor(0, 0, 255, 140),
332
+ ]
333
+ for ch_idx, vals in enumerate(vals_list):
334
+ hn = vals / max_val
335
+ p.setPen(QPen(colors[ch_idx]))
336
+ for i in range(bin_count):
337
+ x0 = x_pos(float(bin_edges[i]))
338
+ x1 = x_pos(float(bin_edges[i + 1]))
339
+ w = max(1, x1 - x0)
340
+ h = int(hn[i] * usable_h)
341
+ y0 = axis_y - h
342
+ p.drawRect(x0, y0, w, h)
343
+ else:
344
+ vals = vals_list[0]
345
+ hn = vals / max_val
346
+ p.setPen(QPen(axis_color))
347
+ for i in range(bin_count):
348
+ x0 = x_pos(float(bin_edges[i]))
349
+ x1 = x_pos(float(bin_edges[i + 1]))
350
+ w = max(1, x1 - x0)
351
+ h = int(hn[i] * usable_h)
352
+ y0 = axis_y - h
353
+ p.drawRect(x0, y0, w, h)
354
+
355
+ # ----- axes -----
356
+ p.setPen(QPen(axis_color, 2))
357
+ # X axis at axis_y, Y axis from top_margin down to axis_y
358
+ p.drawLine(left_margin, axis_y, width - 1, axis_y)
359
+ p.drawLine(left_margin, top_margin, left_margin, axis_y)
360
+
361
+ p.setFont(QFont("Arial", 10))
362
+
363
+ # ----- X ticks + grid -----
364
+ if self.log_scale:
365
+ ticks = np.logspace(np.log10(bin_edges[0]), 0.0, 11)
366
+ for t in ticks:
367
+ x = x_pos(float(t))
368
+ if left_margin < x < width - 1:
369
+ p.setPen(grid_pen)
370
+ p.drawLine(x, top_margin, x, axis_y)
371
+ p.setPen(axis_color)
372
+ p.drawLine(x, axis_y, x, axis_y - 5)
373
+ p.setPen(label_color)
374
+ p.drawText(x - 18, axis_y + bottom_margin - 8, f"{t:.3f}")
375
+ else:
376
+ ticks = np.linspace(0.0, 1.0, 11)
377
+ for t in ticks:
378
+ x = x_pos(float(t))
379
+ if left_margin < x < width - 1:
380
+ p.setPen(grid_pen)
381
+ p.drawLine(x, top_margin, x, axis_y)
382
+ p.setPen(axis_color)
383
+ p.drawLine(x, axis_y, x, axis_y - 5)
384
+ p.setPen(label_color)
385
+ p.drawText(x - 10, axis_y + bottom_margin - 8, f"{t:.1f}")
386
+
387
+ # ----- Y ticks + grid -----
388
+ n_yticks = 6
389
+ if self.log_y:
390
+ exps = np.linspace(0.0, max_val, n_yticks)
391
+ norms = exps / max_val
392
+ labels = [f"{10**e:.0f}" for e in exps]
393
+ else:
394
+ vals_for_ticks = np.linspace(0.0, max_val, n_yticks)
395
+ norms = vals_for_ticks / max_val
396
+ labels = [f"{v:.0f}" for v in vals_for_ticks]
397
+
398
+ for i, (yn, lab) in enumerate(zip(norms, labels)):
399
+ y = y_pos(float(yn))
400
+ if 0 < i < n_yticks - 1:
401
+ p.setPen(grid_pen)
402
+ p.drawLine(left_margin, y, width - 1, y)
403
+ p.setPen(axis_color)
404
+ p.drawLine(left_margin - 5, y, left_margin, y)
405
+ p.setPen(label_color)
406
+ p.drawText(2, y + 4, lab)
407
+
408
+ # --- draw effective-max marker if user set one ---
409
+ if self.sensor_max01 < 0.9999:
410
+ x = x_pos(self.sensor_max01)
411
+ p.setPen(QPen(QColor(220, 0, 0), 2, Qt.PenStyle.DashLine))
412
+ p.drawLine(x, top_margin, x, axis_y)
413
+ p.drawText(min(x + 4, width - 80), top_margin + 12,
414
+ self.tr("True Max {0:.4f}").format(self.sensor_max01))
415
+ # store mapping info for Ctrl+click → normalized x
416
+ try:
417
+ self._click_mapping = {
418
+ "left_margin": left_margin,
419
+ "plot_width": plot_width,
420
+ "axis_y": axis_y,
421
+ "top_margin": top_margin,
422
+ "height": height,
423
+ "log_scale": bool(self.log_scale),
424
+ "log_min": log_min,
425
+ "log_max": log_max,
426
+ }
427
+ except Exception:
428
+ self._click_mapping = None
429
+ p.end()
430
+ self.hist_label.setPixmap(pm)
431
+ self.hist_label.resize(pm.size())
432
+
433
+ def _x_pix_to_u(self, x_pix: int) -> float | None:
434
+ """
435
+ Map a horizontal pixel coordinate (in the label) to a normalized
436
+ intensity in [0..1], respecting linear / log X modes.
437
+ """
438
+ m = self._click_mapping
439
+ if not m:
440
+ return None
441
+
442
+ left = m["left_margin"]
443
+ width = max(1, m["plot_width"])
444
+ if x_pix < left or x_pix > left + width:
445
+ return None
446
+
447
+ t = (x_pix - left) / float(width)
448
+ t = max(0.0, min(1.0, t))
449
+
450
+ if not m["log_scale"]:
451
+ # linear: domain is already [0..1]
452
+ return float(t)
453
+
454
+ # log X: t in [0..1] corresponds to [10^log_min .. 10^log_max] (log_max ~ 0)
455
+ log_min = m.get("log_min", None)
456
+ log_max = m.get("log_max", None)
457
+ if log_min is None or log_max is None or abs(log_max - log_min) < 1e-12:
458
+ return float(t)
459
+
460
+ log_v = log_min + t * (log_max - log_min)
461
+ v = 10.0 ** log_v
462
+ # v is in (eps .. 1]; clamp to [0..1]
463
+ return float(max(0.0, min(1.0, v)))
464
+
465
+
466
+ def _recompute_hist_cache(self):
467
+ """Compute histograms once for the current image.
468
+
469
+ This is called when the document image changes. Resizing / zooming
470
+ will only redraw using this cached data.
471
+ """
472
+ img = self.image
473
+ self._bin_edges_lin = None
474
+ self._bin_edges_log = None
475
+ self._counts_lin = None
476
+ self._counts_log = None
477
+ self._is_color = False
478
+ self._eps_log = 1e-6
479
+
480
+ if img is None:
481
+ return
482
+
483
+ a = img
484
+ if a.ndim == 3 and a.shape[2] == 1:
485
+ a = a[..., 0]
486
+
487
+ if a.ndim == 3 and a.shape[2] == 3:
488
+ chans = [a[..., i] for i in range(3)]
489
+ self._is_color = True
490
+ else:
491
+ chan = a if a.ndim == 2 else a[..., 0]
492
+ chans = [chan]
493
+ self._is_color = False
494
+
495
+ bin_count = self._bin_count
496
+
497
+ # --- linear X bins ---
498
+ bin_edges_lin = np.linspace(0.0, 1.0, bin_count + 1).astype(np.float32)
499
+ counts_lin: list[np.ndarray] = []
500
+ for c in chans:
501
+ counts, _ = np.histogram(c.ravel(), bins=bin_edges_lin)
502
+ counts_lin.append(counts.astype(np.float32))
503
+
504
+ # --- log X bins ---
505
+ pos = a[a > 0]
506
+ eps = max(1e-6, float(pos.min())) if pos.size else 1e-6
507
+ log_min, log_max = np.log10(eps), 0.0
508
+ if abs(log_max - log_min) < 1e-12:
509
+ bin_edges_log = np.linspace(eps, 1.0, bin_count + 1).astype(np.float32)
510
+ else:
511
+ bin_edges_log = np.logspace(log_min, log_max, bin_count + 1).astype(np.float32)
512
+
513
+ counts_log: list[np.ndarray] = []
514
+ for c in chans:
515
+ counts, _ = np.histogram(c.ravel(), bins=bin_edges_log)
516
+ counts_log.append(counts.astype(np.float32))
517
+
518
+ self._bin_edges_lin = bin_edges_lin
519
+ self._bin_edges_log = bin_edges_log
520
+ self._counts_lin = counts_lin
521
+ self._counts_log = counts_log
522
+ self._eps_log = float(eps)
523
+
524
+
525
+ def _schedule_redraw(self):
526
+ # Only bother if visible; restart timer each time
527
+ if self.isVisible():
528
+ self._resize_timer.start()
529
+
530
+ def resizeEvent(self, event):
531
+ super().resizeEvent(event)
532
+ self._schedule_redraw()
533
+
534
+ def eventFilter(self, obj, event):
535
+ # Ctrl+click on the histogram pixmap → emit pivotPicked(u)
536
+ if obj is self.hist_label and event.type() == QEvent.Type.MouseButtonPress:
537
+ if (event.button() == Qt.MouseButton.LeftButton and
538
+ (event.modifiers() & Qt.KeyboardModifier.ControlModifier)):
539
+ pos = event.position().toPoint()
540
+ u = self._x_pix_to_u(pos.x())
541
+ if u is not None:
542
+ # emit normalized pivot in [0..1]
543
+ self.pivotPicked.emit(u)
544
+ event.accept()
545
+ return True
546
+
547
+ # When the splitter moves, the scroll_area viewport gets a Resize event
548
+ if self.scroll_area is not None and obj is self.scroll_area.viewport():
549
+ if event.type() == QEvent.Type.Resize:
550
+ self._schedule_redraw()
551
+
552
+ return super().eventFilter(obj, event)
553
+
554
+ def _update_stats(self):
555
+ if self.image is None:
556
+ return
557
+
558
+ img = self.image
559
+ # determine channels
560
+ if img.ndim == 3 and img.shape[2] == 3:
561
+ chans = [img[..., i] for i in range(3)]
562
+ self.stats_table.setColumnCount(3)
563
+ self.stats_table.setHorizontalHeaderLabels(["R", "G", "B"])
564
+ else:
565
+ chan = img if img.ndim == 2 else img[..., 0]
566
+ chans = [chan]
567
+ self.stats_table.setColumnCount(1)
568
+ self.stats_table.setHorizontalHeaderLabels(["Gray"])
569
+
570
+ eps = 1e-6 # tolerance for "exactly 0/1" after float ops
571
+
572
+ row_defs = [
573
+ (self.tr("Min"), lambda c: float(np.min(c)), "{:.4f}"),
574
+ (self.tr("Max"), lambda c: float(np.max(c)), "{:.4f}"),
575
+ (self.tr("Median"), lambda c: float(np.median(c)), "{:.4f}"),
576
+ (self.tr("StdDev"), lambda c: float(np.std(c)), "{:.4f}"),
577
+ (self.tr("MAD"), lambda c: float(np.median(np.abs(c - np.median(c)))), "{:.4f}"),
578
+ (self.tr("Low Clipped"), lambda c: _clip_fmt(c, low=True, eps=eps), "{}"),
579
+ (self.tr("High Clipped"), lambda c: _clip_fmt(c, low=False, eps=eps), "{}"),
580
+ ]
581
+
582
+ def _clip_fmt(c, low: bool, eps: float):
583
+ flat = np.ravel(c)
584
+ n = flat.size if flat.size else 1
585
+ if low:
586
+ k = int(np.count_nonzero(flat <= eps))
587
+ else:
588
+ hi_thr = max(eps, self.sensor_max01 - eps)
589
+ k = int(np.count_nonzero(flat >= hi_thr))
590
+ pct = 100.0 * k / n
591
+ return f"{k} ({pct:.3f}%)"
592
+
593
+ # apply labels + sizes
594
+ self.stats_table.setRowCount(len(row_defs))
595
+ self.stats_table.setVerticalHeaderLabels([lab for lab, _, _ in row_defs])
596
+
597
+ # fill cells
598
+ for r, (lab, fn, fmt) in enumerate(row_defs):
599
+ for c_idx, c_arr in enumerate(chans):
600
+ val = fn(c_arr)
601
+ text = fmt.format(val)
602
+ it = QTableWidgetItem(text)
603
+ it.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
604
+
605
+ # --- visual pop for non-trivial clipping ---
606
+ if lab in (self.tr("Low Clipped"), self.tr("High Clipped")):
607
+ # text looks like: "123 (0.456%)"
608
+ try:
609
+ pct_str = text.split("(")[1].split("%")[0]
610
+ pct = float(pct_str)
611
+ except Exception:
612
+ pct = 0.0
613
+
614
+ # thresholds you can tweak
615
+ # <0.01%: ignore
616
+ # 0.01–0.1%: mild warning
617
+ # 0.1–1%: clear warning
618
+ # >1%: strong warning
619
+ if pct >= 1.0:
620
+ it.setBackground(QColor(100, 30, 30)) # strong red tint
621
+ elif pct >= 0.1:
622
+ it.setBackground(QColor(70, 30, 30)) # medium red tint
623
+ elif pct >= 0.01:
624
+ it.setBackground(QColor(40, 30, 30)) # mild red tint
625
+
626
+ self.stats_table.setItem(r, c_idx, it)
627
+
628
+ self._adjust_stats_width()
629
+
630
+ def _theoretical_native_max_from_meta(self):
631
+ meta = getattr(self.doc, "metadata", None) or {}
632
+ bd = str(meta.get("bit_depth", "")).lower()
633
+
634
+ if "16-bit" in bd:
635
+ return 65535
636
+ if "8-bit" in bd:
637
+ return 255
638
+ if "32-bit unsigned" in bd:
639
+ return 4294967295
640
+ return None
641
+
642
+ def _settings_key_for_native_max(self, native_theoretical_max):
643
+ if native_theoretical_max == 65535:
644
+ return "histogram/sensor_max_native_16"
645
+ if native_theoretical_max == 255:
646
+ return "histogram/sensor_max_native_8"
647
+ if native_theoretical_max == 4294967295:
648
+ return "histogram/sensor_max_native_32u"
649
+ return "histogram/sensor_max_native_generic"
650
+
651
+ def _load_sensor_max_setting(self):
652
+ self.native_theoretical_max = self._theoretical_native_max_from_meta()
653
+ if self.native_theoretical_max:
654
+ key = self._settings_key_for_native_max(self.native_theoretical_max)
655
+ val = self.settings.value(key, None)
656
+ if val is not None:
657
+ try:
658
+ self.sensor_native_max = float(val)
659
+ except Exception:
660
+ self.sensor_native_max = None
661
+
662
+ self._recompute_effective_max01()
663
+
664
+ def _recompute_effective_max01(self):
665
+ if self.native_theoretical_max and self.sensor_native_max:
666
+ self.sensor_max01 = float(self.sensor_native_max) / float(self.native_theoretical_max)
667
+ self.sensor_max01 = float(np.clip(self.sensor_max01, 1e-6, 1.0))
668
+ else:
669
+ self.sensor_max01 = 1.0
670
+
671
+ def _prompt_sensor_max(self):
672
+ self.native_theoretical_max = self._theoretical_native_max_from_meta()
673
+
674
+ if self.native_theoretical_max:
675
+ key = self._settings_key_for_native_max(self.native_theoretical_max)
676
+ current = self.sensor_native_max or self.native_theoretical_max
677
+
678
+ val, ok = QInputDialog.getInt(
679
+ self,
680
+ self.tr("Sensor True Max (ADU)"),
681
+ self.tr("Enter your sensor's true saturation value in native ADU.\n"
682
+ "(Typical max for this file type is {0})\n\n"
683
+ "You can measure this by taking a deliberately overexposed frame\n"
684
+ "and reading its maximum pixel value.").format(self.native_theoretical_max),
685
+ int(current),
686
+ 1,
687
+ int(self.native_theoretical_max)
688
+ )
689
+ if ok:
690
+ self.sensor_native_max = float(val)
691
+ self.settings.setValue(key, float(val))
692
+ else:
693
+ # float images / unknown depth: allow normalized max
694
+ val, ok = QInputDialog.getDouble(
695
+ self,
696
+ self.tr("Histogram Effective Max"),
697
+ self.tr("Enter effective maximum for clipping (normalized units)."),
698
+ float(self.sensor_max01),
699
+ 1e-6,
700
+ 1.0,
701
+ 6
702
+ )
703
+ if ok:
704
+ self.sensor_max01 = float(val)
705
+ self.settings.setValue("histogram/sensor_max01_generic", float(val))
706
+
707
+ self._recompute_effective_max01()
708
+ self._update_stats() # High Clipped row depends on sensor_max01
709
+ self._draw_histogram()
710
+
711
+ def _adjust_stats_width(self):
712
+ """Resize stats table so all columns are visible without a scrollbar."""
713
+ if not self.stats_table:
714
+ return
715
+
716
+ # Let Qt compute natural column widths
717
+ self.stats_table.resizeColumnsToContents()
718
+ self.stats_table.resizeRowsToContents()
719
+
720
+ vh = self.stats_table.verticalHeader()
721
+ frame = self.stats_table.frameWidth()
722
+
723
+ total_w = vh.width() + 2 * frame
724
+
725
+ for col in range(self.stats_table.columnCount()):
726
+ total_w += self.stats_table.columnWidth(col)
727
+
728
+ # Room for a possible vertical scrollbar
729
+ vbar = self.stats_table.verticalScrollBar()
730
+ if vbar is not None:
731
+ total_w += vbar.sizeHint().width()
732
+
733
+ # A tiny padding so text isn't tight
734
+ total_w += 6
735
+
736
+ self.stats_table.setMinimumWidth(total_w)
737
+
738
+
739
+ def _on_doc_destroyed(self, *args):
740
+ # Called when the owner/document goes away.
741
+ try:
742
+ # Avoid re-entrancy; schedule deletion safely.
743
+ self.deleteLater()
744
+ except RuntimeError:
745
+ pass
746
+
747
+ def closeEvent(self, event):
748
+ # Cleanly disconnect to avoid stray callbacks.
749
+ if getattr(self, "_doc_conn", False) and getattr(self, "doc", None) is not None:
750
+ try:
751
+ self.doc.destroyed.disconnect(self._on_doc_destroyed)
752
+ except (TypeError, RuntimeError):
753
+ pass
754
+ self._doc_conn = False
755
+
756
+ super().closeEvent(event)