Spaces:
Running
Running
| <html lang="zh-CN"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>夜视监控检测系统</title> | |
| <!-- 引入 Google Fonts --> | |
| <link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&display=swap" rel="stylesheet"> | |
| <!-- 引入 FontAwesome 图标 --> | |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> | |
| <!-- 引入 TensorFlow.js 和 COCO-SSD 模型 --> | |
| <script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs@latest/dist/tf.min.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/@tensorflow-models/coco-ssd"></script> | |
| <style> | |
| :root { | |
| --primary-green: #0f0; | |
| --dim-green: #003300; | |
| --bg-color: #050505; | |
| --panel-bg: rgba(0, 20, 0, 0.85); | |
| --alert-red: #ff3333; | |
| --scan-line-color: rgba(0, 255, 0, 0.1); | |
| } | |
| * { | |
| box-sizing: border-box; | |
| margin: 0; | |
| padding: 0; | |
| } | |
| body { | |
| font-family: 'Share Tech Mono', monospace; | |
| background-color: var(--bg-color); | |
| color: var(--primary-green); | |
| height: 100vh; | |
| overflow: hidden; | |
| display: flex; | |
| flex-direction: column; | |
| } | |
| /* 顶部导航栏 */ | |
| header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| padding: 1rem 2rem; | |
| background: var(--panel-bg); | |
| border-bottom: 2px solid var(--primary-green); | |
| z-index: 10; | |
| } | |
| .brand { | |
| font-size: 1.5rem; | |
| text-transform: uppercase; | |
| letter-spacing: 2px; | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| } | |
| .brand i { | |
| animation: pulse 2s infinite; | |
| } | |
| .status-badge { | |
| font-size: 0.9rem; | |
| padding: 5px 10px; | |
| border: 1px solid var(--primary-green); | |
| border-radius: 4px; | |
| background: rgba(0, 255, 0, 0.1); | |
| } | |
| .status-badge.offline { | |
| border-color: var(--alert-red); | |
| color: var(--alert-red); | |
| background: rgba(255, 0, 0, 0.1); | |
| } | |
| /* 主布局 */ | |
| main { | |
| flex: 1; | |
| display: grid; | |
| grid-template-columns: 1fr 350px; | |
| gap: 1rem; | |
| padding: 1rem; | |
| height: calc(100vh - 70px); | |
| } | |
| @media (max-width: 900px) { | |
| main { | |
| grid-template-columns: 1fr; | |
| grid-template-rows: 1fr auto; | |
| } | |
| } | |
| /* 视频显示区域 */ | |
| .viewport-container { | |
| position: relative; | |
| background: #000; | |
| border: 2px solid var(--dim-green); | |
| border-radius: 8px; | |
| overflow: hidden; | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| } | |
| /* 隐藏原始视频,我们将在Canvas上绘制 */ | |
| #webcam { | |
| display: none; | |
| } | |
| #output-canvas { | |
| width: 100%; | |
| height: 100%; | |
| object-fit: cover; | |
| /* 夜视滤镜效果 */ | |
| filter: contrast(1.2) brightness(1.1) sepia(1) hue-rotate(50deg) saturate(3); | |
| } | |
| /* 扫描线叠加层 */ | |
| .scanlines { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| background: linear-gradient( | |
| to bottom, | |
| var(--scan-line-color) 50%, | |
| rgba(0, 0, 0, 0) 50% | |
| ); | |
| background-size: 100% 4px; | |
| pointer-events: none; | |
| z-index: 2; | |
| } | |
| .vignette { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| background: radial-gradient(circle, transparent 50%, black 100%); | |
| pointer-events: none; | |
| z-index: 3; | |
| } | |
| /* 覆盖层 UI 元素 (十字准星, 时间等) */ | |
| .overlay-ui { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| pointer-events: none; | |
| z-index: 4; | |
| padding: 20px; | |
| } | |
| .rec-indicator { | |
| position: absolute; | |
| top: 20px; | |
| right: 20px; | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| color: var(--alert-red); | |
| font-weight: bold; | |
| } | |
| .rec-dot { | |
| width: 12px; | |
| height: 12px; | |
| background-color: var(--alert-red); | |
| border-radius: 50%; | |
| animation: blink 1s infinite; | |
| } | |
| .timestamp { | |
| position: absolute; | |
| bottom: 20px; | |
| left: 20px; | |
| font-size: 1.2rem; | |
| } | |
| .crosshair { | |
| position: absolute; | |
| top: 50%; | |
| left: 50%; | |
| transform: translate(-50%, -50%); | |
| width: 60px; | |
| height: 60px; | |
| border: 2px solid var(--primary-green); | |
| border-radius: 50%; | |
| opacity: 0.6; | |
| } | |
| .crosshair::before, .crosshair::after { | |
| content: ''; | |
| position: absolute; | |
| background: var(--primary-green); | |
| } | |
| .crosshair::before { | |
| top: 50%; | |
| left: -10px; | |
| width: 80px; | |
| height: 1px; | |
| } | |
| .crosshair::after { | |
| left: 50%; | |
| top: -10px; | |
| width: 1px; | |
| height: 80px; | |
| } | |
| /* 控制面板 & 日志 */ | |
| aside { | |
| background: var(--panel-bg); | |
| border: 1px solid var(--primary-green); | |
| border-radius: 8px; | |
| display: flex; | |
| flex-direction: column; | |
| padding: 1rem; | |
| gap: 1rem; | |
| overflow-y: auto; | |
| } | |
| h2 { | |
| font-size: 1.2rem; | |
| border-bottom: 1px solid var(--dim-green); | |
| padding-bottom: 0.5rem; | |
| margin-bottom: 0.5rem; | |
| } | |
| .control-group { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 0.8rem; | |
| } | |
| button { | |
| background: transparent; | |
| border: 1px solid var(--primary-green); | |
| color: var(--primary-green); | |
| padding: 10px 15px; | |
| font-family: inherit; | |
| cursor: pointer; | |
| text-transform: uppercase; | |
| transition: all 0.3s; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 8px; | |
| font-weight: bold; | |
| } | |
| button:hover { | |
| background: var(--primary-green); | |
| color: #000; | |
| box-shadow: 0 0 15px var(--primary-green); | |
| } | |
| button:disabled { | |
| opacity: 0.5; | |
| cursor: not-allowed; | |
| filter: grayscale(1); | |
| } | |
| button.stop { | |
| border-color: var(--alert-red); | |
| color: var(--alert-red); | |
| } | |
| button.stop:hover { | |
| background: var(--alert-red); | |
| color: #fff; | |
| box-shadow: 0 0 15px var(--alert-red); | |
| } | |
| .log-container { | |
| flex: 1; | |
| background: #000; | |
| border: 1px solid var(--dim-green); | |
| padding: 10px; | |
| overflow-y: auto; | |
| font-size: 0.85rem; | |
| max-height: 300px; | |
| } | |
| .log-entry { | |
| margin-bottom: 5px; | |
| display: flex; | |
| justify-content: space-between; | |
| } | |
| .log-entry .time { | |
| color: #888; | |
| margin-right: 10px; | |
| } | |
| .log-entry.alert { | |
| color: var(--alert-red); | |
| } | |
| /* 加载遮罩 */ | |
| #loader { | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| background: rgba(0, 0, 0, 0.9); | |
| display: flex; | |
| flex-direction: column; | |
| justify-content: center; | |
| align-items: center; | |
| z-index: 100; | |
| transition: opacity 0.5s; | |
| } | |
| .spinner { | |
| width: 50px; | |
| height: 50px; | |
| border: 5px solid var(--dim-green); | |
| border-top: 5px solid var(--primary-green); | |
| border-radius: 50%; | |
| animation: spin 1s linear infinite; | |
| margin-bottom: 20px; | |
| } | |
| /* 动画 */ | |
| @keyframes spin { | |
| 0% { transform: rotate(0deg); } | |
| 100% { transform: rotate(360deg); } | |
| } | |
| @keyframes pulse { | |
| 0% { opacity: 1; } | |
| 50% { opacity: 0.5; } | |
| 100% { opacity: 1; } | |
| } | |
| @keyframes blink { | |
| 0% { opacity: 1; } | |
| 50% { opacity: 0; } | |
| 100% { opacity: 1; } | |
| } | |
| /* 页脚 */ | |
| .footer-credit { | |
| margin-top: auto; | |
| text-align: center; | |
| font-size: 0.8rem; | |
| color: #666; | |
| padding-top: 10px; | |
| border-top: 1px solid var(--dim-green); | |
| } | |
| .footer-credit a { | |
| color: var(--primary-green); | |
| text-decoration: none; | |
| } | |
| .footer-credit a:hover { | |
| text-decoration: underline; | |
| } | |
| /* 统计数据 */ | |
| .stats-grid { | |
| display: grid; | |
| grid-template-columns: 1fr 1fr; | |
| gap: 10px; | |
| margin-bottom: 10px; | |
| } | |
| .stat-box { | |
| background: rgba(0, 50, 0, 0.3); | |
| padding: 10px; | |
| border-radius: 4px; | |
| text-align: center; | |
| } | |
| .stat-value { | |
| font-size: 1.5rem; | |
| font-weight: bold; | |
| } | |
| .stat-label { | |
| font-size: 0.7rem; | |
| color: #aaa; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- 加载界面 --> | |
| <div id="loader"> | |
| <div class="spinner"></div> | |
| <div id="loading-text">正在初始化神经接口...</div> | |
| </div> | |
| <header> | |
| <div class="brand"> | |
| <i class="fa-solid fa-eye"></i> | |
| <span>NIGHT VISION <span style="font-size:0.8em; color:#aaa;">AI PRO</span></span> | |
| </div> | |
| <div class="status-badge offline" id="system-status"> | |
| SYSTEM OFFLINE | |
| </div> | |
| </header> | |
| <main> | |
| <!-- 视频与AI检测区域 --> | |
| <section class="viewport-container"> | |
| <video id="webcam" playsinline muted></video> | |
| <canvas id="output-canvas"></canvas> | |
| <!-- 视觉特效层 --> | |
| <div class="scanlines"></div> | |
| <div class="vignette"></div> | |
| <!-- UI 叠加层 --> | |
| <div class="overlay-ui"> | |
| <div class="rec-indicator" id="rec-indicator" style="display: none;"> | |
| <div class="rec-dot"></div> REC | |
| </div> | |
| <div class="timestamp" id="timestamp">00:00:00:00</div> | |
| <div class="crosshair"></div> | |
| </div> | |
| </section> | |
| <!-- 控制侧边栏 --> | |
| <aside> | |
| <h2><i class="fa-solid fa-sliders"></i> 控制面板</h2> | |
| <div class="control-group"> | |
| <button id="btn-start" onclick="startSystem()"> | |
| <i class="fa-solid fa-power-off"></i> 启动系统 | |
| </button> | |
| <button id="btn-stop" class="stop" onclick="stopSystem()" disabled> | |
| <i class="fa-solid fa-stop"></i> 停止监控 | |
| </button> | |
| </div> | |
| <h2><i class="fa-solid fa-chart-line"></i> 检测统计</h2> | |
| <div class="stats-grid"> | |
| <div class="stat-box"> | |
| <div class="stat-value" id="fps-counter">0</div> | |
| <div class="stat-label">FPS</div> | |
| </div> | |
| <div class="stat-box"> | |
| <div class="stat-value" id="obj-counter">0</div> | |
| <div class="stat-label">检测目标</div> | |
| </div> | |
| </div> | |
| <h2><i class="fa-solid fa-list-ul"></i> 检测日志</h2> | |
| <div class="log-container" id="log-container"> | |
| <div class="log-entry"><span class="time">--:--:--</span> 等待启动...</div> | |
| </div> | |
| <div class="footer-credit"> | |
| Built with <a href="https://huggingface.co/spaces/akhaliq/anycoder" target="_blank">anycoder</a> | |
| </div> | |
| </aside> | |
| </main> | |
| <script> | |
| // DOM 元素引用 | |
| const video = document.getElementById('webcam'); | |
| const canvas = document.getElementById('output-canvas'); | |
| const ctx = canvas.getContext('2d'); | |
| const loader = document.getElementById('loader'); | |
| const loadingText = document.getElementById('loading-text'); | |
| const logContainer = document.getElementById('log-container'); | |
| const systemStatus = document.getElementById('system-status'); | |
| const recIndicator = document.getElementById('rec-indicator'); | |
| const timestampEl = document.getElementById('timestamp'); | |
| const btnStart = document.getElementById('btn-start'); | |
| const btnStop = document.getElementById('btn-stop'); | |
| const fpsCounter = document.getElementById('fps-counter'); | |
| const objCounter = document.getElementById('obj-counter'); | |
| // 状态变量 | |
| let model = undefined; | |
| let isRunning = false; | |
| let animationId = null; | |
| let lastFrameTime = 0; | |
| let frameCount = 0; | |
| let lastFpsUpdate = 0; | |
| // 初始化 | |
| async function init() { | |
| try { | |
| loadingText.innerText = "正在加载 TensorFlow.js 模型..."; | |
| // 加载 COCO-SSD 模型 (用于物体检测) | |
| model = await cocoSsd.load(); | |
| loadingText.innerText = "模型加载完成,准备就绪。"; | |
| // 模拟一点延迟以展示加载动画 | |
| setTimeout(() => { | |
| loader.style.opacity = '0'; | |
| setTimeout(() => { | |
| loader.style.display = 'none'; | |
| }, 500); | |
| }, 800); | |
| addLog("系统初始化完成"); | |
| addLog("等待用户启动摄像头..."); | |
| } catch (error) { | |
| loadingText.innerText = "错误: 无法加载模型。"; | |
| console.error(error); | |
| addLog("严重错误: 模型加载失败", true); | |
| } | |
| } | |
| // 启动系统 | |
| async function startSystem() { | |
| if (isRunning) return; | |
| try { | |
| loadingText.style.display = 'block'; | |
| loadingText.innerText = "正在请求摄像头权限..."; | |
| loader.style.display = 'flex'; | |
| loader.style.opacity = '1'; | |
| // 获取摄像头流 | |
| const stream = await navigator.mediaDevices.getUserMedia({ | |
| audio: false, | |
| video: { | |
| facingMode: 'environment', // 优先使用后置摄像头 | |
| width: { ideal: 1280 }, | |
| height: { ideal: 720 } | |
| } | |
| }); | |
| video.srcObject = stream; | |
| // 等待视频元数据加载完成 | |
| video.onloadedmetadata = () => { | |
| video.play(); | |
| // 设置 Canvas 尺寸匹配视频 | |
| canvas.width = video.videoWidth; | |
| canvas.height = video.videoHeight; | |
| loader.style.opacity = '0'; | |
| setTimeout(() => loader.style.display = 'none', 500); | |
| isRunning = true; | |
| updateUIState(true); | |
| addLog("摄像头已连接,开始监控"); | |
| // 开始预测循环 | |
| predictWebcam(); | |
| }; | |
| } catch (error) { | |
| console.error(error); | |
| loadingText.innerText = "无法访问摄像头"; | |
| addLog("错误: 摄像头访问被拒绝", true); | |
| setTimeout(() => { | |
| loader.style.opacity = '0'; | |
| setTimeout(() => loader.style.display = 'none', 500); | |
| }, 2000); | |
| } | |
| } | |
| // 停止系统 | |
| function stopSystem() { | |
| if (!isRunning) return; | |
| isRunning = false; | |
| // 停止视频流 | |
| const stream = video.srcObject; | |
| if (stream) { | |
| const tracks = stream.getTracks(); | |
| tracks.forEach(track => track.stop()); | |
| video.srcObject = null; | |
| } | |
| // 停止动画循环 | |
| if (animationId) { | |
| cancelAnimationFrame(animationId); | |
| } | |
| // 清空 Canvas | |
| ctx.clearRect(0, 0, canvas.width, canvas.height); | |
| updateUIState(false); | |
| addLog("监控已停止"); | |
| } | |
| // 预测循环 (核心逻辑) | |
| async function predictWebcam() { | |
| if (!isRunning) return; | |
| // 1. 在 Canvas 上绘制当前视频帧 | |
| ctx.drawImage(video, 0, 0, canvas.width, canvas.height); | |
| // 2. 使用模型进行检测 | |
| // 仅当模型加载完毕时执行 | |
| if (model !== undefined) { | |
| try { | |
| const predictions = await model.detect(video); | |
| // 3. 绘制检测框和标签 | |
| drawPredictions(predictions); | |
| // 更新统计数据 | |
| updateStats(predictions); | |
| } catch (error) { | |
| console.warn("检测跳帧", error); | |
| } | |
| } | |
| // 4. 更新时间戳 | |
| updateTimestamp(); | |
| // 5. 循环调用 | |
| animationId = requestAnimationFrame(predictWebcam); | |
| } | |
| // 绘制预测结果 | |
| function drawPredictions(predictions) { | |
| ctx.font = '16px "Share Tech Mono"'; | |
| ctx.textBaseline = 'top'; | |
| let detectedObjects = new Set(); | |
| predictions.forEach(prediction => { | |
| const [x, y, width, height] = prediction.bbox; | |
| const label = prediction.class; | |
| const score = Math.round(prediction.score * 100) + '%'; | |
| detectedObjects.add(label); | |
| // 设置样式 (夜视风格:亮绿色) | |
| ctx.strokeStyle = '#0f0'; | |
| ctx.lineWidth = 2; | |
| // 绘制矩形框 | |
| ctx.strokeRect(x, y, width, height); | |
| // 绘制背景标签 | |
| const text = `${label.toUpperCase()} ${score}`; | |
| const textWidth = ctx.measureText(text).width; | |
| const textHeight = 16; | |
| ctx.fillStyle = 'rgba(0, 50, 0, 0.7)'; | |
| ctx.fillRect(x, y, textWidth + 10, textHeight + 4); | |
| // 绘制文字 | |
| ctx.fillStyle = '#0f0'; | |
| ctx.fillText(text, x + 5, y + 2); | |
| // 绘制角落装饰 (增加科技感) | |
| drawCornerDecorations(ctx, x, y, width, height); | |
| }); | |
| // 更新目标计数器显示 | |
| objCounter.innerText = detectedObjects.size; | |
| } | |
| // 绘制角落装饰 | |
| function drawCornerDecorations(ctx, x, y, w, h) { | |
| const lineLen = 10; | |
| ctx.lineWidth = 3; | |
| // 左上 | |
| ctx.beginPath(); | |
| ctx.moveTo(x, y + lineLen); | |
| ctx.lineTo(x, y); | |
| ctx.lineTo(x + lineLen, y); | |
| ctx.stroke(); | |
| // 右上 | |
| ctx.beginPath(); | |
| ctx.moveTo(x + w - lineLen, y); | |
| ctx.lineTo(x + w, y); | |
| ctx.lineTo(x + w, y + lineLen); | |
| ctx.stroke(); | |
| // 左下 | |
| ctx.beginPath(); | |
| ctx.moveTo(x, y + h - lineLen); | |
| ctx.lineTo(x, y + h); | |
| ctx.lineTo(x + lineLen, y + h); | |
| ctx.stroke(); | |
| // 右下 | |
| ctx.beginPath(); | |
| ctx.moveTo(x + w - lineLen, y + h); | |
| ctx.lineTo(x + w, y + h); | |
| ctx.lineTo(x + w, y + h - lineLen); | |
| ctx.stroke(); | |
| } | |
| // 更新统计数据 (FPS) | |
| function updateStats(predictions) { | |
| const now = performance.now(); | |
| frameCount++; | |
| if (now - lastFpsUpdate >= 1000) { | |
| fpsCounter.innerText = frameCount; | |
| frameCount = 0; | |
| lastFpsUpdate = now; | |
| } | |
| // 记录首次检测到的物体到日志 (防止日志刷屏,这里简单处理,只记录最近检测到的物体变化) | |
| // 实际应用中可以做更复杂的去重逻辑 | |
| } | |
| // 更新 UI 状态 | |
| function updateUIState(active) { | |
| if (active) { | |
| systemStatus.innerText = "SYSTEM ONLINE - MONITORING"; | |
| systemStatus.classList.remove('offline'); | |
| recIndicator.style.display = 'flex'; | |
| btnStart.disabled = true; | |
| btnStop.disabled = false; | |
| } else { | |
| systemStatus.innerText = "SYSTEM OFFLINE"; | |
| systemStatus.classList.add('offline'); | |
| recIndicator.style.display = 'none'; | |
| btnStart.disabled = false; | |
| btnStop.disabled = true; | |
| fpsCounter.innerText = '0'; | |
| objCounter.innerText = '0'; | |
| } | |
| } | |
| // 更新时间戳 | |
| function updateTimestamp() { | |
| const now = new Date(); | |
| const h = String(now.getHours()).padStart(2, '0'); | |
| const m = String(now.getMinutes()).padStart(2, '0'); | |
| const s = String(now.getSeconds()).padStart(2, '0'); | |
| const ms = String(Math.floor(now.getMilliseconds() / 10)).padStart(2, '0'); | |
| timestampEl.innerText = `${h}:${m}:${s}:${ms}`; | |
| } | |
| // 添加日志 | |
| function addLog(message, isAlert = false) { | |
| const now = new Date(); | |
| const timeStr = `${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}:${String(now.getSeconds()).padStart(2,'0')}`; | |
| const div = document.createElement('div'); | |
| div.className = `log-entry ${isAlert ? 'alert' : ''}`; | |
| div.innerHTML = `<span class="time">[${timeStr}]</span> <span>${message}</span>`; | |
| logContainer.appendChild(div); | |
| logContainer.scrollTop = logContainer.scrollHeight; // 自动滚动到底部 | |
| } | |
| // 页面加载完成后初始化 | |
| window.addEventListener('DOMContentLoaded', init); | |
| </script> | |
| </body> | |
| </html> |