Pyodide里的Tensorflow目录
可以跑通基本的训练、使用模型过程
This commit is contained in:
@@ -2,5 +2,328 @@ import NavExt from './nav-ext';
|
||||
import * as tf from '@tensorflow/tfjs';
|
||||
import './tensorflow';
|
||||
|
||||
import * as Blockly from 'blockly/core';
|
||||
NavExt.init();
|
||||
window.tf = tf;
|
||||
window.tf = tf;
|
||||
|
||||
let featureExtractor;
|
||||
// featureExtractor = await tf.loadGraphModel("../common/media/tfmodel/model.json");
|
||||
// window.featureExtractor = featureExtractor;
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('modalOverlay').style.display = 'none';
|
||||
}
|
||||
|
||||
// 从IndexedDB删除单个模型
|
||||
async function deleteModel(modelName) {
|
||||
try {
|
||||
await tf.io.removeModel(`indexeddb://${modelName}`);
|
||||
// 从UI移除
|
||||
const modelItem = document.querySelector(`.model-item[data-model-name="${modelName}"]`);
|
||||
if (modelItem) modelItem.remove();
|
||||
} catch (error) {
|
||||
console.error('删除模型失败:', error);
|
||||
alert('删除模型失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 显示单个模型项
|
||||
function displayModelItem(modelName) {
|
||||
const modelsList = document.getElementById('imported-models');
|
||||
if ([...modelsList.children].some(item => item.dataset.modelName === modelName)) {
|
||||
return;
|
||||
}
|
||||
const modelItem = document.createElement('div');
|
||||
modelItem.className = 'model-item';
|
||||
modelItem.dataset.modelName = modelName;
|
||||
modelItem.innerHTML = `
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; padding: 8px 10px; border-bottom: 1px solid #eee;">
|
||||
<span style="font-size: 1em; color: #333;">${modelName}</span>
|
||||
<button class="delete-model" style="
|
||||
background: #f44336;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9em;
|
||||
transition: background-color 0.2s;
|
||||
">删除</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 绑定删除事件
|
||||
modelItem.querySelector('.delete-model').addEventListener('click', () => {
|
||||
deleteModel(modelName);
|
||||
});
|
||||
|
||||
modelsList.appendChild(modelItem);
|
||||
}
|
||||
|
||||
// 清空所有模型
|
||||
async function clearAllModels() {
|
||||
try {
|
||||
const modelInfos = await tf.io.listModels();
|
||||
const deletePromises = Object.keys(modelInfos)
|
||||
.map(path => path.replace('indexeddb://', ''))
|
||||
.map(modelName => tf.io.removeModel(`indexeddb://${modelName}`));
|
||||
|
||||
await Promise.all(deletePromises);
|
||||
document.getElementById('imported-models').innerHTML = '';
|
||||
} catch (error) {
|
||||
console.error('清空模型失败:', error);
|
||||
alert('清空模型失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 加载并显示所有模型
|
||||
async function loadAndDisplayAllModels() {
|
||||
try {
|
||||
const modelInfos = await tf.io.listModels();
|
||||
document.getElementById('imported-models').innerHTML = '';
|
||||
for (const [path] of Object.entries(modelInfos)) {
|
||||
const modelName = path.replace('indexeddb://', '');
|
||||
displayModelItem(modelName);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载模型列表失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function createModal() {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'modalOverlay';
|
||||
Object.assign(overlay.style, {
|
||||
display: 'none',
|
||||
position: 'fixed',
|
||||
top: '0',
|
||||
left: '0',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
zIndex: '20011216',
|
||||
pointerEvents: 'auto'
|
||||
});
|
||||
const content = document.createElement('div');
|
||||
Object.assign(content.style, {
|
||||
backgroundColor: 'white',
|
||||
width: '60%',
|
||||
maxHeight: '80%',
|
||||
margin: '12vh auto',
|
||||
padding: '20px 30px',
|
||||
borderRadius: '12px'
|
||||
});
|
||||
content.innerHTML = `
|
||||
<h2 style="margin-bottom: 20px;">选择本地模型</h2>
|
||||
<div style="margin-bottom: 25px; position: relative; min-height: 200px;"> <!-- 新增 min-height 和 position -->
|
||||
<input type="file" id="model-upload" accept=".json,.bin" multiple style="display: none;">
|
||||
<label for="model-upload" style="
|
||||
background: #1216ab;
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
">选择模型文件</label>
|
||||
<div style="display: flex; gap: 10px; margin-top: 15px; align-items: center;">
|
||||
<span style="line-height: 36px;">导入模型名称:</span>
|
||||
<input type="text" id="model-name" placeholder="模型名称" value="my-model" style="
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
flex: 1;
|
||||
max-width: 200px;
|
||||
">
|
||||
<button id="model-handle" style="
|
||||
background: #1216ab;
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
">保存模型</button>
|
||||
</div>
|
||||
<div style="margin-top: 15px; display: flex; gap: 20px;">
|
||||
<div id="json-status" style="
|
||||
background: #f5f5f5;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
flex: 1;
|
||||
">
|
||||
<span>❌ 模型结构描述文件(model.json)</span>
|
||||
|
||||
<div style="color: #666; font-size: 0.9em; margin-top: 5px;">未选择</div>
|
||||
</div>
|
||||
<div id="weights-status" style="
|
||||
background: #f5f5f5;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
flex: 1;
|
||||
">
|
||||
<span>❌ 权重文件(model.weights.bin)</span>
|
||||
<div style="color: #666; font-size: 0.9em; margin-top: 5px;">0 个已选择</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="output" style="margin-bottom: 15px; min-height: 40px;"></div>
|
||||
<div style="margin-top: 20px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
|
||||
<h3>已导入模型</h3>
|
||||
<div style="display: flex; gap: 10px;">
|
||||
<button id="refresh-models" style="
|
||||
background: #4CAF50;
|
||||
color: white;
|
||||
padding: 5px 10px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
">刷新</button>
|
||||
<button id="clear-models" style="
|
||||
background: #f44336;
|
||||
color: white;
|
||||
padding: 5px 10px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
">清空</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="imported-models" style="
|
||||
background: #f5f5f5;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
height: 200px;
|
||||
overflow-y: auto;
|
||||
">
|
||||
<div style="color: #666; font-style: italic;">加载中...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: flex-end; margin-top: 15px;">
|
||||
<button class="close-btn" style="
|
||||
background: #e0e0e0;
|
||||
border: none;
|
||||
padding: 8px 20px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
overlay.appendChild(content);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
content.querySelector('.close-btn').addEventListener('click', closeModal);
|
||||
overlay.addEventListener('click', (e) => {
|
||||
if (e.target === overlay) closeModal();
|
||||
});
|
||||
// 获取DOM元素
|
||||
const modelUpload = document.getElementById('model-upload');
|
||||
const modelHandle = document.getElementById('model-handle');
|
||||
const outputDiv = document.getElementById('output');
|
||||
|
||||
let jsonFile = null;
|
||||
let weightFiles = [];
|
||||
|
||||
modelUpload.addEventListener('change', async (event) => {
|
||||
const files = event.target.files;
|
||||
|
||||
// 获取状态元素
|
||||
const jsonStatus = document.getElementById('json-status');
|
||||
const weightsStatus = document.getElementById('weights-status');
|
||||
|
||||
// 重置状态显示(保持完整文件名描述)
|
||||
jsonStatus.querySelector('span').textContent = '❌ 模型结构描述文件(model.json)';
|
||||
jsonStatus.querySelector('div').textContent = '未选择';
|
||||
weightsStatus.querySelector('span').textContent = '❌ 权重文件(model.weights.bin)';
|
||||
weightsStatus.querySelector('div').textContent = '0 个已选择';
|
||||
|
||||
// 分离 JSON 和权重文件
|
||||
weightFiles = [];
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
if (files[i].name.endsWith('.json')) {
|
||||
jsonFile = files[i];
|
||||
} else {
|
||||
weightFiles.push(files[i]);
|
||||
}
|
||||
}
|
||||
|
||||
if (!jsonFile) {
|
||||
alert('未找到 model.json 文件');
|
||||
return;
|
||||
}
|
||||
|
||||
outputDiv.innerHTML = '正在处理上传的模型文件...';
|
||||
|
||||
if (jsonFile) {
|
||||
jsonStatus.querySelector('span').textContent = '✅ 模型结构描述文件(model.json)';
|
||||
jsonStatus.querySelector('div').textContent = '已选择';
|
||||
const modelName = jsonFile.name.replace('.json', '');
|
||||
document.getElementById('model-name').value = modelName;
|
||||
}
|
||||
|
||||
if (weightFiles.length > 0) {
|
||||
weightsStatus.querySelector('span').textContent = '✅ 权重文件(model.weights.bin)';
|
||||
weightsStatus.querySelector('div').textContent = `${weightFiles.length} 个已选择`;
|
||||
}
|
||||
});
|
||||
|
||||
modelHandle.addEventListener('click', async () => {
|
||||
try {
|
||||
const modelNameInput = document.getElementById('model-name');
|
||||
const modelName = modelNameInput.value || 'mixly-model';
|
||||
|
||||
const model = await tf.loadLayersModel(
|
||||
tf.io.browserFiles([jsonFile, ...weightFiles])
|
||||
);
|
||||
await model.save(`indexeddb://${modelName}`);
|
||||
loadAndDisplayAllModels();
|
||||
outputDiv.innerHTML = `模型已成功保存为 ${modelName}!`;
|
||||
} catch (error) {
|
||||
outputDiv.innerHTML = `保存模型出错: ${error.message}`;
|
||||
console.error(error);
|
||||
}
|
||||
})
|
||||
|
||||
content.querySelector('#refresh-models').addEventListener('click', loadAndDisplayAllModels);
|
||||
content.querySelector('#clear-models').addEventListener('click', async () => {
|
||||
if (confirm('确定要删除所有模型吗?此操作不可恢复!')) {
|
||||
await clearAllModels();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
createModal();
|
||||
|
||||
await loadAndDisplayAllModels();
|
||||
|
||||
function openModal() {
|
||||
loadAndDisplayAllModels();
|
||||
document.getElementById('modalOverlay').style.display = 'block';
|
||||
}
|
||||
|
||||
const workspace = Blockly.getMainWorkspace();
|
||||
workspace.registerButtonCallback('handleModels', function () {
|
||||
openModal();
|
||||
});
|
||||
|
||||
|
||||
async function prepare_qmyixtxi(imgTensor) {
|
||||
let net = null;
|
||||
|
||||
if (window.featureExtractor) {
|
||||
net = window.featureExtractor;
|
||||
} else {
|
||||
net = await tf.loadGraphModel("../common/media/tfmodel/model.json");
|
||||
window.featureExtractor = net;
|
||||
}
|
||||
const preprocessedImg = imgTensor
|
||||
.resizeBilinear([224, 224])
|
||||
.toFloat()
|
||||
.div(tf.scalar(127.5))
|
||||
.sub(tf.scalar(1))
|
||||
.expandDims(0);
|
||||
|
||||
const features = featureExtractor.predict(preprocessedImg);
|
||||
|
||||
let activation = features;
|
||||
return activation;
|
||||
}
|
||||
window.prepare_qmyixtxi = prepare_qmyixtxi;
|
||||
|
||||
@@ -8,31 +8,6 @@ import teachableModel from './components/teachableModel.vue';
|
||||
// import 'element-plus/theme-chalk/el-notification.css';
|
||||
// import './styles/index.scss';
|
||||
|
||||
|
||||
const userInfo = reactive({
|
||||
is_Login: false,
|
||||
username: '',
|
||||
password: '',
|
||||
})
|
||||
|
||||
const apiurl = ref('http://127.0.0.1:5174')
|
||||
provide('apiurl', apiurl.value)
|
||||
|
||||
provide('userInfo', userInfo)
|
||||
onMounted(() => {
|
||||
// 获取当前的LS里的已经登录的信息
|
||||
if (
|
||||
localStorage.getItem('myAIplatformUsername')
|
||||
&& localStorage.getItem('myAIplatformPassword')
|
||||
) {
|
||||
userInfo.is_Login = true
|
||||
userInfo.username = localStorage.getItem('myAIplatformUsername')
|
||||
userInfo.password = localStorage.getItem('myAIplatformPassword')
|
||||
}
|
||||
else {
|
||||
userInfo.is_Login = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
<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 * 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';
|
||||
@@ -16,459 +25,493 @@ import { Env } from 'mixly';
|
||||
// import 'element-plus/theme-chalk/el-progress.css';
|
||||
// import 'element-plus/theme-chalk/el-upload.css';
|
||||
|
||||
|
||||
const emit = defineEmits(['shot'])
|
||||
const emit = defineEmits(["shot"]);
|
||||
// 类别及其样本的列表
|
||||
const picList = inject('picList')
|
||||
const picList = inject("picList");
|
||||
// 图片列表
|
||||
const shotList = inject('shotList')
|
||||
const shotList = inject("shotList");
|
||||
// 训练状态
|
||||
const states = inject('states')
|
||||
const states = inject("states");
|
||||
|
||||
// 用来显示进度条和名称
|
||||
const classList = ref(
|
||||
picList.value.map((item, idx) => ({
|
||||
name: item.title,
|
||||
progress: 0,
|
||||
})),
|
||||
)
|
||||
const progressColors = ['#FF6F61', '#42A5F5', '#66BB6A', '#FFA726', '#AB47BC']
|
||||
picList.value.map((item, idx) => ({
|
||||
name: item.title,
|
||||
progress: 0,
|
||||
}))
|
||||
);
|
||||
const progressColors = ["#FF6F61", "#42A5F5", "#66BB6A", "#FFA726", "#AB47BC"];
|
||||
// 用来存储模型
|
||||
let featureExtractor
|
||||
let featureExtractor;
|
||||
// 用来存储损失值
|
||||
let lossValues = []
|
||||
let lossValues = [];
|
||||
// 存储被训练的模型
|
||||
let model
|
||||
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'))
|
||||
// 可视化相关
|
||||
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 = ''
|
||||
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'],
|
||||
// 准备数据
|
||||
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",
|
||||
})
|
||||
|
||||
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'],
|
||||
}),
|
||||
);
|
||||
// 添加分类层
|
||||
model.add(
|
||||
tf.layers.dense({
|
||||
units: NUM_CLASSES,
|
||||
activation: "softmax",
|
||||
})
|
||||
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
|
||||
);
|
||||
// 编译模型
|
||||
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)
|
||||
// 没训练完成跳过
|
||||
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%')
|
||||
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}`)
|
||||
}
|
||||
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()
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (e) => {
|
||||
uploadedImg.value = e.target.result;
|
||||
if (!featureExtractor || !model) {
|
||||
ElMessage.error("请先完成模型训练");
|
||||
return false;
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
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 = ''
|
||||
uploadedImg.value = "";
|
||||
}
|
||||
|
||||
let startX = 0
|
||||
let startY = 0
|
||||
let startTop = 0
|
||||
let startRight = 0
|
||||
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)
|
||||
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)
|
||||
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`
|
||||
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)
|
||||
document.removeEventListener("mousemove", onDragging);
|
||||
document.removeEventListener("mouseup", onDragEnd);
|
||||
}
|
||||
|
||||
defineExpose({ train })
|
||||
defineExpose({ train });
|
||||
|
||||
const modelName = ref('')
|
||||
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}`)
|
||||
}
|
||||
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>
|
||||
<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>
|
||||
<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 class="model-item">
|
||||
<b>输入</b>
|
||||
</ElRow>
|
||||
<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 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;
|
||||
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;
|
||||
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;
|
||||
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;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.model-area {
|
||||
margin: 10px auto;
|
||||
width: 85%;
|
||||
max-width: 300px;
|
||||
border-radius: 30px;
|
||||
margin: 10px auto;
|
||||
width: 85%;
|
||||
max-width: 300px;
|
||||
border-radius: 30px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height: 10px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.model-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user