localeye.cam

← Back

mapdata.py

floor_plan version 1.0 · 7 KB

1"""The map's data: what is stored for one map, checked strictly before it is saved, and the floor-plan picture rules.
2
3A map is {"sections": [...], "cameras": [...]} with every position as a fraction of the map (0..1 across, 0..1 down), so a
4floor plan can be added or replaced later and everything stays where it was relative to the building.
5
6 section: {"id", "name", "color" "#rrggbb", "points": [[x, y], ...] (3..64 corners), "cams": null = the cameras whose view
7 overlaps it (worked out on the page) | [streams] = picked by hand, "label_angle": null = auto (a tall, narrow
8 area turns its name sideways) | 0 across | 270 reads upwards | 90 reads downwards}
9 camera: {"id", "stream", "x", "y", "angle" (degrees, 0 = up, clockwise), "fov" (degrees it sees across), "range" (how far
10 it sees, as a fraction of the map's width)}
11"""
12import re
13import secrets
14import struct
15
16MAX_SECTIONS = 300
17MAX_POINTS = 64
18MAX_CAMERAS = 200
19MAX_NAME = 60
20MAX_PLAN_BYTES = 25 * 1024 * 1024
21MAX_PLAN_SIDE = 20000
22DEFAULT_ASPECT = 0.6 # a map with no floor plan yet: 10 wide by 6 tall
23LABEL_ANGLES = (0, 90, 270)
24COLOR_RE = re.compile(r"^#[0-9a-fA-F]{6}$")
25ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,24}$")
26PLAN_TYPES = {"png": "image/png", "jpg": "image/jpeg", "webp": "image/webp"}
27
28
29def _num(v, lo, hi, default):
30 try:
31 f = float(v)
32 except (TypeError, ValueError):
33 return default
34 if f != f or f in (float("inf"), float("-inf")): # NaN / infinity
35 return default
36 return round(min(hi, max(lo, f)), 5)
37
38
39def _id(v, used):
40 v = str(v or "")
41 if not ID_RE.match(v) or v in used:
42 v = secrets.token_hex(4)
43 used.add(v)
44 return v
45
46
47def _name(v, default):
48 v = " ".join(str(v or "").split())[:MAX_NAME]
49 return v or default
50
51
52def clean(data, known_streams):
53 """The map as it may be stored. Raises ValueError with a plain reason for a request that is plainly wrong; quietly drops
54 what can't be kept (a camera that doesn't exist, a broken corner)."""
55 if not isinstance(data, dict):
56 raise ValueError("the map must be an object")
57 sections, cameras = data.get("sections") or [], data.get("cameras") or []
58 if not isinstance(sections, list) or not isinstance(cameras, list):
59 raise ValueError("sections and cameras must be lists")
60 if len(sections) > MAX_SECTIONS:
61 raise ValueError(f"{MAX_SECTIONS} areas at most")
62 if len(cameras) > MAX_CAMERAS:
63 raise ValueError(f"{MAX_CAMERAS} cameras at most")
64 used, out_s, out_c = set(), [], []
65 for s in sections:
66 if not isinstance(s, dict) or not isinstance(s.get("points"), list):
67 continue
68 pts = [[_num(p[0], 0, 1, 0), _num(p[1], 0, 1, 0)] for p in s["points"][:MAX_POINTS]
69 if isinstance(p, (list, tuple)) and len(p) == 2]
70 if len(pts) < 3:
71 continue
72 cams = s.get("cams")
73 if cams is not None:
74 cams = list(dict.fromkeys(c for c in cams if isinstance(c, str) and c in known_streams))[:50] if isinstance(cams, list) else None
75 out_s.append({"id": _id(s.get("id"), used), "name": _name(s.get("name"), "Area"),
76 "color": s["color"] if isinstance(s.get("color"), str) and COLOR_RE.match(s["color"]) else "#ff6a3d",
77 "points": pts, "cams": cams,
78 "label_angle": s.get("label_angle") if s.get("label_angle") in LABEL_ANGLES and not isinstance(s.get("label_angle"), bool) else None})
79 for c in cameras:
80 if not isinstance(c, dict) or c.get("stream") not in known_streams:
81 continue
82 out_c.append({"id": _id(c.get("id"), used), "stream": c["stream"], "x": _num(c.get("x"), 0, 1, 0.5), "y": _num(c.get("y"), 0, 1, 0.5),
83 "angle": round(_num(c.get("angle"), -100000, 100000, 0) % 360, 2), "fov": _num(c.get("fov"), 10, 360, 70),
84 "range": _num(c.get("range"), 0.02, 1.5, 0.2)})
85 return {"sections": out_s, "cameras": out_c}
86
87
88def for_viewer(data, allowed_streams):
89 """What a person may see: only cameras they're allowed to see (on the map and in an area's hand-picked list)."""
90 return {"sections": [dict(s, cams=[c for c in s["cams"] if c in allowed_streams] if s.get("cams") is not None else None)
91 for s in data.get("sections") or []],
92 "cameras": [c for c in data.get("cameras") or [] if c.get("stream") in allowed_streams]}
93
94
95# ------------------------------------------------------------------ floor plan pictures
96def plan_info(head):
97 """(type, width, height) of a PNG / JPEG / WebP from its first bytes. Raises ValueError for anything else.
98 Read by hand on purpose: the web app doesn't load an image library, and nothing is decoded."""
99 if head[:8] == b"\x89PNG\r\n\x1a\n" and head[12:16] == b"IHDR":
100 w, h = struct.unpack(">II", head[16:24])
101 return _sized("png", w, h)
102 if head[:3] == b"\xff\xd8\xff":
103 i = 2
104 while i + 9 < len(head):
105 if head[i] != 0xFF:
106 i += 1
107 continue
108 marker = head[i + 1]
109 if marker in (0xD8, 0x01) or 0xD0 <= marker <= 0xD7:
110 i += 2
111 continue
112 length = struct.unpack(">H", head[i + 2:i + 4])[0]
113 if 0xC0 <= marker <= 0xCF and marker not in (0xC4, 0xC8, 0xCC):
114 h, w = struct.unpack(">HH", head[i + 5:i + 9])
115 return _sized("jpg", w, h)
116 i += 2 + length
117 raise ValueError("couldn't read the JPEG's size (is it a whole picture?)")
118 if head[:4] == b"RIFF" and head[8:12] == b"WEBP":
119 kind = head[12:16]
120 if kind == b"VP8 " and len(head) >= 30:
121 w, h = struct.unpack("<HH", head[26:30])
122 return _sized("webp", w & 0x3FFF, h & 0x3FFF)
123 if kind == b"VP8L" and len(head) >= 25:
124 b = head[21:25]
125 w = 1 + (((b[1] & 0x3F) << 8) | b[0])
126 h = 1 + (((b[3] & 0x0F) << 10) | (b[2] << 2) | ((b[1] & 0xC0) >> 6))
127 return _sized("webp", w, h)
128 if kind == b"VP8X" and len(head) >= 30:
129 w = 1 + int.from_bytes(head[24:27], "little")
130 h = 1 + int.from_bytes(head[27:30], "little")
131 return _sized("webp", w, h)
132 raise ValueError("the floor plan must be a PNG, JPEG or WebP picture (export a PDF or drawing as one of those first)")
133
134
135def _sized(kind, w, h):
136 if not (1 <= w <= MAX_PLAN_SIDE and 1 <= h <= MAX_PLAN_SIDE):
137 raise ValueError(f"that picture is {w} x {h} pixels - {MAX_PLAN_SIDE} at most each way")
138 return kind, w, h