Skip to content

Conversation

@szknowak
Copy link

@szknowak szknowak commented Oct 23, 2025

ChArUco and Aruco board based calibration

extrinsic_calibrator_core:

Aruco parameters

To handle 3 different board types and avoid mishmash (as boards have different params) the structure of camera_topics_parameters_schema.yaml was followed.
detectors/camera.py was refactored to follow this standard.

Detectors

To handle 3 different board types and avoid mishmash all board-type functionalities were moved from extrinsic_calibrator_class.py to detectors/camera.py

In extrinsic_calibrator_class.py:

  • Node init was changed to handle diffrend boards
  • Pose variation calculation was added
  • Rest of the code is the same as oryginal (do not review :D)

In detectors/camera.py:

  • We have CameraBase class which includes all common functionalities between camera_detectors
  • 3 classes (CameraAruco, CameraArucoBoard, CameraChArUcoBoard) which inherits from CameraBase and
    overwrites image_callback where board or marker pose estimation is conducted.
  • CameraAruco.image_callback stays the same as forked code and second parts of each image_callback is also the same
    (do not review :D)

generate_board.py:

  • script to generate charuco and aruco boards
  • It's parameterized (rows, cols, marker size, seperation lenght, square_lenght, aruco dictonary)
  • save_svg function is 100% LLM (it works)
  • output saved as .svg or .png file

Doc with instructions:
https://docs.google.com/document/d/1UvQvqR0VwnnRna9qVXk13PwAKk63E7QZ-09zgk_v6oU/edit?usp=sharing

Feel free to left some comments in the doc if something is unclear

szknowak and others added 9 commits October 27, 2025 12:21
-  added new param (square_lenght)
- corrected unequal vertical and horizontal margin
- generated example of boards for A4, A3 and A2 format
- redefined some variables names to be more clear
…re elegant way

- Updated aruco_parameters.yaml  with ChArUco parameters
- Enhanced extrinsic_calibrator_class.py to handle different calibration methods dynamically.
- Introduced new classes for ArUco parameters to avoid mishmash
- Introduced new camera classes which preform calibration. Each class inherits from base camera class which keeps some shared stuff
-  each of the class has its own calibration procedure and then the process of validating the calibration quality is common for each method
@szknowak szknowak changed the title Grid-based calibration ChArUco and Aruco board based calibration Nov 5, 2025
@szknowak szknowak requested a review from pawelir November 5, 2025 13:12
Copy link

@pawelir pawelir left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@szknowak If we're not aiming to merge it back to original repo, IMO it's a right moment to add .pre-commit config and trigger it for aligned formatting ;)

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we're storing large files, please create .gitattribute file with corresponding definition to avoid lfs in git history

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added pre-commit and git lfs

</export>
</package>

</package>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Empty line missing 🙃

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

solved by pre commit

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's add parameters validation. https://github.com/PickNikRobotics/generate_parameter_library?tab=readme-ov-file#built-in-validators

Some examples:

  • board_mode would benefit from one_of<> validation
  • marker_length can't be assigned a negative value
  • ...

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

aruco_dict should be one_of<> as well

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done, thanks for that tip, super cool stuff

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm do we really need it? Can't we just rely on Python classes created automatically by params generator?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good point - I'll try to use them

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

Comment on lines 9 to 10
class BaseArucoParams(ABC):
def __init__(self, node:Node, params):
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we speak about implementation details

  1. Pydantic inheritance is go to
  2. Conceptually, it'd be better to not pass Node object reference here. If u need logging, param object should return only string message or json structure which you can properly log in client code (ExtrinsicCalibrator)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup, pydantic would be better.

I've tried to switch to it from ABC inheritance, but I see that it wold require a lot of code refactoring, so maybe I could add this when I would have some free time?


# iterate through each marker of the marker_transforms dictionary
for marker_id, transforms in self.marker_transforms.items():
if len(transforms) == 30:
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Magic number

self.node = node
self.camera_name = camera_name
self.camera_id = camera_id
self.image_topic = "/robotic_platform/" + image_topic
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please parametrize

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've deleted it - 'robotic_platform' prefix is needed only while I was working on data from simulation.
On bags from the lab its unneeded.

# Visualize board pose
cv2.drawFrameAxes(cv_image, self.camera_matrix, self.dist_coeffs, rvec, tvec, self.board.getMarkerLength() * 2)

if 0 not in self.marker_transforms and 0 not in self.reliable_marker_transforms:
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What 0 means?

Copy link
Author

@szknowak szknowak Nov 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Orginal calibrator was estimating poses of many aruco markers and keeping it in self.marker_transforms and self.reliable_marker_transforms dicts.

As in board based calibration only one pose is estimated (boards pose) then only first element of
self.marker_transforms and self.reliable_marker_transforms is populated.

So - 0 here is just id of detected object transform (board). And there always be one element in self.marker_transforms and self.reliable_marker_transforms as long as we use one board.

I'll change it from self.marker_transforms -> self.board_transforms (simple list instead of dict with marker ids).
So this strange logic with 0 would not be needed.

Copy link
Author

@szknowak szknowak Nov 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately It is not possible to change this logic with '0' to something more reasonable, so I've changed it to class const.

I've tried to apply the logic I've proposed above but it would require a lot of changes in ExtrinsicCalibrator methods as it operate on self.reliable_marker_transforms which must be a dict with int keys.


from extrinsic_calibrator_core.src.detectors.aruco_params import ArucoMarkerParams, ArucoBoardParams, ChArUcoBoardParams

class CameraBase(ABC):
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see architectural issues here

  • Single responsibility violation - this class handles too many things like camera subscription, marker detection, pose tracking (a sing of such a violation is always multiple constructor arguments, let's say >3 :))
  • Separation of concerns (as above)
  • Misleading naming when we consider domain - it's more a detector than camera
  • As inheritance is often unavoidable we should try to benefit from aggregation as well.

I'd suggest

  • I don't see any benefit from moving subscription out of the original node - let's move all Node instantiation thingies back there
  • Separate concerns by defining PoseTracker
  • Simplify implementation by using Detector ArucoDetector ... class with a single responsibility
  • The above can operate on ROS-agnostic formats (input already converted with cv2.bridge and return generic type)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keep in mind this is a "minimal change" in respect to a third party fork. Some of your suggestions might not be valid here. I would vote for not doing any drastic architectural changes here

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be worth to implement image_callback in the base class with the swappable parts defined in the child classes. That way we can compare thirdparty Camera class to our CameraBase class.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ain't checked implementation - vibe coding I assume ;)

However:

  1. Let's add docs
  2. We should story it under script dir or sth

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yup, save_svg fully vibe coded, generate_aruco_board in 90%, with some small corrections.

What I could improve is to automate saving file name to keep naming convention

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Docs added. Generator changed so it keeps file naming convention 🤓

@@ -0,0 +1,51 @@
aruco_params:

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should be an extrinsic_calibrator_params_definition.yaml file that defines parameters for extrinsic_calibrator node. So that you can lode it to the node in the launch file with substitutions

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good point

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

aruco_dict should be one_of<> as well

default_value: "DICT_6X6_1000"
marker_length:
type: double
default_value: 0.26 No newline at end of file

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing newline

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

solved by pre commit

return True



Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing newline

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

solved by pre commit

Comment on lines 2 to 3
numpy==1.26.4
opencv-contrib-python==4.12.0.88

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why the change?

Copy link
Author

@szknowak szknowak Nov 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've faced this issue:
https://stackoverflow.com/questions/76781469/attributeerror-module-cv2-aruco-has-no-attribute-interpolatecornerscharuco

Contrib version is needed to use Board Pose Estimation methods

Yesterday during first tests in the lab during fresh requirments installing it turned out that opencv-contrib collides with numpy<2. But running calibrator with numpy>2 causes some errors, so probably in requirments I'd left:

numpy==2.2.6
opencv-contrib-python==4.12.0.88

and in dockerfile:
pip install "numpy<2"


from extrinsic_calibrator_core.src.detectors.aruco_params import ArucoMarkerParams, ArucoBoardParams, ChArUcoBoardParams

class CameraBase(ABC):

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keep in mind this is a "minimal change" in respect to a third party fork. Some of your suggestions might not be valid here. I would vote for not doing any drastic architectural changes here

self.parameters = cv2.aruco.DetectorParameters()
self.detector = cv2.aruco.ArucoDetector(self.aruco_dict, self.parameters)

super().log_initialization()

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

super() unnecessary if there is no override

Comment on lines 227 to 230
if 0 not in self.marker_transforms and 0 not in self.reliable_marker_transforms:
self.marker_transforms[0] = deque(maxlen=30)

if 0 not in self.reliable_marker_transforms:

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Logic here is very confusing

Comment on lines 304 to 307
if 0 not in self.marker_transforms and 0 not in self.reliable_marker_transforms:
self.marker_transforms[0] = deque(maxlen=30)

if 0 not in self.reliable_marker_transforms:

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confusing logic


from extrinsic_calibrator_core.src.detectors.aruco_params import ArucoMarkerParams, ArucoBoardParams, ChArUcoBoardParams

class CameraBase(ABC):

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be worth to implement image_callback in the base class with the swappable parts defined in the child classes. That way we can compare thirdparty Camera class to our CameraBase class.

return position_range, rotation_range

def caluclate_marker_pose_variation(self, position_range : np.ndarray, rotation_range : float) -> float:
pose_variation = np.mean(position_range) + np.degrees(rotation_range)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't like the metric. It's a simple mean of ptp's instead of some root square sum. The rotation part is in degrees which is not comparable to meters which skews the whole metric.

I would recommend treating everything as vectors in a 6D space and combining it with vector norm. And reporting all three: position_error, rotation_error and combined_error all in meters.

ChatGPT and Gemini agree with me:
https://chatgpt.com/s/dr_690cbfe778b88191849f010d43b8363f

Image

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

very good point, I'll redefine metric calucations

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

def calculate_error(self, positions: np.ndarray) -> float:
        mean_position = np.mean(positions, axis=0)
        normalized_positions = positions - mean_position
        position_variation = np.linalg.norm(normalized_positions)
        return position_variation

I'll utilize it like this ^

  1. normalize by the mean
  2. vector norm

position error will be in meters
rotation in degrees

@szknowak
Copy link
Author

self.reliable_object_transforms[object_id] = transforms[-1]

prevoius solution (from forked repo) was using last collected transform as a reliable one (used for calibration).

While we have nice logic for checking if all transforms are precise maybe it would be better to use mean value of all transforms, not the last element?

@KrzysztofDubel @pawelir

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants