feat: 生图增加商品尺寸填报

This commit is contained in:
R524809
2026-09-11 17:28:06 +08:00
parent 27c5daed72
commit dbb2bb80bd
8 changed files with 188 additions and 35 deletions
+27 -5
View File
@@ -50,6 +50,27 @@ from services.suite_service import texts_to_raw, validate_model
router = APIRouter(prefix="/api", tags=["suite"])
def _with_dimensions(raw: dict, dimensions, *, required: bool = False) -> dict:
"""将表单长宽高放入提示词上下文,不依赖采集参数是否已有尺寸字段。"""
if dimensions is None:
if required:
raise HTTPException(status_code=400, detail="生成尺寸标注图需要完整填写商品长、宽、高")
return raw
values = (dimensions.length, dimensions.width, dimensions.height)
if not all(value is not None and value > 0 for value in values):
if required:
raise HTTPException(status_code=400, detail="生成尺寸标注图需要完整填写商品长、宽、高")
return raw
enriched = dict(raw)
enriched["dimensions"] = {
"length": dimensions.length,
"width": dimensions.width,
"height": dimensions.height,
"unit": dimensions.unit,
}
return enriched
def _stream_file(handle: io.BufferedRandom, chunk_size: int = 64 * 1024) -> Iterator[bytes]:
"""以固定块返回临时 ZIP,释放响应完成后的临时文件。"""
try:
@@ -64,7 +85,7 @@ def _stream_file(handle: io.BufferedRandom, chunk_size: int = 64 * 1024) -> Iter
@router.post("/suite/plan", response_model=PlanResponse)
async def plan_suite(req: SuitePlanRequest) -> PlanResponse:
"""DeepSeek 根据商品信息生成出图方案(同步接口,数秒级)。"""
product_info = texts_to_raw(req.texts)
product_info = _with_dimensions(texts_to_raw(req.texts), req.dimensions)
if not product_info.get("title"):
raise HTTPException(status_code=400, detail="缺少商品标题,无法规划")
try:
@@ -140,7 +161,7 @@ async def generate_suite(req: SuiteGenerateRequest) -> SuiteCreateResponse:
ratio=spec["ratio"],
style_set=req.style_set,
request_payload={
"context": texts_to_raw(req.texts),
"context": _with_dimensions(texts_to_raw(req.texts), req.dimensions),
"style_prompt": req.style_prompt,
"requirements": req.requirements,
"watermark": req.watermark.model_dump() if req.watermark else None,
@@ -295,6 +316,7 @@ async def suite_image_edit(req: ImageEditSingleRequest) -> ImageEditTaskResponse
raise HTTPException(status_code=400, detail="product_id 不是合法 UUID") from exc
# 单图编辑优先级高于套图;prompt_extra 清空展示文案,避免中文 UI 文案进入模型 prompt。
is_size_chart = bool(req.generate_size_chart)
task = await create_image_job(
product_id=product_id,
kind="image_edit",
@@ -306,7 +328,7 @@ async def suite_image_edit(req: ImageEditSingleRequest) -> ImageEditTaskResponse
ratio=spec["ratio"],
style_set=5,
request_payload={
"context": {"title": "AI 精修"},
"context": _with_dimensions({"title": "AI 精修"}, req.dimensions, required=is_size_chart),
"style_prompt": None,
"requirements": req.prompt,
"watermark": req.watermark.model_dump() if req.watermark else None,
@@ -316,8 +338,8 @@ async def suite_image_edit(req: ImageEditSingleRequest) -> ImageEditTaskResponse
},
item_payloads=[{
"position": 0,
"kind": "custom",
"title": "AI生图",
"kind": "size_chart" if is_size_chart else "custom",
"title": "尺寸标注图" if is_size_chart else "AI生图",
"detail": "",
"prompt_hint": "",
"variant_name": None,
+13
View File
@@ -76,6 +76,15 @@ class WatermarkOptions(BaseModel):
opacity: int = Field(default=30, ge=1, le=100, description="不透明度(%)")
class ProductDimensions(BaseModel):
"""商品长宽高;尺寸图只会使用完整的三项数据。"""
length: float | None = Field(default=None, gt=0, description="长度(按 unit 单位)")
width: float | None = Field(default=None, gt=0, description="宽度(按 unit 单位)")
height: float | None = Field(default=None, gt=0, description="高度(按 unit 单位)")
unit: Literal["mm", "cm"] = Field(default="mm", description="尺寸单位")
class PlanItem(BaseModel):
"""出图方案项:一类图 × 数量,可绑定 SKU 规格。"""
kind: str = Field(default="custom", description="图类型(SUPPORTED_TYPES 之一)")
@@ -94,6 +103,7 @@ class SuitePlanRequest(BaseModel):
image_stats: dict = Field(default_factory=dict, description="分组图片数量统计 {main: n, ...}")
platform: str = Field(default="ozon")
requirements: str | None = Field(default=None, description="生图要求(最高优先级,规划方案必须遵循)")
dimensions: ProductDimensions | None = Field(default=None, description="商品长宽高(mm 或 cm),用于规划尺寸标注图")
class PlanItemOut(BaseModel):
@@ -129,6 +139,7 @@ class SuiteGenerateRequest(BaseModel):
platform: str = Field(default="ozon")
model: str | None = Field(default=None, description="生图模型名(按 MODEL_PROVIDERS 路由 provider)")
watermark: WatermarkOptions | None = Field(default=None, description="生成图水印(服务端后处理合成);关闭时不传")
dimensions: ProductDimensions | None = Field(default=None, description="商品长宽高(mm 或 cm),用于尺寸标注图")
class SuiteCreateResponse(BaseModel):
@@ -177,6 +188,8 @@ class ImageEditSingleRequest(BaseModel):
description="插入锚点:新素材排在该素材(generated 组)之后,方便与原图对比;缺省追加到组尾",
)
watermark: WatermarkOptions | None = Field(default=None, description="单张生图完成后的服务端水印;关闭时不传")
generate_size_chart: bool = Field(default=False, description="是否按尺寸标注图生成")
dimensions: ProductDimensions | None = Field(default=None, description="商品长宽高(mm 或 cm),用于尺寸标注图")
class ImageEditTaskResponse(BaseModel):
+7 -5
View File
@@ -23,10 +23,9 @@ ALLOWED_KINDS = [
]
# 方案总张数安全上限(仅防模型跑飞,不干预正常规划;生图按张计费):
# 默认 AI 自主决定张数(prompt 建议 8 张左右),上限 15;
# 「生图要求」明确写了「N 张」时按用户指定的来,上限放宽到 24。
PLAN_SAFETY_MAX = 15
PLAN_REQUIREMENTS_MAX = 24
# AI 智能规划最多生成 8 张;即使用户在要求中指定更高张数也不突破此上限。
PLAN_SAFETY_MAX = 8
PLAN_REQUIREMENTS_MAX = 8
MAX_VARIANT_MAIN_IMAGES = 2
# 规划器的图类归并。不同模型会把“细节图”写成材质图、卖点图或详情图,
@@ -53,7 +52,7 @@ SYSTEM_PROMPT = """你是一名资深电商视觉策划。根据商品信息规
1. 输出必须是**单行紧凑 JSON**:无换行、无缩进、无空格填充、无注释、无 markdown 围栏。
2. 顶层只有 summary 和 items 两个字段;每个 item 严格只有 kind/title/detail/prompt_hint/count/variant_name 六个字段,不得增删。
3. 文本长度上限(中文字符/英文单词数):summary ≤ 25 字;title ≤ 8 字;detail ≤ 20 字;prompt_hint ≤ 15 个英文词。超限必须删减,不得省略号截断。
4. count 默认 1,仅当该类图确需多个变体时才 >1,最大 3。总张数由你根据商品复杂度与信息量自主决定,不设硬性数量,但建议 8 张左右;若「生图要求」明确指定了张数,必须严格按指定张数输出。
4. count 默认 1,仅当该类图确需多个变体时才 >1,最大 3。总张数由你根据商品复杂度与信息量自主决定,最多 8 张;若「生图要求」明确指定了张数,按指定张数输出但仍不得超过 8 张。
5. variant_name 只能从「SKU规格」列表原样照抄;没有绑定就输出 null。
## 规划规则
@@ -126,6 +125,9 @@ def _normalize_items(raw_items: list, sku_variants: list[str]) -> list[dict]:
def _has_dimensions(product_info: dict) -> bool:
"""仅在已采集到尺寸信息时规划尺寸图,避免模型编造数值。"""
dimensions = product_info.get("dimensions") or {}
if isinstance(dimensions, dict) and all(dimensions.get(key) is not None for key in ("length", "width", "height")):
return True
for param in product_info.get("params") or []:
if not isinstance(param, dict):
continue
+15 -6
View File
@@ -133,6 +133,12 @@ def build_context(raw: dict, fallback_name: str = "", fallback_desc: str = "") -
if len(selling_points) >= 5:
break
dimensions = raw.get("dimensions") or {}
dimension_values = (dimensions.get("length"), dimensions.get("width"), dimensions.get("height")) if isinstance(dimensions, dict) else ()
dimensions_line = ""
if len(dimension_values) == 3 and all(value is not None for value in dimension_values):
unit = dimensions.get("unit") or "mm"
dimensions_line = "L × W × H: " + " × ".join(str(value) for value in dimension_values) + f" {unit}"
params_line = "; ".join(
f"{p.get('key')}: {p.get('value')}" for p in (raw.get("params") or [])[:8]
)
@@ -141,13 +147,16 @@ def build_context(raw: dict, fallback_name: str = "", fallback_desc: str = "") -
"title_en": title, # 采集源多为中文标题,英文场景直接用原词避免乱翻译
"desc": desc,
"selling_points": selling_points[:3],
"params_line": params_line,
"params_line": "; ".join(filter(None, (params_line, dimensions_line))),
# 俄文图片里不能直接渲染中文参数;保留不含汉字的规格(型号、数字、单位)供尺寸图使用。
"params_line_non_cjk": "; ".join(
f"{p.get('key')}: {p.get('value')}"
for p in (raw.get("params") or [])[:8]
if not re.search(r"[\u3400-\u9fff]", f"{p.get('key')} {p.get('value')}")
),
"params_line_non_cjk": "; ".join(filter(None, (
"; ".join(
f"{p.get('key')}: {p.get('value')}"
for p in (raw.get("params") or [])[:8]
if not re.search(r"[\u3400-\u9fff]", f"{p.get('key')} {p.get('value')}")
),
dimensions_line,
))),
"price": raw.get("price") or "",
}