From cf407f187d2fc1f8e0b568ead78858ef2032ccbd Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 20 Oct 2026 20:10:20 +0200 Subject: [PATCH] issue 1440: crop the document out of the platen's white --- modules/scan/cmd/scan/crop.go | 138 +++++++++++++++++++++++++++++ modules/scan/cmd/scan/crop_test.go | 125 ++++++++++++++++++++++++++ 2 files changed, 263 insertions(+) create mode 100644 modules/scan/cmd/scan/crop.go create mode 100644 modules/scan/cmd/scan/crop_test.go diff --git a/modules/scan/cmd/scan/crop.go b/modules/scan/cmd/scan/crop.go new file mode 100644 index 00000000..62554ecb --- /dev/null +++ b/modules/scan/cmd/scan/crop.go @@ -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 +} diff --git a/modules/scan/cmd/scan/crop_test.go b/modules/scan/cmd/scan/crop_test.go new file mode 100644 index 00000000..b3de57cc --- /dev/null +++ b/modules/scan/cmd/scan/crop_test.go @@ -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) + } +}