478 lines
13 KiB
Go
Executable File
478 lines
13 KiB
Go
Executable File
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"image"
|
|
"image/color"
|
|
"image/draw"
|
|
"image/png"
|
|
"math"
|
|
"os/exec"
|
|
|
|
"github.com/liyue201/goqr"
|
|
"github.com/makiuchi-d/gozxing"
|
|
qrx "github.com/makiuchi-d/gozxing/qrcode"
|
|
qrgen "github.com/skip2/go-qrcode"
|
|
)
|
|
|
|
// GenerateQRPNG returns PNG bytes for the given text.
|
|
func GenerateQRPNG(text string, size int) ([]byte, error) {
|
|
if size <= 0 {
|
|
size = 256
|
|
}
|
|
// Use Low EC for larger modules; improves decode reliability for long payloads
|
|
png, err := qrgen.Encode(text, qrgen.Low, size)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return png, nil
|
|
}
|
|
|
|
// DecodeQR decodes first QR code text from an image.
|
|
func DecodeQR(img image.Image) (string, error) {
|
|
// Try basic decode first
|
|
codes, err := goqr.Recognize(img)
|
|
if err == nil && len(codes) > 0 {
|
|
return string(codes[0].Payload), nil
|
|
}
|
|
if err != nil {
|
|
}
|
|
|
|
// Convert palette/indexed images to RGBA first to avoid issues with goqr
|
|
if _, ok := img.ColorModel().(color.Palette); ok {
|
|
bounds := img.Bounds()
|
|
rgba := image.NewRGBA(bounds)
|
|
draw.Draw(rgba, bounds, img, bounds.Min, draw.Src)
|
|
img = rgba
|
|
|
|
// Try again after conversion
|
|
codes, err := goqr.Recognize(img)
|
|
if err == nil && len(codes) > 0 {
|
|
return string(codes[0].Payload), nil
|
|
}
|
|
}
|
|
|
|
// Try multiple preprocessing strategies to improve recognition from clipboard screenshots
|
|
attempts := []image.Image{}
|
|
add := func(im image.Image) {
|
|
if im != nil {
|
|
attempts = append(attempts, im)
|
|
}
|
|
}
|
|
add(img)
|
|
|
|
bounds := img.Bounds()
|
|
w, h := bounds.Dx(), bounds.Dy()
|
|
|
|
// Helper: nearest-neighbour scale
|
|
scale := func(src image.Image, fw, fh int) image.Image {
|
|
if fw <= 0 || fh <= 0 {
|
|
return src
|
|
}
|
|
dst := image.NewNRGBA(image.Rect(0, 0, fw, fh))
|
|
sb := src.Bounds()
|
|
sw, sh := float64(sb.Dx()), float64(sb.Dy())
|
|
for y := 0; y < fh; y++ {
|
|
sy := int(float64(y)/float64(fh)*sh + 0.5)
|
|
if sy >= sb.Dy() {
|
|
sy = sb.Dy() - 1
|
|
}
|
|
for x := 0; x < fw; x++ {
|
|
sx := int(float64(x)/float64(fw)*sw + 0.5)
|
|
if sx >= sb.Dx() {
|
|
sx = sb.Dx() - 1
|
|
}
|
|
dst.Set(x, y, src.At(sb.Min.X+sx, sb.Min.Y+sy))
|
|
}
|
|
}
|
|
return dst
|
|
}
|
|
|
|
// Helper: binarize with adaptive threshold (simple mean + variance fudge)
|
|
binarize := func(src image.Image) image.Image {
|
|
b := src.Bounds()
|
|
gray := image.NewGray(b)
|
|
var sum, sum2 float64
|
|
total := float64(b.Dx() * b.Dy())
|
|
for y := b.Min.Y; y < b.Max.Y; y++ {
|
|
for x := b.Min.X; x < b.Max.X; x++ {
|
|
r, g, bl, _ := src.At(x, y).RGBA()
|
|
lum := float64((r*299 + g*587 + bl*114) / 1000 >> 8) // 0-255 approx
|
|
sum += lum
|
|
sum2 += lum * lum
|
|
}
|
|
}
|
|
mean := sum / total
|
|
variance := (sum2 / total) - mean*mean
|
|
stddev := math.Sqrt(math.Max(variance, 0))
|
|
// threshold tweaks: lower slightly for dark backgrounds
|
|
thr := mean - stddev*0.15
|
|
if thr < 50 {
|
|
thr = 50
|
|
}
|
|
if thr > 200 {
|
|
thr = 200
|
|
}
|
|
for y := b.Min.Y; y < b.Max.Y; y++ {
|
|
for x := b.Min.X; x < b.Max.X; x++ {
|
|
r, g, bl, _ := src.At(x, y).RGBA()
|
|
lum := float64((r*299 + g*587 + bl*114) / 1000 >> 8)
|
|
var c color.Gray
|
|
if lum < thr {
|
|
c = color.Gray{Y: 0}
|
|
} else {
|
|
c = color.Gray{Y: 255}
|
|
}
|
|
gray.Set(x, y, c)
|
|
}
|
|
}
|
|
return gray
|
|
}
|
|
|
|
// Scale strategy: even if image is not tiny, scaled variants sometimes help goqr lock onto module grid
|
|
// 1) If very small, aggressive scale
|
|
if w < 280 || h < 280 {
|
|
factor := 2
|
|
if w < 160 {
|
|
factor = 3
|
|
}
|
|
if w < 100 {
|
|
factor = 4
|
|
}
|
|
add(scale(img, w*factor, h*factor))
|
|
}
|
|
// 2) Always try moderate upscale variants for mid-sized screenshots (helps when original was downscaled with interpolation)
|
|
if w >= 180 && w <= 900 { // avoid exploding huge images
|
|
add(scale(img, w*2, h*2))
|
|
if w*3 < 2000 && h*3 < 2000 { // guard upper bound
|
|
add(scale(img, w*3, h*3))
|
|
}
|
|
}
|
|
|
|
// Add binarized versions (original + scaled)
|
|
add(binarize(img))
|
|
if w < 320 || h < 320 {
|
|
factor := 2
|
|
add(binarize(scale(img, w*factor, h*factor)))
|
|
}
|
|
// Also binarize any newly added large upscales (skip if too big)
|
|
for _, cand := range attempts { // safe: attempts already contains originals
|
|
cw := cand.Bounds().Dx()
|
|
ch := cand.Bounds().Dy()
|
|
if (cw <= 1400 && ch <= 1400) && (cw > w || ch > h) { // only for upscaled variants within limit
|
|
add(binarize(cand))
|
|
}
|
|
}
|
|
|
|
// Add contrast-enhanced alpha-flattened variant
|
|
flatten := func(src image.Image) image.Image {
|
|
b := src.Bounds()
|
|
dst := image.NewRGBA(b)
|
|
draw.Draw(dst, b, image.Black, image.Point{}, draw.Src)
|
|
draw.Draw(dst, b, src, b.Min, draw.Over)
|
|
return dst
|
|
}
|
|
add(flatten(img))
|
|
|
|
// Rotations (90,180,270) in case of orientation issues
|
|
rotate := func(src image.Image) []image.Image {
|
|
b := src.Bounds()
|
|
w := b.Dx()
|
|
h := b.Dy()
|
|
r90 := image.NewRGBA(image.Rect(0, 0, h, w))
|
|
r180 := image.NewRGBA(image.Rect(0, 0, w, h))
|
|
r270 := image.NewRGBA(image.Rect(0, 0, h, w))
|
|
for y := 0; y < h; y++ {
|
|
for x := 0; x < w; x++ {
|
|
c := src.At(b.Min.X+x, b.Min.Y+y)
|
|
// 90
|
|
r90.Set(h-1-y, x, c)
|
|
// 180
|
|
r180.Set(w-1-x, h-1-y, c)
|
|
// 270
|
|
r270.Set(y, w-1-x, c)
|
|
}
|
|
}
|
|
return []image.Image{r90, r180, r270}
|
|
}
|
|
for _, r := range rotate(img) {
|
|
add(r)
|
|
}
|
|
|
|
var firstErr error
|
|
for _, attempt := range attempts {
|
|
codes, err := goqr.Recognize(attempt)
|
|
if err != nil {
|
|
if firstErr == nil {
|
|
firstErr = err
|
|
}
|
|
continue
|
|
}
|
|
if len(codes) > 0 {
|
|
return string(codes[0].Payload), nil
|
|
}
|
|
}
|
|
|
|
// ZXing helper used across multiple branches
|
|
decodeZX := func(src image.Image, hints map[gozxing.DecodeHintType]interface{}) (string, error) {
|
|
binSrc := gozxing.NewLuminanceSourceFromImage(src)
|
|
// Try hybrid first (usually better for photos)
|
|
bmpH, _ := gozxing.NewBinaryBitmap(gozxing.NewHybridBinarizer(binSrc))
|
|
reader := qrx.NewQRCodeReader()
|
|
if res, err := reader.Decode(bmpH, hints); err == nil {
|
|
return res.GetText(), nil
|
|
}
|
|
// Fallback to global histogram
|
|
bmpG, _ := gozxing.NewBinaryBitmap(gozxing.NewGlobalHistgramBinarizer(binSrc))
|
|
if res, err := reader.Decode(bmpG, hints); err == nil {
|
|
return res.GetText(), nil
|
|
} else {
|
|
return "", err
|
|
}
|
|
}
|
|
// Also try ZXing on all preprocessed attempts
|
|
for _, attempt := range attempts {
|
|
if txt, err := decodeZX(attempt, map[gozxing.DecodeHintType]interface{}{
|
|
gozxing.DecodeHintType_TRY_HARDER: true,
|
|
gozxing.DecodeHintType_PURE_BARCODE: true,
|
|
}); err == nil && txt != "" {
|
|
return txt, nil
|
|
}
|
|
if txt, err := decodeZX(attempt, map[gozxing.DecodeHintType]interface{}{
|
|
gozxing.DecodeHintType_TRY_HARDER: true,
|
|
}); err == nil && txt != "" {
|
|
return txt, nil
|
|
}
|
|
}
|
|
// --- Heuristic fallback: auto-invert + quiet-zone pad + gozxing ---
|
|
addQuietZone := func(src image.Image) image.Image {
|
|
b := src.Bounds()
|
|
pad := int(math.Max(float64(b.Dx()/32), 8))
|
|
out := image.NewRGBA(image.Rect(0, 0, b.Dx()+pad*2, b.Dy()+pad*2))
|
|
// fill white
|
|
for y := 0; y < out.Bounds().Dy(); y++ {
|
|
for x := 0; x < out.Bounds().Dx(); x++ {
|
|
out.Set(x, y, color.White)
|
|
}
|
|
}
|
|
draw.Draw(out, b.Add(image.Point{X: pad, Y: pad}), src, b.Min, draw.Src)
|
|
return out
|
|
}
|
|
invert := func(src image.Image) image.Image {
|
|
b := src.Bounds()
|
|
out := image.NewRGBA(b)
|
|
for y := b.Min.Y; y < b.Max.Y; y++ {
|
|
for x := b.Min.X; x < b.Max.X; x++ {
|
|
r, g, bb, a := src.At(x, y).RGBA()
|
|
out.Set(x, y, color.RGBA{uint8(255 - r/257), uint8(255 - g/257), uint8(255 - bb/257), uint8(a / 257)})
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
// estimate dark pixel ratio
|
|
b := img.Bounds()
|
|
var dark, total int
|
|
for y := b.Min.Y; y < b.Max.Y; y++ {
|
|
for x := b.Min.X; x < b.Max.X; x++ {
|
|
r, g, bb, _ := img.At(x, y).RGBA()
|
|
lum := (r*299 + g*587 + bb*114) / 1000
|
|
if lum < 128<<8 {
|
|
dark++
|
|
}
|
|
total++
|
|
}
|
|
}
|
|
ratio := float64(dark) / float64(total)
|
|
needInvert := ratio > 0.85 // almost full dark background (white modules?)
|
|
var modified []image.Image
|
|
base := img
|
|
if needInvert {
|
|
base = invert(base)
|
|
}
|
|
modified = append(modified, addQuietZone(base))
|
|
modified = append(modified, addQuietZone(invert(base)))
|
|
for _, m := range modified {
|
|
if codes, err := goqr.Recognize(m); err == nil && len(codes) > 0 {
|
|
return string(codes[0].Payload), nil
|
|
}
|
|
// Also try ZXing on these variants before moving on
|
|
if txt, err := decodeZX(m, map[gozxing.DecodeHintType]interface{}{
|
|
gozxing.DecodeHintType_TRY_HARDER: true,
|
|
gozxing.DecodeHintType_PURE_BARCODE: true,
|
|
}); err == nil && txt != "" {
|
|
return txt, nil
|
|
}
|
|
if txt, err := decodeZX(m, map[gozxing.DecodeHintType]interface{}{
|
|
gozxing.DecodeHintType_TRY_HARDER: true,
|
|
}); err == nil && txt != "" {
|
|
return txt, nil
|
|
}
|
|
}
|
|
// ZXing with TRY_HARDER and PURE_BARCODE hints which often fix dense PNGs
|
|
hints := map[gozxing.DecodeHintType]interface{}{
|
|
gozxing.DecodeHintType_TRY_HARDER: true,
|
|
// Many of our images are perfect render PNGs from go-qrcode -> enable PURE_BARCODE
|
|
gozxing.DecodeHintType_PURE_BARCODE: true,
|
|
}
|
|
if txt, err := decodeZX(base, hints); err == nil && txt != "" {
|
|
return txt, nil
|
|
}
|
|
// Try again without PURE_BARCODE in case quiet zone is cropped
|
|
hints2 := map[gozxing.DecodeHintType]interface{}{gozxing.DecodeHintType_TRY_HARDER: true}
|
|
if txt, err := decodeZX(base, hints2); err == nil && txt != "" {
|
|
return txt, nil
|
|
}
|
|
// Try ZXing on rotations as well
|
|
for _, r := range rotate(base) {
|
|
if txt, err := decodeZX(r, hints2); err == nil && txt != "" {
|
|
return txt, nil
|
|
}
|
|
if txt, err := decodeZX(r, hints); err == nil && txt != "" {
|
|
return txt, nil
|
|
}
|
|
}
|
|
|
|
// Extra pure-QR recovery: try small insets and simple upscales with PURE_BARCODE.
|
|
inset := func(src image.Image, px int) image.Image {
|
|
if px <= 0 {
|
|
return src
|
|
}
|
|
b := src.Bounds()
|
|
r := image.Rect(b.Min.X+px, b.Min.Y+px, b.Max.X-px, b.Max.Y-px)
|
|
if r.Dx() <= 10 || r.Dy() <= 10 {
|
|
return src
|
|
}
|
|
out := image.NewRGBA(image.Rect(0, 0, r.Dx(), r.Dy()))
|
|
draw.Draw(out, out.Bounds(), src, r.Min, draw.Src)
|
|
return out
|
|
}
|
|
cropSides := func(src image.Image, l, t, r, b int) image.Image {
|
|
bb := src.Bounds()
|
|
rr := image.Rect(bb.Min.X+l, bb.Min.Y+t, bb.Max.X-r, bb.Max.Y-b)
|
|
if rr.Dx() <= 10 || rr.Dy() <= 10 || rr.Min.X >= rr.Max.X || rr.Min.Y >= rr.Max.Y {
|
|
return src
|
|
}
|
|
out := image.NewRGBA(image.Rect(0, 0, rr.Dx(), rr.Dy()))
|
|
draw.Draw(out, out.Bounds(), src, rr.Min, draw.Src)
|
|
return out
|
|
}
|
|
tryPure := func(src image.Image) (string, bool) {
|
|
for _, px := range []int{1, 2, 3} {
|
|
if txt, err := decodeZX(inset(src, px), hints); err == nil && txt != "" {
|
|
return txt, true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
// Try on base, binarized base and scaled variants
|
|
if txt, ok := tryPure(base); ok {
|
|
return txt, nil
|
|
}
|
|
if txt, ok := tryPure(binarize(base)); ok {
|
|
return txt, nil
|
|
}
|
|
if w < 800 && h < 800 { // upscale to mitigate rounding of module size
|
|
if txt, ok := tryPure(scale(base, w*2, h*2)); ok {
|
|
return txt, nil
|
|
}
|
|
if txt, ok := tryPure(scale(base, w*3, h*3)); ok {
|
|
return txt, nil
|
|
}
|
|
}
|
|
|
|
// Brute-force small asymmetric crops to satisfy ZXing's 1 (mod 4) dimension constraint.
|
|
for _, src := range []image.Image{base, binarize(base)} {
|
|
for l := 0; l <= 2; l++ {
|
|
for t := 0; t <= 2; t++ {
|
|
for r := 0; r <= 2; r++ {
|
|
for btm := 0; btm <= 2; btm++ {
|
|
cand := cropSides(src, l, t, r, btm)
|
|
if cand == src {
|
|
continue
|
|
}
|
|
if txt, err := decodeZX(cand, hints); err == nil && txt != "" {
|
|
return txt, nil
|
|
}
|
|
if txt, err := decodeZX(cand, hints2); err == nil && txt != "" {
|
|
return txt, nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Bounding box crop of dark pixels, then upscale and retry
|
|
boundsAll := base.Bounds()
|
|
minX, minY := boundsAll.Max.X, boundsAll.Max.Y
|
|
maxX, maxY := boundsAll.Min.X, boundsAll.Min.Y
|
|
for y := boundsAll.Min.Y; y < boundsAll.Max.Y; y++ {
|
|
for x := boundsAll.Min.X; x < boundsAll.Max.X; x++ {
|
|
r, g, bb, _ := base.At(x, y).RGBA()
|
|
lum := (r*299 + g*587 + bb*114) / 1000
|
|
if lum < 180<<8 { // treat as dark
|
|
if x < minX {
|
|
minX = x
|
|
}
|
|
if y < minY {
|
|
minY = y
|
|
}
|
|
if x > maxX {
|
|
maxX = x
|
|
}
|
|
if y > maxY {
|
|
maxY = y
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if minX < maxX && minY < maxY {
|
|
crop := image.NewRGBA(image.Rect(0, 0, maxX-minX+1, maxY-minY+1))
|
|
draw.Draw(crop, crop.Bounds(), base, image.Point{X: minX, Y: minY}, draw.Src)
|
|
for _, up := range []int{2, 3, 4} {
|
|
cw := crop.Bounds().Dx() * up
|
|
ch := crop.Bounds().Dy() * up
|
|
if cw > 2400 || ch > 2400 {
|
|
continue
|
|
}
|
|
scaled := image.NewRGBA(image.Rect(0, 0, cw, ch))
|
|
for y := 0; y < ch; y++ {
|
|
sy := y / up
|
|
for x := 0; x < cw; x++ {
|
|
sx := x / up
|
|
scaled.Set(x, y, crop.At(sx, sy))
|
|
}
|
|
}
|
|
if txt, err := decodeZX(scaled, hints2); err == nil && txt != "" {
|
|
return txt, nil
|
|
}
|
|
}
|
|
}
|
|
if firstErr != nil {
|
|
return "", firstErr
|
|
}
|
|
// Final external fallback: try zbarimg if available on the system
|
|
if cmd, lookErr := exec.LookPath("zbarimg"); lookErr == nil {
|
|
buf := &bytes.Buffer{}
|
|
if err := png.Encode(buf, img); err == nil {
|
|
c := exec.Command(cmd, "-q", "--raw", "-")
|
|
c.Stdin = bytes.NewReader(buf.Bytes())
|
|
out, err := c.Output()
|
|
if err == nil && len(out) > 0 {
|
|
return string(out), nil
|
|
}
|
|
}
|
|
}
|
|
return "", errors.New("no qr code found")
|
|
}
|
|
|
|
// LoadPNG decodes raw PNG bytes to image.Image.
|
|
func LoadPNG(b []byte) (image.Image, error) {
|
|
im, err := png.Decode(bytes.NewReader(b))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return im, nil
|
|
}
|