From 665ac994feef13e0dea0fbdd905e5f4eaf5137b9 Mon Sep 17 00:00:00 2001 From: skbarber <64565579+skbarber@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:21:13 -0700 Subject: [PATCH 1/6] =?UTF-8?q?GeecsPanels:=20the=20viewer=20=E2=80=94=20z?= =?UTF-8?q?oom,=20pan,=20fit/1:1,=20log=20and=20sqrt=20scales,=20a=20colla?= =?UTF-8?q?psible=20Live=20strip,=20and=20frames=20fetched=20only=20when?= =?UTF-8?q?=20the=20camera=20makes=20one?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The image fills the panel; wheel zooms at the cursor, drag pans, double-click or Fit fits, 1:1 shows camera pixels (unsmoothed when magnified). The colour-mapped frame is kept apart from the view, so later overlays zoom and pan with it (toView/toImage). - Scale: linear / square root / log, in the same lookup table as the levels. - Live folds into a native disclosure whose summary keeps centroid and max. - FrameSource reuses a frame exactly while MJPG1's counter stands still and sends it as X-Frame-Counter; the page skips decoding a frame it already shows. A 1 Hz camera polled at 10 Hz is now one fetch and one draw a second. - Fixed while measuring: the reuse window was timed from the fetch's reply, so a poll at the cap's period was handed the old frame a third of the time (5.5 fps shown of 10). Now timed from the request, and the counter decides whenever it is known. The page also fetches the next frame while drawing the last. Measured: 9.5-10 fps at the 10 fps cap. Co-Authored-By: Claude Opus 5.5 --- GeecsPanels/geecs_panels/__main__.py | 6 +- GeecsPanels/geecs_panels/app.py | 10 +- GeecsPanels/geecs_panels/ca.py | 9 +- GeecsPanels/geecs_panels/feed.py | 48 +++- GeecsPanels/geecs_panels/static/camera.js | 256 ++++++++++++++---- GeecsPanels/geecs_panels/static/panels.css | 17 +- .../geecs_panels/templates/camera.html | 32 ++- GeecsPanels/tests/test_app.py | 4 +- GeecsPanels/tests/test_feed.py | 105 ++++++- 9 files changed, 398 insertions(+), 89 deletions(-) diff --git a/GeecsPanels/geecs_panels/__main__.py b/GeecsPanels/geecs_panels/__main__.py index c9c47a256..bb8b191dd 100644 --- a/GeecsPanels/geecs_panels/__main__.py +++ b/GeecsPanels/geecs_panels/__main__.py @@ -76,7 +76,11 @@ def main() -> None: app = create_app( experiment, devices, - LiveCameras(min_interval=1.0 / args.max_fps), + # Only used when the camera's frame counter is unknown (normally a + # frame is reused exactly while the counter stands still). 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, diff --git a/GeecsPanels/geecs_panels/app.py b/GeecsPanels/geecs_panels/app.py index 94cf4135f..17e3ca8ef 100644 --- a/GeecsPanels/geecs_panels/app.py +++ b/GeecsPanels/geecs_panels/app.py @@ -177,12 +177,14 @@ 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"} - ) + headers = {"Cache-Control": "no-store"} + if frame.tag is not None: + # The page skips decoding a frame it already shows. + headers["X-Frame-Counter"] = str(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: diff --git a/GeecsPanels/geecs_panels/ca.py b/GeecsPanels/geecs_panels/ca.py index 4d0979240..63f466f49 100644 --- a/GeecsPanels/geecs_panels/ca.py +++ b/GeecsPanels/geecs_panels/ca.py @@ -21,7 +21,7 @@ import aioca import httpx -from geecs_panels.camera import JPG_URL_SUFFIX +from geecs_panels.camera import IMAGE_PLUGIN, JPG_URL_SUFFIX, counter_suffix from geecs_panels.feed import CameraFeed, FrameSource, ResolveUrl, Subscription from geecs_panels.registry import Device @@ -118,8 +118,13 @@ 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 diff --git a/GeecsPanels/geecs_panels/feed.py b/GeecsPanels/geecs_panels/feed.py index 29f048e61..ff1750f04 100644 --- a/GeecsPanels/geecs_panels/feed.py +++ b/GeecsPanels/geecs_panels/feed.py @@ -8,7 +8,9 @@ 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, never fetching again until the +counter moves: 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 @@ -164,6 +166,14 @@ def snapshot(self, stale_after: float) -> dict[str, object]: Fetch = Callable[[str], Awaitable[bytes]] +@dataclass(frozen=True) +class Frame: + """One JPEG and the frame counter it was fetched at (``None``: not known).""" + + jpeg: bytes + tag: Optional[object] = None + + class FrameUnavailable(RuntimeError): """No frame could be fetched; the message says why.""" @@ -175,21 +185,39 @@ 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 clock: Callable[[], float] = time.monotonic _url: Optional[str] = None - _frame: Optional[bytes] = None + _frame: Optional[Frame] = None _at: float = float("-inf") - _inflight: Optional[asyncio.Future[bytes]] = None - - 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 + _inflight: Optional[asyncio.Future[Frame]] = None + + async def latest(self) -> Frame: + """The newest frame, shared with concurrent callers. + + With the camera's counter known, the cached frame is reused exactly + while the counter has not moved, so upstream fetches never outrun the + camera. Without it, a frame is reused for ``min_interval`` after it + was asked for — 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). + """ + tag = self.counter() + if self._frame is not None: + if tag is not None and self._frame.tag is not None: + if tag == self._frame.tag: + return self._frame + elif self.clock() - self._at < self.min_interval: + 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. + frame = Frame(await self._fetch(), tag) except BaseException as exc: self._inflight.set_exception(exc) # Mark it retrieved: a request with no concurrent waiter must @@ -197,7 +225,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..e9e10a45d 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-Counter 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,127 @@ 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; - } - sctx.drawImage(bitmap, 0, 0); - bitmap.close(); - const img = sctx.getImageData(0, 0, w, h); + function recolour() { + if (!grey) return; + const img = fctx.createImageData(fw, fh); 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]; - if (autoLive.checked) autoLevels(); - for (let i = 0, j = 0; i < n; i++, j += 4) { + 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] = 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; } - ctx.putImageData(img, 0, 0); + fctx.drawImage(bitmap, 0, 0); + bitmap.close(); + 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(); + if (sized && !view.moved) fit(); + recolour(); + } + + // ---- 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]; + + function fitScale() { return fw && fh ? Math.min(vw / fw, vh / fh) : 1; } + + 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) { + const next = Math.min(MAX_ZOOM, Math.max(fitScale() / 2, 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); + $("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 }); + + let drag = null; + viewport.addEventListener("pointerdown", (ev) => { + if (ev.button !== 0) return; + drag = { x: ev.clientX, y: ev.clientY, ox: view.ox, oy: view.oy }; + viewport.setPointerCapture(ev.pointerId); + viewport.classList.add("panning"); + }); + viewport.addEventListener("pointermove", (ev) => { + const r = viewport.getBoundingClientRect(); + showCursor(ev.clientX - r.left, ev.clientY - r.top); + if (!drag) return; + view.ox = drag.ox + (ev.clientX - drag.x); + view.oy = drag.oy + (ev.clientY - drag.y); + view.moved = true; + draw(); + }); + const endDrag = () => { 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); + + // 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 +250,28 @@ }); }); + // 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; + + async function drawLatest() { + if (drawing) return; + drawing = true; + try { + while (waiting) { + const blob = waiting; + waiting = null; + takeFrame(await createImageBitmap(blob)); + shown.push(performance.now()); + } + } finally { + drawing = false; + } + } + async function loop() { for (;;) { await visible(); @@ -136,31 +279,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())); + const tag = r.headers.get("X-Frame-Counter"); + const blob = await r.blob(); + // The same frame again (a camera slower than the poll): no redraw. + if (tag === null || tag !== lastTag) { + lastTag = tag; + waiting = blob; + drawLatest(); + } 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"; } 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 +307,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 +325,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 +352,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"; @@ -235,6 +379,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 +401,17 @@ // ---- 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(); }); + resize(); connect(); loop(); })(); diff --git a/GeecsPanels/geecs_panels/static/panels.css b/GeecsPanels/geecs_panels/static/panels.css index 5a55cd147..5d9c30b50 100644 --- a/GeecsPanels/geecs_panels/static/panels.css +++ b/GeecsPanels/geecs_panels/static/panels.css @@ -1,13 +1,18 @@ /* 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 canvas{display:block;width:100%;height:100%;cursor:crosshair} +.kit .viewport.panning canvas{cursor:grabbing} +.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..5038df410 100644 --- a/GeecsPanels/geecs_panels/templates/camera.html +++ b/GeecsPanels/geecs_panels/templates/camera.html @@ -51,6 +51,14 @@

Display

+
+ + +
@@ -66,14 +74,21 @@

Display

-
8-bit stream: levels stretch what the IOC sends, nothing is written to the camera.
+
8-bit stream: levels and scale stretch what the IOC sends; nothing is written to the camera.
+ + +
+

View

+
Scroll to zoom at the cursor, drag to pan, double-click to fit.
-
-
live
-
+ {# Rung 0: a native disclosure. Folded, its summary still carries the + few numbers people glance at. #} +
+ Live +
{% for r in readings %}
@@ -84,18 +99,21 @@

Display

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