diff --git a/GeecsPanels/CLAUDE.md b/GeecsPanels/CLAUDE.md index cbaf068da..651e24c18 100644 --- a/GeecsPanels/CLAUDE.md +++ b/GeecsPanels/CLAUDE.md @@ -19,12 +19,18 @@ account) until the maintainer asks. geecs_panels/ __main__.py # CLI; applies geecs_core.epics_env BEFORE anything imports aioca registry.py # native_devices.yaml → Device(name, kind, prefix) - camera.py # the chain contract (PV suffixes) and the freshness rule — pure + camera.py # the chain contract (PV suffixes), the freshness rule and the + # write allow-list (WRITABLE, check_write) — pure + coords.py # image pixels <-> the analysis ROI's output pixels — pure feed.py # CameraFeed (monitored PVs → snapshot), FrameSource (shared JPEG) # — transports injected, so tests need no CA library or IOC ca.py # the real transports: aioca monitors, stream URL, httpx - app.py # routes: /, /camera/{device}, its frame.jpg and events (SSE) + app.py # routes: /, /camera/{device}, its frame.jpg and events (SSE), + # and the writes: put (allow-listed), crosshair colour, to-centroid templates/, static/ # on the GeecsWebTheme kit; colours through tokens only + # camera.js = the viewer (frame loop, zoom/pan, colour map); + # overlay.js = the crosshair + ROI layer, plugged in through + # window.GeecsCameraView's hooks (draw, grab, hover, snapshot) ``` ## Rules this package keeps @@ -40,8 +46,14 @@ geecs_panels/ flight) and pauses while hidden. - **The stream URL is rehosted to the CA address** (`ca.with_ca_host`): the IOC names itself by hostname, which a laptop cannot resolve. -- **Read-only so far.** A write route must name its PV in an allow-list - (design note §4), and acquisition controls wait for the run lock (§6). +- **Writes are allow-listed** (`camera.WRITABLE`): crosshairs (OVER1:1/2) + and the analysis ROI (ROI1) only — display and analysis state, never + what a scan saves. The page names an allow-listed suffix for a plain + put; anything that needs computing goes through its own route (the colour + route takes r/g/b — channel PV names in the script would also trip the + theme's colour-word guard — and to-centroid converts on the server with + `coords`). `--read-only` refuses every write. Acquisition controls (the driver) wait for the run lock + (design note §6). - Never imports GeecsBluesky, the scanner, the portal or the logbook (`.importlinter` `panels-edges`). diff --git a/GeecsPanels/geecs_panels/__main__.py b/GeecsPanels/geecs_panels/__main__.py index c9c47a256..90f905a17 100644 --- a/GeecsPanels/geecs_panels/__main__.py +++ b/GeecsPanels/geecs_panels/__main__.py @@ -41,6 +41,11 @@ def main() -> None: default="", help="URL prefix behind a prefix-preserving reverse proxy", ) + parser.add_argument( + "--read-only", + action="store_true", + help="refuse every write (crosshairs, analysis ROI): a viewer only", + ) parser.add_argument("--log-level", default="INFO", help="Python logging level") args = parser.parse_args() @@ -76,12 +81,16 @@ def main() -> None: app = create_app( experiment, devices, - LiveCameras(min_interval=1.0 / args.max_fps), + # The floor between upstream fetches for ALL viewers of a camera + # (FrameSource.latest). Half the page's poll period, so timer jitter + # never hands a viewer its own previous frame. + LiveCameras(min_interval=0.5 / args.max_fps), registry_path=path, registry_error=error, stale_after=args.stale_after, max_fps=args.max_fps, root_path=args.root_path, + writable=not args.read_only, ) uvicorn.run(app, host=args.host, port=args.port, log_level=args.log_level.lower()) diff --git a/GeecsPanels/geecs_panels/app.py b/GeecsPanels/geecs_panels/app.py index 94cf4135f..cbda03035 100644 --- a/GeecsPanels/geecs_panels/app.py +++ b/GeecsPanels/geecs_panels/app.py @@ -18,13 +18,15 @@ import asyncio import json +import math from collections.abc import AsyncIterator, Callable from importlib.metadata import PackageNotFoundError, version from pathlib import Path -from typing import Optional, Protocol +from typing import Optional, Protocol, Union from fastapi import FastAPI, HTTPException, Request from fastapi.responses import HTMLResponse, Response, StreamingResponse +from pydantic import BaseModel, Field from geecs_web_theme.web import ( ForwardedPrefixMiddleware, RevalidatingStaticFiles, @@ -32,8 +34,9 @@ mount_theme, ) -from geecs_panels.camera import READINGS -from geecs_panels.feed import CameraFeed, FrameSource, FrameUnavailable +from geecs_panels.camera import CROSSHAIRS, READINGS, NotWritable, check_write +from geecs_panels.coords import to_image +from geecs_panels.feed import CameraFeed, FrameSource, FrameUnavailable, WriteFailed from geecs_panels.registry import Device _PKG = Path(__file__).resolve().parent @@ -69,6 +72,24 @@ def feed(self, device: Device) -> CameraFeed: def frames(self, device: Device) -> FrameSource: """The camera's shared frame source.""" + async def put(self, device: Device, suffix: str, value: object) -> None: + """Write one PV under the camera's prefix; :class:`WriteFailed` if the IOC refuses.""" + + +class Colour(BaseModel): + """A crosshair colour, 0..255 a channel (the IOC stores it per overlay).""" + + r: int = Field(ge=0, le=255) + g: int = Field(ge=0, le=255) + b: int = Field(ge=0, le=255) + + +class PutRequest(BaseModel): + """One allow-listed write: a PV suffix under the camera's prefix and its value.""" + + pv: str + value: Union[bool, int, float, str] + def create_app( experiment: str, @@ -80,6 +101,7 @@ def create_app( stale_after: float = 3.0, max_fps: float = 10.0, root_path: str = "", + writable: bool = True, ) -> FastAPI: """Build the panels application. @@ -101,6 +123,9 @@ def create_app( The frame rate the page asks for at most. root_path : str URL prefix behind a prefix-preserving reverse proxy. + writable : bool + Whether the write routes (crosshairs, analysis ROI) are open; off, + every write is a 403 and the page draws its controls disabled. """ app = FastAPI( title="GEECS Panels", @@ -153,10 +178,12 @@ def camera_page(request: Request, name: str) -> HTMLResponse: "readings": READINGS, "stale_after": stale_after, "max_fps": max_fps, + "writable": writable, }, ) _add_live_routes(app, cameras, camera, stale_after) + _add_write_routes(app, cameras, camera, stale_after, writable) @app.get("/health") def health() -> dict[str, object]: @@ -177,12 +204,12 @@ def _add_live_routes( async def frame(name: str) -> Response: source = cameras.frames(camera(name)) try: - jpeg = await source.latest() + frame = await source.latest() except FrameUnavailable as exc: return Response(str(exc), status_code=503, media_type="text/plain") - return Response( - jpeg, media_type="image/jpeg", headers={"Cache-Control": "no-store"} - ) + # The page skips decoding a frame it already shows. + headers = {"Cache-Control": "no-store", "X-Frame-Tag": frame.tag} + return Response(frame.jpeg, media_type="image/jpeg", headers=headers) @app.get("/camera/{name}/events") async def events(request: Request, name: str, once: int = 0) -> StreamingResponse: @@ -199,3 +226,90 @@ async def gen() -> AsyncIterator[str]: return StreamingResponse( gen(), media_type="text/event-stream", headers=_SSE_HEADERS ) + + +def centroid_in_image(feed: CameraFeed, stale_after: float) -> tuple[int, int]: + """The centroid (STAT's, in ROI pixels) in image pixels; ``LookupError`` if it is not fresh.""" + states = {r["key"]: r for r in feed.snapshot(stale_after)["readings"]} + if states["cx"]["state"] != "fresh" or states["cy"]["state"] != "fresh": + raise LookupError("the centroid is not fresh: nothing to move to") + ax, ay = feed.roi_axes() + # Half up, not Python's half-to-even: 120.5 and 121.5 both go up. + return ( + math.floor(to_image(ax, float(states["cx"]["value"])) + 0.5), + math.floor(to_image(ay, float(states["cy"]["value"])) + 0.5), + ) + + +class _Writer: + """What every write route shares: the read-only refusal and the put.""" + + def __init__( + self, cameras: Cameras, camera: Callable[[str], Device], writable: bool + ) -> None: + self.cameras, self.camera, self.writable = cameras, camera, writable + + def device(self, name: str, crosshair: Optional[int] = None) -> Device: + """The camera to write to, refusing a read-only panel or an unknown crosshair.""" + device = self.camera(name) + if not self.writable: + raise HTTPException(403, "this panel is read-only (--read-only)") + if crosshair is not None and crosshair not in CROSSHAIRS: + raise HTTPException(404, f"no crosshair {crosshair}") + return device + + async def put(self, device: Device, suffix: str, value: object) -> None: + """One allow-listed write; the IOC refusing it is a 502.""" + try: + value = check_write(suffix, value) + except NotWritable as exc: + raise HTTPException(422, str(exc)) from exc + try: + await self.cameras.put(device, suffix, value) + except WriteFailed as exc: + raise HTTPException(502, str(exc)) from exc + + +def _add_write_routes( + app: FastAPI, + cameras: Cameras, + camera: Callable[[str], Device], + stale_after: float, + writable: bool, +) -> None: + """The panel's writes: one allow-listed PV at a time (design note §4).""" + w = _Writer(cameras, camera, writable) + + @app.post("/camera/{name}/put") + async def put_one(name: str, request: PutRequest) -> dict[str, object]: + device = w.device(name) + await w.put(device, request.pv, request.value) + return { + "ok": True, + "pv": request.pv, + "value": check_write(request.pv, request.value), + } + + @app.post("/camera/{name}/crosshair/{n}/colour") + async def colour(name: str, n: int, request: Colour) -> dict[str, object]: + """Set crosshair *n*'s colour: its three channel PVs, each allow-listed.""" + device = w.device(name, n) + for field, value in ( + ("Red", request.r), + ("Green", request.g), + ("Blue", request.b), + ): + await w.put(device, f"OVER1:{n}:{field}", value) + return {"ok": True} + + @app.post("/camera/{name}/crosshair/{n}/to-centroid") + async def to_centroid(name: str, n: int) -> dict[str, object]: + """Move crosshair *n* to the centroid, converted from ROI to image pixels.""" + device = w.device(name, n) + try: + x, y = centroid_in_image(cameras.feed(device), stale_after) + except LookupError as exc: + raise HTTPException(409, str(exc)) from exc + await w.put(device, f"OVER1:{n}:CenterX", x) + await w.put(device, f"OVER1:{n}:CenterY", y) + return {"ok": True, "x": x, "y": y} diff --git a/GeecsPanels/geecs_panels/ca.py b/GeecsPanels/geecs_panels/ca.py index 4d0979240..162f96298 100644 --- a/GeecsPanels/geecs_panels/ca.py +++ b/GeecsPanels/geecs_panels/ca.py @@ -21,12 +21,20 @@ import aioca import httpx -from geecs_panels.camera import JPG_URL_SUFFIX -from geecs_panels.feed import CameraFeed, FrameSource, ResolveUrl, Subscription +from geecs_panels.camera import IMAGE_PLUGIN, JPG_URL_SUFFIX, counter_suffix +from geecs_panels.feed import ( + CameraFeed, + FrameSource, + ResolveUrl, + Subscription, + WriteFailed, +) from geecs_panels.registry import Device #: Upstream frame timeout: the IOC answers a snapshot in ~50 ms. FETCH_TIMEOUT_S = 3.0 +#: A write waits for the IOC to process the record, at most this long. +PUT_TIMEOUT_S = 3.0 def subscribe( @@ -118,11 +126,24 @@ def frames(self, device: Device) -> FrameSource: """The camera's frame source.""" source = self._frames.get(device.name) if source is None: + feed = self.feed(device) + counter = counter_suffix(IMAGE_PLUGIN) source = self._frames[device.name] = FrameSource( - url_resolver(device.prefix), self._fetch, self._min_interval + url_resolver(device.prefix), + self._fetch, + self._min_interval, + counter=lambda: feed.values.get(counter), ) return source + async def put(self, device: Device, suffix: str, value: object) -> None: + """Write one PV and wait for the IOC to process it.""" + pv = device.prefix + suffix + try: + await aioca.caput(pv, value, wait=True, timeout=PUT_TIMEOUT_S) + except Exception as exc: + raise WriteFailed(f"{pv}: {exc}") from exc + async def aclose(self) -> None: """Drop every subscription and the HTTP client.""" for feed in self._feeds.values(): diff --git a/GeecsPanels/geecs_panels/camera.py b/GeecsPanels/geecs_panels/camera.py index dc26460af..cdb0db2d5 100644 --- a/GeecsPanels/geecs_panels/camera.py +++ b/GeecsPanels/geecs_panels/camera.py @@ -87,6 +87,134 @@ class Reading: #: The URL of one JPEG frame, published by the IOC (read once, not monitored). JPG_URL_SUFFIX = "MJPG1:JPG_URL_RBV" +#: The crosshairs the panel draws: OVER1's overlays 1 and 2. The IOC stores +#: them (shared by every viewer, autosaved); the panel draws them, in their +#: stored colour, over the colour-mapped image — the stream carries none. +CROSSHAIRS = (1, 2) +#: Field → readback suffix under ``OVER1::``; positions are image +#: (PROC1) pixels. ``Center*`` is what the panel sets: the IOC derives +#: ``Position*`` (the autosaved one) from it. +CROSSHAIR_FIELDS = { + "use": "Use_RBV", + "x": "CenterX_RBV", + "y": "CenterY_RBV", + "sx": "SizeX_RBV", + "sy": "SizeY_RBV", + "r": "Red_RBV", + "g": "Green_RBV", + "b": "Blue_RBV", + "name": "Name_RBV", + "shape": "Shape_RBV", +} +_CROSSHAIR_STRINGS = frozenset({"name", "shape"}) + +#: The analysis ROI (ROI1: STAT only sees this region), field → readback. +#: Min/Size are in image (PROC1) pixels; STAT's results are in the ROI's own +#: output pixels — :mod:`geecs_panels.coords` converts. +ROI_FIELDS = { + "minx": "ROI1:MinX_RBV", + "miny": "ROI1:MinY_RBV", + "sizex": "ROI1:SizeX_RBV", + "sizey": "ROI1:SizeY_RBV", + "binx": "ROI1:BinX_RBV", + "biny": "ROI1:BinY_RBV", + "revx": "ROI1:ReverseX_RBV", + "revy": "ROI1:ReverseY_RBV", +} + + +def crosshair_suffix(n: int, field: str) -> str: + """``OVER1::`` for a crosshair field.""" + return f"OVER1:{n}:{CROSSHAIR_FIELDS[field]}" + + +#: The PVs the panel may WRITE, and nothing else (design note §4). Each is +#: display or analysis state: none changes what a scan saves (that is the +#: camera driver's, behind the run lock of build step 3). Value kinds: +#: ``int`` (a pixel or a 0..255 level), ``bool`` (0/1), ``str``, or a tuple of +#: allowed choices (an enum, written by name). +@dataclass(frozen=True) +class Whole: + """A whole number in ``lo..hi`` (a pixel, a size, a 0..255 level).""" + + lo: int + hi: int + + +_PIXEL = Whole(0, 65535) +_SIZE = Whole(1, 65535) +_LEVEL = Whole(0, 255) + +WRITABLE: dict[str, object] = { + **{ + f"OVER1:{n}:{field}": kind + for n in CROSSHAIRS + for field, kind in ( + ("Use", bool), + ("CenterX", _PIXEL), + ("CenterY", _PIXEL), + ("SizeX", _PIXEL), # an overlay may be a 0-wide line + ("SizeY", _PIXEL), + ("Red", _LEVEL), + ("Green", _LEVEL), + ("Blue", _LEVEL), + ("Name", str), + ("Shape", ("Cross", "Rectangle")), + ) + }, + "ROI1:MinX": _PIXEL, + "ROI1:MinY": _PIXEL, + "ROI1:SizeX": _SIZE, + "ROI1:SizeY": _SIZE, +} + + +class NotWritable(ValueError): + """A write the allow-list refuses; the message says why.""" + + +def _as_flag(suffix: str, value: object) -> object: + if value in (0, 1, True, False): + return int(bool(value)) + raise NotWritable(f"{suffix} takes 0 or 1, not {value!r}") + + +def _as_whole(suffix: str, value: object, kind: Whole) -> object: + whole = isinstance(value, int) or (isinstance(value, float) and value.is_integer()) + if isinstance(value, bool) or not whole: + raise NotWritable(f"{suffix} takes a whole number, not {value!r}") + if not kind.lo <= int(value) <= kind.hi: + raise NotWritable(f"{suffix} takes {kind.lo}..{kind.hi}, not {value!r}") + return int(value) + + +def _as_text(suffix: str, value: object) -> object: + if not isinstance(value, str) or len(value) > 39: + raise NotWritable(f"{suffix} takes text of at most 39 characters") + return value + + +_COERCE = {bool: _as_flag, str: _as_text} + + +def check_write(suffix: str, value: object) -> object: + """The value to put for an allowed write, or :class:`NotWritable`. + + Coerces JSON's types to the PV's: a pixel or level is an ``int`` (a + ``bool`` or a non-integral float is refused), a flag 0/1, a choice one of + its names, text a short string. + """ + kind = WRITABLE.get(suffix) + if kind is None: + raise NotWritable(f"{suffix} is not writable from the panel") + if isinstance(kind, tuple): + if value not in kind: + raise NotWritable(f"{suffix} takes one of {', '.join(kind)}") + return value + if isinstance(kind, Whole): + return _as_whole(suffix, value, kind) + return _COERCE[kind](suffix, value) + def monitored_suffixes() -> list[str]: """Every PV suffix the panel monitors, without duplicates, in a stable order.""" @@ -97,12 +225,20 @@ def monitored_suffixes() -> list[str]: seen[reading.computed_by] = None for plugin in COUNTER_PLUGINS: seen[counter_suffix(plugin)] = None + for n in CROSSHAIRS: + for field in CROSSHAIR_FIELDS: + seen[crosshair_suffix(n, field)] = None + for suffix in ROI_FIELDS.values(): + seen[suffix] = None return list(seen) def string_suffixes() -> frozenset[str]: """The suffixes read as strings (enums shown by name).""" - return frozenset(r.suffix for r in READINGS if r.as_string) + return frozenset( + [r.suffix for r in READINGS if r.as_string] + + [crosshair_suffix(n, f) for n in CROSSHAIRS for f in _CROSSHAIR_STRINGS] + ) def judge( diff --git a/GeecsPanels/geecs_panels/coords.py b/GeecsPanels/geecs_panels/coords.py new file mode 100644 index 000000000..9e645f9e1 --- /dev/null +++ b/GeecsPanels/geecs_panels/coords.py @@ -0,0 +1,50 @@ +"""Image pixels ↔ the analysis ROI's own pixels. + +The image (MJPG1) and the ROI's bounds are both in **image** pixels — PROC1's +output, after any transform. STAT's results (centroid, cursor, profiles) +are in the ROI's **output** pixels: offset by ``MinX``, divided by ``BinX``, +and counted from the far edge when ``ReverseX`` is on. Output pixel ``i`` +covers image pixels ``MinX + i·BinX … MinX + (i+1)·BinX − 1`` (mirrored when +reversed); a position maps to the centre of what it covers. + +One module for both directions, so "set crosshair to centroid" (ROI → +image) and, later, "lineout through a clicked point" (image → ROI) cannot +disagree. +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Axis: + """One axis of ROI1: its image-pixel bounds, binning and direction.""" + + min: int + size: int + bin: int = 1 + reverse: bool = False + + +def _span(axis: Axis) -> int: + """The image pixels the output covers: whole bins only (NDROI drops a remainder).""" + b = max(axis.bin, 1) + return (axis.size // b) * b + + +def to_image(axis: Axis, roi: float) -> float: + """An ROI output position → image pixels.""" + b = max(axis.bin, 1) + offset = roi * b + (b - 1) / 2 + if axis.reverse: + return axis.min + _span(axis) - 1 - offset + return axis.min + offset + + +def to_roi(axis: Axis, image: float) -> float: + """An image position → the ROI's output pixels (the inverse of :func:`to_image`).""" + b = max(axis.bin, 1) + far = axis.min + _span(axis) - 1 + offset = (far - image) if axis.reverse else (image - axis.min) + return (offset - (b - 1) / 2) / b diff --git a/GeecsPanels/geecs_panels/feed.py b/GeecsPanels/geecs_panels/feed.py index 29f048e61..514b169cd 100644 --- a/GeecsPanels/geecs_panels/feed.py +++ b/GeecsPanels/geecs_panels/feed.py @@ -8,7 +8,10 @@ A :class:`FrameSource` serves the latest JPEG with at most one upstream request in flight, reusing a frame for everyone who asks within -``min_interval`` — N viewers cost the camera host one stream. +``min_interval`` — N viewers cost the camera host one stream — and, when +it is given the camera's frame counter, not fetching again until the +counter moves (or ``max_reuse`` passes): a 1 Hz camera polled at 10 Hz +costs one fetch a second. The transports are injected (``subscribe``, ``fetch``) so the logic runs in tests without a CA library or an IOC; :mod:`geecs_panels.ca` supplies the @@ -25,13 +28,18 @@ from geecs_panels.camera import ( COUNTER_PLUGINS, + CROSSHAIR_FIELDS, + CROSSHAIRS, IMAGE_PLUGIN, READINGS, + ROI_FIELDS, counter_suffix, + crosshair_suffix, judge, monitored_suffixes, string_suffixes, ) +from geecs_panels.coords import Axis _COUNTER_SUFFIXES = frozenset(counter_suffix(p) for p in COUNTER_PLUGINS) @@ -155,8 +163,36 @@ def snapshot(self, stale_after: float) -> dict[str, object]: "readings": readings, "image": {"state": image_state, "age": image_age}, "missing": self.missing(), + "crosshairs": self.crosshairs(), + "roi": self.roi(), } + def crosshairs(self) -> list[dict[str, object]]: + """Each crosshair's stored state (``None`` fields: not read yet).""" + return [ + { + "n": n, + **{ + f: self.values.get(crosshair_suffix(n, f)) for f in CROSSHAIR_FIELDS + }, + } + for n in CROSSHAIRS + ] + + def roi(self) -> dict[str, object]: + """The analysis ROI's bounds, binning and direction.""" + return {field: self.values.get(suffix) for field, suffix in ROI_FIELDS.items()} + + def roi_axes(self) -> tuple[Axis, Axis]: + """ROI1's two axes for :mod:`geecs_panels.coords`; raises if not read yet.""" + r = self.roi() + if any(r[k] is None for k in ("minx", "miny", "sizex", "sizey")): + raise LookupError("the analysis ROI has not been read yet") + return ( + Axis(int(r["minx"]), int(r["sizex"]), int(r["binx"] or 1), bool(r["revx"])), + Axis(int(r["miny"]), int(r["sizey"]), int(r["biny"] or 1), bool(r["revy"])), + ) + #: () -> the URL of one JPEG frame from the IOC. ResolveUrl = Callable[[], Awaitable[str]] @@ -164,6 +200,29 @@ def snapshot(self, stale_after: float) -> dict[str, object]: Fetch = Callable[[str], Awaitable[bytes]] +@dataclass(frozen=True) +class Frame: + """One JPEG, the frame counter it was fetched at, and its upstream fetch number. + + ``tag`` names this exact fetch for the page (``"."``): + the page redraws exactly when the tag changes, so a safety refetch under + an unchanged counter is still drawn. + """ + + jpeg: bytes + counter: Optional[object] = None + fetch: int = 0 + + @property + def tag(self) -> str: + """The page's identity for this frame.""" + return f"{'' if self.counter is None else self.counter}.{self.fetch}" + + +class WriteFailed(RuntimeError): + """The IOC did not take a write; the message says why.""" + + class FrameUnavailable(RuntimeError): """No frame could be fetched; the message says why.""" @@ -175,21 +234,55 @@ class FrameSource: resolve_url: ResolveUrl fetch: Fetch min_interval: float + #: The camera's current frame counter (MJPG1's), ``None`` when unknown. + counter: Callable[[], Optional[object]] = lambda: None + #: The longest a frame is reused under an unchanged counter. + max_reuse: float = 1.0 clock: Callable[[], float] = time.monotonic + _fetches: int = 0 _url: Optional[str] = None - _frame: Optional[bytes] = None + _frame: Optional[Frame] = None _at: float = float("-inf") - _inflight: Optional[asyncio.Future[bytes]] = None + _inflight: Optional[asyncio.Future[Frame]] = None + + async def latest(self) -> Frame: + """The newest frame, shared with concurrent callers. + + Three rules, in order: + + - **A floor for everyone:** within ``min_interval`` of the last + fetch, the cached frame is served — however many viewers poll and + however fast the camera, the IOC sees at most one fetch per + ``min_interval`` (N viewers cost one stream). + - **The counter:** past the floor, a frame is reused while the + camera's counter has not moved — a 1 Hz camera polled at 10 Hz is + fetched once a second. + - **A safety net:** a frame is never reused longer than + ``max_reuse``, counter or not — should a counter ever arrive + before its JPEG, the stale frame lasts this long, not a camera + period. (ffmpegServer stores the JPEG, then posts its counter.) - async def latest(self) -> bytes: - """A frame no older than ``min_interval``, shared with concurrent callers.""" - if self._frame is not None and self.clock() - self._at < self.min_interval: - return self._frame + All ages are timed from the request, not the reply: timed from the + reply, a poll at the cap's period landed inside the window behind a + slow fetch and was handed the old frame again (a third of them). + """ + counter = self.counter() + if self._frame is not None: + age = self.clock() - self._at + if age < self.min_interval: + return self._frame + unchanged = counter is not None and counter == self._frame.counter + if unchanged and age < self.max_reuse: + return self._frame if self._inflight is not None: return await asyncio.shield(self._inflight) + asked = self.clock() self._inflight = asyncio.get_running_loop().create_future() try: - frame = await self._fetch() + # Tagged with the counter read BEFORE the fetch: the JPEG is at + # least that new, so a later counter change always refetches. + self._fetches += 1 + frame = Frame(await self._fetch(), counter, self._fetches) except BaseException as exc: self._inflight.set_exception(exc) # Mark it retrieved: a request with no concurrent waiter must @@ -197,7 +290,7 @@ async def latest(self) -> bytes: self._inflight.exception() raise else: - self._frame, self._at = frame, self.clock() + self._frame, self._at = frame, asked self._inflight.set_result(frame) return frame finally: diff --git a/GeecsPanels/geecs_panels/static/camera.js b/GeecsPanels/geecs_panels/static/camera.js index f947700fa..c134047a7 100644 --- a/GeecsPanels/geecs_panels/static/camera.js +++ b/GeecsPanels/geecs_panels/static/camera.js @@ -1,10 +1,17 @@ -// The camera panel: the frame loop, this viewer's colour map and levels, -// and the live readings from the service's SSE stream. +// The camera panel: the frame loop, the viewer (zoom and pan), this viewer's +// colour map, levels and scale, and the live readings from the SSE stream. // // Frames are PULLED one at a time (never more than one in flight), so the // picture lags by one frame whatever the network does; the loop pauses while -// the tab is hidden. Colour map and levels are applied here, to the IOC's -// 8-bit greyscale stream: nothing is written to the camera. +// the tab is hidden, and a frame the camera has not replaced (the service's +// X-Frame-Tag is unchanged) is not decoded again. Colour map, levels and +// scale are applied here, to the IOC's 8-bit greyscale stream: nothing is +// written to the camera. +// +// Coordinates: "image" pixels are the frame's own (PROC1, after any +// transform); "view" pixels are CSS pixels in the viewport. view = image * +// scale + offset. Everything drawn over the image goes through toView / +// toImage, so later layers (crosshairs, the ROI box) zoom and pan with it. (() => { "use strict"; @@ -14,14 +21,17 @@ const maxFps = parseFloat(root.dataset.maxFps) || 10; const storeKey = "geecs-panels:" + root.dataset.device + ":display"; - const canvas = document.getElementById("image"); + const $ = (id) => document.getElementById(id); + const viewport = $("viewport"); + const canvas = $("image"); const ctx = canvas.getContext("2d"); - const scratch = document.createElement("canvas"); - const sctx = scratch.getContext("2d", { willReadFrequently: true }); + // The colour-mapped frame at its own size; drawn into the view at the zoom. + const frame = document.createElement("canvas"); + const fctx = frame.getContext("2d", { willReadFrequently: true }); - const $ = (id) => document.getElementById(id); - const cmapSel = $("cmap"), blackIn = $("level-lo"), whiteIn = $("level-hi"); - const autoLive = $("auto-live"); + const cmapSel = $("cmap"), scaleSel = $("scale"); + const blackIn = $("level-lo"), whiteIn = $("level-hi"); + const autoLive = $("auto-live"), liveStrip = $("live-strip"); // ---- colour maps: control points [t, r, g, b], interpolated to 256 ----- const MAPS = { @@ -33,6 +43,13 @@ jet: [[0, 0, 0, 128], [0.125, 0, 0, 255], [0.375, 0, 255, 255], [0.625, 255, 255, 0], [0.875, 255, 0, 0], [1, 128, 0, 0]], }; + // The scale bends t in 0..1 after the levels: dim features rise with sqrt + // and log. It stretches the 8-bit stream; it adds no dynamic range. + const SCALES = { + linear: (t) => t, + sqrt: (t) => Math.sqrt(t), + log: (t) => Math.log1p(1000 * t) / Math.log1p(1000), + }; function mapColour(points, t) { for (let i = 1; i < points.length; i++) { @@ -46,14 +63,15 @@ return [last[1], last[2], last[3]]; } - // grey level 0..255 → rgb, through the levels then the map. - let lut = new Uint8ClampedArray(256 * 3); + // grey level 0..255 → rgb, through the levels, the scale, then the map. + const lut = new Uint8ClampedArray(256 * 3); function buildLut() { const points = MAPS[cmapSel.value] || MAPS.grey; + const bend = SCALES[scaleSel.value] || SCALES.linear; const black = clampLevel(blackIn.value, 0, 254); const white = Math.max(black + 1, clampLevel(whiteIn.value, 1, 255)); for (let v = 0; v < 256; v++) { - const t = Math.min(1, Math.max(0, (v - black) / (white - black))); + const t = bend(Math.min(1, Math.max(0, (v - black) / (white - black)))); const c = mapColour(points, t); lut[3 * v] = c[0]; lut[3 * v + 1] = c[1]; lut[3 * v + 2] = c[2]; } @@ -67,7 +85,8 @@ function save() { try { localStorage.setItem(storeKey, JSON.stringify({ - cmap: cmapSel.value, black: blackIn.value, white: whiteIn.value, auto: autoLive.checked, + cmap: cmapSel.value, scale: scaleSel.value, black: blackIn.value, + white: whiteIn.value, auto: autoLive.checked, live: liveStrip.open, })); } catch (_) { /* storage unavailable: settings last for this page only */ } } @@ -76,12 +95,18 @@ const s = JSON.parse(localStorage.getItem(storeKey) || "null"); if (!s) return; if (MAPS[s.cmap]) cmapSel.value = s.cmap; - blackIn.value = s.black; whiteIn.value = s.white; autoLive.checked = !!s.auto; + if (SCALES[s.scale]) scaleSel.value = s.scale; + if (s.black !== undefined) blackIn.value = s.black; + if (s.white !== undefined) whiteIn.value = s.white; + autoLive.checked = !!s.auto; + if (s.live === false) liveStrip.open = false; } catch (_) { /* nothing remembered */ } } - // ---- levels from the frame's histogram ---------------------------------- - let grey = null; // the last frame's grey levels, for auto levels and the cursor + // ---- the frame: grey levels kept, colour-mapped into `frame` ------------ + let grey = null; // the last frame's grey levels: recolouring, auto levels, cursor + let fw = 0, fh = 0; + function autoLevels() { if (!grey) return; const hist = new Uint32Array(256); @@ -96,31 +121,172 @@ buildLut(); } - // ---- drawing ------------------------------------------------------------ - function render(bitmap) { - const w = bitmap.width, h = bitmap.height; - if (scratch.width !== w || scratch.height !== h) { - scratch.width = canvas.width = w; - scratch.height = canvas.height = h; + function recolour() { + if (!grey) return; + const img = fctx.createImageData(fw, fh); + const px = img.data; + for (let i = 0, j = 0; i < grey.length; i++, j += 4) { + const k = 3 * grey[i]; + px[j] = lut[k]; px[j + 1] = lut[k + 1]; px[j + 2] = lut[k + 2]; px[j + 3] = 255; + } + fctx.putImageData(img, 0, 0); + draw(); + } + + function takeFrame(bitmap) { + const sized = bitmap.width !== fw || bitmap.height !== fh; + if (sized) { + fw = frame.width = bitmap.width; + fh = frame.height = bitmap.height; } - sctx.drawImage(bitmap, 0, 0); + fctx.drawImage(bitmap, 0, 0); bitmap.close(); - const img = sctx.getImageData(0, 0, w, h); - const px = img.data; - const n = w * h; - if (!grey || grey.length !== n) grey = new Uint8Array(n); - for (let i = 0, j = 0; i < n; i++, j += 4) grey[i] = px[j]; + const px = fctx.getImageData(0, 0, fw, fh).data; + if (!grey || grey.length !== fw * fh) grey = new Uint8Array(fw * fh); + for (let i = 0, j = 0; i < grey.length; i++, j += 4) grey[i] = px[j]; if (autoLive.checked) autoLevels(); - for (let i = 0, j = 0; i < n; i++, j += 4) { - const k = 3 * grey[i]; - px[j] = lut[k]; px[j + 1] = lut[k + 1]; px[j + 2] = lut[k + 2]; + if (sized && !view.moved) fit(); + recolour(); + } + + // ---- hooks for the layers over the image (overlay.js) ------------------ + // draw(ctx) in view pixels; grab(cx, cy) → an edit or null; hover(cx, cy) + // → a CSS cursor or ""; snapshot(snap) with each SSE state. + const hooks = { draw: [], grab: [], hover: [], snapshot: [] }; + + // ---- the viewer: zoom and pan ------------------------------------------- + const view = { scale: 1, ox: 0, oy: 0, moved: false }; + const MAX_ZOOM = 40; + let vw = 0, vh = 0; // viewport size, CSS pixels + + const toView = (x, y) => [x * view.scale + view.ox, y * view.scale + view.oy]; + const toImage = (x, y) => [(x - view.ox) / view.scale, (y - view.oy) / view.scale]; + + // Fit leaves a margin, so the frame's edges — and a full-frame ROI's + // handles on them — sit inside the viewport where a pointer can reach. + const FIT_MARGIN = 16; + function fitScale() { + if (!fw || !fh) return 1; + return Math.max(0.01, Math.min((vw - 2 * FIT_MARGIN) / fw, (vh - 2 * FIT_MARGIN) / fh)); + } + + function fit() { + view.scale = fitScale(); + view.ox = (vw - fw * view.scale) / 2; + view.oy = (vh - fh * view.scale) / 2; + view.moved = false; + draw(); + } + + // Zoom by `factor` keeping the image point under (cx, cy) where it is. + function zoomAt(cx, cy, factor) { + // Down to half the fit, or to 100% for a frame smaller than the view. + const floor = Math.min(1, fitScale() / 2); + const next = Math.min(MAX_ZOOM, Math.max(floor, view.scale * factor)); + const [ix, iy] = toImage(cx, cy); + view.scale = next; + view.ox = cx - ix * next; + view.oy = cy - iy * next; + view.moved = true; + draw(); + } + + function resize() { + const dpr = window.devicePixelRatio || 1; + const r = viewport.getBoundingClientRect(); + vw = r.width; vh = r.height; + canvas.width = Math.round(vw * dpr); + canvas.height = Math.round(vh * dpr); + if (!view.moved) fit(); else draw(); + } + + function draw() { + const dpr = window.devicePixelRatio || 1; + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.clearRect(0, 0, canvas.width, canvas.height); + if (!fw) return; + ctx.setTransform(dpr * view.scale, 0, 0, dpr * view.scale, dpr * view.ox, dpr * view.oy); + // Magnified: show the pixels as squares, never blurred into each other. + ctx.imageSmoothingEnabled = view.scale * dpr < 1; + ctx.drawImage(frame, 0, 0); + // Layers over the image (overlay.js) draw in view (CSS) pixels. + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + for (const layer of hooks.draw) layer(ctx); + $("zoom-level").textContent = Math.round(view.scale * 100) + "%"; + } + + viewport.addEventListener("wheel", (ev) => { + ev.preventDefault(); + const r = viewport.getBoundingClientRect(); + zoomAt(ev.clientX - r.left, ev.clientY - r.top, Math.exp(-ev.deltaY * 0.0015)); + }, { passive: false }); + + // A drag is either an edit a layer claimed (a crosshair, an ROI handle: + // `hooks.grab` returns {move(ix, iy), end()}) or, failing that, a pan. + let drag = null, edit = null; + const local = (ev) => { + const r = viewport.getBoundingClientRect(); + return [ev.clientX - r.left, ev.clientY - r.top]; + }; + viewport.addEventListener("pointerdown", (ev) => { + if (ev.button !== 0) return; + const [cx, cy] = local(ev); + viewport.setPointerCapture(ev.pointerId); + for (const grab of hooks.grab) { + edit = grab(cx, cy); + if (edit) return; } - ctx.putImageData(img, 0, 0); + drag = { x: ev.clientX, y: ev.clientY, ox: view.ox, oy: view.oy }; + viewport.classList.add("panning"); + }); + viewport.addEventListener("pointermove", (ev) => { + const [cx, cy] = local(ev); + showCursor(cx, cy); + if (edit) { edit.move(...toImage(cx, cy)); return; } + if (!drag) { + // Hover: a layer may name the cursor its grab would use. + let cursor = ""; + for (const hover of hooks.hover) cursor = cursor || hover(cx, cy) || ""; + viewport.style.cursor = cursor; + return; + } + view.ox = drag.ox + (ev.clientX - drag.x); + view.oy = drag.oy + (ev.clientY - drag.y); + view.moved = true; + draw(); + }); + const endDrag = () => { + if (edit) { edit.end(); edit = null; } + drag = null; + viewport.classList.remove("panning"); + }; + viewport.addEventListener("pointerup", endDrag); + viewport.addEventListener("pointercancel", endDrag); + viewport.addEventListener("pointerleave", () => { $("cursor").textContent = ""; }); + viewport.addEventListener("dblclick", fit); + $("zoom-fit").addEventListener("click", fit); + $("zoom-one").addEventListener("click", () => zoomAt(vw / 2, vh / 2, 1 / view.scale)); + new ResizeObserver(resize).observe(viewport); + // Moving the window to a screen of another pixel density changes no CSS + // size, so the observer stays quiet: re-size on the density change itself + // (a media query for the current density, re-armed after each change). + (function watchDensity() { + const mq = matchMedia("(resolution: " + (window.devicePixelRatio || 1) + "dppx)"); + mq.addEventListener("change", () => { resize(); watchDensity(); }, { once: true }); + })(); + + // Cursor readout in image pixels (PROC1 coordinates, after any transform). + function showCursor(cx, cy) { + if (!grey) return; + const [ix, iy] = toImage(cx, cy).map(Math.floor); + $("cursor").textContent = ix < 0 || iy < 0 || ix >= fw || iy >= fh ? "" + : "x " + ix + " y " + iy + " level " + grey[iy * fw + ix]; } // ---- the frame loop ----------------------------------------------------- const errBox = $("image-error"), errText = $("image-error-text"); - const shown = []; // arrival times, for the fps readout + const shown = []; // new-frame arrival times, for the fps readout + let lastTag = null; // the service's frame counter of the frame on screen const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const visible = () => new Promise((r) => { if (!document.hidden) return r(); @@ -129,6 +295,40 @@ }); }); + // Fetching and drawing are decoupled: the next request goes out on the + // poll's clock while the last frame is still being decoded and drawn, so + // the frame rate is limited by the cap, not by fetch time + draw time. + // Still one request in flight; a frame that arrives while another is + // being drawn replaces any frame waiting behind it (latest wins). + let waiting = null, drawing = false, lastCounter = null; + + async function drawLatest() { + if (drawing) return; + drawing = true; + try { + while (waiting) { + const blob = waiting; + waiting = null; + const tag = blob.tag; + takeFrame(await createImageBitmap(blob)); + errBox.hidden = true; + // The fps counts camera frames: a safety refetch of the same + // counter (tag ".") is redrawn but not counted. + const counter = tag === null ? null : tag.split(".")[0]; + if (counter === null || counter === "" || counter !== lastCounter) shown.push(performance.now()); + lastCounter = counter; + } + } catch (e) { + // A reply that is not a decodable image: say so, and let the next + // frame (even one with the same tag) be tried rather than skipped. + errText.textContent = "frame could not be decoded: " + String(e.message || e); + errBox.hidden = false; + lastTag = null; + } finally { + drawing = false; + } + } + async function loop() { for (;;) { await visible(); @@ -136,31 +336,26 @@ try { const r = await fetch(frameUrl, { cache: "no-store" }); if (!r.ok) throw new Error((await r.text()) || ("HTTP " + r.status)); - render(await createImageBitmap(await r.blob())); - errBox.hidden = true; - shown.push(performance.now()); - while (shown.length && shown[0] < performance.now() - 2000) shown.shift(); - $("fps").textContent = (shown.length / 2).toFixed(1) + " fps"; + const tag = r.headers.get("X-Frame-Tag"); + const blob = await r.blob(); + // The same frame again (a camera slower than the poll): no redraw. + if (tag === null || tag !== lastTag) { + lastTag = tag; + blob.tag = tag; + waiting = blob; + drawLatest(); + } } catch (e) { errText.textContent = String(e.message || e); errBox.hidden = false; await sleep(1000); } + while (shown.length && shown[0] < performance.now() - 2000) shown.shift(); + $("fps").textContent = (shown.length / 2).toFixed(1) + " fps"; await sleep(Math.max(0, 1000 / maxFps - (performance.now() - t0))); } } - // ---- cursor readout (image = PROC1 coordinates, after any transform) ---- - canvas.addEventListener("mousemove", (ev) => { - if (!grey) return; - const r = canvas.getBoundingClientRect(); - const x = Math.floor((ev.clientX - r.left) * canvas.width / r.width); - const y = Math.floor((ev.clientY - r.top) * canvas.height / r.height); - if (x < 0 || y < 0 || x >= canvas.width || y >= canvas.height) return; - $("cursor").textContent = "x " + x + " y " + y + " level " + grey[y * canvas.width + x]; - }); - canvas.addEventListener("mouseleave", () => { $("cursor").textContent = ""; }); - // ---- live readings ------------------------------------------------------ const SCALE = { exposure: 1000 }; // seconds on the wire, ms on the page // ADDriver DetectorState words → kit words; anything else is "unknown". @@ -169,6 +364,8 @@ waiting: "running", initializing: "running", idle: "paused", error: "failed", aborted: "failed", disconnected: "failed", }; + // What the folded Live strip still shows in its summary line. + const SUMMARY = [["cx", "centroid "], ["cy", ", "], ["max", " max "]]; function fmt(key, value) { if (value === null || value === undefined) return "—"; @@ -185,7 +382,9 @@ } function apply(snap) { + const byKey = {}; for (const r of snap.readings) { + byKey[r.key] = r; const el = root.querySelector('.live[data-key="' + r.key + '"]'); if (!el) continue; // The value is the text before the unit's ; the unit stays. @@ -210,6 +409,8 @@ chip.dataset.state = r.state === "disconnected" ? "unknown" : (DETECTOR[word] || "unknown"); } } + $("live-summary").textContent = SUMMARY.map(([k, label]) => + label + (byKey[k] && byKey[k].state === "fresh" ? fmt(k, byKey[k].value) : "—")).join(""); const img = $("image-state"); if (snap.image.state === "fresh") { img.dataset.state = "ok"; img.textContent = "live"; @@ -221,6 +422,7 @@ } $("missing").hidden = !snap.missing.length; $("missing-list").textContent = snap.missing.join(" "); + for (const f of hooks.snapshot) f(snap); } // The readings are only as fresh as the stream that carries them: if no @@ -235,6 +437,7 @@ el.setAttribute("data-age", "stale"); el.querySelector(".age").textContent = "no stream"; } + $("live-summary").textContent = "no stream"; const chip = $("detector"); chip.dataset.state = "unknown"; chip.textContent = "reconnecting"; const img = $("image-state"); @@ -256,14 +459,23 @@ // ---- wiring ------------------------------------------------------------- restore(); buildLut(); - for (const el of [cmapSel, blackIn, whiteIn]) { - el.addEventListener("change", () => { buildLut(); save(); }); + for (const el of [cmapSel, scaleSel, blackIn, whiteIn]) { + el.addEventListener("change", () => { buildLut(); recolour(); save(); }); } autoLive.addEventListener("change", save); - $("auto-once").addEventListener("click", () => { autoLevels(); save(); }); + liveStrip.addEventListener("toggle", save); + $("auto-once").addEventListener("click", () => { autoLevels(); recolour(); save(); }); $("reset-levels").addEventListener("click", () => { - blackIn.value = 0; whiteIn.value = 255; autoLive.checked = false; buildLut(); save(); + blackIn.value = 0; whiteIn.value = 255; scaleSel.value = "linear"; + autoLive.checked = false; buildLut(); recolour(); save(); }); + // What the layers over the image may use. Assigned before the deferred + // overlay.js runs, which registers into `hooks`. + window.GeecsCameraView = { + root, hooks, toView, toImage, redraw: () => draw(), + frameSize: () => [fw, fh], scale: () => view.scale, + }; + resize(); connect(); loop(); })(); diff --git a/GeecsPanels/geecs_panels/static/overlay.js b/GeecsPanels/geecs_panels/static/overlay.js new file mode 100644 index 000000000..a8fc6dfcd --- /dev/null +++ b/GeecsPanels/geecs_panels/static/overlay.js @@ -0,0 +1,360 @@ +// The layer over the camera image: the two crosshairs and the analysis ROI. +// +// The IOC STORES them (OVER1:1/2 and ROI1: shared by every viewer, +// autosaved); this layer DRAWS them — crosshairs in their stored colour, +// over whatever colour map this viewer chose — and edits them through the +// service's allow-listed writes. Positions are image pixels (PROC1), so +// everything here goes through the viewer's toView/toImage and zooms and +// pans with the image. It plugs into camera.js through window.GeecsCameraView +// (draw, grab, hover and snapshot hooks) and owns nothing else. +// +// An edit shows at once: while dragging, and for a moment after the write, +// this viewer's own value is drawn instead of the stored one, until the +// IOC's readback agrees (or HOLD_MS passes) — no jump back mid-drag. +(() => { + "use strict"; + + const V = window.GeecsCameraView; + if (!V) return; + const root = V.root; + const $ = (id) => document.getElementById(id); + const writable = root.dataset.writable === "true"; + const putUrl = root.dataset.putUrl; + // Per-crosshair routes: ".../crosshair//to-centroid" and ".../colour". + const crosshairUrl = (n, verb) => + root.dataset.centroid1.replace(/\/1\/to-centroid$/, "/" + n + "/" + verb); + const HOLD_MS = 2000; + // A first colour for a crosshair switched on with the IOC's near-black default. + const DEFAULT_COLOURS = { 1: { r: 0, g: 255, b: 0 }, 2: { r: 255, g: 0, b: 255 } }; + const GRAB_PX = 9; // how near the pointer must be to grab a centre or a handle + const EDGE_PX = 6; + + let crosshairs = []; // the stored state, from the latest snapshot + let roi = null; + const held = { xh: {}, roi: null }; // this viewer's edits, shown until stored + + // ---- writes ------------------------------------------------------------- + let errTimer = null; + function sayError(text) { + const el = $("write-error"); + el.textContent = text; + clearTimeout(errTimer); + errTimer = setTimeout(() => { el.textContent = ""; }, 6000); + } + + async function post(url, body) { + const r = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + if (!r.ok) { + let detail = "HTTP " + r.status; + try { + const d = (await r.json()).detail; + // A validation refusal is a list of {msg}; everything else a string. + detail = Array.isArray(d) ? d.map((x) => x.msg).join("; ") : (d || detail); + } catch (_) { /* not JSON */ } + throw new Error(detail); + } + return r.json(); + } + + async function put(pv, value) { + try { + await post(putUrl, { pv: pv, value: value }); + } catch (e) { + sayError(pv + ": " + e.message); + throw e; + } + } + + async function putAll(pairs) { + for (const [pv, value] of pairs) await put(pv, value); + } + + // ---- what to draw: the stored value, or this viewer's edit -------------- + function position(c) { + const h = held.xh[c.n]; + if (h) { + const settled = !h.dragging && (performance.now() > h.until || (h.x === c.x && h.y === c.y)); + if (!settled) return h; + delete held.xh[c.n]; + } + return { x: c.x, y: c.y }; + } + + function bounds() { + const h = held.roi; + if (h) { + const same = roi && h.minx === roi.minx && h.miny === roi.miny && + h.sizex === roi.sizex && h.sizey === roi.sizey; + if (h.dragging || (performance.now() < h.until && !same)) return h; + held.roi = null; + } + return roi; + } + + // ---- drawing ------------------------------------------------------------ + const hex2 = (v) => Math.max(0, Math.min(255, v | 0)).toString(16).padStart(2, "0"); + const colourOf = (c) => "#" + hex2(c.r) + hex2(c.g) + hex2(c.b); + const token = (name) => getComputedStyle(document.body).getPropertyValue(name).trim(); + + function drawCrosshair(ctx, c) { + if (!c.use || c.x === null || c.y === null) return; + const p = position(c); + // An overlay position is a pixel's index: draw on the pixel's centre. + const [vx, vy] = V.toView(p.x + 0.5, p.y + 0.5); + const s = V.scale(); + // Arms in image pixels (the stored size), never shorter than 12 px on screen. + const ax = Math.max(12, ((c.sx || 0) / 2) * s), ay = Math.max(12, ((c.sy || 0) / 2) * s); + const colour = colourOf(c); + ctx.save(); + ctx.lineWidth = 1.5; + ctx.strokeStyle = colour; + ctx.fillStyle = colour; + ctx.beginPath(); + if (c.shape === "Rectangle") { + ctx.rect(vx - ax, vy - ay, 2 * ax, 2 * ay); + } else { + ctx.moveTo(vx - ax, vy); ctx.lineTo(vx + ax, vy); + ctx.moveTo(vx, vy - ay); ctx.lineTo(vx, vy + ay); + } + ctx.stroke(); + if (writable) { + ctx.beginPath(); + ctx.arc(vx, vy, 5, 0, 2 * Math.PI); + ctx.stroke(); + } + ctx.font = "11px " + (token("--ff-mono") || "monospace"); + ctx.fillText(c.name || String(c.n), vx + 7, vy - 7); + ctx.restore(); + } + + function roiRect() { + const b = bounds(); + if (!b || b.minx === null || b.sizex === null) return null; + const [l, t] = V.toView(b.minx, b.miny); + const [r, bt] = V.toView(b.minx + b.sizex, b.miny + b.sizey); + return { l: l, t: t, r: r, b: bt }; + } + + function drawRoi(ctx) { + if (!$("roi-show").checked) return; + const q = roiRect(); + if (!q) return; + ctx.save(); + ctx.strokeStyle = token("--accent"); + ctx.lineWidth = 1.5; + ctx.setLineDash([6, 4]); + ctx.strokeRect(q.l, q.t, q.r - q.l, q.b - q.t); + if (writable) { + ctx.setLineDash([]); + ctx.fillStyle = token("--accent"); + for (const [x, y] of [[q.l, q.t], [q.r, q.t], [q.l, q.b], [q.r, q.b]]) { + ctx.fillRect(x - 3, y - 3, 6, 6); + } + } + ctx.restore(); + } + + V.hooks.draw.push((ctx) => { + drawRoi(ctx); + for (const c of crosshairs) drawCrosshair(ctx, c); + }); + + // ---- hit testing: what a press here would grab ------------------------- + function crosshairAt(cx, cy) { + for (const c of crosshairs) { + if (!c.use || c.x === null) continue; + const p = position(c); + const [vx, vy] = V.toView(p.x + 0.5, p.y + 0.5); + if (Math.hypot(cx - vx, cy - vy) <= GRAB_PX) return c; + } + return null; + } + + // Which ROI edges a press here would move: e.g. "nw", "e"; "" for none. + function roiHandleAt(cx, cy) { + if (!$("roi-show").checked) return ""; + const q = roiRect(); + if (!q) return ""; + const near = (a, b, d) => Math.abs(a - b) <= d; + const inX = cx >= q.l - EDGE_PX && cx <= q.r + EDGE_PX; + const inY = cy >= q.t - EDGE_PX && cy <= q.b + EDGE_PX; + let h = ""; + if (inX && near(cy, q.t, GRAB_PX)) h += "n"; + else if (inX && near(cy, q.b, GRAB_PX)) h += "s"; + if (inY && near(cx, q.l, GRAB_PX)) h += "w"; + else if (inY && near(cx, q.r, GRAB_PX)) h += "e"; + return h; + } + + const CURSORS = { n: "ns-resize", s: "ns-resize", e: "ew-resize", w: "ew-resize", + nw: "nwse-resize", se: "nwse-resize", ne: "nesw-resize", sw: "nesw-resize" }; + + // Nothing is drawn before the first frame: nothing may be grabbed either. + const ready = () => V.frameSize()[0] > 0; + + V.hooks.hover.push((cx, cy) => { + if (!writable || !ready()) return ""; + if (crosshairAt(cx, cy)) return "move"; + return CURSORS[roiHandleAt(cx, cy)] || ""; + }); + + V.hooks.grab.push((cx, cy) => { + if (!writable || !ready()) return null; + const c = crosshairAt(cx, cy); + if (c) return dragCrosshair(c); + const handle = roiHandleAt(cx, cy); + if (handle) return dragRoi(handle); + return null; + }); + + function dragCrosshair(c) { + const p = position(c); + const h = held.xh[c.n] = { x: p.x, y: p.y, dragging: true, until: 0 }; + return { + move(ix, iy) { + // The pixel under the pointer, as the cursor readout names it. + h.x = Math.floor(ix); h.y = Math.floor(iy); + V.redraw(); + }, + end() { + h.dragging = false; + h.until = performance.now() + HOLD_MS; + putAll([["OVER1:" + c.n + ":CenterX", h.x], ["OVER1:" + c.n + ":CenterY", h.y]]) + .catch(() => { delete held.xh[c.n]; V.redraw(); }); + }, + }; + } + + function dragRoi(handle) { + const b = bounds(); + const [fw, fh] = V.frameSize(); + // The edges that do not move stay where they were. + let l = b.minx, t = b.miny, r = b.minx + b.sizex, bt = b.miny + b.sizey; + const h = held.roi = { minx: l, miny: t, sizex: b.sizex, sizey: b.sizey, dragging: true, until: 0 }; + const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, Math.round(v))); + return { + move(ix, iy) { + if (handle.includes("w")) l = clamp(ix, 0, r - 1); + if (handle.includes("e")) r = clamp(ix, l + 1, fw); + if (handle.includes("n")) t = clamp(iy, 0, bt - 1); + if (handle.includes("s")) bt = clamp(iy, t + 1, fh); + h.minx = l; h.miny = t; h.sizex = r - l; h.sizey = bt - t; + V.redraw(); + }, + end() { + h.dragging = false; + h.until = performance.now() + HOLD_MS; + putRoi(h).catch(() => { held.roi = null; V.redraw(); }); + }, + }; + } + + function putRoi(b) { + return putAll([["ROI1:MinX", b.minx], ["ROI1:MinY", b.miny], + ["ROI1:SizeX", b.sizex], ["ROI1:SizeY", b.sizey]]); + } + + // ---- the rail's controls ----------------------------------------------- + const focused = (el) => document.activeElement === el; + // A number field's value, or null for a cleared or non-numeric one — which + // writes nothing (an empty field would otherwise be Number("") = 0), and the + // next snapshot puts the stored value back in the field. + const whole = (input) => { + const raw = input.value.trim(); + const n = Number(raw); + return raw === "" || !Number.isFinite(n) ? null : Math.round(n); + }; + + function fillControls() { + for (const c of crosshairs) { + const box = root.querySelector('.crosshair[data-n="' + c.n + '"]'); + if (!box) continue; + const p = position(c); + box.querySelector(".xh-use").checked = !!c.use; + box.querySelector(".xh-name").textContent = c.name || String(c.n); + if (c.r !== null && !focused(box.querySelector(".xh-colour"))) { + box.querySelector(".xh-colour").value = colourOf(c); + } + for (const [cls, v] of [[".xh-x", p.x], [".xh-y", p.y]]) { + const input = box.querySelector(cls); + if (!focused(input) && v !== null) input.value = v; + } + } + const b = bounds(); + if (b) { + for (const k of ["minx", "miny", "sizex", "sizey"]) { + const input = $("roi-" + k); + if (!focused(input) && b[k] !== null) input.value = b[k]; + } + } + } + + V.hooks.snapshot.push((snap) => { + crosshairs = snap.crosshairs || []; + roi = snap.roi || null; + fillControls(); + V.redraw(); + }); + + for (const box of root.querySelectorAll(".crosshair")) { + const n = Number(box.dataset.n); + const pv = (f) => "OVER1:" + n + ":" + f; + box.querySelector(".xh-use").addEventListener("change", (ev) => { + const on = ev.target.checked; + const c = crosshairs.find((x) => x.n === n) || {}; + const steps = []; + // A never-configured overlay (the IOC's default is a 1-pixel + // rectangle): make it a visible cross first. + if (on && (c.shape !== "Cross" || !(c.sx > 1))) { + const [fw, fh] = V.frameSize(); + const arm = Math.max(40, Math.round(Math.min(fw, fh) / 5)) || 100; + steps.push([pv("Shape"), "Cross"], [pv("SizeX"), arm], [pv("SizeY"), arm]); + if (c.x === null || c.x === undefined || c.x === 0) { + steps.push([pv("CenterX"), Math.round(fw / 2)], [pv("CenterY"), Math.round(fh / 2)]); + } + } + steps.push([pv("Use"), on ? 1 : 0]); + // The IOC's default colour is near-black, invisible on a dark frame. + const dark = on && (c.r || 0) + (c.g || 0) + (c.b || 0) < 60; + putAll(steps) + .then(() => dark && post(crosshairUrl(n, "colour"), DEFAULT_COLOURS[n] || DEFAULT_COLOURS[1])) + .catch(() => {}); + }); + box.querySelector(".xh-colour").addEventListener("change", (ev) => { + const v = ev.target.value; // "#rrggbb" + const ch = (i) => parseInt(v.slice(i, i + 2), 16); + post(crosshairUrl(n, "colour"), { r: ch(1), g: ch(3), b: ch(5) }) + .catch((e) => sayError("crosshair " + n + ": " + e.message)); + }); + for (const [cls, field] of [[".xh-x", "CenterX"], [".xh-y", "CenterY"]]) { + box.querySelector(cls).addEventListener("change", (ev) => { + const v = whole(ev.target); + if (v !== null) put(pv(field), v).catch(() => {}); + }); + } + box.querySelector(".xh-centroid").addEventListener("click", () => { + post(crosshairUrl(n, "to-centroid")).catch((e) => sayError("crosshair " + n + ": " + e.message)); + }); + } + + for (const k of ["minx", "miny", "sizex", "sizey"]) { + $("roi-" + k).addEventListener("change", (ev) => { + const field = { minx: "MinX", miny: "MinY", sizex: "SizeX", sizey: "SizeY" }[k]; + const v = whole(ev.target); + if (v !== null) put("ROI1:" + field, v).catch(() => {}); + }); + } + $("roi-full").addEventListener("click", () => { + const [fw, fh] = V.frameSize(); + if (fw) putRoi({ minx: 0, miny: 0, sizex: fw, sizey: fh }).catch(() => {}); + }); + $("roi-show").addEventListener("change", () => V.redraw()); + + if (!writable) { + for (const el of root.querySelectorAll(".writes input, .writes button")) el.disabled = true; + } +})(); diff --git a/GeecsPanels/geecs_panels/static/panels.css b/GeecsPanels/geecs_panels/static/panels.css index 5a55cd147..2bba60493 100644 --- a/GeecsPanels/geecs_panels/static/panels.css +++ b/GeecsPanels/geecs_panels/static/panels.css @@ -1,13 +1,24 @@ /* GeecsPanels' own rules: only what the kit does not settle. Colours come from theme.css tokens, never literals (GeecsWebTheme's guard). */ -.kit .imagewrap{ - display:flex;justify-content:center;align-items:center; - background:var(--surface-2);border-radius:var(--r); - min-height:240px;overflow:hidden; +/* The image viewport fills the panel and most of the window's height; the + canvas inside it is sized to the viewport in device pixels and the frame + is drawn into it at the viewer's zoom and pan (camera.js). */ +.kit .viewport{ + position:relative;height:calc(100vh - 210px);min-height:360px; + background:var(--surface-2);border-radius:var(--r);overflow:hidden; + touch-action:none; } -/* The whole frame stays on screen: bounded by the viewport's height too. */ -.kit #image{max-width:100%;max-height:calc(100vh - 320px);width:auto;height:auto;image-rendering:pixelated;cursor:crosshair} +.kit .viewport{cursor:crosshair} +.kit .viewport canvas{display:block;width:100%;height:100%;cursor:inherit} +.kit .viewport.panning{cursor:grabbing} +/* The crosshair and ROI controls in the rail. */ +.kit .crosshair{display:flex;flex-direction:column;gap:6px;padding-bottom:8px} +.kit .crosshair input[type="color"]{width:30px;height:26px;padding:0;border:var(--bw) solid var(--rule);border-radius:var(--r);background:var(--surface)} +.kit .field.coord{min-width:0;width:84px} +.kit #write-error{color:var(--crit);font-size:11.5px;min-height:1em} +.kit .live-strip .dbody{padding-top:8px} +.kit .live-strip > summary #live-summary{margin-left:6px} .kit .readings{grid-template-columns:repeat(auto-fill,minmax(110px,1fr))} .kit .readings .live .v{font-size:16px} .kit .field.levels{min-width:0;width:84px} diff --git a/GeecsPanels/geecs_panels/templates/camera.html b/GeecsPanels/geecs_panels/templates/camera.html index d82575190..3ecc78ba6 100644 --- a/GeecsPanels/geecs_panels/templates/camera.html +++ b/GeecsPanels/geecs_panels/templates/camera.html @@ -37,7 +37,10 @@ data-events-url="{{ url_for('events', name=device.name).path }}" data-device="{{ device.name }}" data-stale-after="{{ stale_after }}" - data-max-fps="{{ max_fps }}"> + data-max-fps="{{ max_fps }}" + data-writable="{{ 'true' if writable else 'false' }}" + data-put-url="{{ url_for('put_one', name=device.name).path }}" + data-centroid1="{{ url_for('to_centroid', name=device.name, n=1).path }}">
-
-
live
-
+ {# Rung 0: a native disclosure. Folded, its summary still carries the + few numbers people glance at. #} +
+ Live +
{% for r in readings %}
@@ -84,18 +141,21 @@

Display

{% endfor %}
-
+
image + + + waiting
-
- +
+