init
This commit is contained in:
64
services/a2f_api/README.md
Normal file
64
services/a2f_api/README.md
Normal 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
|
||||
BIN
services/a2f_api/__pycache__/a2f_service.cpython-311.pyc
Normal file
BIN
services/a2f_api/__pycache__/a2f_service.cpython-311.pyc
Normal file
Binary file not shown.
BIN
services/a2f_api/__pycache__/blend_shape_parser.cpython-311.pyc
Normal file
BIN
services/a2f_api/__pycache__/blend_shape_parser.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
services/a2f_api/__pycache__/tts_service.cpython-311.pyc
Normal file
BIN
services/a2f_api/__pycache__/tts_service.cpython-311.pyc
Normal file
Binary file not shown.
40
services/a2f_api/a2f_service.py
Normal file
40
services/a2f_api/a2f_service.py
Normal 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
34
services/a2f_api/api.py
Normal 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)
|
||||
31
services/a2f_api/blend_shape_parser.py
Normal file
31
services/a2f_api/blend_shape_parser.py
Normal 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
|
||||
3
services/a2f_api/requirements.txt
Normal file
3
services/a2f_api/requirements.txt
Normal file
@ -0,0 +1,3 @@
|
||||
flask>=3.0.0
|
||||
flask-cors>=4.0.0
|
||||
pyttsx3>=2.90
|
||||
31
services/a2f_api/text_to_blendshapes_service.py
Normal file
31
services/a2f_api/text_to_blendshapes_service.py
Normal 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
|
||||
}
|
||||
20
services/a2f_api/tts_service.py
Normal file
20
services/a2f_api/tts_service.py
Normal 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
|
||||
Reference in New Issue
Block a user