A point cloud that looks correct on a static bench and smears the moment anything moves usually has a frame-pairing fault, not a calibration fault. If the pipeline builds the cloud inside the depth callback and grabs whatever color frame is newest, it silently assumes color always arrives before depth. Plenty of rigs deliver color after depth, so the sequence numbers never line up and every moving scene registers color to the previous depth frame. This page shows how to confirm the offset, pair frames by the camera's own sequence number under one mutex, and handle mismatched stream rates.
What a one-frame offset looks like on the bench
On a static scene the cloud is perfect: color sits exactly on its depth pixels. Start the belt or swing a target and the color edges trail the depth edges along the direction of motion. A printed face lands downstream of its geometric edge; a logo floats off the part. The offset scale is set by the frame period alone: at 30 fps one frame lasts 33.3 ms, at 60 fps it lasts 16.7 ms. Multiply by scene speed and you have the displacement in millimetres.
The trap is that static-scene acceptance passes while every moving frame is wrong. The fault survives commissioning and only surfaces later as intermittent pick errors or smeared inspection overlays.
Likely causes and the test for each
Work down this list in order; each cause comes with a test that takes minutes.
- The cloud publishes from the depth callback with the newest color frame. The pipeline assumes color precedes depth, which is how most sample pipelines are written. Test: log both sequence numbers where the cloud is built. A constant difference of one is this cause.
- Your rig delivers color after depth. Delivery order is set by sensor start-up, internal pipeline lengths and driver buffering, not by physics. Test: time both callbacks on a monotonic host clock; a consistent gap says the order is reversed.
- The streams run at different rates. Depth at 30 fps against color at 15 fps means the two counters can never be equal at all. Test: plot the sequence difference over 200 frames; a difference that grows linearly is a rate mismatch.
- Host queueing reorders frames. A deep receive queue or a slow consumer makes arrival order differ from capture order. Test: compare the camera's own timestamps, if the driver exposes them, rather than host arrival times; if the offset appears only when the CPU is loaded, queueing is the cause.
If the offset shows on a static scene too, pairing is not your fault. The color-to-depth registration geometry or a stream resolution mismatch is, which is the subject of the depth-to-color alignment and stream resolution guide.
The fix: pair on sequence numbers under one mutex
Pair on the frame counter the camera stamps, not on arrival order. Most machine-vision drivers expose a per-stream sequence number or block counter. Publish the cloud only when the depth frame's number equals the color frame's number, and publish nothing while one side of a pair is missing. The change is small and local:
- In each frame callback, copy the frame and its sequence number first. Callbacks own the buffer only briefly; the torn frames and callback buffer guide covers what happens if you skip the copy.
- Store the copy in a per-stream cache under one mutex shared by both callbacks.
- After storing, run one integer comparison; build and publish the cloud only when the cached sequence numbers match.
- If they do not match, return and wait for the other stream. Do not fall back to newest-color silently.
- Gate strict pairing behind a boolean in your pipeline configuration, so you can switch modes on site and compare clouds.
// Names below are illustrative; use the frame counter your SDK exposes.
std::mutex pair_mutex; // guards both caches and the publish decision
uint64_t color_seq = 0; // newest color frame counter
uint64_t depth_seq = 0; // newest depth frame counter
ColorFrame color_cache; // copied on entry, never held by reference
DepthFrame depth_cache;
void on_color(const ColorFrame& f) { // driver's color thread
std::lock_guard<std::mutex> lock(pair_mutex);
color_cache = f; // copy before any heavy work
color_seq = f.sequence_number;
try_publish();
}
void on_depth(const DepthFrame& f) { // driver's depth thread
std::lock_guard<std::mutex> lock(pair_mutex);
depth_cache = f;
depth_seq = f.sequence_number;
try_publish();
}
void try_publish() { // called with pair_mutex held
if (color_seq != depth_seq) return; // no matched pair yet
enqueue_cloud_build(depth_cache, color_cache); // hand to a worker, stay short
}
Two rules keep this sound. Keep the locked region to copies and the comparison, and hand the heavy cloud build to a worker afterwards: a task must never exit with a mutex held, and memory under a held lock must never be freed. And never re-introduce a newest-color fallback inside the same run; a silent fallback recreates the fault with extra steps.
The test that proves it: run 200 frames of a moving target with the log on. Three things confirm the fix. Every published cloud carries equal color and depth sequence numbers. The publish count equals the matched-pair count. The color-edge offset you measured in the unpaired run collapses to the static registration error.
Worked example: color one frame late on a 30 fps belt at 0.5 m/s
Inputs, and where each comes from: both streams run at 30 fps, the typical rate for an RGBD rig of this class and a setting you would read off the camera's streaming page; color is delivered one frame after depth, the delivery-order fault this page fixes; the belt runs at 0.5 m/s, declared here as the example speed, so substitute your own line speed; and the part carries a ±2 mm tolerance band, declared so the decision has a number to fail against.
Rule in words: one frame of offset lasts one frame period, and the scene travels speed × period between the depth exposure and the color exposure that the pipeline wrongly joins.
Arithmetic:
- t_frame = 1 / 30 fps = 0.0333 s = 33.3 ms per frame
- s_offset = 0.5 m/s × 0.0333 s = 0.0167 m = 16.7 mm
Result: every cloud built from the newest color frame carries color displaced 16.7 mm along the belt direction relative to the depth surface. That is more than eight times the ±2 mm band, so the unpaired pipeline fails on every moving frame, not occasionally.
Decision: with sequence pairing, a published pair shares one exposure instant, so the cross-frame offset returns to 0 mm and the residual is the belt's motion within a single exposure, not a frame-to-frame jump. Threshold: whenever scene speed × frame period exceeds about a tenth of your tolerance band, strict pairing stops being optional. Here 16.7 mm against a 0.2 mm line (a tenth of ±2 mm) clears it by more than a factor of eighty.
Rate branch, same rig with color throttled to 15 fps: equality never fires, so pair through the ratio. Depth at 30 fps and color at 15 fps pair through depth_seq = 2 × color_seq, so depth frame 42 joins color frame 21, because 42 / 2 = 21. Integer ratios stay locked for the whole shift; fractional ones such as 25 against 30 drift each time a frame drops.
Wraps, gaps and timestamp cross-checks
Three details decide whether the pairing survives a long shift.
- Counter wrap. Sequence counters are fixed width. A 16-bit counter at 30 fps wraps every 65,536 / 30 ≈ 2185 s, about 36 minutes; a 32-bit counter takes years. Compare with modulo arithmetic, (a − b) mod 65536 for 16 bits, and treat the counter as free-running. A huge negative-looking jump is a wrap; a jump of two or more is a drop.
- Dropped frames. A gap in one stream means that pair never existed; skip it and log the gap. Occasional drops are normal on a shared link. If gaps exceed roughly one in a hundred, fix the transport before trusting any statistics; the point cloud camera processing load guide separates host load from stream faults step by step.
- Timestamp cross-check. A matched pair should carry near-identical camera timestamps, because the camera captured it as one event. If matched pairs differ by a systematic frame period, the counter you are reading is not the one gating exposure, and you are pairing metadata rather than frames.
Put the modulo check in the cloud publisher, not only in the logger. A wrapped counter must not wedge the pipeline into publishing nothing.
When sequence pairing cannot help
Pairing selects frames; it cannot make two frames share an instant. If a camera exposes color and depth at different times with no shared trigger, equal sequence numbers still label frames that saw different light, and the moving-scene residual stays whatever the inter-exposure gap dictates. Confirm with a pulse test: flash an LED or strobe in view and check which frame in each stream caught it. For two separate cameras, pair by camera timestamp instead of a shared counter, as the pair frames from two synced cameras by timestamp guide sets out; when the scene moves within the inter-frame gap and you need sub-frame alignment, resample to a common instant as covered in the IMU and depth timestamp interpolation guide.
What the host needs
A rig streaming color and depth at 30 fps each raises 60 callbacks per second, and pairing adds one mutex and one integer comparison to each. Keep callbacks to copy-compare-enqueue and the load stays trivial. Let the cloud build run inside the lock, and the queues shift the very offsets you just fixed. If you merge several depth frames into one gap-free cloud downstream, budget for that stage too; the merge multiple depth frames guide shows its cost shape.
The vision controllers category is the host side of this build. A current example: the MV-VC5525-256G70-NN prints 5+ LAN ports, PoE camera ports, 8 built-in light channels and 32 GB of memory, with PCIe expansion supported.
- LAN ports
- 5+ LAN ports
- USB 3 ports
- 3-4 USB 3 ports
- PoE
- PoE camera ports
- Light outputs
- 8 built-in channels
When the same box also runs inference or feeds several cameras, compare what each controller family prints:
| Specification | MV-VC3301H-128G66 | MV-VC5525-256G70-NN | MV-VC4712-256G10-2TH |
|---|---|---|---|
| CPU | Intel® Core i3-8100 | Intel® Core i5-12400 | Intel® Core Ultra 7 processor 255H |
| Memory | 8 GB | 32 GB | 2 × DDR5 6400 MHz non-ECC SO-DIMM memory slots; Up to 48 GB per slot, Up to 96 GB with two slots |
| Storage | 128G SSD | 256 GB SSD | 1 × 7-pin SATA3.0, 1 × M.2 SATA 2280, 1 × M.2 NVMe 2280; 1 × 2.5"/3.5" drive bay |
| GPU | Intel® HD Graphics 630 | Intel® UHD Graphics 730 | Intel® Arc 140T GPU |
| Operating system | Windows 10 64-bit, Not activated | Unactivated Windows 10 | Windows 11 64-bit (not activated) |
| Encryption method | VM6200Pro license key | – | MV-VC47**-**6-**: VM6200pro encryption supported; MV-VC47**-**0-**: encryption not supported |
| Video output | HDMI interface × 1, VGA interface × 1; Supports independent display output at the same time; Max. resolution 1920 × 1080 @ 60 Hz | HDMI interface × 1, VGA interface × 1; Supports independent display output at the same time; Max. resolution 1920 × 1080 @ 60 Hz | Independent dual display supported; 1× HDMI port, Up to 1920 × 1080 @ 60 Hz; 1× VGA port, Up to 1920 × 1080 @ 60 Hz |
| Digital I/O | 8 digital isolated inputs supported; 16 opto-isolated outputs supported (NPN/PNP switchable) | Opto-isolated input × 8; Opto-isolated output × 12 ( supports NPN/PNP switching), digital-isolated low-side driver output × 8; Non-isolated bi-directional differential I/O × 4 | 18 digital isolated inputs supported; 18 isolated digital outputs |
| Network interface | 6× Gigabit Ethernet ports | GigE interface × 7, including interface for PoE × 4 | 4× Gigabit Ethernet ports; PoE power supported, 15 W per port, 25 W total across 4 ports |
| USB Interface | USB 2.0 × 4; USB 3.0 × 4; Built-in USB 2.0 × 1 | USB 2.0 × 4, USB 3.0 × 4, built-in USB 2.0 × 1 | 4× USB 2.0 ports; 2× USB 3.0 ports; 1 × internal USB2.0 port |
| Serial Port | 2× full-function RS-232, RS-485/RS-422 switchable | RS-232 × 2 by default, supports switching to RS-485 and RS-422 | 2 × configurable RS-232/RS-422/RS-485 |
| Light interface | 4-channel 24 V constant-voltage light drive, Max. power per channel: 72 W, Max. power (4 channels): 150 W | 24 VDC constant voltage × 8; (Max. power consumption of single channel is 72 W, total max. power consumption 190 W) | SMR-03V-BC female; 6-channel 24 V constant-voltage light drive, Max. power per channel: 72 W, Max. total power: 110 W |
| Price | $2,419.00 | $4,479.00 · quoted | $6,049.00 · quoted |
All 3 share: Certification CE, KC. Values as printed in each product's data sheet; open a part for its drawing and full specifications.
Prevention checklist for the next rig
Carry these to the next integration; each one exists because it failed someone before.
- Log sequence numbers per frame in production, not only while debugging. It is the cheapest diagnostic you will ever install.
- Run both streams at the same rate, or at integer-divisible rates, from day one.
- Copy frames inside callbacks and process outside the lock, every time.
- Commission with a moving target at line speed, never with a static chart alone.
- Re-run the moving-target check after any driver or firmware update, because delivery order can change with the software stack.
- Keep strict pairing behind a configuration switch, so a field engineer can compare modes without a rebuild.
If you are still choosing hardware, the camera finder filters the 3D sensors by type and series, and industrial mini PCs cover the host class when your build outgrows a vision controller. The 3D vision topic page collects the rest of the integration guides, and you can always reach the team through the contact page.
Questions
Does pairing by sequence number replace depth-to-color registration?
No. Pairing selects the two frames that share one exposure instant; registration is the geometric mapping between the depth sensor's coordinates and the color sensor's, done with the factory intrinsics and extrinsics. You need both: pairing fixes which frames enter the cloud, registration fixes where color pixels land on the depth geometry. If color still sits off the depth edges after correct pairing on a static scene, check the alignment geometry and the stream resolutions next.
What sequence-number ratio pairs 30 fps depth with 10 fps color?
Three to one. Depth frames at three times the color rate pair through depth_seq = 3 × color_seq, so a depth frame numbered 60 joins the color frame numbered 20, because 60 / 3 = 20. Keep the ratio integer: fractional ratios such as 30 against 20 drift every time either camera drops a frame, and the pairing then needs a re-synchronization rule rather than a simple division.
How do I handle sequence numbers that wrap around?
Treat the counter as free-running and compare differences modulo its width: (a − b) mod 65536 for a 16-bit counter, mod 4294967296 for 32 bits. At 30 fps a 16-bit counter wraps about every 36 minutes, so a plain comparison eventually fails on a long-running cell. Read the jumps carefully: a huge negative-looking jump is a wrap, while a jump of two or more within one stream is a dropped frame, which is a different fault.
Can I compensate by delaying color by exactly one frame instead?
Only while nothing else changes. A fixed one-frame delay encodes today's delivery order and rate; a different frame rate, an added stream or a driver update can change the order, and the compensation becomes two frames or points the wrong way. Sequence pairing costs one integer comparison and adapts by itself, so use the fixed delay only as a quick bench check to confirm the size of the offset, never as the permanent fix.
How far does one frame of offset move color on a slow line?
Multiply line speed by the frame period. At 30 fps the period is 33.3 ms, so 0.1 m/s displaces color 3.3 mm, 0.3 m/s displaces it 10 mm and 0.5 m/s displaces it 16.7 mm. Compare that displacement with your tolerance band: once it exceeds roughly a tenth of the feature you are checking, the unpaired cloud fails whenever the scene moves, and strict pairing becomes the difference between pass and fail.
