Commit 3a80b973 authored by vertighel's avatar vertighel
Browse files

refactor: fits_image.py — route /viewer/<cam_id>/, legge cameras.ini



Tutte le route unificate su /viewer/<cam_id>/ (era /viewer/<station>/<camera>/).
Cache e loop keyed su cam_id string.
_get_device() risolve il device tramite campo 'device' in cameras.ini.
Broadcast formato: {cam_id, png}.

Co-Authored-By: default avatarClaude Sonnet 4.6 <noreply@anthropic.com>
parent 99e90d52
Loading
Loading
Loading
Loading
+67 −239
Original line number Diff line number Diff line
@@ -4,9 +4,9 @@
"""
REST API for FITS image serving.

All endpoints live under ``/api/viewer/<station>/<camera>`` and are
All endpoints live under ``/api/viewer/<cam_id>`` and are
camera-agnostic: the same routes serve every scientific or technical
camera that is listed in ``noctua/config/viewer.ini``.
camera that is listed in ``noctua/config/cameras.ini``.

Endpoints
---------
@@ -16,8 +16,6 @@ GET /png?vmin=&vmax=
    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`
    using pure numpy — no scikit-image dependency.
GET /tile?cx=&cy=&rx=&ry=
    Raw ``float32`` arraybuffer tile centred on *(cx, cy)*.
GET /fits
@@ -25,15 +23,16 @@ GET /fits

Configuration
-------------
Station/camera paths are declared in ``noctua/config/viewer.ini``::
Camera paths are declared in ``noctua/config/cameras.ini``::

    [station1/scicam]
    path = /data/station1/latest.fits
    [scicam1]
    fits_path = fits/scicam1.fits

    [station2/allsky]
    path = /data/station2/allsky.fits
    [teccam1]
    fits_path = fits/teccam1.fits
    device    = tec1

The key must match the URL segment ``<station>/<camera>``.
The section name is the ``cam_id`` used in the URL segment ``<cam_id>``.
"""

# System modules
@@ -55,19 +54,15 @@ from noctua.utils.image import array_to_png, auto_range, fits_to_png
viewer_blueprint = Blueprint('viewer', __name__)

# ---------------------------------------------------------------------------
# Path resolution
# Configuration
# ---------------------------------------------------------------------------

PACKAGE_ROOT = Path(__file__).parent.parent.absolute()
PROJECT_ROOT = PACKAGE_ROOT.parent

_cfg = configparser.ConfigParser()
_cfg.read(PACKAGE_ROOT / 'config' / 'viewer.ini')
_cfg.read(PACKAGE_ROOT / 'config' / 'cameras.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


@@ -75,46 +70,21 @@ _PNG_MAX_PX = 512
# FITS data cache
# ---------------------------------------------------------------------------

# 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]] = {}
# Maps cam_id → (file_mtime, float32_ndarray).
_fits_cache: dict[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.

    Parameters
    ----------
    station : str
        Station identifier (e.g. ``'station1'``).
    camera : str
        Camera identifier (e.g. ``'scicam'``, ``'allsky'``).

    Returns
    -------
    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)
def _fits_path(cam_id: str) -> Path | None:
    """Return the filesystem path for a camera's latest FITS file."""
    raw_path = _cfg.get(cam_id, 'fits_path', fallback=None)
    if raw_path is None:
        return None
    return PROJECT_ROOT / DATA_FOLDER / raw_path


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)
def _load_data(cam_id: str) -> np.ndarray | None:
    """Read the primary HDU from disk and return it as a float32 array."""
    path = _fits_path(cam_id)
    if path is None or not path.exists():
        return None
    with fits.open(path) as hdul:
@@ -124,33 +94,9 @@ def _load_data(station: str, camera: str) -> np.ndarray | None:
        return data.astype(np.float32)


def get_cached_data(station: str, camera: str) -> np.ndarray | None:
    """
    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
    ----------
    station : str
        Station identifier.
    camera : str
        Camera identifier.

    Returns
    -------
    np.ndarray or None
        Float32 2-D image array, or ``None`` if the file is missing.
    """
    path = _fits_path(station, camera)
def get_cached_data(cam_id: str) -> np.ndarray | None:
    """Return the FITS image data for a camera, backed by an mtime cache."""
    path = _fits_path(cam_id)
    if path is None or not path.exists():
        return None

@@ -159,14 +105,13 @@ def get_cached_data(station: str, camera: str) -> np.ndarray | None:
    except OSError:
        return None

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

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


@@ -177,16 +122,16 @@ def get_cached_data(station: str, camera: str) -> np.ndarray | None:
_loops: dict[str, asyncio.Task] = {}


def _get_device(station, camera):
    """Return the device instance for a station/camera pair, or None."""
    dev_name = _cfg.get(f'{station}/{camera}', 'device', fallback=None)
def _get_device(cam_id: str):
    """Return the device instance for a cam_id, or None."""
    dev_name = _cfg.get(cam_id, 'device', fallback=None)
    if not dev_name:
        return None
    from noctua import devices as _devices
    return getattr(_devices, dev_name, None)


async def _run_loop(device, exposure: float, station: str, camera: str):
async def _run_loop(device, exposure: float, cam_id: str):
    """Continuous acquisition loop: captures frames and broadcasts PNG via WebSocket."""
    from noctua.web import streamer  # lazy — avoids circular import at module load
    ev = asyncio.get_running_loop()
@@ -195,7 +140,6 @@ async def _run_loop(device, exposure: float, station: str, camera: str):
    try:
        while True:
            if trigger_mode:
                # STX: explicit exposure trigger + ready poll
                await ev.run_in_executor(None, device.start, exposure, 1)
                deadline = ev.time() + exposure + 30.0
                while ev.time() < deadline:
@@ -206,15 +150,14 @@ async def _run_loop(device, exposure: float, station: str, camera: str):

            data = await ev.run_in_executor(None, lambda: device.matrix)
            if data is not None:
                # device.matrix always returns (H, W); dtype selects rendering path
                if uint8_mode:
                    png = array_to_png(data)          # fixed [0, 255], grayscale
                    png = array_to_png(data)
                else:
                    png = fits_to_png(data, thumbnail_size=512)  # viridis + auto-range
                    png = fits_to_png(data, thumbnail_size=512)
                encoded = base64.b64encode(png).decode('utf-8')
                await streamer.broadcaster.broadcast(
                    "fits-preview",
                    {"station": station, "camera": camera, "png": encoded},
                    {"cam_id": cam_id, "png": encoded},
                )

            if not trigger_mode:
@@ -227,25 +170,10 @@ async def _run_loop(device, exposure: float, station: str, camera: str):
# Routes
# ---------------------------------------------------------------------------

@viewer_blueprint.route('/viewer/<station>/<camera>/info')
async def image_info(station, camera):
    """
    Return shape, dtype, and auto-range statistics for the image.

    Parameters
    ----------
    station : str
        Station identifier.
    camera : str
        Camera identifier.

    Returns
    -------
    quart.Response
        JSON with keys ``shape``, ``dtype``, ``vmin_auto``, ``vmax_auto``,
        ``global_min``, ``global_max``.
    """
    data = get_cached_data(station, camera)
@viewer_blueprint.route('/viewer/<cam_id>/info')
async def image_info(cam_id):
    """Return shape, dtype, and auto-range statistics for the image."""
    data = get_cached_data(cam_id)
    if data is None:
        return jsonify({'error': 'FITS not found'}), 404

@@ -260,35 +188,10 @@ async def image_info(station, camera):
    })


@viewer_blueprint.route('/viewer/<station>/<camera>/png')
async def image_png(station, camera):
    """
    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
    ----------
    station : str
        Station identifier.
    camera : str
        Camera identifier.

    Returns
    -------
    quart.Response
        PNG image bytes.

    Query Parameters
    ----------------
    vmin : float, optional
        Lower display limit.  Auto-computed when omitted.
    vmax : float, optional
        Upper display limit.  Auto-computed when omitted.
    """
    data = get_cached_data(station, camera)
@viewer_blueprint.route('/viewer/<cam_id>/png')
async def image_png(cam_id):
    """Render the image as a PNG with Viridis colormap."""
    data = get_cached_data(cam_id)
    if data is None:
        return Response('FITS not found', status=404)

@@ -298,36 +201,10 @@ async def image_png(station, camera):
    return Response(png, mimetype='image/png', headers={'Cache-Control': 'no-store'})


@viewer_blueprint.route('/viewer/<station>/<camera>/panoramic')
async def image_panoramic(station, camera):
    """
    Render a thumbnail PNG (longest side ≤ *max_px* pixels).

    Downscaling is performed by :func:`~noctua.utils.image.array_to_png`
    using pure numpy stride sampling — no extra dependencies required.

    Parameters
    ----------
    station : str
        Station identifier.
    camera : str
        Camera identifier.

    Returns
    -------
    quart.Response
        Small PNG image bytes.

    Query Parameters
    ----------------
    vmin : float, optional
        Lower display limit.  Auto-computed when omitted.
    vmax : float, optional
        Upper display limit.  Auto-computed when omitted.
    max_px : int, optional
        Maximum pixels on the longest side.  Default is ``256``.
    """
    data = get_cached_data(station, camera)
@viewer_blueprint.route('/viewer/<cam_id>/panoramic')
async def image_panoramic(cam_id):
    """Render a thumbnail PNG."""
    data = get_cached_data(cam_id)
    if data is None:
        return Response('FITS not found', status=404)

@@ -338,42 +215,10 @@ async def image_panoramic(station, camera):
    return Response(png, mimetype='image/png', headers={'Cache-Control': 'no-store'})


@viewer_blueprint.route('/viewer/<station>/<camera>/tile')
async def image_tile(station, camera):
    """
    Return a raw ``float32`` arraybuffer tile centred on *(cx, cy)*.

    The response binary layout is::

        [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
        Station identifier.
    camera : str
        Camera identifier.

    Returns
    -------
    quart.Response
        Binary ``application/octet-stream`` payload.

    Query Parameters
    ----------------
    cx : int, optional
        Tile centre X (image pixels).  Defaults to image centre.
    cy : int, optional
        Tile centre Y (image pixels).  Defaults to image centre.
    rx : int, optional
        Half-width of the tile.  Default is ``50``.
    ry : int, optional
        Half-height of the tile.  Default is ``50``.
    """
    data = get_cached_data(station, camera)
@viewer_blueprint.route('/viewer/<cam_id>/tile')
async def image_tile(cam_id):
    """Return a raw float32 arraybuffer tile centred on (cx, cy)."""
    data = get_cached_data(cam_id)
    if data is None:
        return Response('FITS not found', status=404)

@@ -398,25 +243,10 @@ async def image_tile(station, camera):
    )


@viewer_blueprint.route('/viewer/<station>/<camera>/fits')
async def download_fits(station, camera):
    """
    Serve the raw FITS file as a download.

    Parameters
    ----------
    station : str
        Station identifier.
    camera : str
        Camera identifier.

    Returns
    -------
    quart.Response
        File attachment.
    """

    path = _fits_path(station, camera)
@viewer_blueprint.route('/viewer/<cam_id>/fits')
async def download_fits(cam_id):
    """Serve the raw FITS file as a download."""
    path = _fits_path(cam_id)
    if path is None or not path.exists():
        return Response('FITS not found', status=404)

@@ -428,38 +258,36 @@ async def download_fits(station, camera):
    )


@viewer_blueprint.route('/viewer/<station>/<camera>/loop', methods=['GET'])
async def camera_loop_status(station, camera):
@viewer_blueprint.route('/viewer/<cam_id>/loop', methods=['GET'])
async def camera_loop_status(cam_id):
    """Return whether a continuous acquisition loop is running."""
    key = f'{station}/{camera}'
    task = _loops.get(key)
    task = _loops.get(cam_id)
    return jsonify({'running': task is not None and not task.done()})


@viewer_blueprint.route('/viewer/<station>/<camera>/loop', methods=['POST'])
async def camera_loop(station, camera):
@viewer_blueprint.route('/viewer/<cam_id>/loop', methods=['POST'])
async def camera_loop(cam_id):
    """Start or stop a continuous acquisition loop for a teccam."""
    key = f'{station}/{camera}'
    body   = await request.get_json(silent=True) or {}
    action = body.get('action')

    if action == 'start':
        device = _get_device(station, camera)
        device = _get_device(cam_id)
        if device is None:
            return jsonify({'error': 'no device configured'}), 404

        existing = _loops.get(key)
        existing = _loops.get(cam_id)
        if existing and not existing.done():
            existing.cancel()

        exposure    = float(body.get('exposure', 1.0))
        _loops[key] = asyncio.get_running_loop().create_task(
            _run_loop(device, exposure, station, camera)
        _loops[cam_id] = asyncio.get_running_loop().create_task(
            _run_loop(device, exposure, cam_id)
        )
        return jsonify({'status': 'started', 'exposure': exposure})

    if action == 'stop':
        task = _loops.pop(key, None)
        task = _loops.pop(cam_id, None)
        if task and not task.done():
            task.cancel()
        return jsonify({'status': 'stopped'})