use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use drission::ocr::{Ocr, SampleBank};
#[tokio::main]
async fn main() -> drission::Result<()> {
let manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
let src = env_path("SRC").unwrap_or_else(|| manifest.join("yidun_samples"));
let bank_dir = env_path("BANK").unwrap_or_else(|| src.join("bank"));
let labels = env_path("LABELS").unwrap_or_else(|| {
manifest
.join("..")
.join("yidun-train")
.join("label")
.join("labels_seed.txt")
});
let w: f32 = std::env::var("TPL_W")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(1.5);
let gate_hi: f32 = env_f32("GATE_HI", 0.10);
let gate_lo: f32 = env_f32("GATE_LO", 0.01);
let dump = std::env::var("EVAL_DUMP").is_ok();
let text = std::fs::read_to_string(&labels)
.map_err(|e| drission::Error::msg(format!("读标注 {}: {e}", labels.display())))?;
let mut items: Vec<(String, char)> = Vec::new();
for ln in text.lines() {
let ln = ln.trim();
if ln.is_empty() {
continue;
}
let mut it = ln.split('\t');
let (Some(f), Some(c)) = (it.next(), it.next()) else {
continue;
};
if let Some(ch) = c.trim().chars().next() {
items.push((f.to_string(), ch));
}
}
if items.is_empty() {
return Err(drission::Error::msg("标注为空,无可评样本"));
}
let cand: Vec<char> = items
.iter()
.map(|(_, c)| *c)
.collect::<BTreeSet<char>>()
.into_iter()
.collect();
println!(
"[eval] 标注 {} 张 · 候选字表 {} 字「{}」",
items.len(),
cand.len(),
cand.iter().collect::<String>()
);
println!("[eval] crop 目录 = {}", src.display());
println!("[eval] 样本库 = {}", bank_dir.display());
println!("[eval] 加载 ocr 模型(本地缓存)…");
let ocr = Ocr::new().await?;
let bank = SampleBank::from_dir(&bank_dir).ok();
match &bank {
Some(b) => println!("[eval] 样本库载入 {} 张 · 留一法排除待测图自己", b.len()),
None => println!("[eval] ⚠ 样本库未载入,仅评纯 OCR(融合=纯OCR)"),
}
println!("[eval] 融合权重 W = {w}(combo = aff + W×tpl)\n");
let (mut n, mut ocr_ok, mut tpl_ok, mut fus_ok, mut gat_ok) =
(0usize, 0usize, 0usize, 0usize, 0usize);
let mut per: BTreeMap<char, [usize; 3]> = BTreeMap::new();
let mut rescued: Vec<(String, char, char)> = Vec::new(); let mut gated_wrong: Vec<(String, char, char)> = Vec::new(); if dump {
println!("[dump] file truth ocrTop(aff) ocrTruthAff tplTop(sim)\n");
}
for (file, truth) in &items {
let path = src.join(file);
let Ok(bytes) = std::fs::read(&path) else {
println!("[eval] 缺图跳过:{}", path.display());
continue;
};
let Ok(aff) = ocr.char_affinity(&bytes, &cand) else {
continue;
};
let tpl: Vec<f32> = cand
.iter()
.map(|&ch| {
bank.as_ref()
.and_then(|b| b.similarity_image_excluding(&bytes, ch).ok())
.unwrap_or(0.0)
})
.collect();
let fused: Vec<f32> = aff.iter().zip(&tpl).map(|(a, t)| a + w * t).collect();
let ocr_top = aff.iter().cloned().fold(f32::MIN, f32::max).max(0.0);
let gate = gate_weight(ocr_top, gate_hi, gate_lo);
let gated: Vec<f32> = aff
.iter()
.zip(&tpl)
.map(|(a, t)| a + w * gate * t)
.collect();
let p_ocr = argmax_char(&aff, &cand);
let p_tpl = argmax_char(&tpl, &cand);
let p_fus = argmax_char(&fused, &cand);
let p_gat = argmax_char(&gated, &cand);
if dump {
let truth_idx = cand.iter().position(|c| c == truth).unwrap_or(0);
let tpl_top_i = (0..tpl.len())
.max_by(|&a, &b| tpl[a].total_cmp(&tpl[b]))
.unwrap_or(0);
println!(
"[dump] {file} 「{truth}」 「{}」{:.2} {:.2} 「{}」{:.2} gate={:.2}",
p_ocr, ocr_top, aff[truth_idx], cand[tpl_top_i], tpl[tpl_top_i], gate
);
}
n += 1;
let e = per.entry(*truth).or_default();
e[0] += 1;
if p_ocr == *truth {
ocr_ok += 1;
e[1] += 1;
}
if p_tpl == *truth {
tpl_ok += 1;
}
if p_fus == *truth {
fus_ok += 1;
}
if p_gat == *truth {
gat_ok += 1;
e[2] += 1;
}
if p_ocr != *truth && p_gat == *truth {
rescued.push((file.clone(), *truth, p_ocr));
}
if p_gat != *truth {
gated_wrong.push((file.clone(), *truth, p_gat));
}
}
let pct = |k: usize| {
if n == 0 {
0.0
} else {
100.0 * k as f32 / n as f32
}
};
println!(
"════════════════ 结果(N={n} · {} 选 1)════════════════",
cand.len()
);
println!("纯 OCR {ocr_ok:>3}/{n} ({:.1}%)", pct(ocr_ok));
println!("仅字形模板 {tpl_ok:>3}/{n} ({:.1}%)", pct(tpl_ok));
println!(
"always-on 融合(W={w}) {fus_ok:>3}/{n} ({:.1}%) 旧策略 aff+W×tpl",
pct(fus_ok)
);
println!(
"置信门控融合 {gat_ok:>3}/{n} ({:.1}%) ← 推荐(HI={gate_hi} LO={gate_lo})",
pct(gat_ok)
);
println!(
"门控 vs 纯OCR = {:+.1} 个百分点({:+} 张);always-on vs 纯OCR = {:+.1} 个百分点({:+} 张)",
pct(gat_ok) - pct(ocr_ok),
gat_ok as i64 - ocr_ok as i64,
pct(fus_ok) - pct(ocr_ok),
fus_ok as i64 - ocr_ok as i64
);
println!("\n按字(n / 纯OCR对 / 门控融合对):");
for (ch, [cn, co, cf]) in &per {
let warn = if *cn < 2 {
" ← 样本<2,留一法下该字模板无援"
} else {
""
};
println!(" 「{ch}」 {cn} / {co} / {cf}{warn}");
}
if !rescued.is_empty() {
println!(
"\n门控融合纠正了纯 OCR 的误读({} 例,正是第二信号的价值):",
rescued.len()
);
for (f, t, o) in &rescued {
println!(" {f}: 真「{t}」· 纯OCR误判「{o}」→ 门控融合纠正 ✓");
}
}
if !gated_wrong.is_empty() {
println!(
"\n门控融合仍判错({} 例 → 攒样本 / 自训的重点目标):",
gated_wrong.len()
);
for (f, t, p) in &gated_wrong {
println!(" {f}: 真「{t}」→ 判「{p}」");
}
}
Ok(())
}
fn gate_weight(c: f32, hi: f32, lo: f32) -> f32 {
if hi <= lo {
return 1.0;
}
((hi - c) / (hi - lo)).clamp(0.0, 1.0)
}
fn env_f32(key: &str, default: f32) -> f32 {
std::env::var(key)
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(default)
}
fn env_path(key: &str) -> Option<PathBuf> {
std::env::var_os(key)
.filter(|s| !s.is_empty())
.map(PathBuf::from)
}
fn argmax_char(v: &[f32], cand: &[char]) -> char {
let mut bi = 0usize;
let mut bv = f32::MIN;
for (i, &x) in v.iter().enumerate() {
if x > bv {
bv = x;
bi = i;
}
}
cand.get(bi).copied().unwrap_or(cand[0])
}