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