|
| 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