formula_img2latex/formula_img2latex/pipeline.py
dschlueter 388024a17b feat: 0.2.0 – Konsens Qwen = Gemma = GLM als Standard, Gehrke komplett verarbeitet
- Dritte Lesart (GLM-4.6V über OpenRouter, ohne Nachdenken) muss zustimmen;
  keine 2:1-Annahme; fehlender Schlusspunkt der dritten Lesart zählt nicht
- run in Paketen zu 50 mit Statuszeile, beide lokalen Modelle parallel,
  GLM nur für Einige; nur die übernommene Lesart wird gegen das Bild geprüft
- Bild-Veto: Ähnlichkeit >= 0,65, Schriftgrößen-Prüfung abgeschaltet
- Antworten auf 800 Tokens begrenzt; Prompt-Version und Nachdenk-Einstellung
  im Cache-Schlüssel; Verfahrensversion für Prüfergebnisse
- Behoben: Gleichungsnummern aus alten Lesarten ("(3.7)"), gleichzeitiges
  Speichern zweier Engines, Absturz bei Formeln ohne aktuelle Lesart
- apply: deutsches Dezimalkomma 3{,}6; Review-Galerie zeigt aktuelle Lesarten
- tools/vergleich.py auf Konsens-Ablauf umgestellt
- Doku: README, BEDIENUNGSANLEITUNG, neues CHANGELOG

Gehrke-Brückenkurs: 942 von 1505 Formelbildern automatisch ersetzt (63 %),
62 von 62 von Hand geprüften korrekt; Pandoc/MathML 945 von 945.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 07:58:16 +02:00

562 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Workflow steps: scan, recognize, verify, apply, revert."""
from __future__ import annotations
import hashlib
import io
import os
import re
import shutil
import statistics
import threading
import sys
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor, as_completed
from pathlib import Path
from typing import Any, Callable
import numpy as np
from PIL import Image
from .checks import pandoc_mathml_ok
from .classify import classify
from .compare import diff_image
from .decide import consensus_thresholds, decide, decide_consensus, thresholds
from .engines import make_engine
from .engines.vlm import PROMPT_VERSION
from .imageio import load_ink
from .render import PRESETS
from .sources import open_source
from .sources.base import ImageRef, Source
from .store import HUMAN_STATUSES, REPLACE_STATUSES, Formula, Workspace, now_iso, sha256_bytes
from .tex import normalize_for_compare, split_tag, strip_delimiters
from .verify import verify
PRESET_SAMPLE = 12
_SAVE_LOCK = threading.Lock()
def log(msg: str, quiet: bool = False) -> None:
if not quiet:
print(msg, file=sys.stderr, flush=True)
# --------------------------------------------------------------------------------------- scan
def scan(source: Source, ws: Workspace, quiet: bool = False) -> dict[str, int]:
refs = source.image_refs()
groups: dict[str, tuple[Formula, list[ImageRef]]] = {}
for ref in refs:
p = ref.asset_path()
if not p.exists():
continue
sha = sha256_bytes(p.read_bytes())
fid = Formula.make_id(sha, ref.display)
if fid not in groups:
f = ws.load(fid) or Formula(id=fid, sha256=sha, asset=p.relative_to(source.root).as_posix(),
display=ref.display)
groups[fid] = (f, [])
groups[fid][1].append(ref)
counts = {"formula": 0, "maybe": 0, "figure": 0}
for fid, (f, rs) in groups.items():
ref = rs[0]
f.asset = ref.asset_path().relative_to(source.root).as_posix()
f.source_name = source.source_name(ref)
f.known_formula = source.known_formula(ref)
f.alt = ref.alt
f.occurrences = [{"file": r.file.relative_to(source.root).as_posix(), "line": r.line, "md": r.md,
"context": r.context} for r in rs]
data = source.original_bytes(ref)
f.width, f.height = _image_size(data)
f.classify = classify(data, f.asset, f.known_formula, inline=not f.display)
counts[f.classify["label"]] += 1
ws.save(f)
ws.meta["source"] = source.describe()
ws.meta["last_scan"] = {"at": now_iso(), "images": len(refs), "formulas": len(groups), **counts}
ws.save_meta()
log(f"scan: {len(refs)} Bildverweise, {len(groups)} verschiedene Bilder: {counts['formula']} Formeln, "
f"{counts['maybe']} unklar, {counts['figure']} Abbildungen", quiet)
return counts
def _image_size(data: bytes) -> tuple[int, int]:
try:
with Image.open(io.BytesIO(data)) as im:
return im.size
except OSError: # SVG
return 0, 0
def _wanted(f: Formula, include_figures: bool) -> bool:
if f.status in HUMAN_STATUSES:
return False
label = (f.classify or {}).get("label", "maybe")
return include_figures or label != "figure"
def _ref_for(source: Source, f: Formula) -> ImageRef:
occ = f.occurrences[0]
md = source.root / occ["file"]
from .sources.base import scan_markdown
for r in scan_markdown(md):
if r.line == occ["line"] and r.md == occ["md"]:
return r
for r in scan_markdown(md):
if r.md == occ["md"]:
return r
raise FileNotFoundError(f"Bildverweis nicht mehr gefunden: {occ['file']}:{occ['line']}")
# --------------------------------------------------------------------------------- recognize
def needs_third(f: Formula) -> bool:
"""The two primary readings agree; only the confirming third reading is missing."""
return f.status == "needs_review" and "dritte Lesart fehlt" in (f.reason or "")
def recognize(source: Source, ws: Workspace, engine_name: str, model: str | None = None, url: str | None = None,
only_unresolved: bool = False, include_figures: bool = False, limit: int = 0,
workers: int = 4, quiet: bool = False, only_ids: set[str] | None = None,
only_needing_third: bool = False) -> dict[str, int]:
engine = make_engine(engine_name, model, url)
engine.check()
todo = []
for f in ws.all():
if not _wanted(f, include_figures) or (only_ids is not None and f.id not in only_ids):
continue
if only_unresolved and f.status in ("auto_accepted", "not_formula"):
continue
if only_needing_third and not needs_third(f):
continue
if any(c.get("engine") == engine.name and c.get("model") == engine.model
and c.get("prompt") == PROMPT_VERSION and c.get("thinking") == engine.thinking for c in f.candidates):
continue # cached: never ask the same model the same question twice
todo.append(f)
if limit:
todo = todo[:limit]
log(f"recognize: {len(todo)} Bilder mit {engine.name} ({engine.model})", quiet)
stats = {"asked": 0, "latex": 0, "not_formula": 0, "error": 0, "tokens_in": 0, "tokens_out": 0}
def job(f: Formula) -> tuple[Formula, Any]:
ref = _ref_for(source, f)
ctx = f.occurrences[0].get("context", "")
return f, engine.recognize(source.original_bytes(ref), f.asset, ctx, f.display)
with ThreadPoolExecutor(max_workers=max(1, workers)) as pool:
futures = [pool.submit(job, f) for f in todo]
for i, fut in enumerate(as_completed(futures), 1):
f, rec = fut.result()
stats["asked"] += 1
stats["tokens_in"] += rec.tokens_in
stats["tokens_out"] += rec.tokens_out
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, "prompt": PROMPT_VERSION,
"thinking": engine.thinking,
"error": rec.error, "seconds": round(rec.seconds, 2), "created_at": now_iso()}
with _SAVE_LOCK: # another engine may run in parallel: append to the current record
f = ws.load(f.id) or f
f.candidates.append(cand)
ws.save(f)
if rec.error:
stats["error"] += 1
elif rec.tex:
stats["latex"] += 1
else:
stats["not_formula"] += 1
if not quiet and (i % 25 == 0 or i == len(todo)):
log(f" {i}/{len(todo)}")
log(f"recognize: {stats['latex']} LaTeX-Kandidaten, {stats['not_formula']} „keine Formel“, "
f"{stats['error']} Fehler; Tokens {stats['tokens_in']} ein / {stats['tokens_out']} aus", quiet)
return stats
# ------------------------------------------------------------------------------------ verify
def comparison_pngs(c: Any) -> dict[str, bytes]:
"""PNG images of an aligned comparison: original, candidate render (elastic and rigid), overlay."""
out: dict[str, bytes] = {}
if c is None or c.original is None:
return out
for key, arr in (("orig", c.original), ("render", c.candidate), ("rigid", c.raw)):
if arr is None:
continue
buf = io.BytesIO()
Image.fromarray(((1 - np.clip(arr, 0, 1)) * 255).astype(np.uint8)).save(buf, "PNG")
out[key] = buf.getvalue()
buf = io.BytesIO()
Image.fromarray(diff_image(c, upscale=1)).save(buf, "PNG")
out["diff"] = buf.getvalue()
return out
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.
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")
orig = load_ink(data, name)
results = []
best = None
for tex in texes:
v = verify(orig, tex, display, preset, check_variants=variants)
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))
if best is None or key > best[0]:
best = (key, v)
return fid, results, comparison_pngs(best[1].comparison if best else None)
def _distinct_candidates(f: Formula) -> list[str]:
seen: dict[str, str] = {}
for c in f.candidates:
tex = c.get("tex")
if not tex:
continue
body, _tag = split_tag(strip_delimiters(tex))
key = normalize_for_compare(body)
seen.setdefault(key, body)
return list(seen.values())
def choose_preset(source: Source, ws: Workspace, formulas: list[Formula], quiet: bool = False) -> str:
"""Pick the font preset under which a sample of candidates matches the originals best."""
from .render import render_many
from .compare import compare
sample = [f for f in formulas if _distinct_candidates(f)][:PRESET_SAMPLE]
if not sample:
return "cm"
inks = [load_ink(source.original_bytes(_ref_for(source, f)), f.asset) for f in sample]
scores: dict[str, float] = {}
for preset in PRESETS:
rs = render_many([(_distinct_candidates(f)[0], f.display) for f in sample], preset)
vals = [compare(i, r).metrics.get("ncc", 0.0) if r.ok else 0.0 for i, r in zip(inks, rs)]
scores[preset] = float(statistics.median(vals))
best = max(scores, key=lambda k: scores[k])
log("Schrift: " + ", ".join(f"{k} {v:.3f}" for k, v in sorted(scores.items(), key=lambda kv: -kv[1]))
+ f" → {best}", quiet)
return best
def verify_all(source: Source, ws: Workspace, preset: str | None = None, include_figures: bool = False,
workers: int = 0, redo: bool = False, quiet: bool = False,
progress: Callable[[int, int], None] | None = None, only_ids: set[str] | None = None) -> dict[str, int]:
formulas = [f for f in ws.all() if _wanted(f, include_figures) and _distinct_candidates(f)
and (only_ids is None or f.id in only_ids)]
mode = decide_mode(ws)
formulas = [f for f in formulas if _verify_texes(f, mode)]
if not redo:
formulas = [f for f in formulas if not _verified_current(f, mode)]
if preset is None:
preset = ws.settings.get("preset") or choose_preset(source, ws, formulas, quiet)
ws.settings["preset"] = preset
ws.save_meta()
jobs = []
for f in formulas:
ref = _ref_for(source, f)
jobs.append((f.id, source.original_bytes(ref), f.asset, _verify_texes(f, mode), f.display, preset,
mode == "pixel"))
log(f"verify: {len(jobs)} Formeln, Schrift {preset}", quiet)
by_id = {f.id: f for f in formulas}
ws.review_dir.mkdir(parents=True, exist_ok=True)
workers = workers or max(1, (os.cpu_count() or 2) - 2)
done = 0
with ProcessPoolExecutor(max_workers=workers) as pool:
for fut in as_completed([pool.submit(_verify_job, j) for j in jobs]):
fid, results, pngs = fut.result()
f = by_id[fid]
if not results:
continue
best = max(results, key=lambda r: (r["ok"], r["min_contrast"] if r["min_contrast"] is not None else 1.0,
r["metrics"].get("ncc", 0.0)))
f.verification = {"preset": preset, "candidates_key": _cands_key(f, mode), "best": best, "all": results,
"at": now_iso()}
for key, png in pngs.items():
(ws.review_dir / f"{fid}-{key}.png").write_bytes(png)
ws.save(f)
done += 1
if progress:
progress(done, len(jobs))
elif not quiet and (done % 25 == 0 or done == len(jobs)):
log(f" {done}/{len(jobs)}")
return decide_all(ws, quiet=quiet, preset=preset, only_ids=only_ids)
VERIFY_VERSION = 3 # bump when the comparison changes, so stored image checks are recomputed
def _verify_texes(f: Formula, mode: str) -> list[str]:
"""Readings to check against the image. Consensus: only the reading that would be used (the
first primary reading) – the veto needs nothing else, and a later third reading changes nothing."""
if mode != "consensus":
return _distinct_candidates(f)
for r in readings_of(f):
if r.get("tex"):
return [split_tag(strip_delimiters(r["tex"]))[0]]
return []
def _cands_key(f: Formula, mode: str = "pixel") -> str:
text = f"v{VERIFY_VERSION}\n" + "\n".join(sorted(_verify_texes(f, mode)))
return hashlib.sha256(text.encode()).hexdigest()[:16]
def _verified_current(f: Formula, mode: str = "pixel") -> bool:
v = f.verification or {}
return v.get("candidates_key") == _cands_key(f, mode)
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,
only_ids: set[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", only_ids)
th = thresholds(ws.settings.get("thresholds"))
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.
good = [f.verification["best"]["metrics"]["px_per_pt"] for f in formulas
if f.verification["best"].get("ok") and f.verification["best"]["metrics"].get("ncc", 0) >= th["min_ncc"]
and (f.verification["best"].get("min_contrast") or 1.0) >= th["min_contrast"]]
book_ppt = float(statistics.median(good)) if len(good) >= 5 else None
ws.settings["book_px_per_pt"] = book_ppt
texes = [(f.verification["best"]["tex"], f.display) for f in formulas]
pandoc = pandoc_mathml_ok(texes) if texes else []
counts: dict[str, int] = {}
for f, pok in zip(formulas, pandoc):
best = f.verification["best"]
status, reason = decide(best, pok, book_ppt, th)
f.status, f.reason = status, reason
f.final_tex = _with_tag(f, best["tex"]) if status == "auto_accepted" else None
f.decided_at = now_iso()
ws.save(f)
counts[status] = counts.get(status, 0) + 1
for f in ws.all():
if not f.verification and f.status == "pending" and f.candidates and not _distinct_candidates(f):
f.status = "not_formula" if any(c.get("is_formula") is False for c in f.candidates) else "no_candidate"
f.reason = "Modell: keine Formel" if f.status == "not_formula" else "kein Kandidat"
ws.save(f)
counts[f.status] = counts.get(f.status, 0) + 1
ws.save_meta()
log("Entscheidung: " + ", ".join(f"{k} {v}" for k, v in sorted(counts.items())), quiet)
return counts
def _strip_final_punct(tex: str) -> str:
return re.sub(r"(\\[,;!]|\\quad|\\qquad|\s)*[.,;]\s*$", "", strip_delimiters(tex or "")).strip()
def _tex_key(tex: str) -> str:
"""Key under which verify stores a candidate (see _distinct_candidates)."""
return normalize_for_compare(split_tag(strip_delimiters(tex))[0])
def decide_all_consensus(ws: Workspace, quiet: bool, preset: str, only_ids: set[str] | None = None) -> 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
and (only_ids is None or f.id in only_ids)]
plan = []
for f in formulas:
rs = readings_of(f)
bold = bool(th.get("bold_relevant"))
agree = []
for i in range(len(rs)):
for j in range(i + 1, len(rs)):
a, b = rs[i].get("tex") or "", rs[j].get("tex") or ""
if j >= 2: # the confirming reading: a missing final period/comma is no contradiction
a, b = _strip_final_punct(a), _strip_final_punct(b)
how = same_reading(a, b, f.display, preset, bold)
if how:
agree.append((i, j, how))
plan.append((f, rs, agree))
# Book-wide scale from agreed readings that resemble their original well.
by_tex = {f.id: {_tex_key(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(_tex_key(rs[i].get("tex") or ""))
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_key(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, 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:
"""Turn an equation number of the *chosen* reading (\\tag{3.7}) into visible text, like epub2md does.
Only the accepted reading counts – never other or older readings. In consensus mode the number
is part of the compared canonical form, so all agreeing readings carry the same number.
"""
body, tag = split_tag(strip_delimiters(tex))
if not tag:
return body
return body + f"\\qquad\\text{{({tag})}}" if f.display else body
# ------------------------------------------------------------------------------ apply/revert
def _file_sha(p: Path) -> str:
return sha256_bytes(p.read_bytes())
def apply(source: Source, ws: Workspace, dry_run: bool = False, quiet: bool = False) -> dict[str, int]:
"""Rewrite Markdown files from their backed-up originals; idempotent."""
by_occ: dict[tuple[str, int, str], str] = {}
for f in ws.all():
if f.status in REPLACE_STATUSES and f.final_tex:
math = source.format_math(f.final_tex, f.display)
for occ in f.occurrences:
by_occ[(occ["file"], occ["line"], occ["md"])] = math
state = ws.meta.setdefault("applied", {})
files = {occ[0] for occ in by_occ} | set(state)
stats = {"files": 0, "replaced": 0}
for rel in sorted(files):
path = source.root / rel
backup = ws.backup_dir / rel
if not path.exists():
continue
current = _file_sha(path)
known = state.get(rel, {})
if backup.exists() and current not in (known.get("applied_sha"), known.get("original_sha")):
# File was regenerated (epub2md --force) or edited by hand: it is the new original.
if not dry_run:
backup.unlink()
if not backup.exists() and not dry_run:
backup.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(path, backup)
known = state[rel] = {"original_sha": current}
original = (backup if backup.exists() else path).read_text(encoding="utf-8").split("\n")
n = 0
for (frel, line, md), math in by_occ.items():
if frel != rel or line > len(original):
continue
if md in original[line - 1]:
original[line - 1] = original[line - 1].replace(md, math, 1)
n += 1
text = "\n".join(original)
stats["replaced"] += n
if dry_run:
continue
if n == 0 and backup.exists():
shutil.copy2(backup, path)
else:
path.write_text(text, encoding="utf-8")
state[rel] = {"original_sha": known.get("original_sha") or _file_sha(backup),
"applied_sha": _file_sha(path), "replaced": n, "at": now_iso()}
stats["files"] += 1
if not dry_run:
ws.save_meta()
log(f"apply{' (Probelauf)' if dry_run else ''}: {stats['replaced']} Formelbilder in {stats['files']} Dateien "
"durch LaTeX ersetzt", quiet)
return stats
def revert(source: Source, ws: Workspace, force: bool = False, quiet: bool = False) -> dict[str, int]:
state = ws.meta.get("applied", {})
stats = {"restored": 0, "skipped": 0}
restored: list[str] = []
for rel, info in sorted(state.items()):
path, backup = source.root / rel, ws.backup_dir / rel
if not backup.exists():
continue
if path.exists() and _file_sha(path) != info.get("applied_sha") and not force:
log(f" übersprungen (seit apply verändert, --force zum Überschreiben): {rel}", quiet)
stats["skipped"] += 1
continue
shutil.copy2(backup, path)
backup.unlink()
restored.append(rel)
stats["restored"] += 1
ws.meta["applied"] = {k: v for k, v in state.items() if k not in restored}
if not ws.meta["applied"]:
shutil.rmtree(ws.backup_dir, ignore_errors=True)
ws.save_meta()
log(f"revert: {stats['restored']} Dateien wiederhergestellt, {stats['skipped']} übersprungen", quiet)
return stats
def open_all(path: Path, workspace: Path | None, create: bool = False, pdf: Path | None = None
) -> tuple[Source, Workspace]:
source = open_source(path)
if pdf is not None and hasattr(source, "pdf"):
source.pdf = pdf.resolve()
ws_root = workspace or Workspace.default_for(path)
ws = Workspace.open(ws_root, create=create, source=source.describe())
if getattr(source, "pdf", "unset") is None and ws.meta.get("source", {}).get("pdf"):
source.pdf = Path(ws.meta["source"]["pdf"])
return source, ws
# ------------------------------------------------------------------------- run in chunks
def run_consensus(source: Source, ws: Workspace, chunk: int = 50, preset: str | None = None,
include_figures: bool = False, limit: int = 0, cloud_model: str | None = None,
use_cloud: bool = True, ids: list[str] | None = None, quiet: bool = False,
report: Callable[[str], None] | None = None) -> dict[str, int]:
"""Process formulas in chunks: local, local2, verify; cloud only where both agree; verify again.
After every chunk a status line (processed / accepted / review / image) is reported.
"""
say = report or (lambda m: print(m, flush=True))
todo = ids or [f.id for f in ws.all() if _wanted(f, include_figures)]
if limit:
todo = todo[:limit]
totals: dict[str, int] = {}
done: list[str] = []
for start in range(0, len(todo), chunk):
part = set(todo[start:start + chunk])
# both local models run on their own GPU: ask them at the same time
with ThreadPoolExecutor(max_workers=2) as both:
for fut in [both.submit(recognize, source, ws, eng, only_ids=part, include_figures=include_figures,
quiet=True) for eng in ("local", "local2")]:
fut.result()
verify_all(source, ws, preset, include_figures, quiet=True, only_ids=part)
if use_cloud:
recognize(source, ws, "cloud", cloud_model, only_ids=part, only_needing_third=True,
include_figures=include_figures, workers=8, quiet=True)
verify_all(source, ws, preset, include_figures, quiet=True, only_ids=part)
done.extend(sorted(part))
totals = {}
for fid in done:
f = ws.load(fid)
if f is not None:
totals[f.status] = totals.get(f.status, 0) + 1
auto = totals.get("auto_accepted", 0)
image = sum(totals.get(k, 0) for k in ("rejected_keep_image", "not_formula", "no_candidate", "render_failed"))
review = totals.get("needs_review", 0)
say(f"{len(done):5d}/{len(todo)} verarbeitet: automatisch ersetzt {auto} ({auto / len(done):.0%}), "
f"Review {review}, bleibt Bild/keine Formel {image}")
return totals