From 7a5e29be1c02e32f9d7db584db5033a3138d284e Mon Sep 17 00:00:00 2001 From: BBIT-Kai <2911862937@qq.com> Date: Tue, 18 Nov 2025 16:32:09 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E6=A8=A1=E5=9D=97=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=EF=BC=9A=E8=9A=95=E8=8C=A7=E8=A7=86=E9=A2=91=E8=AF=86=E5=88=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bbit_ai/app/app.py | 9 +- bbit_ai/app/config/minIO.py | 5 +- bbit_ai/app/db/postgres.py | 94 +++++++ bbit_ai/app/routers/Vision.py | 39 ++- bbit_ai/app/service/vision.py | 12 - vue/apps/web-antd/package.json | 1 + vue/apps/web-antd/src/api/cv/index.ts | 1 + vue/apps/web-antd/src/api/cv/iva-sc.ts | 25 ++ .../web-antd/src/views/cv/iva-sc/index.vue | 242 ++++++++++-------- vue/pnpm-lock.yaml | 3 + 10 files changed, 304 insertions(+), 127 deletions(-) create mode 100644 vue/apps/web-antd/src/api/cv/iva-sc.ts diff --git a/bbit_ai/app/app.py b/bbit_ai/app/app.py index 37575d5..739ed40 100644 --- a/bbit_ai/app/app.py +++ b/bbit_ai/app/app.py @@ -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__": diff --git a/bbit_ai/app/config/minIO.py b/bbit_ai/app/config/minIO.py index 4975151..cf8b25c 100644 --- a/bbit_ai/app/config/minIO.py +++ b/bbit_ai/app/config/minIO.py @@ -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): diff --git a/bbit_ai/app/db/postgres.py b/bbit_ai/app/db/postgres.py index a3c43f0..d93b33c 100644 --- a/bbit_ai/app/db/postgres.py +++ b/bbit_ai/app/db/postgres.py @@ -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 diff --git a/bbit_ai/app/routers/Vision.py b/bbit_ai/app/routers/Vision.py index a5da11f..0b6ea7e 100644 --- a/bbit_ai/app/routers/Vision.py +++ b/bbit_ai/app/routers/Vision.py @@ -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)) diff --git a/bbit_ai/app/service/vision.py b/bbit_ai/app/service/vision.py index 400a070..7ae5eb6 100644 --- a/bbit_ai/app/service/vision.py +++ b/bbit_ai/app/service/vision.py @@ -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) diff --git a/vue/apps/web-antd/package.json b/vue/apps/web-antd/package.json index 3f46a6f..103637c 100644 --- a/vue/apps/web-antd/package.json +++ b/vue/apps/web-antd/package.json @@ -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", diff --git a/vue/apps/web-antd/src/api/cv/index.ts b/vue/apps/web-antd/src/api/cv/index.ts index 5065750..58168e8 100644 --- a/vue/apps/web-antd/src/api/cv/index.ts +++ b/vue/apps/web-antd/src/api/cv/index.ts @@ -1,4 +1,5 @@ export * from './iva'; +export * from './iva-sc'; export * from './license'; export * from './sca'; export * from './sca2'; diff --git a/vue/apps/web-antd/src/api/cv/iva-sc.ts b/vue/apps/web-antd/src/api/cv/iva-sc.ts new file mode 100644 index 0000000..8a11c79 --- /dev/null +++ b/vue/apps/web-antd/src/api/cv/iva-sc.ts @@ -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'); +} diff --git a/vue/apps/web-antd/src/views/cv/iva-sc/index.vue b/vue/apps/web-antd/src/views/cv/iva-sc/index.vue index 947d55a..2c57955 100644 --- a/vue/apps/web-antd/src/views/cv/iva-sc/index.vue +++ b/vue/apps/web-antd/src/views/cv/iva-sc/index.vue @@ -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(null); const detailList = ref([]); const videoEl = ref(null); +const originalVideoEl = ref(null); +const originalPlayer = ref(null); + const player = ref(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>({}); const chartRef1 = ref(); @@ -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(null); const fileInputRef = ref(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) {
- - - -
-
{{ item.v_name }}
-
{{ item.v_a_time }}
+
{{ item.name }}
+
{{ item.created_at }}
@@ -494,6 +517,17 @@ function changePage(newPage) { controls > +
+ +
diff --git a/vue/pnpm-lock.yaml b/vue/pnpm-lock.yaml index b155890..5c59782 100644 --- a/vue/pnpm-lock.yaml +++ b/vue/pnpm-lock.yaml @@ -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