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+
66import pyrealsense2 as rs
77from rspy import log , test
88import numpy as np
9+ import cv2
910import 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
1314FRAMES_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
2244def 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 ()
0 commit comments