Files
silver_pose/v1/config.py
T

315 lines
11 KiB
Python
Raw Normal View History

2026-07-21 09:45:42 +08:00
"""Validated, credential-safe configuration loading for Silver Pose V1."""
import hashlib
2026-07-21 09:45:42 +08:00
import json
import os
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict
from urllib.parse import quote
2026-07-21 09:45:42 +08:00
class ConfigError(ValueError):
"""Raised when a configuration file cannot safely start V1."""
# Hikvision RTSP path: channel "101" = channel 1 main stream, "102" = sub stream.
_HIK_PATH = "/Streaming/Channels/{0}"
def build_rtsp_url(
host: str,
port: int,
username: str,
password: str,
channel: str = "101",
path_template: str = _HIK_PATH,
) -> str:
"""Build an RTSP URL, percent-encoding credentials per RFC 3986.
Encoding the username and password means special characters such as ``@``,
``:`` and ``/`` in a password no longer break URL parsing. The returned URL
holds credentials and must never be logged or persisted.
"""
user = quote(str(username), safe="")
secret = quote(str(password), safe="")
path = path_template.format(quote(str(channel), safe=""))
return "rtsp://{0}:{1}@{2}:{3}{4}".format(user, secret, host, int(port), path)
def build_ffmpeg_options(
transport: str = "tcp",
timeout_seconds: float = 5.0,
low_latency: bool = True,
) -> str:
"""Build the OpenCV FFmpeg capture options string from friendly settings.
OpenCV reads ``OPENCV_FFMPEG_CAPTURE_OPTIONS`` when the stream is opened, so
the caller must set this into the environment before ``cv2.VideoCapture``.
Format is ``key;value`` pairs joined by ``|``.
"""
if transport not in ("tcp", "udp"):
raise ValueError("transport must be 'tcp' or 'udp'")
parts = ["rtsp_transport;{0}".format(transport)]
if timeout_seconds and float(timeout_seconds) > 0:
parts.append("stimeout;{0}".format(int(float(timeout_seconds) * 1_000_000)))
if low_latency:
parts.append("fflags;nobuffer")
parts.append("flags;low_delay")
return "|".join(parts)
2026-07-21 09:45:42 +08:00
@dataclass(frozen=True)
class EventConfig:
keypoint_confidence_threshold: float
suspect_window_seconds: float
confirm_window_seconds: float
recovery_window_seconds: float
cooldown_seconds: float
require_rapid_drop: bool = False
require_lower_body: bool = False
horizontal_angle_threshold_degrees: float = 45.0
2026-07-21 09:45:42 +08:00
@dataclass(frozen=True)
class AppConfig:
source_id: str
source_url: str
model_path: Path
model_sha256: str
confidence_threshold: float
event: EventConfig
event_dir: Path
source_mode: str = "stream"
transport: str = "tcp"
timeout_seconds: float = 5.0
low_latency: bool = True
@property
def ffmpeg_capture_options(self) -> str:
"""OpenCV FFmpeg options string to set before opening the stream."""
return build_ffmpeg_options(self.transport, self.timeout_seconds, self.low_latency)
2026-07-21 09:45:42 +08:00
@property
def runtime_config_version(self) -> str:
"""Return a stable, non-secret identifier for the active event settings."""
payload = {
"source_id": self.source_id,
"model_sha256": self.model_sha256,
"confidence_threshold": self.confidence_threshold,
"event": {
"keypoint_confidence_threshold": self.event.keypoint_confidence_threshold,
"suspect_window_seconds": self.event.suspect_window_seconds,
"confirm_window_seconds": self.event.confirm_window_seconds,
"recovery_window_seconds": self.event.recovery_window_seconds,
"cooldown_seconds": self.event.cooldown_seconds,
"require_rapid_drop": self.event.require_rapid_drop,
"require_lower_body": self.event.require_lower_body,
"horizontal_angle_threshold_degrees": self.event.horizontal_angle_threshold_degrees,
},
}
canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"))
return "cfg-" + hashlib.sha256(canonical.encode("utf-8")).hexdigest()
2026-07-21 09:45:42 +08:00
_ENVIRONMENT_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
_SHA256 = re.compile(r"^[0-9a-fA-F]{64}$")
def _mapping(value: Any, field_name: str) -> Dict[str, Any]:
if not isinstance(value, dict):
raise ConfigError("{0} must be an object".format(field_name))
return value
def _text(value: Any, field_name: str) -> str:
if not isinstance(value, str) or not value.strip():
raise ConfigError("{0} must be a non-empty string".format(field_name))
return value.strip()
def _number(value: Any, field_name: str, minimum: float, maximum: float) -> float:
try:
result = float(value)
except (TypeError, ValueError):
raise ConfigError("{0} must be numeric".format(field_name))
if not minimum <= result <= maximum:
raise ConfigError(
"{0} must be between {1} and {2}".format(field_name, minimum, maximum)
)
return result
def _bool(value: Any, field_name: str) -> bool:
if not isinstance(value, bool):
raise ConfigError("{0} must be a boolean".format(field_name))
return value
2026-07-21 09:45:42 +08:00
def _resolve_path(config_path: Path, value: Any, field_name: str) -> Path:
raw_path = Path(_text(value, field_name))
if raw_path.is_absolute():
return raw_path
return (config_path.parent / raw_path).resolve()
def write_local_camera_source(
config_path: Path,
source_id: str,
host: str,
port: int,
channel: str,
username: str,
password: str,
mode: str = "stream",
transport: str = "tcp",
timeout_seconds: float = 5.0,
low_latency: bool = True,
) -> Path:
"""Persist a structured camera source into an untracked local config file.
Only ever call this on ``config.local.json`` (git-ignored). Credentials are
written in plaintext by design of the chosen policy; the public example must
never receive them.
"""
path = Path(config_path)
data = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
data["source"] = {
"id": str(source_id),
"host": str(host),
"port": int(port),
"channel": str(channel),
"username": str(username),
"password": str(password),
"mode": mode,
"transport": transport,
"timeout_seconds": float(timeout_seconds),
"low_latency": bool(low_latency),
}
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return path
2026-07-21 09:45:42 +08:00
def load_config(path: Path) -> AppConfig:
"""Load one public/local configuration pair without persisting credentials.
The config file can name an environment variable but must not embed a source
address. The resolved address remains in memory only.
"""
config_path = Path(path).resolve()
try:
raw = json.loads(config_path.read_text(encoding="utf-8"))
except OSError as exc:
raise ConfigError("cannot read config: {0}".format(exc))
except json.JSONDecodeError as exc:
raise ConfigError("invalid JSON config: {0}".format(exc))
root = _mapping(raw, "config")
source = _mapping(root.get("source"), "source")
if "url" in source or "rtsp_url" in source:
raise ConfigError("source must define structured host fields or rtsp_url_env, not a full URL")
if "host" in source:
host = _text(source.get("host"), "source.host")
port = int(_number(source.get("port", 554), "source.port", 1.0, 65535.0))
channel = str(source.get("channel", "101")).strip()
if not channel:
raise ConfigError("source.channel must be a non-empty value")
username = _text(source.get("username"), "source.username")
password = source.get("password")
if not isinstance(password, str) or password == "":
raise ConfigError("source.password must be a non-empty string")
source_url = build_rtsp_url(host, port, username, password, channel)
else:
environment_name = _text(source.get("rtsp_url_env"), "source.rtsp_url_env")
if not _ENVIRONMENT_NAME.match(environment_name):
raise ConfigError("source.rtsp_url_env must be an environment variable name")
source_url = os.environ.get(environment_name)
if not source_url:
raise ConfigError("missing RTSP environment variable: {0}".format(environment_name))
2026-07-21 09:45:42 +08:00
source_mode = source.get("mode", "stream")
if source_mode not in ("replay", "stream"):
raise ConfigError("source.mode must be 'replay' or 'stream'")
transport = str(source.get("transport", "tcp")).strip().lower()
if transport not in ("tcp", "udp"):
raise ConfigError("source.transport must be 'tcp' or 'udp'")
timeout_seconds = _number(
source.get("timeout_seconds", 5.0), "source.timeout_seconds", 0.0, 60.0
)
low_latency = source.get("low_latency", True)
if not isinstance(low_latency, bool):
raise ConfigError("source.low_latency must be a boolean")
2026-07-21 09:45:42 +08:00
model = _mapping(root.get("model"), "model")
model_sha256 = _text(model.get("sha256"), "model.sha256").lower()
if not _SHA256.match(model_sha256):
raise ConfigError("model.sha256 must be a 64-character SHA-256 value")
event_raw = _mapping(root.get("event"), "event")
event = EventConfig(
keypoint_confidence_threshold=_number(
event_raw.get("keypoint_confidence_threshold"),
"event.keypoint_confidence_threshold",
0.0,
1.0,
),
suspect_window_seconds=_number(
event_raw.get("suspect_window_seconds"),
"event.suspect_window_seconds",
0.0,
30.0,
),
confirm_window_seconds=_number(
event_raw.get("confirm_window_seconds"),
"event.confirm_window_seconds",
1.0,
3.0,
),
recovery_window_seconds=_number(
event_raw.get("recovery_window_seconds"),
"event.recovery_window_seconds",
0.0,
300.0,
),
cooldown_seconds=_number(
event_raw.get("cooldown_seconds"),
"event.cooldown_seconds",
0.0,
3600.0,
),
require_rapid_drop=_bool(event_raw.get("require_rapid_drop", False), "event.require_rapid_drop"),
require_lower_body=_bool(event_raw.get("require_lower_body", False), "event.require_lower_body"),
horizontal_angle_threshold_degrees=_number(
event_raw.get("horizontal_angle_threshold_degrees", 45.0),
"event.horizontal_angle_threshold_degrees",
0.0,
90.0,
),
2026-07-21 09:45:42 +08:00
)
artifacts = _mapping(root.get("artifacts"), "artifacts")
return AppConfig(
source_id=_text(source.get("id"), "source.id"),
source_url=source_url,
model_path=_resolve_path(config_path, model.get("path"), "model.path"),
model_sha256=model_sha256,
confidence_threshold=_number(
model.get("confidence_threshold"), "model.confidence_threshold", 0.0, 1.0
),
event=event,
event_dir=_resolve_path(config_path, artifacts.get("event_dir"), "artifacts.event_dir"),
source_mode=source_mode,
transport=transport,
timeout_seconds=timeout_seconds,
low_latency=low_latency,
2026-07-21 09:45:42 +08:00
)