Commit afd50a86 authored by Administrator's avatar Administrator

样本工具改造v3

parent 6fd406f8
...@@ -23,6 +23,7 @@ declare module 'vue' { ...@@ -23,6 +23,7 @@ declare module 'vue' {
ElSteps: typeof import('element-plus/es')['ElSteps'] ElSteps: typeof import('element-plus/es')['ElSteps']
ElSwitch: typeof import('element-plus/es')['ElSwitch'] ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElText: typeof import('element-plus/es')['ElText'] ElText: typeof import('element-plus/es')['ElText']
ElTree: typeof import('element-plus/es')['ElTree']
ElUpload: typeof import('element-plus/es')['ElUpload'] ElUpload: typeof import('element-plus/es')['ElUpload']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']
......
...@@ -3,6 +3,7 @@ import HomeView from '../views/home/index.vue' ...@@ -3,6 +3,7 @@ import HomeView from '../views/home/index.vue'
import SampleHandleView from '../views/sample_handle/index.vue' import SampleHandleView from '../views/sample_handle/index.vue'
import WmNanoBananaView from '../views/wm_nano_banana/index.vue' import WmNanoBananaView from '../views/wm_nano_banana/index.vue'
import SampleHandleViewV2 from '../views/sample_handle_v2/index.vue' import SampleHandleViewV2 from '../views/sample_handle_v2/index.vue'
import SampleHandleViewV3 from '../views/sample_handle_v3/index.vue'
const router = createRouter({ const router = createRouter({
history: createWebHashHistory(import.meta.env.BASE_URL), history: createWebHashHistory(import.meta.env.BASE_URL),
...@@ -10,7 +11,7 @@ const router = createRouter({ ...@@ -10,7 +11,7 @@ const router = createRouter({
{ {
path: '/', path: '/',
name: 'home', name: 'home',
component: SampleHandleViewV2 component: SampleHandleViewV3
}, },
{ {
path: '/report', path: '/report',
...@@ -32,6 +33,11 @@ const router = createRouter({ ...@@ -32,6 +33,11 @@ const router = createRouter({
name: 'sample_handle_v2', name: 'sample_handle_v2',
component: SampleHandleViewV2 component: SampleHandleViewV2
}, },
{
path: '/sample_handle_v3',
name: 'sample_handle_v3',
component: SampleHandleViewV3
},
] ]
}) })
......
import aitoolsService from '@/api/service/aitoolsService'
// 检测设备类型
export const detectDeviceType = () => {
const userAgent = navigator.userAgent.toLowerCase()
let source = ''
if (userAgent.match(/mobile/i) || userAgent.match(/android/i) || userAgent.match(/iphone/i) || userAgent.match(/ipad/i)) {
source = 'mobile'
// if (userAgent.match(/iphone/i) || userAgent.match(/ipad/i)) {
// source = 'ios'
// } else if (userAgent.match(/android/i)) {
// source = 'android'
// }
if (userAgent.match(/micromessenger/i)) {
source = 'wechat'
}
} else {
source = 'pc'
}
console.log('设备类型:', source)
return source
}
// 记录用户操作
export const trackUserAction = async (source: string, user_id: string, content: string, action: string) => {
let param: any = {
source: source,
user_id: user_id,
content: content,
action: action,
}
aitoolsService.commonApi('提交用户轨迹数据', 'track', param)
.then((response) => {
if (response == 'ok') {
console.log(action, '上报成功')
} else {
console.log(action, '上报失败')
}
})
.catch((error) => {
console.log(action, '上报失败:', error)
})
}
import aitoolsService from '@/api/service/aitoolsService';
import { trackUserAction } from './common';
import { ElMessage, ElMessageBox } from 'element-plus';
import utils from '@/utils/utils';
import { reactive, ref } from 'vue';
// 客户留资
export const myCustomerInfo = (form: any, title: any) => {
const customer = reactive({
name: '',
mobile: '',
company: '',
note: '',
})
const customerInfoVisible = ref(false)
const onCustomerInfoSubmit = async () => {
if (!customer.name || !customer.mobile) {
ElMessage({
message: '请填写姓名和手机号',
type: 'warning'
})
return
}
if (!utils.isPhone(customer.mobile)) {
ElMessage({
message: '手机号格式不正确',
type: 'warning'
})
return
}
try {
console.log(customer)
let param: any = {
name: customer.name,
mobile: customer.mobile,
company: customer.company,
source: form.source,
note: customer.note,
}
await aitoolsService.commonApi('记录客户信息', 'take_customer_info', param);
// 统计
trackUserAction(form.source, form.user_id, title.value, '客户留资')
customerInfoVisible.value = false
// 防止重复提交
localStorage.setItem("isSubmitCustomerInfo", 'yes');
// 新开一个浏览器窗口,打开一个链接
openNewWindow();
} catch (error) {
ElMessage({
message: String(error),
type: 'error'
})
}
}
const openNewWindow = () => {
if (form.source === 'pc') {
window.open(form.report_url, '_blank');
} else {
if (form.source === 'wechat') {
ElMessageBox.confirm(
'请先<span style="color: red;">「点击确认」</span>查看完整报告,如需保存报告到手机,再至右上角选择<span style="color: red;">「默认浏览器」</span>打开',
'微信提示',
{
confirmButtonText: '确认',
type: 'info',
center: true,
showClose: false,
showCancelButton: false,
dangerouslyUseHTMLString: true,
closeOnClickModal: false,
closeOnPressEscape: false,
}
).then(() => {
window.location.href = form.report_url;
})
} else {
window.location.href = form.report_url;
}
}
}
const onClickDownloadPDF = () => {
const isSubmitCustomerInfo = localStorage.getItem("isSubmitCustomerInfo");
console.log('isSubmitCustomerInfo =', isSubmitCustomerInfo);
if (isSubmitCustomerInfo === 'yes') {
customerInfoVisible.value = false
// 直接打开链接
openNewWindow();
} else {
customerInfoVisible.value = true
}
}
return {
customer,
customerInfoVisible,
onCustomerInfoSubmit,
onClickDownloadPDF,
}
}
import { ref } from 'vue'
import {
ElMessage,
genFileId,
type UploadInstance,
type UploadProps,
type UploadRawFile,
type UploadFile,
} from 'element-plus'
export const myFileUpload = (FileSizeLimitM: number, form: any, loading: any) => {
// ############ 处理上传文件 begin #############
const upload = ref<UploadInstance>()
const actionUrl = ref(
import.meta.env.MODE === 'production'
? '/file'
: import.meta.env.VITE_APP_BASE_API + '/file'
)
const handleBeforeUpload = async (file: any) => {
const isLimit = file.size / 1024 / 1024 <= FileSizeLimitM
if (!isLimit) {
ElMessage.error('上传文件大小不能超过 '+FileSizeLimitM+'MB!')
return false
}
}
const handleUploadSuccess = (val: Wm.UploadResult, file: UploadFile) => {
// console.log(val)
if (val.code == 0) {
form.file_path = val.data[0].path
console.log('form.file_path =', form.file_path)
form.file_path_domain = val.data[0].url
console.log('form.file_path_domain =', form.file_path_domain)
// console.log('file =', file.name, file.size, file.url)
if (file.name && file.size !== undefined) {
form.file_name = file.name
form.file_size = file.size
}
ElMessage({
message: '上传成功',
type: 'success'
})
} else {
ElMessage({
message: '上传失败:' + val.message,
type: 'error'
})
}
loading.value = false
}
const handleUploadExceed: UploadProps['onExceed'] = (files) => {
// 清除已上传的文件
upload.value!.clearFiles()
// 获取超出限制的第一个文件
const file = files[0] as UploadRawFile
// 给文件分配一个新的唯一标识
file.uid = genFileId()
// 手动触发文件上传
upload.value!.handleStart(file)
// 提交上传
upload.value!.submit()
}
const handleUploadError = (error: Error) => {
ElMessage({
message: String(error.message),
type: 'error'
})
loading.value = false
}
const handleUploadProgress = (event: ProgressEvent, file: UploadFile) => {
file.percentage = Math.round(event.loaded / event.total * 100)
console.log('上传进度:', file.percentage)
form.file_uploading_progress = file.percentage
file.url = '/video-icon.png'
// 根据进度控制 loading 状态
if (file.percentage <= 100) {
loading.value = true
}
// if (file.percentage == 100) {
// loading.value = false
// }
}
const handleRemoveFile = () => {
// 清除已上传的文件
// upload.value!.clearFiles()
form.file_path = ""
form.file_path_domain = ""
form.file_name = ""
form.file_size = 0
console.log('文件列表移除File, form.file_path =', form.file_path, ', form.file_path_domain =', form.file_path_domain)
}
// ############ 处理上传文件 end #############
return {
loading,
upload,
actionUrl,
handleBeforeUpload,
handleUploadSuccess,
handleUploadExceed,
handleUploadError,
handleUploadProgress,
handleRemoveFile,
}
}
import aitoolsService from '@/api/service/aitoolsService'
import utils from '@/utils/utils'
import {
ElMessage,
ElLoading,
ElMessageBox
} from 'element-plus'
import type { Action } from 'element-plus'
import { nextTick, ref } from 'vue'
export const onProcessing = (form: any, steps_active: any, process_loading: any, result_loading: any, dialogVisible: any) => {
const fileToClassify = async () => {
if (!form.file_path || form.file_path.length == 0) {
ElMessage({
message: '请先上传文件',
type: 'warning'
})
return
}
// 进入第二页
from_first_to_second();
// 清除结果
form.sample_result = {
root_path: '',
data: [{
dir_name: '',
images: [],
}]
};
// 提交处理请求
try {
let param: any = {
task_id: form.task_id,
file_path: form.file_path,
crop_range: JSON.stringify(form.crop_range),
box_range: JSON.stringify(form.box_range),
output_type: "dir",
classes_select: "",
target_fps: form.target_fps, // 目标帧率
every_x_gen_dir: form.every_x_gen_dir, // 每多少图片生成一个目录
slider_window: form.slider_window_2 ? 2 : 0, // 是否开启滑窗2张图
}
process_loading.value = true;
// 发起请求
aitoolsService.commonApi('提交处理', 'gen_sample_from_video', param)
.then((response) => {
// console.log(form)
console.log(`接口返回:${response}`);
form.sample_result = response;
// 设置默认值
form.classes_select = {}
form.classes_select2 = {}
response.data.forEach((item: { dir_name: string; images: string[] }) => {
form.classes_select[item.dir_name] = '0';
form.classes_select2[item.dir_name] = '';
});
process_loading.value = false;
})
.catch((error) => {
ElMessage({
message: error,
type: 'error'
});
// // 重置task_id
// form.task_id = utils.genDateTimeStr();
// console.log('重置 task_id =', form.task_id);
process_loading.value = false;
})
} catch (error: any) {
ElMessage({
message: error,
type: 'error'
});
process_loading.value = false;
}
}
function isAllDataClassified(): boolean {
let is_ok = false;
if (!form.sample_result?.data || !form.classes_select) {
ElMessage({
message: '数据未正确初始化',
type: 'error'
});
}
form.sample_result.data.every((item: { dir_name: string; images: string[] }) => {
if (!form.classes_select.hasOwnProperty(item.dir_name)) {
ElMessage({
message: `请为${item.dir_name}分类`,
type: 'error'
});
} else {
is_ok = true
}
});
return is_ok;
}
const gen_sample = async (param: any) => {
result_loading.value = true;
// 提交处理请求
try {
const response = await aitoolsService.commonApi('提交处理', 'gen_sample_from_video', param)
console.log(`接口返回:${response}`);
return response.sample_path;
} catch (error: any) {
ElMessage({
message: error,
type: 'error'
});
} finally {
result_loading.value = false;
}
}
const classifyToDownload = async () => {
if (!isAllDataClassified()) {
return
}
// 进入第3页
from_second_to_third();
// 清除结果
form.sample_path = '';
// 生成样本包
let param: any = {
task_id: form.task_id,
file_path: form.file_path,
crop_range: JSON.stringify(form.crop_range),
box_range: JSON.stringify(form.box_range),
output_type: "zip",
classes_select: JSON.stringify(form.classes_select),
target_fps: form.target_fps, // 目标帧率
}
form.sample_path = await gen_sample(param);
}
const from_first_to_second = () => {
steps_active.value = 1
}
const back_to_first = () => {
steps_active.value = 0;
form.task_id = utils.genDateTimeStr();
console.log('返回首页,task_id =', form.task_id);
}
const from_second_to_third = () => {
steps_active.value = 2;
}
const back_to_second = () => {
steps_active.value = 1;
form.classes_select2 = JSON.parse(JSON.stringify(form.classes_select));
}
function downloadFile(url: string, filename?: string) {
const a = document.createElement('a')
a.href = url
if (filename) {
a.download = filename // 指定下载文件名
}
a.target = '_blank'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
function deleteOneSample(sample_dir: string) {
ElMessageBox.alert(`确认删除 ${sample_dir} 这行样本吗?`, '删除样本', {
// autofocus: false,
confirmButtonText: '删除',
callback: (action: Action) => {
if (action === 'confirm') {
// 删除 form.sample_result.data
const index = form.sample_result.data.findIndex((item: any) => item.dir_name === sample_dir);
form.sample_result.data.splice(index, 1);
// 删除 form.classes_select[sample_dir]
delete form.classes_select[sample_dir];
// 删除 form.classes_select2[sample_dir]
delete form.classes_select2[sample_dir];
ElMessage({
type: 'success',
message: '删除成功'
})
}
},
})
}
const checkSampleVideo = async (dir_name: string) => {
// 提交处理请求
try {
let param: any = {
task_id: form.task_id,
file_path: form.file_path,
dir_name: dir_name,
}
process_loading.value = true;
// 发起请求
const response = await aitoolsService.commonApi('提交处理', 'gen_clip_from_dirname', param);
console.log(`接口返回:${response}`);
process_loading.value = false;
dialogVisible.value = true;
// 使用 nextTick 等待 DOM 更新完成
await nextTick();
const clipVideo = document.getElementById('clip-video') as HTMLVideoElement;
console.log(clipVideo);
if (clipVideo && typeof response === 'string' && response.startsWith('http')) {
clipVideo.src = response;
} else {
ElMessage({
message: response as string,
type: 'error'
});
}
} catch (error: any) {
ElMessage({
message: error as string,
type: 'error'
});
process_loading.value = false;
}
}
const onCheckedListChange = (newValue: any) => {
// console.log('CheckedList changed:', newValue);
console.log('CheckedList changed:', form.checkedList);
}
function deleteMutiSample() {
ElMessageBox.alert(`确认删除所选的这些样本吗?共${form.checkedList.length}行`, '批量删除样本', {
// autofocus: false,
confirmButtonText: '删除',
callback: (action: Action) => {
if (action === 'confirm') {
form.checkedList.forEach((sample_dir: string) => {
// 删除 form.sample_result.data
const index = form.sample_result.data.findIndex((item: any) => item.dir_name === sample_dir);
form.sample_result.data.splice(index, 1);
// 删除 form.classes_select[sample_dir]
delete form.classes_select[sample_dir];
// 删除 form.classes_select2[sample_dir]
delete form.classes_select2[sample_dir];
})
// 清空已选择列表
form.checkedList = [];
ElMessage({
type: 'success',
message: '删除成功'
})
}
},
})
}
return {
from_first_to_second,
back_to_first,
from_second_to_third,
back_to_second,
fileToClassify,
classifyToDownload,
downloadFile,
process_loading,
result_loading,
deleteOneSample,
checkSampleVideo,
onCheckedListChange,
deleteMutiSample,
gen_sample,
}
}
\ No newline at end of file
import { ref } from 'vue'
import {
ElMessage,
} from 'element-plus'
export function useCropBox(form: any, canvas: any, ctx: any) {
const isDrawing = ref(false)
const startX = ref(0)
const startY = ref(0)
const endX = ref(0)
const endY = ref(0)
const boxes = ref<Array<{ start: number[], end: number[] }>>([])
function generate_canvas() {
const el = document.getElementById('crop-canvas') as HTMLCanvasElement
if (el) {
canvas.value = el
ctx.value = canvas.value.getContext('2d')
// 设置 canvas 尺寸与视频一致
const video = document.getElementById('video-player') as HTMLVideoElement
if (video) {
if (video.videoWidth > 0 && video.videoHeight > 0) {
console.log('video size =', video.videoWidth, video.videoHeight)
canvas.value.width = video.videoWidth
canvas.value.height = video.videoHeight
canvas.value.style.zIndex = '2'
drawBoxes()
}
}
}
}
function startDrawing(e: MouseEvent) {
if (boxes.value.length >= 1) {
while (boxes.value.length > 1) {
boxes.value.pop()
}
return
}
const rect = canvas.value!.getBoundingClientRect()
isDrawing.value = true
startX.value = e.clientX - rect.left
startY.value = e.clientY - rect.top
endX.value = startX.value
endY.value = startY.value
console.log('开始绘制,起点:', startX.value, startY.value)
}
function draw(e: MouseEvent) {
if (!isDrawing.value) return
const rect = canvas.value!.getBoundingClientRect()
endX.value = e.clientX - rect.left
endY.value = e.clientY - rect.top
clearCanvas()
drawBoxes()
drawRect(startX.value, startY.value, endX.value - startX.value, endY.value - startY.value)
}
function endDrawing() {
if (!isDrawing.value) return
isDrawing.value = false
boxes.value.push({
start: [startX.value, startY.value],
end: [endX.value, endY.value]
})
clearCanvas()
drawBoxes()
console.log('结束绘制,终点:', endX.value, endY.value)
console.log('框', boxes.value.length, '个: ', boxes.value)
// if (boxes.value.length === 1) {
// form.crop_range = boxes.value[0]
// }
// if (boxes.value.length === 2) {
// if (boxes.value[1].start[0] < boxes.value[0].start[0]
// || boxes.value[1].start[1] < boxes.value[0].start[1]
// || boxes.value[1].end[0] > boxes.value[0].end[0]
// || boxes.value[1].end[1] > boxes.value[0].end[1]) {
// ElMessage({
// message: '扫描框必须在裁剪框内',
// type: 'warning'
// })
// reset()
// } else {
// form.box_range = boxes.value[1]
// form.is_set = true
// }
// }
if (boxes.value.length === 1) {
form.box_range = boxes.value[0]
form.is_set = true
}
}
function drawRect(x: number, y: number, width: number, height: number) {
const c = ctx.value!
c.strokeStyle = 'green'
c.lineWidth = 2
c.strokeRect(x, y, width, height)
}
function drawBoxes() {
boxes.value.forEach(box => {
drawRect(box.start[0], box.start[1], box.end[0] - box.start[0], box.end[1] - box.start[1])
})
}
function clearCanvas() {
const c = canvas.value!
ctx.value!.clearRect(0, 0, c.width, c.height)
}
function reset() {
if (boxes.value.length === 0) {
return
}
while (boxes.value.length > 0) {
boxes.value.pop()
}
clearCanvas()
form.crop_range = {}
form.box_range = {}
form.is_set = false
console.log('重置绘制数据')
}
return {
startDrawing,
draw,
endDrawing,
clearCanvas,
reset,
generate_canvas
}
}
\ No newline at end of file
import { ref } from 'vue'
import {
ElMessage,
} from 'element-plus'
export function useCropBox(form: any, canvas: any, ctx: any) {
const isDrawing = ref(false)
const startX = ref(0)
const startY = ref(0)
const endX = ref(0)
const endY = ref(0)
const boxes = ref<Array<{ start: number[], end: number[] }>>([])
const draggingBoxIndex = ref<number | null>(null) // 正在拖动的框索引
const resizingBoxIndex = ref<number | null>(null) // 正在缩放的框索引
const resizeHandle = ref<'nw' | 'ne' | 'sw' | 'se' | null>(null) // 缩放角方向
const dragStart = ref({ x: 0, y: 0 }) // 拖动起始点
const boxStart = ref({ x: 0, y: 0, width: 0, height: 0 }) // 框原始位置
function isInsideBox(x: number, y: number, box: any): boolean {
return (
x >= box.start[0] &&
x <= box.end[0] &&
y >= box.start[1] &&
y <= box.end[1]
)
}
function getHandleAt(x: number, y: number, box: any): 'nw' | 'ne' | 'sw' | 'se' | null {
const size = 10
const [x1, y1] = box.start
const [x2, y2] = box.end
if (Math.abs(x - x1) < size && Math.abs(y - y1) < size) return 'nw'
if (Math.abs(x - x2) < size && Math.abs(y - y1) < size) return 'ne'
if (Math.abs(x - x1) < size && Math.abs(y - y2) < size) return 'sw'
if (Math.abs(x - x2) < size && Math.abs(y - y2) < size) return 'se'
return null
}
function onMouseDown(e: MouseEvent) {
const rect = canvas.value!.getBoundingClientRect()
const x = e.clientX - rect.left
const y = e.clientY - rect.top
// 检查是否点击到了某个框的角
for (let i = 0; i < boxes.value.length; i++) {
const box = boxes.value[i]
const handle = getHandleAt(x, y, box)
if (handle) {
resizingBoxIndex.value = i
resizeHandle.value = handle
return
}
if (isInsideBox(x, y, box)) {
draggingBoxIndex.value = i
dragStart.value = { x, y }
const [bx1, by1] = box.start
const [bx2, by2] = box.end
boxStart.value = {
x: bx1,
y: by1,
width: bx2 - bx1,
height: by2 - by1
}
return
}
}
}
function onMouseMove(e: MouseEvent) {
const rect = canvas.value!.getBoundingClientRect()
const x = e.clientX - rect.left
const y = e.clientY - rect.top
if (resizingBoxIndex.value !== null && resizeHandle.value) {
const i = resizingBoxIndex.value
const box = boxes.value[i]
const [bx1, by1] = box.start
const [bx2, by2] = box.end
switch (resizeHandle.value) {
case 'nw':
boxes.value[i] = { start: [x, y], end: [bx2, by2] }
break
case 'ne':
boxes.value[i] = { start: [bx1, y], end: [x, by2] }
break
case 'sw':
boxes.value[i] = { start: [x, by1], end: [bx2, y] }
break
case 'se':
boxes.value[i] = { start: [bx1, by1], end: [x, y] }
break
}
clearCanvas()
drawBoxes()
if (i === 0) {
form.crop_range = boxes.value[0]
} else {
form.box_range = boxes.value[1]
}
} else if (draggingBoxIndex.value !== null) {
const i = draggingBoxIndex.value
const dx = x - dragStart.value.x
const dy = y - dragStart.value.y
const box = boxes.value[i]
boxes.value[i] = {
start: [box.start[0] + dx, box.start[1] + dy],
end: [box.end[0] + dx, box.end[1] + dy]
}
clearCanvas()
drawBoxes()
if (i === 0) {
form.crop_range = boxes.value[0]
} else {
form.box_range = boxes.value[1]
}
}
}
function onMouseUp() {
draggingBoxIndex.value = null
resizingBoxIndex.value = null
resizeHandle.value = null
}
function generate_canvas() {
const el = document.getElementById('crop-canvas') as HTMLCanvasElement
if (el) {
canvas.value = el
ctx.value = canvas.value.getContext('2d')
// 设置 canvas 尺寸与视频一致
const video = document.getElementById('video-player') as HTMLVideoElement
if (video) {
if (video.videoWidth > 0 && video.videoHeight > 0) {
console.log('video size =', video.videoWidth, video.videoHeight)
canvas.value.width = video.videoWidth
canvas.value.height = video.videoHeight
canvas.value.style.zIndex = '2'
drawBoxes()
}
}
// 绑定事件监听器
canvas.value.addEventListener('mousedown', onMouseDown)
canvas.value.addEventListener('mousemove', onMouseMove)
canvas.value.addEventListener('mouseup', onMouseUp)
canvas.value.addEventListener('mouseleave', onMouseUp)
}
}
function startDrawing(e: MouseEvent) {
if (boxes.value.length >= 2) {
while (boxes.value.length > 2) {
boxes.value.pop()
}
return
}
const rect = canvas.value!.getBoundingClientRect()
isDrawing.value = true
startX.value = e.clientX - rect.left
startY.value = e.clientY - rect.top
endX.value = startX.value
endY.value = startY.value
console.log('开始绘制,起点:', startX.value, startY.value)
}
function draw(e: MouseEvent) {
if (!isDrawing.value) return
const rect = canvas.value!.getBoundingClientRect()
endX.value = e.clientX - rect.left
endY.value = e.clientY - rect.top
clearCanvas()
drawBoxes()
drawRect(startX.value, startY.value, endX.value - startX.value, endY.value - startY.value)
}
function endDrawing() {
if (!isDrawing.value) return
isDrawing.value = false
boxes.value.push({
start: [startX.value, startY.value],
end: [endX.value, endY.value]
})
clearCanvas()
drawBoxes()
console.log('结束绘制,终点:', endX.value, endY.value)
console.log('框', boxes.value.length, '个: ', boxes.value)
if (boxes.value.length === 1) {
form.crop_range = boxes.value[0]
}
if (boxes.value.length === 2) {
if (boxes.value[1].start[0] < boxes.value[0].start[0]
|| boxes.value[1].start[1] < boxes.value[0].start[1]
|| boxes.value[1].end[0] > boxes.value[0].end[0]
|| boxes.value[1].end[1] > boxes.value[0].end[1]) {
ElMessage({
message: '扫描框必须在裁剪框内',
type: 'warning'
})
reset()
} else {
form.box_range = boxes.value[1]
form.is_set = true
}
}
}
function drawRect(x: number, y: number, width: number, height: number) {
const c = ctx.value!
c.strokeStyle = 'green'
c.lineWidth = 2
c.strokeRect(x, y, width, height)
}
function drawBoxes() {
boxes.value.forEach(box => {
drawRect(box.start[0], box.start[1], box.end[0] - box.start[0], box.end[1] - box.start[1])
})
}
function clearCanvas() {
const c = canvas.value!
ctx.value!.clearRect(0, 0, c.width, c.height)
}
function reset() {
if (boxes.value.length === 0) {
return
}
while (boxes.value.length > 0) {
boxes.value.pop()
}
clearCanvas()
form.crop_range = {}
form.box_range = {}
form.is_set = false
console.log('重置绘制数据')
// 移除事件监听器
const el = document.getElementById('crop-canvas') as HTMLCanvasElement
if (el) {
el.removeEventListener('mousedown', onMouseDown)
el.removeEventListener('mousemove', onMouseMove)
el.removeEventListener('mouseup', onMouseUp)
el.removeEventListener('mouseleave', onMouseUp)
}
}
return {
startDrawing,
draw,
endDrawing,
clearCanvas,
reset,
generate_canvas
}
}
\ No newline at end of file
.home-container {
width: 100%;
}
.title {
:is(span) {
font-size: 25px;
font-weight: bold;
color: #181818;
}
text-align: center;
margin: 20px 0 0 0;
}
.subtitle {
:is(span) {
font-size: 13px;
color: #181818;
}
text-align: center;
margin: 0 0 20px 0;
}
.progress {
margin: 10px 50px;
text-align: center;
}
.content-container {
margin: 20px;
display: flex;
flex-direction: row; /* 默认横向排列 */
justify-content: center; /* 水平居中 */
/* box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); /* 添加阴影效果 */
border-radius: 8px; /* 可选:添加圆角 */
/* background-color: #fff; /* 可选:添加背景色 */
@media (max-width: 768px) { /* 当屏幕宽度小于768px时变为竖向排列 */
flex-direction: column;
}
}
/* 几个页面的公共样式 */
.upload-section,
.progress-section,
.report-section {
padding: 20px;
/* 标题 */
.section-title {
font-size: 18px;
font-weight: bold;
color: #181818;
}
/* 描述 */
.section-desc {
font-size: 12px;
}
}
/* 上传页 */
.upload-section {
/* 上传区域 */
.upload-div {
margin: 20px 0;
/* border: 2px dashed #dddfe5; */
border-radius: 4px;
}
/* 已上传文件 */
.uploaded-div {
margin: 20px 0;
padding: 20px 10px;
border-radius: 4px;
border: 1px dashed #dddfe5; /* 添加边框样式 */
.uploaded-file-info {
position: relative;
width: auto;
}
}
/* 按钮 */
.button {
display: flex;
flex-direction: column;
align-items: flex-start;
.next {
align-self: flex-end;
}
}
}
/* 处理页 */
.progress-section {
.row {
display: flex;
margin: 10px 10px 0 0;
flex-wrap: wrap; /* 如果图片过多,允许换行 */
.images {
/* width: auto; */
/* display: inline-block; */
margin-right: 10px; /* 可选:为每个图片之间添加间距 */
}
}
/* 按钮 */
.button {
background-color: #f56c6c;;
margin-left: 22px;
padding: 10px;
display: flex;
flex-direction: row; /* 修改为行排列 */
justify-content: space-between; /* 使按钮两端对齐 */
align-items: center; /* 垂直居中对齐 */
position: fixed;
bottom: 20px;
gap: 30px;
}
}
/* 结果页*/
.report-section {
/* 按钮 */
.button {
margin: 20px 0;
display: flex;
flex-direction: row; /* 修改为行排列 */
justify-content: space-between; /* 使按钮两端对齐 */
align-items: center; /* 垂直居中对齐 */
}
}
.folder-viewer {
padding: 20px;
display: flex;
flex-direction: column;
gap: 20px;
}
.toolbar {
display: flex;
gap: 10px;
}
.custom-tree-node {
flex: 1;
display: flex;
align-items: center;
justify-content: space-between;
font-size: 14px;
padding-right: 8px;
}
\ No newline at end of file
<script setup lang="ts">
import utils from '@/utils/utils'
import { onUpdated, onMounted, reactive, ref } from 'vue'
import { detectDeviceType } from './compositions/common'
import { myFileUpload } from './compositions/fileUpload'
import { useCropBox } from './compositions/useCropBox'
import { onProcessing } from './compositions/process'
import {
Check,
Delete,
Edit,
Message,
Search,
Star,
} from '@element-plus/icons-vue'
import aitoolsService from '@/api/service/aitoolsService'
const title = ref('元芒数字')
const form = reactive({
source: '',
task_id: '',
file_path: '',
file_path_domain: '',
file_name: '',
file_size: 0,
file_uploading_progress: 0,
crop_range: {},
box_range: {},
is_set: false,
classes_select: <Record<string, string>>({}),
classes_select2: <Record<string, string>>({}),
sample_result: {
root_path: '',
data: [{
dir_name: '',
images: [],
}]
}, // 样本处理结果
sample_path: '',
// 目标帧率
target_fps: '10',
// 图片显示的大小
image_width: '100',
// 查看视频的按钮的类型
checkClipVideoBtnType: <Record<string, string>>({}),
// 每多少图片生成一个目录
every_x_gen_dir: 10,
// 选中的行
checkedList: [] as string[],
// 滑窗2张图
slider_window_2: false,
})
const steps_active = ref(-1)
const FileSizeLimitM = 500
const file_loading = ref(false)
const my_file_upload = myFileUpload(FileSizeLimitM, form, file_loading)
const upload = my_file_upload.upload
const actionUrl = my_file_upload.actionUrl
// 裁剪
const videoPlayer = ref<HTMLVideoElement | null>(null)
const canvas = ref<HTMLCanvasElement | null>(null)
const ctx = ref<CanvasRenderingContext2D | null>(null)
const cropBox = useCropBox(form, canvas, ctx)
// 处理
const classes = [
{value:'0',label:'0'},{value:'1',label:'1'},{value:'2',label:'2'},{value:'3',label:'3'},{value:'4',label:'4'},
{value:'5',label:'5'},{value:'6',label:'6'},{value:'7',label:'7'},{value:'8',label:'8'},{value:'9',label:'9'},
]
const process_loading = ref(false)
const result_loading = ref(false)
const dialogVisible = ref(false)
const processing = onProcessing(form, steps_active, process_loading, result_loading, dialogVisible)
// 目标帧率选项
const fps_options = [
{value: '10', label: '10'},
{value: '25', label: '25'},
]
// 每多少图片生成一个目录选项
const every_x_gen_dir_options = [
{value: 10, label: '10'},
{value: 5, label: '5'},
{value: 3, label: '3'},
]
// 图片显示的大小选项
const image_width_options = [
{value: '100', label: '100'},
{value: '150', label: '150'},
{value: '200', label: '200'},
{value: '250', label: '250'},
{value: '300', label: '300'},
]
onMounted(async () => {
// 检测设备类型
form.source = detectDeviceType();
// 设置页面标题
document.title = title.value;
// 生成task_id
form.task_id = utils.genDateTimeStr();
console.log('页面加载,task_id =', form.task_id);
})
const onVideoLoaded = () => {
if (steps_active.value === 0 && form.is_set && form.sample_result.root_path !== '') {
console.log('切换到步骤 0 视频加载完成');
cropBox.generate_canvas();
}
};
const onCheckClipVideoBtnClick = (dir_name: string) => {
form.checkClipVideoBtnType[dir_name] = 'info';
processing.checkSampleVideo(dir_name);
};
// 文件夹选择相关
import axios from 'axios'
const folderInput = ref<HTMLInputElement | null>(null)
const treeRef = ref(null)
const treeData = ref([])
const expandedKeys = ref([])
const selectedFile = ref(null) // 当前选中的文件节点
const fileMap = new Map() // 相对路径 -> File 对象
const defaultProps = { children: 'children', label: 'label', disabled: 'disabled', }
// 定义 handledFiles 中每个对象的结构
interface HandledFile {
id: number;
relativePath: string;
name: string;
task_id: string;
file_path: string;
file_path_domain: string;
crop_range: string;
box_range: string;
classes_select: string;
target_fps: string;
sample_path: string;
}
// 使用定义的接口类型
const handledFiles = ref<HandledFile[]>([]);
/** 点击“选择文件夹” */
function chooseFolder() {
folderInput.value?.click()
}
/** 文件夹选择完成后 */
function handleFolderSelect(event: any) {
const files = event.target.files
if (!files?.length) return
fileMap.clear()
Array.from(files).forEach((file: any) => {
// 过滤隐藏文件和文件夹
if (file.webkitRelativePath.split('/').some((p: any) => p.startsWith('.'))) return
fileMap.set(file.webkitRelativePath, file)
})
treeData.value = buildTreeFromFiles(Array.from(files))
expandedKeys.value = collectAllIds(treeData.value)
event.target.value = ''
}
/** 构建树结构 */
function buildTreeFromFiles(files: any[]) {
const root = {}
let nodeId = 1
files.forEach(file => {
const relPath = file.webkitRelativePath
const pathParts = relPath.split('/')
// 过滤点开头的
if (pathParts.some(p => p.startsWith('.'))) return
let current = root
pathParts.forEach((part, index) => {
if (!current[part]) {
current[part] = {
__isFile: index === pathParts.length - 1,
__children: {},
__path: pathParts.slice(0, index + 1).join('/'),
}
}
current = current[part].__children
})
})
const traverse = obj =>
Object.entries(obj).map(([name, node]) => ({
id: nodeId++,
label: name,
relativePath: node.__path,
isLeaf: node.__isFile,
children: node.__isFile ? [] : traverse(node.__children),
handled: '未处理',
}))
return traverse(root)
}
/** 收集所有节点 id */
function collectAllIds(nodes: any[]) {
const ids: any[] = []
const loop = (arr: any[]) => {
arr.forEach(i => {
ids.push(i.id)
if (i.children?.length) loop(i.children)
})
}
loop(nodes)
return ids
}
/** 勾选状态变化 */
function handleCheckChange(node, checked) {
if (!node.isLeaf) {
treeRef.value?.setChecked(node, false, false)
return
}
if (checked) {
// 单选逻辑:取消其他勾选
const checkedKeys = treeRef.value?.getCheckedKeys() || []
checkedKeys.forEach(key => {
if (key !== node.id) treeRef.value.setChecked(key, false, false)
})
selectedFile.value = node
// console.log(selectedFile.value)
console.log('✅ 已选文件:', node.id, node.relativePath, node.label)
// console.log('对应的 File 对象:', fileMap.get(node.relativePath))
// console.log('文件大小(字节):', fileMap.get(node.relativePath)?.size)
// console.log('文件类型:', fileMap.get(node.relativePath)?.type)
}
// 如果整棵树都没有被选择节点
const anyChecked = treeRef.value?.getCheckedKeys().length > 0
if (!anyChecked) {
// console.log('❌ 整棵树都没有被选择节点')
selectedFile.value = null
}
}
/** 上传选中的文件 */
async function uploadSelectedFile() {
if (!selectedFile.value) {
return alert('请先选择一个文件!')
}
const file = fileMap.get(selectedFile.value.relativePath)
if (!file) {
return alert('找不到文件对象,可能被过滤掉了。')
}
try {
file_loading.value = true
const formData = new FormData()
formData.append('file', file)
formData.append('relativePath', selectedFile.value.relativePath)
const res = await axios.post(actionUrl.value, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
if (res.data.code == 0) {
form.file_path = res.data.data[0].path
console.log('form.file_path =', form.file_path)
form.file_path_domain = res.data.data[0].url
console.log('form.file_path_domain =', form.file_path_domain)
if (file.name && file.size !== undefined) {
form.file_name = file.name
form.file_size = file.size
}
console.log(form.file_name, form.file_size)
// console.log('✅ 上传成功:', res.data)
// alert('文件上传成功!')
// 进入下一步
steps_active.value = 0
} else {
alert('上传失败:' + res.data.message)
}
} catch (err) {
console.error('❌ 上传失败:', err)
alert('上传失败,请检查控制台。')
} finally {
file_loading.value = false
}
}
const continue_to_negative = () => {
// 保存已处理的信息
handledFiles.value.push({
id: selectedFile.value.id,
relativePath: selectedFile.value.relativePath,
name: selectedFile.value.label,
task_id: form.task_id,
file_path: form.file_path,
file_path_domain: form.file_path_domain,
crop_range: JSON.stringify(form.crop_range),
box_range: JSON.stringify(form.box_range),
classes_select: JSON.stringify(form.classes_select),
target_fps: form.target_fps, // 目标帧率
})
console.log('handledFiles =', handledFiles.value)
// 将已处理文件置灰
treeData.value = treeData.value.map(node => {
const disableNode = (n) => {
if (n.relativePath === selectedFile.value.relativePath) {
return { ...n, disabled: true, handled: '已处理' }
}
if (n.children?.length) {
return { ...n, children: n.children.map(disableNode) }
}
return n
}
return disableNode(node)
})
// 清理勾选
selectedFile.value = null
// 返回首页
steps_active.value = -1;
form.task_id = utils.genDateTimeStr();
console.log('返回首页,task_id =', form.task_id);
form.file_path = '';
form.file_path_domain = '';
form.file_name = '';
form.file_size = 0;
// form.crop_range = {};
// form.box_range = {};
form.classes_select = {};
// form.is_set = false;
}
const downloadAllSamples = async () => {
let params = [];
file_loading.value = true;
for (const fileInfo of handledFiles.value) {
params.push({
task_id: fileInfo.task_id,
file_path: fileInfo.file_path,
crop_range: fileInfo.crop_range,
box_range: fileInfo.box_range,
output_type: "zip",
classes_select: fileInfo.classes_select,
target_fps: fileInfo.target_fps, // 目标帧率
relativePath: fileInfo.relativePath,
});
}
try {
// 生成样本包
const response = await aitoolsService.commonApi('提交处理', 'gen_sample_from_video_muti', {params: params})
console.log(`接口返回:${response.sample_path}`);
if (response.sample_path) {
processing.downloadFile(response.sample_path);
} else {
alert(`生成样本包失败`);
}
} catch (error) {
console.error(`Failed to generate samples:`, error);
alert(`生成样本包失败: ${error}`);
} finally {
file_loading.value = false;
}
console.log(handledFiles.value);
};
</script>
<template>
<main class="home-container">
<!-- 标题 -->
<div class="title"><el-text>生成训练样本</el-text></div>
<div class="subtitle"><el-text>上传视频生成训练样本</el-text></div>
<!-- 步骤条 -->
<div>
<el-steps :active="steps_active" align-center finish-status="success">
<el-step title="上传视频并设置范围"/>
<el-step title="样本分类"/>
<el-step title="下载样本"/>
</el-steps>
</div>
<div class="content-container">
<!-- 选择文件夹目录 -->
<div class="folder-viewer" v-if="steps_active === -1" v-loading="file_loading">
<div class="toolbar">
<el-button type="primary" @click="chooseFolder">选择文件夹</el-button>
</div>
<div class="toolbar">
<el-tree
v-if="treeData.length"
ref="treeRef"
:data="treeData"
:props="defaultProps"
default-expand-all
:expand-on-click-node="false"
node-key="id"
show-checkbox
@check-change="handleCheckChange"
>
<template #default="{ node, data }">
<div class="custom-tree-node">
<span>{{ node.label }}</span>
<span style="margin-left: 5px; color: #67C23A;"> {{ node.disabled ? '已处理' : '' }}</span>
</div>
</template>
</el-tree>
</div>
<!-- 隐藏的文件夹选择输入框 -->
<input
ref="folderInput"
type="file"
webkitdirectory
hidden
@change="handleFolderSelect"
/>
<div class="toolbar">
<el-button v-if="selectedFile"
type="success"
:disabled="!selectedFile"
@click="uploadSelectedFile"
>
上传并设置范围
</el-button>
<el-button v-if="handledFiles.length>0"
type="danger"
@click="downloadAllSamples"
>
下载已处理完的样本
</el-button>
</div>
</div>
<!-- 文件页 -->
<div class="upload-section" v-if="steps_active === 0">
<div><el-text class="section-title">上传文件、设置范围(上传进度:{{ form.file_uploading_progress }}%)</el-text></div>
<div><el-text class="section-desc">上传视频后,可以播放、暂停,然后点击设置,先画裁剪框,再画扫描框</el-text></div>
<div class="upload-div" v-loading="file_loading" v-if="!form.file_path">
<el-upload
ref="upload"
:show-file-list="false"
:limit="1"
drag
accept=".mp4"
:action="actionUrl"
:on-success="my_file_upload.handleUploadSuccess"
:on-exceed="my_file_upload.handleUploadExceed"
:on-error="my_file_upload.handleUploadError"
:on-remove="my_file_upload.handleRemoveFile"
:before-upload="my_file_upload.handleBeforeUpload"
:on-progress="my_file_upload.handleUploadProgress"
list-type="picture"
>
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
<div class="el-upload__text">拖拽文件到这里 或 <em>浏览文件</em></div>
<div class="el-upload__tip" slot="tip">文件格式mp4, 大小限制{{FileSizeLimitM}}M</div>
</el-upload>
</div>
<div class="uploaded-div" v-if="form.file_path" >
<div class="uploaded-file-info">
<video id="video-player" :src="form.file_path_domain" controls @loadeddata="onVideoLoaded"></video>
<canvas
id="crop-canvas"
style="background-color: rgba(255, 0, 0, 0.1); position: absolute; left: 0; z-index: -1;"
@mousedown="cropBox.startDrawing"
@mousemove="cropBox.draw"
@mouseup="cropBox.endDrawing"
>
</canvas>
</div>
<el-button color="#181818" @click="cropBox.generate_canvas">设置</el-button>
<el-button color="#181818" @click="cropBox.reset">重置</el-button>
<el-text style="color: #181818; margin-left: 10px; font-weight: bold;">目标帧率:</el-text>
<el-select v-model="form.target_fps" placeholder="请选择" style="width: 50px" size="small">
<el-option
v-for="item in fps_options"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-text style="color: #181818; font-weight: bold;">,每 </el-text>
<el-select v-model="form.every_x_gen_dir" placeholder="请选择" style="width: 50px" size="small">
<el-option
v-for="item in every_x_gen_dir_options"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-text style="color: #181818; margin-left: 10px; font-weight: bold;">张图片保存一个目录</el-text>
<!-- <p>裁剪框坐标:{{ form.crop_range }}</p> -->
<p>扫描框坐标:{{ form.box_range }}</p>
<!-- <p>{{ form.task_id }}</p> -->
<el-switch v-model="form.slider_window_2" active-text="开启滑窗,每次重复2张图"/>
</div>
<div class="button">
<el-button class="next" color="#181818" size="large"
@click="processing.fileToClassify"
:disabled="!form.is_set">Next</el-button>
</div>
</div>
<!-- 处理页 -->
<div class="progress-section" v-if="steps_active === 1">
<div><el-text class="section-title">样本分类</el-text></div>
<div>
<el-text class="section-desc">当前fps={{ form.target_fps }},图片显示大小:</el-text>
<el-select v-model="form.image_width" placeholder="请选择" style="width: 80px" size="small">
<el-option
v-for="item in image_width_options"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</div>
<!-- 内容 -->
<div v-loading="process_loading">
<el-checkbox-group v-model="form.checkedList" @change="processing.onCheckedListChange">
<div class="row" v-for="dir in form.sample_result.data">
<el-checkbox :value="dir.dir_name" />
<div class="images" v-for="image in dir.images">
<img :src="form.sample_result.root_path + dir.dir_name + '/' + form.classes_select2[dir.dir_name] + '/' + image" :width="form.image_width" />
<p>{{ image }}</p>
</div>
<div class="classifications">
<div><p style="margin-bottom: 5px;">{{ dir.dir_name }}</p></div>
<div>
<el-select v-model="form.classes_select[dir.dir_name]"
placeholder="分类" size="default" style="width: 100px;"
:class="[`class-select-${form.classes_select[dir.dir_name] || 'default'}`]"
>
<el-option
v-for="item in classes"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-button type="danger" size="default" :icon="Delete" circle @click="processing.deleteOneSample(dir.dir_name)" style="margin-left: 10px;"/>
</div>
<div style="margin-top: 5px;">
<el-button :type="form.checkClipVideoBtnType[dir.dir_name] || 'primary'" size="small" @click="onCheckClipVideoBtnClick(dir.dir_name)">查看视频</el-button>
</div>
</div>
</div>
</el-checkbox-group>
</div>
<!-- <p>{{ form.classes_select }}</p> -->
<!-- 按钮 -->
<div class="button">
<!-- <el-button class="back" @click="processing.back_to_first">Back</el-button> -->
<el-button @click="processing.deleteMutiSample()" color="#181818" :disabled="form.checkedList.length==0">批量删除</el-button>
<!-- <el-button class="next" color="#181818" @click="processing.classifyToDownload">Next</el-button> -->
<el-button class="next" color="#181818" @click="continue_to_negative">继续处理</el-button>
</div>
</div>
<!-- 结果页 -->
<div class="report-section" v-if="steps_active === 2">
<div><el-text class="section-title">结果</el-text></div>
<div><el-text class="section-desc">下载样本</el-text></div>
<!-- 内容 -->
<div v-loading="result_loading">
<el-text class="progress-info-sub">
{{ form.sample_path}}
</el-text>
</div>
<!-- 按钮 -->
<div class="button">
<el-button class="back" size="large" @click="processing.back_to_second">Back</el-button>
<el-button class="next" color="#181818" size="large"
@click="processing.downloadFile(form.sample_path)"
:disabled="!(form.sample_path && form.sample_path.length > 0)"
>Download</el-button>
</div>
</div>
</div>
</main>
<el-dialog v-model="dialogVisible" title="视频片段(如果挡住后面的图,按这里可以移动)" draggable width="30%">
<video id="clip-video" controls style="width: 100%;" autoplay></video>
</el-dialog>
</template>
<!-- 样式 只在当前页面生效,优先级比组件样式低 -->
<style lang="scss" scoped src="./index.css"></style>
<!-- 全局样式,如果要改组件样式,得在这里改,但为了避免所有组件都改,这里可以设置class层级 -->
<style lang="scss">
.progress-section {
.classifications {
.class-select-1 {
.el-select__wrapper{
background-color: aquamarine;
}
}
.class-select-2 {
.el-select__wrapper{
background-color: rgb(121, 160, 245);
}
}
.class-select-3 {
.el-select__wrapper{
background-color: rgb(164, 139, 246);
}
}
.class-select-4 {
.el-select__wrapper{
background-color: rgb(243, 115, 115);
}
}
.class-select-5 {
.el-select__wrapper{
background-color: rgb(60, 8, 228);
}
}
.class-select-6 {
.el-select__wrapper{
background-color: rgb(76, 75, 81);
}
}
.class-select-7 {
.el-select__wrapper{
background-color: rgb(214, 47, 161);
}
}
.class-select-8 {
.el-select__wrapper{
background-color: rgb(48, 230, 38);
}
}
.class-select-9 {
.el-select__wrapper{
background-color: rgb(170, 235, 16);
}
}
}
}
</style>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment