commit 49785191c35ffd6b7635af130ca73faf746f2753 Author: hubian <908234780@qq.com> Date: Thu Apr 16 09:42:53 2026 +0800 feat: 视觉记录系统 v1.0.0 - 摄像头定时拍照+智能分析+Web界面 diff --git a/README.md b/README.md new file mode 100644 index 0000000..6604e64 --- /dev/null +++ b/README.md @@ -0,0 +1,130 @@ +# 视觉记录系统 + +一个基于 Python 的视觉监控系统,使用摄像头定期拍照并通过大模型分析场景事件。 + +## 功能特性 + +- 📹 **摄像头拍照** - 定时或手动触发拍照 +- 🔍 **智能分析** - 通过 Vision API 分析图片中的事件 +- 📊 **Web 界面** - 查看图片时间线、事件列表 +- ⚙️ **灵活配置** - 可调整拍照间隔、自动分析开关 +- 🗄️ **数据存储** - SQLite 本地存储图片和事件记录 + +## 系统要求 + +- Python 3.10+ +- Windows / Linux / macOS +- 可用的摄像头 +- Vision API(支持 OpenAI 格式的视觉模型) + +## 快速启动 + +### Windows + +```bash +双击 start.bat 或在命令行运行: +python main.py +``` + +### Linux/macOS + +```bash +./start.sh 或: +python main.py +``` + +访问地址: http://localhost:19016 + +## 配置说明 + +在 `config.py` 中可以修改: + +```python +# Web 服务端口 +WEB_PORT = 19016 + +# 摄像头索引(默认 0) +CAMERA_INDEX = 0 + +# 拍照间隔(秒) +CAPTURE_INTERVAL = 60 + +# 大模型配置 +LLM_API_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1" +LLM_API_KEY = "your-api-key" +LLM_MODEL = "qwen-vl-plus" +``` + +## Web 界面功能 + +### 控制面板 +- 启动/停止定时拍照 +- 设置拍照间隔 +- 开启/关闭自动分析 +- 立即拍照按钮 + +### 图片时间线 +- 查看所有拍摄的图片 +- 按已分析/未分析筛选 +- 点击查看详情 +- 手动分析未分析的图片 + +### 事件列表 +- 查看所有识别的事件 +- 按事件类型筛选 +- 点击事件查看对应图片 + +## API 接口 + +| 接口 | 方法 | 说明 | +|------|------|------| +| `/api/status` | GET | 获取系统状态 | +| `/api/scheduler/start` | POST | 启动定时拍照 | +| `/api/scheduler/stop` | POST | 停止定时拍照 | +| `/api/capture` | POST | 立即拍照 | +| `/api/analyze/{id}` | POST | 分析指定图片 | +| `/api/images` | GET | 获取图片列表 | +| `/api/events` | GET | 获取事件列表 | + +## 项目结构 + +``` +vision-record/ +├── main.py # 主入口 +├── config.py # 配置管理 +├── camera.py # 摄像头拍照模块 +├── analyzer.py # 图片分析模块 +├── database.py # 数据库管理 +├── scheduler.py # 定时任务调度 +├── web/ +│ ├── app.py # FastAPI 后端 +│ └── static/ # 前端文件 +│ ├── index.html +│ ├── style.css +│ └── app.js +├── data/ +│ ├── images/ # 图片存储 +│ └── events.db # SQLite 数据库 +├── requirements.txt +├── start.bat # Windows 启动脚本 +├── start.sh # Linux/Mac 启动脚本 +└── README.md +``` + +## 依赖项 + +- `opencv-python` - 摄像头拍照 +- `fastapi` - Web 后端 +- `uvicorn` - ASGI 服务器 +- `requests` - API 调用 + +## 注意事项 + +- Windows 上使用 DirectShow (`cv2.CAP_DSHOW`) 者提升摄像头性能 +- 拍照间隔建议不少于 10 秒 +- Vision API 需要配置正确的 API Key +- 图片分析可能需要 10-30 秒,请耐心等待 + +## Git 仓库 + +http://192.168.2.8:12007/coder/vision-record \ No newline at end of file diff --git a/analyzer.py b/analyzer.py new file mode 100644 index 0000000..f75eeed --- /dev/null +++ b/analyzer.py @@ -0,0 +1,147 @@ +""" +图片分析模块 - 调用大模型 Vision API +""" +import base64 +import requests +import json +import re +from pathlib import Path +from config import LLM_API_URL, LLM_API_KEY, LLM_MODEL, ANALYSIS_PROMPT + + +class ImageAnalyzer: + """图片分析器""" + + def __init__(self): + self.api_url = LLM_API_URL + self.api_key = LLM_API_KEY + self.model = LLM_MODEL + + def encode_image(self, image_path): + """将图片转为 base64""" + with open(image_path, 'rb') as f: + return base64.b64encode(f.read()).decode('utf-8') + + def analyze(self, image_path): + """分析图片 + + Returns: + dict: {'success': bool, 'events': list, 'error': str} + """ + try: + # 编码图片 + image_base64 = self.encode_image(image_path) + + # 构建请求(OpenAI Vision 格式) + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json" + } + + payload = { + "model": self.model, + "messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": ANALYSIS_PROMPT + }, + { + "type": "image_url", + "image_url": { + "url": f"data:image/jpeg;base64,{image_base64}" + } + } + ] + } + ], + "max_tokens": 500 + } + + # 发送请求 + response = requests.post( + f"{self.api_url}/chat/completions", + headers=headers, + json=payload, + timeout=30 + ) + + if response.status_code != 200: + return { + 'success': False, + 'error': f"API 错误: {response.status_code} - {response.text}" + } + + # 解析结果 + result = response.json() + content = result['choices'][0]['message']['content'] + + # 提取事件信息 + events = self.parse_events(content) + + return { + 'success': True, + 'raw_response': content, + 'events': events + } + + except requests.Timeout: + return {'success': False, 'error': 'API 请求超时'} + except Exception as e: + return {'success': False, 'error': str(e)} + + def parse_events(self, content): + """解析事件信息""" + events = [] + + # 如果无明显事件 + if "无明显事件" in content or "没有明显" in content: + return [{ + 'event_type': '无事件', + 'description': '无明显事件', + 'confidence': '高' + }] + + # 尝试解析结构化格式 + # 事件类型: xxx + # 描述: xxx + # 置信度: xxx + + pattern = r"事件类型[::]\s*(.+?)\s*描述[::]\s*(.+?)\s*置信度[::]\s*(.+)" + matches = re.findall(pattern, content, re.DOTALL) + + if matches: + for match in matches: + events.append({ + 'event_type': match[0].strip(), + 'description': match[1].strip(), + 'confidence': match[2].strip() + }) + else: + # 无法解析结构,将整个内容作为描述 + events.append({ + 'event_type': '其他', + 'description': content[:200], + 'confidence': '中' + }) + + return events + + +def analyze_image(image_path): + """便捷函数""" + analyzer = ImageAnalyzer() + return analyzer.analyze(image_path) + + +if __name__ == "__main__": + # 测试分析(需要先有一张测试图片) + test_image = Path(__file__).parent / "data" / "images" / "test.jpg" + if test_image.exists(): + print(f"分析图片: {test_image}") + result = analyze_image(test_image) + print(f"结果: {json.dumps(result, ensure_ascii=False, indent=2)}") + else: + print("没有测试图片,请先拍照") \ No newline at end of file diff --git a/camera.py b/camera.py new file mode 100644 index 0000000..a5568b6 --- /dev/null +++ b/camera.py @@ -0,0 +1,118 @@ +""" +摄像头拍照模块 - Windows 兼容 +""" +import cv2 +import os +import datetime +from pathlib import Path +from config import IMAGES_DIR, CAMERA_INDEX + + +class CameraCapture: + """摄像头拍照管理""" + + def __init__(self, camera_index=CAMERA_INDEX): + self.camera_index = camera_index + self.cap = None + + def open(self): + """打开摄像头""" + if self.cap is None or not self.cap.isOpened(): + self.cap = cv2.VideoCapture(self.camera_index, cv2.CAP_DSHOW) # Windows DirectShow + if not self.cap.isOpened(): + raise Exception(f"无法打开摄像头 {self.camera_index}") + # 设置分辨率 + self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1280) + self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 720) + return self.cap.isOpened() + + def close(self): + """关闭摄像头""" + if self.cap is not None: + self.cap.release() + self.cap = None + + def capture(self, save_path=None): + """拍照并保存 + + Returns: + dict: {'success': bool, 'path': str, 'timestamp': str, 'error': str} + """ + try: + self.open() + + # 等待摄像头稳定(Windows 上需要预热) + for _ in range(5): + self.cap.read() + + ret, frame = self.cap.read() + if not ret: + return {'success': False, 'error': '无法捕获图像'} + + # 生成文件名 + timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S') + if save_path is None: + filename = f"capture_{timestamp}.jpg" + save_path = IMAGES_DIR / filename + + # 保存图片 + cv2.imwrite(str(save_path), frame, [cv2.IMWRITE_JPEG_QUALITY, 90]) + + return { + 'success': True, + 'path': str(save_path), + 'timestamp': timestamp, + 'filename': save_path.name + } + + except Exception as e: + return {'success': False, 'error': str(e)} + + def get_camera_info(self): + """获取摄像头信息""" + try: + self.open() + width = int(self.cap.get(cv2.CAP_PROP_FRAME_WIDTH)) + height = int(self.cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) + fps = int(self.cap.get(cv2.CAP_PROP_FPS)) + return { + 'index': self.camera_index, + 'width': width, + 'height': height, + 'fps': fps, + 'status': 'opened' + } + except Exception as e: + return { + 'index': self.camera_index, + 'status': 'error', + 'error': str(e) + } + + def __del__(self): + self.close() + + +def list_available_cameras(max_test=5): + """列出可用的摄像头""" + available = [] + for i in range(max_test): + cap = cv2.VideoCapture(i, cv2.CAP_DSHOW) + if cap.isOpened(): + available.append(i) + cap.release() + return available + + +if __name__ == "__main__": + # 测试摄像头 + print("检测可用摄像头...") + cams = list_available_cameras() + print(f"可用摄像头: {cams}") + + if cams: + print("\n测试拍照...") + camera = CameraCapture(cams[0]) + result = camera.capture() + print(f"结果: {result}") + camera.close() \ No newline at end of file diff --git a/config.py b/config.py new file mode 100644 index 0000000..099448d --- /dev/null +++ b/config.py @@ -0,0 +1,69 @@ +""" +配置管理模块 +""" +import os +from pathlib import Path + +# 基础路径 +BASE_DIR = Path(__file__).parent.absolute() +DATA_DIR = BASE_DIR / "data" +IMAGES_DIR = DATA_DIR / "images" +DB_PATH = DATA_DIR / "events.db" + +# 确保目录存在 +IMAGES_DIR.mkdir(parents=True, exist_ok=True) + +# Web 服务配置 +WEB_PORT = 19016 +WEB_HOST = "0.0.0.0" + +# 摄像头配置 +CAMERA_INDEX = 0 # 默认摄像头 +CAPTURE_INTERVAL = 60 # 拍照间隔(秒) + +# 大模型配置(用于图片分析) +LLM_PROVIDER = "bailian" # bailian / openai / local +LLM_API_URL = os.environ.get("VISION_API_URL", "https://dashscope.aliyuncs.com/compatible-mode/v1") +LLM_API_KEY = os.environ.get("VISION_API_KEY", "sk-lm-fuP5tGU8:Hi7YU87jHyDP6Ay8Tl2j") +LLM_MODEL = os.environ.get("VISION_MODEL", "qwen-vl-plus") # 视觉模型 + +# 分析配置 +ANALYSIS_PROMPT = """请分析这张图片,识别其中的重要事件或变化。 + +重点关注: +1. 人物活动(有人出现、离开、动作等) +2. 物体变化(物品移动、新增、消失等) +3. 环境变化(光线、天气、状态等) +4. 异常情况(潜在危险、异常行为等) + +请用简洁的中文描述你观察到的事件,格式如下: +事件类型: [类型] +描述: [简短描述] +置信度: [高/中/低] + +如果没有明显事件,请回答"无明显事件"。 +""" + +# 数据库配置 +DB_SCHEMA = """ +CREATE TABLE IF NOT EXISTS images ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + path TEXT NOT NULL, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, + camera_id INTEGER DEFAULT 0, + analyzed BOOLEAN DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + image_id INTEGER NOT NULL, + event_type TEXT, + description TEXT, + confidence TEXT, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (image_id) REFERENCES images(id) +); + +CREATE INDEX IF NOT EXISTS idx_images_timestamp ON images(timestamp); +CREATE INDEX IF NOT EXISTS idx_events_type ON events(event_type); +""" \ No newline at end of file diff --git a/database.py b/database.py new file mode 100644 index 0000000..69b95d9 --- /dev/null +++ b/database.py @@ -0,0 +1,196 @@ +""" +数据库管理模块 +""" +import sqlite3 +import datetime +from pathlib import Path +from config import DB_PATH, DB_SCHEMA + + +class Database: + """SQLite 数据库管理""" + + def __init__(self): + self.db_path = DB_PATH + self._init_db() + + def _init_db(self): + """初始化数据库""" + conn = sqlite3.connect(self.db_path) + conn.executescript(DB_SCHEMA) + conn.commit() + conn.close() + + def _get_conn(self): + """获取数据库连接""" + return sqlite3.connect(self.db_path) + + def add_image(self, path, camera_id=0): + """添加图片记录 + + Returns: + int: 图片ID + """ + conn = self._get_conn() + cursor = conn.cursor() + cursor.execute( + "INSERT INTO images (path, camera_id, timestamp) VALUES (?, ?, ?)", + (path, camera_id, datetime.datetime.now().isoformat()) + ) + image_id = cursor.lastrowid + conn.commit() + conn.close() + return image_id + + def mark_image_analyzed(self, image_id): + """标记图片已分析""" + conn = self._get_conn() + conn.execute("UPDATE images SET analyzed = 1 WHERE id = ?", (image_id,)) + conn.commit() + conn.close() + + def add_event(self, image_id, event_type, description, confidence): + """添加事件记录""" + conn = self._get_conn() + conn.execute( + "INSERT INTO events (image_id, event_type, description, confidence, timestamp) VALUES (?, ?, ?, ?, ?)", + (image_id, event_type, description, confidence, datetime.datetime.now().isoformat()) + ) + conn.commit() + conn.close() + + def get_images(self, limit=50, offset=0, analyzed_only=False): + """获取图片列表""" + conn = self._get_conn() + conn.row_factory = sqlite3.Row + + if analyzed_only: + cursor = conn.execute( + "SELECT * FROM images WHERE analyzed = 1 ORDER BY timestamp DESC LIMIT ? OFFSET ?", + (limit, offset) + ) + else: + cursor = conn.execute( + "SELECT * FROM images ORDER BY timestamp DESC LIMIT ? OFFSET ?", + (limit, offset) + ) + + rows = cursor.fetchall() + conn.close() + + return [dict(row) for row in rows] + + def get_events(self, limit=50, offset=0, event_type=None): + """获取事件列表""" + conn = self._get_conn() + conn.row_factory = sqlite3.Row + + if event_type: + cursor = conn.execute( + """SELECT e.*, i.path as image_path, i.timestamp as image_timestamp + FROM events e JOIN images i ON e.image_id = i.id + WHERE e.event_type = ? + ORDER BY e.timestamp DESC LIMIT ? OFFSET ?""", + (event_type, limit, offset) + ) + else: + cursor = conn.execute( + """SELECT e.*, i.path as image_path, i.timestamp as image_timestamp + FROM events e JOIN images i ON e.image_id = i.id + ORDER BY e.timestamp DESC LIMIT ? OFFSET ?""", + (limit, offset) + ) + + rows = cursor.fetchall() + conn.close() + + return [dict(row) for row in rows] + + def get_image_by_id(self, image_id): + """获取单个图片""" + conn = self._get_conn() + conn.row_factory = sqlite3.Row + cursor = conn.execute("SELECT * FROM images WHERE id = ?", (image_id,)) + row = cursor.fetchone() + conn.close() + return dict(row) if row else None + + def get_events_by_image(self, image_id): + """获取图片相关的事件""" + conn = self._get_conn() + conn.row_factory = sqlite3.Row + cursor = conn.execute( + "SELECT * FROM events WHERE image_id = ? ORDER BY timestamp DESC", + (image_id,) + ) + rows = cursor.fetchall() + conn.close() + return [dict(row) for row in rows] + + def get_stats(self): + """获取统计信息""" + conn = self._get_conn() + + # 图片统计 + total_images = conn.execute("SELECT COUNT(*) FROM images").fetchone()[0] + analyzed_images = conn.execute("SELECT COUNT(*) FROM images WHERE analyzed = 1").fetchone()[0] + + # 事件统计 + total_events = conn.execute("SELECT COUNT(*) FROM events").fetchone()[0] + + # 事件类型统计 + cursor = conn.execute( + "SELECT event_type, COUNT(*) as count FROM events GROUP BY event_type ORDER BY count DESC" + ) + event_types = [{'type': row[0], 'count': row[1]} for row in cursor.fetchall()] + + conn.close() + + return { + 'total_images': total_images, + 'analyzed_images': analyzed_images, + 'unanalyzed_images': total_images - analyzed_images, + 'total_events': total_events, + 'event_types': event_types + } + + def delete_image(self, image_id): + """删除图片和相关事件""" + conn = self._get_conn() + + # 获取图片路径 + cursor = conn.execute("SELECT path FROM images WHERE id = ?", (image_id,)) + row = cursor.fetchone() + if row: + image_path = row[0] + # 删除事件 + conn.execute("DELETE FROM events WHERE image_id = ?", (image_id,)) + # 删除图片记录 + conn.execute("DELETE FROM images WHERE id = ?", (image_id,)) + conn.commit() + conn.close() + + # 删除实际文件 + try: + Path(image_path).unlink(missing_ok=True) + except: + pass + return True + conn.close() + return False + + def get_unanalyzed_images(self, limit=10): + """获取未分析的图片""" + conn = self._get_conn() + conn.row_factory = sqlite3.Row + cursor = conn.execute( + "SELECT * FROM images WHERE analyzed = 0 ORDER BY timestamp ASC LIMIT ?", + (limit,) + ) + rows = cursor.fetchall() + conn.close() + return [dict(row) for row in rows] + + +# 全局实例 +db = Database() \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..1c5f31b --- /dev/null +++ b/main.py @@ -0,0 +1,27 @@ +""" +视觉记录系统主入口 +""" +import sys +import os + +# 确保路径正确 +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from web.app import run_web +from config import WEB_PORT + + +def main(): + """启动系统""" + print("=" * 50) + print("视觉记录系统启动") + print("=" * 50) + print(f"Web 端口: {WEB_PORT}") + print(f"访问地址: http://localhost:{WEB_PORT}") + print("=" * 50) + + run_web() + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..acebbfe --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +# 视觉记录系统依赖 +opencv-python>=4.8.0 +fastapi>=0.100.0 +uvicorn>=0.23.0 +requests>=2.31.0 \ No newline at end of file diff --git a/scheduler.py b/scheduler.py new file mode 100644 index 0000000..eb16a69 --- /dev/null +++ b/scheduler.py @@ -0,0 +1,223 @@ +""" +定时任务调度模块 +""" +import threading +import time +import datetime +from camera import CameraCapture +from analyzer import ImageAnalyzer +from database import db +from config import CAPTURE_INTERVAL + + +class VisionScheduler: + """视觉记录调度器""" + + def __init__(self): + self.camera = CameraCapture() + self.analyzer = ImageAnalyzer() + self.running = False + self.interval = CAPTURE_INTERVAL + self.timer = None + self.auto_analyze = True # 自动分析 + + # 统计 + self.capture_count = 0 + self.last_capture_time = None + self.last_analyze_time = None + self.errors = [] + + def start(self, interval=None, auto_analyze=None): + """启动定时拍照 + + Args: + interval: 拍照间隔(秒) + auto_analyze: 是否自动分析 + """ + if self.running: + return {'success': False, 'error': '已在运行中'} + + if interval: + self.interval = interval + if auto_analyze is not None: + self.auto_analyze = auto_analyze + + self.running = True + self._schedule_next() + + return { + 'success': True, + 'interval': self.interval, + 'auto_analyze': self.auto_analyze + } + + def stop(self): + """停止定时拍照""" + self.running = False + if self.timer: + self.timer.cancel() + self.timer = None + self.camera.close() + return {'success': True} + + def _schedule_next(self): + """安排下一次拍照""" + if not self.running: + return + self.timer = threading.Timer(self.interval, self._capture_task) + self.timer.start() + + def _capture_task(self): + """拍照任务""" + if not self.running: + return + + try: + # 拍照 + result = self.camera.capture() + + if result['success']: + # 记录到数据库 + image_id = db.add_image(result['path']) + self.capture_count += 1 + self.last_capture_time = datetime.datetime.now().isoformat() + + # 自动分析 + if self.auto_analyze: + self._analyze_task(image_id, result['path']) + else: + self.errors.append({ + 'time': datetime.datetime.now().isoformat(), + 'error': result['error'] + }) + + except Exception as e: + self.errors.append({ + 'time': datetime.datetime.now().isoformat(), + 'error': str(e) + }) + + # 安排下一次 + self._schedule_next() + + def _analyze_task(self, image_id, image_path): + """分析任务""" + try: + result = self.analyzer.analyze(image_path) + + if result['success']: + # 记录事件 + for event in result['events']: + db.add_event( + image_id, + event['event_type'], + event['description'], + event['confidence'] + ) + # 标记已分析 + db.mark_image_analyzed(image_id) + self.last_analyze_time = datetime.datetime.now().isoformat() + else: + self.errors.append({ + 'time': datetime.datetime.now().isoformat(), + 'error': f"分析失败: {result['error']}" + }) + except Exception as e: + self.errors.append({ + 'time': datetime.datetime.now().isoformat(), + 'error': str(e) + }) + + def capture_now(self): + """立即拍照""" + result = self.camera.capture() + + if result['success']: + image_id = db.add_image(result['path']) + self.capture_count += 1 + self.last_capture_time = datetime.datetime.now().isoformat() + + # 如果自动分析开启,立即分析 + if self.auto_analyze: + threading.Thread( + target=self._analyze_task, + args=(image_id, result['path']) + ).start() + + return { + 'success': True, + 'image_id': image_id, + 'path': result['path'], + 'timestamp': result['timestamp'] + } + + return result + + def analyze_now(self, image_id): + """立即分析指定图片""" + image = db.get_image_by_id(image_id) + if not image: + return {'success': False, 'error': '图片不存在'} + + result = self.analyzer.analyze(image['path']) + + if result['success']: + for event in result['events']: + db.add_event( + image_id, + event['event_type'], + event['description'], + event['confidence'] + ) + db.mark_image_analyzed(image_id) + self.last_analyze_time = datetime.datetime.now().isoformat() + + return result + + def analyze_unanalyzed(self): + """分析所有未分析的图片""" + images = db.get_unanalyzed_images(limit=10) + results = [] + + for image in images: + result = self.analyzer.analyze(image['path']) + + if result['success']: + for event in result['events']: + db.add_event( + image['id'], + event['event_type'], + event['description'], + event['confidence'] + ) + db.mark_image_analyzed(image['id']) + results.append({'image_id': image['id'], 'success': True}) + else: + results.append({'image_id': image['id'], 'success': False, 'error': result['error']}) + + return results + + def get_status(self): + """获取调度器状态""" + return { + 'running': self.running, + 'interval': self.interval, + 'auto_analyze': self.auto_analyze, + 'capture_count': self.capture_count, + 'last_capture_time': self.last_capture_time, + 'last_analyze_time': self.last_analyze_time, + 'recent_errors': self.errors[-5:] if self.errors else [] + } + + def set_interval(self, interval): + """设置拍照间隔""" + self.interval = interval + if self.running: + # 重启定时器 + self.stop() + self.start() + return {'success': True, 'interval': interval} + + +# 全局实例 +scheduler = VisionScheduler() \ No newline at end of file diff --git a/start.bat b/start.bat new file mode 100644 index 0000000..626c9a7 --- /dev/null +++ b/start.bat @@ -0,0 +1,27 @@ +@echo off +chcp 65001 >nul +echo ================================ +echo 视觉记录系统启动脚本 (Windows) +echo ================================ + +cd /d %~dp0 + +echo 检查 Python 环境... +python --version +if errorlevel 1 ( + echo 错误: 未找到 Python,请先安装 Python 3.10+ + pause + exit /b 1 +) + +echo 检查依赖... +pip show opencv-python >nul 2>&1 +if errorlevel 1 ( + echo 安装依赖... + pip install -r requirements.txt +) + +echo 启动服务... +python main.py + +pause \ No newline at end of file diff --git a/start.sh b/start.sh new file mode 100644 index 0000000..fadc4cb --- /dev/null +++ b/start.sh @@ -0,0 +1,19 @@ +#!/bin/bash +echo "================================" +echo "视觉记录系统启动脚本 (Linux/Mac)" +echo "================================" + +cd "$(dirname "$0")" + +echo "检查 Python 环境..." +python3 --version || python --version + +echo "检查依赖..." +pip3 show opencv-python 2>/dev/null || pip show opencv-python 2>/dev/null +if [ $? -ne 0 ]; then + echo "安装依赖..." + pip3 install -r requirements.txt || pip install -r requirements.txt +fi + +echo "启动服务..." +python3 main.py || python main.py \ No newline at end of file diff --git a/web/app.py b/web/app.py new file mode 100644 index 0000000..2d68e07 --- /dev/null +++ b/web/app.py @@ -0,0 +1,158 @@ +""" +Web 后端 - FastAPI +""" +from fastapi import FastAPI, HTTPException +from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse, HTMLResponse +from pathlib import Path +import uvicorn +import json + +from config import WEB_PORT, WEB_HOST, IMAGES_DIR +from database import db +from scheduler import scheduler +from camera import list_available_cameras, CameraCapture + + +# 创建应用 +app = FastAPI(title="视觉记录系统") + +# 静态文件目录 +STATIC_DIR = Path(__file__).parent / "static" +app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") +app.mount("/images", StaticFiles(directory=str(IMAGES_DIR)), name="images") + + +# ============== 页面路由 ============== + +@app.get("/", response_class=HTMLResponse) +async def index(): + """主页""" + index_file = STATIC_DIR / "index.html" + if index_file.exists(): + return FileResponse(str(index_file)) + return HTMLResponse("
暂无图片
'; + return; + } + + images.forEach(img => { + const card = document.createElement('div'); + card.className = 'image-card'; + card.onclick = () => openImageModal(img.id); + + const imgEl = document.createElement('img'); + imgEl.src = `/images/${img.path.split('/').pop()}`; + imgEl.alt = `图片 ${img.id}`; + + const info = document.createElement('div'); + info.className = 'image-card-info'; + + const time = new Date(img.timestamp).toLocaleString('zh-CN'); + info.innerHTML = ` +暂无事件记录
'; + } + + modal.classList.add('active'); + } catch (e) { + console.error('加载图片详情失败:', e); + } +} + +function closeModal() { + document.getElementById('image-modal').classList.remove('active'); + currentImageId = null; +} + +async function analyzeCurrentImage() { + if (!currentImageId) return; + + try { + const res = await fetch(`${API_BASE}/api/analyze/${currentImageId}`, { + method: 'POST' + }); + const data = await res.json(); + + if (data.success) { + alert('分析完成!'); + openImageModal(currentImageId); // 刷新详情 + refreshAll(); + } else { + alert('分析失败: ' + data.error); + } + } catch (e) { + alert('请求失败: ' + e.message); + } +} + +async function deleteCurrentImage() { + if (!currentImageId) return; + + if (!confirm('确定删除此图片?')) return; + + try { + const res = await fetch(`${API_BASE}/api/images/${currentImageId}`, { + method: 'DELETE' + }); + const data = await res.json(); + + if (data.success) { + closeModal(); + refreshAll(); + } + } catch (e) { + alert('请求失败: ' + e.message); + } +} + +// ============== 事件相关 ============== + +async function loadEvents() { + const filter = document.getElementById('event-filter').value; + const eventType = filter === 'all' ? '' : filter; + + try { + let url = `${API_BASE}/api/events?limit=50`; + if (eventType) { + url += `&event_type=${encodeURIComponent(eventType)}`; + } + + const res = await fetch(url); + const data = await res.json(); + + renderEvents(data.events); + } catch (e) { + console.error('加载事件失败:', e); + } +} + +function renderEvents(events) { + const list = document.getElementById('events-list'); + list.innerHTML = ''; + + if (events.length === 0) { + list.innerHTML = '暂无事件
'; + return; + } + + events.forEach(event => { + const card = document.createElement('div'); + card.className = `event-card type-${event.event_type}`; + + const time = new Date(event.timestamp).toLocaleString('zh-CN'); + + card.innerHTML = ` +