Skip to content

Commit

Permalink
abstract gpio virtual backend + improved timing requesting images fro…
Browse files Browse the repository at this point in the history
…m cameras.
  • Loading branch information
mgineer85 committed Oct 27, 2024
1 parent 2d1039e commit 8965155
Show file tree
Hide file tree
Showing 9 changed files with 266 additions and 118 deletions.
45 changes: 35 additions & 10 deletions node/services/backends/cameras/abstractbackend.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import io
import logging
from abc import ABC, abstractmethod
from threading import Condition
from queue import Full, Queue
from threading import Condition, Event

logger = logging.getLogger(__name__)


class StreamingOutput(io.BufferedIOBase):
Expand All @@ -21,22 +25,51 @@ def write(self, buf):
self.condition.notify_all()


class AbstractBackend(ABC):
class AbstractCameraBackend(ABC):
def __init__(self):
# used to abort threads when service is stopped.
self._is_running: bool = None

# declare common abstract props
self._nominal_framerate: int = None
self._queue_timestamp_monotonic_ns: Queue = None
self._event_request_tick: Event = None
self._capture: Event = None

# init common abstract props
self._event_request_tick: Event = Event()
self._capture = Event()

def __repr__(self):
return f"{self.__class__}"

@abstractmethod
def start(self, nominal_framerate: int = None):
self._is_running: bool = True

if not nominal_framerate:
# if 0 or None, fail!
raise RuntimeError("nominal framerate needs to be given!")

self._nominal_framerate = nominal_framerate
self._queue_timestamp_monotonic_ns: Queue = Queue(maxsize=1)

@abstractmethod
def stop(self):
self._is_running: bool = False

def do_capture(self, filename: str = None, number_frames: int = 1):
self._capture.set()

def sync_tick(self, timestamp_ns: int):
try:
self._queue_timestamp_monotonic_ns.put_nowait(timestamp_ns)
except Full:
logger.info("could not queue timestamp - camera not started, busy or nominal fps to close to cameras max mode fps?")

def request_tick(self):
self._event_request_tick.set()

@abstractmethod
def start_stream(self):
pass
Expand All @@ -48,11 +81,3 @@ def stop_stream(self):
@abstractmethod
def wait_for_lores_image(self):
pass

@abstractmethod
def do_capture(self, filename: str = None, number_frames: int = 1):
pass

@abstractmethod
def sync_tick(self, timestamp_ns: int):
pass
43 changes: 14 additions & 29 deletions node/services/backends/cameras/picamera2.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,54 +2,44 @@
import time
from datetime import datetime
from pathlib import Path
from queue import Empty, Full, Queue
from threading import Event, Thread
from queue import Empty
from threading import Thread

from libcamera import Transform, controls
from picamera2 import Picamera2, Preview
from picamera2.encoders import MJPEGEncoder
from picamera2.outputs import FileOutput

from ...config.models import ConfigBackendPicamera2
from .abstractbackend import AbstractBackend, StreamingOutput
from .abstractbackend import AbstractCameraBackend, StreamingOutput

logger = logging.getLogger(__name__)


ADJUST_EVERY_X_CYCLE = 8


class Picamera2Backend(AbstractBackend):
class Picamera2Backend(AbstractCameraBackend):
def __init__(self, config: ConfigBackendPicamera2):
super().__init__()
# init with arguments
self._config: ConfigBackendPicamera2 = config

# private props
self._picamera2: Picamera2 = None
self._queue_timestamp_monotonic_ns: Queue = None
self._nominal_framerate: float = None
self._adjust_sync_offset: int = 0
self._capture: Event = None
self._camera_thread: Thread = None
self._streaming_output: StreamingOutput = None

# initialize private props
self._capture = Event()
self._streaming_output: StreamingOutput = StreamingOutput()

logger.info(f"global_camera_info {Picamera2.global_camera_info()}")

def start(self, nominal_framerate: int = None):
"""To start the backend, configure picamera2"""
super().start()

if not nominal_framerate:
# if 0 or None, fail!
raise RuntimeError("nominal framerate needs to be given!")

self._nominal_framerate = nominal_framerate
self._queue_timestamp_monotonic_ns: Queue = Queue(maxsize=1)
super().start(nominal_framerate=nominal_framerate)

# https://github.com/raspberrypi/picamera2/issues/576
if self._picamera2:
Expand All @@ -66,7 +56,7 @@ def start(self, nominal_framerate: int = None):
encode="lores",
display="lores",
buffer_count=2,
queue=True,
queue=True, # TODO: validate. Seems False is working better on slower systems? but also on Pi5?
controls={"FrameRate": self._nominal_framerate},
transform=Transform(hflip=False, vflip=False),
)
Expand Down Expand Up @@ -138,15 +128,6 @@ def wait_for_lores_image(self):

return self._streaming_output.frame

def do_capture(self, filename: str = None, number_frames: int = 1):
self._capture.set()

def sync_tick(self, timestamp_ns: int):
try:
self._queue_timestamp_monotonic_ns.put_nowait(timestamp_ns)
except Full:
logger.info("could not queue timestamp - camera not started, busy or nominal fps to close to cameras max mode fps?")

def _check_framerate(self):
assert self._nominal_framerate is not None

Expand Down Expand Up @@ -219,6 +200,10 @@ def _camera_fun(self):

logger.info(f"####### capture end, took {round((time.time() - tms), 2)}s #######")
else:
self._event_request_tick.wait(timeout=2.0)
self._event_request_tick.clear()
capture_time_assigned_timestamp_ns = 0

job = self._picamera2.capture_request(wait=False)

try:
Expand All @@ -229,9 +214,9 @@ def _camera_fun(self):
# continue so .is_running is checked. if supervisor detected already, var is false and effective aborted the thread.
# if it is still true, maybe it was restarted already and we can try again?
continue

picam_metadata = request.get_metadata()
request.release()
else:
picam_metadata = request.get_metadata()
request.release()

timestamp_delta = picam_metadata["SensorTimestamp"] - capture_time_assigned_timestamp_ns # in ns

Expand All @@ -251,7 +236,7 @@ def _camera_fun(self):
if abs(timestamp_delta / 1.0e6) > 2.0:
# even in debug reduce verbosity a bit if all is fine and within 2ms tolerance
logger.debug(
f"timestamp clk/sensor=({round((capture_time_assigned_timestamp_ns or 0)/1e6,1)}/"
f"timestamp clk/sensor=({round((capture_time_assigned_timestamp_ns)/1e6,1)}/"
f"{round(picam_metadata['SensorTimestamp']/1e6,1)}) ms, "
f"delta={round((timestamp_delta)/1e6,1)} ms, "
f"adj_cycle_cntr={adjust_cycle_counter}, "
Expand Down
26 changes: 19 additions & 7 deletions node/services/backends/cameras/virtualcamera.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,26 @@
import numpy
from PIL import Image

from ...config.models import ConfigBackendVirtualcamera
from .abstractbackend import AbstractBackend
from ...config.models import ConfigBackendVirtualCamera
from .abstractbackend import AbstractCameraBackend

logger = logging.getLogger(__name__)


class VirtualCameraBackend(AbstractBackend):
def __init__(self, config: ConfigBackendVirtualcamera):
class VirtualCameraBackend(AbstractCameraBackend):
def __init__(self, config: ConfigBackendVirtualCamera):
super().__init__()
# init with arguments
self._config = config

# declarations
self._tick_tock_counter: int = None

# initializiation
self._tick_tock_counter = 0

def start(self, nominal_framerate: int = None):
super().start()
super().start(nominal_framerate=nominal_framerate)

def stop(self):
super().stop()
Expand All @@ -30,7 +36,7 @@ def stop_stream(self):
pass

def wait_for_lores_image(self):
time.sleep(0.05)
time.sleep(1.0 / self._nominal_framerate)

byte_io = io.BytesIO()
imarray = numpy.random.rand(200, 200, 3) * 255
Expand All @@ -40,7 +46,13 @@ def wait_for_lores_image(self):
return byte_io.getbuffer()

def do_capture(self, filename: str = None, number_frames: int = 1):
pass
raise NotImplementedError("not yet supported by virtual camera backend")

def sync_tick(self, timestamp_ns: int):
self._tick_tock_counter += 1
if self._tick_tock_counter > 10:
self._tick_tock_counter = 0
logger.debug("tick")

def request_tick(self):
pass
75 changes: 75 additions & 0 deletions node/services/backends/io/abstractbackend.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import time
from abc import ABC, abstractmethod
from threading import Condition


class AbstractIoBackend(ABC):
def __init__(self):
# used to abort threads when service is stopped.
self._is_running: bool = None

# abstract properties
self._clock_rise_in_condition: Condition = None
self._clock_fall_in_condition: Condition = None
self._trigger_in_condition: Condition = None
self._clock_in_timestamp_ns = None

# abstract common properties init
self._clock_rise_in_condition: Condition = Condition()
self._clock_fall_in_condition: Condition = Condition()
self._trigger_in_condition: Condition = Condition()

def __repr__(self):
return f"{self.__class__}"

@abstractmethod
def start(self):
self._is_running: bool = True

@abstractmethod
def stop(self):
self._is_running: bool = False

@abstractmethod
def derive_nominal_framerate_from_clock(self) -> int:
pass

@abstractmethod
def trigger(self, on: bool):
# forward to output trigger
pass

def clock_signal_valid(self) -> bool:
TIMEOUT_CLOCK_SIGNAL_INVALID = 0.5 * 1e9 # 0.5sec
if not self._clock_in_timestamp_ns:
return False
return (time.monotonic_ns() - self._clock_in_timestamp_ns) < TIMEOUT_CLOCK_SIGNAL_INVALID

def _on_clock_rise_in(self):
with self._clock_rise_in_condition:
self._clock_in_timestamp_ns = time.monotonic_ns()
self._clock_rise_in_condition.notify_all()

def _on_clock_fall_in(self):
with self._clock_fall_in_condition:
self._clock_fall_in_condition.notify_all()

def _on_trigger_in(self):
with self._trigger_in_condition:
self._trigger_in_condition.notify_all()

def wait_for_clock_rise_signal(self, timeout: float = 1.0) -> int:
with self._clock_rise_in_condition:
if not self._clock_rise_in_condition.wait(timeout=timeout):
raise TimeoutError("timeout receiving clock signal")

return self._clock_in_timestamp_ns

def wait_for_clock_fall_signal(self, timeout: float = 1.0):
with self._clock_fall_in_condition:
if not self._clock_fall_in_condition.wait(timeout=timeout):
raise TimeoutError("timeout receiving clock signal")

def wait_for_trigger_signal(self, timeout: float = None):
with self._trigger_in_condition:
self._trigger_in_condition.wait(timeout=timeout)
Loading

0 comments on commit 8965155

Please sign in to comment.