新模块功能:蚕茧视频识别
This commit is contained in:
@@ -43,6 +43,7 @@
|
||||
"@vben/utils": "workspace:*",
|
||||
"@vueuse/core": "catalog:",
|
||||
"ant-design-vue": "catalog:",
|
||||
"axios": "catalog:",
|
||||
"dayjs": "catalog:",
|
||||
"handsontable": "^16.0.1",
|
||||
"js-sha256": "^0.11.0",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './iva';
|
||||
export * from './iva-sc';
|
||||
export * from './license';
|
||||
export * from './sca';
|
||||
export * from './sca2';
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { pyRequestClient } from '#/api/request';
|
||||
|
||||
/**
|
||||
* 获取已分析的视频列表
|
||||
*/
|
||||
export async function refreshSCVideoList(name = '', page = 1, pageSize = 9) {
|
||||
return pyRequestClient.get('/cv/getScVideoList', {
|
||||
params: { name, page, page_size: pageSize },
|
||||
});
|
||||
}
|
||||
/**
|
||||
* 获取已分析的视频
|
||||
*/
|
||||
export async function refreshSCVideoDetail(vId = '') {
|
||||
return pyRequestClient.get('/cv/getAnalyticsDetailBySCVideoId', {
|
||||
params: { vId },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传视频分析任务
|
||||
*/
|
||||
export async function getIVASCUploadToken() {
|
||||
return pyRequestClient.get('/cv/getIVASCUploadToken');
|
||||
}
|
||||
@@ -22,11 +22,12 @@ import {
|
||||
} from '@vben/icons';
|
||||
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
|
||||
|
||||
import { Button, Form, Input, message } from 'ant-design-vue';
|
||||
import { Button, Form, message } from 'ant-design-vue';
|
||||
import axios from 'axios';
|
||||
import videojs from 'video.js';
|
||||
|
||||
import * as api from '#/api';
|
||||
import { createImageTaskV2 } from '#/api';
|
||||
import { getIVASCUploadToken } from '#/api';
|
||||
|
||||
import 'video.js/dist/video-js.css';
|
||||
|
||||
@@ -37,11 +38,15 @@ const activeTab = ref<'detail' | 'video'>('detail');
|
||||
const selectedItem = ref<any>(null);
|
||||
const detailList = ref<any[]>([]);
|
||||
const videoEl = ref<HTMLVideoElement | null>(null);
|
||||
const originalVideoEl = ref<HTMLVideoElement | null>(null);
|
||||
const originalPlayer = ref<null | Player>(null);
|
||||
|
||||
const player = ref<null | Player>(null);
|
||||
async function loadList() {
|
||||
error.value = null;
|
||||
const res = await api.refreshVideoList(filterKeyword.value);
|
||||
list.value = res || [];
|
||||
const res = await api.refreshSCVideoList(filterKeyword.value);
|
||||
list.value = res.items || [];
|
||||
total.value = res.total;
|
||||
}
|
||||
function refreshList() {
|
||||
filterKeyword.value = '';
|
||||
@@ -53,15 +58,18 @@ function createTask() {
|
||||
}
|
||||
|
||||
async function selectItem(item: any) {
|
||||
const res = await api.refreshVideoDetail(item.v_id);
|
||||
selectedItem.value = res;
|
||||
const res = await api.refreshSCVideoDetail(item.id);
|
||||
detailList.value = res;
|
||||
selectedItem.value = item;
|
||||
refreshLineChart();
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ key: 'detail', label: '分析详情' },
|
||||
{ key: 'original', label: '原视频' }, // 新增
|
||||
{ key: 'video', label: '分析视频' },
|
||||
];
|
||||
|
||||
let overviewItems: AnalysisOverviewItem[];
|
||||
|
||||
// 监听关键词变化,调用防抖接口
|
||||
@@ -72,45 +80,50 @@ watch(selectedItem, () => {
|
||||
overviewItems = [
|
||||
{
|
||||
icon: SvgDownloadIcon,
|
||||
title: '出现的蚕茧数量',
|
||||
title: '出现的蚕茧总数',
|
||||
totalTitle: '',
|
||||
totalValue: 0,
|
||||
value: selectedItem.value.v_a_count_people,
|
||||
value: selectedItem.value.sc_analysis_total_count,
|
||||
},
|
||||
{
|
||||
icon: SvgCardIcon,
|
||||
title: '出现最多的种类',
|
||||
totalTitle: '',
|
||||
totalValue: 0,
|
||||
value: selectedItem.value.v_a_max_action,
|
||||
value: selectedItem.value.sc_analysis_primary_type,
|
||||
},
|
||||
{
|
||||
icon: SvgCakeIcon,
|
||||
title: '最多同框蚕茧数量',
|
||||
title: '出现次多的种类',
|
||||
totalTitle: '',
|
||||
totalValue: 0,
|
||||
value: selectedItem.value.v_a_total_people,
|
||||
value: selectedItem.value.sc_analysis_secondary_type,
|
||||
},
|
||||
{
|
||||
icon: SvgBellIcon,
|
||||
title: '时间',
|
||||
totalTitle: '最长停留时间',
|
||||
title: '最多同框蚕茧数量',
|
||||
totalTitle: '',
|
||||
totalValue: 0,
|
||||
value: selectedItem.value.v_a_max_stay_time,
|
||||
value: selectedItem.value.sc_analysis_max_count,
|
||||
},
|
||||
];
|
||||
});
|
||||
watch([activeTab, selectedItem], async ([tab]) => {
|
||||
if (tab === 'video' && selectedItem.value?.v_video_play_path) {
|
||||
if (tab === 'video' && selectedItem.value?.ai_video_url) {
|
||||
refreshVideoPlayer();
|
||||
} else if (tab === 'original' && selectedItem.value?.raw_video_url) {
|
||||
refreshOriginalPlayer();
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
loadList();
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
player.value?.dispose();
|
||||
originalPlayer.value?.dispose();
|
||||
player.value = null;
|
||||
originalPlayer.value = null;
|
||||
});
|
||||
|
||||
// ✅ 切换视频项时销毁并重建
|
||||
@@ -119,7 +132,7 @@ function refreshVideoPlayer() {
|
||||
if (player.value) {
|
||||
player.value.src([
|
||||
{
|
||||
src: selectedItem.value.v_video_play_path,
|
||||
src: selectedItem.value.ai_video_url,
|
||||
type: 'video/mp4',
|
||||
},
|
||||
]);
|
||||
@@ -131,7 +144,7 @@ function refreshVideoPlayer() {
|
||||
preload: 'auto',
|
||||
sources: [
|
||||
{
|
||||
src: selectedItem.value.v_video_play_path,
|
||||
src: selectedItem.value.ai_video_url,
|
||||
type: 'video/mp4',
|
||||
},
|
||||
],
|
||||
@@ -139,6 +152,32 @@ function refreshVideoPlayer() {
|
||||
}
|
||||
});
|
||||
}
|
||||
function refreshOriginalPlayer() {
|
||||
nextTick(() => {
|
||||
if (originalPlayer.value) {
|
||||
originalPlayer.value.src([
|
||||
{
|
||||
src: selectedItem.value.raw_video_url,
|
||||
type: 'video/mp4',
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
if (!originalVideoEl.value) return;
|
||||
originalPlayer.value = videojs(originalVideoEl.value, {
|
||||
controls: true,
|
||||
autoplay: false,
|
||||
preload: 'auto',
|
||||
sources: [
|
||||
{
|
||||
src: selectedItem.value.raw_video_url,
|
||||
type: 'video/mp4',
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const showInfoStr = ref<Record<string, number | string>>({});
|
||||
|
||||
const chartRef1 = ref<EchartsUIType>();
|
||||
@@ -150,73 +189,63 @@ const { renderEcharts: renderEcharts2 } = useEcharts(chartRef2);
|
||||
function refreshLineChart() {
|
||||
const data = selectedItem.value;
|
||||
showInfoStr.value = {
|
||||
项目名: data.v_name,
|
||||
文件名: data.v_file_name,
|
||||
文件大小: `${data.v_size} MB`,
|
||||
总时长: `${data.v_duration} 秒`,
|
||||
分辨率: data.v_resolution,
|
||||
视频编码格式: data.v_video_codec,
|
||||
音频编码格式: data.v_audio_codec,
|
||||
总体比特率: data.v_overall_bit_rate,
|
||||
项目名: data.name,
|
||||
文件大小: `${data.size_kb} KB`,
|
||||
总时长: `${data.duration} 秒`,
|
||||
分辨率: data.resolution,
|
||||
视频编码格式: data.video_codec,
|
||||
音频编码格式: data.audio_codec,
|
||||
总体比特率: data.overall_bit_rate,
|
||||
};
|
||||
|
||||
detailList.value = data.v_details_list || [];
|
||||
const temp = detailList.value;
|
||||
// 1. X轴
|
||||
const xAxisData = temp.map((item) => item.time_stamp);
|
||||
|
||||
const detail = selectedItem.value.v_a_details;
|
||||
let yTotalData = Array.isArray(detail.yTotalData)
|
||||
? detail.yTotalData.map((item: any) => [item.first, item.second])
|
||||
: []; // 默认值为空数组
|
||||
let yMaskedData = Array.isArray(detail.yMaskedData)
|
||||
? detail.yMaskedData.map((item: any) => [item.first, item.second])
|
||||
: []; // 默认值为空数组
|
||||
const areaData = Array.isArray(detail.areaData)
|
||||
? detail.areaData.map((actionGroup: any) => {
|
||||
return actionGroup.map((action: any) => {
|
||||
return { xAxis: action.xAxis, itemStyle: action.itemStyle };
|
||||
});
|
||||
})
|
||||
: []; // 默认值为空数组
|
||||
yTotalData = yTotalData.map((item: any) => [
|
||||
new Date(item[0]).getTime(),
|
||||
item[1],
|
||||
]);
|
||||
yMaskedData = yMaskedData.map((item: any) => [
|
||||
new Date(item[0]).getTime(),
|
||||
item[1],
|
||||
]);
|
||||
// 2. 获取类别(other_info 的 key)
|
||||
const categories = Object.keys(temp[0]?.other_info || {});
|
||||
|
||||
renderEcharts1({
|
||||
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
|
||||
series: [
|
||||
{
|
||||
name: '总人数',
|
||||
type: 'line',
|
||||
step: 'end',
|
||||
data: yTotalData,
|
||||
markArea: {
|
||||
itemStyle: { color: 'rgba(255, 173, 177, 0.4)' },
|
||||
data: areaData,
|
||||
},
|
||||
},
|
||||
{ name: '口罩佩戴人数', type: 'line', step: 'end', data: yMaskedData },
|
||||
],
|
||||
tooltip: {
|
||||
axisPointer: {
|
||||
lineStyle: {
|
||||
color: '#019680',
|
||||
width: 1,
|
||||
},
|
||||
},
|
||||
trigger: 'axis',
|
||||
// 3. 构造 series
|
||||
const series = categories.map((key) => ({
|
||||
name: key,
|
||||
type: 'line',
|
||||
stack: 'Total',
|
||||
data: temp.map((item) => item.other_info[key]),
|
||||
}));
|
||||
|
||||
const option = {
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: categories },
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '3%',
|
||||
containLabel: true,
|
||||
},
|
||||
xAxis: { type: 'time' },
|
||||
yAxis: { type: 'value' },
|
||||
});
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: xAxisData,
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
},
|
||||
series,
|
||||
};
|
||||
|
||||
renderEcharts1(option);
|
||||
|
||||
const chartData = data.other_info;
|
||||
|
||||
// 将对象转换为 echarts 的 [{value, name}] 数组
|
||||
const seriesData = Object.entries(chartData).map(([name, value]) => ({
|
||||
name,
|
||||
value,
|
||||
}));
|
||||
|
||||
const maskedRatio = data.v_a_average_masked_ratio * 100;
|
||||
const noMaskedRatio = 100 - maskedRatio;
|
||||
renderEcharts2({
|
||||
legend: { top: '5%', left: 'center' },
|
||||
|
||||
series: [
|
||||
{
|
||||
animationDelay() {
|
||||
@@ -225,17 +254,12 @@ function refreshLineChart() {
|
||||
animationEasing: 'exponentialInOut',
|
||||
animationType: 'scale',
|
||||
avoidLabelOverlap: false,
|
||||
|
||||
// 你原来的颜色保留
|
||||
color: ['#5ab1ef', '#b6a2de', '#67e0e3', '#2ec7c9'],
|
||||
data: [
|
||||
{
|
||||
value: maskedRatio,
|
||||
name: '非正茧(%)',
|
||||
},
|
||||
{
|
||||
value: noMaskedRatio,
|
||||
name: '正茧(%)',
|
||||
},
|
||||
],
|
||||
|
||||
data: seriesData, // 关键改动!!!
|
||||
|
||||
emphasis: {
|
||||
label: {
|
||||
fontSize: '12',
|
||||
@@ -255,11 +279,11 @@ function refreshLineChart() {
|
||||
show: false,
|
||||
},
|
||||
padAngle: 5,
|
||||
name: '正茧平均占比',
|
||||
radius: ['40%', '70%'],
|
||||
type: 'pie',
|
||||
},
|
||||
],
|
||||
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
},
|
||||
@@ -270,7 +294,6 @@ function refreshLineChart() {
|
||||
refreshVideoPlayer();
|
||||
}
|
||||
}
|
||||
const projectName = ref('');
|
||||
const fileName = ref('');
|
||||
const selectedFile = ref<File | null>(null);
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null);
|
||||
@@ -293,15 +316,19 @@ const [Modal, modalApi] = useVbenModal({
|
||||
async function uploadFile() {
|
||||
// 先关闭弹窗
|
||||
modalApi.close();
|
||||
const formData = new FormData();
|
||||
formData.append('file', selectedFile.value!);
|
||||
formData.append('projectName', projectName.value);
|
||||
await createImageTaskV2(formData).then(() => {
|
||||
// 清空表单
|
||||
projectName.value = '';
|
||||
fileName.value = '';
|
||||
selectedFile.value = null;
|
||||
const uploadUrl = await getIVASCUploadToken();
|
||||
// 2. 使用 presigned URL 上传文件
|
||||
const file = selectedFile.value;
|
||||
message.success('正在上传视频');
|
||||
await axios.put(uploadUrl, file, {
|
||||
headers: {
|
||||
'Content-Type': file.type,
|
||||
},
|
||||
});
|
||||
message.success('正在分析,请稍后刷新列表查看');
|
||||
// 清空表单
|
||||
fileName.value = '';
|
||||
selectedFile.value = null;
|
||||
}
|
||||
|
||||
function selectFile() {
|
||||
@@ -334,10 +361,6 @@ function changePage(newPage) {
|
||||
<BaseModal />
|
||||
<Modal>
|
||||
<Form layout="vertical">
|
||||
<Form.Item label="任务名称">
|
||||
<Input v-model:value="projectName" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="上传视频*" required>
|
||||
<div
|
||||
@click="selectFile"
|
||||
@@ -380,13 +403,13 @@ function changePage(newPage) {
|
||||
<div class="flex-1 space-y-2 overflow-auto">
|
||||
<div
|
||||
v-for="item in list"
|
||||
:key="item.v_id"
|
||||
:key="item.id"
|
||||
@click="selectItem(item)"
|
||||
class="cursor-pointer rounded border p-3 hover:bg-gray-100"
|
||||
:class="{ 'bg-gray-100': item.v_id === selectedItem?.v_id }"
|
||||
:class="{ 'bg-gray-100': item.id === selectedItem?.id }"
|
||||
>
|
||||
<div class="text-base font-medium">{{ item.v_name }}</div>
|
||||
<div class="text-sm text-gray-400">{{ item.v_a_time }}</div>
|
||||
<div class="text-base font-medium">{{ item.name }}</div>
|
||||
<div class="text-sm text-gray-400">{{ item.created_at }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 分页 -->
|
||||
@@ -494,6 +517,17 @@ function changePage(newPage) {
|
||||
controls
|
||||
></video>
|
||||
</div>
|
||||
<div
|
||||
v-show="activeTab === 'original'"
|
||||
class="flex h-full space-x-4"
|
||||
>
|
||||
<video
|
||||
ref="originalVideoEl"
|
||||
class="video-js vjs-default-skin h-full w-full"
|
||||
preload="auto"
|
||||
controls
|
||||
></video>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Generated
+3
@@ -689,6 +689,9 @@ importers:
|
||||
ant-design-vue:
|
||||
specifier: 'catalog:'
|
||||
version: 4.2.6(vue@3.5.16(typescript@5.8.3))
|
||||
axios:
|
||||
specifier: 'catalog:'
|
||||
version: 1.9.0
|
||||
dayjs:
|
||||
specifier: 'catalog:'
|
||||
version: 1.11.13
|
||||
|
||||
Reference in New Issue
Block a user