feat: add RapidOCR QEMU console capture

This commit is contained in:
2026-08-16 16:02:37 +09:00
parent 8e8ab22d30
commit 2477777e1a
5 changed files with 754 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
# Development tools
## QEMU console OCR
Capture the current QEMU VGA screen and print detected console text:
```powershell
uv run python tools/qemu_ocr.py
```
Useful options:
```powershell
# Machine-readable boxes, confidence scores, and text
uv run python tools/qemu_ocr.py --json
# OCR an existing screenshot without recapturing
uv run python tools/qemu_ocr.py --image .qemu/qemu-screen.png
# Save extracted text
uv run python tools/qemu_ocr.py -o .qemu/qemu-screen.txt
```
The tool invokes `.qemu/screenshot.ps1`, runs RapidOCR with ONNX Runtime, sorts
recognized lines by screen position, and emits UTF-8 text. Screenshots and OCR
output under `.qemu/` remain ignored build artifacts.
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env python3
"""Capture the QEMU VGA console and print its text with RapidOCR."""
from __future__ import annotations
import argparse
import json
import logging
import subprocess
import sys
from pathlib import Path
from rapidocr import RapidOCR
ROOT = Path(__file__).resolve().parent.parent
DEFAULT_IMAGE = ROOT / ".qemu" / "qemu-screen.png"
SCREENSHOT = ROOT / ".qemu" / "screenshot.ps1"
def capture() -> Path:
subprocess.run(
[
"powershell",
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
str(SCREENSHOT),
],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
text=True,
)
if not DEFAULT_IMAGE.is_file():
raise RuntimeError(f"QEMU screenshot was not created: {DEFAULT_IMAGE}")
return DEFAULT_IMAGE
def recognize(image: Path, min_score: float) -> list[dict[str, object]]:
logging.disable(logging.INFO)
result = RapidOCR()(image)
rows: list[dict[str, object]] = []
if result.txts is None or result.boxes is None or result.scores is None:
return rows
for box, text, score in zip(result.boxes, result.txts, result.scores):
if float(score) < min_score:
continue
rows.append(
{
"x": int(min(point[0] for point in box)),
"y": int(min(point[1] for point in box)),
"text": text,
"score": round(float(score), 5),
}
)
rows.sort(key=lambda row: (int(row["y"]), int(row["x"])))
return rows
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--image", type=Path, help="OCR an existing image instead of capturing QEMU")
parser.add_argument("--min-score", type=float, default=0.5)
parser.add_argument("--json", action="store_true", help="emit locations and scores as JSON")
parser.add_argument("-o", "--output", type=Path, help="also save output as UTF-8 text")
args = parser.parse_args()
image = args.image.resolve() if args.image else capture()
rows = recognize(image, args.min_score)
if args.json:
rendered = json.dumps({"image": str(image), "lines": rows}, ensure_ascii=False, indent=2)
else:
rendered = "\n".join(str(row["text"]) for row in rows)
if args.output:
args.output.write_text(rendered + ("\n" if rendered else ""), encoding="utf-8")
if rendered:
print(rendered)
return 0 if rows else 1
if __name__ == "__main__":
try:
raise SystemExit(main())
except (OSError, RuntimeError, subprocess.CalledProcessError) as exc:
print(f"qemu-ocr: {exc}", file=sys.stderr)
raise SystemExit(2)