feat(scan): whiten receipt backgrounds and embed OCR in PDFs
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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}.`)
|
||||
|
||||
@@ -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[]
|
||||
|
||||
Reference in New Issue
Block a user