Skip to content

Commit 4e83f8c

Browse files
committed
Add timestamp metadata coverage across tests
Expands test coverage for frame timestamp metadata end-to-end: Basler backend reads now validate hardware timestamp extraction, controller/recording manager tests assert metadata forwarding, and video recorder tests verify schema_version 2 sidecar output for both software-only and hardware-backed timestamps. Also adds focused unit tests for `FrameTimestampMetadata` source/frame field splitting and default-reported value behavior.
1 parent 393fb89 commit 4e83f8c

6 files changed

Lines changed: 314 additions & 1 deletion

File tree

‎tests/cameras/backends/conftest.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -526,6 +526,9 @@ def GrabSucceeded(self):
526526
def Release(self):
527527
self.released = True
528528

529+
def GetTimeStamp(self):
530+
return 123456789
531+
529532
class InstantCamera:
530533
def __init__(self, device):
531534
self._device = device
@@ -550,6 +553,7 @@ def __init__(self, device):
550553

551554
self.AcquisitionFrameRateEnable = FakePylon._Feature(False)
552555
self.AcquisitionFrameRate = FakePylon._Feature(30.0)
556+
self.GevTimestampTickFrequency = FakePylon._Feature(1_000_000_000.0)
553557

554558
self.MaxNumBuffer = FakePylon._Feature(10)
555559

‎tests/cameras/backends/test_basler_backend.py‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
import numpy as np
44
import pytest
55

6+
from dlclivegui.cameras.base import CapturedFrame
7+
from dlclivegui.utils.timestamps import FrameTimestampMetadata
8+
69
# ---------------------------------------------------------------------
710
# Core lifecycle
811
# ---------------------------------------------------------------------
@@ -507,3 +510,43 @@ def raise_timeout(*_args, **_kwargs):
507510

508511
finally:
509512
backend.close()
513+
class TestBaslerFrameTimestamps:
514+
@pytest.mark.unit
515+
def test_read_returns_captured_frame_with_hardware_timestamp_metadata(
516+
self,
517+
patch_basler_sdk,
518+
basler_settings_factory,
519+
):
520+
import dlclivegui.cameras.backends.basler_backend as bb
521+
522+
settings = basler_settings_factory()
523+
be = bb.BaslerCameraBackend(settings)
524+
be.open()
525+
526+
captured = be.read()
527+
528+
assert isinstance(captured, CapturedFrame)
529+
assert captured.frame is not None
530+
assert isinstance(captured.software_timestamp, float)
531+
532+
meta = captured.timestamp_metadata
533+
assert isinstance(meta, FrameTimestampMetadata)
534+
535+
assert meta.backend == "basler"
536+
assert meta.source == "grab_result.GetTimeStamp"
537+
assert meta.kind == "camera_clock"
538+
assert meta.raw_unit == "ticks"
539+
assert meta.raw_value == 123456789
540+
assert meta.tick_frequency_hz == pytest.approx(1_000_000_000.0)
541+
assert meta.seconds == pytest.approx(0.123456789)
542+
assert meta.default_reported == "seconds"
543+
544+
source_dict = meta.to_source_dict()
545+
assert source_dict["backend"] == "basler"
546+
assert source_dict["source"] == "grab_result.GetTimeStamp"
547+
548+
frame_dict = meta.to_frame_dict()
549+
assert frame_dict["seconds"] == pytest.approx(0.123456789)
550+
assert frame_dict["raw_value"] == 123456789
551+
552+
be.close()

‎tests/gui/test_rec_manager.py‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from dlclivegui.gui.recording_manager import RecordingManager
88
from dlclivegui.services.multi_camera_controller import get_camera_id, get_display_id
99
from dlclivegui.utils.stats import RecorderStats
10+
from dlclivegui.utils.timestamps import FrameTimestampMetadata
1011

1112

1213
@pytest.fixture
@@ -422,3 +423,41 @@ def test_start_all_passes_writegear_options(
422423
assert rec.writer_options["-crf"] == "23"
423424
assert rec.writer_options["-preset"] == "ultrafast"
424425
assert rec.writer_options["-tune"] == "zerolatency"
426+
427+
428+
class TestRecordingManagerTimestampMetadata:
429+
@pytest.mark.unit
430+
def test_write_frame_passes_timestamp_metadata(
431+
self,
432+
recording_settings,
433+
_active_cams_two,
434+
current_frames,
435+
patch_video_recorder,
436+
patch_build_run_dir,
437+
):
438+
mgr = RecordingManager()
439+
mgr.start_all(recording_settings, _active_cams_two, current_frames, session_name="Sess")
440+
441+
cam0_id = get_camera_id(_active_cams_two[0])
442+
frame = current_frames[cam0_id]
443+
444+
meta = FrameTimestampMetadata(
445+
source="grab_result.GetTimeStamp",
446+
backend="basler",
447+
default_reported="seconds",
448+
seconds=0.001,
449+
raw_value=1_000_000,
450+
raw_unit="ticks",
451+
tick_frequency_hz=1_000_000_000.0,
452+
kind="camera_clock",
453+
)
454+
455+
mgr.write_frame(cam0_id, frame, timestamp=123.0, timestamp_metadata=meta)
456+
457+
rec = mgr.recorders[cam0_id]
458+
assert len(rec.write_calls) == 1
459+
460+
written_frame, written_timestamp, written_metadata = rec.write_calls[0]
461+
assert written_frame is frame
462+
assert written_timestamp == 123.0
463+
assert written_metadata is meta

‎tests/services/test_multicam_controller.py‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
# tests/services/test_multicam_controller.py
2+
import numpy as np
23
import pytest
34

45
from dlclivegui.cameras.factory import CameraFactory
@@ -12,6 +13,7 @@
1213
get_camera_id,
1314
get_display_id,
1415
)
16+
from dlclivegui.utils.timestamps import FrameTimestampMetadata
1517

1618

1719
@pytest.mark.unit
@@ -547,3 +549,50 @@ def on_recording_frame(camera_id, frame, timestamp, timestamp_metadata=None):
547549
finally:
548550
with qtbot.waitSignal(mc.all_stopped, timeout=2000):
549551
mc.stop(wait=True)
552+
553+
554+
class TestRecordingFrameTimestamps:
555+
@pytest.mark.unit
556+
def test_recording_frame_ready_forwards_timestamp_metadata(self, qtbot):
557+
mc = MultiCameraController()
558+
mc._running = True
559+
mc._recording_frame_emission_enabled = True
560+
561+
cam_id = "basler:0815-0000"
562+
mc._settings[cam_id] = CameraSettings(
563+
name="C",
564+
backend="basler",
565+
index=0,
566+
enabled=True,
567+
).apply_defaults()
568+
mc._camera_display_order = [cam_id]
569+
mc._display_ids[cam_id] = "C"
570+
571+
frame = np.zeros((10, 10), dtype=np.uint8)
572+
meta = FrameTimestampMetadata(
573+
source="grab_result.GetTimeStamp",
574+
backend="basler",
575+
default_reported="seconds",
576+
seconds=0.001,
577+
raw_value=1_000_000,
578+
raw_unit="ticks",
579+
tick_frequency_hz=1_000_000_000.0,
580+
kind="camera_clock",
581+
)
582+
583+
seen = []
584+
585+
def on_recording_frame(camera_id, emitted_frame, timestamp, timestamp_metadata):
586+
seen.append((camera_id, emitted_frame, timestamp, timestamp_metadata))
587+
588+
mc.recording_frame_ready.connect(on_recording_frame)
589+
590+
mc._on_frame_captured(cam_id, frame, 123.0, meta)
591+
592+
assert len(seen) == 1
593+
594+
camera_id, emitted_frame, timestamp, timestamp_metadata = seen[0]
595+
assert camera_id == cam_id
596+
assert emitted_frame is frame
597+
assert timestamp == 123.0
598+
assert timestamp_metadata is meta

‎tests/services/test_video_recorder.py‎

Lines changed: 116 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import pytest
1010

1111
import dlclivegui.services.video_recorder as vr_mod
12+
from dlclivegui.utils.timestamps import FrameTimestampMetadata
1213

1314
# ----------------------------
1415
# Helpers
@@ -228,10 +229,14 @@ def test_stop_writes_timestamps_sidecar_json(patch_writegear, output_path, rgb_f
228229
data = json.loads(ts_path.read_text())
229230
assert data["video_file"] == output_path.name
230231
assert data["num_frames"] == 2
231-
assert data["timestamps"] == [10.0, 12.0]
232232
assert data["start_time"] == 10.0
233233
assert data["end_time"] == 12.0
234234
assert data["duration_seconds"] == 2.0
235+
assert data["schema_version"] == 2
236+
assert data["timestamps"] == [10.0, 12.0]
237+
assert data["timestamp_sources"]["hardware_timestamp"] is None
238+
assert data["frame_timestamps"][0]["software_timestamp"] == 10.0
239+
assert data["frame_timestamps"][1]["software_timestamp"] == 12.0
235240

236241

237242
def test_encoder_write_error_sets_encode_error_and_future_writes_raise(patch_writegear, output_path, rgb_frame):
@@ -418,3 +423,113 @@ def close(self):
418423
rec.stop()
419424

420425
assert written[0].shape == (10, 20, 3)
426+
427+
428+
class TestVideoRecorderTimestampSidecar:
429+
def test_stop_writes_software_only_timestamp_sidecar_json(
430+
self,
431+
patch_writegear,
432+
output_path,
433+
rgb_frame,
434+
):
435+
rec = vr_mod.VideoRecorder(output_path, buffer_size=10)
436+
rec.start()
437+
438+
rec.write(rgb_frame, timestamp=10.0)
439+
rec.write(rgb_frame, timestamp=12.0)
440+
441+
wait_until(lambda: len(FakeWriteGear.instances[0].frames) >= 2)
442+
rec.stop()
443+
444+
ts_path = output_path.with_suffix("").with_suffix(output_path.suffix + "_timestamps.json")
445+
assert ts_path.exists()
446+
447+
data = json.loads(ts_path.read_text())
448+
449+
assert data["schema_version"] == 2
450+
assert data["video_file"] == output_path.name
451+
assert data["num_frames"] == 2
452+
453+
# Backward-compatible list.
454+
assert data["timestamps"] == [10.0, 12.0]
455+
456+
assert data["timestamp_sources"]["software_timestamp"]["kind"] == "software_wall_clock"
457+
assert data["timestamp_sources"]["hardware_timestamp"] is None
458+
459+
assert data["frame_timestamps"] == [
460+
{
461+
"frame_index": 0,
462+
"software_timestamp": 10.0,
463+
},
464+
{
465+
"frame_index": 1,
466+
"software_timestamp": 12.0,
467+
},
468+
]
469+
470+
def test_stop_writes_hardware_timestamp_metadata_sidecar_json(
471+
self,
472+
patch_writegear,
473+
output_path,
474+
rgb_frame,
475+
):
476+
rec = vr_mod.VideoRecorder(output_path, buffer_size=10)
477+
rec.start()
478+
479+
meta = FrameTimestampMetadata(
480+
source="grab_result.GetTimeStamp",
481+
backend="basler",
482+
default_reported="seconds",
483+
seconds=0.001,
484+
raw_value=1_000_000,
485+
raw_unit="ticks",
486+
tick_frequency_hz=1_000_000_000.0,
487+
timebase="Basler camera timestamp counter",
488+
kind="camera_clock",
489+
)
490+
491+
rec.write(rgb_frame, timestamp=10.0, timestamp_metadata=meta)
492+
493+
wait_until(lambda: len(FakeWriteGear.instances[0].frames) >= 1)
494+
rec.stop()
495+
496+
ts_path = output_path.with_suffix("").with_suffix(output_path.suffix + "_timestamps.json")
497+
assert ts_path.exists()
498+
499+
data = json.loads(ts_path.read_text())
500+
501+
assert data["schema_version"] == 2
502+
assert data["video_file"] == output_path.name
503+
assert data["num_frames"] == 1
504+
505+
# Backward-compatible software timestamp list.
506+
assert data["timestamps"] == [10.0]
507+
assert data["start_time"] == 10.0
508+
assert data["end_time"] == 10.0
509+
assert data["duration_seconds"] == 0.0
510+
511+
# Static hardware source metadata is written once.
512+
hw_source = data["timestamp_sources"]["hardware_timestamp"]
513+
assert hw_source == {
514+
"source": "grab_result.GetTimeStamp",
515+
"backend": "basler",
516+
"default_reported": "seconds",
517+
"raw_unit": "ticks",
518+
"tick_frequency_hz": 1_000_000_000.0,
519+
"timebase": "Basler camera timestamp counter",
520+
"kind": "camera_clock",
521+
"extra": {},
522+
}
523+
524+
# Per-frame records contain only per-frame values.
525+
frame_ts = data["frame_timestamps"]
526+
assert len(frame_ts) == 1
527+
528+
rec0 = frame_ts[0]
529+
assert rec0["frame_index"] == 0
530+
assert rec0["software_timestamp"] == 10.0
531+
assert rec0["hardware_timestamp"] == {
532+
"seconds": 0.001,
533+
"raw_value": 1_000_000,
534+
}
535+
assert rec0["hardware_timestamp_default"] == 0.001

‎tests/utils/test_timestamps.py‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import pytest
2+
3+
from dlclivegui.utils.timestamps import FrameTimestampMetadata
4+
5+
6+
class TestFrameTimestampMetadata:
7+
def test_splits_source_and_frame_values(self):
8+
meta = FrameTimestampMetadata(
9+
source="grab_result.GetTimeStamp",
10+
backend="basler",
11+
default_reported="seconds",
12+
seconds=0.123456789,
13+
wall_clock_time=None,
14+
raw_value=123456789,
15+
raw_unit="ticks",
16+
tick_frequency_hz=1_000_000_000.0,
17+
timebase="Basler camera timestamp counter",
18+
kind="camera_clock",
19+
)
20+
21+
assert meta.to_source_dict() == {
22+
"source": "grab_result.GetTimeStamp",
23+
"backend": "basler",
24+
"default_reported": "seconds",
25+
"raw_unit": "ticks",
26+
"tick_frequency_hz": 1_000_000_000.0,
27+
"timebase": "Basler camera timestamp counter",
28+
"kind": "camera_clock",
29+
"extra": {},
30+
}
31+
32+
frame_dict = meta.to_frame_dict()
33+
assert frame_dict["seconds"] == pytest.approx(0.123456789)
34+
assert frame_dict["raw_value"] == 123456789
35+
assert "wall_clock_time" not in frame_dict
36+
37+
assert meta.get_default_reported() == pytest.approx(0.123456789)
38+
39+
def test_default_reported_raw_value(self):
40+
meta = FrameTimestampMetadata(
41+
source="device_counter",
42+
backend="some_backend",
43+
default_reported="raw_value",
44+
raw_value=42,
45+
raw_unit="frames",
46+
kind="frame_counter",
47+
)
48+
49+
assert meta.to_frame_dict() == {"raw_value": 42}
50+
assert meta.get_default_reported() == 42
51+
52+
def test_unknown_default_field_returns_none(self):
53+
meta = FrameTimestampMetadata(
54+
source="device_counter",
55+
backend="some_backend",
56+
default_reported="seconds",
57+
raw_value=42,
58+
raw_unit="frames",
59+
kind="frame_counter",
60+
)
61+
62+
assert meta.to_frame_dict() == {"raw_value": 42}
63+
assert meta.get_default_reported() is None

0 commit comments

Comments
 (0)