344 lines
11 KiB
Go
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
|
|
}
|