LDAQ + pyTelops: buffered, software-triggered synchronized capture#
Capture a NI-DAQ accelerometer and a Telops thermal camera together, with the camera recording to its onboard 16 GB buffer at full frame rate instead of streaming live. A single software trigger starts both, and the small residual offset is measured so it can be removed in post-processing.
Why buffered instead of live streaming (as in example 013):
The camera records every frame to internal memory at full sensor speed, decoupled from the host and the network. Nothing on the PC side can drop frames or add latency during the capture.
The accelerometer runs through LDAQ as usual.
Because the camera is not a live LDAQ source here, it is driven directly with the pyTelops
CameraAPI, and the frames are downloaded after the capture.
Synchronization. The start trigger is fired in software: the camera MOI (moment of interest) and the LDAQ trigger are issued back to back, bracketed by perf_counter timestamps. Expect a residual start offset of roughly 1 to 2 ms (GigE command latency plus the LDAQ trigger granularity), plus a small drift between the two independent clocks over the record. For tighter synchronization, see the note at the end.
[ ]:
import warnings
warnings.filterwarnings("ignore", message="A module that was compiled using NumPy 1.x")
1. Parameters#
Everything you might tune lives here.
[ ]:
import time
import threading
import numpy as np
import matplotlib.pyplot as plt
import LDAQ
from pyTelops import Camera
# --- Camera (onboard buffer) ---
FPS = 1000.0 # camera frame rate [Hz]
INTEGRATION_TIME = 50.0 # integration (exposure) time [us]
CALIBRATION_MODE = "RT" # radiometric temperature -> float32 Celsius
CAPTURE_S = 2.0 # post-trigger capture duration [s]
PRE_MOI = 100 # frames recorded before the trigger (pre-trigger margin)
# --- Accelerometer (NI DAQ via LDAQ) ---
NI_TASK = "AccelerationTask" # NI MAX task name, or an LDAQ AITask object
ACCEL_CHANNEL = 1 # channel index to plot (task-dependent)
N_POST = round(FPS * CAPTURE_S) # frames recorded after the trigger
N_FRAMES = N_POST + PRE_MOI # total frames per buffer sequence
print(f"Camera: {N_FRAMES} frames at {FPS:.0f} fps "
f"({PRE_MOI} pre-trigger + {N_POST} post-trigger = {CAPTURE_S:.2f} s)")
2. Connect and configure the camera (buffer mode)#
Set the acquisition parameters, then configure and arm the onboard buffer with a software MOI source. After buffer_arm() the camera continuously fills a rolling pre-trigger buffer and waits for the MOI; the delay between arming here and firing the trigger later does not matter.
[ ]:
cam = Camera()
cam.connect()
cam.calibration_mode = CALIBRATION_MODE
cam.integration_time = INTEGRATION_TIME
cam.frame_rate = FPS
FPS_ACTUAL = cam.frame_rate # the camera may clamp the requested rate
print(f"Connected. Frame rate {FPS_ACTUAL:.1f} Hz (max {cam.frame_rate_max:.1f} Hz)")
cam.buffer_configure(
n_sequences=1,
frames_per_seq=N_FRAMES,
pre_moi=PRE_MOI,
moi_source="software",
)
cam.buffer_arm()
print(cam.buffer_info())
3. Build the accelerometer source and launch LDAQ#
Core.run() blocks while it waits for a trigger, so it runs in a background thread. We then wait until the source reports ready and fire the trigger from the main thread. autostart=False keeps it from starting on its own; hotkeys=False because the trigger is issued programmatically.
[ ]:
accel = LDAQ.national_instruments.NIAcquisition(
task=NI_TASK, # NI MAX task name, or an AITask object
acquisition_name="accel",
)
core = LDAQ.Core(acquisitions=[accel])
core_thread = threading.Thread(
target=core.run,
kwargs=dict(
measurement_duration=CAPTURE_S,
autoclose=True,
autostart=False,
hotkeys=False,
verbose=0,
),
daemon=True,
)
core_thread.start()
# Wait until the accelerometer source is armed and ready for the trigger.
# Break early if the worker thread dies or signals a failure during setup,
# instead of waiting out the full timeout on a misleading "not ready" message.
t_wait = time.perf_counter()
while not all(a.is_ready for a in core.acquisitions):
worker_failed = not core_thread.is_alive() or (
getattr(core, "stop_event", None) is not None and core.stop_event.is_set()
)
if worker_failed:
raise RuntimeError(
"LDAQ setup failed before the source became ready. Check the "
"traceback printed above - this is usually a wrong task name or "
"missing hardware."
)
if time.perf_counter() - t_wait > 15.0:
raise TimeoutError("LDAQ source did not become ready within 15 s")
time.sleep(0.005)
time.sleep(0.2) # settle
print("Accelerometer armed and ready.")
4. Fire the synchronized software trigger#
Both triggers are fired back to back. The camera MOI marks camera t = 0; the LDAQ global trigger marks accelerometer t = 0. The gap between the two host calls (host_offset) is the correction applied to the camera timeline later.
[ ]:
t_cam = time.perf_counter()
cam.buffer_fire_moi()
t_acc = time.perf_counter()
core.start_acquisition()
t_after = time.perf_counter()
host_offset = t_acc - t_cam # camera was triggered this many seconds before accel
print(f"host_offset (camera leads accel): {host_offset * 1e3:.2f} ms")
print(f" MOI call {(t_acc - t_cam) * 1e3:.2f} ms, "
f"trigger call {(t_after - t_acc) * 1e3:.2f} ms")
5. Wait for the buffer, then download#
Join the LDAQ thread (it returns after its CAPTURE_S window), wait for the camera to finish writing the sequence, and download the frames from the onboard buffer.
[ ]:
core_thread.join(timeout=CAPTURE_S + 30.0)
if core_thread.is_alive():
raise TimeoutError("LDAQ measurement did not finish in time")
status = cam.buffer_wait(timeout=60.0)
print(f"Buffer status: {status.name}, {cam.buffer_recorded_frames(0)} frames recorded")
frames = cam.buffer_download(sequence=0, verbose=False) # (N, H, W) float32 Celsius in RT mode
print(f"Downloaded frames: {frames.shape}, {frames.dtype}")
6. Retrieve the accelerometer data#
The accelerometer time vector already has t = 0 at the trigger.
[ ]:
meas = core.get_measurement_dict()
acc = meas["accel"]
t_acc_data = acc["time"]
accel_signal = acc["data"][:, ACCEL_CHANNEL]
accel_rate = acc["sample_rate"]
print(f"Accelerometer: {accel_signal.shape[0]} samples at {accel_rate:.0f} Hz "
f"({t_acc_data[-1]:.2f} s)")
7. Align the two sources on a common timeline#
The MOI frame sits at index PRE_MOI in the downloaded stack and is camera t = 0. Scale the frame index by the camera’s actual frame rate, then shift by the measured host_offset so the camera timeline matches the accelerometer one.
[ ]:
n_frames = frames.shape[0]
t_frames = (np.arange(n_frames) - PRE_MOI) / FPS_ACTUAL - host_offset
mean_temps = frames.mean(axis=(1, 2))
print(f"Frame timeline: {t_frames[0]:.3f} s to {t_frames[-1]:.3f} s "
f"(MOI at index {PRE_MOI})")
8. Plot the synchronized timeline#
[ ]:
fig, axes = plt.subplots(3, 1, figsize=(10, 10),
gridspec_kw={"height_ratios": [1.2, 1, 2]})
axes[0].plot(t_acc_data, accel_signal, lw=0.7, color="steelblue")
axes[0].axvline(0.0, color="k", ls="--", lw=1, alpha=0.6)
axes[0].set(xlabel="time [s]", ylabel="acceleration",
title=f"NI accelerometer @ {accel_rate:.0f} Hz")
axes[0].grid(alpha=0.3)
axes[1].plot(t_frames, mean_temps, lw=1.3, color="crimson", marker="o", ms=2)
axes[1].axvline(0.0, color="k", ls="--", lw=1, alpha=0.6)
axes[1].set(xlabel="time [s]", ylabel="mean T [deg C]",
title=f"Telops buffered capture: {n_frames} frames @ {FPS_ACTUAL:.0f} fps")
axes[1].grid(alpha=0.3)
idx0 = int(np.argmin(np.abs(t_frames))) # frame nearest the trigger
im = axes[2].imshow(frames[idx0], cmap="inferno", aspect="auto")
axes[2].set(title=f"Frame nearest t=0 (index {idx0}, t={t_frames[idx0]:.3f} s)",
xlabel="pixel x", ylabel="pixel y")
plt.colorbar(im, ax=axes[2], label="deg C")
plt.tight_layout()
plt.show()
9. Save the synchronized capture#
[ ]:
out_path = "buffered_sync_capture.npz"
np.savez_compressed(
out_path,
frames=frames,
frame_time=t_frames,
accel_time=t_acc_data,
accel_signal=accel_signal,
fps_actual=FPS_ACTUAL,
accel_rate=accel_rate,
pre_moi=PRE_MOI,
host_offset=host_offset,
)
print(f"Saved {out_path}")
10. Clean up#
[ ]:
cam.buffer_clear()
cam.disconnect()
print(f"Camera connected: {cam.is_connected}")
Notes on synchronization#
This example uses a software trigger: the camera MOI and the LDAQ trigger are fired back to back and the residual offset is measured (host_offset) and removed when building the frame timeline. The realistic residual is about 1 to 2 ms, dominated by GigE command latency and the LDAQ trigger granularity, plus a small drift between the two independent clocks over the record.
To tighten synchronization toward tens of microseconds, use a shared hardware trigger instead:
Wire a single electrical trigger pulse to the camera BNC trigger input and to a spare NI-DAQ input channel.
Configure the camera with
configure_trigger(source="external")andbuffer_configure(moi_source="external").The shared edge then appears in the accelerometer data (sample-accurate
t = 0) and starts the camera at the same instant.