Skip to content

Commit ce222f0

Browse files
committed
Improve OCR and add file retention toggle
1 parent bf56da1 commit ce222f0

4 files changed

Lines changed: 75 additions & 9 deletions

File tree

api/src/config/env.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ const env = {
5050
? path.resolve(process.env.OCR_WORKER_SCRIPT)
5151
: path.resolve(__dirname, "..", "..", "worker", "ocr_demo.py"),
5252
pythonBin: process.env.PYTHON_BIN || null,
53+
keepReceiptFiles: boolFromEnv(process.env.RECEIPT_KEEP_FILES, true),
5354

5455
// AI Parser
5556
aiProvider: (process.env.AI_PROVIDER || "gemini").toLowerCase(),

api/src/controllers/receipts.controller.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import asyncHandler from "../middleware/async.js";
33

44
import { parseReceiptText } from "../services/aiParser.service.js";
55
import { runOcrBuffer } from "../services/ocr.service.js";
6+
import env from "../config/env.js";
67
import { parseDateOnly } from "./records.controller.js";
78

89
import { query } from "../config/db.js";
@@ -150,6 +151,17 @@ export const confirmUpload = asyncHandler(async (req, res) => {
150151
});
151152
}
152153

154+
// 7) Optional: remove uploaded file after processing (useful for testing)
155+
if (!env.keepReceiptFiles) {
156+
try {
157+
if (receipt.object_key) {
158+
await deleteObject({ key: receipt.object_key });
159+
}
160+
} catch (err) {
161+
console.error("Error deleting R2 object after OCR", receipt.id, err);
162+
}
163+
}
164+
153165
res.status(200).json({
154166
receipt: updatedReceipt,
155167
autoRecord,

api/src/services/ocr.service.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ export function runOcrBuffer(buffer) {
4444

4545
try {
4646
const parsed = JSON.parse(stdout);
47+
if (parsed?.error) {
48+
return reject(new Error(`OCR worker error: ${parsed.error}`));
49+
}
4750
return resolve(parsed);
4851
} catch {
4952
return reject(new Error(`Failed to parse OCR output: ${stdout}`));

worker/ocr_demo.py

Lines changed: 59 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,65 @@
33
import json
44
import fitz # PyMuPDF
55
import pytesseract
6-
from PIL import Image, ImageFile
6+
from PIL import Image, ImageFile, ImageOps, ImageFilter
77
import io
88

99
# Prevent truncated-image crashes in Pillow
1010
ImageFile.LOAD_TRUNCATED_IMAGES = True
1111

1212

13+
TESSERACT_CONFIG = "--oem 3 --psm 6 -l eng"
14+
15+
16+
def _preprocess_image(img: Image.Image) -> Image.Image:
17+
# Normalize orientation, boost contrast, and improve OCR readability.
18+
try:
19+
img = ImageOps.exif_transpose(img)
20+
except Exception:
21+
pass
22+
23+
img = img.convert("L")
24+
img = ImageOps.autocontrast(img)
25+
26+
# Scale up small images for better OCR, cap large images to avoid huge memory use.
27+
max_side = max(img.size)
28+
if max_side < 1000:
29+
scale = 1000 / max_side
30+
img = img.resize((int(img.size[0] * scale), int(img.size[1] * scale)), Image.BICUBIC)
31+
elif max_side > 3000:
32+
scale = 3000 / max_side
33+
img = img.resize((int(img.size[0] * scale), int(img.size[1] * scale)), Image.BICUBIC)
34+
35+
img = img.filter(ImageFilter.SHARPEN)
36+
return img
37+
38+
39+
def _ocr_image(img: Image.Image) -> str:
40+
img = _preprocess_image(img)
41+
text = pytesseract.image_to_string(img, config=TESSERACT_CONFIG)
42+
return text or ""
43+
44+
45+
def _render_page_to_image(page) -> Image.Image:
46+
# Render PDF page to raster image for OCR fallback.
47+
pix = page.get_pixmap(dpi=200)
48+
mode = "RGB" if pix.alpha == 0 else "RGBA"
49+
return Image.frombytes(mode, [pix.width, pix.height], pix.samples)
50+
51+
1352
def process_pdf(buffer: bytes) -> str:
1453
try:
1554
pdf = fitz.open(stream=buffer, filetype="pdf")
1655
text = ""
1756
for page in pdf:
1857
text += page.get_text() or ""
58+
if text.strip():
59+
return text
60+
61+
# Fallback: render pages and OCR if no embedded text exists.
62+
for page in pdf:
63+
img = _render_page_to_image(page)
64+
text += _ocr_image(img)
1965
return text
2066
except Exception as e:
2167
# Output to stderr so Node can capture useful debugging info
@@ -26,8 +72,7 @@ def process_pdf(buffer: bytes) -> str:
2672
def process_image(buffer: bytes) -> str:
2773
try:
2874
img = Image.open(io.BytesIO(buffer))
29-
text = pytesseract.image_to_string(img)
30-
return text or ""
75+
return _ocr_image(img)
3176
except Exception as e:
3277
print(f"Image processing error: {e}", file=sys.stderr)
3378
return ""
@@ -49,17 +94,22 @@ def main():
4994
# Detect PDFs safely
5095
is_pdf = buffer.startswith(b"%PDF") or buffer[:4] == b"\x25\x50\x44\x46"
5196

52-
if is_pdf:
53-
text = process_pdf(buffer)
54-
else:
55-
text = process_image(buffer)
97+
error = ""
98+
try:
99+
if is_pdf:
100+
text = process_pdf(buffer)
101+
else:
102+
text = process_image(buffer)
103+
except Exception as e:
104+
error = str(e)
105+
text = ""
56106

57107
# Always output valid JSON
58108
try:
59-
print(json.dumps({"text": text}))
109+
print(json.dumps({"text": text, "error": error}))
60110
except Exception as e:
61111
print(f"JSON output error: {e}", file=sys.stderr)
62-
print('{"text": ""}')
112+
print('{"text": "", "error": "json_output_error"}')
63113

64114

65115
if __name__ == "__main__":

0 commit comments

Comments
 (0)