Files
nexarch/dms/internal/batchsplit/classify.go
T
patrickandClaude Sonnet 5 7701f9eb8b
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
INT-10: leerseiten- und barcode-trennseiten fuer den stapelimport
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 20:07:23 +02:00

189 lines
4.4 KiB
Go

package batchsplit
import (
"fmt"
"image"
"image/png"
"os"
"strings"
"github.com/makiuchi-d/gozxing"
"github.com/makiuchi-d/gozxing/oned"
"github.com/makiuchi-d/gozxing/qrcode"
)
// Class ist das Ergebnis der Seitenklassifikation; Value traegt bei einer
// Barcode-Trennseite den Barcode-Wert ohne Praefix.
type Class struct {
Separator bool
Value string
}
// Page ist eine Stapelseite; das Rasterbild wird erst beim Zugriff geladen.
type Page struct {
Number int
Content []byte
path string
img image.Image
loaded bool
}
func newPage(n int, content []byte, imagePath string) *Page {
return &Page{Number: n, Content: content, path: imagePath}
}
func (p *Page) Image() (image.Image, error) {
if p.loaded {
return p.img, nil
}
if p.path == "" {
return nil, fmt.Errorf("seite %d ist nicht gerastert", p.Number)
}
f, err := os.Open(p.path)
if err != nil {
return nil, err
}
defer func() { _ = f.Close() }()
img, err := png.Decode(f)
if err != nil {
return nil, fmt.Errorf("seite %d dekodieren: %w", p.Number, err)
}
p.img, p.loaded = img, true
return img, nil
}
type Classifier interface {
NeedsImage() bool
Classify(p *Page) (Class, error)
}
// DetectorClassifier fuehrt einen inhaltsbasierten Detector als Classifier.
type DetectorClassifier struct{ Detector }
func (DetectorClassifier) NeedsImage() bool { return false }
func (d DetectorClassifier) Classify(p *Page) (Class, error) {
return Class{Separator: d.IsSeparator(p.Content)}, nil
}
// Chain fragt die Classifier der Reihe nach; die erste Trennseite gewinnt,
// den Wert liefert der erste nicht leere.
type Chain []Classifier
func (c Chain) NeedsImage() bool {
for _, x := range c {
if x.NeedsImage() {
return true
}
}
return false
}
func (c Chain) Classify(p *Page) (Class, error) {
for _, x := range c {
cls, err := x.Classify(p)
if err != nil {
return Class{}, err
}
if cls.Separator {
return cls, nil
}
}
return Class{}, nil
}
const (
DefaultBlankInk = 0.0005
inkThreshold = 160
inkMarginRatio = 0.05
)
// BlankClassifier erkennt eine Leerseite am Tintenanteil des gerasterten
// Bildes; ein Randstreifen bleibt unberuecksichtigt (Scannerschatten).
type BlankClassifier struct{ MaxInk float64 }
func (BlankClassifier) NeedsImage() bool { return true }
func (b BlankClassifier) Classify(p *Page) (Class, error) {
img, err := p.Image()
if err != nil {
return Class{}, err
}
max := b.MaxInk
if max <= 0 {
max = DefaultBlankInk
}
return Class{Separator: inkRatio(img) <= max}, nil
}
func inkRatio(img image.Image) float64 {
bounds := img.Bounds()
mx := int(float64(bounds.Dx()) * inkMarginRatio)
my := int(float64(bounds.Dy()) * inkMarginRatio)
x0, x1 := bounds.Min.X+mx, bounds.Max.X-mx
y0, y1 := bounds.Min.Y+my, bounds.Max.Y-my
total := (x1 - x0) * (y1 - y0)
if total <= 0 {
return 0
}
ink := 0
if g, ok := img.(*image.Gray); ok {
for y := y0; y < y1; y++ {
row := g.Pix[(y-g.Rect.Min.Y)*g.Stride+(x0-g.Rect.Min.X) : (y-g.Rect.Min.Y)*g.Stride+(x1-g.Rect.Min.X)]
for _, v := range row {
if v < inkThreshold {
ink++
}
}
}
return float64(ink) / float64(total)
}
for y := y0; y < y1; y++ {
for x := x0; x < x1; x++ {
r, g, b, _ := img.At(x, y).RGBA()
if (r+g+b)/3>>8 < inkThreshold {
ink++
}
}
}
return float64(ink) / float64(total)
}
// BarcodeClassifier erkennt eine Trennseite an einem Code-128- oder QR-Code,
// dessen Text mit Prefix beginnt. Ohne Praefix wuerde jede Seite mit einem
// Barcode (z. B. Rechnungsnummer) den Stapel teilen.
type BarcodeClassifier struct{ Prefix string }
func (BarcodeClassifier) NeedsImage() bool { return true }
func (b BarcodeClassifier) Classify(p *Page) (Class, error) {
if b.Prefix == "" {
return Class{}, fmt.Errorf("barcode-praefix fehlt")
}
img, err := p.Image()
if err != nil {
return Class{}, err
}
for _, text := range decodeBarcodes(img) {
if strings.HasPrefix(text, b.Prefix) {
return Class{Separator: true, Value: strings.TrimSpace(strings.TrimPrefix(text, b.Prefix))}, nil
}
}
return Class{}, nil
}
func decodeBarcodes(img image.Image) []string {
bmp, err := gozxing.NewBinaryBitmapFromImage(img)
if err != nil {
return nil
}
hints := map[gozxing.DecodeHintType]interface{}{gozxing.DecodeHintType_TRY_HARDER: true}
var out []string
for _, r := range []gozxing.Reader{oned.NewCode128Reader(), qrcode.NewQRCodeReader()} {
if res, err := r.Decode(bmp, hints); err == nil {
out = append(out, res.GetText())
}
}
return out
}