scitex 2.8.1__py3-none-any.whl → 2.10.0__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/__init__.py +15 -7
- scitex/__version__.py +1 -2
- scitex/_install_guide.py +250 -0
- scitex/_optional_deps.py +206 -39
- scitex/ai/_gen_ai/_Groq.py +2 -4
- scitex/ai/_gen_ai/_OpenAI.py +5 -2
- scitex/ai/_gen_ai/_Perplexity.py +20 -6
- scitex/audio/__init__.py +24 -15
- scitex/audio/_cross_process_lock.py +139 -0
- scitex/audio/_mcp_handlers.py +256 -0
- scitex/audio/_mcp_tool_schemas.py +203 -0
- scitex/audio/engines/elevenlabs_engine.py +5 -2
- scitex/audio/mcp_server.py +98 -457
- scitex/bridge/__init__.py +30 -19
- scitex/bridge/_figrecipe.py +245 -0
- scitex/bridge/_helpers.py +2 -1
- scitex/bridge/_plt_vis.py +23 -10
- scitex/bridge/_stats_plt.py +18 -5
- scitex/bridge/_stats_vis.py +16 -2
- scitex/browser/__init__.py +84 -44
- scitex/browser/automation/__init__.py +5 -1
- scitex/browser/core/BrowserMixin.py +17 -4
- scitex/browser/core/__init__.py +11 -2
- scitex/browser/remote/CaptchaHandler.py +1 -1
- scitex/browser/remote/ZenRowsAPIClient.py +1 -1
- scitex/capture/grid.py +487 -0
- scitex/capture/mcp_handlers.py +401 -0
- scitex/capture/mcp_tool_defs.py +192 -0
- scitex/capture/mcp_tools.py +241 -0
- scitex/capture/mcp_utils.py +30 -0
- scitex/cli/convert.py +421 -0
- scitex/cli/main.py +6 -4
- scitex/datetime/__init__.py +46 -0
- scitex/datetime/_linspace.py +100 -0
- scitex/datetime/_normalize_timestamp.py +306 -0
- scitex/db/_delete_duplicates.py +4 -4
- scitex/db/_sqlite3/_delete_duplicates.py +11 -2
- scitex/dev/plt/__init__.py +61 -62
- scitex/dev/plt/demo_plotters/__init__.py +0 -0
- scitex/dev/plt/demo_plotters/plot_mpl_axhline.py +28 -0
- scitex/dev/plt/demo_plotters/plot_mpl_axhspan.py +28 -0
- scitex/dev/plt/demo_plotters/plot_mpl_axvline.py +28 -0
- scitex/dev/plt/demo_plotters/plot_mpl_axvspan.py +28 -0
- scitex/dev/plt/demo_plotters/plot_mpl_bar.py +29 -0
- scitex/dev/plt/demo_plotters/plot_mpl_barh.py +29 -0
- scitex/dev/plt/demo_plotters/plot_mpl_boxplot.py +28 -0
- scitex/dev/plt/demo_plotters/plot_mpl_contour.py +31 -0
- scitex/dev/plt/demo_plotters/plot_mpl_contourf.py +31 -0
- scitex/dev/plt/demo_plotters/plot_mpl_errorbar.py +30 -0
- scitex/dev/plt/demo_plotters/plot_mpl_eventplot.py +28 -0
- scitex/dev/plt/demo_plotters/plot_mpl_fill.py +30 -0
- scitex/dev/plt/demo_plotters/plot_mpl_fill_between.py +31 -0
- scitex/dev/plt/demo_plotters/plot_mpl_hexbin.py +28 -0
- scitex/dev/plt/demo_plotters/plot_mpl_hist.py +28 -0
- scitex/dev/plt/demo_plotters/plot_mpl_hist2d.py +28 -0
- scitex/dev/plt/demo_plotters/plot_mpl_imshow.py +29 -0
- scitex/dev/plt/demo_plotters/plot_mpl_pcolormesh.py +31 -0
- scitex/dev/plt/demo_plotters/plot_mpl_pie.py +29 -0
- scitex/dev/plt/demo_plotters/plot_mpl_plot.py +29 -0
- scitex/dev/plt/demo_plotters/plot_mpl_quiver.py +31 -0
- scitex/dev/plt/demo_plotters/plot_mpl_scatter.py +28 -0
- scitex/dev/plt/demo_plotters/plot_mpl_stackplot.py +31 -0
- scitex/dev/plt/demo_plotters/plot_mpl_stem.py +29 -0
- scitex/dev/plt/demo_plotters/plot_mpl_step.py +29 -0
- scitex/dev/plt/demo_plotters/plot_mpl_violinplot.py +28 -0
- scitex/dev/plt/demo_plotters/plot_sns_barplot.py +29 -0
- scitex/dev/plt/demo_plotters/plot_sns_boxplot.py +29 -0
- scitex/dev/plt/demo_plotters/plot_sns_heatmap.py +28 -0
- scitex/dev/plt/demo_plotters/plot_sns_histplot.py +29 -0
- scitex/dev/plt/demo_plotters/plot_sns_kdeplot.py +29 -0
- scitex/dev/plt/demo_plotters/plot_sns_lineplot.py +31 -0
- scitex/dev/plt/demo_plotters/plot_sns_scatterplot.py +29 -0
- scitex/dev/plt/demo_plotters/plot_sns_stripplot.py +29 -0
- scitex/dev/plt/demo_plotters/plot_sns_swarmplot.py +29 -0
- scitex/dev/plt/demo_plotters/plot_sns_violinplot.py +29 -0
- scitex/dev/plt/demo_plotters/plot_stx_bar.py +29 -0
- scitex/dev/plt/demo_plotters/plot_stx_barh.py +29 -0
- scitex/dev/plt/demo_plotters/plot_stx_box.py +28 -0
- scitex/dev/plt/demo_plotters/plot_stx_boxplot.py +28 -0
- scitex/dev/plt/demo_plotters/plot_stx_conf_mat.py +28 -0
- scitex/dev/plt/demo_plotters/plot_stx_contour.py +31 -0
- scitex/dev/plt/demo_plotters/plot_stx_ecdf.py +28 -0
- scitex/dev/plt/demo_plotters/plot_stx_errorbar.py +30 -0
- scitex/dev/plt/demo_plotters/plot_stx_fill_between.py +31 -0
- scitex/dev/plt/demo_plotters/plot_stx_fillv.py +28 -0
- scitex/dev/plt/demo_plotters/plot_stx_heatmap.py +28 -0
- scitex/dev/plt/demo_plotters/plot_stx_image.py +28 -0
- scitex/dev/plt/demo_plotters/plot_stx_imshow.py +28 -0
- scitex/dev/plt/demo_plotters/plot_stx_joyplot.py +28 -0
- scitex/dev/plt/demo_plotters/plot_stx_kde.py +28 -0
- scitex/dev/plt/demo_plotters/plot_stx_line.py +28 -0
- scitex/dev/plt/demo_plotters/plot_stx_mean_ci.py +28 -0
- scitex/dev/plt/demo_plotters/plot_stx_mean_std.py +28 -0
- scitex/dev/plt/demo_plotters/plot_stx_median_iqr.py +28 -0
- scitex/dev/plt/demo_plotters/plot_stx_raster.py +28 -0
- scitex/dev/plt/demo_plotters/plot_stx_rectangle.py +28 -0
- scitex/dev/plt/demo_plotters/plot_stx_scatter.py +29 -0
- scitex/dev/plt/demo_plotters/plot_stx_shaded_line.py +29 -0
- scitex/dev/plt/demo_plotters/plot_stx_violin.py +28 -0
- scitex/dev/plt/demo_plotters/plot_stx_violinplot.py +28 -0
- scitex/dev/plt/mpl/get_dir_ax.py +46 -0
- scitex/dev/plt/mpl/get_signatures.py +176 -0
- scitex/dev/plt/mpl/get_signatures_details.py +522 -0
- scitex/dict/_pop_keys.py +1 -7
- scitex/dsp/__init__.py +15 -10
- scitex/dsp/add_noise.py +5 -2
- scitex/dsp/example.py +35 -22
- scitex/dsp/filt.py +8 -3
- scitex/dsp/reference.py +3 -2
- scitex/dsp/utils/__init__.py +2 -1
- scitex/dsp/utils/_differential_bandpass_filters.py +14 -4
- scitex/dt/__init__.py +39 -2
- scitex/errors.py +82 -521
- scitex/fig/__init__.py +4 -4
- scitex/fig/editor/edit/panel_loader.py +1 -1
- scitex/fig/io/_bundle.py +7 -7
- scitex/fts/README.md +262 -0
- scitex/fts/TODO.md +66 -0
- scitex/fts/__init__.py +90 -0
- scitex/fts/_bundle/README_IN_BUNDLE.md +102 -0
- scitex/fts/_bundle/_FTS.py +657 -0
- scitex/fts/_bundle/__init__.py +38 -0
- scitex/fts/_bundle/_children.py +216 -0
- scitex/fts/_bundle/_conversion/__init__.py +15 -0
- scitex/fts/_bundle/_conversion/_bundle2dict.py +44 -0
- scitex/fts/_bundle/_conversion/_dict2bundle.py +50 -0
- scitex/fts/_bundle/_dataclasses/_Axes.py +57 -0
- scitex/fts/_bundle/_dataclasses/_BBox.py +54 -0
- scitex/fts/_bundle/_dataclasses/_ColumnDef.py +72 -0
- scitex/fts/_bundle/_dataclasses/_DataFormat.py +40 -0
- scitex/fts/_bundle/_dataclasses/_DataInfo.py +135 -0
- scitex/fts/_bundle/_dataclasses/_DataSource.py +44 -0
- scitex/fts/_bundle/_dataclasses/_Node.py +319 -0
- scitex/fts/_bundle/_dataclasses/_NodeRefs.py +45 -0
- scitex/fts/_bundle/_dataclasses/_SizeMM.py +38 -0
- scitex/fts/_bundle/_dataclasses/__init__.py +35 -0
- scitex/fts/_bundle/_extractors/__init__.py +32 -0
- scitex/fts/_bundle/_extractors/_extract_bar.py +131 -0
- scitex/fts/_bundle/_extractors/_extract_line.py +71 -0
- scitex/fts/_bundle/_extractors/_extract_scatter.py +79 -0
- scitex/fts/_bundle/_loader.py +134 -0
- scitex/fts/_bundle/_mpl_helpers.py +389 -0
- scitex/fts/_bundle/_saver.py +269 -0
- scitex/fts/_bundle/_storage.py +200 -0
- scitex/fts/_bundle/_utils/__init__.py +55 -0
- scitex/fts/_bundle/_utils/_const.py +26 -0
- scitex/fts/_bundle/_utils/_errors.py +73 -0
- scitex/fts/_bundle/_utils/_generate.py +21 -0
- scitex/fts/_bundle/_utils/_types.py +76 -0
- scitex/fts/_bundle/_validation.py +434 -0
- scitex/fts/_bundle/_zipbundle.py +165 -0
- scitex/fts/_fig/__init__.py +22 -0
- scitex/fts/_fig/_backend/__init__.py +53 -0
- scitex/fts/_fig/_backend/_export.py +165 -0
- scitex/fts/_fig/_backend/_parser.py +188 -0
- scitex/fts/_fig/_backend/_render.py +538 -0
- scitex/fts/_fig/_composite.py +345 -0
- scitex/fts/_fig/_dataclasses/_ChannelEncoding.py +46 -0
- scitex/fts/_fig/_dataclasses/_Encoding.py +82 -0
- scitex/fts/_fig/_dataclasses/_Theme.py +441 -0
- scitex/fts/_fig/_dataclasses/_TraceEncoding.py +52 -0
- scitex/fts/_fig/_dataclasses/__init__.py +47 -0
- scitex/fts/_fig/_editor/__init__.py +14 -0
- scitex/fts/_fig/_editor/_cui/__init__.py +33 -0
- scitex/fts/_fig/_editor/_cui/_backend_detector.py +39 -0
- scitex/fts/_fig/_editor/_cui/_bundle_resolver.py +366 -0
- scitex/fts/_fig/_editor/_cui/_editor_launcher.py +175 -0
- scitex/fts/_fig/_editor/_cui/_manual_handler.py +52 -0
- scitex/fts/_fig/_editor/_cui/_panel_loader.py +246 -0
- scitex/fts/_fig/_editor/_cui/_path_resolver.py +66 -0
- scitex/fts/_fig/_editor/_defaults.py +300 -0
- scitex/fts/_fig/_editor/_gui/__init__.py +11 -0
- scitex/fts/_fig/_editor/_gui/_flask_editor/__init__.py +20 -0
- scitex/fts/_fig/_editor/_gui/_flask_editor/_bbox.py +1339 -0
- scitex/fts/_fig/_editor/_gui/_flask_editor/_core.py +1688 -0
- scitex/fts/_fig/_editor/_gui/_flask_editor/_plotter.py +664 -0
- scitex/fts/_fig/_editor/_gui/_flask_editor/_renderer.py +853 -0
- scitex/fts/_fig/_editor/_gui/_flask_editor/_utils.py +79 -0
- scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/base/reset.css +41 -0
- scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/base/typography.css +16 -0
- scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/base/variables.css +85 -0
- scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/components/buttons.css +217 -0
- scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/components/context-menu.css +93 -0
- scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/components/dropdown.css +57 -0
- scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/components/forms.css +112 -0
- scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/components/modal.css +59 -0
- scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/components/sections.css +212 -0
- scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/features/canvas.css +176 -0
- scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/features/element-inspector.css +190 -0
- scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/features/loading.css +59 -0
- scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/features/overlay.css +45 -0
- scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/features/panel-grid.css +95 -0
- scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/features/selection.css +101 -0
- scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/features/statistics.css +138 -0
- scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/index.css +31 -0
- scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/layout/container.css +7 -0
- scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/layout/controls.css +56 -0
- scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/layout/preview.css +78 -0
- scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/alignment/axis.js +314 -0
- scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/alignment/basic.js +107 -0
- scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/alignment/distribute.js +54 -0
- scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/canvas/canvas.js +172 -0
- scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/canvas/dragging.js +258 -0
- scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/canvas/resize.js +48 -0
- scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/canvas/selection.js +71 -0
- scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/core/api.js +288 -0
- scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/core/state.js +143 -0
- scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/core/utils.js +245 -0
- scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/dev/element-inspector.js +992 -0
- scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/editor/bbox.js +339 -0
- scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/editor/element-drag.js +286 -0
- scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/editor/overlay.js +371 -0
- scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/editor/preview.js +293 -0
- scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/main.js +426 -0
- scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/shortcuts/context-menu.js +152 -0
- scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/shortcuts/keyboard.js +265 -0
- scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/ui/controls.js +184 -0
- scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/ui/download.js +57 -0
- scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/ui/help.js +100 -0
- scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/ui/theme.js +34 -0
- scitex/fts/_fig/_editor/_gui/_flask_editor/templates/__init__.py +124 -0
- scitex/fts/_fig/_editor/_gui/_flask_editor/templates/_html.py +851 -0
- scitex/fts/_fig/_editor/_gui/_flask_editor/templates/_scripts.py +4932 -0
- scitex/fts/_fig/_editor/_gui/_flask_editor/templates/_styles.py +1657 -0
- scitex/fts/_fig/_editor/_gui/_flask_editor.py +36 -0
- scitex/fts/_fig/_models/_Annotations.py +115 -0
- scitex/fts/_fig/_models/_Axes.py +152 -0
- scitex/fts/_fig/_models/_Figure.py +138 -0
- scitex/fts/_fig/_models/_Guides.py +104 -0
- scitex/fts/_fig/_models/_Plot.py +123 -0
- scitex/fts/_fig/_models/_Styles.py +245 -0
- scitex/fts/_fig/_models/__init__.py +80 -0
- scitex/fts/_fig/_models/_plot_types/__init__.py +156 -0
- scitex/fts/_fig/_models/_plot_types/_bar.py +43 -0
- scitex/fts/_fig/_models/_plot_types/_box.py +38 -0
- scitex/fts/_fig/_models/_plot_types/_distribution.py +36 -0
- scitex/fts/_fig/_models/_plot_types/_errorbar.py +60 -0
- scitex/fts/_fig/_models/_plot_types/_histogram.py +30 -0
- scitex/fts/_fig/_models/_plot_types/_image.py +61 -0
- scitex/fts/_fig/_models/_plot_types/_line.py +57 -0
- scitex/fts/_fig/_models/_plot_types/_scatter.py +30 -0
- scitex/fts/_fig/_models/_plot_types/_seaborn.py +121 -0
- scitex/fts/_fig/_models/_plot_types/_violin.py +36 -0
- scitex/fts/_fig/_utils/__init__.py +129 -0
- scitex/fts/_fig/_utils/_auto_layout.py +127 -0
- scitex/fts/_fig/_utils/_calc_bounds.py +111 -0
- scitex/fts/_fig/_utils/_const_sizes.py +48 -0
- scitex/fts/_fig/_utils/_convert_coords.py +77 -0
- scitex/fts/_fig/_utils/_get_template.py +178 -0
- scitex/fts/_fig/_utils/_normalize.py +73 -0
- scitex/fts/_fig/_utils/_plot_layout.py +397 -0
- scitex/fts/_fig/_utils/_validate.py +197 -0
- scitex/fts/_kinds/__init__.py +45 -0
- scitex/fts/_kinds/_figure/__init__.py +19 -0
- scitex/fts/_kinds/_figure/_composite.py +345 -0
- scitex/fts/_kinds/_plot/__init__.py +25 -0
- scitex/fts/_kinds/_plot/_backend/__init__.py +53 -0
- scitex/fts/_kinds/_plot/_backend/_export.py +165 -0
- scitex/fts/_kinds/_plot/_backend/_parser.py +188 -0
- scitex/fts/_kinds/_plot/_backend/_render.py +538 -0
- scitex/fts/_kinds/_plot/_dataclasses/_ChannelEncoding.py +46 -0
- scitex/fts/_kinds/_plot/_dataclasses/_Encoding.py +82 -0
- scitex/fts/_kinds/_plot/_dataclasses/_Theme.py +441 -0
- scitex/fts/_kinds/_plot/_dataclasses/_TraceEncoding.py +52 -0
- scitex/fts/_kinds/_plot/_dataclasses/__init__.py +47 -0
- scitex/fts/_kinds/_plot/_models/_Annotations.py +115 -0
- scitex/fts/_kinds/_plot/_models/_Axes.py +152 -0
- scitex/fts/_kinds/_plot/_models/_Figure.py +138 -0
- scitex/fts/_kinds/_plot/_models/_Guides.py +104 -0
- scitex/fts/_kinds/_plot/_models/_Plot.py +123 -0
- scitex/fts/_kinds/_plot/_models/_Styles.py +245 -0
- scitex/fts/_kinds/_plot/_models/__init__.py +80 -0
- scitex/fts/_kinds/_plot/_models/_plot_types/__init__.py +156 -0
- scitex/fts/_kinds/_plot/_models/_plot_types/_bar.py +43 -0
- scitex/fts/_kinds/_plot/_models/_plot_types/_box.py +38 -0
- scitex/fts/_kinds/_plot/_models/_plot_types/_distribution.py +36 -0
- scitex/fts/_kinds/_plot/_models/_plot_types/_errorbar.py +60 -0
- scitex/fts/_kinds/_plot/_models/_plot_types/_histogram.py +30 -0
- scitex/fts/_kinds/_plot/_models/_plot_types/_image.py +61 -0
- scitex/fts/_kinds/_plot/_models/_plot_types/_line.py +57 -0
- scitex/fts/_kinds/_plot/_models/_plot_types/_scatter.py +30 -0
- scitex/fts/_kinds/_plot/_models/_plot_types/_seaborn.py +121 -0
- scitex/fts/_kinds/_plot/_models/_plot_types/_violin.py +36 -0
- scitex/fts/_kinds/_plot/_utils/__init__.py +129 -0
- scitex/fts/_kinds/_plot/_utils/_auto_layout.py +127 -0
- scitex/fts/_kinds/_plot/_utils/_calc_bounds.py +111 -0
- scitex/fts/_kinds/_plot/_utils/_const_sizes.py +48 -0
- scitex/fts/_kinds/_plot/_utils/_convert_coords.py +77 -0
- scitex/fts/_kinds/_plot/_utils/_get_template.py +178 -0
- scitex/fts/_kinds/_plot/_utils/_normalize.py +73 -0
- scitex/fts/_kinds/_plot/_utils/_plot_layout.py +397 -0
- scitex/fts/_kinds/_plot/_utils/_validate.py +197 -0
- scitex/fts/_kinds/_shape/__init__.py +141 -0
- scitex/fts/_kinds/_stats/__init__.py +56 -0
- scitex/fts/_kinds/_stats/_dataclasses/_Stats.py +423 -0
- scitex/fts/_kinds/_stats/_dataclasses/__init__.py +48 -0
- scitex/fts/_kinds/_table/__init__.py +72 -0
- scitex/fts/_kinds/_table/_latex/__init__.py +93 -0
- scitex/fts/_kinds/_table/_latex/_editor/__init__.py +11 -0
- scitex/fts/_kinds/_table/_latex/_editor/_app.py +725 -0
- scitex/fts/_kinds/_table/_latex/_export.py +279 -0
- scitex/fts/_kinds/_table/_latex/_figure_exporter.py +153 -0
- scitex/fts/_kinds/_table/_latex/_stats_formatter.py +274 -0
- scitex/fts/_kinds/_table/_latex/_table_exporter.py +362 -0
- scitex/fts/_kinds/_table/_latex/_utils.py +369 -0
- scitex/fts/_kinds/_table/_latex/_validator.py +445 -0
- scitex/fts/_kinds/_text/__init__.py +77 -0
- scitex/fts/_schemas/data_info.schema.json +75 -0
- scitex/fts/_schemas/encoding.schema.json +90 -0
- scitex/fts/_schemas/node.schema.json +145 -0
- scitex/fts/_schemas/render_manifest.schema.json +62 -0
- scitex/fts/_schemas/stats.schema.json +132 -0
- scitex/fts/_schemas/theme.schema.json +141 -0
- scitex/fts/_stats/__init__.py +48 -0
- scitex/fts/_stats/_dataclasses/_Stats.py +423 -0
- scitex/fts/_stats/_dataclasses/__init__.py +48 -0
- scitex/fts/_tables/__init__.py +65 -0
- scitex/fts/_tables/_latex/__init__.py +93 -0
- scitex/fts/_tables/_latex/_editor/__init__.py +11 -0
- scitex/fts/_tables/_latex/_editor/_app.py +725 -0
- scitex/fts/_tables/_latex/_export.py +279 -0
- scitex/fts/_tables/_latex/_figure_exporter.py +153 -0
- scitex/fts/_tables/_latex/_stats_formatter.py +274 -0
- scitex/fts/_tables/_latex/_table_exporter.py +362 -0
- scitex/fts/_tables/_latex/_utils.py +369 -0
- scitex/fts/_tables/_latex/_validator.py +445 -0
- scitex/gen/__init__.py +66 -25
- scitex/gen/misc.py +28 -0
- scitex/io/__init__.py +47 -32
- scitex/io/_load.py +87 -36
- scitex/io/_load_modules/__init__.py +10 -7
- scitex/io/_load_modules/_pandas.py +6 -1
- scitex/io/_save.py +299 -1556
- scitex/io/_save_modules/__init__.py +76 -19
- scitex/io/_save_modules/_figure_utils.py +90 -0
- scitex/io/_save_modules/_image_csv.py +497 -0
- scitex/io/_save_modules/_legends.py +91 -0
- scitex/io/_save_modules/_pltz_bundle.py +356 -0
- scitex/io/_save_modules/_pltz_stx.py +536 -0
- scitex/io/_save_modules/_stx_bundle.py +104 -0
- scitex/io/_save_modules/_symlink.py +96 -0
- scitex/io/_save_modules/_yaml.py +1 -1
- scitex/io/_save_modules/_zarr.py +64 -18
- scitex/io/bundle/README.md +212 -0
- scitex/io/bundle/__init__.py +110 -0
- scitex/io/{_bundle.py → bundle/_core.py} +168 -97
- scitex/io/bundle/_nested.py +713 -0
- scitex/io/bundle/_types.py +74 -0
- scitex/io/{_zip_bundle.py → bundle/_zip.py} +93 -45
- scitex/io/utils/h5_to_zarr.py +1 -1
- scitex/logging/__init__.py +108 -13
- scitex/logging/_errors.py +508 -0
- scitex/logging/_formatters.py +30 -6
- scitex/logging/_warnings.py +261 -0
- scitex/plt/__init__.py +4 -1
- scitex/plt/_figrecipe.py +236 -0
- scitex/plt/_subplots/_AxisWrapper.py +6 -0
- scitex/plt/_subplots/_AxisWrapperMixins/_UnitAwareMixin.py +112 -1
- scitex/plt/_subplots/_FigWrapper.py +15 -0
- scitex/plt/_subplots/_SubplotsWrapper.py +125 -489
- scitex/plt/_subplots/_export_as_csv.py +11 -0
- scitex/plt/_subplots/_export_as_csv_formatters/__init__.py +2 -0
- scitex/plt/_subplots/_export_as_csv_formatters/_format_pcolormesh.py +66 -0
- scitex/plt/_subplots/_export_as_csv_formatters/_format_stackplot.py +62 -0
- scitex/plt/_subplots/_export_as_csv_formatters/test_formatters.py +208 -0
- scitex/plt/_subplots/_fonts.py +71 -0
- scitex/plt/_subplots/_mm_layout.py +282 -0
- scitex/plt/gallery/__init__.py +99 -2
- scitex/plt/styles/_plot_postprocess.py +3 -1
- scitex/plt/utils/_configure_mpl.py +16 -19
- scitex/repro/_RandomStateManager.py +13 -8
- scitex/resource/__init__.py +19 -1
- scitex/resource/_utils/_get_env_info.py +13 -25
- scitex/schema/__init__.py +149 -160
- scitex/schema/_encoding.py +273 -0
- scitex/schema/_figure_elements.py +406 -0
- scitex/schema/_theme.py +360 -0
- scitex/schema/_validation.py +0 -98
- scitex/scholar/__init__.py +56 -14
- scitex/scholar/auth/ScholarAuthManager.py +1 -1
- scitex/scholar/auth/__init__.py +11 -2
- scitex/scholar/auth/providers/BaseAuthenticator.py +1 -1
- scitex/scholar/auth/providers/EZProxyAuthenticator.py +1 -1
- scitex/scholar/auth/providers/OpenAthensAuthenticator.py +1 -1
- scitex/scholar/auth/providers/ShibbolethAuthenticator.py +1 -1
- scitex/scholar/config/ScholarConfig.py +1 -1
- scitex/scholar/core/Scholar.py +1 -1
- scitex/session/_decorator.py +18 -16
- scitex/session/_lifecycle.py +9 -11
- scitex/session/template.py +9 -8
- scitex/sh/test_sh.py +72 -0
- scitex/sh/test_sh_simple.py +61 -0
- scitex/stats/__init__.py +221 -97
- scitex/stats/_schema.py +21 -22
- scitex/stats/descriptive/_circular.py +212 -351
- scitex/stats/descriptive/_describe.py +81 -132
- scitex/stats/descriptive/_nan.py +205 -433
- scitex/stats/descriptive/_real.py +127 -141
- scitex/str/_format_plot_text.py +5 -5
- scitex/str/_latex.py +26 -84
- scitex/str/_latex_fallback.py +53 -47
- scitex/web/_search_pubmed.py +5 -4
- scitex/writer/tests/test_diff_between.py +451 -0
- scitex/writer/tests/test_document_section.py +311 -0
- scitex/writer/tests/test_document_workflow.py +393 -0
- scitex/writer/tests/test_writer.py +361 -0
- scitex/writer/tests/test_writer_integration.py +303 -0
- {scitex-2.8.1.dist-info → scitex-2.10.0.dist-info}/METADATA +364 -181
- {scitex-2.8.1.dist-info → scitex-2.10.0.dist-info}/RECORD +412 -97
- scitex/scholar/docs/to_claude/guidelines/examples/mgmt/ARCHITECTURE_EXAMPLE.md +0 -905
- scitex/scholar/docs/to_claude/guidelines/examples/mgmt/BULLETIN_BOARD_EXAMPLE.md +0 -99
- scitex/scholar/docs/to_claude/guidelines/examples/mgmt/PROJECT_DESCRIPTION_EXAMPLE.md +0 -96
- {scitex-2.8.1.dist-info → scitex-2.10.0.dist-info}/WHEEL +0 -0
- {scitex-2.8.1.dist-info → scitex-2.10.0.dist-info}/entry_points.txt +0 -0
- {scitex-2.8.1.dist-info → scitex-2.10.0.dist-info}/licenses/LICENSE +0 -0
scitex/io/_save.py
CHANGED
|
@@ -1,151 +1,65 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
#
|
|
3
|
-
# Timestamp: "2025-11-14 08:56:29 (ywatanabe)"
|
|
2
|
+
# Timestamp: 2025-12-19
|
|
4
3
|
# File: /home/ywatanabe/proj/scitex-code/src/scitex/io/_save.py
|
|
5
4
|
|
|
6
|
-
|
|
7
|
-
import os
|
|
8
|
-
|
|
9
|
-
__FILE__ = __file__
|
|
10
|
-
|
|
11
|
-
import warnings
|
|
12
|
-
|
|
13
|
-
|
|
14
5
|
"""
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
- File path or name where the object should be saved
|
|
20
|
-
3. Output:
|
|
21
|
-
- Saved files in various formats (e.g., CSV, NPY, PKL, JOBLIB, PNG, HTML, TIFF, MP4, YAML, JSON, HDF5, PTH, MAT, CBM)
|
|
22
|
-
4. Prerequisites:
|
|
23
|
-
- Python 3.x
|
|
24
|
-
- Required libraries: numpy, pandas, torch, matplotlib, plotly, h5py, joblib, PIL, ruamel.yaml
|
|
6
|
+
Save utilities for various data types to different file formats.
|
|
7
|
+
|
|
8
|
+
Supported formats include CSV, NPY, PKL, JOBLIB, PNG, HTML, TIFF, MP4, YAML,
|
|
9
|
+
JSON, HDF5, PTH, MAT, CBM, and FTS bundles (.zip or directory).
|
|
25
10
|
"""
|
|
26
11
|
|
|
27
|
-
"""Imports"""
|
|
28
12
|
import inspect
|
|
29
13
|
import os as _os
|
|
30
14
|
from pathlib import Path
|
|
31
|
-
from typing import Any
|
|
32
|
-
from typing import Union
|
|
15
|
+
from typing import Any, Union
|
|
33
16
|
|
|
34
17
|
from scitex import logging
|
|
35
|
-
|
|
36
|
-
from scitex.sh import sh
|
|
37
18
|
from scitex.path._clean import clean
|
|
38
19
|
from scitex.path._getsize import getsize
|
|
20
|
+
from scitex.sh import sh
|
|
39
21
|
from scitex.str._clean_path import clean_path
|
|
40
22
|
from scitex.str._color_text import color_text
|
|
41
23
|
from scitex.str._readable_bytes import readable_bytes
|
|
42
24
|
|
|
43
|
-
# Import save functions from the
|
|
44
|
-
from ._save_modules import
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
25
|
+
# Import save functions from the modular structure
|
|
26
|
+
from ._save_modules import (
|
|
27
|
+
get_figure_with_data,
|
|
28
|
+
handle_image_with_csv,
|
|
29
|
+
save_bibtex,
|
|
30
|
+
save_catboost,
|
|
31
|
+
save_csv,
|
|
32
|
+
save_excel,
|
|
33
|
+
save_hdf5,
|
|
34
|
+
save_html,
|
|
35
|
+
save_joblib,
|
|
36
|
+
save_json,
|
|
37
|
+
save_matlab,
|
|
38
|
+
save_mp4,
|
|
39
|
+
save_npy,
|
|
40
|
+
save_npz,
|
|
41
|
+
save_pickle,
|
|
42
|
+
save_pickle_compressed,
|
|
43
|
+
save_pltz_bundle,
|
|
44
|
+
save_stx_bundle,
|
|
45
|
+
save_tex,
|
|
46
|
+
save_text,
|
|
47
|
+
save_torch,
|
|
48
|
+
save_yaml,
|
|
49
|
+
save_zarr,
|
|
50
|
+
symlink,
|
|
51
|
+
symlink_to,
|
|
52
|
+
)
|
|
65
53
|
|
|
66
54
|
logger = logging.getLogger()
|
|
67
55
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
obj : various matplotlib objects
|
|
76
|
-
Could be Figure, Axes, FigWrapper, AxisWrapper, or other matplotlib objects
|
|
77
|
-
|
|
78
|
-
Returns
|
|
79
|
-
-------
|
|
80
|
-
object or None
|
|
81
|
-
Figure or axes object that has export_as_csv methods, or None if not found
|
|
82
|
-
"""
|
|
83
|
-
import matplotlib.axes
|
|
84
|
-
import matplotlib.figure
|
|
85
|
-
import matplotlib.pyplot as plt
|
|
86
|
-
|
|
87
|
-
# Check if object already has export methods (SciTeX wrapped objects)
|
|
88
|
-
if hasattr(obj, "export_as_csv"):
|
|
89
|
-
return obj
|
|
90
|
-
|
|
91
|
-
# Handle matplotlib Figure objects
|
|
92
|
-
if isinstance(obj, matplotlib.figure.Figure):
|
|
93
|
-
# Get the current axes that might be wrapped with SciTeX functionality
|
|
94
|
-
current_ax = plt.gca()
|
|
95
|
-
if hasattr(current_ax, "export_as_csv"):
|
|
96
|
-
return current_ax
|
|
97
|
-
|
|
98
|
-
# Check all axes in the figure
|
|
99
|
-
for ax in obj.axes:
|
|
100
|
-
if hasattr(ax, "export_as_csv"):
|
|
101
|
-
return ax
|
|
102
|
-
|
|
103
|
-
return None
|
|
104
|
-
|
|
105
|
-
# Handle matplotlib Axes objects
|
|
106
|
-
if isinstance(obj, matplotlib.axes.Axes):
|
|
107
|
-
if hasattr(obj, "export_as_csv"):
|
|
108
|
-
return obj
|
|
109
|
-
return None
|
|
110
|
-
|
|
111
|
-
# Handle FigWrapper or similar SciTeX objects
|
|
112
|
-
if hasattr(obj, "figure") and hasattr(obj.figure, "axes"):
|
|
113
|
-
# Check if the wrapper itself has export methods
|
|
114
|
-
if hasattr(obj, "export_as_csv"):
|
|
115
|
-
return obj
|
|
116
|
-
|
|
117
|
-
# Check the underlying figure's axes
|
|
118
|
-
for ax in obj.figure.axes:
|
|
119
|
-
if hasattr(ax, "export_as_csv"):
|
|
120
|
-
return ax
|
|
121
|
-
|
|
122
|
-
return None
|
|
123
|
-
|
|
124
|
-
# Handle AxisWrapper or similar SciTeX objects
|
|
125
|
-
if hasattr(obj, "_axis_mpl") or hasattr(obj, "_ax"):
|
|
126
|
-
if hasattr(obj, "export_as_csv"):
|
|
127
|
-
return obj
|
|
128
|
-
return None
|
|
129
|
-
|
|
130
|
-
# Try to get the current figure and its axes as fallback
|
|
131
|
-
try:
|
|
132
|
-
current_fig = plt.gcf()
|
|
133
|
-
current_ax = plt.gca()
|
|
134
|
-
|
|
135
|
-
if hasattr(current_ax, "export_as_csv"):
|
|
136
|
-
return current_ax
|
|
137
|
-
elif hasattr(current_fig, "export_as_csv"):
|
|
138
|
-
return current_fig
|
|
139
|
-
|
|
140
|
-
# Check all axes in current figure
|
|
141
|
-
for ax in current_fig.axes:
|
|
142
|
-
if hasattr(ax, "export_as_csv"):
|
|
143
|
-
return ax
|
|
144
|
-
|
|
145
|
-
except:
|
|
146
|
-
pass
|
|
147
|
-
|
|
148
|
-
return None
|
|
56
|
+
# Re-export for backward compatibility
|
|
57
|
+
_get_figure_with_data = get_figure_with_data
|
|
58
|
+
_symlink = symlink
|
|
59
|
+
_symlink_to = symlink_to
|
|
60
|
+
_save_stx_bundle = save_stx_bundle
|
|
61
|
+
_save_pltz_bundle = save_pltz_bundle
|
|
62
|
+
_handle_image_with_csv = handle_image_with_csv
|
|
149
63
|
|
|
150
64
|
|
|
151
65
|
def save(
|
|
@@ -170,264 +84,58 @@ def save(
|
|
|
170
84
|
Parameters
|
|
171
85
|
----------
|
|
172
86
|
obj : Any
|
|
173
|
-
The object to be saved.
|
|
87
|
+
The object to be saved.
|
|
174
88
|
specified_path : Union[str, Path]
|
|
175
|
-
The file
|
|
89
|
+
The file path where the object should be saved.
|
|
176
90
|
makedirs : bool, optional
|
|
177
91
|
If True, create the directory path if it does not exist. Default is True.
|
|
178
92
|
verbose : bool, optional
|
|
179
93
|
If True, print a message upon successful saving. Default is True.
|
|
180
94
|
symlink_from_cwd : bool, optional
|
|
181
|
-
If True, create a
|
|
95
|
+
If True, create a symlink from the current working directory. Default is False.
|
|
182
96
|
symlink_to : Union[str, Path], optional
|
|
183
|
-
If specified, create a symlink at this path
|
|
97
|
+
If specified, create a symlink at this path. Default is None.
|
|
184
98
|
dry_run : bool, optional
|
|
185
|
-
If True, simulate the saving process
|
|
99
|
+
If True, simulate the saving process. Default is False.
|
|
186
100
|
auto_crop : bool, optional
|
|
187
|
-
If True, automatically crop
|
|
188
|
-
Vector formats (PDF/SVG) are not cropped. Default is True.
|
|
101
|
+
If True, automatically crop saved images. Default is True.
|
|
189
102
|
crop_margin_mm : float, optional
|
|
190
|
-
Margin in millimeters
|
|
191
|
-
At 300 DPI: 1mm = ~12 pixels. Default is 1.0mm (Nature Reviews style).
|
|
103
|
+
Margin in millimeters for auto_crop. Default is 1.0mm.
|
|
192
104
|
use_caller_path : bool, optional
|
|
193
|
-
If True,
|
|
194
|
-
This is useful when stx.io.save is called from within scitex library code.
|
|
195
|
-
Default is False.
|
|
105
|
+
If True, determine script path by skipping internal library frames.
|
|
196
106
|
metadata_extra : dict, optional
|
|
197
|
-
Additional metadata to merge with auto-collected metadata.
|
|
198
|
-
plot_type, style information, etc. Example:
|
|
199
|
-
metadata_extra = {
|
|
200
|
-
"plot_type": "line",
|
|
201
|
-
"style": {
|
|
202
|
-
"name": "SCITEX_STYLE",
|
|
203
|
-
"overrides": {"ax_width_mm": 50}
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
Default is None.
|
|
107
|
+
Additional metadata to merge with auto-collected metadata.
|
|
207
108
|
json_schema : str, optional
|
|
208
|
-
Schema type for JSON metadata output.
|
|
209
|
-
- "editable": Schema v0.3.0 with element geometry for interactive editing (default)
|
|
210
|
-
- "recipe": Minimal schema with method calls + data refs
|
|
211
|
-
- "verbose": Full schema with all artist details
|
|
212
|
-
Default is "editable".
|
|
109
|
+
Schema type for JSON metadata output. Default is "editable".
|
|
213
110
|
**kwargs
|
|
214
|
-
Additional keyword arguments
|
|
215
|
-
|
|
216
|
-
Returns
|
|
217
|
-
-------
|
|
218
|
-
None
|
|
219
|
-
|
|
220
|
-
Notes
|
|
221
|
-
-----
|
|
222
|
-
Supported formats include CSV, NPY, PKL, JOBLIB, PNG, HTML, TIFF, MP4, YAML, JSON, HDF5, PTH, MAT, CBM,
|
|
223
|
-
and SciTeX bundles (.figz, .pltz, .statsz).
|
|
224
|
-
The function dynamically selects the appropriate saving mechanism based on the file extension.
|
|
225
|
-
|
|
226
|
-
Bundle Formats:
|
|
227
|
-
- .figz: Publication figure bundle (panels dict). Default: ZIP archive.
|
|
228
|
-
- .pltz: Plot bundle (matplotlib figure). Default: directory bundle.
|
|
229
|
-
- .statsz: Statistics bundle (comparisons list). Default: directory bundle.
|
|
230
|
-
- Use .d suffix (e.g., "Figure1.figz.d") to force directory format for .figz.
|
|
231
|
-
|
|
232
|
-
Examples
|
|
233
|
-
--------
|
|
234
|
-
>>> import scitex
|
|
235
|
-
>>> import numpy as np
|
|
236
|
-
>>> import pandas as pd
|
|
237
|
-
>>> import torch
|
|
238
|
-
>>> import matplotlib.pyplot as plt
|
|
239
|
-
|
|
240
|
-
>>> # Save NumPy array
|
|
241
|
-
>>> arr = np.array([1, 2, 3])
|
|
242
|
-
>>> scitex.io.save(arr, "data.npy")
|
|
243
|
-
|
|
244
|
-
>>> # Save Pandas DataFrame
|
|
245
|
-
>>> df = pd.DataFrame({"col1": [1, 2], "col2": [3, 4]})
|
|
246
|
-
>>> scitex.io.save(df, "data.csv")
|
|
247
|
-
|
|
248
|
-
>>> # Save PyTorch tensor
|
|
249
|
-
>>> tensor = torch.tensor([1, 2, 3])
|
|
250
|
-
>>> scitex.io.save(tensor, "model.pth")
|
|
251
|
-
|
|
252
|
-
>>> # Save dictionary
|
|
253
|
-
>>> data_dict = {"a": 1, "b": 2, "c": [3, 4, 5]}
|
|
254
|
-
>>> scitex.io.save(data_dict, "data.pkl")
|
|
255
|
-
|
|
256
|
-
>>> # Save matplotlib figure
|
|
257
|
-
>>> plt.figure()
|
|
258
|
-
>>> plt.plot(np.array([1, 2, 3]))
|
|
259
|
-
>>> scitex.io.save(plt, "plot.png")
|
|
260
|
-
|
|
261
|
-
>>> # Save as YAML
|
|
262
|
-
>>> scitex.io.save(data_dict, "config.yaml")
|
|
263
|
-
|
|
264
|
-
>>> # Save as JSON
|
|
265
|
-
>>> scitex.io.save(data_dict, "data.json")
|
|
111
|
+
Additional keyword arguments for the underlying save function.
|
|
266
112
|
"""
|
|
267
113
|
try:
|
|
268
|
-
# Convert Path objects to strings for consistency
|
|
269
114
|
if isinstance(specified_path, Path):
|
|
270
115
|
specified_path = str(specified_path)
|
|
271
116
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
########################################
|
|
275
|
-
#
|
|
276
|
-
# Determine saving directory from the script.
|
|
277
|
-
#
|
|
278
|
-
# When called in /path/to/script.py,
|
|
279
|
-
# data will be saved under `/path/to/script.py_out/`
|
|
280
|
-
#
|
|
281
|
-
# When called in a Jupyter notebook /path/to/notebook.ipynb,
|
|
282
|
-
# data will be saved under `/path/to/notebook_out/`
|
|
283
|
-
#
|
|
284
|
-
# When called in ipython environment,
|
|
285
|
-
# data will be saved under `/tmp/{_os.getenv("USER")/`
|
|
286
|
-
#
|
|
287
|
-
########################################
|
|
288
|
-
spath, sfname = None, None
|
|
289
|
-
|
|
290
|
-
# f-expression handling - safely parse f-strings
|
|
291
|
-
if specified_path.startswith('f"') or specified_path.startswith("f'"):
|
|
292
|
-
# Remove the f prefix and quotes
|
|
293
|
-
path_content = specified_path[2:-1]
|
|
294
|
-
|
|
295
|
-
# Get the caller's frame to access their local variables
|
|
296
|
-
frame = inspect.currentframe().f_back
|
|
297
|
-
try:
|
|
298
|
-
# Use string formatting with the caller's locals and globals
|
|
299
|
-
# This is much safer than eval() as it only does string substitution
|
|
300
|
-
import re
|
|
301
|
-
|
|
302
|
-
# Find all {variable} patterns
|
|
303
|
-
variables = re.findall(r"\{([^}]+)\}", path_content)
|
|
304
|
-
format_dict = {}
|
|
305
|
-
for var in variables:
|
|
306
|
-
# Only allow simple variable names, not arbitrary expressions
|
|
307
|
-
if re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*$", var):
|
|
308
|
-
if var in frame.f_locals:
|
|
309
|
-
format_dict[var] = frame.f_locals[var]
|
|
310
|
-
elif var in frame.f_globals:
|
|
311
|
-
format_dict[var] = frame.f_globals[var]
|
|
312
|
-
else:
|
|
313
|
-
raise ValueError(f"Invalid variable name in f-string: {var}")
|
|
314
|
-
|
|
315
|
-
# Use str.format() which is safe
|
|
316
|
-
specified_path = path_content.format(**format_dict)
|
|
317
|
-
finally:
|
|
318
|
-
del frame # Avoid reference cycles
|
|
319
|
-
|
|
320
|
-
# When full path
|
|
321
|
-
if specified_path.startswith("/"):
|
|
322
|
-
spath = specified_path
|
|
323
|
-
|
|
324
|
-
# When relative path
|
|
325
|
-
else:
|
|
326
|
-
# Import here to avoid circular imports
|
|
327
|
-
from scitex.gen._detect_environment import detect_environment
|
|
328
|
-
from scitex.gen._get_notebook_path import get_notebook_info_simple
|
|
329
|
-
|
|
330
|
-
# Detect the current environment
|
|
331
|
-
env_type = detect_environment()
|
|
332
|
-
|
|
333
|
-
if env_type == "jupyter":
|
|
334
|
-
# Special handling for Jupyter notebooks
|
|
335
|
-
notebook_name, notebook_dir = get_notebook_info_simple()
|
|
336
|
-
|
|
337
|
-
if notebook_name:
|
|
338
|
-
# Remove .ipynb extension and add _out
|
|
339
|
-
notebook_base = _os.path.splitext(notebook_name)[0]
|
|
340
|
-
sdir = _os.path.join(
|
|
341
|
-
notebook_dir or _os.getcwd(), f"{notebook_base}_out"
|
|
342
|
-
)
|
|
343
|
-
else:
|
|
344
|
-
# Fallback if we can't detect notebook name
|
|
345
|
-
sdir = _os.path.join(_os.getcwd(), "notebook_out")
|
|
346
|
-
|
|
347
|
-
spath = _os.path.join(sdir, specified_path)
|
|
348
|
-
|
|
349
|
-
elif env_type == "script":
|
|
350
|
-
# Regular script handling
|
|
351
|
-
if use_caller_path:
|
|
352
|
-
# Smart path detection: skip internal scitex library frames
|
|
353
|
-
script_path = None
|
|
354
|
-
scitex_src_path = _os.path.join(
|
|
355
|
-
_os.path.dirname(__file__), "..", ".."
|
|
356
|
-
)
|
|
357
|
-
scitex_src_path = _os.path.abspath(scitex_src_path)
|
|
358
|
-
|
|
359
|
-
# Walk through the call stack from caller to find the first non-scitex frame
|
|
360
|
-
for frame_info in inspect.stack()[1:]:
|
|
361
|
-
frame_path = _os.path.abspath(frame_info.filename)
|
|
362
|
-
# Skip frames from scitex library
|
|
363
|
-
if not frame_path.startswith(scitex_src_path):
|
|
364
|
-
script_path = frame_path
|
|
365
|
-
break
|
|
366
|
-
|
|
367
|
-
# Fallback to stack[1] if we couldn't find a non-scitex frame
|
|
368
|
-
if script_path is None:
|
|
369
|
-
script_path = inspect.stack()[1].filename
|
|
370
|
-
else:
|
|
371
|
-
script_path = inspect.stack()[1].filename
|
|
372
|
-
|
|
373
|
-
sdir = clean_path(_os.path.splitext(script_path)[0] + "_out")
|
|
374
|
-
spath = _os.path.join(sdir, specified_path)
|
|
117
|
+
# Handle f-string expressions
|
|
118
|
+
specified_path = _parse_fstring_path(specified_path)
|
|
375
119
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
script_path = inspect.stack()[1].filename
|
|
379
|
-
|
|
380
|
-
if (
|
|
381
|
-
("ipython" in script_path)
|
|
382
|
-
or ("<stdin>" in script_path)
|
|
383
|
-
or env_type in ["ipython", "interactive"]
|
|
384
|
-
):
|
|
385
|
-
script_path = f"/tmp/{_os.getenv('USER')}"
|
|
386
|
-
sdir = script_path
|
|
387
|
-
else:
|
|
388
|
-
# Unknown environment, use current directory
|
|
389
|
-
sdir = _os.path.join(_os.getcwd(), "output")
|
|
390
|
-
|
|
391
|
-
spath = _os.path.join(sdir, specified_path)
|
|
392
|
-
|
|
393
|
-
# Sanitization
|
|
120
|
+
# Determine save path
|
|
121
|
+
spath = _determine_save_path(specified_path, use_caller_path)
|
|
394
122
|
spath_final = clean(spath)
|
|
395
|
-
########################################
|
|
396
123
|
|
|
397
|
-
#
|
|
124
|
+
# Prepare symlink path from cwd
|
|
398
125
|
spath_cwd = _os.getcwd() + "/" + specified_path
|
|
399
126
|
spath_cwd = clean(spath_cwd)
|
|
400
127
|
|
|
401
|
-
#
|
|
402
|
-
|
|
403
|
-
# Also skip deletion for HDF5 files when a key is specified
|
|
404
|
-
should_skip_deletion = spath_final.endswith(".csv") or (
|
|
405
|
-
(spath_final.endswith(".hdf5") or spath_final.endswith(".h5"))
|
|
406
|
-
and "key" in kwargs
|
|
407
|
-
)
|
|
408
|
-
|
|
409
|
-
if not should_skip_deletion:
|
|
410
|
-
for path in [spath_final, spath_cwd]:
|
|
411
|
-
sh(["rm", "-f", f"{path}"], verbose=False)
|
|
128
|
+
# Remove existing files (skip for CSV/HDF5 with key)
|
|
129
|
+
_cleanup_existing_files(spath_final, spath_cwd, kwargs)
|
|
412
130
|
|
|
413
131
|
if dry_run:
|
|
414
|
-
|
|
415
|
-
try:
|
|
416
|
-
rel_path = _os.path.relpath(spath, _os.getcwd())
|
|
417
|
-
except ValueError:
|
|
418
|
-
rel_path = spath
|
|
419
|
-
|
|
420
|
-
if verbose:
|
|
421
|
-
logger.success(
|
|
422
|
-
color_text(f"(dry run) Saved to: ./{rel_path}", c="yellow")
|
|
423
|
-
)
|
|
132
|
+
_handle_dry_run(spath, verbose)
|
|
424
133
|
return
|
|
425
134
|
|
|
426
|
-
# Ensure directory exists
|
|
427
135
|
if makedirs:
|
|
428
136
|
_os.makedirs(_os.path.dirname(spath_final), exist_ok=True)
|
|
429
137
|
|
|
430
|
-
# Main
|
|
138
|
+
# Main save
|
|
431
139
|
_save(
|
|
432
140
|
obj,
|
|
433
141
|
spath_final,
|
|
@@ -447,536 +155,110 @@ def save(
|
|
|
447
155
|
_symlink(spath, spath_cwd, symlink_from_cwd, verbose)
|
|
448
156
|
_symlink_to(spath_final, symlink_to, verbose)
|
|
449
157
|
return Path(spath)
|
|
450
|
-
# return True
|
|
451
158
|
|
|
452
159
|
except AssertionError:
|
|
453
|
-
# Re-raise assertion errors - these are validation failures that should stop execution
|
|
454
160
|
raise
|
|
455
161
|
except Exception as e:
|
|
456
|
-
logger.error(
|
|
457
|
-
f"Error occurred while saving: {str(e)}\n"
|
|
458
|
-
f"Debug: Initial script_path = {inspect.stack()[1].filename}\n"
|
|
459
|
-
f"Debug: Final spath = {spath}\n"
|
|
460
|
-
f"Debug: specified_path type = {type(specified_path)}\n"
|
|
461
|
-
f"Debug: specified_path = {specified_path}"
|
|
462
|
-
)
|
|
162
|
+
logger.error(f"Error occurred while saving: {str(e)}")
|
|
463
163
|
return False
|
|
464
164
|
|
|
465
165
|
|
|
466
|
-
def
|
|
467
|
-
"""
|
|
468
|
-
if
|
|
469
|
-
|
|
470
|
-
sh(["rm", "-f", f"{spath_cwd}"], verbose=False)
|
|
471
|
-
sh(["ln", "-sfr", f"{spath}", f"{spath_cwd}"], verbose=False)
|
|
472
|
-
if verbose:
|
|
473
|
-
# Get file extension to provide more informative message
|
|
474
|
-
ext = _os.path.splitext(spath_cwd)[1].lower()
|
|
475
|
-
logger.success(color_text(f"(Symlinked to: {spath_cwd})"))
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
def _symlink_to(spath_final, symlink_to, verbose):
|
|
479
|
-
"""Create a symbolic link at the specified path pointing to the saved file."""
|
|
480
|
-
if symlink_to:
|
|
481
|
-
# Convert Path objects to strings for consistency
|
|
482
|
-
if isinstance(symlink_to, Path):
|
|
483
|
-
symlink_to = str(symlink_to)
|
|
484
|
-
|
|
485
|
-
# Clean the symlink path
|
|
486
|
-
symlink_to = clean(symlink_to)
|
|
487
|
-
|
|
488
|
-
# Ensure the symlink directory exists (only if there is a directory component)
|
|
489
|
-
symlink_dir = _os.path.dirname(symlink_to)
|
|
490
|
-
if symlink_dir: # Only create directory if there's a directory component
|
|
491
|
-
_os.makedirs(symlink_dir, exist_ok=True)
|
|
492
|
-
|
|
493
|
-
# Remove existing symlink or file
|
|
494
|
-
sh(["rm", "-f", f"{symlink_to}"], verbose=False)
|
|
166
|
+
def _parse_fstring_path(specified_path):
|
|
167
|
+
"""Parse f-string expressions in path."""
|
|
168
|
+
if not (specified_path.startswith('f"') or specified_path.startswith("f'")):
|
|
169
|
+
return specified_path
|
|
495
170
|
|
|
496
|
-
|
|
497
|
-
sh(["ln", "-sfr", f"{spath_final}", f"{symlink_to}"], verbose=False)
|
|
171
|
+
import re
|
|
498
172
|
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
173
|
+
path_content = specified_path[2:-1]
|
|
174
|
+
frame = inspect.currentframe().f_back.f_back
|
|
175
|
+
try:
|
|
176
|
+
variables = re.findall(r"\{([^}]+)\}", path_content)
|
|
177
|
+
format_dict = {}
|
|
178
|
+
for var in variables:
|
|
179
|
+
if re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*$", var):
|
|
180
|
+
if var in frame.f_locals:
|
|
181
|
+
format_dict[var] = frame.f_locals[var]
|
|
182
|
+
elif var in frame.f_globals:
|
|
183
|
+
format_dict[var] = frame.f_globals[var]
|
|
184
|
+
else:
|
|
185
|
+
raise ValueError(f"Invalid variable name in f-string: {var}")
|
|
186
|
+
return path_content.format(**format_dict)
|
|
187
|
+
finally:
|
|
188
|
+
del frame
|
|
508
189
|
|
|
509
|
-
Bundle structure v2.0 (layered - default):
|
|
510
|
-
plot.pltz.d/
|
|
511
|
-
spec.json # Semantic: WHAT to plot (canonical)
|
|
512
|
-
style.json # Appearance: HOW it looks (canonical)
|
|
513
|
-
data.csv # Raw data (immutable)
|
|
514
|
-
exports/ # PNG, SVG, hitmap
|
|
515
|
-
cache/ # geometry_px.json, render_manifest.json
|
|
516
190
|
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
plot.svg - vector export (optional)
|
|
522
|
-
plot.pdf - publication export (optional)
|
|
191
|
+
def _determine_save_path(specified_path, use_caller_path):
|
|
192
|
+
"""Determine the full save path based on environment."""
|
|
193
|
+
if specified_path.startswith("/"):
|
|
194
|
+
return specified_path
|
|
523
195
|
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
obj : matplotlib.figure.Figure
|
|
527
|
-
The figure to save.
|
|
528
|
-
spath : str or Path
|
|
529
|
-
Output path (e.g., "plot.pltz.d" or "plot.pltz").
|
|
530
|
-
as_zip : bool
|
|
531
|
-
If True, save as ZIP archive.
|
|
532
|
-
data : pandas.DataFrame, optional
|
|
533
|
-
Data to embed in the bundle as plot.csv.
|
|
534
|
-
layered : bool
|
|
535
|
-
If True (default), use new layered format (spec/style/geometry).
|
|
536
|
-
If False, use legacy single JSON format.
|
|
537
|
-
**kwargs
|
|
538
|
-
Additional arguments passed to savefig.
|
|
539
|
-
"""
|
|
540
|
-
from pathlib import Path
|
|
541
|
-
import tempfile
|
|
542
|
-
import json
|
|
543
|
-
import numpy as np
|
|
544
|
-
from ._bundle import save_bundle, BundleType
|
|
545
|
-
|
|
546
|
-
p = Path(spath)
|
|
547
|
-
|
|
548
|
-
# Extract basename from path (e.g., "myplot.pltz" -> "myplot", "myplot.pltz.d" -> "myplot")
|
|
549
|
-
basename = p.stem # e.g., "myplot.pltz" or "myplot"
|
|
550
|
-
if basename.endswith('.pltz'):
|
|
551
|
-
basename = basename[:-5] # Remove .pltz suffix
|
|
552
|
-
elif basename.endswith('.d'):
|
|
553
|
-
# Handle myplot.pltz.d -> myplot.pltz -> myplot
|
|
554
|
-
basename = Path(basename).stem
|
|
555
|
-
if basename.endswith('.pltz'):
|
|
556
|
-
basename = basename[:-5]
|
|
557
|
-
|
|
558
|
-
# Extract figure from various matplotlib object types
|
|
559
|
-
import matplotlib.figure
|
|
560
|
-
fig = obj
|
|
561
|
-
if hasattr(obj, 'figure'):
|
|
562
|
-
fig = obj.figure
|
|
563
|
-
elif hasattr(obj, 'fig'):
|
|
564
|
-
fig = obj.fig
|
|
196
|
+
from scitex.gen._detect_environment import detect_environment
|
|
197
|
+
from scitex.gen._get_notebook_path import get_notebook_info_simple
|
|
565
198
|
|
|
566
|
-
|
|
567
|
-
raise TypeError(f"Expected matplotlib Figure, got {type(obj).__name__}")
|
|
199
|
+
env_type = detect_environment()
|
|
568
200
|
|
|
569
|
-
|
|
201
|
+
if env_type == "jupyter":
|
|
202
|
+
notebook_name, notebook_dir = get_notebook_info_simple()
|
|
203
|
+
if notebook_name:
|
|
204
|
+
notebook_base = _os.path.splitext(notebook_name)[0]
|
|
205
|
+
sdir = _os.path.join(notebook_dir or _os.getcwd(), f"{notebook_base}_out")
|
|
206
|
+
else:
|
|
207
|
+
sdir = _os.path.join(_os.getcwd(), "notebook_out")
|
|
208
|
+
return _os.path.join(sdir, specified_path)
|
|
570
209
|
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
210
|
+
elif env_type == "script":
|
|
211
|
+
if use_caller_path:
|
|
212
|
+
script_path = _find_caller_script_path()
|
|
213
|
+
else:
|
|
214
|
+
script_path = inspect.stack()[2].filename
|
|
215
|
+
sdir = clean_path(_os.path.splitext(script_path)[0] + "_out")
|
|
216
|
+
return _os.path.join(sdir, specified_path)
|
|
575
217
|
|
|
576
|
-
# Determine bundle directory path
|
|
577
|
-
if as_zip:
|
|
578
|
-
# For ZIP: save to temp dir, then compress
|
|
579
|
-
temp_dir = Path(tempfile.mkdtemp())
|
|
580
|
-
bundle_dir = temp_dir / f"{basename}.pltz.d"
|
|
581
|
-
zip_path = p if not str(p).endswith('.d') else Path(str(p)[:-2])
|
|
582
218
|
else:
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
if csv_source is not None and hasattr(csv_source, 'export_as_csv'):
|
|
591
|
-
try:
|
|
592
|
-
csv_df = csv_source.export_as_csv()
|
|
593
|
-
except Exception:
|
|
594
|
-
pass
|
|
595
|
-
|
|
596
|
-
save_layered_pltz_bundle(
|
|
597
|
-
fig=fig,
|
|
598
|
-
bundle_dir=bundle_dir,
|
|
599
|
-
basename=basename,
|
|
600
|
-
dpi=dpi,
|
|
601
|
-
csv_df=csv_df,
|
|
602
|
-
)
|
|
603
|
-
|
|
604
|
-
# Compress to ZIP if requested
|
|
605
|
-
if as_zip:
|
|
606
|
-
from ._bundle import pack_bundle
|
|
607
|
-
pack_bundle(bundle_dir, zip_path)
|
|
608
|
-
shutil.rmtree(temp_dir) # Clean up temp directory
|
|
609
|
-
|
|
610
|
-
return # Done with layered format
|
|
611
|
-
|
|
612
|
-
# === Legacy format below (DEPRECATED - kept for reference) ===
|
|
613
|
-
|
|
614
|
-
# Calculate size info
|
|
615
|
-
fig_width_inch, fig_height_inch = fig.get_size_inches()
|
|
616
|
-
fig_dpi = fig.get_dpi()
|
|
617
|
-
|
|
618
|
-
# Build spec according to contract (using basename for file references)
|
|
619
|
-
spec = {
|
|
620
|
-
'schema': {'name': 'scitex.plt.plot', 'version': '1.0.0'},
|
|
621
|
-
'backend': 'mpl',
|
|
622
|
-
'data': {
|
|
623
|
-
'source': f'{basename}.csv',
|
|
624
|
-
'path': f'{basename}.csv',
|
|
625
|
-
'hash': None, # Will be computed after data extraction
|
|
626
|
-
'columns': [], # Will be populated after data extraction
|
|
627
|
-
},
|
|
628
|
-
'size': {
|
|
629
|
-
'width_inch': round(fig_width_inch, 2),
|
|
630
|
-
'height_inch': round(fig_height_inch, 2),
|
|
631
|
-
'width_mm': round(fig_width_inch * 25.4, 2),
|
|
632
|
-
'height_mm': round(fig_height_inch * 25.4, 2),
|
|
633
|
-
'width_px': int(fig_width_inch * dpi),
|
|
634
|
-
'height_px': int(fig_height_inch * dpi),
|
|
635
|
-
'dpi': dpi,
|
|
636
|
-
'crop_margin_mm': 1.0,
|
|
637
|
-
},
|
|
638
|
-
'axes': [],
|
|
639
|
-
'theme': {
|
|
640
|
-
'mode': 'light',
|
|
641
|
-
'colors': {
|
|
642
|
-
'background': 'transparent',
|
|
643
|
-
'axes_bg': 'white',
|
|
644
|
-
'text': 'black',
|
|
645
|
-
'spine': 'black',
|
|
646
|
-
'tick': 'black',
|
|
647
|
-
}
|
|
648
|
-
},
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
# Extract data from plot lines if no data provided
|
|
652
|
-
extracted_data = {}
|
|
653
|
-
|
|
654
|
-
# Extract axes metadata
|
|
655
|
-
for i, ax in enumerate(fig.axes):
|
|
656
|
-
# Get axes bounding box in figure coordinates (0-1)
|
|
657
|
-
bbox = ax.get_position()
|
|
658
|
-
|
|
659
|
-
ax_info = {
|
|
660
|
-
'xlabel': ax.get_xlabel() or None,
|
|
661
|
-
'ylabel': ax.get_ylabel() or None,
|
|
662
|
-
'title': ax.get_title() or None,
|
|
663
|
-
'xlim': [round(v, 2) for v in ax.get_xlim()],
|
|
664
|
-
'ylim': [round(v, 2) for v in ax.get_ylim()],
|
|
665
|
-
'plot_type': 'line', # Default, could be detected
|
|
666
|
-
# Bounding box in normalized figure coordinates (0-1)
|
|
667
|
-
'bbox': {
|
|
668
|
-
'x0': round(bbox.x0, 4),
|
|
669
|
-
'y0': round(bbox.y0, 4),
|
|
670
|
-
'x1': round(bbox.x1, 4),
|
|
671
|
-
'y1': round(bbox.y1, 4),
|
|
672
|
-
'width': round(bbox.width, 4),
|
|
673
|
-
'height': round(bbox.height, 4),
|
|
674
|
-
},
|
|
675
|
-
# Bounding box in mm
|
|
676
|
-
'bbox_mm': {
|
|
677
|
-
'x0': round(bbox.x0 * fig_width_inch * 25.4, 2),
|
|
678
|
-
'y0': round(bbox.y0 * fig_height_inch * 25.4, 2),
|
|
679
|
-
'x1': round(bbox.x1 * fig_width_inch * 25.4, 2),
|
|
680
|
-
'y1': round(bbox.y1 * fig_height_inch * 25.4, 2),
|
|
681
|
-
'width': round(bbox.width * fig_width_inch * 25.4, 2),
|
|
682
|
-
'height': round(bbox.height * fig_height_inch * 25.4, 2),
|
|
683
|
-
},
|
|
684
|
-
# Bounding box in pixels
|
|
685
|
-
'bbox_px': {
|
|
686
|
-
'x0': int(bbox.x0 * fig_width_inch * dpi),
|
|
687
|
-
'y0': int(bbox.y0 * fig_height_inch * dpi),
|
|
688
|
-
'x1': int(bbox.x1 * fig_width_inch * dpi),
|
|
689
|
-
'y1': int(bbox.y1 * fig_height_inch * dpi),
|
|
690
|
-
'width': int(bbox.width * fig_width_inch * dpi),
|
|
691
|
-
'height': int(bbox.height * fig_height_inch * dpi),
|
|
692
|
-
},
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
# SciTeX-specific axis dimensions
|
|
696
|
-
if hasattr(ax, '_scitex_axes_width_mm'):
|
|
697
|
-
ax_info['axes_width_mm'] = ax._scitex_axes_width_mm
|
|
219
|
+
script_path = inspect.stack()[2].filename
|
|
220
|
+
if (
|
|
221
|
+
("ipython" in script_path)
|
|
222
|
+
or ("<stdin>" in script_path)
|
|
223
|
+
or env_type in ["ipython", "interactive"]
|
|
224
|
+
):
|
|
225
|
+
sdir = f"/tmp/{_os.getenv('USER')}"
|
|
698
226
|
else:
|
|
699
|
-
|
|
227
|
+
sdir = _os.path.join(_os.getcwd(), "output")
|
|
228
|
+
return _os.path.join(sdir, specified_path)
|
|
700
229
|
|
|
701
|
-
if hasattr(ax, '_scitex_axes_height_mm'):
|
|
702
|
-
ax_info['axes_height_mm'] = ax._scitex_axes_height_mm
|
|
703
|
-
else:
|
|
704
|
-
ax_info['axes_height_mm'] = round(bbox.height * fig_height_inch * 25.4, 1)
|
|
705
|
-
|
|
706
|
-
# Extract line data for CSV and build lines array
|
|
707
|
-
lines_info = []
|
|
708
|
-
for j, line in enumerate(ax.get_lines()):
|
|
709
|
-
label = line.get_label()
|
|
710
|
-
if label is None or label.startswith('_'):
|
|
711
|
-
label = f'series_{j}'
|
|
712
|
-
xdata, ydata = line.get_data()
|
|
713
|
-
if len(xdata) > 0:
|
|
714
|
-
col_x = f'{label}_x' if i == 0 else f'ax{i}_{label}_x'
|
|
715
|
-
col_y = f'{label}_y' if i == 0 else f'ax{i}_{label}_y'
|
|
716
|
-
extracted_data[col_x] = np.array(xdata)
|
|
717
|
-
extracted_data[col_y] = np.array(ydata)
|
|
718
|
-
|
|
719
|
-
# Get line color (convert RGBA to hex)
|
|
720
|
-
color = line.get_color()
|
|
721
|
-
if isinstance(color, (list, tuple)):
|
|
722
|
-
import matplotlib.colors as mcolors
|
|
723
|
-
color = mcolors.to_hex(color)
|
|
724
|
-
|
|
725
|
-
lines_info.append({
|
|
726
|
-
'label': label,
|
|
727
|
-
'x_col': col_x,
|
|
728
|
-
'y_col': col_y,
|
|
729
|
-
'color': color,
|
|
730
|
-
'linewidth': line.get_linewidth(),
|
|
731
|
-
})
|
|
732
|
-
|
|
733
|
-
if lines_info:
|
|
734
|
-
ax_info['lines'] = lines_info
|
|
735
|
-
|
|
736
|
-
spec['axes'].append(ax_info)
|
|
737
|
-
|
|
738
|
-
# Handle theme from figure
|
|
739
|
-
if hasattr(fig, '_scitex_theme'):
|
|
740
|
-
theme_mode = fig._scitex_theme
|
|
741
|
-
spec['theme']['mode'] = theme_mode
|
|
742
|
-
# Update colors based on theme mode
|
|
743
|
-
if theme_mode == 'dark':
|
|
744
|
-
spec['theme']['colors'] = {
|
|
745
|
-
'background': 'transparent',
|
|
746
|
-
'axes_bg': 'transparent',
|
|
747
|
-
'text': '#e8e8e8',
|
|
748
|
-
'spine': '#e8e8e8',
|
|
749
|
-
'tick': '#e8e8e8',
|
|
750
|
-
}
|
|
751
|
-
# Re-apply theme colors to ensure legends and other elements get the correct colors
|
|
752
|
-
from scitex.plt.utils._figure_mm import _apply_theme_colors
|
|
753
|
-
for ax in fig.axes:
|
|
754
|
-
_apply_theme_colors(ax, theme='dark')
|
|
755
|
-
|
|
756
|
-
# Build bundle data (include basename for file naming)
|
|
757
|
-
bundle_data = {'spec': spec, 'basename': basename}
|
|
758
|
-
|
|
759
|
-
# Use provided data or extracted data for CSV
|
|
760
|
-
# Priority: 1) explicit data param, 2) export_as_csv method, 3) line extraction fallback
|
|
761
|
-
csv_df = None
|
|
762
|
-
if data is not None:
|
|
763
|
-
csv_df = data
|
|
764
|
-
bundle_data['data'] = data
|
|
765
|
-
else:
|
|
766
|
-
# Try to use export_as_csv from SciTeX wrapped objects (handles all plot types)
|
|
767
|
-
csv_source = _get_figure_with_data(obj)
|
|
768
|
-
if csv_source is not None and hasattr(csv_source, 'export_as_csv'):
|
|
769
|
-
try:
|
|
770
|
-
csv_df = csv_source.export_as_csv()
|
|
771
|
-
if csv_df is not None and not csv_df.empty:
|
|
772
|
-
bundle_data['data'] = csv_df
|
|
773
|
-
logger.debug(f"CSV data extracted via export_as_csv: {len(csv_df)} rows, {len(csv_df.columns)} cols")
|
|
774
|
-
except Exception as e:
|
|
775
|
-
logger.debug(f"export_as_csv failed: {e}")
|
|
776
|
-
csv_df = None
|
|
777
|
-
|
|
778
|
-
# Fallback to line extraction if export_as_csv didn't work
|
|
779
|
-
if csv_df is None and extracted_data:
|
|
780
|
-
try:
|
|
781
|
-
import pandas as pd
|
|
782
|
-
# Pad arrays to same length
|
|
783
|
-
max_len = max(len(v) for v in extracted_data.values())
|
|
784
|
-
padded = {}
|
|
785
|
-
for k, v in extracted_data.items():
|
|
786
|
-
if len(v) < max_len:
|
|
787
|
-
padded[k] = np.pad(v, (0, max_len - len(v)), constant_values=np.nan)
|
|
788
|
-
else:
|
|
789
|
-
padded[k] = v
|
|
790
|
-
csv_df = pd.DataFrame(padded)
|
|
791
|
-
bundle_data['data'] = csv_df
|
|
792
|
-
logger.debug(f"CSV data extracted via line fallback: {len(csv_df)} rows")
|
|
793
|
-
except ImportError:
|
|
794
|
-
pass
|
|
795
230
|
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
csv_str = csv_df.to_csv(index=False)
|
|
801
|
-
csv_hash = hashlib.sha256(csv_str.encode()).hexdigest()
|
|
802
|
-
spec['data']['hash'] = f'sha256:{csv_hash[:16]}'
|
|
803
|
-
spec['data']['columns'] = list(csv_df.columns)
|
|
804
|
-
|
|
805
|
-
# Save figure to multiple formats
|
|
806
|
-
import warnings
|
|
807
|
-
from PIL import Image as PILImage
|
|
808
|
-
from scitex.plt.utils._hitmap import (
|
|
809
|
-
apply_hitmap_colors, restore_original_colors, extract_path_data,
|
|
810
|
-
extract_selectable_regions, HITMAP_BACKGROUND_COLOR, HITMAP_AXES_COLOR
|
|
231
|
+
def _find_caller_script_path():
|
|
232
|
+
"""Find the first non-scitex frame in the call stack."""
|
|
233
|
+
scitex_src_path = _os.path.abspath(
|
|
234
|
+
_os.path.join(_os.path.dirname(__file__), "..", "..")
|
|
811
235
|
)
|
|
236
|
+
for frame_info in inspect.stack()[3:]:
|
|
237
|
+
frame_path = _os.path.abspath(frame_info.filename)
|
|
238
|
+
if not frame_path.startswith(scitex_src_path):
|
|
239
|
+
return frame_path
|
|
240
|
+
return inspect.stack()[2].filename
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _cleanup_existing_files(spath_final, spath_cwd, kwargs):
|
|
244
|
+
"""Remove existing files to prevent circular links."""
|
|
245
|
+
should_skip = spath_final.endswith(".csv") or (
|
|
246
|
+
(spath_final.endswith(".hdf5") or spath_final.endswith(".h5"))
|
|
247
|
+
and "key" in kwargs
|
|
248
|
+
)
|
|
249
|
+
if not should_skip:
|
|
250
|
+
for path in [spath_final, spath_cwd]:
|
|
251
|
+
sh(["rm", "-f", f"{path}"], verbose=False)
|
|
812
252
|
|
|
813
|
-
crop_box = None
|
|
814
|
-
color_map = {}
|
|
815
|
-
|
|
816
|
-
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
817
|
-
tmp_path = Path(tmp_dir)
|
|
818
|
-
|
|
819
|
-
# Suppress tight_layout warnings for SciTeX figures with custom axes
|
|
820
|
-
with warnings.catch_warnings():
|
|
821
|
-
warnings.filterwarnings('ignore', message='.*tight_layout.*')
|
|
822
|
-
|
|
823
|
-
# Always use transparent background for SciTeX figures (both light and dark themes)
|
|
824
|
-
use_transparent = True
|
|
825
|
-
|
|
826
|
-
# Save PNG (raster) - required
|
|
827
|
-
png_path = tmp_path / "plot.png"
|
|
828
|
-
fig.savefig(png_path, dpi=dpi, bbox_inches='tight', format='png', transparent=use_transparent)
|
|
829
|
-
|
|
830
|
-
# Save SVG (vector) - optional
|
|
831
|
-
svg_path = tmp_path / "plot.svg"
|
|
832
|
-
fig.savefig(svg_path, bbox_inches='tight', format='svg')
|
|
833
|
-
|
|
834
|
-
# Save PDF (vector) - optional
|
|
835
|
-
pdf_path = tmp_path / "plot.pdf"
|
|
836
|
-
fig.savefig(pdf_path, bbox_inches='tight', format='pdf')
|
|
837
|
-
|
|
838
|
-
# Now generate hitmap by applying ID colors to data elements ONLY
|
|
839
|
-
# Keep axes/spines/labels with original colors to preserve bbox_inches='tight' bounds
|
|
840
|
-
# Also detects logical groups (histogram, bar_series, etc.)
|
|
841
|
-
original_props, color_map, groups = apply_hitmap_colors(fig)
|
|
842
|
-
|
|
843
|
-
# Store original background colors and set hitmap colors
|
|
844
|
-
original_fig_facecolor = fig.patch.get_facecolor()
|
|
845
|
-
original_ax_facecolors = []
|
|
846
|
-
original_ax_props = []
|
|
847
|
-
for ax in fig.axes:
|
|
848
|
-
original_ax_facecolors.append(ax.get_facecolor())
|
|
849
|
-
# Store axis element colors for restoration
|
|
850
|
-
ax_props = {
|
|
851
|
-
'ax': ax,
|
|
852
|
-
'spine_colors': {k: v.get_edgecolor() for k, v in ax.spines.items()},
|
|
853
|
-
'tick_colors': ax.tick_params, # Will restore later
|
|
854
|
-
'xlabel_color': ax.xaxis.label.get_color(),
|
|
855
|
-
'ylabel_color': ax.yaxis.label.get_color(),
|
|
856
|
-
'title_color': ax.title.get_color(),
|
|
857
|
-
}
|
|
858
|
-
original_ax_props.append(ax_props)
|
|
859
|
-
# Set hitmap colors for non-data elements
|
|
860
|
-
ax.set_facecolor(HITMAP_BACKGROUND_COLOR)
|
|
861
|
-
for spine in ax.spines.values():
|
|
862
|
-
spine.set_color(HITMAP_AXES_COLOR)
|
|
863
|
-
ax.tick_params(colors=HITMAP_AXES_COLOR, labelcolor=HITMAP_AXES_COLOR)
|
|
864
|
-
ax.xaxis.label.set_color(HITMAP_AXES_COLOR)
|
|
865
|
-
ax.yaxis.label.set_color(HITMAP_AXES_COLOR)
|
|
866
|
-
ax.title.set_color(HITMAP_AXES_COLOR)
|
|
867
|
-
|
|
868
|
-
fig.patch.set_facecolor(HITMAP_BACKGROUND_COLOR)
|
|
869
|
-
|
|
870
|
-
# Save hitmap PNG with same bbox_inches='tight'
|
|
871
|
-
hitmap_path = tmp_path / "plot_hitmap.png"
|
|
872
|
-
fig.savefig(hitmap_path, dpi=dpi, bbox_inches='tight', format='png', facecolor=HITMAP_BACKGROUND_COLOR)
|
|
873
|
-
|
|
874
|
-
# Optimize hitmap PNG size using zlib compression
|
|
875
|
-
try:
|
|
876
|
-
hitmap_img = PILImage.open(hitmap_path).convert('RGB')
|
|
877
|
-
hitmap_img.save(hitmap_path, format='PNG', optimize=True, compress_level=9)
|
|
878
|
-
except Exception:
|
|
879
|
-
pass # Keep original if optimization fails
|
|
880
|
-
|
|
881
|
-
# Save hitmap SVG with same bbox_inches='tight'
|
|
882
|
-
hitmap_svg_path = tmp_path / "plot_hitmap.svg"
|
|
883
|
-
fig.savefig(hitmap_svg_path, bbox_inches='tight', format='svg')
|
|
884
|
-
|
|
885
|
-
# Restore original colors (data elements)
|
|
886
|
-
restore_original_colors(original_props)
|
|
887
|
-
|
|
888
|
-
# Restore original figure and axes colors
|
|
889
|
-
fig.patch.set_facecolor(original_fig_facecolor)
|
|
890
|
-
for i, ax in enumerate(fig.axes):
|
|
891
|
-
ax.set_facecolor(original_ax_facecolors[i])
|
|
892
|
-
if i < len(original_ax_props):
|
|
893
|
-
props = original_ax_props[i]
|
|
894
|
-
for spine_name, color in props['spine_colors'].items():
|
|
895
|
-
ax.spines[spine_name].set_edgecolor(color)
|
|
896
|
-
ax.xaxis.label.set_color(props['xlabel_color'])
|
|
897
|
-
ax.yaxis.label.set_color(props['ylabel_color'])
|
|
898
|
-
ax.title.set_color(props['title_color'])
|
|
899
|
-
|
|
900
|
-
# Now apply auto-crop to BOTH PNG and hitmap with same parameters
|
|
901
|
-
try:
|
|
902
|
-
from scitex.plt.utils._crop import crop
|
|
903
|
-
|
|
904
|
-
# Crop PNG and get crop coordinates
|
|
905
|
-
_, crop_offset = crop(
|
|
906
|
-
str(png_path),
|
|
907
|
-
output_path=str(png_path),
|
|
908
|
-
overwrite=True,
|
|
909
|
-
margin=12, # ~1mm at 300 DPI
|
|
910
|
-
verbose=False,
|
|
911
|
-
return_offset=True,
|
|
912
|
-
)
|
|
913
|
-
crop_box = (crop_offset['left'], crop_offset['upper'],
|
|
914
|
-
crop_offset['right'], crop_offset['lower'])
|
|
915
|
-
|
|
916
|
-
# Apply SAME crop to hitmap PNG
|
|
917
|
-
crop(
|
|
918
|
-
str(hitmap_path),
|
|
919
|
-
output_path=str(hitmap_path),
|
|
920
|
-
overwrite=True,
|
|
921
|
-
crop_box=crop_box,
|
|
922
|
-
verbose=False,
|
|
923
|
-
)
|
|
924
|
-
except Exception as e:
|
|
925
|
-
crop_box = None
|
|
926
|
-
logger.debug(f"Crop failed: {e}")
|
|
927
|
-
|
|
928
|
-
# Validate sizes match
|
|
929
|
-
with PILImage.open(png_path) as png_img, PILImage.open(hitmap_path) as hm_img:
|
|
930
|
-
if png_img.size != hm_img.size:
|
|
931
|
-
logger.warning(f"Size mismatch: PNG={png_img.size}, Hitmap={hm_img.size}")
|
|
932
|
-
|
|
933
|
-
with open(png_path, 'rb') as f:
|
|
934
|
-
bundle_data['png'] = f.read()
|
|
935
|
-
|
|
936
|
-
with open(hitmap_path, 'rb') as f:
|
|
937
|
-
bundle_data['hitmap_png'] = f.read()
|
|
938
|
-
|
|
939
|
-
with open(svg_path, 'rb') as f:
|
|
940
|
-
bundle_data['svg'] = f.read()
|
|
941
|
-
|
|
942
|
-
with open(hitmap_svg_path, 'rb') as f:
|
|
943
|
-
bundle_data['hitmap_svg'] = f.read()
|
|
944
|
-
|
|
945
|
-
with open(pdf_path, 'rb') as f:
|
|
946
|
-
bundle_data['pdf'] = f.read()
|
|
947
|
-
|
|
948
|
-
# Add hit_regions to spec
|
|
949
|
-
try:
|
|
950
|
-
path_data = extract_path_data(fig)
|
|
951
|
-
|
|
952
|
-
spec['hit_regions'] = {
|
|
953
|
-
'strategy': 'hybrid',
|
|
954
|
-
'hit_map': f'{basename}_hitmap.png',
|
|
955
|
-
'hit_map_svg': f'{basename}_hitmap.svg',
|
|
956
|
-
'color_map': {str(k): v for k, v in color_map.items()},
|
|
957
|
-
'groups': groups, # Logical groups (histogram, bar_series, etc.)
|
|
958
|
-
'path_data': path_data,
|
|
959
|
-
}
|
|
960
|
-
|
|
961
|
-
if crop_box is not None:
|
|
962
|
-
spec['hit_regions']['crop_box'] = {
|
|
963
|
-
'left': int(crop_box[0]),
|
|
964
|
-
'upper': int(crop_box[1]),
|
|
965
|
-
'right': int(crop_box[2]),
|
|
966
|
-
'lower': int(crop_box[3]),
|
|
967
|
-
}
|
|
968
|
-
|
|
969
|
-
# Extract selectable regions (bounding boxes for axis/annotation elements)
|
|
970
|
-
# This complements hitmap color-based selection with bbox-based selection
|
|
971
|
-
selectable_regions = extract_selectable_regions(fig)
|
|
972
|
-
if selectable_regions and selectable_regions.get('axes'):
|
|
973
|
-
spec['selectable_regions'] = selectable_regions
|
|
974
|
-
|
|
975
|
-
except Exception as e:
|
|
976
|
-
logger.debug(f"Hit regions spec failed: {e}")
|
|
977
253
|
|
|
978
|
-
|
|
979
|
-
|
|
254
|
+
def _handle_dry_run(spath, verbose):
|
|
255
|
+
"""Handle dry run mode."""
|
|
256
|
+
if verbose:
|
|
257
|
+
try:
|
|
258
|
+
rel_path = _os.path.relpath(spath, _os.getcwd())
|
|
259
|
+
except ValueError:
|
|
260
|
+
rel_path = spath
|
|
261
|
+
logger.success(color_text(f"(dry run) Saved to: ./{rel_path}", c="yellow"))
|
|
980
262
|
|
|
981
263
|
|
|
982
264
|
def _save(
|
|
@@ -993,741 +275,205 @@ def _save(
|
|
|
993
275
|
json_schema="editable",
|
|
994
276
|
**kwargs,
|
|
995
277
|
):
|
|
996
|
-
|
|
997
|
-
# This ensures all saves go through the same pipeline and get
|
|
998
|
-
# the yellow confirmation message
|
|
999
|
-
|
|
1000
|
-
# Get file extension
|
|
278
|
+
"""Core dispatcher for saving objects to various formats."""
|
|
1001
279
|
ext = _os.path.splitext(spath)[1].lower()
|
|
1002
280
|
|
|
1003
|
-
#
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
as_zip = kwargs.get('as_zip', not spath.endswith(".d"))
|
|
1017
|
-
if bext == ".figz":
|
|
1018
|
-
import scitex.fig as sfig
|
|
1019
|
-
# figz defaults to ZIP, so always pass as_zip explicitly
|
|
1020
|
-
sfig.save_figz(obj, spath, as_zip=as_zip, **bundle_kwargs)
|
|
1021
|
-
elif bext == ".pltz":
|
|
1022
|
-
_save_pltz_bundle(obj, spath, as_zip=as_zip, **bundle_kwargs)
|
|
1023
|
-
elif bext == ".statsz":
|
|
1024
|
-
import scitex.stats as sstats
|
|
1025
|
-
sstats.save_statsz(obj, spath, as_zip=as_zip, **bundle_kwargs)
|
|
1026
|
-
|
|
1027
|
-
# Log "Saved to:" for bundle formats (consistent with other formats)
|
|
1028
|
-
# For bundles, determine the actual saved path (zip or directory)
|
|
1029
|
-
bundle_path = spath if as_zip else f"{spath}.d" if not spath.endswith(".d") else spath
|
|
1030
|
-
|
|
1031
|
-
if verbose and _os.path.exists(bundle_path):
|
|
1032
|
-
file_size = getsize(bundle_path)
|
|
1033
|
-
file_size = readable_bytes(file_size)
|
|
1034
|
-
try:
|
|
1035
|
-
rel_path = _os.path.relpath(bundle_path, _os.getcwd())
|
|
1036
|
-
except ValueError:
|
|
1037
|
-
rel_path = bundle_path
|
|
1038
|
-
logger.success(f"Saved to: ./{rel_path} ({file_size})")
|
|
1039
|
-
|
|
1040
|
-
# Handle symlinks for bundle formats (consistent with other formats)
|
|
1041
|
-
if symlink_from_cwd and _os.path.exists(bundle_path):
|
|
1042
|
-
# Create symlink from cwd to bundle path
|
|
1043
|
-
bundle_basename = _os.path.basename(bundle_path)
|
|
1044
|
-
bundle_cwd = _os.path.join(_os.getcwd(), bundle_basename)
|
|
1045
|
-
_symlink(bundle_path, bundle_cwd, symlink_from_cwd, verbose)
|
|
1046
|
-
|
|
1047
|
-
if symlink_to and _os.path.exists(bundle_path):
|
|
1048
|
-
_symlink_to(bundle_path, symlink_to, verbose)
|
|
1049
|
-
|
|
281
|
+
# Check if this is a matplotlib figure being saved to FTS bundle format
|
|
282
|
+
# FTS bundles use .zip (archive) or no extension (directory)
|
|
283
|
+
if _is_matplotlib_figure(obj):
|
|
284
|
+
# Save as FTS bundle if:
|
|
285
|
+
# 1. Path ends with .zip (create ZIP bundle)
|
|
286
|
+
# 2. Path has no extension and doesn't match other formats (create directory bundle)
|
|
287
|
+
if ext == ".zip" or (ext == "" and not spath.endswith("/")):
|
|
288
|
+
# Check if explicitly requesting FTS bundle or just .zip
|
|
289
|
+
# Pop as_zip from kwargs to avoid duplicate parameter error
|
|
290
|
+
as_zip = kwargs.pop("as_zip", ext == ".zip")
|
|
291
|
+
_save_fts_bundle(
|
|
292
|
+
obj, spath, as_zip, verbose, symlink_from_cwd, symlink_to, **kwargs
|
|
293
|
+
)
|
|
1050
294
|
return
|
|
1051
295
|
|
|
1052
|
-
#
|
|
296
|
+
# Dispatch to format handlers
|
|
1053
297
|
if ext in _FILE_HANDLERS:
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
no_csv=no_csv,
|
|
1070
|
-
symlink_from_cwd=symlink_from_cwd,
|
|
1071
|
-
symlink_to=symlink_to,
|
|
1072
|
-
dry_run=dry_run,
|
|
1073
|
-
auto_crop=auto_crop,
|
|
1074
|
-
crop_margin_mm=crop_margin_mm,
|
|
1075
|
-
metadata_extra=metadata_extra,
|
|
1076
|
-
json_schema=json_schema,
|
|
1077
|
-
**kwargs,
|
|
1078
|
-
)
|
|
1079
|
-
elif ext in [".hdf5", ".h5", ".zarr"]:
|
|
1080
|
-
# HDF5 and Zarr files may need special 'key' parameter
|
|
1081
|
-
_FILE_HANDLERS[ext](obj, spath, **kwargs)
|
|
1082
|
-
else:
|
|
1083
|
-
_FILE_HANDLERS[ext](obj, spath, **kwargs)
|
|
1084
|
-
# csv - special case as it doesn't have a dot prefix in dispatch
|
|
298
|
+
_dispatch_handler(
|
|
299
|
+
ext,
|
|
300
|
+
obj,
|
|
301
|
+
spath,
|
|
302
|
+
verbose,
|
|
303
|
+
no_csv,
|
|
304
|
+
symlink_from_cwd,
|
|
305
|
+
symlink_to,
|
|
306
|
+
dry_run,
|
|
307
|
+
auto_crop,
|
|
308
|
+
crop_margin_mm,
|
|
309
|
+
metadata_extra,
|
|
310
|
+
json_schema,
|
|
311
|
+
kwargs,
|
|
312
|
+
)
|
|
1085
313
|
elif spath.endswith(".csv"):
|
|
1086
314
|
save_csv(obj, spath, **kwargs)
|
|
1087
|
-
# Check for special extension cases not in dispatch
|
|
1088
315
|
elif spath.endswith(".pkl.gz"):
|
|
1089
316
|
save_pickle_compressed(obj, spath, **kwargs)
|
|
1090
317
|
else:
|
|
1091
318
|
logger.warning(f"Unsupported file format. {spath} was not saved.")
|
|
1092
|
-
|
|
1093
|
-
if verbose:
|
|
1094
|
-
if _os.path.exists(spath):
|
|
1095
|
-
file_size = getsize(spath)
|
|
1096
|
-
file_size = readable_bytes(file_size)
|
|
1097
|
-
# Get relative path from current working directory
|
|
1098
|
-
try:
|
|
1099
|
-
rel_path = _os.path.relpath(spath, _os.getcwd())
|
|
1100
|
-
except ValueError:
|
|
1101
|
-
rel_path = spath
|
|
1102
|
-
|
|
1103
|
-
logger.success(f"Saved to: ./{rel_path} ({file_size})")
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
def _save_separate_legends(obj, spath, symlink_from_cwd=False, dry_run=False, **kwargs):
|
|
1107
|
-
"""Save separate legend files if ax.legend('separate') was used."""
|
|
1108
|
-
if dry_run:
|
|
1109
|
-
return
|
|
1110
|
-
|
|
1111
|
-
import matplotlib.figure
|
|
1112
|
-
import matplotlib.pyplot as plt
|
|
1113
|
-
|
|
1114
|
-
# Get the matplotlib figure object
|
|
1115
|
-
fig = None
|
|
1116
|
-
if isinstance(obj, matplotlib.figure.Figure):
|
|
1117
|
-
fig = obj
|
|
1118
|
-
elif hasattr(obj, "_fig_mpl"):
|
|
1119
|
-
fig = obj._fig_mpl
|
|
1120
|
-
elif hasattr(obj, "figure"):
|
|
1121
|
-
if isinstance(obj.figure, matplotlib.figure.Figure):
|
|
1122
|
-
fig = obj.figure
|
|
1123
|
-
elif hasattr(obj.figure, "_fig_mpl"):
|
|
1124
|
-
fig = obj.figure._fig_mpl
|
|
1125
|
-
|
|
1126
|
-
if fig is None:
|
|
1127
319
|
return
|
|
1128
320
|
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
321
|
+
if verbose and _os.path.exists(spath):
|
|
322
|
+
file_size = readable_bytes(getsize(spath))
|
|
323
|
+
try:
|
|
324
|
+
rel_path = _os.path.relpath(spath, _os.getcwd())
|
|
325
|
+
except ValueError:
|
|
326
|
+
rel_path = spath
|
|
327
|
+
logger.success(f"Saved to: ./{rel_path} ({file_size})")
|
|
1132
328
|
|
|
1133
|
-
# Save each legend as a separate file
|
|
1134
|
-
base_path = _os.path.splitext(spath)[0]
|
|
1135
|
-
ext = _os.path.splitext(spath)[1]
|
|
1136
|
-
|
|
1137
|
-
for legend_params in fig._separate_legend_params:
|
|
1138
|
-
# Create a new figure for the legend
|
|
1139
|
-
legend_fig = plt.figure(figsize=legend_params["figsize"])
|
|
1140
|
-
legend_ax = legend_fig.add_subplot(111)
|
|
1141
|
-
|
|
1142
|
-
# Create the legend
|
|
1143
|
-
legend = legend_ax.legend(
|
|
1144
|
-
legend_params["handles"],
|
|
1145
|
-
legend_params["labels"],
|
|
1146
|
-
loc="center",
|
|
1147
|
-
frameon=legend_params["frameon"],
|
|
1148
|
-
fancybox=legend_params["fancybox"],
|
|
1149
|
-
shadow=legend_params["shadow"],
|
|
1150
|
-
**legend_params["kwargs"],
|
|
1151
|
-
)
|
|
1152
329
|
|
|
1153
|
-
|
|
1154
|
-
|
|
330
|
+
def _is_matplotlib_figure(obj):
|
|
331
|
+
"""Check if object is a matplotlib figure or a wrapped figure.
|
|
1155
332
|
|
|
1156
|
-
|
|
1157
|
-
|
|
333
|
+
Handles both raw matplotlib.figure.Figure and SciTeX FigWrapper objects.
|
|
334
|
+
"""
|
|
335
|
+
try:
|
|
336
|
+
import matplotlib.figure
|
|
1158
337
|
|
|
1159
|
-
#
|
|
1160
|
-
|
|
1161
|
-
|
|
338
|
+
# Direct matplotlib figure
|
|
339
|
+
if isinstance(obj, matplotlib.figure.Figure):
|
|
340
|
+
return True
|
|
1162
341
|
|
|
1163
|
-
#
|
|
1164
|
-
|
|
342
|
+
# Wrapped figure (e.g., FigWrapper from scitex.plt)
|
|
343
|
+
if hasattr(obj, "figure") and isinstance(obj.figure, matplotlib.figure.Figure):
|
|
344
|
+
return True
|
|
1165
345
|
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
print(
|
|
1170
|
-
color_text(
|
|
1171
|
-
f"\nSaved legend to: {legend_filename} ({file_size})",
|
|
1172
|
-
c="yellow",
|
|
1173
|
-
)
|
|
1174
|
-
)
|
|
346
|
+
return False
|
|
347
|
+
except ImportError:
|
|
348
|
+
return False
|
|
1175
349
|
|
|
1176
350
|
|
|
1177
|
-
def
|
|
1178
|
-
obj,
|
|
1179
|
-
spath,
|
|
1180
|
-
verbose=False,
|
|
1181
|
-
no_csv=False,
|
|
1182
|
-
symlink_from_cwd=False,
|
|
1183
|
-
dry_run=False,
|
|
1184
|
-
symlink_to=None,
|
|
1185
|
-
auto_crop=True,
|
|
1186
|
-
crop_margin_mm=1.0,
|
|
1187
|
-
metadata_extra=None,
|
|
1188
|
-
json_schema="editable",
|
|
1189
|
-
**kwargs,
|
|
351
|
+
def _save_fts_bundle(
|
|
352
|
+
obj, spath, as_zip, verbose, symlink_from_cwd, symlink_to_path, **kwargs
|
|
1190
353
|
):
|
|
1191
|
-
"""
|
|
1192
|
-
if dry_run:
|
|
1193
|
-
return
|
|
354
|
+
"""Save matplotlib figure as FTS bundle (.zip or directory).
|
|
1194
355
|
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
if "metadata" not in kwargs or kwargs["metadata"] is None:
|
|
1198
|
-
try:
|
|
1199
|
-
# Check if this is a matplotlib figure or scitex wrapper
|
|
1200
|
-
import matplotlib.figure
|
|
1201
|
-
|
|
1202
|
-
fig_mpl = None
|
|
1203
|
-
if isinstance(obj, matplotlib.figure.Figure):
|
|
1204
|
-
fig_mpl = obj
|
|
1205
|
-
elif hasattr(obj, "_fig_mpl"): # FigWrapper
|
|
1206
|
-
fig_mpl = obj._fig_mpl
|
|
1207
|
-
elif hasattr(obj, "figure") and isinstance(
|
|
1208
|
-
obj.figure, matplotlib.figure.Figure
|
|
1209
|
-
):
|
|
1210
|
-
fig_mpl = obj.figure
|
|
1211
|
-
|
|
1212
|
-
# If we have a figure, try to collect metadata
|
|
1213
|
-
if fig_mpl is not None:
|
|
1214
|
-
# Get axes from scitex wrapper if available (for multi-axes support)
|
|
1215
|
-
# Priority: FigWrapper.axes (AxesWrapper) > mpl axes with _scitex_wrapper > mpl axes
|
|
1216
|
-
ax = None
|
|
1217
|
-
|
|
1218
|
-
# First try to get AxesWrapper from FigWrapper (obj)
|
|
1219
|
-
if hasattr(obj, "axes"):
|
|
1220
|
-
# obj is FigWrapper, get its axes (could be AxisWrapper or AxesWrapper)
|
|
1221
|
-
ax = obj.axes
|
|
1222
|
-
elif hasattr(fig_mpl, "axes") and len(fig_mpl.axes) > 0:
|
|
1223
|
-
mpl_ax = fig_mpl.axes[0]
|
|
1224
|
-
# Try to get scitex wrapper which has history for recipe schema
|
|
1225
|
-
if hasattr(mpl_ax, '_scitex_wrapper'):
|
|
1226
|
-
ax = mpl_ax._scitex_wrapper
|
|
1227
|
-
else:
|
|
1228
|
-
ax = mpl_ax
|
|
1229
|
-
|
|
1230
|
-
# Collect metadata using scitex's metadata collector
|
|
1231
|
-
try:
|
|
1232
|
-
if json_schema == "editable":
|
|
1233
|
-
from scitex.plt.utils.metadata import export_editable_figure
|
|
1234
|
-
auto_metadata = export_editable_figure(fig_mpl)
|
|
1235
|
-
elif json_schema == "recipe":
|
|
1236
|
-
from scitex.plt.utils import collect_recipe_metadata
|
|
1237
|
-
auto_metadata = collect_recipe_metadata(
|
|
1238
|
-
fig_mpl, ax,
|
|
1239
|
-
auto_crop=auto_crop,
|
|
1240
|
-
crop_margin_mm=crop_margin_mm,
|
|
1241
|
-
)
|
|
1242
|
-
else:
|
|
1243
|
-
from scitex.plt.utils import collect_figure_metadata
|
|
1244
|
-
auto_metadata = collect_figure_metadata(fig_mpl, ax)
|
|
1245
|
-
|
|
1246
|
-
if auto_metadata:
|
|
1247
|
-
kwargs["metadata"] = auto_metadata
|
|
1248
|
-
collected_metadata = auto_metadata # Save for JSON export
|
|
1249
|
-
if verbose:
|
|
1250
|
-
schema_names = {"editable": "editable v0.3", "recipe": "recipe", "verbose": "verbose"}
|
|
1251
|
-
schema_name = schema_names.get(json_schema, json_schema)
|
|
1252
|
-
logger.info(f" • Auto-collected metadata ({schema_name} schema)")
|
|
1253
|
-
except ImportError:
|
|
1254
|
-
pass # collect_figure_metadata not available
|
|
1255
|
-
except Exception as e:
|
|
1256
|
-
if verbose:
|
|
1257
|
-
logger.warning(f"Could not auto-collect metadata: {e}")
|
|
1258
|
-
except Exception:
|
|
1259
|
-
pass # Silently continue if auto-collection fails
|
|
1260
|
-
else:
|
|
1261
|
-
# Use explicitly provided metadata
|
|
1262
|
-
collected_metadata = kwargs.get("metadata")
|
|
356
|
+
Delegates to scitex.fts.from_matplotlib as the single source of truth
|
|
357
|
+
for bundle structure (canonical/artifacts/payload/children).
|
|
1263
358
|
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
collected_metadata = copy.deepcopy(collected_metadata)
|
|
359
|
+
When figrecipe is available and enabled on the figure, also saves
|
|
360
|
+
recipe.yaml for reproducibility.
|
|
361
|
+
"""
|
|
362
|
+
# Get the actual matplotlib figure
|
|
363
|
+
import matplotlib.figure
|
|
1270
364
|
|
|
1271
|
-
|
|
1272
|
-
if "plot_type" in metadata_extra:
|
|
1273
|
-
collected_metadata["plot_type"] = metadata_extra["plot_type"]
|
|
365
|
+
from scitex.fts import from_matplotlib
|
|
1274
366
|
|
|
1275
|
-
|
|
1276
|
-
if "style" in metadata_extra:
|
|
1277
|
-
collected_metadata["style"] = metadata_extra["style"]
|
|
367
|
+
from ._save_modules._figure_utils import get_figure_with_data
|
|
1278
368
|
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
369
|
+
if isinstance(obj, matplotlib.figure.Figure):
|
|
370
|
+
fig = obj
|
|
371
|
+
fig_wrapper = None
|
|
372
|
+
elif hasattr(obj, "figure") and isinstance(obj.figure, matplotlib.figure.Figure):
|
|
373
|
+
fig = obj.figure
|
|
374
|
+
fig_wrapper = obj # Keep wrapper for figrecipe access
|
|
375
|
+
else:
|
|
376
|
+
raise TypeError(f"Expected matplotlib figure, got {type(obj)}")
|
|
377
|
+
|
|
378
|
+
# Extract optional parameters
|
|
379
|
+
# Support both "csv_df" and "data" parameter names for user convenience
|
|
380
|
+
csv_df = kwargs.get("csv_df") or kwargs.get("data")
|
|
381
|
+
dpi = kwargs.get("dpi", 300)
|
|
382
|
+
name = kwargs.get("name") or Path(spath).stem
|
|
383
|
+
|
|
384
|
+
# Extract CSV data from scitex.plt tracking if available
|
|
385
|
+
scitex_source = get_figure_with_data(obj)
|
|
386
|
+
if csv_df is None and scitex_source is not None:
|
|
387
|
+
if hasattr(scitex_source, "export_as_csv"):
|
|
388
|
+
try:
|
|
389
|
+
csv_df = scitex_source.export_as_csv()
|
|
390
|
+
except Exception:
|
|
391
|
+
pass
|
|
1283
392
|
|
|
1284
|
-
|
|
1285
|
-
|
|
393
|
+
# Delegate to FTS (single source of truth)
|
|
394
|
+
# Encoding is built from CSV columns directly for consistency
|
|
395
|
+
from_matplotlib(fig, spath, name=name, csv_df=csv_df, dpi=dpi)
|
|
1286
396
|
|
|
1287
|
-
|
|
397
|
+
# Save figrecipe recipe.yaml if available
|
|
398
|
+
try:
|
|
399
|
+
from scitex.bridge._figrecipe import _save_recipe_to_path
|
|
1288
400
|
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
401
|
+
bundle_path = Path(spath)
|
|
402
|
+
if bundle_path.suffix != ".zip": # Skip zip for now
|
|
403
|
+
_save_recipe_to_path(fig_wrapper or obj, bundle_path / "recipe.yaml")
|
|
404
|
+
except (ImportError, Exception):
|
|
405
|
+
pass # figrecipe is optional
|
|
1294
406
|
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
dpi = kwargs.get("dpi", 300)
|
|
1304
|
-
margin_px = int(crop_margin_mm * dpi / 25.4) # 25.4mm per inch
|
|
1305
|
-
|
|
1306
|
-
# Crop the saved image in place, get crop offset for metadata adjustment
|
|
1307
|
-
_, crop_offset = crop(
|
|
1308
|
-
spath,
|
|
1309
|
-
output_path=spath,
|
|
1310
|
-
margin=margin_px,
|
|
1311
|
-
overwrite=True,
|
|
1312
|
-
verbose=False,
|
|
1313
|
-
return_offset=True,
|
|
1314
|
-
)
|
|
1315
|
-
|
|
1316
|
-
# Adjust axes_bbox_px in metadata to account for crop offset
|
|
1317
|
-
if crop_offset and collected_metadata:
|
|
1318
|
-
if "axes_bbox_px" in collected_metadata:
|
|
1319
|
-
bbox = collected_metadata["axes_bbox_px"]
|
|
1320
|
-
# Subtract crop offset from all coordinates
|
|
1321
|
-
# left/upper is where the crop started
|
|
1322
|
-
left_offset = crop_offset["left"]
|
|
1323
|
-
upper_offset = crop_offset["upper"]
|
|
1324
|
-
bbox["x0"] = bbox.get("x0", 0) - left_offset
|
|
1325
|
-
bbox["x1"] = bbox.get("x1", 0) - left_offset
|
|
1326
|
-
bbox["y0"] = bbox.get("y0", 0) - upper_offset
|
|
1327
|
-
bbox["y1"] = bbox.get("y1", 0) - upper_offset
|
|
1328
|
-
# Update width/height to match new image size
|
|
1329
|
-
# (bbox width/height shouldn't change, but figure size does)
|
|
1330
|
-
|
|
1331
|
-
# Also update figure size in metadata
|
|
1332
|
-
if "figure" in collected_metadata:
|
|
1333
|
-
fig_meta = collected_metadata["figure"]
|
|
1334
|
-
if "size_px" in fig_meta:
|
|
1335
|
-
fig_meta["size_px"] = [
|
|
1336
|
-
crop_offset["new_width"],
|
|
1337
|
-
crop_offset["new_height"],
|
|
1338
|
-
]
|
|
1339
|
-
if "dimensions" in collected_metadata:
|
|
1340
|
-
dim_meta = collected_metadata["dimensions"]
|
|
1341
|
-
if "figure_size_px" in dim_meta:
|
|
1342
|
-
dim_meta["figure_size_px"] = [
|
|
1343
|
-
crop_offset["new_width"],
|
|
1344
|
-
crop_offset["new_height"],
|
|
1345
|
-
]
|
|
1346
|
-
|
|
1347
|
-
if verbose:
|
|
1348
|
-
logger.info(
|
|
1349
|
-
f" • Auto-cropped with {crop_margin_mm}mm margin ({margin_px}px at {dpi} DPI)"
|
|
1350
|
-
)
|
|
1351
|
-
|
|
1352
|
-
except Exception as e:
|
|
1353
|
-
logger.warning(f"Auto-crop failed: {e}. Image saved without cropping.")
|
|
1354
|
-
|
|
1355
|
-
# Handle separate legend saving
|
|
1356
|
-
_save_separate_legends(
|
|
1357
|
-
obj,
|
|
1358
|
-
spath,
|
|
1359
|
-
symlink_from_cwd=symlink_from_cwd,
|
|
1360
|
-
dry_run=dry_run,
|
|
1361
|
-
**kwargs,
|
|
1362
|
-
)
|
|
407
|
+
bundle_path = spath
|
|
408
|
+
if verbose and _os.path.exists(bundle_path):
|
|
409
|
+
file_size = readable_bytes(getsize(bundle_path))
|
|
410
|
+
try:
|
|
411
|
+
rel_path = _os.path.relpath(bundle_path, _os.getcwd())
|
|
412
|
+
except ValueError:
|
|
413
|
+
rel_path = bundle_path
|
|
414
|
+
logger.success(f"Saved to: ./{rel_path} ({file_size})")
|
|
1363
415
|
|
|
1364
|
-
if
|
|
1365
|
-
|
|
1366
|
-
|
|
416
|
+
if symlink_from_cwd and _os.path.exists(bundle_path):
|
|
417
|
+
bundle_basename = _os.path.basename(bundle_path)
|
|
418
|
+
bundle_cwd = _os.path.join(_os.getcwd(), bundle_basename)
|
|
419
|
+
_symlink(bundle_path, bundle_cwd, symlink_from_cwd, verbose)
|
|
1367
420
|
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
image_extensions = ["png", "jpg", "jpeg", "gif", "tiff", "tif", "svg", "pdf"]
|
|
1371
|
-
parent_dir = _os.path.dirname(spath)
|
|
1372
|
-
parent_name = _os.path.basename(parent_dir)
|
|
1373
|
-
filename_without_ext = _os.path.splitext(_os.path.basename(spath))[0]
|
|
421
|
+
if symlink_to_path and _os.path.exists(bundle_path):
|
|
422
|
+
_symlink_to(bundle_path, symlink_to_path, verbose)
|
|
1374
423
|
|
|
1375
|
-
csv_path = None # Initialize to avoid UnboundLocalError when CSV export is skipped
|
|
1376
|
-
try:
|
|
1377
|
-
# Get the figure object that may contain plot data
|
|
1378
|
-
fig_obj = _get_figure_with_data(obj)
|
|
1379
|
-
|
|
1380
|
-
if fig_obj is not None:
|
|
1381
|
-
# Save regular CSV if export method exists
|
|
1382
|
-
if hasattr(fig_obj, "export_as_csv"):
|
|
1383
|
-
csv_data = fig_obj.export_as_csv()
|
|
1384
|
-
if csv_data is not None and not csv_data.empty:
|
|
1385
|
-
# Determine CSV path based on parent directory name
|
|
1386
|
-
if parent_name.lower() in image_extensions:
|
|
1387
|
-
# Parent directory is named after an image extension (e.g., png/)
|
|
1388
|
-
# Create parallel csv/ directory
|
|
1389
|
-
grandparent_dir = _os.path.dirname(parent_dir)
|
|
1390
|
-
csv_dir = _os.path.join(grandparent_dir, "csv")
|
|
1391
|
-
csv_path = _os.path.join(
|
|
1392
|
-
csv_dir, filename_without_ext + ".csv"
|
|
1393
|
-
)
|
|
1394
|
-
else:
|
|
1395
|
-
# Save CSV in same directory as image
|
|
1396
|
-
csv_path = _os.path.splitext(spath)[0] + ".csv"
|
|
1397
|
-
|
|
1398
|
-
# Ensure parent directory exists
|
|
1399
|
-
_os.makedirs(_os.path.dirname(csv_path), exist_ok=True)
|
|
1400
|
-
# Save directly using _save to avoid path doubling
|
|
1401
|
-
# Don't pass image-specific kwargs to CSV save
|
|
1402
|
-
_save(
|
|
1403
|
-
csv_data,
|
|
1404
|
-
csv_path,
|
|
1405
|
-
verbose=True,
|
|
1406
|
-
symlink_from_cwd=False, # Will handle symlink manually
|
|
1407
|
-
dry_run=dry_run,
|
|
1408
|
-
no_csv=True,
|
|
1409
|
-
)
|
|
1410
|
-
|
|
1411
|
-
# Update metadata with actual CSV info (after export)
|
|
1412
|
-
# This ensures column names match exactly, including any
|
|
1413
|
-
# deduplication suffixes added by pandas
|
|
1414
|
-
if collected_metadata is not None:
|
|
1415
|
-
try:
|
|
1416
|
-
from scitex.plt.utils._collect_figure_metadata import (
|
|
1417
|
-
_compute_csv_hash,
|
|
1418
|
-
)
|
|
1419
|
-
|
|
1420
|
-
# Ensure data section exists
|
|
1421
|
-
if "data" not in collected_metadata:
|
|
1422
|
-
collected_metadata["data"] = {}
|
|
1423
|
-
|
|
1424
|
-
# Get actual column names from exported DataFrame
|
|
1425
|
-
actual_columns = list(csv_data.columns)
|
|
1426
|
-
|
|
1427
|
-
# Update data section with csv_path (relative to JSON)
|
|
1428
|
-
# Since JSON and CSV are in the same or parallel directories,
|
|
1429
|
-
# use just the filename for simplicity
|
|
1430
|
-
collected_metadata["data"]["csv_path"] = _os.path.basename(csv_path)
|
|
1431
|
-
|
|
1432
|
-
# Update columns to use flat list of actual columns
|
|
1433
|
-
collected_metadata["data"]["columns_actual"] = actual_columns
|
|
1434
|
-
|
|
1435
|
-
# Compute hash of actual CSV data
|
|
1436
|
-
collected_metadata["data"]["csv_hash"] = _compute_csv_hash(
|
|
1437
|
-
csv_data
|
|
1438
|
-
)
|
|
1439
|
-
except Exception:
|
|
1440
|
-
pass # Silently continue if update fails
|
|
1441
|
-
|
|
1442
|
-
# Create symlink_to for CSV if it was specified for the image
|
|
1443
|
-
if symlink_to:
|
|
1444
|
-
# Apply same directory transformation for symlink
|
|
1445
|
-
symlink_parent_dir = _os.path.dirname(symlink_to)
|
|
1446
|
-
symlink_parent_name = _os.path.basename(symlink_parent_dir)
|
|
1447
|
-
symlink_filename_without_ext = _os.path.splitext(
|
|
1448
|
-
_os.path.basename(symlink_to)
|
|
1449
|
-
)[0]
|
|
1450
|
-
|
|
1451
|
-
if symlink_parent_name.lower() in image_extensions:
|
|
1452
|
-
symlink_grandparent_dir = _os.path.dirname(
|
|
1453
|
-
symlink_parent_dir
|
|
1454
|
-
)
|
|
1455
|
-
csv_symlink_to = _os.path.join(
|
|
1456
|
-
symlink_grandparent_dir,
|
|
1457
|
-
"csv",
|
|
1458
|
-
symlink_filename_without_ext + ".csv",
|
|
1459
|
-
)
|
|
1460
|
-
else:
|
|
1461
|
-
csv_symlink_to = (
|
|
1462
|
-
_os.path.splitext(symlink_to)[0] + ".csv"
|
|
1463
|
-
)
|
|
1464
|
-
|
|
1465
|
-
_symlink_to(csv_path, csv_symlink_to, True)
|
|
1466
|
-
|
|
1467
|
-
# Create symlink for CSV manually if needed
|
|
1468
|
-
if symlink_from_cwd:
|
|
1469
|
-
# Get the relative path from the original specified path
|
|
1470
|
-
# This preserves the directory structure for the symlink
|
|
1471
|
-
import inspect
|
|
1472
|
-
|
|
1473
|
-
frame_info = inspect.stack()
|
|
1474
|
-
# Find the original specified_path from the parent save() call
|
|
1475
|
-
for frame in frame_info:
|
|
1476
|
-
if "specified_path" in frame.frame.f_locals:
|
|
1477
|
-
original_path = frame.frame.f_locals[
|
|
1478
|
-
"specified_path"
|
|
1479
|
-
]
|
|
1480
|
-
if isinstance(original_path, str):
|
|
1481
|
-
# Apply same directory transformation for symlink
|
|
1482
|
-
orig_parent_dir = _os.path.dirname(
|
|
1483
|
-
original_path
|
|
1484
|
-
)
|
|
1485
|
-
orig_parent_name = _os.path.basename(
|
|
1486
|
-
orig_parent_dir
|
|
1487
|
-
)
|
|
1488
|
-
orig_filename_without_ext = _os.path.splitext(
|
|
1489
|
-
_os.path.basename(original_path)
|
|
1490
|
-
)[0]
|
|
1491
|
-
|
|
1492
|
-
if orig_parent_name.lower() in image_extensions:
|
|
1493
|
-
orig_grandparent_dir = _os.path.dirname(
|
|
1494
|
-
orig_parent_dir
|
|
1495
|
-
)
|
|
1496
|
-
csv_relative = _os.path.join(
|
|
1497
|
-
orig_grandparent_dir,
|
|
1498
|
-
"csv",
|
|
1499
|
-
orig_filename_without_ext + ".csv",
|
|
1500
|
-
)
|
|
1501
|
-
else:
|
|
1502
|
-
csv_relative = original_path.replace(
|
|
1503
|
-
_os.path.splitext(original_path)[1],
|
|
1504
|
-
".csv",
|
|
1505
|
-
)
|
|
1506
|
-
|
|
1507
|
-
csv_cwd = _os.path.join(
|
|
1508
|
-
_os.getcwd(), csv_relative
|
|
1509
|
-
)
|
|
1510
|
-
_symlink(csv_path, csv_cwd, True, True)
|
|
1511
|
-
break
|
|
1512
|
-
else:
|
|
1513
|
-
# Fallback to basename if we can't find the original path
|
|
1514
|
-
csv_cwd = (
|
|
1515
|
-
_os.getcwd() + "/" + _os.path.basename(csv_path)
|
|
1516
|
-
)
|
|
1517
|
-
_symlink(csv_path, csv_cwd, True, True)
|
|
1518
|
-
|
|
1519
|
-
# Save SigmaPlot CSV if method exists
|
|
1520
|
-
if hasattr(fig_obj, "export_as_csv_for_sigmaplot"):
|
|
1521
|
-
sigmaplot_data = fig_obj.export_as_csv_for_sigmaplot()
|
|
1522
|
-
if sigmaplot_data is not None and not sigmaplot_data.empty:
|
|
1523
|
-
# Determine SigmaPlot CSV path based on parent directory name
|
|
1524
|
-
if parent_name.lower() in image_extensions:
|
|
1525
|
-
grandparent_dir = _os.path.dirname(parent_dir)
|
|
1526
|
-
csv_dir = _os.path.join(grandparent_dir, "csv")
|
|
1527
|
-
csv_sigmaplot_path = _os.path.join(
|
|
1528
|
-
csv_dir, filename_without_ext + "_for_sigmaplot.csv"
|
|
1529
|
-
)
|
|
1530
|
-
else:
|
|
1531
|
-
csv_sigmaplot_path = spath.replace(
|
|
1532
|
-
ext_wo_dot, "csv"
|
|
1533
|
-
).replace(".csv", "_for_sigmaplot.csv")
|
|
1534
|
-
|
|
1535
|
-
# Ensure parent directory exists
|
|
1536
|
-
_os.makedirs(
|
|
1537
|
-
_os.path.dirname(csv_sigmaplot_path), exist_ok=True
|
|
1538
|
-
)
|
|
1539
|
-
# Save directly using _save to avoid path doubling
|
|
1540
|
-
# Don't pass image-specific kwargs to CSV save
|
|
1541
|
-
_save(
|
|
1542
|
-
sigmaplot_data,
|
|
1543
|
-
csv_sigmaplot_path,
|
|
1544
|
-
verbose=True,
|
|
1545
|
-
symlink_from_cwd=False, # Will handle symlink manually
|
|
1546
|
-
dry_run=dry_run,
|
|
1547
|
-
no_csv=True,
|
|
1548
|
-
)
|
|
1549
|
-
|
|
1550
|
-
# Create symlink_to for SigmaPlot CSV if it was specified for the image
|
|
1551
|
-
if symlink_to:
|
|
1552
|
-
symlink_parent_dir = _os.path.dirname(symlink_to)
|
|
1553
|
-
symlink_parent_name = _os.path.basename(symlink_parent_dir)
|
|
1554
|
-
symlink_filename_without_ext = _os.path.splitext(
|
|
1555
|
-
_os.path.basename(symlink_to)
|
|
1556
|
-
)[0]
|
|
1557
|
-
|
|
1558
|
-
if symlink_parent_name.lower() in image_extensions:
|
|
1559
|
-
symlink_grandparent_dir = _os.path.dirname(
|
|
1560
|
-
symlink_parent_dir
|
|
1561
|
-
)
|
|
1562
|
-
csv_sigmaplot_symlink_to = _os.path.join(
|
|
1563
|
-
symlink_grandparent_dir,
|
|
1564
|
-
"csv",
|
|
1565
|
-
symlink_filename_without_ext + "_for_sigmaplot.csv",
|
|
1566
|
-
)
|
|
1567
|
-
else:
|
|
1568
|
-
csv_sigmaplot_symlink_to = (
|
|
1569
|
-
_os.path.splitext(symlink_to)[0]
|
|
1570
|
-
+ "_for_sigmaplot.csv"
|
|
1571
|
-
)
|
|
1572
|
-
|
|
1573
|
-
_symlink_to(
|
|
1574
|
-
csv_sigmaplot_path,
|
|
1575
|
-
csv_sigmaplot_symlink_to,
|
|
1576
|
-
True,
|
|
1577
|
-
)
|
|
1578
|
-
|
|
1579
|
-
# Create symlink for SigmaPlot CSV manually if needed
|
|
1580
|
-
if symlink_from_cwd:
|
|
1581
|
-
csv_cwd = (
|
|
1582
|
-
_os.getcwd()
|
|
1583
|
-
+ "/"
|
|
1584
|
-
+ _os.path.basename(csv_sigmaplot_path)
|
|
1585
|
-
)
|
|
1586
|
-
_symlink(csv_sigmaplot_path, csv_cwd, True, True)
|
|
1587
|
-
except Exception as e:
|
|
1588
|
-
logger.warning(f"CSV export failed: {e}")
|
|
1589
|
-
|
|
1590
|
-
# Save metadata as JSON if collected
|
|
1591
|
-
if collected_metadata is not None and not dry_run:
|
|
1592
|
-
try:
|
|
1593
|
-
# Check if the path contains an image extension directory (e.g., ./png/, ./jpg/)
|
|
1594
|
-
# If so, save JSON in a parallel ./json/ directory
|
|
1595
|
-
# Example: ./path/to/output/png/fig.png -> ./path/to/output/json/fig.json
|
|
1596
|
-
# Example: ./path/to/output/fig.png -> ./path/to/output/fig.json (same dir)
|
|
1597
|
-
image_extensions = [
|
|
1598
|
-
"png",
|
|
1599
|
-
"jpg",
|
|
1600
|
-
"jpeg",
|
|
1601
|
-
"gif",
|
|
1602
|
-
"tiff",
|
|
1603
|
-
"tif",
|
|
1604
|
-
"svg",
|
|
1605
|
-
"pdf",
|
|
1606
|
-
]
|
|
1607
|
-
parent_dir = _os.path.dirname(spath)
|
|
1608
|
-
parent_name = _os.path.basename(parent_dir)
|
|
1609
|
-
filename_without_ext = _os.path.splitext(_os.path.basename(spath))[0]
|
|
1610
|
-
|
|
1611
|
-
if parent_name.lower() in image_extensions:
|
|
1612
|
-
# Parent directory is named after an image extension (e.g., png/)
|
|
1613
|
-
# Create parallel json/ directory
|
|
1614
|
-
grandparent_dir = _os.path.dirname(parent_dir)
|
|
1615
|
-
json_dir = _os.path.join(grandparent_dir, "json")
|
|
1616
|
-
json_path = _os.path.join(json_dir, filename_without_ext + ".json")
|
|
1617
|
-
else:
|
|
1618
|
-
# Save JSON in same directory as image
|
|
1619
|
-
json_path = _os.path.splitext(spath)[0] + ".json"
|
|
1620
|
-
|
|
1621
|
-
# Ensure parent directory exists
|
|
1622
|
-
_os.makedirs(_os.path.dirname(json_path), exist_ok=True)
|
|
1623
|
-
|
|
1624
|
-
# Save metadata as JSON
|
|
1625
|
-
_save(
|
|
1626
|
-
collected_metadata,
|
|
1627
|
-
json_path,
|
|
1628
|
-
verbose=True,
|
|
1629
|
-
symlink_from_cwd=False, # Will handle symlink manually
|
|
1630
|
-
dry_run=dry_run,
|
|
1631
|
-
no_csv=True,
|
|
1632
|
-
)
|
|
1633
424
|
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
# Find the original specified_path from the parent save() call
|
|
1671
|
-
for frame in frame_info:
|
|
1672
|
-
if "specified_path" in frame.frame.f_locals:
|
|
1673
|
-
original_path = frame.frame.f_locals["specified_path"]
|
|
1674
|
-
if isinstance(original_path, str):
|
|
1675
|
-
# Apply same directory transformation for symlink
|
|
1676
|
-
orig_parent_dir = _os.path.dirname(original_path)
|
|
1677
|
-
orig_parent_name = _os.path.basename(orig_parent_dir)
|
|
1678
|
-
orig_filename_without_ext = _os.path.splitext(
|
|
1679
|
-
_os.path.basename(original_path)
|
|
1680
|
-
)[0]
|
|
1681
|
-
|
|
1682
|
-
if orig_parent_name.lower() in image_extensions:
|
|
1683
|
-
orig_grandparent_dir = _os.path.dirname(orig_parent_dir)
|
|
1684
|
-
json_relative = _os.path.join(
|
|
1685
|
-
orig_grandparent_dir,
|
|
1686
|
-
"json",
|
|
1687
|
-
orig_filename_without_ext + ".json",
|
|
1688
|
-
)
|
|
1689
|
-
else:
|
|
1690
|
-
json_relative = original_path.replace(
|
|
1691
|
-
_os.path.splitext(original_path)[1],
|
|
1692
|
-
".json",
|
|
1693
|
-
)
|
|
1694
|
-
|
|
1695
|
-
json_cwd = _os.path.join(_os.getcwd(), json_relative)
|
|
1696
|
-
_symlink(json_path, json_cwd, True, True)
|
|
1697
|
-
break
|
|
1698
|
-
else:
|
|
1699
|
-
# Fallback to basename if we can't find the original path
|
|
1700
|
-
json_cwd = _os.getcwd() + "/" + _os.path.basename(json_path)
|
|
1701
|
-
_symlink(json_path, json_cwd, True, True)
|
|
1702
|
-
|
|
1703
|
-
except AssertionError:
|
|
1704
|
-
# Re-raise assertion errors - these are validation failures that should stop execution
|
|
1705
|
-
raise
|
|
1706
|
-
except Exception as e:
|
|
1707
|
-
logger.warning(f"JSON metadata export failed: {e}")
|
|
425
|
+
def _dispatch_handler(
|
|
426
|
+
ext,
|
|
427
|
+
obj,
|
|
428
|
+
spath,
|
|
429
|
+
verbose,
|
|
430
|
+
no_csv,
|
|
431
|
+
symlink_from_cwd,
|
|
432
|
+
symlink_to_path,
|
|
433
|
+
dry_run,
|
|
434
|
+
auto_crop,
|
|
435
|
+
crop_margin_mm,
|
|
436
|
+
metadata_extra,
|
|
437
|
+
json_schema,
|
|
438
|
+
kwargs,
|
|
439
|
+
):
|
|
440
|
+
"""Dispatch to the appropriate file handler."""
|
|
441
|
+
image_exts = [".png", ".jpg", ".jpeg", ".gif", ".tiff", ".tif", ".svg", ".pdf"]
|
|
442
|
+
if ext in image_exts:
|
|
443
|
+
_handle_image_with_csv(
|
|
444
|
+
obj,
|
|
445
|
+
spath,
|
|
446
|
+
verbose=verbose,
|
|
447
|
+
no_csv=no_csv,
|
|
448
|
+
symlink_from_cwd=symlink_from_cwd,
|
|
449
|
+
symlink_to_path=symlink_to_path,
|
|
450
|
+
dry_run=dry_run,
|
|
451
|
+
auto_crop=auto_crop,
|
|
452
|
+
crop_margin_mm=crop_margin_mm,
|
|
453
|
+
metadata_extra=metadata_extra,
|
|
454
|
+
json_schema=json_schema,
|
|
455
|
+
**kwargs,
|
|
456
|
+
)
|
|
457
|
+
elif ext in [".hdf5", ".h5", ".zarr"]:
|
|
458
|
+
_FILE_HANDLERS[ext](obj, spath, **kwargs)
|
|
459
|
+
else:
|
|
460
|
+
_FILE_HANDLERS[ext](obj, spath, **kwargs)
|
|
1708
461
|
|
|
1709
462
|
|
|
1710
463
|
# Dispatch dictionary for O(1) file format lookup
|
|
1711
464
|
_FILE_HANDLERS = {
|
|
1712
|
-
# Canvas directory format (scitex.fig)
|
|
1713
|
-
".canvas": save_canvas,
|
|
1714
|
-
# Excel formats
|
|
1715
465
|
".xlsx": save_excel,
|
|
1716
466
|
".xls": save_excel,
|
|
1717
|
-
# NumPy formats
|
|
1718
467
|
".npy": save_npy,
|
|
1719
468
|
".npz": save_npz,
|
|
1720
|
-
# Pickle formats
|
|
1721
469
|
".pkl": save_pickle,
|
|
1722
470
|
".pickle": save_pickle,
|
|
1723
471
|
".pkl.gz": save_pickle_compressed,
|
|
1724
|
-
# Other binary formats
|
|
1725
472
|
".joblib": save_joblib,
|
|
1726
473
|
".pth": save_torch,
|
|
1727
474
|
".pt": save_torch,
|
|
1728
475
|
".mat": save_matlab,
|
|
1729
476
|
".cbm": save_catboost,
|
|
1730
|
-
# Text formats
|
|
1731
477
|
".json": save_json,
|
|
1732
478
|
".yaml": save_yaml,
|
|
1733
479
|
".yml": save_yaml,
|
|
@@ -1737,23 +483,20 @@ _FILE_HANDLERS = {
|
|
|
1737
483
|
".css": save_text,
|
|
1738
484
|
".js": save_text,
|
|
1739
485
|
".tex": save_tex,
|
|
1740
|
-
# Bibliography
|
|
1741
486
|
".bib": save_bibtex,
|
|
1742
|
-
# Data formats
|
|
1743
487
|
".html": save_html,
|
|
1744
488
|
".hdf5": save_hdf5,
|
|
1745
489
|
".h5": save_hdf5,
|
|
1746
490
|
".zarr": save_zarr,
|
|
1747
|
-
# Media formats
|
|
1748
491
|
".mp4": save_mp4,
|
|
1749
|
-
".png":
|
|
1750
|
-
".jpg":
|
|
1751
|
-
".jpeg":
|
|
1752
|
-
".gif":
|
|
1753
|
-
".tiff":
|
|
1754
|
-
".tif":
|
|
1755
|
-
".svg":
|
|
1756
|
-
".pdf":
|
|
492
|
+
".png": handle_image_with_csv,
|
|
493
|
+
".jpg": handle_image_with_csv,
|
|
494
|
+
".jpeg": handle_image_with_csv,
|
|
495
|
+
".gif": handle_image_with_csv,
|
|
496
|
+
".tiff": handle_image_with_csv,
|
|
497
|
+
".tif": handle_image_with_csv,
|
|
498
|
+
".svg": handle_image_with_csv,
|
|
499
|
+
".pdf": handle_image_with_csv,
|
|
1757
500
|
}
|
|
1758
501
|
|
|
1759
502
|
# EOF
|