#!/usr/bin/env python3
"""三引擎裸描述盲测报告页生成器。
- 逐图随机匿名(甲/乙/丙), 映射写 results/blind_map.json
- 页面元素: 大图 + 程序化事实锚点(details折叠) + 三张匿名描述卡 + 单选判定 + 揭晓按钮
"""
import json, base64, random, hashlib
from pathlib import Path

NL = Path('/home/kai/shared/qwen-eye-exp/nltest')
RES = NL / 'results'
OUT_DIR = Path('/home/kai/shared/2026-08-27')
OUT = OUT_DIR / 'nl-blind-test.html'

IMAGES = ['L1_ocr', 'L2_scene', 'L3_night', 'L4_grid']
IMG_TITLE = {
    'L1_ocr': ('L1 · 特写+文字', '难度① 主体特写带手写字条 —— 考 OCR 与微观细节'),
    'L2_scene': ('L2 · 中景场景', '难度② 单主体环境互动 —— 考方位与主体状态'),
    'L3_night': ('L3 · 夜景远景', '难度③ 低光远景多光源 —— 考光照与色温感知'),
    'L4_grid': ('L4 · 四格拼图', '难度④ 合成 2×2 多格 —— 考网格分解与逐格分析'),
}
ARMS = ['local', 'hosted', '46v']

def b64(p):
    return 'data:image/jpeg;base64,' + base64.b64encode(Path(p).read_bytes()).decode()

# ── 载入结果 ──
data = {a: json.loads((RES / f'nl_{a}.json').read_text()) for a in ARMS}
facts = json.loads((NL / 'facts.json').read_text())

# ── 盲测映射 (稳定随机: seed=内容哈希, 复现一致) ──
random.seed(hashlib.sha256(json.dumps({k: sorted(v) for k, v in facts.items()}, sort_keys=True).encode()).hexdigest())
blind_map = {}
for img in IMAGES:
    arms_shuffled = ARMS[:]
    random.shuffle(arms_shuffled)
    blind_map[img] = dict(zip(['甲', '乙', '丙'], arms_shuffled))
(RES / 'blind_map.json').write_text(json.dumps(blind_map, ensure_ascii=False, indent=1))

DIM_LABELS = [
    ('overall', '整体概览'), ('subject', '主体'), ('layout', '方位布局'),
    ('colors', '颜色'), ('lighting', '光照'), ('composition', '构图'),
    ('text', '文字转录'), ('defects', '缺陷检查'),
]

def fact_html(name):
    f = facts[name]
    doms = ' '.join(f"<span class='sw' style='background:{c}'></span>{c} <span class='muted'>{round(p*100)}%</span>"
                    for c, p in [(d[0], d[1]/100) for d in f['dominant_colors']])
    extra = ''
    if name == 'L4_grid':
        extra = f"<p style='margin:4px 0'><b>真实拼图顺序:</b> {facts['_grid_truth']}</p>"
    return f"""<div>
<p style='margin:4px 0'><b>平均亮度:</b> {f['avg_brightness']}/255</p>
<p style='margin:4px 0'><b>尺寸:</b> {f['size'][0]}×{f['size'][1]}</p>
<p style='margin:8px 0 2px'><b>程序化主色 (PIL 自适应调色板):</b><br>{doms}</p>{extra}</div>"""

cards_all = ''
for idx, img in enumerate(IMAGES):
    title, sub = IMG_TITLE[img]
    label_of = blind_map[img]          # {'甲': arm, ...}
    descs = []
    for i, lbl in enumerate(['甲', '乙', '丙']):
        arm = label_of[lbl]
        rec = data[arm][img]
        dims_html = ''
        if rec.get('dims'):
            rows = ''
            for key, zh in DIM_LABELS:
                val = str(rec['dims'].get(key) or '').strip()
                if not val:
                    val = '<span class="muted">(未作答)</span>'
                rows += f"<tr><td class='dim-td'>{zh}</td><td>{val}</td></tr>"
            dims_html = f"<table class='dims'>{rows}</table>"
        else:
            dims_html = f"<pre class='raw'>解析失败原始输出:\n{rec.get('raw','')[:800]}</pre>"
        descs.append(f"""
<div class='desc-card' id='{img}-{i}'>
  <div class='desc-head badge-{i}'>{lbl} 号引擎</div>
  {dims_html}
</div>""")
    cards_all += f"""
<h2 class='sec-title'>{title}</h2>
<p class='muted' style='font-size:13px'>{sub}</p>
<div class='imgbox'><img src="{b64(NL / f'images/{img}.jpg')}" alt='{img}'></div>
<div class='row'>
  <div class='left'>{ ''.join(descs) }</div>
  <div class='right'>
    <div class='judge-card'>
      <h3>🧑‍⚖️ 你的裁决</h3>
      <p style='font-size:12.5px' class='muted'>对照原图读完三份描述后，选出你心中最强的引擎：</p>
      {''.join(f"<label class='opt'><input type='radio' name='win_{img}' value='{l}' onchange=\"save('{img}','{l}')\"> {l} 号引擎</label>" for l in ['甲','乙','丙'])}
      <button onclick="reveal('{img}')">揭晓甲乙丙是谁</button>
      <div id='verdict-{img}' class='verdict'></div>
      <details style='margin-top:10px'>
        <summary style='cursor:pointer;font-size:12.5px;color:#79b8ff'>📐 程序化事实锚点（判完再看）</summary>
        <div style='font-size:12.5px;margin-top:6px'>{fact_html(img)}</div>
      </details>
    </div>
  </div>
</div>"""

ARM_NAME = {'local': 'qwen-eye :8125', 'hosted': 'glm-5.3-flash', '46v': 'glm-4.6v'}
map_rows = ''
for k in IMAGES:
    tds = ''.join(f"<td>{lbl} = <b>{ARM_NAME[blind_map[k][lbl]]}</b></td>" for lbl in ['甲', '乙', '丙'])
    map_rows += f"<tr><td>{IMG_TITLE[k][0]}</td>{tds}</tr>"

SCRIPT_BLOCK = '''<script>
const MAP = __MAP_JSON__;
const NAME = {'local':'qwen-eye (本地)','hosted':'glm-5.3-flash (托管)','46v':'glm-4.6v (托管)'};
function save(img, choice) {
  const picks = JSON.parse(localStorage.getItem('nl_picks')||'{}');
  picks[img]=choice; localStorage.setItem('nl_picks',JSON.stringify(picks)); refresh();
}
function reveal(img) {
  const m = MAP[img];
  const v = document.getElementById('verdict-'+img);
  v.innerHTML = Object.entries(m).map(function(e){return '<div>'+e[0]+' = <b>'+NAME[e[1]]+'</b></div>';}).join('');
  v.classList.add('show');
}
function refresh() {
  const picks = JSON.parse(localStorage.getItem('nl_picks')||'{}');
  const ks = Object.keys(picks);
  document.getElementById('scoreN').textContent = ks.length;
  document.getElementById('scoreList').textContent =
    ks.map(function(k){return k.split('_')[0]+':'+picks[k];}).join(' · ') || '尚无';
  document.querySelectorAll("input[type=radio]").forEach(function(r){
    r.checked = picks[r.name.replace('win_','')]===r.value;
  });
}
refresh();
</script>'''

html = f"""<!DOCTYPE html>
<html lang="zh"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>三引擎视觉描述盲测 · Kai 裁判局</title>
<style>
*{{margin:0;padding:0;box-sizing:border-box}}
body{{background:#0f0f1e;color:#e0e0e0;font-family:-apple-system,'PingFang SC',sans-serif;line-height:1.55}}
.wrap{{max-width:1280px;margin:0 auto;padding:20px}}
h1{{font-size:23px}} .sub{{color:#aabbcc;font-size:13px}}
.rule{{background:#14142a;border:1px dashed #30475e;border-radius:10px;padding:14px 16px;margin:14px 0;font-size:13.5px;color:#c9d1d9}}
.sec-title{{margin:34px 0 6px;font-size:19px;border-bottom:1px solid #2a2a4e;padding-bottom:6px}}
.imgbox img{{width:100%;border-radius:10px;border:1px solid #2a2a4e;display:block}}
.row{{display:grid;grid-template-columns:minmax(0,1fr) 300px;gap:14px;margin-top:12px}}
.left{{display:flex;flex-direction:column;gap:12px}}
.desc-card{{background:#16162b;border:1px solid #2a2a4e;border-radius:10px;padding:12px 14px}}
.badge-0{{background:#1e3a5f}} .badge-1{{background:#3a2e1e}} .badge-2{{background:#274027}}
.desc-head{{display:inline-block;padding:3px 12px;border-radius:99px;font-weight:700;font-size:13px;margin-bottom:8px}}
table.dims{{width:100%;border-collapse:collapse;font-size:12.8px}}
table.dims td{{border-top:1px dashed #26264a;padding:6px 6px;vertical-align:top}}
td.dim-td{{white-space:nowrap;color:#79b8ff;font-weight:600;width:72px}}
.right{{position:sticky;top:14px;align-self:start}}
.judge-card{{background:#1a1a2e;border:1px solid #e94560;border-radius:10px;padding:14px}}
.opt{{display:block;padding:7px 10px;margin:4px 0;background:#22223f;border-radius:8px;cursor:pointer;font-size:14px}}
.opt:hover{{background:#2b2b52}}
.judge-card button{{margin-top:10px;width:100%;padding:9px;border:none;border-radius:8px;background:#1f6feb;color:#fff;font-size:13.5px;cursor:pointer;font-weight:600}}
.verdict{{margin-top:10px;font-size:13px;display:none;background:#101024;border-radius:8px;padding:10px}}
.verdict.show{{display:block}}
.sw{{display:inline-block;width:11px;height:11px;border-radius:3px;margin-right:3px;vertical-align:-1px;border:1px solid #444}}
.muted{{color:#667788}}
.raw{{white-space:pre-wrap;font-size:12px;color:#aabbcc}}
.scorebar{{position:sticky;top:0;z-index:50;background:#161b22ee;border-bottom:1px solid #30363d;padding:10px 20px;display:flex;gap:16px;align-items:center;font-size:13.5px;backdrop-filter:blur(6px)}}
.scorebar b{{color:#FFD54F}}
footer{{margin:30px 0 10px;color:#667788;font-size:12px;text-align:center}}
</style></head><body>

<div class='scorebar'>⚖️ 已裁定: <span id='scoreN'>0</span>/4 张 · 你选中的赢家: <span id='scoreList' class='muted'>尚无</span></div>
<div class='wrap'>
<h1>👁‍🗨 三引擎视觉描述盲测 · 裁判局</h1>
<div class='sub'>2026-08-27 · qwen-eye(:8125 本地) vs glm-5.3-flash vs glm-4.6v · 同图同 prompt(temp 0.1) · 八维强制框架</div>
<div class='rule'>
<b>玩法</b>: 四张复杂度递进的图片，三个引擎各自输出完整八维描述（整体/主体/方位/颜色/光照/构图/文字/缺陷），已随机匿名为<b>甲/乙/丙</b>。
每张图读完描述直接点选你认为的最强者，最后「揭晓」对照。所有描述均为引擎原生输出，<b>零剪辑零代笔</b>；
programmatic 锚点（亮度/主色 PIL 实测）供你交叉核对颜色与光照维度是否瞎编。<br>
<span class='muted'>参考背景: 三臂此前在「spec 对照验收」协议下的表现——4.6v 松判全放行 / flash 严判 / eye 高确定性偏严——但本页考的是裸描述能力，忘掉旧结论大胆判。</span>
</div>
{cards_all}

<h2 class='sec-title'>🔑 揭晓总表</h2>
<table style='width:100%;border-collapse:collapse;font-size:14px' border='1' cellpadding='8'>
<tr style='background:#16162b'><th>图片</th><th>甲</th><th>乙</th><th>丙</th></tr>
{map_rows}
</table>

<footer>协议: 四图统一 900/1236px JPEG 重采样 · prompt 全文见 ~/shared/qwen-eye-exp/nltest/nl_run.py · 数据 results/nl_*.json · 盲测映射 results/blind_map.json</footer>
</div>

__SCRIPT_BLOCK__
</body></html>"""

html = html.replace('__MAP_JSON__', json.dumps(blind_map, ensure_ascii=False))
html = html.replace('__SCRIPT_BLOCK__', SCRIPT_BLOCK)
OUT.write_text(html)
print(f"written {OUT} ({OUT.stat().st_size/1024:.0f} KB)")
print("blind_map:", json.dumps(blind_map, ensure_ascii=False))
