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

@@ -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[]