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
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>
@@ -41,6 +41,7 @@ type Config struct {
|
||||
Retrieval RetrievalConfig `mapstructure:"retrieval"`
|
||||
Generation GenerationConfig `mapstructure:"generation"`
|
||||
BrowserFetch BrowserFetchConfig `mapstructure:"browser_fetch"`
|
||||
MediaSupply MediaSupplyConfig `mapstructure:"media_supply"`
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
@@ -409,6 +410,41 @@ type BrowserFetchConfig struct {
|
||||
TriggerStatus []int `mapstructure:"trigger_status"`
|
||||
}
|
||||
|
||||
type MediaSupplyConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
DefaultMarkupPercent float64 `mapstructure:"default_markup_percent"`
|
||||
MinimumMarkupCents int64 `mapstructure:"minimum_markup_cents"`
|
||||
Worker MediaSupplyWorkerConfig `mapstructure:"worker"`
|
||||
Meijiequan MeijiequanConfig `mapstructure:"meijiequan"`
|
||||
}
|
||||
|
||||
type MediaSupplyWorkerConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
PollInterval time.Duration `mapstructure:"poll_interval"`
|
||||
BatchSize int `mapstructure:"batch_size"`
|
||||
OrderMaxAttempts int `mapstructure:"order_max_attempts"`
|
||||
SyncMaxAttempts int `mapstructure:"sync_max_attempts"`
|
||||
BacklinkSyncInterval time.Duration `mapstructure:"backlink_sync_interval"`
|
||||
BacklinkBatchSize int `mapstructure:"backlink_batch_size"`
|
||||
}
|
||||
|
||||
type MeijiequanConfig struct {
|
||||
BaseURL string `mapstructure:"base_url"`
|
||||
UserID string `mapstructure:"user_id"`
|
||||
Username string `mapstructure:"username"`
|
||||
Password string `mapstructure:"password"`
|
||||
SessionTTL time.Duration `mapstructure:"session_ttl"`
|
||||
RequestTimeout time.Duration `mapstructure:"request_timeout"`
|
||||
SyncPageDelay time.Duration `mapstructure:"sync_page_delay"`
|
||||
SyncPageSize int `mapstructure:"sync_page_size"`
|
||||
MaxSyncPages int `mapstructure:"max_sync_pages"`
|
||||
PublishedPageSize int `mapstructure:"published_page_size"`
|
||||
PublishedMaxPages int `mapstructure:"published_max_pages"`
|
||||
SearchOptionsTTL time.Duration `mapstructure:"search_options_ttl"`
|
||||
UpstreamLockTTL time.Duration `mapstructure:"upstream_lock_ttl"`
|
||||
UpstreamMinInterval time.Duration `mapstructure:"upstream_min_interval"`
|
||||
}
|
||||
|
||||
func Load(configPath string) (*Config, error) {
|
||||
cfg, _, err := loadWithFiles(configPath)
|
||||
return cfg, err
|
||||
@@ -444,6 +480,7 @@ func loadWithFiles(configPath string) (*Config, []string, error) {
|
||||
NormalizeComplianceConfig(&cfg.Compliance)
|
||||
NormalizeGenerationConfig(&cfg.Generation)
|
||||
NormalizeBrowserFetchConfig(&cfg.BrowserFetch)
|
||||
NormalizeMediaSupplyConfig(&cfg.MediaSupply)
|
||||
|
||||
files := []string{configFile}
|
||||
if localConfigFile != "" {
|
||||
@@ -781,6 +818,88 @@ func NormalizeBrowserFetchConfig(cfg *BrowserFetchConfig) {
|
||||
}
|
||||
}
|
||||
|
||||
func NormalizeMediaSupplyConfig(cfg *MediaSupplyConfig) {
|
||||
if cfg == nil {
|
||||
return
|
||||
}
|
||||
if cfg.DefaultMarkupPercent < 0 {
|
||||
cfg.DefaultMarkupPercent = 0
|
||||
}
|
||||
if cfg.MinimumMarkupCents < 0 {
|
||||
cfg.MinimumMarkupCents = 0
|
||||
}
|
||||
if cfg.Worker.PollInterval <= 0 {
|
||||
cfg.Worker.PollInterval = 5 * time.Second
|
||||
}
|
||||
if cfg.Worker.BatchSize <= 0 {
|
||||
cfg.Worker.BatchSize = 1
|
||||
}
|
||||
if cfg.Worker.BatchSize > 10 {
|
||||
cfg.Worker.BatchSize = 10
|
||||
}
|
||||
if cfg.Worker.OrderMaxAttempts <= 0 {
|
||||
cfg.Worker.OrderMaxAttempts = 3
|
||||
}
|
||||
if cfg.Worker.SyncMaxAttempts <= 0 {
|
||||
cfg.Worker.SyncMaxAttempts = 2
|
||||
}
|
||||
if cfg.Worker.BacklinkSyncInterval <= 0 {
|
||||
cfg.Worker.BacklinkSyncInterval = 10 * time.Minute
|
||||
}
|
||||
if cfg.Worker.BacklinkBatchSize <= 0 {
|
||||
cfg.Worker.BacklinkBatchSize = 20
|
||||
}
|
||||
if cfg.Worker.BacklinkBatchSize > 100 {
|
||||
cfg.Worker.BacklinkBatchSize = 100
|
||||
}
|
||||
cfg.Meijiequan.BaseURL = strings.TrimRight(strings.TrimSpace(cfg.Meijiequan.BaseURL), "/")
|
||||
if cfg.Meijiequan.BaseURL == "" {
|
||||
cfg.Meijiequan.BaseURL = "http://www.meijiequan.com"
|
||||
}
|
||||
cfg.Meijiequan.UserID = strings.TrimSpace(cfg.Meijiequan.UserID)
|
||||
cfg.Meijiequan.Username = strings.TrimSpace(cfg.Meijiequan.Username)
|
||||
cfg.Meijiequan.Password = strings.TrimSpace(cfg.Meijiequan.Password)
|
||||
if cfg.Meijiequan.SessionTTL <= 0 {
|
||||
cfg.Meijiequan.SessionTTL = 4 * time.Hour
|
||||
}
|
||||
if cfg.Meijiequan.RequestTimeout <= 0 {
|
||||
cfg.Meijiequan.RequestTimeout = 30 * time.Second
|
||||
}
|
||||
if cfg.Meijiequan.SyncPageDelay <= 0 {
|
||||
cfg.Meijiequan.SyncPageDelay = 800 * time.Millisecond
|
||||
}
|
||||
if cfg.Meijiequan.SyncPageSize <= 0 {
|
||||
cfg.Meijiequan.SyncPageSize = 100
|
||||
}
|
||||
if cfg.Meijiequan.SyncPageSize > 500 {
|
||||
cfg.Meijiequan.SyncPageSize = 500
|
||||
}
|
||||
if cfg.Meijiequan.MaxSyncPages <= 0 {
|
||||
cfg.Meijiequan.MaxSyncPages = 200
|
||||
}
|
||||
if cfg.Meijiequan.PublishedPageSize <= 0 {
|
||||
cfg.Meijiequan.PublishedPageSize = 20
|
||||
}
|
||||
if cfg.Meijiequan.PublishedPageSize > 100 {
|
||||
cfg.Meijiequan.PublishedPageSize = 100
|
||||
}
|
||||
if cfg.Meijiequan.PublishedMaxPages <= 0 {
|
||||
cfg.Meijiequan.PublishedMaxPages = 3
|
||||
}
|
||||
if cfg.Meijiequan.PublishedMaxPages > 20 {
|
||||
cfg.Meijiequan.PublishedMaxPages = 20
|
||||
}
|
||||
if cfg.Meijiequan.SearchOptionsTTL <= 0 {
|
||||
cfg.Meijiequan.SearchOptionsTTL = 12 * time.Hour
|
||||
}
|
||||
if cfg.Meijiequan.UpstreamLockTTL <= 0 {
|
||||
cfg.Meijiequan.UpstreamLockTTL = 2 * time.Minute
|
||||
}
|
||||
if cfg.Meijiequan.UpstreamMinInterval <= 0 {
|
||||
cfg.Meijiequan.UpstreamMinInterval = cfg.Meijiequan.SyncPageDelay
|
||||
}
|
||||
}
|
||||
|
||||
func NormalizeComplianceConfig(cfg *ComplianceConfig) {
|
||||
if cfg == nil {
|
||||
return
|
||||
@@ -930,6 +1049,78 @@ func applyEnvOverrides(cfg *Config) {
|
||||
if statuses, ok := lookupNonEmptyEnv("BROWSER_FETCH_TRIGGER_STATUS"); ok {
|
||||
cfg.BrowserFetch.TriggerStatus = parseIntList(statuses)
|
||||
}
|
||||
if enabled, ok := lookupBoolEnv("MEDIA_SUPPLY_ENABLED"); ok {
|
||||
cfg.MediaSupply.Enabled = enabled
|
||||
}
|
||||
if markup, ok := lookupFloatEnv("MEDIA_SUPPLY_DEFAULT_MARKUP_PERCENT"); ok {
|
||||
cfg.MediaSupply.DefaultMarkupPercent = markup
|
||||
}
|
||||
if cents, ok := lookupInt64Env("MEDIA_SUPPLY_MINIMUM_MARKUP_CENTS"); ok {
|
||||
cfg.MediaSupply.MinimumMarkupCents = cents
|
||||
}
|
||||
if enabled, ok := lookupBoolEnv("MEDIA_SUPPLY_WORKER_ENABLED"); ok {
|
||||
cfg.MediaSupply.Worker.Enabled = enabled
|
||||
}
|
||||
if interval, ok := lookupDurationEnv("MEDIA_SUPPLY_WORKER_POLL_INTERVAL"); ok {
|
||||
cfg.MediaSupply.Worker.PollInterval = interval
|
||||
}
|
||||
if n, ok := lookupIntEnv("MEDIA_SUPPLY_WORKER_BATCH_SIZE"); ok {
|
||||
cfg.MediaSupply.Worker.BatchSize = n
|
||||
}
|
||||
if n, ok := lookupIntEnv("MEDIA_SUPPLY_ORDER_MAX_ATTEMPTS"); ok {
|
||||
cfg.MediaSupply.Worker.OrderMaxAttempts = n
|
||||
}
|
||||
if n, ok := lookupIntEnv("MEDIA_SUPPLY_SYNC_MAX_ATTEMPTS"); ok {
|
||||
cfg.MediaSupply.Worker.SyncMaxAttempts = n
|
||||
}
|
||||
if interval, ok := lookupDurationEnv("MEDIA_SUPPLY_BACKLINK_SYNC_INTERVAL"); ok {
|
||||
cfg.MediaSupply.Worker.BacklinkSyncInterval = interval
|
||||
}
|
||||
if n, ok := lookupIntEnv("MEDIA_SUPPLY_BACKLINK_BATCH_SIZE"); ok {
|
||||
cfg.MediaSupply.Worker.BacklinkBatchSize = n
|
||||
}
|
||||
if baseURL, ok := lookupNonEmptyEnv("MEIJIEQUAN_BASE_URL"); ok {
|
||||
cfg.MediaSupply.Meijiequan.BaseURL = baseURL
|
||||
}
|
||||
if userID, ok := lookupNonEmptyEnv("MEIJIEQUAN_USER_ID"); ok {
|
||||
cfg.MediaSupply.Meijiequan.UserID = userID
|
||||
}
|
||||
if username, ok := lookupNonEmptyEnv("MEIJIEQUAN_USERNAME"); ok {
|
||||
cfg.MediaSupply.Meijiequan.Username = username
|
||||
}
|
||||
if password, ok := lookupNonEmptyEnv("MEIJIEQUAN_PASSWORD"); ok {
|
||||
cfg.MediaSupply.Meijiequan.Password = password
|
||||
}
|
||||
if ttl, ok := lookupDurationEnv("MEIJIEQUAN_SESSION_TTL"); ok {
|
||||
cfg.MediaSupply.Meijiequan.SessionTTL = ttl
|
||||
}
|
||||
if timeout, ok := lookupDurationEnv("MEIJIEQUAN_REQUEST_TIMEOUT"); ok {
|
||||
cfg.MediaSupply.Meijiequan.RequestTimeout = timeout
|
||||
}
|
||||
if delay, ok := lookupDurationEnv("MEIJIEQUAN_SYNC_PAGE_DELAY"); ok {
|
||||
cfg.MediaSupply.Meijiequan.SyncPageDelay = delay
|
||||
}
|
||||
if n, ok := lookupIntEnv("MEIJIEQUAN_SYNC_PAGE_SIZE"); ok {
|
||||
cfg.MediaSupply.Meijiequan.SyncPageSize = n
|
||||
}
|
||||
if n, ok := lookupIntEnv("MEIJIEQUAN_MAX_SYNC_PAGES"); ok {
|
||||
cfg.MediaSupply.Meijiequan.MaxSyncPages = n
|
||||
}
|
||||
if n, ok := lookupIntEnv("MEIJIEQUAN_PUBLISHED_PAGE_SIZE"); ok {
|
||||
cfg.MediaSupply.Meijiequan.PublishedPageSize = n
|
||||
}
|
||||
if n, ok := lookupIntEnv("MEIJIEQUAN_PUBLISHED_MAX_PAGES"); ok {
|
||||
cfg.MediaSupply.Meijiequan.PublishedMaxPages = n
|
||||
}
|
||||
if ttl, ok := lookupDurationEnv("MEIJIEQUAN_SEARCH_OPTIONS_TTL"); ok {
|
||||
cfg.MediaSupply.Meijiequan.SearchOptionsTTL = ttl
|
||||
}
|
||||
if ttl, ok := lookupDurationEnv("MEIJIEQUAN_UPSTREAM_LOCK_TTL"); ok {
|
||||
cfg.MediaSupply.Meijiequan.UpstreamLockTTL = ttl
|
||||
}
|
||||
if interval, ok := lookupDurationEnv("MEIJIEQUAN_UPSTREAM_MIN_INTERVAL"); ok {
|
||||
cfg.MediaSupply.Meijiequan.UpstreamMinInterval = interval
|
||||
}
|
||||
if apiKey, ok := lookupNonEmptyEnv("QDRANT_API_KEY"); ok {
|
||||
cfg.Qdrant.APIKey = apiKey
|
||||
}
|
||||
@@ -1468,6 +1659,32 @@ func lookupIntEnv(key string) (int, bool) {
|
||||
return number, true
|
||||
}
|
||||
|
||||
func lookupInt64Env(key string) (int64, bool) {
|
||||
value, ok := lookupNonEmptyEnv(key)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
number, err := strconv.ParseInt(value, 10, 64)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return number, true
|
||||
}
|
||||
|
||||
func lookupFloatEnv(key string) (float64, bool) {
|
||||
value, ok := lookupNonEmptyEnv(key)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
number, err := strconv.ParseFloat(value, 64)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return number, true
|
||||
}
|
||||
|
||||
func maxInt(value, fallback int) int {
|
||||
if value < fallback {
|
||||
return fallback
|
||||
|
||||
@@ -368,6 +368,46 @@ cache:
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAppliesMediaSupplyMeijiequanAccountOverrides(t *testing.T) {
|
||||
t.Setenv("MEIJIEQUAN_USERNAME", " 17788409108 ")
|
||||
t.Setenv("MEIJIEQUAN_PASSWORD", " env-password ")
|
||||
t.Setenv("MEIJIEQUAN_SYNC_PAGE_DELAY", "1500ms")
|
||||
t.Setenv("MEIJIEQUAN_SEARCH_OPTIONS_TTL", "2h")
|
||||
t.Setenv("MEIJIEQUAN_UPSTREAM_MIN_INTERVAL", "3s")
|
||||
t.Setenv("MEDIA_SUPPLY_BACKLINK_SYNC_INTERVAL", "15m")
|
||||
t.Setenv("MEDIA_SUPPLY_BACKLINK_BATCH_SIZE", "12")
|
||||
t.Setenv("MEIJIEQUAN_PUBLISHED_PAGE_SIZE", "30")
|
||||
t.Setenv("MEIJIEQUAN_PUBLISHED_MAX_PAGES", "4")
|
||||
|
||||
configPath := writeTestConfig(t, `
|
||||
media_supply:
|
||||
meijiequan:
|
||||
base_url: "http://www.meijiequan.com/"
|
||||
user_id: " 962 "
|
||||
username: config-user
|
||||
password: config-password
|
||||
`)
|
||||
|
||||
cfg, err := Load(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
|
||||
if cfg.MediaSupply.Meijiequan.BaseURL != "http://www.meijiequan.com" ||
|
||||
cfg.MediaSupply.Meijiequan.UserID != "962" ||
|
||||
cfg.MediaSupply.Meijiequan.Username != "17788409108" ||
|
||||
cfg.MediaSupply.Meijiequan.Password != "env-password" ||
|
||||
cfg.MediaSupply.Meijiequan.SyncPageDelay != 1500*time.Millisecond ||
|
||||
cfg.MediaSupply.Meijiequan.SearchOptionsTTL != 2*time.Hour ||
|
||||
cfg.MediaSupply.Meijiequan.UpstreamMinInterval != 3*time.Second ||
|
||||
cfg.MediaSupply.Worker.BacklinkSyncInterval != 15*time.Minute ||
|
||||
cfg.MediaSupply.Worker.BacklinkBatchSize != 12 ||
|
||||
cfg.MediaSupply.Meijiequan.PublishedPageSize != 30 ||
|
||||
cfg.MediaSupply.Meijiequan.PublishedMaxPages != 4 {
|
||||
t.Fatalf("unexpected meijiequan config: %#v", cfg.MediaSupply.Meijiequan)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadPrefersEnvSchedulerMetricsSettings(t *testing.T) {
|
||||
t.Setenv("SCHEDULER_HTTP_HOST", "0.0.0.0")
|
||||
t.Setenv("SCHEDULER_INTERNAL_METRICS_TOKEN", "env-metrics-token")
|
||||
|
||||
@@ -92,6 +92,12 @@ func Diff(previous, current *Config) []FieldChange {
|
||||
} else if !reflect.DeepEqual(previous.BrowserFetch, current.BrowserFetch) {
|
||||
addChange("browser_fetch", true)
|
||||
}
|
||||
if previous.MediaSupply.Worker.PollInterval != current.MediaSupply.Worker.PollInterval ||
|
||||
previous.MediaSupply.Worker.BatchSize != current.MediaSupply.Worker.BatchSize {
|
||||
addChange("media_supply.worker", false)
|
||||
} else if !reflect.DeepEqual(previous.MediaSupply, current.MediaSupply) {
|
||||
addChange("media_supply", true)
|
||||
}
|
||||
if previous.Generation.QueueSize != current.Generation.QueueSize ||
|
||||
previous.Generation.WorkerConcurrency != current.Generation.WorkerConcurrency {
|
||||
addChange("generation.worker", false)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package digitocr
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 用一张内存图测试链路:4 个矩形色块,颜色满足 IsOrange,等距排布。
|
||||
// 训练前 Templates 全 0,所以匹配结果都是 0,但能验证切分和归一化不 panic。
|
||||
func TestRecognizeImage_SmokeTest(t *testing.T) {
|
||||
img := image.NewRGBA(image.Rect(0, 0, 50, 25))
|
||||
orange := color.RGBA{R: 240, G: 130, B: 40, A: 255}
|
||||
// 4 个 8x16 的实心块,模拟 4 个数字
|
||||
for d := 0; d < 4; d++ {
|
||||
x0 := 5 + d*10
|
||||
for y := 5; y < 21; y++ {
|
||||
for x := x0; x < x0+8; x++ {
|
||||
img.Set(x, y, orange)
|
||||
}
|
||||
}
|
||||
}
|
||||
got, err := RecognizeImage(img, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("RecognizeImage: %v", err)
|
||||
}
|
||||
if len(got) != 4 {
|
||||
t.Fatalf("want 4 digits, got %q", got)
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 149 B |
|
After Width: | Height: | Size: 148 B |
|
After Width: | Height: | Size: 157 B |
|
After Width: | Height: | Size: 150 B |
|
After Width: | Height: | Size: 154 B |
|
After Width: | Height: | Size: 151 B |
|
After Width: | Height: | Size: 154 B |
|
After Width: | Height: | Size: 146 B |
|
After Width: | Height: | Size: 139 B |
|
After Width: | Height: | Size: 146 B |
|
After Width: | Height: | Size: 147 B |
|
After Width: | Height: | Size: 151 B |
|
After Width: | Height: | Size: 140 B |
|
After Width: | Height: | Size: 159 B |
|
After Width: | Height: | Size: 163 B |
|
After Width: | Height: | Size: 157 B |
|
After Width: | Height: | Size: 150 B |
|
After Width: | Height: | Size: 163 B |
|
After Width: | Height: | Size: 158 B |
|
After Width: | Height: | Size: 148 B |
|
After Width: | Height: | Size: 154 B |
|
After Width: | Height: | Size: 139 B |
|
After Width: | Height: | Size: 155 B |
|
After Width: | Height: | Size: 158 B |
|
After Width: | Height: | Size: 151 B |
|
After Width: | Height: | Size: 151 B |
|
After Width: | Height: | Size: 149 B |
|
After Width: | Height: | Size: 154 B |
|
After Width: | Height: | Size: 156 B |
|
After Width: | Height: | Size: 150 B |
|
After Width: | Height: | Size: 156 B |
|
After Width: | Height: | Size: 157 B |
|
After Width: | Height: | Size: 159 B |
|
After Width: | Height: | Size: 158 B |
|
After Width: | Height: | Size: 161 B |
|
After Width: | Height: | Size: 159 B |
|
After Width: | Height: | Size: 153 B |
|
After Width: | Height: | Size: 158 B |
|
After Width: | Height: | Size: 155 B |
|
After Width: | Height: | Size: 152 B |
|
After Width: | Height: | Size: 152 B |
|
After Width: | Height: | Size: 156 B |
|
After Width: | Height: | Size: 157 B |
|
After Width: | Height: | Size: 156 B |
|
After Width: | Height: | Size: 148 B |
|
After Width: | Height: | Size: 145 B |
|
After Width: | Height: | Size: 158 B |
|
After Width: | Height: | Size: 151 B |
|
After Width: | Height: | Size: 148 B |
|
After Width: | Height: | Size: 155 B |
|
After Width: | Height: | Size: 136 B |
|
After Width: | Height: | Size: 159 B |
|
After Width: | Height: | Size: 158 B |
|
After Width: | Height: | Size: 158 B |
|
After Width: | Height: | Size: 151 B |
|
After Width: | Height: | Size: 158 B |
|
After Width: | Height: | Size: 157 B |
|
After Width: | Height: | Size: 156 B |
@@ -0,0 +1,16 @@
|
||||
package digitocr
|
||||
|
||||
// Code generated by cmd/train. DO NOT EDIT.
|
||||
|
||||
var Templates = [10]Bitmap{
|
||||
{0,0,0,1,1,0,0,0,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,0,0,1,1,0,0,0,1,1,0,0,0,0,1,1,0,0,1,1,0,0,0,0,1,1,0,0,1,1,0,0,0,0,1,1,0,0,1,1,0,0,0,0,1,1,0,0,0,1,1,0,0,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0}, // d=0, n=27
|
||||
{0,0,1,1,0,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0}, // d=1, n=26
|
||||
{0,0,1,1,1,1,0,0,0,0,0,1,1,0,0,1,1,0,0,0,1,1,0,0,0,0,1,1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0}, // d=2, n=29
|
||||
{0,1,1,1,1,1,0,0,0,0,1,1,0,0,0,1,1,0,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,1,1,0,0,1,1,0,0,0,1,1,0,0,0,0,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0}, // d=3, n=20
|
||||
{0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,0,1,1,0,0,0,0,1,1,0,0,1,1,0,0,0,1,1,0,0,0,1,1,0,0,0,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0}, // d=4, n=16
|
||||
{1,1,1,1,1,1,1,0,0,0,1,1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,1,1,0,1,1,1,0,0,0,0,1,1,1,0,0,1,1,0,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,1,1,0,0,1,1,0,0,0,0,1,1,0,0,0,1,1,0,0,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0}, // d=5, n=19
|
||||
{0,0,1,1,1,1,0,0,0,0,0,1,1,0,0,1,1,0,0,0,1,1,0,0,0,0,1,0,0,0,1,1,0,0,0,0,0,0,0,0,1,1,0,1,1,1,0,0,0,0,1,1,1,0,0,1,1,0,0,0,1,1,0,0,0,0,1,1,0,0,1,1,0,0,0,0,1,1,0,0,0,1,1,0,0,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0}, // d=6, n=27
|
||||
{1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0}, // d=7, n=27
|
||||
{0,0,1,1,1,1,0,0,0,0,0,1,1,0,0,1,1,0,0,0,1,1,0,0,0,0,1,1,0,0,0,1,1,0,0,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,1,1,0,0,1,1,0,0,0,1,1,0,0,0,0,1,1,0,0,1,1,0,0,0,0,1,1,0,0,0,1,1,0,0,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0}, // d=8, n=20
|
||||
{0,0,1,1,1,1,0,0,0,0,0,1,1,0,0,1,1,0,0,0,1,1,0,0,0,0,1,1,0,0,1,1,0,0,0,0,1,1,0,0,0,1,1,0,0,1,1,1,0,0,0,0,1,1,1,0,1,1,0,0,0,0,0,0,0,0,1,1,0,0,0,1,0,0,0,0,1,1,0,0,0,1,1,0,0,1,1,0,0,0,0,0,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0}, // d=9, n=21
|
||||
}
|
||||