Commit 5ef67faa authored by vertighel's avatar vertighel
Browse files

feat: viewer improvements — WS tile delivery, FITS cache, overlay, throttle



- WebSocket tile-request path: magnifier tile requests use the existing
  /web/socket connection instead of per-hover HTTP fetches; REST remains
  as fallback when WS is not yet open.
- Module-level FITS cache in fits_image.py (mtime-invalidated): shared
  between tile-request handler and stream broadcast loop, eliminating
  redundant disk reads.
- Overlay canvas (cv-overlay) added to both layout variants in
  viewer_panel.html; public setOverlayDrawer/clearOverlayDrawer/imageToCanvas
  API on FitsViewer.
- PNG cap at 512 px (longest side) in /png endpoint.
- Throttle with trailing edge replaces debounce for magnifier updates:
  leading edge fires immediately every 30 ms while moving (~33 fps),
  trailing edge captures final cursor position on stop.
- fits-viewer.js fully commented in English with named variables and
  single-responsibility methods.

Co-Authored-By: default avatarClaude Sonnet 4.6 <noreply@anthropic.com>
parent efa076e9
Loading
Loading
Loading
Loading
+89 −34
Original line number Diff line number Diff line
@@ -13,7 +13,7 @@ Endpoints
GET /info
    JSON metadata: shape, dtype, auto-range percentiles.
GET /png?vmin=&vmax=
    Full-resolution PNG rendered with Viridis.
    PNG rendered with Viridis, longest side capped at 512 px.
GET /panoramic?vmin=&vmax=&max_px=
    Small PNG thumbnail (longest side ≤ *max_px*, default 256).
    Thumbnail downscaling is done inside :func:`~noctua.utils.image.array_to_png`
@@ -40,6 +40,7 @@ The key must match the URL segment ``<station>/<camera>``.
import asyncio
import base64
import configparser
import os
from pathlib import Path

# Third-party modules
@@ -63,12 +64,24 @@ PROJECT_ROOT = PACKAGE_ROOT.parent
_cfg = configparser.ConfigParser()
_cfg.read(PACKAGE_ROOT / 'config' / 'viewer.ini')

# PNG delivered to the browser for the primary view.  Capping at 512 px is a
# deliberate trade-off: the main canvas is 800 CSS px wide but typically
# rendered at a lower physical resolution, so 512 px gives crisp results while
# cutting transfer size to ~1/16 of a 4 k FITS full-res render.
_PNG_MAX_PX = 512


# ---------------------------------------------------------------------------
# Private helpers
# FITS data cache
# ---------------------------------------------------------------------------

def _fits_path(station, camera):
# Maps (station, camera) → (file_mtime, float32_ndarray).
# Entries are refreshed only when the file's mtime advances, so every
# subsequent request within the same file-write cycle hits RAM, not disk.
_fits_cache: dict[tuple[str, str], tuple[float, np.ndarray]] = {}


def _fits_path(station: str, camera: str) -> Path | None:
    """
    Return the filesystem path for a camera's latest FITS file.

@@ -84,16 +97,46 @@ def _fits_path(station, camera):
    pathlib.Path or None
        Absolute path to the FITS file, or ``None`` if not configured.
    """

    raw_path = _cfg.get(f'{station}/{camera}', 'path', fallback=None)
    if raw_path is None:
        return None
    return PROJECT_ROOT / DATA_FOLDER / raw_path


def _load_data(station, camera):
def _load_data(station: str, camera: str) -> np.ndarray | None:
    """
    Read the primary HDU from disk and return it as a ``float32`` array.

    This is a low-level helper; prefer :func:`get_cached_data` for all
    call sites that may fire frequently (e.g. tile requests, previews).

    Returns
    -------
    np.ndarray or None
    """
    path = _fits_path(station, camera)
    if path is None or not path.exists():
        return None
    with fits.open(path) as hdul:
        data = hdul[0].data
        if data is None:
            return None
        return data.astype(np.float32)


def get_cached_data(station: str, camera: str) -> np.ndarray | None:
    """
    Load the primary HDU data as a ``float32`` array.
    Return the FITS image data for a camera, backed by an mtime cache.

    The first call for a given camera reads the file from disk.  Subsequent
    calls reuse the in-memory array as long as the file's mtime has not
    changed.  When the file is updated the cache entry is replaced.

    This function is **synchronous** and safe to call from the asyncio event
    loop when a cache hit is expected (i.e. after the streaming loop has
    already loaded the data).  On a cache miss it reads the FITS file, which
    may block the event loop briefly; callers that cannot tolerate this should
    wrap the call in ``run_in_executor``.

    Parameters
    ----------
@@ -105,17 +148,26 @@ def _load_data(station, camera):
    Returns
    -------
    np.ndarray or None
        Image data, or ``None`` if the file is missing or empty.
        Float32 2-D image array, or ``None`` if the file is missing.
    """

    path = _fits_path(station, camera)
    if path is None or not path.exists():
        return None
    with fits.open(path) as hdul:
        data = hdul[0].data
        if data is None:

    try:
        current_mtime = os.path.getmtime(path)
    except OSError:
        return None
        return data.astype(np.float32)

    cached_mtime, cached_array = _fits_cache.get((station, camera), (None, None))
    if cached_mtime is not None and cached_mtime == current_mtime:
        return cached_array  # fast path: file unchanged

    # File is new or has been updated — reload and store in cache.
    data = _load_data(station, camera)
    if data is not None:
        _fits_cache[(station, camera)] = (current_mtime, data)
    return data


# ---------------------------------------------------------------------------
@@ -193,8 +245,7 @@ async def image_info(station, camera):
        JSON with keys ``shape``, ``dtype``, ``vmin_auto``, ``vmax_auto``,
        ``global_min``, ``global_max``.
    """

    data = _load_data(station, camera)
    data = get_cached_data(station, camera)
    if data is None:
        return jsonify({'error': 'FITS not found'}), 404

@@ -212,7 +263,11 @@ async def image_info(station, camera):
@viewer_blueprint.route('/viewer/<station>/<camera>/png')
async def image_png(station, camera):
    """
    Render the full image as a PNG with Viridis colormap.
    Render the image as a PNG with Viridis colormap.

    The PNG is capped at ``_PNG_MAX_PX`` pixels on its longest side.
    This keeps the transferred payload small: the browser canvas is
    typically 800 CSS px, so 512 px of image data is more than adequate.

    Parameters
    ----------
@@ -233,14 +288,13 @@ async def image_png(station, camera):
    vmax : float, optional
        Upper display limit.  Auto-computed when omitted.
    """

    data = _load_data(station, camera)
    data = get_cached_data(station, camera)
    if data is None:
        return Response('FITS not found', status=404)

    vmin = request.args.get('vmin', default=None, type=float)
    vmax = request.args.get('vmax', default=None, type=float)
    png = fits_to_png(data, vmin=vmin, vmax=vmax)
    png  = fits_to_png(data, vmin=vmin, vmax=vmax, thumbnail_size=_PNG_MAX_PX)
    return Response(png, mimetype='image/png', headers={'Cache-Control': 'no-store'})


@@ -273,8 +327,7 @@ async def image_panoramic(station, camera):
    max_px : int, optional
        Maximum pixels on the longest side.  Default is ``256``.
    """

    data = _load_data(station, camera)
    data = get_cached_data(station, camera)
    if data is None:
        return Response('FITS not found', status=404)

@@ -294,6 +347,9 @@ async def image_tile(station, camera):

        [tw: int32][th: int32][data: float32 × tw × th]

    The REST endpoint is retained as a fallback for clients that cannot
    use the WebSocket tile-request path.

    Parameters
    ----------
    station : str
@@ -317,8 +373,7 @@ async def image_tile(station, camera):
    ry : int, optional
        Half-height of the tile.  Default is ``50``.
    """

    data = _load_data(station, camera)
    data = get_cached_data(station, camera)
    if data is None:
        return Response('FITS not found', status=404)

+73 −6
Original line number Diff line number Diff line
@@ -7,16 +7,19 @@ Quart web blueprint: HTML pages, WebSocket hub, and background tasks.

# System modules
import asyncio
import base64
import configparser
import json
import os
from pathlib import Path

# Third-party modules
import numpy as np
from quart import Blueprint, render_template, websocket, redirect, url_for, request

# Custom modules
from noctua.api.basecontext import ends, resource_registry
from noctua.api.fits_image import get_cached_data
from noctua.utils.structure import current_log_path
from noctua.utils.logger import log
from .stream import BroadcastManager, StreamManager
@@ -90,6 +93,12 @@ async def ws():
    ``{"action": "refresh", "station": str, "camera": str}``
        Trigger an immediate one-shot FITS preview broadcast for the
        given camera.
    ``{"action": "tile-request", "station": str, "camera": str,
       "cx": int, "cy": int, "rx": int, "ry": int, "seq": int}``
        Extract a float32 tile from the in-memory FITS cache and respond
        immediately on the same WebSocket connection with a ``"fits-tile"``
        message.  The ``seq`` field is echoed back so the client can discard
        responses that arrived after a newer request was issued.
    """

    real_ws = websocket._get_current_object()
@@ -112,10 +121,10 @@ async def ws():
        }))

    # Send recent log history on connect
    path = current_log_path()
    if os.path.exists(path):
    log_path = current_log_path()
    if os.path.exists(log_path):
        try:
            with open(path, 'r') as f:
            with open(log_path, 'r') as f:
                last_10 = f.readlines()[-10:]
                if last_10:
                    await real_ws.send(json.dumps({"name": "new-lines", "data": last_10}))
@@ -126,7 +135,6 @@ async def ws():
        while True:
            message_raw = await websocket.receive()
            msg    = json.loads(message_raw)

            action = msg.get("action")

            if action == "set_force":
@@ -141,12 +149,71 @@ async def ws():
                        streamer.broadcast_preview_now(station, camera)
                    )

            elif action == "tile-request":
                await _handle_tile_request(real_ws, msg)

    except asyncio.CancelledError:
        pass
    finally:
        await broadcaster.unregister(real_ws)


async def _handle_tile_request(ws_conn, msg: dict) -> None:
    """
    Respond to a WebSocket tile-request with a base64-encoded float32 patch.

    The data comes from the in-memory FITS cache (populated by the streaming
    loop), so disk I/O only occurs on the very first request after a file
    update.  All subsequent requests within the same file-write cycle are
    served from RAM.

    The response is a JSON ``"fits-tile"`` message::

        {
            "name": "fits-tile",
            "data": {
                "seq": <int>,    // echoed from the request
                "w":   <int>,    // actual tile width  (may be smaller near edges)
                "h":   <int>,    // actual tile height
                "b64": <string>  // base64-encoded raw float32 bytes, row-major
            }
        }

    Parameters
    ----------
    ws_conn
        The active Quart WebSocket object for this connection.
    msg : dict
        Parsed JSON from the client.
    """
    station = msg.get("station", "")
    camera  = msg.get("camera",  "")
    cx      = int(msg.get("cx", 0))
    cy      = int(msg.get("cy", 0))
    rx      = int(msg.get("rx", 50))
    ry      = int(msg.get("ry", 50))
    seq     = msg.get("seq", 0)

    data = get_cached_data(station, camera)
    if data is None:
        return

    h, w = data.shape[:2]
    x0, x1 = max(0, cx - rx), min(w, cx + rx)
    y0, y1 = max(0, cy - ry), min(h, cy + ry)
    tile    = data[y0:y1, x0:x1]
    tw, th  = tile.shape[1], tile.shape[0]

    # Encode as base64 so the response stays JSON (no binary WebSocket frames).
    # At 50×50 float32 the payload is ~3.3 kB before b64 (~4.5 kB after) — negligible.
    tile_b64 = base64.b64encode(tile.astype(np.float32).tobytes()).decode()

    await ws_conn.send(json.dumps({
        "name": "fits-tile",
        "data": {"seq": seq, "w": tw, "h": th, "b64": tile_b64},
    }))


# ---------------------------------------------------------------------------
# HTML routes
# ---------------------------------------------------------------------------
+9 −0
Original line number Diff line number Diff line
@@ -63,6 +63,11 @@
         style="background:#000; overflow:hidden; border-radius:6px;">
        <canvas class="cv-main d-block" width="800" height="800"
                style="width:100%; cursor:crosshair; transform-origin:0 0;"></canvas>
        {#- cv-overlay: transparent 2-D canvas for annotations (crosshairs, ROI, etc.)
            pointer-events:none passes all mouse events through to cv-main.
            Not CSS-transformed; fits-viewer.js imageToCanvas() handles the coordinate mapping. -#}
        <canvas class="cv-overlay position-absolute top-0 start-0" width="800" height="800"
                style="width:100%; pointer-events:none;"></canvas>
        <div class="cv-crosshair position-absolute"
             style="pointer-events:none; width:12px; height:12px;
                    transform:translate(-50%,-50%); display:none;">
@@ -129,6 +134,10 @@
                 style="background:#000; overflow:hidden; border-radius:6px;">
                <canvas class="cv-main d-block" width="800" height="800"
                        style="width:100%; cursor:crosshair; transform-origin:0 0;"></canvas>
                {#- cv-overlay: transparent annotation layer above cv-main.
                    See compact layout above for full comment. -#}
                <canvas class="cv-overlay position-absolute top-0 start-0" width="800" height="800"
                        style="width:100%; pointer-events:none;"></canvas>
                <div class="cv-crosshair position-absolute"
                     style="pointer-events:none; width:12px; height:12px;
                            transform:translate(-50%,-50%); display:none;">
+711 −277

File changed.

Preview size limit exceeded, changes collapsed.

+12 −2
Original line number Diff line number Diff line
@@ -80,13 +80,23 @@ function connect() {
    };
}

// Esposizione controllata dell'interfaccia di invio socket
// Controlled interface for outbound WebSocket messages.
window.noctuaSocket = {
    // Send a legacy {action, value} message (used by dependency-guard and controls).
    send: (action, value) => {
        if (socket && socket.readyState === WebSocket.OPEN) {
            socket.send(JSON.stringify({ action, value }));
        }
    },
    // Send an arbitrary JSON-serialisable object as a single message.
    // Used by the FITS viewer for tile-request messages.
    sendMsg: (msgObject) => {
        if (socket && socket.readyState === WebSocket.OPEN) {
            socket.send(JSON.stringify(msgObject));
        }
    },
    // Returns true when the connection is fully open and ready to send.
    isOpen: () => socket !== null && socket.readyState === WebSocket.OPEN,
};

connect();
Loading