From ce44d803b73a65b2cc31db5bcc662139029463d3 Mon Sep 17 00:00:00 2001
From: doum <doum>
Date: 星期五, 03 七月 2026 10:07:32 +0800
Subject: [PATCH] 海康电表维护
---
admin/src/views/business/collectionMedia.vue | 370 +++++
server/visits/dmvisit_service/src/main/java/com/doumee/service/business/snapshot/SnapshotAnalyzeRequest.java | 23
server/snapshot_infer/requirements.txt | 9
server/db/business.delivery_media_snapshot.sql | 16
server/snapshot_infer/tools/export_feedback.py | 80 +
server/visits/dmvisit_service/src/main/java/com/doumee/service/business/snapshot/SnapshotAnalyzeResponse.java | 62
server/visits/dmvisit_service/src/main/java/com/doumee/dao/admin/request/DeliverySnapshotManualRequest.java | 21
server/snapshot_infer/app/schemas.py | 53
server/snapshot_infer/models/version.json | 8
server/snapshot_infer/training/evaluate.py | 171 ++
server/snapshot_infer/app/pipeline.py | 112 +
server/snapshot_infer/docs/training_troubleshooting.md | 96 +
server/snapshot_infer/training/extract_frames.py | 7
server/snapshot_infer/app/asr.py | 81 +
server/visits/dmvisit_service/src/main/java/com/doumee/service/business/snapshot/SnapshotInferClient.java | 122 +
server/visits/dmvisit_admin/src/main/java/com/doumee/cloud/admin/CollectionStationCloudController.java | 28
server/snapshot_infer/tools/convert_labelstudio.py | 64 +
server/snapshot_infer/training/config.yaml | 28
server/snapshot_infer/README.md | 127 +
server/snapshot_infer/tools/export_media_list.py | 136 ++
server/visits/dmvisit_service/src/main/java/com/doumee/service/business/collection/MediaFrameUtil.java | 142 ++
server/snapshot_infer/app/fusion.py | 52
server/snapshot_infer/training/requirements-train.txt | 10
server/snapshot_infer/app/main.py | 38
server/snapshot_infer/data/annotations.jsonl | 4
server/snapshot_infer/app/__init__.py | 0
server/system_service/src/main/java/com/doumee/core/utils/DateUtil.java | 2
server/db/business.delivery_media_snapshot_feedback.sql | 13
server/db/business.collection_media.snapshot.alter.sql | 5
server/snapshot_infer/app/frame_sampler.py | 45
server/visits/dmvisit_service/src/main/java/com/doumee/service/business/impl/collection/CollectionMediaSyncServiceImpl.java | 67
server/snapshot_infer/data/annotations_poc_sample.jsonl | 2
server/snapshot_infer/app/quality.py | 54
admin/src/api/business/collectionMedia.js | 13
server/snapshot_infer/training/train.py | 206 +++
server/visits/dmvisit_service/src/main/java/com/doumee/dao/business/DeliveryMediaSnapshotMapper.java | 7
server/system_service/src/main/java/com/doumee/core/utils/Constants.java | 2
server/visits/dmvisit_service/src/main/resources/application-dev.yml | 10
server/snapshot_infer/training/export_onnx.py | 128 ++
server/visits/dmvisit_service/src/main/resources/application-pro.yml | 10
server/visits/dmvisit_service/src/main/java/com/doumee/service/business/snapshot/SnapshotInferProperties.java | 40
server/snapshot_infer/data/labels.csv | 291 ++++
server/visits/dmvisit_service/src/main/java/com/doumee/core/haikang/isapi/model/MediaItemDTO.java | 2
server/visits/dmvisit_service/src/main/java/com/doumee/dao/business/model/CollectionMedia.java | 8
server/db/business.collection_media.sql | 3
server/snapshot_infer/docs/annotation_spec.md | 58
server/snapshot_infer/app/video_io.py | 67 +
server/snapshot_infer/training/prepare_dataset.py | 150 ++
server/snapshot_infer/app/onnx_infer.py | 100 +
server/snapshot_infer/docs/deploy_checklist.md | 34
server/visits/dmvisit_service/src/main/java/com/doumee/dao/business/model/DeliveryMediaSnapshotFeedback.java | 31
server/snapshot_infer/app/temporal.py | 47
server/visits/dmvisit_service/src/main/java/com/doumee/dao/business/model/DeliveryMediaSnapshot.java | 46
server/visits/dmvisit_service/src/main/java/com/doumee/service/business/DeliverySnapshotService.java | 17
server/snapshot_infer/tools/benchmark_cpu.py | 52
server/snapshot_infer/.gitignore | 10
server/visits/dmvisit_service/src/main/java/com/doumee/dao/business/DeliveryMediaSnapshotFeedbackMapper.java | 7
server/visits/dmvisit_service/src/main/java/com/doumee/service/business/collection/CollectionMediaConstants.java | 29
server/snapshot_infer/tools/label_studio_config.xml | 9
server/visits/dmvisit_service/src/main/java/com/doumee/service/business/impl/collection/DeliverySnapshotServiceImpl.java | 396 ++++++
60 files changed, 3,801 insertions(+), 20 deletions(-)
diff --git a/admin/src/api/business/collectionMedia.js b/admin/src/api/business/collectionMedia.js
index a5bbd01..05bf8e0 100644
--- a/admin/src/api/business/collectionMedia.js
+++ b/admin/src/api/business/collectionMedia.js
@@ -102,3 +102,16 @@
return new Blob([blob], { type: 'video/mp4' })
})
}
+
+export function analyzeMediaSnapshot (id) {
+ return request.post(`/visitsAdmin/cloudService/business/collectionStation/media/snapshot/analyze/${id}`, {})
+}
+
+export function fetchMediaSnapshots (id, cacheBust) {
+ const config = cacheBust ? { params: { _t: cacheBust } } : {}
+ return request.get(`/visitsAdmin/cloudService/business/collectionStation/media/snapshot/${id}`, config)
+}
+
+export function saveManualMediaSnapshot (data) {
+ return request.post('/visitsAdmin/cloudService/business/collectionStation/media/snapshot/manual', data)
+}
diff --git a/admin/src/views/business/collectionMedia.vue b/admin/src/views/business/collectionMedia.vue
index d6ca607..2745fb5 100644
--- a/admin/src/views/business/collectionMedia.vue
+++ b/admin/src/views/business/collectionMedia.vue
@@ -51,19 +51,38 @@
<el-table-column prop="endTime" label="缁撴潫鏃堕棿" min-width="160" />
<el-table-column prop="downloadStatus" label="涓嬭浇鐘舵��" width="90">
<template slot-scope="{row}">
- <span v-if="row.downloadStatus === 1">宸蹭笅杞�</span>
- <span v-else-if="row.downloadStatus === 2">澶辫触</span>
- <span v-else-if="row.downloadStatus === 3">涓嬭浇涓�</span>
- <span v-else>寰呬笅杞�</span>
+ <span :class="downloadStatusClass(row)">{{ downloadStatusLabel(row) }}</span>
</template>
</el-table-column>
- <el-table-column prop="filePathLocal" label="鏈湴璺緞" min-width="160" show-overflow-tooltip />
- <el-table-column label="鎿嶄綔" width="220" fixed="right">
+ <el-table-column prop="snapshotStatus" label="蹇収" width="90">
<template slot-scope="{row}">
- <el-button type="text" v-if="canPreview(row)" @click="handlePreview(row)">棰勮</el-button>
+ <span :class="snapshotStatusClass(row)">{{ snapshotStatusLabel(row) }}</span>
+ </template>
+ </el-table-column>
+ <el-table-column prop="filePathLocal" label="鏈湴璺緞" min-width="120">
+ <template slot-scope="{row}">
+ <div v-if="isDownloadedVideo(row)" class="list-video-cell" @click="handlePreview(row)">
+ <video
+ :key="'thumb-' + row.id"
+ :src="buildListVideoSrc(row)"
+ class="list-video-thumb"
+ muted
+ preload="metadata"
+ playsinline
+ />
+ </div>
+ <span v-else class="path-text" :title="row.filePathLocal">{{ row.filePathLocal || '-' }}</span>
+ </template>
+ </el-table-column>
+ <el-table-column label="鎿嶄綔" width="240" fixed="right">
+ <template slot-scope="{row}">
<el-button type="text" v-if="canDownloadToStation(row)" v-permissions="['business:collectionMedia:download', 'business:collectionStation:sync']"
@click="handleDownload(row)">涓嬭浇</el-button>
<el-button type="text" v-if="row.downloadStatus === 1" @click="handleSaveLocal(row)">涓嬭浇鍒版湰鍦�</el-button>
+ <el-button type="text" v-if="row.downloadStatus === 1 && row.snapshotStatus !== 1"
+ @click="handleAnalyzeSnapshot(row)">鐢熸垚蹇収</el-button>
+ <el-button type="text" v-if="row.downloadStatus === 1" @click="openManualCorrect(row)">浜哄伐绾犳</el-button>
+ <el-button type="text" v-if="row.snapshotStatus === 2" @click="handleViewSnapshot(row)">鏌ョ湅蹇収</el-button>
</template>
</el-table-column>
</el-table>
@@ -82,6 +101,44 @@
<div v-else-if="!previewLoading" class="preview-empty">鏆備笉鏀寔璇ョ被鍨嬮瑙�</div>
</div>
</el-dialog>
+
+ <el-dialog :title="snapshotTitle" :visible.sync="snapshotVisible" width="720px" append-to-body>
+ <div v-loading="snapshotLoading" class="snapshot-wrap">
+ <div v-if="snapshotList.length" class="snapshot-grid">
+ <div v-for="item in snapshotList" :key="item.id + '-' + snapshotCacheVersion" class="snapshot-item">
+ <div class="snapshot-label">{{ snapshotTypeLabel(item.snapshotType) }}</div>
+ <el-image v-if="item.fileUrlFull" :src="buildSnapshotImageUrl(item.fileUrlFull)" fit="contain"
+ class="snapshot-image" :preview-src-list="[buildSnapshotImageUrl(item.fileUrlFull)]" />
+ <div class="snapshot-meta">
+ <span>鏃跺埢: {{ item.timestampSec }}s</span>
+ <span v-if="item.confidence">缃俊搴�: {{ item.confidence }}</span>
+ <span>鏉ユ簮: {{ item.source || '-' }}</span>
+ </div>
+ </div>
+ </div>
+ <div v-else-if="!snapshotLoading" class="preview-empty">鏆傛棤蹇収锛岃鍏堢偣鍑汇�岀敓鎴愬揩鐓с��</div>
+ <div v-if="snapshotMediaRow" class="snapshot-actions">
+ <el-button type="primary" plain @click="openManualCorrect(snapshotMediaRow)">浜哄伐绾犳鏃跺埢</el-button>
+ </div>
+ </div>
+ </el-dialog>
+
+ <el-dialog title="浜哄伐绾犳蹇収鏃跺埢" :visible.sync="manualCorrectVisible" width="860px" append-to-body
+ @close="closeManualCorrect">
+ <div v-loading="manualCorrectLoading" class="manual-correct-wrap">
+ <video v-if="manualPreviewSrc" ref="manualVideo" :src="manualPreviewSrc" controls preload="metadata"
+ playsinline class="preview-video" @loadedmetadata="onManualVideoLoaded" @timeupdate="onManualTimeUpdate" />
+ <div class="manual-slider">
+ <span>褰撳墠鏃跺埢: {{ manualTimestampSec.toFixed(1) }}s</span>
+ <el-slider v-model="manualTimestampSec" :min="0" :max="manualDurationSec" :step="0.5"
+ @input="seekManualVideo" />
+ </div>
+ <div class="manual-buttons">
+ <el-button type="primary" :loading="manualSaving" @click="saveManualSnapshot(1)">璁句负闂ㄥご鍥�</el-button>
+ <el-button type="success" :loading="manualSaving" @click="saveManualSnapshot(2)">璁句负浜や粯鍥�</el-button>
+ </div>
+ </div>
+ </el-dialog>
</TableLayout>
</template>
@@ -90,7 +147,16 @@
import TableLayout from '@/layouts/TableLayout'
import Pagination from '@/components/common/Pagination'
import { syncMedia, downloadMedia, batchDownloadMedia, list as fetchStationList } from '@/api/business/collectionStation'
-import { fetchPreviewText, fetchPreviewBlob, fetchMediaFile, ensureMp4Blob, buildPreviewUrl } from '@/api/business/collectionMedia'
+import {
+ fetchPreviewText,
+ fetchPreviewBlob,
+ fetchMediaFile,
+ ensureMp4Blob,
+ buildPreviewUrl,
+ analyzeMediaSnapshot,
+ fetchMediaSnapshots,
+ saveManualMediaSnapshot
+} from '@/api/business/collectionMedia'
export default {
name: 'CollectionMedia',
@@ -113,7 +179,23 @@
previewRow: null,
previewUseDirectUrl: false,
previewBlobUrl: '',
- downloadPollTimer: null
+ downloadPollTimer: null,
+ snapshotVisible: false,
+ snapshotLoading: false,
+ snapshotTitle: '閰嶉�佸揩鐓�',
+ snapshotList: [],
+ snapshotPollTimer: null,
+ snapshotMediaRow: null,
+ manualCorrectVisible: false,
+ manualCorrectLoading: false,
+ manualCorrectRow: null,
+ manualPreviewSrc: '',
+ manualBlobUrl: '',
+ manualTimestampSec: 0,
+ manualDurationSec: 600,
+ manualSaving: false,
+ manualSeekFromSlider: false,
+ snapshotCacheVersion: 0
}
},
created () {
@@ -131,7 +213,9 @@
},
beforeDestroy () {
this.revokePreviewUrl()
+ this.revokeManualPreviewUrl()
this.stopDownloadPoll()
+ this.stopSnapshotPoll()
},
methods: {
loadStations () {
@@ -151,8 +235,11 @@
if (row.mediaType === 2) return '闊抽'
return '瑙嗛'
},
- canPreview (row) {
- return row.downloadStatus === 1 && (row.fileUrlFull || row.filePathLocal)
+ isDownloadedVideo (row) {
+ return row.downloadStatus === 1 && row.filePathLocal && this.resolvePreviewMode(row) === 'video'
+ },
+ buildListVideoSrc (row) {
+ return row.fileUrlFull || buildPreviewUrl(row.id)
},
canDownloadToStation (row) {
return row.downloadStatus !== 1 && row.downloadStatus !== 3
@@ -199,6 +286,94 @@
}).catch(err => {
this.$message.error(err.message || '涓嬭浇鍒版湰鍦板け璐�')
})
+ },
+ snapshotTypeLabel (type) {
+ return type === 2 ? '璐у搧浜や粯鍥�' : '鍒板簵闂ㄥご鍥�'
+ },
+ downloadStatusLabel (row) {
+ const map = { 0: '寰呬笅杞�', 1: '宸蹭笅杞�', 2: '澶辫触', 3: '涓嬭浇涓�' }
+ return map[row.downloadStatus] != null ? map[row.downloadStatus] : '寰呬笅杞�'
+ },
+ downloadStatusClass (row) {
+ const map = { 0: 'status-info', 1: 'status-success', 2: 'status-danger', 3: 'status-primary' }
+ return map[row.downloadStatus] || 'status-info'
+ },
+ snapshotStatusLabel (row) {
+ if (row.snapshotStatus === 2) return '宸插畬鎴�'
+ if (row.snapshotStatus === 1) return '鍒嗘瀽涓�'
+ if (row.snapshotStatus === 3) return '澶辫触'
+ if (row.downloadStatus === 1) return '鏈垎鏋�'
+ return '-'
+ },
+ snapshotStatusClass (row) {
+ if (row.snapshotStatus === 2) return 'status-success'
+ if (row.snapshotStatus === 1) return 'status-warning'
+ if (row.snapshotStatus === 3) return 'status-danger'
+ if (row.downloadStatus === 1) return 'status-info'
+ return 'status-muted'
+ },
+ buildSnapshotImageUrl (url) {
+ if (!url) return ''
+ const sep = url.indexOf('?') >= 0 ? '&' : '?'
+ return `${url}${sep}_t=${this.snapshotCacheVersion}`
+ },
+ handleAnalyzeSnapshot (row) {
+ analyzeMediaSnapshot(row.id).then(res => {
+ this.$message.success(res || '宸叉彁浜ゅ揩鐓у垎鏋�')
+ this.search()
+ this.startSnapshotPoll(row.id)
+ })
+ },
+ handleViewSnapshot (row) {
+ this.snapshotMediaRow = row
+ this.snapshotTitle = (row.fileName || '濯掍綋') + ' - 閰嶉�佸揩鐓�'
+ this.snapshotVisible = true
+ this.loadSnapshots(row.id)
+ },
+ loadSnapshots (mediaId) {
+ this.snapshotLoading = true
+ this.snapshotList = []
+ this.snapshotCacheVersion = Date.now()
+ fetchMediaSnapshots(mediaId, this.snapshotCacheVersion).then(list => {
+ this.snapshotList = list || []
+ this.snapshotLoading = false
+ }).catch(err => {
+ this.snapshotLoading = false
+ this.$message.error(err.message || '鍔犺浇蹇収澶辫触')
+ })
+ },
+ startSnapshotPoll (mediaId) {
+ this.stopSnapshotPoll()
+ let count = 0
+ this.snapshotPollTimer = setInterval(() => {
+ count++
+ if (count > 40) {
+ this.stopSnapshotPoll()
+ return
+ }
+ this.api.fetchList({
+ page: this.tableData.pagination.pageIndex,
+ capacity: this.tableData.pagination.pageSize,
+ model: this.searchForm,
+ sorts: this.tableData.sorts
+ }).then(data => {
+ this.tableData.list = data.records
+ this.tableData.pagination.total = data.total
+ const row = (data.records || []).find(item => item.id === mediaId)
+ if (row && row.snapshotStatus !== 1) {
+ this.stopSnapshotPoll()
+ if (row.snapshotStatus === 2 && this.snapshotVisible) {
+ this.loadSnapshots(mediaId)
+ }
+ }
+ }).catch(() => {})
+ }, 3000)
+ },
+ stopSnapshotPoll () {
+ if (this.snapshotPollTimer) {
+ clearInterval(this.snapshotPollTimer)
+ this.snapshotPollTimer = null
+ }
},
startDownloadPoll () {
this.stopDownloadPoll()
@@ -333,6 +508,92 @@
URL.revokeObjectURL(this.previewBlobUrl)
this.previewBlobUrl = ''
}
+ },
+ openManualCorrect (row) {
+ if (row.downloadStatus !== 1) {
+ this.$message.warning('璇峰厛涓嬭浇濯掍綋鏂囦欢')
+ return
+ }
+ this.manualCorrectRow = row
+ this.manualCorrectVisible = true
+ this.manualCorrectLoading = true
+ this.manualTimestampSec = 0
+ this.manualDurationSec = 600
+ this.revokeManualPreviewUrl()
+ fetchPreviewBlob(row.id)
+ .then(blob => ensureMp4Blob(blob))
+ .then(blob => {
+ this.manualBlobUrl = URL.createObjectURL(blob)
+ this.manualPreviewSrc = this.manualBlobUrl
+ this.manualCorrectLoading = false
+ })
+ .catch(err => {
+ this.manualCorrectLoading = false
+ this.$message.error(err.message || '鍔犺浇瑙嗛澶辫触')
+ })
+ },
+ closeManualCorrect () {
+ this.revokeManualPreviewUrl()
+ this.manualPreviewSrc = ''
+ this.manualCorrectRow = null
+ },
+ revokeManualPreviewUrl () {
+ if (this.manualBlobUrl) {
+ URL.revokeObjectURL(this.manualBlobUrl)
+ this.manualBlobUrl = ''
+ }
+ },
+ onManualVideoLoaded () {
+ const video = this.$refs.manualVideo
+ if (video && video.duration && isFinite(video.duration)) {
+ this.manualDurationSec = Math.max(1, Math.floor(video.duration))
+ }
+ },
+ onManualTimeUpdate () {
+ if (this.manualSeekFromSlider) {
+ return
+ }
+ const video = this.$refs.manualVideo
+ if (video) {
+ this.manualTimestampSec = Math.round(video.currentTime * 2) / 2
+ }
+ },
+ seekManualVideo (val) {
+ const video = this.$refs.manualVideo
+ if (!video) {
+ return
+ }
+ this.manualSeekFromSlider = true
+ video.currentTime = val
+ setTimeout(() => {
+ this.manualSeekFromSlider = false
+ }, 200)
+ },
+ saveManualSnapshot (snapshotType) {
+ if (!this.manualCorrectRow) {
+ return
+ }
+ this.manualSaving = true
+ saveManualMediaSnapshot({
+ mediaId: this.manualCorrectRow.id,
+ snapshotType,
+ timestampSec: this.manualTimestampSec
+ }).then(res => {
+ this.manualSaving = false
+ this.$message.success(res || '淇濆瓨鎴愬姛')
+ const mediaId = this.manualCorrectRow.id
+ this.snapshotCacheVersion = Date.now()
+ this.search()
+ if (this.snapshotMediaRow && this.snapshotMediaRow.id === mediaId) {
+ this.snapshotMediaRow = { ...this.snapshotMediaRow, snapshotStatus: 2 }
+ }
+ if (this.snapshotVisible && this.snapshotMediaRow && this.snapshotMediaRow.id === mediaId) {
+ this.loadSnapshots(mediaId)
+ }
+ }).catch(err => {
+ this.manualSaving = false
+ this.$message.error(err.message || '淇濆瓨澶辫触')
+ })
}
}
}
@@ -369,4 +630,91 @@
color: #909399;
padding: 40px 0;
}
+.snapshot-wrap {
+ min-height: 120px;
+}
+.snapshot-grid {
+ display: flex;
+ gap: 16px;
+ flex-wrap: wrap;
+}
+.snapshot-item {
+ flex: 1;
+ min-width: 280px;
+}
+.snapshot-label {
+ font-weight: 600;
+ margin-bottom: 8px;
+}
+.snapshot-image {
+ width: 100%;
+ max-height: 280px;
+ background: #f5f7fa;
+}
+.snapshot-meta {
+ margin-top: 8px;
+ font-size: 12px;
+ color: #606266;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+.snapshot-actions {
+ margin-top: 16px;
+ text-align: center;
+}
+.manual-correct-wrap {
+ min-height: 200px;
+}
+.manual-slider {
+ margin-top: 16px;
+}
+.manual-buttons {
+ margin-top: 16px;
+ display: flex;
+ gap: 12px;
+ justify-content: center;
+}
+.list-video-cell {
+ display: inline-block;
+ cursor: pointer;
+ line-height: 0;
+}
+.list-video-thumb {
+ width: 70px;
+ height: 70px;
+ object-fit: cover;
+ background: #000;
+ border-radius: 4px;
+ vertical-align: middle;
+}
+.path-text {
+ display: inline-block;
+ max-width: 160px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.status-success {
+ color: #67c23a;
+ font-weight: 500;
+}
+.status-danger {
+ color: #f56c6c;
+ font-weight: 500;
+}
+.status-warning {
+ color: #e6a23c;
+ font-weight: 500;
+}
+.status-primary {
+ color: #409eff;
+ font-weight: 500;
+}
+.status-info {
+ color: #909399;
+}
+.status-muted {
+ color: #c0c4cc;
+}
</style>
diff --git a/server/db/business.collection_media.snapshot.alter.sql b/server/db/business.collection_media.snapshot.alter.sql
new file mode 100644
index 0000000..c79c35e
--- /dev/null
+++ b/server/db/business.collection_media.snapshot.alter.sql
@@ -0,0 +1,5 @@
+-- collection_media 澧炲姞蹇収鍒嗘瀽鐘舵��
+ALTER TABLE `collection_media`
+ ADD COLUMN `snapshot_status` int(11) DEFAULT '0' COMMENT '0鏈垎鏋� 1鍒嗘瀽涓� 2瀹屾垚 3澶辫触' AFTER `download_time`,
+ ADD COLUMN `snapshot_time` datetime DEFAULT NULL COMMENT '蹇収鍒嗘瀽瀹屾垚鏃堕棿' AFTER `snapshot_status`,
+ ADD COLUMN `snapshot_message` varchar(512) DEFAULT NULL COMMENT '蹇収鍒嗘瀽澶辫触鍘熷洜' AFTER `snapshot_time`;
diff --git a/server/db/business.collection_media.sql b/server/db/business.collection_media.sql
index 217091c..866575e 100644
--- a/server/db/business.collection_media.sql
+++ b/server/db/business.collection_media.sql
@@ -15,6 +15,9 @@
`file_path_local` varchar(512) DEFAULT NULL COMMENT 'FTP鏈湴鐩稿璺緞',
`download_status` int(11) DEFAULT '0' COMMENT '0寰呬笅杞� 1宸蹭笅杞� 2澶辫触 3涓嬭浇涓�',
`download_time` datetime DEFAULT NULL COMMENT '涓嬭浇瀹屾垚鏃堕棿',
+ `snapshot_status` int(11) DEFAULT '0' COMMENT '0鏈垎鏋� 1鍒嗘瀽涓� 2瀹屾垚 3澶辫触',
+ `snapshot_time` datetime DEFAULT NULL COMMENT '蹇収鍒嗘瀽瀹屾垚鏃堕棿',
+ `snapshot_message` varchar(512) DEFAULT NULL COMMENT '蹇収鍒嗘瀽澶辫触鍘熷洜',
`create_date` datetime DEFAULT NULL COMMENT '鍒涘缓鏃堕棿',
`isdeleted` int(11) DEFAULT '0' COMMENT '鏄惁鍒犻櫎0鍚� 1鏄�',
PRIMARY KEY (`id`),
diff --git a/server/db/business.delivery_media_snapshot.sql b/server/db/business.delivery_media_snapshot.sql
new file mode 100644
index 0000000..da5663d
--- /dev/null
+++ b/server/db/business.delivery_media_snapshot.sql
@@ -0,0 +1,16 @@
+CREATE TABLE IF NOT EXISTS `delivery_media_snapshot` (
+ `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '涓婚敭',
+ `media_id` int(11) NOT NULL COMMENT 'collection_media.id',
+ `transport_code` varchar(64) DEFAULT NULL COMMENT '杩愬崟鍙�(鍙��)',
+ `snapshot_type` int(11) NOT NULL COMMENT '1闂ㄥご 2浜や粯',
+ `timestamp_sec` decimal(10,2) DEFAULT NULL COMMENT '瑙嗛鍐呯鏁�',
+ `file_path` varchar(512) DEFAULT NULL COMMENT 'FTP鐩稿璺緞',
+ `confidence` decimal(5,4) DEFAULT NULL COMMENT '缃俊搴�',
+ `source` varchar(16) DEFAULT NULL COMMENT 'ai/asr/hybrid/voice/manual/mock',
+ `model_version` varchar(32) DEFAULT NULL COMMENT '妯″瀷鐗堟湰',
+ `create_date` datetime DEFAULT NULL COMMENT '鍒涘缓鏃堕棿',
+ `isdeleted` int(11) DEFAULT '0' COMMENT '0鍚� 1鏄�',
+ PRIMARY KEY (`id`),
+ KEY `idx_media_id` (`media_id`),
+ KEY `idx_transport_code` (`transport_code`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='閰嶉�佸獟浣撳揩鐓�(闂ㄥご/浜や粯)';
diff --git a/server/db/business.delivery_media_snapshot_feedback.sql b/server/db/business.delivery_media_snapshot_feedback.sql
new file mode 100644
index 0000000..37a4dad
--- /dev/null
+++ b/server/db/business.delivery_media_snapshot_feedback.sql
@@ -0,0 +1,13 @@
+CREATE TABLE IF NOT EXISTS `delivery_media_snapshot_feedback` (
+ `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '涓婚敭',
+ `media_id` int(11) NOT NULL COMMENT 'collection_media.id',
+ `snapshot_type` int(11) NOT NULL COMMENT '1闂ㄥご 2浜や粯',
+ `ai_time_sec` decimal(10,2) DEFAULT NULL COMMENT 'AI鍘熸椂鍒�',
+ `manual_time_sec` decimal(10,2) NOT NULL COMMENT '浜哄伐绾犳鏃跺埢',
+ `model_version` varchar(32) DEFAULT NULL COMMENT '绾犳鏃舵ā鍨嬬増鏈�',
+ `create_date` datetime DEFAULT NULL COMMENT '鍒涘缓鏃堕棿',
+ `isdeleted` int(11) DEFAULT '0' COMMENT '0鍚� 1鏄�',
+ PRIMARY KEY (`id`),
+ KEY `idx_media_id` (`media_id`),
+ KEY `idx_create_date` (`create_date`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='蹇収浜哄伐绾犳鍙嶉(鐢ㄤ簬澧為噺璁粌)';
diff --git a/server/snapshot_infer/.gitignore b/server/snapshot_infer/.gitignore
new file mode 100644
index 0000000..a917ec8
--- /dev/null
+++ b/server/snapshot_infer/.gitignore
@@ -0,0 +1,10 @@
+models/*.onnx
+models/*.pt
+models/*.pth
+data/frames/
+data/raw/
+__pycache__/
+*.pyc
+.venv/
+venv/
+*.whl
diff --git a/server/snapshot_infer/README.md b/server/snapshot_infer/README.md
new file mode 100644
index 0000000..b4737f9
--- /dev/null
+++ b/server/snapshot_infer/README.md
@@ -0,0 +1,127 @@
+# snapshot-infer
+
+閰嶉�佷换鍔¤棰� **闂ㄥご / 浜や粯** 鏃跺埢妫�娴嬫帹鐞嗘湇鍔★紙ONNX 瑙嗚 + faster-whisper ASR 铻嶅悎锛夈��
+
+## 鏋舵瀯
+
+1. Java `dmvisit_service` 涓嬭浇 MP4 鍒� FTP 鍚庯紝寮傛璋冪敤 `POST /analyze`
+2. 鏈湇鍔′笅杞借棰� 鈫� 骞惰 **鎶藉抚 ONNX 鎵撳垎** + **ASR 鍏抽敭璇�** 鈫� 铻嶅悎鏃跺埢
+3. Java 渚� FFmpeg 鎴抚骞朵笂浼� FTP锛坄delivery_media_snapshot` 琛級
+
+## 鐜瑕佹眰
+
+- Python 3.9+锛圵indows 鐢� `py` 鍚姩鍣級
+- **ffmpeg / ffprobe** 鍦� PATH 鎴栬缃� `FFMPEG_DIR`
+- `models/` 鐩綍涓嬫斁缃缁冨鍑虹殑 ONNX锛�
+ - `storefront_int8.onnx` / `handover_int8.onnx`锛堟垨 float 鐗堬級
+ - `version.json`
+
+## 瀹夎涓庡惎鍔紙Windows CPU锛�
+
+```powershell
+cd server/snapshot_infer
+py -m pip install -r requirements.txt
+# 璁粌渚濊禆锛堜粎璁粌鏈猴級
+# py -m pip install -r training/requirements-train.txt
+
+$env:SNAPSHOT_MODEL_DIR = "./models"
+$env:WHISPER_MODEL = "tiny"
+$env:SNAPSHOT_SAMPLE_FPS = "0.5"
+py -m uvicorn app.main:app --host 0.0.0.0 --port 8095
+```
+
+Linux:
+
+```bash
+cd server/snapshot_infer
+python3 -m pip install -r requirements.txt
+export SNAPSHOT_MODEL_DIR=./models WHISPER_MODEL=tiny SNAPSHOT_SAMPLE_FPS=0.5
+python3 -m uvicorn app.main:app --host 0.0.0.0 --port 8095
+```
+
+## API
+
+| 鏂规硶 | 璺緞 | 璇存槑 |
+|------|------|------|
+| GET | `/health` | 杩斿洖 `status`, `modelVersion`, ONNX/ASR 鍔犺浇鐘舵�� |
+| POST | `/analyze` | 涓绘帹鐞嗭紙璇锋眰/鍝嶅簲 camelCase锛屼笌 Java DTO 瀵归綈锛� |
+
+### `/health` 绀轰緥
+
+```json
+{
+ "status": "ok",
+ "modelVersion": "1.0.0",
+ "onnxStorefrontLoaded": true,
+ "onnxHandoverLoaded": true,
+ "asrAvailable": true
+}
+```
+
+`status=degraded` 琛ㄧず ONNX 鏈姞杞藉畬鏁达紝闇�閮ㄧ讲妯″瀷鏂囦欢銆�
+
+## dmvisit 閰嶇疆
+
+`application-dev.yml` / `application-pro.yml`:
+
+```yaml
+snapshot:
+ infer:
+ base-url: http://127.0.0.1:8095
+ sample-fps: 0.5
+ fail-open-mock: false # 鐢熶骇蹇呴』涓� false
+```
+
+- **dev** 鍙 `fail-open-mock: true` 鍦ㄦ棤 ONNX 鏃跺洖閫� 25%/75% mock
+- **pro** 宸查粯璁� `fail-open-mock: false`锛屾帹鐞嗗け璐ュ啓鍏� `snapshot_status=3`
+
+## 璁粌娴佺▼
+
+璇﹁ [`docs/annotation_spec.md`](docs/annotation_spec.md)銆�
+
+```powershell
+cd server/snapshot_infer/training
+py -m pip install -r requirements-train.txt
+
+# 1. 鍑嗗鏍囨敞 JSONL -> data/annotations.jsonl
+# 2. 鎶藉抚鎵撴爣
+py prepare_dataset.py -c config.yaml
+
+# 3. 璁粌
+py train.py -c config.yaml
+
+# 4. 瀵煎嚭 ONNX锛圵indows 鑻� INT8 閲忓寲宕╂簝锛屼細鑷姩浣跨敤 float .onnx锛�
+py export_onnx.py -c config.yaml --version 1.0.0
+# 鎴栬烦杩囬噺鍖栵細py export_onnx.py -c config.yaml --version 1.0.0 --no-quantize
+
+# 5. 璇勪及
+py evaluate.py -c config.yaml --annotations ../data/annotations.jsonl
+```
+
+### Windows 鏃犳硶鐢熸垚 `*_int8.onnx`
+
+閮ㄥ垎 Windows 鐜涓� `onnxruntime.quantization` 浼氳繘绋嬪穿婧冿紙exit `3221225477`锛夈��**涓嶅奖鍝嶄娇鐢�**锛�
+
+- 瀵煎嚭鑴氭湰浼氳嚜鍔ㄥ洖閫�鍒� `storefront.onnx` / `handover.onnx`锛坒loat32锛�
+- `version.json` 浼氳褰曞疄闄呮枃浠跺悕
+- 鎺ㄧ悊鏈嶅姟鍚屾牱鍙姞杞� float 妯″瀷锛屼粎閫熷害鐣ユ參浜� INT8
+
+鑻ュ繀椤� INT8锛屽彲鍦� Linux 璁粌鏈烘墽琛� `export_onnx.py`锛屾垨灏濊瘯 `py -m pip install onnxruntime==1.16.3` 鍚庨噸璇曘��
+
+## 宸ュ叿
+
+| 鑴氭湰 | 鐢ㄩ�� |
+|------|------|
+| `tools/export_media_list.py --mysql` | 浠� DB 瀵煎嚭寰呮爣娉ㄦ竻鍗� |
+| `tools/export_feedback.py` | 瀵煎嚭浜哄伐绾犳鍙嶉涓鸿缁� JSONL |
+| `tools/benchmark_cpu.py <video_url>` | CPU 鍗曟潯鍘嬫祴 |
+
+## 鐩戞帶
+
+- 鍚姩鍚庤闂� `GET http://127.0.0.1:8095/health`
+- `onnxStorefrontLoaded=false` 鈫� 妫�鏌� `models/` 璺緞涓庣幆澧冨彉閲� `SNAPSHOT_MODEL_DIR`
+- 鍒嗘瀽澶辫触鍘熷洜瑙� `collection_media.snapshot_message`
+
+## 鏁版嵁搴擄紙闇�鎵ц SQL锛�
+
+- `server/db/business.delivery_media_snapshot_feedback.sql` 鈥� 浜哄伐绾犳鍙嶉琛�
diff --git a/server/snapshot_infer/app/__init__.py b/server/snapshot_infer/app/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/server/snapshot_infer/app/__init__.py
diff --git a/server/snapshot_infer/app/asr.py b/server/snapshot_infer/app/asr.py
new file mode 100644
index 0000000..c0ec6e9
--- /dev/null
+++ b/server/snapshot_infer/app/asr.py
@@ -0,0 +1,81 @@
+# -*- coding: utf-8 -*-
+import logging
+import os
+import subprocess
+import tempfile
+from typing import List, Optional, Tuple
+
+from app.schemas import AsrHit, KeywordConfig
+from app.video_io import get_ffmpeg_cmd
+
+logger = logging.getLogger(__name__)
+
+_whisper_model = None
+
+
+def _get_whisper():
+ global _whisper_model
+ if _whisper_model is None:
+ from faster_whisper import WhisperModel
+ model_size = os.environ.get("WHISPER_MODEL", "tiny")
+ _whisper_model = WhisperModel(model_size, device="cpu", compute_type="int8")
+ logger.info("鍔犺浇 Whisper 妯″瀷: %s", model_size)
+ return _whisper_model
+
+
+def extract_audio_wav(video_path: str) -> str:
+ out = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
+ out.close()
+ cmd = [
+ get_ffmpeg_cmd("ffmpeg"), "-y", "-i", video_path,
+ "-vn", "-acodec", "pcm_s16le", "-ar", "16000", "-ac", "1",
+ out.name,
+ ]
+ subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+ return out.name
+
+
+def transcribe(video_path: str) -> List[Tuple[str, float, float]]:
+ wav = extract_audio_wav(video_path)
+ try:
+ model = _get_whisper()
+ segments, _ = model.transcribe(wav, language="zh", vad_filter=True)
+ return [(seg.text.strip(), seg.start, seg.end) for seg in segments if seg.text.strip()]
+ except Exception as e:
+ logger.warning("ASR 澶辫触: %s", e)
+ return []
+ finally:
+ if os.path.isfile(wav):
+ os.remove(wav)
+
+
+def match_keywords(
+ segments: List[Tuple[str, float, float]],
+ keywords: KeywordConfig,
+) -> List[AsrHit]:
+ hits: List[AsrHit] = []
+ for text, start, _end in segments:
+ for kw in keywords.storefront:
+ if kw in text:
+ hits.append(AsrHit(keyword=kw, time_sec=round(start, 2)))
+ break
+ for kw in keywords.handover:
+ if kw in text:
+ hits.append(AsrHit(keyword=kw, time_sec=round(start, 2)))
+ break
+ return hits
+
+
+def best_asr_time(hits: List[AsrHit], keywords: List[str]) -> Optional[float]:
+ for hit in hits:
+ if hit.keyword in keywords:
+ return hit.time_sec
+ return None
+
+
+def asr_available() -> bool:
+ try:
+ import faster_whisper # noqa: F401
+ return True
+ except ImportError:
+ return False
diff --git a/server/snapshot_infer/app/frame_sampler.py b/server/snapshot_infer/app/frame_sampler.py
new file mode 100644
index 0000000..a0c33ba
--- /dev/null
+++ b/server/snapshot_infer/app/frame_sampler.py
@@ -0,0 +1,45 @@
+# -*- coding: utf-8 -*-
+import logging
+import os
+import subprocess
+import tempfile
+from typing import List, Tuple
+
+from app.video_io import get_ffmpeg_cmd
+
+logger = logging.getLogger(__name__)
+
+
+def sample_frames(video_path: str, sample_fps: float, duration: float) -> List[Tuple[float, str]]:
+ """鎸� sample_fps 鎶藉抚锛岃繑鍥� [(time_sec, jpg_path), ...]"""
+ if sample_fps <= 0:
+ sample_fps = 0.5
+ step = 1.0 / sample_fps
+ out_dir = tempfile.mkdtemp(prefix="snap_frames_")
+ frames: List[Tuple[float, str]] = []
+ t = 0.0
+ idx = 0
+ ffmpeg = get_ffmpeg_cmd("ffmpeg")
+ while t <= duration:
+ out_path = os.path.join(out_dir, f"{idx:06d}.jpg")
+ cmd = [
+ ffmpeg, "-y", "-ss", f"{t:.3f}",
+ "-i", video_path,
+ "-frames:v", "1", "-q:v", "2",
+ out_path,
+ ]
+ print(f"cmd{cmd}")
+ subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+ if os.path.isfile(out_path) and os.path.getsize(out_path) > 0:
+ frames.append((round(t, 3), out_path))
+ t += step
+ idx += 1
+ return frames
+
+
+def cleanup_frames(frames: List[Tuple[float, str]]) -> None:
+ if not frames:
+ return
+ out_dir = os.path.dirname(frames[0][1])
+ import shutil
+ shutil.rmtree(out_dir, ignore_errors=True)
diff --git a/server/snapshot_infer/app/fusion.py b/server/snapshot_infer/app/fusion.py
new file mode 100644
index 0000000..64b4dd4
--- /dev/null
+++ b/server/snapshot_infer/app/fusion.py
@@ -0,0 +1,52 @@
+# -*- coding: utf-8 -*-
+from typing import List, Optional, Tuple
+
+from app.schemas import AsrHit, KeywordConfig, SnapshotHit
+
+
+def fuse_hit(
+ vision: Optional[Tuple[float, float]],
+ asr_time: Optional[float],
+ asr_weight: float = 0.7,
+ window: float = 2.0,
+) -> Optional[SnapshotHit]:
+ if vision and asr_time is not None:
+ vt, vc = vision
+ if abs(vt - asr_time) <= window * 3:
+ t = round(asr_weight * asr_time + (1 - asr_weight) * vt, 2)
+ conf = min(0.99, vc + 0.1)
+ return SnapshotHit(time_sec=t, confidence=round(conf, 4), source="hybrid")
+ t = round(asr_weight * asr_time + (1 - asr_weight) * vt, 2)
+ return SnapshotHit(time_sec=t, confidence=round(vc, 4), source="hybrid")
+ if asr_time is not None:
+ return SnapshotHit(time_sec=round(asr_time, 2), confidence=0.75, source="asr")
+ if vision:
+ return SnapshotHit(time_sec=round(vision[0], 2), confidence=round(vision[1], 4), source="ai")
+ return None
+
+
+def fuse_results(
+ sf_vision: Optional[Tuple[float, float]],
+ ho_vision: Optional[Tuple[float, float]],
+ asr_hits: List[AsrHit],
+ keywords: KeywordConfig,
+ duration: float,
+) -> Tuple[Optional[SnapshotHit], Optional[SnapshotHit]]:
+ from app.asr import best_asr_time
+
+ sf_asr = best_asr_time(asr_hits, keywords.storefront)
+ ho_asr = best_asr_time(asr_hits, keywords.handover)
+
+ storefront = fuse_hit(sf_vision, sf_asr)
+ min_ho = (storefront.time_sec + 30.0) if storefront else 0.0
+ if ho_vision and ho_vision[0] < min_ho:
+ ho_vision = None
+ if ho_asr is not None and ho_asr < min_ho:
+ ho_asr = None
+
+ handover = fuse_hit(ho_vision, ho_asr)
+
+ if storefront and handover and handover.time_sec <= storefront.time_sec:
+ handover.time_sec = round(min(duration - 1, storefront.time_sec + 60), 2)
+
+ return storefront, handover
diff --git a/server/snapshot_infer/app/main.py b/server/snapshot_infer/app/main.py
new file mode 100644
index 0000000..fb3ecfa
--- /dev/null
+++ b/server/snapshot_infer/app/main.py
@@ -0,0 +1,38 @@
+# -*- coding: utf-8 -*-
+"""閰嶉�佽棰戦棬澶�/浜や粯鏃跺埢妫�娴嬫帹鐞嗘湇鍔★紙ONNX + ASR锛夈��"""
+import logging
+import os
+
+from fastapi import FastAPI
+from fastapi.responses import JSONResponse
+from pydantic import BaseModel
+
+from app.asr import asr_available
+from app.pipeline import get_registry, run_analyze
+from app.schemas import AnalyzeRequest, HealthResponse
+
+logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
+
+app = FastAPI(title="snapshot-infer", version="2.0.0")
+
+
+def _camel_json(model: BaseModel) -> JSONResponse:
+ return JSONResponse(content=model.model_dump(by_alias=True, exclude_none=True))
+
+
+@app.get("/health")
+def health():
+ reg = get_registry()
+ body = HealthResponse(
+ status="ok" if reg.ready else "degraded",
+ model_version=reg.version,
+ onnx_storefront_loaded=reg.storefront.loaded if reg.storefront else False,
+ onnx_handover_loaded=reg.handover.loaded if reg.handover else False,
+ asr_available=asr_available(),
+ )
+ return _camel_json(body)
+
+
+@app.post("/analyze")
+def analyze(req: AnalyzeRequest):
+ return _camel_json(run_analyze(req))
diff --git a/server/snapshot_infer/app/onnx_infer.py b/server/snapshot_infer/app/onnx_infer.py
new file mode 100644
index 0000000..a63d5f3
--- /dev/null
+++ b/server/snapshot_infer/app/onnx_infer.py
@@ -0,0 +1,100 @@
+# -*- coding: utf-8 -*-
+import json
+import logging
+import os
+from typing import List, Optional, Tuple
+
+import numpy as np
+import onnxruntime as ort
+from PIL import Image
+
+logger = logging.getLogger(__name__)
+
+IMAGENET_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32)
+IMAGENET_STD = np.array([0.229, 0.224, 0.225], dtype=np.float32)
+
+
+class OnnxClassifier:
+ def __init__(self, model_path: str, image_size: int = 224):
+ self.image_size = image_size
+ self.session: Optional[ort.InferenceSession] = None
+ if model_path and os.path.isfile(model_path):
+ self.session = ort.InferenceSession(
+ model_path,
+ providers=["CPUExecutionProvider"],
+ )
+ logger.info("鍔犺浇 ONNX: %s", model_path)
+
+ @property
+ def loaded(self) -> bool:
+ return self.session is not None
+
+ def preprocess(self, image_path: str) -> np.ndarray:
+ img = Image.open(image_path).convert("RGB").resize((self.image_size, self.image_size))
+ arr = np.array(img).astype(np.float32) / 255.0
+ arr = (arr - IMAGENET_MEAN) / IMAGENET_STD
+ return arr.transpose(2, 0, 1)[None]
+
+ def predict_batch(self, image_paths: List[str], batch_size: int = 16) -> List[float]:
+ if not self.session:
+ return [0.0] * len(image_paths)
+ scores = []
+ for i in range(0, len(image_paths), batch_size):
+ batch_paths = image_paths[i : i + batch_size]
+ batch = np.concatenate([self.preprocess(p) for p in batch_paths], axis=0)
+ logits = self.session.run(None, {"input": batch})[0]
+ for logit in logits:
+ scores.append(float(1.0 / (1.0 + np.exp(-logit[0]))))
+ return scores
+
+
+class ModelRegistry:
+ def __init__(self, model_dir: str):
+ self.model_dir = model_dir
+ self.version = "unknown"
+ self.image_size = 224
+ self.storefront: Optional[OnnxClassifier] = None
+ self.handover: Optional[OnnxClassifier] = None
+ self._load()
+
+ def _load(self):
+ version_path = os.path.join(self.model_dir, "version.json")
+ sf_name = ho_name = None
+ if os.path.isfile(version_path):
+ with open(version_path, encoding="utf-8") as f:
+ meta = json.load(f)
+ self.version = meta.get("model_version", "1.0.0")
+ self.image_size = int(meta.get("image_size", 224))
+ sf_name = meta.get("storefront_model")
+ ho_name = meta.get("handover_model")
+
+ def resolve(name: str, fallback: str) -> str:
+ if name:
+ path = os.path.join(self.model_dir, name)
+ if os.path.isfile(path):
+ return path
+ for suffix in (f"{fallback}_int8.onnx", f"{fallback}.onnx"):
+ path = os.path.join(self.model_dir, suffix)
+ if os.path.isfile(path):
+ return path
+ return ""
+
+ sf_path = resolve(sf_name, "storefront")
+ ho_path = resolve(ho_name, "handover")
+ self.storefront = OnnxClassifier(sf_path, self.image_size) if sf_path else OnnxClassifier("", self.image_size)
+ self.handover = OnnxClassifier(ho_path, self.image_size) if ho_path else OnnxClassifier("", self.image_size)
+
+ @property
+ def ready(self) -> bool:
+ return self.storefront.loaded and self.handover.loaded
+
+
+def score_frames(
+ registry: ModelRegistry,
+ frames: List[Tuple[float, str]],
+) -> Tuple[List[Tuple[float, float]], List[Tuple[float, float]]]:
+ times = [f[0] for f in frames]
+ paths = [f[1] for f in frames]
+ sf_scores = registry.storefront.predict_batch(paths)
+ ho_scores = registry.handover.predict_batch(paths)
+ return list(zip(times, sf_scores)), list(zip(times, ho_scores))
diff --git a/server/snapshot_infer/app/pipeline.py b/server/snapshot_infer/app/pipeline.py
new file mode 100644
index 0000000..ea0bb9c
--- /dev/null
+++ b/server/snapshot_infer/app/pipeline.py
@@ -0,0 +1,112 @@
+# -*- coding: utf-8 -*-
+import logging
+import os
+from concurrent.futures import ThreadPoolExecutor, as_completed
+
+from app.asr import asr_available, match_keywords, transcribe
+from app.frame_sampler import cleanup_frames, sample_frames
+from app.fusion import fuse_results
+from app.onnx_infer import ModelRegistry, score_frames
+from app.quality import refine_time_in_window
+from app.schemas import AnalyzeRequest, AnalyzeResponse, KeywordConfig
+from app.temporal import find_peaks_ordered
+from app.video_io import temp_video
+
+logger = logging.getLogger(__name__)
+
+_registry: ModelRegistry = None
+
+
+def get_registry() -> ModelRegistry:
+ global _registry
+ if _registry is None:
+ model_dir = os.environ.get("SNAPSHOT_MODEL_DIR", os.path.join(os.path.dirname(__file__), "..", "models"))
+ _registry = ModelRegistry(os.path.abspath(model_dir))
+ return _registry
+
+
+def run_analyze(req: AnalyzeRequest) -> AnalyzeResponse:
+ registry = get_registry()
+ if not registry.ready:
+ return AnalyzeResponse(
+ success=False,
+ model_version=registry.version,
+ message="ONNX 妯″瀷鏈姞杞斤紝璇峰皢 storefront/handover ONNX 鏀惧叆 models/ 鐩綍",
+ )
+ if not req.video_url:
+ return AnalyzeResponse(success=False, model_version=registry.version, message="video_url 涓嶈兘涓虹┖")
+
+ keywords = req.keywords or KeywordConfig()
+ sample_fps = req.sample_fps if req.sample_fps and req.sample_fps > 0 else float(os.environ.get("SNAPSHOT_SAMPLE_FPS", "0.5"))
+
+ try:
+ with temp_video(req.video_url, req.duration_sec or 0.0) as (video_path, duration):
+ if req.duration_sec and req.duration_sec > 0:
+ duration = req.duration_sec
+
+ asr_hits = []
+ sf_vision = ho_vision = None
+
+ def vision_task():
+ frames = sample_frames(video_path, sample_fps, duration)
+ try:
+ sf_scores, ho_scores = score_frames(registry, frames)
+ return find_peaks_ordered(sf_scores, ho_scores, duration), frames
+ finally:
+ cleanup_frames(frames)
+
+ def asr_task():
+ if not req.enable_asr or not asr_available():
+ return []
+ segments = transcribe(video_path)
+ return match_keywords(segments, keywords)
+
+ with ThreadPoolExecutor(max_workers=2) as pool:
+ futures = {pool.submit(vision_task): "vision"}
+ if req.enable_asr:
+ futures[pool.submit(asr_task)] = "asr"
+ vision_result = None
+ for fut in as_completed(futures):
+ if futures[fut] == "vision":
+ vision_result, _ = fut.result()
+ else:
+ asr_hits = fut.result()
+
+ if vision_result:
+ sf_vision, ho_vision = vision_result
+
+ storefront, handover = fuse_results(sf_vision, ho_vision, asr_hits, keywords, duration)
+
+ if storefront:
+ t, _ = refine_time_in_window(video_path, storefront.time_sec)
+ storefront.time_sec = t
+ if handover:
+ t, _ = refine_time_in_window(video_path, handover.time_sec)
+ handover.time_sec = t
+
+ if not storefront or not handover:
+ return AnalyzeResponse(
+ success=False,
+ model_version=registry.version,
+ duration_sec=duration,
+ storefront=storefront,
+ handover=handover,
+ asr_hits=asr_hits,
+ message="鏈兘妫�娴嬪埌闂ㄥご鎴栦氦浠樻椂鍒�",
+ )
+
+ return AnalyzeResponse(
+ success=True,
+ model_version=registry.version,
+ duration_sec=round(duration, 2),
+ storefront=storefront,
+ handover=handover,
+ asr_hits=asr_hits,
+ )
+ except Exception as e:
+ logger.exception("鍒嗘瀽澶辫触 media_id=%s", req.media_id)
+ return AnalyzeResponse(
+ success=False,
+ model_version=registry.version,
+ message=str(e),
+ )
diff --git a/server/snapshot_infer/app/quality.py b/server/snapshot_infer/app/quality.py
new file mode 100644
index 0000000..9d81365
--- /dev/null
+++ b/server/snapshot_infer/app/quality.py
@@ -0,0 +1,54 @@
+# -*- coding: utf-8 -*-
+import logging
+import subprocess
+from typing import Optional, Tuple
+
+import cv2
+import numpy as np
+
+from app.video_io import get_ffmpeg_cmd
+
+logger = logging.getLogger(__name__)
+
+
+def laplacian_score(image_path: str) -> float:
+ img = cv2.imread(image_path, cv2.IMREAD_GRAYSCALE)
+ if img is None:
+ return 0.0
+ return float(cv2.Laplacian(img, cv2.CV_64F).var())
+
+
+def refine_time_in_window(
+ video_path: str,
+ center_sec: float,
+ window_sec: float = 2.0,
+ step: float = 0.5,
+) -> Tuple[float, float]:
+ """鍦� [center-window, center+window] 鍐呴�夋竻鏅板害鏈�楂樺抚锛岃繑鍥� (best_sec, score)銆�"""
+ import os
+ import tempfile
+
+ best_t = center_sec
+ best_score = 0.0
+ ffmpeg = get_ffmpeg_cmd("ffmpeg")
+ t = max(0.0, center_sec - window_sec)
+ end = center_sec + window_sec
+ tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
+ tmp.close()
+ try:
+ while t <= end:
+ cmd = [
+ ffmpeg, "-y", "-ss", f"{t:.3f}", "-i", video_path,
+ "-frames:v", "1", "-q:v", "2", tmp.name,
+ ]
+ subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+ if os.path.isfile(tmp.name) and os.path.getsize(tmp.name) > 0:
+ score = laplacian_score(tmp.name)
+ if score > best_score:
+ best_score = score
+ best_t = t
+ t += step
+ finally:
+ if os.path.isfile(tmp.name):
+ os.remove(tmp.name)
+ return round(best_t, 2), best_score
diff --git a/server/snapshot_infer/app/schemas.py b/server/snapshot_infer/app/schemas.py
new file mode 100644
index 0000000..7f27b9d
--- /dev/null
+++ b/server/snapshot_infer/app/schemas.py
@@ -0,0 +1,53 @@
+# -*- coding: utf-8 -*-
+from typing import List, Optional
+
+from pydantic import BaseModel, ConfigDict, Field
+
+
+class KeywordConfig(BaseModel):
+ model_config = ConfigDict(populate_by_name=True)
+ storefront: List[str] = Field(default_factory=lambda: ["鍒板簵", "鍒拌揪", "闂ㄥご"])
+ handover: List[str] = Field(default_factory=lambda: ["浜や粯", "浜よ揣", "绛炬敹"])
+
+
+class AnalyzeRequest(BaseModel):
+ model_config = ConfigDict(populate_by_name=True)
+ media_id: int = Field(alias="mediaId")
+ video_url: Optional[str] = Field(default=None, alias="videoUrl")
+ sample_fps: float = Field(default=0.5, alias="sampleFps")
+ enable_asr: bool = Field(default=True, alias="enableAsr")
+ keywords: Optional[KeywordConfig] = None
+ duration_sec: Optional[float] = Field(default=None, alias="durationSec")
+
+
+class SnapshotHit(BaseModel):
+ model_config = ConfigDict(populate_by_name=True)
+ time_sec: float = Field(alias="timeSec")
+ confidence: float
+ source: str
+
+
+class AsrHit(BaseModel):
+ model_config = ConfigDict(populate_by_name=True)
+ keyword: str
+ time_sec: float = Field(alias="timeSec")
+
+
+class AnalyzeResponse(BaseModel):
+ model_config = ConfigDict(populate_by_name=True)
+ success: bool = True
+ model_version: str = Field(default="1.0.0", alias="modelVersion")
+ duration_sec: float = Field(default=0.0, alias="durationSec")
+ storefront: Optional[SnapshotHit] = None
+ handover: Optional[SnapshotHit] = None
+ asr_hits: List[AsrHit] = Field(default_factory=list, alias="asrHits")
+ message: Optional[str] = None
+
+
+class HealthResponse(BaseModel):
+ model_config = ConfigDict(populate_by_name=True)
+ status: str
+ model_version: str = Field(alias="modelVersion")
+ onnx_storefront_loaded: bool = Field(default=False, alias="onnxStorefrontLoaded")
+ onnx_handover_loaded: bool = Field(default=False, alias="onnxHandoverLoaded")
+ asr_available: bool = Field(default=False, alias="asrAvailable")
diff --git a/server/snapshot_infer/app/temporal.py b/server/snapshot_infer/app/temporal.py
new file mode 100644
index 0000000..2dc53f0
--- /dev/null
+++ b/server/snapshot_infer/app/temporal.py
@@ -0,0 +1,47 @@
+# -*- coding: utf-8 -*-
+from typing import List, Optional, Tuple
+
+
+def smooth_scores(time_scores: List[Tuple[float, float]], window: int = 3) -> List[Tuple[float, float]]:
+ if len(time_scores) <= 1 or window <= 1:
+ return time_scores
+ half = window // 2
+ smoothed = []
+ for i, (t, _) in enumerate(time_scores):
+ lo = max(0, i - half)
+ hi = min(len(time_scores), i + half + 1)
+ avg = sum(s for _, s in time_scores[lo:hi]) / (hi - lo)
+ smoothed.append((t, avg))
+ return smoothed
+
+
+def find_peak(
+ time_scores: List[Tuple[float, float]],
+ min_time: float = 0.0,
+ min_confidence: float = 0.3,
+) -> Optional[Tuple[float, float]]:
+ if not time_scores:
+ return None
+ candidates = [(t, s) for t, s in time_scores if t >= min_time and s >= min_confidence]
+ if not candidates:
+ candidates = time_scores
+ best = max(candidates, key=lambda x: x[1])
+ if best[1] < 0.1:
+ return None
+ return best
+
+
+def find_peaks_ordered(
+ sf_scores: List[Tuple[float, float]],
+ ho_scores: List[Tuple[float, float]],
+ duration: float,
+ min_gap: float = 30.0,
+) -> Tuple[Optional[Tuple[float, float]], Optional[Tuple[float, float]]]:
+ sf = find_peak(smooth_scores(sf_scores))
+ min_ho = (sf[0] + min_gap) if sf else 0.0
+ ho = find_peak(smooth_scores(ho_scores), min_time=min_ho)
+ if sf and ho and ho[0] <= sf[0]:
+ ho = find_peak(smooth_scores(ho_scores), min_time=sf[0] + 1.0)
+ if sf and not ho and duration > sf[0] + min_gap:
+ ho = find_peak(smooth_scores(ho_scores), min_time=sf[0] + min_gap)
+ return sf, ho
diff --git a/server/snapshot_infer/app/video_io.py b/server/snapshot_infer/app/video_io.py
new file mode 100644
index 0000000..4e38510
--- /dev/null
+++ b/server/snapshot_infer/app/video_io.py
@@ -0,0 +1,67 @@
+# -*- coding: utf-8 -*-
+import json
+import logging
+import os
+import shutil
+import subprocess
+import tempfile
+from contextlib import contextmanager
+from typing import Optional
+
+import httpx
+
+logger = logging.getLogger(__name__)
+
+
+def get_ffmpeg_cmd(name: str = "ffmpeg") -> str:
+ ffmpeg_dir = "D:/code/ffmpeg/"
+# ffmpeg_dir = os.environ.get("FFMPEG_DIR", "")
+ print(f"ffmpeg_dir:{ffmpeg_dir}")
+ if ffmpeg_dir:
+ exe = "ffmpeg.exe" if os.name == "nt" else "ffmpeg"
+ print(f"exe:{exe}")
+ if name == "ffprobe":
+ exe = "ffprobe.exe" if os.name == "nt" else "ffprobe"
+ return os.path.join(ffmpeg_dir, exe)
+ return name
+
+
+def probe_duration(video_path: str) -> float:
+ cmd = [
+ get_ffmpeg_cmd("ffprobe"),
+ "-v", "error",
+ "-show_entries", "format=duration",
+ "-of", "default=noprint_wrappers=1:nokey=1",
+ video_path,
+ ]
+ try:
+ out = subprocess.check_output(cmd, stderr=subprocess.DEVNULL, text=True).strip()
+ return float(out) if out else 0.0
+ except Exception as e:
+ logger.warning("ffprobe 澶辫触: %s", e)
+ return 0.0
+
+
+def download_video(url: str, dest_path: str, timeout: float = 600.0) -> None:
+ with httpx.stream("GET", url, timeout=timeout, follow_redirects=True) as resp:
+ resp.raise_for_status()
+ with open(dest_path, "wb") as f:
+ for chunk in resp.iter_bytes(chunk_size=65536):
+ f.write(chunk)
+
+
+@contextmanager
+def temp_video(video_url: Optional[str], duration_hint: float = 0.0):
+ tmp_dir = tempfile.mkdtemp(prefix="snap_infer_")
+ video_path = os.path.join(tmp_dir, "video.mp4")
+ try:
+ if video_url:
+ download_video(video_url, video_path)
+ else:
+ raise ValueError("video_url 涓嶈兘涓虹┖")
+ duration = probe_duration(video_path)
+ if duration <= 0:
+ duration = duration_hint if duration_hint > 0 else 1200.0
+ yield video_path, duration
+ finally:
+ shutil.rmtree(tmp_dir, ignore_errors=True)
diff --git a/server/snapshot_infer/data/annotations.jsonl b/server/snapshot_infer/data/annotations.jsonl
new file mode 100644
index 0000000..769dad1
--- /dev/null
+++ b/server/snapshot_infer/data/annotations.jsonl
@@ -0,0 +1,4 @@
+{"media_id": 1, "video_path": "http://192.168.0.3/file/collection_media/20260611/0986a054-ccbe-408b-86fc-b834e9c2f4dd.mp4", "storefront_time_sec": 25.5, "handover_time_sec": 49.5, "store_type": "鍏徃", "has_voice_marker": false, "driver_date": "demo_20250601", "split": "train", "notes": "璞嗙背绉戞妧娲鹃�佹牱渚�1"}
+{"media_id": 2, "video_path": "http://192.168.0.3/file/collection_media/20260611/6808c257-8df7-43f7-b8dc-03789e184929.mp4", "storefront_time_sec": 18.5, "handover_time_sec": 37.5, "store_type": "鍏徃", "has_voice_marker": false, "driver_date": "demo_20250603", "split": "val", "notes": "璞嗙背绉戞妧娲鹃�佹牱渚�3"}
+{"media_id": 3, "video_path": "http://192.168.0.3/file/collection_media/20260611/0986a054-ccbe-408b-86fc-b834e9c2f4dd.mp4", "storefront_time_sec": 25.5, "handover_time_sec": 49.5, "store_type": "鍏徃", "has_voice_marker": false, "driver_date": "demo_20250602", "split": "val", "notes": "璞嗙背绉戞妧娲鹃�佹牱渚�2"}
+{"media_id": 4, "video_path": "http://192.168.0.3/file/collection_media/20260611/6808c257-8df7-43f7-b8dc-03789e184929.mp4", "storefront_time_sec": 18.5, "handover_time_sec": 37.5, "store_type": "鍏徃", "has_voice_marker": false, "driver_date": "demo_20250603", "split": "train", "notes": "璞嗙背绉戞妧娲鹃�佹牱渚�4"}
\ No newline at end of file
diff --git a/server/snapshot_infer/data/annotations_poc_sample.jsonl b/server/snapshot_infer/data/annotations_poc_sample.jsonl
new file mode 100644
index 0000000..6a592ed
--- /dev/null
+++ b/server/snapshot_infer/data/annotations_poc_sample.jsonl
@@ -0,0 +1,2 @@
+{"media_id": 1, "video_path": "http://192.168.0.3/file/collection_media/20260611/6808c257-8df7-43f7-b8dc-03789e184929.mp4", "storefront_time_sec": 25.5, "handover_time_sec": 49.5, "store_type": "鍏徃", "has_voice_marker": true, "driver_date": "demo_20250601", "split": "train", "notes": "璞嗙背绉戞妧娲鹃�佹牱渚�1"}
+{"media_id": 2, "video_path": "http://192.168.0.3/file/collection_media/20260611/17911eb5-ee71-421f-af72-bd804d563201.mp4", "storefront_time_sec": 18.5, "handover_time_sec": 37.5, "store_type": "鍏徃", "has_voice_marker": false, "driver_date": "demo_20250602", "split": "val", "notes": "璞嗙背绉戞妧娲鹃�佹牱渚�2"}
diff --git a/server/snapshot_infer/data/labels.csv b/server/snapshot_infer/data/labels.csv
new file mode 100644
index 0000000..ca7d75c
--- /dev/null
+++ b/server/snapshot_infer/data/labels.csv
@@ -0,0 +1,291 @@
+frame_path,label,task,split
+1/21000.jpg,1,storefront,train
+1/22000.jpg,1,storefront,train
+1/23000.jpg,1,storefront,train
+1/24000.jpg,1,storefront,train
+1/25000.jpg,1,storefront,train
+1/26000.jpg,1,storefront,train
+1/27000.jpg,1,storefront,train
+1/28000.jpg,1,storefront,train
+1/29000.jpg,1,storefront,train
+1/30000.jpg,1,storefront,train
+1/45000.jpg,1,handover,train
+1/46000.jpg,1,handover,train
+1/47000.jpg,1,handover,train
+1/48000.jpg,1,handover,train
+1/49000.jpg,1,handover,train
+1/50000.jpg,1,handover,train
+1/51000.jpg,1,handover,train
+1/52000.jpg,1,handover,train
+1/53000.jpg,1,handover,train
+1/54000.jpg,1,handover,train
+1/0.jpg,0,other,train
+1/1000.jpg,0,other,train
+1/2000.jpg,0,other,train
+1/3000.jpg,0,other,train
+1/4000.jpg,0,other,train
+1/5000.jpg,0,other,train
+1/6000.jpg,0,other,train
+1/7000.jpg,0,other,train
+1/8000.jpg,0,other,train
+1/9000.jpg,0,other,train
+1/10000.jpg,0,other,train
+1/11000.jpg,0,other,train
+1/12000.jpg,0,other,train
+1/13000.jpg,0,other,train
+1/14000.jpg,0,other,train
+1/15000.jpg,0,other,train
+1/16000.jpg,0,other,train
+1/17000.jpg,0,other,train
+1/18000.jpg,0,other,train
+1/19000.jpg,0,other,train
+1/20000.jpg,0,other,train
+1/31000.jpg,0,other,train
+1/32000.jpg,0,other,train
+1/33000.jpg,0,other,train
+1/34000.jpg,0,other,train
+1/35000.jpg,0,other,train
+1/36000.jpg,0,other,train
+1/37000.jpg,0,other,train
+1/38000.jpg,0,other,train
+1/39000.jpg,0,other,train
+1/40000.jpg,0,other,train
+1/41000.jpg,0,other,train
+1/42000.jpg,0,other,train
+1/43000.jpg,0,other,train
+1/44000.jpg,0,other,train
+1/55000.jpg,0,other,train
+1/56000.jpg,0,other,train
+1/57000.jpg,0,other,train
+1/58000.jpg,0,other,train
+1/59000.jpg,0,other,train
+1/60000.jpg,0,other,train
+1/61000.jpg,0,other,train
+1/62000.jpg,0,other,train
+1/63000.jpg,0,other,train
+1/64000.jpg,0,other,train
+1/65000.jpg,0,other,train
+1/66000.jpg,0,other,train
+1/67000.jpg,0,other,train
+1/68000.jpg,0,other,train
+1/69000.jpg,0,other,train
+1/70000.jpg,0,other,train
+1/71000.jpg,0,other,train
+1/72000.jpg,0,other,train
+1/73000.jpg,0,other,train
+1/74000.jpg,0,other,train
+1/75000.jpg,0,other,train
+1/76000.jpg,0,other,train
+1/77000.jpg,0,other,train
+1/78000.jpg,0,other,train
+1/79000.jpg,0,other,train
+1/80000.jpg,0,other,train
+1/81000.jpg,0,other,train
+1/82000.jpg,0,other,train
+1/83000.jpg,0,other,train
+1/84000.jpg,0,other,train
+1/85000.jpg,0,other,train
+1/86000.jpg,0,other,train
+1/87000.jpg,0,other,train
+1/88000.jpg,0,other,train
+1/89000.jpg,0,other,train
+1/90000.jpg,0,other,train
+1/91000.jpg,0,other,train
+1/92000.jpg,0,other,train
+1/93000.jpg,0,other,train
+1/94000.jpg,0,other,train
+1/95000.jpg,0,other,train
+1/96000.jpg,0,other,train
+1/97000.jpg,0,other,train
+2/14000.jpg,1,storefront,val
+2/15000.jpg,1,storefront,val
+2/16000.jpg,1,storefront,val
+2/17000.jpg,1,storefront,val
+2/18000.jpg,1,storefront,val
+2/19000.jpg,1,storefront,val
+2/20000.jpg,1,storefront,val
+2/21000.jpg,1,storefront,val
+2/22000.jpg,1,storefront,val
+2/23000.jpg,1,storefront,val
+2/33000.jpg,1,handover,val
+2/34000.jpg,1,handover,val
+2/35000.jpg,1,handover,val
+2/36000.jpg,1,handover,val
+2/37000.jpg,1,handover,val
+2/38000.jpg,1,handover,val
+2/39000.jpg,1,handover,val
+2/40000.jpg,1,handover,val
+2/41000.jpg,1,handover,val
+2/42000.jpg,1,handover,val
+2/0.jpg,0,other,val
+2/1000.jpg,0,other,val
+2/2000.jpg,0,other,val
+2/3000.jpg,0,other,val
+2/4000.jpg,0,other,val
+2/5000.jpg,0,other,val
+2/6000.jpg,0,other,val
+2/7000.jpg,0,other,val
+2/8000.jpg,0,other,val
+2/9000.jpg,0,other,val
+2/10000.jpg,0,other,val
+2/11000.jpg,0,other,val
+2/12000.jpg,0,other,val
+2/13000.jpg,0,other,val
+2/24000.jpg,0,other,val
+2/25000.jpg,0,other,val
+2/26000.jpg,0,other,val
+2/27000.jpg,0,other,val
+2/28000.jpg,0,other,val
+2/29000.jpg,0,other,val
+2/30000.jpg,0,other,val
+2/31000.jpg,0,other,val
+2/32000.jpg,0,other,val
+2/43000.jpg,0,other,val
+2/44000.jpg,0,other,val
+2/45000.jpg,0,other,val
+2/46000.jpg,0,other,val
+3/21000.jpg,1,storefront,val
+3/22000.jpg,1,storefront,val
+3/23000.jpg,1,storefront,val
+3/24000.jpg,1,storefront,val
+3/25000.jpg,1,storefront,val
+3/26000.jpg,1,storefront,val
+3/27000.jpg,1,storefront,val
+3/28000.jpg,1,storefront,val
+3/29000.jpg,1,storefront,val
+3/30000.jpg,1,storefront,val
+3/45000.jpg,1,handover,val
+3/46000.jpg,1,handover,val
+3/47000.jpg,1,handover,val
+3/48000.jpg,1,handover,val
+3/49000.jpg,1,handover,val
+3/50000.jpg,1,handover,val
+3/51000.jpg,1,handover,val
+3/52000.jpg,1,handover,val
+3/53000.jpg,1,handover,val
+3/54000.jpg,1,handover,val
+3/0.jpg,0,other,val
+3/1000.jpg,0,other,val
+3/2000.jpg,0,other,val
+3/3000.jpg,0,other,val
+3/4000.jpg,0,other,val
+3/5000.jpg,0,other,val
+3/6000.jpg,0,other,val
+3/7000.jpg,0,other,val
+3/8000.jpg,0,other,val
+3/9000.jpg,0,other,val
+3/10000.jpg,0,other,val
+3/11000.jpg,0,other,val
+3/12000.jpg,0,other,val
+3/13000.jpg,0,other,val
+3/14000.jpg,0,other,val
+3/15000.jpg,0,other,val
+3/16000.jpg,0,other,val
+3/17000.jpg,0,other,val
+3/18000.jpg,0,other,val
+3/19000.jpg,0,other,val
+3/20000.jpg,0,other,val
+3/31000.jpg,0,other,val
+3/32000.jpg,0,other,val
+3/33000.jpg,0,other,val
+3/34000.jpg,0,other,val
+3/35000.jpg,0,other,val
+3/36000.jpg,0,other,val
+3/37000.jpg,0,other,val
+3/38000.jpg,0,other,val
+3/39000.jpg,0,other,val
+3/40000.jpg,0,other,val
+3/41000.jpg,0,other,val
+3/42000.jpg,0,other,val
+3/43000.jpg,0,other,val
+3/44000.jpg,0,other,val
+3/55000.jpg,0,other,val
+3/56000.jpg,0,other,val
+3/57000.jpg,0,other,val
+3/58000.jpg,0,other,val
+3/59000.jpg,0,other,val
+3/60000.jpg,0,other,val
+3/61000.jpg,0,other,val
+3/62000.jpg,0,other,val
+3/63000.jpg,0,other,val
+3/64000.jpg,0,other,val
+3/65000.jpg,0,other,val
+3/66000.jpg,0,other,val
+3/67000.jpg,0,other,val
+3/68000.jpg,0,other,val
+3/69000.jpg,0,other,val
+3/70000.jpg,0,other,val
+3/71000.jpg,0,other,val
+3/72000.jpg,0,other,val
+3/73000.jpg,0,other,val
+3/74000.jpg,0,other,val
+3/75000.jpg,0,other,val
+3/76000.jpg,0,other,val
+3/77000.jpg,0,other,val
+3/78000.jpg,0,other,val
+3/79000.jpg,0,other,val
+3/80000.jpg,0,other,val
+3/81000.jpg,0,other,val
+3/82000.jpg,0,other,val
+3/83000.jpg,0,other,val
+3/84000.jpg,0,other,val
+3/85000.jpg,0,other,val
+3/86000.jpg,0,other,val
+3/87000.jpg,0,other,val
+3/88000.jpg,0,other,val
+3/89000.jpg,0,other,val
+3/90000.jpg,0,other,val
+3/91000.jpg,0,other,val
+3/92000.jpg,0,other,val
+3/93000.jpg,0,other,val
+3/94000.jpg,0,other,val
+3/95000.jpg,0,other,val
+3/96000.jpg,0,other,val
+3/97000.jpg,0,other,val
+4/14000.jpg,1,storefront,train
+4/15000.jpg,1,storefront,train
+4/16000.jpg,1,storefront,train
+4/17000.jpg,1,storefront,train
+4/18000.jpg,1,storefront,train
+4/19000.jpg,1,storefront,train
+4/20000.jpg,1,storefront,train
+4/21000.jpg,1,storefront,train
+4/22000.jpg,1,storefront,train
+4/23000.jpg,1,storefront,train
+4/33000.jpg,1,handover,train
+4/34000.jpg,1,handover,train
+4/35000.jpg,1,handover,train
+4/36000.jpg,1,handover,train
+4/37000.jpg,1,handover,train
+4/38000.jpg,1,handover,train
+4/39000.jpg,1,handover,train
+4/40000.jpg,1,handover,train
+4/41000.jpg,1,handover,train
+4/42000.jpg,1,handover,train
+4/0.jpg,0,other,train
+4/1000.jpg,0,other,train
+4/2000.jpg,0,other,train
+4/3000.jpg,0,other,train
+4/4000.jpg,0,other,train
+4/5000.jpg,0,other,train
+4/6000.jpg,0,other,train
+4/7000.jpg,0,other,train
+4/8000.jpg,0,other,train
+4/9000.jpg,0,other,train
+4/10000.jpg,0,other,train
+4/11000.jpg,0,other,train
+4/12000.jpg,0,other,train
+4/13000.jpg,0,other,train
+4/24000.jpg,0,other,train
+4/25000.jpg,0,other,train
+4/26000.jpg,0,other,train
+4/27000.jpg,0,other,train
+4/28000.jpg,0,other,train
+4/29000.jpg,0,other,train
+4/30000.jpg,0,other,train
+4/31000.jpg,0,other,train
+4/32000.jpg,0,other,train
+4/43000.jpg,0,other,train
+4/44000.jpg,0,other,train
+4/45000.jpg,0,other,train
+4/46000.jpg,0,other,train
diff --git a/server/snapshot_infer/docs/annotation_spec.md b/server/snapshot_infer/docs/annotation_spec.md
new file mode 100644
index 0000000..536b18d
--- /dev/null
+++ b/server/snapshot_infer/docs/annotation_spec.md
@@ -0,0 +1,58 @@
+# 閰嶉�佽棰戦棬澶�/浜や粯鏃跺埢鏍囨敞瑙勮寖
+
+## 鐩爣
+
+涓� ONNX 甯у垎绫绘ā鍨嬫彁渚涜缁冩爣绛撅細姣忔潯瑙嗛鏍囨敞 **闂ㄥご鏈�浣虫椂鍒�** 涓� **浜や粯鏈�浣虫椂鍒�**锛堝悇 1 涓鏁帮級銆�
+
+## 瀛楁璇存槑
+
+| 瀛楁 | 绫诲瀷 | 蹇呭~ | 璇存槑 |
+|------|------|------|------|
+| `media_id` | int | 鏄� | 瀵瑰簲 `collection_media.id` |
+| `video_path` | string | 鏄� | 鏈湴璺緞鎴� FTP HTTP URL |
+| `storefront_time_sec` | float | 鏄� | 闂ㄥご鏈�浣冲抚鏃跺埢锛堢锛� |
+| `handover_time_sec` | float | 鏄� | 浜や粯鏈�浣冲抚鏃跺埢锛堢锛� |
+| `store_type` | string | 鍚� | 渚垮埄搴�/瓒呭競/椁愰ギ/鍏朵粬 |
+| `has_voice_marker` | bool | 鍚� | 鏄惁鍚�屽埌搴�/浜や粯銆嶈闊� |
+| `recorder_sn` | string | 鍚� | 璁惧 SN |
+| `driver_date` | string | 鍚� | 鍙告満+鏃ユ湡锛岀敤浜� train/val/test 鍒嗙粍 |
+| `split` | string | 鏄� | `train` / `val` / `test` |
+| `notes` | string | 鍚� | badcase 璇存槑 |
+
+## 绫诲埆杈圭晫
+
+- **storefront锛堥棬澶达級**锛氬簵鎷涖�侀棬鐗屻�佸簵閾哄叆鍙d负涓讳綋锛涗汉鍙叆鐢讳絾璐у搧闈炰富浣�
+- **handover锛堜氦浠橈級**锛氳揣鍝�/鍖呰鍦ㄧ敾闈腑蹇冿紝鍙閫掍氦銆佹斁缃�佺鏀跺姩浣�
+- **other锛堣礋鏍锋湰锛�**锛氳杞︺�佷粨搴撱�佸簵鍐呰蛋鍔ㄣ�佺┖闀滅瓑锛堣缁冩椂鑷姩浠庨潪 卤5s 绐楀彛閲囨牱锛�
+
+## 鏍囨敞鎿嶄綔
+
+1. 鎾斁鏁存 MP4锛屾殏鍋滃湪 **鏈�娓呮櫚銆佹瀯鍥炬渶濂�** 鐨勯棬澶寸敾闈紝璁板綍褰撳墠绉掓暟
+2. 缁х画鎾斁锛屽湪 **璐у搧浜ゆ帴鏈�娓呮櫚** 鐨勪竴甯ц褰曠鏁�
+3. 绾︽潫锛歚handover_time_sec > storefront_time_sec`锛堥�氬父鐩稿樊鏁板崄绉掍互涓婏級
+4. 鑻ユ煇鏉¤棰戞棤浜や粯鍦烘櫙锛堜粎鍒板簵锛夛紝鍦� `notes` 鏍囨敞銆屾棤浜や粯銆嶏紝璇ユ潯鏆備笉绾冲叆璁粌
+
+## 瀵煎嚭鏍煎紡锛圝SONL锛�
+
+姣忚涓�鏉� JSON锛�
+
+```json
+{"media_id": 123, "video_path": "http://host/collection_media/20250609/123.mp4", "storefront_time_sec": 742.5, "handover_time_sec": 1085.2, "store_type": "渚垮埄搴�", "has_voice_marker": true, "driver_date": "driver001_20250609", "split": "train"}
+```
+
+## 鏁版嵁鍒掑垎
+
+- train / val / test = **70% / 15% / 15%**
+- 鎸� `driver_date` 鎴� `recorder_sn + 鏃ユ湡` **鍒嗙粍鍒掑垎**锛岄伩鍏嶅悓鍙告満鍚屽ぉ瑙嗛娉勬紡鍒版祴璇曢泦
+
+## 瑙勬ā寤鸿
+
+| 闃舵 | 瑙嗛鏁� |
+|------|--------|
+| POC | 80~100 |
+| 鍐呮祴 | 300+ |
+| 涓婄嚎 | 1000+ |
+
+## Label Studio
+
+瑙� [`label_studio_config.xml`](../tools/label_studio_config.xml)銆傚鍏� `tools/export_media_list.py` 鐢熸垚鐨� CSV 鍚庯紝鏍囨敞涓や釜鏃堕棿鐐瑰苟瀵煎嚭 JSON锛屽啀鐢� `tools/convert_labelstudio.py` 杞负 JSONL銆�
diff --git a/server/snapshot_infer/docs/deploy_checklist.md b/server/snapshot_infer/docs/deploy_checklist.md
new file mode 100644
index 0000000..6f530f0
--- /dev/null
+++ b/server/snapshot_infer/docs/deploy_checklist.md
@@ -0,0 +1,34 @@
+# 閮ㄧ讲涓庤瘎浼版鏌ユ竻鍗�
+
+## 涓婄嚎鍓�
+
+- [ ] 鎵ц `business.delivery_media_snapshot_feedback.sql`
+- [ ] 璁粌骞堕儴缃� `models/storefront_int8.onnx`銆乣models/handover_int8.onnx`銆乣version.json`
+- [ ] 鎺ㄧ悊鏈哄畨瑁� ffmpeg锛屼笖鑳借闂� Java 鏋勯�犵殑 `video_url`锛團TP HTTP 鍓嶇紑锛�
+- [ ] `application-pro.yml` 涓� `snapshot.infer.fail-open-mock: false`
+- [ ] `GET /health` 杩斿洖 `status=ok` 涓斾袱涓� ONNX loaded=true
+
+## 鎬ц兘鐩爣锛�10 鍒嗛挓瑙嗛锛孋PU锛�
+
+| 鎸囨爣 | POC 鐩爣 |
+|------|----------|
+| 鍗曟潯 analyze 鑰楁椂 | < 10 鍒嗛挓 |
+| 闂ㄥご MAE | < 8s |
+| 浜や粯 MAE | < 8s |
+| 椤哄簭姝g‘鐜� | > 95% |
+
+鍘嬫祴鍛戒护锛�
+
+```powershell
+cd server/snapshot_infer
+py tools/benchmark_cpu.py "http://your-ftp-host/collection_media/xxx.mp4" --sample-fps 0.5
+```
+
+## 鏁呴殰鎺掓煡
+
+| 鐜拌薄 | 澶勭悊 |
+|------|------|
+| health degraded | 妫�鏌� ONNX 鏂囦欢鏄惁鍦� SNAPSHOT_MODEL_DIR |
+| analyze 瓒呮椂 | 澧炲ぇ Java `read-timeout-ms`锛涢檷浣� `sample-fps` |
+| ASR 鎱� | 纭 `WHISPER_MODEL=tiny`锛涙垨 `enable-asr: false` 绾瑙� |
+| Java snapshot_status=3 | 鏌ョ湅 `snapshot_message` 涓庢帹鐞嗘湇鍔℃棩蹇� |
diff --git a/server/snapshot_infer/docs/training_troubleshooting.md b/server/snapshot_infer/docs/training_troubleshooting.md
new file mode 100644
index 0000000..ab7e65f
--- /dev/null
+++ b/server/snapshot_infer/docs/training_troubleshooting.md
@@ -0,0 +1,96 @@
+# 璁粌鏁堟灉涓嶄匠 鈥� 鎺掓煡涓庢敼杩涙寚鍗�
+
+## 宸蹭慨澶嶇殑浠g爜闂锛堝繀鍋氾級
+
+姝ゅ墠 `train.py` **鍙鍙� `task=storefront/handover` 涓� `label=1` 鐨勮**锛宍task=other` 鐨勮礋鏍锋湰鍏ㄩ儴琚拷鐣ワ紝妯″瀷鐩稿綋浜庛�屽叏鍥鹃兘鏄绫汇�嶏紝楠岃瘉蹇呯劧寰堝樊銆�
+
+`prepare_dataset.py` 宸叉敼涓猴細**姣忎竴甯у悓鏃跺啓鍏� storefront / handover 涓よ锛屽惈 label 0/1**銆�
+
+璇� **閲嶆柊鐢熸垚鏁版嵁骞惰缁�**锛�
+
+```powershell
+cd server/snapshot_infer/training
+py prepare_dataset.py -c config.yaml
+py train.py -c config.yaml
+py export_onnx.py -c config.yaml --version 1.0.1
+py evaluate.py -c config.yaml --split val
+```
+
+璁粌寮�濮嬫椂浼氭墦鍗� `train pos=... neg=...`锛�**neg 蹇呴』 > 0**銆�
+
+---
+
+## 浣犲綋鍓嶆暟鎹殑闂
+
+鏌ョ湅 `data/annotations.jsonl`锛�
+
+| 闂 | 璇存槑 |
+|------|------|
+| 浠� **4 鏉�** 鏍囨敞 | 杩滀綆浜� POC 寤鸿 80~100 鏉� |
+| 瀹為檯鍙湁 **2 涓笉鍚岃棰�** | media 1/3 鍚屼竴鏂囦欢锛�2/4 鍚屼竴鏂囦欢 |
+| train/val **閲嶅鍚屼竴瑙嗛** | 楠岃瘉鎸囨爣铏氶珮鎴栧け鐪燂紝鏃犳硶鍙嶆槧娉涘寲 |
+| 鍦烘櫙鍗曚竴 | 鍏ㄦ槸銆屽叕鍙搞�嶅鍐咃紝妯″瀷瀛︿笉鍒扮湡瀹為棬澶�/浜や粯 |
+
+**缁撹**锛氬嵆浣夸唬鐮佹纭紝4 鏉℃牱鏈篃鍑犱箮涓嶅彲鑳借鍑哄彲鐢ㄦā鍨嬨�傞渶瑕佺户缁爣娉� **80+ 鏉$湡瀹為厤閫佽棰�**锛屼笖 train/val 鎸� **鍙告満+鏃ユ湡** 鍒嗙粍锛屼笉鑳藉悓涓�瑙嗛杩� train 鍙堣繘 val銆�
+
+---
+
+## 鏍囨敞璐ㄩ噺妫�鏌�
+
+1. **闂ㄥご鏃跺埢**锛氬簵鎷�/鍏ュ彛鏈�娓呮櫚鐨勪竴甯э紙涓嶆槸杞﹀唴銆佷笉鏄儗褰憋級
+2. **浜や粯鏃跺埢**锛氳揣鍝�/浜ゆ帴鍔ㄤ綔鏈�娓呮櫚鐨勪竴甯�
+3. 绾︽潫锛歚handover_time_sec > storefront_time_sec + 20s`
+4. 鏈夎闊虫爣璁扮殑瑙嗛锛屽彲寮� ASR 铻嶅悎锛坄enable-asr: true`锛夛紝鍑忚交绾瑙夊帇鍔�
+
+---
+
+## 璁粌鍙傛暟寤鸿锛坄config.yaml`锛�
+
+```yaml
+sampling:
+ sample_fps: 1.0 # 涓庢帹鐞� sample-fps 涓�鑷�
+ positive_window_sec: 3.0 # 姝f牱鏈獥鍙� 卤3s锛堝師 5s 杩囧浼氭ā绯婂嘲鍊硷級
+ other_downsample_ratio: 2 # 璐熸牱鏈笉瑕佸お灏�
+
+train:
+ batch_size: 32
+ epochs: 40
+ lr: 0.0005 # 鍏ㄩ噺寰皟鏃跺彲鐣ラ檷
+ early_stop_patience: 8
+ freeze_backbone: true # 鏍锋湰 <200 鏃惰嚜鍔ㄥ喕缁� backbone锛堝凡瀹炵幇锛�
+```
+
+鏍锋湰閲� >300 鍚庯紝鍙 `freeze_backbone: false` 鍋氬垎灞傚涔犵巼寰皟銆�
+
+---
+
+## 璇勪及鎸囨爣鎬庝箞鐪�
+
+`evaluate.py` 杈撳嚭锛�
+
+| 鎸囨爣 | POC 鐩爣 |
+|------|----------|
+| 闂ㄥご MAE | < 8s |
+| 浜や粯 MAE | < 8s |
+| 椤哄簭姝g‘鐜� | > 95% |
+| 鍙� 5 绉掑懡涓巼 | > 70% |
+
+鑻ュ抚绾� loss 浣庝絾 MAE 浠嶅ぇ锛氭鏌� **sample_fps 璁粌涓庢帹鐞嗘槸鍚︿竴鑷�**銆佹爣娉ㄦ椂鍒绘槸鍚﹀噯銆�
+
+---
+
+## 鐭湡鍙敤鏂规锛堟暟鎹笉澶熸椂锛�
+
+1. **ASR 涓轰富**锛氬徃鏈哄埌搴�/浜や粯鏃舵竻鏅拌銆屽埌搴椼�嶃�屼氦浠樸�嶏紝寮� `snapshot.infer.enable-asr: true`锛岃瑙変粎杈呭姪
+2. **浜哄伐绾犳**锛欰dmin銆屼汉宸ョ籂姝c�嶅啓鍏� feedback锛岀Н绱悗鍐嶈
+3. **dev mock 鍏滃簳**锛歚fail-open-mock: true` 浠呯敤浜庤仈璋冿紝涓嶆槸鐢熶骇鏂规
+
+---
+
+## 鎺ㄨ崘杩唬椤哄簭
+
+1. 閲嶆柊 `prepare_dataset` + `train` + `export`锛堜慨澶嶈礋鏍锋湰锛�
+2. 鏍囨敞鎵╁埌 50~100 鏉★紝鍘绘帀 val 涓� train 閲嶅瑙嗛
+3. 璺� `evaluate.py`锛岃褰� MAE
+4. 涓婄嚎 snapshot-infer锛岀敤 10 鏉$湡瀹炶棰戜汉宸ョ湅銆屾煡鐪嬪揩鐓с�嶆晥鏋�
+5. badcase 浜哄伐绾犳 鈫� `export_feedback.py` 鈫� 鍚堝苟杩� `annotations.jsonl` 澧為噺璁粌
diff --git a/server/snapshot_infer/models/version.json b/server/snapshot_infer/models/version.json
new file mode 100644
index 0000000..6e3f754
--- /dev/null
+++ b/server/snapshot_infer/models/version.json
@@ -0,0 +1,8 @@
+{
+ "model_version": "1.0.0",
+ "storefront_model": "storefront.onnx",
+ "handover_model": "handover.onnx",
+ "image_size": 224,
+ "quantized": false,
+ "exported_at": "2026-06-12T09:24:48.314213Z"
+}
\ No newline at end of file
diff --git a/server/snapshot_infer/requirements.txt b/server/snapshot_infer/requirements.txt
new file mode 100644
index 0000000..af35971
--- /dev/null
+++ b/server/snapshot_infer/requirements.txt
@@ -0,0 +1,9 @@
+fastapi>=0.100.0
+uvicorn>=0.22.0
+pydantic>=2.0.0
+onnxruntime>=1.16.0
+opencv-python-headless>=4.8.0
+numpy>=1.24.0
+pillow>=10.0.0
+faster-whisper>=1.0.0
+httpx>=0.25.0
diff --git a/server/snapshot_infer/tools/benchmark_cpu.py b/server/snapshot_infer/tools/benchmark_cpu.py
new file mode 100644
index 0000000..ac0d539
--- /dev/null
+++ b/server/snapshot_infer/tools/benchmark_cpu.py
@@ -0,0 +1,52 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""CPU 鍘嬫祴锛氬鍗曟潯瑙嗛閲嶅璋冪敤 pipeline锛岀粺璁¤�楁椂銆�"""
+import argparse
+import os
+import sys
+import time
+
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
+
+from app.schemas import AnalyzeRequest
+from app.pipeline import run_analyze, get_registry
+
+
+def main():
+ parser = argparse.ArgumentParser()
+ parser.add_argument("video_url", help="鍙闂殑 MP4 URL")
+ parser.add_argument("--media-id", type=int, default=1)
+ parser.add_argument("--duration", type=float, default=0)
+ parser.add_argument("--sample-fps", type=float, default=0.5)
+ parser.add_argument("--no-asr", action="store_true")
+ parser.add_argument("--repeat", type=int, default=1)
+ args = parser.parse_args()
+
+ reg = get_registry()
+ print(f"model_version={reg.version} ready={reg.ready}")
+ if not reg.ready:
+ print("璀﹀憡: ONNX 妯″瀷鏈氨缁紝缁撴灉鍙兘澶辫触")
+
+ req = AnalyzeRequest(
+ media_id=args.media_id,
+ video_url=args.video_url,
+ sample_fps=args.sample_fps,
+ enable_asr=not args.no_asr,
+ duration_sec=args.duration if args.duration > 0 else None,
+ )
+ times = []
+ for i in range(args.repeat):
+ t0 = time.perf_counter()
+ resp = run_analyze(req)
+ elapsed = time.perf_counter() - t0
+ times.append(elapsed)
+ print(f"run {i+1}: success={resp.success} elapsed={elapsed:.1f}s "
+ f"storefront={resp.storefront.time_sec if resp.storefront else None} "
+ f"handover={resp.handover.time_sec if resp.handover else None} "
+ f"msg={resp.message or ''}")
+ if times:
+ print(f"avg={sum(times)/len(times):.1f}s min={min(times):.1f}s max={max(times):.1f}s")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/server/snapshot_infer/tools/convert_labelstudio.py b/server/snapshot_infer/tools/convert_labelstudio.py
new file mode 100644
index 0000000..6d675df
--- /dev/null
+++ b/server/snapshot_infer/tools/convert_labelstudio.py
@@ -0,0 +1,64 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""灏� Label Studio 瀵煎嚭 JSON 杞负璁粌鐢� JSONL銆�"""
+import argparse
+import json
+import sys
+
+
+def convert(in_path, out_path, default_split="train"):
+ with open(in_path, encoding="utf-8") as f:
+ tasks = json.load(f)
+ if isinstance(tasks, dict):
+ tasks = tasks.get("tasks") or tasks.get("data") or [tasks]
+
+ count = 0
+ with open(out_path, "w", encoding="utf-8") as out:
+ for task in tasks:
+ data = task.get("data") or task
+ media_id = data.get("media_id") or data.get("id")
+ video_path = data.get("video_path") or data.get("video")
+ annotations = task.get("annotations") or []
+ storefront = handover = None
+ for ann in annotations:
+ for r in ann.get("result") or []:
+ if r.get("type") != "timelinelabels":
+ continue
+ labels = (r.get("value") or {}).get("timelinelabels") or []
+ ranges = (r.get("value") or {}).get("ranges") or []
+ if not ranges:
+ continue
+ t = float(ranges[0].get("start", 0))
+ if "storefront" in labels:
+ storefront = t
+ if "handover" in labels:
+ handover = t
+ if storefront is None or handover is None:
+ continue
+ item = {
+ "media_id": int(media_id) if media_id else count,
+ "video_path": video_path,
+ "storefront_time_sec": round(storefront, 2),
+ "handover_time_sec": round(handover, 2),
+ "store_type": data.get("store_type", ""),
+ "has_voice_marker": bool(data.get("has_voice_marker")),
+ "driver_date": data.get("driver_date", ""),
+ "split": data.get("split") or default_split,
+ "notes": data.get("notes", ""),
+ }
+ out.write(json.dumps(item, ensure_ascii=False) + "\n")
+ count += 1
+ print(f"杞崲 {count} 鏉� -> {out_path}")
+
+
+def main():
+ parser = argparse.ArgumentParser()
+ parser.add_argument("input", help="Label Studio 瀵煎嚭 JSON")
+ parser.add_argument("-o", "--output", default="data/annotations.jsonl")
+ parser.add_argument("--split", default="train")
+ args = parser.parse_args()
+ convert(args.input, args.output, args.split)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/server/snapshot_infer/tools/export_feedback.py b/server/snapshot_infer/tools/export_feedback.py
new file mode 100644
index 0000000..f8e45a2
--- /dev/null
+++ b/server/snapshot_infer/tools/export_feedback.py
@@ -0,0 +1,80 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""浠� MySQL delivery_media_snapshot_feedback 瀵煎嚭澧為噺璁粌 JSONL銆�"""
+import argparse
+import json
+import os
+import sys
+
+try:
+ import pymysql
+except ImportError:
+ pymysql = None
+
+
+def main():
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--host", default=os.environ.get("MYSQL_HOST", "127.0.0.1"))
+ parser.add_argument("--port", type=int, default=int(os.environ.get("MYSQL_PORT", "3306")))
+ parser.add_argument("--user", default=os.environ.get("MYSQL_USER", "root"))
+ parser.add_argument("--password", default=os.environ.get("MYSQL_PASSWORD", ""))
+ parser.add_argument("--database", default=os.environ.get("MYSQL_DATABASE", "wuhuyancao"))
+ parser.add_argument("--ftp-prefix", default=os.environ.get("FTP_RESOURCE_PREFIX", "http://127.0.0.1/files"))
+ parser.add_argument("-o", "--output", default="data/feedback_export.jsonl")
+ args = parser.parse_args()
+
+ if pymysql is None:
+ print("璇峰畨瑁� pymysql", file=sys.stderr)
+ sys.exit(1)
+
+ media_folder = os.environ.get("COLLECTION_MEDIA_FOLDER", "/collection_media/")
+ conn = pymysql.connect(
+ host=args.host, port=args.port, user=args.user,
+ password=args.password, database=args.database, charset="utf8mb4",
+ )
+ sql = """
+ SELECT f.media_id, f.snapshot_type, f.ai_time_sec, f.manual_time_sec,
+ m.file_path_local, m.start_time, m.recorder_sn
+ FROM delivery_media_snapshot_feedback f
+ JOIN collection_media m ON m.id = f.media_id AND m.isdeleted = 0
+ WHERE f.isdeleted = 0
+ ORDER BY f.id
+ """
+ groups = {}
+ with conn.cursor() as cur:
+ cur.execute(sql)
+ for row in cur.fetchall():
+ media_id, snap_type, ai_t, manual_t, path_local, start_time, recorder_sn = row
+ if media_id not in groups:
+ video_url = args.ftp_prefix.rstrip("/") + "/" + media_folder.strip("/") + "/" + (path_local or "").lstrip("/")
+ driver_date = ""
+ if start_time:
+ driver_date = f"{recorder_sn or 'unknown'}_{start_time.strftime('%Y%m%d')}"
+ groups[media_id] = {
+ "media_id": media_id,
+ "video_path": video_url,
+ "storefront_time_sec": None,
+ "handover_time_sec": None,
+ "driver_date": driver_date,
+ "split": "train",
+ "notes": "from_feedback",
+ }
+ if snap_type == 1:
+ groups[media_id]["storefront_time_sec"] = float(manual_t)
+ elif snap_type == 2:
+ groups[media_id]["handover_time_sec"] = float(manual_t)
+ conn.close()
+
+ os.makedirs(os.path.dirname(args.output) or ".", exist_ok=True)
+ count = 0
+ with open(args.output, "w", encoding="utf-8") as out:
+ for item in groups.values():
+ if item["storefront_time_sec"] is None or item["handover_time_sec"] is None:
+ continue
+ out.write(json.dumps(item, ensure_ascii=False) + "\n")
+ count += 1
+ print(f"瀵煎嚭 {count} 鏉� -> {args.output}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/server/snapshot_infer/tools/export_media_list.py b/server/snapshot_infer/tools/export_media_list.py
new file mode 100644
index 0000000..6056918
--- /dev/null
+++ b/server/snapshot_infer/tools/export_media_list.py
@@ -0,0 +1,136 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""浠� MySQL collection_media 瀵煎嚭寰呮爣娉ㄨ棰戞竻鍗曪紙CSV + JSONL 妯℃澘锛夈��"""
+import argparse
+import csv
+import json
+import os
+import sys
+
+try:
+ import pymysql
+except ImportError:
+ pymysql = None
+
+
+def export_from_mysql(host, port, user, password, database, ftp_prefix, limit, out_csv, out_jsonl):
+ if pymysql is None:
+ print("璇峰畨瑁� pymysql: py -m pip install pymysql", file=sys.stderr)
+ sys.exit(1)
+ conn = pymysql.connect(
+ host=host, port=port, user=user, password=password,
+ database=database, charset="utf8mb4",
+ )
+ sql = """
+ SELECT id, file_name, file_path_local, start_time, end_time, recorder_sn
+ FROM collection_media
+ WHERE isdeleted = 0 AND download_status = 1 AND media_type = 0
+ AND file_path_local IS NOT NULL AND file_path_local != ''
+ ORDER BY id DESC
+ LIMIT %s
+ """
+ with conn.cursor() as cur:
+ cur.execute(sql, (limit,))
+ rows = cur.fetchall()
+ conn.close()
+
+ media_folder = os.environ.get("COLLECTION_MEDIA_FOLDER", "/collection_media/")
+ if not media_folder.endswith("/"):
+ media_folder += "/"
+
+ records = []
+ for row in rows:
+ media_id, file_name, file_path_local, start_time, end_time, recorder_sn = row
+ video_url = ftp_prefix.rstrip("/") + "/" + media_folder.lstrip("/") + file_path_local.lstrip("/")
+ driver_date = ""
+ if start_time:
+ driver_date = f"{recorder_sn or 'unknown'}_{start_time.strftime('%Y%m%d')}"
+ records.append({
+ "media_id": media_id,
+ "file_name": file_name or "",
+ "video_path": video_url,
+ "recorder_sn": recorder_sn or "",
+ "driver_date": driver_date,
+ "storefront_time_sec": "",
+ "handover_time_sec": "",
+ "store_type": "",
+ "has_voice_marker": "",
+ "split": "",
+ "notes": "",
+ })
+
+ os.makedirs(os.path.dirname(out_csv) or ".", exist_ok=True)
+ with open(out_csv, "w", newline="", encoding="utf-8-sig") as f:
+ writer = csv.DictWriter(f, fieldnames=list(records[0].keys()) if records else [])
+ writer.writeheader()
+ writer.writerows(records)
+
+ with open(out_jsonl, "w", encoding="utf-8") as f:
+ for r in records:
+ template = {
+ "media_id": r["media_id"],
+ "video_path": r["video_path"],
+ "storefront_time_sec": 0.0,
+ "handover_time_sec": 0.0,
+ "store_type": "",
+ "has_voice_marker": False,
+ "driver_date": r["driver_date"],
+ "split": "train",
+ "notes": "TODO: 濉啓鏍囨敞",
+ }
+ f.write(json.dumps(template, ensure_ascii=False) + "\n")
+
+ print(f"瀵煎嚭 {len(records)} 鏉� -> {out_csv}, {out_jsonl}")
+
+
+def export_from_csv(in_csv, out_jsonl):
+ records = []
+ with open(in_csv, newline="", encoding="utf-8-sig") as f:
+ for row in csv.DictReader(f):
+ records.append(row)
+ with open(out_jsonl, "w", encoding="utf-8") as f:
+ for r in records:
+ item = {
+ "media_id": int(r["media_id"]),
+ "video_path": r.get("video_path") or r.get("video_url", ""),
+ "storefront_time_sec": float(r["storefront_time_sec"]) if r.get("storefront_time_sec") else 0.0,
+ "handover_time_sec": float(r["handover_time_sec"]) if r.get("handover_time_sec") else 0.0,
+ "store_type": r.get("store_type", ""),
+ "has_voice_marker": str(r.get("has_voice_marker", "")).lower() in ("1", "true", "yes"),
+ "driver_date": r.get("driver_date", ""),
+ "split": r.get("split") or "train",
+ "notes": r.get("notes", ""),
+ }
+ f.write(json.dumps(item, ensure_ascii=False) + "\n")
+ print(f"杞崲 {len(records)} 鏉� -> {out_jsonl}")
+
+
+def main():
+ parser = argparse.ArgumentParser(description="瀵煎嚭 collection_media 寰呮爣娉ㄦ竻鍗�")
+ parser.add_argument("--mysql", action="store_true", help="浠� MySQL 璇诲彇")
+ parser.add_argument("--host", default=os.environ.get("MYSQL_HOST", "127.0.0.1"))
+ parser.add_argument("--port", type=int, default=int(os.environ.get("MYSQL_PORT", "3306")))
+ parser.add_argument("--user", default=os.environ.get("MYSQL_USER", "root"))
+ parser.add_argument("--password", default=os.environ.get("MYSQL_PASSWORD", ""))
+ parser.add_argument("--database", default=os.environ.get("MYSQL_DATABASE", "wuhuyancao"))
+ parser.add_argument("--ftp-prefix", default=os.environ.get("FTP_RESOURCE_PREFIX", "http://127.0.0.1/files"))
+ parser.add_argument("--limit", type=int, default=200)
+ parser.add_argument("--out-csv", default="data/annotation_tasks.csv")
+ parser.add_argument("--out-jsonl", default="data/annotations_template.jsonl")
+ parser.add_argument("--from-csv", help="浠庡凡濉� CSV 杞� JSONL")
+ args = parser.parse_args()
+
+ if args.from_csv:
+ export_from_csv(args.from_csv, args.out_jsonl)
+ elif args.mysql:
+ export_from_mysql(
+ args.host, args.port, args.user, args.password, args.database,
+ args.ftp_prefix, args.limit, args.out_csv, args.out_jsonl,
+ )
+ else:
+ print("璇锋寚瀹� --mysql 鎴� --from-csv", file=sys.stderr)
+ sys.exit(1)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/server/snapshot_infer/tools/label_studio_config.xml b/server/snapshot_infer/tools/label_studio_config.xml
new file mode 100644
index 0000000..f943041
--- /dev/null
+++ b/server/snapshot_infer/tools/label_studio_config.xml
@@ -0,0 +1,9 @@
+<View>
+ <Header value="閰嶉�佽棰戯細闂ㄥご / 浜や粯鏃跺埢鏍囨敞"/>
+ <Video name="video" value="$video_path" sync="audio"/>
+ <Labels name="event" toName="video">
+ <Label value="storefront" background="#FFA500"/>
+ <Label value="handover" background="#008000"/>
+ </Labels>
+ <TextArea name="notes" toName="video" placeholder="澶囨敞锛氶棬搴楃被鍨嬨�佹槸鍚︽湁璇煶鏍囪绛�"/>
+</View>
diff --git a/server/snapshot_infer/training/config.yaml b/server/snapshot_infer/training/config.yaml
new file mode 100644
index 0000000..521f297
--- /dev/null
+++ b/server/snapshot_infer/training/config.yaml
@@ -0,0 +1,28 @@
+# 璁粌閰嶇疆
+data:
+ annotations_jsonl: ../data/annotations.jsonl
+ frames_dir: ../data/frames
+ labels_csv: ../data/labels.csv
+
+sampling:
+ sample_fps: 1.0
+ positive_window_sec: 3.0
+ other_downsample_ratio: 2
+
+model:
+ backbone: mobilenet_v3_small
+ image_size: 224
+ pos_weight: null
+
+train:
+ batch_size: 32
+ epochs: 40
+ lr: 0.0005
+ early_stop_patience: 8
+ num_workers: 0
+ freeze_backbone: true
+
+export:
+ opset: 17
+ quantize: true
+ output_dir: ../models
diff --git a/server/snapshot_infer/training/evaluate.py b/server/snapshot_infer/training/evaluate.py
new file mode 100644
index 0000000..d02778c
--- /dev/null
+++ b/server/snapshot_infer/training/evaluate.py
@@ -0,0 +1,171 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""瑙嗛绾ц瘎浼帮細涓庢帹鐞� pipeline 涓�鑷寸殑鏃跺簭骞虫粦 + 椤哄簭绾︽潫銆�"""
+import argparse
+import json
+import os
+import subprocess
+import sys
+import tempfile
+from pathlib import Path
+
+import numpy as np
+import onnxruntime as ort
+import yaml
+from PIL import Image
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+from app.temporal import find_peaks_ordered, smooth_scores
+
+
+def load_config(path):
+ with open(path, encoding="utf-8") as f:
+ return yaml.safe_load(f)
+
+
+def resolve_model(models_dir, name):
+ version_path = models_dir / "version.json"
+ if version_path.is_file():
+ meta = json.loads(version_path.read_text(encoding="utf-8"))
+ key = f"{name}_model"
+ if meta.get(key):
+ p = models_dir / meta[key]
+ if p.is_file():
+ return p
+ for suffix in (f"{name}_int8.onnx", f"{name}.onnx"):
+ p = models_dir / suffix
+ if p.is_file():
+ return p
+ return None
+
+
+def ffprobe_duration(video_path):
+ cmd = [
+ "ffprobe", "-v", "error", "-show_entries", "format=duration",
+ "-of", "default=noprint_wrappers=1:nokey=1", video_path,
+ ]
+ try:
+ out = subprocess.check_output(cmd, stderr=subprocess.DEVNULL, text=True).strip()
+ return float(out) if out else 0.0
+ except Exception:
+ return 0.0
+
+
+def download_if_needed(video_path, cache_dir):
+ if os.path.isfile(video_path):
+ return video_path, None
+ if not video_path.startswith("http"):
+ return None, None
+ import httpx
+ os.makedirs(cache_dir, exist_ok=True)
+ local = os.path.join(cache_dir, "eval_tmp.mp4")
+ with httpx.stream("GET", video_path, timeout=600.0, follow_redirects=True) as r:
+ r.raise_for_status()
+ with open(local, "wb") as f:
+ for chunk in r.iter_bytes():
+ f.write(chunk)
+ return local, local
+
+
+def sample_scores(video_path, session, sample_fps, image_size):
+ duration = ffprobe_duration(video_path)
+ if duration <= 0:
+ return [], duration
+ times, scores = [], []
+ t, step = 0.0, 1.0 / sample_fps
+ tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
+ tmp.close()
+ mean = np.array([0.485, 0.456, 0.406], dtype=np.float32)
+ std = np.array([0.229, 0.224, 0.225], dtype=np.float32)
+ try:
+ while t <= duration:
+ subprocess.run(
+ ["ffmpeg", "-y", "-ss", str(t), "-i", video_path, "-frames:v", "1", "-q:v", "2", tmp.name],
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
+ )
+ if os.path.isfile(tmp.name) and os.path.getsize(tmp.name) > 0:
+ img = Image.open(tmp.name).convert("RGB").resize((image_size, image_size))
+ arr = (np.array(img).astype(np.float32) / 255.0 - mean) / std
+ arr = arr.transpose(2, 0, 1)[None].astype(np.float32)
+ logit = session.run(None, {"input": arr})[0][0][0]
+ prob = float(1.0 / (1.0 + np.exp(-logit)))
+ times.append(round(t, 2))
+ scores.append(prob)
+ t += step
+ finally:
+ if os.path.isfile(tmp.name):
+ os.remove(tmp.name)
+ return list(zip(times, scores)), duration
+
+
+def main():
+ parser = argparse.ArgumentParser()
+ parser.add_argument("-c", "--config", default=str(Path(__file__).parent / "config.yaml"))
+ parser.add_argument("--annotations", default="../data/annotations.jsonl")
+ parser.add_argument("--models-dir", default="../models")
+ parser.add_argument("--sample-fps", type=float, default=0.5)
+ parser.add_argument("--split", default="val")
+ args = parser.parse_args()
+ cfg = load_config(args.config)
+ size = cfg["model"]["image_size"]
+ models_dir = Path(args.config).resolve().parent / args.models_dir
+
+ sf_path = resolve_model(models_dir, "storefront")
+ ho_path = resolve_model(models_dir, "handover")
+ if not sf_path or not ho_path:
+ print("鏈壘鍒� ONNX 妯″瀷锛岃鍏� export_onnx.py")
+ return
+
+ sf_sess = ort.InferenceSession(str(sf_path), providers=["CPUExecutionProvider"])
+ ho_sess = ort.InferenceSession(str(ho_path), providers=["CPUExecutionProvider"])
+
+ ann_path = Path(args.config).resolve().parent / args.annotations
+ items = []
+ with open(ann_path, encoding="utf-8") as f:
+ for line in f:
+ if line.strip():
+ items.append(json.loads(line))
+ val_items = [i for i in items if i.get("split") == args.split] or items
+
+ cache_dir = str(Path(args.config).resolve().parent / "../data/eval_cache")
+ sf_mae = ho_mae = order_ok = hit5 = n = 0
+ for item in val_items:
+ vp = item["video_path"]
+ local, tmp = download_if_needed(vp, cache_dir)
+ if not local:
+ print(f"璺宠繃 media_id={item['media_id']}: 瑙嗛涓嶅彲璁块棶")
+ continue
+ sf_scores, duration = sample_scores(local, sf_sess, args.sample_fps, size)
+ ho_scores, _ = sample_scores(local, ho_sess, args.sample_fps, size)
+ sf_peak, ho_peak = find_peaks_ordered(sf_scores, ho_scores, duration)
+ pred_sf = sf_peak[0] if sf_peak else 0.0
+ pred_ho = ho_peak[0] if ho_peak else 0.0
+ gt_sf = float(item["storefront_time_sec"])
+ gt_ho = float(item["handover_time_sec"])
+ sf_err = abs(pred_sf - gt_sf)
+ ho_err = abs(pred_ho - gt_ho)
+ sf_mae += sf_err
+ ho_mae += ho_err
+ if pred_ho > pred_sf:
+ order_ok += 1
+ if sf_err <= 5 and ho_err <= 5:
+ hit5 += 1
+ n += 1
+ print(
+ f"media_id={item['media_id']} gt_sf={gt_sf}s pred_sf={pred_sf:.1f}s err={sf_err:.1f}s | "
+ f"gt_ho={gt_ho}s pred_ho={pred_ho:.1f}s err={ho_err:.1f}s"
+ )
+ if tmp and os.path.isfile(tmp):
+ os.remove(tmp)
+
+ if n == 0:
+ print("鏃犲彲鐢ㄩ獙璇佹牱鏈�")
+ return
+ print("---")
+ print(f"鏍锋湰鏁�={n}")
+ print(f"闂ㄥご MAE={sf_mae/n:.2f}s 浜や粯 MAE={ho_mae/n:.2f}s")
+ print(f"椤哄簭姝g‘鐜�={order_ok/n*100:.1f}% 鍙�5绉掑懡涓巼={hit5/n*100:.1f}%")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/server/snapshot_infer/training/export_onnx.py b/server/snapshot_infer/training/export_onnx.py
new file mode 100644
index 0000000..93a0d6b
--- /dev/null
+++ b/server/snapshot_infer/training/export_onnx.py
@@ -0,0 +1,128 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""瀵煎嚭 PyTorch 鏉冮噸涓� ONNX锛屽苟鍙�� INT8 鍔ㄦ�侀噺鍖栥��"""
+import argparse
+import json
+import os
+import shutil
+import subprocess
+import sys
+from datetime import datetime
+from pathlib import Path
+
+import torch
+import yaml
+from train import build_model
+
+
+def load_config(path):
+ with open(path, encoding="utf-8") as f:
+ cfg = yaml.safe_load(f)
+ base = Path(path).resolve().parent
+ out = cfg["export"]["output_dir"]
+ if not os.path.isabs(out):
+ cfg["export"]["output_dir"] = str((base / out).resolve())
+ return cfg
+
+
+def try_quantize_subprocess(onnx_path: str, int8_path: str) -> bool:
+ """鍦ㄥ瓙杩涚▼鎵ц ORT 閲忓寲锛岄伩鍏嶄富杩涚▼鍥� Windows 涓� ORT bug 宕╂簝銆�"""
+ code = (
+ "import sys\n"
+ "from onnxruntime.quantization import QuantType, quantize_dynamic\n"
+ "quantize_dynamic(sys.argv[1], sys.argv[2], weight_type=QuantType.QUInt8)\n"
+ "print('OK')\n"
+ )
+ try:
+ result = subprocess.run(
+ [sys.executable, "-c", code, onnx_path, int8_path],
+ capture_output=True,
+ text=True,
+ timeout=600,
+ )
+ except subprocess.TimeoutExpired:
+ print(f"閲忓寲瓒呮椂: {int8_path}")
+ return False
+ if result.returncode != 0:
+ err = (result.stderr or result.stdout or "").strip()
+ if err:
+ print(f"閲忓寲澶辫触 exit={result.returncode}: {err[:500]}")
+ else:
+ print(f"閲忓寲澶辫触 exit={result.returncode}锛圵indows 涓� ORT 閲忓寲鍣ㄥ彲鑳藉穿婧冿級")
+ return False
+ return os.path.isfile(int8_path) and os.path.getsize(int8_path) > 0
+
+
+def export_one(task, cfg, do_quantize: bool):
+ out_dir = cfg["export"]["output_dir"]
+ os.makedirs(out_dir, exist_ok=True)
+ size = cfg["model"]["image_size"]
+ ckpt = os.path.join(out_dir, f"{task}.pt")
+ if not os.path.isfile(ckpt):
+ raise FileNotFoundError(f"鏈壘鍒版潈閲� {ckpt}锛岃鍏堣繍琛� train.py")
+
+ model = build_model()
+ state = torch.load(ckpt, map_location="cpu")
+ model.load_state_dict(state)
+ model.eval()
+
+ dummy = torch.randn(1, 3, size, size)
+ onnx_path = os.path.join(out_dir, f"{task}.onnx")
+ torch.onnx.export(
+ model,
+ dummy,
+ onnx_path,
+ input_names=["input"],
+ output_names=["logits"],
+ dynamic_axes={"input": {0: "batch"}, "logits": {0: "batch"}},
+ opset_version=cfg["export"]["opset"],
+ )
+ print(f"瀵煎嚭 float ONNX: {onnx_path}")
+
+ int8_path = os.path.join(out_dir, f"{task}_int8.onnx")
+ if do_quantize and cfg["export"].get("quantize"):
+ if try_quantize_subprocess(onnx_path, int8_path):
+ print(f"閲忓寲 INT8: {int8_path}")
+ return os.path.basename(int8_path)
+ print(
+ f"WARN: {task}_int8.onnx 閲忓寲鏈垚鍔燂紝鎺ㄧ悊鏈嶅姟灏嗕娇鐢� float 妯″瀷 {task}.onnx\n"
+ " 甯歌鍘熷洜: Windows 涓� onnxruntime.quantization 宕╂簝銆�"
+ " 鍙崌绾�/闄嶇骇 onnxruntime锛屾垨浣跨敤 --no-quantize 璺宠繃閲忓寲銆�"
+ )
+ elif do_quantize:
+ print(f"璺宠繃閲忓寲锛坈onfig quantize=false锛�")
+
+ return os.path.basename(onnx_path)
+
+
+def main():
+ parser = argparse.ArgumentParser(description="瀵煎嚭 storefront/handover ONNX 妯″瀷")
+ parser.add_argument("-c", "--config", default=str(Path(__file__).parent / "config.yaml"))
+ parser.add_argument("--version", default="1.0.0")
+ parser.add_argument("--no-quantize", action="store_true", help="浠呭鍑� float .onnx锛屼笉灏濊瘯 INT8")
+ args = parser.parse_args()
+ cfg = load_config(args.config)
+ do_quantize = not args.no_quantize
+
+ paths = {}
+ for task in ("storefront", "handover"):
+ paths[task] = export_one(task, cfg, do_quantize)
+
+ version_info = {
+ "model_version": args.version,
+ "storefront_model": paths["storefront"],
+ "handover_model": paths["handover"],
+ "image_size": cfg["model"]["image_size"],
+ "quantized": do_quantize and paths["storefront"].endswith("_int8.onnx"),
+ "exported_at": datetime.utcnow().isoformat() + "Z",
+ }
+ version_path = os.path.join(cfg["export"]["output_dir"], "version.json")
+ with open(version_path, "w", encoding="utf-8") as f:
+ json.dump(version_info, f, indent=2, ensure_ascii=False)
+ print(f"鍐欏叆 {version_path}")
+ print(f" storefront -> {paths['storefront']}")
+ print(f" handover -> {paths['handover']}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/server/snapshot_infer/training/extract_frames.py b/server/snapshot_infer/training/extract_frames.py
new file mode 100644
index 0000000..1a603dc
--- /dev/null
+++ b/server/snapshot_infer/training/extract_frames.py
@@ -0,0 +1,7 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""prepare_dataset 鍒悕鍏ュ彛銆�"""
+from prepare_dataset import main
+
+if __name__ == "__main__":
+ main()
diff --git a/server/snapshot_infer/training/prepare_dataset.py b/server/snapshot_infer/training/prepare_dataset.py
new file mode 100644
index 0000000..e960262
--- /dev/null
+++ b/server/snapshot_infer/training/prepare_dataset.py
@@ -0,0 +1,150 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""浠� annotations.jsonl 鐢熸垚 labels.csv锛堝抚璺緞, label, task锛夈��"""
+import argparse
+import csv
+import json
+import os
+import random
+import subprocess
+import sys
+from pathlib import Path
+
+import yaml
+
+ROOT = Path(__file__).resolve().parent.parent
+
+
+def load_config(path):
+ with open(path, encoding="utf-8") as f:
+ cfg = yaml.safe_load(f)
+ base = Path(path).resolve().parent
+ for key in ("annotations_jsonl", "frames_dir", "labels_csv"):
+ p = cfg["data"][key]
+ if not os.path.isabs(p):
+ cfg["data"][key] = str((base / p).resolve())
+ cfg["data"]["output_dir"] = cfg["export"]["output_dir"]
+ if not os.path.isabs(cfg["export"]["output_dir"]):
+ cfg["export"]["output_dir"] = str((base / cfg["export"]["output_dir"]).resolve())
+ return cfg
+
+
+def ffprobe_duration(video_path, ffmpeg_dir=""):
+ ffprobe = "ffprobe"
+ if ffmpeg_dir:
+ ffprobe = os.path.join(ffmpeg_dir, "ffprobe.exe" if os.name == "nt" else "ffprobe")
+ cmd = [
+ ffprobe, "-v", "error", "-show_entries", "format=duration",
+ "-of", "default=noprint_wrappers=1:nokey=1", video_path,
+ ]
+ try:
+ out = subprocess.check_output(cmd, stderr=subprocess.DEVNULL, text=True).strip()
+ return float(out) if out else 0.0
+ except Exception:
+ return 0.0
+
+
+def extract_frame_at(video_path, out_path, sec, ffmpeg_dir=""):
+ ffmpeg = "ffmpeg"
+ if ffmpeg_dir:
+ ffmpeg = os.path.join(ffmpeg_dir, "ffmpeg.exe" if os.name == "nt" else "ffmpeg")
+ os.makedirs(os.path.dirname(out_path), exist_ok=True)
+ cmd = [
+ ffmpeg, "-y", "-ss", str(sec), "-i", video_path,
+ "-frames:v", "1", "-q:v", "2", out_path,
+ ]
+ subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False)
+
+
+def process_video(item, cfg, ffmpeg_dir):
+ media_id = item["media_id"]
+ video_path = item["video_path"]
+ if not os.path.isfile(video_path) and not video_path.startswith("http"):
+ print(f"璺宠繃 media_id={media_id}: 瑙嗛涓嶅瓨鍦� {video_path}")
+ return []
+
+ local_video = video_path
+ tmp_video = None
+ if video_path.startswith("http"):
+ import httpx
+ tmp_video = os.path.join(cfg["data"]["frames_dir"], f"_tmp_{media_id}.mp4")
+ os.makedirs(cfg["data"]["frames_dir"], exist_ok=True)
+ with httpx.stream("GET", video_path, timeout=600.0) as r:
+ r.raise_for_status()
+ with open(tmp_video, "wb") as f:
+ for chunk in r.iter_bytes():
+ f.write(chunk)
+ local_video = tmp_video
+
+ duration = ffprobe_duration(local_video, ffmpeg_dir)
+ if duration <= 0:
+ duration = max(item.get("handover_time_sec", 600), 600)
+
+ sample_fps = cfg["sampling"]["sample_fps"]
+ win = cfg["sampling"]["positive_window_sec"]
+ sf_t = float(item["storefront_time_sec"])
+ ho_t = float(item["handover_time_sec"])
+
+ rows = []
+ t = 0.0
+ step = 1.0 / sample_fps
+ other_count = 0
+ other_budget = max(1, int(duration * sample_fps / cfg["sampling"]["other_downsample_ratio"]))
+ while t <= duration:
+ rel = f"{media_id}/{int(t * 1000)}.jpg"
+ out_path = os.path.join(cfg["data"]["frames_dir"], rel)
+ is_sf = abs(t - sf_t) <= win
+ is_ho = abs(t - ho_t) <= win
+ need_other = not is_sf and not is_ho and other_count < other_budget
+ if is_sf or is_ho or need_other:
+ extract_frame_at(local_video, out_path, t, ffmpeg_dir)
+ if os.path.isfile(out_path):
+ split = item.get("split", "train")
+ sf_label = 1 if is_sf else 0
+ ho_label = 1 if is_ho else 0
+ rows.append((rel, sf_label, "storefront", split))
+ rows.append((rel, ho_label, "handover", split))
+ if need_other:
+ other_count += 1
+ t += step
+ if tmp_video and os.path.isfile(tmp_video):
+ os.remove(tmp_video)
+ return rows
+
+
+def main():
+ parser = argparse.ArgumentParser()
+ parser.add_argument("-c", "--config", default=str(Path(__file__).parent / "config.yaml"))
+ parser.add_argument("--ffmpeg-dir", default=os.environ.get("FFMPEG_DIR", ""))
+ parser.add_argument("--limit", type=int, default=0)
+ args = parser.parse_args()
+ cfg = load_config(args.config)
+ os.makedirs(cfg["data"]["frames_dir"], exist_ok=True)
+
+ items = []
+ with open(cfg["data"]["annotations_jsonl"], encoding="utf-8") as f:
+ for line in f:
+ line = line.strip()
+ if line:
+ items.append(json.loads(line))
+ if args.limit:
+ items = items[: args.limit]
+
+ all_rows = []
+ for i, item in enumerate(items):
+ if item.get("storefront_time_sec", 0) <= 0 or item.get("handover_time_sec", 0) <= 0:
+ print(f"璺宠繃鏈爣娉� media_id={item.get('media_id')}")
+ continue
+ print(f"[{i+1}/{len(items)}] media_id={item['media_id']}")
+ all_rows.extend(process_video(item, cfg, args.ffmpeg_dir))
+
+ with open(cfg["data"]["labels_csv"], "w", newline="", encoding="utf-8") as f:
+ w = csv.writer(f)
+ w.writerow(["frame_path", "label", "task", "split"])
+ for row in all_rows:
+ w.writerow(row)
+ print(f"鍐欏叆 {len(all_rows)} 琛� -> {cfg['data']['labels_csv']}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/server/snapshot_infer/training/requirements-train.txt b/server/snapshot_infer/training/requirements-train.txt
new file mode 100644
index 0000000..3420c7b
--- /dev/null
+++ b/server/snapshot_infer/training/requirements-train.txt
@@ -0,0 +1,10 @@
+torch>=2.0.0
+torchvision>=0.15.0
+onnx>=1.14.0
+onnxruntime>=1.16.0
+pyyaml>=6.0
+pillow>=9.0.0
+opencv-python-headless>=4.8.0
+numpy>=1.24.0
+tqdm>=4.65.0
+httpx>=0.25.0
diff --git a/server/snapshot_infer/training/train.py b/server/snapshot_infer/training/train.py
new file mode 100644
index 0000000..8fc2ef9
--- /dev/null
+++ b/server/snapshot_infer/training/train.py
@@ -0,0 +1,206 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""璁粌 storefront / handover 浜屽垎绫绘ā鍨嬶紙MobileNetV3-Small锛夈��"""
+import argparse
+import csv
+import os
+from pathlib import Path
+
+import torch
+import torch.nn as nn
+import yaml
+from PIL import Image
+from torch.utils.data import DataLoader, Dataset
+from torchvision import models, transforms
+from tqdm import tqdm
+
+
+class FrameDataset(Dataset):
+ def __init__(self, rows, frames_dir, transform):
+ self.rows = rows
+ self.frames_dir = frames_dir
+ self.transform = transform
+
+ def __len__(self):
+ return len(self.rows)
+
+ def __getitem__(self, idx):
+ path, label = self.rows[idx]
+ img = Image.open(os.path.join(self.frames_dir, path)).convert("RGB")
+ return self.transform(img), torch.tensor(label, dtype=torch.float32)
+
+
+def load_config(path):
+ with open(path, encoding="utf-8") as f:
+ cfg = yaml.safe_load(f)
+ base = Path(path).resolve().parent
+ for key in ("frames_dir", "labels_csv"):
+ p = cfg["data"][key]
+ if not os.path.isabs(p):
+ cfg["data"][key] = str((base / p).resolve())
+ out = cfg["export"]["output_dir"]
+ if not os.path.isabs(out):
+ cfg["export"]["output_dir"] = str((base / out).resolve())
+ return cfg
+
+
+def read_labels(csv_path, task, split=None):
+ """璇诲彇鏌愪换鍔$殑璁粌鏍锋湰锛氳 task 琛屽惈 label 0/1锛涘吋瀹规棫鐗� task=other 璐熸牱鏈��"""
+ rows = []
+ with open(csv_path, newline="", encoding="utf-8") as f:
+ for r in csv.DictReader(f):
+ if split and r.get("split") and r["split"] != split:
+ continue
+ row_task = r["task"]
+ label = int(r["label"])
+ if row_task == task:
+ rows.append((r["frame_path"], label))
+ elif row_task == "other" and label == 0:
+ rows.append((r["frame_path"], 0))
+ return rows
+
+
+def count_labels(rows):
+ pos = sum(1 for _, y in rows if y == 1)
+ neg = len(rows) - pos
+ return pos, neg
+
+
+def build_model(freeze_backbone=False):
+ m = models.mobilenet_v3_small(weights=models.MobileNet_V3_Small_Weights.DEFAULT)
+ if freeze_backbone:
+ for p in m.features.parameters():
+ p.requires_grad = False
+ in_f = m.classifier[0].in_features
+ m.classifier = nn.Sequential(
+ nn.Linear(in_f, 128),
+ nn.Hardswish(),
+ nn.Dropout(0.2),
+ nn.Linear(128, 1),
+ )
+ return m
+
+
+def build_transforms(size, augment=False):
+ if augment:
+ return transforms.Compose([
+ transforms.Resize((size, size)),
+ transforms.RandomApply([
+ transforms.ColorJitter(brightness=0.3, contrast=0.3, saturation=0.2),
+ ], p=0.7),
+ transforms.RandomApply([
+ transforms.GaussianBlur(kernel_size=3),
+ ], p=0.2),
+ transforms.ToTensor(),
+ transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]),
+ ])
+ return transforms.Compose([
+ transforms.Resize((size, size)),
+ transforms.ToTensor(),
+ transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]),
+ ])
+
+
+def train_task(task, cfg):
+ frames_dir = cfg["data"]["frames_dir"]
+ train_rows = read_labels(cfg["data"]["labels_csv"], task, "train")
+ val_rows = read_labels(cfg["data"]["labels_csv"], task, "val")
+ if not train_rows:
+ train_rows = read_labels(cfg["data"]["labels_csv"], task)
+ if not val_rows:
+ val_rows = train_rows[: max(1, len(train_rows) // 10)]
+
+ train_pos, train_neg = count_labels(train_rows)
+ val_pos, val_neg = count_labels(val_rows)
+ print(f"[{task}] train pos={train_pos} neg={train_neg} | val pos={val_pos} neg={val_neg}")
+ if train_neg == 0:
+ print(f"WARN: [{task}] 璁粌闆嗘棤璐熸牱鏈紒璇烽噸鏂拌繍琛� prepare_dataset.py 鐢熸垚 labels.csv")
+ if train_pos < 10:
+ print(f"WARN: [{task}] 姝f牱鏈繃灏戯紙<10锛夛紝寤鸿鏍囨敞鑷冲皯 80+ 鏉¤棰�")
+
+ size = cfg["model"]["image_size"]
+ train_loader = DataLoader(
+ FrameDataset(train_rows, frames_dir, build_transforms(size, augment=True)),
+ batch_size=cfg["train"]["batch_size"],
+ shuffle=True,
+ num_workers=cfg["train"]["num_workers"],
+ )
+ val_loader = DataLoader(
+ FrameDataset(val_rows, frames_dir, build_transforms(size, augment=False)),
+ batch_size=cfg["train"]["batch_size"],
+ shuffle=False,
+ num_workers=cfg["train"]["num_workers"],
+ )
+
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
+ freeze = cfg["train"].get("freeze_backbone", True) and train_pos < 200
+ model = build_model(freeze_backbone=freeze).to(device)
+ if freeze:
+ print(f"[{task}] 灏忔牱鏈ā寮忥細鍐荤粨 backbone锛屼粎璁粌鍒嗙被澶�")
+
+ pos_count = max(train_pos, 1)
+ neg_count = max(train_neg, 1)
+ auto_pos_weight = min(neg_count / pos_count, 10.0)
+ pos_weight_val = cfg["model"].get("pos_weight") or auto_pos_weight
+ pos_weight = torch.tensor([pos_weight_val], device=device)
+ print(f"[{task}] pos_weight={pos_weight_val:.2f}")
+
+ criterion = nn.BCEWithLogitsLoss(pos_weight=pos_weight)
+ lr = cfg["train"]["lr"]
+ if freeze:
+ optimizer = torch.optim.Adam(filter(lambda p: p.requires_grad, model.parameters()), lr=lr)
+ else:
+ optimizer = torch.optim.Adam([
+ {"params": model.features.parameters(), "lr": lr * 0.1},
+ {"params": model.classifier.parameters(), "lr": lr},
+ ])
+
+ best_loss = float("inf")
+ patience = 0
+ os.makedirs(cfg["export"]["output_dir"], exist_ok=True)
+ ckpt_path = os.path.join(cfg["export"]["output_dir"], f"{task}.pt")
+
+ for epoch in range(cfg["train"]["epochs"]):
+ model.train()
+ for x, y in tqdm(train_loader, desc=f"{task} epoch {epoch+1}"):
+ x, y = x.to(device), y.to(device).unsqueeze(1)
+ optimizer.zero_grad()
+ loss = criterion(model(x), y)
+ loss.backward()
+ optimizer.step()
+
+ model.eval()
+ val_loss = 0.0
+ n = 0
+ with torch.no_grad():
+ for x, y in val_loader:
+ x, y = x.to(device), y.to(device).unsqueeze(1)
+ val_loss += criterion(model(x), y).item() * x.size(0)
+ n += x.size(0)
+ val_loss /= max(n, 1)
+ print(f"{task} epoch {epoch+1} val_loss={val_loss:.4f}")
+ if val_loss < best_loss:
+ best_loss = val_loss
+ patience = 0
+ torch.save(model.state_dict(), ckpt_path)
+ else:
+ patience += 1
+ if patience >= cfg["train"]["early_stop_patience"]:
+ break
+ print(f"淇濆瓨 {ckpt_path}")
+ return ckpt_path
+
+
+def main():
+ parser = argparse.ArgumentParser()
+ parser.add_argument("-c", "--config", default=str(Path(__file__).parent / "config.yaml"))
+ parser.add_argument("--task", choices=["storefront", "handover", "both"], default="both")
+ args = parser.parse_args()
+ cfg = load_config(args.config)
+ tasks = ["storefront", "handover"] if args.task == "both" else [args.task]
+ for t in tasks:
+ train_task(t, cfg)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/server/system_service/src/main/java/com/doumee/core/utils/Constants.java b/server/system_service/src/main/java/com/doumee/core/utils/Constants.java
index ef249bc..81aa4d0 100644
--- a/server/system_service/src/main/java/com/doumee/core/utils/Constants.java
+++ b/server/system_service/src/main/java/com/doumee/core/utils/Constants.java
@@ -242,8 +242,6 @@
public static final String CS_DOWNLOAD_BATCH_SIZE = "CS_DOWNLOAD_BATCH_SIZE";
public static final String CS_FFMPEG_PATH = "CS_FFMPEG_PATH";
public static final String COLLECTION_MEDIA_FOLDER = "COLLECTION_MEDIA";
- /** 濯掍綋涓嬭浇鐘舵�侊細涓嬭浇涓� */
- public static final int COLLECTION_MEDIA_DOWNLOADING = 3;
public static final String SMS ="SMS" ;
public static final String SMS_COMNAME = "SMS_COMNAME";
public static final String SMS_IP ="SMS_IP" ;
diff --git a/server/system_service/src/main/java/com/doumee/core/utils/DateUtil.java b/server/system_service/src/main/java/com/doumee/core/utils/DateUtil.java
index d27f3b5..4dd1f61 100644
--- a/server/system_service/src/main/java/com/doumee/core/utils/DateUtil.java
+++ b/server/system_service/src/main/java/com/doumee/core/utils/DateUtil.java
@@ -462,7 +462,7 @@
* @return String
* @throws Exception
*/
- public static String getNowShortDate() throws Exception {
+ public static String getNowShortDate() {
String nowDate = "";
try {
java.sql.Date date = null;
diff --git a/server/visits/dmvisit_admin/src/main/java/com/doumee/cloud/admin/CollectionStationCloudController.java b/server/visits/dmvisit_admin/src/main/java/com/doumee/cloud/admin/CollectionStationCloudController.java
index d9a3500..5002bcc 100644
--- a/server/visits/dmvisit_admin/src/main/java/com/doumee/cloud/admin/CollectionStationCloudController.java
+++ b/server/visits/dmvisit_admin/src/main/java/com/doumee/cloud/admin/CollectionStationCloudController.java
@@ -5,11 +5,14 @@
import com.doumee.core.annotation.pr.PreventRepeat;
import com.doumee.core.utils.Constants;
import com.doumee.dao.admin.request.CollectionMediaSyncRequest;
+import com.doumee.dao.admin.request.DeliverySnapshotManualRequest;
import com.doumee.dao.business.model.CollectionMedia;
import com.doumee.dao.business.model.CollectionDockDevice;
import com.doumee.dao.business.model.CollectionStation;
+import com.doumee.dao.business.model.DeliveryMediaSnapshot;
import com.doumee.service.business.CollectionMediaSyncService;
import com.doumee.service.business.CollectionStationService;
+import com.doumee.service.business.DeliverySnapshotService;
import com.doumee.service.business.third.model.ApiResponse;
import com.doumee.service.business.third.model.PageData;
import com.doumee.service.business.third.model.PageWrap;
@@ -30,6 +33,8 @@
private CollectionStationService collectionStationService;
@Autowired
private CollectionMediaSyncService collectionMediaSyncService;
+ @Autowired
+ private DeliverySnapshotService deliverySnapshotService;
@PreventRepeat
@ApiOperation("鏂板缓閲囬泦绔�")
@@ -175,4 +180,27 @@
javax.servlet.http.HttpServletResponse response) {
collectionMediaSyncService.downloadMediaFile(id, request, response);
}
+
+ @PreventRepeat
+ @ApiOperation("鎻愪氦濯掍綋蹇収鍒嗘瀽(闂ㄥご/浜や粯)")
+ @PostMapping("/media/snapshot/analyze/{id}")
+ @CloudRequiredPermission({"business:collectionMedia:query", "business:collectionStation:query"})
+ public ApiResponse<String> analyzeMediaSnapshot(@PathVariable Integer id) {
+ return ApiResponse.success(deliverySnapshotService.submitAnalyze(id));
+ }
+
+ @ApiOperation("鏌ヨ濯掍綋蹇収(闂ㄥご/浜や粯)")
+ @GetMapping("/media/snapshot/{id}")
+ @CloudRequiredPermission({"business:collectionMedia:query", "business:collectionStation:query"})
+ public ApiResponse<List<DeliveryMediaSnapshot>> listMediaSnapshot(@PathVariable Integer id) {
+ return ApiResponse.success(deliverySnapshotService.listByMediaId(id));
+ }
+
+ @PreventRepeat
+ @ApiOperation("鎵嬪姩鎸囧畾濯掍綋蹇収鏃堕棿鐐�")
+ @PostMapping("/media/snapshot/manual")
+ @CloudRequiredPermission({"business:collectionMedia:query", "business:collectionStation:query"})
+ public ApiResponse<String> saveManualMediaSnapshot(@RequestBody DeliverySnapshotManualRequest request) {
+ return ApiResponse.success(deliverySnapshotService.saveManual(request));
+ }
}
diff --git a/server/visits/dmvisit_service/src/main/java/com/doumee/core/haikang/isapi/model/MediaItemDTO.java b/server/visits/dmvisit_service/src/main/java/com/doumee/core/haikang/isapi/model/MediaItemDTO.java
index 7e9c51f..857acf5 100644
--- a/server/visits/dmvisit_service/src/main/java/com/doumee/core/haikang/isapi/model/MediaItemDTO.java
+++ b/server/visits/dmvisit_service/src/main/java/com/doumee/core/haikang/isapi/model/MediaItemDTO.java
@@ -10,7 +10,7 @@
private String fileName;
private String playbackUri;
private String contentType;
- private int mediaType;
+ private Integer mediaType;
private Long fileSize;
private Date startTime;
private Date endTime;
diff --git a/server/visits/dmvisit_service/src/main/java/com/doumee/dao/admin/request/DeliverySnapshotManualRequest.java b/server/visits/dmvisit_service/src/main/java/com/doumee/dao/admin/request/DeliverySnapshotManualRequest.java
new file mode 100644
index 0000000..1541c1e
--- /dev/null
+++ b/server/visits/dmvisit_service/src/main/java/com/doumee/dao/admin/request/DeliverySnapshotManualRequest.java
@@ -0,0 +1,21 @@
+package com.doumee.dao.admin.request;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+@Data
+@ApiModel("鎵嬪姩鎸囧畾濯掍綋蹇収")
+public class DeliverySnapshotManualRequest {
+
+ @ApiModelProperty(value = "濯掍綋ID", required = true)
+ private Integer mediaId;
+
+ @ApiModelProperty(value = "1闂ㄥご 2浜や粯", required = true)
+ private Integer snapshotType;
+
+ @ApiModelProperty(value = "瑙嗛鍐呯鏁�", required = true)
+ private BigDecimal timestampSec;
+}
diff --git a/server/visits/dmvisit_service/src/main/java/com/doumee/dao/business/DeliveryMediaSnapshotFeedbackMapper.java b/server/visits/dmvisit_service/src/main/java/com/doumee/dao/business/DeliveryMediaSnapshotFeedbackMapper.java
new file mode 100644
index 0000000..8ce3402
--- /dev/null
+++ b/server/visits/dmvisit_service/src/main/java/com/doumee/dao/business/DeliveryMediaSnapshotFeedbackMapper.java
@@ -0,0 +1,7 @@
+package com.doumee.dao.business;
+
+import com.doumee.dao.business.model.DeliveryMediaSnapshotFeedback;
+import com.github.yulichang.base.MPJBaseMapper;
+
+public interface DeliveryMediaSnapshotFeedbackMapper extends MPJBaseMapper<DeliveryMediaSnapshotFeedback> {
+}
diff --git a/server/visits/dmvisit_service/src/main/java/com/doumee/dao/business/DeliveryMediaSnapshotMapper.java b/server/visits/dmvisit_service/src/main/java/com/doumee/dao/business/DeliveryMediaSnapshotMapper.java
new file mode 100644
index 0000000..a2a1cdd
--- /dev/null
+++ b/server/visits/dmvisit_service/src/main/java/com/doumee/dao/business/DeliveryMediaSnapshotMapper.java
@@ -0,0 +1,7 @@
+package com.doumee.dao.business;
+
+import com.doumee.dao.business.model.DeliveryMediaSnapshot;
+import com.github.yulichang.base.MPJBaseMapper;
+
+public interface DeliveryMediaSnapshotMapper extends MPJBaseMapper<DeliveryMediaSnapshot> {
+}
diff --git a/server/visits/dmvisit_service/src/main/java/com/doumee/dao/business/model/CollectionMedia.java b/server/visits/dmvisit_service/src/main/java/com/doumee/dao/business/model/CollectionMedia.java
index 328a5d7..9057e42 100644
--- a/server/visits/dmvisit_service/src/main/java/com/doumee/dao/business/model/CollectionMedia.java
+++ b/server/visits/dmvisit_service/src/main/java/com/doumee/dao/business/model/CollectionMedia.java
@@ -52,6 +52,14 @@
private Integer downloadStatus;
private Date downloadTime;
+
+ @ApiModelProperty(value = "0鏈垎鏋� 1鍒嗘瀽涓� 2瀹屾垚 3澶辫触")
+ private Integer snapshotStatus;
+
+ private Date snapshotTime;
+
+ private String snapshotMessage;
+
private Date createDate;
private Integer isdeleted;
}
diff --git a/server/visits/dmvisit_service/src/main/java/com/doumee/dao/business/model/DeliveryMediaSnapshot.java b/server/visits/dmvisit_service/src/main/java/com/doumee/dao/business/model/DeliveryMediaSnapshot.java
new file mode 100644
index 0000000..a8c0a55
--- /dev/null
+++ b/server/visits/dmvisit_service/src/main/java/com/doumee/dao/business/model/DeliveryMediaSnapshot.java
@@ -0,0 +1,46 @@
+package com.doumee.dao.business.model;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.util.Date;
+
+@Data
+@ApiModel("閰嶉�佸獟浣撳揩鐓�")
+@TableName("`delivery_media_snapshot`")
+public class DeliveryMediaSnapshot {
+
+ @TableId(type = IdType.AUTO)
+ private Integer id;
+
+ private Integer mediaId;
+
+ private String transportCode;
+
+ @ApiModelProperty(value = "1闂ㄥご 2浜や粯")
+ private Integer snapshotType;
+
+ private BigDecimal timestampSec;
+
+ private String filePath;
+
+ private BigDecimal confidence;
+
+ private String source;
+
+ private String modelVersion;
+
+ private Date createDate;
+
+ private Integer isdeleted;
+
+ @TableField(exist = false)
+ @ApiModelProperty(value = "FTP瀹屾暣璁块棶鍦板潃")
+ private String fileUrlFull;
+}
diff --git a/server/visits/dmvisit_service/src/main/java/com/doumee/dao/business/model/DeliveryMediaSnapshotFeedback.java b/server/visits/dmvisit_service/src/main/java/com/doumee/dao/business/model/DeliveryMediaSnapshotFeedback.java
new file mode 100644
index 0000000..72b3f52
--- /dev/null
+++ b/server/visits/dmvisit_service/src/main/java/com/doumee/dao/business/model/DeliveryMediaSnapshotFeedback.java
@@ -0,0 +1,31 @@
+package com.doumee.dao.business.model;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.util.Date;
+
+@Data
+@TableName("`delivery_media_snapshot_feedback`")
+public class DeliveryMediaSnapshotFeedback {
+
+ @TableId(type = IdType.AUTO)
+ private Integer id;
+
+ private Integer mediaId;
+
+ private Integer snapshotType;
+
+ private BigDecimal aiTimeSec;
+
+ private BigDecimal manualTimeSec;
+
+ private String modelVersion;
+
+ private Date createDate;
+
+ private Integer isdeleted;
+}
diff --git a/server/visits/dmvisit_service/src/main/java/com/doumee/service/business/DeliverySnapshotService.java b/server/visits/dmvisit_service/src/main/java/com/doumee/service/business/DeliverySnapshotService.java
new file mode 100644
index 0000000..420cb9b
--- /dev/null
+++ b/server/visits/dmvisit_service/src/main/java/com/doumee/service/business/DeliverySnapshotService.java
@@ -0,0 +1,17 @@
+package com.doumee.service.business;
+
+import com.doumee.dao.admin.request.DeliverySnapshotManualRequest;
+import com.doumee.dao.business.model.DeliveryMediaSnapshot;
+
+import java.util.List;
+
+public interface DeliverySnapshotService {
+
+ String submitAnalyze(Integer mediaId);
+
+ List<DeliveryMediaSnapshot> listByMediaId(Integer mediaId);
+
+ String saveManual(DeliverySnapshotManualRequest request);
+
+ void submitAnalyzeAsync(Integer mediaId);
+}
diff --git a/server/visits/dmvisit_service/src/main/java/com/doumee/service/business/collection/CollectionMediaConstants.java b/server/visits/dmvisit_service/src/main/java/com/doumee/service/business/collection/CollectionMediaConstants.java
new file mode 100644
index 0000000..0d773dd
--- /dev/null
+++ b/server/visits/dmvisit_service/src/main/java/com/doumee/service/business/collection/CollectionMediaConstants.java
@@ -0,0 +1,29 @@
+package com.doumee.service.business.collection;
+
+/**
+ * 閲囬泦绔欏獟浣�/蹇収涓氬姟甯搁噺锛堝畾涔夊湪 dmvisit_service锛岄伩鍏嶄緷璧栨湭瀹夎鐨� system_service 鏂扮増鏈級銆�
+ */
+public final class CollectionMediaConstants {
+
+ /** 濯掍綋涓嬭浇鐘舵�侊細涓嬭浇涓� */
+ public static final int DOWNLOAD_STATUS_DOWNLOADING = 3;
+
+ /** 濯掍綋蹇収鐘舵�侊細鏈垎鏋� */
+ public static final int SNAPSHOT_STATUS_NONE = 0;
+ /** 濯掍綋蹇収鐘舵�侊細鍒嗘瀽涓� */
+ public static final int SNAPSHOT_STATUS_PROCESSING = 1;
+ /** 濯掍綋蹇収鐘舵�侊細瀹屾垚 */
+ public static final int SNAPSHOT_STATUS_DONE = 2;
+ /** 濯掍綋蹇収鐘舵�侊細澶辫触 */
+ public static final int SNAPSHOT_STATUS_FAILED = 3;
+
+ /** 蹇収绫诲瀷锛氶棬澶� */
+ public static final int SNAPSHOT_TYPE_STOREFRONT = 1;
+ /** 蹇収绫诲瀷锛氫氦浠� */
+ public static final int SNAPSHOT_TYPE_HANDOVER = 2;
+
+ public static final String COLLECTION_SNAPSHOT_FOLDER = "COLLECTION_SNAPSHOT";
+
+ private CollectionMediaConstants() {
+ }
+}
diff --git a/server/visits/dmvisit_service/src/main/java/com/doumee/service/business/collection/MediaFrameUtil.java b/server/visits/dmvisit_service/src/main/java/com/doumee/service/business/collection/MediaFrameUtil.java
new file mode 100644
index 0000000..888bd18
--- /dev/null
+++ b/server/visits/dmvisit_service/src/main/java/com/doumee/service/business/collection/MediaFrameUtil.java
@@ -0,0 +1,142 @@
+package com.doumee.service.business.collection;
+
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Locale;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 浠庤棰戞寚瀹氭椂鍒绘埅鍙� JPEG 甯с��
+ */
+@Slf4j
+public final class MediaFrameUtil {
+
+ private static final long SNAPSHOT_TIMEOUT_MINUTES = 10;
+
+ private MediaFrameUtil() {
+ }
+
+ public static double probeDurationSec(String ffmpegPath, File source) {
+ if (source == null || !source.exists() || source.length() <= 0) {
+ return 0;
+ }
+ String ffprobe = resolveFfprobe(ffmpegPath);
+ List<String> command = Arrays.asList(
+ ffprobe,
+ "-v", "error",
+ "-show_entries", "format=duration",
+ "-of", "default=noprint_wrappers=1:nokey=1",
+ source.getAbsolutePath()
+ );
+ try {
+ ProcessBuilder builder = new ProcessBuilder(command);
+ builder.redirectErrorStream(true);
+ Process process = builder.start();
+ String output = readStream(process.getInputStream());
+ boolean finished = process.waitFor(2, TimeUnit.MINUTES);
+ if (!finished) {
+ process.destroyForcibly();
+ return 0;
+ }
+ if (process.exitValue() != 0 || StringUtils.isBlank(output)) {
+ return 0;
+ }
+ return Double.parseDouble(output.trim());
+ } catch (Exception e) {
+ log.warn("ffprobe 璇诲彇鏃堕暱澶辫触: {}", e.getMessage());
+ return 0;
+ }
+ }
+
+ public static boolean extractFrame(String ffmpegPath, File source, File target, double second) {
+ if (source == null || !source.exists() || source.length() <= 0) {
+ return false;
+ }
+ if (second < 0) {
+ second = 0;
+ }
+ String ffmpeg = resolveExecutable(ffmpegPath, "ffmpeg");
+ String sec = formatSeconds(second);
+ List<String> command = Arrays.asList(
+ ffmpeg,
+ "-y",
+ "-ss", sec,
+ "-i", source.getAbsolutePath(),
+ "-frames:v", "1",
+ "-q:v", "2",
+ target.getAbsolutePath()
+ );
+ try {
+ ProcessBuilder builder = new ProcessBuilder(command);
+ builder.redirectErrorStream(true);
+ Process process = builder.start();
+ readStream(process.getInputStream());
+ boolean finished = process.waitFor(SNAPSHOT_TIMEOUT_MINUTES, TimeUnit.MINUTES);
+ if (!finished) {
+ process.destroyForcibly();
+ log.error("FFmpeg 鎴抚瓒呮椂 source={} sec={}", source.getAbsolutePath(), sec);
+ return false;
+ }
+ if (process.exitValue() != 0) {
+ log.error("FFmpeg 鎴抚澶辫触 exitCode={} source={} sec={}", process.exitValue(), source.getAbsolutePath(), sec);
+ return false;
+ }
+ return target.exists() && target.length() > 0;
+ } catch (Exception e) {
+ log.error("FFmpeg 鎴抚寮傚父 source={} sec={}: {}", source.getAbsolutePath(), sec, e.getMessage());
+ return false;
+ }
+ }
+
+ private static String formatSeconds(double second) {
+ if (second <= 0) {
+ return "0";
+ }
+ return String.format(Locale.US, "%.3f", second);
+ }
+
+ private static String readStream(java.io.InputStream in) throws Exception {
+ StringBuilder sb = new StringBuilder();
+ try (BufferedReader reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) {
+ String line;
+ while ((line = reader.readLine()) != null) {
+ sb.append(line).append('\n');
+ }
+ }
+ return sb.toString();
+ }
+
+ private static String resolveExecutable(String configuredPath, String defaultName) {
+ if (StringUtils.isNotBlank(configuredPath)) {
+ return configuredPath.trim();
+ }
+ return defaultName;
+ }
+
+ private static String resolveFfprobe(String ffmpegPath) {
+ if (StringUtils.isBlank(ffmpegPath)) {
+ return "ffprobe";
+ }
+ String path = ffmpegPath.trim();
+ if (path.contains("/") || path.contains("\\")) {
+ int slash = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'));
+ if (slash >= 0) {
+ String dir = path.substring(0, slash + 1);
+ String name = path.substring(slash + 1);
+ if (name.toLowerCase().endsWith(".exe")) {
+ return dir + name.replaceAll("(?i)ffmpeg\\.exe$", "ffprobe.exe");
+ }
+ return dir + name.replaceAll("(?i)ffmpeg$", "ffprobe");
+ }
+ return path.replaceAll("(?i)ffmpeg", "ffprobe");
+ }
+ return "ffprobe";
+ }
+}
diff --git a/server/visits/dmvisit_service/src/main/java/com/doumee/service/business/impl/collection/CollectionMediaSyncServiceImpl.java b/server/visits/dmvisit_service/src/main/java/com/doumee/service/business/impl/collection/CollectionMediaSyncServiceImpl.java
index 8edca1c..9a3336f 100644
--- a/server/visits/dmvisit_service/src/main/java/com/doumee/service/business/impl/collection/CollectionMediaSyncServiceImpl.java
+++ b/server/visits/dmvisit_service/src/main/java/com/doumee/service/business/impl/collection/CollectionMediaSyncServiceImpl.java
@@ -19,12 +19,13 @@
import com.doumee.dao.business.model.CollectionMedia;
import com.doumee.dao.business.model.CollectionStation;
import com.doumee.service.business.CollectionMediaSyncService;
+import com.doumee.service.business.DeliverySnapshotService;
+import com.doumee.service.business.collection.CollectionMediaConstants;
import com.doumee.service.business.third.model.PageData;
import com.doumee.service.business.third.model.PageWrap;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
@@ -54,6 +55,8 @@
private CollectionMediaMapper collectionMediaMapper;
@Autowired
private CollectionStationMapper collectionStationMapper;
+ @Autowired
+ private DeliverySnapshotService deliverySnapshotService;
@Autowired
private SystemDictDataBiz systemDictDataBiz;
@Resource(name = "asyncExecutor")
@@ -105,13 +108,19 @@
if (station == null || Constants.equalsInteger(station.getIsdeleted(), Constants.ONE)) {
throw new BusinessException(ResponseStatus.DATA_EMPTY);
}
- List<MediaItemDTO> items = isapiClient.searchMediaAll(station, startTime, endTime, trackId, IsapiConstants.MAX_PAGE_RESULTS);
+ List<MediaItemDTO> items = isapiClient.searchMediaAll(station, startTime, endTime,
+ resolveSyncTrackId(trackId), IsapiConstants.MAX_PAGE_RESULTS);
log.info("閲囬泦绔欏獟浣撴绱� stationId={} ip={} range={}~{} track={} found={}",
- stationId, station.getIp(), startTime, endTime, trackId, items.size());
+ stationId, station.getIp(), startTime, endTime, resolveSyncTrackId(trackId), items.size());
int count = 0;
+ int skipped = 0;
Date now = new Date();
for (MediaItemDTO item : items) {
if (StringUtils.isBlank(item.getFileIndex())) {
+ continue;
+ }
+ if (!isSyncableMp4Video(item)) {
+ skipped++;
continue;
}
Long exists = collectionMediaMapper.selectCount(new QueryWrapper<CollectionMedia>().lambda()
@@ -140,7 +149,54 @@
collectionMediaMapper.insert(media);
count++;
}
+ if (skipped > 0) {
+ log.info("閲囬泦绔欏獟浣撶储寮曡烦杩囬潪MP4瑙嗛 stationId={} skipped={}", stationId, skipped);
+ }
return count;
+ }
+
+ /** 鍚屾绱㈠紩浠呮绱富鐮佹祦褰曞儚 track锛堥粯璁� 101锛夛紝涓嶅寘鍚姄鍥� track 103 */
+ private String resolveSyncTrackId(String trackId) {
+ if (StringUtils.isNotBlank(trackId)) {
+ String val = trackId.trim();
+ if (!"auto".equalsIgnoreCase(val) && !"*".equals(val) && !"0".equals(val)) {
+ return val;
+ }
+ }
+ return IsapiConstants.DEFAULT_TRACK_ID;
+ }
+
+ /** 鍚屾鍏ュ簱锛氫粎 MP4 瑙嗛锛堟墿灞曞悕 .mp4锛屼笖闈炲浘鐗�/闊抽绫诲瀷锛� */
+ private static boolean isSyncableMp4Video(MediaItemDTO item) {
+ if (item.getMediaType() != null && item.getMediaType() != 0) {
+ return false;
+ }
+ String trackId = StringUtils.defaultString(item.getTrackId());
+ if (trackId.endsWith("3")) {
+ return false;
+ }
+ return isMp4FileName(resolveItemFileName(item));
+ }
+
+ private static String resolveItemFileName(MediaItemDTO item) {
+ if (StringUtils.isNotBlank(item.getFileName())) {
+ return item.getFileName();
+ }
+ String uri = item.getPlaybackUri();
+ if (StringUtils.isBlank(uri)) {
+ return null;
+ }
+ java.util.regex.Matcher m = java.util.regex.Pattern
+ .compile("[?&](?:name|filename|fileName)=([^&\\s]+)", java.util.regex.Pattern.CASE_INSENSITIVE)
+ .matcher(uri.replace("&", "&"));
+ if (m.find()) {
+ try {
+ return java.net.URLDecoder.decode(m.group(1), StandardCharsets.UTF_8.name());
+ } catch (Exception e) {
+ return m.group(1);
+ }
+ }
+ return null;
}
@Override
@@ -152,7 +208,7 @@
if (Constants.equalsInteger(media.getDownloadStatus(), Constants.ONE)) {
throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "鏂囦欢宸蹭笅杞斤紝鏃犻渶閲嶅涓嬭浇");
}
- if (Constants.equalsInteger(media.getDownloadStatus(), Constants.COLLECTION_MEDIA_DOWNLOADING)) {
+ if (Constants.equalsInteger(media.getDownloadStatus(), CollectionMediaConstants.DOWNLOAD_STATUS_DOWNLOADING)) {
throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "鏂囦欢姝e湪涓嬭浇涓紝璇风◢鍚庡埛鏂�");
}
CollectionStation station = collectionStationMapper.selectById(media.getStationId());
@@ -160,7 +216,7 @@
throw new BusinessException(ResponseStatus.DATA_EMPTY);
}
CollectionMedia downloading = new CollectionMedia();
- downloading.setDownloadStatus(Constants.COLLECTION_MEDIA_DOWNLOADING);
+ downloading.setDownloadStatus(CollectionMediaConstants.DOWNLOAD_STATUS_DOWNLOADING);
int updated = collectionMediaMapper.update(downloading, new QueryWrapper<CollectionMedia>().lambda()
.eq(CollectionMedia::getId, mediaId)
.in(CollectionMedia::getDownloadStatus, Constants.ZERO, 2));
@@ -195,6 +251,7 @@
update.setDownloadTime(new Date());
collectionMediaMapper.updateById(update);
log.info("寮傛涓嬭浇鎴愬姛 mediaId={} path={}", mediaId, path);
+ deliverySnapshotService.submitAnalyzeAsync(mediaId);
} catch (Exception e) {
markDownloadFailed(mediaId);
log.error("寮傛涓嬭浇寮傚父 mediaId={}: {}", mediaId, e.getMessage(), e);
diff --git a/server/visits/dmvisit_service/src/main/java/com/doumee/service/business/impl/collection/DeliverySnapshotServiceImpl.java b/server/visits/dmvisit_service/src/main/java/com/doumee/service/business/impl/collection/DeliverySnapshotServiceImpl.java
new file mode 100644
index 0000000..7d814e1
--- /dev/null
+++ b/server/visits/dmvisit_service/src/main/java/com/doumee/service/business/impl/collection/DeliverySnapshotServiceImpl.java
@@ -0,0 +1,396 @@
+package com.doumee.service.business.impl.collection;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
+import com.doumee.biz.system.SystemDictDataBiz;
+import com.doumee.core.constants.ResponseStatus;
+import com.doumee.core.exception.BusinessException;
+import com.doumee.core.utils.Constants;
+import com.doumee.core.utils.DateUtil;
+import com.doumee.core.utils.FtpUtil;
+import com.doumee.service.business.collection.CollectionMediaConstants;
+import com.doumee.service.business.collection.MediaFrameUtil;
+import com.doumee.dao.admin.request.DeliverySnapshotManualRequest;
+import com.doumee.dao.business.CollectionMediaMapper;
+import com.doumee.dao.business.DeliveryMediaSnapshotFeedbackMapper;
+import com.doumee.dao.business.DeliveryMediaSnapshotMapper;
+import com.doumee.dao.business.model.CollectionMedia;
+import com.doumee.dao.business.model.DeliveryMediaSnapshot;
+import com.doumee.dao.business.model.DeliveryMediaSnapshotFeedback;
+import com.doumee.service.business.DeliverySnapshotService;
+import com.doumee.service.business.snapshot.SnapshotAnalyzeRequest;
+import com.doumee.service.business.snapshot.SnapshotAnalyzeResponse;
+import com.doumee.service.business.snapshot.SnapshotInferClient;
+import com.doumee.service.business.snapshot.SnapshotInferProperties;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.util.Date;
+import java.util.List;
+import java.util.UUID;
+import java.util.concurrent.Executor;
+
+@Slf4j
+@Service
+public class DeliverySnapshotServiceImpl implements DeliverySnapshotService {
+
+ @Autowired
+ private CollectionMediaMapper collectionMediaMapper;
+ @Autowired
+ private DeliveryMediaSnapshotMapper deliveryMediaSnapshotMapper;
+ @Autowired
+ private DeliveryMediaSnapshotFeedbackMapper deliveryMediaSnapshotFeedbackMapper;
+ @Autowired
+ private SystemDictDataBiz systemDictDataBiz;
+ @Autowired
+ private SnapshotInferClient snapshotInferClient;
+ @Autowired
+ private SnapshotInferProperties snapshotInferProperties;
+ @Resource(name = "asyncExecutor")
+ private Executor asyncExecutor;
+
+ @Override
+ public String submitAnalyze(Integer mediaId) {
+ CollectionMedia media = requireDownloadedMedia(mediaId);
+ if (Constants.equalsInteger(media.getSnapshotStatus(), CollectionMediaConstants.SNAPSHOT_STATUS_PROCESSING)) {
+ throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "蹇収鍒嗘瀽杩涜涓紝璇风◢鍚庡埛鏂�");
+ }
+ markSnapshotProcessing(mediaId);
+ asyncExecutor.execute(() -> executeAnalyze(mediaId));
+ return "宸叉彁浜ゅ揩鐓у垎鏋愪换鍔★紝璇风◢鍚庡埛鏂版煡鐪�";
+ }
+
+ @Override
+ public void submitAnalyzeAsync(Integer mediaId) {
+ if (!snapshotInferProperties.isAutoOnDownload()) {
+ return;
+ }
+ try {
+ CollectionMedia media = collectionMediaMapper.selectById(mediaId);
+ if (media == null || Constants.equalsInteger(media.getIsdeleted(), Constants.ONE)) {
+ return;
+ }
+ if (!Constants.equalsInteger(media.getDownloadStatus(), Constants.ONE)
+ || StringUtils.isBlank(media.getFilePathLocal())) {
+ return;
+ }
+ if (Constants.equalsInteger(media.getSnapshotStatus(), CollectionMediaConstants.SNAPSHOT_STATUS_PROCESSING)
+ || Constants.equalsInteger(media.getSnapshotStatus(), CollectionMediaConstants.SNAPSHOT_STATUS_DONE)) {
+ return;
+ }
+ if (media.getMediaType() != null && media.getMediaType() != 0) {
+ return;
+ }
+ markSnapshotProcessing(mediaId);
+ asyncExecutor.execute(() -> executeAnalyze(mediaId));
+ } catch (Exception e) {
+ log.warn("鑷姩鎻愪氦蹇収鍒嗘瀽澶辫触 mediaId={}: {}", mediaId, e.getMessage());
+ }
+ }
+
+ @Override
+ public List<DeliveryMediaSnapshot> listByMediaId(Integer mediaId) {
+ List<DeliveryMediaSnapshot> list = deliveryMediaSnapshotMapper.selectList(new QueryWrapper<DeliveryMediaSnapshot>().lambda()
+ .eq(DeliveryMediaSnapshot::getMediaId, mediaId)
+ .eq(DeliveryMediaSnapshot::getIsdeleted, Constants.ZERO)
+ .orderByAsc(DeliveryMediaSnapshot::getSnapshotType));
+ list.forEach(this::fillSnapshotUrl);
+ return list;
+ }
+
+ @Override
+ public String saveManual(DeliverySnapshotManualRequest request) {
+ if (request == null || request.getMediaId() == null || request.getSnapshotType() == null
+ || request.getTimestampSec() == null) {
+ throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "鍙傛暟涓嶅畬鏁�");
+ }
+ if (!Constants.equalsInteger(request.getSnapshotType(), CollectionMediaConstants.SNAPSHOT_TYPE_STOREFRONT)
+ && !Constants.equalsInteger(request.getSnapshotType(), CollectionMediaConstants.SNAPSHOT_TYPE_HANDOVER)) {
+ throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "蹇収绫诲瀷鏃犳晥");
+ }
+ CollectionMedia media = requireDownloadedMedia(request.getMediaId());
+ File videoFile = null;
+ File frameFile = null;
+ try {
+ videoFile = downloadMediaToTemp(media);
+ frameFile = File.createTempFile("hk_snapshot_", ".jpg");
+ if (!MediaFrameUtil.extractFrame(getFfmpegPath(), videoFile, frameFile, request.getTimestampSec().doubleValue())) {
+ throw new BusinessException(ResponseStatus.SERVER_ERROR.getCode(), "鎴抚澶辫触");
+ }
+ String relativePath = uploadSnapshot(frameFile, media.getId(), request.getSnapshotType());
+ saveFeedback(media.getId(), request.getSnapshotType(), request.getTimestampSec());
+ upsertSnapshot(media.getId(), request.getSnapshotType(), request.getTimestampSec(),
+ relativePath, null, "manual", null);
+ CollectionMedia update = new CollectionMedia();
+ update.setId(media.getId());
+ update.setSnapshotStatus(CollectionMediaConstants.SNAPSHOT_STATUS_DONE);
+ update.setSnapshotTime(new Date());
+ update.setSnapshotMessage(null);
+ collectionMediaMapper.updateById(update);
+ return "淇濆瓨鎴愬姛";
+ } catch (BusinessException e) {
+ throw e;
+ } catch (Exception e) {
+ log.error("鎵嬪姩淇濆瓨蹇収澶辫触 mediaId={}: {}", request.getMediaId(), e.getMessage(), e);
+ throw new BusinessException(ResponseStatus.SERVER_ERROR.getCode(), "淇濆瓨蹇収澶辫触");
+ } finally {
+ deleteQuietly(videoFile);
+ deleteQuietly(frameFile);
+ }
+ }
+
+ private void executeAnalyze(Integer mediaId) {
+ CollectionMedia media = collectionMediaMapper.selectById(mediaId);
+ if (media == null || Constants.equalsInteger(media.getIsdeleted(), Constants.ONE)) {
+ return;
+ }
+ File videoFile = null;
+ File storefrontFrame = null;
+ File handoverFrame = null;
+ try {
+ videoFile = downloadMediaToTemp(media);
+ double duration = MediaFrameUtil.probeDurationSec(getFfmpegPath(), videoFile);
+ if (duration <= 0) {
+ duration = estimateDuration(media);
+ }
+ SnapshotAnalyzeRequest analyzeRequest = new SnapshotAnalyzeRequest();
+ analyzeRequest.setMediaId(mediaId);
+ analyzeRequest.setVideoUrl(buildVideoUrl(media));
+ analyzeRequest.setSampleFps(snapshotInferProperties.getSampleFps());
+ analyzeRequest.setEnableAsr(snapshotInferProperties.isEnableAsr());
+ analyzeRequest.setDurationSec(duration);
+ SnapshotAnalyzeResponse analyzeResponse = snapshotInferClient.analyze(analyzeRequest);
+ if (Boolean.FALSE.equals(analyzeResponse.getSuccess())) {
+ throw new BusinessException(ResponseStatus.SERVER_ERROR.getCode(),
+ StringUtils.defaultIfBlank(analyzeResponse.getMessage(), "蹇収鎺ㄧ悊澶辫触"));
+ }
+
+ double storefrontSec = analyzeResponse.getStorefront().getTimeSec();
+ double handoverSec = analyzeResponse.getHandover().getTimeSec();
+ if (handoverSec <= storefrontSec) {
+ handoverSec = Math.min(duration > 0 ? duration - 1 : storefrontSec + 60, storefrontSec + 60);
+ }
+
+ storefrontFrame = File.createTempFile("hk_storefront_", ".jpg");
+ handoverFrame = File.createTempFile("hk_handover_", ".jpg");
+ if (!MediaFrameUtil.extractFrame(getFfmpegPath(), videoFile, storefrontFrame, storefrontSec)) {
+ throw new BusinessException(ResponseStatus.SERVER_ERROR.getCode(), "闂ㄥご鍥炬埅甯уけ璐�");
+ }
+ if (!MediaFrameUtil.extractFrame(getFfmpegPath(), videoFile, handoverFrame, handoverSec)) {
+ throw new BusinessException(ResponseStatus.SERVER_ERROR.getCode(), "浜や粯鍥炬埅甯уけ璐�");
+ }
+
+ String storefrontPath = uploadSnapshot(storefrontFrame, mediaId, CollectionMediaConstants.SNAPSHOT_TYPE_STOREFRONT);
+ String handoverPath = uploadSnapshot(handoverFrame, mediaId, CollectionMediaConstants.SNAPSHOT_TYPE_HANDOVER);
+
+ upsertSnapshot(mediaId, CollectionMediaConstants.SNAPSHOT_TYPE_STOREFRONT,
+ BigDecimal.valueOf(storefrontSec).setScale(2, RoundingMode.HALF_UP),
+ storefrontPath,
+ analyzeResponse.getStorefront().getConfidence(),
+ StringUtils.defaultIfBlank(analyzeResponse.getStorefront().getSource(), "ai"),
+ analyzeResponse.getModelVersion());
+ upsertSnapshot(mediaId, CollectionMediaConstants.SNAPSHOT_TYPE_HANDOVER,
+ BigDecimal.valueOf(handoverSec).setScale(2, RoundingMode.HALF_UP),
+ handoverPath,
+ analyzeResponse.getHandover().getConfidence(),
+ StringUtils.defaultIfBlank(analyzeResponse.getHandover().getSource(), "ai"),
+ analyzeResponse.getModelVersion());
+
+ CollectionMedia done = new CollectionMedia();
+ done.setId(mediaId);
+ done.setSnapshotStatus(CollectionMediaConstants.SNAPSHOT_STATUS_DONE);
+ done.setSnapshotTime(new Date());
+ done.setSnapshotMessage(null);
+ collectionMediaMapper.updateById(done);
+ log.info("蹇収鍒嗘瀽瀹屾垚 mediaId={} storefrontSec={} handoverSec={}", mediaId, storefrontSec, handoverSec);
+ } catch (Exception e) {
+ log.error("蹇収鍒嗘瀽澶辫触 mediaId={}: {}", mediaId, e.getMessage(), e);
+ CollectionMedia fail = new CollectionMedia();
+ fail.setId(mediaId);
+ fail.setSnapshotStatus(CollectionMediaConstants.SNAPSHOT_STATUS_FAILED);
+ fail.setSnapshotMessage(StringUtils.left(e.getMessage(), 500));
+ collectionMediaMapper.updateById(fail);
+ } finally {
+ deleteQuietly(videoFile);
+ deleteQuietly(storefrontFrame);
+ deleteQuietly(handoverFrame);
+ }
+ }
+
+ private void markSnapshotProcessing(Integer mediaId) {
+ CollectionMedia processing = new CollectionMedia();
+ processing.setId(mediaId);
+ processing.setSnapshotStatus(CollectionMediaConstants.SNAPSHOT_STATUS_PROCESSING);
+ processing.setSnapshotMessage(null);
+ collectionMediaMapper.updateById(processing);
+ }
+
+ private CollectionMedia requireDownloadedMedia(Integer mediaId) {
+ CollectionMedia media = collectionMediaMapper.selectById(mediaId);
+ if (media == null || Constants.equalsInteger(media.getIsdeleted(), Constants.ONE)) {
+ throw new BusinessException(ResponseStatus.DATA_EMPTY);
+ }
+ if (!Constants.equalsInteger(media.getDownloadStatus(), Constants.ONE)
+ || StringUtils.isBlank(media.getFilePathLocal())) {
+ throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "璇峰厛涓嬭浇濯掍綋鏂囦欢");
+ }
+ return media;
+ }
+
+ private File downloadMediaToTemp(CollectionMedia media) throws IOException {
+ FtpUtil ftp = createFtpClient();
+ File temp = File.createTempFile("hk_media_snap_", resolveSuffix(media));
+ String remote = getMediaFolder() + media.getFilePathLocal();
+ String local = ftp.download(remote, temp.getAbsolutePath());
+ if (StringUtils.isBlank(local)) {
+ ftp.disconnect();
+ throw new BusinessException(ResponseStatus.SERVER_ERROR.getCode(), "涓嬭浇濯掍綋鏂囦欢澶辫触");
+ }
+ ftp.disconnect();
+ return temp;
+ }
+
+ private String uploadSnapshot(File frameFile, Integer mediaId, int snapshotType) throws IOException {
+ FtpUtil ftp = createFtpClient();
+ try {
+ String folder = getSnapshotFolder();
+ String suffix = snapshotType == CollectionMediaConstants.SNAPSHOT_TYPE_STOREFRONT ? "_storefront.jpg" : "_handover.jpg";
+ String relative = DateUtil.getNowShortDate() + "/" + mediaId + suffix;
+ String remote = folder + relative;
+ try (FileInputStream in = new FileInputStream(frameFile)) {
+ if (!ftp.uploadInputstream(in, remote)) {
+ throw new BusinessException(ResponseStatus.SERVER_ERROR.getCode(), "涓婁紶蹇収澶辫触");
+ }
+ }
+ return relative;
+ } finally {
+ ftp.disconnect();
+ }
+ }
+
+ private void upsertSnapshot(Integer mediaId, int snapshotType, BigDecimal timestampSec, String filePath,
+ Double confidence, String source, String modelVersion) {
+ deliveryMediaSnapshotMapper.update(null, new UpdateWrapper<DeliveryMediaSnapshot>().lambda()
+ .eq(DeliveryMediaSnapshot::getMediaId, mediaId)
+ .eq(DeliveryMediaSnapshot::getSnapshotType, snapshotType)
+ .set(DeliveryMediaSnapshot::getIsdeleted, Constants.ONE));
+ DeliveryMediaSnapshot row = new DeliveryMediaSnapshot();
+ row.setMediaId(mediaId);
+ row.setSnapshotType(snapshotType);
+ row.setTimestampSec(timestampSec);
+ row.setFilePath(filePath);
+ if (confidence != null) {
+ row.setConfidence(BigDecimal.valueOf(confidence).setScale(4, RoundingMode.HALF_UP));
+ }
+ row.setSource(source);
+ row.setModelVersion(modelVersion);
+ row.setCreateDate(new Date());
+ row.setIsdeleted(Constants.ZERO);
+ deliveryMediaSnapshotMapper.insert(row);
+ }
+
+ private void fillSnapshotUrl(DeliveryMediaSnapshot snapshot) {
+ if (StringUtils.isBlank(snapshot.getFilePath())) {
+ return;
+ }
+ try {
+ String prefix = systemDictDataBiz.queryByCode(Constants.FTP, Constants.FTP_RESOURCE_PATH).getCode();
+ snapshot.setFileUrlFull(prefix + getSnapshotFolder() + snapshot.getFilePath());
+ } catch (Exception e) {
+ log.warn("鏋勫缓蹇収URL澶辫触 id={}: {}", snapshot.getId(), e.getMessage());
+ }
+ }
+
+ private String buildVideoUrl(CollectionMedia media) {
+ try {
+ String prefix = systemDictDataBiz.queryByCode(Constants.FTP, Constants.FTP_RESOURCE_PATH).getCode();
+ return prefix + getMediaFolder() + media.getFilePathLocal();
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ private double estimateDuration(CollectionMedia media) {
+ if (media.getStartTime() != null && media.getEndTime() != null) {
+ long ms = media.getEndTime().getTime() - media.getStartTime().getTime();
+ if (ms > 0) {
+ return ms / 1000.0;
+ }
+ }
+ return 1200.0;
+ }
+
+ private FtpUtil createFtpClient() throws IOException {
+ return new FtpUtil(
+ systemDictDataBiz.queryByCode(Constants.FTP, Constants.FTP_HOST).getCode(),
+ Integer.parseInt(systemDictDataBiz.queryByCode(Constants.FTP, Constants.FTP_PORT).getCode()),
+ systemDictDataBiz.queryByCode(Constants.FTP, Constants.FTP_USERNAME).getCode(),
+ systemDictDataBiz.queryByCode(Constants.FTP, Constants.FTP_PWD).getCode());
+ }
+
+ private String getMediaFolder() {
+ try {
+ return systemDictDataBiz.queryByCode(Constants.FTP, Constants.COLLECTION_MEDIA_FOLDER).getCode();
+ } catch (Exception e) {
+ return "/collection_media/";
+ }
+ }
+
+ private String getSnapshotFolder() {
+ try {
+ return systemDictDataBiz.queryByCode(Constants.FTP, CollectionMediaConstants.COLLECTION_SNAPSHOT_FOLDER).getCode();
+ } catch (Exception e) {
+ return "/collection_snapshot/";
+ }
+ }
+
+ private String getFfmpegPath() {
+ try {
+ return systemDictDataBiz.queryByCode(Constants.CS_PARAM, Constants.CS_FFMPEG_PATH).getCode();
+ } catch (Exception e) {
+ return "ffmpeg";
+ }
+ }
+
+ private String resolveSuffix(CollectionMedia media) {
+ if (StringUtils.isNotBlank(media.getFileName()) && media.getFileName().contains(".")) {
+ return media.getFileName().substring(media.getFileName().lastIndexOf('.')).toLowerCase();
+ }
+ return ".mp4";
+ }
+
+ private void saveFeedback(Integer mediaId, int snapshotType, BigDecimal manualTimeSec) {
+ DeliveryMediaSnapshot existing = deliveryMediaSnapshotMapper.selectOne(new QueryWrapper<DeliveryMediaSnapshot>().lambda()
+ .eq(DeliveryMediaSnapshot::getMediaId, mediaId)
+ .eq(DeliveryMediaSnapshot::getSnapshotType, snapshotType)
+ .eq(DeliveryMediaSnapshot::getIsdeleted, Constants.ZERO)
+ .orderByDesc(DeliveryMediaSnapshot::getId)
+ .last("LIMIT 1"));
+ DeliveryMediaSnapshotFeedback feedback = new DeliveryMediaSnapshotFeedback();
+ feedback.setMediaId(mediaId);
+ feedback.setSnapshotType(snapshotType);
+ if (existing != null && existing.getTimestampSec() != null) {
+ feedback.setAiTimeSec(existing.getTimestampSec());
+ feedback.setModelVersion(existing.getModelVersion());
+ }
+ feedback.setManualTimeSec(manualTimeSec);
+ feedback.setCreateDate(new Date());
+ feedback.setIsdeleted(Constants.ZERO);
+ deliveryMediaSnapshotFeedbackMapper.insert(feedback);
+ }
+
+ private void deleteQuietly(File file) {
+ if (file != null && file.exists() && !file.delete()) {
+ log.warn("涓存椂鏂囦欢鍒犻櫎澶辫触: {}", file.getAbsolutePath());
+ }
+ }
+
+}
diff --git a/server/visits/dmvisit_service/src/main/java/com/doumee/service/business/snapshot/SnapshotAnalyzeRequest.java b/server/visits/dmvisit_service/src/main/java/com/doumee/service/business/snapshot/SnapshotAnalyzeRequest.java
new file mode 100644
index 0000000..206043f
--- /dev/null
+++ b/server/visits/dmvisit_service/src/main/java/com/doumee/service/business/snapshot/SnapshotAnalyzeRequest.java
@@ -0,0 +1,23 @@
+package com.doumee.service.business.snapshot;
+
+import lombok.Data;
+
+import java.util.Arrays;
+import java.util.List;
+
+@Data
+public class SnapshotAnalyzeRequest {
+
+ private Integer mediaId;
+ private String videoUrl;
+ private Double sampleFps;
+ private Boolean enableAsr;
+ private Double durationSec;
+ private KeywordConfig keywords = new KeywordConfig();
+
+ @Data
+ public static class KeywordConfig {
+ private List<String> storefront = Arrays.asList("鍒板簵", "鍒拌揪", "闂ㄥご");
+ private List<String> handover = Arrays.asList("浜や粯", "浜よ揣", "绛炬敹");
+ }
+}
diff --git a/server/visits/dmvisit_service/src/main/java/com/doumee/service/business/snapshot/SnapshotAnalyzeResponse.java b/server/visits/dmvisit_service/src/main/java/com/doumee/service/business/snapshot/SnapshotAnalyzeResponse.java
new file mode 100644
index 0000000..0eadae2
--- /dev/null
+++ b/server/visits/dmvisit_service/src/main/java/com/doumee/service/business/snapshot/SnapshotAnalyzeResponse.java
@@ -0,0 +1,62 @@
+package com.doumee.service.business.snapshot;
+
+
+
+import lombok.Data;
+
+
+
+import java.util.ArrayList;
+
+import java.util.List;
+
+
+
+@Data
+
+public class SnapshotAnalyzeResponse {
+
+
+
+ private Boolean success;
+
+ private String modelVersion;
+
+ private Double durationSec;
+
+ private SnapshotHit storefront;
+
+ private SnapshotHit handover;
+
+ private String message;
+
+ private List<AsrHit> asrHits = new ArrayList<>();
+
+
+
+ @Data
+
+ public static class SnapshotHit {
+
+ private Double timeSec;
+
+ private Double confidence;
+
+ private String source;
+
+ }
+
+
+
+ @Data
+
+ public static class AsrHit {
+
+ private String keyword;
+
+ private Double timeSec;
+
+ }
+
+}
+
diff --git a/server/visits/dmvisit_service/src/main/java/com/doumee/service/business/snapshot/SnapshotInferClient.java b/server/visits/dmvisit_service/src/main/java/com/doumee/service/business/snapshot/SnapshotInferClient.java
new file mode 100644
index 0000000..c16cce9
--- /dev/null
+++ b/server/visits/dmvisit_service/src/main/java/com/doumee/service/business/snapshot/SnapshotInferClient.java
@@ -0,0 +1,122 @@
+package com.doumee.service.business.snapshot;
+
+import cn.hutool.http.HttpRequest;
+import cn.hutool.http.HttpResponse;
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+@Slf4j
+@Component
+public class SnapshotInferClient {
+
+ @Autowired
+ private SnapshotInferProperties properties;
+
+ public boolean healthCheck() {
+ if (StringUtils.isBlank(properties.getBaseUrl())) {
+ return false;
+ }
+ String url = normalizeBaseUrl() + "/health";
+ try {
+ HttpResponse response = HttpRequest.get(url)
+ .timeout(properties.getConnectTimeoutMs())
+ .execute();
+ if (response.getStatus() != 200) {
+ return false;
+ }
+ JSONObject json = JSON.parseObject(response.body());
+ return json != null && "ok".equalsIgnoreCase(json.getString("status"));
+ } catch (Exception e) {
+ log.warn("snapshot-infer health 澶辫触: {}", e.getMessage());
+ return false;
+ }
+ }
+
+ public SnapshotAnalyzeResponse analyze(SnapshotAnalyzeRequest request) {
+ if (StringUtils.isBlank(properties.getBaseUrl())) {
+ return resolveFailure(request, "snapshot.infer.base-url 鏈厤缃�");
+ }
+ String url = normalizeBaseUrl() + "/analyze";
+ String payload = JSON.toJSONString(request);
+ try {
+ HttpResponse response = HttpRequest.post(url)
+ .body(payload)
+ .contentType("application/json")
+ .setConnectionTimeout(properties.getConnectTimeoutMs())
+ .timeout(properties.getReadTimeoutMs())
+ .execute();
+ String body = response.body();
+ if (response.getStatus() != 200) {
+ log.warn("snapshot-infer analyze HTTP {} body={}", response.getStatus(), body);
+ return resolveFailure(request, "鎺ㄧ悊鏈嶅姟 HTTP " + response.getStatus());
+ }
+ SnapshotAnalyzeResponse parsed = JSON.parseObject(body, SnapshotAnalyzeResponse.class);
+ if (parsed == null) {
+ return resolveFailure(request, "鎺ㄧ悊鍝嶅簲涓虹┖");
+ }
+ if (Boolean.FALSE.equals(parsed.getSuccess())) {
+ String msg = StringUtils.defaultIfBlank(parsed.getMessage(), "鎺ㄧ悊鏈娴嬪埌鏈夋晥鏃跺埢");
+ log.warn("snapshot-infer analyze 澶辫触: {}", msg);
+ return resolveFailure(request, msg);
+ }
+ if (parsed.getStorefront() == null || parsed.getHandover() == null) {
+ return resolveFailure(request, "鎺ㄧ悊鍝嶅簲缂哄皯闂ㄥご鎴栦氦浠樻椂鍒�");
+ }
+ if (parsed.getSuccess() == null) {
+ parsed.setSuccess(true);
+ }
+ if (parsed.getAsrHits() == null) {
+ parsed.setAsrHits(new java.util.ArrayList<>());
+ }
+ return parsed;
+ } catch (Exception e) {
+ log.warn("snapshot-infer analyze 寮傚父: {}", e.getMessage());
+ return resolveFailure(request, e.getMessage());
+ }
+ }
+
+ private SnapshotAnalyzeResponse resolveFailure(SnapshotAnalyzeRequest request, String message) {
+ if (properties.isFailOpenMock()) {
+ log.warn("fail-open-mock 鍚敤锛屼娇鐢ㄦ湰鍦� mock: {}", message);
+ return buildLocalMock(request);
+ }
+ SnapshotAnalyzeResponse fail = new SnapshotAnalyzeResponse();
+ fail.setSuccess(false);
+ fail.setMessage(message);
+ fail.setModelVersion("unavailable");
+ return fail;
+ }
+
+ private SnapshotAnalyzeResponse buildLocalMock(SnapshotAnalyzeRequest request) {
+ double duration = request.getDurationSec() != null && request.getDurationSec() > 0
+ ? request.getDurationSec() : 1200.0;
+ double t1 = Math.round(duration * 0.25 * 100.0) / 100.0;
+ double t2 = Math.round(duration * 0.75 * 100.0) / 100.0;
+ if (t2 <= t1) {
+ t2 = t1 + 60.0;
+ }
+ SnapshotAnalyzeResponse response = new SnapshotAnalyzeResponse();
+ response.setSuccess(true);
+ response.setModelVersion("local-mock");
+ response.setDurationSec(duration);
+ SnapshotAnalyzeResponse.SnapshotHit storefront = new SnapshotAnalyzeResponse.SnapshotHit();
+ storefront.setTimeSec(t1);
+ storefront.setConfidence(0.5);
+ storefront.setSource("local-mock");
+ SnapshotAnalyzeResponse.SnapshotHit handover = new SnapshotAnalyzeResponse.SnapshotHit();
+ handover.setTimeSec(t2);
+ handover.setConfidence(0.5);
+ handover.setSource("local-mock");
+ response.setStorefront(storefront);
+ response.setHandover(handover);
+ return response;
+ }
+
+ private String normalizeBaseUrl() {
+ return properties.getBaseUrl().trim().replaceAll("/+$", "");
+ }
+}
diff --git a/server/visits/dmvisit_service/src/main/java/com/doumee/service/business/snapshot/SnapshotInferProperties.java b/server/visits/dmvisit_service/src/main/java/com/doumee/service/business/snapshot/SnapshotInferProperties.java
new file mode 100644
index 0000000..59a21c2
--- /dev/null
+++ b/server/visits/dmvisit_service/src/main/java/com/doumee/service/business/snapshot/SnapshotInferProperties.java
@@ -0,0 +1,40 @@
+package com.doumee.service.business.snapshot;
+
+
+
+import lombok.Data;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+import org.springframework.stereotype.Component;
+
+
+
+@Data
+
+@Component
+
+@ConfigurationProperties(prefix = "snapshot.infer")
+
+public class SnapshotInferProperties {
+
+
+
+ private String baseUrl = "http://127.0.0.1:8095";
+
+ private int connectTimeoutMs = 5000;
+
+ private int readTimeoutMs = 600000;
+
+ private boolean enableAsr = true;
+
+ private boolean autoOnDownload = true;
+
+ private double sampleFps = 0.5;
+
+ /** 鎺ㄧ悊澶辫触鏃舵槸鍚﹀洖閫�鍒版湰鍦� 25%/75% mock锛堢敓浜х幆澧冨簲涓� false锛� */
+
+ private boolean failOpenMock = false;
+
+}
+
diff --git a/server/visits/dmvisit_service/src/main/resources/application-dev.yml b/server/visits/dmvisit_service/src/main/resources/application-dev.yml
index 8dd392d..adcfeeb 100644
--- a/server/visits/dmvisit_service/src/main/resources/application-dev.yml
+++ b/server/visits/dmvisit_service/src/main/resources/application-dev.yml
@@ -19,6 +19,16 @@
debug_model: true
+snapshot:
+ infer:
+ base-url: http://127.0.0.1:8095
+ connect-timeout-ms: 5000
+ read-timeout-ms: 600000
+ enable-asr: true
+ auto-on-download: true
+ sample-fps: 0.5
+ fail-open-mock: true
+
########################鍚屾鏁版嵁妯″紡 ########################
data-sync:
org-user-data-origin: 0 #缁勭粐鏁版嵁 0鑷缓 2浠ユ捣搴蜂负涓� 1鍗庢櫉ERP绯荤粺
diff --git a/server/visits/dmvisit_service/src/main/resources/application-pro.yml b/server/visits/dmvisit_service/src/main/resources/application-pro.yml
index 003f70c..c39980a 100644
--- a/server/visits/dmvisit_service/src/main/resources/application-pro.yml
+++ b/server/visits/dmvisit_service/src/main/resources/application-pro.yml
@@ -17,6 +17,16 @@
debug_model: false
+snapshot:
+ infer:
+ base-url: http://127.0.0.1:8095
+ connect-timeout-ms: 5000
+ read-timeout-ms: 600000
+ enable-asr: true
+ auto-on-download: true
+ sample-fps: 0.5
+ fail-open-mock: false
+
########################鍚屾鏁版嵁妯″紡 ########################
data-sync:
org-user-data-origin: 3 #缁勭粐鏁版嵁 0鑷缓 2浠ユ捣搴蜂负涓� 1鍗庢櫉ERP绯荤粺 3绠�閬撲簯 4閽夐拤
--
Gitblit v1.9.3