diff --git a/alembic/versions/098_viral_video_image_analysis_v5.py b/alembic/versions/098_viral_video_image_analysis_v5.py new file mode 100644 index 000000000..898606ecd --- /dev/null +++ b/alembic/versions/098_viral_video_image_analysis_v5.py @@ -0,0 +1,100 @@ +# -*- coding: utf-8 -*- +"""viral video image_analysis v4 JSON prompt seed + +背景:image_analysis DB prompt 历史上是 XML 输出格式(...), +但 V2 视觉模块 VLM 调用强制 response_format=json_object,XML 被 JSON 解析失败, +导致所有商品图走 fallback 兜底。本迁移固化已在 staging 验证通过的 v4 JSON prompt +(type/products/people/store_info 多类型嵌套 schema),并将其置为 active; +同时修复解析侧(DB prompt 完全控制输出格式,不再追加硬编码 schema)。 + +幂等:v4 记录已存在则更新为已验证内容;不存在则插入。其他 image_analysis 版本 +统一置为 inactive。 +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "098_viral_video_image_analysis_v5" +down_revision = "097_viral_video_image_analysis_v4" +branch_labels = None +depends_on = None + + +V5_NAME = "图片分析JSON v5(人物字段大幅扩充+高质量示例)" +V5_VERSION = 5 +V5_SYSTEM_PROMPT = "你是一位专业的视觉内容分析师。先判断图片主体类型,再按对应维度详细分析,严格输出一个合法的JSON对象。\n\n【第一步:判断主体类型 type】\n- \"product\":单个或多个商品/产品包装/商品陈列\n- \"store\":店铺内景、门店门头、货架陈列、经营环境、活动现场\n- \"person\":人物形象、穿搭造型、肖像照片\n- \"other\":风景/动物/美食/截图/抽象画面等\n\n【第二步:通用字段(所有类型必填)】\n{\n \"type\": \"product/store/person/other\",\n \"scene\": \"具体场景描述,如'白色背景产品图''超市货架''户外街拍''便利店内部''家居环境'\",\n \"mood\": \"整体氛围,如'清新专业''高级干净''热闹促销''温暖明亮''生活化'\",\n \"style\": \"视觉风格,如'商业产品摄影''街拍''门店实拍''人像写真''证件照''活动现场照'\",\n \"colors\": [\n {\"hex\":\"#E60012\",\"name\":\"亮红色\",\"coverage\":0.6},\n {\"hex\":\"#FFFFFF\",\"name\":\"白色\",\"coverage\":0.3}\n ],\n \"visible_text\": [\n {\"text\":\"识别到的文字内容\",\"position\":\"位置描述,如'瓶身正面'\"}\n ],\n \"lighting\": \"光线描述,如'明亮柔光''自然光''室内灯光''逆光'\",\n \"composition\": \"构图描述,如'居中特写''中景''平视角度''俯拍'\"\n}\n\n【商品类 type=product 额外字段】\n在通用字段基础上增加:\n{\n \"subject_type\": \"product\",\n \"products\": [\n {\n \"product_name\": \"商品名称,尽量具体,如'OMO奥妙除菌除螨洗衣液'\",\n \"brand\": \"品牌名,如'OMO奥妙''CHANTE CLAIR大公鸡头',无法识别填null\",\n \"category\": \"类目:服饰/鞋包/美妆/数码/食品/家居清洁/洗护/家居/配饰/母婴/其他\",\n \"product_count\": 1,\n \"package_type\": \"包装类型,如'瓶装''袋装''盒装''罐装''喷雾瓶''多瓶装'\",\n \"package_color\": \"包装主色,具体如'亮红色瓶身''透明瓶身装白色液体''蓝色金属罐'\",\n \"cap_type\": \"瓶盖/瓶口类型,如'透明翻盖式按压瓶口''红白按压喷雾头''黑色旋盖'\",\n \"body_shape\": \"瓶身/包装形状特征,如'带侧面握持把手的竖款瓶身''圆润矮胖造型'\",\n \"label_design\": \"标签设计描述,如'瓶身印十字盾牌图案''贴有公鸡图案标签''大面积品牌logo'\",\n \"key_text_on_package\": [\"瓶身可见的主要文字,如'99除菌''除菌除螨''马赛香''强效去污'\"],\n \"background_elements\": [\"背景元素,如'绿叶''金色时钟造型''气泡''艾草类草本植物''红粉拼接背景'\"],\n \"product_features\": [\n \"包装特征短语3-6个,具体细致,颜色要准确(写'亮红色'不写'红色')\",\n \"包含包装颜色、瓶盖类型、握持结构、标签图案、标志性图形等\"\n ],\n \"key_selling_points\": [\"核心卖点1-3个,如'天然除菌除螨''万能去污一喷净''多瓶家庭装'\"]\n }\n ],\n \"has_person\": false\n}\n多件商品逐个填入products数组,每个元素独立描述。\n\n【门店类 type=store 额外字段】\n在通用字段基础上增加:\n{\n \"subject_type\": \"store\",\n \"store_type\": \"店铺类型,如'社区便利店''连锁超市''品牌专卖店''母婴店''餐饮店'\",\n \"store_layout\": \"空间布局,如'纵深货架布局''L型收银台+两侧货架''开放式陈列'\",\n \"brand_signage\": \"品牌招牌/门头文字,如'全家FamilyMart''XX便利店'\",\n \"visual_elements\": [\n \"视觉元素,如'主色调红白''顶部照明灯管''手写价签''促销海报''地堆陈列''收银台商品'\"\n ],\n \"product_categories_visible\": [\"可见的商品类目,如'饮料''零食''日用品''生鲜'\"],\n \"promotion_elements\": [\"促销活动元素,如'第二件半价海报''红色折扣价签''满减吊旗',无则空数组\"],\n \"customer_flow\": \"客流描述,如'整洁空旷''零星顾客''排队结账',无顾客填'店内无顾客'\",\n \"cleanliness\": \"整洁度,如'干净整洁''略显杂乱''货架整齐'\",\n \"atmosphere\": \"经营氛围,如'亲民生活化''高端精致''热闹促销''日常便民'\",\n \"has_person\": false\n}\n\n【人物类 type=person 额外字段】\n在通用字段基础上增加:\n{\n \"subject_type\": \"person\",\n \"has_person\": true,\n \"person_count\": \"人数整数,如1、2、3\",\n \"gender\": \"男/女/无法判断\",\n \"age_range\": \"儿童/青少年/青年/中年/老年/无法判断\",\n \"apparent_age\": \"视觉年龄数字,如25、30、35\",\n \"ethnicity\": \"人种/地域特征,如'东亚面孔''欧美面孔''东南亚面孔',中国人填'中国面孔'\",\n \"upper_wear\": \"上装完整描述(含颜色+款式+材质+图案),如'米白色针织开衫内搭白色圆领T恤''蓝色条纹短袖衬衫''黑色oversize连帽卫衣'\",\n \"lower_wear\": \"下装完整描述(含颜色+款式+材质+版型),如'浅蓝色高腰直筒牛仔裤''黑色西装阔腿裤''卡其色工装短裤',穿连衣裙填null\",\n \"outerwear\": \"外套/大衣完整描述,如'驼色长款羊毛大衣''黑色皮质机车夹克',无外套填null\",\n \"dress_wear\": \"连衣裙/套装完整描述(穿裙装时填,上装下装字段置空),如'酒红色丝绒吊带长裙''粉色碎花连衣裙配白色腰带'\",\n \"shoes\": \"鞋子完整描述,如'白色厚底运动鞋''黑色尖头高跟鞋''棕色切尔西短靴''米色帆布鞋'\",\n \"bag\": \"包袋完整描述,如'棕色皮质斜挎小包''黑色托特包''银色链条单肩包',无包填null\",\n \"accessories\": [\"配饰数组,含帽子/眼镜/项链/耳环/手表/手链/围巾/腰带/发饰等具体描述,如'金丝边圆框眼镜''淡水珍珠耳环''银色锁骨项链''黑色宽檐礼帽'\"],\n \"hairstyle\": \"发型发色详细描述,如'黑色及腰长直发,空气刘海''栗棕色大波浪卷发侧披一边肩''高马尾扎发,额前碎发''浅金色短发纹理烫'\",\n \"makeup\": \"妆容描述,如'自然裸妆,豆沙色唇''精致全妆,红唇突出''无妆感素颜',男性填null或'无明显妆容'\",\n \"expression\": \"表情情绪,如'甜美微笑对着镜头''冷酷面无表情看侧方''开怀大笑''沉思低头''温柔注视''搞怪鬼脸'\",\n \"pose\": \"姿势动作,如'侧身站立看向镜头,单手撩发''双手插兜正面直视镜头''坐姿翘腿手托腮''行走中回眸''依靠栏杆自然站姿''双手比耶活泼姿势'\",\n \"body_type\": \"体型特征,如'纤细苗条''高挑身材''丰满匀称''娇小玲珑''健硕肌肉'\",\n \"outfit_style\": \"穿搭风格,如'休闲日常''通勤商务''街头潮流''复古港风''法式优雅''甜美少女''运动休闲''御姐气质''学院风''度假风''酷飒辣妹'\",\n \"shot_type\": \"拍摄景别,如'大头特写''胸部以上近景''腰部以上半身''膝盖以上中景''全身照''远景'\",\n \"camera_angle\": \"拍摄角度,如'平视正面''侧面45度''俯视自拍''仰视拍摄''背影'\",\n \"background_details\": [\"背景具体元素数组,如'道路两旁法式梧桐树,叶子泛黄''复古欧式老洋房建筑''秋日暖阳透过树叶光斑''街角咖啡店门口''水泥灰色墙面'\"],\n \"lighting\": \"光线氛围,如'午后golden hour金色逆光,发丝有光边''阴天柔和散射光''室内暖黄灯光''室内冷白荧光灯''户外晴天硬光有明显阴影''夕阳侧光温暖氛围'\",\n \"atmosphere\": \"整体情绪氛围,如'秋日慵懒氛围感''都市时尚感''清新自然氧气感''电影感故事性''生活感随拍''专业时尚大片感'\",\n \"portrait_prompt\": \"80-150字高质量人物肖像描述,用于AI文生图参考,必须包含:年龄感+穿搭完整细节(上装/下装/鞋/包/配饰/发型)+姿势表情+场景背景+光线氛围+风格感,语言具象有画面感。参考范例:'一位25岁左右年轻东亚女性,身穿米白色宽松针织开衫内搭白色基础T恤、浅蓝色高腰直筒牛仔裤、脚踩白色厚底小白鞋,斜挎棕色复古皮质小包,戴金丝边圆框眼镜和珍珠小耳环;黑色及腰长直发自然披落,面带甜美的微笑侧身看向镜头,单手轻轻撩发。秋日午后的上海武康路街头,两旁泛黄梧桐树在金色阳光下形成斑驳光斑,背景是法式老洋房,golden hour逆光勾出柔和发丝光,秋日慵懒氛围感,胶片感街拍写真。'\",\n \"products\": []\n}\n\n【其他类 type=other】\n{\n \"subject_type\": \"other\",\n \"description\": \"描述这是什么内容\",\n \"has_person\": false,\n \"products\": []\n}\n\n【输出要求】\n1. 严格输出一个合法JSON对象,不要任何解释文字、不要markdown代码块、不要XML标签\n2. 商品描述要具体细致:颜色写具体色调(亮红色/米白色/深蓝色),不写笼统词汇\n3. 瓶身/包装上的文字尽量识别(品牌名、功能词、卖点词)\n4. 多个商品逐个描述,不要合并\n5. 无法判断的字段填null或空数组,布尔值填true/false\n6. 只输出JSON,不要JSON以外的任何内容" +V5_USER_PROMPT_TEMPLATE = "分析以下图片:\n图片数量:{image_count}张\n行业:{industry}\n{image_urls}\n\n请按system prompt约定的JSON schema输出分析结果,只返回JSON对象。" +V5_EXAMPLE_OUTPUT = None + + +def upgrade() -> None: + conn = op.get_bind() + + existing = conn.execute( + sa.text("SELECT id FROM viral_video_prompt_templates " "WHERE prompt_type = 'image_analysis' AND version = :v"), + {"v": V5_VERSION}, + ).fetchone() + + if existing is None: + conn.execute( + sa.text( + "INSERT INTO viral_video_prompt_templates " + "(name, prompt_type, version, system_prompt, user_prompt_template, " + "example_output, is_active) " + "VALUES (:name, 'image_analysis', :version, :sys, :user, :example, TRUE)" + ), + { + "name": V5_NAME, + "version": V5_VERSION, + "sys": V5_SYSTEM_PROMPT, + "user": V5_USER_PROMPT_TEMPLATE, + "example": V5_EXAMPLE_OUTPUT, + }, + ) + else: + conn.execute( + sa.text( + "UPDATE viral_video_prompt_templates SET name = :name, " + "system_prompt = :sys, user_prompt_template = :user, " + "example_output = :example, is_active = TRUE WHERE id = :id" + ), + { + "name": V5_NAME, + "sys": V5_SYSTEM_PROMPT, + "user": V5_USER_PROMPT_TEMPLATE, + "example": V5_EXAMPLE_OUTPUT, + "id": existing[0], + }, + ) + + conn.execute( + sa.text( + "UPDATE viral_video_prompt_templates SET is_active = FALSE " + "WHERE prompt_type = 'image_analysis' AND version <> :v" + ), + {"v": V5_VERSION}, + ) + + +def downgrade() -> None: + conn = op.get_bind() + conn.execute( + sa.text("DELETE FROM viral_video_prompt_templates " "WHERE prompt_type = 'image_analysis' AND version = :v"), + {"v": V5_VERSION}, + ) + # 恢复前一个版本(本迁移前活跃的 v4)为 active;若无记录则不处理 + prev = conn.execute( + sa.text( + "SELECT version FROM viral_video_prompt_templates " + "WHERE prompt_type = 'image_analysis' ORDER BY version DESC LIMIT 1" + ) + ).fetchone() + if prev is not None: + conn.execute( + sa.text( + "UPDATE viral_video_prompt_templates SET is_active = TRUE " + "WHERE prompt_type = 'image_analysis' AND version = :v" + ), + {"v": prev[0]}, + ) diff --git a/apps/worker/worker_app/tasks/vision/assembler.py b/apps/worker/worker_app/tasks/vision/assembler.py index 1b91b04fa..4d84e5b50 100644 --- a/apps/worker/worker_app/tasks/vision/assembler.py +++ b/apps/worker/worker_app/tasks/vision/assembler.py @@ -420,7 +420,7 @@ def _assemble_v4(idx: int, fj: dict, ocr_texts: list[str]) -> dict[str, Any]: # 人物类 if vtype == "person" or has_person: - # 取第一个人物信息(v4 schema人物信息在顶层) + # 取第一个人物信息(v5 schema人物信息在顶层) person_info = fj # 兼容people嵌套 ppl = fj.get("people") @@ -429,58 +429,75 @@ def _assemble_v4(idx: int, fj: dict, ocr_texts: list[str]) -> dict[str, Any]: has_person = True portrait_prompt = _build_portrait_prompt_from_v4(person_info) - name = person_info.get("upper_wear") or "人物穿搭" - if "连衣裙" in name: - pass + outfit_style = person_info.get("outfit_style") or "" + upper = person_info.get("upper_wear") or "" + lower = person_info.get("lower_wear") or "" + dress = person_info.get("dress_wear") or "" + outer = person_info.get("outerwear") or "" + if dress: + name = str(dress)[:25] + elif outer and upper: + name = f"{outer}+{upper}"[:30] + elif upper: + name = (str(upper) + (f"+{lower}" if lower else ""))[:30] else: - lower = person_info.get("lower_wear") or "" - if lower: - name = f"{name}+{lower}" + name = "人物穿搭" brand = "无法判断" - category = "服饰" - outfit_parts = [] - for k in ("upper_wear", "lower_wear", "dress_color", "upper_color", "lower_color", "outfit_style"): + category = "人物穿搭" + # appearance: 外套+上衣+下装/裙+鞋+包+发型+妆容 + app_parts = [] + for k in ("outerwear", "upper_wear", "lower_wear", "dress_wear", "shoes", "bag", "hairstyle", "makeup"): v = person_info.get(k) - if v and v not in ("null", None): - outfit_parts.append(str(v)) - appearance = "、".join(outfit_parts) if outfit_parts else "人像穿搭整体造型" - # key_features: 穿搭特征+配饰 + if v and v not in ("null", None, "无明显妆容"): + app_parts.append(str(v)) + appearance = ";".join(app_parts) if app_parts else "人像穿搭整体造型" + # key_features: 服装+配饰+拍摄信息 kf = [] for k in ( + "outfit_style", "upper_wear", "lower_wear", - "upper_color", - "lower_color", + "dress_wear", + "outerwear", + "shoes", + "bag", "hairstyle", "expression", "pose", - "outfit_style", ): v = person_info.get(k) if v and v not in ("null", None, "无法判断"): kf.append(str(v)) acc = person_info.get("accessories") or [] if isinstance(acc, list): - kf.extend(str(a) for a in acc if a) + for a in acc: + if a and str(a) not in kf: + kf.append(str(a)) + elif isinstance(acc, str) and acc: + kf.append(acc) + for k in ("shot_type", "camera_angle", "lighting", "atmosphere"): + v = person_info.get(k) + if v and v not in ("null", None): + kf.append(str(v)) if text_on_package: - kf.append(f"画面文字: {'/'.join(text_on_package[:3])}") - kf = kf[:6] or ["无法判断"] - summary = (person_info.get("outfit_style") or "") + (person_info.get("upper_wear") or "穿搭") - if not summary or summary == "穿搭": + kf.append(f"文字:{'/'.join(text_on_package[:3])}") + kf = kf[:8] or ["无法判断"] + summary = (outfit_style + " " if outfit_style and outfit_style not in name else "") + name[:25] + if not summary.strip(): summary = "人物穿搭" return { "name": name[:30], "brand": brand, "category": category, - "appearance": appearance, + "appearance": appearance[:400], "packaging": "人物形象无包装", "text_on_package": text_on_package, "key_features": kf, "scene": scene, "mood": mood, - "portrait_prompt": portrait_prompt, - "summary": summary[:40], - "_source": "v2_fast_json_v4", + "portrait_prompt": portrait_prompt[:300], + "summary": summary[:50], + "_source": "v2_fast_json_v5", } # 商品类 diff --git a/tests/unit/test_vision_v4_prompt.py b/tests/unit/test_vision_v4_prompt.py index e22f66f54..ed2b52ca8 100755 --- a/tests/unit/test_vision_v4_prompt.py +++ b/tests/unit/test_vision_v4_prompt.py @@ -137,18 +137,18 @@ V4_PERSON: dict[str, Any] = { def test_assemble_v4_person() -> None: r = assembler.assemble_result(0, V4_PERSON, []) assert REQUIRED_KEYS <= set(r.keys()) - assert r["category"] == "服饰" + assert r["category"] == "人物穿搭" + assert r["_source"] == "v2_fast_json_v5" assert "T恤" in r["name"] - assert "阔腿裤" in r["name"] assert "年轻女性" in r["portrait_prompt"] assert "项链" in r["portrait_prompt"] - assert isinstance(r["key_features"], list) and len(r["key_features"]) <= 6 + assert isinstance(r["key_features"], list) and len(r["key_features"]) <= 8 def test_assemble_v4_person_people_nested() -> None: fj = {"type": "person", "people": {**V4_PERSON, "has_person": True}} r = assembler.assemble_result(0, fj, []) - assert r["category"] == "服饰" + assert r["category"] == "人物穿搭" assert "年轻女性" in r["portrait_prompt"]