This commit is contained in:
yinsx
2025-12-24 15:15:13 +08:00
commit df085f3f8f
27 changed files with 1926 additions and 0 deletions

View File

@ -0,0 +1,64 @@
# Text to BlendShapes API
将文字转换为52个形态键的API服务
## 功能
1. 文字 → 音频 (使用 gTTS)
2. 音频 → CSV (使用 Audio2Face)
3. CSV → 52个形态键数据
## 安装
```bash
cd a2f_api
pip install -r requirements.txt
```
## 使用
```bash
python api.py
```
服务将在 `http://localhost:5001` 启动
## API 接口
### POST /text-to-blendshapes
**请求:**
```json
{
"text": "你好世界",
"language": "zh-CN"
}
```
**响应:**
```json
{
"success": true,
"frames": [
{
"timeCode": 0.0,
"blendShapes": {
"EyeBlinkLeft": 0.0,
"EyeLookDownLeft": 0.0,
...
"TongueOut": 0.0
}
}
],
"audio_path": "...",
"csv_path": "..."
}
```
## 文件说明
- `tts_service.py` - 文字转音频服务
- `a2f_service.py` - Audio2Face包装器
- `blend_shape_parser.py` - CSV解析器
- `text_to_blendshapes_service.py` - 主服务
- `api.py` - Flask API

View File

@ -0,0 +1,40 @@
import subprocess
import sys
import os
from pathlib import Path
import glob
class A2FService:
def __init__(self, a2f_url="192.168.1.39:52000"):
self.base_dir = Path(__file__).parent.parent.parent
self.output_dir = self.base_dir / "data" / "output"
self.a2f_script = self.base_dir / "external" / "Audio2Face-3D-Samples" / "scripts" / "audio2face_3d_microservices_interaction_app" / "a2f_3d.py"
self.config_file = self.base_dir / "external" / "Audio2Face-3D-Samples" / "scripts" / "audio2face_3d_microservices_interaction_app" / "config" / "config_james.yml"
self.a2f_url = a2f_url
os.makedirs(self.output_dir, exist_ok=True)
def audio_to_csv(self, audio_path: str) -> str:
cmd = [
sys.executable,
str(self.a2f_script),
"run_inference",
audio_path,
str(self.config_file),
"--url",
self.a2f_url
]
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, cwd=str(self.output_dir))
if result.returncode != 0:
raise RuntimeError(f"A2F inference failed: {result.stdout}")
output_dirs = sorted(glob.glob(str(self.output_dir / "output_*")))
if not output_dirs:
raise RuntimeError("No output directory found")
csv_path = os.path.join(output_dirs[-1], "animation_frames.csv")
if not os.path.exists(csv_path):
raise RuntimeError(f"CSV file not found: {csv_path}")
return csv_path

34
services/a2f_api/api.py Normal file
View File

@ -0,0 +1,34 @@
from flask import Flask, request, jsonify
from flask_cors import CORS
from text_to_blendshapes_service import TextToBlendShapesService
app = Flask(__name__)
CORS(app)
@app.route('/health', methods=['GET'])
def health():
return jsonify({'status': 'ok'})
@app.route('/text-to-blendshapes', methods=['POST'])
def text_to_blendshapes():
try:
data = request.get_json()
if not data or 'text' not in data:
return jsonify({'success': False, 'error': 'Missing text'}), 400
text = data['text']
language = data.get('language', 'zh-CN')
service = TextToBlendShapesService(lang=language)
result = service.text_to_blend_shapes(text)
return jsonify(result)
except Exception as e:
import traceback
traceback.print_exc()
return jsonify({'success': False, 'error': str(e)}), 500
if __name__ == '__main__':
print("Text to BlendShapes API: http://localhost:5001")
app.run(host='0.0.0.0', port=5001, debug=True)

View File

@ -0,0 +1,31 @@
import csv
class BlendShapeParser:
BLEND_SHAPE_KEYS = [
'EyeBlinkLeft', 'EyeLookDownLeft', 'EyeLookInLeft', 'EyeLookOutLeft', 'EyeLookUpLeft',
'EyeSquintLeft', 'EyeWideLeft', 'EyeBlinkRight', 'EyeLookDownRight', 'EyeLookInRight',
'EyeLookOutRight', 'EyeLookUpRight', 'EyeSquintRight', 'EyeWideRight', 'JawForward',
'JawLeft', 'JawRight', 'JawOpen', 'MouthClose', 'MouthFunnel', 'MouthPucker',
'MouthLeft', 'MouthRight', 'MouthSmileLeft', 'MouthSmileRight', 'MouthFrownLeft',
'MouthFrownRight', 'MouthDimpleLeft', 'MouthDimpleRight', 'MouthStretchLeft',
'MouthStretchRight', 'MouthRollLower', 'MouthRollUpper', 'MouthShrugLower',
'MouthShrugUpper', 'MouthPressLeft', 'MouthPressRight', 'MouthLowerDownLeft',
'MouthLowerDownRight', 'MouthUpperUpLeft', 'MouthUpperUpRight', 'BrowDownLeft',
'BrowDownRight', 'BrowInnerUp', 'BrowOuterUpLeft', 'BrowOuterUpRight', 'CheekPuff',
'CheekSquintLeft', 'CheekSquintRight', 'NoseSneerLeft', 'NoseSneerRight', 'TongueOut'
]
@staticmethod
def csv_to_blend_shapes(csv_path: str):
frames = []
with open(csv_path, 'r') as f:
reader = csv.DictReader(f)
for row in reader:
frame = {'timeCode': float(row['timeCode']), 'blendShapes': {}}
for key in BlendShapeParser.BLEND_SHAPE_KEYS:
col_name = f'blendShapes.{key}'
if col_name in row:
frame['blendShapes'][key] = float(row[col_name])
frames.append(frame)
return frames

View File

@ -0,0 +1,3 @@
flask>=3.0.0
flask-cors>=4.0.0
pyttsx3>=2.90

View File

@ -0,0 +1,31 @@
import os
import tempfile
from datetime import datetime
from tts_service import TTSService
from a2f_service import A2FService
from blend_shape_parser import BlendShapeParser
class TextToBlendShapesService:
def __init__(self, lang='zh-CN', a2f_url="192.168.1.39:52000"):
self.tts = TTSService(lang=lang)
self.a2f = A2FService(a2f_url=a2f_url)
self.parser = BlendShapeParser()
def text_to_blend_shapes(self, text: str, output_dir: str = None):
if output_dir is None:
output_dir = tempfile.gettempdir()
os.makedirs(output_dir, exist_ok=True)
timestamp = datetime.now().strftime('%Y%m%d%H%M%S')
audio_path = os.path.join(output_dir, f'tts_{timestamp}.wav')
self.tts.text_to_audio(text, audio_path)
csv_path = self.a2f.audio_to_csv(audio_path)
frames = self.parser.csv_to_blend_shapes(csv_path)
return {
'success': True,
'frames': frames,
'audio_path': audio_path,
'csv_path': csv_path
}

View File

@ -0,0 +1,20 @@
import os
import pyttsx3
class TTSService:
def __init__(self, lang='zh-CN'):
self.lang = lang
self.engine = pyttsx3.init()
if lang == 'zh-CN':
voices = self.engine.getProperty('voices')
for voice in voices:
if 'chinese' in voice.name.lower() or 'zh' in voice.id.lower():
self.engine.setProperty('voice', voice.id)
break
def text_to_audio(self, text: str, output_path: str) -> str:
os.makedirs(os.path.dirname(output_path), exist_ok=True)
self.engine.save_to_file(text, output_path)
self.engine.runAndWait()
return output_path