Commit 444d6c06 authored by vertighel's avatar vertighel
Browse files

feat: camera Loop API + mako exptime fix + fits_image cleanup



- mako.Guider: add loop_exposure attr; start(exptime) sets ExposureTime
  feature before get_frame(); _run_loop sets ExposureTime once at start
- camera.py: add Loop resource (GET/POST/DELETE) with _broadcast_loop
  asyncio task that reads dev.image() and broadcasts fits-preview via WS
- guider.py: re-export Loop for tec1/2/3 (class=Guider → module guider)
- api.ini: add /scicam1-2/loop and /teccam1-3/loop sections
- fits_image.py: remove acquisition loop (_loops, _run_loop, _get_device,
  loop routes); remove unused imports asyncio/base64/array_to_png

Co-Authored-By: default avatarClaude Sonnet 4.6 <noreply@anthropic.com>
parent 74cae1ce
Loading
Loading
Loading
Loading
+65 −1
Original line number Diff line number Diff line
@@ -4,11 +4,39 @@

'''REST API for Camera related operations'''

# System modules
import asyncio
import base64

# Third-party modules
from quart import request

# This module imports
from .baseresource import BaseResource, expects
from noctua.config import constants
from noctua.api.sequencer_instance import seq

# Broadcast tasks keyed by device id — one per looping camera
_broadcast_tasks: dict[int, asyncio.Task] = {}


async def _broadcast_loop(device, cam_id):
    """Broadcast PNG frames via WebSocket while device.looping is True."""
    from noctua.web import streamer
    ev = asyncio.get_running_loop()
    try:
        while device.looping:
            png = await ev.run_in_executor(None, device.image)
            if png:
                encoded = base64.b64encode(png).decode('utf-8')
                await streamer.broadcaster.broadcast(
                    'fits-preview',
                    {'cam_id': cam_id, 'png': encoded},
                )
            await asyncio.sleep(0.5)
    except asyncio.CancelledError:
        pass

class FrameBinning(BaseResource):
    """Binning of the camera."""

@@ -213,3 +241,39 @@ class Settings(BaseResource):

        res = await self.run_blocking(lambda: self.dev.all)
        return self.make_response(res)

class Loop(BaseResource):
    """Continuous acquisition loop with WebSocket broadcast."""

    async def get(self):
        """Return whether the acquisition loop is running."""
        return self.make_response(self.dev.looping, raw_data=self.dev.looping)

    @expects(param_type="number", unit="s", placeholder="1.0")
    async def post(self):
        """Start the acquisition loop.

        Body: {"exptime": <float>}
        """
        body     = await self.get_payload()
        exposure = float(body.get('exptime', 1.0))
        cam_id   = request.path.split('/')[2]

        self.dev.loop_exposure = exposure
        self.dev.looping = True

        existing = _broadcast_tasks.pop(id(self.dev), None)
        if existing and not existing.done():
            existing.cancel()
        _broadcast_tasks[id(self.dev)] = asyncio.get_running_loop().create_task(
            _broadcast_loop(self.dev, cam_id)
        )
        return self.make_response({'running': True, 'exposure': exposure})

    async def delete(self):
        """Stop the acquisition loop."""
        self.dev.looping = False
        task = _broadcast_tasks.pop(id(self.dev), None)
        if task and not task.done():
            task.cancel()
        return self.make_response({'running': False})
+1 −89
Original line number Diff line number Diff line
@@ -36,8 +36,6 @@ The section name is the ``cam_id`` used in the URL segment ``<cam_id>``.
"""

# System modules
import asyncio
import base64
import configparser
import os
from pathlib import Path
@@ -49,7 +47,7 @@ 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, auto_range, fits_to_png
from noctua.utils.image import auto_range, fits_to_png

viewer_blueprint = Blueprint('viewer', __name__)

@@ -115,57 +113,6 @@ def get_cached_data(cam_id: str) -> np.ndarray | None:
    return data


# ---------------------------------------------------------------------------
# Teccam acquisition loop
# ---------------------------------------------------------------------------

_loops: dict[str, asyncio.Task] = {}


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, 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()
    trigger_mode = getattr(device, 'acquisition', 'stream') == 'trigger'
    uint8_mode   = getattr(device, 'dtype', np.float32) == np.uint8
    try:
        while True:
            if trigger_mode:
                await ev.run_in_executor(None, device.start, exposure, 1)
                deadline = ev.time() + exposure + 30.0
                while ev.time() < deadline:
                    ready = await ev.run_in_executor(None, lambda: device.ready)
                    if ready == 1:
                        break
                    await asyncio.sleep(0.3)

            data = await ev.run_in_executor(None, lambda: device.matrix)
            if data is not None:
                if uint8_mode:
                    png = array_to_png(data)
                else:
                    png = fits_to_png(data, thumbnail_size=512)
                encoded = base64.b64encode(png).decode('utf-8')
                await streamer.broadcaster.broadcast(
                    "fits-preview",
                    {"cam_id": cam_id, "png": encoded},
                )

            if not trigger_mode:
                await asyncio.sleep(exposure)
    except asyncio.CancelledError:
        pass


# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@@ -258,38 +205,3 @@ async def download_fits(cam_id):
    )

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


@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."""
    body   = await request.get_json(silent=True) or {}
    action = body.get('action')

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

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

        exposure    = float(body.get('exposure', 1.0))
        _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(cam_id, None)
        if task and not task.done():
            task.cancel()
        return jsonify({'status': 'stopped'})

    return jsonify({'error': 'unknown action'}), 400
+1 −1
Original line number Diff line number Diff line
@@ -8,7 +8,7 @@ from quart import Blueprint, jsonify, request
from noctua.api.guider_instance import guider
from noctua.utils.logger import log

from .camera import FrameBinning, SnapshotRaw, SnapshotState  # noqa: F401 — re-exported for dynamic_import (tec1/2/3 class=Guider → module noctua.api.guider)
from .camera import FrameBinning, Loop, SnapshotRaw, SnapshotState  # noqa: F401 — re-exported for dynamic_import (tec1/2/3 class=Guider → module noctua.api.guider)

guider_api = Blueprint("guider", __name__)

+20 −0
Original line number Diff line number Diff line
@@ -240,6 +240,10 @@ device = cam1
resource = SnapshotState
device = cam1

[/scicam1/loop]
resource = Loop
device = cam1

# [/scicam1/frame]
# resource = Frame
# device = cam1
@@ -309,6 +313,10 @@ device = cam2
resource = SnapshotState
device = cam2

[/scicam2/loop]
resource = Loop
device = cam2

# [/scicam2/snapshot]
# resource = Snapshot
# device = cam2
@@ -338,6 +346,10 @@ device = tec1
resource = SnapshotState
device = tec1

[/teccam1/loop]
resource = Loop
device = tec1

##############
# teccam2 (Mako111 — station2)
##############
@@ -354,6 +366,10 @@ device = tec2
resource = SnapshotState
device = tec2

[/teccam2/loop]
resource = Loop
device = tec2

##############
# teccam3 (Mako115 — station3)
##############
@@ -370,6 +386,10 @@ device = tec3
resource = SnapshotState
device = tec3

[/teccam3/loop]
resource = Loop
device = tec3

##############
# webcam
##############
+11 −1
Original line number Diff line number Diff line
@@ -102,6 +102,7 @@ class Guider(Mako):
        self._streamer = None
        self._looping = False
        self._loop_thread = None
        self.loop_exposure = 1.0


    @property
@@ -121,6 +122,7 @@ class Guider(Mako):

    def _run_loop(self):
        cam = self._check_connection()
        cam.ExposureTime.set(self.loop_exposure * 1e6)
        while self._looping:
            try:
                raw = cam.get_frame().as_numpy_ndarray()
@@ -131,16 +133,24 @@ class Guider(Mako):
                log.error(f"Mako loop error: {e}")
                time.sleep(1.0)

    def start(self):
    def start(self, exptime=None):
        """Capture a single frame via a blocking get_frame() call.

        Refuses with an error if a loop is active; set ``looping = False`` first.
        The result is stored in the internal buffer and accessible via ``matrix``.

        Parameters
        ----------
        exptime : float, optional
            Exposure time in seconds. If given, sets the camera ExposureTime
            feature before capture. If None, the current camera setting is used.
        """
        if self._looping:
            log.error("Camera is looping. Set looping=False first.")
            return
        cam = self._check_connection()
        if exptime is not None:
            cam.ExposureTime.set(exptime * 1e6)
        raw = cam.get_frame().as_numpy_ndarray()
        if raw is not None:
            with self._lock: