Skip to content

Commit 41e0a4d

Browse files
petercorkeclaude
andauthored
test+fix: direct tests for e2h/h2e, h2e rejects <2 rows, document contract (#240)
`e2h` and `h2e` had a single assertion each (N-vector input only). The matrix path and `h2e`'s per-column scaling were only exercised indirectly through the pose tests, and the shape contract for 2D input was undocumented. **Tests** (`tests/base/test_transformsNd.py`) - vector input as list, tuple, 1D array and column vector - matrix input for both functions - `h2e` scaling by each column's own last element - `h2e(e2h(x))` and `e2h(h2e(x))` round trips - `ValueError` for bad types - a characterization test pinning that a `(1,N)` array is a matrix of N 1-vectors, not a row vector. This documents current behavior rather than endorsing it, so any change to it is deliberate. **Behavior change (small)** - `h2e` now raises `ValueError` for input with fewer than two rows (a `(1,N)` array, a length-1 vector, or a scalar). Previously it returned an empty `(0,N)` array, which is never useful: a homogeneous point has at least two elements. Checked callers in MVTB (`Camera.py`, `ImageReshape.py`); both pass 3-row arrays. - `e2h` is unchanged. **Docstrings** (`spatialmath/base/transformsNd.py`) - Both functions state the contract up front: each *column* is a point, rows are never points, with explicit shapes and a note on how to convert a row vector (pass its transpose). - `:raises:` entries added; `e2h`'s `:seealso:` pointed at itself, now `h2e`. Rank-0 and rank-3 rejection is covered by #239 (`isvector`) and is deliberately not duplicated here; this PR is independent of it. Full suite: 355 passed, 3 skipped. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
1 parent b638e40 commit 41e0a4d

2 files changed

Lines changed: 88 additions & 12 deletions

File tree

‎spatialmath/base/transformsNd.py‎

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -771,12 +771,15 @@ def h2e(v: NDArray) -> NDArray:
771771
:param v: homogeneous vector or matrix
772772
:type v: array_like(n), ndarray(n,m)
773773
:return: Euclidean vector
774-
:rtype: ndarray(n-1), ndarray(n-1,m)
774+
:rtype: ndarray(n-1,1), ndarray(n-1,m)
775+
:raises ValueError: if ``v`` has fewer than two rows, or is not a vector or
776+
matrix
775777
776-
- If ``v`` is an N-vector, return an (N-1)-column vector where the elements have
777-
all been scaled by the last element of ``v``.
778-
- If ``v`` is a matrix (NxM), return a matrix (N-1xM), where each column has
779-
been scaled by its last element.
778+
Each *column* of ``v`` is a homogeneous point, and is scaled by its last element:
779+
780+
- If ``v`` is an N-vector, shape (N,) or (N,1), or a list or tuple, return an
781+
(N-1,1) column vector.
782+
- If ``v`` is a matrix (NxM), return a matrix (N-1xM).
780783
781784
.. runblock:: pycon
782785
@@ -788,16 +791,24 @@ def h2e(v: NDArray) -> NDArray:
788791
>>> h2e(h)
789792
790793
.. note:: The result is always a 2D array, a 1D input results in a column vector.
794+
.. note:: A 2D array is always a matrix of column vectors, rows are never
795+
points. To convert a row vector of shape (1,N) pass its transpose. A
796+
homogeneous point needs at least two elements, so an input with a single
797+
row, such as shape (1,N), raises a ``ValueError``.
791798
792-
:seealso: e2h
799+
:seealso: :func:`e2h`
793800
"""
794801
if isinstance(v, np.ndarray) and len(v.shape) == 2:
795802
# dealing with matrix
803+
if v.shape[0] < 2:
804+
raise ValueError("homogeneous input must have at least 2 rows")
796805
return v[:-1, :] / v[-1, :][np.newaxis, :]
797806

798807
elif isvector(v):
799808
# dealing with shape (N,) array
800809
v = getvector(v, out="col")
810+
if v.shape[0] < 2:
811+
raise ValueError("homogeneous input must have at least 2 elements")
801812
return v[0:-1] / v[-1]
802813

803814
else:
@@ -811,12 +822,15 @@ def e2h(v: NDArray) -> NDArray:
811822
:param v: Euclidean vector or matrix
812823
:type v: array_like(n), ndarray(n,m)
813824
:return: homogeneous vector
814-
:rtype: ndarray(n+1,m)
825+
:rtype: ndarray(n+1,1), ndarray(n+1,m)
826+
:raises ValueError: if ``v`` is not a vector or matrix
827+
828+
Each *column* of ``v`` is a Euclidean point, and has a 1 appended:
815829
816-
- If ``v`` is an N-vector, return an (N+1)-column vector where a value of 1 has
817-
been appended as the last element.
818-
- If ``v`` is a matrix (NxM), return a matrix (N+1xM), where each column has
819-
been appended with a value of 1, ie. a row of ones has been appended to the matrix.
830+
- If ``v`` is an N-vector, shape (N,) or (N,1), or a list or tuple, return an
831+
(N+1,1) column vector.
832+
- If ``v`` is a matrix (NxM), return a matrix (N+1xM), ie. a row of ones has
833+
been appended to the matrix.
820834
821835
.. runblock:: pycon
822836
@@ -827,8 +841,12 @@ def e2h(v: NDArray) -> NDArray:
827841
>>> e2h(e)
828842
829843
.. note:: The result is always a 2D array, a 1D input results in a column vector.
844+
.. note:: A 2D array is always a matrix of column vectors, rows are never
845+
points. A row vector of shape (1,N) is therefore N one-dimensional
846+
points and the result has shape (2,N). To convert a row vector to a
847+
single point pass its transpose.
830848
831-
:seealso: e2h
849+
:seealso: :func:`h2e`
832850
"""
833851
if isinstance(v, np.ndarray) and len(v.shape) == 2:
834852
# dealing with matrix

‎tests/base/test_transformsNd.py‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,64 @@ def test_homog(self):
261261

262262
nt.assert_almost_equal(h2e([2, 4, 6, 2]), np.c_[1, 2, 3].T)
263263

264+
def test_e2h_vector(self):
265+
# list, tuple, 1D array, column vector all give an (N+1,1) column
266+
expected = np.c_[1, 2, 3, 1].T
267+
for v in ([1, 2, 3], (1, 2, 3), np.r_[1, 2, 3], np.c_[1, 2, 3].T):
268+
with self.subTest(type=type(v).__name__):
269+
out = e2h(v)
270+
self.assertEqual(out.shape, (4, 1))
271+
nt.assert_almost_equal(out, expected)
272+
273+
def test_e2h_matrix(self):
274+
# each column gains a trailing 1
275+
e = np.c_[[1, 2], [3, 4], [5, 6]]
276+
nt.assert_almost_equal(e2h(e), np.array([[1, 3, 5], [2, 4, 6], [1, 1, 1]]))
277+
278+
def test_h2e_vector(self):
279+
# the result is scaled by the last element
280+
expected = np.c_[1, 2, 3].T
281+
for v in ([2, 4, 6, 2], (2, 4, 6, 2), np.r_[2, 4, 6, 2], np.c_[2, 4, 6, 2].T):
282+
with self.subTest(type=type(v).__name__):
283+
out = h2e(v)
284+
self.assertEqual(out.shape, (3, 1))
285+
nt.assert_almost_equal(out, expected)
286+
287+
def test_h2e_matrix(self):
288+
# each column is scaled by its own last element
289+
h = np.c_[[1, 2, 1], [3, 4, 2], [5, 6, 1]]
290+
nt.assert_almost_equal(h2e(h), np.array([[1, 1.5, 5], [2, 2, 6]]))
291+
292+
def test_homog_roundtrip(self):
293+
v = np.c_[[1, 2, 3], [-4, 5, 0.5]]
294+
nt.assert_almost_equal(h2e(e2h(v)), v)
295+
296+
h = np.c_[[2, 4, 6, 2], [3, 6, 9, 3]]
297+
nt.assert_almost_equal(e2h(h2e(h)), np.c_[[1, 2, 3, 1], [1, 2, 3, 1]])
298+
299+
def test_homog_row_vector_is_matrix(self):
300+
# Documents (rather than endorses) current behavior: a 2D array is
301+
# always a matrix of column vectors, so a (1,N) row is N 1-vectors.
302+
# Changing this would silently break callers passing 1xN matrices.
303+
self.assertEqual(e2h(np.ones((1, 3))).shape, (2, 3))
304+
nt.assert_almost_equal(e2h(np.c_[[1, 2, 3]].T), np.array([[1, 2, 3], [1, 1, 1]]))
305+
nt.assert_almost_equal(h2e(np.array([[1.0, 2, 4], [1, 2, 2]])), np.array([[1, 1, 2]]))
306+
307+
def test_h2e_too_few_rows(self):
308+
# a homogeneous point needs at least 2 elements, h2e of 1 row would be
309+
# an empty (0,N) array
310+
for bad in (np.ones((1, 3)), np.ones((1, 1)), [5], 5, np.ones((1,))):
311+
with self.subTest(value=repr(bad)):
312+
with self.assertRaises(ValueError):
313+
h2e(bad)
314+
315+
def test_homog_bad_type(self):
316+
for convert in (e2h, h2e):
317+
for bad in ("hello", None, np.empty((0,))):
318+
with self.subTest(convert=convert.__name__, value=repr(bad)):
319+
with self.assertRaises(ValueError):
320+
convert(bad)
321+
264322
def test_homtrans(self):
265323
# 3D
266324
T = trotx(pi / 2, t=[1, 2, 3])

0 commit comments

Comments
 (0)