Commit 73a9a1e5 authored by vertighel's avatar vertighel
Browse files

Starting with the vits viewer

parent b93e0471
Loading
Loading
Loading
Loading
Loading
+358 −0
Original line number Diff line number Diff line
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

"""
REST API for FITS image serving.

Endpoints (all under /api/viewer/<station>/<camera>):

  GET  /png?vmin=&vmax=          → full PNG  (rendered with viridis)
  GET  /fits                     → raw FITS file download
  GET  /info                     → JSON: shape, dtype, percentile stats
  GET  /tile?cx=&cy=&r=          → arraybuffer tile (raw float32 pixels)
                                   centred on (cx,cy) with half-radius r
  GET  /panoramic?vmin=&vmax=    → small PNG thumbnail (≤256px longest side)

The station/camera names map to filesystem paths via VIEWER_PATHS in
noctua/config/viewer.ini, e.g.:

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

  [station1/teccam]
  path = /data/station1/latest_tec.fits
"""
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

"""
REST API for FITS image serving.
"""

# System modules
import configparser
from pathlib import Path

# Third-party modules
import numpy as np
from astropy.io import fits
from quart import Blueprint, request, Response, jsonify, send_file

# Custom modules
from noctua.config.constants import DATA_FOLDER
from noctua.utils.image import array_to_png
from noctua.utils.color_tables import viridis

viewer_blueprint = Blueprint('viewer', __name__)

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

_cfg = configparser.ConfigParser()
_cfg_path = PACKAGE_ROOT / 'config' / 'viewer.ini'
_cfg.read(_cfg_path)

def _apply_viridis(data, vmin, vmax):
    """
    Map scalar float data to Viridis RGB uint8 array.

    Parameters
    ----------
    data : np.ndarray
        Scalar 2D array.
    vmin, vmax : float
        Stretching limits.

    Returns
    -------
    np.ndarray
        RGB uint8 array with shape (H, W, 3).
    """

    # Normalize 0.0 to 1.0
    norm = np.clip((data - vmin) / (vmax - vmin), 0, 1)
    # Map to 0-255 indices
    indices = (norm * 255).astype(np.uint8)
    return viridis[indices]

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

    Parameters
    ----------
    station : str
        The name of the station.
    camera : str
        The name of the camera.

    Returns
    -------
    Path or None
        The absolute Path object to the FITS file, or None if not configured.
    """

    key = f'{station}/{camera}'
    raw_path = _cfg.get(key, '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:
    """
    Load the primary HDU data as float32.

    Parameters
    ----------
    station : str
        The name of the station.
    camera : str
        The name of the camera.

    Returns
    -------
    np.ndarray or None
        The image data array, or None if the file is missing or invalid.
    """

    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 _auto_range(data: np.ndarray, lo: float = 0.5, hi: float = 99.5) -> tuple:
    """
    Calculate dynamic range based on percentiles.

    Parameters
    ----------
    data : np.ndarray
        The image data array.
    lo : float, optional
        The lower percentile. Defaults to 0.5.
    hi : float, optional
        The upper percentile. Defaults to 99.5.

    Returns
    -------
    tuple
        A tuple containing (vmin, vmax).
    """

    return float(np.percentile(data, lo)), float(np.percentile(data, hi))


def _thumb(data: np.ndarray, max_px: int = 256) -> np.ndarray:
    """
    Downscale data to fit within max_px on the longest side.

    Parameters
    ----------
    data : np.ndarray
        The original image data.
    max_px : int, optional
        The maximum size in pixels for the longest edge. Defaults to 256.

    Returns
    -------
    np.ndarray
        The downscaled image array.
    """

    h, w = data.shape[:2]
    scale = max_px / max(h, w)
    
    if scale >= 1.0:
        return data
        
    new_h = max(1, int(h * scale))
    new_w = max(1, int(w * scale))
    
    try:
        from skimage.transform import resize as sk_resize
        return sk_resize(data, (new_h, new_w), anti_aliasing=True, preserve_range=True).astype(np.float32)
    except ImportError:
        sy = max(1, h // new_h)
        sx = max(1, w // new_w)
        return data[::sy, ::sx]


@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
        The name of the station.
    camera : str
        The name of the camera.

    Returns
    -------
    Response
        A JSON response containing image metadata.
    """

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

    vmin, vmax = _auto_range(data)
    
    return jsonify({
        'shape': list(data.shape),
        'dtype': str(data.dtype),
        'vmin_auto': vmin,
        'vmax_auto': vmax,
        'global_min': float(data.min()),
        'global_max': float(data.max()),
    })


@viewer_blueprint.route('/viewer/<station>/<camera>/png')
async def image_png(station, camera):
    """
    Render the full image as a PNG.

    Parameters
    ----------
    station : str
        The name of the station.
    camera : str
        The name of the camera.

    Returns
    -------
    Response
        The PNG image data.
    """
    
    data = _load_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)
    if vmin is None or vmax is None:
        vmin, vmax = _auto_range(data)

    rgb = _apply_viridis(data, vmin, vmax)
    png = array_to_png(rgb)  # utils.image.array_to_png handles RGB ndim=3
    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 small thumbnail PNG.

    Parameters
    ----------
    station : str
        The name of the station.
    camera : str
        The name of the camera.

    Returns
    -------
    Response
        The panoramic PNG image data.
    """
    data = _load_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)
    if vmin is None or vmax is None:
        vmin, vmax = _auto_range(data)

    thumb = _thumb(data, max_px=int(request.args.get('max_px', 256)))
    rgb = _apply_viridis(thumb, vmin, vmax)
    png = array_to_png(rgb)
    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).

    Parameters
    ----------
    station : str
        The name of the station.
    camera : str
        The name of the camera.

    Returns
    -------
    Response
        The binary tile data with header.
    """

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

    h, w = data.shape[:2]
    cx = request.args.get('cx', default=w // 2, type=int)
    cy = request.args.get('cy', default=h // 2, type=int)
    rx = request.args.get('rx', default=15, type=int) # Default 15 half-width (30 total)
    ry = request.args.get('ry', default=10, type=int) # Default 10 half-height (20 total)

    x0 = max(0, cx - rx)
    x1 = min(w, cx + rx)
    y0 = max(0, cy - ry)
    y1 = min(h, cy + ry)

    tile = data[y0:y1, x0:x1]
    tw, th = tile.shape[1], tile.shape[0]

    header = np.array([tw, th], dtype=np.int32).tobytes()
    body = tile.astype(np.float32).tobytes()

    return Response(
        header + body,
        mimetype='application/octet-stream',
        headers={'Cache-Control': 'no-store'},
    )

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

    Parameters
    ----------
    station : str
        The name of the station.
    camera : str
        The name of the camera.

    Returns
    -------
    Response
        The raw FITS file attachment.
    """

    path = _fits_path(station, camera)
    
    if path is None or not path.exists():
        return Response('FITS not found', status=404)
        
    return await send_file(path, mimetype='image/fits', as_attachment=True, attachment_filename=path.name)
+2 −1
Original line number Diff line number Diff line
@@ -10,6 +10,7 @@ from quart_cors import cors
from noctua.api import api_blueprint
from noctua.api.baseresource import register_error_handlers
from noctua.web import web_blueprint
from noctua.api.fits_image import viewer_blueprint


app = Quart(__name__)
@@ -19,7 +20,7 @@ register_error_handlers(app)

app.register_blueprint(api_blueprint, url_prefix='/api')
app.register_blueprint(web_blueprint, url_prefix='/web')  # Added Web Blueprint

app.register_blueprint(viewer_blueprint, url_prefix='/api')

def run():
    """
+24 −0
Original line number Diff line number Diff line
# noctua/config/viewer.ini
#
# Map station/camera identifiers to FITS file paths.
# The key must match the URL pattern: /api/viewer/<station>/<camera>/...
#
# A typical observatory with three stations and two cameras each:

[station1/scicam]
path = test_stx.fits

[station1/teccam]
path = temp2.fits

[station2/scicam]
path = /data/station2/latest_sci.fits

[station2/teccam]
path = /data/station2/latest_tec.fits

[station3/scicam]
path = /data/station3/latest_sci.fits

[station3/teccam]
path = /data/station3/latest_tec.fits
+17 −0
Original line number Diff line number Diff line
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

"""
Utilities for image colors.
"""

# System modules

# Third-party modules
import numpy as np

# Custom modules

# Simplified Viridis LUT (256 colors RGB)
viridis = np.array([
[68,1,84],[68,2,85],[68,3,87],[69,5,88],[69,6,90],[69,8,91],[70,9,92],[70,11,94],[70,12,95],[70,14,97],[71,15,98],[71,17,99],[71,18,101],[71,20,102],[71,21,103],[71,22,105],[71,24,106],[72,25,107],[72,26,108],[72,28,110],[72,29,111],[72,30,112],[72,32,113],[72,33,114],[72,34,115],[72,35,116],[71,37,117],[71,38,118],[71,39,119],[71,40,120],[71,42,121],[71,43,122],[71,44,123],[70,45,124],[70,47,124],[70,48,125],[70,49,126],[69,50,127],[69,52,127],[69,53,128],[69,54,129],[68,55,129],[68,57,130],[67,58,131],[67,59,131],[67,60,132],[66,61,132],[66,62,133],[66,64,133],[65,65,134],[65,66,134],[64,67,135],[64,68,135],[63,69,135],[63,71,136],[62,72,136],[62,73,137],[61,74,137],[61,75,137],[61,76,137],[60,77,138],[60,78,138],[59,80,138],[59,81,138],[58,82,139],[58,83,139],[57,84,139],[57,85,139],[56,86,139],[56,87,140],[55,88,140],[55,89,140],[54,90,140],[54,91,140],[53,92,140],[53,93,140],[52,94,141],[52,95,141],[51,96,141],[51,97,141],[50,98,141],[50,99,141],[49,100,141],[49,101,141],[49,102,141],[48,103,141],[48,104,141],[47,105,141],[47,106,141],[46,107,142],[46,108,142],[46,109,142],[45,110,142],[45,111,142],[44,112,142],[44,113,142],[44,114,142],[43,115,142],[43,116,142],[42,117,142],[42,118,142],[42,119,142],[41,120,142],[41,121,142],[40,122,142],[40,122,142],[40,123,142],[39,124,142],[39,125,142],[39,126,142],[38,127,142],[38,128,142],[38,129,142],[37,130,142],[37,131,141],[36,132,141],[36,133,141],[36,134,141],[35,135,141],[35,136,141],[35,137,141],[34,137,141],[34,138,141],[34,139,141],[33,140,141],[33,141,140],[33,142,140],[32,143,140],[32,144,140],[32,145,140],[31,146,140],[31,147,139],[31,148,139],[31,149,139],[31,150,139],[30,151,138],[30,152,138],[30,153,138],[30,153,138],[30,154,137],[30,155,137],[30,156,137],[30,157,136],[30,158,136],[30,159,136],[30,160,135],[31,161,135],[31,162,134],[31,163,134],[32,164,133],[32,165,133],[33,166,133],[33,167,132],[34,167,132],[35,168,131],[35,169,130],[36,170,130],[37,171,129],[38,172,129],[39,173,128],[40,174,127],[41,175,127],[42,176,126],[43,177,125],[44,177,125],[46,178,124],[47,179,123],[48,180,122],[50,181,122],[51,182,121],[53,183,120],[54,184,119],[56,185,118],[57,185,118],[59,186,117],[61,187,116],[62,188,115],[64,189,114],[66,190,113],[68,190,112],[69,191,111],[71,192,110],[73,193,109],[75,194,108],[77,194,107],[79,195,105],[81,196,104],[83,197,103],[85,198,102],[87,198,101],[89,199,100],[91,200,98],[94,201,97],[96,201,96],[98,202,95],[100,203,93],[103,204,92],[105,204,91],[107,205,89],[109,206,88],[112,206,86],[114,207,85],[116,208,84],[119,208,82],[121,209,81],[124,210,79],[126,210,78],[129,211,76],[131,211,75],[134,212,73],[136,213,71],[139,213,70],[141,214,68],[144,214,67],[146,215,65],[149,215,63],[151,216,62],[154,216,60],[157,217,58],[159,217,56],[162,218,55],[165,218,53],[167,219,51],[170,219,50],[173,220,48],[175,220,46],[178,221,44],[181,221,43],[183,221,41],[186,222,39],[189,222,38],[191,223,36],[194,223,34],[197,223,33],[199,224,31],[202,224,30],[205,224,29],[207,225,28],[210,225,27],[212,225,26],[215,226,25],[218,226,24],[220,226,24],[223,227,24],[225,227,24],[228,227,24],[231,228,25],[233,228,25],[236,228,26],[238,229,27],[241,229,28],[243,229,30],[246,230,31],[248,230,33],[250,230,34],[253,231,36]], dtype=np.uint8)
+34 −1
Original line number Diff line number Diff line
@@ -4,7 +4,7 @@ import asyncio
import json

# Third-party modules
from quart import Blueprint, render_template, websocket
from quart import Blueprint, render_template, websocket, redirect, url_for

# Custom modules
from noctua.api.basecontext import ends
@@ -165,3 +165,36 @@ async def subsystem_page(subsystem_name, endpoint_name=None):
        subsystem=subsystem_name,
        endpoints=subsys_endpoints
    )

@web_blueprint.route('/viewer')
async def viewer_index():
    """
    Fallback route that redirects to the default station viewer.

    Returns
    -------
    Response
        A redirect to station1.
    """

    return redirect(url_for('web.viewer', station='station1'))


@web_blueprint.route('/viewer/<string:station>')
async def viewer(station):
    """
    Render the WebGL FITS viewer for a specific station.

    Parameters
    ----------
    station : str
        The identifier of the station (e.g., 'station1').

    Returns
    -------
    str
        The rendered HTML template.
    """

    return await render_template('viewer.html', station=station, api_base='/api')
Loading