scitex 2.3.0__py3-none-any.whl → 2.4.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- scitex/ai/classification/reporters/reporter_utils/_Plotter.py +1 -1
- scitex/ai/plt/__init__.py +2 -2
- scitex/ai/plt/{_plot_conf_mat.py → _stx_conf_mat.py} +3 -3
- scitex/config/PriorityConfig.py +195 -0
- scitex/config/__init__.py +24 -0
- scitex/io/_save.py +125 -34
- scitex/io/_save_modules/_image.py +37 -20
- scitex/plt/__init__.py +470 -17
- scitex/plt/_subplots/_AxisWrapper.py +98 -50
- scitex/plt/_subplots/_AxisWrapperMixins/_MatplotlibPlotMixin.py +559 -124
- scitex/plt/_subplots/_AxisWrapperMixins/_SeabornMixin.py +49 -8
- scitex/plt/_subplots/_SubplotsWrapper.py +76 -91
- scitex/plt/_subplots/_export_as_csv.py +127 -58
- scitex/plt/_subplots/_export_as_csv_formatters/__init__.py +25 -16
- scitex/plt/_subplots/_export_as_csv_formatters/_format_contourf.py +54 -0
- scitex/plt/_subplots/_export_as_csv_formatters/_format_hexbin.py +41 -0
- scitex/plt/_subplots/_export_as_csv_formatters/_format_hist2d.py +41 -0
- scitex/plt/_subplots/_export_as_csv_formatters/_format_imshow.py +59 -47
- scitex/plt/_subplots/_export_as_csv_formatters/_format_matshow.py +42 -0
- scitex/plt/_subplots/_export_as_csv_formatters/_format_pie.py +42 -0
- scitex/plt/_subplots/_export_as_csv_formatters/_format_plot.py +72 -35
- scitex/plt/_subplots/_export_as_csv_formatters/_format_plot_box.py +1 -1
- scitex/plt/_subplots/_export_as_csv_formatters/_format_plot_kde.py +2 -2
- scitex/plt/_subplots/_export_as_csv_formatters/_format_quiver.py +53 -0
- scitex/plt/_subplots/_export_as_csv_formatters/_format_stem.py +42 -0
- scitex/plt/_subplots/_export_as_csv_formatters/_format_step.py +42 -0
- scitex/plt/_subplots/_export_as_csv_formatters/_format_streamplot.py +48 -0
- scitex/plt/_subplots/_export_as_csv_formatters/{_format_plot_conf_mat.py → _format_stx_conf_mat.py} +2 -2
- scitex/plt/_subplots/_export_as_csv_formatters/{_format_plot_ecdf.py → _format_stx_ecdf.py} +2 -2
- scitex/plt/_subplots/_export_as_csv_formatters/{_format_plot_fillv.py → _format_stx_fillv.py} +2 -2
- scitex/plt/_subplots/_export_as_csv_formatters/{_format_plot_heatmap.py → _format_stx_heatmap.py} +2 -2
- scitex/plt/_subplots/_export_as_csv_formatters/{_format_plot_image.py → _format_stx_image.py} +2 -2
- scitex/plt/_subplots/_export_as_csv_formatters/{_format_plot_joyplot.py → _format_stx_joyplot.py} +2 -2
- scitex/plt/_subplots/_export_as_csv_formatters/{_format_plot_line.py → _format_stx_line.py} +3 -3
- scitex/plt/_subplots/_export_as_csv_formatters/{_format_plot_mean_ci.py → _format_stx_mean_ci.py} +2 -2
- scitex/plt/_subplots/_export_as_csv_formatters/{_format_plot_mean_std.py → _format_stx_mean_std.py} +2 -2
- scitex/plt/_subplots/_export_as_csv_formatters/{_format_plot_median_iqr.py → _format_stx_median_iqr.py} +2 -2
- scitex/plt/_subplots/_export_as_csv_formatters/{_format_plot_raster.py → _format_stx_raster.py} +2 -2
- scitex/plt/_subplots/_export_as_csv_formatters/{_format_plot_rectangle.py → _format_stx_rectangle.py} +1 -1
- scitex/plt/_subplots/_export_as_csv_formatters/{_format_plot_scatter_hist.py → _format_stx_scatter_hist.py} +2 -2
- scitex/plt/_subplots/_export_as_csv_formatters/{_format_plot_shaded_line.py → _format_stx_shaded_line.py} +2 -2
- scitex/plt/_subplots/_export_as_csv_formatters/{_format_plot_violin.py → _format_stx_violin.py} +2 -2
- scitex/plt/_subplots/_export_as_csv_formatters/verify_formatters.py +23 -23
- scitex/plt/ax/__init__.py +16 -15
- scitex/plt/ax/_plot/__init__.py +30 -30
- scitex/plt/ax/_plot/_add_fitted_line.py +65 -11
- scitex/plt/ax/_plot/_plot_statistical_shaded_line.py +104 -76
- scitex/plt/ax/_plot/{_plot_conf_mat.py → _stx_conf_mat.py} +10 -10
- scitex/plt/ax/_plot/_stx_ecdf.py +109 -0
- scitex/plt/ax/_plot/{_plot_fillv.py → _stx_fillv.py} +7 -7
- scitex/plt/ax/_plot/_stx_heatmap.py +366 -0
- scitex/plt/ax/_plot/{_plot_image.py → _stx_image.py} +1 -1
- scitex/plt/ax/_plot/_stx_joyplot.py +113 -0
- scitex/plt/ax/_plot/{_plot_raster.py → _stx_raster.py} +37 -25
- scitex/plt/ax/_plot/{_plot_rectangle.py → _stx_rectangle.py} +10 -9
- scitex/plt/ax/_plot/{_plot_scatter_hist.py → _stx_scatter_hist.py} +1 -1
- scitex/plt/ax/_plot/_stx_shaded_line.py +215 -0
- scitex/plt/ax/_plot/{_plot_violin.py → _stx_violin.py} +13 -6
- scitex/plt/ax/_style/__init__.py +3 -0
- scitex/plt/ax/_style/_style_barplot.py +13 -2
- scitex/plt/ax/_style/_style_boxplot.py +78 -32
- scitex/plt/ax/_style/_style_errorbar.py +17 -3
- scitex/plt/ax/_style/_style_scatter.py +17 -3
- scitex/plt/ax/_style/_style_violinplot.py +109 -0
- scitex/plt/color/_vizualize_colors.py +3 -3
- scitex/plt/styles/SCITEX_STYLE.yaml +104 -0
- scitex/plt/styles/__init__.py +57 -0
- scitex/plt/styles/_plot_defaults.py +209 -0
- scitex/plt/styles/_plot_postprocess.py +518 -0
- scitex/plt/styles/_style_loader.py +268 -0
- scitex/plt/styles/presets.py +208 -0
- scitex/plt/utils/_collect_figure_metadata.py +160 -18
- scitex/plt/utils/_colorbar.py +72 -10
- scitex/plt/utils/_configure_mpl.py +108 -52
- scitex/plt/utils/_crop.py +21 -7
- scitex/plt/utils/_figure_mm.py +21 -7
- scitex/stats/__init__.py +13 -1
- scitex/stats/_schema.py +578 -0
- scitex/stats/tests/__init__.py +13 -0
- scitex/stats/tests/correlation/__init__.py +13 -0
- scitex/stats/tests/correlation/_test_pearson.py +262 -0
- scitex/vis/__init__.py +6 -0
- scitex/vis/editor/__init__.py +23 -0
- scitex/vis/editor/_defaults.py +205 -0
- scitex/vis/editor/_edit.py +342 -0
- scitex/vis/editor/_mpl_editor.py +231 -0
- scitex/vis/editor/_tkinter_editor.py +466 -0
- scitex/vis/editor/_web_editor.py +1440 -0
- scitex/vis/model/plot_types.py +15 -15
- {scitex-2.3.0.dist-info → scitex-2.4.1.dist-info}/METADATA +2 -1
- {scitex-2.3.0.dist-info → scitex-2.4.1.dist-info}/RECORD +94 -67
- {scitex-2.3.0.dist-info → scitex-2.4.1.dist-info}/WHEEL +1 -1
- scitex/plt/ax/_plot/_plot_ecdf.py +0 -84
- scitex/plt/ax/_plot/_plot_heatmap.py +0 -277
- scitex/plt/ax/_plot/_plot_joyplot.py +0 -77
- scitex/plt/ax/_plot/_plot_shaded_line.py +0 -142
- scitex/plt/presets.py +0 -224
- {scitex-2.3.0.dist-info → scitex-2.4.1.dist-info}/entry_points.txt +0 -0
- {scitex-2.3.0.dist-info → scitex-2.4.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
# File: ./src/scitex/vis/editor/_edit.py
|
|
4
|
+
"""Main edit function for launching visual editor."""
|
|
5
|
+
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Union, Optional, Literal
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def edit(
|
|
13
|
+
path: Union[str, Path],
|
|
14
|
+
backend: Literal["auto", "web", "dearpygui", "qt", "tkinter", "mpl"] = "auto",
|
|
15
|
+
apply_manual: bool = True,
|
|
16
|
+
) -> None:
|
|
17
|
+
"""
|
|
18
|
+
Launch interactive editor for figure style/annotation editing.
|
|
19
|
+
|
|
20
|
+
Parameters
|
|
21
|
+
----------
|
|
22
|
+
path : str or Path
|
|
23
|
+
Path to figure file. Can be:
|
|
24
|
+
- JSON file (figure.json or figure.manual.json)
|
|
25
|
+
- CSV file (figure.csv) - for data-only start
|
|
26
|
+
- PNG file (figure.png)
|
|
27
|
+
Will auto-detect sibling files in same directory or organized subdirectories.
|
|
28
|
+
backend : str, optional
|
|
29
|
+
GUI backend to use (default: "auto"):
|
|
30
|
+
- "auto": Pick best available with graceful degradation
|
|
31
|
+
(web -> dearpygui -> qt -> tkinter -> mpl)
|
|
32
|
+
- "web": Browser-based editor (Flask/FastAPI, modern UI)
|
|
33
|
+
- "dearpygui": GPU-accelerated modern GUI (fast, requires dearpygui)
|
|
34
|
+
- "qt": Rich desktop editor (requires PyQt5/6 or PySide2/6)
|
|
35
|
+
- "tkinter": Built-in Python GUI (works everywhere)
|
|
36
|
+
- "mpl": Minimal matplotlib interactive mode (always works)
|
|
37
|
+
apply_manual : bool, optional
|
|
38
|
+
If True, load .manual.json overrides if exists (default: True)
|
|
39
|
+
|
|
40
|
+
Returns
|
|
41
|
+
-------
|
|
42
|
+
None
|
|
43
|
+
Editor runs in GUI event loop. Changes saved to .manual.json.
|
|
44
|
+
|
|
45
|
+
Examples
|
|
46
|
+
--------
|
|
47
|
+
>>> import scitex as stx
|
|
48
|
+
>>> stx.vis.edit("output/figure.json") # Auto-select best backend
|
|
49
|
+
>>> stx.vis.edit("output/figure.png", backend="web") # Force web editor
|
|
50
|
+
>>> stx.vis.edit("output/figure.json", backend="tkinter") # Force tkinter
|
|
51
|
+
|
|
52
|
+
Notes
|
|
53
|
+
-----
|
|
54
|
+
- Changes are saved to `{basename}.manual.json` alongside the original
|
|
55
|
+
- Manual JSON includes hash of base JSON for staleness detection
|
|
56
|
+
- Original JSON/CSV files are never modified
|
|
57
|
+
- Backend auto-detection order: web > dearpygui > qt > tkinter > mpl
|
|
58
|
+
"""
|
|
59
|
+
path = Path(path)
|
|
60
|
+
|
|
61
|
+
# Resolve paths (JSON, CSV, PNG)
|
|
62
|
+
json_path, csv_path, png_path = _resolve_figure_paths(path)
|
|
63
|
+
|
|
64
|
+
if not json_path.exists():
|
|
65
|
+
raise FileNotFoundError(f"JSON file not found: {json_path}")
|
|
66
|
+
|
|
67
|
+
# Load data
|
|
68
|
+
import scitex as stx
|
|
69
|
+
metadata = stx.io.load(json_path)
|
|
70
|
+
csv_data = None
|
|
71
|
+
if csv_path and csv_path.exists():
|
|
72
|
+
csv_data = stx.io.load(csv_path)
|
|
73
|
+
|
|
74
|
+
# Load manual overrides if exists
|
|
75
|
+
manual_path = json_path.with_suffix('.manual.json')
|
|
76
|
+
manual_overrides = None
|
|
77
|
+
if apply_manual and manual_path.exists():
|
|
78
|
+
manual_data = stx.io.load(manual_path)
|
|
79
|
+
manual_overrides = manual_data.get('overrides', {})
|
|
80
|
+
|
|
81
|
+
# Resolve backend if "auto"
|
|
82
|
+
if backend == "auto":
|
|
83
|
+
backend = _detect_best_backend()
|
|
84
|
+
|
|
85
|
+
# Launch appropriate backend
|
|
86
|
+
if backend == "web":
|
|
87
|
+
try:
|
|
88
|
+
from ._web_editor import WebEditor
|
|
89
|
+
editor = WebEditor(
|
|
90
|
+
json_path=json_path,
|
|
91
|
+
metadata=metadata,
|
|
92
|
+
csv_data=csv_data,
|
|
93
|
+
png_path=png_path,
|
|
94
|
+
manual_overrides=manual_overrides,
|
|
95
|
+
)
|
|
96
|
+
editor.run()
|
|
97
|
+
except ImportError as e:
|
|
98
|
+
raise ImportError(
|
|
99
|
+
"Web backend requires Flask or FastAPI. "
|
|
100
|
+
"Install with: pip install flask"
|
|
101
|
+
) from e
|
|
102
|
+
elif backend == "dearpygui":
|
|
103
|
+
try:
|
|
104
|
+
from ._dearpygui_editor import DearPyGuiEditor
|
|
105
|
+
editor = DearPyGuiEditor(
|
|
106
|
+
json_path=json_path,
|
|
107
|
+
metadata=metadata,
|
|
108
|
+
csv_data=csv_data,
|
|
109
|
+
manual_overrides=manual_overrides,
|
|
110
|
+
)
|
|
111
|
+
editor.run()
|
|
112
|
+
except ImportError as e:
|
|
113
|
+
raise ImportError(
|
|
114
|
+
"DearPyGui backend requires dearpygui. "
|
|
115
|
+
"Install with: pip install dearpygui"
|
|
116
|
+
) from e
|
|
117
|
+
elif backend == "qt":
|
|
118
|
+
try:
|
|
119
|
+
from ._qt_editor import QtEditor
|
|
120
|
+
editor = QtEditor(
|
|
121
|
+
json_path=json_path,
|
|
122
|
+
metadata=metadata,
|
|
123
|
+
csv_data=csv_data,
|
|
124
|
+
manual_overrides=manual_overrides,
|
|
125
|
+
)
|
|
126
|
+
editor.run()
|
|
127
|
+
except ImportError as e:
|
|
128
|
+
raise ImportError(
|
|
129
|
+
"Qt backend requires PyQt5/PyQt6 or PySide2/PySide6. "
|
|
130
|
+
"Install with: pip install PyQt6"
|
|
131
|
+
) from e
|
|
132
|
+
elif backend == "tkinter":
|
|
133
|
+
from ._tkinter_editor import TkinterEditor
|
|
134
|
+
editor = TkinterEditor(
|
|
135
|
+
json_path=json_path,
|
|
136
|
+
metadata=metadata,
|
|
137
|
+
csv_data=csv_data,
|
|
138
|
+
manual_overrides=manual_overrides,
|
|
139
|
+
)
|
|
140
|
+
editor.run()
|
|
141
|
+
elif backend == "mpl":
|
|
142
|
+
from ._mpl_editor import MplEditor
|
|
143
|
+
editor = MplEditor(
|
|
144
|
+
json_path=json_path,
|
|
145
|
+
metadata=metadata,
|
|
146
|
+
csv_data=csv_data,
|
|
147
|
+
manual_overrides=manual_overrides,
|
|
148
|
+
)
|
|
149
|
+
editor.run()
|
|
150
|
+
else:
|
|
151
|
+
raise ValueError(
|
|
152
|
+
f"Unknown backend: {backend}. "
|
|
153
|
+
"Use 'auto', 'web', 'dearpygui', 'qt', 'tkinter', or 'mpl'."
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _detect_best_backend() -> str:
|
|
158
|
+
"""
|
|
159
|
+
Detect the best available GUI backend with graceful degradation.
|
|
160
|
+
|
|
161
|
+
Order: web > dearpygui > qt > tkinter > mpl
|
|
162
|
+
Shows warnings when falling back to less capable backends.
|
|
163
|
+
"""
|
|
164
|
+
import warnings
|
|
165
|
+
|
|
166
|
+
# Try Web (Flask/FastAPI) - best for modern UI
|
|
167
|
+
try:
|
|
168
|
+
import flask
|
|
169
|
+
return "web"
|
|
170
|
+
except ImportError:
|
|
171
|
+
pass
|
|
172
|
+
try:
|
|
173
|
+
import fastapi
|
|
174
|
+
return "web"
|
|
175
|
+
except ImportError:
|
|
176
|
+
pass
|
|
177
|
+
|
|
178
|
+
# Try DearPyGui - GPU-accelerated, modern
|
|
179
|
+
try:
|
|
180
|
+
import dearpygui
|
|
181
|
+
return "dearpygui"
|
|
182
|
+
except ImportError:
|
|
183
|
+
warnings.warn(
|
|
184
|
+
"Web/Flask not available. Consider: pip install flask\n"
|
|
185
|
+
"Trying DearPyGui..."
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
# Try DearPyGui
|
|
189
|
+
try:
|
|
190
|
+
import dearpygui
|
|
191
|
+
return "dearpygui"
|
|
192
|
+
except ImportError:
|
|
193
|
+
pass
|
|
194
|
+
|
|
195
|
+
# Try Qt (richest desktop features)
|
|
196
|
+
qt_available = False
|
|
197
|
+
try:
|
|
198
|
+
import PyQt6
|
|
199
|
+
qt_available = True
|
|
200
|
+
except ImportError:
|
|
201
|
+
pass
|
|
202
|
+
if not qt_available:
|
|
203
|
+
try:
|
|
204
|
+
import PyQt5
|
|
205
|
+
qt_available = True
|
|
206
|
+
except ImportError:
|
|
207
|
+
pass
|
|
208
|
+
if not qt_available:
|
|
209
|
+
try:
|
|
210
|
+
import PySide6
|
|
211
|
+
qt_available = True
|
|
212
|
+
except ImportError:
|
|
213
|
+
pass
|
|
214
|
+
if not qt_available:
|
|
215
|
+
try:
|
|
216
|
+
import PySide2
|
|
217
|
+
qt_available = True
|
|
218
|
+
except ImportError:
|
|
219
|
+
pass
|
|
220
|
+
|
|
221
|
+
if qt_available:
|
|
222
|
+
warnings.warn(
|
|
223
|
+
"DearPyGui not available. Consider: pip install dearpygui\n"
|
|
224
|
+
"Using Qt backend instead."
|
|
225
|
+
)
|
|
226
|
+
return "qt"
|
|
227
|
+
|
|
228
|
+
# Try Tkinter (built-in, good features)
|
|
229
|
+
try:
|
|
230
|
+
import tkinter
|
|
231
|
+
warnings.warn(
|
|
232
|
+
"Qt not available. Consider: pip install PyQt6\n"
|
|
233
|
+
"Using Tkinter backend (basic features)."
|
|
234
|
+
)
|
|
235
|
+
return "tkinter"
|
|
236
|
+
except ImportError:
|
|
237
|
+
pass
|
|
238
|
+
|
|
239
|
+
# Fall back to matplotlib interactive (always works)
|
|
240
|
+
warnings.warn(
|
|
241
|
+
"No GUI toolkit found. Using minimal matplotlib editor.\n"
|
|
242
|
+
"For better experience, install: pip install flask (web) or pip install PyQt6 (desktop)"
|
|
243
|
+
)
|
|
244
|
+
return "mpl"
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _resolve_figure_paths(path: Path) -> tuple:
|
|
248
|
+
"""
|
|
249
|
+
Resolve JSON, CSV, and PNG paths from any input file path.
|
|
250
|
+
|
|
251
|
+
Handles two patterns:
|
|
252
|
+
1. Flat (sibling): path/to/figure.{json,csv,png}
|
|
253
|
+
2. Organized (subdirs): path/to/{json,csv,png}/figure.{ext}
|
|
254
|
+
|
|
255
|
+
Parameters
|
|
256
|
+
----------
|
|
257
|
+
path : Path
|
|
258
|
+
Input path (can be JSON, CSV, or PNG)
|
|
259
|
+
|
|
260
|
+
Returns
|
|
261
|
+
-------
|
|
262
|
+
tuple
|
|
263
|
+
(json_path, csv_path, png_path) - csv_path/png_path may be None if not found
|
|
264
|
+
"""
|
|
265
|
+
path = Path(path)
|
|
266
|
+
stem = path.stem
|
|
267
|
+
parent = path.parent
|
|
268
|
+
|
|
269
|
+
# Check if this is organized pattern (parent is json/, csv/, png/)
|
|
270
|
+
if parent.name in ('json', 'csv', 'png'):
|
|
271
|
+
base_dir = parent.parent
|
|
272
|
+
json_path = base_dir / 'json' / f'{stem}.json'
|
|
273
|
+
csv_path = base_dir / 'csv' / f'{stem}.csv'
|
|
274
|
+
png_path = base_dir / 'png' / f'{stem}.png'
|
|
275
|
+
else:
|
|
276
|
+
# Flat pattern - sibling files
|
|
277
|
+
json_path = parent / f'{stem}.json'
|
|
278
|
+
csv_path = parent / f'{stem}.csv'
|
|
279
|
+
png_path = parent / f'{stem}.png'
|
|
280
|
+
|
|
281
|
+
# If input was .manual.json, get base json
|
|
282
|
+
if stem.endswith('.manual'):
|
|
283
|
+
base_stem = stem[:-7] # Remove '.manual'
|
|
284
|
+
if parent.name == 'json':
|
|
285
|
+
json_path = parent / f'{base_stem}.json'
|
|
286
|
+
csv_path = parent.parent / 'csv' / f'{base_stem}.csv'
|
|
287
|
+
png_path = parent.parent / 'png' / f'{base_stem}.png'
|
|
288
|
+
else:
|
|
289
|
+
json_path = parent / f'{base_stem}.json'
|
|
290
|
+
csv_path = parent / f'{base_stem}.csv'
|
|
291
|
+
png_path = parent / f'{base_stem}.png'
|
|
292
|
+
|
|
293
|
+
return (
|
|
294
|
+
json_path,
|
|
295
|
+
csv_path if csv_path.exists() else None,
|
|
296
|
+
png_path if png_path.exists() else None,
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _compute_file_hash(path: Path) -> str:
|
|
301
|
+
"""Compute SHA256 hash of file contents."""
|
|
302
|
+
with open(path, 'rb') as f:
|
|
303
|
+
return hashlib.sha256(f.read()).hexdigest()
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def save_manual_overrides(
|
|
307
|
+
json_path: Path,
|
|
308
|
+
overrides: dict,
|
|
309
|
+
) -> Path:
|
|
310
|
+
"""
|
|
311
|
+
Save manual overrides to .manual.json file.
|
|
312
|
+
|
|
313
|
+
Parameters
|
|
314
|
+
----------
|
|
315
|
+
json_path : Path
|
|
316
|
+
Path to base JSON file
|
|
317
|
+
overrides : dict
|
|
318
|
+
Override settings (styles, annotations, etc.)
|
|
319
|
+
|
|
320
|
+
Returns
|
|
321
|
+
-------
|
|
322
|
+
Path
|
|
323
|
+
Path to saved manual.json file
|
|
324
|
+
"""
|
|
325
|
+
import scitex as stx
|
|
326
|
+
|
|
327
|
+
manual_path = json_path.with_suffix('.manual.json')
|
|
328
|
+
|
|
329
|
+
# Compute hash of base JSON for staleness detection
|
|
330
|
+
base_hash = _compute_file_hash(json_path)
|
|
331
|
+
|
|
332
|
+
manual_data = {
|
|
333
|
+
'base_file': json_path.name,
|
|
334
|
+
'base_hash': base_hash,
|
|
335
|
+
'overrides': overrides,
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
stx.io.save(manual_data, manual_path)
|
|
339
|
+
return manual_path
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
# EOF
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
# File: ./src/scitex/vis/editor/_mpl_editor.py
|
|
4
|
+
"""Minimal matplotlib-based figure editor."""
|
|
5
|
+
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Dict, Any, Optional
|
|
8
|
+
import copy
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class MplEditor:
|
|
12
|
+
"""
|
|
13
|
+
Minimal interactive figure editor using matplotlib's built-in interactivity.
|
|
14
|
+
|
|
15
|
+
Features:
|
|
16
|
+
- Basic figure display with navigation toolbar
|
|
17
|
+
- Text-based property editing via console
|
|
18
|
+
- Save to .manual.json
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
json_path: Path,
|
|
24
|
+
metadata: Dict[str, Any],
|
|
25
|
+
csv_data: Optional[Any] = None,
|
|
26
|
+
manual_overrides: Optional[Dict[str, Any]] = None,
|
|
27
|
+
):
|
|
28
|
+
self.json_path = Path(json_path)
|
|
29
|
+
self.metadata = metadata
|
|
30
|
+
self.csv_data = csv_data
|
|
31
|
+
self.manual_overrides = manual_overrides or {}
|
|
32
|
+
self.current_overrides = copy.deepcopy(self.manual_overrides)
|
|
33
|
+
|
|
34
|
+
def run(self):
|
|
35
|
+
"""Launch the matplotlib editor."""
|
|
36
|
+
import matplotlib
|
|
37
|
+
matplotlib.use('TkAgg') # Use interactive backend
|
|
38
|
+
import matplotlib.pyplot as plt
|
|
39
|
+
from matplotlib.widgets import Button, TextBox
|
|
40
|
+
|
|
41
|
+
# Create figure with extra space for controls
|
|
42
|
+
self.fig = plt.figure(figsize=(12, 8))
|
|
43
|
+
|
|
44
|
+
# Main axes for plot
|
|
45
|
+
self.ax = self.fig.add_axes([0.1, 0.25, 0.85, 0.65])
|
|
46
|
+
|
|
47
|
+
# Initial render
|
|
48
|
+
self._render()
|
|
49
|
+
|
|
50
|
+
# Add control buttons
|
|
51
|
+
self._add_controls()
|
|
52
|
+
|
|
53
|
+
# Show instructions
|
|
54
|
+
print("\n" + "="*50)
|
|
55
|
+
print("SciTeX Matplotlib Editor")
|
|
56
|
+
print("="*50)
|
|
57
|
+
print(f"Editing: {self.json_path.name}")
|
|
58
|
+
print("\nControls:")
|
|
59
|
+
print(" - Use navigation toolbar for zoom/pan")
|
|
60
|
+
print(" - Click buttons below figure for actions")
|
|
61
|
+
print(" - Close window when done")
|
|
62
|
+
print("="*50 + "\n")
|
|
63
|
+
|
|
64
|
+
plt.show()
|
|
65
|
+
|
|
66
|
+
def _render(self):
|
|
67
|
+
"""Render the figure."""
|
|
68
|
+
self.ax.clear()
|
|
69
|
+
|
|
70
|
+
# Plot from CSV data
|
|
71
|
+
if self.csv_data is not None:
|
|
72
|
+
self._plot_from_csv()
|
|
73
|
+
else:
|
|
74
|
+
self.ax.text(0.5, 0.5, "No plot data available\n(CSV not found)",
|
|
75
|
+
ha='center', va='center', transform=self.ax.transAxes)
|
|
76
|
+
|
|
77
|
+
# Apply overrides
|
|
78
|
+
if self.current_overrides.get('title'):
|
|
79
|
+
self.ax.set_title(self.current_overrides['title'])
|
|
80
|
+
if self.current_overrides.get('xlabel'):
|
|
81
|
+
self.ax.set_xlabel(self.current_overrides['xlabel'])
|
|
82
|
+
if self.current_overrides.get('ylabel'):
|
|
83
|
+
self.ax.set_ylabel(self.current_overrides['ylabel'])
|
|
84
|
+
if self.current_overrides.get('grid'):
|
|
85
|
+
self.ax.grid(True)
|
|
86
|
+
if self.current_overrides.get('xlim'):
|
|
87
|
+
self.ax.set_xlim(self.current_overrides['xlim'])
|
|
88
|
+
if self.current_overrides.get('ylim'):
|
|
89
|
+
self.ax.set_ylim(self.current_overrides['ylim'])
|
|
90
|
+
if self.current_overrides.get('facecolor'):
|
|
91
|
+
self.ax.set_facecolor(self.current_overrides['facecolor'])
|
|
92
|
+
|
|
93
|
+
# Apply annotations
|
|
94
|
+
for annot in self.current_overrides.get('annotations', []):
|
|
95
|
+
if annot.get('type') == 'text':
|
|
96
|
+
self.ax.text(
|
|
97
|
+
annot.get('x', 0.5),
|
|
98
|
+
annot.get('y', 0.5),
|
|
99
|
+
annot.get('text', ''),
|
|
100
|
+
transform=self.ax.transAxes,
|
|
101
|
+
fontsize=annot.get('fontsize', 10),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
self.fig.canvas.draw()
|
|
105
|
+
|
|
106
|
+
def _plot_from_csv(self):
|
|
107
|
+
"""Reconstruct plot from CSV data."""
|
|
108
|
+
import pandas as pd
|
|
109
|
+
|
|
110
|
+
if isinstance(self.csv_data, pd.DataFrame):
|
|
111
|
+
df = self.csv_data
|
|
112
|
+
else:
|
|
113
|
+
return
|
|
114
|
+
|
|
115
|
+
cols = df.columns.tolist()
|
|
116
|
+
if len(cols) >= 2:
|
|
117
|
+
x_col = cols[0]
|
|
118
|
+
for y_col in cols[1:]:
|
|
119
|
+
try:
|
|
120
|
+
self.ax.plot(df[x_col], df[y_col], label=str(y_col))
|
|
121
|
+
except Exception:
|
|
122
|
+
pass
|
|
123
|
+
if len(cols) > 2:
|
|
124
|
+
self.ax.legend()
|
|
125
|
+
elif len(cols) == 1:
|
|
126
|
+
self.ax.plot(df[cols[0]])
|
|
127
|
+
|
|
128
|
+
def _add_controls(self):
|
|
129
|
+
"""Add control buttons."""
|
|
130
|
+
from matplotlib.widgets import Button, TextBox
|
|
131
|
+
|
|
132
|
+
# Title text box
|
|
133
|
+
ax_title = self.fig.add_axes([0.15, 0.12, 0.3, 0.04])
|
|
134
|
+
self.title_box = TextBox(ax_title, 'Title:', initial=self.current_overrides.get('title', ''))
|
|
135
|
+
self.title_box.on_submit(self._on_title_change)
|
|
136
|
+
|
|
137
|
+
# Grid toggle button
|
|
138
|
+
ax_grid = self.fig.add_axes([0.55, 0.12, 0.1, 0.04])
|
|
139
|
+
self.grid_btn = Button(ax_grid, 'Toggle Grid')
|
|
140
|
+
self.grid_btn.on_clicked(self._toggle_grid)
|
|
141
|
+
|
|
142
|
+
# Save button
|
|
143
|
+
ax_save = self.fig.add_axes([0.7, 0.12, 0.1, 0.04])
|
|
144
|
+
self.save_btn = Button(ax_save, 'Save')
|
|
145
|
+
self.save_btn.on_clicked(self._save)
|
|
146
|
+
|
|
147
|
+
# Edit labels button
|
|
148
|
+
ax_labels = self.fig.add_axes([0.15, 0.05, 0.15, 0.04])
|
|
149
|
+
self.labels_btn = Button(ax_labels, 'Edit Labels')
|
|
150
|
+
self.labels_btn.on_clicked(self._edit_labels)
|
|
151
|
+
|
|
152
|
+
# Add annotation button
|
|
153
|
+
ax_annot = self.fig.add_axes([0.35, 0.05, 0.15, 0.04])
|
|
154
|
+
self.annot_btn = Button(ax_annot, 'Add Text')
|
|
155
|
+
self.annot_btn.on_clicked(self._add_annotation)
|
|
156
|
+
|
|
157
|
+
# Export PNG button
|
|
158
|
+
ax_export = self.fig.add_axes([0.55, 0.05, 0.12, 0.04])
|
|
159
|
+
self.export_btn = Button(ax_export, 'Export PNG')
|
|
160
|
+
self.export_btn.on_clicked(self._export_png)
|
|
161
|
+
|
|
162
|
+
def _on_title_change(self, text):
|
|
163
|
+
"""Handle title change."""
|
|
164
|
+
self.current_overrides['title'] = text
|
|
165
|
+
self._render()
|
|
166
|
+
|
|
167
|
+
def _toggle_grid(self, event):
|
|
168
|
+
"""Toggle grid visibility."""
|
|
169
|
+
self.current_overrides['grid'] = not self.current_overrides.get('grid', False)
|
|
170
|
+
self._render()
|
|
171
|
+
|
|
172
|
+
def _edit_labels(self, event):
|
|
173
|
+
"""Edit axis labels via console."""
|
|
174
|
+
print("\n--- Edit Labels ---")
|
|
175
|
+
xlabel = input(f"X Label [{self.current_overrides.get('xlabel', '')}]: ").strip()
|
|
176
|
+
if xlabel:
|
|
177
|
+
self.current_overrides['xlabel'] = xlabel
|
|
178
|
+
|
|
179
|
+
ylabel = input(f"Y Label [{self.current_overrides.get('ylabel', '')}]: ").strip()
|
|
180
|
+
if ylabel:
|
|
181
|
+
self.current_overrides['ylabel'] = ylabel
|
|
182
|
+
|
|
183
|
+
self._render()
|
|
184
|
+
print("Labels updated!")
|
|
185
|
+
|
|
186
|
+
def _add_annotation(self, event):
|
|
187
|
+
"""Add text annotation via console."""
|
|
188
|
+
print("\n--- Add Text Annotation ---")
|
|
189
|
+
text = input("Text: ").strip()
|
|
190
|
+
if not text:
|
|
191
|
+
return
|
|
192
|
+
|
|
193
|
+
try:
|
|
194
|
+
x = float(input("X position (0-1) [0.5]: ").strip() or "0.5")
|
|
195
|
+
y = float(input("Y position (0-1) [0.5]: ").strip() or "0.5")
|
|
196
|
+
except ValueError:
|
|
197
|
+
print("Invalid position, using defaults")
|
|
198
|
+
x, y = 0.5, 0.5
|
|
199
|
+
|
|
200
|
+
if 'annotations' not in self.current_overrides:
|
|
201
|
+
self.current_overrides['annotations'] = []
|
|
202
|
+
|
|
203
|
+
self.current_overrides['annotations'].append({
|
|
204
|
+
'type': 'text',
|
|
205
|
+
'text': text,
|
|
206
|
+
'x': x,
|
|
207
|
+
'y': y,
|
|
208
|
+
'fontsize': 10,
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
self._render()
|
|
212
|
+
print("Annotation added!")
|
|
213
|
+
|
|
214
|
+
def _save(self, event):
|
|
215
|
+
"""Save to .manual.json."""
|
|
216
|
+
from ._edit import save_manual_overrides
|
|
217
|
+
|
|
218
|
+
try:
|
|
219
|
+
manual_path = save_manual_overrides(self.json_path, self.current_overrides)
|
|
220
|
+
print(f"\nSaved: {manual_path}")
|
|
221
|
+
except Exception as e:
|
|
222
|
+
print(f"\nError saving: {e}")
|
|
223
|
+
|
|
224
|
+
def _export_png(self, event):
|
|
225
|
+
"""Export current view to PNG."""
|
|
226
|
+
output_path = self.json_path.with_suffix('.edited.png')
|
|
227
|
+
self.fig.savefig(output_path, dpi=300, bbox_inches='tight')
|
|
228
|
+
print(f"\nExported: {output_path}")
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
# EOF
|