9d6181260a
Desktop Client Build / Resolve Build Metadata (push) Successful in 43s
Frontend CI / Frontend (push) Successful in 3m49s
Backend CI / Backend (push) Failing after 7m10s
Desktop Client Build / Build Desktop Client (push) Successful in 23m4s
Desktop Client Build / Publish Client Artifacts to NAS (push) Successful in 28s
Introduce an end-to-end media-supply feature: tenant-side resource sync service/worker backed by a Meijiequan supplier client, ops-side management APIs, and admin/ops web views for resources, orders, favorites and submission. Adds a shared digitocr helper, MediaSupply config blocks for tenant and ops, shared types, and migrations for supplier media resources, price overrides, customer visibility and order refunds. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
359 lines
8.2 KiB
Go
359 lines
8.2 KiB
Go
// Package digitocr 识别固定字体、干净背景的多位数字图(如 4 位橙色数码字验证码)。
|
|
// 思路:橙色阈值二值化 → 找前景 bbox → 等分 N 列 → 每块归一到固定网格 → 与 0-9 模板做 Hamming 距离。
|
|
package digitocr
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"image"
|
|
"image/color"
|
|
_ "image/png"
|
|
"io"
|
|
"os"
|
|
"sort"
|
|
)
|
|
|
|
const (
|
|
GridW = 10 // 单数字画布宽(覆盖最宽字符 + 余量)
|
|
GridH = 18 // 单数字画布高
|
|
)
|
|
|
|
// Bitmap 单个数字归一后的位图(按行展开)。
|
|
type Bitmap [GridW * GridH]uint8
|
|
|
|
// Options 控制识别行为。零值即默认 4 位、橙色前景。
|
|
type Options struct {
|
|
Digits int // 期望位数,默认 4
|
|
IsForeground func(color.Color) bool // 自定义前景判定,nil 时用 IsOrange
|
|
}
|
|
|
|
// Recognize 从文件路径读图并识别。
|
|
func Recognize(path string, opts Options) (string, error) {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer f.Close()
|
|
return RecognizeReader(f, opts)
|
|
}
|
|
|
|
// RecognizeReader 从 io.Reader 读图并识别。
|
|
func RecognizeReader(r io.Reader, opts Options) (string, error) {
|
|
img, _, err := image.Decode(r)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return RecognizeImage(img, opts)
|
|
}
|
|
|
|
// RecognizeImage 对已解码的 image.Image 做识别。
|
|
func RecognizeImage(img image.Image, opts Options) (string, error) {
|
|
if opts.Digits == 0 {
|
|
opts.Digits = 4
|
|
}
|
|
if opts.IsForeground == nil {
|
|
opts.IsForeground = IsOrange
|
|
}
|
|
|
|
bin, bbox := Binarize(img, opts.IsForeground)
|
|
if bbox.Empty() {
|
|
return "", errors.New("digitocr: no foreground pixels")
|
|
}
|
|
|
|
cells := SegmentDigits(bin, bbox, opts.Digits)
|
|
out := make([]byte, len(cells))
|
|
for i, cell := range cells {
|
|
bm := Normalize(bin, cell)
|
|
out[i] = '0' + byte(MatchDigit(bm))
|
|
}
|
|
if len(out) != opts.Digits {
|
|
return string(out), fmt.Errorf("digitocr: expected %d digits, segmented %d", opts.Digits, len(out))
|
|
}
|
|
return string(out), nil
|
|
}
|
|
|
|
// SegmentDigits 优先用 8 连通分量切分,每个数字应是一个 CC。
|
|
// CC 数不匹配时退回列投影找零列;再不匹配退回等宽切分。
|
|
func SegmentDigits(bin [][]bool, bbox image.Rectangle, want int) []image.Rectangle {
|
|
if rects := ConnectedComponents(bin); len(rects) == want {
|
|
return rects
|
|
}
|
|
if rects := projectionSplit(bin, bbox); len(rects) == want {
|
|
return rects
|
|
}
|
|
// 兜底:等宽切分
|
|
out := make([]image.Rectangle, want)
|
|
cellW := bbox.Dx() / want
|
|
for i := 0; i < want; i++ {
|
|
out[i] = image.Rect(
|
|
bbox.Min.X+i*cellW, bbox.Min.Y,
|
|
bbox.Min.X+(i+1)*cellW, bbox.Max.Y,
|
|
)
|
|
if i == want-1 {
|
|
out[i].Max.X = bbox.Max.X
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ConnectedComponents 返回所有 8 连通前景分量的 bbox,按左上 x 升序排列。
|
|
// 小于 minPixels 的噪点分量会被丢弃(默认 2)。
|
|
func ConnectedComponents(bin [][]bool) []image.Rectangle {
|
|
const minPixels = 2
|
|
h := len(bin)
|
|
if h == 0 {
|
|
return nil
|
|
}
|
|
w := len(bin[0])
|
|
visited := make([][]bool, h)
|
|
for i := range visited {
|
|
visited[i] = make([]bool, w)
|
|
}
|
|
var rects []image.Rectangle
|
|
stack := make([][2]int, 0, 64)
|
|
for y := 0; y < h; y++ {
|
|
for x := 0; x < w; x++ {
|
|
if !bin[y][x] || visited[y][x] {
|
|
continue
|
|
}
|
|
minX, minY := x, y
|
|
maxX, maxY := x, y
|
|
pixCount := 0
|
|
stack = append(stack[:0], [2]int{x, y})
|
|
visited[y][x] = true
|
|
for len(stack) > 0 {
|
|
p := stack[len(stack)-1]
|
|
stack = stack[:len(stack)-1]
|
|
pixCount++
|
|
if p[0] < minX {
|
|
minX = p[0]
|
|
}
|
|
if p[1] < minY {
|
|
minY = p[1]
|
|
}
|
|
if p[0] > maxX {
|
|
maxX = p[0]
|
|
}
|
|
if p[1] > maxY {
|
|
maxY = p[1]
|
|
}
|
|
for dy := -1; dy <= 1; dy++ {
|
|
for dx := -1; dx <= 1; dx++ {
|
|
if dx == 0 && dy == 0 {
|
|
continue
|
|
}
|
|
nx, ny := p[0]+dx, p[1]+dy
|
|
if nx >= 0 && nx < w && ny >= 0 && ny < h && bin[ny][nx] && !visited[ny][nx] {
|
|
visited[ny][nx] = true
|
|
stack = append(stack, [2]int{nx, ny})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if pixCount >= minPixels {
|
|
rects = append(rects, image.Rect(minX, minY, maxX+1, maxY+1))
|
|
}
|
|
}
|
|
}
|
|
sort.Slice(rects, func(i, j int) bool { return rects[i].Min.X < rects[j].Min.X })
|
|
return rects
|
|
}
|
|
|
|
func projectionSplit(bin [][]bool, bbox image.Rectangle) []image.Rectangle {
|
|
cols := make([]int, bbox.Dx())
|
|
for y := bbox.Min.Y; y < bbox.Max.Y; y++ {
|
|
for x := bbox.Min.X; x < bbox.Max.X; x++ {
|
|
if bin[y][x] {
|
|
cols[x-bbox.Min.X]++
|
|
}
|
|
}
|
|
}
|
|
var rects []image.Rectangle
|
|
inRun, runStart := false, 0
|
|
for i := 0; i <= len(cols); i++ {
|
|
present := i < len(cols) && cols[i] > 0
|
|
if present && !inRun {
|
|
inRun = true
|
|
runStart = i
|
|
} else if !present && inRun {
|
|
inRun = false
|
|
rects = append(rects, image.Rect(
|
|
bbox.Min.X+runStart, bbox.Min.Y,
|
|
bbox.Min.X+i, bbox.Max.Y,
|
|
))
|
|
}
|
|
}
|
|
return rects
|
|
}
|
|
|
|
// IsOrange 经验阈值:识别图中那种橙色前景像素。
|
|
// 如背景色调差异较大,可换 HSV 判 H∈[10°,30°]。
|
|
func IsOrange(c color.Color) bool {
|
|
r, g, b, _ := c.RGBA()
|
|
r8, g8, b8 := r>>8, g>>8, b>>8
|
|
return r8 > 200 && g8 > 80 && g8 < 180 && b8 < 100
|
|
}
|
|
|
|
// Binarize 把图像转成 bool 矩阵 + 所有前景像素的边界框。
|
|
// 默认会通过 StripFrame 去掉与图像边缘相连的"外框"前景(如圆角矩形装饰边)。
|
|
func Binarize(img image.Image, fg func(color.Color) bool) ([][]bool, image.Rectangle) {
|
|
b := img.Bounds()
|
|
w, h := b.Dx(), b.Dy()
|
|
bin := make([][]bool, h)
|
|
for y := 0; y < h; y++ {
|
|
bin[y] = make([]bool, w)
|
|
for x := 0; x < w; x++ {
|
|
if fg(img.At(b.Min.X+x, b.Min.Y+y)) {
|
|
bin[y][x] = true
|
|
}
|
|
}
|
|
}
|
|
StripFrame(bin)
|
|
return bin, computeBBox(bin)
|
|
}
|
|
|
|
// StripFrame 用 8 邻接 flood fill 从图像四边把任何相连的前景"吃掉"。
|
|
// 适用于验证码常见的圆角矩形装饰边——只要数字本身和外框不相连,外框就会被清干净。
|
|
func StripFrame(bin [][]bool) {
|
|
h := len(bin)
|
|
if h == 0 {
|
|
return
|
|
}
|
|
w := len(bin[0])
|
|
type pt struct{ x, y int }
|
|
var stack []pt
|
|
push := func(x, y int) {
|
|
if x >= 0 && x < w && y >= 0 && y < h && bin[y][x] {
|
|
bin[y][x] = false
|
|
stack = append(stack, pt{x, y})
|
|
}
|
|
}
|
|
for x := 0; x < w; x++ {
|
|
push(x, 0)
|
|
push(x, h-1)
|
|
}
|
|
for y := 0; y < h; y++ {
|
|
push(0, y)
|
|
push(w-1, y)
|
|
}
|
|
for len(stack) > 0 {
|
|
p := stack[len(stack)-1]
|
|
stack = stack[:len(stack)-1]
|
|
for dy := -1; dy <= 1; dy++ {
|
|
for dx := -1; dx <= 1; dx++ {
|
|
if dx == 0 && dy == 0 {
|
|
continue
|
|
}
|
|
push(p.x+dx, p.y+dy)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func computeBBox(bin [][]bool) image.Rectangle {
|
|
h := len(bin)
|
|
if h == 0 {
|
|
return image.Rectangle{}
|
|
}
|
|
w := len(bin[0])
|
|
minX, minY := w, h
|
|
maxX, maxY := -1, -1
|
|
for y := 0; y < h; y++ {
|
|
for x := 0; x < w; x++ {
|
|
if bin[y][x] {
|
|
if x < minX {
|
|
minX = x
|
|
}
|
|
if y < minY {
|
|
minY = y
|
|
}
|
|
if x > maxX {
|
|
maxX = x
|
|
}
|
|
if y > maxY {
|
|
maxY = y
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if maxX < 0 {
|
|
return image.Rectangle{}
|
|
}
|
|
return image.Rect(minX, minY, maxX+1, maxY+1)
|
|
}
|
|
|
|
// Normalize 把 cell 内的前景按原尺寸贴到 GridW x GridH 画布的左上角。
|
|
// 字体固定时不做缩放采样,模板就是真实像素,区分度最高。
|
|
// 若数字尺寸超出画布,多出的右/下部分被截断(GridW/GridH 应当设大于最大字符)。
|
|
func Normalize(bin [][]bool, cell image.Rectangle) Bitmap {
|
|
minX, minY := cell.Max.X, cell.Max.Y
|
|
maxX, maxY := cell.Min.X-1, cell.Min.Y-1
|
|
for y := cell.Min.Y; y < cell.Max.Y; y++ {
|
|
if y < 0 || y >= len(bin) {
|
|
continue
|
|
}
|
|
for x := cell.Min.X; x < cell.Max.X; x++ {
|
|
if x < 0 || x >= len(bin[y]) {
|
|
continue
|
|
}
|
|
if bin[y][x] {
|
|
if x < minX {
|
|
minX = x
|
|
}
|
|
if y < minY {
|
|
minY = y
|
|
}
|
|
if x > maxX {
|
|
maxX = x
|
|
}
|
|
if y > maxY {
|
|
maxY = y
|
|
}
|
|
}
|
|
}
|
|
}
|
|
var bm Bitmap
|
|
if minX > maxX {
|
|
return bm
|
|
}
|
|
for y := minY; y <= maxY; y++ {
|
|
gy := y - minY
|
|
if gy >= GridH {
|
|
break
|
|
}
|
|
for x := minX; x <= maxX; x++ {
|
|
gx := x - minX
|
|
if gx >= GridW {
|
|
break
|
|
}
|
|
if bin[y][x] {
|
|
bm[gy*GridW+gx] = 1
|
|
}
|
|
}
|
|
}
|
|
return bm
|
|
}
|
|
|
|
// MatchDigit 与 10 个模板比 Hamming 距离,返回最像的数字。
|
|
func MatchDigit(bm Bitmap) int {
|
|
best, bestD := 0, 1<<30
|
|
for d := 0; d < 10; d++ {
|
|
dist := hamming(bm, Templates[d])
|
|
if dist < bestD {
|
|
bestD = dist
|
|
best = d
|
|
}
|
|
}
|
|
return best
|
|
}
|
|
|
|
func hamming(a, b Bitmap) int {
|
|
n := 0
|
|
for i := range a {
|
|
if a[i] != b[i] {
|
|
n++
|
|
}
|
|
}
|
|
return n
|
|
}
|