issue 1440: crop the document out of the platen's white
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
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)
|
||||
limit := lid - darker
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
"image/jpeg"
|
||||
"math/rand"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// lidWhite is a w x h gray image of the lid's white, with a few specks of dust.
|
||||
func lidWhite(w, h int, seed int64) *image.Gray {
|
||||
g := image.NewGray(image.Rect(0, 0, w, h))
|
||||
draw.Draw(g, g.Bounds(), &image.Uniform{color.Gray{255}}, image.Point{}, draw.Src)
|
||||
r := rand.New(rand.NewSource(seed))
|
||||
for i := 0; i < 40; i++ {
|
||||
g.SetGray(r.Intn(w), r.Intn(h), color.Gray{120})
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
func encode(t *testing.T, img image.Image) []byte {
|
||||
t.Helper()
|
||||
var b bytes.Buffer
|
||||
if err := jpeg.Encode(&b, img, &jpeg.Options{Quality: 95}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b.Bytes()
|
||||
}
|
||||
|
||||
// 1440/AC3: the captured receipt, top-left of a 1696x2203 scan at 200 dpi.
|
||||
func TestCropFindsTheCapturedReceipt(t *testing.T) {
|
||||
c, err := Crop(fixture(t, "scan.jpg"), true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !c.Cropped {
|
||||
t.Fatal("cropped = false for a scan with a receipt on it")
|
||||
}
|
||||
img, err := jpeg.Decode(bytes.NewReader(fixture(t, "scan.jpg")))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
box, found := Box(img)
|
||||
if !found {
|
||||
t.Fatal("no box found")
|
||||
}
|
||||
if box.Min.X > 10 || box.Min.Y > 10 {
|
||||
t.Errorf("box starts at %v, want within 10 px of (0,0)", box.Min)
|
||||
}
|
||||
if box.Max.X < 630 || box.Max.X > 660 {
|
||||
t.Errorf("right edge %d, want 630..660", box.Max.X)
|
||||
}
|
||||
if box.Max.Y < 1730 || box.Max.Y > 1810 {
|
||||
t.Errorf("bottom %d, want 1730..1810", box.Max.Y)
|
||||
}
|
||||
if c.Width != box.Dx() || c.Height != box.Dy() {
|
||||
t.Errorf("cropped image is %dx%d, the box %dx%d", c.Width, c.Height, box.Dx(), box.Dy())
|
||||
}
|
||||
got, err := jpeg.DecodeConfig(bytes.NewReader(c.JPEG))
|
||||
if err != nil || got.Width != box.Dx() || got.Height != box.Dy() {
|
||||
t.Errorf("the cropped JPEG is %+v (%v), want %dx%d", got, err, box.Dx(), box.Dy())
|
||||
}
|
||||
// About 80-84 mm wide at 200 dpi.
|
||||
if mm := float64(c.Width) / 200 * 25.4; mm < 80 || mm > 84 {
|
||||
t.Errorf("%.1f mm wide, want 80-84", mm)
|
||||
}
|
||||
}
|
||||
|
||||
// 1440/AC4: a plain lid with specks has no document.
|
||||
func TestCropKeepsAPlainLidWhole(t *testing.T) {
|
||||
scan := encode(t, lidWhite(800, 1100, 1))
|
||||
c, err := Crop(scan, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.Cropped || c.Width != 800 || c.Height != 1100 || !bytes.Equal(c.JPEG, scan) {
|
||||
t.Fatalf("a plain lid was cropped: %+v", c)
|
||||
}
|
||||
}
|
||||
|
||||
// 1440/AC4: a document that fills the platen is no crop either.
|
||||
func TestCropKeepsAFullPlatenDocumentWhole(t *testing.T) {
|
||||
g := lidWhite(800, 1100, 2)
|
||||
draw.Draw(g, image.Rect(0, 0, 790, 1090), &image.Uniform{color.Gray{200}}, image.Point{}, draw.Src)
|
||||
scan := encode(t, g)
|
||||
c, err := Crop(scan, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.Cropped || c.Width != 800 || c.Height != 1100 {
|
||||
t.Fatalf("a document filling the platen was cropped: %+v", c)
|
||||
}
|
||||
}
|
||||
|
||||
// 1440/AC4: crop=false keeps the captured scan whole, though it has a document.
|
||||
func TestCropOffKeepsTheWholeImage(t *testing.T) {
|
||||
scan := fixture(t, "scan.jpg")
|
||||
c, err := Crop(scan, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.Cropped || c.Width != 1696 || c.Height != 2203 || !bytes.Equal(c.JPEG, scan) {
|
||||
t.Fatalf("crop=false cropped: %+v", c)
|
||||
}
|
||||
}
|
||||
|
||||
// 1440/AC3: a document away from the origin is found where it is, not assumed in the corner.
|
||||
func TestCropFindsADocumentInTheMiddleOfTheGlass(t *testing.T) {
|
||||
g := lidWhite(800, 1100, 3)
|
||||
draw.Draw(g, image.Rect(200, 300, 500, 700), &image.Uniform{color.Gray{240}}, image.Point{}, draw.Src)
|
||||
img, err := jpeg.Decode(bytes.NewReader(encode(t, g)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
box, found := Box(img)
|
||||
if !found {
|
||||
t.Fatal("no box")
|
||||
}
|
||||
if box.Min.X < 190 || box.Min.X > 205 || box.Max.X < 495 || box.Max.X > 510 || box.Min.Y < 290 || box.Min.Y > 305 || box.Max.Y < 695 || box.Max.Y > 710 {
|
||||
t.Fatalf("box = %v, want about (200,300)-(500,700)", box)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user