Add a stub OpenCV-based camera
This allows the server to run on a laptop, to some extent. I've not tested if this will make the whole web app work properly. As StreamingPiCamera2 is hardcoded in various places, this certainly doesn't make cameras totally interchangeable - but it is a step towards that.
This commit is contained in:
parent
c32b44d1a6
commit
3f92ed8266
2 changed files with 152 additions and 1 deletions
|
|
@ -17,9 +17,9 @@ classifiers = [
|
|||
dependencies = [
|
||||
"labthings-fastapi[server]",
|
||||
"labthings-sangaboard",
|
||||
"labthings-picamera2",
|
||||
"camera-stage-mapping ~= 0.1.6",
|
||||
"numpy ~= 1.20",
|
||||
"piexif",
|
||||
"scipy ~= 1.6",
|
||||
"opencv-python ~= 4.7.0",
|
||||
"anyio ~= 4.0",
|
||||
|
|
@ -29,6 +29,10 @@ dependencies = [
|
|||
dev = [
|
||||
"labthings-fastapi[dev]",
|
||||
]
|
||||
pi = [
|
||||
"labthings-picamera2",
|
||||
]
|
||||
|
||||
|
||||
[project.urls]
|
||||
"Homepage" = "https://openflexure.org/"
|
||||
|
|
|
|||
147
src/openflexure_microscope_server/things/opencv_camera.py
Normal file
147
src/openflexure_microscope_server/things/opencv_camera.py
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
"""OpenFlexure Microscope OpenCV Camera
|
||||
|
||||
This module defines a Thing that is responsible for using the stage and
|
||||
camera together to perform an autofocus routine.
|
||||
|
||||
See repository root for licensing information.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
from typing import Literal, Optional
|
||||
from threading import Thread
|
||||
|
||||
import cv2
|
||||
import piexif
|
||||
|
||||
from labthings_fastapi.thing import Thing
|
||||
from labthings_fastapi.utilities import get_blocking_portal
|
||||
from labthings_fastapi.decorators import thing_action, thing_property
|
||||
from labthings_fastapi.dependencies.metadata import GetThingStates
|
||||
from labthings_fastapi.dependencies.blocking_portal import BlockingPortal
|
||||
from labthings_fastapi.outputs.mjpeg_stream import MJPEGStreamDescriptor
|
||||
from labthings_fastapi.outputs.blob import BlobOutput
|
||||
from labthings_fastapi.types.numpy import NDArray
|
||||
|
||||
|
||||
class JPEGBlob(BlobOutput):
|
||||
media_type = "image/jpeg"
|
||||
|
||||
|
||||
class OpenCVCamera(Thing):
|
||||
"""A Thing representing an OpenCV camera"""
|
||||
def __init__(self, camera_index: int=0):
|
||||
self.camera_index = camera_index
|
||||
self._capture_thread: Optional[Thread] = None
|
||||
self._capture_enabled = False
|
||||
|
||||
def __enter__(self):
|
||||
self.cap = cv2.VideoCapture(self.camera_index)
|
||||
self._capture_enabled = True
|
||||
self._capture_thread = Thread(target=self._capture_frames)
|
||||
self._capture_thread.start()
|
||||
return self
|
||||
|
||||
def __exit__(self, _exc_type, _exc_value, _traceback):
|
||||
self.cap.release()
|
||||
|
||||
@thing_property
|
||||
def stream_active(self) -> bool:
|
||||
"Whether the MJPEG stream is active"
|
||||
if self._capture_enabled and self._capture_thread:
|
||||
return self._capture_thread.is_alive()
|
||||
return False
|
||||
mjpeg_stream = MJPEGStreamDescriptor()
|
||||
lores_mjpeg_stream = MJPEGStreamDescriptor()
|
||||
|
||||
def _capture_frames(self):
|
||||
portal = get_blocking_portal(self)
|
||||
while self._capture_enabled:
|
||||
ret, frame = self.cap.read()
|
||||
if not ret:
|
||||
logging.error(f"Failed to capture frame from camera {self.camera_index}")
|
||||
break
|
||||
jpeg = cv2.imencode(".jpg", frame)[1].tobytes()
|
||||
self.mjpeg_stream.add_frame(jpeg, portal)
|
||||
jpeg_lores = cv2.imencode(".jpg", cv2.resize(frame, (320, 240)))[1].tobytes()
|
||||
self.lores_mjpeg_stream.add_frame(jpeg_lores, portal)
|
||||
|
||||
@thing_action
|
||||
def snap_image(self) -> NDArray:
|
||||
"""Acquire one image from the camera.
|
||||
|
||||
This action cannot run if the camera is in use by a background thread, for
|
||||
example if a preview stream is running.
|
||||
"""
|
||||
return self.capture_array()
|
||||
|
||||
@thing_action
|
||||
def capture_array(self) -> NDArray:
|
||||
"""Acquire one image from the camera and return as an array
|
||||
|
||||
This function will produce a nested list containing an uncompressed RGB image.
|
||||
It's likely to be highly inefficient - raw and/or uncompressed captures using
|
||||
binary image formats will be added in due course.
|
||||
"""
|
||||
ret, frame = self.cap.read()
|
||||
if not ret:
|
||||
raise RuntimeError(f"Failed to capture frame from camera {self.camera_index}")
|
||||
return frame
|
||||
|
||||
@thing_action
|
||||
def capture_jpeg(
|
||||
self,
|
||||
metadata_getter: GetThingStates,
|
||||
) -> JPEGBlob:
|
||||
"""Acquire one image from the camera and return as a JPEG blob
|
||||
|
||||
This function will produce a JPEG image.
|
||||
"""
|
||||
frame = self.capture_array()
|
||||
jpeg = cv2.imencode(".jpg", frame)[1].tobytes()
|
||||
exif_dict = {
|
||||
"Exif": {
|
||||
piexif.ExifIFD.UserComment: json.dumps(metadata_getter()).encode("utf-8")
|
||||
},
|
||||
"GPS": {},
|
||||
"Interop": {},
|
||||
"1st": {},
|
||||
"thumbnail": None,
|
||||
}
|
||||
output = io.BytesIO()
|
||||
piexif.insert(piexif.dump(exif_dict), jpeg, output)
|
||||
return JPEGBlob.from_bytes(output.getvalue())
|
||||
|
||||
@thing_action
|
||||
def grab_jpeg(
|
||||
self,
|
||||
portal: BlockingPortal,
|
||||
stream_name: Literal["main", "lores"] = "main",
|
||||
) -> JPEGBlob:
|
||||
"""Acquire one image from the preview stream and return as an array
|
||||
|
||||
This differs from `capture_jpeg` in that it does not pause the MJPEG
|
||||
preview stream. Instead, we simply return the next frame from that
|
||||
stream (either "main" for the preview stream, or "lores" for the low
|
||||
resolution preview). No metadata is returned.
|
||||
"""
|
||||
logging.info(f"StreamingPiCamera2.grab_jpeg(stream_name={stream_name}) starting")
|
||||
stream = (
|
||||
self.lores_mjpeg_stream if stream_name == "lores" else self.mjpeg_stream
|
||||
)
|
||||
frame = portal.call(stream.grab_frame)
|
||||
logging.info(f"StreamingPiCamera2.grab_jpeg(stream_name={stream_name}) got frame")
|
||||
return JPEGBlob.from_bytes(frame)
|
||||
|
||||
@thing_action
|
||||
def grab_jpeg_size(
|
||||
self,
|
||||
portal: BlockingPortal,
|
||||
stream_name: Literal["main", "lores"] = "main",
|
||||
) -> int:
|
||||
"""Acquire one image from the preview stream and return its size"""
|
||||
stream = (
|
||||
self.lores_mjpeg_stream if stream_name == "lores" else self.mjpeg_stream
|
||||
)
|
||||
return portal.call(stream.next_frame_size)
|
||||
Loading…
Add table
Add a link
Reference in a new issue