237 lines
9.2 KiB
Python
237 lines
9.2 KiB
Python
"""未知菜品待学习:持久化存储与管理。"""
|
||
import os
|
||
import json
|
||
import shutil
|
||
import threading
|
||
import datetime
|
||
import numpy as np
|
||
|
||
|
||
# 新未知图与库内已有 pending 特征相似度 ≥ 此值则不入待学习库
|
||
PENDING_DEDUP_SIMILARITY = 0.90
|
||
|
||
|
||
class PendingLearningStore:
|
||
STATUS_PENDING = "pending"
|
||
STATUS_LEARNED = "learned"
|
||
STATUS_IGNORED = "ignored"
|
||
|
||
def __init__(self, base_dir):
|
||
self.base_dir = base_dir
|
||
self.pending_dir = os.path.join(base_dir, "pending_images")
|
||
self.data_file = os.path.join(base_dir, "pending_learning.json")
|
||
self.lock = threading.Lock()
|
||
self._items = []
|
||
self._next_id = 1
|
||
os.makedirs(self.pending_dir, exist_ok=True)
|
||
self._load()
|
||
|
||
def _load(self):
|
||
if not os.path.exists(self.data_file):
|
||
return
|
||
try:
|
||
with open(self.data_file, "r", encoding="utf-8") as f:
|
||
data = json.load(f)
|
||
self._items = data.get("items", [])
|
||
self._next_id = data.get("next_id", 1)
|
||
for item in self._items:
|
||
if item.get("id") is None:
|
||
continue
|
||
self._next_id = max(self._next_id, item["id"] + 1)
|
||
except Exception as e:
|
||
print(f"加载待学习库失败: {e}")
|
||
self._items = []
|
||
self._next_id = 1
|
||
|
||
def _save(self):
|
||
try:
|
||
with open(self.data_file, "w", encoding="utf-8") as f:
|
||
json.dump({"next_id": self._next_id, "items": self._items}, f, ensure_ascii=False, indent=2)
|
||
except Exception as e:
|
||
print(f"保存待学习库失败: {e}")
|
||
|
||
def _find_pending_by_path(self, image_path):
|
||
norm = os.path.normcase(os.path.abspath(image_path))
|
||
for item in self._items:
|
||
if item.get("status") != self.STATUS_PENDING:
|
||
continue
|
||
stored = item.get("source_path") or item.get("image_path")
|
||
if stored and os.path.normcase(os.path.abspath(stored)) == norm:
|
||
return item
|
||
return None
|
||
|
||
def _is_similar_pending(self, feature, threshold=PENDING_DEDUP_SIMILARITY):
|
||
"""若与任一 pending 特征相似度 ≥ threshold,返回 (item, sim);否则 (None, 0)。"""
|
||
feat = np.asarray(feature, dtype=np.float32).reshape(-1)
|
||
best_item = None
|
||
best_sim = 0.0
|
||
for item in self._items:
|
||
if item.get("status") != self.STATUS_PENDING:
|
||
continue
|
||
stored = item.get("feature")
|
||
if stored is None:
|
||
continue
|
||
other = np.asarray(stored, dtype=np.float32).reshape(-1)
|
||
if other.shape != feat.shape:
|
||
continue
|
||
sim = float(np.dot(feat, other))
|
||
if sim >= threshold and sim >= best_sim:
|
||
best_sim = sim
|
||
best_item = item
|
||
return best_item, best_sim
|
||
|
||
def add(self, image_path, feature, suggested_category=None, score=None, reason=None):
|
||
if not image_path or not os.path.exists(image_path):
|
||
return None
|
||
feature = np.asarray(feature, dtype=np.float32)
|
||
new_sug = (suggested_category or "").strip()
|
||
|
||
with self.lock:
|
||
existing = self._find_pending_by_path(image_path)
|
||
if existing:
|
||
old_sug = (existing.get("suggested_category") or "").strip()
|
||
# 同一路径但建议菜名已变:视为新的未知菜,不覆盖旧记录
|
||
if old_sug and new_sug and old_sug != new_sug:
|
||
existing = None
|
||
else:
|
||
existing["suggested_category"] = suggested_category or existing.get("suggested_category")
|
||
existing["score"] = score if score is not None else existing.get("score")
|
||
existing["feature"] = feature.tolist()
|
||
existing["capture_time"] = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||
if reason:
|
||
existing["reason"] = reason
|
||
# 刷新图片副本,避免源文件被覆盖后内容过期
|
||
try:
|
||
dest = existing.get("image_path")
|
||
if dest and dest.startswith(self.pending_dir):
|
||
shutil.copy2(image_path, dest)
|
||
except Exception:
|
||
pass
|
||
self._save()
|
||
return existing["id"]
|
||
|
||
# 与库内已有图相似度 ≥ 0.90 → 不加入待学习库
|
||
similar, sim = self._is_similar_pending(feature, PENDING_DEDUP_SIMILARITY)
|
||
if similar is not None:
|
||
print(
|
||
f"[pending] skip add (sim={sim:.3f}≥{PENDING_DEDUP_SIMILARITY:.2f} "
|
||
f"vs #{similar.get('id')})"
|
||
)
|
||
return None
|
||
|
||
pending_id = self._next_id
|
||
self._next_id += 1
|
||
stamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||
ext = os.path.splitext(image_path)[1] or ".jpeg"
|
||
dest_name = f"pending_{stamp}_{pending_id}{ext}"
|
||
dest_path = os.path.join(self.pending_dir, dest_name)
|
||
try:
|
||
shutil.copy2(image_path, dest_path)
|
||
except Exception as e:
|
||
print(f"复制待学习图片失败: {e}")
|
||
dest_path = image_path
|
||
|
||
item = {
|
||
"id": pending_id,
|
||
"image_path": dest_path,
|
||
"source_path": image_path,
|
||
"feature": feature.tolist(),
|
||
"suggested_category": suggested_category,
|
||
"score": float(score) if score is not None else None,
|
||
"status": self.STATUS_PENDING,
|
||
"capture_time": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||
"learned_category": None,
|
||
"reason": reason,
|
||
}
|
||
self._items.append(item)
|
||
self._save()
|
||
return pending_id
|
||
|
||
def get(self, pending_id):
|
||
for item in self._items:
|
||
if item.get("id") == pending_id:
|
||
return item.copy()
|
||
return None
|
||
|
||
def get_list(self, status=STATUS_PENDING):
|
||
rows = []
|
||
for item in self._items:
|
||
if item.get("status") != status:
|
||
continue
|
||
row = item.copy()
|
||
feat = row.get("feature")
|
||
if feat is not None:
|
||
row["feature"] = np.array(feat, dtype=np.float32)
|
||
rows.append(row)
|
||
rows.sort(key=lambda x: x.get("capture_time", ""), reverse=True)
|
||
return rows
|
||
|
||
def count(self, status=STATUS_PENDING):
|
||
return sum(1 for item in self._items if item.get("status") == status)
|
||
|
||
def mark_learned(self, pending_id, category):
|
||
with self.lock:
|
||
for item in self._items:
|
||
if item.get("id") == pending_id:
|
||
item["status"] = self.STATUS_LEARNED
|
||
item["learned_category"] = category
|
||
item["learn_time"] = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||
self._save()
|
||
return True
|
||
return False
|
||
|
||
def update_suggestion(self, pending_id, suggested_category=None, score=None):
|
||
"""刷新仍待处理项的建议菜名 / 相似度(不改 status)。"""
|
||
with self.lock:
|
||
for item in self._items:
|
||
if item.get("id") != pending_id:
|
||
continue
|
||
if item.get("status") != self.STATUS_PENDING:
|
||
return False
|
||
if suggested_category is not None:
|
||
item["suggested_category"] = suggested_category
|
||
if score is not None:
|
||
item["score"] = float(score)
|
||
self._save()
|
||
return True
|
||
return False
|
||
|
||
def mark_ignored(self, pending_id):
|
||
with self.lock:
|
||
for item in self._items:
|
||
if item.get("id") == pending_id:
|
||
item["status"] = self.STATUS_IGNORED
|
||
self._save()
|
||
return True
|
||
return False
|
||
|
||
def delete(self, pending_id, remove_image=True):
|
||
with self.lock:
|
||
for idx, item in enumerate(self._items):
|
||
if item.get("id") != pending_id:
|
||
continue
|
||
if remove_image:
|
||
path = item.get("image_path")
|
||
if path and os.path.exists(path) and path.startswith(self.pending_dir):
|
||
try:
|
||
os.remove(path)
|
||
except Exception as e:
|
||
print(f"删除待学习图片失败: {e}")
|
||
del self._items[idx]
|
||
self._save()
|
||
return True
|
||
return False
|
||
|
||
def clear_all(self):
|
||
with self.lock:
|
||
for item in self._items:
|
||
path = item.get("image_path")
|
||
if path and os.path.exists(path) and path.startswith(self.pending_dir):
|
||
try:
|
||
os.remove(path)
|
||
except Exception:
|
||
pass
|
||
self._items = []
|
||
self._next_id = 1
|
||
self._save()
|