Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 36 additions & 1 deletion app/ai/image_readability.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,43 @@
"""日报与历史重画共用的文字区视觉约束。"""

IMAGE_READABILITY_RULES = (
import re


# 仅用于迁移已保存 Prompt 中的完整旧规则,不模糊删除用户文案。
LEGACY_IMAGE_READABILITY_RULES = (
"顶部群名称、统计时间、主副标题,以及底部总结、消息数和发言人数,"
"必须使用不透明、平整干净的浅色背景和完整锐利的深色文字,保持高对比度。"
"文字区禁止黑色模糊光晕、暗角、烟雾、渐变阴影、涂抹、擦除或半透明蒙层;"
"不要用纹理或大投影覆盖文字。装饰只能放在文字区外,手机端必须清晰可读。"
)

IMAGE_READABILITY_RULES = (
"群名称、统计时间、主标题、副标题、底部总结和统计数据,必须与漫画画面一起由 ImageGen 原生生成。"
"从构图开始就把副标题和底部总结设计为画面的一部分:根据本图风格选择手绘题签、纸条、"
"场景标牌或与插画相连的文字构图,文字载体的色彩、线条和透视与周围漫画一致;"
"不要默认排成独立的纯文字横条,不要留下空白区域等待后贴文字。"
"保留所有给定文字原文,不增加新的总结分镜、人物、事实或对白。"
"整张图包括文字载体均使用不透明背景;字形完整清晰、文字与局部背景高对比,"
"手机端可读,不遮挡、不裁切、不用过小字号。"
"装饰可以与文字载体自然融合,但不得覆盖笔画;禁止黑色模糊光晕、暗角、烟雾、"
"涂抹、擦除、半透明蒙层或大投影遮挡文字。"
"不得使用 Python、Pillow、SVG 或其他程序在生成图片上补字、贴字或覆盖标题。"
)

_VISUAL_SECTION = re.compile(
r"(?ms)(^【整体视觉】[^\S\r\n]*\r?\n)(.*?)(?=^【[^\r\n】]+】[^\S\r\n]*\r?$|\Z)"
)


def apply_image_visual_rules(prompt: str) -> str:
"""更新视觉区完整规则段,保留其他区块;重复调用不叠加规则。"""
section = _VISUAL_SECTION.search(prompt)
body = section.group(2) if section else prompt
for rule in (LEGACY_IMAGE_READABILITY_RULES, IMAGE_READABILITY_RULES):
body = re.sub(r"(?m)^[ \t]*" + re.escape(rule) + r"[ \t]*(?:\r?\n|$)", "", body)
body = re.sub(r"(?:\r?\n){3,}", "\n\n", body).strip()
if section:
replacement = IMAGE_READABILITY_RULES + ("\n\n" + body if body else "") + "\n\n"
return prompt[:section.start(2)] + replacement + prompt[section.end(2):]
# 兼容没有固定区块的历史 Prompt,不改写其正文。
return (body + "\n\n" if body else "") + IMAGE_READABILITY_RULES
4 changes: 2 additions & 2 deletions app/image/codex_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@

from app.config.settings import Settings, get_settings
from app.ai.concurrency import bounded_slot, normalized_limit
from app.ai.image_readability import IMAGE_READABILITY_RULES
from app.ai.image_readability import apply_image_visual_rules
from app.core.logging import get_logger
from app.image.image_task import ImageTaskResult, detect_image_format, verify_image

Expand Down Expand Up @@ -936,7 +936,7 @@ def _safe_attempt_number(value: object) -> int:
@staticmethod
def _attempt_prompt(prompt_text: str, job_id: str) -> str:
return (
f"$imagegen {prompt_text}\n\n{IMAGE_READABILITY_RULES}\n\n"
f"$imagegen {apply_image_visual_rules(prompt_text)}\n\n"
f"本次生图任务 ID 是 {job_id}。"
"只为本次任务调用一次 ImageGen,并只生成、选择一张最终图片。"
"优先使用 1024×1536 像素的竖版 2:3 画布;"
Expand Down
40 changes: 39 additions & 1 deletion tests/test_image_readability.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@
import pytest
from PIL import Image, ImageDraw

from app.ai.image_readability import IMAGE_READABILITY_RULES
from app.ai.image_readability import (
IMAGE_READABILITY_RULES,
LEGACY_IMAGE_READABILITY_RULES,
apply_image_visual_rules,
)
from app.ai.poster_copy import _overall_visual
from app.image.codex_generator import CodexImageGenerator
from app.image.image_task import verify_image_contract
Expand Down Expand Up @@ -70,6 +74,40 @@ def test_fresh_and_historical_generation_share_readability_rules():
assert IMAGE_READABILITY_RULES in CodexImageGenerator._attempt_prompt("历史 Prompt", "test-job-123")


@pytest.mark.parametrize("newline", ["\n", "\r\n"])
def test_visual_rule_upgrade_preserves_copy_and_is_idempotent(newline):
before = "【副标题】\n既有副标题\n\n"
after = "【漫画分镜】\n原始分镜\n\n【版面1】\n群友说:装饰只能放在文字区外。\n\n【底部总结】\n原始总结"
visual = "【整体视觉】\n手动风格保持不变\n\n" + LEGACY_IMAGE_READABILITY_RULES + "\n\n"
prompt = (before + visual + LEGACY_IMAGE_READABILITY_RULES + "\n\n" + after).replace("\n", newline)
upgraded = apply_image_visual_rules(prompt)
assert upgraded.startswith(before.replace("\n", newline))
assert upgraded.endswith(after.replace("\n", newline))
assert "手动风格保持不变" in upgraded
assert LEGACY_IMAGE_READABILITY_RULES not in upgraded
assert upgraded.count(IMAGE_READABILITY_RULES) == 1
assert apply_image_visual_rules(upgraded) == upgraded
wrapped = CodexImageGenerator._attempt_prompt(upgraded, "test-job-123")
assert wrapped.count(IMAGE_READABILITY_RULES) == 1
assert LEGACY_IMAGE_READABILITY_RULES not in wrapped


def test_legacy_unstructured_prompt_and_embedded_quote_are_preserved():
content = "历史图片说明\n群友引用:“" + LEGACY_IMAGE_READABILITY_RULES + "”"
upgraded = apply_image_visual_rules(content + "\n\n" + LEGACY_IMAGE_READABILITY_RULES)
assert upgraded == content + "\n\n" + IMAGE_READABILITY_RULES
assert apply_image_visual_rules(upgraded) == upgraded


def test_fresh_visual_rules_are_not_appended_twice_by_generator():
prompt = "【整体视觉】\n" + _overall_visual("", explicit_style=False) + "\n\n【底部总结】\n原文"
wrapped = CodexImageGenerator._attempt_prompt(prompt, "test-job-123")
assert wrapped.count(IMAGE_READABILITY_RULES) == 1
assert "平整干净的浅色背景" not in wrapped
assert "由 ImageGen 原生生成" in wrapped
assert "保留所有给定文字原文" in wrapped


@pytest.mark.parametrize("mode", ["RGBA", "LA", "P"])
def test_transparency_is_composited_before_rgb_promotion(tmp_path, mode):
image = Image.new("RGBA", (512, 768), (0, 0, 0, 0))
Expand Down
8 changes: 8 additions & 0 deletions tests/test_v2_image_regeneration.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@ def test_success_atomically_replaces_image_backs_up_old_and_holds_send(tmp_path)
image_fallback_reason="PROMPT_FAILED",
image_variant="pillow",
image_force_local_fallback=True,
send_state="sent",
text_submitted_at="2026-08-21 08:59:30",
image_submitted_at="2026-08-21 08:59:40",
delivery_evidence={"result": "sent", "image_sha256": hashlib.sha256(OLD_PNG).hexdigest()},
)

run = run_regeneration_now(settings, group, run_date, SuccessGenerator())
Expand All @@ -95,6 +99,10 @@ def test_success_atomically_replaces_image_backs_up_old_and_holds_send(tmp_path)
assert run["send_hold"] is True
assert run["needs_manual_send"] is True
assert run["sent_at"] == "2026-08-21 09:00:00"
assert run["send_state"] == "sent"
assert run["text_submitted_at"] == "2026-08-21 08:59:30"
assert run["image_submitted_at"] == "2026-08-21 08:59:40"
assert run["delivery_evidence"] == {"result": "sent", "image_sha256": hashlib.sha256(OLD_PNG).hexdigest()}
assert run["image_fallback_level"] == 0
assert run["image_fallback_reason"] == ""
assert run["image_variant"] == "normal"
Expand Down