Files
mesh-catalog/modules/brother/cmd/brother/crop.go
T

344 lines
11 KiB
Go

package main
import (
"bytes"
"fmt"
"image"
"image/color"
"image/jpeg"
"sort"
)
// Cropping cuts the document out of the platen. The scanner sends the whole glass, a document in one corner
// and the lid's white everywhere else. The lid's level is measured where no document can be (the corner
// farthest from the origin), a pixel is document when it is darker than the lid by a margin, and the rows and
// columns holding enough document pixels give the box (ADR 0464).
const (
// lidBlock is the side of the corner block the lid's level is measured on.
lidBlock = 300
// darker is how much darker than the lid a pixel is document: paper on a white lid is a level or two
// under it, an edge shadow far more.
darker = 1
// rowShare is the fraction of a row's (column's) length that must be document for the row (column) to
// count: specks of dust stay under it.
rowShare = 0.05
// margin is the pixels kept around the box.
margin = 6
// wholeShare is the fraction of the platen a box may fill before it is no crop at all.
wholeShare = 0.9
// jpegQuality is the quality the cropped image is encoded with.
jpegQuality = 90
)
// gray is the pixel's level, 0 to 255.
func grayAt(img image.Image, x, y int) int {
switch m := img.(type) {
case *image.YCbCr:
return int(m.Y[m.YOffset(x, y)])
case *image.Gray:
return int(m.Pix[m.PixOffset(x, y)])
}
return int(color.GrayModel.Convert(img.At(x, y)).(color.Gray).Y)
}
// Box finds the document's box in the image, and whether there is one: false when no document is found or
// when it fills (almost) the platen.
func Box(img image.Image) (image.Rectangle, bool) {
b := img.Bounds()
w, h := b.Dx(), b.Dy()
if w < 2*margin || h < 2*margin {
return b, false
}
limit := lidLimit(img)
cols := make([]int, w)
rows := make([]int, h)
for y := b.Min.Y; y < b.Max.Y; y++ {
for x := b.Min.X; x < b.Max.X; x++ {
if grayAt(img, x, y) < limit {
cols[x-b.Min.X]++
rows[y-b.Min.Y]++
}
}
}
x0, x1 := span(cols, int(rowShare*float64(h)))
y0, y1 := span(rows, int(rowShare*float64(w)))
if x0 < 0 || y0 < 0 {
return b, false
}
box := image.Rect(max(0, x0-margin), max(0, y0-margin), min(w, x1+1+margin), min(h, y1+1+margin)).Add(b.Min)
if float64(box.Dx()*box.Dy()) >= wholeShare*float64(w*h) {
return b, false
}
return box, true
}
// lidLimit is the level under which a pixel is document: the lid's level, measured in the corner block farthest
// from the origin, less darker.
func lidLimit(img image.Image) int {
b := img.Bounds()
w, h := b.Dx(), b.Dy()
side := lidBlock
if side > w/2 {
side = w / 2
}
if side > h/2 {
side = h / 2
}
sum := 0
for y := b.Max.Y - side; y < b.Max.Y; y++ {
for x := b.Max.X - side; x < b.Max.X; x++ {
sum += grayAt(img, x, y)
}
}
lid := int(float64(sum)/float64(side*side) + 0.5)
return lid - darker
}
// span is the first and last index whose count is over the floor, or -1 when none is.
func span(counts []int, floor int) (int, int) {
first, last := -1, -1
for i, c := range counts {
if c > floor {
if first < 0 {
first = i
}
last = i
}
}
return first, last
}
// Cropped is what cropping a scan gave.
type Cropped struct {
// JPEG is the image to file: the scanner's own bytes when kept whole, a re-encoded crop otherwise.
JPEG []byte
Width, Height int
Cropped bool
}
// Crop cuts the document out of a scanned JPEG when crop is set and a document is found; otherwise the whole
// image is kept, its bytes untouched, and Cropped is false.
func Crop(scan []byte, crop bool) (Cropped, error) {
img, err := jpeg.Decode(bytes.NewReader(scan))
if err != nil {
return Cropped{}, fmt.Errorf("the scanned image cannot be read: %w", err)
}
b := img.Bounds()
whole := Cropped{JPEG: scan, Width: b.Dx(), Height: b.Dy()}
if !crop {
return whole, nil
}
box, found := Box(img)
if !found {
return whole, nil
}
sub, ok := img.(interface {
SubImage(image.Rectangle) image.Image
})
if !ok {
return whole, nil
}
var out bytes.Buffer
if err := jpeg.Encode(&out, sub.SubImage(box), &jpeg.Options{Quality: jpegQuality}); err != nil {
return Cropped{}, fmt.Errorf("the cropped image cannot be encoded: %w", err)
}
return Cropped{JPEG: out.Bytes(), Width: box.Dx(), Height: box.Dy(), Cropped: true}, nil
}
// Several documents on the glass (issue 1528). Where the rows-and-columns box of Box would be one image around
// all of them, Boxes finds each document apart from the others: the picture is cut into blocks of about a
// millimetre, a block is document when a quarter of its pixels are darker than the lid (dust is not), blocks
// within gapMM of each other are one region (so a white area inside a document, or a gap in it, does not split
// it), and a region of at least minMM on both sides is a document. Regions whose boxes touch or overlap
// (a text block inside a document's frame) are one document.
const (
// blockMM is the side of a block, in millimetres.
blockMM = 1.0
// blockShare is the fraction of a block's pixels that must be document for the block to count.
blockShare = 0.25
// gapMM is how near two regions must be to be one document, in millimetres: documents lying farther apart than
// this are separate ones.
gapMM = 10.0
// minMM is the least a region may measure, on both sides, to be a document and not a speck or a stain.
minMM = 20.0
)
// Boxes is the box of each separate document on the platen, in reading order (top to bottom by the box's top,
// then left to right), at the image's resolution dpi. It is Box's white-detection (the lid's level and darker) on
// a grid of blocks. A box that fills (almost) the platen is no crop: then the whole image is the answer, as one
// box with found false.
func Boxes(img image.Image, dpi int) ([]image.Rectangle, bool) {
b := img.Bounds()
w, h := b.Dx(), b.Dy()
if dpi <= 0 || w < 2*margin || h < 2*margin {
return []image.Rectangle{b}, false
}
bs := max(2, int(blockMM*float64(dpi)/25.4+0.5))
gw, gh := (w+bs-1)/bs, (h+bs-1)/bs
limit := lidLimit(img)
doc := make([]bool, gw*gh)
for gy := 0; gy < gh; gy++ {
for gx := 0; gx < gw; gx++ {
dark, total := 0, 0
for y := b.Min.Y + gy*bs; y < min(b.Min.Y+(gy+1)*bs, b.Max.Y); y++ {
for x := b.Min.X + gx*bs; x < min(b.Min.X+(gx+1)*bs, b.Max.X); x++ {
total++
if grayAt(img, x, y) < limit {
dark++
}
}
}
doc[gy*gw+gx] = float64(dark) >= blockShare*float64(total)
}
}
// Dust and the ringing of the JPEG around it make single blocks, or a pair, of "document": an opening (every
// block must have all eight neighbours document to survive, and survivors give back their neighbours that are
// document) removes what is not at least three blocks across.
eroded := make([]bool, gw*gh)
for gy := 1; gy < gh-1; gy++ {
for gx := 1; gx < gw-1; gx++ {
all := true
for y := gy - 1; y <= gy+1 && all; y++ {
for x := gx - 1; x <= gx+1; x++ {
if !doc[y*gw+x] {
all = false
break
}
}
}
eroded[gy*gw+gx] = all
}
}
opened := make([]bool, gw*gh)
for gy := 0; gy < gh; gy++ {
for gx := 0; gx < gw; gx++ {
if !eroded[gy*gw+gx] {
continue
}
for y := gy - 1; y <= gy+1; y++ {
for x := gx - 1; x <= gx+1; x++ {
opened[y*gw+x] = true
}
}
}
}
doc = opened
// Join the blocks within the gap: a block is near when it is within gapBlocks/2 of a document block in each
// direction (so two blocks up to gapBlocks apart meet), then the nearby blocks are labelled in 4-connected
// regions; each region's box is that of the document blocks in it.
radius := int(gapMM * float64(dpi) / 25.4 / float64(bs) / 2)
near := make([]bool, gw*gh)
for gy := 0; gy < gh; gy++ {
for gx := 0; gx < gw; gx++ {
if !doc[gy*gw+gx] {
continue
}
for y := max(0, gy-radius); y <= min(gh-1, gy+radius); y++ {
for x := max(0, gx-radius); x <= min(gw-1, gx+radius); x++ {
near[y*gw+x] = true
}
}
}
}
seen := make([]bool, gw*gh)
var boxes []image.Rectangle
for start := range near {
if !near[start] || seen[start] {
continue
}
minX, minY, maxX, maxY := gw, gh, -1, -1
stack := []int{start}
seen[start] = true
for len(stack) > 0 {
c := stack[len(stack)-1]
stack = stack[:len(stack)-1]
cx, cy := c%gw, c/gw
if doc[c] {
minX, minY, maxX, maxY = min(minX, cx), min(minY, cy), max(maxX, cx), max(maxY, cy)
}
for _, d := range [4][2]int{{1, 0}, {-1, 0}, {0, 1}, {0, -1}} {
x, y := cx+d[0], cy+d[1]
if x < 0 || y < 0 || x >= gw || y >= gh || !near[y*gw+x] || seen[y*gw+x] {
continue
}
seen[y*gw+x] = true
stack = append(stack, y*gw+x)
}
}
if maxX < 0 {
continue
}
r := image.Rect(minX*bs, minY*bs, (maxX+1)*bs, (maxY+1)*bs)
if float64(r.Dx())/float64(dpi)*25.4 < minMM || float64(r.Dy())/float64(dpi)*25.4 < minMM {
continue
}
boxes = append(boxes, r)
}
// Boxes that touch or overlap are one document: a frame and the text inside it, or a faint paper whose
// parts the lid's white hides, are one however far apart their blocks lie.
kept := boxes
for merged := true; merged; {
merged = false
for i := 0; i < len(kept) && !merged; i++ {
for j := i + 1; j < len(kept); j++ {
if kept[i].Overlaps(kept[j]) {
kept[i] = kept[i].Union(kept[j])
kept = append(kept[:j], kept[j+1:]...)
merged = true
break
}
}
}
}
if len(kept) == 0 {
return []image.Rectangle{b}, false
}
out := make([]image.Rectangle, len(kept))
for i, r := range kept {
out[i] = image.Rect(max(0, r.Min.X-margin), max(0, r.Min.Y-margin), min(w, r.Max.X+margin), min(h, r.Max.Y+margin)).Add(b.Min)
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].Min.Y != out[j].Min.Y {
return out[i].Min.Y < out[j].Min.Y
}
return out[i].Min.X < out[j].Min.X
})
if len(out) == 1 && float64(out[0].Dx()*out[0].Dy()) >= wholeShare*float64(w*h) {
return []image.Rectangle{b}, false
}
return out, true
}
// CropAll cuts each separate document out of a scanned glass JPEG when crop is set and there are two or more; one
// document, none or crop off is Crop's answer, exactly as before. dpi is the scan's resolution.
func CropAll(scan []byte, crop bool, dpi int) ([]Cropped, error) {
one, err := Crop(scan, crop)
if err != nil || !crop {
return []Cropped{one}, err
}
img, err := jpeg.Decode(bytes.NewReader(scan))
if err != nil {
return nil, fmt.Errorf("the scanned image cannot be read: %w", err)
}
boxes, found := Boxes(img, dpi)
if !found || len(boxes) < 2 {
return []Cropped{one}, nil
}
sub, ok := img.(interface {
SubImage(image.Rectangle) image.Image
})
if !ok {
return []Cropped{one}, nil
}
out := make([]Cropped, 0, len(boxes))
for _, box := range boxes {
var buf bytes.Buffer
if err := jpeg.Encode(&buf, sub.SubImage(box), &jpeg.Options{Quality: jpegQuality}); err != nil {
return nil, fmt.Errorf("the cropped image cannot be encoded: %w", err)
}
out = append(out, Cropped{JPEG: buf.Bytes(), Width: box.Dx(), Height: box.Dy(), Cropped: true})
}
return out, nil
}