scitex 2.7.0__py3-none-any.whl → 2.7.3__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 (297) hide show
  1. scitex/__init__.py +6 -2
  2. scitex/__version__.py +1 -1
  3. scitex/audio/README.md +52 -0
  4. scitex/audio/__init__.py +384 -0
  5. scitex/audio/__main__.py +129 -0
  6. scitex/audio/_tts.py +334 -0
  7. scitex/audio/engines/__init__.py +44 -0
  8. scitex/audio/engines/base.py +275 -0
  9. scitex/audio/engines/elevenlabs_engine.py +143 -0
  10. scitex/audio/engines/gtts_engine.py +162 -0
  11. scitex/audio/engines/pyttsx3_engine.py +131 -0
  12. scitex/audio/mcp_server.py +757 -0
  13. scitex/bridge/_helpers.py +1 -1
  14. scitex/bridge/_plt_vis.py +1 -1
  15. scitex/bridge/_stats_vis.py +1 -1
  16. scitex/dev/plt/__init__.py +272 -0
  17. scitex/dev/plt/plot_mpl_axhline.py +28 -0
  18. scitex/dev/plt/plot_mpl_axhspan.py +28 -0
  19. scitex/dev/plt/plot_mpl_axvline.py +28 -0
  20. scitex/dev/plt/plot_mpl_axvspan.py +28 -0
  21. scitex/dev/plt/plot_mpl_bar.py +29 -0
  22. scitex/dev/plt/plot_mpl_barh.py +29 -0
  23. scitex/dev/plt/plot_mpl_boxplot.py +28 -0
  24. scitex/dev/plt/plot_mpl_contour.py +31 -0
  25. scitex/dev/plt/plot_mpl_contourf.py +31 -0
  26. scitex/dev/plt/plot_mpl_errorbar.py +30 -0
  27. scitex/dev/plt/plot_mpl_eventplot.py +28 -0
  28. scitex/dev/plt/plot_mpl_fill.py +30 -0
  29. scitex/dev/plt/plot_mpl_fill_between.py +31 -0
  30. scitex/dev/plt/plot_mpl_hexbin.py +28 -0
  31. scitex/dev/plt/plot_mpl_hist.py +28 -0
  32. scitex/dev/plt/plot_mpl_hist2d.py +28 -0
  33. scitex/dev/plt/plot_mpl_imshow.py +29 -0
  34. scitex/dev/plt/plot_mpl_pcolormesh.py +31 -0
  35. scitex/dev/plt/plot_mpl_pie.py +29 -0
  36. scitex/dev/plt/plot_mpl_plot.py +29 -0
  37. scitex/dev/plt/plot_mpl_quiver.py +31 -0
  38. scitex/dev/plt/plot_mpl_scatter.py +28 -0
  39. scitex/dev/plt/plot_mpl_stackplot.py +31 -0
  40. scitex/dev/plt/plot_mpl_stem.py +29 -0
  41. scitex/dev/plt/plot_mpl_step.py +29 -0
  42. scitex/dev/plt/plot_mpl_violinplot.py +28 -0
  43. scitex/dev/plt/plot_sns_barplot.py +29 -0
  44. scitex/dev/plt/plot_sns_boxplot.py +29 -0
  45. scitex/dev/plt/plot_sns_heatmap.py +28 -0
  46. scitex/dev/plt/plot_sns_histplot.py +29 -0
  47. scitex/dev/plt/plot_sns_kdeplot.py +29 -0
  48. scitex/dev/plt/plot_sns_lineplot.py +31 -0
  49. scitex/dev/plt/plot_sns_scatterplot.py +29 -0
  50. scitex/dev/plt/plot_sns_stripplot.py +29 -0
  51. scitex/dev/plt/plot_sns_swarmplot.py +29 -0
  52. scitex/dev/plt/plot_sns_violinplot.py +29 -0
  53. scitex/dev/plt/plot_stx_bar.py +29 -0
  54. scitex/dev/plt/plot_stx_barh.py +29 -0
  55. scitex/dev/plt/plot_stx_box.py +28 -0
  56. scitex/dev/plt/plot_stx_boxplot.py +28 -0
  57. scitex/dev/plt/plot_stx_conf_mat.py +28 -0
  58. scitex/dev/plt/plot_stx_contour.py +31 -0
  59. scitex/dev/plt/plot_stx_ecdf.py +28 -0
  60. scitex/dev/plt/plot_stx_errorbar.py +30 -0
  61. scitex/dev/plt/plot_stx_fill_between.py +31 -0
  62. scitex/dev/plt/plot_stx_fillv.py +28 -0
  63. scitex/dev/plt/plot_stx_heatmap.py +28 -0
  64. scitex/dev/plt/plot_stx_image.py +28 -0
  65. scitex/dev/plt/plot_stx_imshow.py +28 -0
  66. scitex/dev/plt/plot_stx_joyplot.py +28 -0
  67. scitex/dev/plt/plot_stx_kde.py +28 -0
  68. scitex/dev/plt/plot_stx_line.py +28 -0
  69. scitex/dev/plt/plot_stx_mean_ci.py +28 -0
  70. scitex/dev/plt/plot_stx_mean_std.py +28 -0
  71. scitex/dev/plt/plot_stx_median_iqr.py +28 -0
  72. scitex/dev/plt/plot_stx_raster.py +28 -0
  73. scitex/dev/plt/plot_stx_rectangle.py +28 -0
  74. scitex/dev/plt/plot_stx_scatter.py +29 -0
  75. scitex/dev/plt/plot_stx_shaded_line.py +29 -0
  76. scitex/dev/plt/plot_stx_violin.py +28 -0
  77. scitex/dev/plt/plot_stx_violinplot.py +28 -0
  78. scitex/fig/__init__.py +352 -0
  79. scitex/{vis → fig}/backend/_parser.py +1 -1
  80. scitex/{vis → fig}/canvas.py +1 -1
  81. scitex/{vis → fig}/editor/_defaults.py +70 -5
  82. scitex/fig/editor/_edit.py +751 -0
  83. scitex/{vis → fig}/editor/_qt_editor.py +181 -1
  84. scitex/fig/editor/flask_editor/_bbox.py +1276 -0
  85. scitex/fig/editor/flask_editor/_core.py +624 -0
  86. scitex/{vis → fig}/editor/flask_editor/_plotter.py +38 -4
  87. scitex/fig/editor/flask_editor/_renderer.py +739 -0
  88. scitex/{vis → fig}/editor/flask_editor/templates/__init__.py +1 -1
  89. scitex/fig/editor/flask_editor/templates/_html.py +834 -0
  90. scitex/fig/editor/flask_editor/templates/_scripts.py +3136 -0
  91. scitex/{vis → fig}/editor/flask_editor/templates/_styles.py +625 -18
  92. scitex/{vis → fig}/io/__init__.py +13 -1
  93. scitex/fig/io/_bundle.py +973 -0
  94. scitex/{vis → fig}/io/_canvas.py +1 -1
  95. scitex/{vis → fig}/io/_data.py +1 -1
  96. scitex/{vis → fig}/io/_export.py +1 -1
  97. scitex/{vis → fig}/io/_load.py +1 -1
  98. scitex/{vis → fig}/io/_panel.py +1 -1
  99. scitex/{vis → fig}/io/_save.py +1 -1
  100. scitex/{vis → fig}/model/__init__.py +1 -1
  101. scitex/{vis → fig}/model/_annotations.py +1 -1
  102. scitex/{vis → fig}/model/_axes.py +1 -1
  103. scitex/{vis → fig}/model/_figure.py +1 -1
  104. scitex/{vis → fig}/model/_guides.py +1 -1
  105. scitex/{vis → fig}/model/_plot.py +1 -1
  106. scitex/{vis → fig}/model/_styles.py +1 -1
  107. scitex/{vis → fig}/utils/__init__.py +1 -1
  108. scitex/io/__init__.py +10 -26
  109. scitex/io/_bundle.py +434 -0
  110. scitex/io/_flush.py +5 -2
  111. scitex/io/_load.py +98 -0
  112. scitex/io/_load_modules/_H5Explorer.py +5 -2
  113. scitex/io/_load_modules/_canvas.py +2 -2
  114. scitex/io/_load_modules/_image.py +3 -4
  115. scitex/io/_load_modules/_txt.py +4 -2
  116. scitex/io/_metadata.py +34 -324
  117. scitex/io/_metadata_modules/__init__.py +46 -0
  118. scitex/io/_metadata_modules/_embed.py +70 -0
  119. scitex/io/_metadata_modules/_read.py +64 -0
  120. scitex/io/_metadata_modules/_utils.py +79 -0
  121. scitex/io/_metadata_modules/embed_metadata_jpeg.py +74 -0
  122. scitex/io/_metadata_modules/embed_metadata_pdf.py +53 -0
  123. scitex/io/_metadata_modules/embed_metadata_png.py +26 -0
  124. scitex/io/_metadata_modules/embed_metadata_svg.py +62 -0
  125. scitex/io/_metadata_modules/read_metadata_jpeg.py +57 -0
  126. scitex/io/_metadata_modules/read_metadata_pdf.py +51 -0
  127. scitex/io/_metadata_modules/read_metadata_png.py +39 -0
  128. scitex/io/_metadata_modules/read_metadata_svg.py +44 -0
  129. scitex/io/_qr_utils.py +5 -3
  130. scitex/io/_save.py +548 -30
  131. scitex/io/_save_modules/_canvas.py +3 -3
  132. scitex/io/_save_modules/_image.py +5 -9
  133. scitex/io/_save_modules/_tex.py +7 -4
  134. scitex/io/utils/h5_to_zarr.py +11 -9
  135. scitex/msword/__init__.py +255 -0
  136. scitex/msword/profiles.py +357 -0
  137. scitex/msword/reader.py +753 -0
  138. scitex/msword/utils.py +289 -0
  139. scitex/msword/writer.py +362 -0
  140. scitex/plt/__init__.py +5 -2
  141. scitex/plt/_subplots/_AxesWrapper.py +6 -6
  142. scitex/plt/_subplots/_AxisWrapper.py +15 -9
  143. scitex/plt/_subplots/_AxisWrapperMixins/_AdjustmentMixin/__init__.py +36 -0
  144. scitex/plt/_subplots/_AxisWrapperMixins/_AdjustmentMixin/_labels.py +264 -0
  145. scitex/plt/_subplots/_AxisWrapperMixins/_AdjustmentMixin/_metadata.py +213 -0
  146. scitex/plt/_subplots/_AxisWrapperMixins/_AdjustmentMixin/_visual.py +128 -0
  147. scitex/plt/_subplots/_AxisWrapperMixins/_MatplotlibPlotMixin/__init__.py +59 -0
  148. scitex/plt/_subplots/_AxisWrapperMixins/_MatplotlibPlotMixin/_base.py +34 -0
  149. scitex/plt/_subplots/_AxisWrapperMixins/_MatplotlibPlotMixin/_scientific.py +593 -0
  150. scitex/plt/_subplots/_AxisWrapperMixins/_MatplotlibPlotMixin/_statistical.py +654 -0
  151. scitex/plt/_subplots/_AxisWrapperMixins/_MatplotlibPlotMixin/_stx_aliases.py +527 -0
  152. scitex/plt/_subplots/_AxisWrapperMixins/_RawMatplotlibMixin.py +321 -0
  153. scitex/plt/_subplots/_AxisWrapperMixins/_SeabornMixin/__init__.py +33 -0
  154. scitex/plt/_subplots/_AxisWrapperMixins/_SeabornMixin/_base.py +152 -0
  155. scitex/plt/_subplots/_AxisWrapperMixins/_SeabornMixin/_wrappers.py +600 -0
  156. scitex/plt/_subplots/_AxisWrapperMixins/__init__.py +79 -5
  157. scitex/plt/_subplots/_FigWrapper.py +6 -6
  158. scitex/plt/_subplots/_SubplotsWrapper.py +28 -18
  159. scitex/plt/_subplots/_export_as_csv.py +35 -5
  160. scitex/plt/_subplots/_export_as_csv_formatters/__init__.py +8 -0
  161. scitex/plt/_subplots/_export_as_csv_formatters/_format_annotate.py +10 -21
  162. scitex/plt/_subplots/_export_as_csv_formatters/_format_eventplot.py +18 -7
  163. scitex/plt/_subplots/_export_as_csv_formatters/_format_imshow2d.py +28 -12
  164. scitex/plt/_subplots/_export_as_csv_formatters/_format_matshow.py +10 -4
  165. scitex/plt/_subplots/_export_as_csv_formatters/_format_plot_imshow.py +13 -1
  166. scitex/plt/_subplots/_export_as_csv_formatters/_format_plot_kde.py +12 -2
  167. scitex/plt/_subplots/_export_as_csv_formatters/_format_plot_scatter.py +10 -3
  168. scitex/plt/_subplots/_export_as_csv_formatters/_format_quiver.py +10 -4
  169. scitex/plt/_subplots/_export_as_csv_formatters/_format_sns_jointplot.py +18 -3
  170. scitex/plt/_subplots/_export_as_csv_formatters/_format_sns_lineplot.py +44 -36
  171. scitex/plt/_subplots/_export_as_csv_formatters/_format_sns_pairplot.py +14 -2
  172. scitex/plt/_subplots/_export_as_csv_formatters/_format_streamplot.py +11 -5
  173. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_bar.py +84 -0
  174. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_barh.py +85 -0
  175. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_conf_mat.py +14 -3
  176. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_contour.py +54 -0
  177. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_ecdf.py +14 -2
  178. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_errorbar.py +120 -0
  179. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_heatmap.py +16 -6
  180. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_image.py +29 -19
  181. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_imshow.py +63 -0
  182. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_joyplot.py +22 -5
  183. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_mean_ci.py +18 -14
  184. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_mean_std.py +18 -14
  185. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_median_iqr.py +18 -14
  186. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_raster.py +10 -2
  187. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_scatter.py +51 -0
  188. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_scatter_hist.py +18 -9
  189. scitex/plt/ax/_plot/_stx_ecdf.py +4 -2
  190. scitex/plt/gallery/_generate.py +421 -14
  191. scitex/plt/io/__init__.py +53 -0
  192. scitex/plt/io/_bundle.py +490 -0
  193. scitex/plt/io/_layered_bundle.py +1343 -0
  194. scitex/plt/styles/SCITEX_STYLE.yaml +26 -0
  195. scitex/plt/styles/__init__.py +14 -0
  196. scitex/plt/styles/presets.py +78 -0
  197. scitex/plt/utils/__init__.py +13 -1
  198. scitex/plt/utils/_collect_figure_metadata.py +10 -14
  199. scitex/plt/utils/_configure_mpl.py +6 -18
  200. scitex/plt/utils/_crop.py +32 -14
  201. scitex/plt/utils/_csv_column_naming.py +54 -0
  202. scitex/plt/utils/_figure_mm.py +116 -1
  203. scitex/plt/utils/_hitmap.py +1643 -0
  204. scitex/plt/utils/metadata/__init__.py +25 -0
  205. scitex/plt/utils/metadata/_core.py +9 -10
  206. scitex/plt/utils/metadata/_dimensions.py +6 -3
  207. scitex/plt/utils/metadata/_editable_export.py +405 -0
  208. scitex/plt/utils/metadata/_geometry_extraction.py +570 -0
  209. scitex/schema/__init__.py +109 -16
  210. scitex/schema/_canvas.py +1 -1
  211. scitex/schema/_plot.py +1015 -0
  212. scitex/schema/_stats.py +2 -2
  213. scitex/stats/__init__.py +117 -0
  214. scitex/stats/io/__init__.py +29 -0
  215. scitex/stats/io/_bundle.py +156 -0
  216. scitex/tex/__init__.py +4 -0
  217. scitex/tex/_export.py +890 -0
  218. {scitex-2.7.0.dist-info → scitex-2.7.3.dist-info}/METADATA +11 -1
  219. {scitex-2.7.0.dist-info → scitex-2.7.3.dist-info}/RECORD +238 -170
  220. scitex/io/memo.md +0 -2827
  221. scitex/plt/REQUESTS.md +0 -191
  222. scitex/plt/_subplots/TODO.md +0 -53
  223. scitex/plt/_subplots/_AxisWrapperMixins/_AdjustmentMixin.py +0 -559
  224. scitex/plt/_subplots/_AxisWrapperMixins/_MatplotlibPlotMixin.py +0 -1609
  225. scitex/plt/_subplots/_AxisWrapperMixins/_SeabornMixin.py +0 -447
  226. scitex/plt/templates/research-master/scitex/vis/gallery/area/fill_between.json +0 -110
  227. scitex/plt/templates/research-master/scitex/vis/gallery/area/fill_betweenx.json +0 -88
  228. scitex/plt/templates/research-master/scitex/vis/gallery/area/stx_fill_between.json +0 -103
  229. scitex/plt/templates/research-master/scitex/vis/gallery/area/stx_fillv.json +0 -106
  230. scitex/plt/templates/research-master/scitex/vis/gallery/categorical/bar.json +0 -92
  231. scitex/plt/templates/research-master/scitex/vis/gallery/categorical/barh.json +0 -92
  232. scitex/plt/templates/research-master/scitex/vis/gallery/categorical/boxplot.json +0 -92
  233. scitex/plt/templates/research-master/scitex/vis/gallery/categorical/stx_bar.json +0 -84
  234. scitex/plt/templates/research-master/scitex/vis/gallery/categorical/stx_barh.json +0 -84
  235. scitex/plt/templates/research-master/scitex/vis/gallery/categorical/stx_box.json +0 -83
  236. scitex/plt/templates/research-master/scitex/vis/gallery/categorical/stx_boxplot.json +0 -93
  237. scitex/plt/templates/research-master/scitex/vis/gallery/categorical/stx_violin.json +0 -91
  238. scitex/plt/templates/research-master/scitex/vis/gallery/categorical/stx_violinplot.json +0 -91
  239. scitex/plt/templates/research-master/scitex/vis/gallery/categorical/violinplot.json +0 -91
  240. scitex/plt/templates/research-master/scitex/vis/gallery/contour/contour.json +0 -97
  241. scitex/plt/templates/research-master/scitex/vis/gallery/contour/contourf.json +0 -98
  242. scitex/plt/templates/research-master/scitex/vis/gallery/contour/stx_contour.json +0 -84
  243. scitex/plt/templates/research-master/scitex/vis/gallery/distribution/hist.json +0 -101
  244. scitex/plt/templates/research-master/scitex/vis/gallery/distribution/hist2d.json +0 -96
  245. scitex/plt/templates/research-master/scitex/vis/gallery/distribution/stx_ecdf.json +0 -95
  246. scitex/plt/templates/research-master/scitex/vis/gallery/distribution/stx_joyplot.json +0 -95
  247. scitex/plt/templates/research-master/scitex/vis/gallery/distribution/stx_kde.json +0 -93
  248. scitex/plt/templates/research-master/scitex/vis/gallery/grid/imshow.json +0 -95
  249. scitex/plt/templates/research-master/scitex/vis/gallery/grid/matshow.json +0 -95
  250. scitex/plt/templates/research-master/scitex/vis/gallery/grid/stx_conf_mat.json +0 -83
  251. scitex/plt/templates/research-master/scitex/vis/gallery/grid/stx_heatmap.json +0 -92
  252. scitex/plt/templates/research-master/scitex/vis/gallery/grid/stx_image.json +0 -121
  253. scitex/plt/templates/research-master/scitex/vis/gallery/grid/stx_imshow.json +0 -84
  254. scitex/plt/templates/research-master/scitex/vis/gallery/line/plot.json +0 -110
  255. scitex/plt/templates/research-master/scitex/vis/gallery/line/step.json +0 -92
  256. scitex/plt/templates/research-master/scitex/vis/gallery/line/stx_line.json +0 -95
  257. scitex/plt/templates/research-master/scitex/vis/gallery/line/stx_shaded_line.json +0 -96
  258. scitex/plt/templates/research-master/scitex/vis/gallery/scatter/hexbin.json +0 -95
  259. scitex/plt/templates/research-master/scitex/vis/gallery/scatter/scatter.json +0 -95
  260. scitex/plt/templates/research-master/scitex/vis/gallery/scatter/stem.json +0 -92
  261. scitex/plt/templates/research-master/scitex/vis/gallery/scatter/stx_scatter.json +0 -84
  262. scitex/plt/templates/research-master/scitex/vis/gallery/special/pie.json +0 -94
  263. scitex/plt/templates/research-master/scitex/vis/gallery/special/stx_raster.json +0 -109
  264. scitex/plt/templates/research-master/scitex/vis/gallery/special/stx_rectangle.json +0 -108
  265. scitex/plt/templates/research-master/scitex/vis/gallery/statistical/errorbar.json +0 -93
  266. scitex/plt/templates/research-master/scitex/vis/gallery/statistical/stx_errorbar.json +0 -84
  267. scitex/plt/templates/research-master/scitex/vis/gallery/statistical/stx_mean_ci.json +0 -96
  268. scitex/plt/templates/research-master/scitex/vis/gallery/statistical/stx_mean_std.json +0 -96
  269. scitex/plt/templates/research-master/scitex/vis/gallery/statistical/stx_median_iqr.json +0 -96
  270. scitex/plt/templates/research-master/scitex/vis/gallery/vector/quiver.json +0 -99
  271. scitex/plt/templates/research-master/scitex/vis/gallery/vector/streamplot.json +0 -100
  272. scitex/vis/__init__.py +0 -177
  273. scitex/vis/editor/_edit.py +0 -390
  274. scitex/vis/editor/flask_editor/_bbox.py +0 -529
  275. scitex/vis/editor/flask_editor/_core.py +0 -168
  276. scitex/vis/editor/flask_editor/_renderer.py +0 -393
  277. scitex/vis/editor/flask_editor/templates/_html.py +0 -513
  278. scitex/vis/editor/flask_editor/templates/_scripts.py +0 -1261
  279. /scitex/{vis → fig}/README.md +0 -0
  280. /scitex/{vis → fig}/backend/__init__.py +0 -0
  281. /scitex/{vis → fig}/backend/_export.py +0 -0
  282. /scitex/{vis → fig}/backend/_render.py +0 -0
  283. /scitex/{vis → fig}/docs/CANVAS_ARCHITECTURE.md +0 -0
  284. /scitex/{vis → fig}/editor/__init__.py +0 -0
  285. /scitex/{vis → fig}/editor/_dearpygui_editor.py +0 -0
  286. /scitex/{vis → fig}/editor/_flask_editor.py +0 -0
  287. /scitex/{vis → fig}/editor/_mpl_editor.py +0 -0
  288. /scitex/{vis → fig}/editor/_tkinter_editor.py +0 -0
  289. /scitex/{vis → fig}/editor/flask_editor/__init__.py +0 -0
  290. /scitex/{vis → fig}/editor/flask_editor/_utils.py +0 -0
  291. /scitex/{vis → fig}/io/_directory.py +0 -0
  292. /scitex/{vis → fig}/model/_plot_types.py +0 -0
  293. /scitex/{vis → fig}/utils/_defaults.py +0 -0
  294. /scitex/{vis → fig}/utils/_validate.py +0 -0
  295. {scitex-2.7.0.dist-info → scitex-2.7.3.dist-info}/WHEEL +0 -0
  296. {scitex-2.7.0.dist-info → scitex-2.7.3.dist-info}/entry_points.txt +0 -0
  297. {scitex-2.7.0.dist-info → scitex-2.7.3.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,1276 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ # File: ./src/scitex/vis/editor/flask_editor/bbox.py
4
+ """Bounding box extraction for figure elements.
5
+
6
+ Updated to integrate Schema v0.3 geometry extraction for shape-based hit testing.
7
+ """
8
+
9
+ from typing import Dict, Any
10
+
11
+ # Try to import schema v0.3 geometry extraction
12
+ try:
13
+ from scitex.plt.utils.metadata._geometry_extraction import (
14
+ extract_axes_bbox_px,
15
+ extract_line_geometry,
16
+ extract_scatter_geometry,
17
+ extract_polygon_geometry,
18
+ extract_bar_group_geometry,
19
+ )
20
+ GEOMETRY_V03_AVAILABLE = True
21
+ except ImportError:
22
+ GEOMETRY_V03_AVAILABLE = False
23
+
24
+
25
+ def extract_bboxes(
26
+ fig, ax, renderer, img_width: int, img_height: int
27
+ ) -> Dict[str, Any]:
28
+ """Extract bounding boxes for all figure elements (single-axis)."""
29
+ from matplotlib.transforms import Bbox
30
+
31
+ # Get figure tight bbox in inches
32
+ fig_bbox = fig.get_tightbbox(renderer)
33
+ tight_x0 = fig_bbox.x0
34
+ tight_y0 = fig_bbox.y0
35
+ tight_width = fig_bbox.width
36
+ tight_height = fig_bbox.height
37
+
38
+ # bbox_inches='tight' adds pad_inches (default 0.1) around the tight bbox
39
+ pad_inches = 0.1
40
+ saved_width_inches = tight_width + 2 * pad_inches
41
+ saved_height_inches = tight_height + 2 * pad_inches
42
+
43
+ # Scale factors for converting inches to pixels
44
+ scale_x = img_width / saved_width_inches
45
+ scale_y = img_height / saved_height_inches
46
+
47
+ bboxes = {}
48
+
49
+ def get_element_bbox(element, name):
50
+ """Get element bbox in image pixel coordinates."""
51
+ try:
52
+ bbox = element.get_window_extent(renderer)
53
+
54
+ elem_x0_inches = bbox.x0 / fig.dpi
55
+ elem_x1_inches = bbox.x1 / fig.dpi
56
+ elem_y0_inches = bbox.y0 / fig.dpi
57
+ elem_y1_inches = bbox.y1 / fig.dpi
58
+
59
+ x0_rel = elem_x0_inches - tight_x0 + pad_inches
60
+ x1_rel = elem_x1_inches - tight_x0 + pad_inches
61
+ y0_rel = saved_height_inches - (elem_y1_inches - tight_y0 + pad_inches)
62
+ y1_rel = saved_height_inches - (elem_y0_inches - tight_y0 + pad_inches)
63
+
64
+ bboxes[name] = {
65
+ "x0": max(0, int(x0_rel * scale_x)),
66
+ "y0": max(0, int(y0_rel * scale_y)),
67
+ "x1": min(img_width, int(x1_rel * scale_x)),
68
+ "y1": min(img_height, int(y1_rel * scale_y)),
69
+ "label": name.replace("_", " ").title(),
70
+ }
71
+ except Exception as e:
72
+ print(f"Error getting bbox for {name}: {e}")
73
+
74
+ def bbox_to_img_coords(bbox):
75
+ """Convert matplotlib bbox to image pixel coordinates."""
76
+ x0_inches = bbox.x0 / fig.dpi
77
+ y0_inches = bbox.y0 / fig.dpi
78
+ x1_inches = bbox.x1 / fig.dpi
79
+ y1_inches = bbox.y1 / fig.dpi
80
+ x0_rel = x0_inches - tight_x0 + pad_inches
81
+ y0_rel = y0_inches - tight_y0 + pad_inches
82
+ x1_rel = x1_inches - tight_x0 + pad_inches
83
+ y1_rel = y1_inches - tight_y0 + pad_inches
84
+ return {
85
+ "x0": int(x0_rel * scale_x),
86
+ "y0": int((saved_height_inches - y1_rel) * scale_y),
87
+ "x1": int(x1_rel * scale_x),
88
+ "y1": int((saved_height_inches - y0_rel) * scale_y),
89
+ }
90
+
91
+ # Get bboxes for title, labels
92
+ # Use ax_00_ prefix for consistency with geometry_px.json format
93
+ ax_prefix = "ax_00_"
94
+ if ax.title.get_text():
95
+ get_element_bbox(ax.title, f"{ax_prefix}title")
96
+ if ax.xaxis.label.get_text():
97
+ get_element_bbox(ax.xaxis.label, f"{ax_prefix}xlabel")
98
+ if ax.yaxis.label.get_text():
99
+ get_element_bbox(ax.yaxis.label, f"{ax_prefix}ylabel")
100
+
101
+ # Get axis bboxes
102
+ _extract_axis_bboxes(ax, renderer, bboxes, bbox_to_img_coords, Bbox, ax_prefix)
103
+
104
+ # Get legend bbox
105
+ legend = ax.get_legend()
106
+ if legend:
107
+ get_element_bbox(legend, f"{ax_prefix}legend")
108
+
109
+ # Get caption bbox (figure-level text)
110
+ for text_artist in fig.texts:
111
+ # Check if this is a caption (positioned below figure)
112
+ text_content = text_artist.get_text()
113
+ if text_content:
114
+ pos = text_artist.get_position()
115
+ # Caption is typically centered horizontally (x ~= 0.5) and below figure (y < 0.1)
116
+ if pos[1] < 0.1:
117
+ get_element_bbox(text_artist, f"{ax_prefix}caption")
118
+ break # Only one caption expected
119
+
120
+ # Get trace (line) bboxes
121
+ _extract_trace_bboxes(
122
+ ax,
123
+ fig,
124
+ renderer,
125
+ bboxes,
126
+ get_element_bbox,
127
+ tight_x0,
128
+ tight_y0,
129
+ saved_height_inches,
130
+ scale_x,
131
+ scale_y,
132
+ pad_inches,
133
+ )
134
+
135
+ # Add schema v0.3 metadata if available
136
+ if GEOMETRY_V03_AVAILABLE:
137
+ axes_bbox = extract_axes_bbox_px(ax, fig)
138
+ bboxes["_meta"] = {
139
+ "schema_version": "0.3.0",
140
+ "axes_bbox_px": axes_bbox,
141
+ "geometry_available": True,
142
+ }
143
+
144
+ return bboxes
145
+
146
+
147
+ def extract_bboxes_multi(
148
+ fig, axes_map: Dict[str, Any], renderer, img_width: int, img_height: int
149
+ ) -> Dict[str, Any]:
150
+ """Extract bounding boxes for all elements in a multi-axis figure.
151
+
152
+ Args:
153
+ fig: Matplotlib figure
154
+ axes_map: Dict mapping axis IDs (e.g., 'ax_00') to matplotlib Axes objects
155
+ renderer: Matplotlib renderer
156
+ img_width: Image width in pixels
157
+ img_height: Image height in pixels
158
+
159
+ Returns:
160
+ Dict with bboxes keyed by "{ax_id}_{element_type}" (e.g., "ax_00_xlabel")
161
+ """
162
+ from matplotlib.transforms import Bbox
163
+
164
+ # Get figure tight bbox in inches
165
+ fig_bbox = fig.get_tightbbox(renderer)
166
+ tight_x0 = fig_bbox.x0
167
+ tight_y0 = fig_bbox.y0
168
+ tight_width = fig_bbox.width
169
+ tight_height = fig_bbox.height
170
+
171
+ # bbox_inches='tight' adds pad_inches (default 0.1) around the tight bbox
172
+ pad_inches = 0.1
173
+ saved_width_inches = tight_width + 2 * pad_inches
174
+ saved_height_inches = tight_height + 2 * pad_inches
175
+
176
+ # Scale factors for converting inches to pixels
177
+ scale_x = img_width / saved_width_inches
178
+ scale_y = img_height / saved_height_inches
179
+
180
+ bboxes = {}
181
+
182
+ def get_element_bbox(element, name, ax_id, current_ax=None):
183
+ """Get element bbox in image pixel coordinates."""
184
+ import numpy as np
185
+ full_name = f"{ax_id}_{name}"
186
+ try:
187
+ bbox = element.get_window_extent(renderer)
188
+
189
+ # Check for invalid bbox (infinity or NaN)
190
+ if not (np.isfinite(bbox.x0) and np.isfinite(bbox.x1) and
191
+ np.isfinite(bbox.y0) and np.isfinite(bbox.y1)):
192
+ # Try to get bbox from data for scatter/collection elements
193
+ if hasattr(element, 'get_offsets') and current_ax is not None:
194
+ offsets = element.get_offsets()
195
+ if len(offsets) > 0 and np.isfinite(offsets).all():
196
+ # Use axis transform to get display coordinates
197
+ display_coords = current_ax.transData.transform(offsets)
198
+ x0 = display_coords[:, 0].min()
199
+ x1 = display_coords[:, 0].max()
200
+ y0 = display_coords[:, 1].min()
201
+ y1 = display_coords[:, 1].max()
202
+ if np.isfinite([x0, x1, y0, y1]).all():
203
+ from matplotlib.transforms import Bbox
204
+ bbox = Bbox.from_extents(x0, y0, x1, y1)
205
+ else:
206
+ return # Skip this element
207
+ else:
208
+ return # Skip this element
209
+ else:
210
+ return # Skip this element
211
+
212
+ elem_x0_inches = bbox.x0 / fig.dpi
213
+ elem_x1_inches = bbox.x1 / fig.dpi
214
+ elem_y0_inches = bbox.y0 / fig.dpi
215
+ elem_y1_inches = bbox.y1 / fig.dpi
216
+
217
+ x0_rel = elem_x0_inches - tight_x0 + pad_inches
218
+ x1_rel = elem_x1_inches - tight_x0 + pad_inches
219
+ y0_rel = saved_height_inches - (elem_y1_inches - tight_y0 + pad_inches)
220
+ y1_rel = saved_height_inches - (elem_y0_inches - tight_y0 + pad_inches)
221
+
222
+ # Clamp values to avoid overflow
223
+ x0_px = max(0, min(img_width, int(x0_rel * scale_x)))
224
+ y0_px = max(0, min(img_height, int(y0_rel * scale_y)))
225
+ x1_px = max(0, min(img_width, int(x1_rel * scale_x)))
226
+ y1_px = max(0, min(img_height, int(y1_rel * scale_y)))
227
+
228
+ bboxes[full_name] = {
229
+ "x0": x0_px,
230
+ "y0": y0_px,
231
+ "x1": x1_px,
232
+ "y1": y1_px,
233
+ "label": f"{ax_id}: {name.replace('_', ' ').title()}",
234
+ "ax_id": ax_id,
235
+ }
236
+ except Exception as e:
237
+ print(f"Error getting bbox for {full_name}: {e}")
238
+
239
+ def bbox_to_img_coords(bbox):
240
+ """Convert matplotlib bbox to image pixel coordinates."""
241
+ x0_inches = bbox.x0 / fig.dpi
242
+ y0_inches = bbox.y0 / fig.dpi
243
+ x1_inches = bbox.x1 / fig.dpi
244
+ y1_inches = bbox.y1 / fig.dpi
245
+ x0_rel = x0_inches - tight_x0 + pad_inches
246
+ y0_rel = y0_inches - tight_y0 + pad_inches
247
+ x1_rel = x1_inches - tight_x0 + pad_inches
248
+ y1_rel = y1_inches - tight_y0 + pad_inches
249
+ return {
250
+ "x0": int(x0_rel * scale_x),
251
+ "y0": int((saved_height_inches - y1_rel) * scale_y),
252
+ "x1": int(x1_rel * scale_x),
253
+ "y1": int((saved_height_inches - y0_rel) * scale_y),
254
+ }
255
+
256
+ # Extract bboxes for each axis
257
+ for ax_id, ax in axes_map.items():
258
+ # Get axes bounding box (the entire panel area)
259
+ try:
260
+ ax_bbox = ax.get_window_extent(renderer)
261
+ coords = bbox_to_img_coords(ax_bbox)
262
+ # Extract actual title/labels from the axes
263
+ title_text = ax.title.get_text() if ax.title else ""
264
+ xlabel_text = ax.xaxis.label.get_text() if ax.xaxis.label else ""
265
+ ylabel_text = ax.yaxis.label.get_text() if ax.yaxis.label else ""
266
+ bboxes[f"{ax_id}_panel"] = {
267
+ **coords,
268
+ "label": f"Panel {ax_id}",
269
+ "ax_id": ax_id,
270
+ "is_panel": True,
271
+ "title": title_text,
272
+ "xlabel": xlabel_text,
273
+ "ylabel": ylabel_text,
274
+ }
275
+ except Exception as e:
276
+ print(f"Error getting panel bbox for {ax_id}: {e}")
277
+
278
+ # Get bboxes for title, labels
279
+ if ax.title.get_text():
280
+ get_element_bbox(ax.title, "title", ax_id, ax)
281
+ if ax.xaxis.label.get_text():
282
+ get_element_bbox(ax.xaxis.label, "xlabel", ax_id, ax)
283
+ if ax.yaxis.label.get_text():
284
+ get_element_bbox(ax.yaxis.label, "ylabel", ax_id, ax)
285
+
286
+ # Get X-axis bbox (spine + ticks + ticklabels)
287
+ _extract_axis_bboxes_for_axis(ax, ax_id, renderer, bboxes, bbox_to_img_coords, Bbox)
288
+
289
+ # Get legend bbox
290
+ legend = ax.get_legend()
291
+ if legend:
292
+ get_element_bbox(legend, "legend", ax_id, ax)
293
+
294
+ # Get trace (line) bboxes
295
+ _extract_trace_bboxes_for_axis(
296
+ ax,
297
+ ax_id,
298
+ fig,
299
+ renderer,
300
+ bboxes,
301
+ get_element_bbox,
302
+ tight_x0,
303
+ tight_y0,
304
+ saved_height_inches,
305
+ scale_x,
306
+ scale_y,
307
+ pad_inches,
308
+ )
309
+
310
+ # Get caption bbox (figure-level text)
311
+ # This is outside the per-axis loop since caption is a figure-level element
312
+ for text_artist in fig.texts:
313
+ text_content = text_artist.get_text()
314
+ if text_content:
315
+ pos = text_artist.get_position()
316
+ # Caption is typically centered horizontally (x ~= 0.5) and below figure (y < 0.1)
317
+ if pos[1] < 0.1:
318
+ try:
319
+ bbox = text_artist.get_window_extent(renderer)
320
+ x0_inches = bbox.x0 / fig.dpi
321
+ x1_inches = bbox.x1 / fig.dpi
322
+ y0_inches = bbox.y0 / fig.dpi
323
+ y1_inches = bbox.y1 / fig.dpi
324
+ x0_rel = x0_inches - tight_x0 + pad_inches
325
+ x1_rel = x1_inches - tight_x0 + pad_inches
326
+ y0_rel = saved_height_inches - (y1_inches - tight_y0 + pad_inches)
327
+ y1_rel = saved_height_inches - (y0_inches - tight_y0 + pad_inches)
328
+ bboxes["caption"] = {
329
+ "x0": max(0, int(x0_rel * scale_x)),
330
+ "y0": max(0, int(y0_rel * scale_y)),
331
+ "x1": min(img_width, int(x1_rel * scale_x)),
332
+ "y1": min(img_height, int(y1_rel * scale_y)),
333
+ "label": "Caption",
334
+ }
335
+ except Exception as e:
336
+ print(f"Error getting caption bbox: {e}")
337
+ break # Only one caption expected
338
+
339
+ # Add schema v0.3 metadata if available
340
+ if GEOMETRY_V03_AVAILABLE:
341
+ axes_bboxes = {}
342
+ for ax_id, ax in axes_map.items():
343
+ axes_bboxes[ax_id] = extract_axes_bbox_px(ax, fig)
344
+ bboxes["_meta"] = {
345
+ "schema_version": "0.3.0",
346
+ "axes": axes_bboxes,
347
+ "geometry_available": True,
348
+ }
349
+
350
+ return bboxes
351
+
352
+
353
+ def _extract_trace_bboxes_for_axis(
354
+ ax,
355
+ ax_id,
356
+ fig,
357
+ renderer,
358
+ bboxes,
359
+ get_element_bbox,
360
+ tight_x0,
361
+ tight_y0,
362
+ saved_height_inches,
363
+ scale_x,
364
+ scale_y,
365
+ pad_inches,
366
+ ):
367
+ """Extract bboxes for all data elements in a specific axis.
368
+
369
+ Handles:
370
+ - Lines (plot, errorbar lines)
371
+ - Scatter points (PathCollection)
372
+ - Fill areas (PolyCollection from fill_between)
373
+ - Bars (Rectangle patches)
374
+ """
375
+ import numpy as np
376
+
377
+ def coords_to_img_points(data_coords):
378
+ """Convert data coordinates to image pixel coordinates."""
379
+ if len(data_coords) == 0:
380
+ return []
381
+ transform = ax.transData
382
+ points_display = transform.transform(data_coords)
383
+ points_img = []
384
+ for px, py in points_display:
385
+ # Skip invalid points (NaN, infinity)
386
+ if not np.isfinite(px) or not np.isfinite(py):
387
+ continue
388
+ px_inches = px / fig.dpi
389
+ py_inches = py / fig.dpi
390
+ x_rel = px_inches - tight_x0 + pad_inches
391
+ y_rel = saved_height_inches - (py_inches - tight_y0 + pad_inches)
392
+ # Clamp to reasonable bounds to avoid overflow
393
+ x_img = max(-10000, min(10000, int(x_rel * scale_x)))
394
+ y_img = max(-10000, min(10000, int(y_rel * scale_y)))
395
+ points_img.append([x_img, y_img])
396
+ # Downsample if too many
397
+ if len(points_img) > 100:
398
+ step = len(points_img) // 100
399
+ points_img = points_img[::step]
400
+ return points_img
401
+
402
+ # 1. Extract lines (plot, errorbar lines, etc.)
403
+ line_idx = 0
404
+ for line in ax.get_lines():
405
+ try:
406
+ label = line.get_label()
407
+ # Include unlabeled lines but mark them appropriately
408
+ if label is None or label.startswith("_"):
409
+ label = None # Will use generic name
410
+
411
+ trace_name = f"trace_{line_idx}"
412
+ full_name = f"{ax_id}_{trace_name}"
413
+ get_element_bbox(line, trace_name, ax_id, ax)
414
+
415
+ if full_name in bboxes:
416
+ bboxes[full_name]["label"] = f"{ax_id}: {label or f'Line {line_idx}'}"
417
+ bboxes[full_name]["trace_idx"] = line_idx
418
+ bboxes[full_name]["element_type"] = "line"
419
+
420
+ xdata, ydata = line.get_xdata(), line.get_ydata()
421
+ if len(xdata) > 0:
422
+ bboxes[full_name]["points"] = coords_to_img_points(
423
+ list(zip(xdata, ydata))
424
+ )
425
+
426
+ # Add schema v0.3 geometry_px if available
427
+ if GEOMETRY_V03_AVAILABLE:
428
+ try:
429
+ geom = extract_line_geometry(line, ax, fig)
430
+ bboxes[full_name]["geometry_px"] = geom
431
+ except Exception:
432
+ pass # Fall back to legacy points
433
+
434
+ line_idx += 1
435
+ except Exception as e:
436
+ print(f"Error getting line bbox for {ax_id}: {e}")
437
+
438
+ # 2. Extract collections (scatter, fill_between, etc.)
439
+ coll_idx = 0
440
+ for coll in ax.collections:
441
+ try:
442
+ label = coll.get_label()
443
+ if label is None or label.startswith("_"):
444
+ # Still extract unlabeled collections but with generic name
445
+ label = None
446
+
447
+ coll_type = type(coll).__name__
448
+ if coll_type == "PathCollection":
449
+ # Scatter points
450
+ element_name = f"scatter_{coll_idx}"
451
+ full_name = f"{ax_id}_{element_name}"
452
+ get_element_bbox(coll, element_name, ax_id, ax)
453
+
454
+ if full_name in bboxes:
455
+ bboxes[full_name]["label"] = f"{ax_id}: {label or f'Scatter {coll_idx}'}"
456
+ bboxes[full_name]["element_type"] = "scatter"
457
+
458
+ # Get scatter point positions
459
+ offsets = coll.get_offsets()
460
+ if len(offsets) > 0:
461
+ bboxes[full_name]["points"] = coords_to_img_points(offsets)
462
+
463
+ # Add schema v0.3 geometry_px if available
464
+ if GEOMETRY_V03_AVAILABLE:
465
+ try:
466
+ geom = extract_scatter_geometry(coll, ax, fig)
467
+ bboxes[full_name]["geometry_px"] = geom
468
+ except Exception:
469
+ pass # Fall back to legacy points
470
+
471
+ elif coll_type in ("PolyCollection", "FillBetweenPolyCollection"):
472
+ # Fill areas (fill_between, etc.)
473
+ element_name = f"fill_{coll_idx}"
474
+ full_name = f"{ax_id}_{element_name}"
475
+ get_element_bbox(coll, element_name, ax_id, ax)
476
+
477
+ if full_name in bboxes:
478
+ bboxes[full_name]["label"] = f"{ax_id}: {label or f'Fill {coll_idx}'}"
479
+ bboxes[full_name]["element_type"] = "fill"
480
+
481
+ # Add schema v0.3 geometry_px if available
482
+ if GEOMETRY_V03_AVAILABLE:
483
+ try:
484
+ geom = extract_polygon_geometry(coll, ax, fig)
485
+ bboxes[full_name]["geometry_px"] = geom
486
+ except Exception:
487
+ pass
488
+
489
+ coll_idx += 1
490
+ except Exception as e:
491
+ print(f"Error getting collection bbox for {ax_id}: {e}")
492
+
493
+ # 3. Extract patches (bars, rectangles, etc.)
494
+ patch_idx = 0
495
+ for patch in ax.patches:
496
+ try:
497
+ label = patch.get_label()
498
+ patch_type = type(patch).__name__
499
+
500
+ if patch_type == "Rectangle":
501
+ # Bar chart bars
502
+ element_name = f"bar_{patch_idx}"
503
+ full_name = f"{ax_id}_{element_name}"
504
+ get_element_bbox(patch, element_name, ax_id, ax)
505
+
506
+ if full_name in bboxes:
507
+ bboxes[full_name]["label"] = f"{ax_id}: {label or f'Bar {patch_idx}'}"
508
+ bboxes[full_name]["element_type"] = "bar"
509
+
510
+ patch_idx += 1
511
+ except Exception as e:
512
+ print(f"Error getting patch bbox for {ax_id}: {e}")
513
+
514
+
515
+ def _extract_axis_bboxes_for_axis(ax, ax_id, renderer, bboxes, bbox_to_img_coords, Bbox):
516
+ """Extract X and Y axis bboxes for a specific axis (multi-axis version)."""
517
+ try:
518
+ # X-axis: combine spine and tick labels into one bbox
519
+ x_axis_bboxes = []
520
+ for ticklabel in ax.xaxis.get_ticklabels():
521
+ if ticklabel.get_visible():
522
+ try:
523
+ tb = ticklabel.get_window_extent(renderer)
524
+ if tb.width > 0:
525
+ x_axis_bboxes.append(tb)
526
+ except Exception:
527
+ pass
528
+ for tick in ax.xaxis.get_major_ticks():
529
+ if tick.tick1line.get_visible():
530
+ try:
531
+ tb = tick.tick1line.get_window_extent(renderer)
532
+ if tb.width > 0 or tb.height > 0:
533
+ x_axis_bboxes.append(tb)
534
+ except Exception:
535
+ pass
536
+ spine_bbox = ax.spines["bottom"].get_window_extent(renderer)
537
+ if spine_bbox.width > 0:
538
+ if x_axis_bboxes:
539
+ tick_union = Bbox.union(x_axis_bboxes)
540
+ constrained_spine = Bbox.from_extents(
541
+ tick_union.x0, spine_bbox.y0, tick_union.x1, spine_bbox.y1
542
+ )
543
+ x_axis_bboxes.append(constrained_spine)
544
+ else:
545
+ x_axis_bboxes.append(spine_bbox)
546
+ if x_axis_bboxes:
547
+ combined = Bbox.union(x_axis_bboxes)
548
+ bboxes[f"{ax_id}_xaxis"] = bbox_to_img_coords(combined)
549
+ bboxes[f"{ax_id}_xaxis"]["label"] = f"{ax_id}: X Axis"
550
+ bboxes[f"{ax_id}_xaxis"]["ax_id"] = ax_id
551
+ bboxes[f"{ax_id}_xaxis"]["element_type"] = "xaxis"
552
+
553
+ # Y-axis: combine spine and tick labels into one bbox
554
+ y_axis_bboxes = []
555
+ for ticklabel in ax.yaxis.get_ticklabels():
556
+ if ticklabel.get_visible():
557
+ try:
558
+ tb = ticklabel.get_window_extent(renderer)
559
+ if tb.width > 0:
560
+ y_axis_bboxes.append(tb)
561
+ except Exception:
562
+ pass
563
+ for tick in ax.yaxis.get_major_ticks():
564
+ if tick.tick1line.get_visible():
565
+ try:
566
+ tb = tick.tick1line.get_window_extent(renderer)
567
+ if tb.width > 0 or tb.height > 0:
568
+ y_axis_bboxes.append(tb)
569
+ except Exception:
570
+ pass
571
+ spine_bbox = ax.spines["left"].get_window_extent(renderer)
572
+ if spine_bbox.height > 0:
573
+ if y_axis_bboxes:
574
+ tick_union = Bbox.union(y_axis_bboxes)
575
+ constrained_spine = Bbox.from_extents(
576
+ spine_bbox.x0, tick_union.y0, spine_bbox.x1, tick_union.y1
577
+ )
578
+ y_axis_bboxes.append(constrained_spine)
579
+ else:
580
+ y_axis_bboxes.append(spine_bbox)
581
+ if y_axis_bboxes:
582
+ combined = Bbox.union(y_axis_bboxes)
583
+ padded = Bbox.from_extents(
584
+ combined.x0 - 10, combined.y0 - 5, combined.x1 + 5, combined.y1 + 5
585
+ )
586
+ bboxes[f"{ax_id}_yaxis"] = bbox_to_img_coords(padded)
587
+ bboxes[f"{ax_id}_yaxis"]["label"] = f"{ax_id}: Y Axis"
588
+ bboxes[f"{ax_id}_yaxis"]["ax_id"] = ax_id
589
+ bboxes[f"{ax_id}_yaxis"]["element_type"] = "yaxis"
590
+
591
+ except Exception as e:
592
+ print(f"Error getting axis bboxes for {ax_id}: {e}")
593
+
594
+
595
+ def _extract_axis_bboxes(ax, renderer, bboxes, bbox_to_img_coords, Bbox, ax_prefix=""):
596
+ """Extract bboxes for X and Y axis elements.
597
+
598
+ Args:
599
+ ax: Matplotlib axis.
600
+ renderer: Figure renderer.
601
+ bboxes: Dict to store bboxes.
602
+ bbox_to_img_coords: Coordinate conversion function.
603
+ Bbox: Matplotlib Bbox class.
604
+ ax_prefix: Prefix for bbox names (e.g., "ax_00_").
605
+ """
606
+ try:
607
+ # X-axis: combine spine and tick labels into one bbox
608
+ x_axis_bboxes = []
609
+ for ticklabel in ax.xaxis.get_ticklabels():
610
+ if ticklabel.get_visible():
611
+ try:
612
+ tb = ticklabel.get_window_extent(renderer)
613
+ if tb.width > 0:
614
+ x_axis_bboxes.append(tb)
615
+ except Exception:
616
+ pass
617
+ for tick in ax.xaxis.get_major_ticks():
618
+ if tick.tick1line.get_visible():
619
+ try:
620
+ tb = tick.tick1line.get_window_extent(renderer)
621
+ if tb.width > 0 or tb.height > 0:
622
+ x_axis_bboxes.append(tb)
623
+ except Exception:
624
+ pass
625
+ spine_bbox = ax.spines["bottom"].get_window_extent(renderer)
626
+ if spine_bbox.width > 0:
627
+ if x_axis_bboxes:
628
+ tick_union = Bbox.union(x_axis_bboxes)
629
+ constrained_spine = Bbox.from_extents(
630
+ tick_union.x0, spine_bbox.y0, tick_union.x1, spine_bbox.y1
631
+ )
632
+ x_axis_bboxes.append(constrained_spine)
633
+ else:
634
+ x_axis_bboxes.append(spine_bbox)
635
+ if x_axis_bboxes:
636
+ combined = Bbox.union(x_axis_bboxes)
637
+ bboxes[f"{ax_prefix}xaxis_spine"] = bbox_to_img_coords(combined)
638
+ bboxes[f"{ax_prefix}xaxis_spine"]["label"] = "X Spine & Ticks"
639
+
640
+ # Y-axis: combine spine and tick labels into one bbox
641
+ y_axis_bboxes = []
642
+ for ticklabel in ax.yaxis.get_ticklabels():
643
+ if ticklabel.get_visible():
644
+ try:
645
+ tb = ticklabel.get_window_extent(renderer)
646
+ if tb.width > 0:
647
+ y_axis_bboxes.append(tb)
648
+ except Exception:
649
+ pass
650
+ for tick in ax.yaxis.get_major_ticks():
651
+ if tick.tick1line.get_visible():
652
+ try:
653
+ tb = tick.tick1line.get_window_extent(renderer)
654
+ if tb.width > 0 or tb.height > 0:
655
+ y_axis_bboxes.append(tb)
656
+ except Exception:
657
+ pass
658
+ spine_bbox = ax.spines["left"].get_window_extent(renderer)
659
+ if spine_bbox.height > 0:
660
+ if y_axis_bboxes:
661
+ tick_union = Bbox.union(y_axis_bboxes)
662
+ constrained_spine = Bbox.from_extents(
663
+ spine_bbox.x0, tick_union.y0, spine_bbox.x1, tick_union.y1
664
+ )
665
+ y_axis_bboxes.append(constrained_spine)
666
+ else:
667
+ y_axis_bboxes.append(spine_bbox)
668
+ if y_axis_bboxes:
669
+ combined = Bbox.union(y_axis_bboxes)
670
+ padded = Bbox.from_extents(
671
+ combined.x0 - 10, combined.y0 - 5, combined.x1 + 5, combined.y1 + 5
672
+ )
673
+ bboxes[f"{ax_prefix}yaxis_spine"] = bbox_to_img_coords(padded)
674
+ bboxes[f"{ax_prefix}yaxis_spine"]["label"] = "Y Spine & Ticks"
675
+
676
+ except Exception as e:
677
+ print(f"Error getting axis bboxes: {e}")
678
+
679
+
680
+ def _extract_trace_bboxes(
681
+ ax,
682
+ fig,
683
+ renderer,
684
+ bboxes,
685
+ get_element_bbox,
686
+ tight_x0,
687
+ tight_y0,
688
+ saved_height_inches,
689
+ scale_x,
690
+ scale_y,
691
+ pad_inches,
692
+ ):
693
+ """Extract bboxes for all data elements (lines, scatter, fill) with proximity detection."""
694
+ import numpy as np
695
+
696
+ def coords_to_img_points(data_coords):
697
+ """Convert data coordinates to image pixel coordinates."""
698
+ if len(data_coords) == 0:
699
+ return []
700
+ transform = ax.transData
701
+ points_display = transform.transform(data_coords)
702
+ points_img = []
703
+ for px, py in points_display:
704
+ if not np.isfinite(px) or not np.isfinite(py):
705
+ continue
706
+ px_inches = px / fig.dpi
707
+ py_inches = py / fig.dpi
708
+ x_rel = px_inches - tight_x0 + pad_inches
709
+ y_rel = saved_height_inches - (py_inches - tight_y0 + pad_inches)
710
+ x_img = max(-10000, min(10000, int(x_rel * scale_x)))
711
+ y_img = max(-10000, min(10000, int(y_rel * scale_y)))
712
+ points_img.append([x_img, y_img])
713
+ if len(points_img) > 100:
714
+ step = len(points_img) // 100
715
+ points_img = points_img[::step]
716
+ return points_img
717
+
718
+ # 1. Extract lines
719
+ for idx, line in enumerate(ax.get_lines()):
720
+ try:
721
+ label = line.get_label()
722
+ # Include unlabeled lines but mark them appropriately
723
+ if label is None or label.startswith("_"):
724
+ label = None # Will use generic name
725
+ get_element_bbox(line, f"trace_{idx}")
726
+ if f"trace_{idx}" in bboxes:
727
+ bboxes[f"trace_{idx}"]["label"] = label or f"Trace {idx}"
728
+ bboxes[f"trace_{idx}"]["trace_idx"] = idx
729
+ bboxes[f"trace_{idx}"]["element_type"] = "line"
730
+
731
+ xdata, ydata = line.get_xdata(), line.get_ydata()
732
+ if len(xdata) > 0:
733
+ bboxes[f"trace_{idx}"]["points"] = coords_to_img_points(
734
+ list(zip(xdata, ydata))
735
+ )
736
+
737
+ # Add schema v0.3 geometry_px if available
738
+ if GEOMETRY_V03_AVAILABLE:
739
+ try:
740
+ geom = extract_line_geometry(line, ax, fig)
741
+ bboxes[f"trace_{idx}"]["geometry_px"] = geom
742
+ except Exception:
743
+ pass
744
+ except Exception as e:
745
+ print(f"Error getting trace bbox: {e}")
746
+
747
+ # 2. Extract collections (scatter, fill_between)
748
+ coll_idx = 0
749
+ for coll in ax.collections:
750
+ try:
751
+ label = coll.get_label()
752
+ if label is None or label.startswith("_"):
753
+ label = None
754
+
755
+ coll_type = type(coll).__name__
756
+ if coll_type == "PathCollection":
757
+ # Scatter points
758
+ elem_key = f"scatter_{coll_idx}"
759
+ get_element_bbox(coll, elem_key)
760
+
761
+ # Initialize entry if bbox extraction failed but we have data
762
+ offsets = coll.get_offsets()
763
+ if elem_key not in bboxes and len(offsets) > 0:
764
+ # Create bbox from data coordinates as fallback
765
+ points_img = coords_to_img_points(offsets)
766
+ if points_img:
767
+ xs = [p[0] for p in points_img]
768
+ ys = [p[1] for p in points_img]
769
+ bboxes[elem_key] = {
770
+ "x0": min(xs) - 10,
771
+ "y0": min(ys) - 10,
772
+ "x1": max(xs) + 10,
773
+ "y1": max(ys) + 10,
774
+ }
775
+
776
+ if elem_key in bboxes:
777
+ bboxes[elem_key]["label"] = label or f"Scatter {coll_idx}"
778
+ bboxes[elem_key]["element_type"] = "scatter"
779
+
780
+ if len(offsets) > 0:
781
+ bboxes[elem_key]["points"] = coords_to_img_points(offsets)
782
+
783
+ # Add schema v0.3 geometry_px if available
784
+ if GEOMETRY_V03_AVAILABLE:
785
+ try:
786
+ geom = extract_scatter_geometry(coll, ax, fig)
787
+ bboxes[elem_key]["geometry_px"] = geom
788
+ except Exception:
789
+ pass
790
+
791
+ elif coll_type in ("PolyCollection", "FillBetweenPolyCollection"):
792
+ # Fill areas
793
+ get_element_bbox(coll, f"fill_{coll_idx}")
794
+ if f"fill_{coll_idx}" in bboxes:
795
+ bboxes[f"fill_{coll_idx}"]["label"] = label or f"Fill {coll_idx}"
796
+ bboxes[f"fill_{coll_idx}"]["element_type"] = "fill"
797
+
798
+ # Add schema v0.3 geometry_px if available
799
+ if GEOMETRY_V03_AVAILABLE:
800
+ try:
801
+ geom = extract_polygon_geometry(coll, ax, fig)
802
+ bboxes[f"fill_{coll_idx}"]["geometry_px"] = geom
803
+ except Exception:
804
+ pass
805
+
806
+ coll_idx += 1
807
+ except Exception as e:
808
+ print(f"Error getting collection bbox: {e}")
809
+
810
+
811
+ def extract_bboxes_from_metadata(
812
+ metadata: Dict[str, Any],
813
+ img_width: int,
814
+ img_height: int,
815
+ ) -> Dict[str, Any]:
816
+ """Extract bounding boxes from pre-computed metadata (without re-rendering).
817
+
818
+ This is used when loading actual PNGs from bundles instead of re-rendering.
819
+ Extracts bbox info from:
820
+ - hit_regions (if available from v0.3 schema)
821
+ - elements dict
822
+ - axes positions
823
+
824
+ Args:
825
+ metadata: JSON metadata from spec.json or panel JSON
826
+ img_width: Image width in pixels
827
+ img_height: Image height in pixels
828
+
829
+ Returns:
830
+ Dict with bboxes keyed by element name
831
+ """
832
+ bboxes = {}
833
+
834
+ # Check for pre-computed hit_regions (v0.3 schema)
835
+ hit_regions = metadata.get("hit_regions", {})
836
+ if hit_regions:
837
+ color_map = hit_regions.get("color_map", {})
838
+ for element_name, color in color_map.items():
839
+ # We don't have exact coords from color map, but we can create placeholder
840
+ bboxes[element_name] = {
841
+ "label": element_name.replace("_", " ").title(),
842
+ "element_type": _guess_element_type(element_name),
843
+ }
844
+
845
+ # Check for geometry_px in cache (v0.3 layered bundle)
846
+ geometry_px = metadata.get("geometry_px", {})
847
+ if geometry_px:
848
+ for element_name, geom in geometry_px.items():
849
+ if isinstance(geom, dict) and "bbox" in geom:
850
+ bbox = geom["bbox"]
851
+ bboxes[element_name] = {
852
+ "x0": bbox.get("x0", 0),
853
+ "y0": bbox.get("y0", 0),
854
+ "x1": bbox.get("x1", img_width),
855
+ "y1": bbox.get("y1", img_height),
856
+ "label": element_name.replace("_", " ").title(),
857
+ "element_type": _guess_element_type(element_name),
858
+ }
859
+ if "points" in geom:
860
+ bboxes[element_name]["points"] = geom["points"]
861
+
862
+ # Extract from elements dict if present
863
+ elements = metadata.get("elements", {})
864
+ if not isinstance(elements, dict):
865
+ elements = {}
866
+ for element_name, element_info in elements.items():
867
+ if not isinstance(element_info, dict):
868
+ continue
869
+ if element_name not in bboxes:
870
+ bboxes[element_name] = {
871
+ "label": element_info.get("label", element_name.replace("_", " ").title()),
872
+ "element_type": element_info.get("type", _guess_element_type(element_name)),
873
+ }
874
+
875
+ # Extract from axes (handle both dict and list formats)
876
+ axes = metadata.get("axes", [])
877
+ if isinstance(axes, list):
878
+ axes_list = axes
879
+ elif isinstance(axes, dict):
880
+ axes_list = list(axes.values())
881
+ else:
882
+ axes_list = []
883
+
884
+ for i, ax_spec in enumerate(axes_list):
885
+ if not isinstance(ax_spec, dict):
886
+ continue
887
+
888
+ ax_id = ax_spec.get("id", f"ax{i}")
889
+
890
+ # Panel bbox - check for "bbox" field (new format) or "position" (old format)
891
+ bbox_spec = ax_spec.get("bbox", {})
892
+ pos = ax_spec.get("position", [])
893
+
894
+ if bbox_spec and isinstance(bbox_spec, dict):
895
+ # New format: bbox with x0, y0, width, height in panel fraction
896
+ x0_frac = bbox_spec.get("x0", 0)
897
+ y0_frac = bbox_spec.get("y0", 0)
898
+ w_frac = bbox_spec.get("width", 1)
899
+ h_frac = bbox_spec.get("height", 1)
900
+ x0 = int(x0_frac * img_width)
901
+ y0 = int(y0_frac * img_height)
902
+ x1 = int((x0_frac + w_frac) * img_width)
903
+ y1 = int((y0_frac + h_frac) * img_height)
904
+ bboxes[f"{ax_id}_panel"] = {
905
+ "x0": x0,
906
+ "y0": y0,
907
+ "x1": x1,
908
+ "y1": y1,
909
+ "label": f"Panel {ax_id}",
910
+ "ax_id": ax_id,
911
+ "is_panel": True,
912
+ }
913
+ elif len(pos) >= 4:
914
+ # Old format: position is in figure fraction [x0, y0, width, height]
915
+ x0 = int(pos[0] * img_width)
916
+ y0 = int((1 - pos[1] - pos[3]) * img_height) # Flip Y
917
+ x1 = int((pos[0] + pos[2]) * img_width)
918
+ y1 = int((1 - pos[1]) * img_height) # Flip Y
919
+ bboxes[f"{ax_id}_panel"] = {
920
+ "x0": x0,
921
+ "y0": y0,
922
+ "x1": x1,
923
+ "y1": y1,
924
+ "label": f"Panel {ax_id}",
925
+ "ax_id": ax_id,
926
+ "is_panel": True,
927
+ }
928
+
929
+ # Title/labels from labels dict (new format) or xaxis/yaxis (old format)
930
+ labels = ax_spec.get("labels", {})
931
+ xaxis = ax_spec.get("xaxis", {})
932
+ yaxis = ax_spec.get("yaxis", {})
933
+
934
+ xlabel = labels.get("xlabel") or (xaxis.get("label") if isinstance(xaxis, dict) else None)
935
+ ylabel = labels.get("ylabel") or (yaxis.get("label") if isinstance(yaxis, dict) else None)
936
+ title = labels.get("title")
937
+
938
+ if xlabel:
939
+ bboxes[f"{ax_id}_xlabel"] = {
940
+ "label": f"{ax_id}: {xlabel}",
941
+ "element_type": "xlabel",
942
+ "ax_id": ax_id,
943
+ }
944
+ if ylabel:
945
+ bboxes[f"{ax_id}_ylabel"] = {
946
+ "label": f"{ax_id}: {ylabel}",
947
+ "element_type": "ylabel",
948
+ "ax_id": ax_id,
949
+ }
950
+ if title:
951
+ bboxes[f"{ax_id}_title"] = {
952
+ "label": title,
953
+ "element_type": "title",
954
+ "ax_id": ax_id,
955
+ }
956
+
957
+ # Extract from traces array (pltz spec format)
958
+ traces = metadata.get("traces", [])
959
+ if isinstance(traces, list):
960
+ for i, trace in enumerate(traces):
961
+ if not isinstance(trace, dict):
962
+ continue
963
+ trace_id = trace.get("id", f"trace_{i}")
964
+ trace_type = trace.get("type", "line")
965
+ trace_label = trace.get("label", f"Trace {i}")
966
+ ax_idx = trace.get("axes_index", 0)
967
+
968
+ # Use axes bbox as fallback for trace bbox
969
+ ax_panel_key = None
970
+ for key in bboxes:
971
+ if key.endswith("_panel") and bboxes[key].get("ax_id", "").endswith(str(ax_idx)):
972
+ ax_panel_key = key
973
+ break
974
+ if not ax_panel_key:
975
+ # Find any panel bbox
976
+ for key in bboxes:
977
+ if key.endswith("_panel"):
978
+ ax_panel_key = key
979
+ break
980
+
981
+ trace_bbox = {
982
+ "label": trace_label,
983
+ "element_type": trace_type,
984
+ "trace_idx": i,
985
+ "ax_id": f"ax{ax_idx}",
986
+ }
987
+
988
+ # Copy panel bbox coordinates if available
989
+ if ax_panel_key and ax_panel_key in bboxes:
990
+ panel = bboxes[ax_panel_key]
991
+ trace_bbox["x0"] = panel.get("x0", 0)
992
+ trace_bbox["y0"] = panel.get("y0", 0)
993
+ trace_bbox["x1"] = panel.get("x1", img_width)
994
+ trace_bbox["y1"] = panel.get("y1", img_height)
995
+
996
+ bboxes[f"trace_{i}"] = trace_bbox
997
+
998
+ # If no bboxes found, return minimal set
999
+ if not bboxes:
1000
+ bboxes["panel"] = {
1001
+ "x0": 0,
1002
+ "y0": 0,
1003
+ "x1": img_width,
1004
+ "y1": img_height,
1005
+ "label": "Panel",
1006
+ "is_panel": True,
1007
+ }
1008
+
1009
+ return bboxes
1010
+
1011
+
1012
+ def _guess_element_type(name: str) -> str:
1013
+ """Guess element type from element name."""
1014
+ name_lower = name.lower()
1015
+ if "line" in name_lower or "trace" in name_lower:
1016
+ return "line"
1017
+ elif "scatter" in name_lower:
1018
+ return "scatter"
1019
+ elif "bar" in name_lower:
1020
+ return "bar"
1021
+ elif "fill" in name_lower:
1022
+ return "fill"
1023
+ elif "xlabel" in name_lower:
1024
+ return "xlabel"
1025
+ elif "ylabel" in name_lower:
1026
+ return "ylabel"
1027
+ elif "title" in name_lower:
1028
+ return "title"
1029
+ elif "legend" in name_lower:
1030
+ return "legend"
1031
+ elif "xaxis" in name_lower:
1032
+ return "xaxis"
1033
+ elif "yaxis" in name_lower:
1034
+ return "yaxis"
1035
+ elif "panel" in name_lower:
1036
+ return "panel"
1037
+ return "unknown"
1038
+
1039
+
1040
+ def extract_bboxes_from_geometry_px(
1041
+ geometry_data: Dict[str, Any],
1042
+ img_width: int,
1043
+ img_height: int,
1044
+ ) -> Dict[str, Any]:
1045
+ """Extract bounding boxes from geometry_px.json (cached pixel coordinates).
1046
+
1047
+ This provides precise pixel coordinates for interactive element selection.
1048
+
1049
+ Args:
1050
+ geometry_data: JSON data from geometry_px.json
1051
+ img_width: Actual image width in pixels
1052
+ img_height: Actual image height in pixels
1053
+
1054
+ Returns:
1055
+ Dict with bboxes keyed by element name
1056
+ """
1057
+ bboxes = {}
1058
+
1059
+ # Get figure dimensions from geometry to calculate scale
1060
+ figure_px = geometry_data.get("figure_px", [img_width, img_height])
1061
+ if isinstance(figure_px, list) and len(figure_px) >= 2:
1062
+ geom_width, geom_height = figure_px[0], figure_px[1]
1063
+ else:
1064
+ geom_width, geom_height = img_width, img_height
1065
+
1066
+ # Scale factor if image size differs from geometry
1067
+ scale_x = img_width / geom_width if geom_width > 0 else 1
1068
+ scale_y = img_height / geom_height if geom_height > 0 else 1
1069
+
1070
+ # Extract axes bboxes
1071
+ axes = geometry_data.get("axes", [])
1072
+ for i, ax in enumerate(axes):
1073
+ if not isinstance(ax, dict):
1074
+ continue
1075
+ ax_id = ax.get("id", f"ax{i}")
1076
+ bbox_px = ax.get("bbox_px", {})
1077
+ if bbox_px:
1078
+ x0 = float(bbox_px.get("x0", 0)) * scale_x
1079
+ y0 = float(bbox_px.get("y0", 0)) * scale_y
1080
+ w = float(bbox_px.get("width", 0)) * scale_x
1081
+ h = float(bbox_px.get("height", 0)) * scale_y
1082
+ bboxes[f"{ax_id}_panel"] = {
1083
+ "x0": int(x0),
1084
+ "y0": int(y0),
1085
+ "x1": int(x0 + w),
1086
+ "y1": int(y0 + h),
1087
+ "label": f"Axes {ax_id}",
1088
+ "ax_id": ax_id,
1089
+ "is_panel": True,
1090
+ }
1091
+
1092
+ # Helper to safely convert to int (handle inf/nan)
1093
+ def safe_int(val, default=0, max_val=10000):
1094
+ import math
1095
+ if val is None or math.isinf(val) or math.isnan(val):
1096
+ return default
1097
+ return max(0, min(int(val), max_val))
1098
+
1099
+ # Extract artists (lines, scatter, bars, etc.)
1100
+ artists = geometry_data.get("artists", [])
1101
+ for i, artist in enumerate(artists):
1102
+ if not isinstance(artist, dict):
1103
+ continue
1104
+
1105
+ artist_id = artist.get("id", str(i))
1106
+ artist_type = artist.get("type", "unknown")
1107
+ artist_label = artist.get("label") or f"{artist_type}_{i}"
1108
+ axes_index = artist.get("axes_index", 0)
1109
+
1110
+ # Get bbox_px
1111
+ bbox_px = artist.get("bbox_px", {})
1112
+ if bbox_px:
1113
+ x0 = float(bbox_px.get("x0", 0)) * scale_x
1114
+ y0 = float(bbox_px.get("y0", 0)) * scale_y
1115
+ w = float(bbox_px.get("width", 0)) * scale_x
1116
+ h = float(bbox_px.get("height", 0)) * scale_y
1117
+
1118
+ artist_bbox = {
1119
+ "x0": safe_int(x0, 0, img_width),
1120
+ "y0": safe_int(y0, 0, img_height),
1121
+ "x1": safe_int(x0 + w, img_width, img_width),
1122
+ "y1": safe_int(y0 + h, img_height, img_height),
1123
+ "label": artist_label,
1124
+ "element_type": artist_type,
1125
+ "trace_idx": i,
1126
+ "ax_id": f"ax{axes_index}",
1127
+ }
1128
+
1129
+ # Get path_px for lines (for precise hover detection)
1130
+ path_px = artist.get("path_px", [])
1131
+ if path_px and len(path_px) > 0:
1132
+ import math
1133
+ # Scale points to actual image coordinates, filter out inf/nan
1134
+ scaled_points = []
1135
+ for pt in path_px:
1136
+ if isinstance(pt, (list, tuple)) and len(pt) >= 2:
1137
+ px, py = pt[0] * scale_x, pt[1] * scale_y
1138
+ if not (math.isinf(px) or math.isinf(py) or math.isnan(px) or math.isnan(py)):
1139
+ scaled_points.append([px, py])
1140
+ if scaled_points:
1141
+ artist_bbox["points"] = scaled_points
1142
+
1143
+ # Get scatter points if available
1144
+ scatter_px = artist.get("scatter_px", [])
1145
+ if scatter_px and len(scatter_px) > 0:
1146
+ import math
1147
+ scaled_points = []
1148
+ for pt in scatter_px:
1149
+ if isinstance(pt, (list, tuple)) and len(pt) >= 2:
1150
+ px, py = pt[0] * scale_x, pt[1] * scale_y
1151
+ if not (math.isinf(px) or math.isinf(py) or math.isnan(px) or math.isnan(py)):
1152
+ scaled_points.append([px, py])
1153
+ if scaled_points:
1154
+ artist_bbox["points"] = scaled_points
1155
+ artist_bbox["element_type"] = "scatter"
1156
+
1157
+ bboxes[f"trace_{i}"] = artist_bbox
1158
+
1159
+ # Extract from selectable_regions (title, xlabel, ylabel, xaxis, yaxis)
1160
+ selectable = geometry_data.get("selectable_regions", {})
1161
+ sel_axes = selectable.get("axes", [])
1162
+ for ax_data in sel_axes:
1163
+ if not isinstance(ax_data, dict):
1164
+ continue
1165
+ ax_idx = ax_data.get("index", 0)
1166
+ ax_id = f"ax{ax_idx}"
1167
+
1168
+ # Title
1169
+ title_data = ax_data.get("title", {})
1170
+ if title_data and "bbox_px" in title_data:
1171
+ bbox = title_data["bbox_px"]
1172
+ if isinstance(bbox, list) and len(bbox) >= 4:
1173
+ bboxes[f"{ax_id}_title"] = {
1174
+ "x0": safe_int(bbox[0] * scale_x, 0, img_width),
1175
+ "y0": safe_int(bbox[1] * scale_y, 0, img_height),
1176
+ "x1": safe_int(bbox[2] * scale_x, img_width, img_width),
1177
+ "y1": safe_int(bbox[3] * scale_y, img_height, img_height),
1178
+ "label": title_data.get("text", "Title"),
1179
+ "element_type": "title",
1180
+ "ax_id": ax_id,
1181
+ }
1182
+
1183
+ # X Label
1184
+ xlabel_data = ax_data.get("xlabel", {})
1185
+ if xlabel_data and "bbox_px" in xlabel_data:
1186
+ bbox = xlabel_data["bbox_px"]
1187
+ if isinstance(bbox, list) and len(bbox) >= 4:
1188
+ bboxes[f"{ax_id}_xlabel"] = {
1189
+ "x0": safe_int(bbox[0] * scale_x, 0, img_width),
1190
+ "y0": safe_int(bbox[1] * scale_y, 0, img_height),
1191
+ "x1": safe_int(bbox[2] * scale_x, img_width, img_width),
1192
+ "y1": safe_int(bbox[3] * scale_y, img_height, img_height),
1193
+ "label": xlabel_data.get("text", "X Label"),
1194
+ "element_type": "xlabel",
1195
+ "ax_id": ax_id,
1196
+ }
1197
+
1198
+ # Y Label
1199
+ ylabel_data = ax_data.get("ylabel", {})
1200
+ if ylabel_data and "bbox_px" in ylabel_data:
1201
+ bbox = ylabel_data["bbox_px"]
1202
+ if isinstance(bbox, list) and len(bbox) >= 4:
1203
+ bboxes[f"{ax_id}_ylabel"] = {
1204
+ "x0": safe_int(bbox[0] * scale_x, 0, img_width),
1205
+ "y0": safe_int(bbox[1] * scale_y, 0, img_height),
1206
+ "x1": safe_int(bbox[2] * scale_x, img_width, img_width),
1207
+ "y1": safe_int(bbox[3] * scale_y, img_height, img_height),
1208
+ "label": ylabel_data.get("text", "Y Label"),
1209
+ "element_type": "ylabel",
1210
+ "ax_id": ax_id,
1211
+ }
1212
+
1213
+ # X Axis spine
1214
+ xaxis_data = ax_data.get("xaxis", {})
1215
+ if xaxis_data:
1216
+ spine = xaxis_data.get("spine", {})
1217
+ if spine and "bbox_px" in spine:
1218
+ bbox = spine["bbox_px"]
1219
+ if isinstance(bbox, list) and len(bbox) >= 4:
1220
+ bboxes[f"{ax_id}_xaxis"] = {
1221
+ "x0": safe_int(bbox[0] * scale_x, 0, img_width),
1222
+ "y0": safe_int(bbox[1] * scale_y, 0, img_height),
1223
+ "x1": safe_int(bbox[2] * scale_x, img_width, img_width),
1224
+ "y1": safe_int(bbox[3] * scale_y, img_height, img_height),
1225
+ "label": "X Axis",
1226
+ "element_type": "xaxis",
1227
+ "ax_id": ax_id,
1228
+ }
1229
+
1230
+ # Y Axis spine
1231
+ yaxis_data = ax_data.get("yaxis", {})
1232
+ if yaxis_data:
1233
+ spine = yaxis_data.get("spine", {})
1234
+ if spine and "bbox_px" in spine:
1235
+ bbox = spine["bbox_px"]
1236
+ if isinstance(bbox, list) and len(bbox) >= 4:
1237
+ bboxes[f"{ax_id}_yaxis"] = {
1238
+ "x0": safe_int(bbox[0] * scale_x, 0, img_width),
1239
+ "y0": safe_int(bbox[1] * scale_y, 0, img_height),
1240
+ "x1": safe_int(bbox[2] * scale_x, img_width, img_width),
1241
+ "y1": safe_int(bbox[3] * scale_y, img_height, img_height),
1242
+ "label": "Y Axis",
1243
+ "element_type": "yaxis",
1244
+ "ax_id": ax_id,
1245
+ }
1246
+
1247
+ # Legend
1248
+ legend_data = ax_data.get("legend", {})
1249
+ if legend_data and "bbox_px" in legend_data:
1250
+ bbox = legend_data["bbox_px"]
1251
+ if isinstance(bbox, list) and len(bbox) >= 4:
1252
+ bboxes[f"{ax_id}_legend"] = {
1253
+ "x0": safe_int(bbox[0] * scale_x, 0, img_width),
1254
+ "y0": safe_int(bbox[1] * scale_y, 0, img_height),
1255
+ "x1": safe_int(bbox[2] * scale_x, img_width, img_width),
1256
+ "y1": safe_int(bbox[3] * scale_y, img_height, img_height),
1257
+ "label": "Legend",
1258
+ "element_type": "legend",
1259
+ "ax_id": ax_id,
1260
+ }
1261
+
1262
+ # If no bboxes found, return minimal set
1263
+ if not bboxes:
1264
+ bboxes["panel"] = {
1265
+ "x0": 0,
1266
+ "y0": 0,
1267
+ "x1": img_width,
1268
+ "y1": img_height,
1269
+ "label": "Panel",
1270
+ "is_panel": True,
1271
+ }
1272
+
1273
+ return bboxes
1274
+
1275
+
1276
+ # EOF