Skip to content

Commit

Permalink
Timestamps now returned when frame transfer starts
Browse files Browse the repository at this point in the history
  • Loading branch information
crnbaker committed Mar 24, 2022
1 parent f67dd4f commit d405ea7
Show file tree
Hide file tree
Showing 10 changed files with 62 additions and 38 deletions.
18 changes: 13 additions & 5 deletions capture_script.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import time
from datetime import timedelta
from typing import cast

from cv2 import imshow, waitKey
from numpy import array, diff

from pymagewell.pro_capture_device import ProCaptureDevice
from pymagewell.pro_capture_controller import ProCaptureController
Expand All @@ -10,18 +13,23 @@
if __name__ == '__main__':

device_settings = ProCaptureSettings()
device_settings.transfer_mode = TransferMode.TIMER
device = MockProCaptureDevice(device_settings)
device_settings.transfer_mode = TransferMode.LOW_LATENCY
device = ProCaptureDevice(device_settings)
frame_grabber = ProCaptureController(device)

print('PRESS Q TO QUIT!')

counter = 0
timestamps = []
while True:
frame = frame_grabber.transfer_when_ready()
t = time.perf_counter()
timestamps.append(frame.timestamp)
imshow("video", frame.as_array())
if waitKey(1) & 0xFF == ord('q'):
break
# print(f"Frame took {time.perf_counter() - t} seconds to display on screen.")
# print(frame.timestamp)
if counter % 20 == 0:
mean_period = array([p.total_seconds() for p in diff(array(timestamps))]).mean()
print(f'Average frame rate over last 20 frames: {1 / mean_period} Hz')
print(f'Last frame timestamp: {frame.timestamp}')
counter += 1
frame_grabber.shutdown()
22 changes: 11 additions & 11 deletions pymagewell/pro_capture_controller.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import time
from ctypes import create_string_buffer, string_at
from datetime import datetime
from functools import singledispatchmethod
from typing import Optional

Expand Down Expand Up @@ -63,54 +64,53 @@ def _handle_event(self, event: Event) -> Optional[VideoFrame]:
def _(self, event: TimerEvent) -> Optional[VideoFrame]:
"""If timer event received, then whole frame is on device. This method transfers it to a buffer in PC memory,
makes a copy, marks the buffer memory as free and then returns the copy."""
self._device.start_a_frame_transfer(self._transfer_buffer)
timestamp = self._device.start_a_frame_transfer(self._transfer_buffer)
self._wait_for_transfer_to_complete(timeout_ms=2000)
if not self._device.transfer_status.whole_frame_transferred: # this marks the buffer memory as free
raise IOError("Only part of frame has been acquired")
return self._format_frame()
return self._format_frame(timestamp)

@_handle_event.register
def _(self, event: FrameBufferedEvent) -> Optional[VideoFrame]:
"""If FrameBufferedEvent event received, then whole frame is on device. This method transfers it to a buffer in
PC memory, makes a copy, marks the buffer memory as free and then returns the copy."""
self._device.start_a_frame_transfer(self._transfer_buffer)
timestamp = self._device.start_a_frame_transfer(self._transfer_buffer)
self._wait_for_transfer_to_complete(timeout_ms=2000)
if not self._device.transfer_status.whole_frame_transferred: # this marks the buffer memory as free
raise IOError("Only part of frame has been acquired")
return self._format_frame()
return self._format_frame(timestamp)

@_handle_event.register
def _(self, event: FrameBufferingEvent) -> Optional[VideoFrame]:
"""If FrameBufferingEvent event received, then a frame has started to be acquired by the card. This method
starts the transfer of the available lines to a buffer in PC memory while the acquisition is still happening.
It then waits until all lines have been received (this query also frees the memory), copies the buffer contents
and returns the copy."""
self._device.start_a_frame_transfer(self._transfer_buffer)
timestamp = self._device.start_a_frame_transfer(self._transfer_buffer)
self._wait_for_transfer_to_complete(timeout_ms=2000)
t = time.perf_counter()
wait_start_t = time.perf_counter()
while (
self._device.transfer_status.num_lines_transferred < self._device.frame_properties.dimensions.rows
and (time.perf_counter() - t) < 1
and (time.perf_counter() - wait_start_t) < 1
):
# this marks the buffer memory as free
pass

return self._format_frame()
return self._format_frame(timestamp)

@_handle_event.register
def _(self, event: SignalChangeEvent) -> None:
"""If a SignalChangeEvent is received, then the source signal has changed and no frame is available."""
print("Frame grabber signal change detected")

def _format_frame(self) -> VideoFrame:
def _format_frame(self, timestamp: datetime) -> VideoFrame:
"""Copy the contents of the transfer buffer, and return it as a VideoFrame."""
t = self._device.frame_status.top_frame_time_code
# Copy the acquired frame
string_buffer = string_at(self._transfer_buffer, self._device.frame_properties.size_in_bytes)
frame = VideoFrame(
string_buffer,
dimensions=self._device.frame_properties.dimensions,
timestamp=t,
timestamp=timestamp,
)
return frame

Expand Down
3 changes: 2 additions & 1 deletion pymagewell/pro_capture_device/device_interface.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from abc import ABC, abstractmethod
from ctypes import Array, c_char
from dataclasses import dataclass
from datetime import datetime

from pymagewell.events.events import (
TransferCompleteEvent,
Expand Down Expand Up @@ -102,7 +103,7 @@ def fps(self) -> float:
raise NotImplementedError()

@abstractmethod
def start_a_frame_transfer(self, frame_buffer: Array[c_char]) -> None:
def start_a_frame_transfer(self, frame_buffer: Array[c_char]) -> datetime:
raise NotImplementedError()

@abstractmethod
Expand Down
2 changes: 2 additions & 0 deletions pymagewell/pro_capture_device/device_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
mwcap_smpte_timecode,
)

DEVICE_CLOCK_TICK_PERIOD_IN_SECONDS = 1e-7


@dataclass
class ImageCoordinateInPixels:
Expand Down
3 changes: 2 additions & 1 deletion pymagewell/pro_capture_device/device_status.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
AspectRatio,
ImageCoordinateInPixels,
FrameTimeCode,
DEVICE_CLOCK_TICK_PERIOD_IN_SECONDS,
)


Expand Down Expand Up @@ -49,7 +50,7 @@ def from_mw_video_signal_status(cls, status: mw_video_signal_status) -> "SignalS
image_dimensions=ImageSizeInPixels(cols=status.cx, rows=status.cy),
total_dimensions=ImageSizeInPixels(cols=status.cxTotal, rows=status.cyTotal),
interlaced=bool(status.bInterlaced),
frame_period_s=float(status.dwFrameDuration * 1e-7), # why 1e-7?
frame_period_s=float(status.dwFrameDuration * DEVICE_CLOCK_TICK_PERIOD_IN_SECONDS),
aspect_ratio=AspectRatio(ver=status.nAspectY, hor=status.nAspectX),
segmented=bool(status.bSegmentedFrame),
)
Expand Down
6 changes: 4 additions & 2 deletions pymagewell/pro_capture_device/mock_pro_capture_device.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from ctypes import Array, c_char
from datetime import datetime
from operator import mod
from threading import Thread
from time import monotonic, sleep
Expand All @@ -14,6 +15,7 @@
TimerEvent,
)
from pymagewell.events.notification import Notification
from pymagewell.pro_capture_device.device_interface import ProCaptureEvents
from pymagewell.pro_capture_device.device_settings import (
ProCaptureSettings,
ImageSizeInPixels,
Expand All @@ -31,7 +33,6 @@
SignalState,
)
from pymagewell.pro_capture_device.pro_capture_device_impl import ProCaptureDeviceImpl
from pymagewell.pro_capture_device.device_interface import ProCaptureEvents

MOCK_RESOLUTION = ImageSizeInPixels(cols=1920, rows=1080)
MOCK_ASPECT_RATIO = AspectRatio(hor=16, ver=9)
Expand Down Expand Up @@ -116,10 +117,11 @@ def start_grabbing(self) -> None:
def stop_grabbing(self) -> None:
self._is_grabbing = False

def start_a_frame_transfer(self, frame_buffer: Array[c_char]) -> None:
def start_a_frame_transfer(self, frame_buffer: Array[c_char]) -> datetime:
random_ints = (255 * random.rand(self.frame_properties.size_in_bytes)).astype(uint8).tobytes()
frame_buffer[: self.frame_properties.size_in_bytes] = random_ints # type: ignore
self.events.transfer_complete.set()
return datetime.now()

def shutdown(self) -> None:
self._is_grabbing = False
Expand Down
27 changes: 20 additions & 7 deletions pymagewell/pro_capture_device/pro_capture_device.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from ctypes import create_unicode_buffer, Array, c_char, addressof
from datetime import datetime, timedelta
from typing import cast, Optional

from mwcapture.libmwcapture import (
Expand Down Expand Up @@ -36,6 +37,7 @@
from pymagewell.pro_capture_device.device_settings import (
TransferMode,
ProCaptureSettings,
DEVICE_CLOCK_TICK_PERIOD_IN_SECONDS,
)
from pymagewell.pro_capture_device.pro_capture_device_impl import ProCaptureDeviceImpl
from pymagewell.pro_capture_device.device_interface import ProCaptureEvents
Expand All @@ -57,6 +59,10 @@ def __init__(self, settings: ProCaptureSettings):
self.mw_capture_init_instance() # type: ignore
self.mw_refresh_device() # type: ignore
self._channel = create_channel(self)

self._device_time_in_s_at_init = self._get_device_time_in_s()
self._system_time_at_init = datetime.now()

self._timer = FrameTimer(self, self._channel, self._register_timer_event(TimerEvent()))

self._signal_change_event = cast(SignalChangeEvent, self._register_event(SignalChangeEvent()))
Expand Down Expand Up @@ -97,7 +103,7 @@ def _register_timer_event(self, event: TimerEvent) -> TimerEvent:
return event

def schedule_timer_event(self) -> None:
self._timer.schedule_timer_event(self._get_device_time())
self._timer.schedule_timer_event(self._get_device_time_in_ticks())

@property
def buffer_status(self) -> OnDeviceBufferStatus:
Expand Down Expand Up @@ -149,7 +155,7 @@ def transfer_status(self) -> TransferStatus:
self.mw_get_video_capture_status(self._channel, mw_capture_status) # type: ignore
return TransferStatus.from_mw_video_capture_status(mw_capture_status)

def _get_device_time(self) -> mw_device_time:
def _get_device_time_in_ticks(self) -> mw_device_time:
"""Read a timestamp from the device."""
time = mw_device_time() # type: ignore
result = self.mw_get_device_time(self._channel, time) # type: ignore
Expand All @@ -158,10 +164,16 @@ def _get_device_time(self) -> mw_device_time:
else:
return time

def start_a_frame_transfer(self, frame_buffer: Array[c_char]) -> None:
def _get_device_time_in_s(self) -> float:
return int(self._get_device_time_in_ticks().m_ll_device_time.value) * DEVICE_CLOCK_TICK_PERIOD_IN_SECONDS

def start_a_frame_transfer(self, frame_buffer: Array[c_char]) -> datetime:
"""Start the transfer of lines from the device to a buffer in PC memory."""
in_low_latency_mode = self.transfer_mode == TransferMode.LOW_LATENCY
notify_size = self._settings.num_lines_per_chunk if in_low_latency_mode else 0

seconds_since_init = self._get_device_time_in_s() - self._device_time_in_s_at_init
frame_timestamp = self._system_time_at_init + timedelta(seconds=seconds_since_init)
result = self.mw_capture_video_frame_to_virtual_address_ex( # type: ignore
hchannel=self._channel,
iframe=self.buffer_status.last_buffered_frame_index,
Expand Down Expand Up @@ -193,8 +205,9 @@ def start_a_frame_transfer(self, frame_buffer: Array[c_char]) -> None:
satrange=MWCAP_VIDEO_SATURATION_UNKNOWN,
)
if result != MW_SUCCEEDED:
print(f"Frame grab failed with error code {result}")
return None
raise IOError(f"Frame grab failed with error code {result}")
else:
return frame_timestamp

def shutdown(self) -> None:
self._timer.shutdown()
Expand Down Expand Up @@ -232,8 +245,8 @@ def schedule_timer_event(self, device_time_now: mw_device_time) -> None:
if self._frame_expire_time is None:
self._frame_expire_time = device_time_now
self._frame_expire_time.m_ll_device_time.value += int(
1e7 * self._device.signal_status.frame_period_s
) # why 1e7
self._device.signal_status.frame_period_s / DEVICE_CLOCK_TICK_PERIOD_IN_SECONDS
)

if self._timer_event.is_registered:
result = self._device.mw_schedule_timer(
Expand Down
5 changes: 1 addition & 4 deletions pymagewell/pro_capture_device/pro_capture_device_impl.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,7 @@ def transfer_mode(self) -> TransferMode:

@property
def frame_properties(self) -> FrameProperties:
return FrameProperties(
dimensions=self._settings.dimensions,
size_in_bytes=self._settings.image_size_in_bytes,
)
return FrameProperties(dimensions=self._settings.dimensions, size_in_bytes=self._settings.image_size_in_bytes)

@property
def fps(self) -> float:
Expand Down
4 changes: 2 additions & 2 deletions pymagewell/video_frame.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
from dataclasses import dataclass
from datetime import datetime

from PIL import Image
from numpy import array, uint8
from numpy.typing import NDArray

from pymagewell.pro_capture_device.device_settings import (
ImageSizeInPixels,
FrameTimeCode,
)


@dataclass
class VideoFrame:
string_buffer: bytes
dimensions: ImageSizeInPixels
timestamp: FrameTimeCode
timestamp: datetime

def as_pillow_image(self) -> Image.Image:
return Image.frombuffer(
Expand Down
10 changes: 5 additions & 5 deletions tests/test_capture_controller.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from datetime import datetime
from time import perf_counter
from unittest import TestCase

Expand Down Expand Up @@ -27,11 +28,10 @@ def setUp(self) -> None:
def tearDown(self) -> None:
self._controller.shutdown()

# def test_frame_timestamp(self) -> None:
# frame = self._controller.transfer_when_ready(timeout_ms=1000)
# seconds_since_frame = (datetime.now() - frame.timestamp.as_datetime(
# self._device.signal_status.frame_period_s)).total_seconds()
# self.assertTrue(seconds_since_frame < 0.25)
def test_frame_timestamp(self) -> None:
frame = self._controller.transfer_when_ready(timeout_ms=1000)
seconds_since_frame = (datetime.now() - frame.timestamp).total_seconds()
self.assertTrue(0 <= seconds_since_frame < 0.25)

def test_frame_size(self) -> None:
frame = self._controller.transfer_when_ready(timeout_ms=1000)
Expand Down

0 comments on commit d405ea7

Please sign in to comment.