From 8c378c58c4c7688916e02eadf5ef77e64ae0ba0d Mon Sep 17 00:00:00 2001 From: "TinyOS Lover (3PO_Kang)" Date: Tue, 14 Oct 2025 00:28:28 +0900 Subject: [PATCH] Add Streamlit interface for motion mosaics --- .../tile_send/motion_mosaic_streamlit.py | 79 ++++ .../tile_send/send_motion_mosaic_no_detect.py | 412 ++++++++++++++++++ 2 files changed, 491 insertions(+) create mode 100644 apps/camera/motion/tile_send/motion_mosaic_streamlit.py create mode 100644 apps/camera/motion/tile_send/send_motion_mosaic_no_detect.py diff --git a/apps/camera/motion/tile_send/motion_mosaic_streamlit.py b/apps/camera/motion/tile_send/motion_mosaic_streamlit.py new file mode 100644 index 000000000..672498148 --- /dev/null +++ b/apps/camera/motion/tile_send/motion_mosaic_streamlit.py @@ -0,0 +1,79 @@ +"""Streamlit interface to display the latest motion mosaic image.""" + +from __future__ import annotations + +from datetime import datetime +from pathlib import Path +from typing import Iterable + +import streamlit as st + +from send_motion_mosaic_no_detect import ( + OUTPUT_MOSAIC, + MOTION_DIR, + KST, + create_mosaic, + latest_images, +) + +STREAMLIT_MOSAIC = OUTPUT_MOSAIC.with_name("streamlit_motion_mosaic.jpg") + + +def _display_mosaic(image_path: Path, caption: str) -> None: + """Render the mosaic image and associated caption in the UI.""" + + st.image(str(image_path), caption=caption, use_column_width=True) + st.caption(f"이미지 소스 디렉터리: {MOTION_DIR}") + + +def _select_recent_images(limit: int = 32) -> Iterable[Path]: + """Return the most recent motion images up to ``limit`` entries.""" + + return latest_images(limit) + + +def _build_caption(image_paths: Iterable[Path]) -> str: + """Generate a caption for the mosaic using the newest image timestamp.""" + + newest = max(image_paths, key=lambda p: p.stat().st_mtime) + timestamp = datetime.fromtimestamp(newest.stat().st_mtime, KST) + formatted = timestamp.strftime("%Y-%m-%d %H:%M:%S %Z") + return f"최신 이미지: {formatted}" + + +st.set_page_config(page_title="BerePi Motion Mosaic", layout="wide") +st.title("Motion Mosaic 뷰어") +st.caption( + "최근 모션 이미지를 모아 만든 모자이크를 확인하려면 아래 버튼을 눌러 주세요." +) + +if "mosaic" not in st.session_state: + st.session_state.mosaic = None + +info_placeholder = st.empty() + +if st.button("최근 사진 요청"): + try: + images = list(_select_recent_images()) + except Exception as exc: # pragma: no cover - runtime feedback + st.error(f"이미지 목록을 불러오는 중 오류가 발생했습니다: {exc}") + else: + if not images: + st.warning( + "모션 이미지가 없습니다. 카메라 또는 Motion 설정을 확인해 주세요." + ) + else: + with st.spinner("모자이크를 생성하고 있습니다..."): + try: + mosaic_path = create_mosaic(images, STREAMLIT_MOSAIC, cols=8, rows=4) + except Exception as exc: # pragma: no cover - runtime feedback + st.error(f"모자이크 생성에 실패했습니다: {exc}") + else: + caption = _build_caption(images) + st.session_state.mosaic = (mosaic_path, caption) +if st.session_state.mosaic is not None: + mosaic_path, caption = st.session_state.mosaic + st.success("모자이크를 불러왔습니다.") + _display_mosaic(mosaic_path, caption) +else: + info_placeholder.info("최근 사진을 확인하려면 '최근 사진 요청' 버튼을 눌러 주세요.") diff --git a/apps/camera/motion/tile_send/send_motion_mosaic_no_detect.py b/apps/camera/motion/tile_send/send_motion_mosaic_no_detect.py new file mode 100644 index 000000000..03c0c546d --- /dev/null +++ b/apps/camera/motion/tile_send/send_motion_mosaic_no_detect.py @@ -0,0 +1,412 @@ +#!/usr/bin/env python3 +"""Generate mosaics from motion images and send them via telegram. + +This variant of ``send_motion_mosaic`` intentionally skips running the +``detect_people`` routine so that it can operate on systems where CPU usage is +at a premium. It still collects the thirty-two most recent images in +``/var/lib/motion`` (or all images for a specific day when ``--date`` is +provided), assembles mosaics, records disk information to InfluxDB, generates +graphs, and sends the artefacts via ``telegram-send`` with progress reporting +from ``rich``. +""" + +from __future__ import annotations + +import os +import re +import subprocess +import time +import shutil +import argparse +from datetime import datetime, timedelta, date +from zoneinfo import ZoneInfo + +from pathlib import Path +from typing import Any, Dict, Iterable, List + +try: + import psutil +except ImportError: # pragma: no cover - optional dependency + psutil = None + +import matplotlib.pyplot as plt +from PIL import Image, ImageDraw, ImageFont +from rich.console import Console +from rich.progress import Progress +# use the classic influxdb client instead of raw HTTP requests +from influxdb import InfluxDBClient + +MOTION_DIR = Path("/var/lib/motion") +OUTPUT_MOSAIC = Path("/tmp/motion_mosaic.jpg") +PEOPLE_GRAPH = Path("/tmp/person_count.png") +DISK_GRAPH = Path("/tmp/disk_free.png") + +INFLUX_HOST = os.getenv("INFLUX_HOST", "localhost") +INFLUX_PORT = int(os.getenv("INFLUX_PORT", "8086")) +INFLUX_USER = os.getenv("INFLUX_USER", "admin") +INFLUX_PASSWORD = os.getenv("INFLUX_PASSWORD", "admin") +INFLUX_DB = os.getenv("INFLUX_DB", "motion") + +KST = ZoneInfo("Asia/Seoul") + +console = Console() + + +def print_system_usage(tag: str = "", disk_path: str = str(MOTION_DIR)) -> None: + """Print current system CPU, RAM and disk usage with an optional tag.""" + if psutil is None: + console.print(f"{tag}CPU/RAM/Disk usage unavailable (psutil missing)") + return + cpu = psutil.cpu_percent(interval=None) + mem = psutil.virtual_memory() + usage = psutil.disk_usage(disk_path) + free_gb = usage.free / (1024 ** 3) + console.print( + f"{tag}CPU: {cpu:.1f}% | RAM: {mem.percent:.1f}% | Disk Free: {free_gb:.1f} GB" + ) + + +def ensure_influx_running() -> None: + """Start the InfluxDB service if it is not running.""" + running = subprocess.run( + ["pgrep", "-f", "influxd"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ).returncode + if running != 0: + console.print("InfluxDB not running, attempting to start...") + started = subprocess.run( + ["systemctl", "start", "influxdb"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ).returncode + if started != 0: + subprocess.Popen( + ["influxd"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + time.sleep(5) + + +def ensure_database(client: InfluxDBClient, name: str) -> None: + """Create the given database in InfluxDB if it doesn't exist.""" + try: + existing = {db["name"] for db in client.get_list_database()} + if name not in existing: + client.create_database(name) + except Exception as exc: # pragma: no cover - best effort + console.print(f"Failed to verify/create database {name}: {exc}") + + + +def _images_in_range(start: datetime, end: datetime) -> List[Path]: + """Return image paths whose mtime lies between ``start`` and ``end``.""" + images: List[Path] = [] + + exts = {".jpg", ".jpeg", ".png"} + for entry in os.scandir(MOTION_DIR): + if not entry.is_file(): + continue + if not entry.name.lower().endswith(tuple(exts)): + continue + mtime = datetime.fromtimestamp(entry.stat().st_mtime) + if start <= mtime <= end: + images.append(Path(entry.path)) + return sorted(images, key=lambda p: p.stat().st_mtime, reverse=True) + + +def _images_since(start: datetime) -> List[Path]: + """Return images newer than ``start`` limited to the last four days.""" + now = datetime.now() + cutoff = max(start, now - timedelta(days=2)) + return _images_in_range(cutoff, now) + + +def timestamp_from_filename(path: Path) -> datetime: + """Return timestamp encoded in ``path`` or fall back to file mtime.""" + match = re.search(r"(\d{8})[_-](\d{6})", path.name) + if match: + dt_str = match.group(1) + match.group(2) + try: + return datetime.strptime(dt_str, "%Y%m%d%H%M%S").replace(tzinfo=KST) + except ValueError: + pass + return datetime.fromtimestamp(path.stat().st_mtime, KST) + + +def images_for_day(day: date) -> List[Path]: + """Return all images for the given ``day`` (00:00-23:59).""" + start = datetime.combine(day, datetime.min.time()) + end = start + timedelta(days=1) + return _images_in_range(start, end) + + +def wait_for_images(start: datetime, count: int) -> List[Path]: + """Block until ``count`` images exist after ``start``.""" + while True: + imgs = _images_since(start) + if len(imgs) >= count: + return imgs[:count] + time.sleep(2) + + +def latest_images(count: int) -> List[Path]: + """Return the ``count`` most recent images.""" + recent = _images_since(datetime.now() - timedelta(days=4)) + return recent[:count] + + +def log_images(image_paths: Iterable[Path]) -> None: + """Print paths of images being processed with progress.""" + paths = list(image_paths) + with Progress() as progress: + task = progress.add_task("Images", total=len(paths)) + for p in paths: + progress.print(str(p)) + progress.advance(task) + + +def create_mosaic( + image_paths: Iterable[Path], output_path: Path, cols: int = 4, rows: int = 4 +) -> Path: + """Assemble images into a mosaic of ``cols`` by ``rows`` tiles. + + The earliest timestamp among the images is rendered onto the mosaic. + """ + + paths = list(image_paths)[: cols * rows] + if not paths: + raise ValueError("No images provided for mosaic") + imgs = [Image.open(p) for p in paths] + + w, h = imgs[0].size + mosaic = Image.new("RGB", (cols * w, rows * h)) + + with Progress() as progress: + task = progress.add_task("Assembling mosaic", total=len(imgs)) + for idx, (img, path) in enumerate(zip(imgs, paths)): + x = (idx % cols) * w + y = (idx // cols) * h + mosaic.paste(img, (x, y)) + progress.print(str(path)) + + progress.advance(task) + + # annotate with the earliest timestamp of the included images + earliest = min(paths, key=lambda p: p.stat().st_mtime).stat().st_mtime + time_text = datetime.fromtimestamp(earliest, KST).strftime("%Y-%m-%d %H:%M:%S") + draw = ImageDraw.Draw(mosaic) + try: + font = ImageFont.truetype( + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 24 + ) + except OSError: # pragma: no cover - font may be missing + font = ImageFont.load_default() + + _, _, _, text_height = draw.textbbox((0, 0), time_text, font=font) + x, y = 10, mosaic.height - text_height - 10 + # draw black outline for readability + for dx in (-1, 0, 1): + for dy in (-1, 0, 1): + if dx or dy: + draw.text((x + dx, y + dy), time_text, font=font, fill="black") + draw.text((x, y), time_text, font=font, fill="white") + + mosaic.save(output_path) + return output_path + + +def detect_people( + model: Any, + image_paths: Iterable[Path], + client: InfluxDBClient | None = None, + times: Dict[Path, datetime] | None = None, + +) -> Dict[Path, int]: + """Detect people in images and optionally write counts to InfluxDB.""" + + paths = list(image_paths) + counts: Dict[Path, int] = {} + with Progress() as progress: + task = progress.add_task("Detecting people", total=len(paths)) + for path in paths: + results = model(str(path)) + persons = sum(1 for c in results[0].boxes.cls if int(c) == 0) + counts[path] = persons + progress.print(str(path)) + progress.advance(task) + + if client is not None: + write_counts(client, counts, times) + + + return counts + + +def write_counts( + client: InfluxDBClient, + counts: Dict[Path, int], + times: Dict[Path, datetime] | None = None, +) -> None: + points = [] + for path, num in counts.items(): + ts = times[path] if times and path in times else datetime.fromtimestamp( + path.stat().st_mtime, KST + ) + points.append( + { + "measurement": "person_count", + "tags": {"source": "motion"}, + "time": ts.isoformat(), + "fields": {"count": num}, + } + ) + if points: + client.write_points(points) + + +def write_disk_free(client: InfluxDBClient) -> None: + free = shutil.disk_usage("/").free + point = { + "measurement": "disk_free", + "time": datetime.now(KST).isoformat(), + "fields": {"bytes": free}, + } + client.write_points([point]) + + +def generate_graph( + client: InfluxDBClient, measurement: str, field: str, output: Path +) -> Path: + query = ( + f'SELECT "{field}" FROM "{measurement}" ' + f'WHERE time > now() - 48h' + ) + result = client.query(query) + points = list(result.get_points(measurement=measurement)) + + times: List[datetime] = [] + values: List[float] = [] + for p in points: + times.append(datetime.fromisoformat(p["time"].replace("Z", "+00:00"))) + values.append(p[field]) + + + if times and values: + fig, ax = plt.subplots() + ax.plot(times, values, "o", linestyle="none") + ax.set_title(measurement) + ax.set_xlabel("time") + ax.set_ylabel("value") + ax.grid(True, linestyle="--", linewidth=0.5) + + fig.tight_layout() + time_text = datetime.now(KST).strftime("%Y-%m-%d %H:%M:%S") + fig.text(0.01, 0.01, time_text, ha="left", va="bottom") + fig.savefig(output) + plt.close(fig) + return output + + +def send_via_telegram(paths: Iterable[Path], delay: float = 1.0) -> None: + for path in paths: + subprocess.run(["telegram-send", "-i", str(path)], check=True) + time.sleep(delay) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--date", help="YYYY-MM-DD to mosaic instead of live feed") + parser.add_argument( + "--pcount", + action="store_true", + help="count people in images from the last 24 hours", + ) + args = parser.parse_args() + + perf_start = time.perf_counter() + print_system_usage("Start ") + + if args.pcount: + images = _images_since(datetime.now() - timedelta(days=1)) + if not images: + console.print("No images found in last 24 hours") + else: + console.print( + "Person detection is disabled; skipping --pcount processing." + ) + print_system_usage("End ") + console.print(f"Elapsed time: {time.perf_counter() - perf_start:.2f}s") + return + + if args.date: + target_day = datetime.strptime(args.date, "%Y-%m-%d").date() + images = images_for_day(target_day) + if not images: + console.print(f"No images found for {args.date}") + print_system_usage("End ") + console.print(f"Elapsed time: {time.perf_counter() - perf_start:.2f}s") + return + + ensure_influx_running() + client = InfluxDBClient( + host=INFLUX_HOST, + port=INFLUX_PORT, + username=INFLUX_USER or None, + password=INFLUX_PASSWORD or None, + ) + ensure_database(client, INFLUX_DB) + client.switch_database(INFLUX_DB) + + console.print("Skipping person detection to reduce CPU usage.") + + for idx in range(0, len(images), 16): + batch = images[idx : idx + 16] + log_images(batch) + mosaic_path = OUTPUT_MOSAIC.with_name(f"motion_mosaic_{idx//16}.jpg") + create_mosaic(batch, mosaic_path) + send_via_telegram([mosaic_path]) + + write_disk_free(client) + generate_graph(client, "person_count", "count", PEOPLE_GRAPH) + generate_graph(client, "disk_free", "bytes", DISK_GRAPH) + send_via_telegram([PEOPLE_GRAPH, DISK_GRAPH]) + client.close() + + print_system_usage("End ") + console.print(f"Elapsed time: {time.perf_counter() - perf_start:.2f}s") + return + + images = latest_images(32) + + log_images(images) + + ensure_influx_running() + client = InfluxDBClient( + host=INFLUX_HOST, + port=INFLUX_PORT, + username=INFLUX_USER or None, + password=INFLUX_PASSWORD or None, + ) + ensure_database(client, INFLUX_DB) + client.switch_database(INFLUX_DB) + + console.print("Skipping person detection to reduce CPU usage.") + + write_disk_free(client) + generate_graph(client, "person_count", "count", PEOPLE_GRAPH) + generate_graph(client, "disk_free", "bytes", DISK_GRAPH) + client.close() + + mosaic_path = create_mosaic(images, OUTPUT_MOSAIC, cols=8, rows=4) + + send_via_telegram([mosaic_path, PEOPLE_GRAPH, DISK_GRAPH]) + + print_system_usage("End ") + console.print(f"Elapsed time: {time.perf_counter() - perf_start:.2f}s") + + +if __name__ == "__main__": + main() +