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

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.Report import reportRouter
from routers.Service import serviceRouter from routers.Service import serviceRouter
from routers.Vision import visionRouter from routers.Vision import visionRouter
from service.Analyze import mq_pull_analysis_async
async def ai_lab(): async def ai_lab():
@@ -47,7 +46,7 @@ async def ai_lab():
app.include_router(r, prefix="/llm", tags=["llm"]) app.include_router(r, prefix="/llm", tags=["llm"])
app.include_router(visionRouter, prefix="/cv", tags=["cv"]) app.include_router(visionRouter, prefix="/cv", tags=["cv"])
app.include_router(publicRouter, prefix="/api/public", tags=["api"]) 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) server = Server(config)
await server.serve() await server.serve()
@@ -59,7 +58,7 @@ async def main():
task_api = asyncio.create_task(ai_lab()) task_api = asyncio.create_task(ai_lab())
# MCP服务-ailab # 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" # 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)) # 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)) task_mcp2 = asyncio.create_task(init_mcp_server(endpoint_url_ql))
# RabbitMQ服务 # 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_mcp1, task_mcp2, task_mq)
await asyncio.gather(task_api, task_mcp2, task_mq) await asyncio.gather(task_api, task_mcp2)
if __name__ == "__main__": 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)): def get_upload_token(user_id, bucket_name, object_name, xpires=timedelta(minutes=15)):
upload_url = minio_client.presigned_put_object( return minio_client.presigned_put_object(
bucket_name=bucket_name, object_name=object_name, expires=xpires 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): 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 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()}" 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检测 # YOLO检测
img_bytes_out, results_json = YOLOSingleton.detect(img_bytes) 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") speed_json = results_json.get("speed_ms")
file_bytes_out = BytesIO(img_bytes_out) file_bytes_out = BytesIO(img_bytes_out)
+1
View File
@@ -43,6 +43,7 @@
"@vben/utils": "workspace:*", "@vben/utils": "workspace:*",
"@vueuse/core": "catalog:", "@vueuse/core": "catalog:",
"ant-design-vue": "catalog:", "ant-design-vue": "catalog:",
"axios": "catalog:",
"dayjs": "catalog:", "dayjs": "catalog:",
"handsontable": "^16.0.1", "handsontable": "^16.0.1",
"js-sha256": "^0.11.0", "js-sha256": "^0.11.0",
+1
View File
@@ -1,4 +1,5 @@
export * from './iva'; export * from './iva';
export * from './iva-sc';
export * from './license'; export * from './license';
export * from './sca'; export * from './sca';
export * from './sca2'; 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');
}
+136 -102
View File
@@ -22,11 +22,12 @@ import {
} from '@vben/icons'; } from '@vben/icons';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts'; 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 videojs from 'video.js';
import * as api from '#/api'; import * as api from '#/api';
import { createImageTaskV2 } from '#/api'; import { getIVASCUploadToken } from '#/api';
import 'video.js/dist/video-js.css'; import 'video.js/dist/video-js.css';
@@ -37,11 +38,15 @@ const activeTab = ref<'detail' | 'video'>('detail');
const selectedItem = ref<any>(null); const selectedItem = ref<any>(null);
const detailList = ref<any[]>([]); const detailList = ref<any[]>([]);
const videoEl = ref<HTMLVideoElement | null>(null); const videoEl = ref<HTMLVideoElement | null>(null);
const originalVideoEl = ref<HTMLVideoElement | null>(null);
const originalPlayer = ref<null | Player>(null);
const player = ref<null | Player>(null); const player = ref<null | Player>(null);
async function loadList() { async function loadList() {
error.value = null; error.value = null;
const res = await api.refreshVideoList(filterKeyword.value); const res = await api.refreshSCVideoList(filterKeyword.value);
list.value = res || []; list.value = res.items || [];
total.value = res.total;
} }
function refreshList() { function refreshList() {
filterKeyword.value = ''; filterKeyword.value = '';
@@ -53,15 +58,18 @@ function createTask() {
} }
async function selectItem(item: any) { async function selectItem(item: any) {
const res = await api.refreshVideoDetail(item.v_id); const res = await api.refreshSCVideoDetail(item.id);
selectedItem.value = res; detailList.value = res;
selectedItem.value = item;
refreshLineChart(); refreshLineChart();
} }
const tabs = [ const tabs = [
{ key: 'detail', label: '分析详情' }, { key: 'detail', label: '分析详情' },
{ key: 'original', label: '原视频' }, // 新增
{ key: 'video', label: '分析视频' }, { key: 'video', label: '分析视频' },
]; ];
let overviewItems: AnalysisOverviewItem[]; let overviewItems: AnalysisOverviewItem[];
// 监听关键词变化,调用防抖接口 // 监听关键词变化,调用防抖接口
@@ -72,45 +80,50 @@ watch(selectedItem, () => {
overviewItems = [ overviewItems = [
{ {
icon: SvgDownloadIcon, icon: SvgDownloadIcon,
title: '出现的蚕茧数', title: '出现的蚕茧数',
totalTitle: '', totalTitle: '',
totalValue: 0, totalValue: 0,
value: selectedItem.value.v_a_count_people, value: selectedItem.value.sc_analysis_total_count,
}, },
{ {
icon: SvgCardIcon, icon: SvgCardIcon,
title: '出现最多的种类', title: '出现最多的种类',
totalTitle: '', totalTitle: '',
totalValue: 0, totalValue: 0,
value: selectedItem.value.v_a_max_action, value: selectedItem.value.sc_analysis_primary_type,
}, },
{ {
icon: SvgCakeIcon, icon: SvgCakeIcon,
title: '最多同框蚕茧数量', title: '出现次多的种类',
totalTitle: '', totalTitle: '',
totalValue: 0, totalValue: 0,
value: selectedItem.value.v_a_total_people, value: selectedItem.value.sc_analysis_secondary_type,
}, },
{ {
icon: SvgBellIcon, icon: SvgBellIcon,
title: '时间', title: '最多同框蚕茧数量',
totalTitle: '最长停留时间', totalTitle: '',
totalValue: 0, totalValue: 0,
value: selectedItem.value.v_a_max_stay_time, value: selectedItem.value.sc_analysis_max_count,
}, },
]; ];
}); });
watch([activeTab, selectedItem], async ([tab]) => { watch([activeTab, selectedItem], async ([tab]) => {
if (tab === 'video' && selectedItem.value?.v_video_play_path) { if (tab === 'video' && selectedItem.value?.ai_video_url) {
refreshVideoPlayer(); refreshVideoPlayer();
} else if (tab === 'original' && selectedItem.value?.raw_video_url) {
refreshOriginalPlayer();
} }
}); });
onMounted(() => { onMounted(() => {
loadList(); loadList();
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
player.value?.dispose(); player.value?.dispose();
originalPlayer.value?.dispose();
player.value = null; player.value = null;
originalPlayer.value = null;
}); });
// ✅ 切换视频项时销毁并重建 // ✅ 切换视频项时销毁并重建
@@ -119,7 +132,7 @@ function refreshVideoPlayer() {
if (player.value) { if (player.value) {
player.value.src([ player.value.src([
{ {
src: selectedItem.value.v_video_play_path, src: selectedItem.value.ai_video_url,
type: 'video/mp4', type: 'video/mp4',
}, },
]); ]);
@@ -131,7 +144,7 @@ function refreshVideoPlayer() {
preload: 'auto', preload: 'auto',
sources: [ sources: [
{ {
src: selectedItem.value.v_video_play_path, src: selectedItem.value.ai_video_url,
type: 'video/mp4', 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 showInfoStr = ref<Record<string, number | string>>({});
const chartRef1 = ref<EchartsUIType>(); const chartRef1 = ref<EchartsUIType>();
@@ -150,73 +189,63 @@ const { renderEcharts: renderEcharts2 } = useEcharts(chartRef2);
function refreshLineChart() { function refreshLineChart() {
const data = selectedItem.value; const data = selectedItem.value;
showInfoStr.value = { showInfoStr.value = {
项目名: data.v_name, 项目名: data.name,
文件: data.v_file_name, 文件大小: `${data.size_kb} KB`,
文件大小: `${data.v_size} MB`, 总时长: `${data.duration} `,
总时长: `${data.v_duration}`, 分辨率: data.resolution,
分辨率: data.v_resolution, 视频编码格式: data.video_codec,
频编码格式: data.v_video_codec, 频编码格式: data.audio_codec,
音频编码格式: data.v_audio_codec, 总体比特率: data.overall_bit_rate,
总体比特率: data.v_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; // 2. 获取类别(other_info 的 key
let yTotalData = Array.isArray(detail.yTotalData) const categories = Object.keys(temp[0]?.other_info || {});
? 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],
]);
renderEcharts1({ // 3. 构造 series
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true }, const series = categories.map((key) => ({
series: [ name: key,
{
name: '总人数',
type: 'line', type: 'line',
step: 'end', stack: 'Total',
data: yTotalData, data: temp.map((item) => item.other_info[key]),
markArea: { }));
itemStyle: { color: 'rgba(255, 173, 177, 0.4)' },
data: areaData, const option = {
}, tooltip: { trigger: 'axis' },
}, legend: { data: categories },
{ name: '口罩佩戴人数', type: 'line', step: 'end', data: yMaskedData }, grid: {
], left: '3%',
tooltip: { right: '4%',
axisPointer: { bottom: '3%',
lineStyle: { containLabel: true,
color: '#019680', },
width: 1, xAxis: {
}, type: 'category',
}, boundaryGap: false,
trigger: 'axis', data: xAxisData,
}, },
xAxis: { type: 'time' }, yAxis: {
yAxis: { type: 'value' }, 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({ renderEcharts2({
legend: { top: '5%', left: 'center' }, legend: { top: '5%', left: 'center' },
series: [ series: [
{ {
animationDelay() { animationDelay() {
@@ -225,17 +254,12 @@ function refreshLineChart() {
animationEasing: 'exponentialInOut', animationEasing: 'exponentialInOut',
animationType: 'scale', animationType: 'scale',
avoidLabelOverlap: false, avoidLabelOverlap: false,
// 你原来的颜色保留
color: ['#5ab1ef', '#b6a2de', '#67e0e3', '#2ec7c9'], color: ['#5ab1ef', '#b6a2de', '#67e0e3', '#2ec7c9'],
data: [
{ data: seriesData, // 关键改动!!!
value: maskedRatio,
name: '非正茧(%)',
},
{
value: noMaskedRatio,
name: '正茧(%)',
},
],
emphasis: { emphasis: {
label: { label: {
fontSize: '12', fontSize: '12',
@@ -255,11 +279,11 @@ function refreshLineChart() {
show: false, show: false,
}, },
padAngle: 5, padAngle: 5,
name: '正茧平均占比',
radius: ['40%', '70%'], radius: ['40%', '70%'],
type: 'pie', type: 'pie',
}, },
], ],
tooltip: { tooltip: {
trigger: 'item', trigger: 'item',
}, },
@@ -270,7 +294,6 @@ function refreshLineChart() {
refreshVideoPlayer(); refreshVideoPlayer();
} }
} }
const projectName = ref('');
const fileName = ref(''); const fileName = ref('');
const selectedFile = ref<File | null>(null); const selectedFile = ref<File | null>(null);
const fileInputRef = ref<HTMLInputElement | null>(null); const fileInputRef = ref<HTMLInputElement | null>(null);
@@ -293,15 +316,19 @@ const [Modal, modalApi] = useVbenModal({
async function uploadFile() { async function uploadFile() {
// 先关闭弹窗 // 先关闭弹窗
modalApi.close(); modalApi.close();
const formData = new FormData(); const uploadUrl = await getIVASCUploadToken();
formData.append('file', selectedFile.value!); // 2. 使用 presigned URL 上传文件
formData.append('projectName', projectName.value); const file = selectedFile.value;
await createImageTaskV2(formData).then(() => { message.success('正在上传视频');
await axios.put(uploadUrl, file, {
headers: {
'Content-Type': file.type,
},
});
message.success('正在分析,请稍后刷新列表查看');
// 清空表单 // 清空表单
projectName.value = '';
fileName.value = ''; fileName.value = '';
selectedFile.value = null; selectedFile.value = null;
});
} }
function selectFile() { function selectFile() {
@@ -334,10 +361,6 @@ function changePage(newPage) {
<BaseModal /> <BaseModal />
<Modal> <Modal>
<Form layout="vertical"> <Form layout="vertical">
<Form.Item label="任务名称">
<Input v-model:value="projectName" />
</Form.Item>
<Form.Item label="上传视频*" required> <Form.Item label="上传视频*" required>
<div <div
@click="selectFile" @click="selectFile"
@@ -380,13 +403,13 @@ function changePage(newPage) {
<div class="flex-1 space-y-2 overflow-auto"> <div class="flex-1 space-y-2 overflow-auto">
<div <div
v-for="item in list" v-for="item in list"
:key="item.v_id" :key="item.id"
@click="selectItem(item)" @click="selectItem(item)"
class="cursor-pointer rounded border p-3 hover:bg-gray-100" 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-base font-medium">{{ item.name }}</div>
<div class="text-sm text-gray-400">{{ item.v_a_time }}</div> <div class="text-sm text-gray-400">{{ item.created_at }}</div>
</div> </div>
</div> </div>
<!-- 分页 --> <!-- 分页 -->
@@ -494,6 +517,17 @@ function changePage(newPage) {
controls controls
></video> ></video>
</div> </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> </template>
</div> </div>
</div> </div>
+3
View File
@@ -689,6 +689,9 @@ importers:
ant-design-vue: ant-design-vue:
specifier: 'catalog:' specifier: 'catalog:'
version: 4.2.6(vue@3.5.16(typescript@5.8.3)) version: 4.2.6(vue@3.5.16(typescript@5.8.3))
axios:
specifier: 'catalog:'
version: 1.9.0
dayjs: dayjs:
specifier: 'catalog:' specifier: 'catalog:'
version: 1.11.13 version: 1.11.13