Skip to content

Commit 3de67da

Browse files
authored
PR realsenseai#14296 from AviaAv: improve and enable basic color test
2 parents 632d1b2 + aaaea31 commit 3de67da

2 files changed

Lines changed: 224 additions & 42 deletions

File tree

Lines changed: 223 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,63 +1,245 @@
11
# License: Apache 2.0. See LICENSE file in root directory.
22
# Copyright(c) 2025 RealSense, Inc. All Rights Reserved.
33

4-
# test:device D400*
5-
# test:donotrun
4+
# test:device each(D400*)
5+
66
import pyrealsense2 as rs
77
from rspy import log, test
88
import numpy as np
9+
import cv2
910
import time
1011

11-
NUM_FRAMES = 10 # Number of frames to check
12-
COLOR_TOLERANCE = 20 # Acceptable per-channel deviation in RGB values
12+
NUM_FRAMES = 100 # Number of frames to check
13+
COLOR_TOLERANCE = 30 # Acceptable per-channel deviation in RGB values
1314
FRAMES_PASS_THRESHOLD =0.8 # Percentage of frames that needs to pass
15+
DEBUG_MODE = False
16+
17+
# A4 size in pixels at 96 DPI
18+
A4_WIDTH = 794
19+
A4_HEIGHT = 1123
1420

15-
# Known pixel positions and expected RGB values
16-
color_points = {
17-
"orange": ((920, 400), (116, 38, 15)),
18-
"red": ((800, 500), (105, 13, 9)),
19-
"green": ((700, 400), (14, 21, 9))
21+
# expected colors (insertion order -> mapped row-major to 3x3 grid)
22+
expected_colors = {
23+
"red": (132, 60, 60),
24+
"green": (40, 84, 72),
25+
"blue": (20, 67, 103),
26+
"black": (35, 35, 35),
27+
"white": (130, 130, 130),
28+
"gray": (90, 90, 90),
29+
"purple": (56, 72, 98),
30+
"orange": (136, 66, 50),
31+
"yellow": (136, 122, 60),
2032
}
33+
# list of color names in insertion order -> used left->right, top->bottom
34+
color_names = list(expected_colors.keys())
35+
36+
# we are given a 3x3 grid, we split it using 2 vertical and 2 horizontal separators
37+
# we also calculate the center of each grid cell for sampling from it for the test
38+
xs = [A4_WIDTH / 6.0, A4_WIDTH / 2.0, 5.0 * A4_WIDTH / 6.0]
39+
ys = [A4_HEIGHT / 6.0, A4_HEIGHT / 2.0, 5.0 * A4_HEIGHT / 6.0]
40+
centers = [(x, y) for y in ys for x in xs]
41+
42+
dev, ctx = test.find_first_device_or_exit()
2143

2244
def is_color_close(actual, expected, tolerance):
2345
return all(abs(int(a) - int(e)) <= tolerance for a, e in zip(actual, expected))
2446

25-
test.start("Basic Color Image Quality Test")
47+
def compute_homography(pts):
48+
"""
49+
Given 4 points (the detected ArUco marker centers), find the 3×3 matrix that stretches/rotates
50+
the four ArUco points so they become the corners of an A4 page (used to "flatten" the page in an image)
51+
"""
52+
pts_sorted = sorted(pts, key=lambda p: (p[1], p[0]))
53+
top_left, top_right = sorted(pts_sorted[:2], key=lambda p: p[0])
54+
bottom_left, bottom_right = sorted(pts_sorted[2:], key=lambda p: p[0])
55+
56+
src = np.array([top_left, top_right, bottom_right, bottom_left], dtype=np.float32)
57+
dst = np.array([[0,0],[A4_WIDTH-1,0],[A4_WIDTH-1,A4_HEIGHT-1],[0,A4_HEIGHT-1]], dtype=np.float32)
58+
M = cv2.getPerspectiveTransform(src, dst)
59+
return M # we later use M to get our roi
60+
61+
62+
def draw_debug(frame_bgr, a4_page_bgr):
63+
"""
64+
Simple debug view:
65+
- left: camera frame
66+
- right: focused view on the A4 page with grid and color names
67+
"""
68+
vertical_lines = [A4_WIDTH / 3.0, 2.0 * A4_WIDTH / 3.0]
69+
horizontal_lines = [A4_HEIGHT / 3.0, 2.0 * A4_HEIGHT / 3.0]
70+
H, W = a4_page_bgr.shape[:2]
71+
72+
# draw grid on a4 page image
73+
for x in vertical_lines:
74+
cv2.line(a4_page_bgr, (int(x), 0), (int(x), H - 1), (255, 255, 255), 2)
75+
for y in horizontal_lines:
76+
cv2.line(a4_page_bgr, (0, int(y)), (W - 1, int(y)), (255, 255, 255), 2)
77+
78+
# label centers with color names
79+
for i, (cx, cy) in enumerate(centers):
80+
cx_i, cy_i = int(round(cx)), int(round(cy))
81+
lbl = color_names[i] if i < len(color_names) else str(i)
82+
# white marker with black text for readability
83+
cv2.circle(a4_page_bgr, (cx_i, cy_i), 10, (255, 255, 255), -1)
84+
cv2.putText(a4_page_bgr, lbl, (cx_i + 12, cy_i + 6),
85+
cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0,0,0), 2)
86+
87+
# resize and display side by side
88+
height = 600
89+
frame_width = int(frame_bgr.shape[1] * (height / frame_bgr.shape[0]))
90+
a4_page_width = int(a4_page_bgr.shape[1] * (height / a4_page_bgr.shape[0]))
91+
left = cv2.resize(frame_bgr, (frame_width, height))
92+
right = cv2.resize(a4_page_bgr, (a4_page_width, height))
93+
return np.hstack([left, right])
94+
95+
96+
def detect_a4_page(img, dict_type=cv2.aruco.DICT_4X4_1000, required_ids=(0,1,2,3)):
97+
"""
98+
Detect ArUco markers and return center of each one
99+
Returns None if not all required markers are found
100+
"""
101+
# init aruco detector
102+
aruco = cv2.aruco
103+
dictionary = aruco.getPredefinedDictionary(dict_type)
104+
try:
105+
# new API (OpenCV >= 4.7)
106+
parameters = aruco.DetectorParameters()
107+
detector = aruco.ArucoDetector(dictionary, parameters)
108+
corners, ids, _ = detector.detectMarkers(img)
109+
except AttributeError:
110+
# legacy API (OpenCV <= 4.6) - used on some of our machines
111+
parameters = aruco.DetectorParameters_create()
112+
corners, ids, _ = aruco.detectMarkers(img, dictionary, parameters=parameters)
113+
114+
if ids is None or not all(rid in ids for rid in required_ids):
115+
return None
116+
117+
id_to_corner = dict(zip(ids.flatten(), corners)) # map id to corners
118+
values = [id_to_corner[rid][0].mean(axis=0) for rid in required_ids] # for each required id, get center of marker coords
119+
120+
return np.array(values, dtype=np.float32)
121+
122+
123+
def find_roi_location(pipeline):
124+
"""
125+
Returns a matrix that transforms from frame to region of interest
126+
This matrix will later be used with cv2.warpPerspective()
127+
"""
128+
# stream until page found
129+
page_pts = None
130+
start_time = time.time()
131+
while page_pts is None and time.time() - start_time < 5:
132+
frames = pipeline.wait_for_frames()
133+
color_frame = frames.get_color_frame()
134+
img_bgr = np.asanyarray(color_frame.get_data())
135+
136+
if DEBUG_MODE:
137+
cv2.imshow("PageDetect - waiting for page", img_bgr)
138+
cv2.waitKey(1)
139+
140+
page_pts = detect_a4_page(img_bgr)
141+
142+
if page_pts is None:
143+
log.e("Failed to detect page within timeout")
144+
test.fail()
145+
raise Exception("Page not found")
146+
147+
# page found - use it to calculate transformation matrix from frame to region of interest
148+
M = compute_homography(page_pts)
149+
cv2.destroyAllWindows()
150+
return M, page_pts
151+
26152

27-
try:
28-
dev, ctx = test.find_first_device_or_exit()
153+
def is_cfg_supported(resolution, fps):
154+
color_sensor = dev.first_color_sensor()
155+
for p in color_sensor.get_stream_profiles():
156+
if p.stream_type() == rs.stream.color and p.format() == rs.format.bgr8:
157+
v = p.as_video_stream_profile()
158+
if (v.width(), v.height()) == resolution and v.fps() == fps:
159+
return True
160+
return False
29161

162+
163+
def run_test(resolution, fps):
164+
test.start("Basic Color Image Quality Test:", f"{resolution[0]}x{resolution[1]} @ {fps}fps")
165+
color_match_count = {color: 0 for color in expected_colors.keys()}
30166
pipeline = rs.pipeline(ctx)
31167
cfg = rs.config()
32-
cfg.enable_stream(rs.stream.color, 1280, 720, rs.format.rgb8, 30)
168+
cfg.enable_stream(rs.stream.color, resolution[0], resolution[1], rs.format.bgr8, fps)
33169
pipeline_profile = pipeline.start(cfg)
34-
frames = pipeline.wait_for_frames()
35-
time.sleep(2)
170+
for i in range(30): # skip initial frames
171+
pipeline.wait_for_frames()
172+
try:
36173

37-
color_match_count = {name: 0 for name in color_points}
174+
# find region of interest (page) and get the transformation matrix
175+
# page_pts is only used for debug display
176+
M, page_pts = find_roi_location(pipeline)
38177

39-
for i in range(NUM_FRAMES):
40-
frames = pipeline.wait_for_frames()
41-
color_frame = frames.get_color_frame()
42-
image = np.asanyarray(color_frame.get_data())
43-
44-
for color, (pos, expected_rgb) in color_points.items():
45-
x, y = pos
46-
pixel = image[y, x]
47-
if is_color_close(pixel, expected_rgb, COLOR_TOLERANCE):
48-
color_match_count[color] += 1
49-
else:
50-
log.d(f"Frame {i} - {color} at ({x},{y}): {pixel}{expected_rgb}")
51-
52-
# Check per-color pass threshold
53-
min_passes = int(NUM_FRAMES * FRAMES_PASS_THRESHOLD)
54-
for color_name, count in color_match_count.items():
55-
log.i(f"{color_name.title()} passed in {count}/{NUM_FRAMES} frames")
56-
test.check(count >= min_passes)
57-
58-
except Exception as e:
59-
test.unexpected_exception(e)
60-
61-
pipeline.stop()
62-
test.finish()
63-
test.print_results_and_exit()
178+
# sampling loop
179+
for i in range(NUM_FRAMES):
180+
frames = pipeline.wait_for_frames()
181+
color_frame = frames.get_color_frame()
182+
img_bgr = np.asanyarray(color_frame.get_data())
183+
184+
# use M to get the region of interest - our colored grid printed in the lab
185+
a4_bgr = cv2.warpPerspective(img_bgr, M, (A4_WIDTH, A4_HEIGHT))
186+
187+
# sample each grid center and compare to expected color by row-major insertion order
188+
for idx, (x, y) in enumerate(centers):
189+
color = color_names[idx] if idx < len(color_names) else str(idx)
190+
expected_rgb = expected_colors[color]
191+
x = int(round(x))
192+
y = int(round(y))
193+
b, g, r = (int(v) for v in a4_bgr[y, x]) # stream is BGR, convert to RGB
194+
pixel = (r, g, b)
195+
if is_color_close(pixel, expected_rgb, COLOR_TOLERANCE):
196+
color_match_count[color] += 1
197+
else:
198+
log.d(f"Frame {i} - {color} at ({x},{y}) sampled: {pixel} too far from expected {expected_rgb}")
199+
200+
if DEBUG_MODE:
201+
dbg = draw_debug(img_bgr, a4_bgr)
202+
cv2.imshow("PageDetect - camera | A4", dbg)
203+
cv2.waitKey(1)
204+
205+
# wait for close
206+
# if DEBUG_MODE:
207+
# cv2.waitKey(0)
208+
209+
# check colors sampled correctly
210+
min_passes = int(NUM_FRAMES * FRAMES_PASS_THRESHOLD)
211+
for name, count in color_match_count.items():
212+
log.i(f"{name.title()} passed in {count}/{NUM_FRAMES} frames")
213+
test.check(count >= min_passes)
214+
215+
except Exception as e:
216+
test.fail()
217+
raise e
218+
finally:
219+
cv2.destroyAllWindows()
220+
221+
pipeline.stop()
222+
test.finish()
223+
224+
225+
log.d("context:", test.context)
226+
if "nightly" not in test.context:
227+
configurations = [((1280, 720), 30)]
228+
else:
229+
configurations = [
230+
((640,480), 15),
231+
((640,480), 30),
232+
((640,480), 60),
233+
((848,480), 15),
234+
((848,480), 30),
235+
((848,480), 60),
236+
((1280,720), 5),
237+
((1280,720), 10),
238+
((1280,720), 15),
239+
]
240+
241+
for cfg in configurations:
242+
if is_cfg_supported(*cfg):
243+
run_test(*cfg)
244+
245+
test.print_results_and_exit()

unit-tests/requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ uvicorn==0.34.0
33
pydantic==2.10.6
44
aiortc==1.11.0
55
opencv-python==4.11.0.86
6-
numpy==2.2.4
6+
numpy==2.0.2 # max version currently working on Jetson
77
python-socketio==5.13.0
88
pytest==8.3.5
99
pytest-asyncio==0.26.0

0 commit comments

Comments
 (0)