Skip to content

Commit e66437b

Browse files
authored
Merge pull request #91 from pupil-labs/camera_controls
Exposed endpoints for camera control
2 parents 5fa9332 + 6d1f712 commit e66437b

9 files changed

Lines changed: 609 additions & 62 deletions

File tree

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import asyncio
2+
import logging
3+
4+
from rich import print # noqa: A004
5+
6+
from pupil_labs.realtime_api import Device, DeviceError, Network, receive_video_frames
7+
8+
9+
async def main():
10+
async with Network() as network:
11+
dev_info = await network.wait_for_new_device(timeout_seconds=5)
12+
13+
if dev_info is None:
14+
print("No device could be found! Abort")
15+
return
16+
else:
17+
print(f"Connecting to {dev_info.addresses[0]}:{dev_info.port}")
18+
19+
async with Device.from_discovered_device(dev_info) as device:
20+
# Initiate video stream before querying camera state to ensure camera is active
21+
# You could instead simply open the scene video preview in the companion app
22+
status = await device.get_status()
23+
sensor_world = status.direct_world_sensor()
24+
frames_itr = receive_video_frames(sensor_world.url)
25+
await anext(frames_itr)
26+
27+
state = None
28+
try:
29+
state = await device.get_camera_state()
30+
print("Current state:", state)
31+
except DeviceError as err:
32+
print(err)
33+
print("Open the scene video preview in the companion app")
34+
35+
try:
36+
await device.set_camera_state(
37+
ae_mode="auto",
38+
man_exp=50,
39+
gain=50,
40+
brightness=0,
41+
contrast=70,
42+
gamma=300,
43+
validate_with_state=state,
44+
)
45+
except DeviceError as err:
46+
print(err)
47+
48+
await frames_itr.aclose()
49+
50+
51+
if __name__ == "__main__":
52+
logging.basicConfig(level="DEBUG")
53+
asyncio.run(main())

examples/simple/camera_controls.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import time
2+
3+
import cv2
4+
from rich import print # noqa: A004
5+
6+
from pupil_labs.realtime_api.simple import discover_one_device
7+
8+
9+
def set_state_1(device, camera_state):
10+
print("Updating camera to state 1")
11+
device.set_camera_state(
12+
ae_mode="manual",
13+
man_exp=500,
14+
gain=64,
15+
brightness=0,
16+
contrast=32,
17+
gamma=300,
18+
validate_with_state=camera_state,
19+
)
20+
21+
22+
def set_state_2(device, camera_state):
23+
print("Updating camera to state 2")
24+
device.set_camera_state(
25+
ae_mode="manual",
26+
man_exp=1000,
27+
gain=75,
28+
brightness=-20,
29+
contrast=50,
30+
gamma=200,
31+
validate_with_state=camera_state,
32+
)
33+
34+
35+
def main():
36+
print("Looking for the next best device...")
37+
device = discover_one_device(max_search_duration_seconds=10)
38+
if device is None:
39+
print("No device found.")
40+
raise SystemExit(-1)
41+
42+
# Initiate video stream before querying camera state to ensure camera is active
43+
# You could instead simply open the scene video preview in the companion app
44+
device.receive_scene_video_frame()
45+
46+
print("Retrieving camera state...")
47+
camera_state = device.get_camera_state()
48+
print(f"Current camera state: {camera_state}")
49+
50+
set_state_1(device, camera_state)
51+
last_state = 1
52+
last_tick_time = time.time()
53+
while True:
54+
bgr_pixels, frame_datetime = device.receive_scene_video_frame()
55+
draw_time(bgr_pixels, frame_datetime)
56+
cv2.imshow("Scene Camera - Press ESC to quit", bgr_pixels)
57+
58+
if time.time() - last_tick_time > 4:
59+
if last_state == 1:
60+
set_state_2(device, camera_state)
61+
last_state = 2
62+
else:
63+
set_state_1(device, camera_state)
64+
last_state = 1
65+
last_tick_time = time.time()
66+
67+
if cv2.waitKey(1) & 0xFF == 27:
68+
break
69+
70+
device.close()
71+
72+
73+
def draw_time(frame, timestamp):
74+
frame_txt_font_name = cv2.FONT_HERSHEY_SIMPLEX
75+
frame_txt_font_scale = 1.0
76+
frame_txt_thickness = 1
77+
78+
# first line: frame index
79+
frame_txt = str(timestamp)
80+
81+
cv2.putText(
82+
frame,
83+
frame_txt,
84+
(20, 50),
85+
frame_txt_font_name,
86+
frame_txt_font_scale,
87+
(255, 255, 255),
88+
thickness=frame_txt_thickness,
89+
lineType=cv2.LINE_8,
90+
)
91+
92+
93+
if __name__ == "__main__":
94+
main()

pyproject.toml

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,16 @@ dependencies = [
3333
"zeroconf>=0.146.1",
3434
]
3535

36+
[project.optional-dependencies]
37+
examples = [
38+
"opencv-python",
39+
"rich",
40+
"librosa",
41+
"speechrecognition",
42+
"openai-whisper",
43+
]
44+
45+
3646
[project.urls]
3747
Homepage = "https://pupil-labs.github.io/pl-realtime-api"
3848
Documentation = "https://pupil-labs.github.io/pl-realtime-api"
@@ -69,7 +79,6 @@ dev = [
6979
"tomli>=2.0; python_version < '3.11'",
7080
"types-deprecated>=1.2.15.20250304",
7181
]
72-
examples = ["opencv-python", "rich", "librosa", "speechrecognition", "openai-whisper"]
7382

7483
[build-system]
7584
requires = ["hatchling", "uv-dynamic-versioning"]
@@ -88,7 +97,7 @@ source = ["pupil_labs"]
8897

8998
[tool.deptry.per_rule_ignores]
9099
DEP001 = ["pupil_labs", "cv2", "whisper", "speech_recognition", "pyaudio", "audio_player", "aiortsp", "dpkt"]
91-
DEP002 = ["opencv-python"]
100+
DEP002 = ["opencv-python", "openai-whisper"]
92101
DEP003 = ["pupil_labs", "typing_extensions", "pydantic_core", "aiortsp", "setuptools"]
93102
DEP004 = ["cv2", "mkdocs", "rich", "librosa", "whisper", "speech_recognition", "pytest"]
94103

src/pupil_labs/realtime_api/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import importlib.metadata
99

10+
from .camera_control import CameraState
1011
from .device import APIPath, Device, DeviceError, StatusUpdateNotifier
1112
from .discovery import Network, discover_devices
1213
from .streaming import (
@@ -42,6 +43,7 @@
4243
"APIPath",
4344
"AudioFrame",
4445
"BlinkEventData",
46+
"CameraState",
4547
"Device",
4648
"DeviceError",
4749
"DualMonocularGazeData",
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
from collections.abc import Sequence
2+
from dataclasses import dataclass, fields
3+
from enum import Enum
4+
from typing import Literal, cast
5+
6+
from typing_extensions import NotRequired, TypedDict
7+
8+
9+
class ChangeRequestParameters(TypedDict):
10+
ae_mode: NotRequired[Literal["auto", "manual"]]
11+
man_exp: NotRequired[int]
12+
gain: NotRequired[int]
13+
brightness: NotRequired[int]
14+
contrast: NotRequired[int]
15+
gamma: NotRequired[int]
16+
camera: Literal["world"]
17+
18+
19+
@dataclass(frozen=True)
20+
class ControlStateEnum:
21+
current_value: str
22+
allowed_values: Sequence[str]
23+
24+
def validate(self, value: str) -> None:
25+
if value not in self.allowed_values:
26+
raise ValueError(f"`{value}` not in allowed_values={self.allowed_values}")
27+
28+
29+
class ControlStateEnumResponse(TypedDict):
30+
control_name: str
31+
current_value: str
32+
allowed_values: Sequence[str]
33+
value_type: str
34+
35+
36+
@dataclass(frozen=True)
37+
class ControlStateInteger:
38+
current_value: int
39+
value_min: int
40+
value_max: int
41+
42+
def validate(self, value: int) -> None:
43+
if not (self.value_min <= value <= self.value_max):
44+
raise ValueError(
45+
f"`{value!r}` not in range [{self.value_min!r}, {self.value_max!r}]"
46+
)
47+
48+
49+
class ControlStateIntegerResponse(TypedDict):
50+
control_name: str
51+
current_value: int
52+
value_min: int
53+
value_max: int
54+
value_type: str
55+
56+
57+
class ControlStateResponseEnvelope(TypedDict):
58+
message: str
59+
result: ControlStateIntegerResponse | ControlStateEnumResponse
60+
61+
62+
@dataclass(frozen=True)
63+
class CameraState:
64+
ae_mode: ControlStateEnum
65+
man_exp: ControlStateInteger
66+
gain: ControlStateInteger
67+
brightness: ControlStateInteger
68+
contrast: ControlStateInteger
69+
gamma: ControlStateInteger
70+
71+
@classmethod
72+
def state_class_by_attr(
73+
cls, name: str
74+
) -> type[ControlStateEnum] | type[ControlStateInteger]:
75+
return cast(
76+
type[ControlStateEnum] | type[ControlStateInteger],
77+
{f.name: f.type for f in fields(cls)}[name],
78+
)
79+
80+
81+
class Control(Enum):
82+
AUTOEXPOSURE_MODE = "ae_mode"
83+
MANUAL_EXPOSURE_TIME = "man_exp"
84+
BRIGHTNESS = "brightness"
85+
CONTRAST = "contrast"
86+
GAMMA = "gamma"
87+
GAIN = "gain"

0 commit comments

Comments
 (0)