scitex 2.3.0__py3-none-any.whl → 2.4.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. scitex/ai/classification/reporters/reporter_utils/_Plotter.py +1 -1
  2. scitex/ai/plt/__init__.py +2 -2
  3. scitex/ai/plt/{_plot_conf_mat.py → _stx_conf_mat.py} +3 -3
  4. scitex/config/PriorityConfig.py +195 -0
  5. scitex/config/__init__.py +24 -0
  6. scitex/io/_save.py +125 -34
  7. scitex/io/_save_modules/_image.py +37 -20
  8. scitex/plt/__init__.py +470 -17
  9. scitex/plt/_subplots/_AxisWrapper.py +98 -50
  10. scitex/plt/_subplots/_AxisWrapperMixins/_MatplotlibPlotMixin.py +559 -124
  11. scitex/plt/_subplots/_AxisWrapperMixins/_SeabornMixin.py +49 -8
  12. scitex/plt/_subplots/_SubplotsWrapper.py +76 -91
  13. scitex/plt/_subplots/_export_as_csv.py +127 -58
  14. scitex/plt/_subplots/_export_as_csv_formatters/__init__.py +25 -16
  15. scitex/plt/_subplots/_export_as_csv_formatters/_format_contourf.py +54 -0
  16. scitex/plt/_subplots/_export_as_csv_formatters/_format_hexbin.py +41 -0
  17. scitex/plt/_subplots/_export_as_csv_formatters/_format_hist2d.py +41 -0
  18. scitex/plt/_subplots/_export_as_csv_formatters/_format_imshow.py +59 -47
  19. scitex/plt/_subplots/_export_as_csv_formatters/_format_matshow.py +42 -0
  20. scitex/plt/_subplots/_export_as_csv_formatters/_format_pie.py +42 -0
  21. scitex/plt/_subplots/_export_as_csv_formatters/_format_plot.py +72 -35
  22. scitex/plt/_subplots/_export_as_csv_formatters/_format_plot_box.py +1 -1
  23. scitex/plt/_subplots/_export_as_csv_formatters/_format_plot_kde.py +2 -2
  24. scitex/plt/_subplots/_export_as_csv_formatters/_format_quiver.py +53 -0
  25. scitex/plt/_subplots/_export_as_csv_formatters/_format_stem.py +42 -0
  26. scitex/plt/_subplots/_export_as_csv_formatters/_format_step.py +42 -0
  27. scitex/plt/_subplots/_export_as_csv_formatters/_format_streamplot.py +48 -0
  28. scitex/plt/_subplots/_export_as_csv_formatters/{_format_plot_conf_mat.py → _format_stx_conf_mat.py} +2 -2
  29. scitex/plt/_subplots/_export_as_csv_formatters/{_format_plot_ecdf.py → _format_stx_ecdf.py} +2 -2
  30. scitex/plt/_subplots/_export_as_csv_formatters/{_format_plot_fillv.py → _format_stx_fillv.py} +2 -2
  31. scitex/plt/_subplots/_export_as_csv_formatters/{_format_plot_heatmap.py → _format_stx_heatmap.py} +2 -2
  32. scitex/plt/_subplots/_export_as_csv_formatters/{_format_plot_image.py → _format_stx_image.py} +2 -2
  33. scitex/plt/_subplots/_export_as_csv_formatters/{_format_plot_joyplot.py → _format_stx_joyplot.py} +2 -2
  34. scitex/plt/_subplots/_export_as_csv_formatters/{_format_plot_line.py → _format_stx_line.py} +3 -3
  35. scitex/plt/_subplots/_export_as_csv_formatters/{_format_plot_mean_ci.py → _format_stx_mean_ci.py} +2 -2
  36. scitex/plt/_subplots/_export_as_csv_formatters/{_format_plot_mean_std.py → _format_stx_mean_std.py} +2 -2
  37. scitex/plt/_subplots/_export_as_csv_formatters/{_format_plot_median_iqr.py → _format_stx_median_iqr.py} +2 -2
  38. scitex/plt/_subplots/_export_as_csv_formatters/{_format_plot_raster.py → _format_stx_raster.py} +2 -2
  39. scitex/plt/_subplots/_export_as_csv_formatters/{_format_plot_rectangle.py → _format_stx_rectangle.py} +1 -1
  40. scitex/plt/_subplots/_export_as_csv_formatters/{_format_plot_scatter_hist.py → _format_stx_scatter_hist.py} +2 -2
  41. scitex/plt/_subplots/_export_as_csv_formatters/{_format_plot_shaded_line.py → _format_stx_shaded_line.py} +2 -2
  42. scitex/plt/_subplots/_export_as_csv_formatters/{_format_plot_violin.py → _format_stx_violin.py} +2 -2
  43. scitex/plt/_subplots/_export_as_csv_formatters/verify_formatters.py +23 -23
  44. scitex/plt/ax/__init__.py +16 -15
  45. scitex/plt/ax/_plot/__init__.py +30 -30
  46. scitex/plt/ax/_plot/_add_fitted_line.py +65 -11
  47. scitex/plt/ax/_plot/_plot_statistical_shaded_line.py +104 -76
  48. scitex/plt/ax/_plot/{_plot_conf_mat.py → _stx_conf_mat.py} +10 -10
  49. scitex/plt/ax/_plot/_stx_ecdf.py +109 -0
  50. scitex/plt/ax/_plot/{_plot_fillv.py → _stx_fillv.py} +7 -7
  51. scitex/plt/ax/_plot/_stx_heatmap.py +366 -0
  52. scitex/plt/ax/_plot/{_plot_image.py → _stx_image.py} +1 -1
  53. scitex/plt/ax/_plot/_stx_joyplot.py +113 -0
  54. scitex/plt/ax/_plot/{_plot_raster.py → _stx_raster.py} +37 -25
  55. scitex/plt/ax/_plot/{_plot_rectangle.py → _stx_rectangle.py} +10 -9
  56. scitex/plt/ax/_plot/{_plot_scatter_hist.py → _stx_scatter_hist.py} +1 -1
  57. scitex/plt/ax/_plot/_stx_shaded_line.py +215 -0
  58. scitex/plt/ax/_plot/{_plot_violin.py → _stx_violin.py} +13 -6
  59. scitex/plt/ax/_style/__init__.py +3 -0
  60. scitex/plt/ax/_style/_style_barplot.py +13 -2
  61. scitex/plt/ax/_style/_style_boxplot.py +78 -32
  62. scitex/plt/ax/_style/_style_errorbar.py +17 -3
  63. scitex/plt/ax/_style/_style_scatter.py +17 -3
  64. scitex/plt/ax/_style/_style_violinplot.py +109 -0
  65. scitex/plt/color/_vizualize_colors.py +3 -3
  66. scitex/plt/styles/SCITEX_STYLE.yaml +104 -0
  67. scitex/plt/styles/__init__.py +57 -0
  68. scitex/plt/styles/_plot_defaults.py +209 -0
  69. scitex/plt/styles/_plot_postprocess.py +518 -0
  70. scitex/plt/styles/_style_loader.py +268 -0
  71. scitex/plt/styles/presets.py +208 -0
  72. scitex/plt/utils/_collect_figure_metadata.py +160 -18
  73. scitex/plt/utils/_colorbar.py +72 -10
  74. scitex/plt/utils/_configure_mpl.py +108 -52
  75. scitex/plt/utils/_crop.py +21 -7
  76. scitex/plt/utils/_figure_mm.py +21 -7
  77. scitex/stats/__init__.py +13 -1
  78. scitex/stats/_schema.py +578 -0
  79. scitex/stats/tests/__init__.py +13 -0
  80. scitex/stats/tests/correlation/__init__.py +13 -0
  81. scitex/stats/tests/correlation/_test_pearson.py +262 -0
  82. scitex/vis/__init__.py +6 -0
  83. scitex/vis/editor/__init__.py +23 -0
  84. scitex/vis/editor/_defaults.py +205 -0
  85. scitex/vis/editor/_edit.py +342 -0
  86. scitex/vis/editor/_mpl_editor.py +231 -0
  87. scitex/vis/editor/_tkinter_editor.py +466 -0
  88. scitex/vis/editor/_web_editor.py +1440 -0
  89. scitex/vis/model/plot_types.py +15 -15
  90. {scitex-2.3.0.dist-info → scitex-2.4.1.dist-info}/METADATA +2 -1
  91. {scitex-2.3.0.dist-info → scitex-2.4.1.dist-info}/RECORD +94 -67
  92. {scitex-2.3.0.dist-info → scitex-2.4.1.dist-info}/WHEEL +1 -1
  93. scitex/plt/ax/_plot/_plot_ecdf.py +0 -84
  94. scitex/plt/ax/_plot/_plot_heatmap.py +0 -277
  95. scitex/plt/ax/_plot/_plot_joyplot.py +0 -77
  96. scitex/plt/ax/_plot/_plot_shaded_line.py +0 -142
  97. scitex/plt/presets.py +0 -224
  98. {scitex-2.3.0.dist-info → scitex-2.4.1.dist-info}/entry_points.txt +0 -0
  99. {scitex-2.3.0.dist-info → scitex-2.4.1.dist-info}/licenses/LICENSE +0 -0
@@ -44,7 +44,7 @@ try:
44
44
 
45
45
  # Import scitex plotting functions
46
46
  import scitex as stx
47
- from scitex.ai.plt.plot_conf_mat import plot_conf_mat as conf_mat
47
+ from scitex.ai.plt.stx_conf_mat import stx_conf_mat as conf_mat
48
48
  from scitex.ai.plt.plot_roc_curve import plot_roc_curve as roc_auc
49
49
  from scitex.ai.plt.plot_pre_rec_curve import plot_pre_rec_curve as pre_rec_auc
50
50
 
scitex/ai/plt/__init__.py CHANGED
@@ -6,7 +6,7 @@ but re-exported here for backward compatibility. New code should import directly
6
6
  from scitex.ai.metrics instead.
7
7
  """
8
8
 
9
- from ._plot_conf_mat import calc_bACC_from_conf_mat, calc_bacc_from_conf_mat, plot_conf_mat, conf_mat
9
+ from ._stx_conf_mat import calc_bACC_from_conf_mat, calc_bacc_from_conf_mat, stx_conf_mat, conf_mat
10
10
  from ._plot_learning_curve import (
11
11
  plot_learning_curve,
12
12
  _prepare_metrics_df,
@@ -37,7 +37,7 @@ vline_at_epochs = _add_epoch_vlines
37
37
 
38
38
  __all__ = [
39
39
  # Plotting functions
40
- "plot_conf_mat",
40
+ "stx_conf_mat",
41
41
  "conf_mat", # backward compat
42
42
  "plot_learning_curve",
43
43
  "learning_curve", # backward compat
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env python3
2
2
  # -*- coding: utf-8 -*-
3
3
  # Timestamp: "2025-10-02 07:15:10 (ywatanabe)"
4
- # File: /ssh:sp:/home/ywatanabe/proj/scitex_repo/src/scitex/ml/plt/plot_conf_mat.py
4
+ # File: /ssh:sp:/home/ywatanabe/proj/scitex_repo/src/scitex/ml/plt/stx_conf_mat.py
5
5
  # ----------------------------------------
6
6
  from __future__ import annotations
7
7
  import os
@@ -28,7 +28,7 @@ from scitex.ai.metrics import calc_bacc_from_conf_mat
28
28
  calc_bACC_from_conf_mat = calc_bacc_from_conf_mat
29
29
 
30
30
 
31
- def plot_conf_mat(
31
+ def stx_conf_mat(
32
32
  cm=None,
33
33
  y_true=None,
34
34
  y_pred=None,
@@ -270,7 +270,7 @@ def plot_conf_mat(
270
270
 
271
271
 
272
272
  # Backward compatibility alias
273
- conf_mat = plot_conf_mat
273
+ conf_mat = stx_conf_mat
274
274
 
275
275
 
276
276
  # Metric calculation functions have been moved to scitex.ai.metrics
@@ -0,0 +1,195 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ # Timestamp: "2025-12-01 18:53:13 (ywatanabe)"
4
+ # File: /home/ywatanabe/proj/scitex-code/src/scitex/config/PriorityConfig.py
5
+
6
+
7
+ """
8
+ Priority-based configuration resolver.
9
+
10
+ Provides clean precedence hierarchy: direct → env → config → default
11
+
12
+ Based on priority-config by ywatanabe (https://github.com/ywatanabe1989/priority-config)
13
+ Incorporated into scitex for self-contained configuration management.
14
+ """
15
+
16
+ import os
17
+ from typing import Dict
18
+ from typing import List
19
+ from typing import Optional, Type, Any
20
+
21
+
22
+ class PriorityConfig:
23
+ """Universal config resolver with precedence: direct → env → config → default
24
+
25
+ Examples
26
+ --------
27
+ >>> from scitex.config import PriorityConfig
28
+ >>> config = PriorityConfig(config_dict={"port": 3000}, env_prefix="SCITEX_")
29
+ >>> port = config.resolve("port", None, default=8000, type=int)
30
+ 3000 # from config_dict (env not set)
31
+ >>> # With env: PORT=5000 python script.py
32
+ >>> port = config.resolve("port", None, default=8000, type=int)
33
+ 5000 # env takes precedence over config
34
+ >>> port = config.resolve("port", 9000, default=8000, type=int)
35
+ 9000 # direct value takes highest precedence
36
+ """
37
+
38
+ SENSITIVE_EXPRESSIONS = [
39
+ "API",
40
+ "PASSWORD",
41
+ "SECRET",
42
+ "TOKEN",
43
+ "KEY",
44
+ "PASS",
45
+ "AUTH",
46
+ "CREDENTIAL",
47
+ "PRIVATE",
48
+ "CERT",
49
+ ]
50
+
51
+ def __init__(
52
+ self,
53
+ config_dict: Optional[Dict[str, Any]] = None,
54
+ env_prefix: str = "",
55
+ auto_uppercase: bool = True,
56
+ ):
57
+ """Initialize PriorityConfig.
58
+
59
+ Parameters
60
+ ----------
61
+ config_dict : dict, optional
62
+ Dictionary with configuration values
63
+ env_prefix : str
64
+ Prefix for environment variables (e.g., "SCITEX_")
65
+ auto_uppercase : bool
66
+ Whether to uppercase keys for env lookup
67
+ """
68
+ self.config_dict = config_dict or {}
69
+ self.env_prefix = env_prefix
70
+ self.auto_uppercase = auto_uppercase
71
+ self.resolution_log: List[Dict[str, Any]] = []
72
+
73
+ def __repr__(self) -> str:
74
+ return f"PriorityConfig(prefix='{self.env_prefix}', configs={len(self.config_dict)})"
75
+
76
+ def get(self, key: str) -> Any:
77
+ """Get value from config dict only."""
78
+ return self.config_dict.get(key)
79
+
80
+ def resolve(
81
+ self,
82
+ key: str,
83
+ direct_val: Any = None,
84
+ default: Any = None,
85
+ type: Type = str,
86
+ mask: Optional[bool] = None,
87
+ ) -> Any:
88
+ """Get value with precedence hierarchy.
89
+
90
+ Precedence: direct → env → config → default
91
+
92
+ Parameters
93
+ ----------
94
+ key : str
95
+ Configuration key to resolve
96
+ direct_val : Any, optional
97
+ Direct value (highest precedence)
98
+ default : Any, optional
99
+ Default value if not found elsewhere
100
+ type : Type
101
+ Type conversion (str, int, float, bool, list)
102
+ mask : bool, optional
103
+ Override automatic masking of sensitive values
104
+
105
+ Returns
106
+ -------
107
+ Any
108
+ Resolved configuration value
109
+ """
110
+ source = None
111
+ final_value = None
112
+
113
+ # Replace dots with underscores for env key (e.g., axes.width_mm -> AXES_WIDTH_MM)
114
+ normalized_key = key.replace(".", "_")
115
+ env_key = f"{self.env_prefix}{normalized_key.upper() if self.auto_uppercase else normalized_key}"
116
+ env_val = os.getenv(env_key)
117
+
118
+ # Priority: direct → env → config → default
119
+ if direct_val is not None:
120
+ source = "direct"
121
+ final_value = direct_val
122
+ elif env_val:
123
+ source = f"env:{env_key}"
124
+ final_value = self._convert_type(env_val, type)
125
+ elif key in self.config_dict:
126
+ source = "config"
127
+ final_value = self.config_dict[key]
128
+ else:
129
+ source = "default"
130
+ final_value = default
131
+
132
+ if mask is False:
133
+ should_mask = False
134
+ else:
135
+ should_mask = self._is_sensitive(key)
136
+
137
+ display_value = (
138
+ self._mask_value(final_value) if should_mask else final_value
139
+ )
140
+
141
+ self.resolution_log.append(
142
+ {
143
+ "key": key,
144
+ "source": source,
145
+ "value": display_value,
146
+ "type": type.__name__,
147
+ }
148
+ )
149
+
150
+ return final_value
151
+
152
+ def print_resolutions(self) -> None:
153
+ """Print how each config was resolved."""
154
+ if not self.resolution_log:
155
+ print("No configurations resolved yet")
156
+ return
157
+
158
+ print("Configuration Resolution Log:")
159
+ print("-" * 50)
160
+ for entry in self.resolution_log:
161
+ print(
162
+ f"{entry['key']:<20} = {entry['value']:<20} ({entry['source']})"
163
+ )
164
+
165
+ def clear_log(self) -> None:
166
+ """Clear resolution log."""
167
+ self.resolution_log = []
168
+
169
+ def _convert_type(self, value: str, type: Type) -> Any:
170
+ """Convert string value to specified type."""
171
+ if type == int:
172
+ return int(value)
173
+ elif type == float:
174
+ return float(value)
175
+ elif type == bool:
176
+ return value.lower() in ("true", "1", "yes")
177
+ elif type == list:
178
+ return value.split(",")
179
+ return value
180
+
181
+ def _is_sensitive(self, key: str) -> bool:
182
+ """Check if key contains sensitive expressions."""
183
+ key_upper = key.upper()
184
+ return any(expr in key_upper for expr in self.SENSITIVE_EXPRESSIONS)
185
+
186
+ def _mask_value(self, value: Any) -> str:
187
+ """Mask sensitive values for display."""
188
+ if value is None:
189
+ return None
190
+ value_str = str(value)
191
+ if len(value_str) <= 4:
192
+ return "****"
193
+ return value_str[:2] + "*" * (len(value_str) - 4) + value_str[-2:]
194
+
195
+ # EOF
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ # Timestamp: "2025-12-01 21:00:00 (ywatanabe)"
4
+ # File: ./src/scitex/config/__init__.py
5
+
6
+ """
7
+ SciTeX configuration module.
8
+
9
+ Provides priority-based configuration resolution with clean precedence:
10
+ direct → config → env → default
11
+
12
+ Usage:
13
+ from scitex.config import PriorityConfig
14
+
15
+ config = PriorityConfig(config_dict={"port": 3000}, env_prefix="SCITEX_")
16
+ port = config.resolve("port", None, default=8000, type=int)
17
+ """
18
+
19
+ from .PriorityConfig import PriorityConfig
20
+
21
+ __all__ = ["PriorityConfig"]
22
+
23
+
24
+ # EOF
scitex/io/_save.py CHANGED
@@ -157,7 +157,7 @@ def save(
157
157
  dry_run: bool = False,
158
158
  no_csv: bool = False,
159
159
  use_caller_path: bool = False,
160
- auto_crop: bool = False,
160
+ auto_crop: bool = True,
161
161
  crop_margin_mm: float = 1.0,
162
162
  metadata_extra: dict = None,
163
163
  **kwargs,
@@ -183,7 +183,7 @@ def save(
183
183
  If True, simulate the saving process without actually writing files. Default is False.
184
184
  auto_crop : bool, optional
185
185
  If True, automatically crop the saved image to content area with margin (for PNG/JPEG/TIFF).
186
- Vector formats (PDF/SVG) are not cropped. Default is False.
186
+ Vector formats (PDF/SVG) are not cropped. Default is True.
187
187
  crop_margin_mm : float, optional
188
188
  Margin in millimeters to add around content when auto_crop=True.
189
189
  At 300 DPI: 1mm = ~12 pixels. Default is 1.0mm (Nature Reviews style).
@@ -764,6 +764,13 @@ def _handle_image_with_csv(
764
764
  ext = _os.path.splitext(spath)[1].lower()
765
765
  ext_wo_dot = ext.replace(".", "")
766
766
 
767
+ # Check if the path contains an image extension directory (e.g., ./png/, ./jpg/)
768
+ # If so, save CSV in a parallel ./csv/ directory
769
+ image_extensions = ['png', 'jpg', 'jpeg', 'gif', 'tiff', 'tif', 'svg', 'pdf']
770
+ parent_dir = _os.path.dirname(spath)
771
+ parent_name = _os.path.basename(parent_dir)
772
+ filename_without_ext = _os.path.splitext(_os.path.basename(spath))[0]
773
+
767
774
  try:
768
775
  # Get the figure object that may contain plot data
769
776
  fig_obj = _get_figure_with_data(obj)
@@ -773,10 +780,18 @@ def _handle_image_with_csv(
773
780
  if hasattr(fig_obj, "export_as_csv"):
774
781
  csv_data = fig_obj.export_as_csv()
775
782
  if csv_data is not None and not csv_data.empty:
776
- # Save CSV in same directory as image with .csv extension
777
- # Example: ./path/to/output/fig.jpg -> ./path/to/output/fig.csv
778
- csv_path = _os.path.splitext(spath)[0] + ".csv"
779
- # Ensure parent directory exists (should already exist from image save)
783
+ # Determine CSV path based on parent directory name
784
+ if parent_name.lower() in image_extensions:
785
+ # Parent directory is named after an image extension (e.g., png/)
786
+ # Create parallel csv/ directory
787
+ grandparent_dir = _os.path.dirname(parent_dir)
788
+ csv_dir = _os.path.join(grandparent_dir, "csv")
789
+ csv_path = _os.path.join(csv_dir, filename_without_ext + ".csv")
790
+ else:
791
+ # Save CSV in same directory as image
792
+ csv_path = _os.path.splitext(spath)[0] + ".csv"
793
+
794
+ # Ensure parent directory exists
780
795
  _os.makedirs(_os.path.dirname(csv_path), exist_ok=True)
781
796
  # Save directly using _save to avoid path doubling
782
797
  # Don't pass image-specific kwargs to CSV save
@@ -791,9 +806,19 @@ def _handle_image_with_csv(
791
806
 
792
807
  # Create symlink_to for CSV if it was specified for the image
793
808
  if symlink_to:
794
- csv_symlink_to = (
795
- _os.path.splitext(symlink_to)[0] + ".csv"
796
- )
809
+ # Apply same directory transformation for symlink
810
+ symlink_parent_dir = _os.path.dirname(symlink_to)
811
+ symlink_parent_name = _os.path.basename(symlink_parent_dir)
812
+ symlink_filename_without_ext = _os.path.splitext(_os.path.basename(symlink_to))[0]
813
+
814
+ if symlink_parent_name.lower() in image_extensions:
815
+ symlink_grandparent_dir = _os.path.dirname(symlink_parent_dir)
816
+ csv_symlink_to = _os.path.join(
817
+ symlink_grandparent_dir, "csv", symlink_filename_without_ext + ".csv"
818
+ )
819
+ else:
820
+ csv_symlink_to = _os.path.splitext(symlink_to)[0] + ".csv"
821
+
797
822
  _symlink_to(csv_path, csv_symlink_to, True)
798
823
 
799
824
  # Create symlink for CSV manually if needed
@@ -810,13 +835,22 @@ def _handle_image_with_csv(
810
835
  "specified_path"
811
836
  ]
812
837
  if isinstance(original_path, str):
813
- # Replace extension to get CSV path structure
814
- csv_relative = original_path.replace(
815
- _os.path.splitext(original_path)[
816
- 1
817
- ],
818
- ".csv",
819
- )
838
+ # Apply same directory transformation for symlink
839
+ orig_parent_dir = _os.path.dirname(original_path)
840
+ orig_parent_name = _os.path.basename(orig_parent_dir)
841
+ orig_filename_without_ext = _os.path.splitext(_os.path.basename(original_path))[0]
842
+
843
+ if orig_parent_name.lower() in image_extensions:
844
+ orig_grandparent_dir = _os.path.dirname(orig_parent_dir)
845
+ csv_relative = _os.path.join(
846
+ orig_grandparent_dir, "csv", orig_filename_without_ext + ".csv"
847
+ )
848
+ else:
849
+ csv_relative = original_path.replace(
850
+ _os.path.splitext(original_path)[1],
851
+ ".csv",
852
+ )
853
+
820
854
  csv_cwd = _os.path.join(
821
855
  _os.getcwd(), csv_relative
822
856
  )
@@ -835,10 +869,18 @@ def _handle_image_with_csv(
835
869
  if hasattr(fig_obj, "export_as_csv_for_sigmaplot"):
836
870
  sigmaplot_data = fig_obj.export_as_csv_for_sigmaplot()
837
871
  if sigmaplot_data is not None and not sigmaplot_data.empty:
838
- # For CSV files, we want them in the same directory as the image
839
- csv_sigmaplot_path = spath.replace(
840
- ext_wo_dot, "csv"
841
- ).replace(".csv", "_for_sigmaplot.csv")
872
+ # Determine SigmaPlot CSV path based on parent directory name
873
+ if parent_name.lower() in image_extensions:
874
+ grandparent_dir = _os.path.dirname(parent_dir)
875
+ csv_dir = _os.path.join(grandparent_dir, "csv")
876
+ csv_sigmaplot_path = _os.path.join(csv_dir, filename_without_ext + "_for_sigmaplot.csv")
877
+ else:
878
+ csv_sigmaplot_path = spath.replace(
879
+ ext_wo_dot, "csv"
880
+ ).replace(".csv", "_for_sigmaplot.csv")
881
+
882
+ # Ensure parent directory exists
883
+ _os.makedirs(_os.path.dirname(csv_sigmaplot_path), exist_ok=True)
842
884
  # Save directly using _save to avoid path doubling
843
885
  # Don't pass image-specific kwargs to CSV save
844
886
  _save(
@@ -852,10 +894,21 @@ def _handle_image_with_csv(
852
894
 
853
895
  # Create symlink_to for SigmaPlot CSV if it was specified for the image
854
896
  if symlink_to:
855
- csv_sigmaplot_symlink_to = (
856
- _os.path.splitext(symlink_to)[0]
857
- + "_for_sigmaplot.csv"
858
- )
897
+ symlink_parent_dir = _os.path.dirname(symlink_to)
898
+ symlink_parent_name = _os.path.basename(symlink_parent_dir)
899
+ symlink_filename_without_ext = _os.path.splitext(_os.path.basename(symlink_to))[0]
900
+
901
+ if symlink_parent_name.lower() in image_extensions:
902
+ symlink_grandparent_dir = _os.path.dirname(symlink_parent_dir)
903
+ csv_sigmaplot_symlink_to = _os.path.join(
904
+ symlink_grandparent_dir, "csv", symlink_filename_without_ext + "_for_sigmaplot.csv"
905
+ )
906
+ else:
907
+ csv_sigmaplot_symlink_to = (
908
+ _os.path.splitext(symlink_to)[0]
909
+ + "_for_sigmaplot.csv"
910
+ )
911
+
859
912
  _symlink_to(
860
913
  csv_sigmaplot_path,
861
914
  csv_sigmaplot_symlink_to,
@@ -881,11 +934,26 @@ def _handle_image_with_csv(
881
934
  # Save metadata as JSON if collected
882
935
  if collected_metadata is not None and not dry_run:
883
936
  try:
884
- # Save JSON in same directory as image with .json extension
885
- # Example: ./path/to/output/fig.png -> ./path/to/output/fig.json
886
- json_path = _os.path.splitext(spath)[0] + ".json"
937
+ # Check if the path contains an image extension directory (e.g., ./png/, ./jpg/)
938
+ # If so, save JSON in a parallel ./json/ directory
939
+ # Example: ./path/to/output/png/fig.png -> ./path/to/output/json/fig.json
940
+ # Example: ./path/to/output/fig.png -> ./path/to/output/fig.json (same dir)
941
+ image_extensions = ['png', 'jpg', 'jpeg', 'gif', 'tiff', 'tif', 'svg', 'pdf']
942
+ parent_dir = _os.path.dirname(spath)
943
+ parent_name = _os.path.basename(parent_dir)
944
+ filename_without_ext = _os.path.splitext(_os.path.basename(spath))[0]
945
+
946
+ if parent_name.lower() in image_extensions:
947
+ # Parent directory is named after an image extension (e.g., png/)
948
+ # Create parallel json/ directory
949
+ grandparent_dir = _os.path.dirname(parent_dir)
950
+ json_dir = _os.path.join(grandparent_dir, "json")
951
+ json_path = _os.path.join(json_dir, filename_without_ext + ".json")
952
+ else:
953
+ # Save JSON in same directory as image
954
+ json_path = _os.path.splitext(spath)[0] + ".json"
887
955
 
888
- # Ensure parent directory exists (should already exist from image save)
956
+ # Ensure parent directory exists
889
957
  _os.makedirs(_os.path.dirname(json_path), exist_ok=True)
890
958
 
891
959
  # Save metadata as JSON
@@ -900,7 +968,19 @@ def _handle_image_with_csv(
900
968
 
901
969
  # Create symlink_to for JSON if it was specified for the image
902
970
  if symlink_to:
903
- json_symlink_to = _os.path.splitext(symlink_to)[0] + ".json"
971
+ # Apply same directory transformation for symlink
972
+ symlink_parent_dir = _os.path.dirname(symlink_to)
973
+ symlink_parent_name = _os.path.basename(symlink_parent_dir)
974
+ symlink_filename_without_ext = _os.path.splitext(_os.path.basename(symlink_to))[0]
975
+
976
+ if symlink_parent_name.lower() in image_extensions:
977
+ symlink_grandparent_dir = _os.path.dirname(symlink_parent_dir)
978
+ json_symlink_to = _os.path.join(
979
+ symlink_grandparent_dir, "json", symlink_filename_without_ext + ".json"
980
+ )
981
+ else:
982
+ json_symlink_to = _os.path.splitext(symlink_to)[0] + ".json"
983
+
904
984
  _symlink_to(json_path, json_symlink_to, True)
905
985
 
906
986
  # Create symlink for JSON manually if needed
@@ -915,11 +995,22 @@ def _handle_image_with_csv(
915
995
  if "specified_path" in frame.frame.f_locals:
916
996
  original_path = frame.frame.f_locals["specified_path"]
917
997
  if isinstance(original_path, str):
918
- # Replace extension to get JSON path structure
919
- json_relative = original_path.replace(
920
- _os.path.splitext(original_path)[1],
921
- ".json",
922
- )
998
+ # Apply same directory transformation for symlink
999
+ orig_parent_dir = _os.path.dirname(original_path)
1000
+ orig_parent_name = _os.path.basename(orig_parent_dir)
1001
+ orig_filename_without_ext = _os.path.splitext(_os.path.basename(original_path))[0]
1002
+
1003
+ if orig_parent_name.lower() in image_extensions:
1004
+ orig_grandparent_dir = _os.path.dirname(orig_parent_dir)
1005
+ json_relative = _os.path.join(
1006
+ orig_grandparent_dir, "json", orig_filename_without_ext + ".json"
1007
+ )
1008
+ else:
1009
+ json_relative = original_path.replace(
1010
+ _os.path.splitext(original_path)[1],
1011
+ ".json",
1012
+ )
1013
+
923
1014
  json_cwd = _os.path.join(_os.getcwd(), json_relative)
924
1015
  _symlink(json_path, json_cwd, True, True)
925
1016
  break
@@ -1,12 +1,11 @@
1
1
  #!/usr/bin/env python3
2
2
  # -*- coding: utf-8 -*-
3
- # Timestamp: "2025-05-18 14:52:34 (ywatanabe)"
4
- # File: /ssh:sp:/home/ywatanabe/proj/scitex_repo/src/scitex/io/_save_modules/_save_image.py
5
- # ----------------------------------------
3
+ # Timestamp: "2025-12-01 12:23:32 (ywatanabe)"
4
+ # File: /home/ywatanabe/proj/scitex-code/src/scitex/io/_save_modules/_image.py
5
+
6
6
  import os
7
+
7
8
  __FILE__ = __file__
8
- __DIR__ = os.path.dirname(__FILE__)
9
- # ----------------------------------------
10
9
 
11
10
  import io as _io
12
11
  import logging
@@ -17,15 +16,23 @@ from PIL import Image
17
16
  logger = logging.getLogger(__name__)
18
17
 
19
18
 
20
- def save_image(obj, spath, metadata=None, add_qr=False, qr_position='bottom-right', verbose=False, **kwargs):
19
+ def save_image(
20
+ obj,
21
+ spath,
22
+ metadata=None,
23
+ add_qr=False,
24
+ qr_position="bottom-right",
25
+ verbose=False,
26
+ **kwargs,
27
+ ):
21
28
  # Add URL to metadata if not present
22
29
  if metadata is not None:
23
30
  if verbose:
24
31
  logger.info(f"📝 Saving figure with metadata to: {spath}")
25
32
 
26
- if 'url' not in metadata:
33
+ if "url" not in metadata:
27
34
  metadata = dict(metadata)
28
- metadata['url'] = 'https://scitex.ai'
35
+ metadata["url"] = "https://scitex.ai"
29
36
  if verbose:
30
37
  logger.info(" • Auto-added URL: https://scitex.ai")
31
38
 
@@ -35,12 +42,16 @@ def save_image(obj, spath, metadata=None, add_qr=False, qr_position='bottom-righ
35
42
  logger.info(f" • Adding QR code at position: {qr_position}")
36
43
  try:
37
44
  from .._qr_utils import add_qr_to_figure
45
+
38
46
  # Only add QR for matplotlib figures
39
- if hasattr(obj, 'savefig') or (hasattr(obj, 'figure') and hasattr(obj.figure, 'savefig')):
40
- fig = obj if hasattr(obj, 'savefig') else obj.figure
47
+ if hasattr(obj, "savefig") or (
48
+ hasattr(obj, "figure") and hasattr(obj.figure, "savefig")
49
+ ):
50
+ fig = obj if hasattr(obj, "savefig") else obj.figure
41
51
  obj = add_qr_to_figure(fig, metadata, position=qr_position)
42
52
  except Exception as e:
43
53
  import warnings
54
+
44
55
  warnings.warn(f"Failed to add QR code: {e}")
45
56
 
46
57
  # png
@@ -67,7 +78,7 @@ def save_image(obj, spath, metadata=None, add_qr=False, qr_position='bottom-righ
67
78
  # matplotlib
68
79
  else:
69
80
  # Use kwargs dpi if provided, otherwise default to 300
70
- save_kwargs = {'format': 'tiff', 'dpi': kwargs.get('dpi', 300)}
81
+ save_kwargs = {"format": "tiff", "dpi": kwargs.get("dpi", 300)}
71
82
  save_kwargs.update(kwargs)
72
83
  try:
73
84
  obj.savefig(spath, **save_kwargs)
@@ -85,7 +96,9 @@ def save_image(obj, spath, metadata=None, add_qr=False, qr_position='bottom-righ
85
96
  obj.write_image(buf, format="png")
86
97
  buf.seek(0)
87
98
  img = Image.open(buf)
88
- img.convert("RGB").save(spath, "JPEG", quality=100, subsampling=0, optimize=False)
99
+ img.convert("RGB").save(
100
+ spath, "JPEG", quality=100, subsampling=0, optimize=False
101
+ )
89
102
  buf.close()
90
103
 
91
104
  # PIL image
@@ -95,7 +108,7 @@ def save_image(obj, spath, metadata=None, add_qr=False, qr_position='bottom-righ
95
108
 
96
109
  # matplotlib
97
110
  else:
98
- save_kwargs = {'format': 'png'}
111
+ save_kwargs = {"format": "png"}
99
112
  save_kwargs.update(kwargs)
100
113
  try:
101
114
  obj.savefig(buf, **save_kwargs)
@@ -105,7 +118,9 @@ def save_image(obj, spath, metadata=None, add_qr=False, qr_position='bottom-righ
105
118
  buf.seek(0)
106
119
  img = Image.open(buf)
107
120
  # Save JPEG with very high quality settings for daily use (quality=98 is near-lossless)
108
- img.convert("RGB").save(spath, "JPEG", quality=100, subsampling=0, optimize=False)
121
+ img.convert("RGB").save(
122
+ spath, "JPEG", quality=100, subsampling=0, optimize=False
123
+ )
109
124
  buf.close()
110
125
  del obj
111
126
 
@@ -125,7 +140,7 @@ def save_image(obj, spath, metadata=None, add_qr=False, qr_position='bottom-righ
125
140
  # matplotlib
126
141
  else:
127
142
  buf = _io.BytesIO()
128
- save_kwargs = {'format': 'png'}
143
+ save_kwargs = {"format": "png"}
129
144
  save_kwargs.update(kwargs)
130
145
  try:
131
146
  obj.savefig(buf, **save_kwargs)
@@ -144,7 +159,7 @@ def save_image(obj, spath, metadata=None, add_qr=False, qr_position='bottom-righ
144
159
  obj.write_image(file=spath, format="svg")
145
160
  # Matplotlib
146
161
  else:
147
- save_kwargs = {'format': 'svg'}
162
+ save_kwargs = {"format": "svg"}
148
163
  save_kwargs.update(kwargs)
149
164
  try:
150
165
  obj.savefig(spath, **save_kwargs)
@@ -160,15 +175,15 @@ def save_image(obj, spath, metadata=None, add_qr=False, qr_position='bottom-righ
160
175
  # PIL Image - convert to PDF
161
176
  elif isinstance(obj, Image.Image):
162
177
  # Convert RGBA to RGB if needed
163
- if obj.mode == 'RGBA':
164
- rgb_img = Image.new('RGB', obj.size, (255, 255, 255))
178
+ if obj.mode == "RGBA":
179
+ rgb_img = Image.new("RGB", obj.size, (255, 255, 255))
165
180
  rgb_img.paste(obj, mask=obj.split()[3])
166
181
  rgb_img.save(spath, "PDF")
167
182
  else:
168
183
  obj.save(spath, "PDF")
169
184
  # Matplotlib
170
185
  else:
171
- save_kwargs = {'format': 'pdf'}
186
+ save_kwargs = {"format": "pdf"}
172
187
  save_kwargs.update(kwargs)
173
188
  try:
174
189
  obj.savefig(spath, **save_kwargs)
@@ -179,12 +194,14 @@ def save_image(obj, spath, metadata=None, add_qr=False, qr_position='bottom-righ
179
194
  # Embed metadata if provided
180
195
  if metadata is not None:
181
196
  from .._metadata import embed_metadata
197
+
182
198
  try:
183
199
  embed_metadata(spath, metadata)
184
200
  if verbose:
185
- logger.info(f" • Embedded metadata: {metadata}")
201
+ logger.debug(f" • Embedded metadata: {metadata}")
186
202
  except Exception as e:
187
203
  import warnings
204
+
188
205
  warnings.warn(f"Failed to embed metadata: {e}")
189
206
 
190
207
  # EOF