vlalab 0.1.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.
- vlalab/__init__.py +82 -0
- vlalab/adapters/__init__.py +10 -0
- vlalab/adapters/converter.py +146 -0
- vlalab/adapters/dp_adapter.py +181 -0
- vlalab/adapters/groot_adapter.py +148 -0
- vlalab/apps/__init__.py +1 -0
- vlalab/apps/streamlit/__init__.py +1 -0
- vlalab/apps/streamlit/app.py +103 -0
- vlalab/apps/streamlit/pages/__init__.py +1 -0
- vlalab/apps/streamlit/pages/dataset_viewer.py +322 -0
- vlalab/apps/streamlit/pages/inference_viewer.py +360 -0
- vlalab/apps/streamlit/pages/latency_viewer.py +256 -0
- vlalab/cli.py +137 -0
- vlalab/core.py +672 -0
- vlalab/logging/__init__.py +10 -0
- vlalab/logging/jsonl_writer.py +114 -0
- vlalab/logging/run_loader.py +216 -0
- vlalab/logging/run_logger.py +343 -0
- vlalab/schema/__init__.py +17 -0
- vlalab/schema/run.py +162 -0
- vlalab/schema/step.py +177 -0
- vlalab/viz/__init__.py +9 -0
- vlalab/viz/mpl_fonts.py +161 -0
- vlalab-0.1.0.dist-info/METADATA +443 -0
- vlalab-0.1.0.dist-info/RECORD +29 -0
- vlalab-0.1.0.dist-info/WHEEL +5 -0
- vlalab-0.1.0.dist-info/entry_points.txt +2 -0
- vlalab-0.1.0.dist-info/licenses/LICENSE +21 -0
- vlalab-0.1.0.dist-info/top_level.txt +1 -0
vlalab/schema/run.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""
|
|
2
|
+
VLA-Lab Run Schema
|
|
3
|
+
|
|
4
|
+
Defines the metadata structure for a deployment run.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass, field, asdict
|
|
8
|
+
from typing import List, Optional, Dict, Any
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
import json
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class CameraConfig:
|
|
15
|
+
"""Camera configuration."""
|
|
16
|
+
name: str
|
|
17
|
+
resolution: Optional[List[int]] = None # [width, height]
|
|
18
|
+
fps: Optional[float] = None
|
|
19
|
+
camera_type: Optional[str] = None # "realsense", "usb", etc.
|
|
20
|
+
serial_number: Optional[str] = None
|
|
21
|
+
|
|
22
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
23
|
+
d = {"name": self.name}
|
|
24
|
+
if self.resolution is not None:
|
|
25
|
+
d["resolution"] = self.resolution
|
|
26
|
+
if self.fps is not None:
|
|
27
|
+
d["fps"] = self.fps
|
|
28
|
+
if self.camera_type is not None:
|
|
29
|
+
d["camera_type"] = self.camera_type
|
|
30
|
+
if self.serial_number is not None:
|
|
31
|
+
d["serial_number"] = self.serial_number
|
|
32
|
+
return d
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def from_dict(cls, data: Dict[str, Any]) -> "CameraConfig":
|
|
36
|
+
return cls(**data)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class RunMeta:
|
|
41
|
+
"""Metadata for a deployment run."""
|
|
42
|
+
# Required fields
|
|
43
|
+
run_name: str
|
|
44
|
+
start_time: str # ISO format timestamp
|
|
45
|
+
|
|
46
|
+
# Model info
|
|
47
|
+
model_name: str = "unknown"
|
|
48
|
+
model_path: Optional[str] = None
|
|
49
|
+
model_type: Optional[str] = None # "diffusion_policy", "groot", etc.
|
|
50
|
+
|
|
51
|
+
# Task info
|
|
52
|
+
task_name: str = "unknown"
|
|
53
|
+
task_prompt: Optional[str] = None
|
|
54
|
+
|
|
55
|
+
# Robot info
|
|
56
|
+
robot_name: str = "unknown"
|
|
57
|
+
robot_type: Optional[str] = None # "franka", "ur5", etc.
|
|
58
|
+
|
|
59
|
+
# Camera info
|
|
60
|
+
cameras: List[CameraConfig] = field(default_factory=list)
|
|
61
|
+
|
|
62
|
+
# Inference config
|
|
63
|
+
inference_freq: Optional[float] = None # Hz
|
|
64
|
+
action_dim: Optional[int] = None
|
|
65
|
+
action_horizon: Optional[int] = None
|
|
66
|
+
|
|
67
|
+
# Deployment info
|
|
68
|
+
server_config: Dict[str, Any] = field(default_factory=dict)
|
|
69
|
+
client_config: Dict[str, Any] = field(default_factory=dict)
|
|
70
|
+
|
|
71
|
+
# Statistics (updated during/after run)
|
|
72
|
+
end_time: Optional[str] = None
|
|
73
|
+
total_steps: int = 0
|
|
74
|
+
total_duration_s: Optional[float] = None
|
|
75
|
+
|
|
76
|
+
# Version info
|
|
77
|
+
vlalab_version: str = "0.1.0"
|
|
78
|
+
framework_version: Optional[str] = None
|
|
79
|
+
|
|
80
|
+
# Extra fields
|
|
81
|
+
extra: Dict[str, Any] = field(default_factory=dict)
|
|
82
|
+
|
|
83
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
84
|
+
d = {
|
|
85
|
+
"run_name": self.run_name,
|
|
86
|
+
"start_time": self.start_time,
|
|
87
|
+
"model_name": self.model_name,
|
|
88
|
+
"task_name": self.task_name,
|
|
89
|
+
"robot_name": self.robot_name,
|
|
90
|
+
"cameras": [cam.to_dict() for cam in self.cameras],
|
|
91
|
+
"total_steps": self.total_steps,
|
|
92
|
+
"vlalab_version": self.vlalab_version,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
# Add optional fields if set
|
|
96
|
+
optional_fields = [
|
|
97
|
+
"model_path", "model_type", "task_prompt", "robot_type",
|
|
98
|
+
"inference_freq", "action_dim", "action_horizon",
|
|
99
|
+
"end_time", "total_duration_s", "framework_version",
|
|
100
|
+
]
|
|
101
|
+
for field_name in optional_fields:
|
|
102
|
+
value = getattr(self, field_name)
|
|
103
|
+
if value is not None:
|
|
104
|
+
d[field_name] = value
|
|
105
|
+
|
|
106
|
+
if self.server_config:
|
|
107
|
+
d["server_config"] = self.server_config
|
|
108
|
+
if self.client_config:
|
|
109
|
+
d["client_config"] = self.client_config
|
|
110
|
+
if self.extra:
|
|
111
|
+
d["extra"] = self.extra
|
|
112
|
+
|
|
113
|
+
return d
|
|
114
|
+
|
|
115
|
+
def to_json(self, indent: int = 2) -> str:
|
|
116
|
+
return json.dumps(self.to_dict(), indent=indent)
|
|
117
|
+
|
|
118
|
+
def save(self, path: str):
|
|
119
|
+
"""Save metadata to JSON file."""
|
|
120
|
+
with open(path, "w") as f:
|
|
121
|
+
f.write(self.to_json())
|
|
122
|
+
|
|
123
|
+
@classmethod
|
|
124
|
+
def from_dict(cls, data: Dict[str, Any]) -> "RunMeta":
|
|
125
|
+
cameras = [
|
|
126
|
+
CameraConfig.from_dict(cam) if isinstance(cam, dict) else cam
|
|
127
|
+
for cam in data.get("cameras", [])
|
|
128
|
+
]
|
|
129
|
+
|
|
130
|
+
return cls(
|
|
131
|
+
run_name=data["run_name"],
|
|
132
|
+
start_time=data["start_time"],
|
|
133
|
+
model_name=data.get("model_name", "unknown"),
|
|
134
|
+
model_path=data.get("model_path"),
|
|
135
|
+
model_type=data.get("model_type"),
|
|
136
|
+
task_name=data.get("task_name", "unknown"),
|
|
137
|
+
task_prompt=data.get("task_prompt"),
|
|
138
|
+
robot_name=data.get("robot_name", "unknown"),
|
|
139
|
+
robot_type=data.get("robot_type"),
|
|
140
|
+
cameras=cameras,
|
|
141
|
+
inference_freq=data.get("inference_freq"),
|
|
142
|
+
action_dim=data.get("action_dim"),
|
|
143
|
+
action_horizon=data.get("action_horizon"),
|
|
144
|
+
server_config=data.get("server_config", {}),
|
|
145
|
+
client_config=data.get("client_config", {}),
|
|
146
|
+
end_time=data.get("end_time"),
|
|
147
|
+
total_steps=data.get("total_steps", 0),
|
|
148
|
+
total_duration_s=data.get("total_duration_s"),
|
|
149
|
+
vlalab_version=data.get("vlalab_version", "0.1.0"),
|
|
150
|
+
framework_version=data.get("framework_version"),
|
|
151
|
+
extra=data.get("extra", {}),
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
@classmethod
|
|
155
|
+
def from_json(cls, json_str: str) -> "RunMeta":
|
|
156
|
+
return cls.from_dict(json.loads(json_str))
|
|
157
|
+
|
|
158
|
+
@classmethod
|
|
159
|
+
def load(cls, path: str) -> "RunMeta":
|
|
160
|
+
"""Load metadata from JSON file."""
|
|
161
|
+
with open(path, "r") as f:
|
|
162
|
+
return cls.from_json(f.read())
|
vlalab/schema/step.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""
|
|
2
|
+
VLA-Lab Step Schema
|
|
3
|
+
|
|
4
|
+
Defines the data structure for a single inference step.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass, field, asdict
|
|
8
|
+
from typing import List, Optional, Dict, Any, Union
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
import json
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class ImageRef:
|
|
15
|
+
"""Reference to an image artifact file."""
|
|
16
|
+
path: str # Relative path from run_dir
|
|
17
|
+
camera_name: str = "default"
|
|
18
|
+
shape: Optional[List[int]] = None # [H, W, C]
|
|
19
|
+
encoding: str = "jpeg"
|
|
20
|
+
|
|
21
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
22
|
+
return asdict(self)
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def from_dict(cls, data: Dict[str, Any]) -> "ImageRef":
|
|
26
|
+
return cls(**data)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class ObsData:
|
|
31
|
+
"""Observation data for a step."""
|
|
32
|
+
state: List[float] = field(default_factory=list) # Low-dim state (pose, gripper, etc.)
|
|
33
|
+
images: List[ImageRef] = field(default_factory=list) # Image references
|
|
34
|
+
|
|
35
|
+
# Optional detailed state breakdown
|
|
36
|
+
pose: Optional[List[float]] = None # [x, y, z, qx, qy, qz, qw] or [x, y, z, qw, qx, qy, qz]
|
|
37
|
+
gripper: Optional[float] = None
|
|
38
|
+
|
|
39
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
40
|
+
d = {
|
|
41
|
+
"state": self.state,
|
|
42
|
+
"images": [img.to_dict() for img in self.images],
|
|
43
|
+
}
|
|
44
|
+
if self.pose is not None:
|
|
45
|
+
d["pose"] = self.pose
|
|
46
|
+
if self.gripper is not None:
|
|
47
|
+
d["gripper"] = self.gripper
|
|
48
|
+
return d
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def from_dict(cls, data: Dict[str, Any]) -> "ObsData":
|
|
52
|
+
images = [ImageRef.from_dict(img) for img in data.get("images", [])]
|
|
53
|
+
return cls(
|
|
54
|
+
state=data.get("state", []),
|
|
55
|
+
images=images,
|
|
56
|
+
pose=data.get("pose"),
|
|
57
|
+
gripper=data.get("gripper"),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class ActionData:
|
|
63
|
+
"""Action data for a step."""
|
|
64
|
+
values: List[List[float]] = field(default_factory=list) # Action chunk: [[a1], [a2], ...]
|
|
65
|
+
|
|
66
|
+
# Optional metadata
|
|
67
|
+
action_dim: Optional[int] = None
|
|
68
|
+
chunk_size: Optional[int] = None
|
|
69
|
+
|
|
70
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
71
|
+
d = {"values": self.values}
|
|
72
|
+
if self.action_dim is not None:
|
|
73
|
+
d["action_dim"] = self.action_dim
|
|
74
|
+
if self.chunk_size is not None:
|
|
75
|
+
d["chunk_size"] = self.chunk_size
|
|
76
|
+
return d
|
|
77
|
+
|
|
78
|
+
@classmethod
|
|
79
|
+
def from_dict(cls, data: Dict[str, Any]) -> "ActionData":
|
|
80
|
+
return cls(
|
|
81
|
+
values=data.get("values", []),
|
|
82
|
+
action_dim=data.get("action_dim"),
|
|
83
|
+
chunk_size=data.get("chunk_size"),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class TimingData:
|
|
89
|
+
"""Timing data for a step (all times in milliseconds or Unix timestamps)."""
|
|
90
|
+
# Timestamps (Unix time, float)
|
|
91
|
+
client_send: Optional[float] = None
|
|
92
|
+
server_recv: Optional[float] = None
|
|
93
|
+
infer_start: Optional[float] = None
|
|
94
|
+
infer_end: Optional[float] = None
|
|
95
|
+
send_timestamp: Optional[float] = None
|
|
96
|
+
|
|
97
|
+
# Computed latencies (milliseconds)
|
|
98
|
+
transport_latency_ms: Optional[float] = None
|
|
99
|
+
inference_latency_ms: Optional[float] = None
|
|
100
|
+
total_latency_ms: Optional[float] = None
|
|
101
|
+
message_interval_ms: Optional[float] = None
|
|
102
|
+
|
|
103
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
104
|
+
d = {}
|
|
105
|
+
for k, v in asdict(self).items():
|
|
106
|
+
if v is not None:
|
|
107
|
+
d[k] = v
|
|
108
|
+
return d
|
|
109
|
+
|
|
110
|
+
@classmethod
|
|
111
|
+
def from_dict(cls, data: Dict[str, Any]) -> "TimingData":
|
|
112
|
+
return cls(
|
|
113
|
+
client_send=data.get("client_send"),
|
|
114
|
+
server_recv=data.get("server_recv"),
|
|
115
|
+
infer_start=data.get("infer_start"),
|
|
116
|
+
infer_end=data.get("infer_end"),
|
|
117
|
+
send_timestamp=data.get("send_timestamp"),
|
|
118
|
+
transport_latency_ms=data.get("transport_latency_ms"),
|
|
119
|
+
inference_latency_ms=data.get("inference_latency_ms"),
|
|
120
|
+
total_latency_ms=data.get("total_latency_ms"),
|
|
121
|
+
message_interval_ms=data.get("message_interval_ms"),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
def compute_latencies(self):
|
|
125
|
+
"""Compute latency values from timestamps."""
|
|
126
|
+
if self.server_recv is not None and self.client_send is not None:
|
|
127
|
+
self.transport_latency_ms = (self.server_recv - self.client_send) * 1000
|
|
128
|
+
|
|
129
|
+
if self.infer_end is not None and self.infer_start is not None:
|
|
130
|
+
self.inference_latency_ms = (self.infer_end - self.infer_start) * 1000
|
|
131
|
+
|
|
132
|
+
if self.send_timestamp is not None and self.client_send is not None:
|
|
133
|
+
self.total_latency_ms = (self.send_timestamp - self.client_send) * 1000
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass
|
|
137
|
+
class StepRecord:
|
|
138
|
+
"""A single step record in the inference log."""
|
|
139
|
+
step_idx: int
|
|
140
|
+
obs: ObsData = field(default_factory=ObsData)
|
|
141
|
+
action: ActionData = field(default_factory=ActionData)
|
|
142
|
+
timing: TimingData = field(default_factory=TimingData)
|
|
143
|
+
|
|
144
|
+
# Optional fields
|
|
145
|
+
tags: Dict[str, Any] = field(default_factory=dict)
|
|
146
|
+
prompt: Optional[str] = None # For language-conditioned models
|
|
147
|
+
|
|
148
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
149
|
+
d = {
|
|
150
|
+
"step_idx": self.step_idx,
|
|
151
|
+
"obs": self.obs.to_dict(),
|
|
152
|
+
"action": self.action.to_dict(),
|
|
153
|
+
"timing": self.timing.to_dict(),
|
|
154
|
+
}
|
|
155
|
+
if self.tags:
|
|
156
|
+
d["tags"] = self.tags
|
|
157
|
+
if self.prompt is not None:
|
|
158
|
+
d["prompt"] = self.prompt
|
|
159
|
+
return d
|
|
160
|
+
|
|
161
|
+
def to_json(self) -> str:
|
|
162
|
+
return json.dumps(self.to_dict())
|
|
163
|
+
|
|
164
|
+
@classmethod
|
|
165
|
+
def from_dict(cls, data: Dict[str, Any]) -> "StepRecord":
|
|
166
|
+
return cls(
|
|
167
|
+
step_idx=data["step_idx"],
|
|
168
|
+
obs=ObsData.from_dict(data.get("obs", {})),
|
|
169
|
+
action=ActionData.from_dict(data.get("action", {})),
|
|
170
|
+
timing=TimingData.from_dict(data.get("timing", {})),
|
|
171
|
+
tags=data.get("tags", {}),
|
|
172
|
+
prompt=data.get("prompt"),
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
@classmethod
|
|
176
|
+
def from_json(cls, json_str: str) -> "StepRecord":
|
|
177
|
+
return cls.from_dict(json.loads(json_str))
|
vlalab/viz/__init__.py
ADDED
vlalab/viz/mpl_fonts.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Matplotlib 字体配置工具
|
|
3
|
+
|
|
4
|
+
目标:
|
|
5
|
+
- 自动选择可用的中文字体(优先 WenQuanYi / Noto / Source Han)
|
|
6
|
+
- 必要时通过字体文件路径 addfont(绕过 matplotlib 缓存导致的"明明装了字体却找不到")
|
|
7
|
+
- 抑制常见的 Glyph missing / findfont 噪音警告
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Iterable, Optional
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class FontSetupResult:
|
|
18
|
+
chosen_font: Optional[str]
|
|
19
|
+
available_chinese_like_fonts: list[str]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _unique_preserve_order(items: Iterable[str]) -> list[str]:
|
|
23
|
+
seen: set[str] = set()
|
|
24
|
+
out: list[str] = []
|
|
25
|
+
for x in items:
|
|
26
|
+
if x and x not in seen:
|
|
27
|
+
out.append(x)
|
|
28
|
+
seen.add(x)
|
|
29
|
+
return out
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def setup_matplotlib_fonts(verbose: bool = True) -> FontSetupResult:
|
|
33
|
+
"""
|
|
34
|
+
配置 matplotlib 中文字体。
|
|
35
|
+
|
|
36
|
+
返回:
|
|
37
|
+
- chosen_font: 选中的字体名称;若为 None 表示未找到合适中文字体
|
|
38
|
+
- available_chinese_like_fonts: 检测到的"疑似中文字体"列表(用于诊断)
|
|
39
|
+
"""
|
|
40
|
+
import os
|
|
41
|
+
import warnings
|
|
42
|
+
|
|
43
|
+
import matplotlib as mpl
|
|
44
|
+
import matplotlib.font_manager as fm
|
|
45
|
+
|
|
46
|
+
# 抑制字体相关噪音 warning(不影响实际渲染)
|
|
47
|
+
warnings.filterwarnings("ignore", category=UserWarning, message=".*Glyph.*missing.*")
|
|
48
|
+
warnings.filterwarnings("ignore", category=UserWarning, message="findfont: Font family.*")
|
|
49
|
+
warnings.filterwarnings("ignore", category=UserWarning, module="matplotlib.font_manager")
|
|
50
|
+
|
|
51
|
+
# 常见中文字体文件路径(存在则 addfont,避免缓存/扫描问题)
|
|
52
|
+
font_paths = [
|
|
53
|
+
# WenQuanYi
|
|
54
|
+
"/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
|
|
55
|
+
"/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
|
|
56
|
+
# Noto CJK(不同发行版路径/后缀会不同,尽量覆盖)
|
|
57
|
+
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
|
58
|
+
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttf",
|
|
59
|
+
"/usr/share/fonts/opentype/noto/NotoSansCJKsc-Regular.otf",
|
|
60
|
+
"/usr/share/fonts/opentype/noto/NotoSansCJKtc-Regular.otf",
|
|
61
|
+
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
|
|
62
|
+
"/usr/share/fonts/truetype/noto/NotoSansCJKsc-Regular.otf",
|
|
63
|
+
"/usr/share/fonts/truetype/noto/NotoSansCJKtc-Regular.otf",
|
|
64
|
+
# Source Han
|
|
65
|
+
"/usr/share/fonts/opentype/source-han-sans/SourceHanSansCN-Regular.otf",
|
|
66
|
+
"/usr/share/fonts/opentype/source-han-sans/SourceHanSansSC-Regular.otf",
|
|
67
|
+
"/usr/share/fonts/opentype/source-han-sans/SourceHanSansTC-Regular.otf",
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
for p in font_paths:
|
|
71
|
+
if os.path.exists(p):
|
|
72
|
+
try:
|
|
73
|
+
fm.fontManager.addfont(p)
|
|
74
|
+
except Exception:
|
|
75
|
+
# addfont 失败不应中断主流程
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
# 重新获取字体列表(包含 addfont 后的新字体)
|
|
79
|
+
all_fonts = [f.name for f in fm.fontManager.ttflist]
|
|
80
|
+
all_fonts_lower = [n.lower() for n in all_fonts]
|
|
81
|
+
|
|
82
|
+
# 首选字体名称(按优先级)
|
|
83
|
+
preferred_names = [
|
|
84
|
+
"WenQuanYi Micro Hei",
|
|
85
|
+
"WenQuanYi Zen Hei",
|
|
86
|
+
"Noto Sans CJK SC",
|
|
87
|
+
"Noto Sans CJK TC",
|
|
88
|
+
"Source Han Sans CN",
|
|
89
|
+
"Source Han Sans SC",
|
|
90
|
+
"Source Han Sans TC",
|
|
91
|
+
# Windows/macOS 常见
|
|
92
|
+
"SimHei",
|
|
93
|
+
"Microsoft YaHei",
|
|
94
|
+
"PingFang SC",
|
|
95
|
+
"STHeiti",
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
chosen: Optional[str] = None
|
|
99
|
+
|
|
100
|
+
# 1) 先严格按名称匹配
|
|
101
|
+
available_set = set(all_fonts)
|
|
102
|
+
for name in preferred_names:
|
|
103
|
+
if name in available_set:
|
|
104
|
+
chosen = name
|
|
105
|
+
break
|
|
106
|
+
|
|
107
|
+
# 2) 再做一次"模糊匹配"(不同系统字体命名略有差异)
|
|
108
|
+
if chosen is None:
|
|
109
|
+
preferred_keywords = [
|
|
110
|
+
"wenquanyi",
|
|
111
|
+
"wqy",
|
|
112
|
+
"noto sans cjk",
|
|
113
|
+
"noto cjk",
|
|
114
|
+
"source han sans",
|
|
115
|
+
"simhei",
|
|
116
|
+
"yahei",
|
|
117
|
+
"pingfang",
|
|
118
|
+
"stheiti",
|
|
119
|
+
"cjk",
|
|
120
|
+
]
|
|
121
|
+
for kw in preferred_keywords:
|
|
122
|
+
for i, name_l in enumerate(all_fonts_lower):
|
|
123
|
+
if kw in name_l:
|
|
124
|
+
chosen = all_fonts[i]
|
|
125
|
+
break
|
|
126
|
+
if chosen is not None:
|
|
127
|
+
break
|
|
128
|
+
|
|
129
|
+
# 诊断信息:列出"疑似中文字体"
|
|
130
|
+
chinese_like = []
|
|
131
|
+
chinese_keywords = [
|
|
132
|
+
"wenquanyi",
|
|
133
|
+
"wqy",
|
|
134
|
+
"noto",
|
|
135
|
+
"cjk",
|
|
136
|
+
"source han",
|
|
137
|
+
"simhei",
|
|
138
|
+
"yahei",
|
|
139
|
+
"pingfang",
|
|
140
|
+
"heiti",
|
|
141
|
+
]
|
|
142
|
+
for i, name_l in enumerate(all_fonts_lower):
|
|
143
|
+
if any(k in name_l for k in chinese_keywords):
|
|
144
|
+
chinese_like.append(all_fonts[i])
|
|
145
|
+
chinese_like = sorted(set(chinese_like))
|
|
146
|
+
|
|
147
|
+
if chosen is not None:
|
|
148
|
+
# 注意:不要覆盖掉用户可能已有的 font.sans-serif 配置,采用"前置 + 去重"
|
|
149
|
+
current = list(mpl.rcParams.get("font.sans-serif", []))
|
|
150
|
+
mpl.rcParams["font.family"] = "sans-serif"
|
|
151
|
+
mpl.rcParams["font.sans-serif"] = _unique_preserve_order([chosen, *current, "DejaVu Sans"])
|
|
152
|
+
mpl.rcParams["axes.unicode_minus"] = False
|
|
153
|
+
if verbose:
|
|
154
|
+
print(f"[字体] 使用字体: {chosen}")
|
|
155
|
+
else:
|
|
156
|
+
# 没找到中文字体也不报错,只提示(图里中文会是方块)
|
|
157
|
+
mpl.rcParams["axes.unicode_minus"] = False
|
|
158
|
+
if verbose:
|
|
159
|
+
print("[字体] 警告: 未找到可用中文字体,中文可能显示为方块")
|
|
160
|
+
|
|
161
|
+
return FontSetupResult(chosen_font=chosen, available_chinese_like_fonts=chinese_like)
|