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

家里的仪表

刻度系统的建模,而非 OCR

modesinglescorernumeric_plus_exec自动化5/5趣味3/5
01

输入与证据锚点

输入 INPUT

水表、电表、体重秤、血压计、量杯、卡尺、压力表实拍,含指针位于两刻度之间的样本

证据锚点 EVIDENCE ANCHOR
MeasureBench仪表读数需要精细局部感知加上刻度间隔、小数位、单位归一这类轻量计算,此前少被评测
02

双轴设计

轴一 · 能力 AXIS Ⅰ
读数相对误差
通常测「能不能做到」
AXIS Ⅱ 轴二 · 节制
刻度系统描述正确率
通常测「会不会编造、该拒识时拒识」
理想方向 会建模 · 读不准 真本事:读得准 且 刻度系统说得清 蒙对:读数近似 · 刻度系统是编的 双低 >5 个最小刻度 · 记 0 <1 个最小刻度 · 记满 100% 50% 0% 轴二 · 刻度系统描述正确率 ↑ 轴一 · 读数相对误差(个最小刻度)→(向右=误差更小)
示意:每个模型 × 每张仪表图在平面上落一个点。横轴以「最小刻度」为单位,水表、卡尺、血压计等量纲不同的表因此可比;纵轴按量程、最小刻度、单位三项逐项比对折算。非真实跑分。
两轴独立计分、独立报告,合起来画 Pareto 前沿——只报轴一的横评没有信息量。
03

Prompt

PROMPT主 PROMPT读出这个仪表的示数。按顺序输出:
读出这个仪表的示数。按顺序输出:

1. 刻度系统:量程、最小刻度、单位、是否存在倍率或多圈、是否有副尺。
2. 读数,含正确的小数位。指针位于两刻度之间时,说明你如何取值。
3. 一段刻度解析程序:输入指针端点(或液面位置)的像素坐标与表盘中心/量程端点坐标,
   输出数值。必须处理非线性刻度和多圈表的情况,若本表不适用,写明为什么。

输出 JSON:{"scale": {...}, "reading": ..., "parser": "..."}
0 1 2 3 4 MPa 指针位于两刻度之间 2 2.5 最小刻度 = 0.5 reading ≈ 2 + 0.5 × 位置比例 量程 0 – 4 · 共 4 大格 · 无副尺
示意(非真实样本):一道典型表盘的刻度系统——先答「量程 / 最小刻度 / 单位 / 倍率多圈 / 副尺」(第 1 问),再答指针落在两刻线之间时如何内插取值(第 2 问),最后交一段能吃像素坐标的解析程序(第 3 问)。量杯、卡尺同构,只是刻度从弧线换成直线。
PROMPT 要求 ↔ RUBRIC 维度
题干本体读出示数,含正确的小数位(相对误差以「最小刻度」计)→读数精度35脚本
要求 ①刻度系统五项:量程、最小刻度、单位、是否倍率或多圈、是否副尺→刻度系统描述30脚本
要求 ③刻度解析程序:像素坐标 → 数值,要经得起合成表盘回放验证→解析程序25脚本
要求 ②+③两刻度之间的取值说明;非线性刻度、多圈表不适用时写明原因→边界情况10judge

四维合计 100:三维由脚本可算——包括真的拿合成表盘去跑模型交出的 parser;仅「边界情况」需要 judge。题目要求的每一项输出都有对应的计分去处。

04

Rubric 评分表

维度满分由谁打判据
读数精度35脚本相对误差 <1 个最小刻度记满,>5 个最小刻度记 0。
刻度系统描述30脚本量程、最小刻度、单位三项逐项比对。
解析程序25脚本用合成表盘(已知答案)跑它的 parser,误差 <2% 记满。
边界情况10judge指针在两刻度之间、多圈表、副尺的处理说明是否正确。

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

05

跑批命令

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

数据准备:按 data/C18/manifest.jsonl 的格式放置图片与真值(真值读数、量程、最小刻度、单位,以及供第 3 问回放用的合成表盘参数)。score 阶段的 judge 只处理本案例 rubric 中非 deterministic 的维度。

06

实测结果 · Live Run

results/C18.json results/z-ai__glm-5v-turbo/C18.json results/moonshotai__kimi-k3/C18.json
真实性声明

以下是真实调用三个模型跑出的结果:stealth/ox-alpha(2026-08-22 实跑)、z-ai/glm-5v-turbo 与 moonshotai/kimi-k3(各自独立实跑)。三者面对的是同一套 3 张仪表图与逐字相同的 Prompt(三份 results 文件已逐字节核对一致),样本规模为演示级(每模型 3 张仪表图,远小于原始题库设计),每条样本单轮提问。输入图片、实际发送的 Prompt、模型回复原文与分数全部照录自 results/C18.json、results/z-ai__glm-5v-turbo/C18.json、results/moonshotai__kimi-k3/C18.json 与 data/C18/,未做任何润色或改写;打分脚本没算的维度如实标注「未计算」,不补算、不包装成严格基准。此前标注为空响应的两次调用(ox-alpha 的 pressure_gauge、kimi-k3 的 water_meter_needle)已用相同的 prompt 与图片重新执行并补齐真实回复,本页照录补跑后的最新结果。

方法论局限 · 运行方式不完全对等三个模型的调用方式相同(同一套 run_batch.py 风格的 OpenRouter chat/completions 调用),但 stealth/ox-alpha 是强制深度推理的 stealth 模型,glm-5v-turbo 与 kimi-k3 的推理行为未特别配置——输出风格与推理深度可能天然不同,解读下面的横向对比时要注意这一局限。
模型图例 MODELS GLM-X-Preview-F (stealth/ox-alpha) GLM-5V (glm-5v-turbo) Kimi K3 (moonshotai/kimi-k3)
样本3 模型 × 3 图 × 单轮 ox-alpha 运行日期2026-08-22 均值↓ ox-alpha0.006 均值↓ glm-5v-turbo0.673 均值↓ kimi-k30.338 score_error三个模型均为 null(打分无报错)
值得注意 · 三个模型各自的异常与不一致处

stealth/ox-alpha:三条样本均有完整正文,读数依次为 2.3 / 6.4 / 23.5——样本 1(pressure_gauge)与真值完全一致(误差记 0),另两条误差 0.015 / 0.002,三条均值仅 0.006;parser可运行_近似 三条均记 1。本段只是对脚本输出的复述与算术平均,不是额外评分。

z-ai/glm-5v-turbo:三条样本都有正文输出(completion 1999–2878 tok),但 pressure_gauge 与 water_meter_needle 两条的打分脚本记录 reading_extracted = 0.0 并据此记满误差 1.0——与其回复原文中写明的 reading.value = 2.25 / 6.6 不一致;页面把双方原文并排照录,不替任何一方补算或改判。parser可运行_近似 三条均记 0(其中 calipers 一条交出的是 JavaScript 实现)。

moonshotai/kimi-k3:pressure_gauge 读出 2.30,与真值完全一致(误差记 0,与 ox-alpha 相同);water_meter_needle 这次也有完整正文且原文写明 6.5,但打分脚本记录 reading_extracted = null 并按规则记满误差 1.0——与回复原文不一致,页面把双方原文并排照录,不替任何一方补算或改判。

PROMPT真实发送的 Prompt三个模型 × 三条样本共用同一段,逐字发送,vars 均为空 {}
读出这个仪表的示数。按顺序输出:

1. 刻度系统:量程、最小刻度、单位、是否存在倍率或多圈、是否有副尺。
2. 读数,含正确的小数位。指针位于两刻度之间时,说明你如何取值。
3. 一段刻度解析程序:输入指针端点(或液面位置)的像素坐标与表盘中心/量程端点坐标,
   输出数值。必须处理非线性刻度和多圈表的情况,若本表不适用,写明为什么。

输出 JSON:{"scale": {...}, "reading": ..., "parser": "..."}

三个模型 × 三条样本(pressure_gauge / water_meter_needle / calipers)使用同一段 prompt,三份 results 文件中的 prompt 字符串已核对逐字节一致;data/C18/manifest.jsonl 中三者的 vars 均为空对象 {},题库模板没有发生任何变量替换,上文即逐字发送的文本(与第 03 节的题目模板一致)。因此下文每个样本只展示一次输入图片,三个模型的回复原文并排照录。

样本 1 / 3 pressure_gauge 真值 reading = 2.3 bar(range [0, 4])· 三模型同图同 prompt ox-alpha:命中真值 2.3 · 误差 0.0 GLM-5V:提取 0.0 · 误差记 1.0 Kimi K3:命中真值 2.30 · 误差 0.0
真实输入样本:压力表盘,量程 0–4,单位 bar,红色指针位于 2 与 2.5 之间
真实输入图片 · data/C18/images/pressure_gauge.png
truth:reading = 2.3 · unit = bar · range = [0, 4](三个模型的输入完全相同)
脚本打分对照 · score.per_item[0] × 3 模型
ox-alphaGLM-5VKimi K3
reading_extracted 2.3 (与真值一致) 0.0 (⚠ 脚本提取记录为 0.0;回复原文写的是 2.25——两者不一致,照录不补算) 2.3 (与真值一致)
truth2.3 (unit bar · 三份 JSON 相同)
读数相对误差 ↓0.01.00.0
parser可运行_近似1.00.00.0
usage p 453 / c 5770 (total 6223 tok · cost 0 · finish_reason "stop") p 402 / c 2456 (reasoning 1612 · cost 0.0103064) p 449 / c 7481 (reasoning 6622 · cost 0.1305963)
RESPONSE stealth/ox-alpha · 模型回复原文刻度系统 → 读数 2.3 bar → 解析程序 → JSON;读数与真值一致
## 1. 刻度系统

| 项目 | 内容 |
|---|---|
| 量程 | 0 – 4 bar |
| 最小刻度(分度值) | 0.5 bar(仅有主刻度线,无小刻度) |
| 单位 | bar(表盘下方标注) |
| 倍率 / 多圈 | 无倍率;单圈表,扫角约 270°(0 在左下、4 在右下、2 在正上方) |
| 副尺 | 无 |

## 2. 读数

- 指针位于 **2 与 2.5 之间**,无小刻度,需按角度内插估读。
- 以像素估算:针尖约 (256, 106),表盘中心约 (210, 236),指针偏离竖直方向约 19°–20°;每 0.5 bar 对应 270°/8 = 33.75°,故超出 "2" 约 0.57 个格 → **读数 ≈ 2.29 ≈ 2.3 bar**。
- 分度值 0.5 bar,按 1/10 分度估读(0.05 bar),结果记为 **2.30 bar**。

## 3. 刻度解析程序

本表为**线性、单圈**刻度,程序直接适用;同时通过标定表 `cal` 支持非线性刻度,通过 `turns/rev` 支持多圈表(多圈时同一角度对应多个示数,必须外部提供当前圈序,否则存在歧义)。

```python
import math

def read_gauge(tip, center, p0, p1, v0=0.0, v1=4.0, turns=1, rev=0, cal=None):
    """tip/center/p0/p1: (x,y)像素坐标; p0/p1=量程起/止刻度中点
       v0/v1: 起/止示数; turns: 总圈数; rev: 当前圈序(多圈表必填,0起)
       cal: 非线性刻度标定表[(相对起点顺时针角度deg, 示数),...]; 线性传None"""
    ang   = lambda p: math.degrees(math.atan2(p[1]-center[1], p[0]-center[0]))
    a0    = ang(p0)
    sweep = lambda p: (ang(p) - a0) % 360.0        # 相对起点的顺时针扫角
    s_tip, s_end = sweep(tip), sweep(p1)
    if cal is None:                                # —— 线性刻度 ——
        if turns > 1 and not 0 <= rev < turns:
            raise ValueError("多圈表单角度有歧义, 必须提供 rev")
        per  = (v1 - v0) / turns
        return round(v0 + rev * per + (s_tip / s_end) * per, 3)
    pts = sorted((sweep(a), v) for a, v in cal)    # —— 非线性: 角度-示数插值 ——
    s = min(max(s_tip, pts[0][0]), pts[-1][0])
    for (sa, va), (sb, vb) in zip(pts, pts[1:]):
        if sa <= s <= sb:
            return round(va + (vb - va) * (s - sa) / (sb - sa), 3)
    return pts[-1][1]

# 本表调用(像素为估计值):
# read_gauge(tip=(256,106), center=(210,236), p0=(108,339), p1=(313,339))
# -> 2.29 ≈ 2.3 bar
```

## 输出 JSON

```json
{
  "scale": {
    "range": [0, 4],
    "unit": "bar",
    "smallest_division": 0.5,
    "minor_ticks": false,
    "multiplier": 1,
    "multi_turn": false,
    "sweep_angle_deg": 270,
    "vernier": false
  },
  "reading": 2.3,
  "parser": "import math\ndef read_gauge(tip, center, p0, p1, v0=0.0, v1=4.0, turns=1, rev=0, cal=None):\n    ang   = lambda p: math.degrees(math.atan2(p[1]-center[1], p[0]-center[0]))\n    a0    = ang(p0)\n    sweep = lambda p: (ang(p) - a0) % 360.0\n    s_tip, s_end = sweep(tip), sweep(p1)\n    if cal is None:\n        if turns > 1 and not 0 <= rev < turns:\n            raise ValueError('multi-turn gauge needs rev')\n        per = (v1 - v0) / turns\n        return round(v0 + rev * per + (s_tip / s_end) * per, 3)\n    pts = sorted((sweep(a), v) for a, v in cal)\n    s = min(max(s_tip, pts[0][0]), pts[-1][0])\n    for (sa, va), (sb, vb) in zip(pts, pts[1:]):\n        if sa <= s <= sb:\n            return round(va + (vb - va) * (s - sa) / (sb - sa), 3)\n    return pts[-1][1]\n# 本表: read_gauge(tip=(256,106), center=(210,236), p0=(108,339), p1=(313,339)) -> 2.29"
}
```
usage:prompt 453 tok · completion 5770 tok · total 6223 · cost 0 · finish_reason "stop"
RESPONSE z-ai/glm-5v-turbo · 模型回复原文纯 JSON 输出:scale → reading(2.25 bar)→ parser(Python)
```json
{
  "scale": {
    "range": "0 ~ 4 bar",
    "min_division": "0.25 bar",
    "unit": "bar",
    "multiplier_or_multi_turn": "无(单圈线性刻度)",
    "vernier_scale": "无"
  },
  "reading": {
    "value": 2.25,
    "explanation": "指针位于刻度 2 与 2.5 之间。该表盘每 0.5 bar 有一个大格,中间有 1 个小格,故最小分度为 0.25 bar。指针尖端恰好指向或非常接近 2 与 2.5 之间的中间刻度线(即 2.25 刻度处)。按物理仪表读数规则,估读到最小分度的下一位,读数约为 2.25 bar(合理范围在 2.20 ~ 2.30 bar 之间均视为正确)。"
  },
  "parser": "def parse_gauge(pointer_pos, center_pos, zero_angle, full_scale_angle, min_val=0, max_val=4, is_nonlinear=False, calibration_curve=None):
    """
    解析表盘读数的通用程序
    :param pointer_pos: 指针端点的像素坐标 (x, y)
    :param center_pos: 表盘中心的像素坐标 (x, y)
    :param zero_angle: 零刻度对应的角度(弧度)
    :param full_scale_angle: 满量程对应的角度(弧度)
    :param min_val: 量程最小值
    :param max_val: 量程最大值
    :param is_nonlinear: 是否为非线性刻度(如对数、方根等)
    :param calibration_curve: 非线性刻度的校正函数(角度->数值),若为 None 则按线性处理
    :return: 仪表读数
    """
    import math
    
    # 1. 计算指针相对中心的角度
    dx = pointer_pos[0] - center_pos[0]
    dy = pointer_pos[1] - center_pos[1]
    # atan2 返回 -pi 到 pi,需根据表盘实际方向调整基准
    theta = math.atan2(dy, dx)
    
    # 2. 归一化角度到 [0, 1] 区间
    # 处理角度跨越 -pi/pi 的情况(如表盘跨越12点钟方向)
    angle_range = full_scale_angle - zero_angle
    if angle_range == 0:
        angle_range = 2 * math.pi # 完整一圈
    
    normalized = (theta - zero_angle) / angle_range
    
    # 处理多圈情况(若存在倍率或多圈指示):
    # 对于多圈表,通常需要结合机械计数器或历史帧数据判断圈数。
    # 单幅静态图像无法确定绝对圈数,此处仅计算当前圈内的相对位置。
    # 如果已知总圈数 N,可加上 N * (max_val - min_val)
    normalized = normalized % 1.0 
    
    # 3. 映射为数值
    if is_nonlinear and calibration_curve:
        value = calibration_curve(normalized)
    else:
        # 默认线性处理
        value = min_val + normalized * (max_val - min_val)
    
    return value

# 针对本表的说明:
# 本表为标准线性刻度压力表,不涉及非线性或多圈复杂情况。
# 可直接通过指针角度与 0-4bar 范围的比例关系计算出精确读数。"
}
```
usage:prompt 402 tok · completion 2456 tok(reasoning 1612)· total 2858 · cost 0.0103064 · finish_reason "stop"
RESPONSE moonshotai/kimi-k3 · 模型回复原文单行 JSON:scale → reading 2.30 → parser(Python);读数与真值一致
{"scale":{"range":[0,4],"min_division":0.5,"unit":"bar","multiplier":null,"multi_turn":false,"sub_scale":false,"note":"No range multiplier (dial reads directly in bar), single-turn less-than-360 deg movement (0 to 4 bar spans about 270 deg of arc), and no vernier/sub-scale. The pointer tip falls between the 2 and 2.5 marks; because the scale is uniform I take the value by linear interpolation in dial angle: theta_tip about 69.6 deg (math angle, y up) versus 90 deg at '2' and 56.25 deg at '2.5', fraction=(90-69.6)/(90-56.25)=0.604, value=2+0.604*0.5=2.30 bar. With a 0.5 bar minor division this is normally quoted to 0.1 bar, i.e. 2.3 bar."},"reading":2.30,"parser":"import math\nfrom bisect import bisect_right\n\ndef read_gauge(center, tip, start_pt, end_pt, v_min, v_max, clockwise=True, calib=None, turns=0, turn_value=None, sweep_deg=None):\n    '''center/tip/start_pt/end_pt are (x,y) image pixels (y down). start_pt/end_pt are the dial points for v_min/v_max. clockwise=True means value increases clockwise (true for this gauge). calib (optional) is a monotonic list of (phi,value) for a NON-LINEAR scale, phi=fractional arc from start in [0,1]; if given it overrides the linear map. turns/turn_value handle MULTI-TURN dials: turns=completed full revolutions, turn_value=value added per revolution. sweep_deg=total increasing-direction sweep start->end; default computed from the two end points.'''\n    TAU=2*math.pi\n    def ang(p):\n        return math.atan2(-(p[1]-center[1]), p[0]-center[0]) % TAU\n    def fwd(a,b):\n        d=(b-a)%TAU\n        return d if clockwise else (TAU-d)%TAU\n    a0,a1,at=ang(start_pt),ang(end_pt),ang(tip)\n    sweep=math.radians(sweep_deg) if sweep_deg is not None else fwd(a0,a1)\n    if sweep<=0:\n        raise ValueError('zero sweep')\n    phi=min(max(fwd(a0,at)/sweep,0.0),1.0)\n    if calib:\n        xs=[p for p,_ in calib]; ys=[v for _,v in calib]\n        i=min(max(bisect_right(xs,phi)-1,0),len(xs)-2)\n        x0,x1=xs[i],xs[i+1]; y0,y1=ys[i],ys[i+1]\n        base=y0 if x1==x0 else y0+(y1-y0)*(phi-x0)/(x1-x0)\n    else:\n        base=v_min+phi*(v_max-v_min)\n    if turn_value is not None:\n        return turns*turn_value+(base-v_min)\n    return base\n\n# This gauge: read_gauge(center=(210,237), tip=(256,113), start_pt=<pixel of the 0 mark>, end_pt=<pixel of the 4 mark>, v_min=0.0, v_max=4.0, clockwise=True) -> about 2.30. Non-linear (calib) and multi-turn (turns/turn_value) branches are NOT used here: the 0-4 bar range is a single uniform arc of ~270 deg (under one revolution, the pointer never passes the origin again), so turns=0, turn_value=None and the linear map suffices."}
usage:prompt 449 tok · completion 7481 tok(reasoning 6622)· total 7930 · cost 0.1305963 · finish_reason "stop"
样本 2 / 3 water_meter_needle 真值 reading = 6.5 m³(range [0, 10],最小刻度 0.1)· 三模型同图同 prompt ox-alpha:读数 6.4 · 误差 0.015 GLM-5V:提取 0.0 · 误差记 1.0 Kimi K3:原文写 6.5 · 提取 null · 误差记 1.0
真实输入样本:水表表盘,量程 0–10 m³,红色指针位于 6 与 7 之间
真实输入图片 · data/C18/images/water_meter_needle.png
truth:reading = 6.5 · unit = m3(最小刻度 0.1)· range = [0, 10](三个模型的输入完全相同)
脚本打分对照 · score.per_item[1] × 3 模型
ox-alphaGLM-5VKimi K3
reading_extracted 6.4 (模型几何解算得 6.42,按最小刻度 0.1 就近取 6.4) 0.0 (⚠ 脚本提取记录为 0.0;回复原文写的是 6.6——两者不一致,照录不补算) null (⚠ 脚本提取记录为 null;回复原文写明 6.5——两者不一致,照录不补算)
truth6.5 (unit m3,最小刻度 0.1 · 三份 JSON 相同)
读数相对误差 ↓0.015 (|6.4 − 6.5| = 0.1,恰 1 个最小刻度)1.01.0
parser可运行_近似1.0 (启发式签名调用成功,近似判定)0.00.0
usage p 453 / c 7898 (total 8351 tok · cost 0 · finish_reason "stop") p 402 / c 2878 (reasoning 1702 · cost 0.0119944) p 449 / c 6759 (reasoning 6149 · cost 0.1025592)
RESPONSE stealth/ox-alpha · 模型回复原文刻度系统 → 读数 6.4 m³ → 解析程序 → JSON
## 1. 刻度系统

| 项目 | 内容 |
|---|---|
| 类型 | 模拟指针式水表表盘 |
| 量程 | 0 – 10 |
| 单位 | m³ |
| 最小刻度 | 0.1 m³ |
| 倍率 | 无(×1) |
| 圈数 | 单圈(扫角约 268°,0 在左下、10 在右下,顺时针递增) |
| 副尺/游标 | 无 |
| 线性 | 均匀刻度,每 1 m³ ≈ 26.8° |

## 2. 读数

- 指针位于 **6 与 7 之间**,落在 6.4 与 6.5 分度之间、略靠近 6.4。
- 几何解算:指针角 ≈ 51°,零点方向 ≈ 224°,满量程方向 ≈ −45°,每单位 26.8° → (224−51)/26.8 ≈ **6.42**。
- 按最小刻度 0.1 就近取整分度 → **示数 = 6.4 m³**。

## 3. 刻度解析程序

```python
import math

def read_gauge(tip, center, zero_pt, full_pt, calib=None,
               full_scale=10.0, turns=1, nd=1):
    """tip/center/zero_pt/full_pt: (x,y) 屏幕像素坐标(y向下)
    calib: [(value, angle_deg)] 非线性刻度标定表(可选, 角度为数学角/y向上)
    turns: 多圈表圈数; 返回读数"""
    ang = math.degrees(math.atan2(center[1]-tip[1], tip[0]-center[0]))
    if calib:                                  # 非线性刻度: 按标定表分段插值
        c = sorted(calib, key=lambda p: p[1])
        for (va, aa), (vb, ab) in zip(c, c[1:]):
            if aa <= ang <= ab:
                return round(va + (ang-aa)/(ab-aa)*(vb-va), nd)
        raise ValueError("指针角度超出标定量程")
    az = math.degrees(math.atan2(center[1]-zero_pt[1], zero_pt[0]-center[0]))
    af = math.degrees(math.atan2(center[1]-full_pt[1], full_pt[0]-center[0]))
    span = (az - af) % 360 or 360              # 顺时针总扫角(自动处理跨0°/180°)
    rel  = (az - ang) % 360                    # 指针相对零点的顺时针角
    return round(rel / span * full_scale * turns, nd)   # turns>1 即多圈表

# 本表实测参数: 中心(211,236), 0刻度(123,321), 满量程(295,321), 指针尖(296,132)
print(read_gauge((296,132), (211,236), (123,321), (295,321)))   # -> 6.4
```

**适用性说明**:
- **非线性刻度**:本表刻度均匀(各整数间距角度相等),走线性分支即可;`calib` 分支保留用于非均匀表盘(如对数/压缩刻度),本表**不适用也无需使用**。
- **多圈表**:本表为单圈(0→10 仅一次扫过),`turns=1`。真正的多圈水表单帧无法分辨当前是第几圈,须依赖字轮计数器或连续帧累计转角,此处不涉及。

## 输出 JSON

```json
{
  "scale": {
    "type": "analog_pointer_dial",
    "application": "water_meter",
    "range": {"min": 0, "max": 10},
    "unit": "m3",
    "min_division": 0.1,
    "decimal_places": 1,
    "multiplier": 1,
    "turns": 1,
    "sub_scale_or_vernier": false,
    "linearity": "linear_uniform",
    "sweep_angle_deg": 268.4,
    "deg_per_unit": 26.84
  },
  "reading": {
    "value": 6.4,
    "unit": "m3",
    "geometric_raw": 6.42,
    "rule": "指针位于6.4与6.5分度之间且更靠近6.4,按最小刻度0.1就近取值"
  },
  "parser": "import math\n\ndef read_gauge(tip, center, zero_pt, full_pt, calib=None, full_scale=10.0, turns=1, nd=1):\n    ang = math.degrees(math.atan2(center[1]-tip[1], tip[0]-center[0]))\n    if calib:\n        c = sorted(calib, key=lambda p: p[1])\n        for (va, aa), (vb, ab) in zip(c, c[1:]):\n            if aa <= ang <= ab:\n                return round(va + (ang-aa)/(ab-aa)*(vb-va), nd)\n        raise ValueError('needle out of calibrated range')\n    az = math.degrees(math.atan2(center[1]-zero_pt[1], zero_pt[0]-center[0]))\n    af = math.degrees(math.atan2(center[1]-full_pt[1], full_pt[0]-center[0]))\n    span = (az - af) % 360 or 360\n    rel = (az - ang) % 360\n    return round(rel / span * full_scale * turns, nd)\n\n# 本表: print(read_gauge((296,132),(211,236),(123,321),(295,321))) -> 6.4\n# 注: 本表为线性单圈刻度, 非线性calib分支与多圈turns分支在此不适用;\n#     多圈表单帧无法确定圈数, 需字轮计数器或连续帧累计。"
}
```
usage:prompt 453 tok · completion 7898 tok · total 8351 · cost 0 · finish_reason "stop"
RESPONSE z-ai/glm-5v-turbo · 模型回复原文纯 JSON 输出:scale → reading(6.6 m³)→ parser(Python)
```json
{
  "scale": {
    "range": "0 - 10",
    "min_division": 0.1,
    "unit": "m³ (立方米)",
    "multiplier": "无 (1:1)",
    "multi_turn": false,
    "vernier": false,
    "description": "该表为单圈线性刻度仪表,量程0到10,最小分度值为0.1m³,无副尺及倍率装置。"
  },
  "reading": {
    "value": 6.6,
    "unit": "m³",
    "estimation_detail": "指针位于刻度 6 与 7 之间。根据标注的最小刻度 0.1 进行估读,指针位置约越过刻度 6 后第 6 个小格左右(或在 6.5 至 7.0 之间的 60% 位置),故读数约为 6.6 m³(合理估读范围应在 6.55 ~ 6.65 之间)。"
  },
  "parser": "import math

def parse_analog_meter(pointer_end, center, zero_pos, max_pos, 
                       min_val=0, max_val=10, 
                       calibration_points=None, 
                       is_multi_turn=False, current_turn=0):
    """
    通用仪表刻度解析程序
    
    参数:
        pointer_end: (x, y) 指针尖端像素坐标
        center: (x, y) 表盘中心像素坐标
        zero_pos: (x, y) 零刻度(或起始刻度)参考坐标
        max_pos: (x, y) 满量程(或终止刻度)参考坐标
        min_val: 量程最小值 (默认0)
        max_val: 量程最大值 (默认10)
        calibration_points: [(angle, value), ...] 用于非线性刻度的校准点列表
        is_multi_turn: 是否为多圈表 (布尔值)
        current_turn: 当前圈数 (若为多圈表)
        
    返回:
        float: 读取的数值
    """
    
    def get_angle(p1, p2):
        """计算两点间向量与水平正方向的夹角(弧度, 范围 -pi 到 pi)"""
        return math.atan2(p2[1] - p1[1], p2[0] - p1[0])
    
    # 1. 计算关键角度
    angle_pointer = get_angle(center, pointer_end)
    angle_zero = get_angle(center, zero_pos)
    angle_max = get_angle(center, max_pos)
    
    # 2. 处理角度跨越 -pi/pi 边界的情况 (确保顺时针或逆时针计算正确)
    # 假设刻度是从 zero_pos 扫向 max_pos
    if angle_max < angle_zero:
        angle_max += 2 * math.pi
    if angle_pointer < angle_zero:
        angle_pointer += 2 * math.pi
        
    total_sweep = angle_max - angle_zero
    pointer_offset = angle_pointer - angle_zero
    
    # 限制指针在量程范围内
    pointer_offset = max(0, min(pointer_offset, total_sweep))
    
    # 3. 数值映射
    ratio = pointer_offset / total_sweep if total_sweep != 0 else 0
    
    # --- 处理非线性刻度 ---
    if calibration_points:
        # 使用样条插值或分段线性插值处理非线性关系
        # 此处简化为基于校准点的最近邻/线性插值逻辑
        sorted_points = sorted(calibration_points, key=lambda x: x[0])
        # 实际实现需引入 scipy.interpolate 或手动二分查找插值
        value = min_val + ratio * (max_val - min_val) # Fallback to linear if no lib
        print("Warning: Non-linear calibration requested but not fully implemented in this snippet.")
    else:
        # --- 标准线性刻度 (适用于本水表) ---
        value = min_val + ratio * (max_val - min_val)
    
    # --- 处理多圈表 ---
    if is_multi_turn:
        value += current_turn * (max_val - min_val)
        
    return round(value, 2) # 保留两位小数以反映估读位

# 本表适用性说明:
# 本表(WATER_METER_NEEDLE)属于标准的单圈线性刻度仪表。
# 因此调用时 is_multi_turn=False, calibration_points=None 即可准确读数。
# 示例调用逻辑:
# reading = parse_analog_meter(
#     pointer_end=(px_ptr_x, px_ptr_y), 
#     center=(px_cen_x, px_cen_y), 
#     zero_pos=(px_0_x, px_0_y), 
#     max_pos=(px_10_x, px_10_y),
#     max_val=10
# )"
}
```
usage:prompt 402 tok · completion 2878 tok(reasoning 1702)· total 3280 · cost 0.0119944 · finish_reason "stop"
RESPONSE moonshotai/kimi-k3 · 模型回复原文单行 JSON:scale → reading 6.5 → parser(Python);脚本未提取到读数
{"scale":{"range":"0-10","min_division":0.1,"unit":"m3","multiplier_or_multiturn":"none; single partial dial (about 270 deg arc from 0 to 10), no multiplier marked","subscale":"none","note":"Pointer tip falls between the 6 and 7 ticks. The dial is angularly uniform, so I interpolate linearly by central angle and estimate to the 0.1 division: pixel geometry gives about 6.50, rounded to the 0.1 division = 6.5 m3."},"reading":6.5,"parser":"import math\ndef ang(p,c):\n    return math.degrees(math.atan2(p[1]-c[1],p[0]-c[0]))%360.0\ndef read(P,C,calib,dir=1,turns=0,span=None,nonlinear=False):\n    # calib=[(value,point),...] ascending; first/last are range endpoints. dir=1: value increases clockwise in screen coords (y down).\n    vs=[v for v,_ in calib]; aa=[ang(q,C) for _,q in calib]; a0=aa[0]\n    def rel(a):\n        return ((a-a0)%360.0) if dir>0 else ((a0-a)%360.0)\n    if span is None:\n        span=rel(aa[-1]); span=360.0 if span==0 else span\n    s=rel(ang(P,C))\n    if span<360.0 and turns:\n        raise ValueError('partial dial: no extra turns')\n    if s>span:\n        s=span\n    if not nonlinear:\n        return vs[0]+(vs[-1]-vs[0])*(s/span)+turns*(vs[-1]-vs[0])\n    xs=[rel(a) for a in aa]\n    import numpy as np\n    return float(np.interp(s,xs,vs))\n# This meter: dir=1, calib=[(0,p0),(10,p10)] or all integer ticks, span auto=270 deg, turns=0, nonlinear=False. Nonlinear branch does monotone interpolation over calibration ticks; multi-turn branch requires a full 360-deg dial plus a turns count from a subdial/counter and adds (vmax-vmin) per turn. Not applicable here: 0-10 m3 is one uniform 270-deg arc, angle-vs-value is linear, and there is no subscale/multiplier, so turns=0 and nonlinear=False and those branches never trigger."}
usage:prompt 449 tok · completion 6759 tok(reasoning 6149)· total 7208 · cost 0.1025592 · finish_reason "stop"
样本 3 / 3 calipers 真值 reading = 23.45 mm(最小刻度 1)· 三模型同图同 prompt ox-alpha:读数 23.5 · 误差 0.002 GLM-5V:读数 23.0 · 误差 0.019 Kimi K3:读数 23.8 · 误差 0.015
真实输入样本:直线均匀刻度尺,量程 0–45,红色指针位于 23 与 24 之间
真实输入图片 · data/C18/images/calipers.png
truth:reading = 23.45 · unit = mm · min_division = 1(三个模型的输入完全相同)
脚本打分对照 · score.per_item[2] × 3 模型
ox-alphaGLM-5VKimi K3
reading_extracted 23.5 (模型像素内插 (255−18)/(471−18)×45 ≈ 23.5) 23.0 (模型称「指针正对刻度线 23」,估读位记 0) 23.8 (模型目测为 23 与 24 间约 0.8 个分度处)
truth23.45 (unit mm,最小刻度 1 · 三份 JSON 相同)
读数相对误差 ↓0.0020.0190.015
parser可运行_近似1.0 (启发式签名调用成功,近似判定)0.0 (该条交出的 parser 为 JavaScript 实现)1.0
usage p 336 / c 6138 (total 6474 tok · cost 0 · finish_reason "stop") p 210 / c 1999 (reasoning 1526 · cost 0.008248) p 370 / c 23140 (reasoning 22015 · cost 0.34821)
RESPONSE stealth/ox-alpha · 模型回复原文直线均匀刻度尺 → 读数 23.5 → 解析程序 → JSON
## 1. 刻度系统

| 项目 | 内容 |
|---|---|
| 类型 | 直线式均匀刻度尺 |
| 量程 | 0 ~ 45 |
| 最小刻度(分度值) | **1**(每 5 个大格间均分 5 小格) |
| 单位 | 图中未标注(无量纲,视具体仪器而定) |
| 倍率 | 无(×1) |
| 多圈 | 否(单向直尺,无回绕) |
| 副尺(游标) | 无 |

## 2. 读数

- 红色指针位于 **23 与 24 两条刻线之间**,且无副尺可精读。
- 取值方法:按“最小分度的 1/10”估读,即在相邻两刻线间做**线性内插**。
- 像素标定:0 刻线 ≈ x=18 px,45 刻线 ≈ x=471 px,红线 ≈ x=255 px:
  (255−18)/(471−18)×45 ≈ **23.5**
- 正确小数位:分度值为 1,应估读到 **0.1 位** → **读数 ≈ 23.5**(不确定度约 ±0.2)

## 3. 刻度解析程序

支持线性/非线性刻度与多圈表;本表为**均匀线性、单圈、无副尺**,故非线性分支与多圈分支在此不适用(代码中保留以通用,并注明原因)。

```json
{
  "scale": {
    "type": "linear_ruler",
    "range": [0, 45],
    "smallest_division": 1,
    "unit": null,
    "multiplier": 1,
    "multi_turn": false,
    "vernier": false,
    "major_tick_step": 5,
    "minor_ticks_per_major": 5
  },
  "reading": {
    "value": 23.5,
    "decimals": 1,
    "pointer_between": [23, 24],
    "method": "无副尺,指针落在两刻线之间,按分度值1的1/10线性内插估读",
    "pixel_calibration": {"zero_px": 18, "full_px": 471, "pointer_px": 255},
    "uncertainty": 0.2
  },
  "parser": "import bisect\n\ndef parse_pointer(pointer_px, calib, kind='linear', total_turns=1, transform=None):\n    \"\"\"\n    pointer_px  : 指针端点像素坐标(沿刻度方向的一维坐标)\n    calib       : [(value, px), ...] 标定表, 至少两个已知刻度点\n    kind        : 'linear'=均匀线性刻度; 'nonlinear'=非线性刻度\n    total_turns : 总圈数(=1 单圈; >1 多圈表)\n    transform   : 非线性刻度正向函数 y=f(value), 如平方刻度 f=lambda v: v*v\n    \"\"\"\n    calib = sorted(calib, key=lambda t: t[1])\n    pxs  = [p for _, p in calib]\n    vals = [float(v) for v, _ in calib]\n\n    def interp(x, xs, ys):\n        if x <= xs[0]:\n            return ys[0] + (ys[1]-ys[0])*(x-xs[0])/(xs[1]-xs[0])\n        if x >= xs[-1]:\n            return ys[-1] + (ys[-1]-ys[-2])*(x-xs[-1])/(xs[-1]-xs[-2])\n        i = bisect.bisect_right(xs, x) - 1\n        return ys[i] + (ys[i+1]-ys[i])*(x-xs[i])/(xs[i+1]-xs[i])\n\n    if kind == 'linear':\n        value = interp(pointer_px, pxs, vals)          # 两点即为精确直线\n    else:\n        if transform is None:\n            raise ValueError('非线性刻度必须提供 transform=f(value)')\n        ys = [transform(v) for v in vals]\n        yp = interp(pointer_px, pxs, ys)               # 先插值出 f(v_pointer)\n        lo, hi = min(vals), max(vals)\n        for _ in range(100):                           # 二分反解 f(v)=yp\n            mid = 0.5*(lo+hi)\n            if transform(mid) < yp: lo = mid\n            else: hi = mid\n        value = 0.5*(lo+hi)\n\n    if total_turns > 1:\n        # 多圈表: 单个像素坐标在一圈内回绕, 绝对读数还须圈计数信号;\n        # 若调用方已将多圈展开为连续像素坐标, 则此处直接返回展开值。\n        span = max(vals) - min(vals)\n        return {'within_turn': round(value % span, 1),\n                'note': '绝对读数 = within_turn + k*span, k 需圈计数信号'}\n    return round(value, 1)\n\n# ---- 本表调用示例 ----\ncalib = [(0, 18), (45, 471)]   # 0刻线与45刻线的像素位置\nprint(parse_pointer(255, calib))   # -> 23.5\n# 适用性说明: 本表为均匀线性单圈直尺, 无副尺,\n# 故 kind='nonlinear' 与 total_turns>1 分支对本表不适用, 仅作通用保留。"
}
```
usage:prompt 336 tok · completion 6138 tok · total 6474 · cost 0 · finish_reason "stop"
RESPONSE z-ai/glm-5v-turbo · 模型回复原文纯 JSON 输出:scale → reading(23.0)→ parser(JavaScript)
```json
{
  "scale": {
    "range": "0 ~ 45",
    "min_division": 1,
    "unit": "未标注 (通用单位)",
    "multiplier_or_multi_turn": false,
    "has_vernier": false
  },
  "reading": {
    "value": 23.0,
    "explanation": "指针准确指向刻度 23 的位置。该仪表最小刻度为 1,根据物理测量读数规范,需估读到最小刻度的下一位(即 0.1 位)。由于指针正对刻度线,估读位记为 0,最终示数为 23.0。"
  },
  "parser": "function parseScale(pointerX, xStart, xEnd, valStart, valEnd, isNonLinear = false, turnCount = 1) {
  const pixelSpan = xEnd - xStart;
  const valueSpan = valEnd - valStart;
  const ratio = (pointerX - xStart) / pixelSpan;

  let baseValue;
  if (isNonLinear) {
    // 若为非线性刻度(如欧姆表、对数刻度),需通过标定点的查找表(LUT)或拟合曲线函数计算
    baseValue = applyNonLinearMapping(ratio); 
  } else {
    // 本表为线性刻度,采用线性插值
    baseValue = valStart + ratio * valueSpan;
  }

  // 处理多圈表盘(如某些万用表、计时器)
  if (turnCount > 1) {
    baseValue += (turnCount - 1) * valueSpan;
  }

  // 模拟人工读数:四舍五入到最小刻度的下一位 (此处假设保留1位小数)
  return Math.round(baseValue * 10) / 10;
}
// 本表调用示例: parseScale(指针像素X, 刻度0像素X, 刻度45像素X, 0, 45)"
}
```
usage:prompt 210 tok · completion 1999 tok(reasoning 1526)· total 2209 · cost 0.008248 · finish_reason "stop"
RESPONSE moonshotai/kimi-k3 · 模型回复原文JSON 输出:scale → reading(23.8)→ parser(Python,含 Dial 类)
```json
{
  "scale": {
    "range": [0, 45],
    "min_division": 1,
    "unit": "图中未印出单位;按刻度单位读出(若实物为毫米直尺/卡尺主尺,则单位为 mm,数值不变)",
    "multiplier_or_multiturn": "无倍率、无多圈:单一、直线、均匀量程",
    "vernier": "无副尺(无游标),仅主尺加指针"
  },
  "reading": {
    "value": 23.8,
    "unit": "刻度单位",
    "decimal_places": 1,
    "method": "相邻数字刻度相差 5,其间 5 等分,故最小分度=1;模拟仪表再估读 1 位到 0.1。红线尖端在 20–25 大格内落在分度线 23 与 24 之间、约 0.8 个分度处(更靠近 24),故取 23+0.8≈23.8;若按最近分度线取舍则为 24。通用规则:指针在两分度之间时,读数=较小分度值+目测该分度的小数部分,且只估读到最小分度的下一位(本表即 0.1)。"
  },
  "parser": "import math\n\ndef _t_on_line(p, a, b):\n    ax, ay = a; bx, by = b; px, py = p\n    dx, dy = bx - ax, by - ay\n    return ((px - ax) * dx + (py - ay) * dy) / (dx * dx + dy * dy)\n\ndef read_linear(p, end0, v0, end1, v1):\n    # 本表:直线均匀刻度 -> 仿射映射;投影可吸收指针尖端相对刻度线的垂直偏移\n    t = _t_on_line(p, end0, end1)\n    return v0 + t * (v1 - v0)\n\ndef _lerp_table(x, xs, ys):\n    # 非线性刻度:用单调分段线性穿过标定点(xs 升序);点足够密或改用 PCHIP/三次样条\n    if x <= xs[0]: return ys[0]\n    if x >= xs[-1]: return ys[-1]\n    lo, hi = 0, len(xs) - 1\n    while hi - lo > 1:\n        m = (lo + hi) // 2\n        if xs[m] <= x: lo = m\n        else: hi = m\n    x0, x1 = xs[lo], xs[hi]; y0, y1 = ys[lo], ys[hi]\n    return y0 + (y1 - y0) * (x - x0) / (x1 - x0)\n\nclass Dial:\n    # center=(cx,cy);calib=[(deg_in_turn,value),...] 按 deg 升序、覆盖一圈;\n    # per_turn=每整圈增加值(None 表示单圈表)。\n    def __init__(self, center, calib, per_turn=None):\n        self.c = center\n        self.a = [d for d, v in calib]\n        self.v = [v for d, v in calib]\n        self.per_turn = per_turn\n        self.prev = None; self.cum = 0.0\n    def _deg(self, p):\n        return math.degrees(math.atan2(p[1] - self.c[1], p[0] - self.c[0])) % 360.0\n    def read(self, p, turns=None):\n        a = self._deg(p)\n        if turns is None:                 # 多圈:按连续性解缠绕累计转角\n            if self.prev is None: self.cum = a\n            else: self.cum += (a - self.prev + 180.0) % 360.0 - 180.0\n            self.prev = a\n            n = math.floor(self.cum / 360.0); within = self.cum - 360.0 * n\n        else:                             # 或显式给定圈数(粗针/齿轮)\n            n, within = turns, a\n        base = _lerp_table(within, self.a, self.v)   # 单圈内非线性插值\n        return base if self.per_turn is None else base + n * self.per_turn\n\n# 本仪表调用:value = read_linear(p=(x_red,y_red), end0=(x_at_0,y), v0=0, end1=(x_at_45,y), v1=45)\n# 非线性与多圈分支对本表不适用:刻度为直线、均匀、单量程,无旋转中心/表盘、无圈数累计、无游标,\n# 示值仅是指针 x 坐标的仿射函数,因此只走 read_linear 分支。"
}
```
usage:prompt 370 tok · completion 23140 tok(reasoning 22015)· total 23510 · cost 0.34821 · finish_reason "stop"
案例级汇总 · score.读数相对误差_均值(↓ 越低越好 · 轴 0–1)
stealth/ox-alpha0.006
z-ai/glm-5v-turbo0.673
moonshotai/kimi-k30.338

ox-alpha 三条样本全部有完整正文且误差都很小(0.0 / 0.015 / 0.002,均值 0.006);glm-5v-turbo 的 0.673 由两条被脚本提取为 0.0 的样本主导(其回复原文分别写明 2.25 / 6.6);kimi-k3 的 0.338 来自 water_meter_needle 一条——脚本提取为 null 并按规则记满误差(其回复原文写明 6.5),另两条为 0.0 / 0.015。n=3 演示级样本,不构成排名依据。

样本truth指标 ox-alpha GLM-5V Kimi K3
pressure_gauge2.3 barreading_extracted2.30.0 ⚠(原文写 2.25)2.3
读数相对误差 ↓0.01.00.0
parser可运行_近似1.00.00.0
water_meter_needle6.5 m³reading_extracted6.40.0 ⚠(原文写 6.6)null ⚠(原文写 6.5)
读数相对误差 ↓0.0151.01.0
parser可运行_近似1.00.00.0
calipers23.45 mmreading_extracted23.523.023.8
读数相对误差 ↓0.0020.0190.015
parser可运行_近似1.00.0 (JavaScript 实现)1.0
读数相对误差_均值 ↓score 聚合值0.0060.6730.338

上表所有数字取自三份 results 文件的 score.per_item 与 score.读数相对误差_均值;score_error 三个模型均为 null,limitations 三份完全相同。三个模型均未产出 rubric 中「刻度系统描述(30 分)」「边界情况(10 分)」两个维度的得分,也没有 0–100 总分——页面照实呈现脚本算出的两个指标,不补算。

近似判定 parser可运行_近似 不是严格基准:三份 results 的 limitations 原文均为「模型生成代码的函数签名不受约束,沙箱用启发式多签名尝试调用,非严格基准」「样本规模为演示级缩小版(3),非原始题库规模」,且本次未做合成表盘回放验证(本机环境限制,近似执行而非严格基准)。本案例 parser 成功率:ox-alpha 3/3、kimi-k3 1/3、glm-5v-turbo 0/3。

本案例三模型差异要点:① stealth/ox-alpha 三条样本全部有完整回复且读数都准(2.3 / 6.4 / 23.5,误差 0.0 / 0.015 / 0.002),其中 pressure_gauge 精确命中真值;② moonshotai/kimi-k3 在 pressure_gauge 上同样精确命中 2.30、calipers 读 23.8,但 water_meter_needle 的回复原文虽写明 6.5,脚本却记录 reading_extracted = null 并按规则记满误差 1.0(与回复原文不一致,照录不补算);③ z-ai/glm-5v-turbo 三条都有正文,但 pressure/water 两条被脚本提取为 0.0(其回复原文分别写明 2.25 / 6.6),仅 calipers 一条提取一致(23.0);④ 对同一根水表指针,ox 读 6.4、glm 读 6.6、k3 原文写 6.5——同一刻度系统下的取值判断本身就是分歧点。