Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion justfile
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,9 @@ typing:

# Perform all checks
[group('qa')]
check-all: lint cov typing
check-all: lint cov typing

# Remove all __pycache__ folders
[group('maintenance')]
clean-pycache:
find . -type d -name "__pycache__" -prune -exec rm -rf {} +
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ dependencies = [

[project.scripts]
found-attitude = "found_CLI_tools.attitude.main:main"
found-edge-point-gen = "found_CLI_tools.edge_point_gen.main:main"

[dependency-groups]
dev = ["pytest", "coverage"]
Expand Down
Empty file.
65 changes: 65 additions & 0 deletions src/found_CLI_tools/edge_point_gen/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"""Command-line entry point for the edge-point generator."""

from __future__ import annotations

import argparse
from typing import Sequence

from .projection import FilmEdgePoint, generate_edge_point


def _tuple(values: Sequence[float]) -> tuple[float, ...]:
return tuple(float(component) for component in values)


def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=(
"Project a world-space coordinate into the film plane using a camera "
"orientation expressed as a quaternion."
)
)
parser.add_argument(
"--point",
nargs=3,
metavar=("X", "Y", "Z"),
type=float,
required=True,
help="World/cartesian coordinate of the target point.",
)
parser.add_argument(
"--rotation",
nargs=4,
metavar=("QX", "QY", "QZ", "QW"),
type=float,
required=True,
help="Camera orientation quaternion (x, y, z, w).",
)
parser.add_argument(
"--focal-length",
type=float,
default=1.0,
help="Film-plane distance from the camera origin (default: 1).",
)
return parser.parse_args(argv)


def format_edge_point(edge_point: FilmEdgePoint) -> str:
"""Render the FilmEdgePoint with full precision."""

return f"Film edge point -> x: {edge_point.x:.15g}, y: {edge_point.y:.15g}"


def main(argv: Sequence[str] | None = None) -> int:
args = parse_args(argv)
edge_point = generate_edge_point(
point=_tuple(args.point),
quaternion=_tuple(args.rotation),
focal_length=float(args.focal_length),
)
print(format_edge_point(edge_point))
return 0


if __name__ == "__main__": # pragma: no cover
raise SystemExit(main())
84 changes: 84 additions & 0 deletions src/found_CLI_tools/edge_point_gen/projection.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
"""Core math utilities for the edge-point generator.

The implementation follows the perspective projection model described in
common camera-projection references: a world-space point is rotated into the
camera frame and intersected with the film plane at ``z = focal_length``.
"""

from __future__ import annotations

from dataclasses import dataclass
from typing import Sequence, Tuple

from scipy.spatial.transform import Rotation

Vector3 = Tuple[float, float, float]
Quaternion = Tuple[float, float, float, float]

_EPSILON = 1e-12


@dataclass(frozen=True)
class FilmEdgePoint:
"""Represents a precise (non-integer) coordinate on the film plane."""

x: float
y: float


def _coerce_vector(
values: Sequence[float], expected_length: int, name: str
) -> Tuple[float, ...]:
"""Validate and coerce a numeric sequence into a tuple of floats."""

try:
vector = tuple(float(component) for component in values)
except TypeError as exc:
raise TypeError(f"{name} must be an iterable of numbers") from exc

if len(vector) != expected_length:
raise ValueError(f"{name} must contain {expected_length} values")

return vector


def generate_edge_point(
point: Sequence[float],
quaternion: Sequence[float],
focal_length: float = 1.0,
) -> FilmEdgePoint:
"""Project a world-space point into film-plane coordinates.

Args:
point: The (x, y, z) coordinate of the point in world/cartesian space.
quaternion: Camera orientation as (x, y, z, w) quaternion rotating the
camera frame into the world frame.
focal_length: Distance between camera origin and film plane. Units are
preserved in the output film coordinates.

Returns:
FilmEdgePoint: The projected film-plane coordinate (x_f, y_f).

Raises:
ValueError: If the inputs are malformed, the focal length is invalid, or
the point lies behind the camera/film plane.
"""

if focal_length <= 0:
raise ValueError("focal_length must be positive")

world_point = _coerce_vector(point, 3, "point")
quat = _coerce_vector(quaternion, 4, "quaternion")

rotation = Rotation.from_quat(quat)
camera_point = rotation.inv().apply(world_point)

z_cam = float(camera_point[2])
if z_cam <= _EPSILON:
raise ValueError("Point projects behind the camera or onto the film plane")

scale = focal_length / z_cam
x_film = float(camera_point[0]) * scale
y_film = float(camera_point[1]) * scale

return FilmEdgePoint(x=x_film, y=y_film)
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ def to_tuple(self):
return self.local_attitude, self.calibration_attitude, self.num_attitude_pairs


class IntegrationTest(unittest.TestCase):
class AttitudeIntegrationTest(unittest.TestCase):
def assertAttitudesAlmostEqual(self, att1: Attitude, att2: Attitude):
self.assertAlmostEqual(att1.ra, att2.ra, 2)
self.assertAlmostEqual(att1.de, att2.de, 2)
Expand Down
49 changes: 49 additions & 0 deletions tests/found_CLI_tools/edge_point_gen/test_projection.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import unittest

from scipy.spatial.transform import Rotation

from found_CLI_tools.edge_point_gen.projection import FilmEdgePoint, generate_edge_point


class ProjectionTest(unittest.TestCase):
def test_identity_projection(self):
result = generate_edge_point((0.0, 0.0, 10.0), (0.0, 0.0, 0.0, 1.0), 35.0)

self.assertIsInstance(result, FilmEdgePoint)
self.assertAlmostEqual(0.0, result.x)
self.assertAlmostEqual(0.0, result.y)

def test_projection_scales_with_depth(self):
result = generate_edge_point((2.0, -1.0, 20.0), (0.0, 0.0, 0.0, 1.0), 50.0)

self.assertAlmostEqual(5.0, result.x)
self.assertAlmostEqual(-2.5, result.y)

def test_projection_respects_rotation(self):
yaw_90 = Rotation.from_euler("y", 90, degrees=True).as_quat()
result = generate_edge_point((10.0, 0.0, 0.0), yaw_90, 20.0)

self.assertAlmostEqual(0.0, result.x, places=7)
self.assertAlmostEqual(0.0, result.y, places=7)

def test_point_behind_camera_rejected(self):
with self.assertRaises(ValueError):
generate_edge_point((0.0, 0.0, -5.0), (0.0, 0.0, 0.0, 1.0), 35.0)

def test_invalid_focal_length_rejected(self):
with self.assertRaises(ValueError):
generate_edge_point((0.0, 0.0, 5.0), (0.0, 0.0, 0.0, 1.0), 0.0)

def test_invalid_vector_length_rejected(self):
with self.assertRaises(ValueError):
generate_edge_point((0.0, 1.0), (0.0, 0.0, 0.0, 1.0), 10.0)

with self.assertRaises(ValueError):
generate_edge_point((0.0, 1.0, 5.0), (0.0, 0.0, 1.0), 10.0)

def test_invalid_vector_type_rejected(self):
with self.assertRaises(TypeError):
generate_edge_point(0.5, (0.0, 0.0, 0.0, 1.0), 10.0) # type: ignore[arg-type]

with self.assertRaises(TypeError):
generate_edge_point((0.0, 1.0, 5.0), None, 10.0) # type: ignore[arg-type]
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import io
import unittest
from contextlib import redirect_stdout

from found_CLI_tools.edge_point_gen import main as edge_cli


class ProjectionIntegrationTest(unittest.TestCase):
def test_parse_args_handles_basic_values(self):
args = edge_cli.parse_args(
[
"--point",
"0",
"1",
"2",
"--rotation",
"0",
"0",
"0",
"1",
"--focal-length",
"10",
]
)

self.assertEqual([0.0, 1.0, 2.0], args.point)
self.assertEqual([0.0, 0.0, 0.0, 1.0], args.rotation)
self.assertEqual(10.0, args.focal_length)

def test_main_outputs_precise_edge_point(self):
argv = [
"--point",
"0",
"0",
"5",
"--rotation",
"0",
"0",
"0",
"1",
"--focal-length",
"25",
]
buffer = io.StringIO()

with redirect_stdout(buffer):
exit_code = edge_cli.main(argv)

self.assertEqual(0, exit_code)
output = buffer.getvalue().strip()
self.assertIn("Film edge point", output)
self.assertIn("x: 0", output)
self.assertIn("y: 0", output)
13 changes: 13 additions & 0 deletions tests/smoke_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,15 @@ def main():
print(f"Failed to import attitude module: {e}")
sys.exit(1)

# Test 2b: Import the edge point generator
try:
from found_CLI_tools import edge_point_gen # noqa: F401

print("Edge point generator imported successfully")
except ImportError as e:
print(f"Failed to import edge point generator: {e}")
sys.exit(1)

# Test 3: Check that the main function exists
try:
from found_CLI_tools.attitude.main import main as attitude_main
Expand All @@ -41,6 +50,10 @@ def main():
# Test 4: Import key classes
try:
from found_CLI_tools.attitude.transform import Attitude, DCM # noqa: F401
from found_CLI_tools.edge_point_gen.projection import ( # noqa: F401
FilmEdgePoint,
generate_edge_point,
)

print("Core classes imported successfully")
except ImportError as e:
Expand Down