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 | |
| 3 | A map is {"sections": [...], "cameras": [...]} with every position as a fraction of the map (0..1 across, 0..1 down), so a |
| 4 | floor 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 | """ |
| 12 | import re |
| 13 | import secrets |
| 14 | import struct |
| 15 | |
| 16 | MAX_SECTIONS = 300 |
| 17 | MAX_POINTS = 64 |
| 18 | MAX_CAMERAS = 200 |
| 19 | MAX_NAME = 60 |
| 20 | MAX_PLAN_BYTES = 25 * 1024 * 1024 |
| 21 | MAX_PLAN_SIDE = 20000 |
| 22 | DEFAULT_ASPECT = 0.6 # a map with no floor plan yet: 10 wide by 6 tall |
| 23 | LABEL_ANGLES = (0, 90, 270) |
| 24 | COLOR_RE = re.compile(r"^#[0-9a-fA-F]{6}$") |
| 25 | ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,24}$") |
| 26 | PLAN_TYPES = {"png": "image/png", "jpg": "image/jpeg", "webp": "image/webp"} |
| 27 | |
| 28 | |
| 29 | def _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 | |
| 39 | def _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 | |
| 47 | def _name(v, default): |
| 48 | v = " ".join(str(v or "").split())[:MAX_NAME] |
| 49 | return v or default |
| 50 | |
| 51 | |
| 52 | def 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 | |
| 88 | def 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 |
| 96 | def 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 | |
| 135 | def _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 |