OpenSTBench 0.2.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.
- openstbench/__init__.py +143 -0
- openstbench/dataset.py +199 -0
- openstbench/emotion_evaluator.py +262 -0
- openstbench/latency/__init__.py +4 -0
- openstbench/latency/agent.py +108 -0
- openstbench/latency/basics.py +57 -0
- openstbench/latency/cli.py +257 -0
- openstbench/latency/instance.py +176 -0
- openstbench/latency/metrics.py +418 -0
- openstbench/latency/utils.py +295 -0
- openstbench/paralinguistic_evaluator.py +1318 -0
- openstbench/speaker_similarity_evaluator.py +147 -0
- openstbench/speech_quality_evaluator.py +171 -0
- openstbench/translation_evaluator.py +257 -0
- openstbench-0.2.0.dist-info/METADATA +181 -0
- openstbench-0.2.0.dist-info/RECORD +18 -0
- openstbench-0.2.0.dist-info/WHEEL +5 -0
- openstbench-0.2.0.dist-info/top_level.txt +1 -0
openstbench/__init__.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Top-level exports for the ``openstbench`` package.
|
|
2
|
+
|
|
3
|
+
This module keeps package import resilient to optional dependency failures.
|
|
4
|
+
Importing ``openstbench`` should succeed even when a specific evaluator's
|
|
5
|
+
extra dependencies are missing; the failure is raised only when that symbol is
|
|
6
|
+
actually accessed.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from importlib import import_module
|
|
10
|
+
from typing import Dict, Tuple
|
|
11
|
+
|
|
12
|
+
__version__ = "0.2.0"
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"TranslationEvaluator",
|
|
16
|
+
"EmotionEvaluator",
|
|
17
|
+
"ParalinguisticEvaluator",
|
|
18
|
+
"BaseAudioEventPredictor",
|
|
19
|
+
"BaseAudioEventLocalizer",
|
|
20
|
+
"ClapAudioEventPredictor",
|
|
21
|
+
"ClapSlidingWindowEventLocalizer",
|
|
22
|
+
"EventAlignmentConfig",
|
|
23
|
+
"EventLocalization",
|
|
24
|
+
"EventLocalizationConfig",
|
|
25
|
+
"EventPrediction",
|
|
26
|
+
"EventPredictionConfig",
|
|
27
|
+
"ParalinguisticSample",
|
|
28
|
+
"load_paralinguistic_manifest",
|
|
29
|
+
"load_paralinguistic_samples",
|
|
30
|
+
"load_paralinguistic_audio_from_folder",
|
|
31
|
+
"build_paralinguistic_inputs",
|
|
32
|
+
"evaluate_paralinguistic_dataset",
|
|
33
|
+
"SpeechQualityEvaluator",
|
|
34
|
+
"LatencyEvaluator",
|
|
35
|
+
"SpeakerSimilarityEvaluator",
|
|
36
|
+
"GenericAgent",
|
|
37
|
+
"AgentPipeline",
|
|
38
|
+
"ReadAction",
|
|
39
|
+
"WriteAction",
|
|
40
|
+
"load_text_from_file_or_list",
|
|
41
|
+
"load_audio_from_folder",
|
|
42
|
+
"Dataset",
|
|
43
|
+
"load_dataset",
|
|
44
|
+
"list_datasets",
|
|
45
|
+
"get_dataset_info",
|
|
46
|
+
"create_dataset_from_json",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
_EXPORT_SPECS: Tuple[Tuple[str, Tuple[str, ...]], ...] = (
|
|
51
|
+
(
|
|
52
|
+
"translation_evaluator",
|
|
53
|
+
(
|
|
54
|
+
"TranslationEvaluator",
|
|
55
|
+
"load_audio_from_folder",
|
|
56
|
+
"load_text_from_file_or_list",
|
|
57
|
+
),
|
|
58
|
+
),
|
|
59
|
+
("emotion_evaluator", ("EmotionEvaluator",)),
|
|
60
|
+
(
|
|
61
|
+
"paralinguistic_evaluator",
|
|
62
|
+
(
|
|
63
|
+
"BaseAudioEventPredictor",
|
|
64
|
+
"BaseAudioEventLocalizer",
|
|
65
|
+
"ClapAudioEventPredictor",
|
|
66
|
+
"ClapSlidingWindowEventLocalizer",
|
|
67
|
+
"EventAlignmentConfig",
|
|
68
|
+
"EventLocalization",
|
|
69
|
+
"EventLocalizationConfig",
|
|
70
|
+
"EventPrediction",
|
|
71
|
+
"EventPredictionConfig",
|
|
72
|
+
"ParalinguisticEvaluator",
|
|
73
|
+
"ParalinguisticSample",
|
|
74
|
+
"build_paralinguistic_inputs",
|
|
75
|
+
"evaluate_paralinguistic_dataset",
|
|
76
|
+
"load_audio_from_folder",
|
|
77
|
+
"load_paralinguistic_manifest",
|
|
78
|
+
"load_paralinguistic_samples",
|
|
79
|
+
),
|
|
80
|
+
),
|
|
81
|
+
("speech_quality_evaluator", ("SpeechQualityEvaluator",)),
|
|
82
|
+
("speaker_similarity_evaluator", ("SpeakerSimilarityEvaluator",)),
|
|
83
|
+
(
|
|
84
|
+
"dataset",
|
|
85
|
+
(
|
|
86
|
+
"Dataset",
|
|
87
|
+
"create_dataset_from_json",
|
|
88
|
+
"get_dataset_info",
|
|
89
|
+
"list_datasets",
|
|
90
|
+
"load_dataset",
|
|
91
|
+
),
|
|
92
|
+
),
|
|
93
|
+
("latency.agent", ("AgentPipeline", "GenericAgent")),
|
|
94
|
+
("latency.basics", ("ReadAction", "WriteAction")),
|
|
95
|
+
("latency.cli", ("LatencyEvaluator",)),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
_IMPORT_ERRORS: Dict[str, Tuple[str, Exception]] = {}
|
|
100
|
+
_SYMBOL_TO_MODULE: Dict[str, str] = {}
|
|
101
|
+
|
|
102
|
+
for _module_name, _names in _EXPORT_SPECS:
|
|
103
|
+
for _name in _names:
|
|
104
|
+
if _module_name == "paralinguistic_evaluator" and _name == "load_audio_from_folder":
|
|
105
|
+
_SYMBOL_TO_MODULE["load_paralinguistic_audio_from_folder"] = _module_name
|
|
106
|
+
else:
|
|
107
|
+
_SYMBOL_TO_MODULE[_name] = _module_name
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _load_module_exports(module_name: str) -> None:
|
|
111
|
+
names = [name for name, owner in _SYMBOL_TO_MODULE.items() if owner == module_name]
|
|
112
|
+
try:
|
|
113
|
+
module = import_module(f".{module_name}", __name__)
|
|
114
|
+
except Exception as exc: # pragma: no cover - depends on optional deps
|
|
115
|
+
for name in names:
|
|
116
|
+
_IMPORT_ERRORS[name] = (module_name, exc)
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
for name in names:
|
|
120
|
+
if module_name == "paralinguistic_evaluator" and name == "load_paralinguistic_audio_from_folder":
|
|
121
|
+
globals()[name] = getattr(module, "load_audio_from_folder")
|
|
122
|
+
else:
|
|
123
|
+
globals()[name] = getattr(module, name)
|
|
124
|
+
_IMPORT_ERRORS.pop(name, None)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def __getattr__(name: str):
|
|
128
|
+
if name in _SYMBOL_TO_MODULE:
|
|
129
|
+
module_name = _SYMBOL_TO_MODULE[name]
|
|
130
|
+
_load_module_exports(module_name)
|
|
131
|
+
if name in globals():
|
|
132
|
+
return globals()[name]
|
|
133
|
+
if name in _IMPORT_ERRORS:
|
|
134
|
+
module_name, exc = _IMPORT_ERRORS[name]
|
|
135
|
+
raise ImportError(
|
|
136
|
+
f"Cannot import '{name}' from 'openstbench' because "
|
|
137
|
+
f"'openstbench.{module_name}' failed to load: {exc}"
|
|
138
|
+
) from exc
|
|
139
|
+
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def __dir__():
|
|
143
|
+
return sorted(set(globals()) | set(__all__))
|
openstbench/dataset.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""
|
|
2
|
+
内置数据集管理 - 支持音频文件下载
|
|
3
|
+
优先使用本地缓存,无则联网下载
|
|
4
|
+
"""
|
|
5
|
+
import os
|
|
6
|
+
import json
|
|
7
|
+
import urllib.request
|
|
8
|
+
import zipfile
|
|
9
|
+
import shutil
|
|
10
|
+
from typing import Dict, List, Optional, Union
|
|
11
|
+
|
|
12
|
+
# 数据集下载地址
|
|
13
|
+
DATASET_URLS = {
|
|
14
|
+
"zh-en-littleprince": "https://github.com/sjtuayj/OpenSTBench/releases/download/v0.1.0/zh-en-littleprince.zip",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
# 默认缓存目录
|
|
18
|
+
# DEFAULT_CACHE_DIR = os.path.expanduser("~/.datasets")
|
|
19
|
+
DEFAULT_CACHE_DIR = "./datasets"
|
|
20
|
+
|
|
21
|
+
class Dataset:
|
|
22
|
+
"""内置数据集类"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, data: List[Dict], base_dir: str):
|
|
25
|
+
self._data = data
|
|
26
|
+
self._base_dir = base_dir
|
|
27
|
+
|
|
28
|
+
def __len__(self) -> int:
|
|
29
|
+
return len(self._data)
|
|
30
|
+
|
|
31
|
+
def __getitem__(self, idx: int) -> Dict:
|
|
32
|
+
item = self._data[idx].copy()
|
|
33
|
+
if "source_speech_path" in item:
|
|
34
|
+
filename = os.path.basename(item["source_speech_path"])
|
|
35
|
+
item["source_speech_path"] = os.path.join(self._base_dir, "audio", filename)
|
|
36
|
+
return item
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def ids(self) -> List[str]:
|
|
40
|
+
return [item.get("id", f"sample_{i}") for i, item in enumerate(self._data)]
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def source_texts(self) -> List[str]:
|
|
44
|
+
return [item["source_text"] for item in self._data]
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def reference_texts(self) -> List[str]:
|
|
48
|
+
return [item["reference_text"] for item in self._data]
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def audio_paths(self) -> List[str]:
|
|
52
|
+
return [self[i].get("source_speech_path", "") for i in range(len(self))]
|
|
53
|
+
|
|
54
|
+
def verify_audio_files(self) -> Dict[str, Union[int, List[str]]]:
|
|
55
|
+
"""验证音频文件完整性"""
|
|
56
|
+
missing = [p for p in self.audio_paths if not os.path.exists(p)]
|
|
57
|
+
return {
|
|
58
|
+
"total": len(self),
|
|
59
|
+
"found": len(self) - len(missing),
|
|
60
|
+
"missing": len(missing),
|
|
61
|
+
"missing_files": missing,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def list_datasets() -> List[str]:
|
|
66
|
+
"""列出所有可用数据集"""
|
|
67
|
+
return list(DATASET_URLS.keys())
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _is_dataset_cached(name: str, cache_dir: str) -> bool:
|
|
71
|
+
"""检查数据集是否已下载"""
|
|
72
|
+
dataset_dir = os.path.join(cache_dir, name)
|
|
73
|
+
data_file = os.path.join(dataset_dir, "dataset_paired.json")
|
|
74
|
+
audio_dir = os.path.join(dataset_dir, "audio")
|
|
75
|
+
return os.path.exists(data_file) and os.path.exists(audio_dir)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def get_dataset_info(name: str, cache_dir: Optional[str] = None) -> Dict:
|
|
79
|
+
"""获取数据集信息"""
|
|
80
|
+
if name not in DATASET_URLS:
|
|
81
|
+
raise ValueError(f"未知数据集: {name}")
|
|
82
|
+
|
|
83
|
+
cache_dir = cache_dir or DEFAULT_CACHE_DIR
|
|
84
|
+
is_cached = _is_dataset_cached(name, cache_dir)
|
|
85
|
+
|
|
86
|
+
info = {
|
|
87
|
+
"name": name,
|
|
88
|
+
"url": DATASET_URLS[name],
|
|
89
|
+
"cache_dir": os.path.join(cache_dir, name),
|
|
90
|
+
"is_downloaded": is_cached,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if is_cached:
|
|
94
|
+
dataset = load_dataset(name, cache_dir=cache_dir)
|
|
95
|
+
info["num_samples"] = len(dataset)
|
|
96
|
+
verify = dataset.verify_audio_files()
|
|
97
|
+
info["audio_complete"] = verify["missing"] == 0
|
|
98
|
+
|
|
99
|
+
return info
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def load_dataset(
|
|
103
|
+
name: str,
|
|
104
|
+
cache_dir: Optional[str] = None,
|
|
105
|
+
force_download: bool = False,
|
|
106
|
+
) -> Dataset:
|
|
107
|
+
"""
|
|
108
|
+
加载数据集(优先本地,无则下载)
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
name: 数据集名称
|
|
112
|
+
cache_dir: 缓存目录
|
|
113
|
+
force_download: 强制重新下载
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
Dataset 对象
|
|
117
|
+
"""
|
|
118
|
+
if name not in DATASET_URLS:
|
|
119
|
+
available = ", ".join(DATASET_URLS.keys())
|
|
120
|
+
raise ValueError(f"未知数据集: {name}。可用: {available}")
|
|
121
|
+
|
|
122
|
+
cache_dir = cache_dir or DEFAULT_CACHE_DIR
|
|
123
|
+
dataset_dir = os.path.join(cache_dir, name)
|
|
124
|
+
data_file = os.path.join(dataset_dir, "dataset_paired.json")
|
|
125
|
+
|
|
126
|
+
# 检查本地缓存
|
|
127
|
+
if _is_dataset_cached(name, cache_dir) and not force_download:
|
|
128
|
+
print(f"✅ [Local] 使用本地数据集: {name}")
|
|
129
|
+
print(f" 路径: {dataset_dir}")
|
|
130
|
+
else:
|
|
131
|
+
print(f"⏳ [Online] 下载数据集: {name}")
|
|
132
|
+
_download_dataset(name, cache_dir)
|
|
133
|
+
|
|
134
|
+
# 加载数据
|
|
135
|
+
with open(data_file, "r", encoding="utf-8") as f:
|
|
136
|
+
data = json.load(f)
|
|
137
|
+
|
|
138
|
+
dataset = Dataset(data=data, base_dir=dataset_dir)
|
|
139
|
+
|
|
140
|
+
# 验证完整性
|
|
141
|
+
verify = dataset.verify_audio_files()
|
|
142
|
+
if verify["missing"] > 0:
|
|
143
|
+
print(f" ⚠️ 缺少 {verify['missing']} 个音频文件")
|
|
144
|
+
else:
|
|
145
|
+
print(f" ✅ 数据完整 ({verify['total']} 条样本, 音频齐全)")
|
|
146
|
+
|
|
147
|
+
return dataset
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _download_dataset(name: str, cache_dir: str):
|
|
151
|
+
"""下载数据集"""
|
|
152
|
+
url = DATASET_URLS[name]
|
|
153
|
+
dataset_dir = os.path.join(cache_dir, name)
|
|
154
|
+
zip_path = os.path.join(cache_dir, f"{name}.zip")
|
|
155
|
+
|
|
156
|
+
os.makedirs(cache_dir, exist_ok=True)
|
|
157
|
+
|
|
158
|
+
if os.path.exists(dataset_dir):
|
|
159
|
+
shutil.rmtree(dataset_dir)
|
|
160
|
+
|
|
161
|
+
print(f" URL: {url}")
|
|
162
|
+
|
|
163
|
+
try:
|
|
164
|
+
urllib.request.urlretrieve(url, zip_path, _download_progress)
|
|
165
|
+
print()
|
|
166
|
+
|
|
167
|
+
print(f" 📦 解压中...")
|
|
168
|
+
os.makedirs(dataset_dir, exist_ok=True)
|
|
169
|
+
with zipfile.ZipFile(zip_path, 'r') as zf:
|
|
170
|
+
zf.extractall(dataset_dir)
|
|
171
|
+
|
|
172
|
+
os.remove(zip_path)
|
|
173
|
+
print(f" ✅ 下载完成: {dataset_dir}")
|
|
174
|
+
|
|
175
|
+
except Exception as e:
|
|
176
|
+
if os.path.exists(zip_path):
|
|
177
|
+
os.remove(zip_path)
|
|
178
|
+
if os.path.exists(dataset_dir):
|
|
179
|
+
shutil.rmtree(dataset_dir)
|
|
180
|
+
raise RuntimeError(f"下载失败: {e}")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _download_progress(block_num, block_size, total_size):
|
|
184
|
+
"""下载进度条"""
|
|
185
|
+
downloaded = block_num * block_size
|
|
186
|
+
if total_size > 0:
|
|
187
|
+
percent = min(100, downloaded * 100 // total_size)
|
|
188
|
+
bar_len = 40
|
|
189
|
+
filled = int(bar_len * percent // 100)
|
|
190
|
+
bar = "█" * filled + "░" * (bar_len - filled)
|
|
191
|
+
print(f"\r [{bar}] {percent}%", end="", flush=True)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def create_dataset_from_json(json_path: str) -> Dataset:
|
|
195
|
+
"""从本地 JSON 创建数据集"""
|
|
196
|
+
with open(json_path, "r", encoding="utf-8") as f:
|
|
197
|
+
data = json.load(f)
|
|
198
|
+
base_dir = os.path.dirname(os.path.abspath(json_path))
|
|
199
|
+
return Dataset(data=data, base_dir=base_dir)
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
import torch
|
|
4
|
+
import numpy as np
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import List, Dict, Optional, Union
|
|
7
|
+
from tqdm import tqdm
|
|
8
|
+
from numpy.linalg import norm
|
|
9
|
+
|
|
10
|
+
def _load_data_list(
|
|
11
|
+
input_data: Union[str, List[str]],
|
|
12
|
+
name: str,
|
|
13
|
+
target_type: str = "audio"
|
|
14
|
+
) -> List[str]:
|
|
15
|
+
if isinstance(input_data, list):
|
|
16
|
+
return input_data
|
|
17
|
+
if not isinstance(input_data, str):
|
|
18
|
+
raise ValueError(f"{name} 必须是 文件路径(str) 或 列表(List[str])")
|
|
19
|
+
|
|
20
|
+
path = Path(input_data)
|
|
21
|
+
if not path.exists():
|
|
22
|
+
raise FileNotFoundError(f"{name} 文件不存在: {input_data}")
|
|
23
|
+
|
|
24
|
+
suffix = path.suffix.lower()
|
|
25
|
+
|
|
26
|
+
if suffix == ".json":
|
|
27
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
28
|
+
data = json.load(f)
|
|
29
|
+
|
|
30
|
+
if target_type == "audio":
|
|
31
|
+
candidate_keys = ["audio", "path", "file", "wav", "mp3"]
|
|
32
|
+
elif target_type == "label":
|
|
33
|
+
candidate_keys = ["label", "emotion", "class", "reference"]
|
|
34
|
+
else:
|
|
35
|
+
candidate_keys = ["text", "sentence", "content", "transcript"]
|
|
36
|
+
|
|
37
|
+
if isinstance(data, list):
|
|
38
|
+
if not data: return []
|
|
39
|
+
if isinstance(data[0], str): return data
|
|
40
|
+
if isinstance(data[0], dict):
|
|
41
|
+
for key in candidate_keys:
|
|
42
|
+
if key in data[0]:
|
|
43
|
+
return [item[key] for item in data]
|
|
44
|
+
raise ValueError(f"JSON 列表项中未找到常见{target_type}字段: {candidate_keys}")
|
|
45
|
+
|
|
46
|
+
if isinstance(data, dict):
|
|
47
|
+
plural_candidates = [k + "s" for k in candidate_keys]
|
|
48
|
+
plural_candidates += ["target_text", "hypothesis", "source_text"] if target_type == "text" else []
|
|
49
|
+
for key in plural_candidates:
|
|
50
|
+
if key in data:
|
|
51
|
+
return data[key]
|
|
52
|
+
raise ValueError(f"JSON 字典中未找到常见{target_type}列表字段")
|
|
53
|
+
raise ValueError("不支持的 JSON 格式")
|
|
54
|
+
else:
|
|
55
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
56
|
+
return [line.strip() for line in f if line.strip()]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _load_audio_from_folder(folder_path: str, extensions: tuple = (".wav", ".mp3", ".flac")) -> List[str]:
|
|
60
|
+
folder = Path(folder_path)
|
|
61
|
+
if not folder.exists():
|
|
62
|
+
raise FileNotFoundError(f"文件夹不存在: {folder_path}")
|
|
63
|
+
audio_files = []
|
|
64
|
+
for ext in extensions:
|
|
65
|
+
audio_files.extend(folder.glob(f"*{ext}"))
|
|
66
|
+
audio_files = sorted(audio_files, key=lambda x: x.stem)
|
|
67
|
+
if not audio_files:
|
|
68
|
+
raise ValueError(f"文件夹中没有音频文件: {folder_path}")
|
|
69
|
+
return [str(f) for f in audio_files]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class EmotionEvaluator:
|
|
73
|
+
"""
|
|
74
|
+
基于 Emotion2Vec+ 的情感评测器
|
|
75
|
+
支持:
|
|
76
|
+
[1] 跨语种保真度评测:基于 768-d 高维嵌入特征的 Cosine Similarity 保真度。
|
|
77
|
+
[2] 离散情感分类评测:基于模型 Zero-Shot 分类能力的识别准确度评测。
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
# 唯一依赖的基座模型
|
|
81
|
+
DEFAULT_E2V_MODEL = "iic/emotion2vec_plus_large"
|
|
82
|
+
|
|
83
|
+
def __init__(self,
|
|
84
|
+
e2v_model_path: Optional[str] = None,
|
|
85
|
+
custom_label_map: Optional[Dict[str, str]] = None,
|
|
86
|
+
device: Optional[str] = None):
|
|
87
|
+
|
|
88
|
+
self.device = device if device else ("cuda" if torch.cuda.is_available() else "cpu")
|
|
89
|
+
self.e2v_model_path = e2v_model_path or self.DEFAULT_E2V_MODEL
|
|
90
|
+
self.e2v_model = None
|
|
91
|
+
|
|
92
|
+
# 将用户的自定义标签映射统一全部小写,确保健壮性
|
|
93
|
+
self.custom_label_map = {k.lower(): v.lower() for k, v in (custom_label_map or {}).items()}
|
|
94
|
+
|
|
95
|
+
def _load_e2v_model(self):
|
|
96
|
+
if self.e2v_model is not None: return
|
|
97
|
+
print(f"⏳ 正在加载 Emotion2Vec+ 大模型: {self.e2v_model_path}")
|
|
98
|
+
try:
|
|
99
|
+
from funasr import AutoModel
|
|
100
|
+
self.e2v_model = AutoModel(model=self.e2v_model_path, device=self.device, disable_update=True)
|
|
101
|
+
print("✅ Emotion2Vec+ 大模型加载成功!")
|
|
102
|
+
except ImportError:
|
|
103
|
+
print("❌ Emotion2Vec+ 依赖缺失。请执行: pip install funasr modelscope")
|
|
104
|
+
except Exception as e:
|
|
105
|
+
print(f"❌ Emotion2Vec+ 分类模型加载失败: {e}")
|
|
106
|
+
|
|
107
|
+
# =============== 特征提取部分 ===============
|
|
108
|
+
|
|
109
|
+
def _extract_e2v_embeddings(self, audio_paths: List[str]) -> List[np.ndarray]:
|
|
110
|
+
"""提取 Emotion2Vec+ 的综合高维特征 Embedding 用于衡量总体保真度相似度"""
|
|
111
|
+
self._load_e2v_model()
|
|
112
|
+
if not self.e2v_model: return [np.zeros(768)] * len(audio_paths)
|
|
113
|
+
|
|
114
|
+
results = []
|
|
115
|
+
for path in tqdm(audio_paths, desc="🔍 音频 E2V 高维特征", unit="file"):
|
|
116
|
+
try:
|
|
117
|
+
res = self.e2v_model.generate(path, output_dir=None, granularity="utterance", extract_embedding=True)
|
|
118
|
+
if isinstance(res, list) and len(res) > 0 and 'feats' in res[0]:
|
|
119
|
+
emb = np.array(res[0]['feats']).squeeze()
|
|
120
|
+
results.append(emb)
|
|
121
|
+
else:
|
|
122
|
+
results.append(np.zeros(768))
|
|
123
|
+
except Exception as e:
|
|
124
|
+
results.append(np.zeros(768))
|
|
125
|
+
return results
|
|
126
|
+
|
|
127
|
+
def _extract_cls_emotion(self, audio_paths: List[str]) -> List[str]:
|
|
128
|
+
"""提取 Emotion2Vec+ 的离散情感分类预测结果"""
|
|
129
|
+
self._load_e2v_model()
|
|
130
|
+
if not self.e2v_model: return ["unknown"] * len(audio_paths)
|
|
131
|
+
|
|
132
|
+
results = []
|
|
133
|
+
for path in tqdm(audio_paths, desc="🔍 音频 E2V 离散分类标签 예측", unit="file"):
|
|
134
|
+
try:
|
|
135
|
+
# 默认分类模式,返回具体的 label
|
|
136
|
+
res = self.e2v_model.generate(path, output_dir=None, granularity="utterance", extract_embedding=False)
|
|
137
|
+
if isinstance(res, list) and len(res) > 0 and 'labels' in res[0] and 'scores' in res[0]:
|
|
138
|
+
labels = res[0]['labels']
|
|
139
|
+
scores = res[0]['scores']
|
|
140
|
+
|
|
141
|
+
# 1. 找到得分最高的标签索引
|
|
142
|
+
best_idx = np.argmax(scores)
|
|
143
|
+
best_label_str = labels[best_idx]
|
|
144
|
+
|
|
145
|
+
# 2. 解析出英文部分 (因为输出格式是 '生气/angry')
|
|
146
|
+
if "/" in best_label_str:
|
|
147
|
+
label = best_label_str.split("/")[-1].lower()
|
|
148
|
+
else:
|
|
149
|
+
label = best_label_str.lower()
|
|
150
|
+
|
|
151
|
+
# 3. 清洗可能存在的特殊占位符
|
|
152
|
+
label = label.replace("<|", "").replace("|>", "").strip()
|
|
153
|
+
else:
|
|
154
|
+
label = "unknown"
|
|
155
|
+
|
|
156
|
+
# 应用自定义对齐
|
|
157
|
+
if label in self.custom_label_map:
|
|
158
|
+
label = self.custom_label_map[label]
|
|
159
|
+
results.append(label)
|
|
160
|
+
except Exception as e:
|
|
161
|
+
results.append("unknown")
|
|
162
|
+
return results
|
|
163
|
+
|
|
164
|
+
# =============== 评测主入口 ===============
|
|
165
|
+
|
|
166
|
+
def evaluate_all(self,
|
|
167
|
+
source_audio: Optional[Union[List[str], str]] = None,
|
|
168
|
+
target_audio: Optional[Union[List[str], str]] = None,
|
|
169
|
+
reference_labels: Optional[Union[List[str], str]] = None,
|
|
170
|
+
verbose: bool = True) -> Dict[str, float]:
|
|
171
|
+
"""
|
|
172
|
+
动态融合引擎入口:
|
|
173
|
+
- 仅传 target_audio 与 reference_labels -> 计算 Accuracy
|
|
174
|
+
- 仅传 source_audio 与 target_audio -> 计算 E2V 特征保真度
|
|
175
|
+
"""
|
|
176
|
+
|
|
177
|
+
if target_audio is None:
|
|
178
|
+
if source_audio is not None and reference_labels is not None:
|
|
179
|
+
target_audio = source_audio
|
|
180
|
+
source_audio = None
|
|
181
|
+
else:
|
|
182
|
+
raise ValueError("🚨 必须提供 target_audio 参数运行评测。")
|
|
183
|
+
|
|
184
|
+
if isinstance(target_audio, str) and os.path.isdir(target_audio):
|
|
185
|
+
tgt_paths = _load_audio_from_folder(target_audio)
|
|
186
|
+
else:
|
|
187
|
+
tgt_paths = _load_data_list(target_audio, "Target Audio Paths", "audio")
|
|
188
|
+
|
|
189
|
+
num_samples = len(tgt_paths)
|
|
190
|
+
if num_samples == 0:
|
|
191
|
+
raise ValueError("没有找到目标音频数据,评测停止。")
|
|
192
|
+
|
|
193
|
+
run_fidelity = source_audio is not None
|
|
194
|
+
run_classification = reference_labels is not None
|
|
195
|
+
results = {}
|
|
196
|
+
|
|
197
|
+
# ==================== 分支 1:保真度相似度计算 ====================
|
|
198
|
+
if run_fidelity:
|
|
199
|
+
if verbose: print(f"\n📝 启动【跨模态综合保真度运算】 ({num_samples} 个 Source-Target 音频距离比对)...")
|
|
200
|
+
|
|
201
|
+
if isinstance(source_audio, str) and os.path.isdir(source_audio):
|
|
202
|
+
src_paths = _load_audio_from_folder(source_audio)
|
|
203
|
+
else:
|
|
204
|
+
src_paths = _load_data_list(source_audio, "Source Audio Paths", "audio")
|
|
205
|
+
|
|
206
|
+
if len(src_paths) != num_samples:
|
|
207
|
+
raise ValueError(f"数目不一致: Source ({len(src_paths)}) != Target ({num_samples})")
|
|
208
|
+
|
|
209
|
+
src_e2v_embs = self._extract_e2v_embeddings(src_paths)
|
|
210
|
+
tgt_e2v_embs = self._extract_e2v_embeddings(tgt_paths)
|
|
211
|
+
|
|
212
|
+
e2v_cosine_sim_total = 0.0
|
|
213
|
+
valid_e2v_count = 0
|
|
214
|
+
|
|
215
|
+
for i in range(num_samples):
|
|
216
|
+
s_emb = src_e2v_embs[i]
|
|
217
|
+
t_emb = tgt_e2v_embs[i]
|
|
218
|
+
|
|
219
|
+
n_s = norm(s_emb)
|
|
220
|
+
n_t = norm(t_emb)
|
|
221
|
+
if n_s > 0 and n_t > 0:
|
|
222
|
+
sim = np.dot(s_emb, t_emb) / (n_s * n_t)
|
|
223
|
+
e2v_cosine_sim_total += float(sim)
|
|
224
|
+
valid_e2v_count += 1
|
|
225
|
+
|
|
226
|
+
final_cosine = (e2v_cosine_sim_total / valid_e2v_count) if valid_e2v_count > 0 else 0.0
|
|
227
|
+
results["Emotion2Vec_Cosine_Similarity"] = round(final_cosine, 4)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# ==================== 分支 2:离散情感识别计算 ====================
|
|
231
|
+
if run_classification:
|
|
232
|
+
if verbose: print(f"\n📝 启动【Emotion2Vec+ 离散分类准确率运算】 ({num_samples} 个特征识别与金标准比对)...")
|
|
233
|
+
refs = _load_data_list(reference_labels, "Reference Labels", "label")
|
|
234
|
+
|
|
235
|
+
if len(refs) != num_samples:
|
|
236
|
+
raise ValueError(f"参考标签数量 ({len(refs)}) 与目标音频数量 ({num_samples}) 不匹配!")
|
|
237
|
+
|
|
238
|
+
preds = self._extract_cls_emotion(tgt_paths)
|
|
239
|
+
|
|
240
|
+
correct = 0
|
|
241
|
+
for p, r in zip(preds, refs):
|
|
242
|
+
if p.strip() == r.strip().lower():
|
|
243
|
+
correct += 1
|
|
244
|
+
|
|
245
|
+
acc = correct / len(refs) if len(refs) > 0 else 0.0
|
|
246
|
+
results["Audio_Emotion_Accuracy"] = round(acc, 4)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
# ==================== 输出反馈 ====================
|
|
250
|
+
if verbose:
|
|
251
|
+
print("\n📊 [EmotionEvaluator] Emotion2Vec+ 综合评测报告:")
|
|
252
|
+
print(f" - 有效评测样本量: {num_samples}条")
|
|
253
|
+
|
|
254
|
+
if run_fidelity:
|
|
255
|
+
print("\n [深度学习综合特征保真度] (数值范围[-1, 1],越接近 1.00 代表整体情感越相似)")
|
|
256
|
+
print(f" - Emotion2Vec+ 综合情感特征余弦相似度: {results['Emotion2Vec_Cosine_Similarity']:.4f}")
|
|
257
|
+
|
|
258
|
+
if run_classification:
|
|
259
|
+
print("\n [深度学习离散情感识别分类率] (数值越高代表预设标签识别越准)")
|
|
260
|
+
print(f" - Audio Emotion Accuracy (离散情感识别准确率): {results['Audio_Emotion_Accuracy']:.2%}")
|
|
261
|
+
|
|
262
|
+
return results
|