Skip to content
This repository was archived by the owner on Dec 30, 2025. It is now read-only.
18 changes: 9 additions & 9 deletions docs/coordinates.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,21 @@ Orientation
.. autoclass:: zoloto.coords.Orientation
:members:

Coordinates
-----------
.. autoclass:: zoloto.coords.Coordinates
PixelCoordinates
----------------
.. autoclass:: zoloto.coords.PixelCoordinates
:members:
:no-inherited-members:

ThreeDCoordinates
-----------------
.. autoclass:: zoloto.coords.ThreeDCoordinates
CartesianCoordinates
--------------------
.. autoclass:: zoloto.coords.CartesianCoordinates
:members:
:no-inherited-members:

Spherical
---------
.. autoclass:: zoloto.coords.Spherical
SphericalCoordinates
--------------------
.. autoclass:: zoloto.coords.SphericalCoordinates
:members:
:no-inherited-members:

Expand Down
59 changes: 58 additions & 1 deletion tests/test_coords.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
"""Tests for coordinates classes."""
from __future__ import annotations

import math

import pytest
from hypothesis import given
from hypothesis.strategies import floats, tuples
from pyquaternion import Quaternion

from zoloto.coords import Orientation
from zoloto.coords import CartesianCoordinates, Orientation, SphericalCoordinates


@given(tuples(floats(), floats(), floats()))
Expand Down Expand Up @@ -62,3 +65,57 @@ def test_repr(euler_angles: tuple[float, float, float]) -> None:

for name, val in zip(names, ypr):
assert f"{name}={val}" in repr_str


@pytest.mark.parametrize(
"cartesian,expected",
[
pytest.param(
CartesianCoordinates(0, 0, 0),
SphericalCoordinates(0, 0, 0),
id="origin",
),
pytest.param(
CartesianCoordinates(0, 0, 1),
SphericalCoordinates(
theta=math.pi / 2,
phi=math.pi / 2,
distance=1,
),
id="in-front-of-you",
),
pytest.param(
CartesianCoordinates(0, 1, 0),
SphericalCoordinates(
theta=0,
phi=0,
distance=1,
),
id="above-you",
),
pytest.param(
CartesianCoordinates(1, 0, 0),
SphericalCoordinates(
theta=math.pi / 2,
phi=0,
distance=1,
),
id="to-one-side",
),
pytest.param(
CartesianCoordinates(1000, 1000, 0),
SphericalCoordinates(
theta=0.7853981633974484, # math.pi / 4, with floating point error
phi=0,
distance=1414,
),
id="to-one-side-and-up",
),
],
)
def test_spherical_from_cartesian(
cartesian: CartesianCoordinates,
expected: SphericalCoordinates,
) -> None:
spherical = SphericalCoordinates.from_cartesian(cartesian)
assert spherical == expected
9 changes: 5 additions & 4 deletions tests/test_marker.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import json
import math
from typing import Any
from unittest import TestCase
from unittest.mock import patch
Expand Down Expand Up @@ -71,10 +72,10 @@ def test_cartesian_coordinates(self) -> None:
self.assertAlmostEqual(int(z), 910, delta=100) # HACK: Sometimes it changes

def test_spherical_coordinates(self) -> None:
rot_x, rot_y, dist = self.marker.spherical
dist, rot_x, rot_y = self.marker.spherical
self.assertEqual(dist, self.marker.distance)
self.assertAlmostEqual(rot_x, 0, delta=0.1)
self.assertAlmostEqual(rot_y, 0, delta=0.1)
self.assertAlmostEqual(rot_x, math.pi / 2, delta=0.1)
self.assertAlmostEqual(rot_y, math.pi / 2, delta=0.1)

def test_as_dict(self) -> None:
marker_dict = self.marker.as_dict()
Expand Down Expand Up @@ -119,7 +120,7 @@ def test_marker_types(self) -> None:

self.assertIsType(self.marker.spherical.rot_x, float)
self.assertIsType(self.marker.spherical.rot_y, float)
self.assertIsType(self.marker.spherical.dist, int)
self.assertIsType(self.marker.spherical.distance, int)

self.assertIsType(self.marker.cartesian.x, float)
self.assertIsType(self.marker.cartesian.y, float)
Expand Down
2 changes: 1 addition & 1 deletion tests/test_module.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ def test_exposes_marker_type() -> None:

@pytest.mark.parametrize(
"coordinate_struct",
["Coordinates", "Orientation", "ThreeDCoordinates", "Spherical"],
["PixelCoordinates", "Orientation", "CartesianCoordinates", "SphericalCoordinates"],
)
def test_exposes_coordinates(coordinate_struct: str) -> None:
assert getattr(zoloto, coordinate_struct) == getattr(
Expand Down
13 changes: 9 additions & 4 deletions zoloto/__init__.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
from __future__ import annotations

from zoloto.coords import Coordinates, Orientation, Spherical, ThreeDCoordinates
from zoloto.coords import (
CartesianCoordinates,
Orientation,
PixelCoordinates,
SphericalCoordinates,
)
from zoloto.marker import Marker
from zoloto.marker_type import MarkerType

__version__ = "0.9.0"


__all__ = [
"Coordinates",
"CartesianCoordinates",
"Orientation",
"Spherical",
"ThreeDCoordinates",
"PixelCoordinates",
"SphericalCoordinates",
"Marker",
"MarkerType",
]
125 changes: 113 additions & 12 deletions zoloto/coords.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
from __future__ import annotations
Comment thread
PeterJCLaw marked this conversation as resolved.

import math
from typing import Iterator, NamedTuple, Tuple

from cached_property import cached_property
from cv2 import Rodrigues
from pyquaternion import Quaternion


class Coordinates(NamedTuple):
class PixelCoordinates(NamedTuple):
"""
Coordinates within an image made up from pixels.

This type allows float values to account for computed locations which are
not limited to exact pixel boundaries.

:param float x: X coordinate
:param float y: Y coordinate
"""
Expand All @@ -17,8 +23,24 @@ class Coordinates(NamedTuple):
y: float


class ThreeDCoordinates(NamedTuple):
class CartesianCoordinates(NamedTuple):
"""
Cartesian coordinates, rotated on their side.

The X axis is horizontal relative to the camera's perspective, i.e: left &
right within the frame of the image. Zero is at the centre of the image.
Increasing values indicate greater distance to the right.

The Y axis is vertical relative to the camera's perspective, i.e: up & down
within the frame of the image. Zero is at the centre of the image.
Increasing values indicate greater distance below the centre of the image.

The Z axis extends directly away from the camera. Zero is at the camera.
Increasing values indicate greater distance from the camera.

These match traditional cartesian coordinates when the camera is facing
upwards.

:param float x: X coordinate
:param float y: Y coordinate
:param float z: Z coordinate
Expand All @@ -29,16 +51,54 @@ class ThreeDCoordinates(NamedTuple):
z: float


class Spherical(NamedTuple):
class SphericalCoordinates(NamedTuple):
"""
:param float rot_x: Rotation around the X-axis, in radians
:param float rot_y: Rotation around the Y-axis, in radians
:param float dist: Distance
SphericalCoordinates coordinates, rotated onto their side.

This is comparable to the ISO convention for spherical coordinates, applied
to our rotated axes. Here θ is measured down from the y-axis (rather than
the usual z-axis) while φ is measured around the y-axis.
Comment thread
RealOrangeOne marked this conversation as resolved.

See https://en.wikipedia.org/wiki/Spherical_coordinate_system and
https://studentrobotics.org/docs/programming/sr/vision/#SphericalCoordinates.

:param float distance: Radial distance from the origin.
:param float theta: Polar angle, θ, in radians. This is the angle "down"
from the y-axis to the vector which points to the location. For points
with zero cartesian x-coordinate value, this can be viewed as the
rotation about the x-axis. Zero is on the positive y-axis.
:param float phi: Azimuth angle, φ, in radians. This is the angle from the
x-axis around the polar (y-axis) to the projection of the point on the
x-z plane. This can be viewed as rotation about the y-axis. Zero is at
the centre of the image.
"""

rot_x: float
rot_y: float
dist: int
distance: int
Comment thread
RealOrangeOne marked this conversation as resolved.
theta: float
phi: float

@property
def rot_x(self) -> float:
"""Approximate rotation around the x-axis, an alias for ``self.theta``."""
return self.theta

@property
def rot_y(self) -> float:
"""Rotation around the y-axis, an alias for ``self.phi``."""
return self.phi

@classmethod
def from_cartesian(cls, cartesian: CartesianCoordinates) -> SphericalCoordinates:
if not any(cartesian):
return SphericalCoordinates(0, 0, 0)

distance = math.sqrt(sum(x**2 for x in cartesian))
x, y, z = cartesian
return SphericalCoordinates(
distance=int(distance),
theta=math.acos(y / distance),
phi=math.atan2(z, x),
)


ThreeTuple = Tuple[float, float, float]
Expand All @@ -59,17 +119,58 @@ def __init__(self, e_x: float, e_y: float, e_z: float):

@property
def rot_x(self) -> float:
"""Get rotation angle around x axis in radians."""
"""
Get rotation angle around X axis in radians.

The X axis is horizontal relative to the camera's perspective, i.e: left
& right within the frame of the image.

Increasing values represent an increasing clockwise rotation of the
marker as seen from the camera's left.

Zero values for April Tags markers have the marker facing away from the
camera. The practical effect of this is that an April Tags marker facing
the camera square-on will have a value of ``pi`` (or equivalently
``-pi``) and the value will decrease as the marker diverges from
square-on.

For observed markers positive values therefore indicate a rotation of
the top of the marker away from the camera, such that marker could be
said to be leaning backwards, with the value decreasing as the marker
leans back further.
"""
return self.roll

@property
def rot_y(self) -> float:
"""Get rotation angle around y axis in radians."""
"""
Get rotation angle around Y axis in radians.

The Y axis is vertical relative to the camera's perspective, i.e: up &
down within the frame of the image.

Positive values indicate a rotation of an observed marker towards the
camera's right. This is a rotation of the marker counter-clockwise about
the Y axis as seen from above the marker.

Zero values for April Tags markers have the marker facing the camera
square-on.
"""
return self.pitch

@property
def rot_z(self) -> float:
"""Get rotation angle around z axis in radians."""
"""
Get rotation angle around Z axis in radians.

The Z axis extends directly away from the camera.

Positive values indicate a rotation counter-clockwise from the
perspective of the camera.

Zero values for April Tags markers have the marker reference point at
the top left.
"""
return self.yaw

@property

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

These don't really make sense with the current coordinate system.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Could you elaborate on what you mean? Both in terms of which version you're referring to as "current" and which of the properties you're referring to here.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Yaw, pitch and roll do not line up with their standard definition https://en.wikipedia.org/wiki/Aircraft_principal_axes?wprov=sfla1

For instance yaw is a rotation about the vertical axis which is not the case here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I would probably say that the correct fix here is to fix the incorrect convention, as we discussed in the kit team meeting last night.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Unfortunately changing the mappings of which axes are which has the potential to be severely breaking to competitors code.
Even the changes which are in this branch as it stands are technically breaking, though relatively unlikely to affect most cases (and have the advantage of making this logic match what's actually in the SR docs).

Expand Down
Loading