feat: Konsens zweier Bildmodelle als Entscheider, elastischer Bildvergleich
Entscheidung (Standard: decide=consensus): - Zwei lokale Vision-Modelle (local: Qwen3-VL, local2: Gemma 4) lesen jedes Formelbild; angenommen wird nur bei inhaltlicher Übereinstimmung (Normalform oder Render-Gleichheit), Pandoc-Prüfung und grobem Bild-Veto - Optional dritte Lesart als Gegenprobe (require_third) bzw. Stichwahl - Fettdruck standardmäßig irrelevant (bold_relevant=0) - Token-Wahrscheinlichkeiten aus Logprobs (p_min, p_mean) statt Selbsteinschätzung; "Nachdenken" lokaler Modelle abgeschaltet (Gemma 4: ~10x schneller) - consensus.py: Normalform auf Baumbasis (Abstände, \left/\right, kursiv/aufrecht, Längenvarianten, Matrix-Umgebungen, TeX-Umlaute, \atop), Render-Vergleich Bildvergleich (Veto und Pixel-Modus decide=pixel): - Elastische Ausrichtung per DTW über Spalten und Zeilen; Dehnen kostet proportional zur Tinte, Varianten nutzen die Verzerrung des Kandidaten - Layouttreue Prüfvarianten, Äquivalenzklassen, rauschnormierter Kontrast Werkzeuge: calibrate --consensus, tools/vergleich.py (50er-Stichprobe mit Bildern), tools/konsens_bericht.py (Übersichtsseite); Ausgaben in vergleich/ (nicht versioniert) Messung Göllmann (2 x 150 Formeln, Fettdruck egal): Qwen = Gemma = GLM angenommen 61 % / 57 %, davon falsch 0; Qwen = Gemma allein: 1 bzw. 2 Fehler. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
eb72d0b0f2
commit
fe95cb572c
15 changed files with 1087 additions and 73 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -24,3 +24,6 @@ output/
|
||||||
.vscode/
|
.vscode/
|
||||||
*.swp
|
*.swp
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
# Vergleichsbilder zum Einstellen (Ausschnitte aus Büchern, nicht versionieren)
|
||||||
|
vergleich/
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,11 @@ import os
|
||||||
import posixpath
|
import posixpath
|
||||||
import random
|
import random
|
||||||
import re
|
import re
|
||||||
|
import shutil
|
||||||
import statistics
|
import statistics
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
import zipfile
|
import zipfile
|
||||||
from concurrent.futures import ProcessPoolExecutor, as_completed
|
from concurrent.futures import ProcessPoolExecutor, as_completed
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -64,6 +67,9 @@ def corpus(epub: Path) -> list[tuple[str, bytes, str, bool]]:
|
||||||
|
|
||||||
|
|
||||||
def synth_jpeg(tex: str, display: bool, rng: random.Random) -> bytes | None:
|
def synth_jpeg(tex: str, display: bool, rng: random.Random) -> bytes | None:
|
||||||
|
"""Small JPEG like the Gehrke books: LaTeX + dvipng with bitmap fonts at ~102 dpi, quality 72."""
|
||||||
|
if shutil.which("dvipng") and shutil.which("latex"):
|
||||||
|
return _dvipng_jpeg(tex, display, int(round(SYNTH_PX_PER_PT * 72.27 * rng.uniform(0.97, 1.03))))
|
||||||
r = render_many([(tex, display)], "cm")[0]
|
r = render_many([(tex, display)], "cm")[0]
|
||||||
if not r.ok:
|
if not r.ok:
|
||||||
return None
|
return None
|
||||||
|
|
@ -79,13 +85,32 @@ def synth_jpeg(tex: str, display: bool, rng: random.Random) -> bytes | None:
|
||||||
return buf.getvalue()
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def _dvipng_jpeg(tex: str, display: bool, dpi: int) -> bytes | None:
|
||||||
|
with tempfile.TemporaryDirectory(prefix="f2l-dvipng-") as d:
|
||||||
|
body = ("$\\displaystyle " if display else "$") + tex + "$"
|
||||||
|
Path(d, "t.tex").write_text("\\documentclass[10pt]{article}\\usepackage{amsmath,amssymb,mathtools,bm,mathrsfs}"
|
||||||
|
"\\pagestyle{empty}\\begin{document}" + body + "\\end{document}", encoding="utf-8")
|
||||||
|
env = dict(os.environ, openout_any="p", openin_any="p")
|
||||||
|
subprocess.run(["latex", "-interaction=nonstopmode", "-no-shell-escape", "t.tex"], cwd=d, env=env,
|
||||||
|
capture_output=True, timeout=60, check=False)
|
||||||
|
if not Path(d, "t.dvi").exists():
|
||||||
|
return None
|
||||||
|
subprocess.run(["dvipng", "-q", "-D", str(dpi), "-T", "tight", "-bg", "White", "-o", "t.png", "t.dvi"],
|
||||||
|
cwd=d, capture_output=True, timeout=60, check=False)
|
||||||
|
if not Path(d, "t.png").exists():
|
||||||
|
return None
|
||||||
|
buf = io.BytesIO()
|
||||||
|
Image.open(Path(d, "t.png")).convert("L").save(buf, "JPEG", quality=72)
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
def _equivalent(orig: np.ndarray, a: str, b: str, display: bool, preset: str) -> bool:
|
def _equivalent(orig: np.ndarray, a: str, b: str, display: bool, preset: str) -> bool:
|
||||||
"""True if a and b render identically at the original's scale (e.g. \\le vs \\leq)."""
|
"""True if a and b render identically at the original's scale (e.g. \\le vs \\leq)."""
|
||||||
ra, rb = render_many([(a, display), (b, display)], preset)
|
ra, rb = render_many([(a, display), (b, display)], preset)
|
||||||
if not (ra.ok and rb.ok):
|
if not (ra.ok and rb.ok):
|
||||||
return False
|
return False
|
||||||
ca = compare(orig, ra)
|
ca = compare(orig, ra)
|
||||||
cb = compare(orig, rb, px_per_pt=ca.metrics.get("px_per_pt"))
|
cb = compare(orig, rb, warp_from=ca)
|
||||||
return ca.ok and cb.ok and contrast(ca, cb) is None
|
return ca.ok and cb.ok and contrast(ca, cb) is None
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -99,7 +124,7 @@ def _job(args: tuple[str, bytes, str, bool, str, int]) -> dict[str, Any]:
|
||||||
vt = verify(orig, truth, display, preset)
|
vt = verify(orig, truth, display, preset)
|
||||||
res["truth_ver"] = vt.to_json()
|
res["truth_ver"] = vt.to_json()
|
||||||
res["truth_status"] = decide(res["truth_ver"], True, None, th)[0]
|
res["truth_status"] = decide(res["truth_ver"], True, None, th)[0]
|
||||||
starts = [v.tex for v in variants(truth)]
|
starts = [v.tex for v in variants(truth) if not v.equivalent]
|
||||||
rng.shuffle(starts)
|
rng.shuffle(starts)
|
||||||
res["errors"] = []
|
res["errors"] = []
|
||||||
for wrong in starts[:3]:
|
for wrong in starts[:3]:
|
||||||
|
|
@ -135,7 +160,7 @@ def calibrate(epubs: list[Path], samples: int = 200, preset: str | None = None,
|
||||||
img = synth_jpeg(tex, display, rng)
|
img = synth_jpeg(tex, display, rng)
|
||||||
if img:
|
if img:
|
||||||
syn.append((name + "#synth.jpg", img, tex, display))
|
syn.append((name + "#synth.jpg", img, tex, display))
|
||||||
sets.append((f"{epub.stem[:40]} [synthetisch, cm]", syn, "cm"))
|
sets.append((f"{epub.stem[:40]} [synthetisch dvipng, cm]", syn, "cm"))
|
||||||
summary: dict[str, Any] = {"sets": []}
|
summary: dict[str, Any] = {"sets": []}
|
||||||
for label, items, p in sets:
|
for label, items, p in sets:
|
||||||
log(f"prüfe {label}: {len(items)} Formeln …")
|
log(f"prüfe {label}: {len(items)} Formeln …")
|
||||||
|
|
@ -194,3 +219,111 @@ def _print(s: dict[str, Any], log: Any) -> None:
|
||||||
f"Review/Bild {s['errors_to_review_or_rejected']}, FALSCH ANGENOMMEN {s['errors_wrongly_accepted']}")
|
f"Review/Bild {s['errors_to_review_or_rejected']}, FALSCH ANGENOMMEN {s['errors_wrongly_accepted']}")
|
||||||
for ex in s["wrongly_accepted_examples"]:
|
for ex in s["wrongly_accepted_examples"]:
|
||||||
log(f" falsch: {ex['final']} (Start {ex['start']})")
|
log(f" falsch: {ex['final']} (Start {ex['start']})")
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------- consensus calibration
|
||||||
|
|
||||||
|
def _consensus_eval(args: tuple) -> dict[str, Any]:
|
||||||
|
"""Worker: compare both readings with each other and with the truth; rough image check."""
|
||||||
|
from .consensus import same_reading
|
||||||
|
name, data, truth, display, preset, ra, rb = args
|
||||||
|
os.environ.setdefault("OMP_NUM_THREADS", "1")
|
||||||
|
ta, tb = ra.get("tex") or "", rb.get("tex") or ""
|
||||||
|
res: dict[str, Any] = {"name": name, "truth": truth, "display": display, "a": ra, "b": rb,
|
||||||
|
"a_ok": bool(same_reading(ta, truth, display, preset)),
|
||||||
|
"b_ok": bool(same_reading(tb, truth, display, preset)),
|
||||||
|
"agree": same_reading(ta, tb, display, preset)}
|
||||||
|
res["veto"] = None
|
||||||
|
if res["agree"]:
|
||||||
|
v = verify(load_ink(data, name), ta, display, preset, check_variants=False)
|
||||||
|
res["veto"] = {"ok": v.ok, "reason": v.reason, "metrics": v.metrics}
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
def calibrate_consensus(epubs: list[Path], samples: int = 150, preset: str | None = None, synthetic: bool = True,
|
||||||
|
report: Path | None = None, workers: int = 0, quiet: bool = False,
|
||||||
|
engines: tuple[str, str] = ("local", "local2")) -> dict[str, Any]:
|
||||||
|
"""Measure the two-model consensus rule on images whose TeX is known."""
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
|
||||||
|
from .decide import consensus_thresholds, decide_consensus
|
||||||
|
from .engines import make_engine
|
||||||
|
|
||||||
|
def log(m: str) -> None:
|
||||||
|
if not quiet:
|
||||||
|
print(m, file=sys.stderr, flush=True)
|
||||||
|
|
||||||
|
eng = [make_engine(e) for e in engines]
|
||||||
|
for e in eng:
|
||||||
|
e.check()
|
||||||
|
log("Modelle: " + ", ".join(f"{e.name}={e.model}" for e in eng))
|
||||||
|
rng = random.Random(42)
|
||||||
|
sets: list[tuple[str, list[tuple[str, bytes, str, bool]], str]] = []
|
||||||
|
for epub in epubs:
|
||||||
|
items = corpus(epub)
|
||||||
|
if not items:
|
||||||
|
continue
|
||||||
|
smp = rng.sample(items, min(samples, len(items)))
|
||||||
|
sets.append((f"{epub.stem[:40]} [echt]", smp, preset or _best_preset(smp[:12])))
|
||||||
|
if synthetic:
|
||||||
|
syn = [(n + "#synth.jpg", img, t, d) for n, _d, t, d in smp for img in [synth_jpeg(t, d, rng)] if img]
|
||||||
|
sets.append((f"{epub.stem[:40]} [dvipng, Gehrke-Stil]", syn, "cm"))
|
||||||
|
summary: dict[str, Any] = {"engines": [f"{e.name}={e.model}" for e in eng], "sets": []}
|
||||||
|
for label, items, p in sets:
|
||||||
|
log(f"{label}: {len(items)} Formeln lesen …")
|
||||||
|
readings: list[list[dict[str, Any]]] = [[{}] * len(items) for _ in eng]
|
||||||
|
|
||||||
|
def ask(k: int, i: int) -> None:
|
||||||
|
n, d, _t, disp = items[i]
|
||||||
|
readings[k][i] = eng[k].recognize(d, n, "", disp).to_json()
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=8) as pool:
|
||||||
|
list(pool.map(lambda ki: ask(*ki), [(k, i) for k in range(len(eng)) for i in range(len(items))]))
|
||||||
|
jobs = [(n, d, t, disp, p, readings[0][i], readings[1][i]) for i, (n, d, t, disp) in enumerate(items)]
|
||||||
|
with ProcessPoolExecutor(max_workers=workers or max(1, (os.cpu_count() or 2) - 2)) as ex:
|
||||||
|
results = list(ex.map(_consensus_eval, jobs))
|
||||||
|
s = _summarize_consensus(label, results, consensus_thresholds, decide_consensus)
|
||||||
|
summary["sets"].append(s)
|
||||||
|
s["results"] = results
|
||||||
|
_print_consensus(s, log)
|
||||||
|
if report:
|
||||||
|
report.write_text(json.dumps(summary, ensure_ascii=False, indent=1, default=str), encoding="utf-8")
|
||||||
|
log(f"Bericht: {report}")
|
||||||
|
return summary
|
||||||
|
|
||||||
|
|
||||||
|
def _summarize_consensus(label: str, results: list[dict[str, Any]], consensus_thresholds: Any,
|
||||||
|
decide_consensus: Any) -> dict[str, Any]:
|
||||||
|
n = len(results)
|
||||||
|
out: dict[str, Any] = {"label": label, "formulas": n,
|
||||||
|
"a_correct": sum(r["a_ok"] for r in results), "b_correct": sum(r["b_ok"] for r in results),
|
||||||
|
"agree": sum(bool(r["agree"]) for r in results),
|
||||||
|
"agree_wrong": sum(bool(r["agree"]) and not r["a_ok"] for r in results),
|
||||||
|
"sweep": []}
|
||||||
|
out["agree_wrong_examples"] = [{"truth": r["truth"], "a": r["a"].get("tex"), "b": r["b"].get("tex")}
|
||||||
|
for r in results if r["agree"] and not r["a_ok"]][:10]
|
||||||
|
for pm in (0.0, 0.8, 0.9, 0.95, 0.98):
|
||||||
|
for pn in (0.0, 0.2, 0.3, 0.5):
|
||||||
|
th = consensus_thresholds({"min_p_mean": pm, "min_p_min": pn})
|
||||||
|
acc = wrong = 0
|
||||||
|
for r in results:
|
||||||
|
agree = [(0, 1, r["agree"])] if r["agree"] else []
|
||||||
|
st, _reason, _use = decide_consensus([r["a"], r["b"]], agree, r["veto"], True, None, th)
|
||||||
|
if st == "auto_accepted":
|
||||||
|
acc += 1
|
||||||
|
wrong += not r["a_ok"]
|
||||||
|
out["sweep"].append({"min_p_mean": pm, "min_p_min": pn, "accepted": acc, "wrong": wrong})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _print_consensus(s: dict[str, Any], log: Any) -> None:
|
||||||
|
n = max(1, s["formulas"])
|
||||||
|
log(f" {s['label']}: {s['formulas']} Formeln")
|
||||||
|
log(f" Modell A richtig {s['a_correct'] / n:.0%}, Modell B richtig {s['b_correct'] / n:.0%}, "
|
||||||
|
f"einig {s['agree'] / n:.0%}, davon FALSCH {s['agree_wrong']}")
|
||||||
|
for ex in s["agree_wrong_examples"][:5]:
|
||||||
|
log(f" einig, aber falsch: {ex['a']} (richtig: {ex['truth']})")
|
||||||
|
log(" Schwellen (Mittel/Minimum) → automatisch angenommen / davon falsch:")
|
||||||
|
for row in s["sweep"]:
|
||||||
|
log(f" p_mean ≥ {row['min_p_mean']:.2f}, p_min ≥ {row['min_p_min']:.2f}: "
|
||||||
|
f"{row['accepted']:4d} ({row['accepted'] / n:.0%}) / {row['wrong']}")
|
||||||
|
|
|
||||||
|
|
@ -121,6 +121,8 @@ def build_parser() -> argparse.ArgumentParser:
|
||||||
help="zusätzlich kleine JPEG-Nachbildungen (wie Gehrke) aus denselben Formeln erzeugen")
|
help="zusätzlich kleine JPEG-Nachbildungen (wie Gehrke) aus denselben Formeln erzeugen")
|
||||||
sp.add_argument("--report", type=Path, default=None, help="Bericht als JSON speichern")
|
sp.add_argument("--report", type=Path, default=None, help="Bericht als JSON speichern")
|
||||||
sp.add_argument("--workers", type=int, default=0)
|
sp.add_argument("--workers", type=int, default=0)
|
||||||
|
sp.add_argument("--consensus", action="store_true",
|
||||||
|
help="Konsensregel messen: beide lokalen Modelle (local, local2) lesen die Formeln")
|
||||||
sp.add_argument("--quiet", "-q", action="store_true")
|
sp.add_argument("--quiet", "-q", action="store_true")
|
||||||
return p
|
return p
|
||||||
|
|
||||||
|
|
@ -160,6 +162,11 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
def _dispatch(args: argparse.Namespace) -> int:
|
def _dispatch(args: argparse.Namespace) -> int:
|
||||||
from . import pipeline
|
from . import pipeline
|
||||||
|
|
||||||
|
if args.command == "calibrate" and args.consensus:
|
||||||
|
from .calibrate import calibrate_consensus
|
||||||
|
calibrate_consensus(args.epubs, samples=args.samples, preset=None if args.preset == "auto" else args.preset,
|
||||||
|
synthetic=args.synthetic, report=args.report, workers=args.workers, quiet=args.quiet)
|
||||||
|
return EXIT_OK
|
||||||
if args.command == "calibrate":
|
if args.command == "calibrate":
|
||||||
from .calibrate import calibrate
|
from .calibrate import calibrate
|
||||||
calibrate(args.epubs, samples=args.samples, preset=None if args.preset == "auto" else args.preset,
|
calibrate(args.epubs, samples=args.samples, preset=None if args.preset == "auto" else args.preset,
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,10 @@ BIN_THRESHOLD = 0.4
|
||||||
MAX_ASPECT_DEVIATION = 0.25
|
MAX_ASPECT_DEVIATION = 0.25
|
||||||
TARGET_STROKE = 2.0 # strokes thicker than this (px) are scaled down before comparing
|
TARGET_STROKE = 2.0 # strokes thicker than this (px) are scaled down before comparing
|
||||||
OVERSAMPLE = 4
|
OVERSAMPLE = 4
|
||||||
|
ELASTIC_BAND = 0.4 # max. drift of the column/row mapping, share of the formula size
|
||||||
|
ELASTIC_STEP = 0.05 # cost of every stretched or compressed step (ink² units)
|
||||||
|
ELASTIC_INK = 0.5 # extra stretch cost per unit of ink in the stretched column
|
||||||
|
CONTRAST_BLUR = 0.7 # px; damps hinting/antialiasing differences in the contrast test
|
||||||
SUBPIXEL_MAX_HEIGHT = 150 # quarter-pixel alignment only for small images (px)
|
SUBPIXEL_MAX_HEIGHT = 150 # quarter-pixel alignment only for small images (px)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -45,6 +49,10 @@ class Comparison:
|
||||||
original: np.ndarray | None = None # aligned canvases, for diff images
|
original: np.ndarray | None = None # aligned canvases, for diff images
|
||||||
candidate: np.ndarray | None = None
|
candidate: np.ndarray | None = None
|
||||||
mismatch: np.ndarray | None = None
|
mismatch: np.ndarray | None = None
|
||||||
|
raw: np.ndarray | None = None # rigidly aligned candidate before elastic warping
|
||||||
|
maps: tuple[np.ndarray, np.ndarray] | None = None # (columns, rows) of the elastic warp
|
||||||
|
margin: int = 0
|
||||||
|
noise: float | None = None # cached noise level, see noise_level()
|
||||||
|
|
||||||
|
|
||||||
def stroke_width(ink: np.ndarray) -> float:
|
def stroke_width(ink: np.ndarray) -> float:
|
||||||
|
|
@ -121,12 +129,77 @@ def _aligned_candidate(orig: np.ndarray, hi: np.ndarray, scale: float, margin: i
|
||||||
return best, best_score
|
return best, best_score
|
||||||
|
|
||||||
|
|
||||||
|
def _dtw_map(o: np.ndarray, c: np.ndarray, band_frac: float = ELASTIC_BAND, step_cost: float = ELASTIC_STEP,
|
||||||
|
ink_cost: float = ELASTIC_INK) -> tuple[np.ndarray, float]:
|
||||||
|
"""Monotone mapping orig column i -> candidate column map[i] (dynamic time warping).
|
||||||
|
|
||||||
|
Each original column advances the candidate by 0 (stretch), 1, 2 or 3 columns. Blank or
|
||||||
|
uniform columns (spacing, arrow shafts, fraction bars) can therefore grow or shrink, while
|
||||||
|
every candidate column that is skipped costs its full ink, so no symbol can be dropped and
|
||||||
|
no order can change. Stretching costs in proportion to the column's ink, so warping happens
|
||||||
|
in spacing and thin strokes, not inside glyphs. Returns the map and the share of non-diagonal
|
||||||
|
steps.
|
||||||
|
"""
|
||||||
|
n, m = o.shape[1], c.shape[1]
|
||||||
|
oc, cc = o.T.astype(np.float64), c.T.astype(np.float64)
|
||||||
|
cost = (oc * oc).sum(1)[:, None] + (cc * cc).sum(1)[None, :] - 2.0 * oc @ cc.T
|
||||||
|
np.maximum(cost, 0.0, out=cost)
|
||||||
|
ink = (cc * cc).sum(1)
|
||||||
|
band = max(4, int(band_frac * max(n, m)))
|
||||||
|
jj = np.arange(m)
|
||||||
|
inf = np.inf
|
||||||
|
back = np.zeros((n, m), dtype=np.int8)
|
||||||
|
# start: any column, skipping the candidate ink before it
|
||||||
|
prev = cost[0] + np.concatenate([[0.0], np.cumsum(ink)[:-1]])
|
||||||
|
prev[jj > band] = inf
|
||||||
|
for i in range(1, n):
|
||||||
|
diag = np.concatenate([[inf], prev[:-1]])
|
||||||
|
rep = prev + step_cost + ink_cost * ink
|
||||||
|
sk2 = np.concatenate([[inf, inf], prev[:-2]]) + np.concatenate([[0.0], ink[:-1]]) + step_cost
|
||||||
|
sk3 = (np.concatenate([[inf, inf, inf], prev[:-3]]) + np.concatenate([[0.0], ink[:-1]])
|
||||||
|
+ np.concatenate([[0.0, 0.0], ink[:-2]]) + 2 * step_cost)
|
||||||
|
opts = np.stack([diag, rep, sk2, sk3])
|
||||||
|
k = np.argmin(opts, axis=0)
|
||||||
|
cur = opts[k, jj] + cost[i]
|
||||||
|
cur[np.abs(jj - i * m / n) > band] = inf
|
||||||
|
back[i] = k
|
||||||
|
prev = cur
|
||||||
|
tail = np.concatenate([np.cumsum(ink[::-1])[::-1][1:], [0.0]]) # candidate ink after the end
|
||||||
|
j = int(np.argmin(prev + tail))
|
||||||
|
path = np.empty(n, dtype=np.int64)
|
||||||
|
nondiag = 0
|
||||||
|
for i in range(n - 1, -1, -1):
|
||||||
|
path[i] = j
|
||||||
|
if i == 0:
|
||||||
|
break
|
||||||
|
k = int(back[i, j])
|
||||||
|
nondiag += k != 0
|
||||||
|
j -= (1, 0, 2, 3)[k]
|
||||||
|
j = max(j, 0)
|
||||||
|
return path, nondiag / max(1, n - 1)
|
||||||
|
|
||||||
|
|
||||||
|
def elastic_warp(o: np.ndarray, c: np.ndarray) -> tuple[np.ndarray, float, tuple[np.ndarray, np.ndarray]]:
|
||||||
|
"""Warp candidate canvas *c* onto original canvas *o*: columns first, then rows."""
|
||||||
|
cols, sc = _dtw_map(o, c)
|
||||||
|
w = c[:, cols]
|
||||||
|
rows, sr = _dtw_map(o.T, w.T)
|
||||||
|
return w[rows, :], max(sc, sr), (cols, rows)
|
||||||
|
|
||||||
|
|
||||||
def compare(orig_ink: np.ndarray, rendered: Rendered, tolerance: float | None = None,
|
def compare(orig_ink: np.ndarray, rendered: Rendered, tolerance: float | None = None,
|
||||||
px_per_pt: float | None = None) -> Comparison:
|
px_per_pt: float | None = None, elastic: bool = True, warp_from: Comparison | None = None
|
||||||
|
) -> Comparison:
|
||||||
"""Compare a trimmed original ink image with a rendered candidate.
|
"""Compare a trimmed original ink image with a rendered candidate.
|
||||||
|
|
||||||
*px_per_pt* fixes the scale (e.g. for confusable variants of an already aligned candidate:
|
*px_per_pt* fixes the scale (e.g. for confusable variants of an already aligned candidate:
|
||||||
same font size, so only the position is searched).
|
same font size, so only the position is searched). With *elastic* (default) spacing, arrow
|
||||||
|
and bar lengths may differ: the candidate is warped onto the original before measuring, so
|
||||||
|
only the symbols and their relative positions count.
|
||||||
|
|
||||||
|
*warp_from*: reuse the scale, canvas and elastic warp of an already compared candidate. Used
|
||||||
|
for its confusable variants, which must not get their own warp – a free warp would let a
|
||||||
|
wrong symbol bend itself into the right one.
|
||||||
"""
|
"""
|
||||||
if not rendered.ok:
|
if not rendered.ok:
|
||||||
return Comparison(False, rendered.error or "nicht gerendert")
|
return Comparison(False, rendered.error or "nicht gerendert")
|
||||||
|
|
@ -153,6 +226,8 @@ def compare(orig_ink: np.ndarray, rendered: Rendered, tolerance: float | None =
|
||||||
sx = sy
|
sx = sy
|
||||||
|
|
||||||
margin = int(np.ceil(max(h, w) * 0.06)) + 3
|
margin = int(np.ceil(max(h, w) * 0.06)) + 3
|
||||||
|
if warp_from is not None:
|
||||||
|
px_per_pt = warp_from.metrics["px_per_pt"]
|
||||||
if px_per_pt:
|
if px_per_pt:
|
||||||
lo = up = px_per_pt * norm / zoom
|
lo = up = px_per_pt * norm / zoom
|
||||||
rounds, step = 2, lo * 0.004
|
rounds, step = 2, lo * 0.004
|
||||||
|
|
@ -163,6 +238,8 @@ def compare(orig_ink: np.ndarray, rendered: Rendered, tolerance: float | None =
|
||||||
grid = list(np.linspace(lo, up, 9))
|
grid = list(np.linspace(lo, up, 9))
|
||||||
# the scaled candidate must always fit onto the padded original
|
# the scaled candidate must always fit onto the padded original
|
||||||
margin = max(margin, int(np.ceil(max(hi.shape[1] * up - w, hi.shape[0] * up - h, 0))) // 2 + 3)
|
margin = max(margin, int(np.ceil(max(hi.shape[1] * up - w, hi.shape[0] * up - h, 0))) // 2 + 3)
|
||||||
|
if warp_from is not None:
|
||||||
|
margin = warp_from.margin # same canvas as the candidate
|
||||||
best_scale, best_score, best_cand = lo, -np.inf, None
|
best_scale, best_score, best_cand = lo, -np.inf, None
|
||||||
for _round in range(rounds): # coarse grid, then finer grids around the best scale
|
for _round in range(rounds): # coarse grid, then finer grids around the best scale
|
||||||
for s in grid:
|
for s in grid:
|
||||||
|
|
@ -174,6 +251,27 @@ def compare(orig_ink: np.ndarray, rendered: Rendered, tolerance: float | None =
|
||||||
if best_cand is None:
|
if best_cand is None:
|
||||||
return Comparison(False, "Kandidat passt nicht auf das Original", metrics)
|
return Comparison(False, "Kandidat passt nicht auf das Original", metrics)
|
||||||
opad = _pad(orig, margin)
|
opad = _pad(orig, margin)
|
||||||
|
rigid_ncc = _ncc(opad, best_cand)
|
||||||
|
warp_share = 0.0
|
||||||
|
raw, maps = best_cand, None
|
||||||
|
if warp_from is not None and warp_from.maps is not None and warp_from.raw is not None:
|
||||||
|
raw = _register(best_cand, warp_from.raw)
|
||||||
|
cols, rows = warp_from.maps
|
||||||
|
best_cand = raw[:, cols][rows, :]
|
||||||
|
maps = warp_from.maps
|
||||||
|
elif elastic:
|
||||||
|
# The rigid scale is a compromise when the layout differs; glyph height (sy) is the better
|
||||||
|
# guide. Try both and keep what explains the original best after warping.
|
||||||
|
scales = [best_scale] if px_per_pt else sorted({best_scale, sy * 0.98, sy, sy * 1.02})
|
||||||
|
best_e = None
|
||||||
|
for s in scales:
|
||||||
|
cand = best_cand if s == best_scale else _aligned_candidate(orig, hi, float(s), margin)[0]
|
||||||
|
warped, share, mp = elastic_warp(opad, cand)
|
||||||
|
score = _ncc(opad, warped)
|
||||||
|
if best_e is None or score > best_e[0]:
|
||||||
|
best_e = (score, s, warped, share, cand, mp)
|
||||||
|
assert best_e is not None
|
||||||
|
_, best_scale, best_cand, warp_share, raw, maps = best_e
|
||||||
|
|
||||||
tol = tolerance if tolerance is not None else max(1.0, 0.6 * sw)
|
tol = tolerance if tolerance is not None else max(1.0, 0.6 * sw)
|
||||||
ob, rb = opad > BIN_THRESHOLD, best_cand > BIN_THRESHOLD
|
ob, rb = opad > BIN_THRESHOLD, best_cand > BIN_THRESHOLD
|
||||||
|
|
@ -221,46 +319,57 @@ def compare(orig_ink: np.ndarray, rendered: Rendered, tolerance: float | None =
|
||||||
+ max(0.0, abs(hi.shape[0] * best_scale - h) - 1.5) / h,
|
+ max(0.0, abs(hi.shape[0] * best_scale - h) - 1.5) / h,
|
||||||
"px_per_pt": best_scale * zoom / norm,
|
"px_per_pt": best_scale * zoom / norm,
|
||||||
"tolerance": tol,
|
"tolerance": tol,
|
||||||
|
"rigid_ncc": rigid_ncc,
|
||||||
|
"warp_share": warp_share,
|
||||||
|
"elastic": 1.0 if elastic else 0.0,
|
||||||
})
|
})
|
||||||
return Comparison(True, None, metrics, opad, best_cand, mismatch)
|
return Comparison(True, None, metrics, opad, best_cand, mismatch, raw, maps, margin)
|
||||||
|
|
||||||
|
|
||||||
def contrast(a: Comparison, b: Comparison, max_shift: float = 0.5) -> float | None:
|
def contrast(a: Comparison, b: Comparison, max_shift: float = 0.5) -> float | None:
|
||||||
"""How much better candidate *a* explains the original than candidate *b*, in [-1, 1].
|
"""Evidence that candidate *a* explains the original better than variant *b*.
|
||||||
|
|
||||||
Only the pixels where the two renders differ are looked at (a likelihood-ratio test on the
|
Both are warped with the same elastic mapping (warp_from), so they differ only where the
|
||||||
discriminating region), so a wrong subscript digit is not drowned by the rest of the formula.
|
symbols differ. Inside a glyph-sized window around that region the squared errors of both
|
||||||
Returns None if the two renders are identical at this resolution (equivalent spellings such as
|
are compared. The difference is measured in units of the candidate's typical residual (the
|
||||||
\\le and \\leq).
|
noise level of the whole formula: JPEG, hinting, antialiasing) per differing pixel:
|
||||||
|
|
||||||
|
evidence = (err_b - err_a) / (noise² · differing pixels)
|
||||||
|
|
||||||
|
Positive: the original shows *a*; negative: it shows *b*; near 0: indistinguishable at this
|
||||||
|
resolution. Returns None if the two renders are identical (equivalent spellings).
|
||||||
"""
|
"""
|
||||||
if not (a.ok and b.ok) or a.original is None or b.original is None:
|
if not (a.ok and b.ok) or a.original is None or b.original is None:
|
||||||
return 1.0 if a.ok and not b.ok else (-1.0 if b.ok and not a.ok else 0.0)
|
return 99.0 if a.ok and not b.ok else (-99.0 if b.ok and not a.ok else 0.0)
|
||||||
assert a.candidate is not None and b.candidate is not None
|
assert a.candidate is not None and b.candidate is not None
|
||||||
a_orig, a_cand, b_cand = a.original, a.candidate, b.candidate
|
|
||||||
if a.original.shape != b.original.shape:
|
if a.original.shape != b.original.shape:
|
||||||
# Both canvases are the same original with different symmetric margins: crop to the smaller.
|
return 99.0 # the variant does not even fit onto the candidate's canvas
|
||||||
hh = min(a.original.shape[0], b.original.shape[0])
|
ao, ac = a.original, a.candidate
|
||||||
ww = min(a.original.shape[1], b.original.shape[1])
|
bc = _register(b.candidate, ac)
|
||||||
|
region = np.abs(ac - bc) > 0.2
|
||||||
def crop(x: np.ndarray) -> np.ndarray:
|
|
||||||
dy, dx = (x.shape[0] - hh) // 2, (x.shape[1] - ww) // 2
|
|
||||||
return x[dy:dy + hh, dx:dx + ww]
|
|
||||||
|
|
||||||
a_orig, a_cand, b_cand = crop(a.original), crop(a.candidate), crop(b.candidate)
|
|
||||||
b_cand = _register(b_cand, a_cand)
|
|
||||||
region = np.abs(a_cand - b_cand) > 0.2
|
|
||||||
if not region.any():
|
if not region.any():
|
||||||
return None
|
return None
|
||||||
# Window around the differing pixels; the variants are rendered layout-preserving (see
|
g = glyph_size(ao > BIN_THRESHOLD)
|
||||||
# confusables.probe), so the difference stays local to the changed symbol.
|
|
||||||
g = glyph_size(a_orig > BIN_THRESHOLD)
|
|
||||||
ys, xs = np.nonzero(region)
|
ys, xs = np.nonzero(region)
|
||||||
y0, y1 = max(0, ys.min() - g), min(region.shape[0], ys.max() + g + 1)
|
y0, y1 = max(0, ys.min() - g), min(region.shape[0], ys.max() + g + 1)
|
||||||
x0, x1 = max(0, xs.min() - g), min(region.shape[1], xs.max() + g + 1)
|
x0, x1 = max(0, xs.min() - g), min(region.shape[1], xs.max() + g + 1)
|
||||||
o = a_orig[y0:y1, x0:x1]
|
o = ao[y0:y1, x0:x1]
|
||||||
ea = _best_shift_error(o, a_cand[y0:y1, x0:x1], max_shift)
|
ea = _best_shift_error(o, ac[y0:y1, x0:x1], max_shift)
|
||||||
eb = _best_shift_error(o, b_cand[y0:y1, x0:x1], max_shift)
|
eb = _best_shift_error(o, bc[y0:y1, x0:x1], max_shift)
|
||||||
return (eb - ea) / (eb + ea) if eb + ea > 0 else 0.0
|
return (eb - ea) / (noise_level(a) * float(region.sum()))
|
||||||
|
|
||||||
|
|
||||||
|
def noise_level(c: Comparison) -> float:
|
||||||
|
"""Mean squared residual (blurred) of a candidate on the ink support of the whole formula."""
|
||||||
|
if c.noise is None:
|
||||||
|
assert c.original is not None and c.candidate is not None
|
||||||
|
o, r = c.original, c.candidate
|
||||||
|
if CONTRAST_BLUR:
|
||||||
|
o = cv2.GaussianBlur(o, (0, 0), CONTRAST_BLUR)
|
||||||
|
r = cv2.GaussianBlur(r, (0, 0), CONTRAST_BLUR)
|
||||||
|
support = cv2.dilate(((c.original > 0.1) | (c.candidate > 0.1)).astype(np.uint8), np.ones((3, 3), np.uint8)) > 0
|
||||||
|
c.noise = float(((o - r) ** 2)[support].mean()) + 1e-4 if support.any() else 1.0
|
||||||
|
return c.noise
|
||||||
|
|
||||||
|
|
||||||
def _shift(img: np.ndarray, dy: float, dx: float) -> np.ndarray:
|
def _shift(img: np.ndarray, dy: float, dx: float) -> np.ndarray:
|
||||||
|
|
@ -297,6 +406,9 @@ _LOCAL_SHIFTS = [(dy * 0.25, dx * 0.25) for dy in range(-4, 5) for dx in range(-
|
||||||
|
|
||||||
def _best_shift_error(o: np.ndarray, win: np.ndarray, max_shift: float) -> float:
|
def _best_shift_error(o: np.ndarray, win: np.ndarray, max_shift: float) -> float:
|
||||||
"""Smallest squared error of window *win* against *o* over small sub-pixel shifts."""
|
"""Smallest squared error of window *win* against *o* over small sub-pixel shifts."""
|
||||||
|
if CONTRAST_BLUR:
|
||||||
|
o = cv2.GaussianBlur(o, (0, 0), CONTRAST_BLUR)
|
||||||
|
win = cv2.GaussianBlur(win, (0, 0), CONTRAST_BLUR)
|
||||||
pad = 2
|
pad = 2
|
||||||
big = np.pad(win, pad)
|
big = np.pad(win, pad)
|
||||||
best = float("inf")
|
best = float("inf")
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,23 @@ GROUPS: list[tuple[str, ...]] = [
|
||||||
("'", "\\prime", ","),
|
("'", "\\prime", ","),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Same symbol in another length, size or upright/italic shape. Per the project's acceptance rule
|
||||||
|
# ("every symbol right and in the right place; spacing and lengths may differ") such variants
|
||||||
|
# never count as doubt; they may still replace the candidate if they match the image better.
|
||||||
|
# Bold vs. normal and \\mathbb/\\mathcal/\\mathfrak vs. normal stay distinct (vector vs. scalar, ℝ vs. R).
|
||||||
|
EQUIVALENT: list[set[str]] = [
|
||||||
|
{"\\vec", "\\overrightarrow"}, {"\\bar", "\\overline"}, {"\\hat", "\\widehat"}, {"\\tilde", "\\widetilde"},
|
||||||
|
{"\\to", "\\rightarrow", "\\longrightarrow"}, {"\\frac", "\\dfrac", "\\tfrac"}, {"\\le", "\\leq"},
|
||||||
|
{"\\ge", "\\geq"}, {"\\ne", "\\neq"}, {"\\dots", "\\ldots"}, {"'", "\\prime"},
|
||||||
|
{"\\boldsymbol", "\\mathbf", "\\bm"}, {"\\mathrm", "\\mathit"},
|
||||||
|
]
|
||||||
|
_UPRIGHT = {"\\mathrm", "\\mathit"}
|
||||||
|
|
||||||
|
|
||||||
|
def _equivalent(t: str, alt: str) -> bool:
|
||||||
|
return any(t in g and alt in g for g in EQUIVALENT)
|
||||||
|
|
||||||
|
|
||||||
_INDEX: dict[str, list[str]] = {}
|
_INDEX: dict[str, list[str]] = {}
|
||||||
for _g in GROUPS:
|
for _g in GROUPS:
|
||||||
for _t in _g:
|
for _t in _g:
|
||||||
|
|
@ -97,6 +114,7 @@ class Variant:
|
||||||
kind: str # e.g. "subst 3->8", "sub<->sup", "delete -", "insert -"
|
kind: str # e.g. "subst 3->8", "sub<->sup", "delete -", "insert -"
|
||||||
priority: int # lower = tried first when capping
|
priority: int # lower = tried first when capping
|
||||||
probe: str = "" # layout-preserving TeX for the contrast test ("" = render tex itself)
|
probe: str = "" # layout-preserving TeX for the contrast test ("" = render tex itself)
|
||||||
|
equivalent: bool = False # same symbols, only length/size/upright-italic differs (see EQUIVALENT)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def render_tex(self) -> str:
|
def render_tex(self) -> str:
|
||||||
|
|
@ -172,10 +190,11 @@ def variants(tex: str, limit: int = MAX_VARIANTS) -> list[Variant]:
|
||||||
toks = tokenize(tex)
|
toks = tokenize(tex)
|
||||||
out: dict[str, Variant] = {}
|
out: dict[str, Variant] = {}
|
||||||
|
|
||||||
def add(new_toks: list[str], kind: str, prio: int, probe_toks: list[str] | None = None) -> None:
|
def add(new_toks: list[str], kind: str, prio: int, probe_toks: list[str] | None = None,
|
||||||
|
equivalent: bool = False) -> None:
|
||||||
s = "".join(new_toks).strip()
|
s = "".join(new_toks).strip()
|
||||||
if s and s != tex.strip() and s not in out:
|
if s and s != tex.strip() and s not in out:
|
||||||
out[s] = Variant(s, kind, prio, "".join(probe_toks).strip() if probe_toks else "")
|
out[s] = Variant(s, kind, prio, "".join(probe_toks).strip() if probe_toks else "", equivalent)
|
||||||
|
|
||||||
def probe(i: int, wrap: str | None) -> list[str] | None:
|
def probe(i: int, wrap: str | None) -> list[str] | None:
|
||||||
if wrap is None:
|
if wrap is None:
|
||||||
|
|
@ -192,16 +211,16 @@ def variants(tex: str, limit: int = MAX_VARIANTS) -> list[Variant]:
|
||||||
for alt in _INDEX.get(t, []):
|
for alt in _INDEX.get(t, []):
|
||||||
prio = 1 if t.isdigit() or not t.isalpha() else 2
|
prio = 1 if t.isdigit() or not t.isalpha() else 2
|
||||||
add(toks[:i] + [_needs_space(prev, alt, nxt)] + toks[i + 1:], f"subst {t}->{alt}", prio,
|
add(toks[:i] + [_needs_space(prev, alt, nxt)] + toks[i + 1:], f"subst {t}->{alt}", prio,
|
||||||
probe(i, _probe_subst(t, alt)))
|
probe(i, _probe_subst(t, alt)), _equivalent(t, alt))
|
||||||
if len(t) == 1 and t.isalpha() and not _in_font_arg(toks, i):
|
if len(t) == 1 and t.isalpha() and not _in_font_arg(toks, i):
|
||||||
for font in (_FONTS_UPPER if t.isupper() else _FONTS_LOWER):
|
for font in (_FONTS_UPPER if t.isupper() else _FONTS_LOWER):
|
||||||
add(toks[:i] + [font + "{" + t + "}"] + toks[i + 1:], f"font {font}{{{t}}}", 3,
|
add(toks[:i] + [font + "{" + t + "}"] + toks[i + 1:], f"font {font}{{{t}}}", 3,
|
||||||
probe(i, _probe_subst(t, font + "{" + t + "}")))
|
probe(i, _probe_subst(t, font + "{" + t + "}")), font in _UPRIGHT)
|
||||||
if len(t) == 1 and t.isalpha() and t.lower() not in "xvnioepkwatbszg":
|
if len(t) == 1 and t.isalpha() and t.lower() not in "xvnioepkwatbszg":
|
||||||
add(toks[:i] + [t.swapcase()] + toks[i + 1:], f"case {t}", 3, probe(i, _probe_subst(t, t.swapcase())))
|
add(toks[:i] + [t.swapcase()] + toks[i + 1:], f"case {t}", 3, probe(i, _probe_subst(t, t.swapcase())))
|
||||||
if t in _DELETABLE:
|
if t in _DELETABLE:
|
||||||
add(toks[:i] + toks[i + 1:], f"delete {t}", 1,
|
add(toks[:i] + toks[i + 1:], f"delete {t}", 1,
|
||||||
probe(i, None if t in _TAKES_ARG or t.startswith("\\\\,") else _phantom(t)))
|
probe(i, None if t in _TAKES_ARG or t.startswith("\\\\,") else _phantom(t)), t in _UPRIGHT)
|
||||||
if t in _MINUS_AFTER and nxt not in ("-", "+"):
|
if t in _MINUS_AFTER and nxt not in ("-", "+"):
|
||||||
add(toks[:i + 1] + ["-"] + toks[i + 1:], f"insert - after {t}", 2)
|
add(toks[:i + 1] + ["-"] + toks[i + 1:], f"insert - after {t}", 2)
|
||||||
if len(t) == 1 and t.isalpha() and nxt != "'":
|
if len(t) == 1 and t.isalpha() and nxt != "'":
|
||||||
|
|
|
||||||
145
formula_img2latex/consensus.py
Normal file
145
formula_img2latex/consensus.py
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
"""Do two LaTeX readings of the same image say the same thing?
|
||||||
|
|
||||||
|
Acceptance rule of the project: every symbol must be right and in the right place relative to the
|
||||||
|
others; spacing and lengths may differ. Two readings therefore count as the same if
|
||||||
|
|
||||||
|
1. their canonical forms are equal (spacing, \\left/\\right, braces, upright vs. italic, length
|
||||||
|
variants such as \\vec/\\overrightarrow removed or unified), or
|
||||||
|
2. they render to the same picture (render against render: exact, no noisy original involved),
|
||||||
|
e.g. \\sin x vs. \\operatorname{sin} x.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from .compare import _register
|
||||||
|
from .confusables import tokenize
|
||||||
|
from .imageio import trim
|
||||||
|
from .render import render_many
|
||||||
|
from .tex import prepare_for_render, split_tag, strip_delimiters
|
||||||
|
|
||||||
|
# tokens that carry no symbol
|
||||||
|
_DROP = {"\\,", "\\;", "\\:", "\\!", "\\ ", "~", "\\quad", "\\qquad", "\\left", "\\right", "\\big", "\\Big", "\\bigg",
|
||||||
|
"\\Bigg", "\\bigl", "\\bigr", "\\Bigl", "\\Bigr", "\\biggl", "\\biggr", "\\displaystyle", "\\textstyle",
|
||||||
|
"\\scriptstyle", "\\limits", "\\nolimits", "\\middle", "\\hfill", "\\enspace", "\\thinspace", "\\medspace"}
|
||||||
|
# wrappers whose argument is kept (upright vs. italic is the same symbol)
|
||||||
|
_UNWRAP = {"\\mathrm", "\\mathit", "\\operatorname", "\\textrm", "\\textit", "\\mathnormal", "\\mbox", "\\text",
|
||||||
|
"\\textnormal", "\\textup"}
|
||||||
|
_SYNONYM = {
|
||||||
|
"\\overrightarrow": "\\vec", "\\overline": "\\bar", "\\widehat": "\\hat", "\\widetilde": "\\tilde",
|
||||||
|
"\\rightarrow": "\\to", "\\longrightarrow": "\\to", "\\dfrac": "\\frac", "\\tfrac": "\\frac",
|
||||||
|
"\\leq": "\\le", "\\geq": "\\ge", "\\neq": "\\ne", "\\ldots": "\\dots", "\\prime": "'",
|
||||||
|
"\\mathbf": "\\boldsymbol", "\\bm": "\\boldsymbol", "\\lbrace": "\\{", "\\rbrace": "\\}", "\\vert": "|",
|
||||||
|
"\\lvert": "|", "\\rvert": "|", "\\Vert": "\\|", "\\lVert": "\\|", "\\rVert": "\\|", "\\gets": "\\leftarrow",
|
||||||
|
"\\land": "\\wedge", "\\lor": "\\vee", "\\lnot": "\\neg", "\\leqslant": "\\le", "\\geqslant": "\\ge",
|
||||||
|
"\\varnothing": "\\emptyset", "\\colon": ":", "\\cdotp": "\\cdot", "\\ast": "*",
|
||||||
|
"\\iff": "\\Leftrightarrow", "\\Longleftrightarrow": "\\Leftrightarrow", "\\implies": "\\Rightarrow",
|
||||||
|
"\\Longrightarrow": "\\Rightarrow", "\\impliedby": "\\Leftarrow", "\\Longleftarrow": "\\Leftarrow",
|
||||||
|
"\\longleftarrow": "\\leftarrow", "\\longmapsto": "\\mapsto", "\\longleftrightarrow": "\\leftrightarrow",
|
||||||
|
"\\cdots": "\\dots", "\\dotsc": "\\dots", "\\dotsb": "\\dots", "\\mid": "|",
|
||||||
|
}
|
||||||
|
# matrix environments with built-in delimiters -> delimiters + plain matrix
|
||||||
|
_ENV_DELIMS = {"pmatrix": ("(", ")"), "bmatrix": ("[", "]"), "Bmatrix": ("\\{", "\\}"), "vmatrix": ("|", "|"),
|
||||||
|
"Vmatrix": ("\\|", "\\|"), "matrix": ("", "")}
|
||||||
|
|
||||||
|
|
||||||
|
_UMLAUT = {"a": "ä", "o": "ö", "u": "ü", "A": "Ä", "O": "Ö", "U": "Ü"}
|
||||||
|
_BOLD = {"\\boldsymbol", "\\mathbf", "\\bm", "\\pmb"}
|
||||||
|
# commands that never take an argument: a group right after them is free-standing
|
||||||
|
_NOARG = {"\\cdot", "\\times", "\\pm", "\\mp", "\\le", "\\ge", "\\ne", "\\in", "\\to", "\\Rightarrow",
|
||||||
|
"\\Leftrightarrow", "\\approx", "\\equiv", "\\sim", "\\quad", "\\qquad", "\\cup", "\\cap", "\\setminus"}
|
||||||
|
|
||||||
|
|
||||||
|
def canonical(tex: str, bold: bool = False) -> str:
|
||||||
|
"""Canonical token string of a formula (see module docstring).
|
||||||
|
|
||||||
|
*bold*: whether bold vs. normal counts (False: \\boldsymbol{v} and v are the same reading).
|
||||||
|
"""
|
||||||
|
body, tag = split_tag(strip_delimiters(tex))
|
||||||
|
body = prepare_for_render(body)
|
||||||
|
body = re.sub(r'\{?\\"\{?([aouAOU])\}?\}?', lambda m: _UMLAUT[m.group(1)], body) # f{\"u}r -> für
|
||||||
|
body = re.sub(r"\{?\\ss\{?\}?\}?", "ß", body)
|
||||||
|
body = re.sub(r"\{([^{}]*)\}\s*\\atop\s*\{([^{}]*)\}", r"\\substack{\1\\\\\2}", body)
|
||||||
|
body = re.sub(r"\\begin\{array\}\{[^{}]*\}", r"\\begin{matrix}", body).replace("\\end{array}", "\\end{matrix}")
|
||||||
|
for env, (lft, rgt) in _ENV_DELIMS.items():
|
||||||
|
body = body.replace(f"\\begin{{{env}}}", f"{lft} \\begin{{MATRIX}}").replace(f"\\end{{{env}}}", f"\\end{{MATRIX}} {rgt}")
|
||||||
|
body = re.sub(r"\\not\s*=", r"\\ne ", body)
|
||||||
|
body = re.sub(r"\\not\s*\\in(?![A-Za-z])", r"\\notin ", body)
|
||||||
|
toks = [t for t in tokenize(body) if not t.isspace()]
|
||||||
|
toks = [_SYNONYM.get(t, t) for t in toks if t not in _DROP]
|
||||||
|
unwrap = _UNWRAP if bold else _UNWRAP | _BOLD
|
||||||
|
toks = [t for t in toks if t not in unwrap] # keep the argument group, unwrap below
|
||||||
|
toks = _flatten_groups(toks)
|
||||||
|
s = " ".join(toks)
|
||||||
|
return s + (f"#tag({tag})" if tag else "")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse(toks: list[str], i: int = 0) -> tuple[list, int]:
|
||||||
|
"""Token list -> nested lists for {...} groups."""
|
||||||
|
out: list = []
|
||||||
|
while i < len(toks):
|
||||||
|
t = toks[i]
|
||||||
|
if t == "{":
|
||||||
|
sub, i = _parse(toks, i + 1)
|
||||||
|
out.append(sub)
|
||||||
|
continue
|
||||||
|
if t == "}":
|
||||||
|
return out, i + 1
|
||||||
|
out.append(t)
|
||||||
|
i += 1
|
||||||
|
return out, i
|
||||||
|
|
||||||
|
|
||||||
|
def _emit(nodes: list) -> list[str]:
|
||||||
|
"""Serialize: argument groups keep braces (if longer than one token), free groups dissolve."""
|
||||||
|
out: list[str] = []
|
||||||
|
prev: object = None
|
||||||
|
for n in nodes:
|
||||||
|
if isinstance(n, list):
|
||||||
|
inner = _emit(n)
|
||||||
|
is_arg = isinstance(prev, list) or prev in ("^", "_", "]") or (
|
||||||
|
isinstance(prev, str) and prev.startswith("\\") and prev[1:].isalpha() and prev not in _NOARG)
|
||||||
|
if len(inner) <= 1 or not is_arg:
|
||||||
|
out.extend(inner)
|
||||||
|
else:
|
||||||
|
out.extend(["{", *inner, "}"])
|
||||||
|
else:
|
||||||
|
out.append(n)
|
||||||
|
prev = n
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _flatten_groups(toks: list[str]) -> list[str]:
|
||||||
|
return _emit(_parse(toks)[0])
|
||||||
|
|
||||||
|
|
||||||
|
def render_equal(a: str, b: str, display: bool, preset: str = "cm", zoom: float = 4.0) -> bool:
|
||||||
|
"""True if both readings render to the same picture (render vs. render)."""
|
||||||
|
ra, rb = render_many([(a, display), (b, display)], preset)
|
||||||
|
if not (ra.ok and rb.ok):
|
||||||
|
return False
|
||||||
|
ia, ib = trim(ra.rasterize(zoom)), trim(rb.rasterize(zoom))
|
||||||
|
if ia.size == 0 or ib.size == 0 or abs(ia.shape[0] - ib.shape[0]) > 2 or abs(ia.shape[1] - ib.shape[1]) > 2:
|
||||||
|
return False
|
||||||
|
h, w = max(ia.shape[0], ib.shape[0]) + 6, max(ia.shape[1], ib.shape[1]) + 6
|
||||||
|
ca = np.zeros((h, w), np.float32)
|
||||||
|
cb = np.zeros((h, w), np.float32)
|
||||||
|
ca[3:3 + ia.shape[0], 3:3 + ia.shape[1]] = ia
|
||||||
|
cb[3:3 + ib.shape[0], 3:3 + ib.shape[1]] = ib
|
||||||
|
cb = _register(cb, ca, max_px=2)
|
||||||
|
diff = np.abs(ca - cb) > 0.5
|
||||||
|
return int(diff.sum()) <= max(2, int(0.002 * (ca > 0.5).sum()))
|
||||||
|
|
||||||
|
|
||||||
|
def same_reading(a: str, b: str, display: bool, preset: str = "cm", bold: bool = False) -> str | None:
|
||||||
|
"""'normalform' or 'render' if both readings mean the same, else None."""
|
||||||
|
if not a or not b:
|
||||||
|
return None
|
||||||
|
if canonical(a, bold) == canonical(b, bold):
|
||||||
|
return "normalform"
|
||||||
|
if render_equal(strip_delimiters(a), strip_delimiters(b), display, preset):
|
||||||
|
return "render"
|
||||||
|
return None
|
||||||
|
|
@ -12,8 +12,9 @@ from __future__ import annotations
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
DEFAULTS: dict[str, float] = {
|
DEFAULTS: dict[str, float] = {
|
||||||
"min_contrast": 0.40, # accept: worst win over a confusable variant (calibrated: worst rival 0.35)
|
"min_contrast": 2.5, # accept: weakest evidence against any confusable variant, one-line formulas
|
||||||
"review_contrast": -0.10, # below this a variant explains the image better -> reject
|
"min_contrast_multiline": 4.5, # the same for formulas with several lines (aligned, matrices)
|
||||||
|
"review_contrast": -1.0, # below this a variant explains the image better -> reject
|
||||||
"min_ncc": 0.85, # accept
|
"min_ncc": 0.85, # accept
|
||||||
"review_ncc": 0.15, # below: nothing in common (wrong image/figure) -> keep image
|
"review_ncc": 0.15, # below: nothing in common (wrong image/figure) -> keep image
|
||||||
"max_aspect_dev": 0.08, # accept: proportions within 8 %
|
"max_aspect_dev": 0.08, # accept: proportions within 8 %
|
||||||
|
|
@ -43,14 +44,17 @@ def decide(ver: dict[str, Any], pandoc_ok: bool, book_px_per_pt: float | None,
|
||||||
problems.append("Pandoc kann die Formel nicht nach MathML umsetzen")
|
problems.append("Pandoc kann die Formel nicht nach MathML umsetzen")
|
||||||
if ncc < th["min_ncc"]:
|
if ncc < th["min_ncc"]:
|
||||||
problems.append(f"Ähnlichkeit {ncc:.3f} < {th['min_ncc']}")
|
problems.append(f"Ähnlichkeit {ncc:.3f} < {th['min_ncc']}")
|
||||||
if aspect_dev > th["max_aspect_dev"]:
|
# With elastic comparison, lengths and spacing may differ by design; only symbols and their
|
||||||
|
# relative positions count.
|
||||||
|
if aspect_dev > th["max_aspect_dev"] and not m.get("elastic"):
|
||||||
problems.append(f"Proportionen weichen um {aspect_dev:.0%} ab")
|
problems.append(f"Proportionen weichen um {aspect_dev:.0%} ab")
|
||||||
if book_px_per_pt:
|
if book_px_per_pt:
|
||||||
dev = abs(float(m.get("px_per_pt", book_px_per_pt)) / book_px_per_pt - 1.0)
|
dev = abs(float(m.get("px_per_pt", book_px_per_pt)) / book_px_per_pt - 1.0)
|
||||||
if dev > th["max_scale_dev"]:
|
if dev > th["max_scale_dev"]:
|
||||||
problems.append(f"Schriftgröße weicht um {dev:.0%} vom Buch ab (Stil?)")
|
problems.append(f"Schriftgröße weicht um {dev:.0%} vom Buch ab (Stil?)")
|
||||||
if mc is not None and mc < th["min_contrast"]:
|
need = th["min_contrast_multiline"] if "\\\\" in (ver.get("tex") or "") else th["min_contrast"]
|
||||||
problems.append(f"nicht eindeutig gegenüber „{ver.get('worst_variant')}“ (Kontrast {mc:.2f})")
|
if mc is not None and mc < need:
|
||||||
|
problems.append(f"nicht eindeutig gegenüber „{ver.get('worst_variant')}“ (Beleg {mc:.2f} < {need})")
|
||||||
if not problems:
|
if not problems:
|
||||||
why = "alle Prüfungen bestanden"
|
why = "alle Prüfungen bestanden"
|
||||||
if mc is None:
|
if mc is None:
|
||||||
|
|
@ -58,3 +62,88 @@ def decide(ver: dict[str, Any], pandoc_ok: bool, book_px_per_pt: float | None,
|
||||||
return "auto_accepted", why
|
return "auto_accepted", why
|
||||||
hopeless = ncc < th["review_ncc"] or (mc is not None and mc < th["review_contrast"])
|
hopeless = ncc < th["review_ncc"] or (mc is not None and mc < th["review_contrast"])
|
||||||
return ("rejected_keep_image" if hopeless else "needs_review"), "; ".join(problems)
|
return ("rejected_keep_image" if hopeless else "needs_review"), "; ".join(problems)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- consensus mode
|
||||||
|
|
||||||
|
CONSENSUS_DEFAULTS: dict[str, float] = {
|
||||||
|
"min_p_mean": 0.90, # geometric mean of the LaTeX token probabilities, each reading
|
||||||
|
"min_p_min": 0.30, # least certain LaTeX token, each reading
|
||||||
|
"veto_ncc": 0.75, # rough image check: the agreed reading must resemble the original
|
||||||
|
"veto_scale_dev": 0.15, # and have the book's font size (px/pt) within 15 %
|
||||||
|
"bold_relevant": 0, # 1: bold vs. normal counts as a different symbol
|
||||||
|
"require_third": 0, # 1: a third reading (e.g. cloud) must agree as well
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def consensus_thresholds(overrides: dict[str, Any] | None = None) -> dict[str, float]:
|
||||||
|
t = dict(CONSENSUS_DEFAULTS)
|
||||||
|
for k, v in (overrides or {}).items():
|
||||||
|
if k in t and v is not None:
|
||||||
|
t[k] = float(v)
|
||||||
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
def _sure(r: dict[str, Any], th: dict[str, float]) -> str | None:
|
||||||
|
"""None if the reading is confident enough, else a German reason."""
|
||||||
|
pm, pn = r.get("p_mean"), r.get("p_min")
|
||||||
|
if pm is None or pn is None:
|
||||||
|
return f"{r.get('model', '?')}: keine Token-Wahrscheinlichkeiten"
|
||||||
|
if pm < th["min_p_mean"] or pn < th["min_p_min"]:
|
||||||
|
return f"{r.get('model', '?')} unsicher (Mittel {pm:.2f}, Minimum {pn:.2f})"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def decide_consensus(readings: list[dict[str, Any]], agree: list[tuple[int, int, str]], veto: dict[str, Any] | None,
|
||||||
|
pandoc_ok: bool, book_px_per_pt: float | None, th: dict[str, float]
|
||||||
|
) -> tuple[str, str, int | None]:
|
||||||
|
"""Decide from several model readings of one image.
|
||||||
|
|
||||||
|
*readings*: latest candidate per model ({model, tex, is_formula, p_min, p_mean}), the two
|
||||||
|
primary models first. *agree*: pairs (i, j, how) of readings that say the same. *veto*:
|
||||||
|
verification of the chosen reading (rough image check). Returns (status, reason, index of
|
||||||
|
the reading to use or None).
|
||||||
|
"""
|
||||||
|
if len(readings) < 2:
|
||||||
|
return "needs_review", "nur eine Lesart (zweites Modell fehlt)", 0 if readings and readings[0].get("tex") else None
|
||||||
|
flags = [r.get("is_formula") for r in readings[:2]]
|
||||||
|
if all(f is False for f in flags):
|
||||||
|
return "not_formula", "beide Modelle: keine Formel", None
|
||||||
|
pair = None
|
||||||
|
for i, j, how in agree:
|
||||||
|
if {i, j} == {0, 1}:
|
||||||
|
pair = (i, j, how)
|
||||||
|
break
|
||||||
|
tie = ""
|
||||||
|
if pair is None:
|
||||||
|
for i, j, how in agree: # tie-break: a third reading agrees with one of the two
|
||||||
|
if 2 in (i, j) or i >= 2 or j >= 2:
|
||||||
|
pair, tie = (i, j, how), " (Stichwahl)"
|
||||||
|
break
|
||||||
|
if pair is None:
|
||||||
|
texts = " | ".join(f"{r.get('model', '?')}: {r.get('tex') or '–'}" for r in readings[:3])
|
||||||
|
return "needs_review", f"Modelle uneinig: {texts}", 0 if readings[0].get("tex") else 1
|
||||||
|
i, j, how = pair
|
||||||
|
if th.get("require_third") and not tie:
|
||||||
|
third = [k for k in range(2, len(readings))]
|
||||||
|
if not third:
|
||||||
|
return "needs_review", f"einig ({how}), aber dritte Lesart fehlt", i
|
||||||
|
if not any({a, b} in ({0, k}, {1, k}) for a, b, _ in agree for k in third):
|
||||||
|
texts = " | ".join(f"{r.get('model', '?')}: {r.get('tex') or '–'}" for r in readings[2:3])
|
||||||
|
return "needs_review", f"zwei Modelle einig ({how}), dritte Lesart weicht ab: {texts}", i
|
||||||
|
tie = " (dritte Lesart bestätigt)"
|
||||||
|
use = i if (readings[i].get("p_mean") or 0) >= (readings[j].get("p_mean") or 0) else j
|
||||||
|
problems = [p for p in (_sure(readings[i], th), _sure(readings[j], th)) if p]
|
||||||
|
if not pandoc_ok:
|
||||||
|
problems.append("Pandoc kann die Formel nicht nach MathML umsetzen")
|
||||||
|
if veto is None or not veto.get("ok"):
|
||||||
|
problems.append("Bildabgleich nicht möglich" + (f": {veto.get('reason')}" if veto and veto.get("reason") else ""))
|
||||||
|
else:
|
||||||
|
m = veto.get("metrics", {})
|
||||||
|
if m.get("ncc", 0.0) < th["veto_ncc"]:
|
||||||
|
problems.append(f"Bild-Veto: Ähnlichkeit {m.get('ncc', 0.0):.2f} < {th['veto_ncc']}")
|
||||||
|
if book_px_per_pt and abs(m.get("px_per_pt", book_px_per_pt) / book_px_per_pt - 1) > th["veto_scale_dev"]:
|
||||||
|
problems.append(f"Bild-Veto: Schriftgröße weicht um {abs(m['px_per_pt'] / book_px_per_pt - 1):.0%} ab")
|
||||||
|
if problems:
|
||||||
|
return "needs_review", f"einig ({how}){tie}; " + "; ".join(problems), use
|
||||||
|
return "auto_accepted", f"zwei Modelle einig ({how}){tie}, sicher, Bild-Veto bestanden", use
|
||||||
|
|
|
||||||
|
|
@ -12,18 +12,24 @@ import os
|
||||||
from .vlm import VisionEngine
|
from .vlm import VisionEngine
|
||||||
|
|
||||||
DEFAULT_LOCAL_URL = "http://127.0.0.1:8011/v1"
|
DEFAULT_LOCAL_URL = "http://127.0.0.1:8011/v1"
|
||||||
|
DEFAULT_LOCAL2_URL = "http://127.0.0.1:8012/v1"
|
||||||
DEFAULT_CLOUD_URL = "https://openrouter.ai/api/v1"
|
DEFAULT_CLOUD_URL = "https://openrouter.ai/api/v1"
|
||||||
DEFAULT_CLOUD_MODEL = "google/gemini-2.5-flash"
|
DEFAULT_CLOUD_MODEL = "google/gemini-2.5-flash"
|
||||||
|
|
||||||
|
|
||||||
def make_engine(name: str, model: str | None = None, url: str | None = None) -> VisionEngine:
|
def make_engine(name: str, model: str | None = None, url: str | None = None) -> VisionEngine:
|
||||||
"""'local' (llama.cpp server) or 'cloud' (OpenRouter)."""
|
"""'local'/'local2' (two llama.cpp servers with different models) or 'cloud' (OpenRouter)."""
|
||||||
if name == "local":
|
if name in ("local", "local2"):
|
||||||
|
suffix = "" if name == "local" else "2"
|
||||||
return VisionEngine(
|
return VisionEngine(
|
||||||
name="local",
|
name=name,
|
||||||
base_url=url or os.environ.get("FORMULA_LOCAL_URL", DEFAULT_LOCAL_URL),
|
base_url=url or os.environ.get(f"FORMULA_LOCAL{suffix}_URL",
|
||||||
model=model or os.environ.get("FORMULA_LOCAL_MODEL", ""),
|
DEFAULT_LOCAL_URL if name == "local" else DEFAULT_LOCAL2_URL),
|
||||||
api_key=os.environ.get("FORMULA_LOCAL_API_KEY", ""),
|
model=model or os.environ.get(f"FORMULA_LOCAL{suffix}_MODEL", ""),
|
||||||
|
api_key=os.environ.get(f"FORMULA_LOCAL{suffix}_API_KEY", ""),
|
||||||
|
# Transcribing needs no chain of thought; it costs ~10x time (Gemma 4) and flattens the
|
||||||
|
# token probabilities of the answer. FORMULA_LOCAL_THINKING=1 switches it back on.
|
||||||
|
thinking=os.environ.get(f"FORMULA_LOCAL{suffix}_THINKING", "0") == "1",
|
||||||
)
|
)
|
||||||
if name == "cloud":
|
if name == "cloud":
|
||||||
return VisionEngine(
|
return VisionEngine(
|
||||||
|
|
@ -33,4 +39,4 @@ def make_engine(name: str, model: str | None = None, url: str | None = None) ->
|
||||||
api_key=os.environ.get("OPENROUTER_API_KEY", ""),
|
api_key=os.environ.get("OPENROUTER_API_KEY", ""),
|
||||||
needs_key=True,
|
needs_key=True,
|
||||||
)
|
)
|
||||||
raise ValueError(f"unbekannte Engine {name!r} (local, cloud)")
|
raise ValueError(f"unbekannte Engine {name!r} (local, local2, cloud)")
|
||||||
|
|
|
||||||
|
|
@ -25,14 +25,17 @@ PROMPT = """Transcribe the mathematical formula in the image into LaTeX (amsmath
|
||||||
Rules:
|
Rules:
|
||||||
- Reproduce exactly what is printed: every symbol, digit, sign, sub- and superscript, prime, accent and bracket.
|
- Reproduce exactly what is printed: every symbol, digit, sign, sub- and superscript, prime, accent and bracket.
|
||||||
- Do not simplify, complete, reorder or correct anything. Do not add a trailing period unless one is printed.
|
- Do not simplify, complete, reorder or correct anything. Do not add a trailing period unless one is printed.
|
||||||
- Use \\mathbb, \\mathcal, \\mathrm, \\boldsymbol etc. where the print uses such fonts.
|
- Check every letter and digit for BOLD type. Bold symbols (vectors, matrices, the zero vector) are common
|
||||||
|
and easy to miss: write them as \\boldsymbol{v}, \\boldsymbol{0}. Non-bold symbols stay plain.
|
||||||
|
- Use \\mathbb, \\mathcal, \\mathrm etc. where the print uses such fonts.
|
||||||
- Use \\frac for fractions, \\sqrt for roots, \\begin{pmatrix}/\\begin{array}/\\begin{cases} for matrices and cases,
|
- Use \\frac for fractions, \\sqrt for roots, \\begin{pmatrix}/\\begin{array}/\\begin{cases} for matrices and cases,
|
||||||
\\begin{aligned} for several aligned lines.
|
\\begin{aligned} for several aligned lines.
|
||||||
- Write an equation number such as (3.7) at the right margin as \\tag{3.7}.
|
- Write an equation number such as (3.7) at the right margin as \\tag{3.7}.
|
||||||
- Do not wrap the result in $ or \\[ \\].
|
- Do not wrap the result in $ or \\[ \\].
|
||||||
- If the image is not a formula (photo, diagram, plot, plain text), set is_formula to false and latex to "".
|
- If the image is not a formula (photo, diagram, plot, plain text), set is_formula to false and latex to "".
|
||||||
|
- confidence: how sure you are (0-100) that every symbol of your LaTeX matches the image.
|
||||||
{context}
|
{context}
|
||||||
Answer with JSON only: {"is_formula": true, "latex": "..."}"""
|
Answer with JSON only: {"is_formula": true, "latex": "...", "confidence": 0}"""
|
||||||
|
|
||||||
CONTEXT_LINE = "- The formula is {where} this text from the book (for orientation only, do not copy it): \"{text}\"\n"
|
CONTEXT_LINE = "- The formula is {where} this text from the book (for orientation only, do not copy it): \"{text}\"\n"
|
||||||
|
|
||||||
|
|
@ -48,6 +51,9 @@ class Recognition:
|
||||||
seconds: float = 0.0
|
seconds: float = 0.0
|
||||||
tokens_in: int = 0
|
tokens_in: int = 0
|
||||||
tokens_out: int = 0
|
tokens_out: int = 0
|
||||||
|
p_min: float | None = None # smallest token probability inside the LaTeX string
|
||||||
|
p_mean: float | None = None # geometric mean of those token probabilities
|
||||||
|
self_conf: float | None = None # the model's own 0-100 estimate (stored, not trusted)
|
||||||
|
|
||||||
def to_json(self) -> dict:
|
def to_json(self) -> dict:
|
||||||
return {k: v for k, v in self.__dict__.items()}
|
return {k: v for k, v in self.__dict__.items()}
|
||||||
|
|
@ -101,9 +107,63 @@ def parse_answer(text: str) -> tuple[str | None, bool | None]:
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_confidence(text: str) -> float | None:
|
||||||
|
m = re.search(r'"confidence"\s*:\s*"?(\d+(?:\.\d+)?)', text)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
v = float(m.group(1))
|
||||||
|
return v * 100 if v <= 1.0 and "." in m.group(1) else v
|
||||||
|
|
||||||
|
|
||||||
|
def _latex_span(content: str) -> tuple[int, int] | None:
|
||||||
|
"""Character span of the JSON string value of "latex" in the model answer."""
|
||||||
|
m = re.search(r'"latex"\s*:\s*"', content)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
i = m.end()
|
||||||
|
j = i
|
||||||
|
while j < len(content):
|
||||||
|
if content[j] == "\\":
|
||||||
|
j += 2
|
||||||
|
continue
|
||||||
|
if content[j] == '"':
|
||||||
|
return i, j
|
||||||
|
j += 1
|
||||||
|
return i, len(content)
|
||||||
|
|
||||||
|
|
||||||
|
def latex_token_probs(content: str, items: list[dict]) -> tuple[float | None, float | None]:
|
||||||
|
"""(min, geometric mean) of the probabilities of the tokens that make up the LaTeX value.
|
||||||
|
|
||||||
|
*items* is the OpenAI `logprobs.content` list ({"token", "logprob"}). Token texts are laid end
|
||||||
|
to end to find which tokens overlap the LaTeX string; if they do not reproduce the answer
|
||||||
|
(some servers report bytes), all tokens are used.
|
||||||
|
"""
|
||||||
|
lps = [(it.get("token", ""), it.get("logprob")) for it in items if it.get("logprob") is not None]
|
||||||
|
if not lps:
|
||||||
|
return None, None
|
||||||
|
span = _latex_span(content)
|
||||||
|
joined = "".join(t for t, _ in lps)
|
||||||
|
sel: list[float] = []
|
||||||
|
if span and joined == content:
|
||||||
|
pos = 0
|
||||||
|
for tok, lp in lps:
|
||||||
|
a, b = pos, pos + len(tok)
|
||||||
|
pos = b
|
||||||
|
if b > span[0] and a < span[1] and tok.strip():
|
||||||
|
sel.append(float(lp))
|
||||||
|
if not sel:
|
||||||
|
sel = [float(lp) for t, lp in lps if t.strip()]
|
||||||
|
if not sel:
|
||||||
|
return None, None
|
||||||
|
return float(np.exp(min(sel))), float(np.exp(np.mean(sel)))
|
||||||
|
|
||||||
|
|
||||||
class VisionEngine:
|
class VisionEngine:
|
||||||
def __init__(self, name: str, base_url: str, model: str, api_key: str = "", needs_key: bool = False) -> None:
|
def __init__(self, name: str, base_url: str, model: str, api_key: str = "", needs_key: bool = False,
|
||||||
|
thinking: bool | None = None) -> None:
|
||||||
self.name = name
|
self.name = name
|
||||||
|
self.thinking = thinking # None: server default; False: ask llama.cpp chat templates not to "think"
|
||||||
self.base_url = base_url.rstrip("/")
|
self.base_url = base_url.rstrip("/")
|
||||||
self.model = model
|
self.model = model
|
||||||
self.api_key = api_key
|
self.api_key = api_key
|
||||||
|
|
@ -142,6 +202,9 @@ class VisionEngine:
|
||||||
"model": self.model,
|
"model": self.model,
|
||||||
"temperature": 0,
|
"temperature": 0,
|
||||||
"max_tokens": 1500,
|
"max_tokens": 1500,
|
||||||
|
"logprobs": True,
|
||||||
|
"top_logprobs": 1,
|
||||||
|
**({"chat_template_kwargs": {"enable_thinking": self.thinking}} if self.thinking is not None else {}),
|
||||||
"messages": [{
|
"messages": [{
|
||||||
"role": "user",
|
"role": "user",
|
||||||
"content": [
|
"content": [
|
||||||
|
|
@ -162,12 +225,15 @@ class VisionEngine:
|
||||||
continue
|
continue
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
js = r.json()
|
js = r.json()
|
||||||
text = (js["choices"][0]["message"].get("content") or "").strip()
|
choice = js["choices"][0]
|
||||||
|
content = choice["message"].get("content") or ""
|
||||||
|
text = content.strip()
|
||||||
usage = js.get("usage") or {}
|
usage = js.get("usage") or {}
|
||||||
tex, isf = parse_answer(text)
|
tex, isf = parse_answer(text)
|
||||||
|
p_min, p_mean = latex_token_probs(content, ((choice.get("logprobs") or {}).get("content") or []))
|
||||||
return Recognition(self.name, js.get("model", self.model), tex, isf, text[:2000], None,
|
return Recognition(self.name, js.get("model", self.model), tex, isf, text[:2000], None,
|
||||||
time.time() - t0, int(usage.get("prompt_tokens") or 0),
|
time.time() - t0, int(usage.get("prompt_tokens") or 0),
|
||||||
int(usage.get("completion_tokens") or 0))
|
int(usage.get("completion_tokens") or 0), p_min, p_mean, parse_confidence(text))
|
||||||
except (requests.RequestException, KeyError, IndexError, ValueError) as e:
|
except (requests.RequestException, KeyError, IndexError, ValueError) as e:
|
||||||
last_err = str(e)[:300]
|
last_err = str(e)[:300]
|
||||||
time.sleep(1 + attempt)
|
time.sleep(1 + attempt)
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ from PIL import Image
|
||||||
from .checks import pandoc_mathml_ok
|
from .checks import pandoc_mathml_ok
|
||||||
from .classify import classify
|
from .classify import classify
|
||||||
from .compare import diff_image
|
from .compare import diff_image
|
||||||
from .decide import decide, thresholds
|
from .decide import consensus_thresholds, decide, decide_consensus, thresholds
|
||||||
from .engines import make_engine
|
from .engines import make_engine
|
||||||
from .imageio import load_ink
|
from .imageio import load_ink
|
||||||
from .render import PRESETS
|
from .render import PRESETS
|
||||||
|
|
@ -136,6 +136,7 @@ def recognize(source: Source, ws: Workspace, engine_name: str, model: str | None
|
||||||
stats["tokens_in"] += rec.tokens_in
|
stats["tokens_in"] += rec.tokens_in
|
||||||
stats["tokens_out"] += rec.tokens_out
|
stats["tokens_out"] += rec.tokens_out
|
||||||
cand = {"engine": engine.name, "model": engine.model, "tex": rec.tex, "is_formula": rec.is_formula,
|
cand = {"engine": engine.name, "model": engine.model, "tex": rec.tex, "is_formula": rec.is_formula,
|
||||||
|
"p_min": rec.p_min, "p_mean": rec.p_mean, "self_conf": rec.self_conf,
|
||||||
"error": rec.error, "seconds": round(rec.seconds, 2), "created_at": now_iso()}
|
"error": rec.error, "seconds": round(rec.seconds, 2), "created_at": now_iso()}
|
||||||
f.candidates.append(cand)
|
f.candidates.append(cand)
|
||||||
if rec.error:
|
if rec.error:
|
||||||
|
|
@ -155,13 +156,15 @@ def recognize(source: Source, ws: Workspace, engine_name: str, model: str | None
|
||||||
# ------------------------------------------------------------------------------------ verify
|
# ------------------------------------------------------------------------------------ verify
|
||||||
|
|
||||||
def comparison_pngs(c: Any) -> dict[str, bytes]:
|
def comparison_pngs(c: Any) -> dict[str, bytes]:
|
||||||
"""PNG images of an aligned comparison: original, candidate render and colored overlay."""
|
"""PNG images of an aligned comparison: original, candidate render (elastic and rigid), overlay."""
|
||||||
out: dict[str, bytes] = {}
|
out: dict[str, bytes] = {}
|
||||||
if c is None or c.original is None:
|
if c is None or c.original is None:
|
||||||
return out
|
return out
|
||||||
for key, arr in (("orig", c.original), ("render", c.candidate)):
|
for key, arr in (("orig", c.original), ("render", c.candidate), ("rigid", c.raw)):
|
||||||
|
if arr is None:
|
||||||
|
continue
|
||||||
buf = io.BytesIO()
|
buf = io.BytesIO()
|
||||||
Image.fromarray(((1 - np.clip(arr, 0, 1)) * 255).astype(np.uint8), "L").save(buf, "PNG")
|
Image.fromarray(((1 - np.clip(arr, 0, 1)) * 255).astype(np.uint8)).save(buf, "PNG")
|
||||||
out[key] = buf.getvalue()
|
out[key] = buf.getvalue()
|
||||||
buf = io.BytesIO()
|
buf = io.BytesIO()
|
||||||
Image.fromarray(diff_image(c, upscale=1)).save(buf, "PNG")
|
Image.fromarray(diff_image(c, upscale=1)).save(buf, "PNG")
|
||||||
|
|
@ -169,15 +172,20 @@ def comparison_pngs(c: Any) -> dict[str, bytes]:
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _verify_job(args: tuple[str, bytes, str, list[str], bool, str]) -> tuple[str, list[dict], dict[str, bytes]]:
|
def _verify_job(args: tuple) -> tuple[str, list[dict], dict[str, bytes]]:
|
||||||
"""Worker process: verify all distinct candidates of one formula; return records and images."""
|
"""Worker process: verify all distinct candidates of one formula; return records and images.
|
||||||
fid, data, name, texes, display, preset = args
|
|
||||||
|
With variants=False (consensus mode) this is only the rough image check: render, elastic
|
||||||
|
comparison, scale – no confusable variants, no repair.
|
||||||
|
"""
|
||||||
|
fid, data, name, texes, display, preset = args[:6]
|
||||||
|
variants = args[6] if len(args) > 6 else True
|
||||||
os.environ.setdefault("OMP_NUM_THREADS", "1")
|
os.environ.setdefault("OMP_NUM_THREADS", "1")
|
||||||
orig = load_ink(data, name)
|
orig = load_ink(data, name)
|
||||||
results = []
|
results = []
|
||||||
best = None
|
best = None
|
||||||
for tex in texes:
|
for tex in texes:
|
||||||
v = verify(orig, tex, display, preset)
|
v = verify(orig, tex, display, preset, check_variants=variants)
|
||||||
results.append(v.to_json())
|
results.append(v.to_json())
|
||||||
key = (v.ok, v.min_contrast if v.min_contrast is not None else 1.0, v.metrics.get("ncc", 0.0))
|
key = (v.ok, v.min_contrast if v.min_contrast is not None else 1.0, v.metrics.get("ncc", 0.0))
|
||||||
if best is None or key > best[0]:
|
if best is None or key > best[0]:
|
||||||
|
|
@ -229,7 +237,8 @@ def verify_all(source: Source, ws: Workspace, preset: str | None = None, include
|
||||||
jobs = []
|
jobs = []
|
||||||
for f in formulas:
|
for f in formulas:
|
||||||
ref = _ref_for(source, f)
|
ref = _ref_for(source, f)
|
||||||
jobs.append((f.id, source.original_bytes(ref), f.asset, _distinct_candidates(f), f.display, preset))
|
jobs.append((f.id, source.original_bytes(ref), f.asset, _distinct_candidates(f), f.display, preset,
|
||||||
|
decide_mode(ws) == "pixel"))
|
||||||
log(f"verify: {len(jobs)} Formeln, Schrift {preset}", quiet)
|
log(f"verify: {len(jobs)} Formeln, Schrift {preset}", quiet)
|
||||||
by_id = {f.id: f for f in formulas}
|
by_id = {f.id: f for f in formulas}
|
||||||
ws.review_dir.mkdir(parents=True, exist_ok=True)
|
ws.review_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
@ -251,7 +260,7 @@ def verify_all(source: Source, ws: Workspace, preset: str | None = None, include
|
||||||
progress(done, len(jobs))
|
progress(done, len(jobs))
|
||||||
elif not quiet and (done % 25 == 0 or done == len(jobs)):
|
elif not quiet and (done % 25 == 0 or done == len(jobs)):
|
||||||
log(f" {done}/{len(jobs)}")
|
log(f" {done}/{len(jobs)}")
|
||||||
return decide_all(ws, quiet=quiet)
|
return decide_all(ws, quiet=quiet, preset=preset)
|
||||||
|
|
||||||
|
|
||||||
def _cands_key(f: Formula) -> str:
|
def _cands_key(f: Formula) -> str:
|
||||||
|
|
@ -263,7 +272,24 @@ def _verified_current(f: Formula) -> bool:
|
||||||
return v.get("candidates_key") == _cands_key(f)
|
return v.get("candidates_key") == _cands_key(f)
|
||||||
|
|
||||||
|
|
||||||
def decide_all(ws: Workspace, quiet: bool = False) -> dict[str, int]:
|
def decide_mode(ws: Workspace) -> str:
|
||||||
|
return ws.settings.get("decide") or "consensus"
|
||||||
|
|
||||||
|
|
||||||
|
def readings_of(f: Formula) -> list[dict]:
|
||||||
|
"""Latest reading per model; local engines first (primary pair), then the rest (tie-break)."""
|
||||||
|
latest: dict[str, dict] = {}
|
||||||
|
for c in f.candidates:
|
||||||
|
if c.get("error"):
|
||||||
|
continue
|
||||||
|
latest[f"{c.get('engine')}|{c.get('model')}"] = c
|
||||||
|
order = {"local": 0, "local2": 1, "cloud": 2}
|
||||||
|
return sorted(latest.values(), key=lambda c: order.get(c.get("engine", ""), 3))
|
||||||
|
|
||||||
|
|
||||||
|
def decide_all(ws: Workspace, quiet: bool = False, preset: str | None = None) -> dict[str, int]:
|
||||||
|
if decide_mode(ws) == "consensus":
|
||||||
|
return decide_all_consensus(ws, quiet, preset or ws.settings.get("preset") or "cm")
|
||||||
th = thresholds(ws.settings.get("thresholds"))
|
th = thresholds(ws.settings.get("thresholds"))
|
||||||
formulas = [f for f in ws.all() if f.verification and f.status not in HUMAN_STATUSES]
|
formulas = [f for f in ws.all() if f.verification and f.status not in HUMAN_STATUSES]
|
||||||
# Book-wide scale: all formula images of a book share one resolution, so px/pt is constant.
|
# Book-wide scale: all formula images of a book share one resolution, so px/pt is constant.
|
||||||
|
|
@ -294,6 +320,46 @@ def decide_all(ws: Workspace, quiet: bool = False) -> dict[str, int]:
|
||||||
return counts
|
return counts
|
||||||
|
|
||||||
|
|
||||||
|
def decide_all_consensus(ws: Workspace, quiet: bool, preset: str) -> dict[str, int]:
|
||||||
|
from .consensus import same_reading
|
||||||
|
th = consensus_thresholds(ws.settings.get("consensus_thresholds"))
|
||||||
|
formulas = [f for f in ws.all() if f.candidates and f.status not in HUMAN_STATUSES]
|
||||||
|
plan = []
|
||||||
|
for f in formulas:
|
||||||
|
rs = readings_of(f)
|
||||||
|
bold = bool(th.get("bold_relevant"))
|
||||||
|
agree = [(i, j, how) for i in range(len(rs)) for j in range(i + 1, len(rs))
|
||||||
|
for how in [same_reading(rs[i].get("tex") or "", rs[j].get("tex") or "", f.display, preset, bold)] if how]
|
||||||
|
plan.append((f, rs, agree))
|
||||||
|
# Book-wide scale from agreed readings that resemble their original well.
|
||||||
|
by_tex = {f.id: {r["tex"]: r for r in (f.verification or {}).get("all", [])} for f, _, _ in plan}
|
||||||
|
good = []
|
||||||
|
for f, rs, agree in plan:
|
||||||
|
for i, j, _ in agree[:1]:
|
||||||
|
v = by_tex[f.id].get(rs[i].get("tex"))
|
||||||
|
if v and v.get("ok") and v["metrics"].get("ncc", 0) >= 0.85:
|
||||||
|
good.append(v["metrics"]["px_per_pt"])
|
||||||
|
book_ppt = float(statistics.median(good)) if len(good) >= 5 else ws.settings.get("book_px_per_pt")
|
||||||
|
ws.settings["book_px_per_pt"] = book_ppt
|
||||||
|
chosen = []
|
||||||
|
for f, rs, agree in plan:
|
||||||
|
status, reason, use = decide_consensus(rs, agree, None, True, book_ppt, th) # preliminary: which reading
|
||||||
|
chosen.append(rs[use]["tex"] if use is not None and rs[use].get("tex") else None)
|
||||||
|
pandoc = pandoc_mathml_ok([(t or "x", f.display) for (f, _, _), t in zip(plan, chosen)]) if plan else []
|
||||||
|
counts: dict[str, int] = {}
|
||||||
|
for (f, rs, agree), tex, pok in zip(plan, chosen, pandoc):
|
||||||
|
veto = by_tex[f.id].get(tex) if tex else None
|
||||||
|
status, reason, use = decide_consensus(rs, agree, veto, pok, book_ppt, th)
|
||||||
|
f.status, f.reason = status, reason
|
||||||
|
f.final_tex = _with_tag(f, strip_delimiters(tex)) if status == "auto_accepted" and tex else None
|
||||||
|
f.decided_at = now_iso()
|
||||||
|
ws.save(f)
|
||||||
|
counts[status] = counts.get(status, 0) + 1
|
||||||
|
ws.save_meta()
|
||||||
|
log("Entscheidung (Konsens): " + ", ".join(f"{k} {v}" for k, v in sorted(counts.items())), quiet)
|
||||||
|
return counts
|
||||||
|
|
||||||
|
|
||||||
def _with_tag(f: Formula, tex: str) -> str:
|
def _with_tag(f: Formula, tex: str) -> str:
|
||||||
"""Re-attach an equation number recognized as \\tag{...} – as visible text, like epub2md does."""
|
"""Re-attach an equation number recognized as \\tag{...} – as visible text, like epub2md does."""
|
||||||
for c in f.candidates:
|
for c in f.candidates:
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ from .compare import Comparison, compare, contrast
|
||||||
from .confusables import variants
|
from .confusables import variants
|
||||||
from .render import render_many
|
from .render import render_many
|
||||||
|
|
||||||
SWITCH_MARGIN = 0.25 # a variant must beat the candidate by this much to replace it
|
SWITCH_MARGIN = 2.0 # evidence (see compare.contrast) by which a variant must win to replace the candidate
|
||||||
MAX_CLIMB = 3
|
MAX_CLIMB = 3
|
||||||
MIN_NCC_FOR_VARIANTS = 0.6 # hopeless candidates are not worth the variant test
|
MIN_NCC_FOR_VARIANTS = 0.6 # hopeless candidates are not worth the variant test
|
||||||
|
|
||||||
|
|
@ -53,21 +53,23 @@ def _check_variants(orig: np.ndarray, cand: Comparison, tex: str, display: bool,
|
||||||
if not vs:
|
if not vs:
|
||||||
return None, None, None, 0, 0, None
|
return None, None, None, 0, 0, None
|
||||||
rendered = render_many([(v.render_tex, display) for v in vs], preset)
|
rendered = render_many([(v.render_tex, display) for v in vs], preset)
|
||||||
ppt = cand.metrics["px_per_pt"]
|
|
||||||
min_c, worst, worst_kind, n, eq = None, None, None, 0, 0
|
min_c, worst, worst_kind, n, eq = None, None, None, 0, 0
|
||||||
rival: tuple[float, str] | None = None
|
rival: tuple[float, str] | None = None
|
||||||
for v, r in zip(vs, rendered):
|
for v, r in zip(vs, rendered):
|
||||||
if not r.ok:
|
if not r.ok:
|
||||||
continue
|
continue
|
||||||
c = contrast(cand, compare(orig, r, px_per_pt=ppt))
|
c = contrast(cand, compare(orig, r, warp_from=cand))
|
||||||
if c is None:
|
if c is None:
|
||||||
eq += 1
|
eq += 1
|
||||||
continue
|
continue
|
||||||
|
if c < -SWITCH_MARGIN and (rival is None or c < rival[0]):
|
||||||
|
rival = (c, v.tex)
|
||||||
|
if v.equivalent:
|
||||||
|
eq += 1
|
||||||
|
continue
|
||||||
n += 1
|
n += 1
|
||||||
if min_c is None or c < min_c:
|
if min_c is None or c < min_c:
|
||||||
min_c, worst, worst_kind = c, v.tex, v.kind
|
min_c, worst, worst_kind = c, v.tex, v.kind
|
||||||
if c < -SWITCH_MARGIN and (rival is None or c < rival[0]):
|
|
||||||
rival = (c, v.tex)
|
|
||||||
return min_c, worst, worst_kind, n, eq, rival
|
return min_c, worst, worst_kind, n, eq, rival
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -95,12 +95,14 @@ def test_verify_accepts_truth_and_repairs_single_errors() -> None:
|
||||||
@needs_latex
|
@needs_latex
|
||||||
def test_decision_rules() -> None:
|
def test_decision_rules() -> None:
|
||||||
th = thresholds()
|
th = thresholds()
|
||||||
ok = {"ok": True, "tex": "x", "metrics": {"ncc": 0.95, "aspect_dev": 0.0, "px_per_pt": 1.4}, "min_contrast": 0.6}
|
ok = {"ok": True, "tex": "x", "metrics": {"ncc": 0.95, "aspect_dev": 0.0, "px_per_pt": 1.4}, "min_contrast": 3.0}
|
||||||
assert decide(ok, True, 1.4, th)[0] == "auto_accepted"
|
assert decide(ok, True, 1.4, th)[0] == "auto_accepted"
|
||||||
assert decide(ok, False, 1.4, th)[0] == "needs_review" # Pandoc fails
|
assert decide(ok, False, 1.4, th)[0] == "needs_review" # Pandoc fails
|
||||||
assert decide(ok, True, 1.7, th)[0] == "needs_review" # wrong size/style
|
assert decide(ok, True, 1.7, th)[0] == "needs_review" # wrong size/style
|
||||||
assert decide({**ok, "min_contrast": 0.2}, True, 1.4, th)[0] == "needs_review"
|
assert decide({**ok, "min_contrast": 1.0}, True, 1.4, th)[0] == "needs_review"
|
||||||
assert decide({**ok, "min_contrast": -0.5}, True, 1.4, th)[0] == "rejected_keep_image"
|
assert decide({**ok, "min_contrast": -2.0}, True, 1.4, th)[0] == "rejected_keep_image"
|
||||||
|
multi = {**ok, "tex": r"\begin{aligned}a&=b\\c&=d\end{aligned}"}
|
||||||
|
assert decide(multi, True, 1.4, th)[0] == "needs_review" # stricter for several lines
|
||||||
assert decide({"ok": False, "reason": "x"}, True, None, th)[0] == "render_failed"
|
assert decide({"ok": False, "reason": "x"}, True, None, th)[0] == "render_failed"
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,8 @@ def _inject(ws: Workspace, by_asset: dict[str, str]) -> None:
|
||||||
The mechanics are tested here, not the calibrated thresholds (test_core.test_decision_rules),
|
The mechanics are tested here, not the calibrated thresholds (test_core.test_decision_rules),
|
||||||
so the contrast threshold is set low enough for the synthetic images.
|
so the contrast threshold is set low enough for the synthetic images.
|
||||||
"""
|
"""
|
||||||
ws.settings["thresholds"] = {"min_contrast": 0.1}
|
ws.settings["thresholds"] = {"min_contrast": 0.5}
|
||||||
|
ws.settings["decide"] = "pixel"
|
||||||
ws.save_meta()
|
ws.save_meta()
|
||||||
for f in ws.all():
|
for f in ws.all():
|
||||||
for name, tex in by_asset.items():
|
for name, tex in by_asset.items():
|
||||||
|
|
@ -106,3 +107,81 @@ def test_prepare_image_enlarges_small_formulas(epub2md_dir: Path) -> None:
|
||||||
data = (epub2md_dir / "media" / "images" / "0001-inline.jpg").read_bytes()
|
data = (epub2md_dir / "media" / "images" / "0001-inline.jpg").read_bytes()
|
||||||
with Image.open(io.BytesIO(prepare_image(data, "x.jpg"))) as im:
|
with Image.open(io.BytesIO(prepare_image(data, "x.jpg"))) as im:
|
||||||
assert im.height >= 96 and im.mode == "L"
|
assert im.height >= 96 and im.mode == "L"
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------- consensus
|
||||||
|
|
||||||
|
def _reading(engine: str, model: str, tex: str | None, p_mean: float = 0.99, p_min: float = 0.8,
|
||||||
|
is_formula: bool = True) -> dict:
|
||||||
|
return {"engine": engine, "model": model, "tex": tex, "is_formula": is_formula, "p_mean": p_mean, "p_min": p_min}
|
||||||
|
|
||||||
|
|
||||||
|
@needs_latex
|
||||||
|
@needs_pandoc
|
||||||
|
def test_consensus_mode(epub2md_dir: Path) -> None:
|
||||||
|
source, ws = pipeline.open_all(epub2md_dir, None, create=True)
|
||||||
|
pipeline.scan(source, ws, quiet=True)
|
||||||
|
by = {f.asset.rsplit("/", 1)[1]: f for f in ws.all()}
|
||||||
|
inline, display = by["0001-inline.jpg"], by["0002-display.jpg"]
|
||||||
|
inline.candidates = [_reading("local", "A", r"L=\{-\sqrt{2};\sqrt{2}\}"),
|
||||||
|
_reading("local2", "B", r"L = \{ - \sqrt2 ; \sqrt2 \}")] # same, other spelling
|
||||||
|
display.candidates = [_reading("local", "A", r"x_{2,k}=\arcsin\left(-\frac{3}{5}\right)+2k\pi"),
|
||||||
|
_reading("local2", "B", r"x_{3,k}=\arcsin\left(-\frac{3}{5}\right)+2k\pi")] # differ
|
||||||
|
ws.save(inline)
|
||||||
|
ws.save(display)
|
||||||
|
pipeline.verify_all(source, ws, preset="cm", workers=2, quiet=True)
|
||||||
|
st = {f.asset.rsplit("/", 1)[1]: f for f in ws.all()}
|
||||||
|
assert st["0001-inline.jpg"].status == "auto_accepted", st["0001-inline.jpg"].reason
|
||||||
|
assert st["0002-display.jpg"].status == "needs_review"
|
||||||
|
assert "uneinig" in st["0002-display.jpg"].reason
|
||||||
|
|
||||||
|
# a third reading breaks the tie
|
||||||
|
d = st["0002-display.jpg"]
|
||||||
|
d.candidates.append(_reading("cloud", "C", r"x_{2,k} = \arcsin(-\frac35) + 2k\pi"))
|
||||||
|
ws.save(d)
|
||||||
|
pipeline.verify_all(source, ws, preset="cm", workers=2, quiet=True)
|
||||||
|
d = ws.load(d.id)
|
||||||
|
assert d.status == "auto_accepted" and "Stichwahl" in d.reason, d.reason
|
||||||
|
|
||||||
|
|
||||||
|
def test_consensus_rules() -> None:
|
||||||
|
from formula_img2latex.decide import consensus_thresholds, decide_consensus
|
||||||
|
th = consensus_thresholds()
|
||||||
|
a, b = _reading("local", "A", "x^2"), _reading("local2", "B", "x^{2}")
|
||||||
|
veto = {"ok": True, "metrics": {"ncc": 0.9, "px_per_pt": 1.4}}
|
||||||
|
assert decide_consensus([a, b], [(0, 1, "normalform")], veto, True, 1.4, th)[0] == "auto_accepted"
|
||||||
|
assert decide_consensus([a, b], [], veto, True, 1.4, th)[0] == "needs_review" # disagree
|
||||||
|
low = _reading("local2", "B", "x^{2}", p_mean=0.7)
|
||||||
|
assert decide_consensus([a, low], [(0, 1, "normalform")], veto, True, 1.4, th)[0] == "needs_review" # unsure
|
||||||
|
bad = {"ok": True, "metrics": {"ncc": 0.5, "px_per_pt": 1.4}}
|
||||||
|
assert decide_consensus([a, b], [(0, 1, "normalform")], bad, True, 1.4, th)[0] == "needs_review" # veto
|
||||||
|
big = {"ok": True, "metrics": {"ncc": 0.9, "px_per_pt": 1.8}}
|
||||||
|
assert decide_consensus([a, b], [(0, 1, "normalform")], big, True, 1.4, th)[0] == "needs_review" # size
|
||||||
|
assert decide_consensus([a, b], [(0, 1, "normalform")], veto, False, 1.4, th)[0] == "needs_review" # pandoc
|
||||||
|
nf = [_reading("local", "A", None, is_formula=False), _reading("local2", "B", None, is_formula=False)]
|
||||||
|
assert decide_consensus(nf, [], None, True, 1.4, th)[0] == "not_formula"
|
||||||
|
assert decide_consensus([a], [], veto, True, 1.4, th)[0] == "needs_review" # one model
|
||||||
|
|
||||||
|
|
||||||
|
def test_canonical_form() -> None:
|
||||||
|
from formula_img2latex.consensus import canonical
|
||||||
|
same = [(r"x^{2}+y^{2}", "x^2 + y^2"), (r"\vec{b}", r"\overrightarrow{b}"), (r"\left(a\right)", "(a)"),
|
||||||
|
(r"\mathrm{d}x", "dx"), (r"\frac{1}{2}", r"\dfrac12"), (r"{\mathrm{e}}^{x}", "e^x"), (r"x\le1", r"x \leq 1"),
|
||||||
|
(r"\mathbf{v}", r"\boldsymbol{v}")]
|
||||||
|
differ = [(r"x_{2,k}", r"x_{2;k}"), ("x^{2}", "x_{2}"), (r"\mathbb{R}", "R"), ("a_{n+1}", "a_n+1"),
|
||||||
|
(r"\frac{a}{b+c}", r"\frac{a}{b}+c"), (r"x \in t", r"x \int"), (r"\sqrt[3]{x}", r"\sqrt{x}")]
|
||||||
|
assert all(canonical(a) == canonical(b) for a, b in same)
|
||||||
|
assert all(canonical(a) != canonical(b) for a, b in differ)
|
||||||
|
|
||||||
|
|
||||||
|
def test_logprob_confidence() -> None:
|
||||||
|
import math
|
||||||
|
|
||||||
|
from formula_img2latex.engines.vlm import latex_token_probs, parse_confidence
|
||||||
|
content = '{"is_formula": true, "latex": "x^{2}", "confidence": 97}'
|
||||||
|
toks = ['{"', "is", "_formula", '": ', "true", ', "', "latex", '": "', "x", "^{", "2", "}", '", "', "confidence",
|
||||||
|
'": ', "97", "}"]
|
||||||
|
items = [{"token": t, "logprob": math.log(0.5 if t == "2" else 0.99)} for t in toks]
|
||||||
|
p_min, p_mean = latex_token_probs(content, items)
|
||||||
|
assert p_min == 0.5 and 0.8 < p_mean < 0.9 # only the four LaTeX tokens count
|
||||||
|
assert parse_confidence(content) == 97
|
||||||
|
|
|
||||||
125
tools/konsens_bericht.py
Normal file
125
tools/konsens_bericht.py
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Visual report of a consensus calibration (calibrate --consensus --report X.json).
|
||||||
|
|
||||||
|
python3 tools/konsens_bericht.py BERICHT.json EPUB [--name konsens-goellmann]
|
||||||
|
|
||||||
|
Writes vergleich/<name>/index.html (not versioned): per formula the original image, the rendered
|
||||||
|
truth, the rendered readings of both models and the verdict (agree+right / agree+WRONG / disagree).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import html
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import random
|
||||||
|
import sys
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
from formula_img2latex.calibrate import _dvipng_jpeg # noqa: E402
|
||||||
|
from formula_img2latex.render import render_many # noqa: E402
|
||||||
|
from formula_img2latex.tex import strip_delimiters # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def png_uri(img: Image.Image) -> str:
|
||||||
|
buf = io.BytesIO()
|
||||||
|
img.save(buf, "PNG")
|
||||||
|
return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def orig_img(data: bytes, scale: int) -> Image.Image:
|
||||||
|
im = Image.open(io.BytesIO(data))
|
||||||
|
if im.mode in ("P", "LA", "RGBA") or "transparency" in im.info:
|
||||||
|
im = im.convert("RGBA")
|
||||||
|
bg = Image.new("RGBA", im.size, "white")
|
||||||
|
im = Image.alpha_composite(bg, im)
|
||||||
|
im = im.convert("RGB")
|
||||||
|
return im.resize((im.width * scale, im.height * scale), Image.NEAREST)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("report", type=Path)
|
||||||
|
ap.add_argument("epub", type=Path)
|
||||||
|
ap.add_argument("--name", default=None)
|
||||||
|
args = ap.parse_args()
|
||||||
|
rep = json.loads(args.report.read_text(encoding="utf-8"))
|
||||||
|
name = args.name or "konsens-" + args.epub.name.split("_")[0].lower()
|
||||||
|
out = Path(__file__).resolve().parents[1] / "vergleich" / name
|
||||||
|
out.mkdir(parents=True, exist_ok=True)
|
||||||
|
z = zipfile.ZipFile(args.epub)
|
||||||
|
models = rep.get("engines", ["A", "B"])
|
||||||
|
parts = [f"<h1>Konsensmessung: {html.escape(args.epub.name[:70])}</h1>",
|
||||||
|
f"<p>Modell A = {html.escape(models[0])}, Modell B = {html.escape(models[1])}. "
|
||||||
|
"Grün = beide einig und richtig, <b style='color:#a32020'>Rot = beide einig, aber falsch</b>, "
|
||||||
|
"Grau = uneinig. Gerendert mit pdflatex; „richtig“ heißt: gleiche Zeichen an gleicher Stelle "
|
||||||
|
"(Abstände, Pfeillängen, kursiv/aufrecht egal).</p>"]
|
||||||
|
for s in rep["sets"]:
|
||||||
|
res = s["results"]
|
||||||
|
n = max(1, len(res))
|
||||||
|
parts.append(f"<h2>{html.escape(s['label'])}</h2><p>{len(res)} Formeln · A richtig {s['a_correct'] / n:.0%} · "
|
||||||
|
f"B richtig {s['b_correct'] / n:.0%} · einig {s['agree'] / n:.0%} · "
|
||||||
|
f"<b>einig, aber falsch: {s['agree_wrong']}</b></p>")
|
||||||
|
order = sorted(res, key=lambda r: (0 if r["agree"] and not r["a_ok"] else 1 if not r["agree"] else 2))
|
||||||
|
texes = []
|
||||||
|
for r in order:
|
||||||
|
texes += [(strip_delimiters(r["truth"]), r["display"]), (r["a"].get("tex") or "", r["display"]),
|
||||||
|
(r["b"].get("tex") or "", r["display"])]
|
||||||
|
rendered = render_many([(t or "\\text{–}", d) for t, d in texes], "cm")
|
||||||
|
rng = random.Random(1)
|
||||||
|
for k, r in enumerate(order):
|
||||||
|
if r["name"].endswith("#synth.jpg"):
|
||||||
|
data = _dvipng_jpeg(strip_delimiters(r["truth"]), r["display"], 102) or b""
|
||||||
|
else:
|
||||||
|
data = z.read(r["name"])
|
||||||
|
try:
|
||||||
|
o = orig_img(data, 2)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
continue
|
||||||
|
imgs = []
|
||||||
|
for j in range(3):
|
||||||
|
rr = rendered[3 * k + j]
|
||||||
|
if rr.ok:
|
||||||
|
g = (rr.rasterize(2.0) * 255).astype(np.uint8)
|
||||||
|
imgs.append(png_uri(Image.fromarray(255 - g)))
|
||||||
|
else:
|
||||||
|
imgs.append("")
|
||||||
|
if r["agree"] and not r["a_ok"]:
|
||||||
|
cls, verdict = "bad", "einig, aber FALSCH"
|
||||||
|
elif r["agree"]:
|
||||||
|
cls, verdict = "good", "einig und richtig"
|
||||||
|
else:
|
||||||
|
cls, verdict = "dis", "uneinig"
|
||||||
|
mark = lambda ok: "✓" if ok else "✗" # noqa: E731
|
||||||
|
pa, pb = r["a"].get("p_mean"), r["b"].get("p_mean")
|
||||||
|
parts.append(
|
||||||
|
f"<section class={cls}><div class=v>{verdict}</div><table>"
|
||||||
|
f"<tr><th>Original</th><td><img src='{png_uri(o)}'></td><td></td></tr>"
|
||||||
|
f"<tr><th>richtig</th><td><img src='{imgs[0]}'></td><td><code>{html.escape(r['truth'])}</code></td></tr>"
|
||||||
|
f"<tr><th>A {mark(r['a_ok'])}</th><td><img src='{imgs[1]}'></td><td><code>{html.escape(r['a'].get('tex') or '–')}</code>"
|
||||||
|
f" <small>p={pa and round(pa, 3)}</small></td></tr>"
|
||||||
|
f"<tr><th>B {mark(r['b_ok'])}</th><td><img src='{imgs[2]}'></td><td><code>{html.escape(r['b'].get('tex') or '–')}</code>"
|
||||||
|
f" <small>p={pb and round(pb, 3)}</small></td></tr></table></section>")
|
||||||
|
del rng
|
||||||
|
rng = random.Random(1)
|
||||||
|
(out / "index.html").write_text(
|
||||||
|
"<!doctype html><meta charset=utf-8><title>Konsens</title><style>"
|
||||||
|
"body{font:14px system-ui,sans-serif;margin:16px;background:#f6f6f3}section{background:#fff;border:1px solid #ddd;"
|
||||||
|
"border-left:6px solid #999;border-radius:6px;padding:6px 10px;margin:8px 0}section.good{border-left-color:#1f7a3a}"
|
||||||
|
"section.bad{border-left-color:#a32020;background:#fff5f5}.v{font-weight:600;font-size:13px}"
|
||||||
|
"th{text-align:left;font-weight:400;color:#666;width:80px;vertical-align:middle}td{padding:2px 8px;vertical-align:middle}"
|
||||||
|
"code{font-size:12px}img{max-width:700px}</style>" + "".join(parts), encoding="utf-8")
|
||||||
|
print(out / "index.html")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
160
tools/vergleich.py
Normal file
160
tools/vergleich.py
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Tuning aid: verify a fixed sample of formulas and write comparison images + an HTML overview.
|
||||||
|
|
||||||
|
python3 tools/vergleich.py AUSGABE [--n 50] [--seed 1] [--name gehrke-50]
|
||||||
|
|
||||||
|
The sample (formula ids) is stored in vergleich/<name>/sample.json and reused on later runs, so
|
||||||
|
changes to the procedure are compared on the same formulas. Output (not versioned, it shows
|
||||||
|
excerpts of the book):
|
||||||
|
|
||||||
|
vergleich/<name>/
|
||||||
|
├── index.html all formulas: image panel, LaTeX, measurements, decision, reason
|
||||||
|
├── summary.json counts per status, all numbers
|
||||||
|
├── sample.json the formula ids of the sample
|
||||||
|
└── NN-<id>.png Original / LaTeX rigid / LaTeX elastic / difference
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import html
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import random
|
||||||
|
import sys
|
||||||
|
from concurrent.futures import ProcessPoolExecutor, as_completed
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
from formula_img2latex.checks import pandoc_mathml_ok # noqa: E402
|
||||||
|
from formula_img2latex.decide import decide, thresholds # noqa: E402
|
||||||
|
from formula_img2latex.pipeline import _distinct_candidates, _ref_for, _verify_job, open_all # noqa: E402
|
||||||
|
|
||||||
|
FONT = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"
|
||||||
|
LABELS = (("orig", "Original"), ("rigid", "LaTeX starr"), ("render", "LaTeX elastisch"), ("diff", "Differenz"))
|
||||||
|
|
||||||
|
|
||||||
|
def panel(pngs: dict[str, bytes], target_h: int = 70) -> Image.Image:
|
||||||
|
"""Stack the four images, enlarged with visible pixels, each with a label."""
|
||||||
|
imgs = {k: Image.open(io.BytesIO(v)).convert("RGB") for k, v in pngs.items()}
|
||||||
|
if not imgs:
|
||||||
|
return Image.new("RGB", (300, 40), "white")
|
||||||
|
h0 = next(iter(imgs.values())).height
|
||||||
|
f = max(1, min(8, round(target_h / max(h0, 1))))
|
||||||
|
try:
|
||||||
|
font = ImageFont.truetype(FONT, 14)
|
||||||
|
except OSError:
|
||||||
|
font = ImageFont.load_default()
|
||||||
|
rows = []
|
||||||
|
for key, label in LABELS:
|
||||||
|
if key not in imgs:
|
||||||
|
continue
|
||||||
|
im = imgs[key]
|
||||||
|
im = im.resize((im.width * f, im.height * f), Image.NEAREST)
|
||||||
|
row = Image.new("RGB", (im.width + 140, im.height + 8), "white")
|
||||||
|
ImageDraw.Draw(row).text((4, 4), label, fill=(80, 80, 80), font=font)
|
||||||
|
row.paste(im, (136, 4))
|
||||||
|
rows.append(row)
|
||||||
|
w = max(r.width for r in rows)
|
||||||
|
out = Image.new("RGB", (w, sum(r.height for r in rows) + 4), "white")
|
||||||
|
y = 0
|
||||||
|
for r in rows:
|
||||||
|
out.paste(r, (0, y))
|
||||||
|
y += r.height
|
||||||
|
ImageDraw.Draw(out).rectangle([0, 0, out.width - 1, out.height - 1], outline=(200, 200, 200))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
ap.add_argument("source", type=Path)
|
||||||
|
ap.add_argument("--n", type=int, default=50)
|
||||||
|
ap.add_argument("--seed", type=int, default=1)
|
||||||
|
ap.add_argument("--name", default=None, help="Unterordner in vergleich/ (Standard: <buch>-<n>)")
|
||||||
|
ap.add_argument("--preset", default=None)
|
||||||
|
ap.add_argument("--workers", type=int, default=16)
|
||||||
|
ap.add_argument("--new-sample", action="store_true", help="neue Stichprobe ziehen")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
source, ws = open_all(args.source, None)
|
||||||
|
name = args.name or f"{args.source.name.split('_')[0].lower()}-{args.n}"
|
||||||
|
out = Path(__file__).resolve().parents[1] / "vergleich" / name
|
||||||
|
out.mkdir(parents=True, exist_ok=True)
|
||||||
|
sample_file = out / "sample.json"
|
||||||
|
pool = [f for f in ws.all() if _distinct_candidates(f) and (f.classify or {}).get("label") != "figure"]
|
||||||
|
if sample_file.exists() and not args.new_sample:
|
||||||
|
ids = json.loads(sample_file.read_text())
|
||||||
|
else:
|
||||||
|
ids = sorted(f.id for f in random.Random(args.seed).sample(pool, min(args.n, len(pool))))
|
||||||
|
sample_file.write_text(json.dumps(ids, indent=1))
|
||||||
|
by_id = {f.id: f for f in pool}
|
||||||
|
formulas = [by_id[i] for i in ids if i in by_id]
|
||||||
|
preset = args.preset or ws.settings.get("preset") or "cm"
|
||||||
|
book_ppt = ws.settings.get("book_px_per_pt")
|
||||||
|
th = thresholds(ws.settings.get("thresholds"))
|
||||||
|
|
||||||
|
jobs = [(f.id, source.original_bytes(_ref_for(source, f)), f.asset, _distinct_candidates(f), f.display, preset)
|
||||||
|
for f in formulas]
|
||||||
|
results: dict[str, tuple[dict, dict[str, bytes]]] = {}
|
||||||
|
with ProcessPoolExecutor(max_workers=args.workers) as ex:
|
||||||
|
for fut in as_completed([ex.submit(_verify_job, j) for j in jobs]):
|
||||||
|
fid, res, pngs = fut.result()
|
||||||
|
best = max(res, key=lambda r: (r["ok"], r["min_contrast"] if r["min_contrast"] is not None else 1.0,
|
||||||
|
r["metrics"].get("ncc", 0.0)))
|
||||||
|
results[fid] = (best, pngs)
|
||||||
|
print(f" {len(results)}/{len(jobs)}", file=sys.stderr, end="\r")
|
||||||
|
print(file=sys.stderr)
|
||||||
|
pandoc = dict(zip([f.id for f in formulas], pandoc_mathml_ok([(results[f.id][0]["tex"], f.display) for f in formulas])))
|
||||||
|
|
||||||
|
for old in out.glob("*.png"):
|
||||||
|
old.unlink()
|
||||||
|
counts: dict[str, int] = {}
|
||||||
|
rows_html, summary = [], []
|
||||||
|
for n, f in enumerate(formulas, 1):
|
||||||
|
best, pngs = results[f.id]
|
||||||
|
status, reason = decide(best, pandoc[f.id], book_ppt, th)
|
||||||
|
counts[status] = counts.get(status, 0) + 1
|
||||||
|
img = f"{n:02d}-{f.id}.png"
|
||||||
|
panel(pngs).save(out / img)
|
||||||
|
m = best.get("metrics", {})
|
||||||
|
mc = best.get("min_contrast")
|
||||||
|
summary.append({"n": n, "id": f.id, "status": status, "reason": reason, "tex": best.get("tex"),
|
||||||
|
"ocr": [c.get("tex") for c in f.candidates if c.get("tex")], "display": f.display,
|
||||||
|
"min_contrast": mc, "worst_variant": best.get("worst_variant"),
|
||||||
|
"worst_kind": best.get("worst_kind"), "climbed_from": best.get("climbed_from"),
|
||||||
|
"metrics": m})
|
||||||
|
climbed = ""
|
||||||
|
if best.get("climbed_from"):
|
||||||
|
climbed = "<div class=fix>korrigiert aus: <code>" + html.escape(best["climbed_from"][0]) + "</code></div>"
|
||||||
|
rows_html.append(
|
||||||
|
f"<section class='{status}'><h2>{n:02d} · <span class=st>{status}</span> · "
|
||||||
|
f"{'abgesetzt' if f.display else 'im Text'} · {html.escape(f.occurrences[0]['file'])}:{f.occurrences[0]['line']}</h2>"
|
||||||
|
f"<img src='{img}'><div><code>{html.escape(best.get('tex') or '')}</code></div>{climbed}"
|
||||||
|
f"<div class=m>NCC elastisch {m.get('ncc', 0):.3f} · starr {m.get('rigid_ncc', 0):.3f} · "
|
||||||
|
f"Verzerrung {m.get('warp_share', 0):.2f} · px/pt {m.get('px_per_pt', 0):.3f} · "
|
||||||
|
f"Kontrast {('–' if mc is None else f'{mc:.3f}')} (knappste Variante: {html.escape(best.get('worst_kind') or '–')}) · "
|
||||||
|
f"Pandoc {'ok' if pandoc[f.id] else 'FEHLER'}</div><div class=r>{html.escape(reason)}</div></section>")
|
||||||
|
head = " · ".join(f"{k}: {v}" for k, v in sorted(counts.items()))
|
||||||
|
(out / "index.html").write_text(
|
||||||
|
"<!doctype html><meta charset=utf-8><title>Vergleich " + html.escape(name) + "</title><style>"
|
||||||
|
"body{font:14px system-ui,sans-serif;margin:16px;background:#f6f6f3}section{background:#fff;border:1px solid #ddd;"
|
||||||
|
"border-radius:6px;padding:8px 12px;margin:10px 0}h2{font-size:14px;margin:0 0 6px}img{max-width:100%}"
|
||||||
|
".auto_accepted .st{color:#1f7a3a}.needs_review .st{color:#a15c00}.rejected_keep_image .st,.render_failed .st{color:#a32020}"
|
||||||
|
".m{font:12px monospace;color:#555;margin-top:4px}.r{font-size:13px}.fix{font-size:12px;color:#2a5db0}"
|
||||||
|
"code{font-size:12px}</style>"
|
||||||
|
f"<h1>Vergleich {html.escape(name)}</h1><p>{len(formulas)} Formeln, Schrift {preset} · {head}</p>"
|
||||||
|
"<p>Zeilen je Bild: Original · LaTeX starr ausgerichtet · LaTeX elastisch ausgerichtet · Differenz "
|
||||||
|
"(schwarz = beide, rot = nur Original, blau = nur LaTeX, gelb = Abweichungsfleck)</p>"
|
||||||
|
+ "".join(rows_html), encoding="utf-8")
|
||||||
|
(out / "summary.json").write_text(json.dumps({"preset": preset, "counts": counts, "formulas": summary},
|
||||||
|
ensure_ascii=False, indent=1), encoding="utf-8")
|
||||||
|
print(f"{len(formulas)} Formeln geprüft – {head}")
|
||||||
|
print(f"Übersicht: {out / 'index.html'}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Loading…
Reference in a new issue