From d405ea7bbdb8db44a26149bfecbba8f9195535e2 Mon Sep 17 00:00:00 2001 From: crnbaker Date: Thu, 24 Mar 2022 14:04:39 +0000 Subject: [PATCH] Timestamps now returned when frame transfer starts --- capture_script.py | 18 +++++++++---- pymagewell/pro_capture_controller.py | 22 +++++++-------- .../pro_capture_device/device_interface.py | 3 ++- .../pro_capture_device/device_settings.py | 2 ++ .../pro_capture_device/device_status.py | 3 ++- .../mock_pro_capture_device.py | 6 +++-- .../pro_capture_device/pro_capture_device.py | 27 ++++++++++++++----- .../pro_capture_device_impl.py | 5 +--- pymagewell/video_frame.py | 4 +-- tests/test_capture_controller.py | 10 +++---- 10 files changed, 62 insertions(+), 38 deletions(-) diff --git a/capture_script.py b/capture_script.py index 4027cbb..c7caff0 100644 --- a/capture_script.py +++ b/capture_script.py @@ -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 @@ -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() diff --git a/pymagewell/pro_capture_controller.py b/pymagewell/pro_capture_controller.py index 4208d7a..4eccd8a 100644 --- a/pymagewell/pro_capture_controller.py +++ b/pymagewell/pro_capture_controller.py @@ -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 @@ -63,21 +64,21 @@ 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]: @@ -85,32 +86,31 @@ def _(self, event: FrameBufferingEvent) -> Optional[VideoFrame]: 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 diff --git a/pymagewell/pro_capture_device/device_interface.py b/pymagewell/pro_capture_device/device_interface.py index de02ceb..c2c7de6 100644 --- a/pymagewell/pro_capture_device/device_interface.py +++ b/pymagewell/pro_capture_device/device_interface.py @@ -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, @@ -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 diff --git a/pymagewell/pro_capture_device/device_settings.py b/pymagewell/pro_capture_device/device_settings.py index 5defc55..f7ca1aa 100644 --- a/pymagewell/pro_capture_device/device_settings.py +++ b/pymagewell/pro_capture_device/device_settings.py @@ -12,6 +12,8 @@ mwcap_smpte_timecode, ) +DEVICE_CLOCK_TICK_PERIOD_IN_SECONDS = 1e-7 + @dataclass class ImageCoordinateInPixels: diff --git a/pymagewell/pro_capture_device/device_status.py b/pymagewell/pro_capture_device/device_status.py index 9d2d016..f4792ce 100644 --- a/pymagewell/pro_capture_device/device_status.py +++ b/pymagewell/pro_capture_device/device_status.py @@ -20,6 +20,7 @@ AspectRatio, ImageCoordinateInPixels, FrameTimeCode, + DEVICE_CLOCK_TICK_PERIOD_IN_SECONDS, ) @@ -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), ) diff --git a/pymagewell/pro_capture_device/mock_pro_capture_device.py b/pymagewell/pro_capture_device/mock_pro_capture_device.py index c3f1aa0..b518dc9 100644 --- a/pymagewell/pro_capture_device/mock_pro_capture_device.py +++ b/pymagewell/pro_capture_device/mock_pro_capture_device.py @@ -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 @@ -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, @@ -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) @@ -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 diff --git a/pymagewell/pro_capture_device/pro_capture_device.py b/pymagewell/pro_capture_device/pro_capture_device.py index 1e31d49..8e94648 100644 --- a/pymagewell/pro_capture_device/pro_capture_device.py +++ b/pymagewell/pro_capture_device/pro_capture_device.py @@ -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 ( @@ -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 @@ -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())) @@ -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: @@ -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 @@ -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, @@ -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() @@ -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( diff --git a/pymagewell/pro_capture_device/pro_capture_device_impl.py b/pymagewell/pro_capture_device/pro_capture_device_impl.py index 54b3cd5..6898bd4 100644 --- a/pymagewell/pro_capture_device/pro_capture_device_impl.py +++ b/pymagewell/pro_capture_device/pro_capture_device_impl.py @@ -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: diff --git a/pymagewell/video_frame.py b/pymagewell/video_frame.py index 5da104d..a010725 100644 --- a/pymagewell/video_frame.py +++ b/pymagewell/video_frame.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from datetime import datetime from PIL import Image from numpy import array, uint8 @@ -6,7 +7,6 @@ from pymagewell.pro_capture_device.device_settings import ( ImageSizeInPixels, - FrameTimeCode, ) @@ -14,7 +14,7 @@ class VideoFrame: string_buffer: bytes dimensions: ImageSizeInPixels - timestamp: FrameTimeCode + timestamp: datetime def as_pillow_image(self) -> Image.Image: return Image.frombuffer( diff --git a/tests/test_capture_controller.py b/tests/test_capture_controller.py index f38ad1c..942e3fa 100644 --- a/tests/test_capture_controller.py +++ b/tests/test_capture_controller.py @@ -1,3 +1,4 @@ +from datetime import datetime from time import perf_counter from unittest import TestCase @@ -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)