Skip to content

Commit 3a91257

Browse files
authored
Laser pattern test (realsenseai#15380)
2 parents a2db341 + 12f0566 commit 3a91257

1 file changed

Lines changed: 165 additions & 0 deletions

File tree

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
# License: Apache 2.0. See LICENSE file in root directory.
2+
# Copyright(c) 2026 RealSense, Inc. All Rights Reserved.
3+
4+
"""
5+
Verifies the laser projector is actually emitting a structured-light dot pattern.
6+
7+
Captures an averaged IR image with the emitter OFF and another with it ON (same
8+
static scene, fixed exposure), then diffs them directly: the laser only adds
9+
light where its dots land, so real dots show up in the difference image as many
10+
small, bright, scattered blobs -- the same thing a human would see comparing the
11+
two frames side by side. A uniform brightness shift (e.g. ambient light changing
12+
between captures) would instead show up as one large blob, which is filtered out.
13+
"""
14+
15+
import pytest
16+
import pyrealsense2 as rs
17+
import numpy as np
18+
import cv2
19+
import time
20+
import logging
21+
from iq_helper import save_failure_snapshot
22+
23+
log = logging.getLogger(__name__)
24+
25+
pytestmark = [
26+
pytest.mark.context("image-quality"),
27+
pytest.mark.device_each("D400*"),
28+
pytest.mark.device_each("D500*"),
29+
pytest.mark.timeout(120),
30+
]
31+
32+
NUM_FRAMES = 15 # frames averaged per measurement, to average out sensor read noise
33+
SETTLE_FRAMES_TO_DISCARD = 5 # frames dropped after toggling the emitter, to let the new state take effect
34+
MIN_DOT_AREA_PX = 1 # smallest connected component counted as a dot
35+
MAX_DOT_AREA_PX = 50 # above this, treat it as a brightness blob, not a laser dot
36+
MIN_DOT_COUNT = 30 # need at least this many dot-sized blobs in the diff image
37+
GRID_SIZE = 4 # frame divided into GRID_SIZE x GRID_SIZE cells to check spread
38+
MIN_GRID_CELLS_COVERED = 6 # dots must be spread across at least this many cells (of GRID_SIZE**2)
39+
MIN_QUADRANTS_COVERED = 3 # covered cells must span at least this many of the 4 image quadrants,
40+
# so the dots aren't all clustered in one corner
41+
EXPOSURE_FRACTION = 6.0 # manual exposure = 1/EXPOSURE_FRACTION of frame time, short enough to avoid saturation
42+
43+
44+
def capture_avg_ir(pipeline):
45+
"""Discard a few frames to let the emitter state settle, then return the pixel-wise mean IR image."""
46+
for _ in range(SETTLE_FRAMES_TO_DISCARD):
47+
pipeline.wait_for_frames()
48+
49+
frames = []
50+
for _ in range(NUM_FRAMES):
51+
ir_frame = pipeline.wait_for_frames().get_infrared_frame(1)
52+
if ir_frame:
53+
frames.append(np.asanyarray(ir_frame.get_data()).astype(np.float32))
54+
55+
if not frames:
56+
pytest.fail("No IR frames captured — pipeline returned no valid infrared frames")
57+
return np.mean(frames, axis=0)
58+
59+
60+
def find_dot_blobs(diff_image):
61+
"""
62+
Threshold the ON-minus-OFF difference image (Otsu, like a human picking out
63+
"the bright bits") and return the dot-sized connected components plus the
64+
binary mask used, for debugging.
65+
"""
66+
diff_u8 = cv2.normalize(np.clip(diff_image, 0, None), None, 0, 255, cv2.NORM_MINMAX, dtype=cv2.CV_8U)
67+
_, mask = cv2.threshold(diff_u8, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
68+
_, _, stats, centroids = cv2.connectedComponentsWithStats(mask, connectivity=8)
69+
70+
dots = [(centroids[i], stats[i, cv2.CC_STAT_AREA]) for i in range(1, len(stats)) # skip background label 0
71+
if MIN_DOT_AREA_PX <= stats[i, cv2.CC_STAT_AREA] <= MAX_DOT_AREA_PX]
72+
return dots, mask
73+
74+
75+
def draw_debug(off_img, on_img, diff_mask, dots):
76+
off_bgr = cv2.cvtColor(cv2.normalize(off_img, None, 0, 255, cv2.NORM_MINMAX, dtype=cv2.CV_8U), cv2.COLOR_GRAY2BGR)
77+
on_bgr = cv2.cvtColor(cv2.normalize(on_img, None, 0, 255, cv2.NORM_MINMAX, dtype=cv2.CV_8U), cv2.COLOR_GRAY2BGR)
78+
mask_bgr = cv2.cvtColor(diff_mask, cv2.COLOR_GRAY2BGR)
79+
for (cx, cy), _ in dots:
80+
cv2.circle(mask_bgr, (int(cx), int(cy)), 4, (0, 0, 255), 1)
81+
cv2.putText(off_bgr, "OFF", (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 0, 255), 2)
82+
cv2.putText(on_bgr, "ON", (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 0), 2)
83+
cv2.putText(mask_bgr, f"dots={len(dots)}", (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 0), 2)
84+
return np.hstack([off_bgr, on_bgr, mask_bgr])
85+
86+
87+
def test_laser_pattern_visible(test_device_wrapped):
88+
dev, ctx = test_device_wrapped
89+
product_name = dev.get_info(rs.camera_info.name)
90+
pre_sensor = dev.first_depth_sensor()
91+
92+
if not pre_sensor.supports(rs.option.emitter_enabled):
93+
pytest.skip(f"{product_name} does not support emitter_enabled")
94+
95+
cfg = rs.config()
96+
# On hubless multi-device rigs (e.g. Jetson with D457 + D436) the context sees every
97+
# connected device; without enable_device(sn) the pipeline picks the first match.
98+
cfg.enable_device(dev.get_info(rs.camera_info.serial_number))
99+
cfg.enable_stream(rs.stream.infrared, 1, rs.format.y8, 30)
100+
101+
pipeline = rs.pipeline(ctx)
102+
if not cfg.can_resolve(pipeline):
103+
pytest.skip(f"{product_name} does not support an IR y8 stream")
104+
105+
pattern_visible = False
106+
dots = []
107+
covered_cells = set()
108+
covered_quadrants = set()
109+
profile = pipeline.start(cfg)
110+
111+
try:
112+
sensor = profile.get_device().first_depth_sensor()
113+
if sensor.supports(rs.option.laser_power):
114+
sensor.set_option(rs.option.laser_power, sensor.get_option_range(rs.option.laser_power).max)
115+
if sensor.supports(rs.option.enable_auto_exposure):
116+
sensor.set_option(rs.option.enable_auto_exposure, 0)
117+
118+
pipeline.wait_for_frames()
119+
time.sleep(1) # let the stream stabilize before touching exposure/emitter
120+
121+
if sensor.supports(rs.option.exposure):
122+
# Fix exposure so the OFF/ON images differ only by the laser's own light,
123+
# not by auto-exposure compensating for it -- otherwise the diff isn't a clean A/B.
124+
fps = profile.get_stream(rs.stream.infrared, 1).fps()
125+
sensor.set_option(rs.option.exposure, (1_000_000.0 / fps) / EXPOSURE_FRACTION)
126+
127+
sensor.set_option(rs.option.emitter_enabled, 0)
128+
off_img = capture_avg_ir(pipeline)
129+
130+
sensor.set_option(rs.option.emitter_enabled, 1)
131+
on_img = capture_avg_ir(pipeline)
132+
133+
dots, mask = find_dot_blobs(on_img - off_img)
134+
h, w = mask.shape
135+
covered_cells = {
136+
(
137+
min(int(cx * GRID_SIZE / w), GRID_SIZE - 1),
138+
min(int(cy * GRID_SIZE / h), GRID_SIZE - 1)
139+
)
140+
for (cx, cy), _ in dots
141+
}
142+
143+
# Map each covered cell to its image quadrant (2x2 blocks of the grid) to confirm
144+
# the dots aren't all clustered in one corner.
145+
covered_quadrants = {(col // (GRID_SIZE // 2), row // (GRID_SIZE // 2)) for col, row in covered_cells}
146+
147+
log.info(f"{product_name}: {len(dots)} dot-sized blobs in ON-OFF diff, "
148+
f"spread across {len(covered_cells)}/{GRID_SIZE * GRID_SIZE} grid cells "
149+
f"in {len(covered_quadrants)}/4 quadrants")
150+
151+
pattern_visible = (len(dots) >= MIN_DOT_COUNT
152+
and len(covered_cells) >= MIN_GRID_CELLS_COVERED
153+
and len(covered_quadrants) >= MIN_QUADRANTS_COVERED)
154+
155+
if not pattern_visible:
156+
dbg = draw_debug(off_img, on_img, mask, dots)
157+
save_failure_snapshot(__file__, pipeline, dbg)
158+
finally:
159+
pipeline.stop()
160+
161+
assert pattern_visible, (
162+
f"Laser dot pattern not detected on {product_name}: found {len(dots)} dot-sized blobs "
163+
f"(need >={MIN_DOT_COUNT}) across {len(covered_cells)} grid cells (need >={MIN_GRID_CELLS_COVERED}) "
164+
f"in {len(covered_quadrants)} quadrants (need >={MIN_QUADRANTS_COVERED})"
165+
)

0 commit comments

Comments
 (0)