#!/usr/bin/env python3
"""四引擎同题匿名盲审页 (基于 image-pair-blind-review 模板扩四槽, kais-html §0 合规)
判读轴 = Ideogram精华: 中文招牌「龙轩咖啡」文字正确性 + 整体质感 → OCR 同视区切片强制
自检四件套: 键↔摆放字节比对 / 匿名泄漏断言 / node --check 语法门 / base64 非空
"""
import base64, io, json, os, random, re, shutil, subprocess, sys
from PIL import Image

# ============ CONFIG ============
OUT = "/home/kai/shared/2026-09-09/ideogram_blind4"
PAGE = OUT + "/blind_4arm.html"
QUADS = [  # (sid, case名, tier标签, guide, [图1..图4路径], 引擎名[揭晓区], 耗时s[揭晓区])
    ("C1", "深夜咖啡店", "统一prompt·seed9909·1360x768",
     "优先: ①中文招牌「龙轩咖啡」逐字正确性(先看切片) ②照片质感/氛围 ③构图与咖啡店元素完整",
     [OUT + "/s1_ideo.png", OUT + "/ks.png", OUT + "/zimg.png", OUT + "/qwen.png"],
     ["Ideogram4 (int8-convrot + Realism LoRA 0.8, 20步 res_multistep)", "Ks = Krea2 turbo (8步 euler cfg1.0)", "Z-Image turbo (9步 euler cfg1.0)", "Qwen-Image 2512 fp8 (20步 euler cfg2.5)"],
     [103.0, 55.0, 20.0, 684.6]),  # 实测history口径; Ideogram4臂=复用S1同底图(本身即该引擎seed9909产出)
    ("C2", "赛博唐人街", "0906盲测原prompt·seed9909·1360x768",
     "目标文字5处: 红色大招牌「龙轩酒家」· 蓝竖排「深淵茶館」· 粉色「小籠包」· 英文「NOODLE BAR 24H」。先逐招牌切片验字(中文错一笔即算错, 背景杂乱小招牌不计), 再评整图质感",
     [OUT + "/ideo_c1.png", OUT + "/ks_c1.png", OUT + "/zimg_c1.png", OUT + "/qwen_c1.png"],
     ["Ideogram4 (int8-convrot + Realism LoRA 0.8, 20步 res_multistep)", "Ks = Krea2 turbo (8步 euler cfg1.0)", "Z-Image turbo (9步 euler cfg1.0)", "Qwen-Image 2512 fp8 (20步 euler cfg2.5)"],
     [105.1, 110.0, 25.0, 145.0]),
]
SHUFFLE_SEED = 20260909
ANON_PREFIX = "bta4"
GUIDE_TEXT = "优先质量/文字正确性/细节质感"  # 兜底, 每题以 QUADS 的 guide 为准
CTL_SRC = OUT + "/ctl.png"
# =================================

B64_W, B64_Q = 1100, 82
CROP_W, CROP_Q = 720, 84

TIMING = {}
tp = OUT + "/render_timing.json"
if os.path.exists(tp):
    TIMING = json.load(open(tp))

def enc_b64(p, max_w=B64_W, q=B64_Q):
    im = Image.open(p).convert("RGB")
    if im.width > max_w:
        im = im.resize((max_w, int(im.height * max_w / im.width)), Image.LANCZOS)
    buf = io.BytesIO()
    im.save(buf, "JPEG", quality=q)
    return "data:image/jpeg;base64," + base64.b64encode(buf.getvalue()).decode()

OCR_SNIPPET = '''
import sys, json
from rapidocr_onnxruntime import RapidOCR
ocr = RapidOCR()
out=[]
res,_ = ocr(sys.argv[1])
if res:
    for box,text,conf in res:
        xs=[p[0] for p in box]; ys=[p[1] for p in box]
        out.append({"x0":float(min(xs)),"y0":float(min(ys)),"x1":float(max(xs)),
                    "y1":float(max(ys)),"t":str(text),"c":float(conf)})
print(json.dumps(out))
'''

def ocr_boxes(png):
    try:
        r = subprocess.run(["python3","-c",OCR_SNIPPET,png],capture_output=True,text=True,timeout=180)
        return json.loads(r.stdout.strip().splitlines()[-1])
    except Exception:
        return []

def text_regions(paths, max_n=2):
    """四图 OCR 盒并集 → 同一相对坐标区, 四槽共用于同视区切片"""
    W, H = Image.open(paths[0]).size
    regs = []
    for p in paths:
        for b in ocr_boxes(p):
            w, h = b["x1"]-b["x0"], b["y1"]-b["y0"]
            if h < H*0.018 or b["c"] < 0.45: continue
            regs.append({"rx": max(0.0, b["x0"]/W-0.05), "ry": max(0.0, b["y0"]/H-0.10),
                         "rw": min(1.0, w/W+0.10), "rh": min(1.0, h/H+0.20), "label": b["t"][:12]})
    regs.sort(key=lambda r: -(r["rw"]*r["rh"]))
    out = []
    for r in regs:
        if any(abs(r["rx"]-o["rx"]) < 0.06 and abs(r["ry"]-o["ry"]) < 0.06 for o in out): continue
        out.append(r)
        if len(out) >= max_n: break
    return out

def crop_b64(p, reg, max_w=CROP_W, q=CROP_Q):
    im = Image.open(p).convert("RGB")
    W, H = im.size
    box = (int(reg["rx"]*W), int(reg["ry"]*H),
           int(min(1,reg["rx"]+reg["rw"])*W), int(min(1,reg["ry"]+reg["rh"])*H))
    crop = im.crop(box)
    if crop.width > max_w:
        crop = crop.resize((max_w, int(crop.height*max_w/crop.width)), Image.LANCZOS)
    buf = io.BytesIO()
    crop.save(buf, "JPEG", quality=q)
    return "data:image/jpeg;base64," + base64.b64encode(buf.getvalue()).decode()

# ---------- 匿名化 + 槽位洗牌 ----------
rng = random.Random(SHUFFLE_SEED)
items, mapping, anon_quads = [], {}, []
for (sid, case, tier, guide, paths, engines, durs) in QUADS:
    if not all(os.path.exists(p) for p in paths):
        missing = [os.path.basename(p) for p in paths if not os.path.exists(p)]
        print(f"SKIP {sid} (missing: {missing})"); continue
    order = list(range(4))
    rng.shuffle(order)                       # order[pos] = 源引擎索引
    anon = []
    for pos in range(4):
        ap = f"{OUT}/{ANON_PREFIX}_{sid}_{pos+1}.png"
        shutil.copyfile(paths[order[pos]], ap)
        anon.append(ap)
    mapping[sid] = {f"slot{pos+1}": engines[order[pos]] for pos in range(4)}
    regs = text_regions(anon)
    slots = []
    for pos in range(4):
        slots.append({"img": enc_b64(anon[pos]),
                      "crops": [{"b64": crop_b64(anon[pos], r), "label": r["label"]} for r in regs]})
    items.append({"sid": sid, "case": case, "tier": tier, "guide": guide, "slots": slots})
    anon_quads.append((sid, [paths[i] for i in order], order))

if CTL_SRC and os.path.exists(CTL_SRC):
    ctl_b64 = enc_b64(CTL_SRC)
    items.append({"sid": "CTL", "case": "对照锚", "tier": "两侧同片", "slots": [{"img": ctl_b64, "crops": []}]*2})

# 揭晓区行(只在折叠区出现): 槽位→引擎 + 耗时
REVEAL_ROWS = {}
for (sid, case, tier, guide, paths, engines, durs) in QUADS:
    if sid not in mapping: continue
    rows = []
    for pos in range(4):
        eng = mapping[sid][f"slot{pos+1}"]
        src_note = " (复用本次S1同底图臂)" if "Ideogram4" in eng else ""
        d = durs[engines.index(eng)] if eng in engines else None
        dtxt = ""
        rows.append(f"• 图{pos+1} = <b>{eng}</b>{src_note}")
    rows.append("• 口径: 各臂为各引擎 0905/0906 验证过的现役配方; Ideogram4 臂即本次底图原始产出 (seed9909 同源)")
    REVEAL_ROWS[sid] = rows
DATA = {"items": items, "reveal": [{"sid": k, "rows": v} for k, v in REVEAL_ROWS.items()]}

JS = r"""
const DATA = __DATA__;
const SIDS = DATA.items.map(x=>x.sid);
const LS='quad_votes_bld4', LC='quad_cmt_bld4';
let V = JSON.parse(localStorage.getItem(LS)||'{}');
let C = JSON.parse(localStorage.getItem(LC)||'{}');
function save(){ localStorage.setItem(LS, JSON.stringify(V)); localStorage.setItem(LC, JSON.stringify(C)); paint(); }
function setG(sid,slot,g){ const k=sid+'_g'+slot; V[k]=(V[k]===g?undefined:g); if(!V[k]) delete V[k]; save(); }
function setStar(sid,slot){ const k=sid+'*'; if(V[k]===slot) delete V[k]; else V[k]=slot; save(); }
function setCtl(v){ V['CTL']=(V['CTL']===v?undefined:v); if(!V['CTL']) delete V['CTL']; save(); }
function buildSummary(){
  const GIDS=SIDS.filter(s=>s!=='CTL');
  const parts=SIDS.map(s=>{
    if(s==='CTL') return 'CTL='+(V['CTL']||'?');
    let g=[];
    for(let i=1;i<=4;i++){ if(V[s+'_g'+i]) g.push(i+V[s+'_g'+i]); }
    let t=s+': '+(g.length?g.join(' '):'?')+(V[s+'*']?(' ★图'+V[s+'*']):'');
    if(C[s]) t+='['+C[s].replace(/[\[=|]/g,' ').slice(0,80)+']';
    return t;});
  let s='盲审 '+parts.join(' | ');
  if(C['overall']) s+=' | 总评: '+C['overall'].replace(/[\[=|]/g,' ').slice(0,160);
  return s;
}
function updateBar(){ document.getElementById('sumbox').value=buildSummary(); }
function paint(){
  document.querySelectorAll('.gbtn').forEach(b=>{
    const on = V[b.dataset.sid+'_g'+b.dataset.slot]===b.dataset.g;
    b.classList.toggle('on', on); b.classList.toggle('g'+b.dataset.g, on);});
  document.querySelectorAll('.sbtn').forEach(b=>{ b.classList.toggle('on', V[b.dataset.sid+'*']==+b.dataset.slot);});
  document.querySelectorAll('.cbtn').forEach(b=>{ b.classList.toggle('on', V['CTL']===b.dataset.v);});
  DATA.reveal.forEach(r=>{
    const el=document.getElementById('rv_'+r.sid); if(!el) return;
    let html=r.rows.map(x=>'<div>'+x+'</div>').join('');
    for(let i=1;i<=4;i++){
      const g=V[r.sid+'_g'+i], st=V[r.sid+'*'];
      if(g||st===i) html+='<div class="myrv">图'+i+' 你评: <b class="hi-'+g+'">'+(g||'')+(st===i?'★最佳':'')+'</b></div>';
    }
    el.innerHTML=html;});
  const w=document.getElementById('ctlwarn'); if(w) w.style.display=(V['CTL']==='异')?'block':'none';
  updateBar();
}
function copySummary(){
  const t=buildSummary(), ta=document.getElementById('sumbox');
  const ok=()=>{ const b=document.getElementById('cpbtn'); const o=b.textContent; b.textContent='✅ 已复制'; setTimeout(()=>b.textContent=o,2500); };
  if((window.isSecureContext)&&navigator.clipboard&&navigator.clipboard.writeText){ navigator.clipboard.writeText(t).then(ok,ok); }
  else { ta.focus(); ta.select(); let good=false; try{good=document.execCommand('copy');}catch(e){} ok(); if(!good) alert('请手动全选文本框复制'); }
}
document.addEventListener('input',e=>{
  if(e.target.classList&&e.target.classList.contains('fb-comment')){
    const sid=e.target.dataset.sid; if(sid==='overall') C['overall']=e.target.value; else C[sid]=e.target.value;
    localStorage.setItem(LC,JSON.stringify(C)); updateBar(); paint();}});
function fsImg(img){ img.classList.toggle('big'); }
"""

CSS = """
*{margin:0;padding:0;box-sizing:border-box}
body{background:#0d1117;color:#c9d1d9;font-family:-apple-system,'PingFang SC',sans-serif;padding-bottom:120px}
.header{position:sticky;top:0;z-index:200;background:#161b22;border-bottom:1px solid #30363d;padding:12px 20px}
.header h1{font-size:17px}.header .sub{font-size:12px;color:#8b949e;margin-top:3px}
.wrap{max-width:1100px;margin:0 auto;padding:14px}
.quad{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin:10px 0}
@media(max-width:700px){.quad{grid-template-columns:1fr}}
.card{background:#161b22;border:1px solid #30363d;border-radius:10px;overflow:hidden}
.badge{display:inline-block;background:#1f6feb;color:#fff;font-size:11px;padding:1px 8px;border-radius:10px;margin-left:6px}
.card img{width:100%;display:block;cursor:zoom-in}
.card img.big{max-height:88vh;width:auto;max-width:100%;margin:0 auto;cursor:zoom-out;position:relative;z-index:300}
.fb{margin-top:10px;padding:10px 12px;background:#111a24;border:1px solid #23364a;border-radius:8px}
.fb-row{display:flex;gap:6px;align-items:center;flex-wrap:wrap}
.fb-label{font-size:12px;color:#8b949e}
.gbtn,.sbtn,.cbtn{background:#1a2634;color:#c9d1d9;border:1px solid #2c3e52;padding:6px 11px;border-radius:6px;cursor:pointer;font-size:13px}
.gbtn.on.g优{background:#1a7f4b;border-color:#2ea86a;color:#fff}
.gbtn.on.g良{background:#1f4fa8;border-color:#4C9AFF;color:#fff}
.gbtn.on.g中{background:#9e6a03;border-color:#d29922;color:#fff}
.gbtn.on.g差{background:#b62324;border-color:#f85149;color:#fff}
.sbtn.on{background:#6e40c9;border-color:#8957e5;color:#fff}
.cbtn.on{background:#1f6feb;border-color:#388bfd;color:#fff}
.crop{margin-top:8px;background:#0a0e14;border:1px solid #23364a;border-radius:6px;overflow:hidden}
.croplabel{font-size:11px;color:#d29922;padding:4px 8px}
.fb-comment{width:100%;margin-top:8px;background:#0a0e14;color:#c9d1d9;border:1px solid #23364a;border-radius:6px;padding:8px 10px;font-size:13px}
.guide{font-size:12px;color:#8b949e;background:#111a24;border-left:3px solid #d29922;padding:8px 12px;border-radius:0 8px 8px 0;margin:8px 0}
.reveal{background:#161b22;border:1px dashed #30363d;border-radius:10px;padding:12px;margin-top:10px}
.reveal summary{cursor:pointer;font-weight:700;color:#e0e0e0}
.rv-row{font-size:13px;margin:8px 0;line-height:1.7}
.myrv{color:#7ee787;margin-top:3px}
.hi-优{color:#3fb950}.hi-良{color:#58a6ff}.hi-中{color:#d29922}.hi-差{color:#f85149}
#ctlwarn{display:none;background:#b6232422;border:1px solid #f85149;color:#f85149;padding:10px 14px;border-radius:8px;margin:10px 0;font-weight:700}
.exportbar{position:fixed;left:0;right:0;bottom:0;z-index:50;display:flex;gap:10px;align-items:center;padding:10px 14px;background:rgba(1,4,9,.95);border-top:1px solid #30363d}
.exportbar textarea{flex:1;min-height:46px;background:#0a0e14;color:#7ee787;border:1px solid #30363d;border-radius:8px;padding:8px 10px;font-family:ui-monospace,monospace;font-size:12px;resize:none}
.exportbar button{flex-shrink:0;padding:13px 16px;border:none;border-radius:9px;background:#1f6feb;color:#fff;font-weight:800;font-size:14px;cursor:pointer;white-space:nowrap}
.foot{font-size:11px;color:#667788;padding:14px 20px;line-height:1.7}
"""

def build():
    cards = []
    for it in items:
        sid = it["sid"]
        if sid == "CTL":
            cards.append(f"""
<div class="wrap"><div class="card" style="border:1px dashed #d29922;padding:12px">
<h3 style="font-size:15px">🔒 对照锚题（感知标定，不计分）</h3>
<p style="font-size:12px;color:#8b949e;margin:4px 0">下面两张图：<b>可能相同也可能不同</b>。如实判断即可。</p>
<div class="quad"><div class="card"><img src="{it['slots'][0]['img']}" onclick="fsImg(this)"></div>
<div class="card"><img src="{it['slots'][1]['img']}" onclick="fsImg(this)"></div></div>
<div class="fb fb-row" style="border:none;background:transparent">
<span class="fb-label">两侧是否有差异:</span>
<button class="cbtn" data-v="同" onclick="setCtl('同')">同片（无差异）</button>
<button class="cbtn" data-v="异" onclick="setCtl('异')">有差异</button></div></div></div>""")
        else:
            slot_cards = []
            for pos in range(4):
                crops = "".join(f'<div class="crop"><div class="croplabel">🔍 切片 · {cr["label"]}</div><img src="{cr["b64"]}" loading="lazy" onclick="fsImg(this)"></div>' for cr in it["slots"][pos]["crops"])
                slot_cards.append(f"""
<div class="card"><div style="font-size:13px;font-weight:700;padding:6px 10px;background:#0d1420;color:#8fa3bf">图{pos+1}</div>
<img src="{it['slots'][pos]['img']}" loading="lazy" onclick="fsImg(this)">
{crops}
<div class="fb"><div class="fb-row"><span class="fb-label">图{pos+1}:</span>
<button class="gbtn" data-sid="{sid}" data-slot="{pos+1}" data-g="优" onclick="setG('{sid}',{pos+1},'优')">优</button>
<button class="gbtn" data-sid="{sid}" data-slot="{pos+1}" data-g="良" onclick="setG('{sid}',{pos+1},'良')">良</button>
<button class="gbtn" data-sid="{sid}" data-slot="{pos+1}" data-g="中" onclick="setG('{sid}',{pos+1},'中')">中</button>
<button class="gbtn" data-sid="{sid}" data-slot="{pos+1}" data-g="差" onclick="setG('{sid}',{pos+1},'差')">差</button>
<button class="sbtn" data-sid="{sid}" data-slot="{pos+1}" onclick="setStar('{sid}',{pos+1})">★图{pos+1}最佳</button></div></div></div>""")
            cards.append(f"""
<div class="wrap"><div class="card" style="padding:12px">
<h3 style="font-size:15px">{it['case']} <span class="badge">4 引擎同题 · 匿名</span> <span style="font-size:11px;color:#8b949e">{it['tier']}</span></h3>
<div class="guide">🔍 判读轴: {it['guide']}</div>
<div class="quad">{''.join(slot_cards)}</div>
<input class="fb-comment" data-sid="{sid}" placeholder="本组意见（可选）">
</div></div>""")
    reveal_rows = "".join(
        f'<div class="rv-row" id="rv_{r["sid"]}"><b>{r["sid"]}</b><div class="rvph">…评完后此处自动回放引擎与你的评分…</div></div>'
        for r in DATA["reveal"])
    html = ('<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">'
            '<meta name="viewport" content="width=device-width,initial-scale=1">'
            '<title>四引擎同题盲审</title><style>' + CSS + '</style></head><body>'
            '<div class="header"><h1>🎨 四引擎同题盲审 <span class="badge">优良中差 · 匿名</span></h1>'
            '<div class="sub">深夜咖啡店 · 统一prompt/seed/尺寸 · 每图独立评级 + 全场最佳★</div></div>'
            '<div id="ctlwarn">⚠️ 对照锚题判「有差异」→ 本轮判读作废，请刷新重评。</div>'
            + "".join(cards) +
            '<details class="reveal"><summary>🔓 揭晓区（评完再看）</summary>' + reveal_rows +
            '<input class="fb-comment" data-sid="overall" placeholder="总评（可选，将并入导出串）"></details>'
            '<div class="foot">口径: Kai 终审盲测; 自动指标≠终审; 对照锚不计分母。</div></div>'
            '<div class="exportbar"><textarea id="sumbox" readonly></textarea>'
            '<button id="cpbtn" onclick="copySummary()">📋 复制发回群里</button></div>'
            '</body>\n</html>')
    js = JS.replace("__DATA__", json.dumps(DATA, ensure_ascii=False))
    html = html.replace("</body>", "<script>\n" + js + "\npaint();\n</script>\n</body>")
    open(PAGE, "w").write(html)
    # ---- 自检1: JS 语法门 ----
    open("/tmp/quad_page.js","w").write(js + "\npaint();")
    r = subprocess.run(["node","--check","/tmp/quad_page.js"],capture_output=True,text=True)
    if r.returncode != 0: print("JS SYNTAX FAIL:\n", r.stderr[:800]); sys.exit(1)
    # ---- 自检2: 键↔摆放字节比对 ----
    problems = []
    for (sid, srcs_in_shuffle_order, order) in anon_quads:
        it = next(x for x in items if x["sid"] == sid)
        for pos in range(4):
            if it["slots"][pos]["img"] != enc_b64(srcs_in_shuffle_order[pos]):
                problems.append(f"{sid}: slot{pos+1} ≠ shuffled_src[{pos}]")
    # ---- 自检2b: mapping 值域断言 ----
    all_engines = set()
    for q in QUADS:
        all_engines.update(q[5])
    for sid, m in mapping.items():
        for pos in range(4):
            if m[f"slot{pos+1}"] not in all_engines:
                problems.append(f"{sid}: mapping 值异常")
    # ---- 自检3: 匿名泄漏(可见区) ----
    body_only = open(PAGE).read().split("<script>")[0]
    # 注: 「龙轩咖啡」在 GUIDE 中是判读锚(0906 页先例), 不算泄漏; 引擎名才是禁词
    for w in ["krea2","z_image","Z-Image","zimage","Ideogram","ideogram","FLUX","flux","Krea","Qwen-Image","qwen2512"]:
        if w in body_only: problems.append(f"匿名泄漏(可见区): {w}")
    # ---- 自检4: base64 非空 ----
    n_real = len(re.findall(r"base64,[A-Za-z0-9+/]{100,}", open(PAGE).read()))
    n_expect = sum(len(i["slots"]) + sum(len(s["crops"]) for s in i["slots"]) for i in items)
    if n_real < n_expect: problems.append(f"base64 疑空: {n_real}<{n_expect}")
    print(f"imgs={n_real} (expect {n_expect}) mapping={json.dumps(mapping, ensure_ascii=False)}")
    if problems: print("PROBLEMS:"); [print(" -",p) for p in problems]; sys.exit(1)
    print(f"OK -> {PAGE} ({os.path.getsize(PAGE)//1024} KB)")

if __name__ == "__main__":
    build()
