feat(scan): whiten receipt backgrounds and embed OCR in PDFs

This commit is contained in:
2026-09-24 17:41:30 +02:00
parent b9cfdbe29e
commit a65bd72d74
26 changed files with 572 additions and 76 deletions

View File

@@ -10,6 +10,9 @@ FEDEO_SCAN_MODE=Color
FEDEO_SCAN_SOURCE=
FEDEO_SCAN_TIMEOUT_SECONDS=900
FEDEO_SCAN_POSTPROCESS=false
FEDEO_SCAN_BACKGROUND_CLEANUP=true
FEDEO_SCAN_OCR=true
FEDEO_SCAN_OCR_LANGUAGE=deu+eng
FEDEO_SCAN_POSTPROCESS_PROFILE=document
FEDEO_SCAN_POSTPROCESS_PYTHON=
FEDEO_SCAN_POSTPROCESS_STRICT=false

View File

@@ -13,6 +13,9 @@ FROM node:20-bookworm-slim AS runtime
ENV NODE_ENV=production
ENV FEDEO_WORK_DIR=/work
ENV FEDEO_SCAN_POSTPROCESS=false
ENV FEDEO_SCAN_BACKGROUND_CLEANUP=true
ENV FEDEO_SCAN_OCR=true
ENV FEDEO_SCAN_OCR_LANGUAGE=deu+eng
ENV FEDEO_SCAN_POSTPROCESS_PROFILE=document
ENV FEDEO_SCAN_POSTPROCESS_PYTHON=/opt/fedeo-device-agent/.venv-opencv/bin/python
ENV FEDEO_SCAN_POSTPROCESS_STRICT=false
@@ -28,6 +31,9 @@ RUN apt-get update \
python3-pip \
python3-venv \
sane-utils \
tesseract-ocr \
tesseract-ocr-deu \
tesseract-ocr-eng \
&& rm -rf /var/lib/apt/lists/*
COPY requirements-opencv.txt ./

View File

@@ -44,11 +44,11 @@ FEDEO_TEST_POSTGRES_IMAGE=postgres:16-alpine node --import tsx tests/instanceAge
### macOS
```bash
brew install node sane-backends
brew install node sane-backends tesseract tesseract-lang
scanimage -L
```
Für die Dateierstellung wird Python mit Pillow benötigt, beispielsweise über `npm run setup:opencv` (siehe unten).
Für die Dateierstellung und Hintergrundbereinigung wird Python mit Pillow und OpenCV benötigt, beispielsweise über `npm run setup:opencv` (siehe unten). `tesseract --list-langs` muss für die Standardkonfiguration `deu` und `eng` enthalten.
Drucken nutzt später das macOS-Drucksystem/CUPS:
@@ -60,7 +60,7 @@ lpstat -p
```bash
sudo apt update
sudo apt install -y nodejs npm sane-utils python3-pil
sudo apt install -y nodejs npm sane-utils python3-pil python3-opencv tesseract-ocr tesseract-ocr-deu tesseract-ocr-eng
scanimage -L
```
@@ -90,6 +90,9 @@ Der iX1600 wird vom SANE-Backend `fujitsu` per USB unterstützt. Die USB-ID ist
FEDEO_SCAN_RESOLUTION=300
FEDEO_SCAN_MODE=Color
FEDEO_SCAN_POSTPROCESS=false
FEDEO_SCAN_BACKGROUND_CLEANUP=true
FEDEO_SCAN_OCR=true
FEDEO_SCAN_OCR_LANGUAGE=deu+eng
FEDEO_SCAN_TIMEOUT_SECONDS=900
```
@@ -139,7 +142,24 @@ npm run build:installer
`npm test` prüft mit `check:installer`, dass das Paket dem aktuellen Quellcode entspricht. Das Paket enthält keine Zugangsdaten; diese ergänzt erst der Browser beim Download. Die Installer-Tests verwenden temporäre Verzeichnisse und simulierte Systembefehle.
## OpenCV-Nachbearbeitung
## Scannerhintergrund und OCR
Hintergrundbereinigung und OCR sind standardmäßig aktiv und lassen sich in **Administration → Scanner** als Vorgabe oder im einzelnen Scan-Dialog einstellen:
- **Scannerhintergrund weiß machen:** Bei einem kleinen Bon oder Dokument werden sicher erkannte Flächen außerhalb des Papiers weiß. Seitengröße, Position und Inhalt des Papiers bleiben erhalten. Unsichere Papiergrenzen werden nicht verändert. Diese Verarbeitung ist unabhängig vom zusätzlichen Zuschnitt.
- **Durchsuchbares PDF (OCR):** Der Agent erkennt nach der Bildverarbeitung Text mit Tesseract und bettet ihn direkt in das PDF ein, bevor er es hochlädt. Das gilt auch für normale Dateien ohne Eingangsbeleg. Deutsch und Englisch sind vorausgewählt; PNG und TIFF erhalten keine OCR-Textschicht. Die Darstellung bleibt das gescannte Bild, ergänzt um auswählbaren und durchsuchbaren Text ([Tesseract-Dokumentation](https://tesseract-ocr.github.io/tessdoc/Command-Line-Usage.html)).
```env
FEDEO_SCAN_BACKGROUND_CLEANUP=true
FEDEO_SCAN_OCR=true
FEDEO_SCAN_OCR_LANGUAGE=deu+eng
```
Nach einem Update von FEDEO bestehende Agenten über **Einrichtung öffnen** mit dem neuen Linux-Installer aktualisieren. Dieser installiert auch OpenCV, Tesseract und die Sprachpakete. Bei manueller Installation die oben genannten Pakete und die aktuelle Agent-Software installieren und den Dienst neu starten. Der Agent meldet die verfügbare Verarbeitung beim Start an FEDEO; fehlende Fähigkeiten werden vor dem Auftrag gemeldet. Zusätzlich prüft er die benötigten Pakete und die gewählte OCR-Sprache vor jedem Papiereinzug. Ein OCR-Fehler erzeugt keinen erfolgreichen Scan ohne Textschicht; die Rohseiten bleiben zur Wiederherstellung erhalten.
Die OCR läuft innerhalb des Scan-Auftrags nach dem Einzug. Bei langen Stapeln kann dieser Schritt länger dauern; das Zeitlimit für die Dateierstellung wird ebenfalls über `FEDEO_SCAN_TIMEOUT_SECONDS` gesetzt. Leere Rückseiten und Seitenreihenfolge bleiben erhalten.
## Zusätzliche OpenCV-Nachbearbeitung
Für automatischen Zuschnitt, leichte Entzerrung, Rotation und Kontrastkorrektur kann die OpenCV-Pipeline aktiviert werden.
@@ -156,7 +176,7 @@ FEDEO_SCAN_POSTPROCESS_PYTHON=/pfad/zum/agent/.venv-opencv/bin/python
FEDEO_SCAN_POSTPROCESS_STRICT=false
```
Wenn `FEDEO_SCAN_POSTPROCESS_PYTHON` leer bleibt, verwendet der Agent automatisch `.venv-opencv/bin/python`, sofern diese Umgebung existiert, sonst `python3`. Pillow ist auch ohne Bildkorrektur für die Dateierstellung erforderlich. Falls OpenCV nicht installiert ist und `FEDEO_SCAN_POSTPROCESS_STRICT=false` gesetzt ist, werden die Rohseiten im gewünschten Ausgabeformat zusammengeführt. Eine fehlgeschlagene Korrektur verliert keine Seiten. Die Warnung erscheint im Scan-Ergebnis. Beim iX1600-Profil ist die Bildkorrektur zunächst ausgeschaltet, damit der vollständige A4-Scan erhalten bleibt.
Wenn `FEDEO_SCAN_POSTPROCESS_PYTHON` leer bleibt, verwendet der Agent automatisch `.venv-opencv/bin/python`, sofern diese Umgebung existiert, sonst `python3`. Pillow ist auch ohne Bildkorrektur für die Dateierstellung erforderlich. Falls die zusätzliche Korrektur fehlschlägt und `FEDEO_SCAN_POSTPROCESS_STRICT=false` gesetzt ist, werden die betroffenen Rohseiten weiterverarbeitet; die Warnung erscheint im Scan-Ergebnis. Fehlendes OpenCV kann nur bei ausgeschalteter Hintergrundbereinigung toleriert werden. Beim iX1600-Profil ist der zusätzliche Zuschnitt ausgeschaltet, damit der vollständige A4-Scan erhalten bleibt; Hintergrundbereinigung und OCR sind eingeschaltet.
Profile:
@@ -166,7 +186,7 @@ Profile:
## Container-Betrieb
Auf Linux und Raspberry Pi OS kann der Agent komplett im Container laufen. Dadurch bleiben Node.js, Python, OpenCV und SANE im Image. Auf dem Host werden dann nur Docker und Zugriff auf den USB-Scanner benötigt.
Auf Linux und Raspberry Pi OS kann der Agent komplett im Container laufen. Dadurch bleiben Node.js, Python, OpenCV, Tesseract einschließlich Deutsch/Englisch und SANE im Image. Auf dem Host werden dann nur Docker und Zugriff auf den USB-Scanner benötigt.
```bash
cp .env.example .env
@@ -202,7 +222,7 @@ npm test
npm run test:python
```
Die Tests benötigen Python 3 mit Pillow. Sie simulieren `scanimage`, erstellen echte PDFs und prüfen Duplex-/Simplex-Stapel, Seitenreihenfolge, Auflösung, leeren Einzug, Papierstau, beschädigte Seiten, Dateinamen und OpenCV-Ausfälle. Ein Test mit angeschlossenem iX1600 bleibt zusätzlich erforderlich.
Die Tests benötigen Python 3 mit Pillow, NumPy und OpenCV. Für den OCR-Integrationstest sind Tesseract mit `deu`/`eng` und `pdftotext` aus Poppler erforderlich; ohne diese Programme wird er übersprungen. Bei Verwendung der virtuellen Umgebung zuerst `. .venv-opencv/bin/activate` ausführen. Die Tests simulieren `scanimage`, erstellen echte PDFs und prüfen Duplex-/Simplex-Stapel, Seitenreihenfolge, Auflösung, leeren Einzug, Papierstau, beschädigte Seiten, Dateinamen und OpenCV-Ausfälle. Bildtests prüfen die Hintergrundbereinigung bei geraden und schrägen Bons sowie den Erhalt von Text, farbigen Logos und vollständigen Seiten. Der OCR-Test liest die tatsächliche PDF-Textschicht einschließlich leerer Rückseiten aus. Ein Test mit angeschlossenem iX1600 bleibt zusätzlich erforderlich.
Hardwareprüfung am 17.09.2026: ScanSnap iX1600 per USB unter Linux Mint 22.3 mit SANE 1.2.1. Zwei A4-Blätter wurden mit 300 dpi in Farbe beidseitig gescannt; das erzeugte PDF enthält vier A4-Seiten in der Reihenfolge Vorderseite/Rückseite. Alle vier Seiten wurden visuell geprüft. Der Test umfasste den lokalen Agenten bis zur PDF-Erstellung; der FEDEO-Upload wird separat mit einem simulierten Dateispeicher getestet.

View File

@@ -14,7 +14,7 @@ async function filesUnder(directory) {
const packageJson = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8'))
const paths = [
...(await filesUnder('dist')).filter(file => file.endsWith('.js')),
'scripts/scan_document.py', 'scripts/opencv_postprocess.py', 'scripts/setup-opencv.sh',
'scripts/scan_document.py', 'scripts/scan_background.py', 'scripts/opencv_postprocess.py', 'scripts/setup-opencv.sh',
'requirements-opencv.txt',
]
const files = Object.fromEntries(await Promise.all(paths.map(async file => [file, (await readFile(path.join(root, file))).toString('base64')])))

View File

@@ -25,17 +25,22 @@ FEDEO_SETUP_UNIT="fedeo-device-agent-$FEDEO_SETUP_ID.service"
FEDEO_SETUP_UNITS="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
echo 'FEDEO Scan-Agent wird für diesen Benutzer eingerichtet.'
if ! command -v node >/dev/null || ! command -v scanimage >/dev/null || ! python3 -c 'import PIL' >/dev/null 2>&1; then
fedeo_processing_ready() {
python3 -c 'import PIL, cv2, numpy' >/dev/null 2>&1 &&
tesseract --list-langs 2>/dev/null | grep -qx deu &&
tesseract --list-langs 2>/dev/null | grep -qx eng
}
if ! command -v node >/dev/null || ! command -v scanimage >/dev/null || ! fedeo_processing_ready; then
if ! command -v apt-get >/dev/null; then
echo 'Bitte Node.js >= 20, SANE (scanimage), Python 3 und Pillow installieren und das Skript erneut starten.' >&2
echo 'Bitte Node.js >= 20, SANE, Python/Pillow/OpenCV sowie Tesseract mit Deutsch/Englisch installieren und das Skript erneut starten.' >&2
exit 1
fi
echo 'Fehlende Systempakete werden über apt installiert (sudo benötigt).'
sudo apt-get update
sudo apt-get install -y nodejs sane-utils python3-pil
sudo apt-get install -y nodejs sane-utils python3-pil python3-opencv tesseract-ocr tesseract-ocr-deu tesseract-ocr-eng
fi
node -e 'if (Number(process.versions.node.split(".")[0]) < 20) { console.error("Node.js >= 20 wird benötigt. Bitte Node.js aktualisieren und die Installation erneut starten."); process.exit(1) }'
python3 -c 'import PIL'
fedeo_processing_ready || { echo 'Die benötigte Bildverarbeitung oder die OCR-Sprachpakete fehlen weiterhin.' >&2; exit 1; }
FEDEO_SETUP_NODE="$(command -v node)"
FEDEO_SETUP_HAS_SYSTEMD=false

View File

@@ -0,0 +1,74 @@
"""Conservative paper masking. Keep the canvas and every pixel inside the paper."""
from pathlib import Path
import cv2
import numpy as np
def whiten_background(image):
height, width = image.shape[:2]
scale = min(1.0, 1000.0 / max(height, width))
small = cv2.resize(image, None, fx=scale, fy=scale, interpolation=cv2.INTER_AREA)
gray = cv2.cvtColor(small, cv2.COLOR_BGR2GRAY)
border = np.concatenate((gray[0], gray[-1], gray[:, 0], gray[:, -1]))
background = float(np.median(border))
# White surroundings already need no correction. Never search inside a
# white document for an isolated text box or logo to keep instead.
if background >= 235 or np.percentile(border, 75) - np.percentile(border, 25) > 55:
return image
threshold, _ = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
mask = np.uint8(gray > max(threshold, background + 20)) * 255
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, np.ones((5, 5), np.uint8))
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
candidates = []
area = gray.size
for contour in contours:
contour_area = cv2.contourArea(contour)
if not 0.015 * area < contour_area < 0.985 * area:
continue
perimeter = cv2.arcLength(contour, True)
polygon = cv2.approxPolyDP(contour, 0.018 * perimeter, True)
if len(polygon) != 4 or not cv2.isContourConvex(polygon):
continue
# Use the quadrilateral only to recognize a sheet, then keep its full
# contour hull so rounded or slightly uneven edges cannot lose content.
polygon = cv2.convexHull(contour)
paper = np.zeros_like(gray)
cv2.fillPoly(paper, [polygon], 255)
inside = cv2.erode(paper, np.ones((7, 7), np.uint8)) > 0
ring = (cv2.dilate(paper, np.ones((9, 9), np.uint8)) > 0) & (paper == 0)
if inside.sum() < 100 or ring.sum() < 50:
continue
paper_light = float(np.median(gray[inside]))
# A paper edge must separate a mostly light sheet from darker scanner
# backing all around it. Ambiguous photos/pages are left untouched.
if paper_light < background + 30 or np.mean(gray[ring] < paper_light - 20) < 0.85:
continue
candidates.append(polygon)
if not candidates:
return image
keep_small = np.zeros_like(gray)
cv2.fillPoly(keep_small, candidates, 255)
# Do not erase another significant object that we could not classify.
outside = (keep_small == 0)
if np.mean(gray[outside] > background + 30) > 0.04:
return image
keep = np.zeros((height, width), dtype=np.uint8)
polygons = [np.rint(polygon / scale).astype(np.int32) for polygon in candidates]
cv2.fillPoly(keep, polygons, 255)
# Rounding/downsampling uncertainty grows with the original scan DPI.
padding = max(2, int(np.ceil(2 / scale)))
keep = cv2.dilate(keep, np.ones((2 * padding + 1, 2 * padding + 1), np.uint8))
result = image.copy()
result[keep == 0] = 255
return result
def clean_scan_background(input_path: Path, output_path: Path):
image = cv2.imread(str(input_path), cv2.IMREAD_COLOR)
if image is None:
raise RuntimeError("Scan-Bild konnte nicht gelesen werden")
if not cv2.imwrite(str(output_path), whiten_background(image)):
raise RuntimeError("Bereinigtes Scan-Bild konnte nicht gespeichert werden")

View File

@@ -1,21 +1,56 @@
#!/usr/bin/env python3
"""Assemble ordered scanner pages; OpenCV is optional, PDF creation is not."""
"""Prepare ordered scanner pages and publish an optionally searchable PDF atomically."""
import argparse
import json
import math
from pathlib import Path
import re
import signal
import subprocess
import tempfile
from PIL import Image
from PIL import Image, PdfParser
def create_document(inputs, output, resolution, use_postprocess=False, profile="document", strict=False):
def run_ocr_command(arguments):
process = subprocess.Popen(arguments, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
try:
stdout, stderr = process.communicate()
except BaseException:
process.kill()
process.wait()
raise
if process.returncode != 0:
raise RuntimeError("OCR fehlgeschlagen: " + stderr.strip())
return stdout
def check_ocr(language):
if not re.fullmatch(r"[a-zA-Z0-9_]+(?:\+[a-zA-Z0-9_]+)*", language):
raise ValueError("Ungültige OCR-Sprache")
try:
languages = run_ocr_command(["tesseract", "--list-langs"]).splitlines()
except FileNotFoundError as error:
raise RuntimeError("Tesseract fehlt. Bitte tesseract-ocr und die Sprachpakete tesseract-ocr-deu/tesseract-ocr-eng installieren.") from error
missing = set(language.split("+")) - {line.strip() for line in languages}
if missing:
raise RuntimeError("OCR-Sprachpakete fehlen: " + ", ".join(sorted(missing)))
def create_document(inputs, output, resolution, use_postprocess=False, profile="document", strict=False,
ocr=False, ocr_language="deu+eng", background_cleanup=False):
if not inputs:
raise ValueError("Keine Seiten vorhanden")
if not math.isfinite(resolution) or resolution <= 0:
raise ValueError("Ungültige Scan-Auflösung")
output = Path(output)
file_format = {".pdf": "PDF", ".png": "PNG", ".tiff": "TIFF"}.get(output.suffix.lower())
if not file_format:
raise ValueError("Unbekanntes Ausgabeformat")
if file_format != "PDF" and len(inputs) != 1:
raise ValueError("Mehrseitige Scans benötigen PDF als Ausgabeformat")
if (len(inputs) != 1 or ocr) and file_format != "PDF":
raise ValueError("Mehrseitige Scans und OCR benötigen PDF als Ausgabeformat")
if ocr:
check_ocr(ocr_language)
warnings = []
processor = None
@@ -27,47 +62,92 @@ def create_document(inputs, output, resolution, use_postprocess=False, profile="
if strict:
raise
warnings.append("OpenCV fehlt; alle Seiten wurden ohne Bildkorrektur übernommen.")
if background_cleanup:
from scan_background import clean_scan_background
partial = output.with_name(output.name + ".partial")
try:
for index, input_path in enumerate(inputs):
input_path = Path(input_path)
image_path = input_path
corrected = input_path.with_name(input_path.stem + ".corrected.png")
if processor:
try:
processor(input_path, corrected, profile)
image_path = corrected
except Exception:
if strict:
raise
warnings.append(f"Seite {index + 1}: Bildkorrektur fehlgeschlagen; Rohscan übernommen.")
with tempfile.TemporaryDirectory(prefix="prepared-", dir=output.parent) as directory:
prepared = []
for index, input_path in enumerate(inputs):
image_path = Path(input_path)
corrected = Path(directory) / f"corrected-{index:06}.png"
if processor:
try:
processor(image_path, corrected, profile)
image_path = corrected
except Exception:
if strict:
raise
warnings.append(f"Seite {index + 1}: Bildkorrektur fehlgeschlagen; Rohscan übernommen.")
if background_cleanup:
cleaned = Path(directory) / f"cleaned-{index:06}.png"
clean_scan_background(image_path, cleaned)
image_path = cleaned
# Only one decoded page is kept in memory, even for large duplex stacks.
with Image.open(image_path) as image:
with image.convert("RGB") as rgb:
if file_format == "PDF":
rgb.save(partial, "PDF", resolution=resolution, quality=90, append=index > 0)
else:
rgb.save(partial, file_format, dpi=(resolution, resolution))
corrected.unlink(missing_ok=True)
partial.replace(output)
# Only one decoded page is kept in memory, even for duplex stacks.
with Image.open(image_path) as image:
with image.convert("RGB") as rgb:
if ocr:
page = Path(directory).resolve() / f"page-{index:06}.png"
rgb.save(page, "PNG", dpi=(resolution, resolution))
prepared.append(page)
elif file_format == "PDF":
rgb.save(partial, "PDF", resolution=resolution, quality=90, append=index > 0)
else:
rgb.save(partial, file_format, dpi=(resolution, resolution))
corrected.unlink(missing_ok=True)
if background_cleanup:
cleaned.unlink(missing_ok=True)
if ocr:
page_list = Path(directory) / "pages.txt"
if any("\n" in str(page) or "\r" in str(page) for page in prepared):
raise ValueError("Das Scan-Arbeitsverzeichnis darf keinen Zeilenumbruch enthalten")
page_list.write_text("\n".join(map(str, prepared)) + "\n", encoding="utf8")
prefix = Path(directory) / "searchable"
run_ocr_command(["tesseract", str(page_list), str(prefix), "-l", ocr_language,
"--dpi", str(round(resolution)), "pdf"])
searchable = prefix.with_suffix(".pdf")
with PdfParser.PdfParser(filename=str(searchable)) as pdf:
if len(pdf.pages) != len(inputs):
raise RuntimeError("OCR-PDF ist unvollständig; Rohscans bleiben erhalten")
searchable.replace(partial)
partial.replace(output)
finally:
partial.unlink(missing_ok=True)
return {"pageCount": len(inputs), "warnings": warnings}
def interrupted(*_):
raise SystemExit("Scan-Verarbeitung abgebrochen")
def main():
# Let finally blocks stop Tesseract and remove incomplete PDFs on agent timeout.
signal.signal(signal.SIGTERM, interrupted)
parser = argparse.ArgumentParser(description="Scan-Seiten zu einer Datei zusammenführen")
parser.add_argument("--inputs", nargs="+", required=True)
parser.add_argument("--output", required=True)
parser.add_argument("--resolution", type=float, required=True)
parser.add_argument("--inputs", nargs="+")
parser.add_argument("--output")
parser.add_argument("--resolution", type=float, default=300)
parser.add_argument("--profile", choices=["document", "receipt", "raw"], default="document")
parser.add_argument("--postprocess", action="store_true")
parser.add_argument("--strict", action="store_true")
parser.add_argument("--background-cleanup", action="store_true")
parser.add_argument("--ocr", action="store_true")
parser.add_argument("--ocr-language", default="deu+eng")
parser.add_argument("--check-runtime", action="store_true")
args = parser.parse_args()
print(json.dumps(create_document(args.inputs, args.output, args.resolution, args.postprocess, args.profile, args.strict)))
if args.check_runtime:
if args.background_cleanup or (args.postprocess and args.strict):
import cv2, numpy
if args.ocr:
check_ocr(args.ocr_language)
print(json.dumps({"ready": True}))
return
if not args.inputs or not args.output:
parser.error("--inputs und --output sind erforderlich")
print(json.dumps(create_document(args.inputs, args.output, args.resolution, args.postprocess, args.profile,
args.strict, args.ocr, args.ocr_language, args.background_cleanup)))
if __name__ == "__main__":

View File

@@ -62,6 +62,9 @@ export const loadConfig = (): AgentConfig => {
scanSource: optional(process.env.FEDEO_SCAN_SOURCE),
scanTimeoutSeconds: numberFromEnv(process.env.FEDEO_SCAN_TIMEOUT_SECONDS, 900),
scanPostprocess: booleanFromEnv(process.env.FEDEO_SCAN_POSTPROCESS, false),
scanBackgroundCleanup: booleanFromEnv(process.env.FEDEO_SCAN_BACKGROUND_CLEANUP, true),
scanOcr: booleanFromEnv(process.env.FEDEO_SCAN_OCR, true),
scanOcrLanguage: optional(process.env.FEDEO_SCAN_OCR_LANGUAGE) || "deu+eng",
postprocessProfile: postprocessProfileFromEnv(process.env.FEDEO_SCAN_POSTPROCESS_PROFILE),
postprocessPython: optional(process.env.FEDEO_SCAN_POSTPROCESS_PYTHON) || defaultPostprocessPython(),
postprocessStrict: booleanFromEnv(process.env.FEDEO_SCAN_POSTPROCESS_STRICT, false),

View File

@@ -7,6 +7,7 @@ import { loadConfig } from "./config.js"
import { log } from "./logger.js"
import { listPrinters } from "./print/cups.js"
import { hasSane, listScanners, runScan } from "./scan/sane.js"
import { processingCapabilities } from "./scan/postprocess.js"
import { AgentHeartbeat } from "./types.js"
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
@@ -19,6 +20,7 @@ const stringifyError = (error: unknown) => {
const main = async () => {
const config = loadConfig()
const api = new FedeoApi(config)
const processing = await processingCapabilities(config)
log.info("FEDEO Geräte-Agent startet", {
platform: process.platform,
@@ -38,6 +40,7 @@ const main = async () => {
const heartbeatPayload: AgentHeartbeat = {
capabilities: {
scan: scanAvailable,
...processing,
print: printerNames.length > 0,
platform: process.platform,
},

View File

@@ -6,15 +6,34 @@ import { runCommand } from "../commands.js"
const agentRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..")
const documentScript = path.join(agentRoot, "scripts/scan_document.py")
export const checkScanRuntime = async (config: AgentConfig, postprocess: boolean) => {
const imports = postprocess && config.postprocessStrict ? "import PIL, cv2, numpy" : "import PIL"
type ProcessingOptions = { ocr: boolean; ocrLanguage: string; backgroundCleanup: boolean }
export const checkScanRuntime = async (config: AgentConfig, postprocess: boolean, options: ProcessingOptions) => {
const args = [documentScript, "--check-runtime", "--ocr-language", options.ocrLanguage]
if (postprocess) args.push("--postprocess")
if (config.postprocessStrict) args.push("--strict")
if (options.backgroundCleanup) args.push("--background-cleanup")
if (options.ocr) args.push("--ocr")
try {
const result = await runCommand(config.postprocessPython, ["-c", imports], { timeoutMs: 10_000 })
const result = await runCommand(config.postprocessPython, args, { timeoutMs: 10_000 })
if (result.code === 0) return
} catch {
throw new Error(result.stderr.trim())
} catch (error) {
// Report missing runtimes before the scanner consumes any paper.
const detail = error instanceof Error ? error.message : String(error)
throw new Error("Scan-Verarbeitung nicht verfügbar. Bitte den Agenten mit Python/Pillow, OpenCV und Tesseract samt OCR-Sprachpaketen installieren. " + detail)
}
throw new Error("Python/Pillow (bei strikter Nachbearbeitung zusätzlich OpenCV) fehlt. Bitte npm run setup:opencv ausführen oder python3-pil installieren.")
}
export const processingCapabilities = async (config: AgentConfig) => {
const available = async (options: ProcessingOptions) => {
try { await checkScanRuntime(config, false, options); return true } catch { return false }
}
const [ocr, backgroundCleanup] = await Promise.all([
available({ ocr: true, ocrLanguage: config.scanOcrLanguage, backgroundCleanup: false }),
available({ ocr: false, ocrLanguage: config.scanOcrLanguage, backgroundCleanup: true }),
])
return { ocr, backgroundCleanup }
}
export const createScanDocument = async (
@@ -23,11 +42,14 @@ export const createScanDocument = async (
outputPath: string,
resolution: number,
postprocess: boolean,
profile: AgentConfig["postprocessProfile"]
profile: AgentConfig["postprocessProfile"],
options: ProcessingOptions
): Promise<ScanResult> => {
const args = [documentScript, "--output", outputPath, "--resolution", String(resolution), "--profile", profile]
if (postprocess) args.push("--postprocess")
if (config.postprocessStrict) args.push("--strict")
if (options.backgroundCleanup) args.push("--background-cleanup")
if (options.ocr) args.push("--ocr", "--ocr-language", options.ocrLanguage)
args.push("--inputs", ...pages)
const result = await runCommand(config.postprocessPython, args, {

View File

@@ -53,7 +53,12 @@ export const runScan = async (
const postprocess = booleanSetting(settings, "postprocess", config.scanPostprocess)
const profile = stringSetting(settings, "postprocessProfile") || config.postprocessProfile
if (!["document", "receipt", "raw"].includes(profile)) throw new Error("Unbekanntes Nachbearbeitungsprofil")
await checkScanRuntime(config, postprocess)
const processing = {
backgroundCleanup: booleanSetting(settings, "backgroundCleanup", config.scanBackgroundCleanup),
ocr: format === "pdf" && booleanSetting(settings, "ocr", config.scanOcr),
ocrLanguage: stringSetting(settings, "ocrLanguage") || config.scanOcrLanguage,
}
await checkScanRuntime(config, postprocess, processing)
// Each physical scan has its own directory. A filename cannot escape it or overwrite another job.
await mkdir(config.workDir, { recursive: true, mode: 0o700 })
@@ -111,8 +116,8 @@ export const runScan = async (
throw new Error("Der Duplex-Scan ist unvollständig: Zu einer Vorderseite fehlt die Rückseite.")
}
await reportProgress(`${pages.length} Seiten gescannt. Datei wird erstellt...`)
return await createScanDocument(config, pages, outputPath, resolution, postprocess, profile as AgentConfig["postprocessProfile"])
await reportProgress(`${pages.length} Seiten gescannt. ${processing.ocr ? "Durchsuchbares PDF mit OCR wird erstellt..." : "Datei wird erstellt..."}`)
return await createScanDocument(config, pages, outputPath, resolution, postprocess, profile as AgentConfig["postprocessProfile"], processing)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
throw new Error(`${message} Rohscans bleiben zur Wiederherstellung in ${jobDir}.`)

View File

@@ -11,6 +11,9 @@ export type AgentConfig = {
scanSource?: string
scanTimeoutSeconds: number
scanPostprocess: boolean
scanBackgroundCleanup: boolean
scanOcr: boolean
scanOcrLanguage: string
postprocessProfile: "document" | "receipt" | "raw"
postprocessPython: string
postprocessStrict: boolean
@@ -20,6 +23,8 @@ export type AgentHeartbeat = {
capabilities: {
scan: boolean
print: boolean
ocr: boolean
backgroundCleanup: boolean
platform: NodeJS.Platform
}
scannerNames: string[]

View File

@@ -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))

View File

@@ -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" }

View 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()

View 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()