scitex 2.14.0__py3-none-any.whl → 2.15.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 (264) hide show
  1. scitex/__init__.py +71 -17
  2. scitex/_env_loader.py +156 -0
  3. scitex/_mcp_resources/__init__.py +37 -0
  4. scitex/_mcp_resources/_cheatsheet.py +135 -0
  5. scitex/_mcp_resources/_figrecipe.py +138 -0
  6. scitex/_mcp_resources/_formats.py +102 -0
  7. scitex/_mcp_resources/_modules.py +337 -0
  8. scitex/_mcp_resources/_session.py +149 -0
  9. scitex/_mcp_tools/__init__.py +4 -0
  10. scitex/_mcp_tools/audio.py +66 -0
  11. scitex/_mcp_tools/diagram.py +11 -95
  12. scitex/_mcp_tools/introspect.py +210 -0
  13. scitex/_mcp_tools/plt.py +260 -305
  14. scitex/_mcp_tools/scholar.py +74 -0
  15. scitex/_mcp_tools/social.py +27 -0
  16. scitex/_mcp_tools/template.py +24 -0
  17. scitex/_mcp_tools/writer.py +17 -210
  18. scitex/ai/_gen_ai/_PARAMS.py +10 -7
  19. scitex/ai/classification/reporters/_SingleClassificationReporter.py +45 -1603
  20. scitex/ai/classification/reporters/_mixins/__init__.py +36 -0
  21. scitex/ai/classification/reporters/_mixins/_constants.py +67 -0
  22. scitex/ai/classification/reporters/_mixins/_cv_summary.py +387 -0
  23. scitex/ai/classification/reporters/_mixins/_feature_importance.py +119 -0
  24. scitex/ai/classification/reporters/_mixins/_metrics.py +275 -0
  25. scitex/ai/classification/reporters/_mixins/_plotting.py +179 -0
  26. scitex/ai/classification/reporters/_mixins/_reports.py +153 -0
  27. scitex/ai/classification/reporters/_mixins/_storage.py +160 -0
  28. scitex/ai/classification/timeseries/_TimeSeriesSlidingWindowSplit.py +30 -1550
  29. scitex/ai/classification/timeseries/_sliding_window_core.py +467 -0
  30. scitex/ai/classification/timeseries/_sliding_window_plotting.py +369 -0
  31. scitex/audio/README.md +40 -36
  32. scitex/audio/__init__.py +129 -61
  33. scitex/audio/_branding.py +185 -0
  34. scitex/audio/_mcp/__init__.py +32 -0
  35. scitex/audio/_mcp/handlers.py +59 -6
  36. scitex/audio/_mcp/speak_handlers.py +238 -0
  37. scitex/audio/_relay.py +225 -0
  38. scitex/audio/_tts.py +18 -10
  39. scitex/audio/engines/base.py +17 -10
  40. scitex/audio/engines/elevenlabs_engine.py +7 -2
  41. scitex/audio/mcp_server.py +228 -75
  42. scitex/canvas/README.md +1 -1
  43. scitex/canvas/editor/_dearpygui/__init__.py +25 -0
  44. scitex/canvas/editor/_dearpygui/_editor.py +147 -0
  45. scitex/canvas/editor/_dearpygui/_handlers.py +476 -0
  46. scitex/canvas/editor/_dearpygui/_panels/__init__.py +17 -0
  47. scitex/canvas/editor/_dearpygui/_panels/_control.py +119 -0
  48. scitex/canvas/editor/_dearpygui/_panels/_element_controls.py +190 -0
  49. scitex/canvas/editor/_dearpygui/_panels/_preview.py +43 -0
  50. scitex/canvas/editor/_dearpygui/_panels/_sections.py +390 -0
  51. scitex/canvas/editor/_dearpygui/_plotting.py +187 -0
  52. scitex/canvas/editor/_dearpygui/_rendering.py +504 -0
  53. scitex/canvas/editor/_dearpygui/_selection.py +295 -0
  54. scitex/canvas/editor/_dearpygui/_state.py +93 -0
  55. scitex/canvas/editor/_dearpygui/_utils.py +61 -0
  56. scitex/canvas/editor/flask_editor/_core/__init__.py +27 -0
  57. scitex/canvas/editor/flask_editor/_core/_bbox_extraction.py +200 -0
  58. scitex/canvas/editor/flask_editor/_core/_editor.py +173 -0
  59. scitex/canvas/editor/flask_editor/_core/_export_helpers.py +353 -0
  60. scitex/canvas/editor/flask_editor/_core/_routes_basic.py +190 -0
  61. scitex/canvas/editor/flask_editor/_core/_routes_export.py +332 -0
  62. scitex/canvas/editor/flask_editor/_core/_routes_panels.py +252 -0
  63. scitex/canvas/editor/flask_editor/_core/_routes_save.py +218 -0
  64. scitex/canvas/editor/flask_editor/_core.py +25 -1684
  65. scitex/canvas/editor/flask_editor/templates/__init__.py +32 -70
  66. scitex/cli/__init__.py +38 -43
  67. scitex/cli/audio.py +160 -41
  68. scitex/cli/capture.py +133 -20
  69. scitex/cli/introspect.py +488 -0
  70. scitex/cli/main.py +200 -109
  71. scitex/cli/mcp.py +60 -34
  72. scitex/cli/plt.py +414 -0
  73. scitex/cli/repro.py +15 -8
  74. scitex/cli/resource.py +15 -8
  75. scitex/cli/scholar/__init__.py +154 -8
  76. scitex/cli/scholar/_crossref_scitex.py +296 -0
  77. scitex/cli/scholar/_fetch.py +25 -3
  78. scitex/cli/social.py +355 -0
  79. scitex/cli/stats.py +136 -11
  80. scitex/cli/template.py +129 -12
  81. scitex/cli/tex.py +15 -8
  82. scitex/cli/writer.py +49 -299
  83. scitex/cloud/__init__.py +41 -2
  84. scitex/config/README.md +1 -1
  85. scitex/config/__init__.py +16 -2
  86. scitex/config/_env_registry.py +256 -0
  87. scitex/context/__init__.py +22 -0
  88. scitex/dev/__init__.py +20 -1
  89. scitex/diagram/__init__.py +42 -19
  90. scitex/diagram/mcp_server.py +13 -125
  91. scitex/gen/__init__.py +50 -14
  92. scitex/gen/_list_packages.py +4 -4
  93. scitex/introspect/__init__.py +82 -0
  94. scitex/introspect/_call_graph.py +303 -0
  95. scitex/introspect/_class_hierarchy.py +163 -0
  96. scitex/introspect/_core.py +41 -0
  97. scitex/introspect/_docstring.py +131 -0
  98. scitex/introspect/_examples.py +113 -0
  99. scitex/introspect/_imports.py +271 -0
  100. scitex/{gen/_inspect_module.py → introspect/_list_api.py} +48 -56
  101. scitex/introspect/_mcp/__init__.py +41 -0
  102. scitex/introspect/_mcp/handlers.py +233 -0
  103. scitex/introspect/_members.py +155 -0
  104. scitex/introspect/_resolve.py +89 -0
  105. scitex/introspect/_signature.py +131 -0
  106. scitex/introspect/_source.py +80 -0
  107. scitex/introspect/_type_hints.py +172 -0
  108. scitex/io/_save.py +1 -2
  109. scitex/io/bundle/README.md +1 -1
  110. scitex/logging/_formatters.py +19 -9
  111. scitex/mcp_server.py +98 -5
  112. scitex/os/__init__.py +4 -0
  113. scitex/{gen → os}/_check_host.py +4 -5
  114. scitex/plt/__init__.py +245 -550
  115. scitex/plt/_subplots/_AxisWrapperMixins/_SeabornMixin/_wrappers.py +5 -10
  116. scitex/plt/docs/EXTERNAL_PACKAGE_BRANDING.md +149 -0
  117. scitex/plt/gallery/README.md +1 -1
  118. scitex/plt/utils/_hitmap/__init__.py +82 -0
  119. scitex/plt/utils/_hitmap/_artist_extraction.py +343 -0
  120. scitex/plt/utils/_hitmap/_color_application.py +346 -0
  121. scitex/plt/utils/_hitmap/_color_conversion.py +121 -0
  122. scitex/plt/utils/_hitmap/_constants.py +40 -0
  123. scitex/plt/utils/_hitmap/_hitmap_core.py +334 -0
  124. scitex/plt/utils/_hitmap/_path_extraction.py +357 -0
  125. scitex/plt/utils/_hitmap/_query.py +113 -0
  126. scitex/plt/utils/_hitmap.py +46 -1616
  127. scitex/plt/utils/_metadata/__init__.py +80 -0
  128. scitex/plt/utils/_metadata/_artists/__init__.py +25 -0
  129. scitex/plt/utils/_metadata/_artists/_base.py +195 -0
  130. scitex/plt/utils/_metadata/_artists/_collections.py +356 -0
  131. scitex/plt/utils/_metadata/_artists/_extract.py +57 -0
  132. scitex/plt/utils/_metadata/_artists/_images.py +80 -0
  133. scitex/plt/utils/_metadata/_artists/_lines.py +261 -0
  134. scitex/plt/utils/_metadata/_artists/_patches.py +247 -0
  135. scitex/plt/utils/_metadata/_artists/_text.py +106 -0
  136. scitex/plt/utils/_metadata/_csv.py +416 -0
  137. scitex/plt/utils/_metadata/_detect.py +225 -0
  138. scitex/plt/utils/_metadata/_legend.py +127 -0
  139. scitex/plt/utils/_metadata/_rounding.py +117 -0
  140. scitex/plt/utils/_metadata/_verification.py +202 -0
  141. scitex/schema/README.md +1 -1
  142. scitex/scholar/__init__.py +8 -0
  143. scitex/scholar/_mcp/crossref_handlers.py +265 -0
  144. scitex/scholar/core/Scholar.py +63 -1700
  145. scitex/scholar/core/_mixins/__init__.py +36 -0
  146. scitex/scholar/core/_mixins/_enrichers.py +270 -0
  147. scitex/scholar/core/_mixins/_library_handlers.py +100 -0
  148. scitex/scholar/core/_mixins/_loaders.py +103 -0
  149. scitex/scholar/core/_mixins/_pdf_download.py +375 -0
  150. scitex/scholar/core/_mixins/_pipeline.py +312 -0
  151. scitex/scholar/core/_mixins/_project_handlers.py +125 -0
  152. scitex/scholar/core/_mixins/_savers.py +69 -0
  153. scitex/scholar/core/_mixins/_search.py +103 -0
  154. scitex/scholar/core/_mixins/_services.py +88 -0
  155. scitex/scholar/core/_mixins/_url_finding.py +105 -0
  156. scitex/scholar/crossref_scitex.py +367 -0
  157. scitex/scholar/docs/EXTERNAL_PACKAGE_BRANDING.md +149 -0
  158. scitex/scholar/examples/00_run_all.sh +120 -0
  159. scitex/scholar/jobs/_executors.py +27 -3
  160. scitex/scholar/pdf_download/ScholarPDFDownloader.py +38 -416
  161. scitex/scholar/pdf_download/_cli.py +154 -0
  162. scitex/scholar/pdf_download/strategies/__init__.py +11 -8
  163. scitex/scholar/pdf_download/strategies/manual_download_fallback.py +80 -3
  164. scitex/scholar/pipelines/ScholarPipelineBibTeX.py +73 -121
  165. scitex/scholar/pipelines/ScholarPipelineParallel.py +80 -138
  166. scitex/scholar/pipelines/ScholarPipelineSingle.py +43 -63
  167. scitex/scholar/pipelines/_single_steps.py +71 -36
  168. scitex/scholar/storage/_LibraryManager.py +97 -1695
  169. scitex/scholar/storage/_mixins/__init__.py +30 -0
  170. scitex/scholar/storage/_mixins/_bibtex_handlers.py +128 -0
  171. scitex/scholar/storage/_mixins/_library_operations.py +218 -0
  172. scitex/scholar/storage/_mixins/_metadata_conversion.py +226 -0
  173. scitex/scholar/storage/_mixins/_paper_saving.py +456 -0
  174. scitex/scholar/storage/_mixins/_resolution.py +376 -0
  175. scitex/scholar/storage/_mixins/_storage_helpers.py +121 -0
  176. scitex/scholar/storage/_mixins/_symlink_handlers.py +226 -0
  177. scitex/security/README.md +3 -3
  178. scitex/session/README.md +1 -1
  179. scitex/session/__init__.py +26 -7
  180. scitex/session/_decorator.py +1 -1
  181. scitex/sh/README.md +1 -1
  182. scitex/sh/__init__.py +7 -4
  183. scitex/social/__init__.py +155 -0
  184. scitex/social/docs/EXTERNAL_PACKAGE_BRANDING.md +149 -0
  185. scitex/stats/_mcp/_handlers/__init__.py +31 -0
  186. scitex/stats/_mcp/_handlers/_corrections.py +113 -0
  187. scitex/stats/_mcp/_handlers/_descriptive.py +78 -0
  188. scitex/stats/_mcp/_handlers/_effect_size.py +106 -0
  189. scitex/stats/_mcp/_handlers/_format.py +94 -0
  190. scitex/stats/_mcp/_handlers/_normality.py +110 -0
  191. scitex/stats/_mcp/_handlers/_posthoc.py +224 -0
  192. scitex/stats/_mcp/_handlers/_power.py +247 -0
  193. scitex/stats/_mcp/_handlers/_recommend.py +102 -0
  194. scitex/stats/_mcp/_handlers/_run_test.py +279 -0
  195. scitex/stats/_mcp/_handlers/_stars.py +48 -0
  196. scitex/stats/_mcp/handlers.py +19 -1171
  197. scitex/stats/auto/_stat_style.py +175 -0
  198. scitex/stats/auto/_style_definitions.py +411 -0
  199. scitex/stats/auto/_styles.py +22 -620
  200. scitex/stats/descriptive/__init__.py +11 -8
  201. scitex/stats/descriptive/_ci.py +39 -0
  202. scitex/stats/power/_power.py +15 -4
  203. scitex/str/__init__.py +2 -1
  204. scitex/str/_title_case.py +63 -0
  205. scitex/template/README.md +1 -1
  206. scitex/template/__init__.py +25 -10
  207. scitex/template/_code_templates.py +147 -0
  208. scitex/template/_mcp/handlers.py +81 -0
  209. scitex/template/_mcp/tool_schemas.py +55 -0
  210. scitex/template/_templates/__init__.py +51 -0
  211. scitex/template/_templates/audio.py +233 -0
  212. scitex/template/_templates/canvas.py +312 -0
  213. scitex/template/_templates/capture.py +268 -0
  214. scitex/template/_templates/config.py +43 -0
  215. scitex/template/_templates/diagram.py +294 -0
  216. scitex/template/_templates/io.py +107 -0
  217. scitex/template/_templates/module.py +53 -0
  218. scitex/template/_templates/plt.py +202 -0
  219. scitex/template/_templates/scholar.py +267 -0
  220. scitex/template/_templates/session.py +130 -0
  221. scitex/template/_templates/session_minimal.py +43 -0
  222. scitex/template/_templates/session_plot.py +67 -0
  223. scitex/template/_templates/session_stats.py +77 -0
  224. scitex/template/_templates/stats.py +323 -0
  225. scitex/template/_templates/writer.py +296 -0
  226. scitex/template/clone_writer_directory.py +5 -5
  227. scitex/ui/_backends/_email.py +10 -2
  228. scitex/ui/_backends/_webhook.py +5 -1
  229. scitex/web/_search_pubmed.py +10 -6
  230. scitex/writer/README.md +1 -1
  231. scitex/writer/__init__.py +43 -34
  232. scitex/writer/_mcp/handlers.py +11 -744
  233. scitex/writer/_mcp/tool_schemas.py +5 -335
  234. scitex-2.15.3.dist-info/METADATA +667 -0
  235. {scitex-2.14.0.dist-info → scitex-2.15.3.dist-info}/RECORD +241 -120
  236. scitex/canvas/editor/flask_editor/templates/_scripts.py +0 -4933
  237. scitex/canvas/editor/flask_editor/templates/_styles.py +0 -1658
  238. scitex/diagram/_compile.py +0 -312
  239. scitex/diagram/_diagram.py +0 -355
  240. scitex/diagram/_mcp/__init__.py +0 -4
  241. scitex/diagram/_mcp/handlers.py +0 -400
  242. scitex/diagram/_mcp/tool_schemas.py +0 -157
  243. scitex/diagram/_presets.py +0 -173
  244. scitex/diagram/_schema.py +0 -182
  245. scitex/diagram/_split.py +0 -278
  246. scitex/gen/_ci.py +0 -12
  247. scitex/gen/_title_case.py +0 -89
  248. scitex/plt/_mcp/__init__.py +0 -4
  249. scitex/plt/_mcp/_handlers_annotation.py +0 -102
  250. scitex/plt/_mcp/_handlers_figure.py +0 -195
  251. scitex/plt/_mcp/_handlers_plot.py +0 -252
  252. scitex/plt/_mcp/_handlers_style.py +0 -219
  253. scitex/plt/_mcp/handlers.py +0 -74
  254. scitex/plt/_mcp/tool_schemas.py +0 -497
  255. scitex/plt/mcp_server.py +0 -231
  256. scitex/scholar/examples/SUGGESTIONS.md +0 -865
  257. scitex/scholar/examples/dev.py +0 -38
  258. scitex-2.14.0.dist-info/METADATA +0 -1238
  259. /scitex/{gen → context}/_detect_environment.py +0 -0
  260. /scitex/{gen → context}/_get_notebook_path.py +0 -0
  261. /scitex/{gen/_shell.py → sh/_shell_legacy.py} +0 -0
  262. {scitex-2.14.0.dist-info → scitex-2.15.3.dist-info}/WHEEL +0 -0
  263. {scitex-2.14.0.dist-info → scitex-2.15.3.dist-info}/entry_points.txt +0 -0
  264. {scitex-2.14.0.dist-info → scitex-2.15.3.dist-info}/licenses/LICENSE +0 -0
@@ -1,7 +1,6 @@
1
1
  #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
3
- # Timestamp: "2025-12-12 (ywatanabe)"
4
- # File: scitex/plt/utils/_hitmap.py
2
+ # Timestamp: "2026-01-24 (ywatanabe)"
3
+ # File: /home/ywatanabe/proj/scitex-python/src/scitex/plt/utils/_hitmap.py
5
4
 
6
5
  """
7
6
  Hit map generation utilities for interactive element selection.
@@ -20,1624 +19,55 @@ Based on experimental results (see FIGZ_PLTZ_STATSZ.md):
20
19
  Reserved colors:
21
20
  - Black (#000000, ID=0): Background/no element
22
21
  - Dark gray (#010101, ID=65793): Non-selectable axes elements (spines, labels, ticks)
22
+
23
+ This module re-exports all functions from the _hitmap package for backward
24
+ compatibility. The actual implementation is in the _hitmap/ subpackage.
23
25
  """
24
26
 
25
- import warnings
26
- from typing import Dict, List, Any, Optional, Tuple
27
- import numpy as np
27
+ # Re-export all public API from the _hitmap package
28
+ from ._hitmap import (
29
+ HITMAP_AXES_COLOR,
30
+ HITMAP_BACKGROUND_COLOR,
31
+ _apply_id_color,
32
+ _id_to_rgb,
33
+ _prepare_hitmap_figure,
34
+ _restore_figure_props,
35
+ _rgb_to_id_lookup,
36
+ _to_native,
37
+ apply_hitmap_colors,
38
+ detect_logical_groups,
39
+ extract_path_data,
40
+ extract_selectable_regions,
41
+ generate_hitmap_id_colors,
42
+ generate_hitmap_with_bbox_tight,
43
+ get_all_artists,
44
+ get_all_artists_with_groups,
45
+ query_hitmap_neighborhood,
46
+ restore_original_colors,
47
+ save_hitmap_png,
48
+ )
28
49
 
29
50
  __all__ = [
30
- 'get_all_artists',
31
- 'get_all_artists_with_groups',
32
- 'detect_logical_groups',
33
- 'generate_hitmap_id_colors',
34
- 'extract_path_data',
35
- 'extract_selectable_regions',
36
- 'query_hitmap_neighborhood',
37
- 'save_hitmap_png',
38
- 'apply_hitmap_colors',
39
- 'restore_original_colors',
40
- 'generate_hitmap_with_bbox_tight',
41
- 'HITMAP_BACKGROUND_COLOR',
42
- 'HITMAP_AXES_COLOR',
43
- '_rgb_to_id_lookup',
51
+ "get_all_artists",
52
+ "get_all_artists_with_groups",
53
+ "detect_logical_groups",
54
+ "generate_hitmap_id_colors",
55
+ "extract_path_data",
56
+ "extract_selectable_regions",
57
+ "query_hitmap_neighborhood",
58
+ "save_hitmap_png",
59
+ "apply_hitmap_colors",
60
+ "restore_original_colors",
61
+ "generate_hitmap_with_bbox_tight",
62
+ "HITMAP_BACKGROUND_COLOR",
63
+ "HITMAP_AXES_COLOR",
64
+ "_rgb_to_id_lookup",
65
+ "_to_native",
66
+ "_id_to_rgb",
67
+ "_apply_id_color",
68
+ "_prepare_hitmap_figure",
69
+ "_restore_figure_props",
44
70
  ]
45
71
 
46
72
 
47
- def _to_native(obj: Any) -> Any:
48
- """Convert numpy types to native Python types for JSON serialization."""
49
- if isinstance(obj, np.integer):
50
- return int(obj)
51
- elif isinstance(obj, np.floating):
52
- return float(obj)
53
- elif isinstance(obj, np.ndarray):
54
- return obj.tolist()
55
- elif isinstance(obj, dict):
56
- return {k: _to_native(v) for k, v in obj.items()}
57
- elif isinstance(obj, list):
58
- return [_to_native(v) for v in obj]
59
- return obj
60
-
61
-
62
- def get_all_artists(fig, include_text: bool = False) -> List[Tuple[Any, int, str]]:
63
- """
64
- Extract all selectable artists from a figure.
65
-
66
- Parameters
67
- ----------
68
- fig : matplotlib.figure.Figure
69
- The figure to extract artists from.
70
- include_text : bool
71
- Whether to include text elements.
72
-
73
- Returns
74
- -------
75
- list of tuple
76
- List of (artist, axes_index, artist_type) tuples.
77
- """
78
- artists = []
79
-
80
- for ax_idx, ax in enumerate(fig.axes):
81
- # Lines (Line2D)
82
- for line in ax.get_lines():
83
- label = line.get_label()
84
- if not label.startswith('_'): # Skip internal lines
85
- artists.append((line, ax_idx, 'line'))
86
-
87
- # Scatter plots (PathCollection)
88
- for coll in ax.collections:
89
- coll_type = type(coll).__name__
90
- if 'PathCollection' in coll_type:
91
- artists.append((coll, ax_idx, 'scatter'))
92
- elif 'PolyCollection' in coll_type or 'FillBetween' in coll_type:
93
- artists.append((coll, ax_idx, 'fill'))
94
- elif 'QuadMesh' in coll_type:
95
- artists.append((coll, ax_idx, 'mesh'))
96
-
97
- # Bars (Rectangle patches in containers)
98
- for container in ax.containers:
99
- if hasattr(container, 'patches') and container.patches:
100
- artists.append((container, ax_idx, 'bar'))
101
-
102
- # Individual patches (rectangles, circles, etc.)
103
- for patch in ax.patches:
104
- patch_type = type(patch).__name__
105
- if patch_type == 'Rectangle':
106
- artists.append((patch, ax_idx, 'rectangle'))
107
- elif patch_type in ('Circle', 'Ellipse'):
108
- artists.append((patch, ax_idx, 'circle'))
109
- elif patch_type == 'Polygon':
110
- artists.append((patch, ax_idx, 'polygon'))
111
-
112
- # Images
113
- for img in ax.images:
114
- artists.append((img, ax_idx, 'image'))
115
-
116
- # Text (optional)
117
- if include_text:
118
- for text in ax.texts:
119
- if text.get_text():
120
- artists.append((text, ax_idx, 'text'))
121
-
122
- return artists
123
-
124
-
125
- def detect_logical_groups(fig) -> Dict[str, Dict[str, Any]]:
126
- """
127
- Detect logical groups in a matplotlib figure.
128
-
129
- Logical groups represent high-level plot elements that may consist of
130
- multiple physical matplotlib artists. For example:
131
- - Histogram: Many Rectangle patches grouped as one "histogram"
132
- - Bar series: BarContainer with multiple bars
133
- - Box plot: Box, whiskers, caps, median, fliers as one "boxplot"
134
- - Error bars: Line + error caps as one "errorbar"
135
-
136
- Parameters
137
- ----------
138
- fig : matplotlib.figure.Figure
139
- The figure to analyze.
140
-
141
- Returns
142
- -------
143
- dict
144
- Dictionary mapping group_id to group info:
145
- {
146
- "histogram_0": {
147
- "type": "histogram",
148
- "label": "...",
149
- "axes_index": 0,
150
- "artists": [list of matplotlib artists],
151
- "role": "logical"
152
- },
153
- ...
154
- }
155
- """
156
- groups = {}
157
- group_counter = {} # Track count per type for unique IDs
158
-
159
- def get_group_id(group_type: str, ax_idx: int) -> str:
160
- """Generate unique group ID."""
161
- key = f"{group_type}_{ax_idx}"
162
- if key not in group_counter:
163
- group_counter[key] = 0
164
- idx = group_counter[key]
165
- group_counter[key] += 1
166
- return f"{group_type}_{ax_idx}_{idx}"
167
-
168
- for ax_idx, ax in enumerate(fig.axes):
169
- # 1. Detect BarContainers (covers bar charts and histograms)
170
- # First, count how many BarContainers exist on this axis
171
- bar_containers = [c for c in ax.containers if 'BarContainer' in type(c).__name__]
172
- n_bar_containers = len(bar_containers)
173
-
174
- for container in ax.containers:
175
- container_type = type(container).__name__
176
-
177
- if 'BarContainer' in container_type:
178
- # Determine if it's a histogram, grouped bar series, or simple categorical bar
179
- patches = list(container.patches) if hasattr(container, 'patches') else []
180
- if not patches:
181
- continue
182
-
183
- # Check if bars are adjacent (histogram) or spaced (bar chart)
184
- is_histogram = False
185
- if len(patches) > 1:
186
- # Check if bars are adjacent (no gaps between them)
187
- widths = [p.get_width() for p in patches]
188
- x_positions = [p.get_x() for p in patches]
189
- if len(x_positions) > 1:
190
- gaps = [x_positions[i+1] - (x_positions[i] + widths[i])
191
- for i in range(len(x_positions)-1)]
192
- # If gaps are very small relative to bar width, it's a histogram
193
- avg_width = sum(widths) / len(widths)
194
- is_histogram = all(abs(g) < avg_width * 0.1 for g in gaps)
195
-
196
- if is_histogram:
197
- # Histogram: group all bins together
198
- group_type = 'histogram'
199
- group_id = get_group_id(group_type, ax_idx)
200
-
201
- label = ''
202
- if hasattr(container, 'get_label'):
203
- label = container.get_label()
204
- if not label or label.startswith('_'):
205
- label = f"{group_type}_{len([g for g in groups if group_type in g])}"
206
-
207
- groups[group_id] = {
208
- 'type': group_type,
209
- 'label': label,
210
- 'axes_index': ax_idx,
211
- 'artists': patches,
212
- 'artist_types': ['rectangle'] * len(patches),
213
- 'role': 'logical',
214
- 'member_count': len(patches),
215
- }
216
-
217
- elif n_bar_containers > 1:
218
- # Multiple bar containers = grouped bar chart
219
- # Group bars by series (each container is a series)
220
- group_type = 'bar_series'
221
- group_id = get_group_id(group_type, ax_idx)
222
-
223
- label = ''
224
- if hasattr(container, 'get_label'):
225
- label = container.get_label()
226
- if not label or label.startswith('_'):
227
- label = f"{group_type}_{len([g for g in groups if group_type in g])}"
228
-
229
- groups[group_id] = {
230
- 'type': group_type,
231
- 'label': label,
232
- 'axes_index': ax_idx,
233
- 'artists': patches,
234
- 'artist_types': ['rectangle'] * len(patches),
235
- 'role': 'logical',
236
- 'member_count': len(patches),
237
- }
238
-
239
- # else: Single bar container with spaced bars = simple categorical bar chart
240
- # Don't create a group - each bar is standalone and selectable individually
241
-
242
- elif 'ErrorbarContainer' in container_type:
243
- # Error bar container
244
- group_id = get_group_id('errorbar', ax_idx)
245
- artists = []
246
- artist_types = []
247
-
248
- if hasattr(container, 'lines'):
249
- data_line, caplines, barlinecols = container.lines
250
- if data_line:
251
- artists.append(data_line)
252
- artist_types.append('line')
253
- artists.extend(caplines)
254
- artist_types.extend(['line'] * len(caplines))
255
- artists.extend(barlinecols)
256
- artist_types.extend(['line_collection'] * len(barlinecols))
257
-
258
- label = container.get_label() if hasattr(container, 'get_label') else ''
259
- if not label or label.startswith('_'):
260
- label = f"errorbar_{len([g for g in groups if 'errorbar' in g])}"
261
-
262
- groups[group_id] = {
263
- 'type': 'errorbar',
264
- 'label': label,
265
- 'axes_index': ax_idx,
266
- 'artists': artists,
267
- 'artist_types': artist_types,
268
- 'role': 'logical',
269
- 'member_count': len(artists),
270
- }
271
-
272
- # 2. Detect box plots (look for specific pattern of artists)
273
- # Box plots create: boxes (Rectangle), whiskers (Line2D), caps (Line2D),
274
- # medians (Line2D), fliers (Line2D)
275
- # They are typically created via ax.bxp() or ax.boxplot()
276
- if hasattr(ax, '_boxplot_info'):
277
- # Some matplotlib versions store boxplot info
278
- pass # Handle if available
279
-
280
- # 3. Detect pie charts (Wedge patches)
281
- wedges = [p for p in ax.patches if type(p).__name__ == 'Wedge']
282
- if wedges:
283
- group_id = get_group_id('pie', ax_idx)
284
- groups[group_id] = {
285
- 'type': 'pie',
286
- 'label': 'Pie Chart',
287
- 'axes_index': ax_idx,
288
- 'artists': wedges,
289
- 'artist_types': ['wedge'] * len(wedges),
290
- 'role': 'logical',
291
- 'member_count': len(wedges),
292
- }
293
-
294
- # 4. Detect contour sets (multiple PolyCollections from same contour call)
295
- # Contours typically have collections with increasing/decreasing levels
296
- poly_collections = [c for c in ax.collections
297
- if 'PolyCollection' in type(c).__name__
298
- and hasattr(c, 'get_array')
299
- and c.get_array() is not None]
300
- if len(poly_collections) > 2:
301
- # Likely a contour plot
302
- group_id = get_group_id('contour', ax_idx)
303
- groups[group_id] = {
304
- 'type': 'contour',
305
- 'label': 'Contour Plot',
306
- 'axes_index': ax_idx,
307
- 'artists': poly_collections,
308
- 'artist_types': ['poly_collection'] * len(poly_collections),
309
- 'role': 'logical',
310
- 'member_count': len(poly_collections),
311
- }
312
-
313
- return groups
314
-
315
-
316
- def get_all_artists_with_groups(
317
- fig,
318
- include_text: bool = False
319
- ) -> Tuple[List[Tuple[Any, int, str, Optional[str]]], Dict[str, Dict[str, Any]]]:
320
- """
321
- Extract all selectable artists from a figure with logical group information.
322
-
323
- This is an enhanced version of get_all_artists() that also detects and
324
- returns logical groups (e.g., histogram bins grouped as one element).
325
-
326
- Parameters
327
- ----------
328
- fig : matplotlib.figure.Figure
329
- The figure to extract artists from.
330
- include_text : bool
331
- Whether to include text elements.
332
-
333
- Returns
334
- -------
335
- tuple
336
- (artists_list, groups_dict) where:
337
- - artists_list: List of (artist, axes_index, artist_type, group_id) tuples
338
- - groups_dict: Dictionary of logical groups
339
-
340
- Examples
341
- --------
342
- >>> artists, groups = get_all_artists_with_groups(fig)
343
- >>> for artist, ax_idx, atype, group_id in artists:
344
- ... if group_id:
345
- ... print(f"{atype} belongs to group {group_id}")
346
- """
347
- # First, detect logical groups
348
- groups = detect_logical_groups(fig)
349
-
350
- # Create a mapping from artist to group_id
351
- artist_to_group = {}
352
- for group_id, group_info in groups.items():
353
- for artist in group_info['artists']:
354
- artist_to_group[id(artist)] = group_id
355
-
356
- # Now get all artists with group information
357
- artists_with_groups = []
358
-
359
- for ax_idx, ax in enumerate(fig.axes):
360
- # Lines (Line2D)
361
- for line in ax.get_lines():
362
- label = line.get_label()
363
- if not label.startswith('_'): # Skip internal lines
364
- group_id = artist_to_group.get(id(line))
365
- artists_with_groups.append((line, ax_idx, 'line', group_id))
366
-
367
- # Scatter plots (PathCollection)
368
- for coll in ax.collections:
369
- coll_type = type(coll).__name__
370
- group_id = artist_to_group.get(id(coll))
371
- if 'PathCollection' in coll_type:
372
- artists_with_groups.append((coll, ax_idx, 'scatter', group_id))
373
- elif 'PolyCollection' in coll_type or 'FillBetween' in coll_type:
374
- artists_with_groups.append((coll, ax_idx, 'fill', group_id))
375
- elif 'QuadMesh' in coll_type:
376
- artists_with_groups.append((coll, ax_idx, 'mesh', group_id))
377
-
378
- # Bars - handle both container level and individual patches
379
- processed_patches = set()
380
- for container in ax.containers:
381
- if hasattr(container, 'patches') and container.patches:
382
- # Check if first patch belongs to a group
383
- group_id = artist_to_group.get(id(container.patches[0]))
384
-
385
- if group_id:
386
- # Grouped bars (histogram or bar_series): add container as single element
387
- artists_with_groups.append((container, ax_idx, 'bar', group_id))
388
- # Mark patches as processed
389
- for patch in container.patches:
390
- processed_patches.add(id(patch))
391
- else:
392
- # Simple categorical bar: add each patch individually (standalone)
393
- for patch in container.patches:
394
- artists_with_groups.append((patch, ax_idx, 'rectangle', None))
395
- processed_patches.add(id(patch))
396
-
397
- # Individual patches (rectangles, circles, etc.) not in containers
398
- for patch in ax.patches:
399
- if id(patch) in processed_patches:
400
- continue
401
- patch_type = type(patch).__name__
402
- group_id = artist_to_group.get(id(patch))
403
- if patch_type == 'Rectangle':
404
- artists_with_groups.append((patch, ax_idx, 'rectangle', group_id))
405
- elif patch_type in ('Circle', 'Ellipse'):
406
- artists_with_groups.append((patch, ax_idx, 'circle', group_id))
407
- elif patch_type == 'Polygon':
408
- artists_with_groups.append((patch, ax_idx, 'polygon', group_id))
409
- elif patch_type == 'Wedge':
410
- artists_with_groups.append((patch, ax_idx, 'wedge', group_id))
411
-
412
- # Images
413
- for img in ax.images:
414
- group_id = artist_to_group.get(id(img))
415
- artists_with_groups.append((img, ax_idx, 'image', group_id))
416
-
417
- # Text (optional)
418
- if include_text:
419
- for text in ax.texts:
420
- if text.get_text():
421
- group_id = artist_to_group.get(id(text))
422
- artists_with_groups.append((text, ax_idx, 'text', group_id))
423
-
424
- return artists_with_groups, groups
425
-
426
-
427
- def _id_to_rgb(element_id: int) -> Tuple[int, int, int]:
428
- """
429
- Convert element ID to unique, human-readable RGB color using hash-based generation.
430
-
431
- Uses a hash function to generate visually distinct colors that are:
432
- 1. Deterministic (same ID always gives same color)
433
- 2. Visually distinct (spread across the color space)
434
- 3. Bright and saturated (easy to see)
435
-
436
- The first 12 elements use a hand-picked palette for maximum distinctness.
437
- Beyond that, uses hash-based HSV generation with high saturation.
438
-
439
- Parameters
440
- ----------
441
- element_id : int
442
- Element ID (1-based). ID 0 is reserved for background.
443
-
444
- Returns
445
- -------
446
- tuple
447
- (R, G, B) values (0-255)
448
-
449
- Notes
450
- -----
451
- The hash ensures:
452
- - Same element_id always maps to the same color
453
- - Colors are well-distributed across the spectrum
454
- - Avoids dark colors (reserved for background/axes)
455
- """
456
- import colorsys
457
- import hashlib
458
-
459
- if element_id <= 0:
460
- return (0, 0, 0) # Background
461
-
462
- # Hand-picked palette for first 12 elements (most common case)
463
- # These are maximally distinct primary/secondary colors
464
- DISTINCT_COLORS = [
465
- (255, 0, 0), # 1: Red
466
- (0, 200, 0), # 2: Green (slightly darker for visibility)
467
- (0, 100, 255), # 3: Blue (lighter for visibility)
468
- (255, 200, 0), # 4: Yellow/Gold
469
- (255, 0, 200), # 5: Magenta/Pink
470
- (0, 220, 220), # 6: Cyan
471
- (255, 100, 0), # 7: Orange
472
- (150, 0, 255), # 8: Purple
473
- (0, 255, 100), # 9: Spring Green
474
- (255, 100, 150), # 10: Salmon/Rose
475
- (100, 255, 0), # 11: Lime
476
- (100, 150, 255), # 12: Sky Blue
477
- ]
478
-
479
- if element_id <= len(DISTINCT_COLORS):
480
- return DISTINCT_COLORS[element_id - 1]
481
-
482
- # For IDs > 12, use hash-based color generation
483
- # Hash the ID to get a pseudo-random but deterministic value
484
- hash_bytes = hashlib.md5(str(element_id).encode()).digest()
485
-
486
- # Use hash bytes to generate HSV values
487
- # Hue: full range (0-1) for variety
488
- hue = int.from_bytes(hash_bytes[0:2], 'big') / 65535.0
489
-
490
- # Saturation: high (0.7-1.0) for vivid colors
491
- saturation = 0.7 + (int.from_bytes(hash_bytes[2:3], 'big') / 255.0) * 0.3
492
-
493
- # Value: high (0.75-1.0) to avoid dark colors
494
- value = 0.75 + (int.from_bytes(hash_bytes[3:4], 'big') / 255.0) * 0.25
495
-
496
- r, g, b = colorsys.hsv_to_rgb(hue, saturation, value)
497
- return (int(r * 255), int(g * 255), int(b * 255))
498
-
499
-
500
- def _rgb_to_id_lookup(r: int, g: int, b: int, color_map: dict) -> int:
501
- """
502
- Convert RGB color back to element ID using the color map.
503
-
504
- Since we use human-readable colors, we need to look up in the map.
505
-
506
- Parameters
507
- ----------
508
- r, g, b : int
509
- RGB values (0-255)
510
- color_map : dict
511
- Color map from generate_hitmap_id_colors (maps ID -> info with 'rgb' key)
512
-
513
- Returns
514
- -------
515
- int
516
- Element ID, or 0 if not found
517
- """
518
- rgb = [r, g, b]
519
- for element_id, info in color_map.items():
520
- if info.get('rgb') == rgb:
521
- return element_id
522
- return 0
523
-
524
-
525
- # Reserved colors for hitmap (human-readable)
526
- HITMAP_BACKGROUND_COLOR = '#1a1a1a' # Dark gray (not pure black, easier to see)
527
- HITMAP_AXES_COLOR = '#404040' # Medium gray (non-selectable axes elements)
528
-
529
-
530
- def _rgb_to_id(r: int, g: int, b: int) -> int:
531
- """Convert RGB color back to element ID."""
532
- return (r << 16) | (g << 8) | b
533
-
534
-
535
- def generate_hitmap_id_colors(
536
- fig,
537
- dpi: int = 100,
538
- include_text: bool = False,
539
- ) -> Tuple[np.ndarray, Dict[int, Dict[str, Any]]]:
540
- """
541
- Generate a hit map using unique ID colors (fastest method).
542
-
543
- Assigns unique RGB colors to each element, renders once, and creates
544
- a pixel-perfect hit map where each pixel's RGB values encode the
545
- element ID using 24-bit color space (~16.7M unique IDs).
546
-
547
- Parameters
548
- ----------
549
- fig : matplotlib.figure.Figure
550
- The figure to generate hit map for.
551
- dpi : int
552
- Resolution for hit map rendering.
553
- include_text : bool
554
- Whether to include text elements in hit map.
555
-
556
- Returns
557
- -------
558
- tuple
559
- (hitmap_array, color_map) where:
560
- - hitmap_array: uint32 array with element IDs (0 = background)
561
- - color_map: dict mapping ID to element info
562
-
563
- Notes
564
- -----
565
- Performance: ~89ms for complex figures (33x faster than sequential)
566
- Uses RGB 24-bit encoding for up to ~16.7 million unique element IDs.
567
- """
568
- import matplotlib.pyplot as plt
569
- import copy
570
-
571
- # Get all artists
572
- artists = get_all_artists(fig, include_text)
573
-
574
- if not artists:
575
- h = int(fig.get_figheight() * dpi)
576
- w = int(fig.get_figwidth() * dpi)
577
- return np.zeros((h, w), dtype=np.uint32), {}
578
-
579
- # Store original properties for restoration
580
- original_props = []
581
-
582
- # Build color map
583
- color_map = {}
584
-
585
- for i, (artist, ax_idx, artist_type) in enumerate(artists):
586
- element_id = i + 1
587
- # Use full RGB 24-bit encoding for unique ID colors
588
- r, g, b = _id_to_rgb(element_id)
589
- hex_color = f"#{r:02x}{g:02x}{b:02x}"
590
-
591
- # Store original properties
592
- props = {'artist': artist, 'type': artist_type}
593
- try:
594
- if hasattr(artist, 'get_color'):
595
- props['color'] = artist.get_color()
596
- if hasattr(artist, 'get_facecolor'):
597
- props['facecolor'] = artist.get_facecolor()
598
- if hasattr(artist, 'get_edgecolor'):
599
- props['edgecolor'] = artist.get_edgecolor()
600
- if hasattr(artist, 'get_alpha'):
601
- props['alpha'] = artist.get_alpha()
602
- if hasattr(artist, 'get_antialiased'):
603
- props['antialiased'] = artist.get_antialiased()
604
- except Exception:
605
- pass
606
- original_props.append(props)
607
-
608
- # Build color map entry (use element_id as key)
609
- label = ''
610
- if hasattr(artist, 'get_label'):
611
- label = artist.get_label()
612
- if label.startswith('_'):
613
- label = f'{artist_type}_{i}'
614
-
615
- color_map[element_id] = {
616
- 'id': element_id,
617
- 'type': artist_type,
618
- 'label': label,
619
- 'axes_index': ax_idx,
620
- 'rgb': [r, g, b],
621
- }
622
-
623
- # Apply ID color and disable anti-aliasing
624
- try:
625
- _apply_id_color(artist, hex_color)
626
- except Exception:
627
- pass
628
-
629
- # Make non-artist elements the reserved axes color (NOT black)
630
- # This distinguishes axes from background while making them non-selectable
631
- axes_color = HITMAP_AXES_COLOR
632
- for ax in fig.axes:
633
- ax.grid(False)
634
- for spine in ax.spines.values():
635
- spine.set_color(axes_color)
636
- ax.set_facecolor(HITMAP_BACKGROUND_COLOR)
637
- ax.tick_params(colors=axes_color, labelcolor=axes_color)
638
- ax.xaxis.label.set_color(axes_color)
639
- ax.yaxis.label.set_color(axes_color)
640
- ax.title.set_color(axes_color)
641
- if ax.get_legend():
642
- ax.get_legend().set_visible(False)
643
-
644
- fig.patch.set_facecolor(HITMAP_BACKGROUND_COLOR)
645
-
646
- # Render
647
- fig.canvas.draw()
648
- img = np.array(fig.canvas.buffer_rgba())
649
- # Convert RGB to element ID using 24-bit encoding
650
- hitmap = (img[:, :, 0].astype(np.uint32) << 16) | \
651
- (img[:, :, 1].astype(np.uint32) << 8) | \
652
- img[:, :, 2].astype(np.uint32)
653
-
654
- # Restore original properties
655
- for props in original_props:
656
- artist = props['artist']
657
- try:
658
- if 'color' in props and hasattr(artist, 'set_color'):
659
- artist.set_color(props['color'])
660
- if 'facecolor' in props and hasattr(artist, 'set_facecolor'):
661
- artist.set_facecolor(props['facecolor'])
662
- if 'edgecolor' in props and hasattr(artist, 'set_edgecolor'):
663
- artist.set_edgecolor(props['edgecolor'])
664
- if 'alpha' in props and hasattr(artist, 'set_alpha'):
665
- artist.set_alpha(props['alpha'])
666
- if 'antialiased' in props and hasattr(artist, 'set_antialiased'):
667
- artist.set_antialiased(props['antialiased'])
668
- except Exception:
669
- pass
670
-
671
- return hitmap, color_map
672
-
673
-
674
- def _apply_id_color(artist, hex_color: str):
675
- """Apply ID color to an artist, handling different artist types."""
676
- artist_type = type(artist).__name__
677
-
678
- if hasattr(artist, 'set_color'):
679
- artist.set_color(hex_color)
680
- if hasattr(artist, 'set_antialiased'):
681
- artist.set_antialiased(False)
682
-
683
- elif hasattr(artist, 'set_facecolor'):
684
- artist.set_facecolor(hex_color)
685
- if hasattr(artist, 'set_edgecolor'):
686
- artist.set_edgecolor(hex_color)
687
- if hasattr(artist, 'set_alpha'):
688
- artist.set_alpha(1.0)
689
- if hasattr(artist, 'set_antialiased'):
690
- artist.set_antialiased(False)
691
-
692
- # Handle BarContainer
693
- if hasattr(artist, 'patches'):
694
- for patch in artist.patches:
695
- patch.set_facecolor(hex_color)
696
- patch.set_edgecolor(hex_color)
697
- if hasattr(patch, 'set_antialiased'):
698
- patch.set_antialiased(False)
699
-
700
-
701
- def _prepare_hitmap_figure(
702
- fig,
703
- include_text: bool = False,
704
- ) -> Tuple[Dict[int, Dict[str, Any]], List[Dict[str, Any]]]:
705
- """
706
- Prepare a figure for hitmap rendering by coloring elements with unique IDs.
707
-
708
- This function modifies the figure in-place by:
709
- 1. Assigning unique RGB colors to each artist (24-bit ID encoding)
710
- 2. Hiding non-selectable elements (axes, spines, grid, etc.)
711
- 3. Setting background to black (ID = 0)
712
-
713
- Parameters
714
- ----------
715
- fig : matplotlib.figure.Figure
716
- The figure to prepare for hitmap rendering.
717
- include_text : bool
718
- Whether to include text elements.
719
-
720
- Returns
721
- -------
722
- tuple
723
- (color_map, original_props) where:
724
- - color_map: dict mapping ID to element info
725
- - original_props: list of dicts with original artist properties (for restoration)
726
-
727
- Notes
728
- -----
729
- After calling this function, you can render the figure using savefig()
730
- with bbox_inches='tight' to get a pixel-perfect hitmap.
731
- Call _restore_figure_props(original_props) to restore the figure.
732
- """
733
- artists = get_all_artists(fig, include_text)
734
-
735
- if not artists:
736
- return {}, []
737
-
738
- # Store original properties for restoration
739
- original_props = []
740
-
741
- # Build color map
742
- color_map = {}
743
-
744
- for i, (artist, ax_idx, artist_type) in enumerate(artists):
745
- element_id = i + 1
746
- # Use full RGB 24-bit encoding for unique ID colors
747
- r, g, b = _id_to_rgb(element_id)
748
- hex_color = f"#{r:02x}{g:02x}{b:02x}"
749
-
750
- # Store original properties
751
- props = {'artist': artist, 'type': artist_type}
752
- try:
753
- if hasattr(artist, 'get_color'):
754
- props['color'] = artist.get_color()
755
- if hasattr(artist, 'get_facecolor'):
756
- props['facecolor'] = artist.get_facecolor()
757
- if hasattr(artist, 'get_edgecolor'):
758
- props['edgecolor'] = artist.get_edgecolor()
759
- if hasattr(artist, 'get_alpha'):
760
- props['alpha'] = artist.get_alpha()
761
- if hasattr(artist, 'get_antialiased'):
762
- props['antialiased'] = artist.get_antialiased()
763
- except Exception:
764
- pass
765
- original_props.append(props)
766
-
767
- # Build color map entry
768
- label = ''
769
- if hasattr(artist, 'get_label'):
770
- label = artist.get_label()
771
- if label.startswith('_'):
772
- label = f'{artist_type}_{i}'
773
-
774
- color_map[element_id] = {
775
- 'id': element_id,
776
- 'type': artist_type,
777
- 'label': label,
778
- 'axes_index': ax_idx,
779
- 'rgb': [r, g, b],
780
- }
781
-
782
- # Apply ID color and disable anti-aliasing
783
- try:
784
- _apply_id_color(artist, hex_color)
785
- except Exception:
786
- pass
787
-
788
- # Hide non-artist elements (we need to save these for restoration too)
789
- axes_props = []
790
- for ax in fig.axes:
791
- ax_props = {
792
- 'ax': ax,
793
- 'grid_visible': ax.xaxis.get_gridlines()[0].get_visible() if ax.xaxis.get_gridlines() else False,
794
- 'facecolor': ax.get_facecolor(),
795
- 'spines_visible': {k: v.get_visible() for k, v in ax.spines.items()},
796
- 'xlabel': ax.get_xlabel(),
797
- 'ylabel': ax.get_ylabel(),
798
- 'title': ax.get_title(),
799
- 'legend_visible': ax.get_legend().get_visible() if ax.get_legend() else None,
800
- 'tick_params': {}, # Complex to save/restore, skip for now
801
- }
802
- axes_props.append(ax_props)
803
-
804
- ax.grid(False)
805
- for spine in ax.spines.values():
806
- spine.set_color(HITMAP_AXES_COLOR)
807
- ax.set_facecolor(HITMAP_BACKGROUND_COLOR)
808
- ax.tick_params(colors=HITMAP_AXES_COLOR, labelcolor=HITMAP_AXES_COLOR)
809
- ax.xaxis.label.set_color(HITMAP_AXES_COLOR)
810
- ax.yaxis.label.set_color(HITMAP_AXES_COLOR)
811
- ax.title.set_color(HITMAP_AXES_COLOR)
812
- if ax.get_legend():
813
- ax.get_legend().set_visible(False)
814
-
815
- # Save figure background
816
- original_props.append({
817
- 'type': '_figure_patch',
818
- 'facecolor': fig.patch.get_facecolor(),
819
- 'axes_props': axes_props,
820
- })
821
- fig.patch.set_facecolor(HITMAP_BACKGROUND_COLOR)
822
-
823
- return color_map, original_props
824
-
825
-
826
- def _restore_figure_props(original_props: List[Dict[str, Any]]):
827
- """
828
- Restore figure properties after hitmap rendering.
829
-
830
- Parameters
831
- ----------
832
- original_props : list
833
- List of property dicts from _prepare_hitmap_figure().
834
- """
835
- for props in original_props:
836
- if props.get('type') == '_figure_patch':
837
- # Restore axes and figure background
838
- if 'axes_props' in props:
839
- for ax_props in props['axes_props']:
840
- ax = ax_props['ax']
841
- ax.set_facecolor(ax_props['facecolor'])
842
- for spine_name, visible in ax_props['spines_visible'].items():
843
- ax.spines[spine_name].set_visible(visible)
844
- ax.set_xlabel(ax_props['xlabel'])
845
- ax.set_ylabel(ax_props['ylabel'])
846
- ax.set_title(ax_props['title'])
847
- if ax_props['legend_visible'] is not None and ax.get_legend():
848
- ax.get_legend().set_visible(ax_props['legend_visible'])
849
- continue
850
-
851
- artist = props.get('artist')
852
- if not artist:
853
- continue
854
-
855
- try:
856
- if 'color' in props and hasattr(artist, 'set_color'):
857
- artist.set_color(props['color'])
858
- if 'facecolor' in props and hasattr(artist, 'set_facecolor'):
859
- artist.set_facecolor(props['facecolor'])
860
- if 'edgecolor' in props and hasattr(artist, 'set_edgecolor'):
861
- artist.set_edgecolor(props['edgecolor'])
862
- if 'alpha' in props and hasattr(artist, 'set_alpha'):
863
- artist.set_alpha(props['alpha'])
864
- if 'antialiased' in props and hasattr(artist, 'set_antialiased'):
865
- artist.set_antialiased(props['antialiased'])
866
- except Exception:
867
- pass
868
-
869
-
870
- def extract_path_data(
871
- fig,
872
- include_text: bool = False,
873
- ) -> Dict[str, Any]:
874
- """
875
- Extract path/geometry data for client-side hit testing.
876
-
877
- Extracts bounding boxes and path coordinates for all selectable elements,
878
- enabling JavaScript-based hit testing in web editors.
879
-
880
- Parameters
881
- ----------
882
- fig : matplotlib.figure.Figure
883
- The figure to extract data from.
884
- include_text : bool
885
- Whether to include text elements.
886
-
887
- Returns
888
- -------
889
- dict
890
- Exported data structure with figure info and artist geometries.
891
-
892
- Notes
893
- -----
894
- Performance: ~192ms extraction, ~0.01ms client-side queries
895
- Supports: resize/zoom (transform coordinates client-side)
896
- """
897
- with warnings.catch_warnings():
898
- warnings.filterwarnings("ignore", message=".*tight_layout.*")
899
- fig.canvas.draw() # Ensure transforms are computed
900
-
901
- artists = get_all_artists(fig, include_text)
902
-
903
- dpi = fig.dpi
904
- fig_width_px = int(fig.get_figwidth() * dpi)
905
- fig_height_px = int(fig.get_figheight() * dpi)
906
-
907
- export = {
908
- 'figure': {
909
- 'width_px': fig_width_px,
910
- 'height_px': fig_height_px,
911
- 'dpi': dpi,
912
- },
913
- 'axes': [],
914
- 'artists': [],
915
- }
916
-
917
- # Export axes info
918
- for ax in fig.axes:
919
- bbox = ax.get_position()
920
- export['axes'].append({
921
- 'xlim': list(ax.get_xlim()),
922
- 'ylim': list(ax.get_ylim()),
923
- 'bbox_norm': {
924
- 'x0': bbox.x0,
925
- 'y0': bbox.y0,
926
- 'x1': bbox.x1,
927
- 'y1': bbox.y1,
928
- },
929
- 'bbox_px': {
930
- 'x0': int(bbox.x0 * fig_width_px),
931
- 'y0': int((1 - bbox.y1) * fig_height_px),
932
- 'x1': int(bbox.x1 * fig_width_px),
933
- 'y1': int((1 - bbox.y0) * fig_height_px),
934
- },
935
- })
936
-
937
- # Export artist geometries
938
- renderer = fig.canvas.get_renderer()
939
-
940
- for i, (artist, ax_idx, artist_type) in enumerate(artists):
941
- artist_data = {
942
- 'id': i,
943
- 'type': artist_type,
944
- 'axes_index': ax_idx,
945
- 'label': '',
946
- }
947
-
948
- # Get label
949
- if hasattr(artist, 'get_label'):
950
- label = artist.get_label()
951
- artist_data['label'] = label if not label.startswith('_') else f'{artist_type}_{i}'
952
-
953
- # Get bounding box
954
- try:
955
- bbox = artist.get_window_extent(renderer)
956
- artist_data['bbox_px'] = {
957
- 'x0': float(bbox.x0),
958
- 'y0': float(fig_height_px - bbox.y1), # Flip Y
959
- 'x1': float(bbox.x1),
960
- 'y1': float(fig_height_px - bbox.y0),
961
- }
962
- except Exception:
963
- artist_data['bbox_px'] = None
964
-
965
- # Extract type-specific geometry
966
- try:
967
- if artist_type == 'line' and hasattr(artist, 'get_xydata'):
968
- xy = artist.get_xydata()
969
- transform = artist.get_transform()
970
- xy_px = transform.transform(xy)
971
- # Flip Y and limit points for JSON size
972
- xy_px[:, 1] = fig_height_px - xy_px[:, 1]
973
- # Sample if too many points
974
- if len(xy_px) > 100:
975
- indices = np.linspace(0, len(xy_px) - 1, 100, dtype=int)
976
- xy_px = xy_px[indices]
977
- artist_data['path_px'] = xy_px.tolist()
978
- artist_data['linewidth'] = artist.get_linewidth()
979
-
980
- elif artist_type == 'scatter' and hasattr(artist, 'get_offsets'):
981
- offsets = artist.get_offsets()
982
- transform = artist.get_transform()
983
- offsets_px = transform.transform(offsets)
984
- offsets_px[:, 1] = fig_height_px - offsets_px[:, 1]
985
- artist_data['points_px'] = offsets_px.tolist()
986
- sizes = artist.get_sizes()
987
- artist_data['sizes'] = sizes.tolist() if len(sizes) > 0 else [36]
988
-
989
- elif artist_type == 'fill' and hasattr(artist, 'get_paths'):
990
- paths = artist.get_paths()
991
- if paths:
992
- transform = artist.get_transform()
993
- vertices = paths[0].vertices
994
- vertices_px = transform.transform(vertices)
995
- vertices_px[:, 1] = fig_height_px - vertices_px[:, 1]
996
- # Sample if too many vertices
997
- if len(vertices_px) > 100:
998
- indices = np.linspace(0, len(vertices_px) - 1, 100, dtype=int)
999
- vertices_px = vertices_px[indices]
1000
- artist_data['polygon_px'] = vertices_px.tolist()
1001
-
1002
- elif artist_type == 'bar' and hasattr(artist, 'patches'):
1003
- bars = []
1004
- ax = fig.axes[ax_idx]
1005
- for patch in artist.patches:
1006
- # Get data coordinates
1007
- x_data = patch.get_x()
1008
- y_data = patch.get_y()
1009
- w_data = patch.get_width()
1010
- h_data = patch.get_height()
1011
- bars.append({
1012
- 'x': x_data,
1013
- 'y': y_data,
1014
- 'width': w_data,
1015
- 'height': h_data,
1016
- })
1017
- artist_data['bars_data'] = bars
1018
-
1019
- elif artist_type == 'rectangle':
1020
- artist_data['rectangle'] = {
1021
- 'x': artist.get_x(),
1022
- 'y': artist.get_y(),
1023
- 'width': artist.get_width(),
1024
- 'height': artist.get_height(),
1025
- }
1026
-
1027
- except Exception as e:
1028
- artist_data['error'] = str(e)
1029
-
1030
- export['artists'].append(artist_data)
1031
-
1032
- # Convert all numpy types to native Python for JSON serialization
1033
- return _to_native(export)
1034
-
1035
-
1036
- def extract_selectable_regions(fig) -> Dict[str, Any]:
1037
- """
1038
- Extract bounding boxes for axis/annotation elements (non-data elements).
1039
-
1040
- This provides a complementary approach to the color-based hitmap:
1041
- - Data elements: Use hitmap color lookup (pixel-perfect)
1042
- - Axis elements: Use bounding box hit testing (calculated)
1043
-
1044
- The client-side hit test logic:
1045
- 1. Check selectable_regions bounding boxes first (fast rectangle test)
1046
- 2. If no match, sample hitmap color for data elements
1047
-
1048
- Parameters
1049
- ----------
1050
- fig : matplotlib.figure.Figure
1051
- The figure to extract regions from.
1052
-
1053
- Returns
1054
- -------
1055
- dict
1056
- Dictionary with selectable regions:
1057
- {
1058
- "axes": [{
1059
- "index": 0,
1060
- "title": {"bbox_px": [x0, y0, x1, y1], "text": "..."},
1061
- "xlabel": {"bbox_px": [...], "text": "..."},
1062
- "ylabel": {"bbox_px": [...], "text": "..."},
1063
- "xaxis": {
1064
- "spine": {"bbox_px": [...]},
1065
- "ticks": [{"bbox_px": [...], "position": 0.0}, ...],
1066
- "ticklabels": [{"bbox_px": [...], "text": "0"}, ...]
1067
- },
1068
- "yaxis": {...},
1069
- "legend": {
1070
- "bbox_px": [...],
1071
- "entries": [{"bbox_px": [...], "label": "Series A"}, ...]
1072
- }
1073
- }]
1074
- }
1075
- """
1076
- with warnings.catch_warnings():
1077
- warnings.filterwarnings("ignore", message=".*tight_layout.*")
1078
- fig.canvas.draw() # Ensure transforms are computed
1079
-
1080
- dpi = fig.dpi
1081
- fig_width_px = int(fig.get_figwidth() * dpi)
1082
- fig_height_px = int(fig.get_figheight() * dpi)
1083
-
1084
- renderer = fig.canvas.get_renderer()
1085
-
1086
- def get_bbox_px(artist) -> Optional[List[float]]:
1087
- """Get bounding box in pixels (y-flipped for image coordinates)."""
1088
- try:
1089
- bbox = artist.get_window_extent(renderer)
1090
- if bbox.width > 0 and bbox.height > 0:
1091
- return [
1092
- float(bbox.x0),
1093
- float(fig_height_px - bbox.y1), # Flip Y
1094
- float(bbox.x1),
1095
- float(fig_height_px - bbox.y0),
1096
- ]
1097
- except Exception:
1098
- pass
1099
- return None
1100
-
1101
- def get_text_info(text_artist) -> Optional[Dict[str, Any]]:
1102
- """Extract text element info with bounding box."""
1103
- if text_artist is None:
1104
- return None
1105
- text = text_artist.get_text()
1106
- if not text or not text.strip():
1107
- return None
1108
- bbox = get_bbox_px(text_artist)
1109
- if bbox is None:
1110
- return None
1111
- return {
1112
- "bbox_px": bbox,
1113
- "text": text,
1114
- "fontsize": text_artist.get_fontsize(),
1115
- "color": text_artist.get_color(),
1116
- }
1117
-
1118
- regions = {"axes": []}
1119
-
1120
- for ax_idx, ax in enumerate(fig.axes):
1121
- ax_region = {"index": ax_idx}
1122
-
1123
- # Title
1124
- title_info = get_text_info(ax.title)
1125
- if title_info:
1126
- ax_region["title"] = title_info
1127
-
1128
- # X label
1129
- xlabel_info = get_text_info(ax.xaxis.label)
1130
- if xlabel_info:
1131
- ax_region["xlabel"] = xlabel_info
1132
-
1133
- # Y label
1134
- ylabel_info = get_text_info(ax.yaxis.label)
1135
- if ylabel_info:
1136
- ax_region["ylabel"] = ylabel_info
1137
-
1138
- # X axis elements
1139
- xaxis_info = {"spine": None, "ticks": [], "ticklabels": []}
1140
-
1141
- # X spine (bottom)
1142
- if ax.spines['bottom'].get_visible():
1143
- spine_bbox = get_bbox_px(ax.spines['bottom'])
1144
- if spine_bbox:
1145
- xaxis_info["spine"] = {"bbox_px": spine_bbox}
1146
-
1147
- # X ticks and tick labels
1148
- for tick in ax.xaxis.get_major_ticks():
1149
- # Tick mark
1150
- if tick.tick1line.get_visible():
1151
- tick_bbox = get_bbox_px(tick.tick1line)
1152
- if tick_bbox:
1153
- xaxis_info["ticks"].append({
1154
- "bbox_px": tick_bbox,
1155
- "position": float(tick.get_loc()) if hasattr(tick, 'get_loc') else None,
1156
- })
1157
-
1158
- # Tick label
1159
- if tick.label1.get_visible():
1160
- label_info = get_text_info(tick.label1)
1161
- if label_info:
1162
- xaxis_info["ticklabels"].append(label_info)
1163
-
1164
- if xaxis_info["spine"] or xaxis_info["ticks"] or xaxis_info["ticklabels"]:
1165
- ax_region["xaxis"] = xaxis_info
1166
-
1167
- # Y axis elements
1168
- yaxis_info = {"spine": None, "ticks": [], "ticklabels": []}
1169
-
1170
- # Y spine (left)
1171
- if ax.spines['left'].get_visible():
1172
- spine_bbox = get_bbox_px(ax.spines['left'])
1173
- if spine_bbox:
1174
- yaxis_info["spine"] = {"bbox_px": spine_bbox}
1175
-
1176
- # Y ticks and tick labels
1177
- for tick in ax.yaxis.get_major_ticks():
1178
- # Tick mark
1179
- if tick.tick1line.get_visible():
1180
- tick_bbox = get_bbox_px(tick.tick1line)
1181
- if tick_bbox:
1182
- yaxis_info["ticks"].append({
1183
- "bbox_px": tick_bbox,
1184
- "position": float(tick.get_loc()) if hasattr(tick, 'get_loc') else None,
1185
- })
1186
-
1187
- # Tick label
1188
- if tick.label1.get_visible():
1189
- label_info = get_text_info(tick.label1)
1190
- if label_info:
1191
- yaxis_info["ticklabels"].append(label_info)
1192
-
1193
- if yaxis_info["spine"] or yaxis_info["ticks"] or yaxis_info["ticklabels"]:
1194
- ax_region["yaxis"] = yaxis_info
1195
-
1196
- # Legend
1197
- legend = ax.get_legend()
1198
- if legend and legend.get_visible():
1199
- legend_info = {"bbox_px": None, "entries": []}
1200
-
1201
- legend_bbox = get_bbox_px(legend)
1202
- if legend_bbox:
1203
- legend_info["bbox_px"] = legend_bbox
1204
-
1205
- # Legend entries
1206
- for text in legend.get_texts():
1207
- entry_info = get_text_info(text)
1208
- if entry_info:
1209
- legend_info["entries"].append(entry_info)
1210
-
1211
- # Legend handles (the visual markers)
1212
- try:
1213
- handles = legend.legendHandles
1214
- for i, handle in enumerate(handles):
1215
- handle_bbox = get_bbox_px(handle)
1216
- if handle_bbox and i < len(legend_info["entries"]):
1217
- legend_info["entries"][i]["handle_bbox_px"] = handle_bbox
1218
- except Exception:
1219
- pass
1220
-
1221
- if legend_info["bbox_px"] or legend_info["entries"]:
1222
- ax_region["legend"] = legend_info
1223
-
1224
- regions["axes"].append(ax_region)
1225
-
1226
- # Convert all numpy types to native Python for JSON serialization
1227
- return _to_native(regions)
1228
-
1229
-
1230
- def query_hitmap_neighborhood(
1231
- hitmap: np.ndarray,
1232
- x: int,
1233
- y: int,
1234
- color_map: Dict[int, Dict[str, Any]],
1235
- radius: int = 2,
1236
- ) -> List[Dict[str, Any]]:
1237
- """
1238
- Query hit map with neighborhood sampling for smart selection.
1239
-
1240
- Finds all element IDs in a neighborhood around the click point,
1241
- enabling selection of overlapping elements and thin lines.
1242
-
1243
- Parameters
1244
- ----------
1245
- hitmap : np.ndarray
1246
- Hit map array (uint32, element IDs from RGB encoding).
1247
- x : int
1248
- X coordinate (column) of click point.
1249
- y : int
1250
- Y coordinate (row) of click point.
1251
- color_map : dict
1252
- Mapping from element ID to element info.
1253
- radius : int
1254
- Sampling radius (e.g., 2 = 5×5 neighborhood).
1255
-
1256
- Returns
1257
- -------
1258
- list of dict
1259
- List of element info dicts for all elements found in neighborhood,
1260
- sorted by distance from click point (closest first).
1261
-
1262
- Notes
1263
- -----
1264
- Use cases:
1265
- - Alt+Click to select objects underneath (lower z-order)
1266
- - Click on thin lines that might be missed with exact pixel
1267
- - Show candidate list when multiple elements overlap
1268
- """
1269
- h, w = hitmap.shape
1270
- found_ids = set()
1271
- id_distances = {}
1272
-
1273
- # Sample neighborhood
1274
- for dy in range(-radius, radius + 1):
1275
- for dx in range(-radius, radius + 1):
1276
- ny, nx = y + dy, x + dx
1277
- if 0 <= ny < h and 0 <= nx < w:
1278
- element_id = int(hitmap[ny, nx])
1279
- if element_id > 0 and element_id in color_map:
1280
- found_ids.add(element_id)
1281
- # Track minimum distance for each ID
1282
- dist = abs(dx) + abs(dy) # Manhattan distance
1283
- if element_id not in id_distances or dist < id_distances[element_id]:
1284
- id_distances[element_id] = dist
1285
-
1286
- # Sort by distance (closest first), then by ID for stability
1287
- sorted_ids = sorted(found_ids, key=lambda eid: (id_distances[eid], eid))
1288
-
1289
- return [color_map[eid] for eid in sorted_ids]
1290
-
1291
-
1292
- def save_hitmap_png(hitmap: np.ndarray, path: str, color_map: Dict = None):
1293
- """
1294
- Save hit map as a PNG file (RGB encoding for 24-bit IDs).
1295
-
1296
- Parameters
1297
- ----------
1298
- hitmap : np.ndarray
1299
- Hit map array (uint32, element IDs from 24-bit RGB encoding).
1300
- path : str
1301
- Output path for PNG file.
1302
- color_map : dict, optional
1303
- Color map for visualization.
1304
- """
1305
- import matplotlib.pyplot as plt
1306
- from PIL import Image
1307
-
1308
- # Convert 24-bit IDs back to RGB for PNG storage
1309
- h, w = hitmap.shape
1310
- rgb = np.zeros((h, w, 3), dtype=np.uint8)
1311
- rgb[:, :, 0] = (hitmap >> 16) & 0xFF # R
1312
- rgb[:, :, 1] = (hitmap >> 8) & 0xFF # G
1313
- rgb[:, :, 2] = hitmap & 0xFF # B
1314
-
1315
- # Save as RGB PNG (preserves exact ID values)
1316
- img = Image.fromarray(rgb, mode='RGB')
1317
- img.save(path)
1318
-
1319
-
1320
- def apply_hitmap_colors(
1321
- fig,
1322
- include_text: bool = False,
1323
- ) -> Tuple[List[Dict[str, Any]], Dict[int, Dict[str, Any]], Dict[str, Dict[str, Any]]]:
1324
- """
1325
- Apply unique ID colors to data elements in a figure.
1326
-
1327
- This function modifies data elements (lines, patches, etc.) to have unique
1328
- RGB colors for hit testing, while keeping axes/spines/labels unchanged.
1329
- This preserves the bbox_inches='tight' bounding box calculation.
1330
-
1331
- Also detects logical groups (histogram, bar_series, etc.) and assigns
1332
- group_id to each element for hierarchical selection.
1333
-
1334
- Parameters
1335
- ----------
1336
- fig : matplotlib.figure.Figure
1337
- The figure to modify.
1338
- include_text : bool
1339
- Whether to include text elements.
1340
-
1341
- Returns
1342
- -------
1343
- tuple
1344
- (original_props, color_map, groups) where:
1345
- - original_props: list of dicts with original artist properties for restoration
1346
- - color_map: dict mapping ID to element info (includes group_id, role)
1347
- - groups: dict mapping group_id to logical group info
1348
- """
1349
- # Get artists with group information
1350
- artists_with_groups, groups = get_all_artists_with_groups(fig, include_text)
1351
-
1352
- original_props = []
1353
- color_map = {}
1354
-
1355
- for i, (artist, ax_idx, artist_type, group_id) in enumerate(artists_with_groups):
1356
- element_id = i + 1
1357
- r, g, b = _id_to_rgb(element_id)
1358
- hex_color = f"#{r:02x}{g:02x}{b:02x}"
1359
-
1360
- # Store original properties
1361
- props = {'artist': artist, 'type': artist_type}
1362
- try:
1363
- if hasattr(artist, 'get_color'):
1364
- props['color'] = artist.get_color()
1365
- if hasattr(artist, 'get_facecolor'):
1366
- props['facecolor'] = artist.get_facecolor()
1367
- if hasattr(artist, 'get_edgecolor'):
1368
- props['edgecolor'] = artist.get_edgecolor()
1369
- if hasattr(artist, 'get_alpha'):
1370
- props['alpha'] = artist.get_alpha()
1371
- if hasattr(artist, 'get_antialiased'):
1372
- props['antialiased'] = artist.get_antialiased()
1373
- if hasattr(artist, 'get_linewidth'):
1374
- props['linewidth'] = artist.get_linewidth()
1375
- except Exception:
1376
- pass
1377
- original_props.append(props)
1378
-
1379
- # Build color map entry with group information
1380
- label = ''
1381
- if hasattr(artist, 'get_label'):
1382
- label = artist.get_label()
1383
- if label.startswith('_'):
1384
- label = f'{artist_type}_{i}'
1385
-
1386
- # Determine role based on group membership
1387
- role = 'physical' if group_id else 'standalone'
1388
-
1389
- color_map[element_id] = {
1390
- 'id': element_id,
1391
- 'type': artist_type,
1392
- 'label': label,
1393
- 'axes_index': ax_idx,
1394
- 'rgb': [r, g, b],
1395
- 'group_id': group_id, # NEW: logical group this element belongs to
1396
- 'role': role, # NEW: 'physical' (part of group), 'standalone' (no group), or 'logical' (is a group)
1397
- }
1398
-
1399
- # Apply ID color
1400
- try:
1401
- _apply_id_color(artist, hex_color)
1402
- except Exception:
1403
- pass
1404
-
1405
- # Add RGB color to groups for logical selection
1406
- # Groups get IDs starting after all physical elements
1407
- group_id_start = len(artists_with_groups) + 1
1408
- groups_with_colors = {}
1409
- for i, (gid, ginfo) in enumerate(groups.items()):
1410
- logical_id = group_id_start + i
1411
- r, g, b = _id_to_rgb(logical_id)
1412
-
1413
- # Find member element IDs
1414
- member_ids = []
1415
- for elem_id, elem_info in color_map.items():
1416
- if elem_info.get('group_id') == gid:
1417
- member_ids.append(elem_id)
1418
-
1419
- groups_with_colors[gid] = {
1420
- 'id': logical_id,
1421
- 'type': ginfo['type'],
1422
- 'label': ginfo['label'],
1423
- 'axes_index': ginfo['axes_index'],
1424
- 'rgb': [r, g, b],
1425
- 'role': 'logical',
1426
- 'member_ids': member_ids,
1427
- 'member_count': ginfo['member_count'],
1428
- }
1429
-
1430
- return original_props, color_map, groups_with_colors
1431
-
1432
-
1433
- def restore_original_colors(original_props: List[Dict[str, Any]]):
1434
- """
1435
- Restore original colors to artists after hitmap generation.
1436
-
1437
- Parameters
1438
- ----------
1439
- original_props : list
1440
- List of dicts with original artist properties (from apply_hitmap_colors).
1441
- """
1442
- for props in original_props:
1443
- artist = props['artist']
1444
- try:
1445
- if 'color' in props and hasattr(artist, 'set_color'):
1446
- artist.set_color(props['color'])
1447
- if 'facecolor' in props and hasattr(artist, 'set_facecolor'):
1448
- artist.set_facecolor(props['facecolor'])
1449
- if 'edgecolor' in props and hasattr(artist, 'set_edgecolor'):
1450
- artist.set_edgecolor(props['edgecolor'])
1451
- if 'alpha' in props and hasattr(artist, 'set_alpha'):
1452
- artist.set_alpha(props['alpha'])
1453
- if 'antialiased' in props and hasattr(artist, 'set_antialiased'):
1454
- artist.set_antialiased(props['antialiased'])
1455
- if 'linewidth' in props and hasattr(artist, 'set_linewidth'):
1456
- artist.set_linewidth(props['linewidth'])
1457
- except Exception:
1458
- pass
1459
-
1460
-
1461
- def generate_hitmap_with_bbox_tight(
1462
- fig,
1463
- dpi: int = 150,
1464
- include_text: bool = False,
1465
- ) -> Tuple['Image.Image', Dict[int, Dict[str, Any]]]:
1466
- """
1467
- Generate a hitmap image with bbox_inches='tight' to match PNG output.
1468
-
1469
- This function generates a hitmap that exactly matches the PNG saved with
1470
- bbox_inches='tight'. The key insight is that both PNG and hitmap must use
1471
- the same savefig parameters to have identical cropping.
1472
-
1473
- Parameters
1474
- ----------
1475
- fig : matplotlib.figure.Figure
1476
- The figure to generate hit map for.
1477
- dpi : int
1478
- Resolution for hit map rendering.
1479
- include_text : bool
1480
- Whether to include text elements in hit map.
1481
-
1482
- Returns
1483
- -------
1484
- tuple
1485
- (hitmap_image, color_map) where:
1486
- - hitmap_image: PIL.Image.Image with RGB-encoded element IDs
1487
- - color_map: dict mapping ID to element info
1488
- """
1489
- import matplotlib.pyplot as plt
1490
- from PIL import Image
1491
- import io
1492
- import tempfile
1493
-
1494
- # Get all artists
1495
- artists = get_all_artists(fig, include_text)
1496
-
1497
- if not artists:
1498
- # Return empty black image with same size as PNG would have
1499
- buf = io.BytesIO()
1500
- fig.savefig(buf, format='png', dpi=dpi, bbox_inches='tight')
1501
- buf.seek(0)
1502
- img = Image.open(buf).convert('RGB')
1503
- # Create black image of same size
1504
- black_img = Image.new('RGB', img.size, (0, 0, 0))
1505
- return black_img, {}
1506
-
1507
- # Store original properties for restoration
1508
- original_props = []
1509
- original_ax_props = []
1510
-
1511
- # Store original axes properties
1512
- for ax in fig.axes:
1513
- ax_props = {
1514
- 'ax': ax,
1515
- 'facecolor': ax.get_facecolor(),
1516
- 'grid_visible': ax.xaxis.get_gridlines()[0].get_visible() if ax.xaxis.get_gridlines() else False,
1517
- 'spines': {name: spine.get_visible() for name, spine in ax.spines.items()},
1518
- 'xlabel': ax.get_xlabel(),
1519
- 'ylabel': ax.get_ylabel(),
1520
- 'title': ax.get_title(),
1521
- 'tick_params': {
1522
- 'left': ax.yaxis.get_tick_params()['left'] if hasattr(ax.yaxis.get_tick_params(), '__getitem__') else True,
1523
- 'bottom': ax.xaxis.get_tick_params()['bottom'] if hasattr(ax.xaxis.get_tick_params(), '__getitem__') else True,
1524
- },
1525
- }
1526
- if ax.get_legend():
1527
- ax_props['legend_visible'] = ax.get_legend().get_visible()
1528
- original_ax_props.append(ax_props)
1529
-
1530
- original_fig_facecolor = fig.patch.get_facecolor()
1531
-
1532
- # Build color map
1533
- color_map = {}
1534
-
1535
- for i, (artist, ax_idx, artist_type) in enumerate(artists):
1536
- element_id = i + 1
1537
- r, g, b = _id_to_rgb(element_id)
1538
- hex_color = f"#{r:02x}{g:02x}{b:02x}"
1539
-
1540
- # Store original properties
1541
- props = {'artist': artist, 'type': artist_type}
1542
- try:
1543
- if hasattr(artist, 'get_color'):
1544
- props['color'] = artist.get_color()
1545
- if hasattr(artist, 'get_facecolor'):
1546
- props['facecolor'] = artist.get_facecolor()
1547
- if hasattr(artist, 'get_edgecolor'):
1548
- props['edgecolor'] = artist.get_edgecolor()
1549
- if hasattr(artist, 'get_alpha'):
1550
- props['alpha'] = artist.get_alpha()
1551
- if hasattr(artist, 'get_antialiased'):
1552
- props['antialiased'] = artist.get_antialiased()
1553
- except Exception:
1554
- pass
1555
- original_props.append(props)
1556
-
1557
- # Build color map entry
1558
- label = ''
1559
- if hasattr(artist, 'get_label'):
1560
- label = artist.get_label()
1561
- if label.startswith('_'):
1562
- label = f'{artist_type}_{i}'
1563
-
1564
- color_map[element_id] = {
1565
- 'id': element_id,
1566
- 'type': artist_type,
1567
- 'label': label,
1568
- 'axes_index': ax_idx,
1569
- 'rgb': [r, g, b],
1570
- }
1571
-
1572
- # Apply ID color
1573
- try:
1574
- _apply_id_color(artist, hex_color)
1575
- except Exception:
1576
- pass
1577
-
1578
- # Make non-artist elements a reserved "axes" color (NOT black/invisible)
1579
- # This preserves bbox_inches='tight' bounds while distinguishing from background
1580
- # Use HITMAP_AXES_COLOR (#010101) which maps to ID 65793 (non-selectable)
1581
- axes_color = HITMAP_AXES_COLOR
1582
- for ax in fig.axes:
1583
- ax.grid(False)
1584
- # Make spines the reserved axes color instead of black
1585
- for spine in ax.spines.values():
1586
- spine.set_color(axes_color)
1587
- ax.set_facecolor(HITMAP_BACKGROUND_COLOR) # Keep facecolor as background
1588
- # Make tick labels the reserved axes color
1589
- ax.tick_params(colors=axes_color, labelcolor=axes_color)
1590
- # Make axis labels the reserved axes color
1591
- ax.xaxis.label.set_color(axes_color)
1592
- ax.yaxis.label.set_color(axes_color)
1593
- # Make title the reserved axes color
1594
- ax.title.set_color(axes_color)
1595
- if ax.get_legend():
1596
- ax.get_legend().set_visible(False)
1597
-
1598
- fig.patch.set_facecolor(HITMAP_BACKGROUND_COLOR)
1599
-
1600
- # Save hitmap with bbox_inches='tight' - SAME as PNG
1601
- buf = io.BytesIO()
1602
- fig.savefig(buf, format='png', dpi=dpi, bbox_inches='tight', facecolor=HITMAP_BACKGROUND_COLOR)
1603
- buf.seek(0)
1604
- hitmap_img = Image.open(buf).convert('RGB')
1605
-
1606
- # Restore original properties
1607
- for props in original_props:
1608
- artist = props['artist']
1609
- try:
1610
- if 'color' in props and hasattr(artist, 'set_color'):
1611
- artist.set_color(props['color'])
1612
- if 'facecolor' in props and hasattr(artist, 'set_facecolor'):
1613
- artist.set_facecolor(props['facecolor'])
1614
- if 'edgecolor' in props and hasattr(artist, 'set_edgecolor'):
1615
- artist.set_edgecolor(props['edgecolor'])
1616
- if 'alpha' in props and hasattr(artist, 'set_alpha'):
1617
- artist.set_alpha(props['alpha'])
1618
- if 'antialiased' in props and hasattr(artist, 'set_antialiased'):
1619
- artist.set_antialiased(props['antialiased'])
1620
- except Exception:
1621
- pass
1622
-
1623
- # Restore axes properties
1624
- for ax_props in original_ax_props:
1625
- ax = ax_props['ax']
1626
- try:
1627
- ax.set_facecolor(ax_props['facecolor'])
1628
- for name, visible in ax_props['spines'].items():
1629
- ax.spines[name].set_visible(visible)
1630
- ax.set_xlabel(ax_props['xlabel'])
1631
- ax.set_ylabel(ax_props['ylabel'])
1632
- ax.set_title(ax_props['title'])
1633
- if 'legend_visible' in ax_props and ax.get_legend():
1634
- ax.get_legend().set_visible(ax_props['legend_visible'])
1635
- except Exception:
1636
- pass
1637
-
1638
- fig.patch.set_facecolor(original_fig_facecolor)
1639
-
1640
- return hitmap_img, color_map
1641
-
1642
-
1643
73
  # EOF