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/*
core/dataset/*.zip
core/dataset/toy/*
core/dataset/benchmark/*
core/models/*.pth
core/models/*.pt
core/models/*.onnx
build/bin
frontend/node_modules
frontend/dist
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
here: https://wails.io/docs/reference/project-config
## Features
## 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
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.
## Architecture
## 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
import (
"bytes"
"context"
"encoding/base64"
"fmt"
"math"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/disintegration/imaging"
ort "github.com/yalue/onnxruntime_go"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
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 {
@@ -22,25 +33,194 @@ func NewApp() *App {
func (a *App) Startup(ctx context.Context) {
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) {
db, err := gorm.Open(sqlite.Open("app.db"), &gorm.Config{})
func (a *App) loadLabels() ([]string, error) {
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 {
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 {
uploadsDir := publicImagePath
uploadsDir := staticImagesPath
err := os.MkdirAll(uploadsDir, 0755)
if err != nil {
return Response{Code: 1, Message: "failed", Data: err.Error()}
}
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)
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: 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 {
filePath := filepath.Join(publicImagePath, filename)
data, err := os.ReadFile(filePath)
if err != nil {
return Response{Code: 1, Message: "failed", Data: err.Error()}
// validateFilename 检查文件名是否安全,防止路径遍历攻击
func validateFilename(filename string) error {
// 禁止包含路径分隔符
if strings.ContainsAny(filename, "/\\") {
return fmt.Errorf("invalid filename")
}
ext := filepath.Ext(filename)
mimeType := "image/jpeg"
if ext == ".png" {
mimeType = "image/png"
// 禁止 .. 路径遍历
if strings.Contains(filename, "..") {
return fmt.Errorf("invalid filename")
}
base64Data := base64.StdEncoding.EncodeToString(data)
dataURL := "data:image/" + mimeType + ";base64," + base64Data
return Response{Code: 0, Message: "success", Data: dataURL}
return nil
}
func (a *App) GetHistory(page int, pageSize int) Response {
db, err := a.GormDB()
if err != nil {
return Response{Code: 1, Message: err.Error()}
func (a *App) Detect(filename string) Response {
db := a.GormDB()
if err := validateFilename(filename); err != nil {
return Response{Code: 1, Message: "invalid filename"}
}
var total int64
db.Table("history_test").Count(&total)
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
filePath := filepath.Join(staticImagesPath, filename)
imgData, err := os.ReadFile(filePath)
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}
return Response{Code: 0, Message: "success", Data: historyData}
}
func (a *App) Detect(img string) Response {
db, err := a.GormDB()
input, err := preprocessImage(imgData)
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
detectRet := "british_shorthair"
err = db.Table("breeds_test").Where("code = ?", detectRet).First(&breed).Error
err = db.Table("breeds").Where("code = ?", detectRet).First(&breed).Error
if err != nil {
return Response{Code: 1, Message: err.Error()}
}
now := int(time.Now().Unix())
print("confidence", confidence)
one := HistoryItem{Img: img, Breed: int(breed.Id), Date: now}
result := db.Table("history_test").Create(&one)
one := HistoryItem{Img: filename, Breed: int(breed.Id), Confidence: confidence, Date: now}
result := db.Table("history").Create(&one)
if result.Error != nil {
return Response{Code: 1, Message: result.Error.Error()}
}
@@ -120,19 +294,53 @@ func (a *App) Detect(img string) Response {
Code: breed.Code,
Name: breed.Name,
Brief: breed.Brief,
ConfidenceLevel: 0.98,
ConfidenceLevel: confidence,
}
return Response{Code: 0, Message: "success", Data: detectData}
}
func (a *App) DeleteOneHistory(id uint) Response {
db, err := a.GormDB()
func (a *App) GetHistory(page int, pageSize int) Response {
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 {
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 {
return Response{Code: 1, Message: result.Error.Error()}
}
@@ -141,20 +349,17 @@ func (a *App) DeleteOneHistory(id uint) Response {
}
func (a *App) ClearHistory() Response {
db, err := a.GormDB()
if err != nil {
return Response{Code: 1, Message: err.Error()}
}
db := a.GormDB()
result := db.Exec("DELETE FROM history_test")
result := db.Exec("DELETE FROM history")
if result.Error != nil {
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)}
}
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)}
}
+20 -12
View File
@@ -2,11 +2,17 @@ package backend
import (
"context"
ort "github.com/yalue/onnxruntime_go"
"gorm.io/gorm"
)
type (
App struct {
ctx context.Context
ctx context.Context
db *gorm.DB
session *ort.AdvancedSession
labels []string
}
Response struct {
@@ -16,19 +22,21 @@ type (
}
HistoryItem struct {
Id uint `gorm:"primaryKey"`
Img string `gorm:"column:img"`
Breed int `gorm:"column:breed"`
Date int `gorm:"column:date"`
Id uint `gorm:"primaryKey"`
Img string `gorm:"column:img"`
Breed int `gorm:"column:breed"`
Confidence float64 `gorm:"column:confidence"`
Date int `gorm:"column:date"`
}
HistoryWithBreed struct {
Id uint `gorm:"column:id" json:"id"`
Img string `gorm:"column:img" json:"img"`
Breed int `gorm:"column:breed" json:"breed"`
Date int `gorm:"column:date" json:"date"`
Name string `gorm:"column:name" json:"name"`
Brief string `gorm:"column:brief" json:"brief"`
Id uint `gorm:"column:id" json:"id"`
Img string `gorm:"column:img" json:"img"`
ImgData string `gorm:"-" json:"img_data"`
Breed int `gorm:"column:breed" json:"breed"`
Date int `gorm:"column:date" json:"date"`
Name string `gorm:"column:name" json:"name"`
Brief string `gorm:"column:brief" json:"brief"`
}
HistoryData struct {
@@ -50,6 +58,6 @@ type (
Code string `json:"code"`
Name string `json:"name"`
Brief string `json:"brief"`
ConfidenceLevel float32 `json:"confidence_level"`
ConfidenceLevel float64 `json:"confidence_level"`
}
)
+1 -44
View File
@@ -1,44 +1 @@
mode = "toy"
"""
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)
from .const import *
+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)
+9 -7
View File
@@ -3,7 +3,7 @@ import glob
from torchvision import transforms
from torch.utils.data import DataLoader, Dataset
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 = {}
@@ -17,13 +17,14 @@ def default_loader(path):
train_transform = transforms.Compose([
transforms.Resize((input_size)),
transforms.Resize(input_size),
transforms.CenterCrop(input_size),
transforms.RandomHorizontalFlip(p=0.5), # 50%的概率(p=0.5)水平翻转图片
transforms.RandomRotation(10), # 轻微旋转
transforms.ColorJitter(brightness=0.1, contrast=0.1),
transforms.ColorJitter(brightness=0.1, contrast=0.1), # 随机亮度和对比度
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__)))
im_train_list = glob.glob(os.path.join(dataset_root, "dataset", "toy", "train", "*", "*.jpg"))
im_test_list = glob.glob(os.path.join(dataset_root, "dataset", "toy", "test", "*", "*.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", mode, "test", "*", "*.jpg"))
train_dataset = MyDataset(im_train_list, transform=train_transform)
@@ -75,4 +76,5 @@ print("test_dataset", len(test_dataset))
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 glob
import torch
@@ -5,21 +6,32 @@ from torchvision import transforms
from PIL import Image
import numpy as np
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():
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(device)
net = resnet()
net.load_state_dict(torch.load("./model/resnet_epoch_15.pth", weights_only=True))
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")
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)
net.to(device)
print("222")
test_transform = transforms.Compose([
transforms.Resize(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])
])
print("333")
ary = []
for im_path in im_list:
# print("im_path", im_path)
net.eval()
im_data = Image.open(im_path)
@@ -40,11 +56,15 @@ def test():
_, 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)
cv2.imshow("img", img)
cv2.waitKey(0)
result = label_name[pred.cpu().numpy()[0]]
if result in im_path:
ary.append(True)
else:
ary.append(False)
print("ary", ary)
print(sum(ary) / len(ary))
if __name__ == "__main__":
test()
+29 -13
View File
@@ -1,3 +1,4 @@
import os
import cv2
import glob
import torch
@@ -5,17 +6,22 @@ from torchvision import transforms
from PIL import Image
import numpy as np
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():
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(device)
net = resnet18()
net.load_state_dict(torch.load("./model/resnet_epoch_14.pth", weights_only=True))
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")
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)
net.to(device)
@@ -27,6 +33,8 @@ def test():
transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
])
ary = []
errors = []
for im_path in im_list:
net.eval()
im_data = Image.open(im_path)
@@ -36,19 +44,27 @@ def test():
inputs = inputs.to(device)
outputs = net.forward(inputs)
# print("outputs", outputs)
_, 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)
# for i in range(3):
# print(label_name[pred[0, i].item()], " ", prob[0, i].item(), " ", 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)
result = label_name[pred.cpu().numpy()[0]]
img = np.asarray(im_data)
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(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__":
+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
from core.nets.resnet import resnet
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():
@@ -13,9 +13,9 @@ def train():
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):
print("epoch: ", e)
@@ -41,10 +41,12 @@ def train():
scheduler.step()
print("lr: ", optimizer.state_dict()['param_groups'][0]['lr'])
if not os.path.exists("./model"):
os.makedirs("./model")
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(), "./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__":
+7 -5
View File
@@ -2,7 +2,7 @@ import os
import torch
from core.nets.resnet18 import resnet18
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():
@@ -13,7 +13,7 @@ def train():
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) # 余弦退火
@@ -41,10 +41,12 @@ def train():
scheduler.step()
print("lr: ", optimizer.state_dict()['param_groups'][0]['lr'])
if not os.path.exists("./models"):
os.makedirs("./models")
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(), "./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__":
+1 -1
View File
@@ -2,7 +2,7 @@ export const Footer = () => (
<footer class="app-footer">
<span>📩 xiadongliang88@163.com</span>
<span>|</span>
<span>📦 https://git.leonstack.com/owner</span>
<span>📦 https://git.leonstack.com/xiadongliang</span>
<span>|</span>
<span>🌏 https://www.leonstack.com/</span>
</footer>
+14 -6
View File
@@ -1,10 +1,10 @@
import { useState, useEffect } from 'preact/hooks'
import Pagination from './Pagination'
import Modal from './Modal'
import ImagePreview from './ImagePreview'
import { message } from '../utils/toast'
import type { HistoryItem } from '../preact'
const formatDate = (timestamp: number) => {
const date = new Date(timestamp * 1000)
return date.toLocaleString('zh-CN', {
@@ -22,12 +22,13 @@ const History = () => {
const pageSize = 5
const [total, setTotal] = useState<number>(50)
const [historyList, setHistoryList] = useState<HistoryItem[]>([])
const [currentItem, setCurrentItem] = useState<HistoryItem>()
const [showDelete, setShowDelete] = useState<boolean>(false)
const [currentId, setCurrentId] = useState<number | null>(null)
const [showClear, setShowClear] = useState<boolean>(false)
useEffect(() => {
if (!(window as any).go?.main?.App?.GetHistory) {
if (!(window as any).go?.backend?.App?.GetHistory) {
message.error('Wails runtime not ready')
return
}
@@ -35,7 +36,7 @@ const History = () => {
}, [])
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) {
setHistoryList(result.data.list)
setTotal(result.data.total)
@@ -49,6 +50,10 @@ const History = () => {
fetchData(page)
}
const handleShowItem = (item: HistoryItem) => setCurrentItem(item)
const handleItemClose = () => setCurrentItem(undefined)
const handleShowDelete = (id: number) => {
setCurrentId(id)
setShowDelete(true)
@@ -60,7 +65,7 @@ const History = () => {
message.error('currentId为空')
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) {
message.success('删除成功')
setCurrentId(null)
@@ -79,7 +84,7 @@ const History = () => {
const handleClearOk = async() => {
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) {
message.success('已清空历史')
setCurrentId(null)
@@ -104,7 +109,7 @@ const History = () => {
{historyList.map((item: HistoryItem) =>
<div key={item.id} class="history-card">
<div class="card-left">
<ImagePreview filename={item.img} />
<img src={`data:image/jpeg;base64,${item.img_data}`} onClick={() => handleShowItem(item)} />
</div>
<div class="card-right">
<div class="right-top">
@@ -142,6 +147,9 @@ const History = () => {
<Modal open={showClear} title="信息" onClick={handleClearOk} onClose={handleClearClose}>
<p></p>
</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 type { DetectResult } from '../preact'
import ImagePreview from './ImagePreview'
import { message } from '../utils/toast'
const Main = () => {
const fileInputRef = useRef<HTMLInputElement>(null)
const [fileSrc, setFileSrc] = useState<string>('')
const [filename, setFilename] = useState<string>('')
const [step, setStep] = useState<number>(0)
const [detectResult, setDetectResult] = useState<DetectResult | null>(null)
@@ -47,7 +48,6 @@ const Main = () => {
}
const handleFileChange = (e: Event) => {
console.log('handleFileChange')
const target = e.target as HTMLInputElement
const file = target.files?.[0]
if (file) {
@@ -60,12 +60,13 @@ const Main = () => {
const arrayBuffer = await processedFile.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),
file.name
)
if (result.code === 0) {
setFileSrc(result.data)
setFileSrc(result.data.data)
setFilename(result.data.filename)
setStep(1)
} else if (result.code === 1) {
message.error(result.message)
@@ -92,14 +93,16 @@ const Main = () => {
setStep(2)
setTimeout(async() => {
const result = await (window as any).go.main.App.Detect(fileSrc)
if (result.code === 0) {
setDetectResult(result.data)
setStep(3)
} else if (result.code === 1) {
message.error(result.message)
if (filename) {
const result = await (window as any).go.backend.App.Detect(filename)
if (result.code === 0) {
setDetectResult(result.data)
setStep(3)
} else if (result.code === 1) {
message.error(result.message)
}
}
}, 1000)
}, 500)
}
const handleTryOther = () => {
@@ -133,7 +136,7 @@ const Main = () => {
>
{fileSrc.length > 0 && step == 1 ?
<div id="previewContent">
<ImagePreview filename={fileSrc} id="previewImage" />
<img src={`data:image/jpeg;base64,${fileSrc}`} />
<button onClick={handleRemovePhoto}>
</button>
@@ -192,7 +195,7 @@ const Main = () => {
<h2></h2>
</div>
<div class="result-main">
<ImagePreview filename={fileSrc} />
<img src={`data:image/jpeg;base64,${fileSrc}`} />
<div class="result-word">
<div>
<h3>{detectResult?.name}</h3>
@@ -200,7 +203,7 @@ const Main = () => {
<div class="probability-bar">
<div />
</div>
<span> {detectResult ? detectResult.confidence_level * 100 + '%' : ''}</span>
<span> {detectResult ? (detectResult.confidence_level * 100).toFixed(2) + '%' : ''}</span>
</div>
</div>
<p>{detectResult?.brief}</p>
+5 -8
View File
@@ -1,9 +1,8 @@
import type { ModalProps } from '../preact'
const Modal = ({ open, title, onClick, onClose, children }: ModalProps) => {
const handleClose = () => {
onClose?.()
}
const handleClose = () => onClose?.()
const handleConfirm = () => {
onClick?.()
@@ -11,9 +10,7 @@ const Modal = ({ open, title, onClick, onClose, children }: ModalProps) => {
}
const handleMaskClick = (e: MouseEvent) => {
if (e.target === e.currentTarget) {
handleClose()
}
if (e.target === e.currentTarget) handleClose()
}
return (
@@ -27,8 +24,8 @@ const Modal = ({ open, title, onClick, onClose, children }: ModalProps) => {
</div>
{children}
<div class="bottom">
<button onClick={handleClose}></button>
<button onClick={handleConfirm}></button>
<button class="close" onClick={handleClose}></button>
{onClick ? <button class="confirm" onClick={handleConfirm}></button> : null}
</div>
</div>
<div class="app-mask" onClick={handleMaskClick}></div>
-5
View File
@@ -12,11 +12,6 @@ const Pagination = ({
const [num, setNum] = useState<number>(1)
const [seqNums, setSeqNums] = useState<[number, number][]>([])
useEffect(() => {
setValue('1')
setNum(1)
}, [])
useEffect(() => {
if (page) {
setValue(page.toString());
+1
View File
@@ -1,6 +1,7 @@
export interface HistoryItem {
id: number
img: string
img_data: string
breed: number
date: number
name: string
+3 -4
View File
@@ -199,7 +199,7 @@ a
border-radius: 1.5rem
border-width: 2px
border-style: dashed
border-color: #e2e8f0
border-color: #bdc3cb
text-align: center
cursor: pointer
@@ -305,7 +305,6 @@ a
display: flex
align-items: center
justify-content: center
// height: 2.25rem
gap: 0.75rem
margin: 0.5rem 0 1.5rem 0
@@ -474,12 +473,12 @@ a
font-size: 0.875rem
border-radius: 0.25rem
&:first-child
> button.close
color: var(--text)
border: 1px solid #c9c9c9
background-color: none
&:last-child
> button.confirm
color: white
background-color: var(--primary)
+2 -6
View File
@@ -23,9 +23,7 @@ export const message: MessageAPI = {
div.appendChild(subDiv)
document.body.appendChild(div)
setTimeout(() => {
div.remove()
}, 3000)
setTimeout(() => div.remove(), 3000)
},
error: (text: string) => {
const div = document.createElement('div')
@@ -46,8 +44,6 @@ export const message: MessageAPI = {
div.appendChild(subDiv)
document.body.appendChild(div)
setTimeout(() => {
div.remove()
}, 3000)
setTimeout(() => 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 GetImage(arg1:string):Promise<backend.Response>;
export function GormDB():Promise<gorm.DB>;
export function Shutdown():Promise<void>;
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);
}
export function GetImage(arg1) {
return window['go']['backend']['App']['GetImage'](arg1);
}
export function GormDB() {
return window['go']['backend']['App']['GormDB']();
}
export function Shutdown() {
return window['go']['backend']['App']['Shutdown']();
}
export function UploadImage(arg1, arg2) {
return window['go']['backend']['App']['UploadImage'](arg1, arg2);
}
+3
View File
@@ -3,7 +3,9 @@ module sortmeow
go 1.25.0
require (
github.com/disintegration/imaging v1.6.2
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/gorm v1.31.2
)
@@ -37,6 +39,7 @@ require (
github.com/wailsapp/go-webview2 v1.0.22 // indirect
github.com/wailsapp/mimetype v1.4.1 // 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/sys v0.46.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/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/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/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
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/wails/v2 v2.13.0 h1:S7OgXWpj72V91unF8iDWJKbcS9ZpwCT3R0QVru4v2Mg=
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/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.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
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/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
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.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
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>