Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -40,6 +42,8 @@ resize -> denoise -> contrast enhancement -> adaptive threshold
|
v
local Tesseract OCR
|
+----> source + OCR confidence review (HTML)
|
+----> UTF-8 transcription
|
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down
198 changes: 187 additions & 11 deletions main.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -20,6 +23,7 @@
class ConversionResult:
source: Path
text: str
confidence: float | None = None
processed_image: Path | None = None


Expand Down Expand Up @@ -69,21 +73,46 @@ 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:
debug_dir.mkdir(parents=True, exist_ok=True)
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")


Expand All @@ -96,23 +125,161 @@ 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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\n", "<br/>"),
html.escape(item.text or "[No text detected]").replace("\n", "<br/>"),
styles["BodyText"],
),
]
)
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"""
<article class="page-card">
<header>
<span class="page-number">Page {index}</span>
<h2>{html.escape(item.source.name)}</h2>
<span class="confidence">{confidence}</span>
</header>
<div class="review-grid">
<figure>
<img src="{_image_data_uri(item.source)}"
alt="Original handwriting page {index}">
<figcaption>Original page</figcaption>
</figure>
<section class="transcription" aria-label="OCR transcription">
<h3>Recognized text</h3>
<pre>{transcription}</pre>
</section>
</div>
</article>
"""
)

document = f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{html.escape(title)} · OCR review</title>
<style>
:root {{
color-scheme: light;
--ink: #251f1a;
--muted: #6f665f;
--paper: #fffdf8;
--line: #e5ddd0;
--accent: #a75436;
--wash: #f5eee4;
}}
* {{ box-sizing: border-box; }}
body {{
margin: 0;
color: var(--ink);
background: #ebe4da;
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
}}
main {{ width: min(1180px, calc(100% - 32px)); margin: 48px auto; }}
.intro {{ margin-bottom: 28px; }}
.intro p {{ color: var(--muted); max-width: 68ch; }}
.page-card {{
margin: 24px 0;
overflow: hidden;
background: var(--paper);
border: 1px solid var(--line);
border-radius: 20px;
box-shadow: 0 18px 45px rgb(57 42 30 / 10%);
}}
.page-card > header {{
display: flex;
align-items: center;
gap: 12px;
padding: 18px 22px;
border-bottom: 1px solid var(--line);
}}
h1, h2, h3, p {{ margin-top: 0; }}
h2 {{ margin: 0; font-size: 1rem; flex: 1; }}
.page-number, .confidence {{
border-radius: 999px;
padding: 6px 10px;
font-size: .78rem;
font-weight: 700;
}}
.page-number {{ color: white; background: var(--accent); }}
.confidence {{ color: var(--accent); background: var(--wash); }}
.review-grid {{ display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }}
figure {{ margin: 0; padding: 22px; border-right: 1px solid var(--line); }}
img {{ width: 100%; max-height: 720px; object-fit: contain; background: white; border-radius: 10px; }}
figcaption {{ color: var(--muted); font-size: .78rem; margin-top: 8px; }}
.transcription {{ padding: 28px; }}
pre {{
margin: 0;
white-space: pre-wrap;
overflow-wrap: anywhere;
font: 1rem/1.65 ui-monospace, SFMono-Regular, Consolas, monospace;
}}
@media (max-width: 760px) {{
main {{ margin: 24px auto; }}
.review-grid {{ grid-template-columns: 1fr; }}
figure {{ border-right: 0; border-bottom: 1px solid var(--line); }}
.page-card > header {{ flex-wrap: wrap; }}
.confidence {{ width: 100%; }}
}}
@media print {{
body {{ background: white; }}
main {{ width: 100%; margin: 0; }}
.page-card {{ box-shadow: none; break-after: page; }}
}}
</style>
</head>
<body>
<main>
<section class="intro">
<h1>{html.escape(title)}</h1>
<p>Compare every original page with its recognized text before editing,
sharing, or printing the transcription.</p>
</section>
{"".join(cards)}
</main>
</body>
</html>
"""
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"))
Expand All @@ -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


Expand All @@ -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


Expand Down
36 changes: 32 additions & 4 deletions tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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 &lt; 3" in report
assert "Average OCR confidence 92%" in report
assert "<title>Class notes · OCR review</title>" in report


def test_missing_input_is_reported(tmp_path):
missing = tmp_path / "missing.jpg"
try:
Expand Down
Loading