Skip to content

Commit 7daf05e

Browse files
committed
Add example script for fsync slave mode
Signed-off-by: stas.bucik <[email protected]>
1 parent f1b8d97 commit 7daf05e

File tree

4 files changed

+366
-1
lines changed

4 files changed

+366
-1
lines changed

examples/cpp/Misc/CMakeLists.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@ project(misc_examples)
22
cmake_minimum_required(VERSION 3.10)
33

44
add_subdirectory(AutoReconnect)
5-
add_subdirectory(Projectors)
5+
add_subdirectory(Projectors)
6+
add_subdirectory(MultiDevice)
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
project(projectors_examples)
2+
cmake_minimum_required(VERSION 3.10)
3+
4+
## function: dai_add_example(example_name example_src enable_test use_pcl)
5+
## function: dai_set_example_test_labels(example_name ...)
6+
7+
dai_add_example(multi_device multi_device.cpp OFF OFF)
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
#include <algorithm>
2+
#include <chrono>
3+
#include <cstdlib>
4+
#include <iostream>
5+
#include <memory>
6+
#include <opencv2/core.hpp>
7+
#include <opencv2/core/mat.hpp>
8+
#include <opencv2/highgui.hpp>
9+
#include <opencv2/opencv.hpp>
10+
#include <utility>
11+
#include <vector>
12+
#include <string>
13+
14+
#include "depthai/capabilities/ImgFrameCapability.hpp"
15+
#include "depthai/common/CameraBoardSocket.hpp"
16+
#include "depthai/common/CameraExposureOffset.hpp"
17+
#include "depthai/depthai.hpp"
18+
#include "depthai/pipeline/MessageQueue.hpp"
19+
#include "depthai/pipeline/Node.hpp"
20+
#include "depthai/pipeline/datatype/ImgFrame.hpp"
21+
#include "depthai/pipeline/node/Camera.hpp"
22+
#include "depthai/xlink/XLinkConnection.hpp"
23+
24+
class FPSCounter {
25+
public:
26+
std::vector<std::chrono::time_point<std::chrono::steady_clock>> frameTimes;
27+
28+
void tick() {
29+
auto now = std::chrono::steady_clock::now();
30+
frameTimes.push_back(now);
31+
if (frameTimes.size() > 100) {
32+
frameTimes.erase(frameTimes.begin(), frameTimes.begin() + (frameTimes.size() - 100));
33+
}
34+
}
35+
36+
float getFps() {
37+
if (frameTimes.size() <= 1) {
38+
return 0.0f;
39+
}
40+
// Calculate the FPS
41+
return float(frameTimes.size() - 1) * 1e6 / std::chrono::duration_cast<std::chrono::microseconds>(frameTimes.back() - frameTimes.front()).count();
42+
}
43+
};
44+
45+
46+
int main(int argc, char** argv) {
47+
48+
if (argc < 4) {
49+
std::cout << "Usage: " << argv[0] << " <target_fps> <device_ip_1> <device_ip_2> [device_ip_3] ..." << std::endl;
50+
std::exit(0);
51+
}
52+
53+
float TARGET_FPS = std::stof(argv[1]);
54+
55+
std::vector<dai::DeviceInfo> DEVICE_INFOS;
56+
for (int i = 2; i < argc; i++) {
57+
DEVICE_INFOS.emplace_back(std::string(argv[i]));
58+
}
59+
60+
std::vector<std::shared_ptr<dai::MessageQueue>> queues;
61+
std::vector<dai::Pipeline> pipelines;
62+
std::vector<std::string> device_ids;
63+
64+
for (auto deviceInfo : DEVICE_INFOS)
65+
{
66+
auto pipeline = dai::Pipeline(std::make_shared<dai::Device>(deviceInfo));
67+
auto device = pipeline.getDefaultDevice();
68+
69+
std::cout << "=== Connected to " << deviceInfo.getDeviceId() << std::endl;
70+
std::cout << " Device ID: " << device->getDeviceId() << std::endl;
71+
std::cout << " Num of cameras: " << device->getConnectedCameras().size() << std::endl;
72+
73+
// auto socket = device->getConnectedCameras()[0];
74+
auto socket = device->getConnectedCameras()[1];
75+
76+
auto cam = pipeline.create<dai::node::Camera>()->build(socket, std::nullopt, TARGET_FPS);
77+
auto out_q = cam->requestOutput(std::make_pair(640, 480), dai::ImgFrame::Type::NV12, dai::ImgResizeMode::STRETCH)->createOutputQueue();
78+
79+
pipeline.start();
80+
pipelines.push_back(pipeline);
81+
queues.push_back(out_q);
82+
device_ids.push_back(deviceInfo.getXLinkDeviceDesc().name);
83+
}
84+
85+
std::map<int, std::shared_ptr<dai::ImgFrame>> latest_frames;
86+
std::vector<bool> receivedFrames;
87+
std::vector<FPSCounter> fpsCounters(queues.size());
88+
89+
for(auto q : queues) {
90+
receivedFrames.push_back(false);
91+
}
92+
93+
int counter = 0;
94+
95+
while (true) {
96+
for (int i = 0; i < queues.size(); i++) {
97+
auto q = queues[i];
98+
while (q->has()) {
99+
latest_frames.emplace(std::make_pair(i,std::dynamic_pointer_cast<dai::ImgFrame>(q->get())));
100+
if (!receivedFrames[i]) {
101+
std::cout << "=== Received frame from " << device_ids[i] << std::endl;
102+
receivedFrames[i] = true;
103+
}
104+
fpsCounters[i].tick();
105+
}
106+
}
107+
108+
if (latest_frames.size() == queues.size()) {
109+
std::vector<std::chrono::time_point<std::chrono::steady_clock>> ts_values;
110+
for (auto pair : latest_frames) {
111+
auto f = pair.second;
112+
ts_values.push_back(f->getTimestamp(dai::CameraExposureOffset::END));
113+
}
114+
115+
if (counter % 100000 == 0) {
116+
auto diff = *std::max_element(ts_values.begin(), ts_values.end()) - *std::min_element(ts_values.begin(), ts_values.end());
117+
std::cout << 1e-6 * float(std::chrono::duration_cast<std::chrono::microseconds>(diff).count()) << std::endl;
118+
}
119+
120+
if (true) {
121+
// std::vector<cv::Mat> imgs;
122+
cv::Mat imgs;
123+
for (int i = 0; i < queues.size(); i++) {
124+
auto msg = latest_frames[i];
125+
auto fps = fpsCounters[i].getFps();
126+
auto frame = msg->getCvFrame();
127+
128+
cv::putText(frame,
129+
device_ids[i] + " | Timestamp: " + std::to_string(ts_values[i].time_since_epoch().count()) + " | FPS: " + std::to_string(fps).substr(0, 5),
130+
{20, 40},
131+
cv::FONT_HERSHEY_SIMPLEX,
132+
0.6,
133+
{255, 0, 50},
134+
2,
135+
cv::LINE_AA);
136+
137+
if (i == 0) {
138+
imgs = frame;
139+
} else {
140+
cv::hconcat(frame, imgs, imgs);
141+
}
142+
}
143+
144+
std::string sync_status = "out of sync";
145+
if (abs(std::chrono::duration_cast<std::chrono::microseconds>(
146+
*std::max_element(ts_values.begin(), ts_values.end()) -
147+
*std::min_element(ts_values.begin(), ts_values.end())
148+
).count()) < 1e3) {
149+
sync_status = "in sync";
150+
}
151+
auto delta = *std::max_element(ts_values.begin(), ts_values.end()) - *std::min_element(ts_values.begin(), ts_values.end());
152+
cv::Scalar color = (sync_status == "in sync") ? cv::Scalar(0, 255, 0) : cv::Scalar(0, 0, 255);
153+
154+
cv::putText(imgs,
155+
sync_status + " | delta = " + std::to_string(1e-3 * float(std::chrono::duration_cast<std::chrono::microseconds>(delta).count())).substr(0, 5) + " ms",
156+
{20, 80},
157+
cv::FONT_HERSHEY_SIMPLEX,
158+
0.7,
159+
color,
160+
2,
161+
cv::LINE_AA);
162+
163+
cv::imshow("synced_view", imgs);
164+
165+
latest_frames.clear();
166+
}
167+
}
168+
169+
if (cv::waitKey(1) == 'q') {
170+
break;
171+
}
172+
}
173+
174+
return 0;
175+
}
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
#!/usr/bin/env python3
2+
3+
"""
4+
Minimal changes to original script:
5+
* Adds simple timestamp-based synchronisation across multiple devices.
6+
* Presents frames side‑by‑side when they are within 1 / FPS seconds.
7+
* Keeps v3 API usage and overall code structure intact.
8+
"""
9+
10+
import contextlib
11+
import datetime
12+
13+
import cv2
14+
import depthai as dai
15+
import time
16+
import math
17+
18+
import argparse
19+
# ---------------------------------------------------------------------------
20+
# Configuration
21+
# ---------------------------------------------------------------------------
22+
SET_MANUAL_EXPOSURE = False # Set to True to use manual exposure settings
23+
# ---------------------------------------------------------------------------
24+
# Helpers
25+
# ---------------------------------------------------------------------------
26+
class FPSCounter:
27+
def __init__(self):
28+
self.frameTimes = []
29+
30+
def tick(self):
31+
now = time.time()
32+
self.frameTimes.append(now)
33+
self.frameTimes = self.frameTimes[-100:]
34+
35+
def getFps(self):
36+
if len(self.frameTimes) <= 1:
37+
return 0
38+
# Calculate the FPS
39+
return (len(self.frameTimes) - 1) / (self.frameTimes[-1] - self.frameTimes[0])
40+
41+
42+
def format_time(td: datetime.timedelta) -> str:
43+
hours, remainder_seconds = divmod(td.seconds, 3600)
44+
minutes, seconds = divmod(remainder_seconds, 60)
45+
milliseconds, microseconds_remainder = divmod(td.microseconds, 1000)
46+
days_prefix = f"{td.days} day{'s' if td.days != 1 else ''}, " if td.days else ""
47+
return (
48+
f"{days_prefix}{hours:02d}:{minutes:02d}:{seconds:02d}."
49+
f"{milliseconds:03d}.{microseconds_remainder:03d}"
50+
)
51+
52+
53+
# ---------------------------------------------------------------------------
54+
# Pipeline creation (unchanged API – only uses TARGET_FPS constant)
55+
# ---------------------------------------------------------------------------
56+
def createPipeline(pipeline: dai.Pipeline, socket: dai.CameraBoardSocket = dai.CameraBoardSocket.CAM_A):
57+
camRgb = (
58+
pipeline.create(dai.node.Camera)
59+
.build(socket, sensorFps=TARGET_FPS)
60+
)
61+
output = (
62+
camRgb.requestOutput(
63+
(640, 480), dai.ImgFrame.Type.NV12, dai.ImgResizeMode.STRETCH
64+
).createOutputQueue(blocking=False)
65+
)
66+
if SET_MANUAL_EXPOSURE:
67+
camRgb.initialControl.setManualExposure(1000, 100)
68+
return pipeline, output
69+
70+
71+
parser = argparse.ArgumentParser(add_help=False)
72+
parser.add_argument("-d", "--devices", default=[], nargs="+", help="Device IPs, first is master, others are slaves")
73+
parser.add_argument("-f", "--fps", type=float, default=30.0, help="Target FPS")
74+
args = parser.parse_args()
75+
DEVICE_INFOS = [dai.DeviceInfo(ip) for ip in args.devices] #The master camera needs to be first here
76+
assert len(DEVICE_INFOS) > 1, "At least two devices are required for this example."
77+
78+
TARGET_FPS = args.fps # Must match sensorFps in createPipeline()
79+
SYNC_THRESHOLD_SEC = 1.0 / (2 * TARGET_FPS) # Max drift to accept as "in sync"
80+
# ---------------------------------------------------------------------------
81+
# Main
82+
# ---------------------------------------------------------------------------
83+
with contextlib.ExitStack() as stack:
84+
# deviceInfos = dai.Device.getAllAvailableDevices()
85+
# print("=== Found devices: ", deviceInfos)
86+
87+
queues = []
88+
pipelines = []
89+
device_ids = []
90+
91+
for idx, deviceInfo in enumerate(DEVICE_INFOS):
92+
pipeline = stack.enter_context(dai.Pipeline(dai.Device(deviceInfo)))
93+
device = pipeline.getDefaultDevice()
94+
95+
print("=== Connected to", deviceInfo.getDeviceId())
96+
print(" Device ID:", device.getDeviceId())
97+
print(" Num of cameras:", len(device.getConnectedCameras()))
98+
99+
# for c in device.getConnectedCameras():
100+
# print(f" {c}")
101+
102+
# socket = device.getConnectedCameras()[0]
103+
socket = device.getConnectedCameras()[1]
104+
pipeline, out_q = createPipeline(pipeline, socket)
105+
print(type(out_q))
106+
pipeline.start()
107+
108+
pipelines.append(pipeline)
109+
queues.append(out_q)
110+
device_ids.append(deviceInfo.getXLinkDeviceDesc().name)
111+
# if (idx == 0):
112+
# time.sleep(12)
113+
114+
# Buffer for latest frames; key = queue index
115+
latest_frames = {}
116+
fpsCounters = [FPSCounter() for _ in queues]
117+
receivedFrames = [False for _ in queues]
118+
counter = 0
119+
while True:
120+
# -------------------------------------------------------------------
121+
# Collect the newest frame from each queue (non‑blocking)
122+
# -------------------------------------------------------------------
123+
for idx, q in enumerate(queues):
124+
while q.has():
125+
latest_frames[idx] = q.get()
126+
if not receivedFrames[idx]:
127+
print("=== Received frame from", device_ids[idx])
128+
receivedFrames[idx] = True
129+
fpsCounters[idx].tick()
130+
131+
# -------------------------------------------------------------------
132+
# Synchronise: we need at least one frame from every camera and their
133+
# timestamps must align within SYNC_THRESHOLD_SEC.
134+
# -------------------------------------------------------------------
135+
if len(latest_frames) == len(queues):
136+
ts_values = [f.getTimestamp(dai.CameraExposureOffset.END).total_seconds() for f in latest_frames.values()]
137+
if (counter % 100000 == 0):
138+
print(max(ts_values) - min(ts_values))
139+
counter += 1
140+
# if max(ts_values) - min(ts_values) <= SYNC_THRESHOLD_SEC:
141+
# TIMESTAMPS ARE NOT ACCURATE, VERIFIED WITH PIXEL RUNNER
142+
if True:
143+
# Build composite image side‑by‑side
144+
imgs = []
145+
for i in range(len(queues)):
146+
msg = latest_frames[i]
147+
frame = msg.getCvFrame()
148+
fps = fpsCounters[i].getFps()
149+
cv2.putText(
150+
frame,
151+
f"{device_ids[i]} | Timestamp: {ts_values[i]} | FPS:{fps:.2f}",
152+
(20, 40),
153+
cv2.FONT_HERSHEY_SIMPLEX,
154+
0.6,
155+
(255, 0, 50),
156+
2,
157+
cv2.LINE_AA,
158+
)
159+
imgs.append(frame)
160+
161+
sync_status = "in sync" if abs(max(ts_values) - min(ts_values)) < 0.001 else "out of sync"
162+
delta = max(ts_values) - min(ts_values)
163+
color = (0, 255, 0) if sync_status == "in sync" else (0, 0, 255)
164+
165+
cv2.putText(
166+
imgs[0],
167+
f"{sync_status} | delta = {delta*1e3:.3f} ms",
168+
(20, 80),
169+
cv2.FONT_HERSHEY_SIMPLEX,
170+
0.7,
171+
color,
172+
2,
173+
cv2.LINE_AA,
174+
)
175+
176+
cv2.imshow("synced_view", cv2.hconcat(imgs))
177+
latest_frames.clear() # Wait for next batch
178+
179+
if cv2.waitKey(1) & 0xFF == ord("q"):
180+
break
181+
182+
cv2.destroyAllWindows()

0 commit comments

Comments
 (0)