diff --git a/local_analyzer.py b/local_analyzer.py index 90eccab..d47e5d4 100644 --- a/local_analyzer.py +++ b/local_analyzer.py @@ -5,7 +5,7 @@ Local Analyzer - 本地视觉分析(无需大模型) 功能: - 帧间差分:检测运动 - 背景建模:检测前景物体 -- 人体检测:检测人员进出 +- 人员识别:检测并识别人物(新人员自动入库) - 亮度检测:检测光线变化 - 自动判断是否需要调用大模型 """ @@ -13,6 +13,18 @@ import cv2 import numpy as np from pathlib import Path import datetime +import sys +import os + +# 添加项目路径 +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +try: + from person_manager import person_manager + HAS_PERSON_MANAGER = True +except ImportError: + HAS_PERSON_MANAGER = False + print("[LocalAnalyzer] PersonManager not available") class LocalAnalyzer: @@ -104,40 +116,90 @@ class LocalAnalyzer: }) self.motion_count += 1 - # 2. 人体检测 - human_result = self._detect_human(current_frame) - metrics['human_count'] = human_result['count'] + # 2. 人员检测与识别 + person_result = {'persons': [], 'total_count': 0, 'new_count': 0, 'known_count': 0} - # 记录人数变化 - human_count_change = human_result['count'] - self.prev_human_count - metrics['human_count_change'] = human_count_change - - if human_count_change > 0: - events.append({ - 'event_type': '人物活动', - 'description': f'检测到 {human_count_change} 人进入,当前共 {human_result["count"]} 人', - 'confidence': '高', - 'source': 'local' - }) - self.human_count += human_count_change - elif human_count_change < 0: - events.append({ - 'event_type': '人物活动', - 'description': f'检测到 {abs(human_count_change)} 人离开,当前剩 {human_result["count"]} 人', - 'confidence': '高', - 'source': 'local' - }) - elif human_result['count'] > 0: - # 人数没变但有人 - events.append({ - 'event_type': '人物活动', - 'description': f'检测到 {human_result["count"]} 个人(无变化)', - 'confidence': '低', - 'source': 'local' - }) - - # 更新前一帧人数(在 should_call_model 中更新) - # self.prev_human_count = human_result['count'] + if HAS_PERSON_MANAGER: + print(f"[LocalAnalyzer] Using PersonManager for face detection...") + person_result = person_manager.analyze_image(image_path, save_new_person=True) + + metrics['person_count'] = person_result['total_count'] + metrics['new_persons'] = person_result['new_count'] + metrics['known_persons'] = person_result['known_count'] + + # 记录人员变化 + prev_person_count = self.prev_human_count # 用之前的变量名 + person_count_change = person_result['total_count'] - prev_person_count + metrics['person_count_change'] = person_count_change + + for person in person_result['persons']: + if person['is_new']: + events.append({ + 'event_type': '人物活动', + 'description': f'新人出现: {person["name"]},当前共 {person_result["total_count"]} 人', + 'confidence': '高', + 'source': 'local' + }) + self.human_count += 1 + else: + events.append({ + 'event_type': '人物活动', + 'description': f'已知人员: {person["name"]},已访问 {person_manager.persons.get(person["person_id"], {}).get("visit_count", 1)} 次', + 'confidence': '高', + 'source': 'local' + }) + + # 检测人员进出 + if person_count_change > 0: + events.append({ + 'event_type': '人员进出', + 'description': f'检测到 {person_count_change} 人进入,当前共 {person_result["total_count"]} 人', + 'confidence': '高', + 'source': 'local' + }) + elif person_count_change < 0: + events.append({ + 'event_type': '人员进出', + 'description': f'检测到 {abs(person_count_change)} 人离开,当前剩 {person_result["total_count"]} 人', + 'confidence': '高', + 'source': 'local' + }) + + # 更新前一帧人数 + self.prev_human_count = person_result['total_count'] + + else: + # 使用传统人体检测(备用) + human_result = self._detect_human(current_frame) + metrics['human_count'] = human_result['count'] + + human_count_change = human_result['count'] - self.prev_human_count + metrics['human_count_change'] = human_count_change + + if human_count_change > 0: + events.append({ + 'event_type': '人物活动', + 'description': f'检测到 {human_count_change} 人进入,当前共 {human_result["count"]} 人', + 'confidence': '高', + 'source': 'local' + }) + self.human_count += human_count_change + elif human_count_change < 0: + events.append({ + 'event_type': '人物活动', + 'description': f'检测到 {abs(human_count_change)} 人离开,当前剩 {human_result["count"]} 人', + 'confidence': '高', + 'source': 'local' + }) + elif human_result['count'] > 0: + events.append({ + 'event_type': '人物活动', + 'description': f'检测到 {human_result["count"]} 个人(无变化)', + 'confidence': '低', + 'source': 'local' + }) + + self.prev_human_count = human_result['count'] # 3. 亮度检测 brightness_result = self._detect_brightness_change(current_gray, prev_image_path) @@ -306,23 +368,25 @@ class LocalAnalyzer: """判断是否需要调用大模型""" # 条件1:人数变化(最重要) - current_human_count = metrics.get('human_count', 0) - human_count_change = abs(current_human_count - self.prev_human_count) + current_person_count = metrics.get('person_count', metrics.get('human_count', 0)) + person_count_change = metrics.get('person_count_change', metrics.get('human_count_change', 0)) - # 更新前一帧人数 - self.prev_human_count = current_human_count - - if human_count_change >= self.config['human_count_change_threshold']: - print(f"[LocalAnalyzer] Human count changed: {self.prev_human_count} -> {current_human_count}, triggering model") + # 更新前一帧人数(如果还没更新) + if abs(person_count_change) >= self.config['human_count_change_threshold']: + print(f"[LocalAnalyzer] Person count changed: {current_person_count - person_count_change} -> {current_person_count}, triggering model") return True - # 条件2:运动面积超过阈值(排除有人但不动的情况) - # 只有在没有人变化时才用这个条件 + # 条件2:检测到新人 + if metrics.get('new_persons', 0) > 0: + print(f"[LocalAnalyzer] New person detected: {metrics.get('new_persons', 0)}, triggering model") + return True + + # 条件3:运动面积超过阈值(排除有人但不动的情况) if metrics.get('motion_ratio', 0) > self.config['trigger_model_threshold'] * 2: print(f"[LocalAnalyzer] Large motion detected: {metrics.get('motion_ratio', 0):.2%}") return True - # 条件3:亮度大幅变化(灯开关等) + # 条件4:亮度大幅变化(灯开关等) if abs(metrics.get('brightness_change', 0)) > self.config['brightness_change_threshold'] * 2: print(f"[LocalAnalyzer] Brightness changed: {metrics.get('brightness_change', 0)}") return True diff --git a/person_manager.py b/person_manager.py new file mode 100644 index 0000000..06c7d09 --- /dev/null +++ b/person_manager.py @@ -0,0 +1,448 @@ +""" +Person Manager - 人员识别与管理模块 + +功能: +- 人脸检测(MediaPipe) +- 人脸识别,判断是否为同一个人 +- 人员库管理,新人自动添加 +- 追踪人员进出记录 +""" +import cv2 +import numpy as np +import json +import datetime +from pathlib import Path +from config import DATA_DIR + +try: + import mediapipe as mp + HAS_MEDIAPIPE = True +except ImportError: + HAS_MEDIAPIPE = False + print("[PersonManager] MediaPipe not installed, using basic detection") + +try: + import face_recognition + HAS_FACE_REC = True +except ImportError: + HAS_FACE_REC = False + print("[PersonManager] face_recognition not installed, using basic matching") + + +class PersonManager: + """人员识别与管理器""" + + def __init__(self): + self.persons_db_path = DATA_DIR / "persons.json" + self.faces_dir = DATA_DIR / "faces" + + # 创建目录 + self.faces_dir.mkdir(parents=True, exist_ok=True) + + # 加载人员库 + self.persons = self._load_persons_db() + + # 初始化检测器 + self._init_detectors() + + # 配置 + self.config = { + 'face_match_threshold': 0.6, # 人脸匹配阈值 + 'unknown_person_id': 'unknown', # 未知人员ID + 'max_persons': 100, # 最大人员数量 + } + + # 统计 + self.total_detections = 0 + self.known_persons_detected = 0 + self.new_persons_added = 0 + + def _load_persons_db(self): + """加载人员数据库""" + if self.persons_db_path.exists(): + try: + with open(self.persons_db_path, 'r', encoding='utf-8') as f: + return json.load(f) + except: + return {} + return {} + + def _save_persons_db(self): + """保存人员数据库""" + with open(self.persons_db_path, 'w', encoding='utf-8') as f: + json.dump(self.persons, f, ensure_ascii=False, indent=2) + + def _init_detectors(self): + """初始化检测器""" + # MediaPipe 人脸检测 + if HAS_MEDIAPIPE: + self.mp_face_detection = mp.solutions.face_detection + self.face_detector = self.mp_face_detection.FaceDetection( + model_selection=0, # 0: 短距离,1: 远距离 + min_detection_confidence=0.5 + ) + print("[PersonManager] MediaPipe face detector initialized") + else: + # OpenCV DNN 人脸检测 + try: + model_path = cv2.data.haarcascades + 'haarcascade_frontalface_default.xml' + self.cv_face_detector = cv2.CascadeClassifier(model_path) + print("[PersonManager] OpenCV face detector initialized") + except: + self.cv_face_detector = None + print("[PersonManager] No face detector available") + + def detect_faces(self, image): + """检测人脸 + + Args: + image: 图片(numpy array 或路径) + + Returns: + list: [{'bbox': [x,y,w,h], 'confidence': float}] + """ + if isinstance(image, str): + image = cv2.imread(image) + + if image is None: + return [] + + faces = [] + + # MediaPipe 检测 + if HAS_MEDIAPIPE and hasattr(self, 'face_detector'): + rgb_image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) + results = self.face_detector.process(rgb_image) + + if results.detections: + for detection in results.detections: + bboxC = detection.location_data.relative_bounding_box + h, w, _ = image.shape + + x = int(bboxC.xmin * w) + y = int(bboxC.ymin * h) + width = int(bboxC.width * w) + height = int(bboxC.height * h) + + faces.append({ + 'bbox': [x, y, width, height], + 'confidence': detection.score[0], + 'source': 'mediapipe' + }) + + # OpenCV 检测(备用) + elif self.cv_face_detector is not None: + gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) + detections = self.cv_face_detector.detectMultiScale( + gray, + scaleFactor=1.1, + minNeighbors=5, + minSize=(30, 30) + ) + + for (x, y, w, h) in detections: + faces.append({ + 'bbox': [x, y, w, h], + 'confidence': 0.8, + 'source': 'opencv' + }) + + return faces + + def extract_face_encoding(self, image, face_bbox): + """提取人脸特征 + + Args: + image: 图片 + face_bbox: [x, y, w, h] + + Returns: + numpy array: 人脸特征向量 + """ + if isinstance(image, str): + image = cv2.imread(image) + + if image is None: + return None + + x, y, w, h = face_bbox + + # 确保坐标有效 + h_img, w_img = image.shape[:2] + x = max(0, min(x, w_img - 1)) + y = max(0, min(y, h_img - 1)) + w = max(1, min(w, w_img - x)) + h = max(1, min(h, h_img - y)) + + # 提取人脸区域 + face_image = image[y:y+h, x:x+w] + + if HAS_FACE_REC: + # 使用 face_recognition 库 + rgb_face = cv2.cvtColor(face_image, cv2.COLOR_BGR2RGB) + encodings = face_recognition.face_encodings(rgb_face) + + if len(encodings) > 0: + return encodings[0] + + # 简单特征:使用颜色直方图作为特征 + # 将人脸缩放到固定大小 + face_resized = cv2.resize(face_image, (64, 64)) + + # 计算 HSV 直方图 + hsv = cv2.cvtColor(face_resized, cv2.COLOR_BGR2HSV) + hist_h = cv2.calcHist([hsv], [0], None, [16], [0, 180]) + hist_s = cv2.calcHist([hsv], [1], None, [16], [0, 256]) + hist_v = cv2.calcHist([hsv], [2], None, [16], [0, 256]) + + # 合并特征 + feature = np.concatenate([ + cv2.normalize(hist_h, hist_h).flatten(), + cv2.normalize(hist_s, hist_s).flatten(), + cv2.normalize(hist_v, hist_v).flatten() + ]) + + return feature + + def match_face(self, face_encoding, threshold=None): + """匹配人脸,找出对应的已知人员 + + Args: + face_encoding: 人脸特征向量 + threshold: 匹配阈值 + + Returns: + dict: {'person_id': str, 'name': str, 'is_new': bool} + """ + if threshold is None: + threshold = self.config['face_match_threshold'] + + if face_encoding is None: + return {'person_id': 'unknown', 'name': 'Unknown', 'is_new': False} + + best_match = None + best_distance = float('inf') + + for person_id, person_data in self.persons.items(): + if 'face_encoding' in person_data: + stored_encoding = np.array(person_data['face_encoding']) + + if HAS_FACE_REC: + # face_recognition 距离计算 + distance = face_recognition.face_distance([stored_encoding], face_encoding)[0] + else: + # 简单特征距离 + distance = np.linalg.norm(stored_encoding - face_encoding) + + if distance < best_distance: + best_distance = distance + best_match = person_data + + if best_match and best_distance < threshold: + self.known_persons_detected += 1 + return { + 'person_id': best_match.get('person_id'), + 'name': best_match.get('name', 'Unknown'), + 'is_new': False, + 'confidence': 1 - best_distance + } + + # 未匹配到,是新人员 + return { + 'person_id': 'unknown', + 'name': 'Unknown', + 'is_new': True, + 'confidence': 0 + } + + def add_new_person(self, image, face_bbox, name=None): + """添加新人员到库 + + Args: + image: 图片 + face_bbox: 人脸位置 + name: 人员名称(可选) + + Returns: + dict: 新人员信息 + """ + if isinstance(image, str): + image = cv2.imread(image) + + # 提取特征 + face_encoding = self.extract_face_encoding(image, face_bbox) + + if face_encoding is None: + return None + + # 生成人员ID + person_id = f"person_{len(self.persons) + 1}" + if name is None: + name = f"Person #{len(self.persons) + 1}" + + # 保存人脸图片 + x, y, w, h = face_bbox + face_image = image[y:y+h, x:x+w] + face_path = self.faces_dir / f"{person_id}.jpg" + cv2.imwrite(str(face_path), face_image) + + # 记录到数据库 + person_data = { + 'person_id': person_id, + 'name': name, + 'face_encoding': face_encoding.tolist() if isinstance(face_encoding, np.ndarray) else face_encoding, + 'face_path': str(face_path), + 'first_seen': datetime.datetime.now().isoformat(), + 'last_seen': datetime.datetime.now().isoformat(), + 'visit_count': 1 + } + + self.persons[person_id] = person_data + self._save_persons_db() + + self.new_persons_added += 1 + + print(f"[PersonManager] New person added: {person_id} ({name})") + + return person_data + + def update_person_visit(self, person_id): + """更新人员访问记录""" + if person_id in self.persons: + self.persons[person_id]['last_seen'] = datetime.datetime.now().isoformat() + self.persons[person_id]['visit_count'] += 1 + self._save_persons_db() + + def analyze_image(self, image_path, save_new_person=True): + """分析图片中的人员 + + Args: + image_path: 图片路径 + save_new_person: 是否保存新人员 + + Returns: + dict: { + 'faces': list, # 检测到的人脸 + 'persons': list, # 识别的人员 + 'new_count': int, # 新人员数量 + 'known_count': int, # 已知人员数量 + } + """ + image = cv2.imread(image_path) + if image is None: + return {'faces': [], 'persons': [], 'error': 'Cannot load image'} + + self.total_detections += 1 + + # 检测人脸 + faces = self.detect_faces(image) + + persons = [] + new_count = 0 + known_count = 0 + + for face in faces: + bbox = face['bbox'] + + # 提取特征 + encoding = self.extract_face_encoding(image, bbox) + + # 匹配 + match_result = self.match_face(encoding) + + if match_result['is_new']: + # 新人员 + new_count += 1 + + if save_new_person and len(self.persons) < self.config['max_persons']: + new_person = self.add_new_person(image, bbox) + if new_person: + persons.append({ + 'person_id': new_person['person_id'], + 'name': new_person['name'], + 'bbox': bbox, + 'is_new': True, + 'confidence': face['confidence'] + }) + else: + persons.append({ + 'person_id': 'unknown', + 'name': 'Unknown (new)', + 'bbox': bbox, + 'is_new': True, + 'confidence': face['confidence'] + }) + else: + # 已知人员 + known_count += 1 + self.update_person_visit(match_result['person_id']) + + persons.append({ + 'person_id': match_result['person_id'], + 'name': match_result['name'], + 'bbox': bbox, + 'is_new': False, + 'confidence': match_result['confidence'] + }) + + return { + 'faces': faces, + 'persons': persons, + 'new_count': new_count, + 'known_count': known_count, + 'total_count': len(persons) + } + + def get_persons_list(self): + """获取人员列表""" + return [ + { + 'person_id': p['person_id'], + 'name': p['name'], + 'visit_count': p['visit_count'], + 'first_seen': p['first_seen'], + 'last_seen': p['last_seen'] + } + for p in self.persons.values() + ] + + def get_stats(self): + """获取统计信息""" + return { + 'total_persons': len(self.persons), + 'total_detections': self.total_detections, + 'known_persons_detected': self.known_persons_detected, + 'new_persons_added': self.new_persons_added, + 'recognition_rate': self.known_persons_detected / max(self.total_detections, 1) + } + + def reset(self): + """重置统计""" + self.total_detections = 0 + self.known_persons_detected = 0 + self.new_persons_added = 0 + + +# 全局实例 +person_manager = PersonManager() + + +if __name__ == "__main__": + # 测试 + import sys + + if len(sys.argv) >= 2: + test_image = sys.argv[1] + + print(f"[Test] Analyzing: {test_image}") + result = person_manager.analyze_image(test_image) + + print(f"[Test] Faces detected: {len(result['faces'])}") + print(f"[Test] Persons: {result['total_count']}") + print(f"[Test] New: {result['new_count']}, Known: {result['known_count']}") + + for person in result['persons']: + status = "NEW" if person['is_new'] else "KNOWN" + print(f" - [{status}] {person['name']} (confidence: {person['confidence']:.2f})") + else: + print("Usage: python person_manager.py ") \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 43d3077..088c713 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,9 @@ opencv-python>=4.8.0 fastapi>=0.100.0 uvicorn>=0.23.0 -requests>=2.31.0 \ No newline at end of file +requests>=2.31.0 +numpy>=1.20.0 + +# Optional: More accurate face detection and recognition +# mediapipe>=0.10.0 +# face-recognition>=1.7.0 (requires dlib, may need manual install on Windows) \ No newline at end of file diff --git a/web/app.py b/web/app.py index 490373b..83b55d9 100644 --- a/web/app.py +++ b/web/app.py @@ -165,6 +165,35 @@ async def analyze_unanalyzed(): return {"results": results} +@app.get("/api/persons") +async def get_persons(): + """获取人员列表""" + from person_manager import person_manager + return {"persons": person_manager.get_persons_list(), "stats": person_manager.get_stats()} + + +@app.delete("/api/persons/{person_id}") +async def delete_person(person_id: str): + """删除人员""" + from person_manager import person_manager + if person_id in person_manager.persons: + del person_manager.persons[person_id] + person_manager._save_persons_db() + return {"success": True} + raise HTTPException(status_code=404, detail="人员不存在") + + +@app.post("/api/persons/{person_id}/rename") +async def rename_person(person_id: str, name: str): + """重命名人员""" + from person_manager import person_manager + if person_id in person_manager.persons: + person_manager.persons[person_id]['name'] = name + person_manager._save_persons_db() + return {"success": True, "name": name} + raise HTTPException(status_code=404, detail="人员不存在") + + # ============== 图片 API ============== @app.get("/api/images") diff --git a/web/static/app.js b/web/static/app.js index 0a41995..455d08b 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -405,6 +405,96 @@ function closeSettingsModal() { document.getElementById('settings-modal').classList.remove('active'); } +// Persons Management +function openPersonsModal() { + loadPersonsList(); + document.getElementById('persons-modal').classList.add('active'); +} + +function closePersonsModal() { + document.getElementById('persons-modal').classList.remove('active'); +} + +function loadPersonsList() { + fetch(API_BASE + '/api/persons') + .then(function(res) { return res.json(); }) + .then(function(data) { + var statsDiv = document.getElementById('persons-stats'); + var listDiv = document.getElementById('persons-list'); + + // 统计信息 + statsDiv.innerHTML = '
' + + 'Total: ' + data.stats.total_persons + '' + + 'Detected: ' + data.stats.total_detections + '' + + 'Known: ' + data.stats.known_persons_detected + '' + + 'New: ' + data.stats.new_persons_added + '' + + '
'; + + // 人员列表 + if (data.persons.length === 0) { + listDiv.innerHTML = '

No persons recorded yet

'; + return; + } + + listDiv.innerHTML = ''; + data.persons.forEach(function(person) { + var item = document.createElement('div'); + item.className = 'person-item'; + + var firstSeen = new Date(person.first_seen).toLocaleDateString(); + var lastSeen = new Date(person.last_seen).toLocaleDateString(); + + item.innerHTML = '
' + + '' + person.name + '' + + '' + person.person_id + '' + + '
' + + '
' + + 'Visits: ' + person.visit_count + '' + + 'First: ' + firstSeen + '' + + 'Last: ' + lastSeen + '' + + '
' + + '
' + + '' + + '' + + '
'; + + listDiv.appendChild(item); + }); + }) + .catch(function(e) { console.error('Load persons failed:', e); }); +} + +function renamePerson(personId) { + var newName = prompt('Enter new name:'); + if (!newName) return; + + fetch(API_BASE + '/api/persons/' + personId + '/rename?name=' + encodeURIComponent(newName), { + method: 'POST' + }) + .then(function(res) { return res.json(); }) + .then(function(data) { + if (data.success) { + showToast('Renamed to ' + newName, 1500); + loadPersonsList(); + } + }) + .catch(function(e) { showToast('Error: ' + e.message, 3000); }); +} + +function deletePerson(personId) { + if (!confirm('Delete this person?')) return; + + fetch(API_BASE + '/api/persons/' + personId, {method: 'DELETE'}) + .then(function(res) { return res.json(); }) + .then(function(data) { + if (data.success) { + showToast('Person deleted', 1500); + loadPersonsList(); + } + }) + .catch(function(e) { showToast('Error: ' + e.message, 3000); }); +} + function loadSettingsForm() { fetch(API_BASE + '/api/config') .then(function(res) { return res.json(); }) diff --git a/web/static/index.html b/web/static/index.html index e5f5028..1b21255 100644 --- a/web/static/index.html +++ b/web/static/index.html @@ -29,6 +29,11 @@ +
+

👥 人员库

+ +
+

⚙️ 系统设置

@@ -155,6 +160,22 @@
+ + +