新模块功能:蚕茧视频识别

This commit is contained in:
BBIT-Kai
2025-11-18 16:32:09 +08:00
parent 530cede0bd
commit 7a5e29be1c
10 changed files with 304 additions and 127 deletions
+1
View File
@@ -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
View File
@@ -1,4 +1,5 @@
export * from './iva';
export * from './iva-sc';
export * from './license';
export * from './sca';
export * from './sca2';
+25
View File
@@ -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');
}
+138 -104
View File
@@ -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>
+3
View File
@@ -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