From 2f0b3fea3c91d44356acf47f11055c2e9161c9e4 Mon Sep 17 00:00:00 2001 From: Matic Tonin Date: Sun, 6 Aug 2023 00:43:26 +0200 Subject: [PATCH 01/28] Changed traceLevel as argument --- calibrate.py | 7 ++----- depthai_calibration | 2 +- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/calibrate.py b/calibrate.py index 3e559d7be..755d41c19 100755 --- a/calibrate.py +++ b/calibrate.py @@ -293,8 +293,7 @@ def get_synced(self): # Mark minimum if min_ts_diff is None or (acc_diff < min_ts_diff['ts'] and abs(acc_diff - min_ts_diff['ts']) > 0.03): min_ts_diff = {'ts': acc_diff, 'indicies': indicies.copy()} - if self.traceLevel == 0 or self.traceLevel == 1: - print('new minimum:', min_ts_diff, 'min required:', self.min_diff_timestamp) + print('new minimum:', min_ts_diff, 'min required:', self.min_diff_timestamp) if min_ts_diff['ts'] < self.min_diff_timestamp: # Check if atleast 5 messages deep @@ -838,9 +837,7 @@ def capture_images_sync(self): def calibrate(self): print("Starting image processing") - stereo_calib = calibUtils.StereoCalibration() - stereo_calib.traceLevel = self.args.traceLevel - stereo_calib.output_scale_factor = self.args.outputScaleFactor + stereo_calib = calibUtils.StereoCalibration(self.args.traceLevel, self.args.outputScaleFactor) dest_path = str(Path('resources').absolute()) # self.args.cameraMode = 'perspective' # hardcoded for now try: diff --git a/depthai_calibration b/depthai_calibration index bf0c18661..0958433ec 160000 --- a/depthai_calibration +++ b/depthai_calibration @@ -1 +1 @@ -Subproject commit bf0c1866182462d427d56a9c1659632493f52055 +Subproject commit 0958433ec4ca8dc300c613933190570101f5e1eb From b3b62c80d30381e72ef93cd84c1df6adb660558e Mon Sep 17 00:00:00 2001 From: Matic Tonin Date: Thu, 10 Aug 2023 00:55:36 +0200 Subject: [PATCH 02/28] Adding argument for disableCamera --- calibrate.py | 318 ++++++++++++++++++++++++++------------------ depthai_calibration | 2 +- 2 files changed, 191 insertions(+), 129 deletions(-) diff --git a/calibrate.py b/calibrate.py index 755d41c19..0374da0a5 100755 --- a/calibrate.py +++ b/calibrate.py @@ -40,6 +40,20 @@ 'CAM_H' : dai.CameraBoardSocket.CAM_H } +productNametoBoard = { + 'RGB' : dai.CameraBoardSocket.CAM_A, + 'LEFT' : dai.CameraBoardSocket.CAM_B, + 'RIGHT' : dai.CameraBoardSocket.CAM_C, + 'CAM_A' : dai.CameraBoardSocket.CAM_A, + 'CAM_B' : dai.CameraBoardSocket.CAM_B, + 'CAM_C' : dai.CameraBoardSocket.CAM_C, + 'CAM_D' : dai.CameraBoardSocket.CAM_D, + 'CAM_E' : dai.CameraBoardSocket.CAM_E, + 'CAM_F' : dai.CameraBoardSocket.CAM_F, + 'CAM_G' : dai.CameraBoardSocket.CAM_G, + 'CAM_H' : dai.CameraBoardSocket.CAM_H + } + camToMonoRes = { 'OV7251' : dai.MonoCameraProperties.SensorResolution.THE_480_P, 'OV9282' : dai.MonoCameraProperties.SensorResolution.THE_800_P, @@ -71,6 +85,12 @@ def create_blank(width, height, rgb_color=(0, 0, 0)): return image +class ParseKwargs(argparse.Action): + def __call__(self, parser, namespace, values, option_string=None): + setattr(namespace, self.dest, dict()) + for value in values: + key, value = value.split('=') + getattr(namespace, self.dest)[key] = value def parse_args(): epilog_text = ''' @@ -116,7 +136,7 @@ def parse_args(): help="Display rectified images with lines drawn for epipolar check") parser.add_argument("-m", "--mode", default=['capture', 'process'], nargs='*', type=str, required=False, help="Space-separated list of calibration options to run. By default, executes the full 'capture process' pipeline. To execute a single step, enter just that step (ex: 'process').") - parser.add_argument("-brd", "--board", default=None, type=str, required=True, + parser.add_argument("-brd", "--board", default=None, type=str, help="BW1097, BW1098OBC - Board type from resources/depthai_boards/boards (not case-sensitive). " "Or path to a custom .json board config. Mutually exclusive with [-fv -b -w]") parser.add_argument("-iv", "--invertVertical", dest="invert_v", default=False, action="store_true", @@ -127,8 +147,8 @@ def parse_args(): help="Sets the maximum epiploar allowed with rectification. Default: %(default)s") parser.add_argument("-cm", "--cameraMode", default="perspective", type=str, required=False, help="Choose between perspective and Fisheye") - parser.add_argument("-rlp", "--rgbLensPosition", default=135, type=int, - required=False, help="Set the manual lens position of the camera for calibration") + parser.add_argument('-rlp', '--rgbLensPosition', nargs='*', action=ParseKwargs, required=False, help="Set the manual lens position of the camera for calibration. Example -rlp rgb=135 night=135") + parser.add_argument('-dsb', '--disableCamera', nargs='+', required=False, help="Set which camera should be disabled. Example -dsb rgb left right") parser.add_argument("-cd", "--captureDelay", default=5, type=int, required=False, help="Choose how much delay to add between pressing the key and capturing the image. Default: %(default)s") parser.add_argument("-fac", "--factoryCalibration", default=False, action="store_true", @@ -143,7 +163,7 @@ def parse_args(): help="Save calibration file to this path") parser.add_argument('-dst', '--datasetPath', type=str, default="dataset", help="Path to dataset used for processing images") - parser.add_argument('-mdmp', '--minDetectedMarkersPercent', type=float, default=0.5, + parser.add_argument('-mdmp', '--minDetectedMarkersPercent', type=float, default=0.1, help="Minimum percentage of detected markers to consider a frame valid") parser.add_argument('-nm', '--numMarkers', type=int, default=None, help="Number of markers in the board") parser.add_argument('-mt', '--mouseTrigger', default=False, action="store_true", @@ -154,6 +174,8 @@ def parse_args(): help="Set to trace the steps in calibration. Number from 1 to 5. If you want to display all, set trace number to 10.") parser.add_argument('-mst', '--minSyncTimestamp', type=float, default=0.05, help="Minimum time difference between pictures taken from different cameras. Default: %(default)s ") + parser.add_argument('-it', '--numPictures', type=float, default=None, + help="Number of pictures taken.") options = parser.parse_args() # Set some extra defaults, `-brd` would override them @@ -329,7 +351,8 @@ def __init__(self): self.output_scale_factor = self.args.outputScaleFactor self.aruco_dictionary = cv2.aruco.Dictionary_get( cv2.aruco.DICT_4X4_1000) - self.focus_value = self.args.rgbLensPosition + self.device = dai.Device() + if self.args.board: board_path = Path(self.args.board) if not board_path.exists(): @@ -341,6 +364,29 @@ def __init__(self): self.board_config = json.load(fp) self.board_config = self.board_config['board_config'] self.board_config_backup = self.board_config + else: + cameraProperties = self.device.getConnectedCameraFeatures() + calibData = self.device.readCalibration() + eeprom = calibData.getEepromData() + print(f"Product name: {eeprom.productName}, board name {eeprom.boardName}") + detection = eeprom.productName.split() + if "AF" in detection: + detection.remove("AF") + if "FF" in detection: + detection.remove("FF") + if "9782" in detection: + detection.remove("9782") + self.board_name = '-'.join(detection).upper() + board_path = Path(self.board_name) + if not board_path.exists(): + board_path = (Path(__file__).parent / 'resources/depthai_boards/boards' / self.board_name.upper()).with_suffix('.json').resolve() + if not board_path.exists(): + raise ValueError( + 'Board config not found: {}'.format(board_path)) + with open(board_path) as fp: + self.board_config = json.load(fp) + self.board_config = self.board_config['board_config'] + self.board_config_backup = self.board_config # TODO: set the total images # random polygons for count @@ -363,17 +409,17 @@ def __init__(self): name = self.board_config['cameras'][cam_id]['name'] self.coverageImages[name] = None - self.device = dai.Device() cameraProperties = self.device.getConnectedCameraFeatures() for properties in cameraProperties: for in_cam in self.board_config['cameras'].keys(): cam_info = self.board_config['cameras'][in_cam] - if properties.socket == stringToCam[in_cam]: - self.board_config['cameras'][in_cam]['sensorName'] = properties.sensorName - print('Cam: {} and focus: {}'.format(cam_info['name'], properties.hasAutofocus)) - self.board_config['cameras'][in_cam]['hasAutofocus'] = properties.hasAutofocus - # self.auto_checkbox_dict[cam_info['name'] + '-Camera-connected'].check() - break + if cam_info["name"] not in self.args.disableCamera: + if properties.socket == stringToCam[in_cam]: + self.board_config['cameras'][in_cam]['sensorName'] = properties.sensorName + print('Cam: {} and focus: {}'.format(cam_info['name'], properties.hasAutofocus)) + self.board_config['cameras'][in_cam]['hasAutofocus'] = properties.hasAutofocus + # self.auto_checkbox_dict[cam_info['name'] + '-Camera-connected'].check() + break self.charuco_board = cv2.aruco.CharucoBoard_create( self.args.squaresX, self.args.squaresY, @@ -393,7 +439,8 @@ def startPipeline(self): self.camera_queue = {} for config_cam in self.board_config['cameras']: cam = self.board_config['cameras'][config_cam] - self.camera_queue[cam['name']] = self.device.getOutputQueue(cam['name'], 1, False) + if cam["name"] not in self.args.disableCamera: + self.camera_queue[cam['name']] = self.device.getOutputQueue(cam['name'], 1, False) def is_markers_found(self, frame): marker_corners, _, _ = cv2.aruco.detectMarkers( @@ -461,43 +508,44 @@ def create_pipeline(self): fps = self.args.framerate for cam_id in self.board_config['cameras']: cam_info = self.board_config['cameras'][cam_id] - if cam_info['type'] == 'mono': - cam_node = pipeline.createMonoCamera() - xout = pipeline.createXLinkOut() + if cam_info["name"] not in self.args.disableCamera: + if cam_info['type'] == 'mono': + cam_node = pipeline.createMonoCamera() + xout = pipeline.createXLinkOut() - cam_node.setBoardSocket(stringToCam[cam_id]) - cam_node.setResolution(camToMonoRes[cam_info['sensorName']]) - cam_node.setFps(fps) + cam_node.setBoardSocket(stringToCam[cam_id]) + cam_node.setResolution(camToMonoRes[cam_info['sensorName']]) + cam_node.setFps(fps) - xout.setStreamName(cam_info['name']) - cam_node.out.link(xout.input) - else: - cam_node = pipeline.createColorCamera() - xout = pipeline.createXLinkOut() - - cam_node.setBoardSocket(stringToCam[cam_id]) - sensorName = cam_info['sensorName'] - print(f'Sensor name is {sensorName}') - cam_node.setResolution(camToRgbRes[cam_info['sensorName'].upper()]) - cam_node.setFps(fps) - - xout.setStreamName(cam_info['name']) - cam_node.isp.link(xout.input) - if cam_info['sensorName'] == "OV9*82": - cam_node.initialControl.setSharpness(0) - cam_node.initialControl.setLumaDenoise(0) - cam_node.initialControl.setChromaDenoise(4) - - if cam_info['hasAutofocus']: - cam_node.initialControl.setManualFocus(self.focus_value) - - controlIn = pipeline.createXLinkIn() - controlIn.setStreamName(cam_info['name'] + '-control') - controlIn.out.link(cam_node.inputControl) - - # cam_node.initialControl.setAntiBandingMode(antibandingOpts[self.args.antibanding]) - xout.input.setBlocking(False) - xout.input.setQueueSize(1) + xout.setStreamName(cam_info['name']) + cam_node.out.link(xout.input) + else: + cam_node = pipeline.createColorCamera() + xout = pipeline.createXLinkOut() + + cam_node.setBoardSocket(stringToCam[cam_id]) + sensorName = cam_info['sensorName'] + print(f'Sensor name is {sensorName}') + cam_node.setResolution(camToRgbRes[cam_info['sensorName'].upper()]) + cam_node.setFps(fps) + + xout.setStreamName(cam_info['name']) + cam_node.isp.link(xout.input) + if cam_info['sensorName'] == "OV9*82": + cam_node.initialControl.setSharpness(0) + cam_node.initialControl.setLumaDenoise(0) + cam_node.initialControl.setChromaDenoise(4) + + if cam_info['hasAutofocus']: + cam_node.initialControl.setManualFocus(int(self.args.rgbLensPosition[stringToCam[cam_id].name.lower()])) + + controlIn = pipeline.createXLinkIn() + controlIn.setStreamName(cam_info['name'] + '-control') + controlIn.out.link(cam_node.inputControl) + + # cam_node.initialControl.setAntiBandingMode(antibandingOpts[self.args.antibanding]) + xout.input.setBlocking(False) + xout.input.setQueueSize(1) return pipeline @@ -515,7 +563,7 @@ def parse_frame(self, frame, stream_name): return True def show_info_frame(self): - info_frame = np.zeros((600, 1000, 3), np.uint8) + info_frame = np.zeros((600, 1100, 3), np.uint8) print("Starting image capture. Press the [ESC] key to abort.") print("Will take {} total images, {} per each polygon.".format( self.total_images, self.args.count)) @@ -523,13 +571,14 @@ def show_info_frame(self): def show(position, text): cv2.putText(info_frame, text, position, cv2.FONT_HERSHEY_SIMPLEX, 1.0, (0, 255, 0)) - + show((25, 40), "Calibration of camera {}". format(self.board_name)) show((25, 100), "Information about image capture:") show((25, 160), "Press the [ESC] key to abort.") show((25, 220), "Press the [spacebar] key to capture the image.") - show((25, 300), "Polygon on the image represents the desired chessboard") - show((25, 340), "position, that will provide best calibration score.") - show((25, 400), "Will take {} total images, {} per each polygon.".format( + show((25, 280), "Press the \"s\" key to stop capturing images and begin calibration.") + show((25, 360), "Polygon on the image represents the desired chessboard") + show((25, 420), "position, that will provide best calibration score.") + show((25, 480), "Will take {} total images, {} per each polygon.".format( self.total_images, self.args.count)) show((25, 550), "To continue, press [spacebar]...") @@ -622,6 +671,7 @@ def capture_images_sync(self): timer = self.args.captureDelay prev_time = None curr_time = None + self.display_name = "Image Window" self.minSyncTimestamp = self.args.minSyncTimestamp syncCollector = MessageSync(len(self.camera_queue), self.minSyncTimestamp) # 3ms tolerance @@ -689,8 +739,8 @@ def capture_images_sync(self): # print(self.height, self.width) self.polygons = calibUtils.setPolygonCoordinates( self.height, self.width) - - localPolygon = np.array([self.polygons[self.current_polygon]]) + if self.current_polygon 720: - #print(cam_info['size'][1]) - reprojection_error_threshold = reprojection_error_threshold * cam_info['size'][1] / 720 - - if cam_info['name'] == 'rgb': - reprojection_error_threshold = 3 - print('Reprojection error threshold -> {}'.format(reprojection_error_threshold)) - - if cam_info['reprojection_error'] > reprojection_error_threshold: - color = red - error_text.append("high Reprojection Error") - text = cam_info['name'] + ' Reprojection Error: ' + format(cam_info['reprojection_error'], '.6f') - print(text) - text = cam_info['name'] + '-reprojection: {}\n'.format(cam_info['reprojection_error'], '.6f') - target_file.write(text) - - # pygame_render_text(self.screen, text, (vis_x, vis_y), color, 30) - - calibration_handler.setDistortionCoefficients(stringToCam[camera], cam_info['dist_coeff']) - calibration_handler.setCameraIntrinsics(stringToCam[camera], cam_info['intrinsics'], cam_info['size'][0], cam_info['size'][1]) - calibration_handler.setFov(stringToCam[camera], cam_info['hfov']) - if self.args.cameraMode != 'perspective': - calibration_handler.setCameraType(stringToCam[camera], dai.CameraModel.Fisheye) - if 'hasAutofocus' in cam_info and cam_info['hasAutofocus']: - calibration_handler.setLensPosition(stringToCam[camera], self.focus_value) - - # log_list.append(self.focusSigma[cam_info['name']]) - # log_list.append(cam_info['reprojection_error']) - # color = green/// - # epErrorZText - if 'extrinsics' in cam_info: - - if 'to_cam' in cam_info['extrinsics']: - right_cam = result_config['cameras'][cam_info['extrinsics']['to_cam']]['name'] - left_cam = cam_info['name'] - - epipolar_threshold = self.args.maxEpiploarError - - if cam_info['extrinsics']['epipolar_error'] > epipolar_threshold: - color = red - error_text.append("high epipolar error between " + left_cam + " and " + right_cam) - elif cam_info['extrinsics']['epipolar_error'] == -1: - color = red - error_text.append("Epiploar validation failed between " + left_cam + " and " + right_cam) - - text = cam_info['name'] + " and " + right_cam + ' epipolar_error: {}\n'.format(cam_info['extrinsics']['epipolar_error'], '.6f') - target_file.write(text) - - # log_list.append(cam_info['extrinsics']['epipolar_error']) - # text = left_cam + "-" + right_cam + ' Avg Epipolar error: ' + format(cam_info['extrinsics']['epipolar_error'], '.6f') - # pygame_render_text(self.screen, text, (vis_x, vis_y), color, 30) - # vis_y += 30 - specTranslation = np.array([cam_info['extrinsics']['specTranslation']['x'], cam_info['extrinsics']['specTranslation']['y'], cam_info['extrinsics']['specTranslation']['z']], dtype=np.float32) - - calibration_handler.setCameraExtrinsics(stringToCam[camera], stringToCam[cam_info['extrinsics']['to_cam']], cam_info['extrinsics']['rotation_matrix'], cam_info['extrinsics']['translation'], specTranslation) - if result_config['stereo_config']['left_cam'] == camera and result_config['stereo_config']['right_cam'] == cam_info['extrinsics']['to_cam']: - calibration_handler.setStereoLeft(stringToCam[camera], result_config['stereo_config']['rectification_left']) - calibration_handler.setStereoRight(stringToCam[cam_info['extrinsics']['to_cam']], result_config['stereo_config']['rectification_right']) - elif result_config['stereo_config']['left_cam'] == cam_info['extrinsics']['to_cam'] and result_config['stereo_config']['right_cam'] == camera: - calibration_handler.setStereoRight(stringToCam[camera], result_config['stereo_config']['rectification_right']) - calibration_handler.setStereoLeft(stringToCam[cam_info['extrinsics']['to_cam']], result_config['stereo_config']['rectification_left']) + if cam_info["name"] not in self.args.disableCamera: + # log_list.append(self.ccm_selected[cam_info['name']]) + reprojection_error_threshold = 1.0 + if cam_info['size'][1] > 720: + #print(cam_info['size'][1]) + reprojection_error_threshold = reprojection_error_threshold * cam_info['size'][1] / 720 + + if cam_info['name'] == 'rgb': + reprojection_error_threshold = 3 + print('Reprojection error threshold -> {}'.format(reprojection_error_threshold)) + + if cam_info['reprojection_error'] > reprojection_error_threshold: + color = red + error_text.append("high Reprojection Error") + text = cam_info['name'] + ' Reprojection Error: ' + format(cam_info['reprojection_error'], '.6f') + print(text) + text = cam_info['name'] + '-reprojection: {}\n'.format(cam_info['reprojection_error'], '.6f') + target_file.write(text) + + # pygame_render_text(self.screen, text, (vis_x, vis_y), color, 30) + + calibration_handler.setDistortionCoefficients(stringToCam[camera], cam_info['dist_coeff']) + calibration_handler.setCameraIntrinsics(stringToCam[camera], cam_info['intrinsics'], cam_info['size'][0], cam_info['size'][1]) + calibration_handler.setFov(stringToCam[camera], cam_info['hfov']) + if self.args.cameraMode != 'perspective': + calibration_handler.setCameraType(stringToCam[camera], dai.CameraModel.Fisheye) + if 'hasAutofocus' in cam_info and cam_info['hasAutofocus']: + print(camera.upper()) + calibration_handler.setLensPosition(stringToCam[camera], self.args.rgbLensPosition[stringToCam[camera.upper()]]) + + # log_list.append(self.focusSigma[cam_info['name']]) + # log_list.append(cam_info['reprojection_error']) + # color = green/// + # epErrorZText + if 'extrinsics' in cam_info: + if 'to_cam' in cam_info['extrinsics']: + right_cam = result_config['cameras'][cam_info['extrinsics']['to_cam']]['name'] + if right_cam not in self.args.disableCamera: + left_cam = cam_info['name'] + + epipolar_threshold = self.args.maxEpiploarError + + if cam_info['extrinsics']['epipolar_error'] > epipolar_threshold: + color = red + error_text.append("high epipolar error between " + left_cam + " and " + right_cam) + elif cam_info['extrinsics']['epipolar_error'] == -1: + color = red + error_text.append("Epiploar validation failed between " + left_cam + " and " + right_cam) + + text = cam_info['name'] + " and " + right_cam + ' epipolar_error: {}\n'.format(cam_info['extrinsics']['epipolar_error'], '.6f') + target_file.write(text) + + # log_list.append(cam_info['extrinsics']['epipolar_error']) + # text = left_cam + "-" + right_cam + ' Avg Epipolar error: ' + format(cam_info['extrinsics']['epipolar_error'], '.6f') + # pygame_render_text(self.screen, text, (vis_x, vis_y), color, 30) + # vis_y += 30 + specTranslation = np.array([cam_info['extrinsics']['specTranslation']['x'], cam_info['extrinsics']['specTranslation']['y'], cam_info['extrinsics']['specTranslation']['z']], dtype=np.float32) + + calibration_handler.setCameraExtrinsics(stringToCam[camera], stringToCam[cam_info['extrinsics']['to_cam']], cam_info['extrinsics']['rotation_matrix'], cam_info['extrinsics']['translation'], specTranslation) + if result_config['stereo_config']['left_cam'] == camera and result_config['stereo_config']['right_cam'] == cam_info['extrinsics']['to_cam']: + calibration_handler.setStereoLeft(stringToCam[camera], result_config['stereo_config']['rectification_left']) + calibration_handler.setStereoRight(stringToCam[cam_info['extrinsics']['to_cam']], result_config['stereo_config']['rectification_right']) + elif result_config['stereo_config']['left_cam'] == cam_info['extrinsics']['to_cam'] and result_config['stereo_config']['right_cam'] == camera: + calibration_handler.setStereoRight(stringToCam[camera], result_config['stereo_config']['rectification_right']) + calibration_handler.setStereoLeft(stringToCam[cam_info['extrinsics']['to_cam']], result_config['stereo_config']['rectification_left']) target_file.close() if len(error_text) == 0: diff --git a/depthai_calibration b/depthai_calibration index 0958433ec..889bc457d 160000 --- a/depthai_calibration +++ b/depthai_calibration @@ -1 +1 @@ -Subproject commit 0958433ec4ca8dc300c613933190570101f5e1eb +Subproject commit 889bc457d9322751f3b951320fa94586f84365b3 From 35a59824fc566e45c987f8ce21a880b057ae86b0 Mon Sep 17 00:00:00 2001 From: Matic Tonin Date: Thu, 10 Aug 2023 00:59:52 +0200 Subject: [PATCH 03/28] Small changes in arguments --- calibrate.py | 25 ++++++------------------- 1 file changed, 6 insertions(+), 19 deletions(-) diff --git a/calibrate.py b/calibrate.py index 0374da0a5..ebdfb58f9 100755 --- a/calibrate.py +++ b/calibrate.py @@ -40,19 +40,6 @@ 'CAM_H' : dai.CameraBoardSocket.CAM_H } -productNametoBoard = { - 'RGB' : dai.CameraBoardSocket.CAM_A, - 'LEFT' : dai.CameraBoardSocket.CAM_B, - 'RIGHT' : dai.CameraBoardSocket.CAM_C, - 'CAM_A' : dai.CameraBoardSocket.CAM_A, - 'CAM_B' : dai.CameraBoardSocket.CAM_B, - 'CAM_C' : dai.CameraBoardSocket.CAM_C, - 'CAM_D' : dai.CameraBoardSocket.CAM_D, - 'CAM_E' : dai.CameraBoardSocket.CAM_E, - 'CAM_F' : dai.CameraBoardSocket.CAM_F, - 'CAM_G' : dai.CameraBoardSocket.CAM_G, - 'CAM_H' : dai.CameraBoardSocket.CAM_H - } camToMonoRes = { 'OV7251' : dai.MonoCameraProperties.SensorResolution.THE_480_P, @@ -163,7 +150,7 @@ def parse_args(): help="Save calibration file to this path") parser.add_argument('-dst', '--datasetPath', type=str, default="dataset", help="Path to dataset used for processing images") - parser.add_argument('-mdmp', '--minDetectedMarkersPercent', type=float, default=0.1, + parser.add_argument('-mdmp', '--minDetectedMarkersPercent', type=float, default=0.7, help="Minimum percentage of detected markers to consider a frame valid") parser.add_argument('-nm', '--numMarkers', type=int, default=None, help="Number of markers in the board") parser.add_argument('-mt', '--mouseTrigger', default=False, action="store_true", @@ -980,25 +967,25 @@ def calibrate(self): right_cam = result_config['cameras'][cam_info['extrinsics']['to_cam']]['name'] if right_cam not in self.args.disableCamera: left_cam = cam_info['name'] - + epipolar_threshold = self.args.maxEpiploarError - + if cam_info['extrinsics']['epipolar_error'] > epipolar_threshold: color = red error_text.append("high epipolar error between " + left_cam + " and " + right_cam) elif cam_info['extrinsics']['epipolar_error'] == -1: color = red error_text.append("Epiploar validation failed between " + left_cam + " and " + right_cam) - + text = cam_info['name'] + " and " + right_cam + ' epipolar_error: {}\n'.format(cam_info['extrinsics']['epipolar_error'], '.6f') target_file.write(text) - + # log_list.append(cam_info['extrinsics']['epipolar_error']) # text = left_cam + "-" + right_cam + ' Avg Epipolar error: ' + format(cam_info['extrinsics']['epipolar_error'], '.6f') # pygame_render_text(self.screen, text, (vis_x, vis_y), color, 30) # vis_y += 30 specTranslation = np.array([cam_info['extrinsics']['specTranslation']['x'], cam_info['extrinsics']['specTranslation']['y'], cam_info['extrinsics']['specTranslation']['z']], dtype=np.float32) - + calibration_handler.setCameraExtrinsics(stringToCam[camera], stringToCam[cam_info['extrinsics']['to_cam']], cam_info['extrinsics']['rotation_matrix'], cam_info['extrinsics']['translation'], specTranslation) if result_config['stereo_config']['left_cam'] == camera and result_config['stereo_config']['right_cam'] == cam_info['extrinsics']['to_cam']: calibration_handler.setStereoLeft(stringToCam[camera], result_config['stereo_config']['rectification_left']) From 64f580dd865a3f788291cbfc5d2357a011bbb76c Mon Sep 17 00:00:00 2001 From: Matic Tonin Date: Thu, 10 Aug 2023 11:46:32 +0200 Subject: [PATCH 04/28] Fixing the default values --- calibrate.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/calibrate.py b/calibrate.py index ebdfb58f9..7abf3bce3 100755 --- a/calibrate.py +++ b/calibrate.py @@ -130,12 +130,12 @@ def parse_args(): help="Invert vertical axis of the camera for the display") parser.add_argument("-ih", "--invertHorizontal", dest="invert_h", default=False, action="store_true", help="Invert horizontal axis of the camera for the display") - parser.add_argument("-ep", "--maxEpiploarError", default="0.7", type=float, required=False, + parser.add_argument("-ep", "--maxEpiploarError", default="0.8", type=float, required=False, help="Sets the maximum epiploar allowed with rectification. Default: %(default)s") parser.add_argument("-cm", "--cameraMode", default="perspective", type=str, required=False, help="Choose between perspective and Fisheye") - parser.add_argument('-rlp', '--rgbLensPosition', nargs='*', action=ParseKwargs, required=False, help="Set the manual lens position of the camera for calibration. Example -rlp rgb=135 night=135") - parser.add_argument('-dsb', '--disableCamera', nargs='+', required=False, help="Set which camera should be disabled. Example -dsb rgb left right") + parser.add_argument('-rlp', '--rgbLensPosition', nargs='*', action=ParseKwargs, required=False, default={} , help="Set the manual lens position of the camera for calibration. Example -rlp rgb=135 night=135") + parser.add_argument('-dsb', '--disableCamera', nargs='+', required=False, default=[] , help="Set which camera should be disabled. Example -dsb rgb left right") parser.add_argument("-cd", "--captureDelay", default=5, type=int, required=False, help="Choose how much delay to add between pressing the key and capturing the image. Default: %(default)s") parser.add_argument("-fac", "--factoryCalibration", default=False, action="store_true", @@ -388,6 +388,7 @@ def __init__(self): # raise Exception( # "OAK-D-Lite Calibration is not supported on main yet. Please use `lite_calibration` branch to calibrate your OAK-D-Lite!!") + #TODO if self.args.cameraMode != "perspective": self.args.minDetectedMarkersPercent = 1 @@ -524,7 +525,10 @@ def create_pipeline(self): cam_node.initialControl.setChromaDenoise(4) if cam_info['hasAutofocus']: - cam_node.initialControl.setManualFocus(int(self.args.rgbLensPosition[stringToCam[cam_id].name.lower()])) + if self.args.rgbLensPosition: + cam_node.initialControl.setManualFocus(int(self.args.rgbLensPosition[stringToCam[cam_id].name.lower()])) + else: + cam_node.initialControl.setManualFocus(135) controlIn = pipeline.createXLinkIn() controlIn.setStreamName(cam_info['name'] + '-control') @@ -955,8 +959,10 @@ def calibrate(self): if self.args.cameraMode != 'perspective': calibration_handler.setCameraType(stringToCam[camera], dai.CameraModel.Fisheye) if 'hasAutofocus' in cam_info and cam_info['hasAutofocus']: - print(camera.upper()) - calibration_handler.setLensPosition(stringToCam[camera], self.args.rgbLensPosition[stringToCam[camera.upper()]]) + if self.args.rgbLensPosition: + calibration_handler.setLensPosition(stringToCam[camera], int(self.args.rgbLensPosition[cam_info["name"]])) + else: + calibration_handler.setLensPosition(stringToCam[camera], int(135)) # log_list.append(self.focusSigma[cam_info['name']]) # log_list.append(cam_info['reprojection_error']) From 470812dc2dfc623049c0ae33cd65830fb3838056 Mon Sep 17 00:00:00 2001 From: Matic Tonin Date: Thu, 10 Aug 2023 12:48:49 +0200 Subject: [PATCH 05/28] Adding argument for enablepoylgons --- calibrate.py | 34 ++++++++++++++++++++++++---------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/calibrate.py b/calibrate.py index 7abf3bce3..c30b92ed2 100755 --- a/calibrate.py +++ b/calibrate.py @@ -163,6 +163,8 @@ def parse_args(): help="Minimum time difference between pictures taken from different cameras. Default: %(default)s ") parser.add_argument('-it', '--numPictures', type=float, default=None, help="Number of pictures taken.") + parser.add_argument('-ebp', '--enablePolynomsdisplay', default=False, action="store_true", + help="Enable the display of polynoms.") options = parser.parse_args() # Set some extra defaults, `-brd` would override them @@ -339,6 +341,7 @@ def __init__(self): self.aruco_dictionary = cv2.aruco.Dictionary_get( cv2.aruco.DICT_4X4_1000) self.device = dai.Device() + self.enablePolynomsdisplay = self.args.enablePolynomsdisplay if self.args.board: board_path = Path(self.args.board) @@ -377,8 +380,11 @@ def __init__(self): # TODO: set the total images # random polygons for count - self.total_images = self.args.count * \ - len(calibUtils.setPolygonCoordinates(1000, 600)) + if self.args.numPictures: + self.total_images = self.args.numPictures + else: + self.total_images = self.args.count * \ + len(calibUtils.setPolygonCoordinates(1000, 600)) if self.traceLevel == 1: print("Using Arguments=", self.args) if self.args.datasetPath: @@ -390,7 +396,7 @@ def __init__(self): #TODO if self.args.cameraMode != "perspective": - self.args.minDetectedMarkersPercent = 1 + self.args.minDetectedMarkersPercent = 1.0 self.coverageImages ={} for cam_id in self.board_config['cameras']: @@ -556,8 +562,12 @@ def parse_frame(self, frame, stream_name): def show_info_frame(self): info_frame = np.zeros((600, 1100, 3), np.uint8) print("Starting image capture. Press the [ESC] key to abort.") - print("Will take {} total images, {} per each polygon.".format( - self.total_images, self.args.count)) + if self.enablePolynomsdisplay: + print("Will take {} total images, {} per each polygon.".format( + self.total_images, self.args.count)) + else: + print("Will take {} total images.".format( + self.total_images)) def show(position, text): cv2.putText(info_frame, text, position, @@ -567,10 +577,14 @@ def show(position, text): show((25, 160), "Press the [ESC] key to abort.") show((25, 220), "Press the [spacebar] key to capture the image.") show((25, 280), "Press the \"s\" key to stop capturing images and begin calibration.") - show((25, 360), "Polygon on the image represents the desired chessboard") - show((25, 420), "position, that will provide best calibration score.") - show((25, 480), "Will take {} total images, {} per each polygon.".format( - self.total_images, self.args.count)) + if self.enablePolynomsdisplay: + show((25, 360), "Polygon on the image represents the desired chessboard") + show((25, 420), "position, that will provide best calibration score.") + show((25, 480), "Will take {} total images, {} per each polygon.".format( + self.total_images, self.args.count)) + else: + show((25, 480), "Will take {} total images.".format( + self.total_images)) show((25, 550), "To continue, press [spacebar]...") cv2.imshow("info", info_frame) @@ -752,7 +766,7 @@ def capture_images_sync(self): localPolygon = np.matmul(localPolygon, perspectiveRotationMatrix).astype(np.int32) localPolygon[0][:, 1] += (height - abs(localPolygon[0][:, 1].max())) localPolygon[0][:, 0] += abs(localPolygon[0][:, 1].min()) - if self.images_captured_polygon Date: Thu, 10 Aug 2023 13:06:37 +0200 Subject: [PATCH 06/28] Adding invertHorizontal/Vertical --- calibrate.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/calibrate.py b/calibrate.py index c30b92ed2..a64827e04 100755 --- a/calibrate.py +++ b/calibrate.py @@ -729,6 +729,12 @@ def capture_images_sync(self): # resizeHeight = height # print(f'Scale Shape is {resizeWidth}x{resizeHeight}' ) + if self.args.invert_v and self.args.invert_h: + currImageList[name] = cv2.flip(currImageList[name], -1) + elif self.args.invert_v: + currImageList[name] = cv2.flip(currImageList[name], 0) + elif self.args.invert_h: + currImageList[name] = cv2.flip(currImageList[name], 1) combinedImage = None combinedCoverageImage = None From 3f1b013f85fb66a78d959e7fc45b18fde7dd86bd Mon Sep 17 00:00:00 2001 From: Matic Tonin Date: Mon, 14 Aug 2023 14:10:55 +0200 Subject: [PATCH 07/28] Saving changes --- calibrate.py | 37 ++++++++++++++++++++++++------------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/calibrate.py b/calibrate.py index a64827e04..6600cec9d 100755 --- a/calibrate.py +++ b/calibrate.py @@ -163,7 +163,7 @@ def parse_args(): help="Minimum time difference between pictures taken from different cameras. Default: %(default)s ") parser.add_argument('-it', '--numPictures', type=float, default=None, help="Number of pictures taken.") - parser.add_argument('-ebp', '--enablePolynomsdisplay', default=False, action="store_true", + parser.add_argument('-ebp', '--enablePolygonsDisplay', default=True, action="store_true", help="Enable the display of polynoms.") options = parser.parse_args() @@ -341,9 +341,10 @@ def __init__(self): self.aruco_dictionary = cv2.aruco.Dictionary_get( cv2.aruco.DICT_4X4_1000) self.device = dai.Device() - self.enablePolynomsdisplay = self.args.enablePolynomsdisplay - + self.enablePolygonsDisplay = self.args.enablePolygonsDisplay + self.board_name = None if self.args.board: + self.board_name = self.args.board board_path = Path(self.args.board) if not board_path.exists(): board_path = (Path(__file__).parent / 'resources/depthai_boards/boards' / self.args.board.upper()).with_suffix('.json').resolve() @@ -540,7 +541,7 @@ def create_pipeline(self): controlIn.setStreamName(cam_info['name'] + '-control') controlIn.out.link(cam_node.inputControl) - # cam_node.initialControl.setAntiBandingMode(antibandingOpts[self.args.antibanding]) + cam_node.initialControl.setAntiBandingMode(antibandingOpts[self.args.antibanding]) xout.input.setBlocking(False) xout.input.setQueueSize(1) @@ -562,7 +563,7 @@ def parse_frame(self, frame, stream_name): def show_info_frame(self): info_frame = np.zeros((600, 1100, 3), np.uint8) print("Starting image capture. Press the [ESC] key to abort.") - if self.enablePolynomsdisplay: + if self.enablePolygonsDisplay: print("Will take {} total images, {} per each polygon.".format( self.total_images, self.args.count)) else: @@ -577,7 +578,7 @@ def show(position, text): show((25, 160), "Press the [ESC] key to abort.") show((25, 220), "Press the [spacebar] key to capture the image.") show((25, 280), "Press the \"s\" key to stop capturing images and begin calibration.") - if self.enablePolynomsdisplay: + if self.enablePolygonsDisplay: show((25, 360), "Polygon on the image represents the desired chessboard") show((25, 420), "position, that will provide best calibration score.") show((25, 480), "Will take {} total images, {} per each polygon.".format( @@ -683,6 +684,7 @@ def capture_images_sync(self): syncCollector.traceLevel = self.args.traceLevel self.mouseTrigger = False sync_trys = 0 + combinedCoverageImage = None while not finished: currImageList = {} for key in self.camera_queue.keys(): @@ -702,7 +704,7 @@ def capture_images_sync(self): resizeHeight = 0 resizeWidth = 0 for name, imgFrame in currImageList.items(): - self.coverageImages[name]=None + #self.coverageImages[name]=None # print(f'original Shape of {name} is {imgFrame.shape}' ) @@ -737,7 +739,6 @@ def capture_images_sync(self): currImageList[name] = cv2.flip(currImageList[name], 1) combinedImage = None - combinedCoverageImage = None for name, imgFrame in currImageList.items(): height, width, _ = imgFrame.shape if width > resizeWidth and height > resizeHeight: @@ -772,7 +773,7 @@ def capture_images_sync(self): localPolygon = np.matmul(localPolygon, perspectiveRotationMatrix).astype(np.int32) localPolygon[0][:, 1] += (height - abs(localPolygon[0][:, 1].max())) localPolygon[0][:, 0] += abs(localPolygon[0][:, 1].min()) - if self.images_captured_polygon resizeHeight: + height_offset = (height - resizeHeight)//2 + self.coverageImages[name] = self.coverageImages[name][height_offset:height_offset+resizeHeight, :] + if len(self.coverageImages[name].shape) != 3: + self.coverageImages[name] = cv2.cvtColor(self.coverageImages[name], cv2.COLOR_GRAY2RGB) + currCoverImage = cv2.resize(self.coverageImages[name], (0, 0), fx=resizeWidth / width, fy=resizeWidth / width) padding = ((height_offset, height_offset), (width_offset,width_offset), (0, 0)) subCoverageImage = np.pad(currCoverImage, padding, 'constant', constant_values=0) if combinedCoverageImage is None: combinedCoverageImage = subCoverageImage else: combinedCoverageImage = np.hstack((combinedCoverageImage, subCoverageImage)) - + print(combinedCoverageImage) if combinedImage is None: combinedImage = subImage @@ -866,11 +873,15 @@ def capture_images_sync(self): tried[name] = self.parse_frame(frameMsg.getCvFrame(), name) print(f'Status of {name} is {tried[name]}') allPassed = allPassed and tried[name] - if allPassed: color = (int(np.random.randint(0, 255)), int(np.random.randint(0, 255)), int(np.random.randint(0, 255))) for name, frameMsg in syncedMsgs.items(): - self.coverageImages[name] = self.draw_corners(frameMsg.getCvFrame(), self.coverageImages[name], color) + print(frameMsg.getCvFrame().shape) + if len(frameMsg.getCvFrame().shape) == 3: + frameMsg_frame = cv2.cvtColor(frameMsg.getCvFrame(), cv2.COLOR_BGR2GRAY) + print(frameMsg.getCvFrame().shape) + self.coverageImages[name] = cv2.cvtColor(self.coverageImages[name], cv2.COLOR_BGR2GRAY) + self.coverageImages[name] = self.draw_corners(frameMsg_frame, self.coverageImages[name], color) if not self.images_captured: if 'stereo_config' in self.board_config['cameras']: leftStereo = self.board_config['cameras'][self.board_config['stereo_config']['left_cam']]['name'] From 96cb51ea79a4b79ecac5f02690c0fbb0b3370467 Mon Sep 17 00:00:00 2001 From: Matic Tonin Date: Tue, 15 Aug 2023 01:25:45 +0200 Subject: [PATCH 08/28] Finishing combinedImage display --- calibrate.py | 40 +++++++++++++++++++++++++--------------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/calibrate.py b/calibrate.py index 6600cec9d..ceb3b75ad 100755 --- a/calibrate.py +++ b/calibrate.py @@ -468,7 +468,7 @@ def draw_corners(self, frame, displayframe, color): marker_corners, ids, charuco_corners, charuco_ids = self.detect_markers_corners(frame) for corner in charuco_corners: corner_int = (int(corner[0][0]), int(corner[0][1])) - cv2.circle(displayframe, corner_int, 8, color, -1) + cv2.circle(displayframe, corner_int, 8*displayframe.shape[1]//1900, color, -1) height, width = displayframe.shape[:2] start_point = (0, 0) # top of the image end_point = (0, height) @@ -684,7 +684,6 @@ def capture_images_sync(self): syncCollector.traceLevel = self.args.traceLevel self.mouseTrigger = False sync_trys = 0 - combinedCoverageImage = None while not finished: currImageList = {} for key in self.camera_queue.keys(): @@ -739,6 +738,7 @@ def capture_images_sync(self): currImageList[name] = cv2.flip(currImageList[name], 1) combinedImage = None + combinedCoverageImage = None for name, imgFrame in currImageList.items(): height, width, _ = imgFrame.shape if width > resizeWidth and height > resizeHeight: @@ -789,25 +789,34 @@ def capture_images_sync(self): width_offset = (resizeWidth - width)//2 subImage = np.pad(imgFrame, ((height_offset, height_offset), (width_offset, width_offset), (0, 0)), 'constant', constant_values=0) if self.coverageImages[name] is not None: - height, width, _ = self.coverageImages[name].shape - if height > resizeHeight: - height_offset = (height - resizeHeight)//2 - self.coverageImages[name] = self.coverageImages[name][height_offset:height_offset+resizeHeight, :] if len(self.coverageImages[name].shape) != 3: self.coverageImages[name] = cv2.cvtColor(self.coverageImages[name], cv2.COLOR_GRAY2RGB) - currCoverImage = cv2.resize(self.coverageImages[name], (0, 0), fx=resizeWidth / width, fy=resizeWidth / width) + imgFrame = self.coverageImages[name] + cv2.resize(imgFrame, (0, 0), fx=self.output_scale_factor*2, fy=self.output_scale_factor*2) + height, width, _ = imgFrame.shape + if width > resizeWidth and height > resizeHeight: + imgFrame = cv2.resize( + imgFrame, (0, 0), fx= resizeWidth / width, fy= resizeWidth / width) + height, width, _ = imgFrame.shape + if height > resizeHeight: + height_offset = (height - resizeHeight)//2 + imgFrame = imgFrame[height_offset:height_offset+resizeHeight, :] + height, width, _ = imgFrame.shape + height_offset = (resizeHeight - height)//2 + width_offset = (resizeWidth - width)//2 padding = ((height_offset, height_offset), (width_offset,width_offset), (0, 0)) - subCoverageImage = np.pad(currCoverImage, padding, 'constant', constant_values=0) + subCoverageImage = np.pad(imgFrame, padding, 'constant', constant_values=0) + print_text = f"Camera: {name}, picture {self.images_captured}" + cv2.putText(subCoverageImage, print_text, (15, 15+height_offset), cv2.FONT_HERSHEY_SIMPLEX, 2*imgFrame.shape[0]/1750, (0, 0, 0), 2) if combinedCoverageImage is None: combinedCoverageImage = subCoverageImage else: combinedCoverageImage = np.hstack((combinedCoverageImage, subCoverageImage)) - print(combinedCoverageImage) - if combinedImage is None: combinedImage = subImage else: combinedImage = np.hstack((combinedImage, subImage)) + key = cv2.waitKey(1) if key == 27 or key == ord("q"): print("py: Calibration has been interrupted!") @@ -846,6 +855,7 @@ def capture_images_sync(self): cv2.imshow(self.display_name, display_image) if combinedCoverageImage is not None: + #combinedCoverageImage = cv2.resize(combinedCoverageImage, (0, 0), fx=self.output_scale_factor*2, fy=self.output_scale_factor*2) cv2.imshow("Coverage-Image", combinedCoverageImage) tried = {} @@ -876,11 +886,11 @@ def capture_images_sync(self): if allPassed: color = (int(np.random.randint(0, 255)), int(np.random.randint(0, 255)), int(np.random.randint(0, 255))) for name, frameMsg in syncedMsgs.items(): - print(frameMsg.getCvFrame().shape) - if len(frameMsg.getCvFrame().shape) == 3: - frameMsg_frame = cv2.cvtColor(frameMsg.getCvFrame(), cv2.COLOR_BGR2GRAY) - print(frameMsg.getCvFrame().shape) - self.coverageImages[name] = cv2.cvtColor(self.coverageImages[name], cv2.COLOR_BGR2GRAY) + frameMsg_frame = frameMsg.getCvFrame() + if len(frameMsg.getCvFrame().shape) != 3: + frameMsg_frame = cv2.cvtColor(frameMsg.getCvFrame(), cv2.COLOR_GRAY2RGB) + if len(self.coverageImages[name].shape) != 3: + self.coverageImages[name] = cv2.cvtColor(self.coverageImages[name], cv2.COLOR_GRAY2RGB) self.coverageImages[name] = self.draw_corners(frameMsg_frame, self.coverageImages[name], color) if not self.images_captured: if 'stereo_config' in self.board_config['cameras']: From 5c7e008d435aa9282f82143fe155c7c9a1a79092 Mon Sep 17 00:00:00 2001 From: Matic Tonin Date: Thu, 17 Aug 2023 01:43:07 +0200 Subject: [PATCH 09/28] Remove numMarkers and board arg --- calibrate.py | 22 +++++++++--------- .../charuco_board_24.pdf | Bin 0 -> 188132 bytes .../charuco_board_28.pdf | Bin 0 -> 251341 bytes .../charuco_board_32.pdf | Bin 0 -> 321843 bytes .../charuco_board_36.pdf | Bin 0 -> 343035 bytes .../charuco_board_42.pdf | Bin 0 -> 546462 bytes .../charuco_board_50.pdf | Bin 0 -> 818226 bytes .../charuco_board_55.pdf | Bin 0 -> 1012881 bytes .../charuco_board_65.pdf | Bin 0 -> 1399093 bytes .../charuco_board_75.pdf | Bin 0 -> 1862268 bytes depthai_calibration | 2 +- 11 files changed, 12 insertions(+), 12 deletions(-) create mode 100644 charuco_boards_user_calib/charuco_board_24.pdf create mode 100644 charuco_boards_user_calib/charuco_board_28.pdf create mode 100644 charuco_boards_user_calib/charuco_board_32.pdf create mode 100644 charuco_boards_user_calib/charuco_board_36.pdf create mode 100644 charuco_boards_user_calib/charuco_board_42.pdf create mode 100644 charuco_boards_user_calib/charuco_board_50.pdf create mode 100644 charuco_boards_user_calib/charuco_board_55.pdf create mode 100644 charuco_boards_user_calib/charuco_board_65.pdf create mode 100644 charuco_boards_user_calib/charuco_board_75.pdf diff --git a/calibrate.py b/calibrate.py index ceb3b75ad..2622d5460 100755 --- a/calibrate.py +++ b/calibrate.py @@ -113,8 +113,8 @@ def parse_args(): help="Square size of calibration pattern used in centimeters. Default: 2.0cm.") parser.add_argument("-ms", "--markerSizeCm", type=float, required=False, help="Marker size in charuco boards.") - parser.add_argument("-db", "--defaultBoard", default=False, action="store_true", - help="Calculates the -ms parameter automatically based on aspect ratio of charuco board in the repository") + parser.add_argument("-db", "--defaultBoard", default=str, + help="Calculates the size of markers, numbers of squareX and squareY base on the choosing board from charuco_boards directory.") parser.add_argument("-nx", "--squaresX", default="11", type=int, required=False, help="number of chessboard squares in X direction in charuco boards.") parser.add_argument("-ny", "--squaresY", default="8", type=int, required=False, @@ -152,7 +152,6 @@ def parse_args(): help="Path to dataset used for processing images") parser.add_argument('-mdmp', '--minDetectedMarkersPercent', type=float, default=0.7, help="Minimum percentage of detected markers to consider a frame valid") - parser.add_argument('-nm', '--numMarkers', type=int, default=None, help="Number of markers in the board") parser.add_argument('-mt', '--mouseTrigger', default=False, action="store_true", help="Enable mouse trigger for image capture") parser.add_argument('-nic', '--noInitCalibration', default=False, action="store_true", @@ -168,11 +167,15 @@ def parse_args(): options = parser.parse_args() # Set some extra defaults, `-brd` would override them + if options.defaultBoard: + board_name = options.defaultBoard + _, size, numX, numY= board_name.split("_") + options.squaresX = numX + options.squaresY = numY if options.markerSizeCm is None: - if options.defaultBoard: - options.markerSizeCm = options.squareSizeCm * 0.75 - else: - raise argparse.ArgumentError(options.markerSizeCm, "-ms / --markerSizeCm needs to be provided (you can use -db / --defaultBoard if using calibration board from this repository or calib.io to calculate -ms automatically)") + options.markerSizeCm = options.squareSizeCm * 0.75 + else: + raise argparse.ArgumentError(options.markerSizeCm, "-ms / --markerSizeCm needs to be provided (you can use -db / --defaultBoard if using calibration board from this repository or calib.io to calculate -ms automatically)") if options.squareSizeCm < 2.2: raise argparse.ArgumentTypeError("-s / --squareSizeCm needs to be greater than 2.2 cm") @@ -441,10 +444,7 @@ def is_markers_found(self, frame): marker_corners, _, _ = cv2.aruco.detectMarkers( frame, self.aruco_dictionary) print("Markers count ... {}".format(len(marker_corners))) - if not self.args.numMarkers: - num_all_markers = math.floor(self.args.squaresX * self.args.squaresY / 2) - else: - num_all_markers = self.args.numMarkers + num_all_markers = math.floor(self.args.squaresX * self.args.squaresY / 2) print(f'Total markers needed -> {(num_all_markers * self.args.minDetectedMarkersPercent)}') return not (len(marker_corners) < (num_all_markers * self.args.minDetectedMarkersPercent)) diff --git a/charuco_boards_user_calib/charuco_board_24.pdf b/charuco_boards_user_calib/charuco_board_24.pdf new file mode 100644 index 0000000000000000000000000000000000000000..b9c194a55c905738d4185d081896be552f8bb88b GIT binary patch literal 188132 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 Ld$@`PU3 z`N}#MsDN|w_7RAB`;OSLii>a&L+19CD4V~g zlxK=)A^3V-C6D&-wSL-oXyf|g4eK`+TOpObLjUL8#`WGC--+hTXNg$9rMPK>_r{HC zGt zl}rf#V~Qq~6V_B!m#9n%Dc7eH(*&_n723z!yT8h5LPzZXdV<;~3eBDSc{7Foe$bVD z1O2=Bg>YRt<&$&oXQ^D|Q!p$(bC}YsE2peg`fH+JsZ9LSz=+LhqIpYk%DR}4m@rLx z_8Lj=p82DW?!J3{BG+kCKKLPlDqw3y^*xmdsuHAB4NnW( z^kk6WXv;f6cmDZAZL*3tKl9VCzo0_9lCPjugy|9eebioTcmR8 z{!`~uw0bL(-mr`>QHxnN`UO?btUt5&UCwRUZk8ZBCj z=!!LKqd0FXQENrC=&iQVRqNj7(N>|nIz31kln|xb#nq{b5}FjH^777{zm5_)h?X?aCO%}0Z~@aW*9yW2%St|>vhCHJG63cS1PkXHDkYGju0Im)A*yA1i{ z`UmCLD@@g{Z``J9d%vkL?P@^i>TZN~W1jDZLJiAg6P#iKkcwbXV+V_J(wOnqjqU5R*L(qpsi({jxb_bhlUSEP)ao&AK}95FF_HnJyVW0vy< zCPpKz*0`3LQJXEQ{oH8Vnb!^{NMm&qq&GGhOr?kdL;<1zQGh5w6d(!^1&9Jf0ipm= zfG9u|APNu#hyp|bq5x5VC_oe-3J?W|0z?6#08xM_KolSf5Cw<=L;<1zQGh5w6d(!^ z1&9Jf0ipm=fG9u|APNu#hyp|bq5x5VC_oe-3J?W|0z?6#08xM_KolSf5Cw<=L;<1z zQGh5w6d(!^1&9Jf0ipm=fG9u|APNu#hyp|bq5x5VC_oe-3J?W|0z?6#08xM_KolSf z5Cw<=L;<1zQGh5w6d(!^1&9Jf0ipm=fG9u|APNu#hyp|bq5x5VC_oe-3J?W|0z?6# z08xM_KolSf5Cw<=L;<1zQGh5w6d(!^1&9Ja6a{v;Uo_k2OrM`S&z?P-hjJd9WwymC zR)q(mV&ZIZEva!@YI3qUV(QG?r|im*CO4bLX0U8Fk1aqbhy98@hQB;Mow1?jh*79F zcV^C<(b>5uJ~}bd8X0Acw?*5ctWlBCC@>*sy2rf9^Kzyym}Z}+44ILeJ9mC!M8wS5 z*-zN+eVMOu`;U8Ke6Fx%qeqT+Pl#?ulaTkFf6mp$_-`#dw}hkIr|Yv;6&7&qtf z9L2_2Wr{gsN={C$0`xpQZnW}Ha(s4dLbUzyXj{T#_LynY?9s8)#hnSD=4}vD=GvsGbltrGV}jdbtg0Fu8!#LXa6d1UD7pGuU^%AUG?gH ztE#K+9va`bQ%L79i#fy+Y{{IOYVO|M95gg(W^%Tr3u1Xg*0?YVd z#B~V`3$=uVhD7taWM$5lCuC;ipzLf*80P}1XU(-vwS;i34EL%zPpzo=z~qdXIkPO` z6(rd?S;Jh_@*inS%x=S@CM2O>NizWlm4#Z*X+VbO_4DG6(fbPMJ9iuM?v2SDT)hHDY$sv}8*N zmlKMMP07g~nw%AvX`P*!!QW;S${m!Hon{G9G!>-kjcZ1zR@H^gP5+SwNCTt+(g10I zH1J2&!0T??<+GPW~zn%!OgDa@!x8@yy(s=V^1Y$DrR7*z4gN1H9+WkE`vN& z2hsy$k<=a(Z;bRndLTQAUO?joWBW)C!Wxhsev=<%XY8M)ZWiuj?87hI>_`0mMc?kP zjMe|#&9WUj9}l_Dfm`RV80(DZS^U=ce5cq2H>>aDf14n`#{WCT*xti#HVY{lKM