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

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
+4 -5
View File
@@ -15,7 +15,6 @@ from routers.RabbitMQ import rqRouter
from routers.Report import reportRouter
from routers.Service import serviceRouter
from routers.Vision import visionRouter
from service.Analyze import mq_pull_analysis_async
async def ai_lab():
@@ -47,7 +46,7 @@ async def ai_lab():
app.include_router(r, prefix="/llm", tags=["llm"])
app.include_router(visionRouter, prefix="/cv", tags=["cv"])
app.include_router(publicRouter, prefix="/api/public", tags=["api"])
config = Config(app=app, host="0.0.0.0", port=13011, log_level="info")
config = Config(app=app, host="0.0.0.0", port=13011, log_level="debug")
server = Server(config)
await server.serve()
@@ -59,7 +58,7 @@ async def main():
task_api = asyncio.create_task(ai_lab())
# MCP服务-ailab
endpoint_url_ai_lab = "wss://ai.ronsunny.cn:8090/aimcp/mcp_endpoint/mcp/?token=TsSP9lBq6Oa1WMkachHoS2TtNt4GKV/Gli24pk5Rjpk%3D"
# endpoint_url_ai_lab = "wss://ai.ronsunny.cn:8090/aimcp/mcp_endpoint/mcp/?token=TsSP9lBq6Oa1WMkachHoS2TtNt4GKV/Gli24pk5Rjpk%3D"
# endpoint_url_ai_lab = "ws://ce_bot_mcp:8004/mcp_endpoint/mcp/?token=TsSP9lBq6Oa1WMkachHoS2TtNt4GKV/Gli24pk5Rjpk%3D"
# task_mcp1 = asyncio.create_task(init_mcp_server(endpoint_url_ai_lab))
@@ -68,10 +67,10 @@ async def main():
task_mcp2 = asyncio.create_task(init_mcp_server(endpoint_url_ql))
# RabbitMQ服务
task_mq = asyncio.create_task(mq_pull_analysis_async())
# task_mq = asyncio.create_task(mq_pull_analysis_async())
# await asyncio.gather(task_api, task_mcp1, task_mcp2, task_mq)
await asyncio.gather(task_api, task_mcp2, task_mq)
await asyncio.gather(task_api, task_mcp2)
if __name__ == "__main__":
+2 -3
View File
@@ -22,11 +22,10 @@ def push_file(bucket_name, object_name, file_bytes, contents, content_type):
)
def get_upload_token(bucket_name, object_name, xpires=timedelta(hours=1)):
upload_url = minio_client.presigned_put_object(
def get_upload_token(user_id, bucket_name, object_name, xpires=timedelta(minutes=15)):
return minio_client.presigned_put_object(
bucket_name=bucket_name, object_name=object_name, expires=xpires
)
return {"upload_url": upload_url, "object_name": object_name}
def get_temp_url(bucket_name, object_name):
+94
View File
@@ -753,3 +753,97 @@ def get_sca_image_list(user_id, name, page=1, page_size=10):
)
return total, result
def get_sca_video_list(name, page=1, page_size=10):
"""
获取用户已分析视频列表,带分页
"""
offset = (page - 1) * page_size
with pg_pool.getConn() as conn:
with conn.cursor() as cursor:
# 1️⃣ 查询总条数
cursor.execute(
"""
SELECT COUNT(*)
FROM sca_videos
WHERE (%s = '' OR name LIKE '%%' || %s || '%%')
""",
(name, name),
)
total = cursor.fetchone()[0]
# 2️⃣ 查询当前页数据
cursor.execute(
"""
SELECT id, name, raw_object_name, ai_object_name, duration, size, video_codec, audio_codec,
overall_bit_rate, resolution, sc_analysis_time, sc_analysis_total_count, sc_analysis_max_count,
sc_analysis_primary_type, sc_analysis_secondary_type, other_info, created_at
FROM sca_videos
WHERE (%s = '' OR name LIKE '%%' || %s || '%%')
ORDER BY created_at DESC
LIMIT %s OFFSET %s
""",
(name, name, page_size, offset),
)
rows = cursor.fetchall()
result = []
for row in rows:
result.append(
{
"id": row[0],
"name": row[1],
"raw_video_url": get_temp_url("video-sca", "raw/" + row[2]),
"ai_video_url": get_temp_url("video-sca", "ai/" + row[3]),
"duration": MyUtils.safe_round(row[4], 2),
"size_kb": MyUtils.safe_round(row[5] / 1024, 2),
"video_codec": row[6],
"audio_codec": row[7],
"overall_bit_rate": row[8],
"resolution": row[9],
"sc_analysis_time": MyUtils.safe_round(row[10], 2),
"sc_analysis_total_count": row[11],
"sc_analysis_max_count": row[12],
"sc_analysis_primary_type": row[13],
"sc_analysis_secondary_type": row[14],
"other_info": json.loads(row[15]),
"created_at": MyUtils.format_datetime(row[16]),
}
)
return total, result
def get_sca_video_details(v_id):
"""
获取指定视频的分析明细列表
"""
with pg_pool.getConn() as conn:
with conn.cursor() as cursor:
cursor.execute(
"""
SELECT id, v_id, time_stamp, other_info
FROM sca_video_details
WHERE v_id = %s
ORDER BY time_stamp ASC
""",
(v_id,),
)
rows = cursor.fetchall()
result = []
for row in rows:
# other_info 从 JSON 字符串解析回字典
result.append(
{
"id": row[0],
"v_id": row[1],
"time_stamp": row[2],
"other_info": row[3],
}
)
return result
+36 -3
View File
@@ -153,8 +153,41 @@ def getSilkwormCocoonAnalysisTasks(
)
@visionRouter.post("/getIVASCUploadToken")
def getIVASCUploadToken():
# ————————————————————————————————蚕茧视频识别任务————————————————————————————————————————————————
@visionRouter.get("/getIVASCUploadToken")
def getIVASCUploadToken(
user_id: UUID = Depends(get_user_id_from_token),
):
# 生成唯一文件名,避免覆盖
object_name = f"raw/{uuid.uuid4()}"
return get_upload_token("video-sca", object_name)
return BaseResponse(data=get_upload_token(user_id, "video-sca", object_name))
@visionRouter.get("/getScVideoList")
def getScVideoList(
user_id: UUID = Depends(get_user_id_from_token),
name: str = "",
page: int = Query(1, ge=1),
page_size: int = Query(10, ge=1, le=100),
):
if not user_id:
return {"error": "userId is required"}
total, items = pg.get_sca_video_list(name, page=page, page_size=page_size)
return BaseResponse(
data={
"total": total,
"items": items,
}
)
@visionRouter.get("/getAnalyticsDetailBySCVideoId")
def getAnalyticsDetailBySCVideoId(
user_id: UUID = Depends(get_user_id_from_token),
vId: str = "",
):
if not user_id:
return {"error": "userId is required"}
return BaseResponse(data=pg.get_sca_video_details(vId))
-12
View File
@@ -132,18 +132,6 @@ def process_silkworm_cocoon_image(
# YOLO检测
img_bytes_out, results_json = YOLOSingleton.detect(img_bytes)
# results_json = {
# "total_objects": "",
# "max_confidence": "",
# "min_confidence": "",
# "avg_confidence": "",
# "class_counts": "",
# "speed_ms": {
# "preprocess": "",
# "inference": "",
# "postprocess": "",
# },
# }
speed_json = results_json.get("speed_ms")
file_bytes_out = BytesIO(img_bytes_out)
+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