Compare commits
3
Commits
87ff2c15d0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9379fbb8f8 | ||
|
|
c20606c003 | ||
|
|
1ca033f25a |
+2
-2
@@ -85,7 +85,7 @@
|
||||
### 接口4 用户特征 `/api/v1/face/features`(**网关本机**)
|
||||
- **做什么**:照片 → 几十项面部特征(脸型/眉形/肤色/三庭五眼/四季色彩季型/量感/基因风格/性别…)。`data.features` 是 JSON 字符串。
|
||||
- **怎么实现**(`gateway/`,逻辑参考 worker `face_features.py` / `/home/xsl/fuyan`):调**火山方舟 豆包视觉模型**
|
||||
`doubao-seed-1-6-vision`(OpenAI 兼容,base64 data URI 喂图),解析眉形/年龄/动静/性别/基因风格 5 项。
|
||||
`doubao-seed-2-0-lite-260428`(OpenAI 兼容,base64 data URI 喂图),解析眉形/年龄/动静/性别/基因风格 5 项。
|
||||
**`face_shape`(脸型)改为本机 `face/face_shape_classifier.py`(MediaPipe)计算并覆盖豆包结果**。
|
||||
无人脸→1001。网关需可达 `ark.cn-beijing.volces.com`,API Key 走网关配置(不入 git)。
|
||||
⚠️ 因此**网关机不再是纯轻量代理**,需额外安装 `mediapipe`/`opencv-python`/`numpy<2`(见 `requirements.txt`)。
|
||||
@@ -115,7 +115,7 @@
|
||||
(不再是"网关不装 torch/mediapipe/opencv",只是仍不需要 torch/transformers/scikit-image 等重依赖)。
|
||||
- 配置 `gateway/config.json`(不入 git):`workers` 列表、`shared_password`、
|
||||
`ark` 的 api_key/base_url/model、`public_base_url`、超时(**生发接口慢,`request_timeout_seconds` 调大 ≥120s**)。
|
||||
- 托管 `/static/annotations/`(落盘的图),定期清理。
|
||||
- 托管 `/static/annotations/`(落盘的图),永久保留,不自动清理。
|
||||
|
||||
---
|
||||
|
||||
|
||||
+1
-1
@@ -352,7 +352,7 @@
|
||||
|
||||
## 接口 4:用户特征接口
|
||||
|
||||
**说明**:输入用户照片,由**火山方舟 豆包视觉模型**(`doubao-seed-1-6-vision`)分析,输出一大批面部特征。
|
||||
**说明**:输入用户照片,由**火山方舟 豆包视觉模型**(`doubao-seed-2-0-lite-260428`)分析,输出一大批面部特征。
|
||||
|
||||
**请求**:`POST /api/v1/face/features`
|
||||
|
||||
|
||||
@@ -203,7 +203,7 @@ def create_annotated_image(image_bgr, measure_result, ear_mask=None, hair_mask=N
|
||||
|
||||
# --- 自适应尺寸:字号/线宽/虚线/箭头按短边缩放 ---
|
||||
s = min(w, h)
|
||||
font_size = max(11, round(s * 0.026)) # 字体更小
|
||||
font_size = max(12, round(s * 0.030)) # 字号上调一档
|
||||
line_w = max(1, round(s * 0.0022))
|
||||
dash_len = max(4, round(s * 0.008))
|
||||
gap_len = max(2, round(dash_len * 0.7)) # 虚线更稠密(间隙<划线)
|
||||
|
||||
@@ -12,7 +12,7 @@ from face_analysis.calibration import (
|
||||
estimate_scale_factor, normalized_to_pixel, pixel_distance, _lm_list,
|
||||
)
|
||||
from face_analysis.face_mesh_landmarks import (
|
||||
GLABELLA_9, GLABELLA_151, NOSE_BOTTOM, CHIN_TIP,
|
||||
GLABELLA_9, NOSE_BOTTOM, CHIN_TIP,
|
||||
LEFT_EYE_OUTER, LEFT_EYE_INNER, RIGHT_EYE_INNER, RIGHT_EYE_OUTER,
|
||||
LEFT_CHEEK, RIGHT_CHEEK, LEFT_POSITION, RIGHT_POSITION,
|
||||
)
|
||||
@@ -24,10 +24,8 @@ _TOP_RATIO = 0.22 / 0.28 # 顶庭 ÷ 中庭(≈ 0.786)
|
||||
|
||||
|
||||
def _brow_center(lm, w, h):
|
||||
"""眉心 = 索引 9 / 151 中点。"""
|
||||
g9 = normalized_to_pixel(lm[GLABELLA_9], w, h)
|
||||
g151 = normalized_to_pixel(lm[GLABELLA_151], w, h)
|
||||
return (g9[0] + g151[0]) / 2, (g9[1] + g151[1]) / 2
|
||||
"""眉心 = 索引 9(眉间上点)。"""
|
||||
return normalized_to_pixel(lm[GLABELLA_9], w, h)
|
||||
|
||||
|
||||
def estimate_vertical_landmarks(landmarks, image_width, image_height):
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ import os
|
||||
logger = logging.getLogger("hair.worker")
|
||||
|
||||
ARK_BASE_URL = os.getenv("ARK_BASE_URL", "https://ark.cn-beijing.volces.com/api/v3")
|
||||
ARK_MODEL = os.getenv("ARK_MODEL", "doubao-seed-1-6-vision-250815")
|
||||
ARK_MODEL = os.getenv("ARK_MODEL", "doubao-seed-2-0-lite-260428")
|
||||
|
||||
# doubao 中文键 → 接口4 英文字段(脸型不走豆包,见 _local_face_shape)
|
||||
_KEY_MAP = {
|
||||
|
||||
+2
-67
@@ -4,13 +4,10 @@
|
||||
不跑任何算法(无 torch/mediapipe/opencv 依赖)。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
@@ -67,32 +64,15 @@ async def lifespan(app: FastAPI):
|
||||
else:
|
||||
app.state._pool_shutdown = None
|
||||
|
||||
# 确保标注图目录存在
|
||||
# 确保标注图目录存在(永久保留,不做定期清理)
|
||||
static_dir = Path(cfg["static_dir"])
|
||||
static_dir.mkdir(parents=True, exist_ok=True)
|
||||
logger.info("标注图目录: %s", static_dir)
|
||||
|
||||
# 启动定期清理任务(阶段四)
|
||||
cleanup_shutdown = asyncio.Event()
|
||||
cleanup_task = asyncio.create_task(
|
||||
_cleanup_loop(static_dir, cfg, cleanup_shutdown)
|
||||
)
|
||||
app.state._cleanup_shutdown = cleanup_shutdown
|
||||
app.state._cleanup_task = cleanup_task
|
||||
logger.info("标注图目录: %s(永久保留,不自动清理)", static_dir)
|
||||
|
||||
yield
|
||||
|
||||
# 关闭
|
||||
logger.info("网关关闭中...")
|
||||
# 停止清理任务
|
||||
if app.state._cleanup_shutdown:
|
||||
app.state._cleanup_shutdown.set()
|
||||
if app.state._cleanup_task:
|
||||
app.state._cleanup_task.cancel()
|
||||
try:
|
||||
await app.state._cleanup_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
if app.state._pool_shutdown:
|
||||
await app.state._pool_shutdown()
|
||||
logger.info("网关已关闭")
|
||||
@@ -132,51 +112,6 @@ def _get_pool_status_safe():
|
||||
return {"total": 0, "healthy": 0, "busy": 0}
|
||||
|
||||
|
||||
async def _cleanup_loop(annotations_dir: Path, cfg: dict, shutdown: asyncio.Event):
|
||||
"""定期清理 static/annotations/ 中过期的标注图文件。
|
||||
|
||||
配置项(可选,在 config.json 中设定):
|
||||
- cleanup.interval_minutes: 清理间隔,默认 60
|
||||
- cleanup.max_age_hours: 文件保留时长(小时),默认 24
|
||||
"""
|
||||
cleanup_cfg = cfg.get("cleanup", {})
|
||||
interval_s = cleanup_cfg.get("interval_minutes", 60) * 60
|
||||
max_age_s = cleanup_cfg.get("max_age_hours", 24) * 3600
|
||||
|
||||
logger.info(
|
||||
"清理任务启动 | 间隔=%dmin | 保留=%dh | 目录=%s",
|
||||
interval_s // 60, max_age_s // 3600, annotations_dir,
|
||||
)
|
||||
|
||||
while not shutdown.is_set():
|
||||
try:
|
||||
await asyncio.wait_for(shutdown.wait(), timeout=interval_s)
|
||||
break # shutdown
|
||||
except asyncio.TimeoutError:
|
||||
pass # 正常到时,执行清理
|
||||
|
||||
now = time.time()
|
||||
deleted = 0
|
||||
for f in annotations_dir.iterdir():
|
||||
if f.name == ".gitkeep":
|
||||
continue
|
||||
if not f.is_file():
|
||||
continue
|
||||
try:
|
||||
age_s = now - f.stat().st_mtime
|
||||
if age_s > max_age_s:
|
||||
f.unlink()
|
||||
deleted += 1
|
||||
logger.debug("清理过期文件: %s (age=%.1fh)", f.name, age_s / 3600)
|
||||
except Exception:
|
||||
logger.warning("清理文件失败: %s", f.name, exc_info=True)
|
||||
|
||||
if deleted:
|
||||
logger.info("清理完成: 删除 %d 个过期文件", deleted)
|
||||
|
||||
logger.info("清理任务已停止")
|
||||
|
||||
|
||||
@app.get("/gateway-health", include_in_schema=False)
|
||||
async def gateway_health():
|
||||
"""网关自身健康检查(区别于 worker 的 /health)。"""
|
||||
|
||||
@@ -22,9 +22,5 @@
|
||||
"request_timeout_seconds": 600,
|
||||
"retry_on_failure": true,
|
||||
"max_retries": 1
|
||||
},
|
||||
"cleanup": {
|
||||
"interval_minutes": 60,
|
||||
"max_age_hours": 24
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,10 +31,6 @@ DEFAULTS = {
|
||||
"retry_on_failure": True,
|
||||
"max_retries": 1,
|
||||
},
|
||||
"cleanup": {
|
||||
"interval_minutes": 60,
|
||||
"max_age_hours": 24,
|
||||
},
|
||||
"request_log": {
|
||||
"enabled": True,
|
||||
"log_file": "gateway/request_log.jsonl",
|
||||
|
||||
+3
-3
@@ -2,8 +2,8 @@
|
||||
|
||||
设计原则(与用户约定):
|
||||
- 入参/出参日志里**绝不内嵌图片 base64**。图片统一存盘后用 URL 引用,保持日志短小。
|
||||
- 入参图片(image_file / *_base64)当前网关不存盘——这里补存到 static_dir(in_ 前缀),
|
||||
复用现有 24h 清理循环自动回收;url 输入图本身就在远端,不重新下载,直接记 URL。
|
||||
- 入参图片(image_file / *_base64)补存到 static_dir(in_ 前缀),永久保留;
|
||||
url 输入图本身就在远端,不重新下载,直接记 URL。
|
||||
- 出参响应在 forward.py 已把 base64 改写成 *_url(无大图),记录完整 data,
|
||||
但用递归摘要器截断超长结构(landmarks、长字符串),单条上限 ~8KB。
|
||||
|
||||
@@ -39,7 +39,7 @@ def save_image_bytes(
|
||||
"""把图片字节存盘并返回公网 URL。失败返回 None(不抛异常)。
|
||||
|
||||
存到 static_dir/{prefix}{uuid}.{ext},URL = {public_base_url}/static/annotations/{file}。
|
||||
与 forward.py 的 rewrite_base64_to_url 落盘路径/URL 规则一致,可被同一清理循环回收。
|
||||
与 forward.py 的 rewrite_base64_to_url 落盘路径/URL 规则一致;文件永久保留,不自动清理。
|
||||
"""
|
||||
if not data:
|
||||
return None
|
||||
|
||||
Reference in New Issue
Block a user