feat(media-supply): add media resource supply marketplace
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
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>
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
// 本地识别 CLI:读取一张或多张已保存的验证码 PNG,输出识别结果。
|
||||
//
|
||||
// 用法:
|
||||
//
|
||||
// go run ./cmd/recognize path/to/captcha.png [more.png ...]
|
||||
//
|
||||
// 想批量评估准确率时,把文件命名为"真值.png"(如 4930.png),脚本会自动对比。
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/shared/digitocr"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
fmt.Fprintln(os.Stderr, "usage: recognize <png> [png ...]")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
var total, correct int
|
||||
for _, path := range os.Args[1:] {
|
||||
got, err := digitocr.Recognize(path, digitocr.Options{})
|
||||
if err != nil {
|
||||
fmt.Printf("%s\tERROR: %v\n", path, err)
|
||||
continue
|
||||
}
|
||||
truth := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
|
||||
if isAllDigits(truth) && len(truth) == len(got) {
|
||||
total++
|
||||
mark := "OK"
|
||||
if got == truth {
|
||||
correct++
|
||||
} else {
|
||||
mark = "MISS"
|
||||
}
|
||||
fmt.Printf("%s\t%s\t%s (truth=%s)\n", path, got, mark, truth)
|
||||
} else {
|
||||
fmt.Printf("%s\t%s\n", path, got)
|
||||
}
|
||||
}
|
||||
if total > 0 {
|
||||
fmt.Printf("\naccuracy: %d/%d = %.1f%%\n", correct, total, float64(correct)*100/float64(total))
|
||||
}
|
||||
}
|
||||
|
||||
func isAllDigits(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
// 训练工具:读取 samples/0.png ~ samples/9.png,生成 templates.go。
|
||||
//
|
||||
// 用法:
|
||||
//
|
||||
// cd server/internal/shared/digitocr
|
||||
// # 准备 samples/0.png ... 9.png,每张是同字体的单个数字
|
||||
// # 也可以是含多位的整图,命名 NNNN.png(如 4930.png),脚本会按位切分
|
||||
// go run ./cmd/train
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/png"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/shared/digitocr"
|
||||
)
|
||||
|
||||
func main() {
|
||||
groups, err := collectSamples("samples")
|
||||
if err != nil {
|
||||
exit(err)
|
||||
}
|
||||
missing := missingDigits(groups)
|
||||
if len(missing) > 0 {
|
||||
exit(fmt.Errorf("missing samples for digits: %v", missing))
|
||||
}
|
||||
|
||||
templates := [10]digitocr.Bitmap{}
|
||||
for d := 0; d < 10; d++ {
|
||||
templates[d] = voteTemplate(groups[d])
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("package digitocr\n\n")
|
||||
sb.WriteString("// Code generated by cmd/train. DO NOT EDIT.\n\n")
|
||||
sb.WriteString("var Templates = [10]Bitmap{\n")
|
||||
for d := 0; d < 10; d++ {
|
||||
sb.WriteString("\t{")
|
||||
for i, v := range templates[d] {
|
||||
if i > 0 {
|
||||
sb.WriteByte(',')
|
||||
}
|
||||
if v == 0 {
|
||||
sb.WriteByte('0')
|
||||
} else {
|
||||
sb.WriteByte('1')
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(&sb, "}, // d=%d, n=%d\n", d, len(groups[d]))
|
||||
}
|
||||
sb.WriteString("}\n")
|
||||
|
||||
if err := os.WriteFile("templates.go", []byte(sb.String()), 0o644); err != nil {
|
||||
exit(err)
|
||||
}
|
||||
fmt.Printf("wrote templates.go (samples per digit: ")
|
||||
for d := 0; d < 10; d++ {
|
||||
fmt.Printf("%d=%d ", d, len(groups[d]))
|
||||
}
|
||||
fmt.Println(")")
|
||||
}
|
||||
|
||||
// voteTemplate 对一组同数字样本做按位多数投票。
|
||||
func voteTemplate(bms []digitocr.Bitmap) digitocr.Bitmap {
|
||||
var out digitocr.Bitmap
|
||||
if len(bms) == 0 {
|
||||
return out
|
||||
}
|
||||
counts := make([]int, digitocr.GridW*digitocr.GridH)
|
||||
for _, bm := range bms {
|
||||
for i, v := range bm {
|
||||
if v == 1 {
|
||||
counts[i]++
|
||||
}
|
||||
}
|
||||
}
|
||||
threshold := len(bms) / 2
|
||||
for i, c := range counts {
|
||||
if c > threshold {
|
||||
out[i] = 1
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// collectSamples 从 dir 下读取样本。支持两种命名:
|
||||
// - 0.png ~ 9.png:单字符样本,整张图就是一个数字
|
||||
// - NNNN.png(如 4930.png):多位样本,按位切分,按文件名映射到对应数字
|
||||
//
|
||||
// 返回 map[digit][]Bitmap,每个数字累积所有样本以便投票。
|
||||
func collectSamples(dir string) (map[int][]digitocr.Bitmap, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[int][]digitocr.Bitmap{}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSuffix(e.Name(), filepath.Ext(e.Name()))
|
||||
if !allDigits(name) {
|
||||
continue
|
||||
}
|
||||
bms, err := extractBitmaps(filepath.Join(dir, e.Name()), len(name))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", e.Name(), err)
|
||||
}
|
||||
for i, ch := range name {
|
||||
d := int(ch - '0')
|
||||
out[d] = append(out[d], bms[i])
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func extractBitmaps(path string, digits int) ([]digitocr.Bitmap, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
img, _, err := image.Decode(f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bin, bbox := digitocr.Binarize(img, digitocr.IsOrange)
|
||||
if bbox.Empty() {
|
||||
return nil, fmt.Errorf("no foreground pixels")
|
||||
}
|
||||
cells := digitocr.SegmentDigits(bin, bbox, digits)
|
||||
if len(cells) != digits {
|
||||
return nil, fmt.Errorf("segmented %d cells, expected %d", len(cells), digits)
|
||||
}
|
||||
bms := make([]digitocr.Bitmap, digits)
|
||||
for i, cell := range cells {
|
||||
bms[i] = digitocr.Normalize(bin, cell)
|
||||
}
|
||||
return bms, nil
|
||||
}
|
||||
|
||||
func allDigits(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func missingDigits(m map[int][]digitocr.Bitmap) []int {
|
||||
var miss []int
|
||||
for d := 0; d < 10; d++ {
|
||||
if len(m[d]) == 0 {
|
||||
miss = append(miss, d)
|
||||
}
|
||||
}
|
||||
return miss
|
||||
}
|
||||
|
||||
func exit(err error) {
|
||||
fmt.Fprintln(os.Stderr, "train:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
Reference in New Issue
Block a user