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
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
189 lines
4.4 KiB
Go
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
|
|
}
|