feat: sync all remaining python source board configurations

This commit is contained in:
yczpf2019
2026-01-24 16:19:55 +08:00
parent 1990bee9a1
commit 20bde81bbb
519 changed files with 93119 additions and 0 deletions

View File

@@ -0,0 +1,213 @@
<script setup>
import { provide, ref } from 'vue';
import { CirclePlusFilled } from '@element-plus/icons-vue';
import { ElMessage, ElSplitter, ElSplitterPanel, ElCard, ElButton, ElIcon } from 'element-plus';
import ClassBox from './teachableModel/ClassBox.vue';
import CameraBox from './teachableModel/CameraBox.vue';
import ModelArea from './teachableModel/ModelArea.vue';
import TrainArea from './teachableModel/TrainArea.vue';
// import 'element-plus/theme-chalk/src/message.scss';
// import 'element-plus/theme-chalk/src/splitter.scss';
// import 'element-plus/theme-chalk/src/splitter-panel.scss';
// import 'element-plus/theme-chalk/src/card.scss';
// import 'element-plus/theme-chalk/src/button.scss';
// import 'element-plus/theme-chalk/src/icon.scss';
// 存放所有拍摄的内容
const shotList = ref([])
provide('shotList', shotList)
// 存放类别和每个类别内含有的图片样本
const picList = ref([
{ title: '类别 1', list: [], disabled: false },
{ title: '类别 2', list: [], disabled: false },
])
provide('picList', picList)
// 训练状态
const states = ref({
isTraining: 0, // 0为未开始1为训练中2为训练完成
})
provide('states', states)
// 向某个类别添加一个样本
async function handleAddSample(idx, data) {
// 类别已被禁用
if (picList.value[idx].disabled) {
ElMessage.error('该类别已被禁用')
return
}
// 可能传入 base64 字符串上传图片
if (data) {
// data 是 base64说明是上传图片
picList.value[idx].list.push(data)
return
}
// 否则是拍摄,调用摄像头拍摄
await handleParentShot()
// 检查是否成功拍摄到图片
if (
!shotList.value[shotList.value.length - 1]
|| shotList.value[shotList.value.length - 1] === 'data:,'
) {
ElMessage.error('未获取到有效样本')
return
}
// 拍摄到的图片添加到类别中
picList.value[idx].list.push(shotList.value[shotList.value.length - 1])
}
// 添加一个类别
function handleAddClass() {
picList.value.push({
title: `类别 ${picList.value.length + 1}`,
list: [],
disabled: false,
})
}
// 删除一个类别
function handleDeleteClass(idx) {
picList.value.splice(idx, 1)
}
// 禁用/启用一个类别
function handleDisableClass(idx) {
picList.value[idx].disabled = !picList.value[idx].disabled
}
// 重命名一个类别
function handleRenameClass(idx, newTitle) {
picList.value[idx].title = newTitle
}
// 清空一个类别的样本
function handleClearSamples(idx) {
picList.value[idx].list = []
}
// 显示模型区域与否
const showModelArea = ref(true)
// 令摄像头拍摄一张图片加入shotList
const cameraBoxRef = ref(null)
async function handleParentShot() {
await cameraBoxRef.value?.captureShot()
}
// 训练模型
const modelAreaRef = ref(null)
async function handleTrain() {
await modelAreaRef.value.train()
}
</script>
<template>
<div class="main-splitter">
<ElSplitter>
<ElSplitterPanel size="70%" :min="200">
<div class="class-area">
<ClassBox v-for="(item, idx) in picList" :key="idx" :title="item.title" :pic-list="item.list"
:disabled="item.disabled" @add-sample="(data) => handleAddSample(idx, data)" @delete-sample="
(url) => (picList[idx].list = picList[idx].list.filter((i) => i !== url))
" @delete-class="handleDeleteClass(idx)" @disable-class="handleDisableClass(idx)"
@rename-class="(newTitle) => handleRenameClass(idx, newTitle)"
@clear-samples="handleClearSamples(idx)">
<ElButton type="primary" size="mini" @click="handleAddSample(idx)">
添加样本
</ElButton>
</ClassBox>
<ElCard class="add-class-card" @click="handleAddClass">
<div class="add-class-content">
添加一个类别
<ElIcon>
<CirclePlusFilled />
</ElIcon>
</div>
</ElCard>
</div>
</ElSplitterPanel>
<ElSplitterPanel :min="200">
<CameraBox ref="cameraBoxRef" />
<transition name="fade-scale">
<ModelArea v-if="showModelArea" ref="modelAreaRef" @shot="handleParentShot" />
</transition>
<TrainArea @train="handleTrain" />
</ElSplitterPanel>
</ElSplitter>
</div>
</template>
<style scoped>
.title {
font-size: 24px;
font-weight: 500;
color: #011216;
margin: 10px;
padding: 10px;
border-radius: 5px;
background: linear-gradient(to right, #d7dee1, transparent, transparent, transparent);
text-align: left;
}
.teachable-model-container {
width: 80%;
height: 80vh;
margin: 20px;
padding: 5px;
border: 2px solid #000;
border-radius: 5px;
box-shadow: 0px 0px 10px #000;
}
.main-splitter {
height: 100%;
width: 100%;
box-shadow: var(--el-border-color-light) 0px 0px 10px;
user-select: none;
}
.add-class-card {
margin: 10px auto;
border: 1px dotted #000;
max-width: 50vw;
cursor: default;
user-select: none;
box-sizing: border-box;
}
.add-class-card:hover {
cursor: pointer;
color: #409eff;
}
.fade-scale-enter-active {
transition: all 0.5s cubic-bezier(0.55, 0, 0.1, 1);
}
.fade-scale-leave-active {
transition: all 0.3s cubic-bezier(0.55, 0, 0.1, 1);
}
.fade-scale-enter-from {
opacity: 0;
transform: scale(0.8);
}
.fade-scale-enter-to {
opacity: 1;
transform: scale(1);
}
.fade-scale-leave-from {
opacity: 1;
transform: scale(1);
}
.fade-scale-leave-to {
opacity: 0;
transform: scale(0.8);
}
</style>

View File

@@ -0,0 +1,92 @@
<script setup>
import { defineExpose, inject, onMounted, ref } from 'vue';
const videoRef = ref(null)
const shotList = inject('shotList')
// 初始化摄像头
onMounted(() => {
navigator.mediaDevices
.getUserMedia({ video: true })
.then((stream) => {
if (videoRef.value) {
videoRef.value.srcObject = stream
}
})
.catch((err) => {
console.error('无法访问摄像头:', err)
})
})
// 拍摄照片转为正方形存入shotList
function captureShot() {
const video = videoRef.value
if (!video)
return
const vw = video.videoWidth
const vh = video.videoHeight
let sx = 0
let sy = 0
let sw = vw
let sh = vh
if (vw > vh) {
sx = (vw - vh) / 2
sw = vh
}
else if (vh > vw) {
sy = (vh - vw) / 2
sh = vw
}
const size = Math.min(sw, sh)
const canvas = document.createElement('canvas')
canvas.width = size
canvas.height = size
const ctx = canvas.getContext('2d')
ctx.drawImage(video, sx, sy, size, size, 0, 0, size, size)
shotList.value.push(canvas.toDataURL('image/png'))
return canvas.toDataURL('image/png')
}
// 暴露方法
defineExpose({ captureShot })
</script>
<template>
<div class="camera-box">
<video id="webcam" ref="videoRef" class="video-crop" autoplay playsinline muted height="100%" width="100%" />
</div>
</template>
<style scoped>
.camera-box {
margin: 10px auto;
padding: 10px;
border-radius: 20px;
width: 80%;
max-width: 250px;
aspect-ratio: 1/1;
display: flex;
justify-content: center;
align-items: center;
/* background-color: black; */
box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
}
.canvas {
width: 100%;
}
.video-crop {
max-width: 250px;
aspect-ratio: 1/1;
border-radius: 10px;
object-fit: cover;
object-position: center center;
overflow: hidden;
}
.camera-box {
cursor: default;
user-select: none;
}
</style>

View File

@@ -0,0 +1,357 @@
<script setup>
import { Camera, CirclePlus, Delete, EditPen, More } from '@element-plus/icons-vue';
import { saveAs } from 'file-saver';
import JSZip from 'jszip';
import { nextTick, ref, watch, TransitionGroup } from 'vue';
import { ElCard, ElDropdown, ElDropdownMenu, ElDropdownItem, ElButton, ElUpload, ElImage, ElInput, ElIcon } from 'element-plus';
// import 'element-plus/theme-chalk/el-card.css';
// import 'element-plus/theme-chalk/el-dropdown.css';
// import 'element-plus/theme-chalk/el-dropdown-menu.css';
// import 'element-plus/theme-chalk/el-dropdown-item.css';
// import 'element-plus/theme-chalk/el-button.css';
// import 'element-plus/theme-chalk/el-upload.css';
// import 'element-plus/theme-chalk/el-image.css';
// import 'element-plus/theme-chalk/el-input.css';
const props = defineProps({
title: {
type: String,
default: '类别',
},
picList: {
type: Array,
default: () => [],
},
disabled: {
type: Boolean,
default: false,
},
})
const emit = defineEmits([
'add-sample',
'delete-sample',
'rename-class',
'delete-class',
'disable-class',
'clear-samples',
])
const localTitle = ref(props.title)
const editing = ref(false)
const inputTitle = ref(localTitle.value)
const inputRef = ref(null)
const addSampleTimer = ref(null)
function editTitle() {
if (!editing.value) {
editing.value = true
inputTitle.value = localTitle.value
nextTick(() => {
if (inputRef.value && inputRef.value.input) {
inputRef.value.input.select()
}
})
}
}
function saveTitle() {
if (inputTitle.value.trim()) {
localTitle.value = inputTitle.value.trim()
emit('rename-class', localTitle.value)
}
editing.value = false
}
function handleAddSample() {
emit('add-sample')
}
function handleDeleteSample(url) {
emit('delete-sample', url)
}
function startAddSample() {
handleAddSample()
addSampleTimer.value = setInterval(() => {
handleAddSample()
}, 200) // 200ms 可根据需要调整
}
function stopAddSample() {
if (addSampleTimer.value) {
clearInterval(addSampleTimer.value)
addSampleTimer.value = null
}
}
watch(
() => props.title,
(val) => {
localTitle.value = val
},
)
function handleDeleteClass() {
emit('delete-class')
}
function handleDisableClass() {
emit('disable-class')
}
function handleClearSamples() {
emit('clear-samples')
}
function handleExportSamples() {
// 导出为zip
const zip = new JSZip()
props.picList.forEach((url, idx) => {
const base64 = url.split(',')[1]
zip.file(`sample_${idx + 1}.png`, base64, { base64: true })
})
zip.generateAsync({ type: 'blob' }).then((content) => {
saveAs(content, 'samples.zip')
})
}
// 新增:处理图片上传
// 修改处理函数
function handleUploadSample(file) {
return new Promise((resolve, reject) => {
// 新增ZIP文件处理
if (file.type === 'application/zip' || file.name.endsWith('.zip')) {
const zip = new JSZip()
zip
.loadAsync(file)
.then((zipContents) => {
const imageFiles = Object.values(zipContents.files).filter(
file => !file.dir && file.name.match(/\.(png|jpg|jpeg|gif|bmp)$/i),
)
Promise.all(
imageFiles.map((zipFile) => {
return zip
.file(zipFile.name)
.async('blob')
.then((blob) => {
const extractedFile = new File([blob], zipFile.name, {
type: blob.type,
})
const reader = new FileReader()
reader.readAsDataURL(extractedFile)
return new Promise(r => (reader.onload = r))
})
}),
).then((readers) => {
readers.forEach(e => emit('add-sample', e.target.result))
resolve(false)
})
})
.catch(reject)
}
else {
// 原有图片处理逻辑
const reader = new FileReader()
reader.onload = (e) => {
emit('add-sample', e.target.result)
resolve(false)
}
reader.onerror = reject
reader.readAsDataURL(file)
}
})
}
</script>
<template>
<ElCard class="class-box">
<template #header>
<div class="card-header">
<span class="card-header-title" style="cursor: pointer" @click="editTitle">
<template v-if="!editing">
{{ localTitle }}
<ElIcon @click.stop="editTitle">
<EditPen />
</ElIcon>
<span v-if="props.disabled" class="disabled-tag"
style="color: #f56c6c; margin-left: 8px; font-size: 13px">
禁用
</span>
</template>
<template v-else>
<ElInput ref="inputRef" v-model="inputTitle" style="width: 120px" @keyup.enter="saveTitle"
@blur="saveTitle" />
</template>
</span>
<ElDropdown class="card-header-title">
<span class="el-dropdown-link">
<ElIcon>
<More />
</ElIcon>
</span>
<template #dropdown>
<ElDropdownMenu class="class-box-dropdown">
<ElDropdownItem class="class-box-dropdown-item" @click="handleDeleteClass">
<span style="color: #f56c6c">删除类别</span>
</ElDropdownItem>
<ElDropdownItem class="class-box-dropdown-item" @click="handleDisableClass">
{{ props.disabled ? "启用" : "禁用" }}类别
</ElDropdownItem>
<ElDropdownItem class="class-box-dropdown-item" :disabled="props.picList.length === 0"
@click="handleClearSamples">
清空样本
</ElDropdownItem>
<ElDropdownItem class="class-box-dropdown-item" :disabled="props.picList.length === 0"
@click="handleExportSamples">
导出样本
</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
</div>
</template>
<div class="card-content" style="flex-direction: column; display: flex">
<div class="card-content-info">
图像样本 {{ props.picList.length }}
<div style="display: flex; gap: 4px">
<ElButton type="primary" size="small" :disabled="props.disabled" @mousedown="startAddSample"
@mouseup="stopAddSample" @mouseleave="stopAddSample">
拍摄样本
<ElIcon>
<Camera />
</ElIcon>
</ElButton>
<ElUpload :show-file-list="false" :before-upload="handleUploadSample" accept="image/*,.zip"
:disabled="props.disabled">
<ElButton plain type="primary" size="small" :disabled="props.disabled">
上传样本
<ElIcon>
<CirclePlus />
</ElIcon>
</ElButton>
</ElUpload>
</div>
</div>
<TransitionGroup name="fade" tag="div" class="card-content-img">
<div v-for="url in props.picList.slice().reverse()" :key="url" class="card-content-img-wrapper"
style="position: relative; display: inline-block">
<ElImage :src="url" class="card-content-img-item" :class="{ 'img-disabled': props.disabled }" />
<ElIcon class="delete-icon" @click="handleDeleteSample(url)">
<Delete />
</ElIcon>
</div>
</TransitionGroup>
</div>
</ElCard>
</template>
<style>
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
height: 10px;
}
.card-header-title:hover {
color: #409eff;
cursor: pointer;
}
.card-content {
display: flex;
flex-direction: column;
}
.card-content-img {
display: flex;
flex-direction: row;
width: 100%;
overflow-x: auto;
white-space: nowrap;
height: 70px;
}
.card-content-img-item {
margin: 2px;
height: 60px;
aspect-ratio: 1/1;
border-radius: 10px;
box-shadow: 0 0 4px rgba(0, 0, 0, 0.8);
object-fit: cover;
flex-shrink: 0;
}
.card-header-title {
display: flex;
align-items: center;
gap: 4px;
}
.card-content-info {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
}
.fade-enter-active,
.fade-leave-active {
transition: all 0.3s;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: translateY(10px);
}
.card-content-img-wrapper {
position: relative;
display: inline-block;
}
.delete-icon {
position: absolute;
top: 2px;
right: 2px;
font-size: 16px;
z-index: 2;
border-radius: 50%;
padding: 2px;
transition: background 0.2s;
color: #fff;
opacity: 0;
pointer-events: none;
}
.delete-icon:hover {
background: #f56c6c;
}
.card-content-img-wrapper:hover .delete-icon {
opacity: 1;
pointer-events: auto;
}
.img-disabled {
filter: grayscale(1) brightness(0.7);
pointer-events: none;
}
.disabled-tag {
color: #f56c6c;
margin-left: 8px;
font-size: 13px;
}
.class-box {
margin: 10px auto;
max-width: 50vw;
cursor: default;
user-select: none;
}
</style>

View File

@@ -0,0 +1,517 @@
<script setup>
// import * as mobilenet from "@tensorflow-models/mobilenet";
import * as tf from "@tensorflow/tfjs";
import * as tfvis from "@tensorflow/tfjs-vis";
import * as path from "path";
import { inject, ref } from "vue";
import {
ElMessage,
ElButton,
ElCard,
ElRow,
ElCol,
ElInput,
ElProgress,
ElUpload,
} from "element-plus";
import { Env } from "mixly";
// import 'element-plus/theme-chalk/el-message.css';
// import 'element-plus/theme-chalk/el-button.css';
// import 'element-plus/theme-chalk/el-card.css';
// import 'element-plus/theme-chalk/el-row.css';
// import 'element-plus/theme-chalk/el-col.css';
// import 'element-plus/theme-chalk/el-input.css';
// import 'element-plus/theme-chalk/el-progress.css';
// import 'element-plus/theme-chalk/el-upload.css';
const emit = defineEmits(["shot"]);
// 类别及其样本的列表
const picList = inject("picList");
// 图片列表
const shotList = inject("shotList");
// 训练状态
const states = inject("states");
// 用来显示进度条和名称
const classList = ref(
picList.value.map((item, idx) => ({
name: item.title,
progress: 0,
}))
);
const progressColors = ["#FF6F61", "#42A5F5", "#66BB6A", "#FFA726", "#AB47BC"];
// 用来存储模型
let featureExtractor;
// 用来存储损失值
let lossValues = [];
// 存储被训练的模型
let model;
// 单击训练按钮后训练模型
async function train() {
// 可视化相关
const visPanel = document.getElementById("vis-left-panel");
if (visPanel) visPanel.innerHTML = "训练准备中……";
showVisPanel.value = true;
console.log("正在加载Mobilenet……");
// net = await mobilenet.load();
featureExtractor = await tf.loadGraphModel(
path.join(Env.boardDirPath, "teachableModel/model.json")
);
console.log("Mobilenet加载完成。");
if (visPanel) visPanel.innerHTML = "";
// 准备数据
const NUM_CLASSES = picList.value.length;
let xs = null;
let ys = null;
// 准备模型
model = tf.sequential();
// 添加全连接层
model.add(
tf.layers.dense({
inputShape: [1280],
units: 128,
activation: "relu",
})
);
// 添加分类层
model.add(
tf.layers.dense({
units: NUM_CLASSES,
activation: "softmax",
})
);
// 编译模型
model.compile({
optimizer: tf.train.adam(0.001),
loss: "categoricalCrossentropy",
metrics: ["accuracy"],
});
for (let classId = 0; classId < picList.value.length; classId++) {
if (picList.value[classId].disabled) continue;
const images = picList.value[classId].list;
for (let i = 0; i < images.length; i++) {
// 加载图片
const imgElement = new Image();
imgElement.src = images[i];
await new Promise((resolve) => {
imgElement.onload = resolve;
});
// 将图片转化为张量
const imgTensor = tf.browser.fromPixels(imgElement);
// 使用神经网络模型进行推理,获取名为"conv_preds"的卷积层激活值
// let activation = net.infer(imgTensor, "conv_preds");
// 加载特征提取器和你的模型
// 预处理图像并提取特征
const preprocessedImg = imgTensor
.resizeBilinear([224, 224])
.toFloat()
.div(tf.scalar(127.5))
.sub(tf.scalar(1))
.expandDims(0);
const features = featureExtractor.predict(preprocessedImg);
// 将特征输入你的模型
// const predictions = net.predict(features);
let activation = features;
// if (activation.shape.length === 3) {
// activation = activation.reshape([1, 1024]);
// }
// let activation = net.predict(imgTensor);
// 检查激活值张量的维度如果是3维张量则进行形状重塑
// 3维形状通常为 [height, width, channels],重塑为 [1, 1024] 的张量
// 为了适配后续分类层或特征可视化的输入要求
// 转换为one-hot编码
const y = tf.oneHot(tf.tensor1d([classId]).toInt(), NUM_CLASSES);
// 初始化xs和ys
if (xs == null) {
xs = activation.clone();
ys = y.clone();
} else {
const oldXs = xs;
xs = oldXs.concat(activation, 0);
oldXs.dispose();
const oldYs = ys;
ys = oldYs.concat(y, 0);
oldYs.dispose();
}
y.dispose();
imgTensor.dispose();
}
}
// 训练过程可视化相关
lossValues = [];
const metrics = ["loss", "acc", "val_loss", "val_acc", "accuracy", "val_accuracy"];
const container = document.getElementById("vis-left-panel") || {
name: "训练过程",
tab: "训练",
};
// 训练模型
await model.fit(xs, ys, {
epochs: 20,
batchSize: 16,
shuffle: true,
validationSplit: 0.2,
callbacks: tfvis.show.fitCallbacks(container, metrics, {
callbacks: ["onEpochEnd"],
}),
});
console.log("训练完成");
// 与显示进度条相关
classList.value = picList.value
.filter((item) => item.disabled !== true)
.map((item, idx) => ({
name: item.title,
progress: 0,
}));
console.log(classList.value);
states.value.isTraining = 2;
}
setInterval(async () => {
// 没训练完成跳过
if (states.value.isTraining !== 2) return;
// 输入是“上传图片”时跳过
if (uploadedImg.value !== "") return;
// 通知摄像头拍摄照片
emit("shot");
// 加载拍摄的照片
const img = shotList.value[shotList.value.length - 1];
if (!img || img === "data:,") {
ElMessage.error("未获取到有效样本");
return;
}
const imgElement = new Image();
imgElement.src = img;
await new Promise((resolve) => {
imgElement.onload = resolve;
});
// 将图片转换为张量
const imgTensor = tf.browser.fromPixels(imgElement);
// let activation = net.infer(imgTensor, "conv_preds");
let resized = tf.image.resizeBilinear(imgTensor, [224, 224]);
let batched = resized.expandDims(0);
let normalized = batched.div(255);
// let activation = net.predict(imgTensor);
let activation = featureExtractor.predict(normalized);
const pred = model.predict(activation);
const predArr = await pred.data();
// console.log(predArr)
classList.value = [
...classList.value.map((item, idx) => ({
...item,
progress: Number((predArr[idx] * 100).toFixed(2)),
})),
];
// console.log(classList.value)
imgTensor.dispose();
activation.dispose();
pred.dispose();
}, 200);
const showVisPanel = ref(false);
const uploadedImg = ref("");
const uploadedResult = ref("");
const panelTop = ref("20%");
const panelRight = ref("20%");
async function exportModel() {
if (!model) {
ElMessage.error("模型尚未训练完成");
return;
}
try {
// 导出模型到本地文件系统
await model.save(
`downloads://${modelName.value == "" ? "my-model" : modelName.value}`
);
ElMessage.success("模型导出成功");
} catch (error) {
ElMessage.error(`模型导出失败: ${error.message}`);
}
}
async function handleUpload(file) {
const reader = new FileReader();
reader.onload = async (e) => {
uploadedImg.value = e.target.result;
if (!featureExtractor || !model) {
ElMessage.error("请先完成模型训练");
return false;
}
const imgElement = new window.Image();
imgElement.src = uploadedImg.value;
await new Promise((resolve) => (imgElement.onload = resolve));
const imgTensor = tf.browser.fromPixels(imgElement);
// let activation = net.infer(imgTensor, "conv_preds");
let resized = tf.image.resizeBilinear(imgTensor, [224, 224]);
let batched = resized.expandDims(0);
let normalized = batched.div(255);
// let activation = net.predict(imgTensor);
let activation = featureExtractor.predict(normalized);
const pred = model.predict(activation);
const predArr = await pred.data();
classList.value = [
...classList.value.map((item, idx) => ({
...item,
progress: Number((predArr[idx] * 100).toFixed(2)),
})),
];
const maxIdx = predArr.indexOf(Math.max(...predArr));
uploadedResult.value = classList.value[maxIdx]?.name || "未知";
imgTensor.dispose();
activation.dispose();
pred.dispose();
};
reader.readAsDataURL(file);
return false;
}
function switchToUpload() {
uploadedImg.value = "";
}
let startX = 0;
let startY = 0;
let startTop = 0;
let startRight = 0;
function onDragStart(e) {
if (e.button !== 0) return;
startX = e.clientX;
startY = e.clientY;
const topVal = document.getElementById("vis-left-panel-wrapper").style.top;
const rightVal = document.getElementById("vis-left-panel-wrapper").style.right;
startTop = topVal.endsWith("%")
? (window.innerHeight * Number.parseFloat(topVal)) / 100
: Number.parseFloat(topVal);
startRight = rightVal.endsWith("%")
? (window.innerWidth * Number.parseFloat(rightVal)) / 100
: Number.parseFloat(rightVal);
document.addEventListener("mousemove", onDragging);
document.addEventListener("mouseup", onDragEnd);
}
function onDragging(e) {
const deltaX = e.clientX - startX;
const deltaY = e.clientY - startY;
let newTop = startTop + deltaY;
let newRight = startRight - deltaX;
newTop = Math.max(0, Math.min(window.innerHeight - 100, newTop));
newRight = Math.max(0, Math.min(window.innerWidth - 200, newRight));
panelTop.value = `${newTop}px`;
panelRight.value = `${newRight}px`;
}
function onDragEnd() {
document.removeEventListener("mousemove", onDragging);
document.removeEventListener("mouseup", onDragEnd);
}
defineExpose({ train });
const modelName = ref("");
async function saveModel() {
if (!model) {
ElMessage.error("模型尚未训练完成");
return;
}
try {
// 导出模型到本地文件系统
await model.save(
`indexeddb://${modelName.value == "" ? "my-model" : modelName.value}`
);
ElMessage.success("模型保存成功");
} catch (error) {
ElMessage.error(`模型保存失败: ${error.message}`);
}
}
</script>
<template>
<div>
<div
v-show="showVisPanel"
id="vis-left-panel-wrapper"
class="vis-left-panel-wrapper"
:style="`right: ${panelRight}; top: ${panelTop};`"
>
<div class="vis-left-panel-inner-wrapper">
<div class="vis-left-panel-title" style="" @mousedown="onDragStart">
<span> 训练过程可视化 </span>
<ElButton size="small" plain @click="showVisPanel = false"> 隐藏 </ElButton>
</div>
<div id="vis-left-panel" class="vis-left-panel">训练准备中</div>
</div>
</div>
<ElCard v-if="states.isTraining === 2" class="model-area">
<template #header>
<div class="card-header">
模型
<ElButton
v-if="!showVisPanel"
size="small"
style="margin-left: 10px"
type="primary"
plain
@click="showVisPanel = true"
>
显示训练过程
</ElButton>
<ElButton
v-if="states.isTraining === 2"
size="small"
style="margin-left: 10px"
type="success"
plain
@click="exportModel"
>
模型导出至本地
</ElButton>
</div>
</template>
<ElRow class="model-item" style="flex-direction: column; align-items: flex-center">
<ElRow>
<ElCol :span="6" style="display: flex; align-items: center; text-align: right">
名称
</ElCol>
<ElCol :span="12">
<ElInput v-model="modelName" placeholder="请输入模型名称" />
</ElCol>
<ElCol :span="4" style="display: flex; align-items: center">
<ElButton
style="margin: auto 5px"
type="primary"
plain
size="small"
@click="saveModel"
>
保存
</ElButton>
</ElCol>
</ElRow>
</ElRow>
<ElRow class="model-item">
<b>输入</b>
</ElRow>
<ElRow class="model-item">
<div v-if="!uploadedImg" style="margin: auto">
上方拍摄内容
<ElUpload
:show-file-list="false"
accept="image/*"
:before-upload="handleUpload"
>
<ElButton size="small" type="success"> 切换为上传图片 </ElButton>
</ElUpload>
</div>
<div v-else style="margin: auto">
下方上传图片 <br />
<ElButton size="small" type="success" plain @click="switchToUpload">
切换为上方拍摄内容
</ElButton>
<ElUpload
:show-file-list="false"
accept="image/*"
:before-upload="handleUpload"
>
<ElButton size="small" type="success"> 重新上传一张 </ElButton>
</ElUpload>
<img
:src="uploadedImg"
alt="用户上传图片"
style="max-width: 100%; max-height: 150px; border-radius: 10px"
/>
</div>
</ElRow>
<ElRow class="model-item">
<b>输出</b>
</ElRow>
<ElRow
v-for="(item, idx) in classList"
:key="`${item.name}-${idx}`"
class="model-item"
>
<ElCol :span="6">
{{ item.name }}
</ElCol>
<ElCol :span="18">
<ElProgress
class="progress"
:text-inside="true"
:stroke-width="20"
:percentage="item.progress"
:color="progressColors[idx % progressColors.length]"
striped
:format="(p) => `${p}%`"
/>
</ElCol>
</ElRow>
</ElCard>
</div>
</template>
<style scoped>
.vis-left-panel-wrapper {
transition: all 0.3s;
position: fixed;
width: 400px;
z-index: 1000;
box-shadow: 0 0 20px #0001;
}
.vis-left-panel-inner-wrapper {
background: #fff;
border-radius: 10px;
box-shadow: 0 2px 8px #0001;
padding: 10px 10px 0 10px;
border: 1px solid #eee;
}
.vis-left-panel-title {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
cursor: move;
user-select: none;
font-weight: bold;
font-size: 16px;
}
.vis-left-panel {
min-height: 300px;
}
.model-area {
margin: 10px auto;
width: 85%;
max-width: 300px;
border-radius: 30px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
height: 10px;
}
.model-item {
display: flex;
align-items: center;
margin-bottom: 15px;
}
</style>

View File

@@ -0,0 +1,98 @@
<script setup>
import { inject, ref, watch } from 'vue';
import { Warning } from '@element-plus/icons-vue';
import { ElButton, ElPopover, ElCard, ElText, ElIcon } from 'element-plus';
// import 'element-plus/theme-chalk/el-button.css';
// import 'element-plus/theme-chalk/el-popover.css';
// import 'element-plus/theme-chalk/el-card.css';
// import 'element-plus/theme-chalk/el-text.css';
// import 'element-plus/theme-chalk/el-icon.css';
const emit = defineEmits(['train'])
// 获取类别及其样本
const picList = inject('picList')
// 同步训练状态
const states = inject('states')
// 训练按钮是否可用
const isTrainButtonEnabled = ref(false)
const note_text = ref('待训练……')
// 检查训练按钮是否可用
function checkTrainButtonEnabled() {
// 只考虑未被禁用的类别
const enabledList = Array.isArray(picList.value)
? picList.value.filter(item => item.disabled === false)
: []
return (
states.value.isTraining !== 1
&& enabledList.length > 1
&& enabledList.every(item => Array.isArray(item.list) && item.list.length > 0)
)
}
// 监听类别列表和训练状态变化,更新训练按钮的可用性
watch(
[picList.value, () => states.value.isTraining],
() => {
isTrainButtonEnabled.value = checkTrainButtonEnabled()
if (states.value.isTraining === 0) {
note_text.value = '待训练……'
}
else if (states.value.isTraining === 1) {
note_text.value = '训练中……'
}
else if (states.value.isTraining === 2) {
note_text.value = '训练完成'
}
},
{ immediate: true },
)
// 训练模型
function handleTrain() {
if (isTrainButtonEnabled.value) {
emit('train')
states.value.isTraining = 1
}
}
</script>
<template>
<ElCard class="train-area">
<template #header>
<div class="card-header">
训练
</div>
</template>
<ElPopover class="box-item" placement="bottom" :disabled="isTrainButtonEnabled">
<ElIcon>
<Warning />
</ElIcon>
至少有两个类别且每个类别都需有图像样本
<template #reference>
<ElButton :disabled="!isTrainButtonEnabled" type="primary" style="max-width: 100%" @click="handleTrain">
训练模型
</ElButton>
</template>
</ElPopover>
<br>
<ElText>{{ note_text }}</ElText>
</ElCard>
</template>
<style scoped>
.train-area {
margin: 10px auto;
width: 85%;
max-width: 300px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
height: 10px;
}
</style>