494 lines
21 KiB
Python
494 lines
21 KiB
Python
"""Zone gallery — file IO over data/zones/zone_{zid}/place_{pid}/.
|
|
|
|
A two-level hierarchy that replaces the flat place gallery:
|
|
|
|
Zone = name + description (a region, e.g. "Ground Floor")
|
|
└─ Place = name + description + optional reference photos
|
|
+ optional linked face ids (people associated with the place)
|
|
|
|
Layout:
|
|
zones/
|
|
zone_{zid}/
|
|
meta.json {name, description, added_at, linked_map?}
|
|
place_{pid}/
|
|
meta.json {name, description, face_ids:[int], added_at, nav_place?}
|
|
place_1.jpg ← optional reference photos (0..N)
|
|
place_2.png
|
|
|
|
`linked_map` (optional) binds a zone to a nav2 map .db; `nav_place` (optional)
|
|
links a place to a nav2 place name in that map so it can be DRIVEN to. Both are
|
|
only written when set (absent = None on read), so old metadata stays valid.
|
|
|
|
`face_ids` reference enrolled faces in the SEPARATE face gallery
|
|
(data/faces/face_{id}); this module only stores the ids — name/photo
|
|
resolution is done by the caller (route layer + Gemini primer).
|
|
|
|
Reference photos let Gemini visually recognise the place (and, later, let the
|
|
robot navigate to it). A place needs only a name + description; photos and
|
|
linked faces are both optional. Thread-safe via a single internal RLock.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import json
|
|
import re
|
|
import threading
|
|
import zipfile
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Iterable
|
|
|
|
from Project.Sanad.core.logger import get_logger
|
|
|
|
log = get_logger("zone_gallery")
|
|
|
|
_ZONE_DIR_RE = re.compile(r"^zone_(\d+)$")
|
|
_PLACE_DIR_RE = re.compile(r"^place_(\d+)$")
|
|
ALLOWED_EXTS = {".jpg", ".jpeg", ".png"}
|
|
SAMPLE_NAME_RE = re.compile(r"^place_(\d+)\.(jpg|jpeg|png)$", re.IGNORECASE)
|
|
|
|
|
|
@dataclass
|
|
class PlaceEntry:
|
|
id: int
|
|
zone_id: int
|
|
name: str | None
|
|
added_at: str | None
|
|
dir: Path
|
|
description: str | None = None
|
|
face_ids: list[int] = field(default_factory=list)
|
|
sample_paths: list[Path] = field(default_factory=list)
|
|
# Name of the nav2 saved place (in the zone's linked map) this vision place
|
|
# drives to. None = announce/recognise only, no driving.
|
|
nav_place: str | None = None
|
|
|
|
def to_dict(self) -> dict:
|
|
return {
|
|
"id": self.id,
|
|
"zone_id": self.zone_id,
|
|
"name": self.name,
|
|
"description": self.description,
|
|
"face_ids": list(self.face_ids),
|
|
"nav_place": self.nav_place,
|
|
"added_at": self.added_at,
|
|
"photos": [
|
|
{"name": p.name, "size_bytes": p.stat().st_size}
|
|
for p in self.sample_paths if p.exists()
|
|
],
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class ZoneEntry:
|
|
id: int
|
|
name: str | None
|
|
added_at: str | None
|
|
dir: Path
|
|
description: str | None = None
|
|
places: list[PlaceEntry] = field(default_factory=list)
|
|
# nav2 map (.db basename, e.g. "office.db") this zone is bound to. A zone
|
|
# with a linked map can be driven in via "Gemini Nav"; its places link to
|
|
# that map's nav2 places.
|
|
linked_map: str | None = None
|
|
|
|
def to_dict(self) -> dict:
|
|
return {
|
|
"id": self.id,
|
|
"name": self.name,
|
|
"description": self.description,
|
|
"linked_map": self.linked_map,
|
|
"added_at": self.added_at,
|
|
"places": [p.to_dict() for p in self.places],
|
|
}
|
|
|
|
|
|
class ZoneGallery:
|
|
"""File-system backed zone/place gallery rooted at `root` (data/zones/)."""
|
|
|
|
def __init__(self, root: Path | str) -> None:
|
|
self.root = Path(root)
|
|
self._lock = threading.RLock()
|
|
|
|
# ── paths ────────────────────────────────────────────────
|
|
|
|
def _ensure_root(self) -> None:
|
|
self.root.mkdir(parents=True, exist_ok=True)
|
|
|
|
def _zone_dir(self, zone_id: int) -> Path:
|
|
return self.root / f"zone_{zone_id}"
|
|
|
|
def _place_dir(self, zone_id: int, place_id: int) -> Path:
|
|
return self.root / f"zone_{zone_id}" / f"place_{place_id}"
|
|
|
|
def _iter_zone_dirs(self) -> Iterable[tuple[int, Path]]:
|
|
if not self.root.exists():
|
|
return
|
|
for child in sorted(self.root.iterdir()):
|
|
if not child.is_dir():
|
|
continue
|
|
m = _ZONE_DIR_RE.match(child.name)
|
|
if m:
|
|
yield int(m.group(1)), child
|
|
|
|
def _iter_place_dirs(self, zone_dir: Path) -> Iterable[tuple[int, Path]]:
|
|
if not zone_dir.exists():
|
|
return
|
|
for child in sorted(zone_dir.iterdir()):
|
|
if not child.is_dir():
|
|
continue
|
|
m = _PLACE_DIR_RE.match(child.name)
|
|
if m:
|
|
yield int(m.group(1)), child
|
|
|
|
def _samples_in(self, place_dir: Path) -> list[Path]:
|
|
return [p for p in sorted(place_dir.iterdir())
|
|
if p.is_file() and p.suffix.lower() in ALLOWED_EXTS]
|
|
|
|
# ── meta ─────────────────────────────────────────────────
|
|
|
|
def _zone_meta(self, zone_dir: Path) -> tuple[str | None, str | None, str | None, str | None]:
|
|
"""Returns (name, description, added_at, linked_map)."""
|
|
meta_path = zone_dir / "meta.json"
|
|
if not meta_path.exists():
|
|
return None, None, None, None
|
|
try:
|
|
data = json.loads(meta_path.read_text(encoding="utf-8"))
|
|
except Exception:
|
|
return None, None, None, None
|
|
return ((data.get("name") or None), (data.get("description") or None),
|
|
data.get("added_at"), (data.get("linked_map") or None))
|
|
|
|
def _place_meta(self, place_dir: Path) -> tuple[str | None, str | None, list[int], str | None, str | None]:
|
|
"""Returns (name, description, face_ids, added_at, nav_place)."""
|
|
meta_path = place_dir / "meta.json"
|
|
if not meta_path.exists():
|
|
return None, None, [], None, None
|
|
try:
|
|
data = json.loads(meta_path.read_text(encoding="utf-8"))
|
|
except Exception:
|
|
return None, None, [], None, None
|
|
raw_ids = data.get("face_ids") or []
|
|
face_ids: list[int] = []
|
|
for x in raw_ids:
|
|
try:
|
|
face_ids.append(int(x))
|
|
except (TypeError, ValueError):
|
|
continue
|
|
return ((data.get("name") or None), (data.get("description") or None),
|
|
face_ids, data.get("added_at"), (data.get("nav_place") or None))
|
|
|
|
def _write_zone_meta(self, zone_dir: Path, name, description,
|
|
added_at=None, linked_map=None) -> None:
|
|
meta: dict = {}
|
|
if name:
|
|
meta["name"] = name
|
|
if description:
|
|
meta["description"] = description
|
|
if linked_map:
|
|
meta["linked_map"] = linked_map
|
|
meta["added_at"] = added_at or datetime.now().isoformat(timespec="seconds")
|
|
(zone_dir / "meta.json").write_text(
|
|
json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
|
|
def _write_place_meta(self, place_dir: Path, name, description,
|
|
face_ids=None, added_at=None, nav_place=None) -> None:
|
|
meta: dict = {}
|
|
if name:
|
|
meta["name"] = name
|
|
if description:
|
|
meta["description"] = description
|
|
meta["face_ids"] = [int(x) for x in (face_ids or [])]
|
|
if nav_place:
|
|
meta["nav_place"] = nav_place
|
|
meta["added_at"] = added_at or datetime.now().isoformat(timespec="seconds")
|
|
(place_dir / "meta.json").write_text(
|
|
json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
|
|
# ── read ─────────────────────────────────────────────────
|
|
|
|
def _build_place(self, zone_id: int, place_id: int, place_dir: Path) -> PlaceEntry:
|
|
name, desc, face_ids, added, nav_place = self._place_meta(place_dir)
|
|
return PlaceEntry(
|
|
id=place_id, zone_id=zone_id, name=name, description=desc,
|
|
face_ids=face_ids, added_at=added, dir=place_dir,
|
|
sample_paths=self._samples_in(place_dir), nav_place=nav_place,
|
|
)
|
|
|
|
def _build_zone(self, zone_id: int, zone_dir: Path) -> ZoneEntry:
|
|
name, desc, added, linked_map = self._zone_meta(zone_dir)
|
|
places = [self._build_place(zone_id, pid, pdir)
|
|
for pid, pdir in self._iter_place_dirs(zone_dir)]
|
|
return ZoneEntry(id=zone_id, name=name, description=desc,
|
|
added_at=added, dir=zone_dir, places=places,
|
|
linked_map=linked_map)
|
|
|
|
def list_zones(self) -> list[ZoneEntry]:
|
|
with self._lock:
|
|
return [self._build_zone(zid, zdir) for zid, zdir in self._iter_zone_dirs()]
|
|
|
|
def get_zone(self, zone_id: int) -> ZoneEntry | None:
|
|
with self._lock:
|
|
zd = self._zone_dir(zone_id)
|
|
return self._build_zone(zone_id, zd) if zd.is_dir() else None
|
|
|
|
def get_place(self, zone_id: int, place_id: int) -> PlaceEntry | None:
|
|
with self._lock:
|
|
pd = self._place_dir(zone_id, place_id)
|
|
return self._build_place(zone_id, place_id, pd) if pd.is_dir() else None
|
|
|
|
def get_photo(self, zone_id: int, place_id: int, photo_name: str) -> Path | None:
|
|
with self._lock:
|
|
pd = self._place_dir(zone_id, place_id)
|
|
if not pd.is_dir():
|
|
return None
|
|
p = pd / photo_name
|
|
try:
|
|
p.resolve().relative_to(pd.resolve())
|
|
except ValueError:
|
|
return None
|
|
if not p.exists() or p.suffix.lower() not in ALLOWED_EXTS:
|
|
return None
|
|
return p
|
|
|
|
# ── ids ──────────────────────────────────────────────────
|
|
|
|
def next_zone_id(self) -> int:
|
|
with self._lock:
|
|
return max((zid for zid, _ in self._iter_zone_dirs()), default=0) + 1
|
|
|
|
def next_place_id(self, zone_id: int) -> int:
|
|
with self._lock:
|
|
zd = self._zone_dir(zone_id)
|
|
return max((pid for pid, _ in self._iter_place_dirs(zd)), default=0) + 1
|
|
|
|
def _next_sample_name(self, place_dir: Path, ext: str) -> str:
|
|
max_n = 0
|
|
for p in self._samples_in(place_dir):
|
|
m = SAMPLE_NAME_RE.match(p.name)
|
|
if m:
|
|
max_n = max(max_n, int(m.group(1)))
|
|
return f"place_{max_n + 1}{ext.lower()}"
|
|
|
|
@staticmethod
|
|
def _detect_ext(data: bytes) -> str:
|
|
if len(data) >= 8 and data[:8] == b"\x89PNG\r\n\x1a\n":
|
|
return ".png"
|
|
return ".jpg"
|
|
|
|
# ── zone write ───────────────────────────────────────────
|
|
|
|
def create_zone(self, name: str | None = None,
|
|
description: str | None = None) -> ZoneEntry:
|
|
with self._lock:
|
|
self._ensure_root()
|
|
zid = self.next_zone_id()
|
|
zd = self._zone_dir(zid)
|
|
zd.mkdir(parents=True, exist_ok=False)
|
|
self._write_zone_meta(zd, (name or "").strip() or None,
|
|
(description or "").strip() or None)
|
|
log.info("Created zone_%d (name=%s)", zid, name or "(unnamed)")
|
|
return self._build_zone(zid, zd)
|
|
|
|
def rename_zone(self, zone_id: int, name: str | None) -> None:
|
|
with self._lock:
|
|
zd = self._zone_dir(zone_id)
|
|
if not zd.is_dir():
|
|
raise FileNotFoundError(f"zone_{zone_id} not found")
|
|
_, desc, added, linked_map = self._zone_meta(zd)
|
|
self._write_zone_meta(zd, (name or "").strip() or None, desc,
|
|
added_at=added, linked_map=linked_map)
|
|
log.info("Renamed zone_%d → %s", zone_id, name or "(unnamed)")
|
|
|
|
def describe_zone(self, zone_id: int, description: str | None) -> None:
|
|
with self._lock:
|
|
zd = self._zone_dir(zone_id)
|
|
if not zd.is_dir():
|
|
raise FileNotFoundError(f"zone_{zone_id} not found")
|
|
name, _, added, linked_map = self._zone_meta(zd)
|
|
self._write_zone_meta(zd, name, (description or "").strip() or None,
|
|
added_at=added, linked_map=linked_map)
|
|
log.info("Described zone_%d", zone_id)
|
|
|
|
def set_zone_map(self, zone_id: int, linked_map: str | None) -> None:
|
|
"""Bind (or unbind, with None/'') the zone to a nav2 map .db basename."""
|
|
with self._lock:
|
|
zd = self._zone_dir(zone_id)
|
|
if not zd.is_dir():
|
|
raise FileNotFoundError(f"zone_{zone_id} not found")
|
|
name, desc, added, _ = self._zone_meta(zd)
|
|
self._write_zone_meta(zd, name, desc, added_at=added,
|
|
linked_map=(linked_map or "").strip() or None)
|
|
log.info("Linked zone_%d → map %s", zone_id, linked_map or "(none)")
|
|
|
|
def delete_zone(self, zone_id: int) -> None:
|
|
import shutil
|
|
with self._lock:
|
|
zd = self._zone_dir(zone_id)
|
|
if not zd.is_dir():
|
|
raise FileNotFoundError(f"zone_{zone_id} not found")
|
|
shutil.rmtree(zd)
|
|
log.info("Deleted zone_%d (and its places)", zone_id)
|
|
|
|
# ── place write ──────────────────────────────────────────
|
|
|
|
def create_place(self, zone_id: int, name: str | None = None,
|
|
description: str | None = None,
|
|
face_ids: list[int] | None = None,
|
|
image_bytes_list: list[bytes] | None = None,
|
|
nav_place: str | None = None) -> PlaceEntry:
|
|
with self._lock:
|
|
zd = self._zone_dir(zone_id)
|
|
if not zd.is_dir():
|
|
raise FileNotFoundError(f"zone_{zone_id} not found")
|
|
pid = self.next_place_id(zone_id)
|
|
pd = self._place_dir(zone_id, pid)
|
|
pd.mkdir(parents=True, exist_ok=False)
|
|
for idx, data in enumerate(image_bytes_list or [], start=1):
|
|
(pd / f"place_{idx}{self._detect_ext(data)}").write_bytes(data)
|
|
self._write_place_meta(pd, (name or "").strip() or None,
|
|
(description or "").strip() or None, face_ids or [],
|
|
nav_place=(nav_place or "").strip() or None)
|
|
log.info("Created zone_%d/place_%d (name=%s, photos=%d, faces=%d, nav=%s)",
|
|
zone_id, pid, name or "(unnamed)",
|
|
len(image_bytes_list or []), len(face_ids or []), nav_place or "-")
|
|
return self._build_place(zone_id, pid, pd)
|
|
|
|
def rename_place(self, zone_id: int, place_id: int, name: str | None) -> None:
|
|
with self._lock:
|
|
pd = self._place_dir(zone_id, place_id)
|
|
if not pd.is_dir():
|
|
raise FileNotFoundError(f"zone_{zone_id}/place_{place_id} not found")
|
|
_, desc, fids, added, navp = self._place_meta(pd)
|
|
self._write_place_meta(pd, (name or "").strip() or None, desc, fids,
|
|
added_at=added, nav_place=navp)
|
|
log.info("Renamed zone_%d/place_%d → %s", zone_id, place_id, name or "(unnamed)")
|
|
|
|
def describe_place(self, zone_id: int, place_id: int, description: str | None) -> None:
|
|
with self._lock:
|
|
pd = self._place_dir(zone_id, place_id)
|
|
if not pd.is_dir():
|
|
raise FileNotFoundError(f"zone_{zone_id}/place_{place_id} not found")
|
|
name, _, fids, added, navp = self._place_meta(pd)
|
|
self._write_place_meta(pd, name, (description or "").strip() or None, fids,
|
|
added_at=added, nav_place=navp)
|
|
log.info("Described zone_%d/place_%d", zone_id, place_id)
|
|
|
|
def set_place_faces(self, zone_id: int, place_id: int, face_ids: list[int]) -> None:
|
|
"""Replace the set of linked face ids for a place."""
|
|
with self._lock:
|
|
pd = self._place_dir(zone_id, place_id)
|
|
if not pd.is_dir():
|
|
raise FileNotFoundError(f"zone_{zone_id}/place_{place_id} not found")
|
|
name, desc, _, added, navp = self._place_meta(pd)
|
|
clean = []
|
|
for x in (face_ids or []):
|
|
try:
|
|
clean.append(int(x))
|
|
except (TypeError, ValueError):
|
|
continue
|
|
self._write_place_meta(pd, name, desc, clean, added_at=added, nav_place=navp)
|
|
log.info("Set zone_%d/place_%d faces → %s", zone_id, place_id, clean)
|
|
|
|
def set_place_nav(self, zone_id: int, place_id: int, nav_place: str | None) -> None:
|
|
"""Link (or unlink, with None/'') a place to a nav2 place name in the
|
|
zone's map. This is what makes a vision place drivable."""
|
|
with self._lock:
|
|
pd = self._place_dir(zone_id, place_id)
|
|
if not pd.is_dir():
|
|
raise FileNotFoundError(f"zone_{zone_id}/place_{place_id} not found")
|
|
name, desc, fids, added, _ = self._place_meta(pd)
|
|
self._write_place_meta(pd, name, desc, fids, added_at=added,
|
|
nav_place=(nav_place or "").strip() or None)
|
|
log.info("Linked zone_%d/place_%d → nav place %s",
|
|
zone_id, place_id, nav_place or "(none)")
|
|
|
|
def add_photo(self, zone_id: int, place_id: int, image_bytes: bytes) -> str:
|
|
with self._lock:
|
|
pd = self._place_dir(zone_id, place_id)
|
|
if not pd.is_dir():
|
|
raise FileNotFoundError(f"zone_{zone_id}/place_{place_id} not found")
|
|
fname = self._next_sample_name(pd, self._detect_ext(image_bytes))
|
|
(pd / fname).write_bytes(image_bytes)
|
|
log.info("Added %s to zone_%d/place_%d", fname, zone_id, place_id)
|
|
return fname
|
|
|
|
def delete_photo(self, zone_id: int, place_id: int, photo_name: str) -> None:
|
|
with self._lock:
|
|
pd = self._place_dir(zone_id, place_id)
|
|
if not pd.is_dir():
|
|
raise FileNotFoundError(f"zone_{zone_id}/place_{place_id} not found")
|
|
target = self.get_photo(zone_id, place_id, photo_name)
|
|
if target is None:
|
|
raise FileNotFoundError(f"photo {photo_name} not found")
|
|
target.unlink()
|
|
log.info("Deleted %s from zone_%d/place_%d", photo_name, zone_id, place_id)
|
|
|
|
def delete_place(self, zone_id: int, place_id: int) -> None:
|
|
import shutil
|
|
with self._lock:
|
|
pd = self._place_dir(zone_id, place_id)
|
|
if not pd.is_dir():
|
|
raise FileNotFoundError(f"zone_{zone_id}/place_{place_id} not found")
|
|
shutil.rmtree(pd)
|
|
log.info("Deleted zone_%d/place_%d", zone_id, place_id)
|
|
|
|
def zip_place(self, zone_id: int, place_id: int) -> bytes:
|
|
with self._lock:
|
|
pd = self._place_dir(zone_id, place_id)
|
|
if not pd.is_dir():
|
|
raise FileNotFoundError(f"zone_{zone_id}/place_{place_id} not found")
|
|
buf = io.BytesIO()
|
|
with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
|
for p in sorted(pd.iterdir()):
|
|
if p.is_file():
|
|
zf.write(p, arcname=f"zone_{zone_id}_place_{place_id}/{p.name}")
|
|
return buf.getvalue()
|
|
|
|
# ── primer support (used by gemini/script.py) ───────────
|
|
|
|
def load_for_primer(self, max_samples_per_place: int = 3,
|
|
resize_long_side: int = 256
|
|
) -> list[tuple[ZoneEntry, list[tuple[PlaceEntry, list[bytes]]]]]:
|
|
"""Return [(ZoneEntry, [(PlaceEntry, [jpeg_bytes,…]), …]), …].
|
|
|
|
Photo lists may be empty (name+description-only place). Photos are
|
|
resized to longest-side <= resize_long_side, re-encoded JPEG q=85.
|
|
"""
|
|
out: list[tuple[ZoneEntry, list[tuple[PlaceEntry, list[bytes]]]]] = []
|
|
for zone in self.list_zones():
|
|
place_jpegs: list[tuple[PlaceEntry, list[bytes]]] = []
|
|
for place in zone.places:
|
|
jpegs: list[bytes] = []
|
|
for p in place.sample_paths[:max_samples_per_place]:
|
|
try:
|
|
raw = p.read_bytes()
|
|
except OSError:
|
|
continue
|
|
jpegs.append(self._resize_for_primer(raw, resize_long_side) or raw)
|
|
place_jpegs.append((place, jpegs))
|
|
out.append((zone, place_jpegs))
|
|
return out
|
|
|
|
@staticmethod
|
|
def _resize_for_primer(raw: bytes, long_side: int) -> bytes | None:
|
|
try:
|
|
from PIL import Image # type: ignore
|
|
except Exception:
|
|
return None
|
|
try:
|
|
img = Image.open(io.BytesIO(raw))
|
|
img.load()
|
|
if img.mode not in ("RGB", "L"):
|
|
img = img.convert("RGB")
|
|
w, h = img.size
|
|
scale = long_side / max(w, h) if max(w, h) > long_side else 1.0
|
|
if scale < 1.0:
|
|
img = img.resize((max(1, int(w * scale)), max(1, int(h * scale))), Image.LANCZOS)
|
|
buf = io.BytesIO()
|
|
img.save(buf, format="JPEG", quality=85, optimize=True)
|
|
return buf.getvalue()
|
|
except Exception:
|
|
return None
|