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 handwriting page {index} +
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