MM×CODE 多模态(看图)× Coding 评测案例集 v0.1
CASE C11

视觉单测生成

把看图变成写检测器。完全自动可评分

modesingle_then_injectscorerassertion_suite自动化5/5趣味4/5
01

输入与证据锚点

输入 INPUT

一张"正确状态"的基线截图或产品实拍

证据锚点 EVIDENCE ANCHOR
把 VLM 用作高召回过滤器而非独立判定器,是当前证据支持的用法
02

双轴设计

轴一 · 能力 AXIS Ⅰ
注入缺陷召回
检测器灵不灵:注入的回归变体能抓到几成
AXIS Ⅱ 轴二 · 节制
合法变体假阳率
检测器吵不吵:合法变化不该触发断言
两轴独立计分、独立报告,合起来画 Pareto 前沿——只报轴一的横评没有信息量。
03

Prompt

单轮 Prompt:一次把断言套件写完,之后不再追问。

PROMPT主 PROMPT这是一个"正确状态"的基线截图。
这是一个"正确状态"的基线截图。

请输出一组可执行的视觉断言(Python,可用 PIL / numpy / opencv),要求:
1. 在这张基线图上全部通过;
2. 能捕捉这几类回归:布局错位、内容溢出、对比度不足、状态显示错误、资源缺失;
3. 每条断言必须带容差参数,并说明这个容差是为了容忍什么(抗锯齿?字体渲染差异?动态内容?);
4. 不要写"整图像素相等"这类断言。

输出:assertions.py,每条断言一个函数,附一行注释说明它守护什么。
04

评测流水线

mode · single_then_inject

Prompt 只有单轮;之后全是脚本的事——先过基线门槛,再分两臂注入统计。这就是「完全自动可评分」的实现方式。

STEP 1 · 单轮生成

给模型一张「正确状态」基线截图,收回一份断言套件 assertions.py:每条断言一个函数,附一行注释说明它守护什么。

STEP 2 · 基线门槛

全部断言先在基线图上跑一遍,必须全部通过(主 Prompt 的硬性要求第 1 条)——过不了这一关的套件,后面两臂都无从谈起。

STEP 3A · 注入臂5 类 × 4 变体 = 20 张
  • 布局错位
  • 内容溢出
  • 对比度不足
  • 状态显示错误
  • 资源缺失

在基线上制造这 5 类回归的注入变体,数被断言抓住的比例——抓住得越多越好。

→ 轴一 · 注入缺陷召回脚本40 分
剩余 25 分不进注入统计:容差设计(15 分)与可读性(10 分)由 judge 打,判据见下方 Rubric。
05

Rubric 评分表

维度满分由谁打判据
注入缺陷召回40脚本在 5 类 × 4 个注入变体上,被断言抓住的比例。
合法变体假阳率35脚本在 10 张合法变体(换语言、换主题色、动态内容变化、跨设备渲染)上误报的比例。
容差设计15judge容差参数是否有依据。全部写死 0 或全部写得极宽都记 0。
可读性10judge一个新同事能否只看注释就知道每条断言守护什么。

脚本= deterministic,脚本自动计算,不进 judge;judge= 主观维度,必须先看 deterministic 结果再打分。本案例总分归一化到 0-100。

06

跑批命令

python run_batch.py --models anthropic/claude-opus-4.5 openai/gpt-5.2 google/gemini-3-pro moonshotai/kimi-k2.5 --only C11
python score.py --judge anthropic/claude-opus-4.5 --out report.csv

数据准备:按 data/C11/manifest.jsonl 的格式放置图片与真值。score 阶段的 judge 只处理本案例 rubric 中非 deterministic 的维度。

07

实测结果 · Live Run

stealth/ox-alpha · glm-5v-turbo · kimi-k3 · 2026-08-22(kimi-k3 补跑 2026-08-24)
以下是 三个模型各自一次真实调用(经 OpenRouter,Claude Code 环境)跑出的结果——ox-alpha 与 glm-5v-turbo 为 2026 年 8 月 22 日调用,kimi-k3 首跑失败后于 2026 年 8 月 24 日以同一 Prompt、同一图片补跑成功:同一张基线图、同一段 Prompt,分别发给 stealth/ox-alpha、z-ai/glm-5v-turbo、moonshotai/kimi-k3——图片是真实喂给模型的输入文件,Prompt 是真实发送的文本,回复是模型真实原文(未删改、未润色),分数是打分脚本真实计算出的数值。样本规模为演示级——1 张基线图 + 5 张注入缺陷变体 + 4 张合法变化变体,远小于原始题库设计(注入臂 20 张 + 合法臂 10 张)。原始数据见 results/C11.json、results/z-ai__glm-5v-turbo/C11.json、results/moonshotai__kimi-k3/C11.json。
方法论局限 · 运行方式不完全对等:三个模型的调用方式相同(同一套 OpenRouter chat/completions 调用),但 stealth/ox-alpha 是强制深度推理的 stealth 模型,glm-5v-turbo 与 kimi-k3 的推理行为未特别配置——输出风格与推理深度可能天然不同。这是解读本节三模型对比时要注意的局限,不是哪一方「作弊」。
模型stealth/ox-alpha
运行日期2026-08-22
样本1 基线 + 5 缺陷 + 4 合法
对话轮次1(单轮)
Tokens 入/出476 / 40031
finish_reasonstop

07.1 三模型运行状态

同一案例、同一组图片、同一段 Prompt,三个模型各自独立真实跑了一遍(上方 meta 网格为 stealth/ox-alpha 那一次的运行明细)。下表逐项取自各 JSON 的 usage / finish_reason 字段:

模型Tokens 入/出其中推理finish_reason结果
stealth/ox-alpha476 / 400310stop正常交付 14 条断言函数
z-ai/glm-5v-turbo387 / 47002108stop正常交付 5 条断言函数
moonshotai/kimi-k3472 / 2398717011stop正常交付 18 条断言函数(2026-08-24 补跑)

07.2 真实输入图片(三模型共用同一组)

以下 10 张 PNG(520×340)即 data/C11/images/ 下的实际文件:基线图是对话的唯一图片输入;9 张变体图供打分沙箱检验断言套件。蓝标 = 注入缺陷(断言应当抓住), 橙标 = 合法变化(不应误报)。三个模型收到的即这同一组图片文件。

baseline.png — Acme Dashboard 正确状态基线截图
images/baseline.png · 唯一喂给模型的输入:「Acme Dashboard」正确状态基线 —— 深色顶栏 + 卡片 1/2/3(137 / 274 / 411)+「最近活动」面板(4 行分隔线)。
defect_layout_shift.png(真值:defect)
defect_layout_shift.png注入缺陷 · 布局错位
defect_overflow.png(真值:defect)
defect_overflow.png注入缺陷 · 内容溢出
defect_low_contrast.png(真值:defect)
defect_low_contrast.png注入缺陷 · 对比度不足
defect_missing_icon.png(真值:defect)
defect_missing_icon.png注入缺陷 · 资源缺失
defect_state_wrong.png(真值:defect)
defect_state_wrong.png注入缺陷 · 状态显示错误
legal_theme_color.png(真值:legal)
legal_theme_color.png合法变化 · 换主题色
legal_language.png(真值:legal)
legal_language.png合法变化 · 换语言
legal_dynamic_number.png(真值:legal)
legal_dynamic_number.png合法变化 · 动态内容变化
legal_minor_resize.png(真值:legal)
legal_minor_resize.png合法变化 · 跨设备渲染(轻微缩放)

注意:该文件与 baseline.png 逐字节相同(MD5 一致),实际未引入任何变化。

07.3 实际发送的 Prompt(三模型逐字一致)

本次运行 vars 为空对象,未发生任何模板变量替换,实际发送文本与第 03 节的设计模板逐字一致。以下为 JSON 中记录的发送原文——经逐字比对,三份 results JSON 中的 Prompt 完全一致,此处只展示一份:

这是一个"正确状态"的基线截图。

请输出一组可执行的视觉断言(Python,可用 PIL / numpy / opencv),要求:
1. 在这张基线图上全部通过;
2. 能捕捉这几类回归:布局错位、内容溢出、对比度不足、状态显示错误、资源缺失;
3. 每条断言必须带容差参数,并说明这个容差是为了容忍什么(抗锯齿?字体渲染差异?动态内容?);
4. 不要写"整图像素相等"这类断言。

输出:assertions.py,每条断言一个函数,附一行注释说明它守护什么。

07.4 模型真实原文回复(三份)

同一输入,三份返回,全部未删节照录:三个模型均正常交付断言代码(kimi-k3 为 2026-08-24 补跑成功的记录,见第三块)。三份代码思路各不相同:ox-alpha 全局动态检测结构,GLM-5V 固定比例切区+硬编码参考色,kimi-k3 白色掩码定位卡片/面板后做相对几何断言。

stealth/ox-alphafinish_reason=stop · tokens 476 / 40031(其中 reasoning 0)· 交付 14 条断言函数

单轮返回。一次性交付 14 条断言函数(每条带容差参数与守护注释)、一段使用说明、一份「回归类别覆盖映射」表和一段标定说明(自述仅两处常量与基线绑定:顶栏参考色与分隔线数量)。以下为未删节原文(共 18,914 字符),Markdown 源码原样保留:

模型输出原文 · assertions.py 全文及附注(未删节)展开 / 收起
下面是完整的 `assertions.py`。所有几何结构(顶栏高度、卡片框、面板框)都从图中**动态检测**,断言验证的是结构关系与像素统计约束,而非硬编码绝对坐标,因此对基线的轻微渲染差异稳健。

```python
# -*- coding: utf-8 -*-
"""
assertions.py — "Acme Dashboard" 基线截图的视觉回归断言集。

用法:
    python assertions.py baseline.png
    # 或
    from assertions import run_all
    run_all(img_rgb_uint8)

设计约定:
  * 输入为 RGB uint8 ndarray (H, W, 3)。
  * 不做"整图像素相等"式比较; 每条断言只验证一个独立视觉属性,
    并带显式容差参数, docstring 说明该容差容忍的是什么
    (抗锯齿 / 字体渲染差异 / 压缩噪声 / 动态内容)。
  * 顶栏高度、卡片框、面板框由 _detect_layout 动态检测,
    断言检查结构关系与像素统计约束, 而非绝对像素坐标。
"""

from __future__ import annotations

import sys

import cv2
import numpy as np

_LUMA = np.array([0.2126, 0.7152, 0.0722], dtype=np.float64)  # Rec.701


# ===========================================================================
# 内部工具
# ===========================================================================

def _gray(img):
    return cv2.cvtColor(img, cv2.COLOR_RGB2GRAY) if img.ndim == 3 else img


def _lum(img):
    """感知亮度, float32, 0~255。"""
    return (img.astype(np.float32) * _LUMA.astype(np.float32)).sum(axis=-1)


def _rel_lum(lum255):
    """0~255 亮度 -> WCAG 相对亮度。"""
    l = min(max(lum255, 0.0), 255.0) / 255.0
    return l / 12.92 if l <= 0.03928 else ((l + 0.055) / 1.055) ** 2.4


def _contrast_ratio(fg, bg):
    a, b = _rel_lum(fg), _rel_lum(bg)
    hi, lo = max(a, b), min(a, b)
    return (hi + 0.05) / (lo + 0.05)


def _runs(flags, min_len=1):
    """一维布尔数组中的连续 True 段 -> [(start, end), ...](end 不含)。"""
    flags = np.asarray(flags, dtype=bool)
    d = np.diff(np.concatenate(([0], flags.view(np.int8), [0])))
    starts, ends = np.where(d == 1)[0], np.where(d == -1)[0]
    return [(int(s), int(e)) for s, e in zip(starts, ends) if e - s >= min_len]


def _header_height(gray, dark_thresh=150.0):
    """顶栏高度 = 图像顶部连续"暗行"数(页面背景与卡片均为亮色)。"""
    row_mean = gray.mean(axis=1)
    h = 0
    while h < len(row_mean) and row_mean[h] < dark_thresh:
        h += 1
    return h


def _detect_layout(img, white_over_bg=6.0, min_card_w_frac=0.12,
                   max_card_w_frac=0.42, min_card_h_px=40):
    """一次性检测: 顶栏高度 / 页面背景色 / 卡片框 / 活动面板框。

    white_over_bg: "白色区块"相对页面背景的亮度增量阈值 —— 动态取阈值
    而非写死 255, 容忍压缩噪声造成的背景/卡片亮度小幅浮动。
    """
    H, W = img.shape[:2]
    gray = _gray(img)
    header_h = _header_height(gray)

    # 页面背景色样本: 顶栏正下方左侧页边距(中位数抗单像素噪声)
    ys, ye = header_h + 6, min(header_h + 50, H)
    sample = img[ys:ye, 2:max(10, int(W * 0.03))].reshape(-1, 3)
    bg = np.median(sample, axis=0)
    bg_lum = float(np.dot(bg.astype(np.float64), _LUMA))

    mask = (gray >= bg_lum + white_over_bg).astype(np.uint8)
    y_off = header_h + 2
    n, _, stats, _ = cv2.connectedComponentsWithStats(mask[y_off:], connectivity=8)

    cands = []
    for k in range(1, n):
        x, y, w, h, area = (int(v) for v in stats[k])
        if area >= 0.5 * w * h:                      # 排除细碎噪声块
            cands.append((x, y + y_off, x + w, y + h + y_off))

    cards = sorted(
        (b for b in cands
         if min_card_w_frac * W <= b[2] - b[0] <= max_card_w_frac * W
         and b[3] - b[1] >= min_card_h_px),
        key=lambda b: b[0])

    panel = None
    if cards:
        bottom = max(c[3] for c in cards)
        wide = [b for b in cands if b[1] >= bottom - 2 and (b[2] - b[0]) >= 0.5 * W]
        if wide:
            panel = max(wide, key=lambda b: b[2] - b[0])

    return {"H": H, "W": W, "gray": gray, "header_h": header_h,
            "bg_rgb": bg, "bg_lum": bg_lum, "cards": cards, "panel": panel}


# ===========================================================================
# 断言
# ===========================================================================

# 守护: 顶栏品牌底色回归(如被主题改成浅色, 白色标题将不可见)。
def test_header_bar_color(img, layout=None, channel_tol=25.0,
                          expected_rgb=(44.0, 62.0, 80.0)):
    """channel_tol: 各通道均值允许偏差 —— 容忍截图压缩/色彩管理带来的
    轻微色偏; 对整块区域求均值后再比较, 单像素噪声被平均掉。"""
    L = layout or _detect_layout(img)
    hh = L["header_h"]
    assert hh >= 10, "未检测到深色顶栏"
    band = img[int(hh * 0.2):int(hh * 0.6)]          # 取顶栏中部实色区
    mean = band.reshape(-1, 3).mean(axis=0)
    diff = np.abs(mean - np.asarray(expected_rgb))
    assert np.all(diff <= channel_tol), \
        f"顶栏颜色偏移: mean={mean.round(1)}, 期望≈{expected_rgb}"


# 守护: 顶栏高度塌陷/异常增高、未铺满整宽(布局错位)。
def test_header_geometry(img, layout=None, expected_h_frac=0.16,
                         h_tol_frac=0.03, width_slack_px=4, dark_thresh=150.0):
    """h_tol_frac: 顶栏上下边缘各有 1~2px 抗锯齿过渡行, 不同渲染器对边界
    行的归属舍入不同, 允许高度按图高 ±3% 浮动;
    width_slack_px: 容忍左右两端边缘列的抗锯齿。"""
    L = layout or _detect_layout(img)
    hh, W, H = L["header_h"], L["W"], L["H"]
    assert abs(hh / H - expected_h_frac) <= h_tol_frac, \
        f"顶栏高度异常: {hh}px ({hh / H:.3f})"
    row = L["gray"][max(hh // 2, 0)]
    n_dark = int((row < dark_thresh).sum())
    assert n_dark >= W - width_slack_px, f"顶栏未铺满整宽: {n_dark}/{W}"


# 守护: 顶栏标题文字对比度不足或文字消失(可读性回归)。
def test_header_text_contrast(img, layout=None, min_ratio=4.5,
                              text_lum_thresh=170.0, bg_lum_thresh=110.0,
                              text_frac_range=(0.005, 0.40)):
    """text/bg 两个亮度阈值之间留出宽中间带: 抗锯齿产生的笔画边缘过渡
    像素落在中间带、不参与统计, 从而容忍抗锯齿与压缩噪声;
    text_frac_range: 容忍字体渲染差异导致的墨迹覆盖率波动,
    下限排除文字消失, 上限排除底色变亮(主题回归)。"""
    L = layout or _detect_layout(img)
    band = _lum(img)[:L["header_h"]].ravel()
    text = band[band >= text_lum_thresh]
    bg = band[band <= bg_lum_thresh]
    assert text.size >= band.size * text_frac_range[0], "顶栏标题文字缺失"
    assert text.size <= band.size * text_frac_range[1], "顶栏亮色像素异常过多"
    assert bg.size > 0, "顶栏底色不是深色"
    ratio = _contrast_ratio(float(np.median(text)), float(np.median(bg)))
    assert ratio >= min_ratio, f"顶栏文字对比度 {ratio:.2f} < {min_ratio}"


# 守护: 卡片数量/顶边对齐/尺寸一致/间距均匀/水平居中(布局错位)。
def test_cards_grid_geometry(img, layout=None, expected_count=3,
                             pos_tol_px=4, size_tol_px=4, gap_tol_px=4,
                             margin_sym_tol_px=6, top_gap_range=(4, 60)):
    """pos/size/gap_tol_px: 卡片边缘有 1px 抗锯齿及可能的细描边, 二值化
    边界存在 ±1~2px 抖动, 几何比较统一放宽到 4px;
    margin_sym_tol_px: 容忍整行卡片 1~2px 的水平居中偏差;
    top_gap_range: 容忍顶栏与卡片行之间留白的动态变化。"""
    L = layout or _detect_layout(img)
    cards = L["cards"]
    assert len(cards) == expected_count, f"卡片数量 {len(cards)} != {expected_count}"
    tops = [c[1] for c in cards]
    heights = [c[3] - c[1] for c in cards]
    widths = [c[2] - c[0] for c in cards]
    assert max(tops) - min(tops) <= pos_tol_px, f"卡片顶边未对齐: {tops}"
    assert max(heights) - min(heights) <= size_tol_px, f"卡片高度不一致: {heights}"
    assert max(widths) - min(widths) <= size_tol_px, f"卡片宽度不一致: {widths}"
    gaps = [cards[i + 1][0] - cards[i][2] for i in range(len(cards) - 1)]
    assert max(gaps) - min(gaps) <= gap_tol_px, f"卡片间距不均: {gaps}"
    left, right = cards[0][0], L["W"] - cards[-1][2]
    assert abs(left - right) <= margin_sym_tol_px, \
        f"卡片行未水平居中: 左{left} vs 右{right}"
    top_gap = min(tops) - L["header_h"]
    assert top_gap_range[0] <= top_gap <= top_gap_range[1], \
        f"顶栏与卡片行间距异常: {top_gap}"


# 守护: 卡片标题/数值文本缺失或被异常内容替换(资源缺失/状态显示错误)。
def test_card_text_ink_present(img, layout=None, dark_thresh=140.0,
                               label_ink_range=(0.004, 0.50),
                               value_ink_range=(0.008, 0.50)):
    """ink_range 上下限都放宽: 容忍字体 hinting/抗锯齿差异导致的墨迹
    覆盖率波动, 以及数值为动态内容时的位数变化;
    下限排除"空白卡片", 上限排除整块深色(渲染事故)。"""
    L = layout or _detect_layout(img)
    assert L["cards"], "未检测到卡片"
    lum = _lum(img)
    for i, (x0, y0, x1, y1) in enumerate(L["cards"], 1):
        h, w = y1 - y0, x1 - x0
        label = lum[y0 + int(0.08 * h): y0 + int(0.45 * h),
                    x0 + int(0.05 * w): x0 + int(0.95 * w)]
        value = lum[y0 + int(0.45 * h): y1 - int(0.08 * h),
                    x0 + int(0.05 * w): x0 + int(0.90 * w)]
        li = float((label < dark_thresh).mean())
        vi = float((value < dark_thresh).mean())
        assert label_ink_range[0] <= li <= label_ink_range[1], \
            f"卡片{i} 标题墨迹占比异常: {li:.4f}"
        assert value_ink_range[0] <= vi <= value_ink_range[1], \
            f"卡片{i} 数值墨迹占比异常: {vi:.4f}"


# 守护: 数值区状态错误 —— 空值 / 被占位块(如"████")替换 / 内容粘连。
def test_card_value_digit_structure(img, layout=None, dark_thresh=140.0,
                                    comp_range=(2, 8), min_area_px=6):
    """comp_range: 宽区间容忍抗锯齿阈值导致相邻数字粘连(数变少)或单字
    断裂(数变多), 也容忍字体差异; 只排除 0 个连通域(空值)与
    1 个大块(占位符)。基线数值均为 3 位, 若业务允许 1 位数值请下调下限。
    min_area_px: 过滤抗锯齿噪点, 容忍 1~2px 的孤立灰斑。"""
    L = layout or _detect_layout(img)
    assert L["cards"], "未检测到卡片"
    lum = _lum(img)
    for i, (x0, y0, x1, y1) in enumerate(L["cards"], 1):
        h, w = y1 - y0, x1 - x0
        zone = lum[y0 + int(0.45 * h): y1 - int(0.08 * h),
                   x0 + int(0.05 * w): x0 + int(0.90 * w)]
        binimg = (zone < dark_thresh).astype(np.uint8)
        n, _, stats, _ = cv2.connectedComponentsWithStats(binimg, connectivity=8)
        comps = int(sum(1 for k in range(1, n)
                        if stats[k, cv2.CC_STAT_AREA] >= min_area_px))
        assert comp_range[0] <= comps <= comp_range[1], \
            f"卡片{i} 数值连通域数 {comps} 超出 {comp_range}(疑似空值/占位符)"


# 守护: 数值文字对比度不足(浅灰字配白底的无障碍回归)。
def test_card_value_contrast(img, layout=None, min_ratio=4.5,
                             text_lum_thresh=150.0, bg_lum_thresh=210.0):
    """两个亮度阈值之间的宽中间带容纳抗锯齿过渡像素, 使其不参与统计;
    用中位数而非极值估计文字色/底色, 容忍个别压缩噪声像素。"""
    L = layout or _detect_layout(img)
    assert L["cards"], "未检测到卡片"
    lum = _lum(img)
    for i, (x0, y0, x1, y1) in enumerate(L["cards"], 1):
        h, w = y1 - y0, x1 - x0
        zone = lum[y0 + int(0.45 * h): y1 - int(0.08 * h),
                   x0 + int(0.05 * w): x0 + int(0.90 * w)].ravel()
        text = zone[zone <= text_lum_thresh]
        bg = zone[zone >= bg_lum_thresh]
        assert text.size > 0 and bg.size > 0, f"卡片{i} 数值区缺少文字或底色"
        ratio = _contrast_ratio(float(np.median(bg)), float(np.median(text)))
        assert ratio >= min_ratio, f"卡片{i} 数值对比度 {ratio:.2f} < {min_ratio}"


# 守护: 文本/数值溢出卡片边界, 或墨迹出现在沟槽与页边距(内容溢出)。
def test_no_card_overflow(img, layout=None, margin_px=8, max_stray_px=3,
                          dark_thresh=160.0):
    """max_stray_px: 容忍边缘抗锯齿产生的个别中间灰像素被阈值误判为墨迹;
    margin_px: 检查带宽度, 需明显大于 1~2px 的抗锯齿过渡带。"""
    L = layout or _detect_layout(img)
    assert L["cards"], "未检测到卡片"
    gray = L["gray"]
    top = min(c[1] for c in L["cards"])
    bot = max(c[3] for c in L["cards"])
    # 1) 每张卡片右缘内侧窄带必须干净(数值左对齐, 右侧应留白)
    for i, (x0, y0, x1, y1) in enumerate(L["cards"], 1):
        band = gray[y0:y1, max(x0, x1 - margin_px):x1]
        stray = int((band < dark_thresh).sum())
        assert stray <= max_stray_px, \
            f"卡片{i} 右缘内侧有 {stray} 个墨迹像素, 疑似内容溢出"
    # 2) 卡片间沟槽与页面左右边距不允许出现墨迹
    regions = [(L["cards"][i][2], L["cards"][i + 1][0])
               for i in range(len(L["cards"]) - 1)]
    regions += [(0, L["cards"][0][0]), (L["cards"][-1][2], L["W"])]
    for a, b in regions:
        if b - a < 2:
            continue
        stray = int((gray[top:bot, a:b] < dark_thresh).sum())
        assert stray <= max_stray_px, \
            f"卡片行沟槽 [{a},{b}) 有 {stray} 个墨迹像素, 疑似内容溢出"


# 守护: 三张卡片渲染同一数值的状态错误(如数据绑定失败)。
def test_card_values_distinct(img, layout=None, min_diff_frac=0.02,
                              dark_thresh=140.0):
    """min_diff_frac: 不同数字共享部分笔画(如"1"), 二值化阈值也会轻微
    改变笔画形状, 故只要求墨迹图案存在 2% 以上差异而非完全不同;
    若三卡渲染同一值, 差异≈0, 必然失败。"""
    L = layout or _detect_layout(img)
    assert len(L["cards"]) >= 2, "未检测到足够卡片"
    lum = _lum(img)
    zones = []
    for x0, y0, x1, y1 in L["cards"]:
        h, w = y1 - y0, x1 - x0
        zone = lum[y0 + int(0.45 * h): y1 - int(0.08 * h),
                   x0 + int(0.05 * w): x0 + int(0.90 * w)]
        zones.append((zone < dark_thresh).astype(np.uint8))
    shape = tuple(min(z.shape[k] for z in zones) for k in (0, 1))
    zones = [cv2.resize(z, (shape[1], shape[0]),
                        interpolation=cv2.INTER_NEAREST) for z in zones]
    for i in range(len(zones)):
        for j in range(i + 1, len(zones)):
            diff = float(np.mean(zones[i] != zones[j]))
            assert diff >= min_diff_frac, \
                f"卡片{i + 1} 与卡片{j + 1} 数值疑似相同(差异仅 {diff:.4f})"


# 守护: "最近活动"面板缺失、宽度塌陷或与卡片行错位(资源缺失/布局错位)。
def test_activity_panel_geometry(img, layout=None, min_width_frac=0.85,
                                 pos_tol_px=8, top_gap_range=(4, 80)):
    """min_width_frac: 面板宽度需与卡片行相当(比例阈值, 容忍边缘检测
    ±几像素抖动); pos_tol_px: 容忍面板与卡片行左右边缘的对齐偏差
    (抗锯齿 + 设计上 1~2px 的出入); top_gap_range: 容忍两者留白的
    动态变化。"""
    L = layout or _detect_layout(img)
    assert L["cards"], "未检测到卡片"
    assert L["panel"] is not None, "未检测到'最近活动'面板"
    px0, py0, px1, _ = L["panel"]
    cx0 = min(c[0] for c in L["cards"])
    cx1 = max(c[2] for c in L["cards"])
    assert (px1 - px0) >= min_width_frac * (cx1 - cx0), "面板宽度塌陷"
    assert abs(px0 - cx0) <= pos_tol_px and abs(px1 - cx1) <= pos_tol_px, \
        "面板与卡片行左右边缘未对齐"
    gap = py0 - max(c[3] for c in L["cards"])
    assert top_gap_range[0] <= gap <= top_gap_range[1], \
        f"面板与卡片行间距异常: {gap}"


# 守护: 面板标题"最近活动"缺失(资源缺失/文案回归)。
def test_activity_title_present(img, layout=None, dark_thresh=140.0,
                                ink_range=(0.004, 0.50),
                                title_zone=(0.06, 0.35, 0.05, 0.60)):
    """ink_range: 容忍字体渲染差异带来的墨迹覆盖率波动, 下限排除标题
    消失, 上限排除标题区被大块深色污染;
    title_zone=(y0,y1,x0,x1) 为面板内比例区域, 容忍不同内边距实现。"""
    L = layout or _detect_layout(img)
    assert L["panel"] is not None, "未检测到面板"
    px0, py0, px1, py1 = L["panel"]
    ph, pw = py1 - py0, px1 - px0
    ty0, ty1, tx0, tx1 = title_zone
    zone = _lum(img)[py0 + int(ty0 * ph): py0 + int(ty1 * ph),
                     px0 + int(tx0 * pw): px0 + int(tx1 * pw)]
    ink = float((zone < dark_thresh).mean())
    assert ink_range[0] <= ink <= ink_range[1], f"面板标题墨迹占比异常: {ink:.4f}"


# 守护: 活动列表分隔线缺失/数量错误/间距不均(列表渲染回归)。
def test_activity_separator_lines(img, layout=None, expected_count=4,
                                  spacing_tol_px=4.0, thickness_range=(1, 4),
                                  min_lum_drop=6.0, min_line_lum=150.0,
                                  coverage_frac=0.60, inset_px=10,
                                  skip_title_frac=0.20):
    """spacing_tol_px: 1px 细线的抗锯齿会使检测到的线中心偏移 1~2px;
    thickness_range: 容忍线条在不同渲染器/DPI 下 1~2px 粗细差异;
    min_lum_drop: 只认"明显比白底暗"的行, 容忍 ±3 级压缩噪声;
    coverage_frac: 容忍线条两端 1~2px 抗锯齿渐变, 同时排除标题文字行
    (文字只覆盖行左侧一小段, 达不到 60% 覆盖率)。"""
    L = layout or _detect_layout(img)
    assert L["panel"] is not None, "未检测到面板"
    px0, py0, px1, py1 = L["panel"]
    ph = py1 - py0
    ix0, ix1 = px0 + inset_px, px1 - inset_px
    iy0 = py0 + int(skip_title_frac * ph)            # 跳过标题区
    interior = L["gray"][iy0:py1 - 2, ix0:ix1].astype(np.float32)
    row_mean = interior.mean(axis=1)
    white_level = float(np.median(row_mean))
    is_line = (white_level - row_mean >= min_lum_drop) & (row_mean >= min_line_lum)
    lines = []
    for a, b in _runs(is_line, min_len=thickness_range[0]):
        if b - a > thickness_range[1]:
            continue
        coverage = float((interior[a:b] < white_level - min_lum_drop).mean())
        if coverage >= coverage_frac:
            lines.append(0.5 * (a + b) + iy0)
    assert len(lines) == expected_count, \
        f"分隔线数量 {len(lines)} != {expected_count}(位置: {[round(v) for v in lines]})"
    sp = np.diff(lines)
    assert np.all(np.abs(sp - sp.mean()) <= spacing_tol_px), \
        f"分隔线间距不均: {np.round(sp, 1).tolist()}"


# 守护: 页面背景主题回归(变纯白→白卡片消失; 变深/偏色→层次破坏)。
def test_page_background_color(img, layout=None, lum_range=(222.0, 250.0),
                               max_channel_spread=12.0):
    """lum_range: 容忍截图压缩与色彩配置导致的 ±几级亮度漂移;
    max_channel_spread: 容忍轻微偏色, 但排除背景被渲染成明显彩色。"""
    L = layout or _detect_layout(img)
    bg = L["bg_rgb"]
    lum = float(np.dot(bg.astype(np.float64), _LUMA))
    assert lum_range[0] <= lum <= lum_range[1], f"页面背景亮度异常: {lum:.1f}"
    spread = float(bg.max() - bg.min())
    assert spread <= max_channel_spread, f"页面背景偏色: RGB={bg}"


# 守护: 空白区出现意外深色内容(破图图标/越界文本/调试浮层等资源事故)。
def test_no_stray_dark_artifacts(img, layout=None, dark_thresh=120.0,
                                 max_stray_px=20, inflate_px=3):
    """max_stray_px: 容忍内容边缘抗锯齿与压缩振铃在允许区域边界外产生的
    少量深色像素; inflate_px: 已知内容区向外扩几像素, 容忍边界检测的
    ±1~2px 误差。"""
    L = layout or _detect_layout(img)
    H, W = L["H"], L["W"]
    allowed = np.zeros((H, W), dtype=bool)
    allowed[:L["header_h"], :] = True
    boxes = list(L["cards"]) + ([L["panel"]] if L["panel"] else [])
    for x0, y0, x1, y1 in boxes:
        allowed[max(0, y0 - inflate_px):min(H, y1 + inflate_px),
                max(0, x0 - inflate_px):min(W, x1 + inflate_px)] = True
    stray = int(((L["gray"] < dark_thresh) & ~allowed).sum())
    assert stray <= max_stray_px, f"空白区发现 {stray} 个意外深色像素"


# ===========================================================================
# 运行入口
# ===========================================================================

ASSERTIONS = [
    test_header_bar_color,
    test_header_geometry,
    test_header_text_contrast,
    test_cards_grid_geometry,
    test_card_text_ink_present,
    test_card_value_digit_structure,
    test_card_value_contrast,
    test_no_card_overflow,
    test_card_values_distinct,
    test_activity_panel_geometry,
    test_activity_title_present,
    test_activity_separator_lines,
    test_page_background_color,
    test_no_stray_dark_artifacts,
]


def run_all(img, layout=None, stop_on_first=False):
    """依次执行全部断言, 返回 [(断言名, 失败信息), ...]。"""
    layout = layout or _detect_layout(img)   # 结构检测只做一次, 全体复用
    failures = []
    for fn in ASSERTIONS:
        try:
            fn(img, layout=layout)
            print(f"[PASS] {fn.__name__}")
        except AssertionError as exc:
            failures.append((fn.__name__, str(exc)))
            print(f"[FAIL] {fn.__name__}: {exc}")
            if stop_on_first:
                raise
    print(f"\n{len(ASSERTIONS) - len(failures)}/{len(ASSERTIONS)} 项断言通过")
    return failures


if __name__ == "__main__":
    from PIL import Image
    path = sys.argv[1] if len(sys.argv) > 1 else "baseline.png"
    sys.exit(1 if run_all(np.array(Image.open(path).convert("RGB"))) else 0)
```

**回归类别覆盖映射**

| 回归类别 | 对应断言 |
|---|---|
| 布局错位 | `header_geometry`、`cards_grid_geometry`、`activity_panel_geometry`、`separator_lines`(间距) |
| 内容溢出 | `no_card_overflow`、`cards_grid_geometry`(尺寸) |
| 对比度不足 | `header_text_contrast`、`card_value_contrast`、`page_background_color` |
| 状态显示错误 | `card_value_digit_structure`(空值/占位符)、`card_values_distinct`(三卡同值)、`card_text_ink_present` |
| 资源缺失 | `card_text_ink_present`、`activity_panel_geometry`、`activity_title_present`、`separator_lines`(数量)、`no_stray_dark_artifacts`(破图图标) |

**标定说明**:仅两处常量与基线绑定——顶栏参考色 `(44,62,80)`(容差 ±25/通道,同时覆盖 `#2C3E50` 与 `#34495E` 一族的实现)和分隔线数量 `4`;其余全部动态检测。若设计有意变更(换主题色、增删列表行),只需更新这两个常量。
z-ai/glm-5v-turbofinish_reason=stop · tokens 387 / 4700(其中 reasoning 2108)· 交付 5 条断言函数

同样单轮返回。交付 5 条断言函数 + 1 个图片加载辅助函数,思路与 ox-alpha 明显不同:不做全局结构动态检测,而是按固定比例切图(页头取顶部 15%、卡片带取 20%–55%、活动列表取 60%–95%),配合硬编码参考色(自设目标深蓝 RGB (63,81,181))与亮度阈值逐区检测,每条注释说明容差用途;入口假定从固定路径 baseline_dashboard.png 读图。以下为未删节原文,Markdown 源码原样保留:

GLM-5V 输出原文 · assertions.py(未删节)展开 / 收起
```python
import os
import numpy as np
from PIL import Image, ImageStat, ImageDraw

# 注意:在实际 CI/CD 中,image_path 应由测试框架传入
BASELINE_PATH = "baseline_dashboard.png"

def get_image(path=BASELINE_PATH):
    """辅助函数:加载图像并转换为 RGBA numpy 数组"""
    if not os.path.exists(path):
        raise FileNotFoundError(f"找不到基准图像: {path}")
    return np.array(Image.open(path).convert('RGB'))

def assert_header_brand_integrity(img_array, tolerance_rgb=25):
    """
    守护:资源加载与全局样式 (CSS 是否生效)
    
    检测:页头背景色是否为预期的深蓝色。
    如果 CSS 加载失败或样式覆盖,页头可能变成白色或默认浏览器颜色。
    
    参数:
        tolerance_rgb (int): 颜色容差。
        用途: 容忍 JPEG 压缩噪点、抗锯齿导致的边缘像素混合,以及不同显示器/浏览器的轻微色差。
    """
    # 截取页头区域 (假设图像高度的前 15%)
    h, w = img_array.shape[:2]
    header_region = img_array[0:int(h * 0.15), :, :]
    
    # 计算该区域的主色调 (平均值)
    mean_color = np.mean(header_region, axis=(0, 1))
    
    # Acme Dashboard 的目标深蓝色 (约 #3F51B5 -> RGB 63, 81, 181)
    target_blue = np.array([63, 81, 181])
    
    # 计算欧几里得距离
    distance = np.linalg.norm(mean_color - target_blue)
    
    assert distance < tolerance_rgb, \
        f"页头颜色偏差过大 (距离: {distance:.1f}, 容差: {tolerance_rgb})。可能存在样式丢失或资源加载失败。"

def assert_three_column_layout(img_array, position_tolerance_px=10):
    """
    守护:布局错位 (Layout Shift / Responsive Breakpoint)
    
    检测:页面是否维持了三栏卡片布局。
    如果 Flex/Grid 配置错误或窗口宽度计算有误,卡片可能堆叠或重叠。
    
    参数:
        position_tolerance_px (int): 位置容差。
        用途: 容忍亚像素渲染差异、不同操作系统字体渲染导致的微弱宽度变化。
    """
    h, w = img_array.shape[:2]
    
    # 定义卡片的大致垂直范围 (页头下方 20% 到 50%)
    y_start, y_end = int(h * 0.2), int(h * 0.55)
    card_strip = img_array[y_start:y_end, :, :]
    
    # 转换为灰度以便检测边缘/亮度变化
    gray_strip = np.mean(card_strip, axis=2)
    
    # 简单的列检测:沿 X 轴投影,找到亮区(卡片背景)的中心
    # 这里简化处理:假设卡片是白色的,背景是灰色的
    # 我们寻找灰度值高于阈值的连续区间
    brightness_profile = np.mean(gray_strip, axis=0)
    threshold = 200 # 白色阈值
    
    # 找到所有亮区的中心点
    is_white = brightness_profile > threshold
    columns_centers = []
    in_col = False
    start_idx = 0
    
    for i, val in enumerate(is_white):
        if val and not in_col:
            start_idx = i
            in_col = True
        elif not val and in_col:
            center = (start_idx + i) // 2
            columns_centers.append(center)
            in_col = False
            
    # 断言:必须找到 3 个中心点
    assert len(columns_centers) == 3, f"布局错误:期望 3 列,检测到 {len(columns_centers)} 列"
    
    # 断言:顺序正确且间距合理 (防止重叠)
    for i in range(len(columns_centers) - 1):
        gap = columns_centers[i+1] - columns_centers[i]
        # 假设卡片宽度约为屏幕的 30%,间隙应该显著大于 0
        assert gap > w * 0.05, f"卡片 {i+1} 与 {i+2} 可能重叠或过近"

def assert_card_content_visibility(img_array, card_index=0, min_dark_pixel_ratio=0.05):
    """
    守护:内容溢出与状态显示 (Content Visibility / Overflow)
    
    检测:卡片内的数字文本 (如 "137") 是否可见且未被截断。
    如果 overflow: hidden 设置不当或 z-index 错误,数字可能不可见。
    
    参数:
        min_dark_pixel_ratio (float): 深色像素比例阈值。
        用途: 容忍字体加粗差异、不同字体文件渲染出的像素密度略有不同。
    """
    h, w = img_array.shape[:2]
    
    # 计算第 N 个卡片的 ROI (Region of Interest)
    # 假设均分布局,简单切分
    card_width = w // 3
    padding = 20 # 边缘避让
    
    # 选择第一个卡片 (card_index=0)
    x_start = padding 
    x_end = card_width - padding
    y_start = int(h * 0.25) # 卡片上部
    y_end = int(h * 0.50)   # 卡片下部 (数字所在区域)
    
    card_roi = img_array[y_start:y_end, x_start:x_end, :]
    
    # 将深色像素 (文本) 二值化
    # 假设文本是黑色的 (RGB < 100)
    gray_roi = np.mean(card_roi, axis=2)
    dark_mask = gray_roi < 100
    
    dark_ratio = np.sum(dark_mask) / dark_mask.size
    
    assert dark_ratio > min_dark_pixel_ratio, \
        f"卡片 {card_index+1} 内容不可见 (深色像素占比: {dark_ratio:.4f})。可能发生文字截断或颜色丢失。"

def assert_text_contrast_accessibility(img_array, min_contrast_distance=120):
    """
    守护:对比度不足 (Accessibility / Contrast Regression)
    
    检测:文字与背景是否有足够的对比度。
    有时 CSS 变更会导致文字变成浅灰色 (#999),导致无法阅读。
    
    参数:
        min_contrast_distance (int): 颜色欧几里得距离下限。
        用途: 容忍抗锯齿造成的边缘模糊(边缘像素是半透明的混合色)。
    """
    h, w = img_array.shape[:2]
    
    # 采样卡片 2 的背景 (应该是纯白附近)
    bg_x, bg_y = int(w * 0.5), int(h * 0.35) 
    # 采样卡片 2 的文字 (应该是纯黑附近)
    text_x, text_y = int(w * 0.5), int(h * 0.42)
    
    # 取周围 3x3 像素平均以减少噪点影响
    def sample_color(x, y, r=2):
        patch = img_array[y-r:y+r, x-r:x+r, :]
        return np.mean(patch, axis=(0,1))
    
    bg_color = sample_color(bg_x, bg_y)
    text_color = sample_color(text_x, text_y)
    
    contrast_dist = np.linalg.norm(bg_color - text_color)
    
    assert contrast_dist > min_contrast_distance, \
        f"对比度不足!距离={contrast_dist:.1f} (需>{min_contrast_distance})。可能导致阅读困难。"

def assert_activity_feed_structure(img_array, expected_lines=4, line_tolerance=1):
    """
    守护:组件渲染完整性 (Resource Missing / JS Error)
    
    检测:"最近活动" 列表项(横线)是否存在。
    如果前端 JS 报错或后端返回空数组,这些占位线可能消失。
    
    参数:
        line_tolerance (int): 允许的线条数量误差。
        用途: 容忍动态内容加载(例如有时显示 3 行有时 5 行),但在基线测试中主要用于检测“完全消失”(0行)。
    """
    h, w = img_array.shape[:2]
    
    # 截取活动列表区域
    list_top = int(h * 0.6)
    list_bottom = int(h * 0.95)
    list_region = img_array[list_top:list_bottom, int(w*0.05):int(w*0.95), :]
    
    # 转换灰度
    gray = np.mean(list_region, axis=2)
    
    # 使用形态学方法或简单的行投影检测横线
    # 计算每一行的平均亮度
    row_brightness = np.mean(gray, axis=1)
    
    # 寻找局部极小值 (线通常是深色的,或者在这个 UI 里是浅灰色的分割线?
    # 观察图示:是浅灰色的线。所以我们要找亮度略低于背景的点,
    # 或者简单地找明显的水平边缘。
    # 更简单的方法:Sobel 边缘检测或直接计算梯度。
    # 这里使用简化的梯度检测:
    sobel_h = np.abs(np.diff(gray.astype(float), axis=0)) # 垂直边缘强度
    # 我们要找水平线,所以看垂直方向的变化?不,水平线是垂直方向上有上下两个边缘。
    # 简单统计:如果一行中有显著的颜色变化(不是纯色),则认为是线。
    
    # 更鲁棒的方法:检查行的方差。纯白背景方差低,有线的行方差高。
    row_variance = np.var(gray, axis=1)
    
    # 设定阈值检测“非空白”行
    # 背景是 #fff (255),线是 #eee (238)。方差很小但存在。
    # 或者我们可以检测特定颜色的像素数量。
    # 让我们假设线是比背景稍深的颜色。
    # 检测亮度在某个范围内的像素聚集
    
    # 简化逻辑:只要该区域不是全白即可,或者检测特定的水平条纹模式
    # 为了代码可运行性,我们检测“非纯白”行的数量
    non_white_rows = np.sum(row_brightness < 250) 
    
    # 这是一个启发式检查:如果非白行数太少,说明列表是空的
    # 假设每条线高约 1-2px,加上间距
    estimated_lines = non_white_rows // 10 # 粗略估计
    
    # 只要检测到有内容(线条)即可,防止完全空白
    has_content = np.any(gray < 240)
    
    assert has_content, "最近活动列表似乎为空或未渲染 (全白)。可能存在数据获取失败或组件崩溃。"

# --- 执行入口 ---
if __name__ == "__main__":
    try:
        img = get_image()
        print("开始视觉回归测试...")
        
        assert_header_brand_integrity(img)
        print("[PASS] 页头样式正常")
        
        assert_three_column_layout(img)
        print("[PASS] 三栏布局正常")
        
        assert_card_content_visibility(img, 0)
        print("[PASS] 卡片 1 内容可见")
        
        assert_text_contrast_accessibility(img)
        print("[PASS] 文字对比度合格")
        
        assert_activity_feed_structure(img)
        print("[PASS] 活动列表结构完整")
        
        print("
所有断言通过!")
        
    except AssertionError as e:
        print(f"
[FAIL] 回归检测到: {e}")
    except Exception as e:
        print(f"
[ERROR] 运行出错: {e}")
```
moonshotai/kimi-k3finish_reason=stop · tokens 472 / 23987(其中 reasoning 17011)· 交付 18 条断言函数 · 2026-08-24 补跑

单轮返回。一次性交付 18 条断言函数 + 一个 run_all runner。思路与另两家不同:先写死一组基线语义常量(头部深蓝 NAVY≈(46,59,103)、页面浅灰 BG≈(245,246,250)、卡片判白阈值 ≥252),用白色掩码在固定水平带内定位三张卡与活动面板,再对检出的矩形做相对几何断言(数量 / 对齐 / 尺寸 / 间距);数值区另与基线图做 ±2px 搜索的 IoU 墨迹模板比对(test_metric_values_match_baseline,其自注「若数值是动态数据应改用非空+格式断言」)。以下为未删节原文(共 13,483 字符),Markdown 源码原样保留:

kimi-k3 输出原文 · assertions.py 全文及附注(未删节)展开 / 收起
```python
# assertions.py
# -*- coding: utf-8 -*-
"""
Acme Dashboard 视觉回归断言(基线 520x340:深蓝头部 + 三张指标卡 + 最近活动面板)

用法:
    python assertions.py current.png baseline.png
    # 或 pytest 中:  from assertions import run_all
    #                def test_visual(): assert run_all("current.png", "baseline.png")

设计原则:
  - 不做整图像素相等;每条断言只盯一个语义区域/属性。
  - 几何先"检测"(找卡片/面板的实际位置)再"断言",容差只吸收渲染噪声
    (抗锯齿 / 字体光栅化 / 子像素布局),不吸收设计变更。
"""
import sys
import numpy as np
from PIL import Image

# ---------------- 基线语义常量(近似值,配合容差使用) ----------------
NAVY = np.array([46, 59, 103])     # 头部深蓝主色
BG   = np.array([245, 246, 250])   # 页面浅灰背景
NEAR_WHITE = 252                   # 卡片/面板为纯白 255;背景≈245,借此把卡片从背景中分离


# ---------------- 工具函数 ----------------
def _arr(x):
    if isinstance(x, str):
        x = Image.open(x)
    if isinstance(x, Image.Image):
        x = x.convert("RGB")
    return np.asarray(x, dtype=np.int16)

def _lum(a):  # 感知亮度(逐像素)
    return 0.2126 * a[..., 0] + 0.7152 * a[..., 1] + 0.0722 * a[..., 2]

def _rel_lum(rgb):  # WCAG 相对亮度(单颜色)
    c = np.asarray(rgb, float) / 255.0
    c = np.where(c <= 0.03928, c / 12.92, ((c + 0.055) / 1.055) ** 2.4)
    return float(0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2])

def _contrast(c1, c2):  # WCAG 对比度
    l1, l2 = _rel_lum(c1), _rel_lum(c2)
    return (max(l1, l2) + 0.05) / (min(l1, l2) + 0.05)

def _runs(mask1d):  # 1D 布尔数组 -> 连续区间 [(start, end), ...]
    idx = np.flatnonzero(mask1d)
    if idx.size == 0:
        return []
    cuts = np.flatnonzero(np.diff(idx) > 1) + 1
    return [(int(g[0]), int(g[-1])) for g in np.split(idx, cuts)]

def _white_mask(img):
    return np.all(img >= NEAR_WHITE, axis=-1)

def _find_cards(img):
    """检测三张卡片的内接矩形 (x0, y0, x1, y1),按 x 排序。"""
    band = _white_mask(img)[70:158]                 # 卡片所在水平带
    runs = [r for r in _runs(band.mean(axis=0) > 0.55) if r[1] - r[0] > 40]
    row_band = _white_mask(img)[60:170]
    cards = []
    for x0, x1 in runs:
        ys = np.flatnonzero(row_band[:, x0:x1 + 1].mean(axis=1) > 0.6)
        if ys.size:
            cards.append((x0, 60 + int(ys[0]), x1, 60 + int(ys[-1])))
    return cards

def _find_panel(img):
    """检测活动面板的内接矩形。"""
    band = _white_mask(img)[160:332]
    runs = [r for r in _runs(band.mean(axis=0) > 0.5) if r[1] - r[0] > 200]
    assert runs, "未检出活动面板(疑似面板缺失或布局塌陷)"
    x0, x1 = runs[0]
    ys = np.flatnonzero(band[:, x0:x1 + 1].mean(axis=1) > 0.5)
    return x0, 160 + int(ys[0]), x1, 160 + int(ys[-1])

def _metric_ink(img, card):
    """卡片数值区域的二值墨迹掩码(相对卡片定位,对卡片整体平移免疫)。"""
    x0, y0, _, _ = card
    roi = img[y0 + 28:y0 + 56, x0 + 10:x0 + 100]
    return _lum(roi) < 140

def _best_iou(a, b, shift):
    """在 ±shift px 内搜索最佳 IoU;np.roll 边缘环绕影响 ≤2px,可忽略。"""
    best = 0.0
    for dy in range(-shift, shift + 1):
        for dx in range(-shift, shift + 1):
            bs = np.roll(b, (dy, dx), axis=(0, 1))
            u = np.logical_or(a, bs).sum()
            if u:
                best = max(best, np.logical_and(a, bs).sum() / u)
    return best


# ---------------- 断言 ----------------

def test_viewport_size(img, expected=(340, 520), tol=0):
    # 守护: 测试环境回归 —— 视口变化会使所有绝对坐标失效,必须重新基线化
    # tol=0: 视口由测试框架固定,不存在"渲染噪声",任何变化都是环境变更
    h, w = img.shape[:2]
    assert abs(h - expected[0]) <= tol and abs(w - expected[1]) <= tol, \
        f"视口 {w}x{h},期望 {expected[1]}x{expected[0]}"

def test_header_bar(img, color_tol=30, height_range=(40, 80)):
    # 守护: 资源缺失 —— CSS 加载失败时深蓝头条退化为白底
    # color_tol=30: 容忍截图压缩噪声与边缘抗锯齿混色
    # height_range: 容忍跨平台字体/内边距渲染造成的头部高度浮动(基线≈57px)
    m = np.abs(img - NAVY).max(axis=-1) <= color_tol
    row_frac = m.mean(axis=1)
    assert row_frac[:height_range[0]].mean() >= 0.85, "头部深蓝条缺失或颜色漂移"
    below = np.flatnonzero(row_frac[10:] < 0.5)
    assert below.size, "未找到头部下边界"
    bottom = 10 + int(below[0])
    assert height_range[0] <= bottom <= height_range[1], f"头部高度异常: {bottom}px"

def test_header_title_visible(img, min_ink=200):
    # 守护: 状态/资源 —— "Acme Dashboard" 标题缺失或字体加载失败(渲染为空白)
    # min_ink=200: 基线约 600+ 白像素;阈值取 1/3,容忍字体替换导致的笔画面积差异
    roi = img[10:46, 10:190]
    n = int(np.all(roi > 200, axis=-1).sum())
    assert n >= min_ink, f"标题前景像素过少: {n}"

def test_header_contrast(img, min_ratio=4.5):
    # 守护: 对比度不足 —— 标题文字 vs 头部背景(WCAG AA 阈值 4.5)
    # 基线≈11,余量即容差:用中位数采样,容忍抗锯齿边缘像素混入
    bg = np.median(img[20:36, 300:500].reshape(-1, 3), axis=0)
    roi = img[10:46, 10:190]
    fg_px = roi[np.all(roi > 200, axis=-1)]
    assert fg_px.size, "未检出标题前景像素"
    r = _contrast(np.median(fg_px, axis=0), bg)
    assert r >= min_ratio, f"头部对比度 {r:.2f} < {min_ratio}"

def test_page_background(img, color_tol=8):
    # 守护: 资源缺失 —— 样式表失败时背景退化为纯白(与基线 Δ≈10)
    # color_tol=8: 背景是纯色填充,5x5 中位数采样极稳定,故可用紧容差;
    #              必须 <10 才能区分"浅灰背景"与"纯白兜底"
    for (x, y) in [(250, 64), (12, 330), (508, 330), (508, 64)]:
        med = np.median(img[y - 2:y + 3, x - 2:x + 3].reshape(-1, 3), axis=0)
        d = int(np.abs(med - BG).max())
        assert d <= color_tol, f"背景色漂移 @({x},{y}): Δ={d}"

def test_three_cards_layout(img, pos_tol=6, w_range=(130, 170), h_range=(60, 90)):
    # 守护: 布局错位 —— 卡片数量/位置/尺寸
    # pos_tol=6: 容忍子像素布局与边框光栅化(检测到的是边框内侧,固有 +1px)
    # w/h_range: 容忍跨平台盒模型取整,基线约 149x73
    cards = _find_cards(img)
    assert len(cards) == 3, f"检出卡片数={len(cards)},期望 3"
    for i, ((x0, y0, x1, y1), ex) in enumerate(zip(cards, (20, 190, 360))):
        assert abs(x0 - (ex + 1)) <= pos_tol, f"卡片{i+1} 左缘错位: x0={x0}"
        w, h = x1 - x0 + 1, y1 - y0 + 1
        assert w_range[0] <= w <= w_range[1], f"卡片{i+1} 宽度异常: {w}"
        assert h_range[0] <= h <= h_range[1], f"卡片{i+1} 高度异常: {h}"

def test_cards_top_aligned(img, align_tol=2):
    # 守护: 布局错位 —— 同行三张卡片顶部必须对齐
    # align_tol=2: 仅容忍光栅化取整,任何真实布局偏移(>2px)都应报警
    tops = [c[1] for c in _find_cards(img)]
    assert len(tops) == 3 and max(tops) - min(tops) <= align_tol, \
        f"卡片顶部不齐: {tops}"

def test_vertical_rhythm(img, gap_range=(8, 40)):
    # 守护: 布局错位 —— 头部/卡片/面板的垂直层叠顺序与间距
    # gap_range: 基线间距约 20px;范围容忍跨平台间距渲染差异,同时捕获重叠/脱节
    m = np.abs(img - NAVY).max(axis=-1) <= 30
    hb = 10 + int(np.flatnonzero(m.mean(axis=1)[10:] < 0.5)[0])
    cards, panel = _find_cards(img), _find_panel(img)
    g1, g2 = cards[0][1] - hb, panel[1] - cards[0][3]
    assert gap_range[0] <= g1 <= gap_range[1], f"头部-卡片间距异常: {g1}px"
    assert gap_range[0] <= g2 <= gap_range[1], f"卡片-面板间距异常: {g2}px"

def test_cards_fit_without_overlap(img, min_gap=8, margin=6):
    # 守护: 内容溢出 —— 卡片互相重叠或溢出视口
    # min_gap=8: 基线间隙≈20px,容忍布局整体微缩;margin=6: 基线边距≈20px
    cards, W = _find_cards(img), img.shape[1]
    assert cards[0][0] >= margin, "首张卡片贴/出左边界"
    assert cards[-1][2] <= W - margin, "末张卡片溢出右边界"
    for i, (a, b) in enumerate(zip(cards, cards[1:])):
        assert b[0] - a[2] >= min_gap, f"卡片{i+1}/{i+2} 间距不足或重叠"

def test_card_borders_visible(img, min_frac=0.7):
    # 守护: 资源缺失 —— 卡片边框样式丢失(边框行会退化为背景色)
    # min_frac=0.7: 容忍圆角/端点处的抗锯齿像素不满足"灰色"判据
    # 亮度上限 240 用于区分边框灰(≈217)与背景灰(≈246)
    for i, (x0, y0, x1, y1) in enumerate(_find_cards(img)):
        edge = img[y0 - 1, x0 - 1:x1 + 2]
        lum, spread = _lum(edge), edge.max(-1) - edge.min(-1)
        frac = ((lum > 150) & (lum < 240) & (spread < 24)).mean()
        assert frac >= min_frac, f"卡片{i+1} 上边框缺失 (frac={frac:.2f})"

def test_card_labels_visible(img, min_ink=30, ink_lum=175):
    # 守护: 状态显示错误 —— "卡片 1/2/3" 标签丢失
    # min_ink=30: 基线约 80-150 像素;ink_lum=175 兼容灰色标签字(非纯黑)
    for i, (x0, y0, x1, y1) in enumerate(_find_cards(img)):
        n = int((_lum(img[y0 + 6:y0 + 24, x0 + 8:x1 - 8]) < ink_lum).sum())
        assert n >= min_ink, f"卡片{i+1} 标签缺失 (ink={n})"

def test_metric_visible_and_contrast(img, min_ink=80, min_ratio=4.5):
    # 守护: 状态显示错误(数值为空/占位符消失) + 对比度不足(数值 vs 白卡)
    # min_ink=80: 基线约 200-300 像素,容忍字体笔画粗细差异
    # min_ratio=4.5: WCAG AA;基线≈18,余量吸收抗锯齿采样噪声
    for i, c in enumerate(_find_cards(img)):
        x0, y0, _, _ = c
        roi = img[y0 + 28:y0 + 56, x0 + 10:x0 + 100]
        ink = _lum(roi) < 140
        assert int(ink.sum()) >= min_ink, f"卡片{i+1} 数值为空或过淡"
        r = _contrast(np.median(roi[ink], axis=0), (255, 255, 255))
        assert r >= min_ratio, f"卡片{i+1} 数值对比度 {r:.2f} < {min_ratio}"

def test_metric_values_match_baseline(img, ref, iou_tol=0.85, shift_tol=2):
    # 守护: 状态显示错误 —— 数值被改(如 137 渲染成别的值)
    # 相对卡片定位裁剪,对卡片整体平移免疫
    # shift_tol=2: 容忍文本光栅化的亚像素位移
    # iou_tol=0.85: 容忍抗锯齿/字体差异;换一个数字会改动 ~30%+ 墨迹,IoU 跌破阈值
    # 注意: 若数值是动态数据,应改用"非空+格式"断言,此处按基线冻结
    ci, cr = _find_cards(img), _find_cards(ref)
    assert len(ci) == len(cr) == 3, "卡片数量与基线不一致"
    for i, (a, b) in enumerate(zip(ci, cr)):
        iou = _best_iou(_metric_ink(img, a), _metric_ink(ref, b), shift_tol)
        assert iou >= iou_tol, f"卡片{i+1} 数值与基线不符 (IoU={iou:.3f})"

def test_no_text_overflow_between_cards(img, max_stray=5):
    # 守护: 内容溢出 —— 卡片文字溢出到卡片间隙(长数值/字号失控)
    # max_stray=5: 容忍压缩噪点/抗锯齿飞点;真实溢出会产生数十以上墨迹像素
    cards = _find_cards(img)
    y0, y1 = min(c[1] for c in cards), max(c[3] for c in cards)
    for i, (a, b) in enumerate(zip(cards, cards[1:])):
        n = int((_lum(img[y0:y1 + 1, a[2] + 2:b[0] - 1]) < 140).sum())
        assert n <= max_stray, f"卡片{i+1}/{i+2} 间隙出现墨迹({n}px),疑似溢出"

def test_activity_panel_layout(img, pos_tol=8, min_w=440, min_h=120):
    # 守护: 布局错位/资源缺失 —— 活动面板的位置与尺寸(基线内接框≈21,171-499,319)
    # pos_tol=8: 容忍整体布局微移;min_w/min_h: 捕获面板塌陷
    x0, y0, x1, y1 = _find_panel(img)
    assert abs(x0 - 21) <= pos_tol and abs(x1 - 499) <= pos_tol, f"面板水平错位: {x0},{x1}"
    assert abs(y0 - 171) <= pos_tol and abs(y1 - 319) <= pos_tol, f"面板垂直错位: {y0},{y1}"
    assert x1 - x0 + 1 >= min_w and y1 - y0 + 1 >= min_h, "面板尺寸塌陷"

def test_activity_title_visible(img, min_ink=50):
    # 守护: 状态显示错误 —— "最近活动" 标题丢失
    # min_ink=50: 基线约 150+ 像素,容忍字体渲染差异
    n = int((_lum(img[178:202, 28:115]) < 150).sum())
    assert n >= min_ink, f"活动面板标题缺失 (ink={n})"

def test_activity_row_separators(img, expected=4, count_tol=1):
    # 守护: 状态显示错误 —— 列表分隔行丢失/塌陷/空态未渲染(基线 4 行)
    # count_tol=1: 容忍动态内容导致的行数小幅波动
    # 灰线判据(亮度195-248,通道差<14): 容忍分隔线抗锯齿混色
    x0, _, x1, _ = _find_panel(img)
    band = img[200:310, x0 + 12:x1 - 12]
    lum, spread = _lum(band), band.max(-1) - band.min(-1)
    line_rows = ((lum > 195) & (lum < 248) & (spread < 14)).mean(axis=1) > 0.8
    n = len(_runs(line_rows))
    assert abs(n - expected) <= count_tol, f"分隔行数={n},期望 {expected}±{count_tol}"

def test_no_overflow_below_panel(img, max_stray=3):
    # 守护: 内容溢出 —— 列表内容溢出面板/页面底部
    # max_stray=3: 容忍压缩噪点;面板之下应只有纯净背景
    y1 = _find_panel(img)[3]
    n = int((_lum(img[y1 + 3:]) < 140).sum())
    assert n <= max_stray, f"面板下方出现墨迹({n}px),疑似内容溢出"


# ----------------  runner ----------------
def run_all(img_path, ref_path=None):
    img = _arr(img_path)
    ref = _arr(ref_path) if ref_path else img
    if ref_path is None:
        print("[提示] 未提供基线,数值模板比对为自检(恒过),仅作冒烟")
    tests = [
        ("viewport_size",              lambda: test_viewport_size(img)),
        ("header_bar",                 lambda: test_header_bar(img)),
        ("header_title_visible",       lambda: test_header_title_visible(img)),
        ("header_contrast",            lambda: test_header_contrast(img)),
        ("page_background",            lambda: test_page_background(img)),
        ("three_cards_layout",         lambda: test_three_cards_layout(img)),
        ("cards_top_aligned",          lambda: test_cards_top_aligned(img)),
        ("vertical_rhythm",            lambda: test_vertical_rhythm(img)),
        ("cards_fit_without_overlap",  lambda: test_cards_fit_without_overlap(img)),
        ("card_borders_visible",       lambda: test_card_borders_visible(img)),
        ("card_labels_visible",        lambda: test_card_labels_visible(img)),
        ("metric_visible_and_contrast",lambda: test_metric_visible_and_contrast(img)),
        ("metric_values_match",        lambda: test_metric_values_match_baseline(img, ref)),
        ("no_overflow_between_cards",  lambda: test_no_text_overflow_between_cards(img)),
        ("activity_panel_layout",      lambda: test_activity_panel_layout(img)),
        ("activity_title_visible",     lambda: test_activity_title_visible(img)),
        ("activity_row_separators",    lambda: test_activity_row_separators(img)),
        ("no_overflow_below_panel",    lambda: test_no_overflow_below_panel(img)),
    ]
    failed = 0
    for name, fn in tests:
        try:
            fn()
            print(f"PASS  {name}")
        except Exception as e:  # 检测失败(如卡片数≠3)本身即回归信号
            failed += 1
            print(f"FAIL  {name}: {e}")
    print(f"\n{len(tests) - failed}/{len(tests)} 通过")
    return failed == 0


if __name__ == "__main__":
    cur = sys.argv[1] if len(sys.argv) > 1 else "baseline.png"
    ref = sys.argv[2] if len(sys.argv) > 2 else None
    sys.exit(0 if run_all(cur, ref) else 1)
```

**回归类别覆盖对照**

| 回归类别 | 对应断言 |
|---|---|
| 布局错位 | `three_cards_layout` / `cards_top_aligned` / `vertical_rhythm` / `activity_panel_layout` / `viewport_size` |
| 内容溢出 | `cards_fit_without_overlap` / `no_overflow_between_cards` / `no_overflow_below_panel` |
| 对比度不足 | `header_contrast` / `metric_visible_and_contrast`(WCAG 对比度算法,阈值 4.5) |
| 状态显示错误 | `metric_values_match`(IoU 模板比对)/ `activity_row_separators` / 三个 `*_visible` |
| 资源缺失 | `header_bar` / `page_background`(CSS 失败→白底)/ `card_borders_visible` / `header_title_visible`(字体失败) |

两点使用说明:

1. **几何是"检测+断言"而非硬编码**:卡片和面板先从像素中定位,再断言其位置/尺寸,所以整体平移 2-3px 不会误报,但相对错位(卡片不齐、间距异常)会被抓住。
2. **`metric_values_match` 假设数值已冻结**:如果 137/274/411 是动态数据,应把它换成"非空 + 数字格式"断言,否则每次数据变化都会(正确地)报警。

07.5 真实计算出的分数(三模型对比)

以下数值分别取自三份 results JSON 的 score 字段,由同一个打分脚本真实计算;色条式样与 compare.html 案例卡一致(同一根轴上三条色条:蓝 = ox-alpha、橙 = GLM-5V、青绿 = Kimi K3)。

轴一 · 注入缺陷召回_近似轴 0 – 1
stealth/ox-alpha0.0
z-ai/glm-5v-turbo0.0
moonshotai/kimi-k30.0
轴二 · 合法变体假阳率_近似轴 0 – 1
stealth/ox-alpha0.0
z-ai/glm-5v-turbo0.0
moonshotai/kimi-k30.0
三个模型的记录值表面上都是 0.0 / 0.0,但都不能按字面解读:ox-alpha 与 GLM-5V 的 9 个变体全部 sandbox_ok=false——打分沙箱因函数签名不匹配未成功执行过任何一条断言,0.0 是占位值而非测量;Kimi K3 补跑虽交付了完整代码回复,但其 score.per_variant 同样是空数组——没有任何逐变体执行记录,两个 0.0 同样不构成有效测量。judge 打分的两个维度(容差设计、可读性)三份 results 中均未包含。

ox-alpha 单模型明细——数值取自 results/C11.json 的 score 字段,由打分脚本真实计算。但本次运行存在一个必须如实说明的环境限制,见下方红框。

轴一 · 注入缺陷召回(近似)
0.0/ 1.0

脚本记录值:5 个注入缺陷变体中无一被判定「触发」。

轴二 · 合法变体假阳率(近似)
0.0/ 1.0

脚本记录值:4 个合法变体中无一被判定「误报」。

⚠ 这两个 0.0 不是有效测量,不能解读为「召回率 0%」或「误报率 0%」。本次运行的 9 个变体全部 sandbox_ok=false、triggered=null——打分沙箱没有成功执行过模型的任何一条断言。原因:模型自定义的断言函数签名要求调用方传入 RGB 像素数组(f(img, layout=None, …)),而打分脚本按预设只尝试了 (路径) / (bytes) 两种调用方式。score.note 原文:「断言函数签名由模型自定,本脚本尝试 (路径) / (bytes) 两种调用方式,非100%可靠匹配」;flags=["approximate_signature_matching"]。在零次成功执行的前提下,两个指标退化为占位值。C11 属于沙箱为启发式匹配的案例之一(同 C05 / C09 / C18)。另:judge 打分的两个维度(容差设计 15 分、可读性 10 分)未包含在本次 results 中,即本机这次运行未对这两维评分。
变体(score.per_variant)真值触发 triggered断言数 n_funcs报错 errors沙箱执行 sandbox_ok
defect_layout_shift注入缺陷nullnullnullfalse
defect_overflow注入缺陷nullnullnullfalse
defect_low_contrast注入缺陷nullnullnullfalse
defect_missing_icon注入缺陷nullnullnullfalse
defect_state_wrong注入缺陷nullnullnullfalse
legal_theme_color合法变化nullnullnullfalse
legal_language合法变化nullnullnullfalse
legal_dynamic_number合法变化nullnullnullfalse
legal_minor_resize合法变化nullnullnullfalse

表中 null / false 为 results/C11.json 的原始记录值:所有 9 个变体的断言都未被成功执行,因此 triggered / n_funcs / errors 均无数据。GLM-5V 的 per_variant 记录与此表逐项相同(9 个变体同样全部 sandbox_ok=false、triggered=null);Kimi K3 的 per_variant 为空数组(补跑已有完整回复,但无逐变体执行记录,见上方说明)。

07.6 运行限制(三份 results JSON · limitations 原文)

  • 模型生成代码的函数签名不受约束,沙箱用启发式多签名尝试调用,非严格基准
  • 样本规模为演示级缩小版(1),非原始题库规模

三份 results JSON 的 limitations 字段内容逐字相同,以上两条对三个模型的运行同样适用。