feat(scan): whiten receipt backgrounds and embed OCR in PDFs
This commit is contained in:
@@ -52,11 +52,15 @@ for (const start of [false, true]) {
|
||||
await writeFile(path.join(bin, 'scanimage'), '#!/bin/sh\nexit 0\n', { mode: 0o755 })
|
||||
await writeFile(path.join(bin, 'id'), '#!/bin/sh\nprintf "1000\\n"\n', { mode: 0o755 })
|
||||
await writeFile(path.join(bin, 'systemctl'), '#!/bin/sh\nprintf "%s\\n" "$*" >> "$FEDEO_TEST_SYSTEMD_LOG"\n', { mode: 0o755 })
|
||||
const realPython = (await execute('sh', ['-c', 'command -v python3'])).stdout.trim()
|
||||
await writeFile(path.join(bin, 'python3'), '#!/bin/sh\nif [ "$1" = "-c" ] && [ "$2" = "import PIL, cv2, numpy" ]; then exit 0; fi\nexec "$FEDEO_TEST_PYTHON" "$@"\n', { mode: 0o755 })
|
||||
await writeFile(path.join(bin, 'tesseract'), '#!/bin/sh\nprintf "deu\\neng\\n"\n', { mode: 0o755 })
|
||||
const env = {
|
||||
...process.env, PATH: bin + ':' + process.env.PATH,
|
||||
XDG_DATA_HOME: path.join(temp, 'data with spaces %'),
|
||||
XDG_CONFIG_HOME: path.join(temp, 'config'), XDG_STATE_HOME: path.join(temp, 'state'),
|
||||
FEDEO_TEST_SYSTEMD_LOG: path.join(temp, 'systemd.log'),
|
||||
FEDEO_TEST_PYTHON: realPython,
|
||||
}
|
||||
const scriptPath = path.join(temp, 'install.sh')
|
||||
await writeFile(scriptPath, renderAgentInstaller(bundle, options))
|
||||
|
||||
@@ -51,6 +51,7 @@ const setup = async (t: TestContext) => {
|
||||
workDir: path.join(dir, "scans with 100% space"), scannerName: "fujitsu:ScanSnap iX1600:test",
|
||||
scanFormat: "pdf", scanResolution: 300, scanMode: "Color", scanSource: "ADF Duplex",
|
||||
scanTimeoutSeconds: 10, scanPostprocess: false, postprocessProfile: "document",
|
||||
scanBackgroundCleanup: false, scanOcr: false, scanOcrLanguage: "deu+eng",
|
||||
postprocessPython: "python3", postprocessStrict: false,
|
||||
}
|
||||
const job: ScanJob = { id: "test-job", tenantId: 1, agentId: "test-agent", status: "running", settings: { paperSize: "A4" } }
|
||||
@@ -143,6 +144,18 @@ test("missing image runtime is detected before feeding paper", async (t) => {
|
||||
await assert.rejects(args(), /ENOENT/)
|
||||
})
|
||||
|
||||
test("missing requested OCR language fails before the scanner consumes paper", async (t) => {
|
||||
const { config, job, dir, args } = await setup(t)
|
||||
const tesseract = path.join(dir, "tesseract")
|
||||
await writeFile(tesseract, "#!/bin/sh\nprintf 'List of available languages (1):\\neng\\n'\n")
|
||||
await chmod(tesseract, 0o755)
|
||||
job.settings!.ocr = true
|
||||
job.settings!.ocrLanguage = "deu+eng"
|
||||
await assert.rejects(runScan(config, job), /OCR-Sprachpakete fehlen: deu/)
|
||||
await assert.rejects(args(), /ENOENT/)
|
||||
await assert.rejects(readdir(config.workDir), /ENOENT/)
|
||||
})
|
||||
|
||||
test("single-page scans work without batch and requested paths cannot escape the job directory", async (t) => {
|
||||
const { config, job, args } = await setup(t)
|
||||
job.settings = { source: "Flatbed", format: "png" }
|
||||
|
||||
64
agents/fedeo-device-agent/tests/scan_background_test.py
Normal file
64
agents/fedeo-device-agent/tests/scan_background_test.py
Normal file
@@ -0,0 +1,64 @@
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
|
||||
from scan_background import whiten_background
|
||||
|
||||
|
||||
class ScanBackgroundTest(unittest.TestCase):
|
||||
def receipt(self, background=45, paper=245):
|
||||
image = np.full((1600, 1200, 3), background, dtype=np.uint8)
|
||||
image[120:1440, 360:820] = paper
|
||||
for y in range(220, 1350, 70):
|
||||
cv2.putText(image, "BON 12,34 EUR", (375, y), cv2.FONT_HERSHEY_SIMPLEX, .7, (20, 20, 20), 2)
|
||||
cv2.rectangle(image, (400, 140), (780, 180), (40, 80, 210), -1)
|
||||
return image
|
||||
|
||||
def test_receipt_on_dark_or_gray_backing_keeps_canvas_text_and_colored_logo(self):
|
||||
for background in (35, 140):
|
||||
image = self.receipt(background)
|
||||
result = whiten_background(image)
|
||||
self.assertEqual(result.shape, image.shape)
|
||||
self.assertTrue(np.all(result[:80] == 255))
|
||||
self.assertTrue(np.all(result[:, :250] == 255))
|
||||
self.assertTrue(np.array_equal(result[120:1440, 360:820], image[120:1440, 360:820]))
|
||||
|
||||
def test_slanted_receipt_is_whitened_without_rotating_or_cropping(self):
|
||||
image = np.full((1600, 1200, 3), 55, dtype=np.uint8)
|
||||
polygon = np.array([[450, 100], [890, 155], [720, 1450], [280, 1390]])
|
||||
cv2.fillPoly(image, [polygon], (245, 245, 245))
|
||||
cv2.putText(image, "FEDEO", (470, 700), cv2.FONT_HERSHEY_SIMPLEX, 1.5, (0, 0, 0), 3)
|
||||
result = whiten_background(image)
|
||||
self.assertEqual(result.shape, image.shape)
|
||||
inside = np.zeros(image.shape[:2], dtype=np.uint8)
|
||||
cv2.fillPoly(inside, [polygon], 255)
|
||||
self.assertTrue(np.array_equal(result[inside > 0], image[inside > 0]))
|
||||
self.assertTrue(np.all(result[:50] == 255))
|
||||
|
||||
def test_white_full_page_is_not_replaced_by_its_largest_text_box(self):
|
||||
image = np.full((1600, 1200, 3), 255, dtype=np.uint8)
|
||||
cv2.rectangle(image, (300, 400), (800, 1200), (30, 30, 30), 4)
|
||||
cv2.putText(image, "Footer", (20, 1550), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 0, 0), 2)
|
||||
self.assertTrue(np.array_equal(whiten_background(image), image))
|
||||
|
||||
def test_slanted_receipt_retains_every_paper_edge_pixel_at_300_dpi(self):
|
||||
image = np.full((3508, 2480, 3), 45, dtype=np.uint8)
|
||||
polygon = np.array([[820, 200], [1700, 340], [1250, 3300], [380, 3160]])
|
||||
cv2.fillPoly(image, [polygon], (245, 245, 245))
|
||||
inside = np.zeros(image.shape[:2], dtype=np.uint8)
|
||||
cv2.fillPoly(inside, [polygon], 255)
|
||||
result = whiten_background(image)
|
||||
self.assertTrue(np.array_equal(result[inside > 0], image[inside > 0]))
|
||||
self.assertTrue(np.all(result[:50] == 255))
|
||||
|
||||
def test_blank_and_ambiguous_low_contrast_pages_remain_unchanged(self):
|
||||
for image in [np.full((300, 200, 3), 245, dtype=np.uint8), self.receipt(215, 230)]:
|
||||
self.assertTrue(np.array_equal(whiten_background(image), image))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
71
agents/fedeo-device-agent/tests/scan_ocr_test.py
Normal file
71
agents/fedeo-device-agent/tests/scan_ocr_test.py
Normal file
@@ -0,0 +1,71 @@
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont, PdfParser
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
|
||||
from scan_document import create_document, check_ocr
|
||||
|
||||
|
||||
class ScanOcrTest(unittest.TestCase):
|
||||
def test_missing_language_is_reported_before_processing(self):
|
||||
with patch("scan_document.run_ocr_command", return_value="List of available languages (1):\neng\n"):
|
||||
with self.assertRaisesRegex(RuntimeError, "deu"):
|
||||
check_ocr("deu+eng")
|
||||
with self.assertRaises(ValueError):
|
||||
check_ocr("--bad-language")
|
||||
|
||||
@unittest.skipUnless(shutil.which("tesseract") and shutil.which("pdftotext"), "Tesseract and Poppler required")
|
||||
def test_ocr_keeps_receipt_canvas_blank_back_and_page_order_with_real_extractable_text(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
pages = []
|
||||
for index, text in enumerate(["FEDEO Bon 12345", "", "Zweite Seite 24680"]):
|
||||
page = root / f"page-{index}.png"
|
||||
image = Image.new("RGB", (1240, 1754), (45, 45, 45) if index == 0 else "white")
|
||||
draw = ImageDraw.Draw(image)
|
||||
if index == 0:
|
||||
draw.rectangle((260, 150, 1000, 1550), fill="white")
|
||||
if text:
|
||||
draw.text((300, 300), text, fill="black", font=ImageFont.load_default(size=44))
|
||||
draw.text((300, 390), "Summe EUR 12,34", fill="black", font=ImageFont.load_default(size=36))
|
||||
image.save(page)
|
||||
pages.append(page)
|
||||
output = root / "searchable.pdf"
|
||||
result = create_document(pages, output, 150, ocr=True, background_cleanup=True)
|
||||
self.assertEqual(result["pageCount"], 3)
|
||||
with PdfParser.PdfParser(filename=str(output)) as pdf:
|
||||
self.assertEqual(len(pdf.pages), 3)
|
||||
for reference in pdf.pages:
|
||||
box = pdf.read_indirect(reference)[b"MediaBox"]
|
||||
self.assertAlmostEqual(box[2], 1240 * 72 / 150, delta=.1)
|
||||
self.assertAlmostEqual(box[3], 1754 * 72 / 150, delta=.1)
|
||||
text = subprocess.check_output(["pdftotext", str(output), "-"], text=True)
|
||||
extracted = text.split("\f")
|
||||
self.assertIn("12345", extracted[0])
|
||||
self.assertEqual(extracted[1].strip(), "")
|
||||
self.assertIn("24680", extracted[2])
|
||||
self.assertTrue(all(page.exists() for page in pages))
|
||||
|
||||
def test_ocr_failure_never_publishes_a_partial_document_or_removes_raw_pages(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
page = root / "raw.png"
|
||||
Image.new("RGB", (400, 600), "white").save(page)
|
||||
output = root / "failed.pdf"
|
||||
with patch("scan_document.check_ocr"), patch("scan_document.run_ocr_command", side_effect=RuntimeError("OCR failed")):
|
||||
with self.assertRaisesRegex(RuntimeError, "OCR failed"):
|
||||
create_document([page], output, 300, ocr=True)
|
||||
self.assertFalse(output.exists())
|
||||
self.assertFalse(output.with_name("failed.pdf.partial").exists())
|
||||
self.assertTrue(page.exists())
|
||||
self.assertEqual(list(root.glob("prepared-*")), [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user