Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 65343473d8 | |||
| d7fa9d9e8d | |||
| 90004cced4 | |||
| e672c17eb2 | |||
| fb0e4989cd | |||
| 702f09e6b7 | |||
| 12b0d15473 | |||
| 9344314eac | |||
| 3f49867384 | |||
| dd420c556f | |||
| 2d823a9255 | |||
| ed24c7cd68 | |||
| 69f88434bd | |||
| 599388d9e0 | |||
| 9ffe909dc0 | |||
| 6243196408 | |||
| 3d8f2c2ce2 | |||
| 3eef497dfe | |||
| 61c15eb987 | |||
| 0012ecad30 | |||
| e1994ada0a | |||
| f9f6c53ef4 | |||
| 9699a1fcde | |||
| 305e2bd9d5 | |||
| 30cc58441f | |||
| b3b5dbd459 | |||
| 9f5948dd2b | |||
| fbc1df36e0 | |||
| d36cc09be5 | |||
| fa4dbc6761 | |||
| 77133eb9a0 |
@@ -0,0 +1,2 @@
|
||||
Mon Oct 5 04:09:11 PM CST 2026
|
||||
2198 lite/pro并行竞速 (commit 9699a1f) — CI rebuild trigger Mon Oct 5 08:09:11 AM UTC 2026
|
||||
@@ -0,0 +1,182 @@
|
||||
/* DurationWheelPicker —— 弹层式滚轮选择器(样式与表单一致) */
|
||||
|
||||
/* 触发按钮:外观复用 .vv-select 风格 */
|
||||
.dw-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
background: #fff;
|
||||
border: 1px solid #e0e0e8;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
color: #1f2937;
|
||||
cursor: pointer;
|
||||
box-sizing: border-box;
|
||||
transition: all 0.15s;
|
||||
user-select: none;
|
||||
}
|
||||
.dw-trigger:hover {
|
||||
border-color: #c0c0d0;
|
||||
}
|
||||
.dw-trigger-open,
|
||||
.dw-trigger:focus-within {
|
||||
border-color: #7c3aed !important;
|
||||
box-shadow: 0 0 0 2px rgba(124, 58, 237, 0.12);
|
||||
}
|
||||
.dw-trigger-disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.dw-trigger-val {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dw-trigger-placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
.dw-trigger-arrow {
|
||||
font-size: 10px;
|
||||
color: #9ca3af;
|
||||
margin-left: 8px;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.dw-trigger-arrow-up {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* 弹层容器 */
|
||||
.dw-popup {
|
||||
padding: 8px;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
/* 滚轮 */
|
||||
.dw-picker {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
background: #fafafe;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
.dw-picker-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
height: 100%;
|
||||
overflow-y: scroll;
|
||||
scroll-snap-type: y mandatory;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.dw-picker-list::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.dw-picker-item {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
scroll-snap-align: center;
|
||||
cursor: pointer;
|
||||
font-size: 15px;
|
||||
color: #9ca3af;
|
||||
font-weight: 400;
|
||||
transition:
|
||||
color 0.15s,
|
||||
transform 0.15s,
|
||||
font-weight 0.15s;
|
||||
}
|
||||
.dw-picker-item-val {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.dw-picker-item-unit {
|
||||
font-size: 13px;
|
||||
color: inherit;
|
||||
}
|
||||
.dw-picker-item-active {
|
||||
color: #7c3aed;
|
||||
font-weight: 600;
|
||||
}
|
||||
.dw-picker-item-active .dw-picker-item-val {
|
||||
font-size: 18px;
|
||||
}
|
||||
.dw-picker-item-active .dw-picker-item-unit {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 中心选中条 */
|
||||
.dw-picker-mask {
|
||||
position: absolute;
|
||||
left: 6px;
|
||||
right: 6px;
|
||||
pointer-events: none;
|
||||
background: #f5f0ff;
|
||||
border-radius: 6px;
|
||||
z-index: 1;
|
||||
}
|
||||
.dw-picker-mask::before,
|
||||
.dw-picker-mask::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: #d8c4ff;
|
||||
}
|
||||
.dw-picker-mask::before {
|
||||
top: 0;
|
||||
}
|
||||
.dw-picker-mask::after {
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
/* 上下渐变 */
|
||||
.dw-picker-fade {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 40%;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
.dw-picker-fade-top {
|
||||
top: 0;
|
||||
background: linear-gradient(to bottom, #fafafe 25%, rgba(250, 250, 254, 0));
|
||||
}
|
||||
.dw-picker-fade-bottom {
|
||||
bottom: 0;
|
||||
background: linear-gradient(to top, #fafafe 25%, rgba(250, 250, 254, 0));
|
||||
}
|
||||
|
||||
/* 弹层按钮区 */
|
||||
.dw-popup-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.dw-popup-actions .ant-btn {
|
||||
border-radius: 6px;
|
||||
}
|
||||
.dw-popup-actions .ant-btn-primary {
|
||||
background: #7c3aed;
|
||||
}
|
||||
.dw-popup-actions .ant-btn-primary:hover {
|
||||
background: #6d28d9 !important;
|
||||
}
|
||||
|
||||
/* 覆盖 antd Popover 默认内边距 */
|
||||
.dw-popover .ant-popover-inner {
|
||||
padding: 0 !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
.dw-popover .ant-popover-arrow {
|
||||
display: none;
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* DurationWheelPicker —— 竖屏滚轮式时长选择器(弹层版)
|
||||
*
|
||||
* 设计:
|
||||
* - 外观是和其他表单 Select 一致的输入框(白色底+1px灰边+紫色focus ring)
|
||||
* - 点击输入框弹出 Popover,内部是滚轮 picker(原生 scroll-snap,零依赖)
|
||||
* - 滚轮样式:白底容器,选中行 #7c3aed 紫字加粗+浅紫背景条
|
||||
* - 支持触摸/鼠标滚轮/点击;松手吸附;底部"确认/取消"按钮
|
||||
* - 默认范围 15–30 秒,步长 1 秒
|
||||
*/
|
||||
import React, { useEffect, useMemo, useRef, useState, useCallback } from "react"
|
||||
import { Popover, Button } from "antd"
|
||||
import { DownOutlined } from "@ant-design/icons"
|
||||
import "./DurationWheelPicker.css"
|
||||
|
||||
export interface DurationWheelPickerProps {
|
||||
value?: number
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
unit?: string
|
||||
onChange?: (value: number) => void
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
/** 弹层宽度,默认 160px */
|
||||
popupWidth?: number
|
||||
/** 弹层内滚轮高度,默认 180px */
|
||||
wheelHeight?: number
|
||||
}
|
||||
|
||||
const ITEM_HEIGHT = 36
|
||||
|
||||
const DurationWheelPicker: React.FC<DurationWheelPickerProps> = ({
|
||||
value = 20,
|
||||
min = 15,
|
||||
max = 30,
|
||||
step = 1,
|
||||
unit = "秒",
|
||||
onChange,
|
||||
placeholder = "请选择时长",
|
||||
disabled = false,
|
||||
popupWidth = 160,
|
||||
wheelHeight = 180,
|
||||
}) => {
|
||||
const options = useMemo(() => {
|
||||
const arr: number[] = []
|
||||
for (let v = min; v <= max; v += step) arr.push(v)
|
||||
return arr
|
||||
}, [min, max, step])
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
// 弹层内暂存值,点确认才提交
|
||||
const [draft, setDraft] = useState<number>(value)
|
||||
const listRef = useRef<HTMLUListElement>(null)
|
||||
const scrollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setDraft(value)
|
||||
// 下一帧滚到当前值
|
||||
requestAnimationFrame(() => scrollToValue(value, false))
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open])
|
||||
|
||||
const scrollToValue = useCallback(
|
||||
(v: number, smooth = true) => {
|
||||
const list = listRef.current
|
||||
if (!list) return
|
||||
const idx = options.indexOf(v)
|
||||
if (idx < 0) return
|
||||
list.scrollTo({ top: idx * ITEM_HEIGHT, behavior: smooth ? "smooth" : "auto" })
|
||||
},
|
||||
[options],
|
||||
)
|
||||
|
||||
const handleScroll = () => {
|
||||
if (scrollTimerRef.current) clearTimeout(scrollTimerRef.current)
|
||||
scrollTimerRef.current = setTimeout(() => {
|
||||
const list = listRef.current
|
||||
if (!list) return
|
||||
const idx = Math.round(list.scrollTop / ITEM_HEIGHT)
|
||||
const clamped = Math.max(0, Math.min(options.length - 1, idx))
|
||||
const targetTop = clamped * ITEM_HEIGHT
|
||||
if (Math.abs(list.scrollTop - targetTop) > 1) {
|
||||
list.scrollTo({ top: targetTop, behavior: "smooth" })
|
||||
}
|
||||
setDraft(options[clamped])
|
||||
}, 100)
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
onChange?.(draft)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const handleItemClick = (v: number) => {
|
||||
setDraft(v)
|
||||
scrollToValue(v, true)
|
||||
}
|
||||
|
||||
const maskTop = wheelHeight / 2 - ITEM_HEIGHT / 2
|
||||
|
||||
const wheel = (
|
||||
<div className="dw-popup">
|
||||
<div
|
||||
className="dw-picker"
|
||||
style={{ height: wheelHeight, width: popupWidth - 24 /* padding */ }}
|
||||
>
|
||||
<div className="dw-picker-mask" style={{ top: maskTop, height: ITEM_HEIGHT }} aria-hidden />
|
||||
<div className="dw-picker-fade dw-picker-fade-top" aria-hidden />
|
||||
<div className="dw-picker-fade dw-picker-fade-bottom" aria-hidden />
|
||||
<ul
|
||||
ref={listRef}
|
||||
className="dw-picker-list"
|
||||
onScroll={handleScroll}
|
||||
style={{
|
||||
paddingTop: wheelHeight / 2 - ITEM_HEIGHT / 2,
|
||||
paddingBottom: wheelHeight / 2 - ITEM_HEIGHT / 2,
|
||||
}}
|
||||
>
|
||||
{options.map((v) => {
|
||||
const isActive = v === draft
|
||||
return (
|
||||
<li
|
||||
key={v}
|
||||
className={`dw-picker-item${isActive ? " dw-picker-item-active" : ""}`}
|
||||
style={{ height: ITEM_HEIGHT, lineHeight: `${ITEM_HEIGHT}px` }}
|
||||
onClick={() => handleItemClick(v)}
|
||||
aria-selected={isActive}
|
||||
role="option"
|
||||
>
|
||||
<span className="dw-picker-item-val">{v}</span>
|
||||
<span className="dw-picker-item-unit">{unit}</span>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="dw-popup-actions">
|
||||
<Button size="small" onClick={handleCancel}>
|
||||
取消
|
||||
</Button>
|
||||
<Button size="small" type="primary" onClick={handleConfirm}>
|
||||
确认
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={!disabled && open}
|
||||
onOpenChange={(v) => setOpen(v)}
|
||||
content={wheel}
|
||||
trigger="click"
|
||||
placement="bottomLeft"
|
||||
overlayClassName="dw-popover"
|
||||
overlayStyle={{ padding: 0 }}
|
||||
overlayInnerStyle={{ padding: 0, borderRadius: 10 }}
|
||||
destroyTooltipOnHide
|
||||
>
|
||||
<div
|
||||
className={`dw-trigger${disabled ? " dw-trigger-disabled" : ""}${open ? " dw-trigger-open" : ""}`}
|
||||
style={{ height: 36 }}
|
||||
>
|
||||
<span className={`dw-trigger-val${value != null ? "" : " dw-trigger-placeholder"}`}>
|
||||
{value != null ? `${value}${unit}` : placeholder}
|
||||
</span>
|
||||
<DownOutlined className={`dw-trigger-arrow${open ? " dw-trigger-arrow-up" : ""}`} />
|
||||
</div>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
export default DurationWheelPicker
|
||||
@@ -10,4 +10,4 @@
|
||||
* 功能流程不做积分预校验,直接走生成。
|
||||
* - true:展示完整积分系统 UI。
|
||||
*/
|
||||
export const ENABLE_CREDIT_SYSTEM = false
|
||||
export const ENABLE_CREDIT_SYSTEM = true
|
||||
|
||||
@@ -218,12 +218,12 @@ const PURPOSES = [
|
||||
"悬念短剧",
|
||||
"情绪短片",
|
||||
]
|
||||
const DURATIONS = [15, 20, 30, 45, 60]
|
||||
const RATIOS = [
|
||||
{ v: "9:16", label: "9:16 竖屏(抖音/视频号)" },
|
||||
{ v: "16:9", label: "16:9 横屏(B站/YouTube)" },
|
||||
{ v: "1:1", label: "1:1 方形(小红书)" },
|
||||
]
|
||||
const DURATIONS = Array.from({ length: 16 }, (_, i) => 15 + i)
|
||||
/** 兜底模型列表(接口未返回时使用,字段与 ViralVideoModel 对齐;后端返回后自动覆盖) */
|
||||
const FALLBACK_VIDEO_MODELS: ViralVideoModel[] = [
|
||||
{
|
||||
@@ -437,7 +437,7 @@ const emptyTask = (id: string, title: string): TabTask => ({
|
||||
language: "中文(普通话)",
|
||||
viralStructure: STRUCTURES[0],
|
||||
marketingPurpose: "",
|
||||
duration: 15,
|
||||
duration: 20,
|
||||
persona: "",
|
||||
videoRatio: "9:16",
|
||||
videoModel: "seedance-2.5",
|
||||
@@ -2431,7 +2431,7 @@ const ViralVideoPage: React.FC = () => {
|
||||
options={PURPOSES.map((i) => ({ value: i, label: i }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="vv-form-row" style={{ gridColumn: "1 / -1" }}>
|
||||
<div className="vv-form-row">
|
||||
<label className="vv-label">文案视频时长</label>
|
||||
<Select
|
||||
className="vv-select"
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"""爆款视频 Celery 编排器 — ViralVideoOrchestrator (v1.6 单次 Seedance 出片版).
|
||||
"""爆款视频 Celery 编排器 — ViralVideoOrchestrator.
|
||||
|
||||
v1.6 重大简化(Seedance 2.5 单次最长 30 秒,直接出片):
|
||||
1. _step_image_analysis 图片 VLM 分析(保留)
|
||||
V2 图片分析(10-05):火山OCR专用API + doubao-lite强约束JSON并行,单图<3s,8图<15s;pro VLM单次兜底。输出字段兼容旧格式,下游信任链/t2i零改动。
|
||||
|
||||
流水线步骤:
|
||||
1. _step_image_analysis 图片分析(V2: OCR+qwen3.8-flash并行 + qwen3.7-plus兜底)
|
||||
1.5 _step_video_analysis 参考视频风格分析(可选)
|
||||
2. _step_intent_parsing 用户文案意图解析
|
||||
3. _step_script_generation 编导分镜脚本生成(融合原 copy_fusion+storyboard+review,输出 copy_result 结构 + voiceover_script)
|
||||
@@ -22,11 +24,9 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -330,446 +330,62 @@ def _vision_fallback(idx: int, reason: str, extra: dict | None = None) -> dict:
|
||||
return d
|
||||
|
||||
|
||||
def _is_vision_result_usable(result: dict) -> bool:
|
||||
"""判断 VLM 返回是否有效:name/summary 不能为未识别/无法判断/空,summary 要够长。"""
|
||||
if not isinstance(result, dict):
|
||||
return False
|
||||
name = (result.get("name") or "").strip()
|
||||
if not name or name in ("未识别", "无法判断", "未知"):
|
||||
return False
|
||||
summary = (result.get("summary") or "").strip()
|
||||
if len(summary) < 30 or summary in ("无法判断", "未识别"):
|
||||
return False
|
||||
category = (result.get("category") or "").strip()
|
||||
if category == "非产品图":
|
||||
return True
|
||||
feats = result.get("key_features") or []
|
||||
if not isinstance(feats, list) or len(feats) == 0:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _normalize_image_url(raw: str, idx: int) -> str:
|
||||
"""#2188: 将 job.images 中的 storage_key/相对路径/空值统一归一化为可公网访问 URL。
|
||||
- 以 http:// 或 https:// 开头 → 视为公网 URL
|
||||
- 其他 → 视为 storage_key,用 SharedStorageService.get_url() 转公网 URL
|
||||
- 空值/None/非字符串 → 抛 ValueError(上层 catch 后走 400 错误)
|
||||
返回前做 HTTP HEAD 检查可达性。
|
||||
"""将 job.images 中的 storage_key/相对路径/空值统一归一化为可公网访问 URL。
|
||||
- http(s):// → 直接用
|
||||
- 其他 → storage_key,通过 SharedStorageService.get_url() 转公网 URL
|
||||
- 空值/非字符串 → 抛 ValueError
|
||||
"""
|
||||
import requests as _req
|
||||
|
||||
if not raw or not isinstance(raw, str):
|
||||
raise ValueError(f"图片 #{idx} URL 为空或类型错误: {type(raw).__name__}={raw!r}")
|
||||
url = raw.strip()
|
||||
if not url:
|
||||
raise ValueError(f"图片 #{idx} URL 为空白字符串")
|
||||
# storage_key 判定:不以 http 开头
|
||||
if not url.startswith("http://") and not url.startswith("https://"):
|
||||
# 去掉可能的前导斜杠
|
||||
storage_key = url.lstrip("/")
|
||||
try:
|
||||
from packages.shared.storage import get_storage_service
|
||||
|
||||
_svc = get_storage_service()
|
||||
url = _svc.get_url(storage_key)
|
||||
except Exception as _e:
|
||||
raise ValueError(f"图片 #{idx} storage_key={storage_key!r} 转公网URL失败: {_e}") from _e
|
||||
logger.info("[爆款视频] 图片 #%d storage_key 已转公网 URL: %s", idx, url[:120])
|
||||
# HTTP HEAD 可达性检查
|
||||
if url.startswith("http://") or url.startswith("https://"):
|
||||
return url
|
||||
storage_key = url.lstrip("/")
|
||||
try:
|
||||
_r = _req.head(url, timeout=5, allow_redirects=True)
|
||||
if _r.status_code >= 400:
|
||||
logger.warning("[爆款视频] 图片 #%d URL HEAD 检查返回 %d: %s", idx, _r.status_code, url[:120])
|
||||
from packages.shared.storage import get_storage_service
|
||||
|
||||
url = get_storage_service().get_url(storage_key)
|
||||
except Exception as _e:
|
||||
logger.warning("[爆款视频] 图片 #%d URL HEAD 检查异常: %s url=%s", idx, _e, url[:120])
|
||||
raise ValueError(f"图片 #{idx} storage_key={storage_key!r} 转公网URL失败: {_e}") from _e
|
||||
logger.info("[爆款视频] 图片 #%d storage_key → 公网URL: %s", idx, url[:120])
|
||||
return url
|
||||
|
||||
|
||||
def _analyze_single_image(
|
||||
idx: int,
|
||||
img_url: str,
|
||||
vision_model: str,
|
||||
timeout: int,
|
||||
*,
|
||||
pro_fallback_model: str | None = None,
|
||||
) -> dict:
|
||||
"""单张图片 VLM 分析(#2040:改为从 prompt_loader 读模板 + XML 解析)。
|
||||
|
||||
lite 失败/不可用时用 pro 降级重试 1 次。失败/None 最终返回含默认字段的 dict。
|
||||
"""
|
||||
try:
|
||||
from packages.application.viral_video import xml_parser as xp
|
||||
from packages.application.viral_video.prompt_loader import (
|
||||
get_template,
|
||||
render_system_prompt,
|
||||
render_user_prompt,
|
||||
)
|
||||
from packages.shared.ai_service import call_vision
|
||||
except ImportError as e:
|
||||
logger.warning("[爆款视频] prompt 模板/解析模块不可用: %s", e)
|
||||
return _vision_fallback(idx, f"fallback_import_error:{e}")
|
||||
|
||||
if not img_url or not isinstance(img_url, str):
|
||||
return _vision_fallback(idx, "invalid_url")
|
||||
|
||||
template = get_template("image_analysis")
|
||||
system = render_system_prompt(template)
|
||||
user = render_user_prompt(
|
||||
template,
|
||||
image_count=1,
|
||||
industry="通用",
|
||||
image_urls=f"第1张:{img_url}",
|
||||
)
|
||||
|
||||
def _call(model: str, tmo: int):
|
||||
try:
|
||||
return call_vision(
|
||||
image_url=img_url,
|
||||
prompt=user,
|
||||
model=model,
|
||||
max_tokens=1200, # #2188: 结构化 XML 输出 600-900 字足够,2048 翻倍耗时
|
||||
temperature=0.3,
|
||||
timeout=tmo,
|
||||
system_prompt=system,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("[爆款视频] 图片 #%d call_vision(%s) 异常 err=%s", idx, model, e)
|
||||
return None
|
||||
|
||||
def _xml_to_product(nodes: list, raw_text: str) -> dict:
|
||||
product_nodes = [n for n in nodes if n["tag"] == "product"]
|
||||
scene = xp.text_of(raw_text, "scene") or "通用"
|
||||
mood = xp.text_of(raw_text, "mood") or ""
|
||||
# #2184: #2177 XML 重构后人物信息放在顶层 <people has_person count gender age_range pose expression/>,
|
||||
# 不再是 <product> 的 portrait_prompt 属性。需从顶层 people 标签提取并拼装 portrait_prompt。
|
||||
portrait_prompt = "无人像"
|
||||
try:
|
||||
people_node = xp.find_first(raw_text, "people")
|
||||
if people_node:
|
||||
_pa = people_node.get("attrs") or {}
|
||||
_has_person = xp.attr_bool(_pa.get("has_person"), False)
|
||||
if _has_person:
|
||||
_gender = _pa.get("gender", "无法判断") or "无法判断"
|
||||
_age = _pa.get("age_range", "无法判断") or "无法判断"
|
||||
_hair = _pa.get("hair", "无法判断") or "无法判断"
|
||||
_skin = _pa.get("skin_tone", "无法判断") or "无法判断"
|
||||
_face = _pa.get("face_shape", "无法判断") or "无法判断"
|
||||
_outfit = _pa.get("outfit", "无法判断") or "无法判断"
|
||||
_pose = _pa.get("pose", "无法判断") or "无法判断"
|
||||
_expr = _pa.get("expression", "无法判断") or "无法判断"
|
||||
_count = xp.attr_int(_pa.get("count"), 1)
|
||||
# #2185: VLM有时对外貌属性输出"无法判断",用通用兜底值确保portrait_prompt始终有完整外貌描述
|
||||
if _hair == "无法判断":
|
||||
_hair = "自然发型"
|
||||
if _skin == "无法判断":
|
||||
_skin = "自然"
|
||||
if _face == "无法判断":
|
||||
_face = "标准"
|
||||
if _outfit == "无法判断":
|
||||
_outfit = "日常服装"
|
||||
_parts = []
|
||||
if _gender != "无法判断":
|
||||
_g = _gender + ("性" if not _gender.endswith("性") else "")
|
||||
_parts.append(_g)
|
||||
else:
|
||||
_parts.append("成年人")
|
||||
if _age != "无法判断":
|
||||
_parts.append(_age)
|
||||
_parts.append("人物")
|
||||
_parts.append(_hair)
|
||||
_parts.append(f"{_skin}肤色")
|
||||
_parts.append(f"{_face}脸型")
|
||||
_parts.append(f"身着{_outfit}")
|
||||
if _pose != "无法判断":
|
||||
_parts.append(f"姿态{_pose}")
|
||||
if _expr != "无法判断":
|
||||
_parts.append(f"表情{_expr}")
|
||||
else:
|
||||
_parts.append("表情自然")
|
||||
# #2186: 智能回填——VLM有时省略hair/outfit等外貌属性,但product.name/features/colors里已有相关信息
|
||||
# 从product名字和features中提取服装关键词回填outfit
|
||||
if _outfit in ("日常服装", "无法判断"):
|
||||
for _ppn in product_nodes:
|
||||
_pn = (_ppn.get("attrs") or {}).get("name", "") or ""
|
||||
_pf = (_ppn.get("attrs") or {}).get("features", "") or ""
|
||||
_ptxt = _pn + " " + _pf
|
||||
# 服装关键词识别(常见上装/下装/裙装/套装)
|
||||
_cloth_kws = [
|
||||
"衬衫",
|
||||
"T恤",
|
||||
"毛衣",
|
||||
"针织衫",
|
||||
"卫衣",
|
||||
"外套",
|
||||
"西装",
|
||||
"夹克",
|
||||
"风衣",
|
||||
"大衣",
|
||||
"羽绒服",
|
||||
"马甲",
|
||||
"背心",
|
||||
"连衣裙",
|
||||
"半身裙",
|
||||
"短裙",
|
||||
"长裙",
|
||||
"牛仔裤",
|
||||
"休闲裤",
|
||||
"西裤",
|
||||
"运动裤",
|
||||
"短裤",
|
||||
"旗袍",
|
||||
"汉服",
|
||||
"制服",
|
||||
"polo衫",
|
||||
"POLO衫",
|
||||
"针织",
|
||||
"毛衫",
|
||||
"开衫",
|
||||
"帽衫",
|
||||
"皮夹克",
|
||||
"皮衣",
|
||||
]
|
||||
for _ckw in _cloth_kws:
|
||||
if _ckw in _ptxt:
|
||||
_ci = _ptxt.find(_ckw)
|
||||
# 向前找颜色/材质/款式形容词(白/黑/米/红/蓝/灰/棉/麻/长/短/厚/薄/长袖/短袖/翻领/圆领/V领/印花/条纹等)
|
||||
_start = max(0, _ci - 8)
|
||||
# 向后包含款式词(长袖/短袖/外套/套装/上衣等后续修饰)
|
||||
_end = min(len(_ptxt), _ci + len(_ckw) + 4)
|
||||
_outfit_extract = _ptxt[_start:_end].strip(" ,,。.、")
|
||||
# 仅清理明确的品牌/产品类前缀(不清理颜色/款式/尺寸形容词)
|
||||
_outfit_extract = re.sub(
|
||||
r"^(\S{0,4}牌|\S{0,3}品牌|\S{0,3}款|产品|商品|的)", "", _outfit_extract
|
||||
).strip()
|
||||
# 尾部清理:去掉残留的品牌字/型号字(如"标""ml""g""装"等单字杂字)
|
||||
_outfit_extract = re.sub(
|
||||
r"(标[0-9a-zA-Z]*|\d+\s*(?:ml|g|L|斤|件|个|瓶|盒|包|袋|装)|\s+\d+\s*)$",
|
||||
"",
|
||||
_outfit_extract,
|
||||
flags=re.IGNORECASE,
|
||||
).strip()
|
||||
if len(_outfit_extract) >= 2:
|
||||
_outfit = _outfit_extract
|
||||
break
|
||||
if _outfit not in ("日常服装", "无法判断"):
|
||||
break
|
||||
# 从color标签中提取头发颜色回填hair
|
||||
if _hair in ("自然发型", "无法判断"):
|
||||
_hair_color = ""
|
||||
_color_nodes = [n for n in nodes if n["tag"] == "color"]
|
||||
_hair_kws_map = {
|
||||
"黑": "黑色",
|
||||
"棕": "棕色",
|
||||
"金": "金色",
|
||||
"栗": "栗色",
|
||||
"红": "红色",
|
||||
"白": "白色",
|
||||
"灰": "灰色",
|
||||
"蓝": "蓝色",
|
||||
"黄": "黄色",
|
||||
"紫": "紫色",
|
||||
}
|
||||
for _cn in _color_nodes:
|
||||
_cname = (_cn.get("attrs") or {}).get("name", "") or ""
|
||||
# 小占比颜色更可能是发色(非主色的小面积色),且名称含头发/黑/棕/金等
|
||||
_ccov = 0.0
|
||||
try:
|
||||
_ccov = float((_cn.get("attrs") or {}).get("coverage", "0") or 0)
|
||||
except Exception:
|
||||
pass
|
||||
for _hk, _hv in _hair_kws_map.items():
|
||||
if _hk in _cname and _ccov < 0.3:
|
||||
_hair_color = _hv
|
||||
break
|
||||
if _hair_color:
|
||||
break
|
||||
if _hair_color:
|
||||
_hair = f"{_hair_color}头发"
|
||||
else:
|
||||
_hair = "自然发型"
|
||||
# 重新拼装_parts(回填后)
|
||||
_parts = []
|
||||
if _gender != "无法判断":
|
||||
_g = _gender + ("性" if not _gender.endswith("性") else "")
|
||||
_parts.append(_g)
|
||||
else:
|
||||
_parts.append("成年人")
|
||||
if _age != "无法判断":
|
||||
_parts.append(_age)
|
||||
_parts.append("人物")
|
||||
_parts.append(_hair)
|
||||
_parts.append(f"{_skin}肤色")
|
||||
_parts.append(f"{_face}脸型")
|
||||
_parts.append(f"身着{_outfit}")
|
||||
if _pose != "无法判断":
|
||||
_parts.append(f"姿态{_pose}")
|
||||
if _expr != "无法判断":
|
||||
_parts.append(f"表情{_expr}")
|
||||
else:
|
||||
_parts.append("表情自然")
|
||||
portrait_prompt = ",".join(_parts)
|
||||
logger.info(
|
||||
"[爆款视频] 图片 #%d 解析<people>(回填后): count=%d gender=%s age=%s hair=%s skin=%s face=%s outfit=%s pose=%s expr=%s → %s",
|
||||
idx,
|
||||
_count,
|
||||
_gender,
|
||||
_age,
|
||||
_hair,
|
||||
_skin,
|
||||
_face,
|
||||
_outfit,
|
||||
_pose,
|
||||
_expr,
|
||||
portrait_prompt,
|
||||
)
|
||||
except Exception as _pe:
|
||||
logger.warning("[爆款视频] 图片 #%d 解析<people>标签异常: %s,回退无人像", idx, _pe)
|
||||
for p in product_nodes:
|
||||
a = p["attrs"]
|
||||
text_on_pkg = a.get("text_on_package", "")
|
||||
p_body = p.get("text", "") or ""
|
||||
if not text_on_pkg and p_body:
|
||||
text_on_pkg = xp.text_of(p_body, "text_on_package") or ""
|
||||
text_list = [x.strip() for x in re.split(r"[,,;;]", text_on_pkg) if x.strip()] if text_on_pkg else []
|
||||
features = a.get("features", "")
|
||||
feat_list = [x.strip() for x in re.split(r"[,,;;]", features) if x.strip()] if features else []
|
||||
name = a.get("name", "") or "未识别"
|
||||
brand = a.get("brand", "") or "无法判断"
|
||||
category = a.get("category", "") or "无法判断"
|
||||
appearance = a.get("appearance", "") or "无法判断"
|
||||
packaging = a.get("packaging", "") or "无法判断"
|
||||
summary = a.get("summary", "") or f"{brand} {name}"
|
||||
# 优先取 product 属性上的 portrait_prompt(兼容旧schema),否则用顶层 <people> 解析结果
|
||||
_pp_from_attr = a.get("portrait_prompt", "")
|
||||
if _pp_from_attr and _pp_from_attr != "无人像":
|
||||
portrait_prompt = _pp_from_attr
|
||||
return {
|
||||
"name": name,
|
||||
"brand": brand,
|
||||
"category": category,
|
||||
"appearance": appearance,
|
||||
"packaging": packaging,
|
||||
"text_on_package": text_list,
|
||||
"key_features": feat_list or [features] if features else ["无法判断"],
|
||||
"scene": scene,
|
||||
"mood": mood,
|
||||
"portrait_prompt": portrait_prompt,
|
||||
"summary": summary,
|
||||
"_source": "xml",
|
||||
}
|
||||
# 没有 product 标签但有 <people has_person="true"> 也要能取到人物描述(兜底)
|
||||
if portrait_prompt != "无人像":
|
||||
return {
|
||||
"name": "未识别",
|
||||
"brand": "无法判断",
|
||||
"category": "无法判断",
|
||||
"appearance": "无法判断",
|
||||
"packaging": "无法判断",
|
||||
"text_on_package": [],
|
||||
"key_features": ["无法判断"],
|
||||
"scene": scene,
|
||||
"mood": mood,
|
||||
"portrait_prompt": portrait_prompt,
|
||||
"summary": "未识别",
|
||||
"_source": "xml_no_product",
|
||||
}
|
||||
return _vision_fallback(idx, "no_product_tag")
|
||||
|
||||
def _normalize(raw, source: str) -> dict:
|
||||
if raw is None:
|
||||
return _vision_fallback(idx, f"{source}_none")
|
||||
if not isinstance(raw, str):
|
||||
return _vision_fallback(idx, f"{source}_badtype")
|
||||
nodes = xp.parse_tags(raw)
|
||||
if not nodes:
|
||||
logger.warning("[爆款视频] 图片 #%d XML 解析失败 source=%s", idx, source)
|
||||
return _vision_fallback(idx, f"{source}_xml_fail", {"_raw": raw[:500]})
|
||||
product = _xml_to_product(nodes, raw)
|
||||
product.setdefault("_source", source)
|
||||
product["raw"] = raw[:500]
|
||||
return product
|
||||
|
||||
first_raw = _call(vision_model, timeout)
|
||||
tag1 = vision_model.split("/")[-1] if "/" in vision_model else vision_model
|
||||
first_result = _normalize(first_raw, tag1)
|
||||
if _is_vision_result_usable(first_result):
|
||||
return first_result
|
||||
|
||||
if pro_fallback_model and pro_fallback_model != vision_model:
|
||||
pro_raw = _call(pro_fallback_model, 90) # #2188: pro fallback 给 90s 余量
|
||||
pro_result = _normalize(pro_raw, "pro_fallback")
|
||||
if _is_vision_result_usable(pro_result):
|
||||
pro_result["_fallback_used"] = True
|
||||
return pro_result
|
||||
return pro_result
|
||||
return first_result
|
||||
|
||||
|
||||
def _step_image_analysis(job: ViralVideoJob) -> dict:
|
||||
"""步骤 1: 图片 VLM 分析 — 识别产品特征(v1.6 优化:并行 + lite 模型提速)。
|
||||
#2188: (1) 所有图片 URL 先归一化(storage_key→公网URL+空值报400)
|
||||
(2) 爆款视频强制 lite-first,不依赖 .env USE_LITE 开关
|
||||
(3) max_tokens=1200,max_workers=min(2,n) 防方舟限流
|
||||
(4) lite timeout=30s,pro fallback timeout=90s
|
||||
"""
|
||||
try:
|
||||
from packages.shared.ai_service import call_vision # noqa: F401
|
||||
except ImportError:
|
||||
logger.warning("[爆款视频] ai_service.call_vision 不可用,使用占位结果")
|
||||
return {"products": [_vision_fallback(0, "fallback_import_error")]}
|
||||
"""步骤 1: 图片分析(V2 主路径)。
|
||||
|
||||
架构:
|
||||
- 主力:火山 MediaKit OCR(专用API,未配置时自动跳过)+ qwen3.8-flash 强约束 JSON,每图2路并行,目标<3s;
|
||||
- 外层全并发(workers=8),目标8图<15s;
|
||||
- 兜底:fast 结果不可用时单次调用 qwen3.7-plus(简单、无竞速)。
|
||||
- 唯一后端:阿里云百炼 DashScope,API Key 从环境变量 DASHSCOPE_API_KEY 读取。
|
||||
输出 dict 字段(name/brand/category/appearance/key_features/scene/mood/portrait_prompt/summary/_source)
|
||||
与旧版格式完全一致,下游信任链/t2i/intent_parsing/script_generation 零改动。
|
||||
"""
|
||||
if not job.images:
|
||||
logger.warning("[爆款视频] 任务无 images,跳过图片分析")
|
||||
return {"products": []}
|
||||
|
||||
# #2188 BUG1: URL 归一化 — storage_key→公网URL + 空值报400
|
||||
# URL 归一化(storage_key→公网URL;空值直接400)
|
||||
normalized_urls: list[str] = []
|
||||
for idx, raw in enumerate(job.images):
|
||||
try:
|
||||
normalized_urls.append(_normalize_image_url(raw, idx))
|
||||
except ValueError as _ve:
|
||||
# 空/非法URL:直接让任务失败,不默默走 fallback
|
||||
logger.error("[爆款视频] 图片 #%d URL 归一化失败: %s", idx, _ve)
|
||||
raise # 上层 celery 捕获后标记任务失败,避免"未识别·无法判断"误导
|
||||
normalized_urls.append(_normalize_image_url(raw, idx))
|
||||
|
||||
# #2188 BUG2: 爆款视频强制 lite-first(不依赖 .env 开关),lite timeout=30s,pro fallback 90s
|
||||
try:
|
||||
_s = get_shared_settings()
|
||||
lite_model = _s.doubao_vision_lite_model
|
||||
pro_model = _s.doubao_vision_model
|
||||
except Exception:
|
||||
lite_model = "doubao-seed-2-1-lite-260915"
|
||||
pro_model = "doubao-seed-2-1-pro-260915"
|
||||
vision_model = lite_model # 永远 lite 主跑
|
||||
vision_timeout = 30 # lite 目标 20-30s
|
||||
from worker_app.tasks.vision import analyze_images_v2 as _aiv2
|
||||
except ImportError:
|
||||
try:
|
||||
from tasks.vision import analyze_images_v2 as _aiv2 # type: ignore
|
||||
except ImportError as e:
|
||||
logger.error("[爆款视频] vision 模块导入失败: %s", e)
|
||||
return {"products": [_vision_fallback(0, f"vision_import_error:{e}")]}
|
||||
|
||||
results: list[dict] = [None] * len(normalized_urls) # type: ignore
|
||||
max_workers = min(2, max(1, len(normalized_urls))) # #2188: 并发≤2 防方舟限流
|
||||
logger.info(
|
||||
"[爆款视频] 开始并行图片分析 n=%d model=%s pro_fallback=%s lite_timeout=%d pro_timeout=%d workers=%d",
|
||||
len(normalized_urls),
|
||||
vision_model,
|
||||
pro_model,
|
||||
vision_timeout,
|
||||
90,
|
||||
max_workers,
|
||||
)
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as pool:
|
||||
future_to_idx = {
|
||||
pool.submit(
|
||||
_analyze_single_image, idx, url, vision_model, vision_timeout, pro_fallback_model=pro_model
|
||||
): idx
|
||||
for idx, url in enumerate(normalized_urls)
|
||||
}
|
||||
for fut in as_completed(future_to_idx):
|
||||
idx = future_to_idx[fut]
|
||||
try:
|
||||
results[idx] = fut.result()
|
||||
except Exception as e:
|
||||
logger.warning("[爆款视频] 图片 #%d future 异常 err=%s", idx, e, exc_info=True)
|
||||
results[idx] = _vision_fallback(idx, "future_exception", {"_error": str(e)[:200]})
|
||||
|
||||
return {"products": results}
|
||||
# V2 内部 httpx 直连 dashscope,单次调用无重试,无需调整全局 client
|
||||
results = _aiv2(normalized_urls)
|
||||
return {"products": list(results)}
|
||||
|
||||
|
||||
def _step_video_analysis(job: ViralVideoJob) -> dict | None:
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""V2 图片分析:火山OCR专用API + doubao-lite强约束JSON并行,单次pro VLM兜底。"""
|
||||
|
||||
from .fast_path import analyze_image_v2, analyze_images_v2 # noqa: F401
|
||||
@@ -0,0 +1,307 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""把 fast_json VLM 输出 + OCR 文本组装为与旧 _normalize() 完全一致的 dict。
|
||||
|
||||
目标:下游(信任链t2i/intent_parsing/script_generation)零改动。
|
||||
必出字段:name, brand, category, appearance, packaging, text_on_package,
|
||||
key_features, scene, mood, portrait_prompt, summary, _source
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
# ---------- portrait_prompt 模板 ----------
|
||||
# 目标:60-100 字的人物穿搭描述,用于 Seedream 纯文生图。要求具体、风格化、视觉细节丰富。
|
||||
# 旧 VLM 输出格式参考:"一位25岁左右的亚洲女性,身穿白色V领短袖T恤,黑色高腰阔腿裤,
|
||||
# 搭配银色项链,长发披肩,表情自信,街拍风格,阳光明媚的城市街头"
|
||||
|
||||
|
||||
def _join_parts(*parts: str | None) -> str:
|
||||
return "".join(p for p in parts if p)
|
||||
|
||||
|
||||
_AGE_PREFIX = {
|
||||
"青年": "年轻",
|
||||
"中年": "中年",
|
||||
"老年": "老年",
|
||||
}
|
||||
# gender 后缀
|
||||
_GENDER_WORD = {"男": "男性", "女": "女性"}
|
||||
|
||||
|
||||
def _person_subject(fj: dict[str, Any]) -> str:
|
||||
"""人物主语:年轻女性 / 中年男性 / 少女 / 小男孩 / 人物 等。"""
|
||||
gender = fj.get("gender") or ""
|
||||
age = fj.get("age_range") or ""
|
||||
gw = _GENDER_WORD.get(gender, "")
|
||||
if age == "儿童":
|
||||
if gender == "女":
|
||||
return "小女孩"
|
||||
if gender == "男":
|
||||
return "小男孩"
|
||||
return "儿童"
|
||||
if age == "青少年":
|
||||
if gender == "女":
|
||||
return "少女"
|
||||
if gender == "男":
|
||||
return "少年"
|
||||
return "青少年"
|
||||
prefix = _AGE_PREFIX.get(age, "")
|
||||
if gw:
|
||||
return f"{prefix}{gw}" if prefix else gw
|
||||
return f"{prefix}人物" if prefix else "人物"
|
||||
|
||||
|
||||
def _build_wear_sentence(fj: dict[str, Any]) -> str:
|
||||
"""穿搭段:上装+下装/连衣裙,带颜色+材质+图案。"""
|
||||
upper = fj.get("upper_wear") or ""
|
||||
upper_color = fj.get("upper_color") or ""
|
||||
lower = fj.get("lower_wear") or ""
|
||||
lower_color = fj.get("lower_color") or ""
|
||||
dress_color = fj.get("dress_color") or ""
|
||||
material = fj.get("material") or ""
|
||||
pattern = fj.get("pattern") or ""
|
||||
|
||||
is_dress = ("连衣裙" in upper) or ("裙" in upper and not lower)
|
||||
if is_dress:
|
||||
c = dress_color or upper_color
|
||||
wear = f"{c}{upper}" if c else upper
|
||||
if material and material not in wear:
|
||||
wear = f"{material}{wear}"
|
||||
if pattern and pattern not in wear and pattern != "纯色":
|
||||
wear += f",{pattern}图案"
|
||||
return f"身穿{wear}"
|
||||
|
||||
parts: list[str] = []
|
||||
if upper:
|
||||
up = f"{upper_color}{upper}" if upper_color else upper
|
||||
if material and material not in up:
|
||||
up = f"{material}{up}"
|
||||
if pattern and pattern != "纯色" and pattern not in up:
|
||||
up += f"({pattern})"
|
||||
parts.append(f"上身{up}" if up else "")
|
||||
if lower:
|
||||
lo = f"{lower_color}{lower}" if lower_color else lower
|
||||
parts.append(f"下身{lo}" if lo else "")
|
||||
return ",".join(p for p in parts if p)
|
||||
|
||||
|
||||
def _build_portrait_prompt(fj: dict[str, Any]) -> str:
|
||||
"""组装最终 portrait_prompt(目标 60-100 字,用于 Seedream 纯文生图)。"""
|
||||
if not fj.get("has_person"):
|
||||
# 非人像:用商品+场景+mood 拼一段
|
||||
name = fj.get("product_name") or "商品"
|
||||
brand = fj.get("brand") or ""
|
||||
colors = fj.get("colors") or []
|
||||
style = fj.get("style") or ""
|
||||
scene = fj.get("scene") or ""
|
||||
mood = fj.get("mood") or ""
|
||||
pieces = []
|
||||
if brand:
|
||||
pieces.append(brand)
|
||||
pieces.append(name)
|
||||
if colors:
|
||||
pieces.append("、".join(colors[:3]) + "配色")
|
||||
if style:
|
||||
pieces.append(style + "风格")
|
||||
if mood:
|
||||
pieces.append(mood + "氛围")
|
||||
if scene and scene not in ("通用",):
|
||||
pieces.append(scene + "场景")
|
||||
pieces.append("产品特写")
|
||||
prompt = ",".join(p for p in pieces if p)
|
||||
return prompt if len(prompt) >= 10 else "产品展示图,特写镜头"
|
||||
|
||||
subject = _person_subject(fj)
|
||||
wear = _build_wear_sentence(fj)
|
||||
|
||||
accessories = fj.get("accessories") or []
|
||||
if isinstance(accessories, str):
|
||||
accessories = [accessories]
|
||||
acc_str = ""
|
||||
if accessories:
|
||||
acc_str = ",佩戴" + "、".join(str(a) for a in accessories if a)
|
||||
|
||||
hairstyle = fj.get("hairstyle") or ""
|
||||
expression = fj.get("expression") or ""
|
||||
pose = fj.get("pose") or ""
|
||||
style = fj.get("style") or ""
|
||||
scene = fj.get("scene") or ""
|
||||
mood = fj.get("mood") or ""
|
||||
|
||||
detail_parts: list[str] = []
|
||||
if hairstyle:
|
||||
detail_parts.append(hairstyle)
|
||||
if expression and expression not in ("自然", "平静"):
|
||||
detail_parts.append(f"神情{expression}")
|
||||
if pose and pose not in ("站立",):
|
||||
detail_parts.append(pose)
|
||||
|
||||
style_parts: list[str] = []
|
||||
if style:
|
||||
style_parts.append(style)
|
||||
if mood:
|
||||
style_parts.append(mood)
|
||||
if scene and scene not in ("通用",):
|
||||
style_parts.append(scene)
|
||||
|
||||
pieces = [f"一位{subject}"]
|
||||
if wear:
|
||||
pieces.append(wear)
|
||||
if acc_str:
|
||||
pieces.append(acc_str.lstrip(","))
|
||||
if detail_parts:
|
||||
pieces.append(",".join(detail_parts))
|
||||
if style_parts:
|
||||
# 风格词之间不用逗号,用空格紧凑
|
||||
pieces.append("".join(style_parts) + "风格")
|
||||
else:
|
||||
pieces.append("人像写真")
|
||||
|
||||
full = ",".join(p for p in pieces if p)
|
||||
# 过短补充镜头词
|
||||
if len(full) < 40:
|
||||
full += ",自然光线下人像特写,画面清晰"
|
||||
# 过长截断
|
||||
if len(full) > 120:
|
||||
full = full[:120].rstrip(",") + "。"
|
||||
return full
|
||||
|
||||
|
||||
# ---------- 商品字段 ----------
|
||||
|
||||
|
||||
def _infer_name(fj: dict[str, Any], ocr_texts: list[str]) -> str:
|
||||
pname = fj.get("product_name")
|
||||
if pname and pname != "未识别":
|
||||
return str(pname)
|
||||
# 人物图 → name 用穿搭主件
|
||||
if fj.get("has_person"):
|
||||
up = fj.get("upper_wear") or ""
|
||||
if "连衣裙" in up:
|
||||
return up
|
||||
return up or "人物穿搭"
|
||||
if ocr_texts:
|
||||
# 商品名可能是 OCR 最长的一行(品牌/产品名)
|
||||
return max(ocr_texts, key=len)
|
||||
return "未识别"
|
||||
|
||||
|
||||
def _infer_brand(fj: dict[str, Any], ocr_texts: list[str]) -> str:
|
||||
brand = fj.get("brand")
|
||||
if brand:
|
||||
return str(brand)
|
||||
# OCR 里短的、纯字母/汉字短串可能是 brand
|
||||
for t in ocr_texts:
|
||||
if 1 < len(t) <= 12:
|
||||
return t
|
||||
return "无法判断"
|
||||
|
||||
|
||||
def _infer_category(fj: dict[str, Any]) -> str:
|
||||
cat = fj.get("category")
|
||||
if cat:
|
||||
return str(cat)
|
||||
if fj.get("has_person"):
|
||||
return "服饰"
|
||||
return "非产品图"
|
||||
|
||||
|
||||
def _build_appearance(fj: dict[str, Any]) -> str:
|
||||
"""外观描述:颜色+款式+材质+图案 拼成一段。"""
|
||||
parts: list[str] = []
|
||||
for key, _label in [
|
||||
("upper_color", "主色"),
|
||||
("upper_wear", "款式"),
|
||||
("material", "材质"),
|
||||
("pattern", "图案"),
|
||||
]:
|
||||
v = fj.get(key)
|
||||
if v and v not in ("无法判断", "未知", "纯色"):
|
||||
parts.append(str(v))
|
||||
if not parts:
|
||||
if fj.get("has_person"):
|
||||
return "人像穿搭整体造型"
|
||||
return "无法判断"
|
||||
return "、".join(parts)
|
||||
|
||||
|
||||
def _build_key_features(fj: dict[str, Any], ocr_texts: list[str]) -> list[str]:
|
||||
feats: list[str] = []
|
||||
for key in (
|
||||
"upper_wear",
|
||||
"lower_wear",
|
||||
"upper_color",
|
||||
"lower_color",
|
||||
"dress_color",
|
||||
"material",
|
||||
"pattern",
|
||||
"style",
|
||||
"accessories",
|
||||
):
|
||||
v = fj.get(key)
|
||||
if not v:
|
||||
continue
|
||||
if isinstance(v, list):
|
||||
feats.extend(str(x) for x in v if x)
|
||||
elif isinstance(v, str) and v not in ("无法判断", "未知", "纯色"):
|
||||
feats.append(v)
|
||||
if ocr_texts:
|
||||
feats.append(f"画面文字: {'/'.join(ocr_texts[:3])}")
|
||||
# 去重
|
||||
out: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for f in feats:
|
||||
f = f.strip()
|
||||
if f and f not in seen and len(f) <= 30:
|
||||
seen.add(f)
|
||||
out.append(f)
|
||||
return out[:6] if out else ["无法判断"]
|
||||
|
||||
|
||||
def assemble_result(
|
||||
idx: int,
|
||||
fast_json: dict[str, Any] | None,
|
||||
ocr_texts: list[str],
|
||||
) -> dict[str, Any]:
|
||||
"""把 fast_json 结果 + OCR 文本组装成下游兼容的 product dict。"""
|
||||
fj = fast_json or {}
|
||||
ocr_texts = ocr_texts or []
|
||||
|
||||
portrait_prompt = _build_portrait_prompt(fj)
|
||||
name = _infer_name(fj, ocr_texts)
|
||||
brand = _infer_brand(fj, ocr_texts)
|
||||
category = _infer_category(fj)
|
||||
appearance = _build_appearance(fj)
|
||||
key_features = _build_key_features(fj, ocr_texts)
|
||||
scene = fj.get("scene") or "通用"
|
||||
mood = fj.get("mood") or ""
|
||||
packaging = "无法判断" # 包装细节专用API无,保留占位
|
||||
text_on_package = ocr_texts[:8]
|
||||
summary = _build_summary(fj, name, brand, category)
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"brand": brand,
|
||||
"category": category,
|
||||
"appearance": appearance,
|
||||
"packaging": packaging,
|
||||
"text_on_package": text_on_package,
|
||||
"key_features": key_features,
|
||||
"scene": scene,
|
||||
"mood": mood,
|
||||
"portrait_prompt": portrait_prompt,
|
||||
"summary": summary,
|
||||
"_source": "v2_fast_json",
|
||||
}
|
||||
|
||||
|
||||
def _build_summary(fj: dict, name: str, brand: str, category: str) -> str:
|
||||
if fj.get("has_person"):
|
||||
up = fj.get("upper_wear") or "穿搭"
|
||||
style = fj.get("style") or ""
|
||||
base = f"{style}{up}" if style and style not in up else up
|
||||
return base
|
||||
if brand != "无法判断" and name != brand:
|
||||
return f"{brand} {name}"
|
||||
return name
|
||||
@@ -0,0 +1,144 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""V2 图片分析主路径:每图并行 OCR(火山MediaKit,未配置时自动跳过)+ qwen3.8-flash JSON VLM,
|
||||
失败时单次 qwen3.7-plus 兜底。
|
||||
|
||||
架构(灵应10-05确认):
|
||||
- 唯一后端:阿里云百炼 DashScope,qwen3.8-flash 做快速路径、qwen3.7-plus 做兜底
|
||||
- 主力:单图2路并行(OCR + fast VLM),外层N图全并发(workers=8)
|
||||
- 兜底:单次 pro VLM 调用,无竞速/重试/复杂超时
|
||||
- 输出 dict 格式与旧版完全一致,下游零改动
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from typing import Any
|
||||
|
||||
from . import assembler, ocr_volc, vlm_fallback, vlm_fast_json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 超时(可通过环境变量覆盖)
|
||||
_IMG_WORKERS = int(os.environ.get("VISION_V2_IMG_WORKERS", "8"))
|
||||
_FAST_TIMEOUT = float(os.environ.get("VISION_V2_FAST_TIMEOUT", "12"))
|
||||
_FAST_JSON_TIMEOUT = float(os.environ.get("VISION_V2_FAST_JSON_TIMEOUT", "12"))
|
||||
_OCR_TIMEOUT = float(os.environ.get("VISION_V2_OCR_TIMEOUT", "6"))
|
||||
_PRO_TIMEOUT = float(os.environ.get("VISION_V2_PRO_TIMEOUT", "25"))
|
||||
|
||||
_FALLBACK_RESULT = {
|
||||
"name": "未识别",
|
||||
"brand": "无法判断",
|
||||
"category": "非产品图",
|
||||
"appearance": "无法判断",
|
||||
"packaging": "无法判断",
|
||||
"text_on_package": [],
|
||||
"key_features": ["无法判断"],
|
||||
"scene": "通用",
|
||||
"mood": "",
|
||||
"portrait_prompt": "无法判断",
|
||||
"summary": "未识别",
|
||||
}
|
||||
|
||||
|
||||
def _is_usable(r: dict[str, Any]) -> bool:
|
||||
pp = (r.get("portrait_prompt") or "").strip()
|
||||
if pp and pp not in ("无人像", "无法判断", "未识别"):
|
||||
return True
|
||||
name = (r.get("name") or "").strip()
|
||||
if name and name not in ("未识别", "无法判断", "未知"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def analyze_image_v2(idx: int, img_url: str) -> dict[str, Any]:
|
||||
t0 = time.time()
|
||||
|
||||
fj_result: dict[str, Any] | None = None
|
||||
ocr_result: list[str] = []
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
f_fj = pool.submit(vlm_fast_json.call_fast_json, img_url, timeout=_FAST_JSON_TIMEOUT)
|
||||
f_ocr = pool.submit(ocr_volc.call_ocr, img_url, timeout=_OCR_TIMEOUT)
|
||||
try:
|
||||
for fut in as_completed([f_fj, f_ocr], timeout=_FAST_TIMEOUT):
|
||||
try:
|
||||
res = fut.result(timeout=1)
|
||||
except Exception as e:
|
||||
logger.warning("[vision.v2] 图片 #%d 子任务异常: %s", idx, e)
|
||||
continue
|
||||
if fut is f_fj and isinstance(res, dict):
|
||||
fj_result = res
|
||||
elif fut is f_ocr and isinstance(res, list):
|
||||
ocr_result = res
|
||||
except TimeoutError:
|
||||
for f in (f_fj, f_ocr):
|
||||
if not f.done():
|
||||
f.cancel()
|
||||
logger.warning("[vision.v2] 图片 #%d fast路径超时(%.0fs),走pro兜底", idx, _FAST_TIMEOUT)
|
||||
|
||||
fast_elapsed = time.time() - t0
|
||||
|
||||
if fj_result:
|
||||
assembled = assembler.assemble_result(idx, fj_result, ocr_result)
|
||||
if _is_usable(assembled):
|
||||
assembled["_fast_elapsed"] = round(fast_elapsed, 2)
|
||||
logger.info(
|
||||
"[vision.v2] 图片 #%d fast命中 elapsed=%.2fs pp=%s",
|
||||
idx,
|
||||
fast_elapsed,
|
||||
(assembled.get("portrait_prompt") or "")[:40],
|
||||
)
|
||||
return assembled
|
||||
|
||||
pro_t0 = time.time()
|
||||
pro_result = vlm_fallback.call_pro_vlm(img_url, idx, timeout=_PRO_TIMEOUT)
|
||||
if pro_result and _is_usable(pro_result):
|
||||
pro_result["_fallback_used"] = True
|
||||
pro_result["_fast_elapsed"] = round(fast_elapsed, 2)
|
||||
pro_result["_pro_elapsed"] = round(time.time() - pro_t0, 2)
|
||||
if ocr_result and not pro_result.get("text_on_package"):
|
||||
pro_result["text_on_package"] = ocr_result[:8]
|
||||
logger.info("[vision.v2] 图片 #%d pro兜底命中 total=%.2fs", idx, time.time() - t0)
|
||||
return pro_result
|
||||
|
||||
logger.warning("[vision.v2] 图片 #%d 全路径失败 elapsed=%.2fs", idx, time.time() - t0)
|
||||
out = dict(_FALLBACK_RESULT)
|
||||
out["_source"] = "v2_all_failed"
|
||||
out["text_on_package"] = ocr_result[:8]
|
||||
out["_fast_elapsed"] = round(fast_elapsed, 2)
|
||||
return out
|
||||
|
||||
|
||||
def analyze_images_v2(img_urls: list[str]) -> list[dict[str, Any]]:
|
||||
if not img_urls:
|
||||
return []
|
||||
workers = min(_IMG_WORKERS, len(img_urls), 16)
|
||||
results: list[dict[str, Any] | None] = [None] * len(img_urls)
|
||||
|
||||
logger.info(
|
||||
"[vision.v2] 开始图片分析 n=%d workers=%d fast_timeout=%.0fs pro_timeout=%.0fs",
|
||||
len(img_urls),
|
||||
workers,
|
||||
_FAST_TIMEOUT,
|
||||
_PRO_TIMEOUT,
|
||||
)
|
||||
t0 = time.time()
|
||||
with ThreadPoolExecutor(max_workers=workers) as pool:
|
||||
future_to_idx = {pool.submit(analyze_image_v2, idx, url): idx for idx, url in enumerate(img_urls)}
|
||||
for fut in as_completed(future_to_idx):
|
||||
idx = future_to_idx[fut]
|
||||
try:
|
||||
results[idx] = fut.result()
|
||||
except Exception as e:
|
||||
logger.warning("[vision.v2] 图片 #%d future异常: %s", idx, e, exc_info=True)
|
||||
r = dict(_FALLBACK_RESULT)
|
||||
r["_source"] = "v2_future_exception"
|
||||
results[idx] = r
|
||||
|
||||
elapsed = time.time() - t0
|
||||
succ = sum(1 for r in results if r and _is_usable(r))
|
||||
fb = sum(1 for r in results if r and r.get("_fallback_used"))
|
||||
logger.info("[vision.v2] 完成 n=%d usable=%d pro_fallback=%d elapsed=%.2fs", len(img_urls), succ, fb, elapsed)
|
||||
return [r for r in results if r is not None]
|
||||
@@ -0,0 +1,109 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""火山引擎 AI MediaKit OCR(同步)调用封装。
|
||||
|
||||
接口:POST {mediakit_base_url}/tools-sync/ocr
|
||||
鉴权:Bearer {mediakit_api_key}
|
||||
请求体:{"image_url": "<公网可访问URL>"} (部分版本也支持 image_base64)
|
||||
响应:{"code":0,"data":{"texts":[{"text":"...","bbox":[x,y,w,h],...},...],...}}
|
||||
|
||||
目标:识别商品包装/Logo/水印上的文字,作为 fast_json VLM 的补充。
|
||||
返回值:识别到的文本字符串列表(失败返回 [])。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_TIMEOUT = 8 # OCR 秒级返回,8s 绰绰有余
|
||||
|
||||
|
||||
def call_ocr(img_url: str, *, timeout: int = DEFAULT_TIMEOUT) -> list[str]:
|
||||
"""调用 MediaKit 同步 OCR,返回去重后的纯文本列表。
|
||||
|
||||
不做重试(外层降级逻辑负责)。失败/未配置返回空列表,不抛异常。
|
||||
"""
|
||||
t0 = time.time()
|
||||
try:
|
||||
import httpx
|
||||
|
||||
from packages.shared.mediakit_client import get_mediakit_client
|
||||
|
||||
client = get_mediakit_client()
|
||||
if not client.is_available:
|
||||
logger.info("[vision.v2] mediakit 未配置,跳过 OCR")
|
||||
return []
|
||||
|
||||
url = f"{client.base_url}/tools-sync/ocr"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {client.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
payload: dict[str, Any] = {"image_url": img_url}
|
||||
# 部分文档版本用 image_base64,但公网 URL 场景下 image_url 最简
|
||||
resp = httpx.post(url, headers=headers, json=payload, timeout=timeout)
|
||||
elapsed = time.time() - t0
|
||||
if resp.status_code != 200:
|
||||
logger.warning(
|
||||
"[vision.v2] OCR HTTP %d elapsed=%.1fs body=%s",
|
||||
resp.status_code,
|
||||
elapsed,
|
||||
resp.text[:200],
|
||||
)
|
||||
return []
|
||||
data = resp.json()
|
||||
# 兼容几种可能的响应结构
|
||||
code = data.get("code", data.get("status", 0))
|
||||
if code not in (0, "OK", "success", 200):
|
||||
logger.warning("[vision.v2] OCR 业务错误 code=%s elapsed=%.1fs resp=%s", code, elapsed, str(data)[:200])
|
||||
return []
|
||||
texts = _extract_texts(data)
|
||||
# 去重 + 过滤空
|
||||
seen: set[str] = set()
|
||||
out: list[str] = []
|
||||
for t in texts:
|
||||
t = (t or "").strip()
|
||||
if t and t not in seen and len(t) <= 100: # 过滤过长的误识别
|
||||
seen.add(t)
|
||||
out.append(t)
|
||||
logger.info("[vision.v2] OCR 完成 elapsed=%.1fs n=%d texts=%s", elapsed, len(out), out[:5])
|
||||
return out
|
||||
except Exception as e:
|
||||
elapsed = time.time() - t0
|
||||
logger.warning("[vision.v2] OCR 异常 elapsed=%.1fs err=%s", elapsed, e, exc_info=True)
|
||||
return []
|
||||
|
||||
|
||||
def _extract_texts(data: dict) -> list[str]:
|
||||
"""从 OCR 响应中抽取文本,兼容多种结构。"""
|
||||
out: list[str] = []
|
||||
# 常见结构1: data.texts = [{"text": "..."}, ...]
|
||||
d = data.get("data") or data
|
||||
if isinstance(d, dict):
|
||||
for key in ("texts", "lines", "words", "items", "result"):
|
||||
items = d.get(key)
|
||||
if isinstance(items, list):
|
||||
for it in items:
|
||||
if isinstance(it, dict):
|
||||
txt = it.get("text") or it.get("content") or it.get("word")
|
||||
if txt:
|
||||
out.append(str(txt))
|
||||
elif isinstance(it, str):
|
||||
out.append(it)
|
||||
break
|
||||
# 结构2: data.text = "..."
|
||||
if not out:
|
||||
t = d.get("text")
|
||||
if isinstance(t, str):
|
||||
out.append(t)
|
||||
# 结构3: data.ocr_text / data.content
|
||||
if not out:
|
||||
for key in ("ocr_text", "content", "raw_text"):
|
||||
v = d.get(key)
|
||||
if isinstance(v, str) and v.strip():
|
||||
out.append(v)
|
||||
break
|
||||
return out
|
||||
@@ -0,0 +1,175 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""V2 pro 兜底:qwen3.7-plus(阿里云百炼/DashScope)单次调用。
|
||||
|
||||
fast_json 结果不可用时单次调用,无竞速、无重试、无复杂超时逻辑。
|
||||
直接 httpx 发精简 JSON-only prompt(比旧版 prompt_loader XML 模板短很多,降低延迟)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
||||
_PRO_MODEL = "qwen3.7-plus"
|
||||
_DEFAULT_TIMEOUT = 25
|
||||
_DEFAULT_MAX_TOKENS = 800
|
||||
|
||||
_PRO_SYSTEM = (
|
||||
"你是图片分析助手。仔细观察图片,严格按JSON schema返回一个对象,不要任何解释、"
|
||||
"不要markdown、不要代码块、不要前后缀文字。字段值不确定时填null或空数组。\n"
|
||||
"{\n"
|
||||
' "has_person": true/false,\n'
|
||||
' "gender": "男"/"女"/null,\n'
|
||||
' "age_range": "儿童"/"青少年"/"青年"/"中年"/"老年"/null,\n'
|
||||
' "outfit": "人物穿搭描述,60字以内(例:白色T恤+牛仔裤)",\n'
|
||||
' "hair": "发型",\n'
|
||||
' "pose": "姿态",\n'
|
||||
' "expression": "表情",\n'
|
||||
' "scene": "场景",\n'
|
||||
' "mood": "氛围",\n'
|
||||
' "has_product": true/false,\n'
|
||||
' "category": "服饰/鞋包/美妆/数码/食品/家居/配饰/母婴/非产品图",\n'
|
||||
' "product_name": "产品名称,非产品图填null",\n'
|
||||
' "brand": "品牌或文字标识,无则null",\n'
|
||||
' "key_features": ["特征数组"]\n'
|
||||
"}"
|
||||
)
|
||||
_PRO_USER = "分析这张图片,返回符合schema的JSON。"
|
||||
|
||||
|
||||
def _strip_code_fence(s: str) -> str:
|
||||
s = s.strip()
|
||||
if s.startswith("```"):
|
||||
lines = s.split("\n")
|
||||
if lines and lines[0].startswith("```"):
|
||||
lines = lines[1:]
|
||||
if lines and lines[-1].strip().startswith("```"):
|
||||
lines = lines[:-1]
|
||||
s = "\n".join(lines).strip()
|
||||
return s
|
||||
|
||||
|
||||
def _assemble_pp(obj: dict[str, Any]) -> str:
|
||||
if not obj.get("has_person", False):
|
||||
return "无人像"
|
||||
parts: list[str] = []
|
||||
gender = obj.get("gender")
|
||||
age = obj.get("age_range")
|
||||
if gender:
|
||||
parts.append(gender + ("性" if not gender.endswith("性") else ""))
|
||||
if age:
|
||||
parts.append(age)
|
||||
parts.append("人物")
|
||||
hair = obj.get("hair")
|
||||
if hair:
|
||||
parts.append(hair)
|
||||
outfit = obj.get("outfit")
|
||||
if outfit:
|
||||
parts.append(f"身着{outfit}")
|
||||
pose = obj.get("pose")
|
||||
if pose:
|
||||
parts.append(f"姿态{pose}")
|
||||
expr = obj.get("expression")
|
||||
if expr:
|
||||
parts.append(f"表情{expr}")
|
||||
return ",".join(parts) if parts else "无人像"
|
||||
|
||||
|
||||
def call_pro_vlm(
|
||||
img_url: str,
|
||||
idx: int,
|
||||
*,
|
||||
timeout: int = _DEFAULT_TIMEOUT,
|
||||
) -> dict[str, Any] | None:
|
||||
"""单次调用 qwen3.7-plus,解析后返回 product dict;失败返回 None。"""
|
||||
t0 = time.time()
|
||||
import httpx
|
||||
|
||||
api_key = os.environ.get("DASHSCOPE_API_KEY")
|
||||
if not api_key:
|
||||
logger.warning("[vision.v2] DASHSCOPE_API_KEY 未配置,跳过 pro 兜底")
|
||||
return None
|
||||
|
||||
url = f"{_BASE_URL}/chat/completions"
|
||||
payload: dict[str, Any] = {
|
||||
"model": _PRO_MODEL,
|
||||
"messages": [
|
||||
{"role": "system", "content": _PRO_SYSTEM},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "image_url", "image_url": {"url": img_url}},
|
||||
{"type": "text", "text": _PRO_USER},
|
||||
],
|
||||
},
|
||||
],
|
||||
"temperature": 0.3,
|
||||
"max_tokens": _DEFAULT_MAX_TOKENS,
|
||||
"stream": False,
|
||||
"enable_thinking": False,
|
||||
"response_format": {"type": "json_object"},
|
||||
}
|
||||
try:
|
||||
r = httpx.post(
|
||||
url,
|
||||
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
|
||||
json=payload,
|
||||
timeout=timeout,
|
||||
)
|
||||
elapsed = time.time() - t0
|
||||
if r.status_code != 200:
|
||||
logger.warning("[vision.v2] pro HTTP %d elapsed=%.1fs body=%s", r.status_code, elapsed, r.text[:200])
|
||||
return None
|
||||
data = r.json()
|
||||
raw = (data.get("choices") or [{}])[0].get("message", {}).get("content")
|
||||
if not raw:
|
||||
logger.warning("[vision.v2] pro 返回空 elapsed=%.1fs", elapsed)
|
||||
return None
|
||||
usage = data.get("usage") or {}
|
||||
logger.info(
|
||||
"[vision.v2] pro 完成 idx=%d model=%s elapsed=%.1fs in=%d out=%d",
|
||||
idx,
|
||||
_PRO_MODEL,
|
||||
elapsed,
|
||||
usage.get("prompt_tokens", 0),
|
||||
usage.get("completion_tokens", 0),
|
||||
)
|
||||
text = _strip_code_fence(raw)
|
||||
l, r_pos = text.find("{"), text.rfind("}")
|
||||
if l < 0 or r_pos <= l:
|
||||
logger.warning("[vision.v2] pro 无JSON elapsed=%.1fs head=%s", elapsed, raw[:200])
|
||||
return None
|
||||
obj = json.loads(text[l : r_pos + 1])
|
||||
if not isinstance(obj, dict):
|
||||
return None
|
||||
scene = obj.get("scene") or "通用"
|
||||
mood = obj.get("mood") or ""
|
||||
pp = _assemble_pp(obj)
|
||||
has_person = obj.get("has_person", False)
|
||||
has_product = obj.get("has_product", False)
|
||||
name = obj.get("product_name") or "未识别"
|
||||
brand = obj.get("brand") or "无法判断"
|
||||
category = obj.get("category") or ("非产品图" if has_person and not has_product else "无法判断")
|
||||
return {
|
||||
"name": name,
|
||||
"brand": brand,
|
||||
"category": category,
|
||||
"appearance": obj.get("outfit") or "无法判断",
|
||||
"packaging": "无法判断",
|
||||
"text_on_package": [],
|
||||
"key_features": obj.get("key_features") or ["无法判断"],
|
||||
"scene": scene,
|
||||
"mood": mood,
|
||||
"portrait_prompt": pp,
|
||||
"summary": f"{brand} {name}" if name != "未识别" else "未识别",
|
||||
"_source": "vlm_pro",
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning("[vision.v2] pro 异常 idx=%d elapsed=%.1fs err=%s", idx, time.time() - t0, e, exc_info=True)
|
||||
return None
|
||||
@@ -0,0 +1,178 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""V2 快速路径:qwen3.8-flash(阿里云百炼/DashScope)强约束 JSON-only 调用。
|
||||
|
||||
目标:替代"人体属性/商品检测/图像标签"三个火山不存在的专用云端 API。
|
||||
设计要点:
|
||||
- 直接用 httpx 发最小 payload 到 DashScope OpenAI 兼容 endpoint,不走 ai_client 包装
|
||||
- enable_thinking=false 关闭推理链(reasoning 是延迟主因)
|
||||
- system prompt 极致精简,只给字段 schema 和强约束(禁止自然语言、禁止 markdown)
|
||||
- max_tokens=350、temperature=0.1(稳定输出 JSON)
|
||||
- timeout=12s(失败由外层走 pro 兜底)
|
||||
- API Key 从环境变量 DASHSCOPE_API_KEY 读取
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# DashScope OpenAI 兼容 endpoint
|
||||
_BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
||||
_FAST_MODEL = "qwen3.8-flash"
|
||||
_DEFAULT_TIMEOUT = 12
|
||||
_DEFAULT_MAX_TOKENS = 350
|
||||
|
||||
# 极简 system prompt:只给字段定义 + 硬性输出要求
|
||||
_FAST_SYSTEM = (
|
||||
"你是图片结构化识别器。严格按下方 JSON schema 返回一个对象,不要任何解释、"
|
||||
"不要markdown、不要代码块、不要前后缀文字。字段值不确定时填 null 或空数组。\n"
|
||||
"{\n"
|
||||
' "has_person": true/false,\n'
|
||||
' "gender": "男"/"女"/null,\n'
|
||||
' "age_range": "儿童"/"青少年"/"青年"/"中年"/"老年"/null,\n'
|
||||
' "upper_wear": "上装款式,如T恤/衬衫/卫衣/毛衣/西装/夹克/连衣裙/吊带/背心/外套等",\n'
|
||||
' "upper_color": "上装主色",\n'
|
||||
' "lower_wear": "下装款式;穿连衣裙时填null",\n'
|
||||
' "lower_color": "下装主色",\n'
|
||||
' "dress_color": "连衣裙主色(穿连衣裙时填)",\n'
|
||||
' "accessories": ["眼镜"/"帽子"/"项链"/"耳环"/"背包"/"手表"等数组],\n'
|
||||
' "hairstyle": "发型,如短发/长发/马尾/卷发/丸子头/光头等",\n'
|
||||
' "expression": "表情,如微笑/严肃/酷/开心等",\n'
|
||||
' "pose": "姿势,如站立/坐姿/侧身/行走等",\n'
|
||||
' "scene": "场景,如室内/街拍/户外/办公室/家居/海边/雪景/森林等",\n'
|
||||
' "style": "风格,如休闲/商务/运动/复古/潮流/甜美/酷飒/优雅/街头/法式等",\n'
|
||||
' "has_product": true/false,\n'
|
||||
' "category": "产品类目:服饰/鞋包/美妆/数码/食品/家居/配饰/母婴/非产品图",\n'
|
||||
' "product_name": "产品名称,非产品图填null",\n'
|
||||
' "brand": "品牌或文字标识,无则null",\n'
|
||||
' "material": "材质,如棉质/牛仔/皮革/真丝/针织/涤纶等",\n'
|
||||
' "pattern": "图案,如纯色/条纹/波点/格子/印花/碎花/Logo等",\n'
|
||||
' "colors": ["主色数组"],\n'
|
||||
' "mood": "整体氛围/情绪,如清新/活力/高级/温暖/冷峻/甜美/复古等"\n'
|
||||
"}"
|
||||
)
|
||||
|
||||
_FAST_USER = "识别这张图片的人物穿搭与主体信息,只返回JSON对象。"
|
||||
|
||||
|
||||
def _api_key() -> str | None:
|
||||
return os.environ.get("DASHSCOPE_API_KEY")
|
||||
|
||||
|
||||
def _strip_code_fence(s: str) -> str:
|
||||
s = s.strip()
|
||||
if s.startswith("```"):
|
||||
lines = s.split("\n")
|
||||
if lines and lines[0].startswith("```"):
|
||||
lines = lines[1:]
|
||||
if lines and lines[-1].strip().startswith("```"):
|
||||
lines = lines[:-1]
|
||||
s = "\n".join(lines).strip()
|
||||
return s
|
||||
|
||||
|
||||
def call_fast_json(
|
||||
img_url: str,
|
||||
*,
|
||||
timeout: int = _DEFAULT_TIMEOUT,
|
||||
max_tokens: int = _DEFAULT_MAX_TOKENS,
|
||||
) -> dict[str, Any] | None:
|
||||
"""调用 qwen3.8-flash 返回结构化 dict;失败/非 JSON 返回 None。"""
|
||||
t0 = time.time()
|
||||
import httpx
|
||||
|
||||
api_key = _api_key()
|
||||
if not api_key:
|
||||
logger.warning("[vision.v2] DASHSCOPE_API_KEY 未配置,跳过 fast_json")
|
||||
return None
|
||||
|
||||
url = f"{_BASE_URL}/chat/completions"
|
||||
payload: dict[str, Any] = {
|
||||
"model": _FAST_MODEL,
|
||||
"messages": [
|
||||
{"role": "system", "content": _FAST_SYSTEM},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "image_url", "image_url": {"url": img_url}},
|
||||
{"type": "text", "text": _FAST_USER},
|
||||
],
|
||||
},
|
||||
],
|
||||
"temperature": 0.1,
|
||||
"max_tokens": max_tokens,
|
||||
"stream": False,
|
||||
"enable_thinking": False,
|
||||
"response_format": {"type": "json_object"},
|
||||
}
|
||||
try:
|
||||
resp = httpx.post(
|
||||
url,
|
||||
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
|
||||
json=payload,
|
||||
timeout=timeout,
|
||||
)
|
||||
elapsed = time.time() - t0
|
||||
if resp.status_code == 400 and "enable_thinking" in resp.text[:300].lower():
|
||||
# 极少数 endpoint 版本不识别 enable_thinking,重试一次不带
|
||||
logger.warning("[vision.v2] fast_json HTTP 400 thinking 参数不兼容,重试 elapsed=%.1fs", elapsed)
|
||||
payload.pop("enable_thinking", None)
|
||||
resp = httpx.post(
|
||||
url,
|
||||
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
|
||||
json=payload,
|
||||
timeout=timeout,
|
||||
)
|
||||
elapsed = time.time() - t0
|
||||
if resp.status_code != 200:
|
||||
logger.warning(
|
||||
"[vision.v2] fast_json HTTP %d elapsed=%.1fs body=%s", resp.status_code, elapsed, resp.text[:200]
|
||||
)
|
||||
return None
|
||||
data = resp.json()
|
||||
raw = (data.get("choices") or [{}])[0].get("message", {}).get("content")
|
||||
if not raw:
|
||||
logger.warning("[vision.v2] fast_json 返回空 elapsed=%.1fs", elapsed)
|
||||
return None
|
||||
usage = data.get("usage") or {}
|
||||
reasoning_tokens = usage.get("reasoning_tokens", 0)
|
||||
ctd = usage.get("completion_tokens_details") or {}
|
||||
if not reasoning_tokens:
|
||||
reasoning_tokens = ctd.get("reasoning_tokens", 0)
|
||||
logger.info(
|
||||
"[vision.v2] fast_json 完成 model=%s elapsed=%.1fs in=%d out=%d reasoning=%d",
|
||||
_FAST_MODEL,
|
||||
elapsed,
|
||||
usage.get("prompt_tokens", 0),
|
||||
usage.get("completion_tokens", 0),
|
||||
reasoning_tokens,
|
||||
)
|
||||
text = _strip_code_fence(raw)
|
||||
l, r = text.find("{"), text.rfind("}")
|
||||
if l >= 0 and r > l:
|
||||
text = text[l : r + 1]
|
||||
try:
|
||||
obj = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("[vision.v2] fast_json JSON 解析失败 elapsed=%.1fs head=%s", elapsed, raw[:200])
|
||||
return None
|
||||
if not isinstance(obj, dict):
|
||||
logger.warning("[vision.v2] fast_json 非 dict: %s", type(obj))
|
||||
return None
|
||||
logger.info(
|
||||
"[vision.v2] fast_json 完成 elapsed=%.1fs has_person=%s has_product=%s category=%s",
|
||||
elapsed,
|
||||
obj.get("has_person"),
|
||||
obj.get("has_product"),
|
||||
obj.get("category"),
|
||||
)
|
||||
return obj
|
||||
except Exception as e:
|
||||
elapsed = time.time() - t0
|
||||
logger.warning("[vision.v2] fast_json 异常 elapsed=%.1fs err=%s", elapsed, e, exc_info=True)
|
||||
return None
|
||||
Reference in New Issue
Block a user