Skip to content

Commit 4c35c7f

Browse files
authoredJan 31, 2025
Merge pull request Project-Babble#82 from dfgHiatus/LordOfDragons-vivefacialtracker
(feat) Add support for Vive Faical Tracker
2 parents a68f443 + fe8b57b commit 4c35c7f

File tree

9 files changed

+1723
-45
lines changed

9 files changed

+1723
-45
lines changed
 

‎.gitignore

+4-2
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,21 @@ BabbleApp/babble_settings.json
66
BabbleApp/babble_settings.backup
77
BabbleApp/build
88
BabbleApp/dist
9+
/BabbleApp/Models/dev
10+
/BabbleApp/venv
911
/vivetest
1012
/training
1113
.vscode
1214
/testing
1315
/old_models
1416
/Lib
1517
/share
16-
/BabbleApp/Models/dev
1718

1819
scripts/example_build_app_and_installer.bat
1920
scripts/example_build_app_and_installer.bat
2021
scripts/installer.iss
2122
Glia_cap.py
2223
lazyass_minecraft_script.py
2324
scripts/example_build_app_and_installer.bat
24-
/BabbleApp/venv
25+
*.kdev*
26+
*.code-workspace

‎BabbleApp/.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
*.log

‎BabbleApp/babbleapp.py

+4
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import requests
2323
import threading
2424
import asyncio
25+
import logging
2526
from ctypes import c_int
2627
from babble_model_loader import *
2728
from camera_widget import CameraWidget
@@ -111,6 +112,9 @@ async def async_main():
111112

112113
# Run the update check
113114
await check_for_updates(config, notification_manager)
115+
116+
# Uncomment for low-level Vive Facial Tracker logging
117+
# logging.basicConfig(filename='BabbleApp.log', filemode='w', encoding='utf-8', level=logging.INFO)
114118

115119
cancellation_event = threading.Event()
116120
ROSC = False

‎BabbleApp/camera.py

+81-24
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,20 @@
33
import numpy as np
44
import queue
55
import serial
6-
import serial.tools.list_ports
7-
import threading
6+
import sys
87
import time
8+
import traceback
9+
import threading
10+
from enum import Enum
11+
import serial.tools.list_ports
912
from lang_manager import LocaleStringManager as lang
1013

1114
from colorama import Fore
1215
from config import BabbleConfig, BabbleSettingsConfig
13-
from utils.misc_utils import get_camera_index_by_name, list_camera_names, is_nt
14-
from enum import Enum
15-
import sys
16+
from utils.misc_utils import get_camera_index_by_name, list_camera_names
17+
18+
from vivefacialtracker.vivetracker import ViveTracker
19+
from vivefacialtracker.camera_controller import FTCameraController
1620

1721
WAIT_TIME = 0.1
1822
BUFFER_SIZE = 32768
@@ -24,6 +28,7 @@
2428
# packet (packet-size bytes)
2529
ETVR_HEADER = b"\xff\xa0\xff\xa1"
2630
ETVR_HEADER_LEN = 6
31+
PORTS = ("COM", "/dev/ttyACM")
2732

2833

2934
class CameraState(Enum):
@@ -54,13 +59,15 @@ def __init__(
5459
self.cancellation_event = cancellation_event
5560
self.current_capture_source = config.capture_source
5661
self.cv2_camera: "cv2.VideoCapture" = None
62+
self.vft_camera: FTCameraController = None
5763

5864
self.serial_connection = None
5965
self.last_frame_time = time.time()
6066
self.fps = 0
6167
self.bps = 0
6268
self.start = True
6369
self.buffer = b""
70+
self.frame_number = 0
6471
self.FRAME_SIZE = [0, 0]
6572

6673
self.error_message = f'{Fore.YELLOW}[{lang._instance.get_string("log.warn")}] {lang._instance.get_string("info.enterCaptureOne")} {{}} {lang._instance.get_string("info.enterCaptureTwo")}{Fore.RESET}'
@@ -78,6 +85,8 @@ def run(self):
7885
print(
7986
f'{Fore.CYAN}[{lang._instance.get_string("log.info")}] {lang._instance.get_string("info.exitCaptureThread")}{Fore.RESET}'
8087
)
88+
if self.vft_camera is not None:
89+
self.vft_camera.close()
8190
return
8291
should_push = True
8392
# If things aren't open, retry until they are. Don't let read requests come in any earlier
@@ -86,8 +95,15 @@ def run(self):
8695
self.config.capture_source is not None
8796
and self.config.capture_source != ""
8897
):
89-
ports = ("COM", "/dev/ttyACM")
90-
if any(x in str(self.config.capture_source) for x in ports):
98+
isSerial = any(x in str(self.config.capture_source) for x in PORTS)
99+
100+
if isSerial:
101+
if self.cv2_camera is not None:
102+
self.cv2_camera.release()
103+
self.cv2_camera = None
104+
if self.vft_camera is not None:
105+
self.vft_camera.close()
106+
self.device_is_vft = False;
91107
if (
92108
self.serial_connection is None
93109
or self.camera_status == CameraState.DISCONNECTED
@@ -96,25 +112,51 @@ def run(self):
96112
port = self.config.capture_source
97113
self.current_capture_source = port
98114
self.start_serial_connection(port)
99-
else:
100-
if (
115+
elif ViveTracker.is_device_vive_tracker(self.config.capture_source):
116+
if self.cv2_camera is not None:
117+
self.cv2_camera.release()
118+
self.cv2_camera = None
119+
self.device_is_vft = True;
120+
121+
if self.vft_camera is None:
122+
print(self.error_message.format(self.config.capture_source))
123+
# capture_source is an index into a list of devices, so it should be treated as such
124+
if self.cancellation_event.wait(WAIT_TIME):
125+
return
126+
try:
127+
# Only create the camera once, reuse it
128+
self.vft_camera = FTCameraController(get_camera_index_by_name(self.config.capture_source))
129+
self.vft_camera.open()
130+
should_push = False
131+
except Exception:
132+
print(traceback.format_exc())
133+
if self.vft_camera is not None:
134+
self.vft_camera.close()
135+
else:
136+
# If the camera is already open it don't spam it!!
137+
if (not self.vft_camera.is_open):
138+
self.vft_camera.open()
139+
should_push = False
140+
elif (
101141
self.cv2_camera is None
102142
or not self.cv2_camera.isOpened()
103143
or self.camera_status == CameraState.DISCONNECTED
104-
or self.config.capture_source != self.current_capture_source
144+
or get_camera_index_by_name(self.config.capture_source) != self.current_capture_source
105145
):
146+
if self.vft_camera is not None:
147+
self.vft_camera.close()
148+
self.device_is_vft = False;
149+
106150
print(self.error_message.format(self.config.capture_source))
107151
# This requires a wait, otherwise we can error and possible screw up the camera
108152
# firmware. Fickle things.
109153
if self.cancellation_event.wait(WAIT_TIME):
110154
return
111-
155+
112156
if self.config.capture_source not in self.camera_list:
113157
self.current_capture_source = self.config.capture_source
114158
else:
115-
self.current_capture_source = get_camera_index_by_name(
116-
self.config.capture_source
117-
)
159+
self.current_capture_source = get_camera_index_by_name(self.config.capture_source)
118160

119161
if self.config.use_ffmpeg:
120162
self.cv2_camera = cv2.VideoCapture(
@@ -139,9 +181,12 @@ def run(self):
139181
self.cv2_camera.set(
140182
cv2.CAP_PROP_FPS, self.settings.gui_cam_framerate
141183
)
184+
142185
should_push = False
143186
else:
144187
# We don't have a capture source to try yet, wait for one to show up in the GUI.
188+
if self.vft_camera is not None:
189+
self.vft_camera.close()
145190
if self.cancellation_event.wait(WAIT_TIME):
146191
self.camera_status = CameraState.DISCONNECTED
147192
return
@@ -151,24 +196,35 @@ def run(self):
151196
if should_push and not self.capture_event.wait(timeout=0.02):
152197
continue
153198
if self.config.capture_source is not None:
154-
ports = ("COM", "/dev/ttyACM")
155-
if any(x in str(self.config.capture_source) for x in ports):
199+
if isSerial:
156200
self.get_serial_camera_picture(should_push)
157201
else:
158202
self.__del__()
159-
self.get_cv2_camera_picture(should_push)
203+
self.get_camera_picture(should_push)
160204
if not should_push:
161205
# if we get all the way down here, consider ourselves connected
162206
self.camera_status = CameraState.CONNECTED
163207

164-
def get_cv2_camera_picture(self, should_push):
208+
def get_camera_picture(self, should_push):
165209
try:
166-
ret, image = self.cv2_camera.read()
167-
if not ret:
168-
self.cv2_camera.set(cv2.CAP_PROP_POS_FRAMES, 0)
169-
raise RuntimeError(lang._instance.get_string("error.frame"))
210+
image = None
211+
# Is the current camera a Vive Facial Tracker and have we opened a connection to it before?
212+
if self.vft_camera is not None and self.device_is_vft:
213+
image = self.vft_camera.get_image()
214+
if image is None:
215+
return
216+
self.frame_number = self.frame_number + 1
217+
elif self.cv2_camera is not None and self.cv2_camera.isOpened():
218+
ret, image = self.cv2_camera.read()
219+
if not ret:
220+
self.cv2_camera.set(cv2.CAP_PROP_POS_FRAMES, 0)
221+
raise RuntimeError(lang._instance.get_string("error.frame"))
222+
self.frame_number = self.cv2_camera.get(cv2.CAP_PROP_POS_FRAMES) + 1
223+
else:
224+
# Switching from a Vive Facial Tracker to a CV2 camera
225+
return
226+
170227
self.FRAME_SIZE = image.shape
171-
frame_number = self.cv2_camera.get(cv2.CAP_PROP_POS_FRAMES)
172228
# Calculate FPS
173229
current_frame_time = time.time() # Should be using "time.perf_counter()", not worth ~3x cycles?
174230
delta_time = current_frame_time - self.last_frame_time
@@ -179,8 +235,9 @@ def get_cv2_camera_picture(self, should_push):
179235
self.bps = image.nbytes * self.fps
180236

181237
if should_push:
182-
self.push_image_to_queue(image, frame_number + 1, self.fps)
238+
self.push_image_to_queue(image, self.frame_number, self.fps)
183239
except Exception:
240+
FTCameraController._logger.exception("get_image")
184241
print(
185242
f'{Fore.YELLOW}[{lang._instance.get_string("log.warn")}] {lang._instance.get_string("warn.captureProblem")}{Fore.RESET}'
186243
)

‎BabbleApp/camera_widget.py

+1-4
Original file line numberDiff line numberDiff line change
@@ -323,10 +323,7 @@ def render(self, window, event, values):
323323
if any(x in str(value) for x in ports):
324324
self.config.capture_source = value
325325
else:
326-
cam = get_camera_index_by_name(value) # Set capture_source to the UVC index. Otherwise treat value like an ipcam if we return none
327-
if cam != None:
328-
self.config.capture_source = cam
329-
elif is_valid_int_input(value):
326+
if is_valid_int_input(value):
330327
self.config.capture_source = int(value)
331328
else:
332329
self.config.capture_source = value

‎BabbleApp/utils/misc_utils.py

+10-15
Original file line numberDiff line numberDiff line change
@@ -71,20 +71,16 @@ def is_uvc_device(device):
7171
def list_linux_uvc_devices():
7272
"""List UVC video devices on Linux (excluding metadata devices)"""
7373
try:
74-
result = subprocess.run(["v4l2-ctl", "--list-devices"], stdout=subprocess.PIPE)
75-
output = result.stdout.decode("utf-8")
76-
77-
lines = output.splitlines()
74+
# v4l2-ctl --list-devices breaks if video devices are non-sequential.
75+
# So this might be better?
76+
result = glob.glob("/dev/video*");
7877
devices = []
7978
current_device = None
80-
for line in lines:
81-
if not line.startswith("\t"):
82-
current_device = line.strip()
83-
else:
84-
if "/dev/video" in line and is_uvc_device(line.strip()):
85-
devices.append(
86-
line.strip()
87-
) # We return the path like '/dev/video0'
79+
for line in result:
80+
if is_uvc_device(line):
81+
devices.append(
82+
line
83+
) # We return the path like '/dev/video0'
8884

8985
return devices
9086

@@ -147,10 +143,9 @@ def get_camera_index_by_name(name):
147143
cam_list = list_camera_names()
148144

149145
# On Linux, we use device paths like '/dev/video0' and match directly
146+
# OpenCV expects the actual /dev/video#, not the offset into the device list
150147
if os_type == "Linux":
151-
for i, device_path in enumerate(cam_list):
152-
if device_path == name:
153-
return i
148+
return int(str.replace(name,"/dev/video",""));
154149

155150
# On Windows, match by camera name
156151
elif is_nt:

‎BabbleApp/vivefacialtracker/camera.py

+538
Large diffs are not rendered by default.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
"""
2+
MIT License
3+
4+
Copyright DragonDreams GmbH 2024
5+
6+
Permission is hereby granted, free of charge, to any person obtaining a copy
7+
of this software and associated documentation files (the "Software"), to deal
8+
in the Software without restriction, including without limitation the rights
9+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
copies of the Software, and to permit persons to whom the Software is
11+
furnished to do so, subject to the following conditions:
12+
13+
The above copyright notice and this permission notice shall be included in all
14+
copies or substantial portions of the Software.
15+
16+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22+
SOFTWARE.
23+
"""
24+
25+
import multiprocessing.queues
26+
import multiprocessing
27+
import queue as pqueue
28+
import traceback
29+
import platform
30+
import logging
31+
from struct import pack, unpack
32+
import cv2
33+
import numpy as np
34+
from vivefacialtracker.camera import FTCamera
35+
from vivefacialtracker.vivetracker import ViveTracker
36+
37+
38+
class FTCameraController:
39+
"""Opens a camera grabbing frames as numpy arrays."""
40+
41+
_logger = logging.getLogger("evcta.FTCameraController")
42+
43+
def __init__(self: 'FTCameraController', index: int) -> None:
44+
"""Create camera grabber.
45+
46+
The camera is not yet opened. Set "callback_frame" then call
47+
"open()" to open the device and "start_read()" to start capturing.
48+
49+
Keyword arguments:
50+
index -- Index of the camera. Under Linux this uses the device
51+
file "/dev/video{index}".
52+
"""
53+
self.is_open = False
54+
self._index: int = index
55+
self._proc_read: multiprocessing.Process = None
56+
self._proc_queue: multiprocessing.queues.Queue = None
57+
58+
def close(self: 'FTCameraController') -> None:
59+
"""Closes the device if open.
60+
61+
If capturing stops capturing first.
62+
"""
63+
self.is_open = False
64+
FTCameraController._logger.info("FTCameraController.close: index {}".format(self._index))
65+
self._stop_read()
66+
67+
def open(self: 'FTCameraController') -> None:
68+
"""Start capturing frames if not capturing and device is open."""
69+
if self._proc_read is not None:
70+
return
71+
72+
self.is_open = True
73+
FTCameraController._logger.info("FTCameraController.open: start process")
74+
self._proc_queue = multiprocessing.Queue(maxsize=1)
75+
self._proc_read = multiprocessing.Process(target=self._read_process, args=(self._proc_queue,))
76+
self._proc_read.start()
77+
78+
def _reopen(self: 'FTCameraController') -> None:
79+
FTCameraController._logger.info("FTCameraController._reopen")
80+
self.close()
81+
self.open()
82+
83+
def get_image(self: 'FTCameraController') -> np.ndarray:
84+
"""Get next image or None."""
85+
try:
86+
# timeout of 1s is a bit short. 2s is safer
87+
frame = self._proc_queue.get(True, 2)
88+
shape = unpack('HHH', frame[0:6])
89+
image = np.frombuffer(frame[6:], dtype=np.uint8).reshape(shape)
90+
return image
91+
except pqueue.Empty:
92+
# FTCameraController._logger.info("FTCameraController.get_image: timeout, reopen device")
93+
# self._reopen()
94+
return None
95+
except Exception:
96+
FTCameraController._logger.exception(
97+
"FTCameraController.get_image: Failed getting image")
98+
print(traceback.format_exc())
99+
return None
100+
101+
def _stop_read(self: 'FTCameraController') -> None:
102+
"""Stop capturing frames if capturing."""
103+
if self._proc_read is None:
104+
return
105+
FTCameraController._logger.info("FTCameraController._stop_read: stop process")
106+
self._proc_read.terminate() # sends a SIGTERM
107+
self._proc_read.join(1)
108+
109+
if self._proc_read.exitcode is not None:
110+
FTCameraController._logger.info(
111+
"FTCameraController.stop_read: process terminated")
112+
else:
113+
FTCameraController._logger.info(
114+
"FTCameraController._stop_read: process not responding, killing it")
115+
self._proc_read.kill() # sends a SIGKILL
116+
self._proc_read.join(1)
117+
FTCameraController._logger.info(
118+
"FTCameraController._stop_read: process killed")
119+
self._proc_read = None
120+
121+
def _read_process(self: 'FTCameraController',
122+
queue: multiprocessing.connection.Connection) -> None:
123+
"""Read process function."""
124+
125+
"""
126+
logging.basicConfig(filename='ViveFaceTracker-ReadThread.log', filemode='w',
127+
encoding='utf-8', level=logging.INFO)
128+
"""
129+
130+
FTCameraController._logger.info("FTCameraController._read_process: ENTER")
131+
class Helper(FTCamera.Processor):
132+
"""Helper."""
133+
def __init__(self: 'FTCameraController.Helper',
134+
queue: multiprocessing.connection.Connection) -> None:
135+
self.camera: FTCamera = None
136+
self.tracker: ViveTracker = None
137+
self._queue = queue
138+
139+
def open_camera(self: 'FTCameraController.Helper', index: int,
140+
queue: multiprocessing.connection.Connection) -> None:
141+
"""Open camera."""
142+
self.camera = FTCamera(index)
143+
self.camera.terminator = FTCamera.Terminator()
144+
self.camera.processor = self
145+
self.camera.queue = queue
146+
self.camera.open()
147+
148+
def open_tracker(self: 'FTCameraController.Helper') -> None:
149+
"""Open tracker."""
150+
if platform.system() == 'Linux':
151+
self.tracker = ViveTracker(self.camera.device.fileno())
152+
else:
153+
self.tracker = ViveTracker(self.camera.device, self.camera.device_index)
154+
155+
def close(self: 'FTCameraController.Helper') -> None:
156+
"""Close tracker and camera."""
157+
if self.tracker is not None:
158+
self.tracker.dispose()
159+
self.tracker = None
160+
if self.camera is not None:
161+
self.camera.close()
162+
self.camera.processor = None
163+
self.camera.terminator = None
164+
self.camera.queue = None
165+
self.camera = None
166+
167+
def process(self, frame) -> None:
168+
"""Process frame."""
169+
channel = cv2.split(frame)[0]
170+
frame = cv2.merge((channel, channel, channel))
171+
if self.tracker is not None:
172+
frame = self.tracker.process_frame(frame)
173+
self._queue.put(pack('HHH', *frame.shape) + frame.tobytes())
174+
175+
helper: Helper = Helper(queue)
176+
try:
177+
FTCameraController._logger.info(
178+
"FTCameraController._read_process: open device")
179+
helper.open_camera(self._index, queue)
180+
181+
if not ViveTracker.is_camera_vive_tracker(helper.camera.device):
182+
FTCameraController._logger.exception(
183+
"FTCameraController._read_process: not a VIVE Facial Tracker")
184+
raise RuntimeError("not a VIVE Facial Tracker")
185+
186+
helper.open_tracker()
187+
188+
FTCameraController._logger.info(
189+
"FTCameraController._read_process: start reading")
190+
helper.camera.read()
191+
except Exception:
192+
FTCameraController._logger.exception(
193+
"FTCameraController._read_process: failed open device")
194+
print(traceback.format_exc())
195+
finally:
196+
helper.close()
197+
198+
FTCameraController._logger.info("FTCameraController._read_process: EXIT")

‎BabbleApp/vivefacialtracker/vivetracker.py

+886
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)
Please sign in to comment.