""" Vision Sampler — frame capture, normalization, entropy, motion detection Samples camera frames at adaptive rate, computes evidence metrics: - Shannon entropy (information density) - Perceptual hash (for frame dedup / novelty) - Motion score (frame delta vs previous) """ import base64 import hashlib import io import time import numpy as np from PIL import Image from .stream_state import FrameEvidence, sha256_bytes def parse_data_url(data_url: str) -> bytes: if "," in data_url and data_url.startswith("data:"): data_url = data_url.split(",", 1)[1] return base64.b64decode(data_url) def normalize_jpeg(image_bytes: bytes, max_side: int = 960) -> tuple: im = Image.open(io.BytesIO(image_bytes)).convert("RGB") scale = min(1.0, max_side / max(im.width, im.height)) if scale < 1: im = im.resize((int(im.width * scale), int(im.height * scale))) out = io.BytesIO() im.save(out, format="JPEG", quality=82, optimize=True) return out.getvalue(), im def image_entropy(im: Image.Image) -> float: g = im.convert("L").resize((128, 128)) arr = np.asarray(g, dtype=np.uint8) hist = np.bincount(arr.flatten(), minlength=256).astype(np.float64) probs = hist / max(1, hist.sum()) probs = probs[probs > 0] return float(-(probs * np.log2(probs)).sum()) def average_hash(im: Image.Image, size: int = 8) -> str: g = im.convert("L").resize((size, size)) arr = np.asarray(g, dtype=np.float32) mean = arr.mean() bits = (arr > mean).astype(np.uint8).flatten() value = 0 for bit in bits: value = (value << 1) | int(bit) return f"{value:016x}" def phash_delta(a: str | None, b: str) -> float: if not a: return 1.0 x = int(a, 16) y = int(b, 16) return bin(x ^ y).count("1") / 64.0 def process_frame(data_url: str, previous_phash: str | None = None) -> FrameEvidence: raw = parse_data_url(data_url) jpeg, im = normalize_jpeg(raw) ph = average_hash(im) entropy = image_entropy(im) motion = phash_delta(previous_phash, ph) return FrameEvidence( ts=time.time(), sha256=sha256_bytes(jpeg), phash=ph, entropy=entropy, motion_score=motion, width=im.width, height=im.height, jpeg_b64=base64.b64encode(jpeg).decode("ascii"), ) def should_increase_fps(motion_scores: list, threshold: float = 0.15) -> bool: if len(motion_scores) < 3: return False recent = motion_scores[-3:] return sum(recent) / len(recent) > threshold