From 49785191c35ffd6b7635af130ca73faf746f2753 Mon Sep 17 00:00:00 2001 From: hubian <908234780@qq.com> Date: Thu, 16 Apr 2026 09:42:53 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=A7=86=E8=A7=89=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F=20v1.0.0=20-=20=E6=91=84=E5=83=8F=E5=A4=B4?= =?UTF-8?q?=E5=AE=9A=E6=97=B6=E6=8B=8D=E7=85=A7+=E6=99=BA=E8=83=BD?= =?UTF-8?q?=E5=88=86=E6=9E=90+Web=E7=95=8C=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 130 ++++++++++++++ analyzer.py | 147 ++++++++++++++++ camera.py | 118 +++++++++++++ config.py | 69 ++++++++ database.py | 196 +++++++++++++++++++++ main.py | 27 +++ requirements.txt | 5 + scheduler.py | 223 +++++++++++++++++++++++ start.bat | 27 +++ start.sh | 19 ++ web/app.py | 158 +++++++++++++++++ web/static/app.js | 365 ++++++++++++++++++++++++++++++++++++++ web/static/index.html | 114 ++++++++++++ web/static/style.css | 399 ++++++++++++++++++++++++++++++++++++++++++ 14 files changed, 1997 insertions(+) create mode 100644 README.md create mode 100644 analyzer.py create mode 100644 camera.py create mode 100644 config.py create mode 100644 database.py create mode 100644 main.py create mode 100644 requirements.txt create mode 100644 scheduler.py create mode 100644 start.bat create mode 100644 start.sh create mode 100644 web/app.py create mode 100644 web/static/app.js create mode 100644 web/static/index.html create mode 100644 web/static/style.css 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 = ` +