3 Commits

13 changed files with 87 additions and 5 deletions

11
.gitignore vendored Normal file
View File

@@ -0,0 +1,11 @@
# 运行时生成的数据目录
data/
# Python
__pycache__/
*.pyc
*.pyo
# IDE
.vscode/
.idea/

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -537,7 +537,7 @@ class PersonManager:
if save_new_person and confirmed_change: if save_new_person and confirmed_change:
for person in identified_persons: for person in identified_persons:
if person['is_new'] and len(self.persons) < self.config['max_persons']: if person['is_new'] and len(self.persons) < self.config['max_persons']:
# 保存人脸特征 # 保存人脸特征和人脸图片
x, y, w, h = person['bbox'] x, y, w, h = person['bbox']
face_region = image[y:y+int(h*0.4), x:x+w] face_region = image[y:y+int(h*0.4), x:x+w]
@@ -548,8 +548,8 @@ class PersonManager:
person['person_id'] = person_id person['person_id'] = person_id
person['name'] = f"Person #{len(self.persons) + 1}" person['name'] = f"Person #{len(self.persons) + 1}"
# 保存到人员库 # 保存到人员库(包括人脸图片)
self.add_new_person_with_encoding(person_id, encoding, person['name']) self.add_new_person_with_encoding(person_id, encoding, person['name'], face_region)
# 清空缓冲区,更新状态 # 清空缓冲区,更新状态
self.confirmation_buffer = {key: self.confirmation_buffer[key]} self.confirmation_buffer = {key: self.confirmation_buffer[key]}
@@ -580,13 +580,14 @@ class PersonManager:
'detection_source': 'yolo' 'detection_source': 'yolo'
} }
def add_new_person_with_encoding(self, person_id, encoding, name=None): def add_new_person_with_encoding(self, person_id, encoding, name=None, face_image=None):
"""保存新人员到库(已有 encoding """保存新人员到库(已有 encoding
Args: Args:
person_id: 人员ID person_id: 人员ID
encoding: 特征向量 encoding: 特征向量
name: 名称 name: 名称
face_image: 人脸图片(可选,用于保存人脸图片)
Returns: Returns:
dict: 人员信息 dict: 人员信息
@@ -594,10 +595,18 @@ class PersonManager:
if name is None: if name is None:
name = person_id name = person_id
# 保存人脸图片
face_path = ''
if face_image is not None and face_image.size > 0:
face_path = str(self.faces_dir / f"{person_id}.jpg")
cv2.imwrite(face_path, face_image)
print(f"[PersonManager] Face image saved: {face_path}")
person_data = { person_data = {
'person_id': person_id, 'person_id': person_id,
'name': name, 'name': name,
'face_encoding': encoding.tolist() if isinstance(encoding, np.ndarray) else encoding, 'face_encoding': encoding.tolist() if isinstance(encoding, np.ndarray) else encoding,
'face_path': face_path,
'first_seen': datetime.datetime.now().isoformat(), 'first_seen': datetime.datetime.now().isoformat(),
'last_seen': datetime.datetime.now().isoformat(), 'last_seen': datetime.datetime.now().isoformat(),
'visit_count': 1 'visit_count': 1
@@ -620,6 +629,7 @@ class PersonManager:
'visit_count': p['visit_count'], 'visit_count': p['visit_count'],
'first_seen': p['first_seen'], # 已经精确到秒 'first_seen': p['first_seen'], # 已经精确到秒
'last_seen': p['last_seen'], # 已经精确到秒 'last_seen': p['last_seen'], # 已经精确到秒
'face_path': p.get('face_path', ''), # 人脸图片路径
} }
for p in self.persons.values() for p in self.persons.values()
] ]

Binary file not shown.

View File

@@ -195,6 +195,25 @@ async def rename_person(person_id: str, name: str):
raise HTTPException(status_code=404, detail="人员不存在") raise HTTPException(status_code=404, detail="人员不存在")
@app.get("/api/persons/{person_id}/face")
async def get_person_face(person_id: str):
"""获取人员人脸图片"""
from person_manager import person_manager
from pathlib import Path
if person_id in person_manager.persons:
face_path = person_manager.persons[person_id].get('face_path', '')
if face_path and Path(face_path).exists():
return FileResponse(face_path)
# 检查 faces_dir 中的默认图片
face_file = person_manager.faces_dir / f"{person_id}.jpg"
if face_file.exists():
return FileResponse(str(face_file))
raise HTTPException(status_code=404, detail="人员图片不存在")
@app.get("/api/stats/daily") @app.get("/api/stats/daily")
async def get_daily_stats(date: str = None): async def get_daily_stats(date: str = None):
"""获取每日统计数据""" """获取每日统计数据"""

View File

@@ -630,7 +630,15 @@ function loadPersonsList() {
var firstSeen = new Date(person.first_seen).toLocaleString(); var firstSeen = new Date(person.first_seen).toLocaleString();
var lastSeen = new Date(person.last_seen).toLocaleString(); var lastSeen = new Date(person.last_seen).toLocaleString();
item.innerHTML = '<div class="person-info">' + // 构建人员图片URL
var faceImgHtml = '<div class="person-face-placeholder">No Image</div>';
if (person.face_path) {
faceImgHtml = '<img class="person-face-img" src="/api/persons/' + person.person_id + '/face" alt="' + person.name + '" onerror="this.parentElement.innerHTML=\'<div class=&quot;person-face-placeholder&quot;>No Image</div>\'">';
}
item.innerHTML = '<div class="person-face">' + faceImgHtml + '</div>' +
'<div class="person-content">' +
'<div class="person-info">' +
'<span class="person-name">' + person.name + '</span>' + '<span class="person-name">' + person.name + '</span>' +
'<span class="person-id">' + person.person_id + '</span>' + '<span class="person-id">' + person.person_id + '</span>' +
'</div>' + '</div>' +
@@ -642,6 +650,7 @@ function loadPersonsList() {
'<div class="person-actions">' + '<div class="person-actions">' +
'<button onclick="renamePerson(\'' + person.person_id + '\')" class="btn-small">Rename</button>' + '<button onclick="renamePerson(\'' + person.person_id + '\')" class="btn-small">Rename</button>' +
'<button onclick="deletePerson(\'' + person.person_id + '\')" class="btn-small btn-danger">Delete</button>' + '<button onclick="deletePerson(\'' + person.person_id + '\')" class="btn-small btn-danger">Delete</button>' +
'</div>' +
'</div>'; '</div>';
listDiv.appendChild(item); listDiv.appendChild(item);

View File

@@ -751,6 +751,38 @@ button {
margin-bottom: 8px; margin-bottom: 8px;
border-radius: 8px; border-radius: 8px;
border-left: 4px solid #2196F3; border-left: 4px solid #2196F3;
display: flex;
gap: 12px;
align-items: flex-start;
}
.person-face {
width: 80px;
height: 80px;
border-radius: 8px;
overflow: hidden;
background: #e0e0e0;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.person-face-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.person-face-placeholder {
color: #888;
font-size: 12px;
text-align: center;
}
.person-content {
flex: 1;
min-width: 0;
} }
.person-info { .person-info {
@@ -771,6 +803,7 @@ button {
.person-stats { .person-stats {
display: flex; display: flex;
flex-wrap: wrap;
gap: 10px; gap: 10px;
color: #555; color: #555;
font-size: 12px; font-size: 12px;