Compare commits

...

10 Commits

Author SHA1 Message Date
xiadongliang e21423bb9f enhance 2026-09-08 10:03:26 +08:00
xiadongliang 9ad0d920c1 readme 2026-09-03 15:45:24 +08:00
xiadongliang bf4d764e58 lots change 2026-09-03 14:48:13 +08:00
xiadongliang 2bf7db6bb9 train 2026-08-24 09:40:31 +08:00
xiadongliang 5199e3040f lots of change 2026-08-11 09:36:38 +08:00
xiadongliang 17b5459ce1 convnext 2026-08-05 16:59:16 +08:00
xiadongliang 4637af4f81 yuanxing 2026-08-03 17:45:58 +08:00
xiadongliang a55d449028 new train 2026-07-28 18:06:10 +08:00
xiadongliang 44b0d6b756 git ignore 2026-07-24 17:46:59 +08:00
xiadongliang a217077c13 resnet parm 2026-07-24 17:44:47 +08:00
44 changed files with 1162 additions and 311 deletions
+4
View File
@@ -23,12 +23,16 @@ env/
uploads/* uploads/*
core/dataset/*.zip
core/dataset/toy/* core/dataset/toy/*
core/dataset/benchmark/* core/dataset/benchmark/*
core/models/*.pth core/models/*.pth
core/models/*.pt core/models/*.pt
core/models/*.onnx
build/bin build/bin
frontend/node_modules frontend/node_modules
frontend/dist frontend/dist
frontend/public frontend/public
static/images/*
+141 -12
View File
@@ -1,19 +1,148 @@
# README # CatBreed - Cat Breed Recognition Desktop Application
## About A deep learning-based cat breed recognition desktop application built with Wails framework, supporting image upload, breed prediction, and history management.
This is the official Wails Preact-TS template. ![Wails](https://img.shields.io/badge/Wails-Desktop%20App-blue)
![Preact](https://img.shields.io/badge/Preact-Frontend%20TS-green)
![Go](https://img.shields.io/badge/Go-Backend-blue)
![ONNX](https://img.shields.io/badge/ONNX-Inference-orange)
You can configure the project by editing `wails.json`. More information about the project settings can be found ## Features
here: https://wails.io/docs/reference/project-config
## Live Development - **📸 Image Upload** - Drag & drop or click to upload cat photos (JPG/PNG/JPEG)
- **🔍 Breed Recognition** - Recognizes 12 common cat breeds based on ResNet18 deep residual network
- **📊 Confidence Display** - Shows model prediction confidence percentage
- **📜 History** - Auto-saves each recognition record with pagination support
- **🗑️ Record Management** - Delete individual records or clear all history
- **💾 Local Storage** - Uses SQLite database for history data
To run in live development mode, run `wails dev` in the project directory. This will run a Vite development ## Architecture
server that will provide very fast hot reload of your frontend changes. If you want to develop in a browser
and have access to your Go methods, there is also a dev server that runs on http://localhost:34115. Connect
to this in your browser, and you can call your Go code from devtools.
## Building ```
┌─────────────────────────────────────────────────────┐
│ Wails Framework │
├──────────────────────┬──────────────────────────────┤
│ Frontend (Preact) │ Backend (Go) │
│ ───────────────── │ ────────────────────────── │
│ • Main.tsx │ • app.go (API) │
│ • History.tsx │ • types.go (Data Models) │
│ • Modal.tsx │ • ONNX Runtime Inference │
│ • Component Logic │ • GORM + SQLite │
└──────────────────────┴──────────────────────────────┘
┌─────────────────┐
│ ONNX Model │
│ ResNet18 │
│ 12 Classes │
└─────────────────┘
```
To build a redistributable, production mode package, use `wails build`. ## Project Structure
```
dissertation/
├── backend/ # Go backend
│ ├── app.go # Core business logic
│ └── types.go # Data structure definitions
├── core/ # Python ML module
│ ├── nets/ # Neural network definitions
│ │ ├── resnet.py # ResNet base class
│ │ └── resnet18.py # ResNet18 model
│ ├── dataloader/ # Data loading
│ ├── train/ # Training scripts
│ │ ├── train_resnet18.py # Model training
│ │ └── to_onnx.py # PyTorch → ONNX conversion
│ └── const/ # Hyperparameter configuration
├── frontend/ # Preact + TypeScript frontend
│ └── src/
│ └── components/ # React-style components
│ ├── Main.tsx # Main page (upload/recognition)
│ └── History.tsx # History page
├── static/images/ # Uploaded image storage directory
└── app.db # SQLite database
```
## Quick Start
### Requirements
- Go 1.21+
- Node.js 18+
- Python 3.9+ (for model training)
- Wails CLI
### Install Dependencies
```bash
# Install Wails CLI
go install github.com/wailsapp/wails/v2/cmd/wails@latest
# Install frontend dependencies
cd frontend
npm install
# Return to project root
cd ..
```
### Run the Application
```bash
# Development mode
wails dev
# Production build
wails build
```
### Model Training (Optional)
To retrain the model:
```bash
cd core/train
# Train ResNet18
python train_resnet18.py
# Export to ONNX format
python to_onnx.py
```
The trained model file `resnet18_epoch_50.onnx` should be placed in the `build/bin` directory of the output.
## Technical Details
### Inference Pipeline
1. **Image Preprocessing** - Resize uploaded image to 224×224, apply ImageNet normalization
- Mean: `[0.485, 0.456, 0.406]`
- Std: `[0.229, 0.224, 0.225]`
2. **Tensor Format** - Convert to NCHW format `(1, 3, 224, 224)`
3. **ONNX Inference** - Execute inference via ONNX Runtime Go
4. **Post-processing** - Softmax for confidence calculation, return highest probability class
### Data Models
| Table | Description |
|-------|-------------|
| `breeds` | Cat breed table (code, name, brief description) |
| `history` | Recognition history (image, breed, confidence, timestamp) |
## UI Preview
### Main Page
- Upload area supports drag & drop
- Real-time image preview
- One-click breed recognition
- Result display with confidence progress bar
### History Page
- Paginated history records
- Click image to view details
- Single/batch delete functionality
## License
MIT
BIN
View File
Binary file not shown.
+265 -60
View File
@@ -1,19 +1,30 @@
package backend package backend
import ( import (
"bytes"
"context" "context"
"encoding/base64" "encoding/base64"
"fmt" "fmt"
"math"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"strings"
"time" "time"
"github.com/disintegration/imaging"
ort "github.com/yalue/onnxruntime_go"
"gorm.io/driver/sqlite" "gorm.io/driver/sqlite"
"gorm.io/gorm" "gorm.io/gorm"
) )
var ( var (
publicImagePath = "./frontend/public/images" staticImagesPath = "./static/images"
modelPath = "resnet18_epoch_50.onnx"
// ImageNet 标准化参数
mean = []float32{0.485, 0.456, 0.406}
std = []float32{0.229, 0.224, 0.225}
) )
func NewApp() *App { func NewApp() *App {
@@ -22,25 +33,194 @@ func NewApp() *App {
func (a *App) Startup(ctx context.Context) { func (a *App) Startup(ctx context.Context) {
a.ctx = ctx a.ctx = ctx
// 设置 ONNX Runtime DLL 路径
exeDir, _ := os.Executable()
println("exeDir", exeDir)
ort.SetSharedLibraryPath(filepath.Join(filepath.Dir(exeDir), "onnxruntime.dll"))
// 初始化 ONNX Runtime 环境
err := ort.InitializeEnvironment()
if err != nil {
panic("Failed to initialize ONNX runtime: " + err.Error())
}
// 初始化数据库连接(复用)
a.db, err = gorm.Open(sqlite.Open("app.db"), &gorm.Config{})
if err != nil {
panic("Failed to open database: " + err.Error())
}
// 预加载 labels
a.labels, err = a.loadLabels()
if err != nil {
panic("Failed to load labels: " + err.Error())
}
// 初始化 ONNX session(复用)
a.session, err = a.initONNXSession()
if err != nil {
panic("Failed to initialize ONNX session: " + err.Error())
}
} }
func (a *App) GormDB() (*gorm.DB, error) { func (a *App) loadLabels() ([]string, error) {
db, err := gorm.Open(sqlite.Open("app.db"), &gorm.Config{}) var labels []string
err := a.db.Table("breeds").Pluck("code", &labels).Error
return labels, err
}
func (a *App) initONNXSession() (*ort.AdvancedSession, error) {
exeDir, _ := os.Executable()
modelFilePath := filepath.Join(filepath.Dir(exeDir), modelPath)
inputTensor, err := ort.NewTensor(ort.Shape{1, 3, 224, 224}, make([]float32, 1*3*224*224))
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("create input tensor: %w", err)
} }
return db, nil defer inputTensor.Destroy()
outputTensor, err := ort.NewTensor(ort.Shape{1, 12}, make([]float32, 12))
if err != nil {
return nil, fmt.Errorf("create output tensor: %w", err)
}
defer outputTensor.Destroy()
session, err := ort.NewAdvancedSession(
modelFilePath,
[]string{"input"},
[]string{"output"},
[]ort.Value{inputTensor},
[]ort.Value{outputTensor},
nil,
)
if err != nil {
return nil, fmt.Errorf("create session: %w", err)
}
return session, nil
}
func (a *App) Shutdown() {
if a.session != nil {
a.session.Destroy()
}
if a.db != nil {
sqlDB, _ := a.db.DB()
if sqlDB != nil {
sqlDB.Close()
}
}
}
func preprocessImage(imgData []byte) ([]float32, error) {
// 解码图片
reader := bytes.NewReader(imgData)
img, err := imaging.Decode(reader, imaging.AutoOrientation(true))
if err != nil {
return nil, fmt.Errorf("decode image: %w", err)
}
// 缩放到 224x224
img = imaging.Resize(img, 224, 224, imaging.Lanczos)
// 转换为 RGBA 格式(统一4通道)
rgba := imaging.Clone(img)
// 转换为 float32 数组 (NCHW 格式: 1, 3, 224, 224)
input := make([]float32, 1*3*224*224)
bounds := rgba.Bounds()
idx := 0
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
for x := bounds.Min.X; x < bounds.Max.X; x++ {
// 强制转换为 RGBA,确保4通道
r, g, b, _ := rgba.At(x, y).RGBA()
// RGBA 返回 0-65535,需要转换到 0-255
rf := float32(r>>8) / 255.0
gf := float32(g>>8) / 255.0
bf := float32(b>>8) / 255.0
// ImageNet 标准化
input[idx] = (rf - mean[0]) / std[0] // R channel
input[idx+224*224] = (gf - mean[1]) / std[1] // G channel
input[idx+224*224*2] = (bf - mean[2]) / std[2] // B channel
idx++
}
}
return input, nil
}
func (a *App) runInference(input []float32) (string, float64, error) {
// 每次推理创建新的 tensor(因为输入数据不同),但 session 复用
inputTensor, err := ort.NewTensor(ort.Shape{1, 3, 224, 224}, input)
if err != nil {
return "", 0, fmt.Errorf("create input tensor: %w", err)
}
defer inputTensor.Destroy()
outputTensor, err := ort.NewTensor(ort.Shape{1, 12}, make([]float32, 12))
if err != nil {
return "", 0, fmt.Errorf("create output tensor: %w", err)
}
defer outputTensor.Destroy()
// 使用 App 中预加载的 session(通过 AdvancedSession 复用)
exeDir, _ := os.Executable()
session, err := ort.NewAdvancedSession(
filepath.Join(filepath.Dir(exeDir), modelPath),
[]string{"input"},
[]string{"output"},
[]ort.Value{inputTensor},
[]ort.Value{outputTensor},
nil,
)
if err != nil {
return "", 0, fmt.Errorf("create session: %w", err)
}
defer session.Destroy()
err = session.Run()
if err != nil {
return "", 0, fmt.Errorf("run inference: %w", err)
}
// 获取输出
outputData := outputTensor.GetData()
// 找最大值的索引
maxIdx := 0
maxVal := outputData[0]
for i := 1; i < len(outputData); i++ {
if outputData[i] > maxVal {
maxVal = outputData[i]
maxIdx = i
}
}
// Softmax 计算置信度
var sum float64
for _, v := range outputData {
sum += math.Exp(float64(v))
}
confidence := math.Exp(float64(maxVal)) / sum
return a.labels[maxIdx], confidence, nil
}
func (a *App) GormDB() *gorm.DB {
return a.db
} }
func (a *App) UploadImage(data []byte, filename string) Response { func (a *App) UploadImage(data []byte, filename string) Response {
uploadsDir := publicImagePath uploadsDir := staticImagesPath
err := os.MkdirAll(uploadsDir, 0755) err := os.MkdirAll(uploadsDir, 0755)
if err != nil { if err != nil {
return Response{Code: 1, Message: "failed", Data: err.Error()} return Response{Code: 1, Message: "failed", Data: err.Error()}
} }
ext := filepath.Ext(filename) ext := filepath.Ext(filename)
newFilename := fmt.Sprintf("%d%s", time.Now().UnixMilli(), ext) newFilename := strconv.FormatInt(time.Now().UnixMilli(), 10) + ext
filePath := filepath.Join(uploadsDir, newFilename) filePath := filepath.Join(uploadsDir, newFilename)
err = os.WriteFile(filePath, data, 0644) err = os.WriteFile(filePath, data, 0644)
@@ -48,69 +228,63 @@ func (a *App) UploadImage(data []byte, filename string) Response {
return Response{Code: 1, Message: "failed", Data: err.Error()} return Response{Code: 1, Message: "failed", Data: err.Error()}
} }
return Response{Code: 0, Message: "success", Data: newFilename} imageResult := map[string]any{
"filename": newFilename,
"data": base64.StdEncoding.EncodeToString(data),
}
return Response{Code: 0, Message: "success", Data: imageResult}
} }
func (a *App) GetImage(filename string) Response { // validateFilename 检查文件名是否安全,防止路径遍历攻击
filePath := filepath.Join(publicImagePath, filename) func validateFilename(filename string) error {
// 禁止包含路径分隔符
data, err := os.ReadFile(filePath) if strings.ContainsAny(filename, "/\\") {
if err != nil { return fmt.Errorf("invalid filename")
return Response{Code: 1, Message: "failed", Data: err.Error()}
} }
// 禁止 .. 路径遍历
ext := filepath.Ext(filename) if strings.Contains(filename, "..") {
mimeType := "image/jpeg" return fmt.Errorf("invalid filename")
if ext == ".png" {
mimeType = "image/png"
} }
return nil
base64Data := base64.StdEncoding.EncodeToString(data)
dataURL := "data:image/" + mimeType + ";base64," + base64Data
return Response{Code: 0, Message: "success", Data: dataURL}
} }
func (a *App) GetHistory(page int, pageSize int) Response { func (a *App) Detect(filename string) Response {
db, err := a.GormDB() db := a.GormDB()
if err != nil {
return Response{Code: 1, Message: err.Error()} if err := validateFilename(filename); err != nil {
return Response{Code: 1, Message: "invalid filename"}
} }
var total int64 filePath := filepath.Join(staticImagesPath, filename)
db.Table("history_test").Count(&total) imgData, err := os.ReadFile(filePath)
var historyList []HistoryWithBreed
err = db.Table("history_test").Select("history_test.*, breeds_test.brief, breeds_test.name").
Joins("LEFT JOIN breeds_test ON history_test.breed = breeds_test.id").
Order("history_test.id DESC").Limit(pageSize).Offset((page - 1) * pageSize).Find(&historyList).Error
if err != nil { if err != nil {
return Response{Code: 1, Message: err.Error()} return Response{Code: 1, Message: "failed to read image: " + err.Error()}
} }
historyData := HistoryData{Page: page, PageSize: pageSize, Total: total, List: historyList} input, err := preprocessImage(imgData)
return Response{Code: 0, Message: "success", Data: historyData}
}
func (a *App) Detect(img string) Response {
db, err := a.GormDB()
if err != nil { if err != nil {
return Response{Code: 1, Message: err.Error()} return Response{Code: 1, Message: "failed to preprocess: " + err.Error()}
} }
// 推理(使用 App 中预加载的 labels)
detectRet, confidence, err := a.runInference(input)
if err != nil {
return Response{Code: 1, Message: "model inference failed: " + err.Error()}
}
confidence = math.Round(confidence*10000) / 10000
var breed Breed var breed Breed
detectRet := "british_shorthair" err = db.Table("breeds").Where("code = ?", detectRet).First(&breed).Error
err = db.Table("breeds_test").Where("code = ?", detectRet).First(&breed).Error
if err != nil { if err != nil {
return Response{Code: 1, Message: err.Error()} return Response{Code: 1, Message: err.Error()}
} }
now := int(time.Now().Unix()) now := int(time.Now().Unix())
print("confidence", confidence)
one := HistoryItem{Img: img, Breed: int(breed.Id), Date: now} one := HistoryItem{Img: filename, Breed: int(breed.Id), Confidence: confidence, Date: now}
result := db.Table("history_test").Create(&one) result := db.Table("history").Create(&one)
if result.Error != nil { if result.Error != nil {
return Response{Code: 1, Message: result.Error.Error()} return Response{Code: 1, Message: result.Error.Error()}
} }
@@ -120,19 +294,53 @@ func (a *App) Detect(img string) Response {
Code: breed.Code, Code: breed.Code,
Name: breed.Name, Name: breed.Name,
Brief: breed.Brief, Brief: breed.Brief,
ConfidenceLevel: 0.98, ConfidenceLevel: confidence,
} }
return Response{Code: 0, Message: "success", Data: detectData} return Response{Code: 0, Message: "success", Data: detectData}
} }
func (a *App) DeleteOneHistory(id uint) Response { func (a *App) GetHistory(page int, pageSize int) Response {
db, err := a.GormDB() db := a.GormDB()
var total int64
db.Table("history").Count(&total)
var historyList []HistoryWithBreed
err := db.Table("history").Select("history.*, breeds.brief, breeds.name").
Joins("LEFT JOIN breeds ON history.breed = breeds.id").
Order("history.id DESC").Limit(pageSize).Offset((page - 1) * pageSize).Find(&historyList).Error
if err != nil { if err != nil {
return Response{Code: 1, Message: err.Error()} return Response{Code: 1, Message: err.Error()}
} }
result := db.Table("history_test").Delete(&HistoryItem{}, id) for i := range historyList {
imgPath := filepath.Join(staticImagesPath, historyList[i].Img)
if data, err := os.ReadFile(imgPath); err == nil {
historyList[i].ImgData = base64.StdEncoding.EncodeToString(data)
}
}
historyData := HistoryData{Page: page, PageSize: pageSize, Total: total, List: historyList}
return Response{Code: 0, Message: "success", Data: historyData}
}
func (a *App) DeleteOneHistory(id uint) Response {
db := a.GormDB()
// 先查询获取图片文件名
var item HistoryItem
if err := db.Table("history").Where("id = ?", id).First(&item).Error; err != nil {
return Response{Code: 1, Message: err.Error()}
}
// 删除图片文件
imgPath := filepath.Join(staticImagesPath, item.Img)
os.Remove(imgPath)
result := db.Table("history").Delete(&HistoryItem{}, id)
if result.Error != nil { if result.Error != nil {
return Response{Code: 1, Message: result.Error.Error()} return Response{Code: 1, Message: result.Error.Error()}
} }
@@ -141,20 +349,17 @@ func (a *App) DeleteOneHistory(id uint) Response {
} }
func (a *App) ClearHistory() Response { func (a *App) ClearHistory() Response {
db, err := a.GormDB() db := a.GormDB()
if err != nil {
return Response{Code: 1, Message: err.Error()}
}
result := db.Exec("DELETE FROM history_test") result := db.Exec("DELETE FROM history")
if result.Error != nil { if result.Error != nil {
return Response{Code: 1, Message: result.Error.Error()} return Response{Code: 1, Message: result.Error.Error()}
} }
if err := os.RemoveAll(publicImagePath); err != nil { if err := os.RemoveAll(staticImagesPath); err != nil {
return Response{Code: 1, Message: fmt.Sprintf("failed to remove images: %v", err)} return Response{Code: 1, Message: fmt.Sprintf("failed to remove images: %v", err)}
} }
if err := os.MkdirAll(publicImagePath, 0755); err != nil { if err := os.MkdirAll(staticImagesPath, 0755); err != nil {
return Response{Code: 1, Message: fmt.Sprintf("failed to recreate images dir: %v", err)} return Response{Code: 1, Message: fmt.Sprintf("failed to recreate images dir: %v", err)}
} }
+20 -12
View File
@@ -2,11 +2,17 @@ package backend
import ( import (
"context" "context"
ort "github.com/yalue/onnxruntime_go"
"gorm.io/gorm"
) )
type ( type (
App struct { App struct {
ctx context.Context ctx context.Context
db *gorm.DB
session *ort.AdvancedSession
labels []string
} }
Response struct { Response struct {
@@ -16,19 +22,21 @@ type (
} }
HistoryItem struct { HistoryItem struct {
Id uint `gorm:"primaryKey"` Id uint `gorm:"primaryKey"`
Img string `gorm:"column:img"` Img string `gorm:"column:img"`
Breed int `gorm:"column:breed"` Breed int `gorm:"column:breed"`
Date int `gorm:"column:date"` Confidence float64 `gorm:"column:confidence"`
Date int `gorm:"column:date"`
} }
HistoryWithBreed struct { HistoryWithBreed struct {
Id uint `gorm:"column:id" json:"id"` Id uint `gorm:"column:id" json:"id"`
Img string `gorm:"column:img" json:"img"` Img string `gorm:"column:img" json:"img"`
Breed int `gorm:"column:breed" json:"breed"` ImgData string `gorm:"-" json:"img_data"`
Date int `gorm:"column:date" json:"date"` Breed int `gorm:"column:breed" json:"breed"`
Name string `gorm:"column:name" json:"name"` Date int `gorm:"column:date" json:"date"`
Brief string `gorm:"column:brief" json:"brief"` Name string `gorm:"column:name" json:"name"`
Brief string `gorm:"column:brief" json:"brief"`
} }
HistoryData struct { HistoryData struct {
@@ -50,6 +58,6 @@ type (
Code string `json:"code"` Code string `json:"code"`
Name string `json:"name"` Name string `json:"name"`
Brief string `json:"brief"` Brief string `json:"brief"`
ConfidenceLevel float32 `json:"confidence_level"` ConfidenceLevel float64 `json:"confidence_level"`
} }
) )
+1 -44
View File
@@ -1,44 +1 @@
mode = "toy" from .const import *
"""
epoch 训练多少轮
lr 学习率
batch_size 一轮跑多少张图片
input_size 训练图片输入尺寸
label_name 分类
"""
if mode == "toy":
epoch = 15
lr = 2e-4
batch_size = 2
input_size = 224
# 分类
label_name = [
"american_shorthair",
"bengal",
"british_shorthair",
"exotic_shorthair",
"maine_coon",
"ragdoll",
"sphynx",
]
num_classes = len(label_name)
elif mode == "benchmark":
epoch = 15
lr = 2e-4
batch_size = 2
input_size = 224
# 分类
label_name = [
"american_shorthair",
"bengal",
"british_shorthair",
"exotic_shorthair",
"maine_coon",
"ragdoll",
"sphynx",
]
num_classes = len(label_name)
+50
View File
@@ -0,0 +1,50 @@
mode = "benchmark"
"""
epoch 训练多少轮
lr 学习率
batch_size 一轮跑多少张图片
input_size 训练图片输入尺寸
label_name 分类
"""
if mode == "toy":
epoch = 100
lr = 5e-4
batch_size = 8
input_size = 224
# 分类
label_name = [
"american_shorthair",
"bengal",
"british_shorthair",
"exotic_shorthair",
"maine_coon",
"ragdoll",
"sphynx",
]
num_classes = len(label_name)
elif mode == "benchmark":
epoch = 50 # resnet=80, resnet18=50, convnext=100
lr = 1e-4 # resnet, resnet18=1e-4
weight_decay = 1e-4 # resnet=1e-3, resnet18=1e-4
batch_size = 8
input_size = 224
# 分类
label_name = [
"american_shorthair", # 美国短毛猫
"british_shorthair", # 英国短毛猫
"ragdoll", # 布偶猫
"exotic_shorthair", # 异国短毛猫
"maine_coon", # 缅因猫
"siamese", # 暹罗猫
"sphynx", # 斯芬克斯猫
"turkish_van", # 土耳其梵猫
"bengal", # 孟加拉豹猫
"scottish_fold", # 苏格兰折耳猫
"none", # 风景人物
"other", # 其他动物
]
num_classes = len(label_name)
+8 -6
View File
@@ -3,7 +3,7 @@ import glob
from torchvision import transforms from torchvision import transforms
from torch.utils.data import DataLoader, Dataset from torch.utils.data import DataLoader, Dataset
from PIL import Image from PIL import Image
from core.const import label_name, input_size, batch_size from core.const import mode, label_name, input_size, batch_size
label_dict = {} label_dict = {}
@@ -17,13 +17,14 @@ def default_loader(path):
train_transform = transforms.Compose([ train_transform = transforms.Compose([
transforms.Resize((input_size)), transforms.Resize(input_size),
transforms.CenterCrop(input_size), transforms.CenterCrop(input_size),
transforms.RandomHorizontalFlip(p=0.5), # 50%的概率(p=0.5)水平翻转图片 transforms.RandomHorizontalFlip(p=0.5), # 50%的概率(p=0.5)水平翻转图片
transforms.RandomRotation(10), # 轻微旋转 transforms.RandomRotation(10), # 轻微旋转
transforms.ColorJitter(brightness=0.1, contrast=0.1), transforms.ColorJitter(brightness=0.1, contrast=0.1), # 随机亮度和对比度
transforms.ToTensor(), transforms.ToTensor(),
transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]),
# transforms.RandomErasing(p=0.5, scale=(0.02, 0.2), ratio=(0.3, 3.3)),
]) ])
@@ -62,8 +63,8 @@ class MyDataset(Dataset):
dataset_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) dataset_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
im_train_list = glob.glob(os.path.join(dataset_root, "dataset", "toy", "train", "*", "*.jpg")) im_train_list = glob.glob(os.path.join(dataset_root, "dataset", mode, "train", "*", "*.jpg"))
im_test_list = glob.glob(os.path.join(dataset_root, "dataset", "toy", "test", "*", "*.jpg")) im_test_list = glob.glob(os.path.join(dataset_root, "dataset", mode, "test", "*", "*.jpg"))
train_dataset = MyDataset(im_train_list, transform=train_transform) train_dataset = MyDataset(im_train_list, transform=train_transform)
@@ -76,3 +77,4 @@ print("test_dataset", len(test_dataset))
train_dataloader = DataLoader(dataset=train_dataset, batch_size=batch_size, shuffle=True, num_workers=4) train_dataloader = DataLoader(dataset=train_dataset, batch_size=batch_size, shuffle=True, num_workers=4)
test_dataloader = DataLoader(dataset=test_dataset, batch_size=batch_size, shuffle=False, num_workers=4) test_dataloader = DataLoader(dataset=test_dataset, batch_size=batch_size, shuffle=False, num_workers=4)
+19
View File
@@ -0,0 +1,19 @@
import torch.nn as nn
from torchvision import models
from core.const.const import num_classes
class ConvNeXtTiny(nn.Module):
def __init__(self):
super(ConvNeXtTiny, self).__init__()
self.model = models.convnext_tiny(weights='IMAGENET1K_V1')
self.num_features = self.model.classifier[2].in_features
self.model.classifier[2] = nn.Linear(self.num_features, num_classes)
def forward(self, x):
out = self.model(x)
return out
def pytorch_convnext_tiny():
return ConvNeXtTiny()
-67
View File
@@ -1,67 +0,0 @@
class,image_count,avg_width,avg_height,min_width,min_height,max_width,max_height,formats,corrupt_files
abyssinian,200,148,117,88,46,162,140,"jpeg, png",0
cyprus,200,159,100,93,40,162,140,"jpeg, png",0
lykoi,200,142,122,68,55,162,140,"jpeg, png",0
donskoy,200,141,123,67,54,162,140,jpeg,0
chausie,200,143,119,78,46,162,140,"jpeg, png",0
european_shorthair,200,149,118,70,49,162,140,"jpeg, png",0
turkish_van,200,149,120,87,56,162,140,"jpeg, png",0
pixie_bob,200,147,119,78,49,162,140,"jpeg, png",0
ragdoll,199,149,116,78,50,162,140,jpeg,0
german_rex,199,138,125,72,50,162,140,"jpeg, png",0
american_shorthair,199,147,114,65,50,162,140,"jpeg, png",0
sokoke,199,145,122,70,49,162,140,"jpeg, png",0
khao_manee,198,144,120,78,49,162,140,"jpeg, png",0
thai,198,143,118,68,60,162,140,"jpeg, png",0
cymric,198,148,120,63,43,162,140,"jpeg, png",0
oriental_shorthair,197,141,123,78,54,162,140,jpeg,0
cornish_rex,197,147,117,75,56,162,140,"jpeg, png",0
burmese,197,147,117,45,58,162,140,"jpeg, png",0
savannah,197,153,106,78,40,162,140,"jpeg, png",0
american_wirehair,196,149,117,76,50,162,140,"jpeg, png",0
peterbald,196,144,123,77,50,162,140,"jpeg, png",0
karelian_bobtail,196,145,125,67,65,162,140,"jpeg, png",0
tonkinese,195,148,116,70,53,162,140,"jpeg, png",0
balinese,195,154,112,79,54,162,140,jpeg,0
japanese_bobtail,194,149,118,87,49,162,140,"jpeg, png",0
nebelung,194,143,119,64,46,162,140,jpeg,0
selkirk_rex,192,144,122,78,76,162,140,jpeg,0
persian,192,150,114,78,46,162,140,jpeg,0
manx,192,155,113,86,50,162,140,"jpeg, png",0
himalayan,192,158,109,93,40,162,140,jpeg,0
munchkin,191,147,117,61,48,162,140,jpeg,0
bengal,189,151,115,78,71,162,140,jpeg,0
turkish_angora,188,145,119,66,52,162,140,jpeg,0
vankedisi,187,147,116,63,47,162,140,"jpeg, png",0
scottish_fold,184,143,120,78,50,162,140,jpeg,0
egyptian_mau,184,144,121,72,74,162,140,"jpeg, png",0
ocicat,182,150,115,62,44,162,140,"jpeg, png",0
ragamuffin,182,149,116,78,44,162,140,"jpeg, png",0
serengeti,175,159,100,78,53,300,140,"jpeg, png",0
british_shorthair,174,148,117,78,50,162,140,jpeg,0
toyger,160,145,120,78,50,162,140,"jpeg, png",0
siberian,159,153,116,78,55,162,140,jpeg,0
havana_brown,159,133,127,75,34,300,140,"jpeg, png",0
exotic_shorthair,157,148,118,93,55,300,140,"jpeg, png",0
bombay,154,139,123,46,50,162,140,"jpeg, png",0
korat,152,147,117,93,50,162,140,"jpeg, png",0
safari,150,158,105,79,38,300,140,"jpeg, png",0
american_bobtail,140,155,114,64,48,162,140,jpeg,0
mekong_bobtail,140,149,118,44,50,162,140,"jpeg, png",0
korean_bobtail,139,140,129,63,81,162,140,jpeg,0
siamese,139,152,115,78,53,300,140,"jpeg, png",0
somali,139,150,112,61,53,162,140,jpeg,0
devon_rex,138,150,116,78,55,162,140,jpeg,0
american_curl,138,155,112,91,51,300,140,"jpeg, png",0
ural_rex,137,144,121,48,65,162,140,"jpeg, png",0
singapura,136,158,109,93,54,300,140,"jpeg, png",0
ukrainian_levkoy,134,140,123,78,59,162,140,jpeg,0
maine_coon,133,141,122,66,79,162,140,jpeg,0
birman,131,155,113,93,56,162,140,jpeg,0
oregon_rex,121,147,123,85,66,300,140,"jpeg, png",0
kurilian_bobtail,120,149,121,78,81,162,140,jpeg,0
laperm,120,149,116,78,55,162,140,"jpeg, png",0
sphynx,120,149,117,47,63,162,140,jpeg,0
chartreux,114,146,117,75,61,162,140,jpeg,0
russian_blue,109,152,111,88,36,162,140,jpeg,0
norwegian_forest_cat,97,150,114,78,56,162,140,jpeg,0
1 class image_count avg_width avg_height min_width min_height max_width max_height formats corrupt_files
2 abyssinian 200 148 117 88 46 162 140 jpeg, png 0
3 cyprus 200 159 100 93 40 162 140 jpeg, png 0
4 lykoi 200 142 122 68 55 162 140 jpeg, png 0
5 donskoy 200 141 123 67 54 162 140 jpeg 0
6 chausie 200 143 119 78 46 162 140 jpeg, png 0
7 european_shorthair 200 149 118 70 49 162 140 jpeg, png 0
8 turkish_van 200 149 120 87 56 162 140 jpeg, png 0
9 pixie_bob 200 147 119 78 49 162 140 jpeg, png 0
10 ragdoll 199 149 116 78 50 162 140 jpeg 0
11 german_rex 199 138 125 72 50 162 140 jpeg, png 0
12 american_shorthair 199 147 114 65 50 162 140 jpeg, png 0
13 sokoke 199 145 122 70 49 162 140 jpeg, png 0
14 khao_manee 198 144 120 78 49 162 140 jpeg, png 0
15 thai 198 143 118 68 60 162 140 jpeg, png 0
16 cymric 198 148 120 63 43 162 140 jpeg, png 0
17 oriental_shorthair 197 141 123 78 54 162 140 jpeg 0
18 cornish_rex 197 147 117 75 56 162 140 jpeg, png 0
19 burmese 197 147 117 45 58 162 140 jpeg, png 0
20 savannah 197 153 106 78 40 162 140 jpeg, png 0
21 american_wirehair 196 149 117 76 50 162 140 jpeg, png 0
22 peterbald 196 144 123 77 50 162 140 jpeg, png 0
23 karelian_bobtail 196 145 125 67 65 162 140 jpeg, png 0
24 tonkinese 195 148 116 70 53 162 140 jpeg, png 0
25 balinese 195 154 112 79 54 162 140 jpeg 0
26 japanese_bobtail 194 149 118 87 49 162 140 jpeg, png 0
27 nebelung 194 143 119 64 46 162 140 jpeg 0
28 selkirk_rex 192 144 122 78 76 162 140 jpeg 0
29 persian 192 150 114 78 46 162 140 jpeg 0
30 manx 192 155 113 86 50 162 140 jpeg, png 0
31 himalayan 192 158 109 93 40 162 140 jpeg 0
32 munchkin 191 147 117 61 48 162 140 jpeg 0
33 bengal 189 151 115 78 71 162 140 jpeg 0
34 turkish_angora 188 145 119 66 52 162 140 jpeg 0
35 vankedisi 187 147 116 63 47 162 140 jpeg, png 0
36 scottish_fold 184 143 120 78 50 162 140 jpeg 0
37 egyptian_mau 184 144 121 72 74 162 140 jpeg, png 0
38 ocicat 182 150 115 62 44 162 140 jpeg, png 0
39 ragamuffin 182 149 116 78 44 162 140 jpeg, png 0
40 serengeti 175 159 100 78 53 300 140 jpeg, png 0
41 british_shorthair 174 148 117 78 50 162 140 jpeg 0
42 toyger 160 145 120 78 50 162 140 jpeg, png 0
43 siberian 159 153 116 78 55 162 140 jpeg 0
44 havana_brown 159 133 127 75 34 300 140 jpeg, png 0
45 exotic_shorthair 157 148 118 93 55 300 140 jpeg, png 0
46 bombay 154 139 123 46 50 162 140 jpeg, png 0
47 korat 152 147 117 93 50 162 140 jpeg, png 0
48 safari 150 158 105 79 38 300 140 jpeg, png 0
49 american_bobtail 140 155 114 64 48 162 140 jpeg 0
50 mekong_bobtail 140 149 118 44 50 162 140 jpeg, png 0
51 korean_bobtail 139 140 129 63 81 162 140 jpeg 0
52 siamese 139 152 115 78 53 300 140 jpeg, png 0
53 somali 139 150 112 61 53 162 140 jpeg 0
54 devon_rex 138 150 116 78 55 162 140 jpeg 0
55 american_curl 138 155 112 91 51 300 140 jpeg, png 0
56 ural_rex 137 144 121 48 65 162 140 jpeg, png 0
57 singapura 136 158 109 93 54 300 140 jpeg, png 0
58 ukrainian_levkoy 134 140 123 78 59 162 140 jpeg 0
59 maine_coon 133 141 122 66 79 162 140 jpeg 0
60 birman 131 155 113 93 56 162 140 jpeg 0
61 oregon_rex 121 147 123 85 66 300 140 jpeg, png 0
62 kurilian_bobtail 120 149 121 78 81 162 140 jpeg 0
63 laperm 120 149 116 78 55 162 140 jpeg, png 0
64 sphynx 120 149 117 47 63 162 140 jpeg 0
65 chartreux 114 146 117 75 61 162 140 jpeg 0
66 russian_blue 109 152 111 88 36 162 140 jpeg 0
67 norwegian_forest_cat 97 150 114 78 56 162 140 jpeg 0
+59
View File
@@ -0,0 +1,59 @@
import os
import cv2
import glob
import torch
from torchvision import transforms
from PIL import Image
import numpy as np
from core.nets.convnext_tiny import pytorch_convnext_tiny
from core.const import mode, label_name, input_size
def test():
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(device)
script_dir = os.path.dirname(os.path.abspath(__file__))
core_dir = os.path.dirname(script_dir)
model_dir = os.path.join(core_dir, "models")
dataset_dir = os.path.join(core_dir, "dataset", mode, "test")
print("model_dir", model_dir)
net = pytorch_convnext_tiny()
net.load_state_dict(torch.load(os.path.join(model_dir, "convnext_tiny_epoch_100.pth"), weights_only=True))
im_list = glob.glob(os.path.join(dataset_dir, "*", "*.jpg"))
np.random.shuffle(im_list)
net.to(device)
test_transform = transforms.Compose([
transforms.Resize(input_size),
transforms.CenterCrop(input_size),
transforms.ToTensor(),
transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
])
for im_path in im_list:
net.eval()
im_data = Image.open(im_path)
inputs = test_transform(im_data)
inputs = torch.unsqueeze(inputs, dim=0)
inputs = inputs.to(device)
outputs = net.forward(inputs)
_, pred = torch.max(outputs.data, dim=1)
print(label_name[pred.cpu().numpy()[0]], " ", im_path)
img = np.asarray(im_data)
img = cv2.cvtColor(img, cv2.COLOR_RGB2BGR)
img = cv2.resize(img, (200, 200))
cv2.imshow("img", img)
cv2.waitKey(0)
if __name__ == "__main__":
test()
+64
View File
@@ -0,0 +1,64 @@
import os
import cv2
import glob
import torch
import numpy as np
from PIL import Image
import onnxruntime as ort
from torchvision import transforms
from core.const import mode, label_name, input_size
def test():
providers = ['CUDAExecutionProvider', 'CPUExecutionProvider'] if 'CUDAExecutionProvider' in ort.get_available_providers() else ['CPUExecutionProvider']
print(f"使用设备: {providers[0]}")
script_dir = os.path.dirname(os.path.abspath(__file__))
core_dir = os.path.dirname(script_dir)
model_dir = os.path.join(core_dir, "models")
dataset_dir = os.path.join(core_dir, "dataset", mode, "test")
# 加载ONNX模型
session = ort.InferenceSession(os.path.join(model_dir, "resnet_epoch_100.onnx"), providers=providers)
# 获取输入名称(用于后续推理时指定输入)
input_name = session.get_inputs()[0].name
im_list = glob.glob(os.path.join(dataset_dir, "*", "*.jpg"))
np.random.shuffle(im_list)
# 预处理
test_transform = transforms.Compose([
transforms.Resize(input_size),
transforms.CenterCrop(input_size),
transforms.ToTensor(),
transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
])
for im_path in im_list:
im_data = Image.open(im_path)
inputs = test_transform(im_data)
inputs = torch.unsqueeze(inputs, dim=0) # 这里还在用torch,下面会改
# 将输入转为numpyONNX Runtime需要numpy输入
inputs = inputs.numpy()
if providers[0] == 'CPUExecutionProvider':
inputs = inputs.astype(np.float32)
# ONNX Runtime推理,输出直接是numpy数组
outputs = session.run(None, {input_name: inputs})[0]
# 解析结果,直接用numpy操作
pred = np.argmax(outputs, axis=1)
print(label_name[pred[0]], " ", im_path)
img = np.asarray(im_data)
img = cv2.cvtColor(img, cv2.COLOR_RGB2BGR)
img = cv2.resize(img, (200, int(img.shape[0] * 200 / img.shape[1])))
cv2.imshow("img", img)
cv2.waitKey(0)
if __name__ == "__main__":
test()
+28 -8
View File
@@ -1,3 +1,4 @@
import os
import cv2 import cv2
import glob import glob
import torch import torch
@@ -5,21 +6,32 @@ from torchvision import transforms
from PIL import Image from PIL import Image
import numpy as np import numpy as np
from core.nets.resnet import resnet from core.nets.resnet import resnet
from core.const import label_name, input_size from core.const import mode, label_name, input_size
def test(): def test():
device = torch.device("cuda" if torch.cuda.is_available() else "cpu") device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(device) print(device)
net = resnet() script_dir = os.path.dirname(os.path.abspath(__file__))
net.load_state_dict(torch.load("./model/resnet_epoch_15.pth", weights_only=True)) core_dir = os.path.dirname(script_dir)
model_dir = os.path.join(core_dir, "models")
dataset_dir = os.path.join(core_dir, "dataset", mode, "test")
im_list = glob.glob("./dataset/test/*/*.jpg") print("model_dir", model_dir)
net = resnet()
net.load_state_dict(torch.load(os.path.join(model_dir, "resnet_epoch_80.pth"), weights_only=True))
print("111")
im_list = glob.glob(os.path.join(dataset_dir, "*", "*.jpg"))
np.random.shuffle(im_list) np.random.shuffle(im_list)
net.to(device) net.to(device)
print("222")
test_transform = transforms.Compose([ test_transform = transforms.Compose([
transforms.Resize(input_size), transforms.Resize(input_size),
transforms.CenterCrop(input_size), transforms.CenterCrop(input_size),
@@ -27,7 +39,11 @@ def test():
transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
]) ])
print("333")
ary = []
for im_path in im_list: for im_path in im_list:
# print("im_path", im_path)
net.eval() net.eval()
im_data = Image.open(im_path) im_data = Image.open(im_path)
@@ -40,11 +56,15 @@ def test():
_, pred = torch.max(outputs.data, dim=1) _, pred = torch.max(outputs.data, dim=1)
print(label_name[pred.cpu().numpy()[0]], " ", im_path) print(label_name[pred.cpu().numpy()[0]], " ", im_path)
img = np.asarray(im_data) result = label_name[pred.cpu().numpy()[0]]
img = cv2.cvtColor(img, cv2.COLOR_RGB2BGR)
cv2.imshow("img", img)
cv2.waitKey(0)
if result in im_path:
ary.append(True)
else:
ary.append(False)
print("ary", ary)
print(sum(ary) / len(ary))
if __name__ == "__main__": if __name__ == "__main__":
test() test()
+29 -13
View File
@@ -1,3 +1,4 @@
import os
import cv2 import cv2
import glob import glob
import torch import torch
@@ -5,17 +6,22 @@ from torchvision import transforms
from PIL import Image from PIL import Image
import numpy as np import numpy as np
from core.nets.resnet18 import resnet18 from core.nets.resnet18 import resnet18
from core.const import label_name, input_size from core.const import mode, label_name, input_size
def test(): def test():
device = torch.device("cuda" if torch.cuda.is_available() else "cpu") device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(device) print(device)
net = resnet18() script_dir = os.path.dirname(os.path.abspath(__file__))
net.load_state_dict(torch.load("./model/resnet_epoch_14.pth", weights_only=True)) core_dir = os.path.dirname(script_dir)
model_dir = os.path.join(core_dir, "models")
dataset_dir = os.path.join(core_dir, "dataset", mode, "test")
im_list = glob.glob("./dataset/test/*/*.jpg") net = resnet18()
net.load_state_dict(torch.load(os.path.join(model_dir, "resnet18_epoch_50.pth"), weights_only=True))
im_list = glob.glob(os.path.join(dataset_dir, "*", "*.jpg"))
np.random.shuffle(im_list) np.random.shuffle(im_list)
net.to(device) net.to(device)
@@ -27,6 +33,8 @@ def test():
transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
]) ])
ary = []
errors = []
for im_path in im_list: for im_path in im_list:
net.eval() net.eval()
im_data = Image.open(im_path) im_data = Image.open(im_path)
@@ -36,19 +44,27 @@ def test():
inputs = inputs.to(device) inputs = inputs.to(device)
outputs = net.forward(inputs) outputs = net.forward(inputs)
# print("outputs", outputs)
_, pred = torch.max(outputs.data, dim=1) _, pred = torch.max(outputs.data, dim=1)
print(label_name[pred.cpu().numpy()[0]], " ", im_path) # print(label_name[pred.cpu().numpy()[0]], " ", im_path)
# prob, pred = torch.topk(outputs.data, k=3, dim=1) # img = np.asarray(im_data)
# for i in range(3): # img = cv2.cvtColor(img, cv2.COLOR_RGB2BGR)
# print(label_name[pred[0, i].item()], " ", prob[0, i].item(), " ", im_path) # img = cv2.resize(img, (200, 200))
# cv2.imshow("img", img)
# cv2.waitKey(0)
result = label_name[pred.cpu().numpy()[0]]
img = np.asarray(im_data) if result in im_path:
img = cv2.cvtColor(img, cv2.COLOR_RGB2BGR) ary.append(True)
cv2.imshow("img", img) else:
cv2.waitKey(0) ary.append(False)
print(f"{label_name[pred.cpu().numpy()[0]]} {im_path}\n")
errors.append(f"{label_name[pred.cpu().numpy()[0]]} {im_path}")
print("ary", ary)
# print("errors", errors)
print(sum(ary) / len(ary))
if __name__ == "__main__": if __name__ == "__main__":
+29
View File
@@ -0,0 +1,29 @@
import os
import torch
from core.nets.resnet18 import resnet18
# 加载 pth
net = resnet18()
script_dir = os.path.dirname(os.path.abspath(__file__))
core_dir = os.path.dirname(script_dir)
model_dir = os.path.join(core_dir, "models")
net.load_state_dict(torch.load(os.path.join(model_dir, "resnet18_epoch_50.pth"), map_location="cpu"))
net.eval()
# 导出 ONNX
dummy_input = torch.randn(1, 3, 224, 224)
torch.onnx.export(
net,
dummy_input,
os.path.join(model_dir, "resnet18_epoch_50.onnx"),
export_params=True,
opset_version=11,
input_names=["input"],
output_names=["output"],
dynamic_axes={"input": {0: "batch_size"}, "output": {0: "batch_size"}}
)
+53
View File
@@ -0,0 +1,53 @@
import os
import torch
from core.nets.convnext_tiny import pytorch_convnext_tiny
from core.dataloader.dataloader import train_dataloader
from core.const import epoch, lr, batch_size
def train():
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print("device: ", device)
net = pytorch_convnext_tiny().to(device)
loss_func = torch.nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(net.parameters(), lr=lr)
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=20, eta_min=1e-6)
for e in range(epoch):
print("epoch: ", e)
net.train()
for i, data in enumerate(train_dataloader):
inputs, labels = data
inputs, labels = inputs.to(device), labels.to(device)
outputs = net(inputs)
loss = loss_func(outputs, labels)
optimizer.zero_grad()
loss.backward()
optimizer.step()
_, pred = torch.max(outputs, dim=1)
correct = pred.eq(labels.data).cpu().sum()
print("step: ", i, "loss: ", loss.item(), "correct: ", 1.0 * correct / batch_size)
scheduler.step()
print("lr: ", optimizer.state_dict()['param_groups'][0]['lr'])
script_dir = os.path.dirname(os.path.abspath(__file__))
model_dir = os.path.join(script_dir, "..", "models")
if not os.path.exists(model_dir):
os.makedirs(model_dir)
torch.save(net.state_dict(), os.path.join(model_dir, "convnext_tiny_epoch_{}.pth".format(e + 1)))
if __name__ == "__main__":
train()
+8 -6
View File
@@ -2,7 +2,7 @@ import os
import torch import torch
from core.nets.resnet import resnet from core.nets.resnet import resnet
from core.dataloader.dataloader import train_dataloader from core.dataloader.dataloader import train_dataloader
from core.const import epoch, lr, batch_size from core.const import epoch, lr, weight_decay, batch_size
def train(): def train():
@@ -13,9 +13,9 @@ def train():
loss_func = torch.nn.CrossEntropyLoss() loss_func = torch.nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(net.parameters(), lr=lr) optimizer = torch.optim.Adam(net.parameters(), lr=lr, weight_decay=weight_decay)
scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=5, gamma=0.5) scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=20, gamma=0.5)
for e in range(epoch): for e in range(epoch):
print("epoch: ", e) print("epoch: ", e)
@@ -41,10 +41,12 @@ def train():
scheduler.step() scheduler.step()
print("lr: ", optimizer.state_dict()['param_groups'][0]['lr']) print("lr: ", optimizer.state_dict()['param_groups'][0]['lr'])
if not os.path.exists("./model"): script_dir = os.path.dirname(os.path.abspath(__file__))
os.makedirs("./model") model_dir = os.path.join(script_dir, "..", "models")
if not os.path.exists(model_dir):
os.makedirs(model_dir)
torch.save(net.state_dict(), "./model/resnet_epoch_{}.pth".format(e + 1)) torch.save(net.state_dict(), os.path.join(model_dir, "resnet_epoch_{}.pth".format(e + 1)))
if __name__ == "__main__": if __name__ == "__main__":
+7 -5
View File
@@ -2,7 +2,7 @@ import os
import torch import torch
from core.nets.resnet18 import resnet18 from core.nets.resnet18 import resnet18
from core.dataloader.dataloader import train_dataloader from core.dataloader.dataloader import train_dataloader
from core.const import epoch, lr, batch_size from core.const import epoch, lr, weight_decay, batch_size
def train(): def train():
@@ -13,7 +13,7 @@ def train():
loss_func = torch.nn.CrossEntropyLoss() loss_func = torch.nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(net.parameters(), lr=lr) optimizer = torch.optim.Adam(net.parameters(), lr=lr, weight_decay=weight_decay)
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=20, eta_min=1e-6) # 余弦退火 scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=20, eta_min=1e-6) # 余弦退火
@@ -41,10 +41,12 @@ def train():
scheduler.step() scheduler.step()
print("lr: ", optimizer.state_dict()['param_groups'][0]['lr']) print("lr: ", optimizer.state_dict()['param_groups'][0]['lr'])
if not os.path.exists("./models"): script_dir = os.path.dirname(os.path.abspath(__file__))
os.makedirs("./models") model_dir = os.path.join(script_dir, "..", "models")
if not os.path.exists(model_dir):
os.makedirs(model_dir)
torch.save(net.state_dict(), "./models/resnet18_epoch_{}.pth".format(e + 1)) torch.save(net.state_dict(), os.path.join(model_dir, "resnet18_epoch_{}.pth".format(e + 1)))
if __name__ == "__main__": if __name__ == "__main__":
+1 -1
View File
@@ -2,7 +2,7 @@ export const Footer = () => (
<footer class="app-footer"> <footer class="app-footer">
<span>📩 xiadongliang88@163.com</span> <span>📩 xiadongliang88@163.com</span>
<span>|</span> <span>|</span>
<span>📦 https://git.leonstack.com/owner</span> <span>📦 https://git.leonstack.com/xiadongliang</span>
<span>|</span> <span>|</span>
<span>🌏 https://www.leonstack.com/</span> <span>🌏 https://www.leonstack.com/</span>
</footer> </footer>
+14 -6
View File
@@ -1,10 +1,10 @@
import { useState, useEffect } from 'preact/hooks' import { useState, useEffect } from 'preact/hooks'
import Pagination from './Pagination' import Pagination from './Pagination'
import Modal from './Modal' import Modal from './Modal'
import ImagePreview from './ImagePreview'
import { message } from '../utils/toast' import { message } from '../utils/toast'
import type { HistoryItem } from '../preact' import type { HistoryItem } from '../preact'
const formatDate = (timestamp: number) => { const formatDate = (timestamp: number) => {
const date = new Date(timestamp * 1000) const date = new Date(timestamp * 1000)
return date.toLocaleString('zh-CN', { return date.toLocaleString('zh-CN', {
@@ -22,12 +22,13 @@ const History = () => {
const pageSize = 5 const pageSize = 5
const [total, setTotal] = useState<number>(50) const [total, setTotal] = useState<number>(50)
const [historyList, setHistoryList] = useState<HistoryItem[]>([]) const [historyList, setHistoryList] = useState<HistoryItem[]>([])
const [currentItem, setCurrentItem] = useState<HistoryItem>()
const [showDelete, setShowDelete] = useState<boolean>(false) const [showDelete, setShowDelete] = useState<boolean>(false)
const [currentId, setCurrentId] = useState<number | null>(null) const [currentId, setCurrentId] = useState<number | null>(null)
const [showClear, setShowClear] = useState<boolean>(false) const [showClear, setShowClear] = useState<boolean>(false)
useEffect(() => { useEffect(() => {
if (!(window as any).go?.main?.App?.GetHistory) { if (!(window as any).go?.backend?.App?.GetHistory) {
message.error('Wails runtime not ready') message.error('Wails runtime not ready')
return return
} }
@@ -35,7 +36,7 @@ const History = () => {
}, []) }, [])
const fetchData = async(page: number) => { const fetchData = async(page: number) => {
const result = await (window as any).go.main.App.GetHistory(page, pageSize) const result = await (window as any).go.backend.App.GetHistory(page, pageSize)
if (result.code === 0) { if (result.code === 0) {
setHistoryList(result.data.list) setHistoryList(result.data.list)
setTotal(result.data.total) setTotal(result.data.total)
@@ -49,6 +50,10 @@ const History = () => {
fetchData(page) fetchData(page)
} }
const handleShowItem = (item: HistoryItem) => setCurrentItem(item)
const handleItemClose = () => setCurrentItem(undefined)
const handleShowDelete = (id: number) => { const handleShowDelete = (id: number) => {
setCurrentId(id) setCurrentId(id)
setShowDelete(true) setShowDelete(true)
@@ -60,7 +65,7 @@ const History = () => {
message.error('currentId为空') message.error('currentId为空')
return return
} }
const result = await (window as any).go.main.App.DeleteOneHistory(currentId) const result = await (window as any).go.backend.App.DeleteOneHistory(currentId)
if (result.code === 0) { if (result.code === 0) {
message.success('删除成功') message.success('删除成功')
setCurrentId(null) setCurrentId(null)
@@ -79,7 +84,7 @@ const History = () => {
const handleClearOk = async() => { const handleClearOk = async() => {
setShowClear(false) setShowClear(false)
const result = await (window as any).go.main.App.ClearHistory() const result = await (window as any).go.backend.App.ClearHistory()
if (result.code === 0) { if (result.code === 0) {
message.success('已清空历史') message.success('已清空历史')
setCurrentId(null) setCurrentId(null)
@@ -104,7 +109,7 @@ const History = () => {
{historyList.map((item: HistoryItem) => {historyList.map((item: HistoryItem) =>
<div key={item.id} class="history-card"> <div key={item.id} class="history-card">
<div class="card-left"> <div class="card-left">
<ImagePreview filename={item.img} /> <img src={`data:image/jpeg;base64,${item.img_data}`} onClick={() => handleShowItem(item)} />
</div> </div>
<div class="card-right"> <div class="card-right">
<div class="right-top"> <div class="right-top">
@@ -142,6 +147,9 @@ const History = () => {
<Modal open={showClear} title="信息" onClick={handleClearOk} onClose={handleClearClose}> <Modal open={showClear} title="信息" onClick={handleClearOk} onClose={handleClearClose}>
<p></p> <p></p>
</Modal> </Modal>
<Modal open={!!currentItem} title={currentItem?.name || ''} onClose={handleItemClose}>
<p>{currentItem?.brief || ''}</p>
</Modal>
</> </>
) )
} }
-27
View File
@@ -1,27 +0,0 @@
import { useState, useEffect } from 'preact/hooks'
interface Props {
filename: string
class?: string
id?: string
}
const ImagePreview = ({ filename, ...props }: Props) => {
const [src, setSrc] = useState<string>('')
useEffect(() => {
const loadImage = async () => {
const result = await (window as any).go.main.App.GetImage(filename)
if (result.code === 0) {
setSrc(result.data)
}
}
loadImage()
}, [filename])
if (!src) return <div {...props} />
return <img src={src} {...props} />
}
export default ImagePreview
+17 -14
View File
@@ -1,11 +1,12 @@
import { useState, useRef } from 'preact/hooks' import { useState, useRef } from 'preact/hooks'
import type { DetectResult } from '../preact' import type { DetectResult } from '../preact'
import ImagePreview from './ImagePreview'
import { message } from '../utils/toast' import { message } from '../utils/toast'
const Main = () => { const Main = () => {
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const [fileSrc, setFileSrc] = useState<string>('') const [fileSrc, setFileSrc] = useState<string>('')
const [filename, setFilename] = useState<string>('')
const [step, setStep] = useState<number>(0) const [step, setStep] = useState<number>(0)
const [detectResult, setDetectResult] = useState<DetectResult | null>(null) const [detectResult, setDetectResult] = useState<DetectResult | null>(null)
@@ -47,7 +48,6 @@ const Main = () => {
} }
const handleFileChange = (e: Event) => { const handleFileChange = (e: Event) => {
console.log('handleFileChange')
const target = e.target as HTMLInputElement const target = e.target as HTMLInputElement
const file = target.files?.[0] const file = target.files?.[0]
if (file) { if (file) {
@@ -60,12 +60,13 @@ const Main = () => {
const arrayBuffer = await processedFile.arrayBuffer() const arrayBuffer = await processedFile.arrayBuffer()
const uint8Array = new Uint8Array(arrayBuffer) const uint8Array = new Uint8Array(arrayBuffer)
const result = await (window as any).go.main.App.UploadImage( const result = await (window as any).go.backend.App.UploadImage(
Array.from(uint8Array), Array.from(uint8Array),
file.name file.name
) )
if (result.code === 0) { if (result.code === 0) {
setFileSrc(result.data) setFileSrc(result.data.data)
setFilename(result.data.filename)
setStep(1) setStep(1)
} else if (result.code === 1) { } else if (result.code === 1) {
message.error(result.message) message.error(result.message)
@@ -92,14 +93,16 @@ const Main = () => {
setStep(2) setStep(2)
setTimeout(async() => { setTimeout(async() => {
const result = await (window as any).go.main.App.Detect(fileSrc) if (filename) {
if (result.code === 0) { const result = await (window as any).go.backend.App.Detect(filename)
setDetectResult(result.data) if (result.code === 0) {
setStep(3) setDetectResult(result.data)
} else if (result.code === 1) { setStep(3)
message.error(result.message) } else if (result.code === 1) {
message.error(result.message)
}
} }
}, 1000) }, 500)
} }
const handleTryOther = () => { const handleTryOther = () => {
@@ -133,7 +136,7 @@ const Main = () => {
> >
{fileSrc.length > 0 && step == 1 ? {fileSrc.length > 0 && step == 1 ?
<div id="previewContent"> <div id="previewContent">
<ImagePreview filename={fileSrc} id="previewImage" /> <img src={`data:image/jpeg;base64,${fileSrc}`} />
<button onClick={handleRemovePhoto}> <button onClick={handleRemovePhoto}>
</button> </button>
@@ -192,7 +195,7 @@ const Main = () => {
<h2></h2> <h2></h2>
</div> </div>
<div class="result-main"> <div class="result-main">
<ImagePreview filename={fileSrc} /> <img src={`data:image/jpeg;base64,${fileSrc}`} />
<div class="result-word"> <div class="result-word">
<div> <div>
<h3>{detectResult?.name}</h3> <h3>{detectResult?.name}</h3>
@@ -200,7 +203,7 @@ const Main = () => {
<div class="probability-bar"> <div class="probability-bar">
<div /> <div />
</div> </div>
<span> {detectResult ? detectResult.confidence_level * 100 + '%' : ''}</span> <span> {detectResult ? (detectResult.confidence_level * 100).toFixed(2) + '%' : ''}</span>
</div> </div>
</div> </div>
<p>{detectResult?.brief}</p> <p>{detectResult?.brief}</p>
+5 -8
View File
@@ -1,9 +1,8 @@
import type { ModalProps } from '../preact' import type { ModalProps } from '../preact'
const Modal = ({ open, title, onClick, onClose, children }: ModalProps) => { const Modal = ({ open, title, onClick, onClose, children }: ModalProps) => {
const handleClose = () => { const handleClose = () => onClose?.()
onClose?.()
}
const handleConfirm = () => { const handleConfirm = () => {
onClick?.() onClick?.()
@@ -11,9 +10,7 @@ const Modal = ({ open, title, onClick, onClose, children }: ModalProps) => {
} }
const handleMaskClick = (e: MouseEvent) => { const handleMaskClick = (e: MouseEvent) => {
if (e.target === e.currentTarget) { if (e.target === e.currentTarget) handleClose()
handleClose()
}
} }
return ( return (
@@ -27,8 +24,8 @@ const Modal = ({ open, title, onClick, onClose, children }: ModalProps) => {
</div> </div>
{children} {children}
<div class="bottom"> <div class="bottom">
<button onClick={handleClose}></button> <button class="close" onClick={handleClose}></button>
<button onClick={handleConfirm}></button> {onClick ? <button class="confirm" onClick={handleConfirm}></button> : null}
</div> </div>
</div> </div>
<div class="app-mask" onClick={handleMaskClick}></div> <div class="app-mask" onClick={handleMaskClick}></div>
-5
View File
@@ -12,11 +12,6 @@ const Pagination = ({
const [num, setNum] = useState<number>(1) const [num, setNum] = useState<number>(1)
const [seqNums, setSeqNums] = useState<[number, number][]>([]) const [seqNums, setSeqNums] = useState<[number, number][]>([])
useEffect(() => {
setValue('1')
setNum(1)
}, [])
useEffect(() => { useEffect(() => {
if (page) { if (page) {
setValue(page.toString()); setValue(page.toString());
+1
View File
@@ -1,6 +1,7 @@
export interface HistoryItem { export interface HistoryItem {
id: number id: number
img: string img: string
img_data: string
breed: number breed: number
date: number date: number
name: string name: string
+3 -4
View File
@@ -199,7 +199,7 @@ a
border-radius: 1.5rem border-radius: 1.5rem
border-width: 2px border-width: 2px
border-style: dashed border-style: dashed
border-color: #e2e8f0 border-color: #bdc3cb
text-align: center text-align: center
cursor: pointer cursor: pointer
@@ -305,7 +305,6 @@ a
display: flex display: flex
align-items: center align-items: center
justify-content: center justify-content: center
// height: 2.25rem
gap: 0.75rem gap: 0.75rem
margin: 0.5rem 0 1.5rem 0 margin: 0.5rem 0 1.5rem 0
@@ -474,12 +473,12 @@ a
font-size: 0.875rem font-size: 0.875rem
border-radius: 0.25rem border-radius: 0.25rem
&:first-child > button.close
color: var(--text) color: var(--text)
border: 1px solid #c9c9c9 border: 1px solid #c9c9c9
background-color: none background-color: none
&:last-child > button.confirm
color: white color: white
background-color: var(--primary) background-color: var(--primary)
+2 -6
View File
@@ -23,9 +23,7 @@ export const message: MessageAPI = {
div.appendChild(subDiv) div.appendChild(subDiv)
document.body.appendChild(div) document.body.appendChild(div)
setTimeout(() => { setTimeout(() => div.remove(), 3000)
div.remove()
}, 3000)
}, },
error: (text: string) => { error: (text: string) => {
const div = document.createElement('div') const div = document.createElement('div')
@@ -46,8 +44,6 @@ export const message: MessageAPI = {
div.appendChild(subDiv) div.appendChild(subDiv)
document.body.appendChild(div) document.body.appendChild(div)
setTimeout(() => { setTimeout(() => div.remove(), 3000)
div.remove()
}, 3000)
} }
} }
+2 -2
View File
@@ -11,8 +11,8 @@ export function Detect(arg1:string):Promise<backend.Response>;
export function GetHistory(arg1:number,arg2:number):Promise<backend.Response>; export function GetHistory(arg1:number,arg2:number):Promise<backend.Response>;
export function GetImage(arg1:string):Promise<backend.Response>;
export function GormDB():Promise<gorm.DB>; export function GormDB():Promise<gorm.DB>;
export function Shutdown():Promise<void>;
export function UploadImage(arg1:Array<number>,arg2:string):Promise<backend.Response>; export function UploadImage(arg1:Array<number>,arg2:string):Promise<backend.Response>;
+4 -4
View File
@@ -18,14 +18,14 @@ export function GetHistory(arg1, arg2) {
return window['go']['backend']['App']['GetHistory'](arg1, arg2); return window['go']['backend']['App']['GetHistory'](arg1, arg2);
} }
export function GetImage(arg1) {
return window['go']['backend']['App']['GetImage'](arg1);
}
export function GormDB() { export function GormDB() {
return window['go']['backend']['App']['GormDB'](); return window['go']['backend']['App']['GormDB']();
} }
export function Shutdown() {
return window['go']['backend']['App']['Shutdown']();
}
export function UploadImage(arg1, arg2) { export function UploadImage(arg1, arg2) {
return window['go']['backend']['App']['UploadImage'](arg1, arg2); return window['go']['backend']['App']['UploadImage'](arg1, arg2);
} }
+3
View File
@@ -3,7 +3,9 @@ module sortmeow
go 1.25.0 go 1.25.0
require ( require (
github.com/disintegration/imaging v1.6.2
github.com/wailsapp/wails/v2 v2.13.0 github.com/wailsapp/wails/v2 v2.13.0
github.com/yalue/onnxruntime_go v1.31.0
gorm.io/driver/sqlite v1.6.0 gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.2 gorm.io/gorm v1.31.2
) )
@@ -37,6 +39,7 @@ require (
github.com/wailsapp/go-webview2 v1.0.22 // indirect github.com/wailsapp/go-webview2 v1.0.22 // indirect
github.com/wailsapp/mimetype v1.4.1 // indirect github.com/wailsapp/mimetype v1.4.1 // indirect
golang.org/x/crypto v0.51.0 // indirect golang.org/x/crypto v0.51.0 // indirect
golang.org/x/image v0.40.0 // indirect
golang.org/x/net v0.54.0 // indirect golang.org/x/net v0.54.0 // indirect
golang.org/x/sys v0.46.0 // indirect golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.40.0 // indirect golang.org/x/text v0.40.0 // indirect
+8
View File
@@ -4,6 +4,8 @@ github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
@@ -67,8 +69,13 @@ github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhw
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o= github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
github.com/wailsapp/wails/v2 v2.13.0 h1:S7OgXWpj72V91unF8iDWJKbcS9ZpwCT3R0QVru4v2Mg= github.com/wailsapp/wails/v2 v2.13.0 h1:S7OgXWpj72V91unF8iDWJKbcS9ZpwCT3R0QVru4v2Mg=
github.com/wailsapp/wails/v2 v2.13.0/go.mod h1:nVr/wSIEZ7xxKPkzK65mjpKpaOPQI2k4pvLwGR/i4kc= github.com/wailsapp/wails/v2 v2.13.0/go.mod h1:nVr/wSIEZ7xxKPkzK65mjpKpaOPQI2k4pvLwGR/i4kc=
github.com/yalue/onnxruntime_go v1.31.0 h1:1ln4YW1SFOFfGJZXe3jNOb2JUSt+l2pEneZfV8HdtFA=
github.com/yalue/onnxruntime_go v1.31.0/go.mod h1:b4X26A8pekNb1ACJ58wAXgNKeUCGEAQ9dmACut9Sm/4=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.40.0 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8=
golang.org/x/image v0.40.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
@@ -81,6 +88,7 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

+316
View File
@@ -0,0 +1,316 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>分类喵 - 项目原型图</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f0f0f0;
min-height: 100vh;
padding: 40px 20px;
}
.header {
text-align: center;
margin-bottom: 30px;
}
.header h1 {
font-size: 24px;
color: #333;
margin-bottom: 8px;
}
.header p {
color: #666;
font-size: 13px;
}
</style>
</head>
<body>
<div class="header">
<h1>分类喵 - 项目原型图</h1>
<p>基于深度残差网络的猫咪图片分类应用 | 桌面端</p>
</div>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1400 650" width="1400" height="650">
<defs>
<marker id="arrow" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#999"/>
</marker>
</defs>
<!-- 页面1: 首页 -->
<g id="page1" transform="translate(30, 20)">
<!-- 窗口标题栏 -->
<rect x="0" y="0" width="400" height="30" rx="6" fill="#e0e0e0"/>
<circle cx="15" cy="15" r="5" fill="#fff" stroke="#ccc" stroke-width="1"/>
<circle cx="32" cy="15" r="5" fill="#fff" stroke="#ccc" stroke-width="1"/>
<circle cx="49" cy="15" r="5" fill="#fff" stroke="#ccc" stroke-width="1"/>
<text x="200" y="20" font-size="11" fill="#666" text-anchor="middle">分类喵 - 桌面应用</text>
<!-- 导航栏 -->
<rect x="0" y="30" width="400" height="50" fill="#fff" stroke="#ccc" stroke-width="1"/>
<text x="20" y="60" font-size="14" font-weight="bold">分类喵</text>
<line x1="100" y1="40" x2="100" y2="70" stroke="#ddd" stroke-width="1"/>
<text x="120" y="60" font-size="12" fill="#333">主页</text>
<text x="175" y="60" font-size="12" fill="#999">历史记录</text>
<rect x="320" y="42" width="60" height="26" rx="4" fill="#f5f5f5" stroke="#ddd"/>
<text x="350" y="59" font-size="10" fill="#666" text-anchor="middle">查看代码</text>
<!-- 主内容区 -->
<rect x="0" y="80" width="400" height="520" fill="#fafafa"/>
<!-- 标题 -->
<text x="40" y="130" font-size="20" fill="#333" font-weight="bold">发现<tspan fill="#666"></tspan><tspan fill="#666">分类</tspan></text>
<text x="40" y="155" font-size="11" fill="#888">上传一张猫咪照片,让AI认出它是什么</text>
<text x="40" y="175" font-size="10" fill="#aaa">⚡ 基于深度残差网络</text>
<!-- 上传区域 -->
<rect x="40" y="200" width="320" height="200" rx="8" fill="#fff" stroke="#ccc" stroke-width="1.5" stroke-dasharray="6 3"/>
<!-- 上传图标 -->
<g transform="translate(200, 270)">
<circle cx="0" cy="0" r="35" fill="none" stroke="#ccc" stroke-width="1.5"/>
<path d="M-12 5 L0 -10 L12 5 M-8 15 L0 2 L8 15" stroke="#999" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<text x="200" y="330" font-size="12" fill="#666" text-anchor="middle">点击或拖拽上传图片</text>
<text x="200" y="350" font-size="10" fill="#aaa" text-anchor="middle">支持 JPG、PNG、JPEG 格式</text>
<!-- 按钮 -->
<rect x="40" y="420" width="320" height="40" rx="6" fill="none" stroke="#999" stroke-width="1.5"/>
<text x="200" y="445" font-size="12" fill="#666" text-anchor="middle">🔍 开始检测</text>
<!-- 页面标签 -->
<text x="200" y="620" font-size="11" fill="#666" text-anchor="middle" font-weight="bold">图1: 首页</text>
</g>
<!-- 页面2: 预览状态 -->
<g id="page2" transform="translate(470, 20)">
<rect x="0" y="0" width="400" height="30" rx="6" fill="#e0e0e0"/>
<circle cx="15" cy="15" r="5" fill="#fff" stroke="#ccc" stroke-width="1"/>
<circle cx="32" cy="15" r="5" fill="#fff" stroke="#ccc" stroke-width="1"/>
<circle cx="49" cy="15" r="5" fill="#fff" stroke="#ccc" stroke-width="1"/>
<text x="200" y="20" font-size="11" fill="#666" text-anchor="middle">分类喵 - 桌面应用</text>
<rect x="0" y="30" width="400" height="50" fill="#fff" stroke="#ccc" stroke-width="1"/>
<text x="20" y="60" font-size="14" font-weight="bold">分类喵</text>
<line x1="100" y1="40" x2="100" y2="70" stroke="#ddd" stroke-width="1"/>
<text x="120" y="60" font-size="12" fill="#333">主页</text>
<text x="175" y="60" font-size="12" fill="#999">历史记录</text>
<rect x="320" y="42" width="60" height="26" rx="4" fill="#f5f5f5" stroke="#ddd"/>
<text x="350" y="59" font-size="10" fill="#666" text-anchor="middle">查看代码</text>
<rect x="0" y="80" width="400" height="520" fill="#fafafa"/>
<text x="40" y="130" font-size="20" fill="#333" font-weight="bold">发现<tspan fill="#666"></tspan><tspan fill="#666">分类</tspan></text>
<text x="40" y="155" font-size="11" fill="#888">上传一张猫咪照片,让AI认出它是什么</text>
<text x="40" y="175" font-size="10" fill="#aaa">⚡ 基于深度残差网络</text>
<!-- 上传区域-有图片 -->
<rect x="40" y="200" width="320" height="200" rx="8" fill="#fff" stroke="#999" stroke-width="1.5"/>
<!-- 图片区域 -->
<rect x="60" y="220" width="140" height="160" rx="4" fill="#f0f0f0" stroke="#ddd" stroke-width="1"/>
<!-- 猫图标 -->
<g transform="translate(130, 300)">
<ellipse cx="0" cy="15" rx="35" ry="25" fill="none" stroke="#999" stroke-width="1.5"/>
<ellipse cx="-18" cy="-5" rx="14" ry="16" fill="none" stroke="#999" stroke-width="1.5"/>
<ellipse cx="18" cy="-5" rx="14" ry="16" fill="none" stroke="#999" stroke-width="1.5"/>
<ellipse cx="0" cy="20" rx="6" ry="4" fill="none" stroke="#999" stroke-width="1"/>
</g>
<!-- 移除按钮 -->
<rect x="60" y="220" width="50" height="22" rx="4" fill="#fff" stroke="#ccc"/>
<text x="85" y="235" font-size="9" fill="#666" text-anchor="middle">移除</text>
<!-- 开始检测按钮 -->
<rect x="40" y="420" width="320" height="40" rx="6" fill="#333"/>
<text x="200" y="445" font-size="12" fill="#fff" text-anchor="middle">🔍 开始检测</text>
<text x="200" y="620" font-size="11" fill="#666" text-anchor="middle" font-weight="bold">图2: 图片预览</text>
</g>
<!-- 页面3: 检测中 -->
<g id="page3" transform="translate(910, 20)">
<rect x="0" y="0" width="400" height="30" rx="6" fill="#e0e0e0"/>
<circle cx="15" cy="15" r="5" fill="#fff" stroke="#ccc" stroke-width="1"/>
<circle cx="32" cy="15" r="5" fill="#fff" stroke="#ccc" stroke-width="1"/>
<circle cx="49" cy="15" r="5" fill="#fff" stroke="#ccc" stroke-width="1"/>
<text x="200" y="20" font-size="11" fill="#666" text-anchor="middle">分类喵 - 桌面应用</text>
<rect x="0" y="30" width="400" height="50" fill="#fff" stroke="#ccc" stroke-width="1"/>
<text x="20" y="60" font-size="14" font-weight="bold">分类喵</text>
<line x1="100" y1="40" x2="100" y2="70" stroke="#ddd" stroke-width="1"/>
<text x="120" y="60" font-size="12" fill="#333">主页</text>
<text x="175" y="60" font-size="12" fill="#999">历史记录</text>
<rect x="320" y="42" width="60" height="26" rx="4" fill="#f5f5f5" stroke="#ddd"/>
<text x="350" y="59" font-size="10" fill="#666" text-anchor="middle">查看代码</text>
<rect x="0" y="80" width="400" height="520" fill="#fafafa"/>
<text x="40" y="130" font-size="20" fill="#333" font-weight="bold">发现<tspan fill="#666"></tspan><tspan fill="#666">分类</tspan></text>
<text x="40" y="155" font-size="11" fill="#888">上传一张猫咪照片,让AI认出它是什么</text>
<text x="40" y="175" font-size="10" fill="#aaa">⚡ 基于深度残差网络</text>
<!-- 上传区域-加载中 -->
<rect x="40" y="200" width="320" height="200" rx="8" fill="#fff" stroke="#999" stroke-width="1.5"/>
<!-- 加载动画 -->
<g transform="translate(200, 280)">
<circle cx="0" cy="0" r="35" fill="none" stroke="#ddd" stroke-width="2"/>
<path d="M0 -35 A35 35 0 0 1 35 0" stroke="#666" stroke-width="2" fill="none" stroke-linecap="round">
<animateTransform attributeName="transform" type="rotate" from="0 0 0" to="360 0 0" dur="1s" repeatCount="indefinite"/>
</path>
</g>
<text x="200" y="340" font-size="13" fill="#333" text-anchor="middle" font-weight="500">正在检测...</text>
<text x="200" y="360" font-size="11" fill="#999" text-anchor="middle">请耐心等待~</text>
<text x="200" y="620" font-size="11" fill="#666" text-anchor="middle" font-weight="bold">图3: 检测中</text>
</g>
<!-- 页面4: 结果 -->
<g id="page4" transform="translate(30, 420)">
<rect x="0" y="0" width="400" height="30" rx="6" fill="#e0e0e0"/>
<circle cx="15" cy="15" r="5" fill="#fff" stroke="#ccc" stroke-width="1"/>
<circle cx="32" cy="15" r="5" fill="#fff" stroke="#ccc" stroke-width="1"/>
<circle cx="49" cy="15" r="5" fill="#fff" stroke="#ccc" stroke-width="1"/>
<text x="200" y="20" font-size="11" fill="#666" text-anchor="middle">分类喵 - 桌面应用</text>
<rect x="0" y="30" width="400" height="50" fill="#fff" stroke="#ccc" stroke-width="1"/>
<text x="20" y="60" font-size="14" font-weight="bold">分类喵</text>
<line x1="100" y1="40" x2="100" y2="70" stroke="#ddd" stroke-width="1"/>
<text x="120" y="60" font-size="12" fill="#333">主页</text>
<text x="175" y="60" font-size="12" fill="#999">历史记录</text>
<rect x="320" y="42" width="60" height="26" rx="4" fill="#f5f5f5" stroke="#ddd"/>
<text x="350" y="59" font-size="10" fill="#666" text-anchor="middle">查看代码</text>
<rect x="0" y="80" width="400" height="520" fill="#fafafa"/>
<text x="40" y="130" font-size="20" fill="#333" font-weight="bold">发现<tspan fill="#666"></tspan><tspan fill="#666">分类</tspan></text>
<!-- 完成标记 -->
<g transform="translate(200, 175)">
<circle cx="0" cy="0" r="18" fill="none" stroke="#666" stroke-width="1.5"/>
<path d="M-7 0 L-2 5 L8 -6" stroke="#666" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<text x="200" y="210" font-size="12" fill="#333" text-anchor="middle" font-weight="500">检测完成</text>
<!-- 结果卡片 -->
<rect x="40" y="230" width="320" height="180" rx="8" fill="#fff" stroke="#ccc" stroke-width="1"/>
<!-- 图片 -->
<rect x="60" y="250" width="100" height="100" rx="4" fill="#f0f0f0" stroke="#ddd"/>
<g transform="translate(110, 300)">
<ellipse cx="0" cy="15" rx="28" ry="20" fill="none" stroke="#999" stroke-width="1.5"/>
<ellipse cx="-14" cy="-2" rx="11" ry="13" fill="none" stroke="#999" stroke-width="1.5"/>
<ellipse cx="14" cy="-2" rx="11" ry="13" fill="none" stroke="#999" stroke-width="1.5"/>
<ellipse cx="0" cy="20" rx="5" ry="3" fill="none" stroke="#999" stroke-width="1"/>
</g>
<!-- 结果信息 -->
<text x="180" y="270" font-size="14" fill="#333" font-weight="bold">英短蓝猫</text>
<!-- 置信度条 -->
<rect x="180" y="285" width="160" height="8" rx="4" fill="#e5e5e5"/>
<rect x="180" y="285" width="136" height="8" rx="4" fill="#666"/>
<text x="180" y="310" font-size="10" fill="#666">置信度 85.6%</text>
<!-- 描述 -->
<text x="60" y="440" font-size="11" fill="#888">英国短毛猫是传统英国本地猫的纯种版本...</text>
<!-- 按钮 -->
<rect x="40" y="460" width="320" height="40" rx="6" fill="none" stroke="#666" stroke-width="1.5"/>
<text x="200" y="485" font-size="12" fill="#666" text-anchor="middle">🔄 再试一张</text>
<text x="200" y="620" font-size="11" fill="#666" text-anchor="middle" font-weight="bold">图4: 检测结果</text>
</g>
<!-- 页面5: 历史记录 -->
<g id="page5" transform="translate(470, 420)">
<rect x="0" y="0" width="400" height="30" rx="6" fill="#e0e0e0"/>
<circle cx="15" cy="15" r="5" fill="#fff" stroke="#ccc" stroke-width="1"/>
<circle cx="32" cy="15" r="5" fill="#fff" stroke="#ccc" stroke-width="1"/>
<circle cx="49" cy="15" r="5" fill="#fff" stroke="#ccc" stroke-width="1"/>
<text x="200" y="20" font-size="11" fill="#666" text-anchor="middle">分类喵 - 桌面应用</text>
<rect x="0" y="30" width="400" height="50" fill="#fff" stroke="#ccc" stroke-width="1"/>
<text x="20" y="60" font-size="14" font-weight="bold">分类喵</text>
<line x1="100" y1="40" x2="100" y2="70" stroke="#ddd" stroke-width="1"/>
<text x="120" y="60" font-size="12" fill="#999">主页</text>
<text x="175" y="60" font-size="12" fill="#333">历史记录</text>
<rect x="320" y="42" width="60" height="26" rx="4" fill="#f5f5f5" stroke="#ddd"/>
<text x="350" y="59" font-size="10" fill="#666" text-anchor="middle">查看代码</text>
<rect x="0" y="80" width="400" height="520" fill="#fafafa"/>
<text x="40" y="120" font-size="16" fill="#333" font-weight="bold">历史记录</text>
<!-- 清空按钮 -->
<rect x="320" y="105" width="40" height="28" rx="4" fill="#fff" stroke="#ccc"/>
<text x="340" y="123" font-size="11" fill="#666" text-anchor="middle">🗑️</text>
<!-- 历史记录卡片1 -->
<rect x="40" y="145" width="320" height="90" rx="6" fill="#fff" stroke="#ccc" stroke-width="1"/>
<rect x="55" y="160" width="60" height="60" rx="4" fill="#f0f0f0" stroke="#ddd"/>
<g transform="translate(85, 190)">
<ellipse cx="0" cy="10" rx="20" ry="14" fill="none" stroke="#999" stroke-width="1"/>
<ellipse cx="-10" cy="-2" rx="8" ry="10" fill="none" stroke="#999" stroke-width="1"/>
<ellipse cx="10" cy="-2" rx="8" ry="10" fill="none" stroke="#999" stroke-width="1"/>
</g>
<text x="130" y="180" font-size="13" fill="#333" font-weight="500">英短蓝猫</text>
<text x="130" y="200" font-size="10" fill="#999">2024/07/27 15:32</text>
<text x="130" y="218" font-size="10" fill="#888">英国短毛猫是传统英国...</text>
<text x="350" y="175" font-size="14" fill="#999" text-anchor="middle">×</text>
<!-- 历史记录卡片2 -->
<rect x="40" y="245" width="320" height="90" rx="6" fill="#fff" stroke="#ccc" stroke-width="1"/>
<rect x="55" y="260" width="60" height="60" rx="4" fill="#f0f0f0" stroke="#ddd"/>
<g transform="translate(85, 290)">
<ellipse cx="0" cy="10" rx="20" ry="14" fill="none" stroke="#999" stroke-width="1"/>
<ellipse cx="-10" cy="-2" rx="8" ry="10" fill="none" stroke="#999" stroke-width="1"/>
<ellipse cx="10" cy="-2" rx="8" ry="10" fill="none" stroke="#999" stroke-width="1"/>
</g>
<text x="130" y="280" font-size="13" fill="#333" font-weight="500">狸花猫</text>
<text x="130" y="300" font-size="10" fill="#999">2024/07/27 14:20</text>
<text x="130" y="318" font-size="10" fill="#888">狸花猫是中华田园...</text>
<text x="350" y="275" font-size="14" fill="#999" text-anchor="middle">×</text>
<!-- 分页 -->
<g transform="translate(200, 370)">
<rect x="-100" y="0" width="36" height="32" rx="4" fill="#fff" stroke="#ccc"/>
<text x="-82" y="21" font-size="12" fill="#666" text-anchor="middle"></text>
<rect x="-58" y="0" width="36" height="32" rx="4" fill="#333" stroke="#333"/>
<text x="-40" y="21" font-size="12" fill="#fff" text-anchor="middle">1</text>
<rect x="-16" y="0" width="36" height="32" rx="4" fill="#fff" stroke="#ccc"/>
<text x="2" y="21" font-size="12" fill="#666" text-anchor="middle">2</text>
<rect x="26" y="0" width="36" height="32" rx="4" fill="#fff" stroke="#ccc"/>
<text x="44" y="21" font-size="12" fill="#666" text-anchor="middle"></text>
</g>
<!-- 删除确认弹窗 -->
<rect x="80" y="160" width="240" height="130" rx="8" fill="#fff" stroke="#ccc" stroke-width="1"/>
<text x="200" y="195" font-size="13" fill="#333" text-anchor="middle" font-weight="500">信息</text>
<line x1="80" y1="210" x2="320" y2="210" stroke="#eee"/>
<text x="200" y="240" font-size="12" fill="#666" text-anchor="middle">确定要删除当前记录?</text>
<rect x="100" y="260" width="80" height="32" rx="4" fill="#fff" stroke="#ccc"/>
<text x="140" y="281" font-size="11" fill="#666" text-anchor="middle">取消</text>
<rect x="220" y="260" width="80" height="32" rx="4" fill="#333"/>
<text x="260" y="281" font-size="11" fill="#fff" text-anchor="middle">确定</text>
<text x="200" y="620" font-size="11" fill="#666" text-anchor="middle" font-weight="bold">图5: 历史记录</text>
</g>
<!-- 连接线和标注 -->
<g stroke="#999" stroke-width="1" fill="none" marker-end="url(#arrow)">
<path d="M435 280 L465 280"/>
<path d="M875 280 L905 280"/>
<path d="M435 580 L465 580"/>
<path d="M875 580 L905 580"/>
</g>
<!-- 底部说明 -->
<g transform="translate(910, 420)">
<text x="0" y="0" font-size="12" fill="#666" font-weight="bold">原型图说明</text>
<text x="0" y="25" font-size="10" fill="#888">• 图1: 首页 - 上传猫咪图片</text>
<text x="0" y="43" font-size="10" fill="#888">• 图2: 预览 - 确认上传图片</text>
<text x="0" y="61" font-size="10" fill="#888">• 图3: 检测中 - AI正在识别</text>
<text x="0" y="79" font-size="10" fill="#888">• 图4: 结果 - 显示分类信息</text>
<text x="0" y="97" font-size="10" fill="#888">• 图5: 历史 - 管理识别记录</text>
</g>
</svg>
</body>
</html>