From 033ca4f05b30d272ae57e41728b20c3841cdbb2d Mon Sep 17 00:00:00 2001
From: SimpleCaci <92838396+SimpleCaci@users.noreply.github.com>
Date: Tue, 14 Jul 2026 11:25:31 -0700
Subject: [PATCH 1/4] feat: add visual OCR review report
---
main.py | 198 ++++++++++++++++++++++++++++++++++++++++++++++++++++----
1 file changed, 187 insertions(+), 11 deletions(-)
diff --git a/main.py b/main.py
index 7525242..2cb02bf 100644
--- a/main.py
+++ b/main.py
@@ -1,8 +1,11 @@
-"""Convert photographed handwriting into searchable text and a printable PDF."""
+"""Convert photographed handwriting into searchable text and reviewable documents."""
from __future__ import annotations
import argparse
+import base64
+import html
+import mimetypes
import shutil
from dataclasses import dataclass
from pathlib import Path
@@ -20,6 +23,7 @@
class ConversionResult:
source: Path
text: str
+ confidence: float | None = None
processed_image: Path | None = None
@@ -69,7 +73,22 @@ def convert_images(
if not source.is_file():
raise FileNotFoundError(f"Input image does not exist: {source}")
processed = preprocess_image(source)
- text = pytesseract.image_to_string(processed, lang=language, config=config).strip()
+ ocr_data = pytesseract.image_to_data(
+ processed,
+ lang=language,
+ config=config,
+ output_type=pytesseract.Output.DICT,
+ )
+ words = [word.strip() for word in ocr_data["text"] if word.strip()]
+ confidences = [
+ float(value)
+ for value in ocr_data["conf"]
+ if float(value) >= 0
+ ]
+ text = " ".join(words)
+ confidence = (
+ sum(confidences) / len(confidences) if confidences else None
+ )
debug_path = None
if debug_dir:
@@ -77,13 +96,23 @@ def convert_images(
debug_path = debug_dir / f"{index:02d}-{source.stem}-processed.png"
cv2.imwrite(str(debug_path), processed)
- results.append(ConversionResult(source=source, text=text, processed_image=debug_path))
+ results.append(
+ ConversionResult(
+ source=source,
+ text=text,
+ confidence=confidence,
+ processed_image=debug_path,
+ )
+ )
return results
def write_text(results: list[ConversionResult], output: Path) -> None:
output.parent.mkdir(parents=True, exist_ok=True)
- sections = [f"# {item.source.name}\n\n{item.text or '[No text detected]'}" for item in results]
+ sections = [
+ f"# {item.source.name}\n\n{item.text or '[No text detected]'}"
+ for item in results
+ ]
output.write_text("\n\n".join(sections) + "\n", encoding="utf-8")
@@ -96,7 +125,7 @@ def write_pdf(results: list[ConversionResult], output: Path, title: str) -> None
[
Paragraph(item.source.name, styles["Heading2"]),
Paragraph(
- (item.text or "[No text detected]").replace("&", "&").replace("<", "<").replace(">", ">").replace("\n", "
"),
+ html.escape(item.text or "[No text detected]").replace("\n", "
"),
styles["BodyText"],
),
]
@@ -104,15 +133,153 @@ def write_pdf(results: list[ConversionResult], output: Path, title: str) -> None
if index < len(results) - 1:
story.append(PageBreak())
SimpleDocTemplate(
- str(output), pagesize=letter, title=title, author="SimpleCaci",
- rightMargin=0.65 * inch, leftMargin=0.65 * inch,
- topMargin=0.65 * inch, bottomMargin=0.65 * inch,
+ str(output),
+ pagesize=letter,
+ title=title,
+ author="SimpleCaci",
+ rightMargin=0.65 * inch,
+ leftMargin=0.65 * inch,
+ topMargin=0.65 * inch,
+ bottomMargin=0.65 * inch,
).build(story)
+def _image_data_uri(path: Path) -> str:
+ media_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
+ encoded = base64.b64encode(path.read_bytes()).decode("ascii")
+ return f"data:{media_type};base64,{encoded}"
+
+
+def write_review_report(
+ results: list[ConversionResult], output: Path, title: str
+) -> None:
+ """Write a self-contained visual OCR review report."""
+ output.parent.mkdir(parents=True, exist_ok=True)
+ cards = []
+ for index, item in enumerate(results, start=1):
+ confidence = (
+ "Confidence unavailable"
+ if item.confidence is None
+ else f"Average OCR confidence {item.confidence:.0f}%"
+ )
+ transcription = html.escape(item.text or "[No text detected]")
+ cards.append(
+ f"""
+
+
+ Page {index}
+ {html.escape(item.source.name)}
+ {confidence}
+
+
+
+
+ Original page
+
+
+ Recognized text
+ {transcription}
+
+
+
+ """
+ )
+
+ document = f"""
+
+
+
+
+ {html.escape(title)} · OCR review
+
+
+
+
+
+ {html.escape(title)}
+ Compare every original page with its recognized text before editing,
+ sharing, or printing the transcription.
+
+ {"".join(cards)}
+
+
+
+"""
+ output.write_text(document, encoding="utf-8")
+
+
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
- description="Turn one or more handwriting photos into text and a printable PDF."
+ description="Turn one or more handwriting photos into reviewable documents."
)
parser.add_argument("inputs", nargs="+", type=Path, help="Image files in reading order")
parser.add_argument("-o", "--output", type=Path, default=Path("output/handwriting"))
@@ -121,6 +288,11 @@ def build_parser() -> argparse.ArgumentParser:
parser.add_argument("--psm", type=int, default=6, choices=range(0, 14))
parser.add_argument("--tesseract", help="Path to the Tesseract executable")
parser.add_argument("--debug-images", action="store_true")
+ parser.add_argument(
+ "--no-review-report",
+ action="store_true",
+ help="Skip the self-contained HTML source/transcription review report",
+ )
return parser
@@ -136,14 +308,18 @@ def main(argv: list[str] | None = None) -> int:
)
write_text(results, args.output.with_suffix(".txt"))
write_pdf(results, args.output.with_suffix(".pdf"), args.title)
+ if not args.no_review_report:
+ write_review_report(results, args.output.with_suffix(".html"), args.title)
except (FileNotFoundError, RuntimeError, ValueError) as exc:
print(f"error: {exc}")
return 1
detected = sum(bool(item.text) for item in results)
print(f"Converted {len(results)} page(s); detected text on {detected}.")
- print(f"Text: {args.output.with_suffix('.txt')}")
- print(f"PDF: {args.output.with_suffix('.pdf')}")
+ print(f"Text: {args.output.with_suffix('.txt')}")
+ print(f"PDF: {args.output.with_suffix('.pdf')}")
+ if not args.no_review_report:
+ print(f"Review: {args.output.with_suffix('.html')}")
return 0
From cf5d844af7ad3ca17b7e9881495fe7f9ac445568 Mon Sep 17 00:00:00 2001
From: SimpleCaci <92838396+SimpleCaci@users.noreply.github.com>
Date: Tue, 14 Jul 2026 11:25:49 -0700
Subject: [PATCH 2/4] test: cover OCR confidence and review report
---
tests/test_main.py | 36 ++++++++++++++++++++++++++++++++----
1 file changed, 32 insertions(+), 4 deletions(-)
diff --git a/tests/test_main.py b/tests/test_main.py
index 7805f1e..6519963 100644
--- a/tests/test_main.py
+++ b/tests/test_main.py
@@ -17,19 +17,29 @@ def test_convert_images_uses_ocr_and_saves_debug_image(tmp_path, monkeypatch):
make_image(source)
captured = {}
- def fake_ocr(image, lang, config):
- captured.update(shape=image.shape, lang=lang, config=config)
- return "Hello notes\n"
+ def fake_ocr(image, lang, config, output_type):
+ captured.update(
+ shape=image.shape,
+ lang=lang,
+ config=config,
+ output_type=output_type,
+ )
+ return {
+ "text": ["", "Hello", "notes"],
+ "conf": ["-1", "94", "86"],
+ }
- monkeypatch.setattr(main.pytesseract, "image_to_string", fake_ocr)
+ monkeypatch.setattr(main.pytesseract, "image_to_data", fake_ocr)
results = main.convert_images(
[source], language="eng", page_segmentation=6, debug_dir=tmp_path / "debug"
)
assert results[0].text == "Hello notes"
+ assert results[0].confidence == 90
assert results[0].processed_image.is_file()
assert captured["lang"] == "eng"
assert "--psm 6" in captured["config"]
+ assert captured["output_type"] == main.pytesseract.Output.DICT
def test_writers_create_text_and_pdf(tmp_path):
@@ -49,6 +59,24 @@ def test_writers_create_text_and_pdf(tmp_path):
assert pdf_path.stat().st_size > 500
+def test_review_report_embeds_source_and_escapes_transcription(tmp_path):
+ source = tmp_path / "note.png"
+ make_image(source)
+ output = tmp_path / "review.html"
+
+ main.write_review_report(
+ [main.ConversionResult(source, "Math: 2 < 3", confidence=92)],
+ output,
+ "Class notes",
+ )
+
+ report = output.read_text(encoding="utf-8")
+ assert "data:image/png;base64," in report
+ assert "Math: 2 < 3" in report
+ assert "Average OCR confidence 92%" in report
+ assert "Class notes · OCR review" in report
+
+
def test_missing_input_is_reported(tmp_path):
missing = tmp_path / "missing.jpg"
try:
From 353e7e615a217c752b99244122b97f8a8f2fef41 Mon Sep 17 00:00:00 2001
From: SimpleCaci <92838396+SimpleCaci@users.noreply.github.com>
Date: Tue, 14 Jul 2026 11:26:02 -0700
Subject: [PATCH 3/4] docs: explain the OCR review workflow
---
README.md | 9 ++++++++-
1 file changed, 8 insertions(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 1f4a3a2..d97b939 100644
--- a/README.md
+++ b/README.md
@@ -12,6 +12,8 @@ I built this because writing by hand is often the fastest way for me to think, b
- preserves the image order as PDF page order
- resizes, denoises, increases contrast, and binarizes each page
- runs local Tesseract OCR without uploading notes to a cloud service
+- calculates average word confidence for each page
+- writes a self-contained visual review report beside the transcription and PDF
- writes a UTF-8 text transcription and a printable multipage PDF
- supports alternate Tesseract languages and page-segmentation modes
- can save processed images for recognition troubleshooting
@@ -40,6 +42,8 @@ resize -> denoise -> contrast enhancement -> adaptive threshold
|
v
local Tesseract OCR
+ |
+ +----> source + OCR confidence review (HTML)
|
+----> UTF-8 transcription
|
@@ -95,9 +99,12 @@ python main.py page-1.jpg page-2.jpg page-3.jpg --output output/class-notes --ti
This creates:
+- `output/class-notes.html` — visual source/transcription review
- `output/class-notes.txt`
- `output/class-notes.pdf`
+Open the HTML report in any browser to compare every original page with the OCR result and average word confidence. The report is self-contained, so it can be moved without breaking its images. Use `--no-review-report` when only text and PDF output are needed.
+
Save the processed images when tuning recognition:
```bash
@@ -114,7 +121,7 @@ ruff check main.py tests
pytest -q
```
-The tests isolate OCR itself so they remain deterministic, while still exercising preprocessing, multipage result handling, text output, and real PDF creation.
+The tests isolate OCR itself so they remain deterministic, while still exercising preprocessing, confidence calculation, the self-contained review report, multipage result handling, text output, and real PDF creation.
## Privacy
From 54b6fad99e1bf91950daa513140ebd48b0572f91 Mon Sep 17 00:00:00 2001
From: SimpleCaci <92838396+SimpleCaci@users.noreply.github.com>
Date: Tue, 14 Jul 2026 11:48:02 -0700
Subject: [PATCH 4/4] ci: invoke pytest through Python
---
.github/workflows/validate.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml
index 0d6a983..772b04e 100644
--- a/.github/workflows/validate.yml
+++ b/.github/workflows/validate.yml
@@ -21,4 +21,4 @@ jobs:
- run: python -m pip install --upgrade pip
- run: python -m pip install -r requirements-dev.txt
- run: ruff check main.py tests
- - run: pytest -q
+ - run: python -m pytest -q