diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..5e90e3e --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,26 @@ +name: Simulator tests + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + python-version: ['3.11', '3.13'] + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + with: + python-version: ${{ matrix.python-version }} + cache: pip + cache-dependency-path: requirements-test.txt + - run: python -m pip install -r requirements-test.txt + - run: python -m unittest discover -s tests -v diff --git a/.gitignore b/.gitignore index c591763..485b19c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ __pycache__/ +.venv/ +.venv-tests/ +.ruff_cache/ models/ **/*.ignore* *.pt diff --git a/perception/main.py b/perception/main.py index c078d27..496c5d8 100644 --- a/perception/main.py +++ b/perception/main.py @@ -85,7 +85,10 @@ def main(): if args.sim: source = SimSource(args.sim, (W, H), FPS, args.sim_depth_m, args.sim_depth, args.loop) - print(f"[sim] {args.sim} | synthetic depth, so Z and size checks are not real") + if args.sim_depth: + print(f"[sim] {args.sim} | supplied depth map; camera intrinsics are approximate") + else: + print(f"[sim] {args.sim} | synthetic depth, so Z and size checks are not real") else: source = RealSenseSource() diff --git a/perception/sim.py b/perception/sim.py index e9b3901..8d7c8f9 100644 --- a/perception/sim.py +++ b/perception/sim.py @@ -36,7 +36,7 @@ def deproject(intr, pixel, z): def _letterbox(img, w, h, interp=cv2.INTER_AREA): """Resize keeping aspect ratio and pad, so object sizes are not distorted.""" s = min(w / img.shape[1], h / img.shape[0]) - nw, nh = int(img.shape[1] * s), int(img.shape[0] * s) + nw, nh = max(1, int(img.shape[1] * s)), max(1, int(img.shape[0] * s)) out = np.zeros((h, w) + img.shape[2:], img.dtype) x0, y0 = (w - nw) // 2, (h - nh) // 2 out[y0:y0 + nh, x0:x0 + nw] = cv2.resize(img, (nw, nh), interpolation=interp) @@ -46,8 +46,19 @@ def _letterbox(img, w, h, interp=cv2.INTER_AREA): class SimSource: def __init__(self, path, size=(640, 480), fps=30, depth_m=1.5, depth_path=None, loop=False): - self.w, self.h = size + try: + self.w, self.h = size + except (TypeError, ValueError) as exc: + raise ValueError("Sim size must contain positive integer width and height") from exc + if any(isinstance(value, (bool, np.bool_)) + or not isinstance(value, (int, np.integer)) or value <= 0 + for value in (self.w, self.h)): + raise ValueError("Sim size must contain positive integer width and height") + if not np.isfinite(fps) or fps <= 0: + raise ValueError("Sim FPS must be finite and greater than zero") self.period = 1.0 / fps + if not np.isfinite(self.period): + raise ValueError("Sim FPS must give a finite frame interval") self.depth_scale = 0.001 # depth image is in millimetres self.loop = loop self._last = 0.0 @@ -64,34 +75,45 @@ def __init__(self, path, size=(640, 480), fps=30, depth_m=1.5, self._images = [] self._idx = 0 if p.is_dir(): - self._images = sorted(f for f in p.iterdir() if f.suffix.lower() in IMG_EXT) + self._images = sorted(f for f in p.iterdir() + if f.is_file() and f.suffix.lower() in IMG_EXT) if not self._images: raise ValueError(f"No images in {p}") elif p.suffix.lower() in IMG_EXT: self._images = [p] - elif p.suffix.lower() in VID_EXT: - self._cap = cv2.VideoCapture(str(p)) - if not self._cap.isOpened(): - raise ValueError(f"Could not open video {p}") - else: + elif p.suffix.lower() not in VID_EXT: raise ValueError(f"Unsupported media type: {p.suffix}") if depth_path: + if Path(depth_path).suffix.lower() != ".png": + raise ValueError("--sim-depth must be a single-channel 16-bit PNG in millimetres") d = cv2.imread(str(depth_path), cv2.IMREAD_UNCHANGED) - if d is None or d.dtype != np.uint16: - raise ValueError("--sim-depth must be a 16-bit PNG in millimetres") - self._depth = _letterbox(d, self.w, self.h, cv2.INTER_NEAREST) + if d is None or d.dtype != np.uint16 or d.ndim != 2: + raise ValueError("--sim-depth must be a single-channel 16-bit PNG in millimetres") + self._depth = d else: - self._depth = np.full((self.h, self.w), int(depth_m * 1000), np.uint16) + if not np.isfinite(depth_m) or not (0.001 <= depth_m <= 65.535): + raise ValueError("--sim-depth-m must be finite and between 0.001 and 65.535 metres") + self._depth_mm = round(depth_m * 1000) + self._depth = None + + # Validate depth before opening a video, so constructor errors cannot + # leave an acquired capture behind. + if not self._images: + cap = cv2.VideoCapture(str(p)) + if not cap.isOpened(): + cap.release() + raise ValueError(f"Could not open video {p}") + self._cap = cap - self.static = self._cap is None and len(self._images) == 1 + self.static = p.is_file() and self._cap is None def read(self): """Returns (color_bgr, depth_u16, intrinsics) or None at end of media.""" - wait = self.period - (time.time() - self._last) + wait = self.period - (time.monotonic() - self._last) if wait > 0: time.sleep(wait) - self._last = time.time() + self._last = time.monotonic() if self._cap is not None: ok, frame = self._cap.read() @@ -105,13 +127,22 @@ def read(self): if not self.loop: return None self._idx = 0 - frame = cv2.imread(str(self._images[self._idx])) + image_path = self._images[self._idx] + frame = cv2.imread(str(image_path)) if not self.static: self._idx += 1 if frame is None: - return None + raise ValueError(f"Could not read sim image {image_path}") + + if self._depth is None: + depth = np.full(frame.shape[:2], self._depth_mm, np.uint16) + else: + if self._depth.shape != frame.shape[:2]: + raise ValueError("--sim-depth dimensions must match each colour frame before resizing") + depth = self._depth - return _letterbox(frame, self.w, self.h), self._depth.copy(), self.intr + return (_letterbox(frame, self.w, self.h), + _letterbox(depth, self.w, self.h, cv2.INTER_NEAREST), self.intr) def stop(self): if self._cap is not None: diff --git a/requirements-test.txt b/requirements-test.txt new file mode 100644 index 0000000..959d583 --- /dev/null +++ b/requirements-test.txt @@ -0,0 +1,2 @@ +numpy +opencv-python-headless diff --git a/tests/test_sim.py b/tests/test_sim.py new file mode 100644 index 0000000..f388640 --- /dev/null +++ b/tests/test_sim.py @@ -0,0 +1,383 @@ +r"""Regression tests: repeatable checks that catch unintended changes early. + +Automated regression tests in CI are standard engineering practice. These checks +give future PRs changing the simulator a guardrail, including AI-assisted or +"vibe coding" edits: an unexpected failure can be noticed before merging. They +help reduce repetitive manual checking during PR review; they do not replace +review or guarantee that all core code is correct. If behaviour changes on +purpose, explain the change and update the relevant expectations, rather than +removing a failing check just to get a green result. + +There are 26 SimSource tests and one deproject test. Image checks write real, +tiny PNG files and compare output pixels with known arrays. Video checks replace +VideoCapture with scripted frames or failures and record seek/release calls. +The timing check uses a simulated clock; other checks skip frame delays. +Temporary files and sources are cleaned up after each test. + +Scope: these tests cover simulator inputs, colour/depth alignment, playback, +cleanup, pacing arithmetic and one pixel-to-camera formula example. They do not +run YOLO, main.py's detection filters/tracking/UI, real video decoding, camera or +robot hardware, or real calibration. Passing them does not establish detection +accuracy, robot readiness or sustained real-time performance. + +Run from the repository root in a separate test environment: + python -m venv .venv-tests + +Windows PowerShell: + .\.venv-tests\Scripts\python.exe -m pip install -r requirements-test.txt + .\.venv-tests\Scripts\python.exe -m unittest discover -s tests -v + +Linux/macOS: + .venv-tests/bin/python -m pip install -r requirements-test.txt + .venv-tests/bin/python -m unittest discover -s tests -v + +Standard testing/CI practice: + https://docs.python.org/3/library/unittest.html + https://docs.github.com/en/actions/get-started/continuous-integration +""" + +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +import cv2 +import numpy as np + +from perception import sim + + +class SimSourceTests(unittest.TestCase): + """26 checks for failures that could break simulated perception inputs. + + Seven check alignment and independent depth arrays; eight check numeric/depth + validation; six check image/folder playback; four check video lifecycle; + one checks pacing. Each method states the failure it is intended to catch. + """ + + def setUp(self): + """Give each test a temporary folder and a known 4-by-8 colour image.""" + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + self.image = self.write_image("frame.png", np.full((4, 8, 3), 90, np.uint8)) + + def write_image(self, name, array): + """Encode a real image fixture and check that OpenCV saved it.""" + path = self.root / name + path.parent.mkdir(parents=True, exist_ok=True) + self.assertTrue(cv2.imwrite(str(path), array), f"Could not write fixture {path}") + return path + + def source(self, path=None, **options): + """Create an 8-by-8 source without pacing delays; release it after the test.""" + options.setdefault("size", (8, 8)) + source = sim.SimSource(self.image if path is None else path, **options) + source.period = 0 # These checks exercise media behavior without frame delays. + self.addCleanup(source.stop) + return source + + def video_path(self): + """Create a placeholder path; video decoding is supplied by a mock.""" + path = self.root / "video.mp4" + path.touch() + return path + + def test_synthetic_depth_marks_only_the_letterboxed_content(self): + """Catch false depth in added borders or wrong frame dimensions/depth values.""" + color, depth, intrinsics = self.source(depth_m=1.234).read() + expected_color = np.zeros((8, 8, 3), np.uint8) + expected_color[2:6] = 90 + expected_depth = np.zeros((8, 8), np.uint16) + expected_depth[2:6] = 1234 + np.testing.assert_array_equal(color, expected_color) + np.testing.assert_array_equal(depth, expected_depth) + self.assertEqual(depth.dtype, np.uint16) + self.assertEqual((intrinsics.width, intrinsics.height), (8, 8)) + + def test_registered_depth_uses_nearest_neighbors_and_shared_padding(self): + """Catch colour/depth misalignment or invented depths from blending measurements.""" + native_color = np.array( + [[[10, 20, 30], [40, 50, 60], [70, 80, 90], [100, 110, 120]], + [[130, 140, 150], [160, 170, 180], [190, 200, 210], [220, 230, 240]]], + dtype=np.uint8, + ) + native_depth = np.array([[0, 1000, 2000, 3000], [4000, 5000, 6000, 7000]], + dtype=np.uint16) + image = self.write_image("registered.png", native_color) + depth_path = self.write_image("depth.PNG", native_depth) + color, depth, _ = self.source(image, depth_path=depth_path).read() + expected_color = np.zeros((8, 8, 3), np.uint8) + expected_depth = np.zeros((8, 8), np.uint16) + expected_color[2:6] = native_color.repeat(2, axis=0).repeat(2, axis=1) + expected_depth[2:6] = native_depth.repeat(2, axis=0).repeat(2, axis=1) + np.testing.assert_array_equal(color, expected_color) + np.testing.assert_array_equal(depth, expected_depth) + + def test_extreme_aspect_ratio_keeps_at_least_one_content_pixel(self): + """Catch resize failures that shrink a very thin image to zero pixels.""" + image = self.write_image("thin.png", np.full((1, 1000, 3), 80, np.uint8)) + color, depth, _ = self.source(image).read() + content = np.any(color != 0, axis=2) + self.assertEqual(color.shape, (8, 8, 3)) + self.assertEqual(np.count_nonzero(content), 8) + np.testing.assert_array_equal(depth[content], np.full(8, 1500, np.uint16)) + self.assertFalse(np.any(depth[~content])) + + def test_depth_native_dimensions_must_match_the_color_frame(self): + """Catch pairing a depth map with a differently sized colour image.""" + depth_path = self.write_image("depth.png", np.full((8, 4), 1000, np.uint16)) + source = self.source(depth_path=depth_path) + with self.assertRaises(ValueError): + source.read() + + def test_depth_dimensions_are_checked_on_later_folder_frames(self): + """Catch dimension checks that miss mismatched later folder frames.""" + folder = self.root / "frames" + self.write_image("frames/01.png", np.full((2, 4, 3), 30, np.uint8)) + self.write_image("frames/02.png", np.full((4, 4, 3), 60, np.uint8)) + depth_path = self.write_image("depth.png", np.full((2, 4), 1000, np.uint16)) + source = self.source(folder, depth_path=depth_path) + self.assertIsNotNone(source.read()) + with self.assertRaises(ValueError): + source.read() + + def test_depth_dimensions_are_checked_on_later_video_frames(self): + """Catch dimension checks that miss mismatched later video frames.""" + depth_path = self.write_image("depth.png", np.full((2, 4), 1000, np.uint16)) + capture = mock.Mock() + capture.isOpened.return_value = True + capture.read.side_effect = [ + (True, np.full((2, 4, 3), 30, np.uint8)), + (True, np.full((4, 4, 3), 60, np.uint8)), + ] + with mock.patch.object(sim.cv2, "VideoCapture", return_value=capture): + source = self.source(self.video_path(), depth_path=depth_path) + self.assertIsNotNone(source.read()) + with self.assertRaises(ValueError): + source.read() + + def test_invalid_sizes_are_rejected(self): + """Catch invalid frame dimensions reaching image allocation or resizing.""" + for size in ((0, 8), (8, 0), (-1, 8), (8, -1), (8.0, 8), (8, 8.0), + (True, 8), (8, False), (8,), (8, 8, 8), None): + with self.subTest(size=size), self.assertRaises(ValueError): + sim.SimSource(self.image, size=size) + + def test_invalid_frame_rates_are_rejected(self): + """Catch frame rates that would divide by zero or give invalid timing intervals.""" + for fps in (0, -1, 5e-324, float("nan"), float("inf"), -float("inf")): + with self.subTest(fps=fps), self.assertRaises(ValueError): + sim.SimSource(self.image, fps=fps) + + def test_invalid_synthetic_depth_is_rejected(self): + """Catch synthetic distances outside 1-65535 mm or with nonfinite values.""" + for depth_m in (0, -1, 0.0009, 65.535001, float("nan"), float("inf"), + -float("inf")): + with self.subTest(depth_m=depth_m), self.assertRaises(ValueError): + sim.SimSource(self.image, depth_m=depth_m) + + def test_synthetic_depth_accepts_uint16_millimeter_limits(self): + """Catch incorrect rejection or conversion at the supported depth limits.""" + for depth_m, millimeters in ((0.001, 1), (65.535, 65535)): + with self.subTest(depth_m=depth_m): + _, depth, _ = self.source(depth_m=depth_m).read() + self.assertEqual(int(depth[3, 3]), millimeters) + + def test_synthetic_depth_preserves_whole_millimeter_values(self): + """Catch whole millimetres being lost when floating-point values are truncated.""" + for depth_m, millimeters in ((1.001, 1001), (1.003, 1003)): + with self.subTest(depth_m=depth_m): + _, depth, _ = self.source(depth_m=depth_m).read() + self.assertEqual(int(depth[3, 3]), millimeters) + + def test_depth_map_accepts_missing_measurements_and_ignores_synthetic_depth(self): + """Catch zero depths being filled in or an unused synthetic option rejecting a map.""" + depth_path = self.write_image("depth.png", np.zeros((4, 8), np.uint16)) + _, depth, _ = self.source(depth_path=depth_path, depth_m=float("nan")).read() + self.assertEqual(depth.shape, (8, 8)) + self.assertFalse(np.any(depth)) + + def test_invalid_depth_maps_are_rejected_before_opening_video(self): + """Catch invalid depth files being accepted or capture opening before rejection.""" + eight_bit = self.write_image("eight-bit.png", np.ones((4, 8), np.uint8)) + channels = self.write_image("channels.png", np.ones((4, 8, 3), np.uint16)) + valid_png = self.write_image("depth.png", np.ones((4, 8), np.uint16)) + wrong_extension = self.root / "depth.tiff" + wrong_extension.write_bytes(valid_png.read_bytes()) + unreadable = self.root / "unreadable.png" + unreadable.write_text("This is not an image.", encoding="utf-8") + video = self.video_path() + for depth_path in (eight_bit, channels, wrong_extension, unreadable, + self.root / "missing.png"): + with (self.subTest(depth_path=depth_path.name), + mock.patch.object(sim.cv2, "VideoCapture") as open_video): + with self.assertRaises(ValueError): + sim.SimSource(video, depth_path=depth_path) + open_video.assert_not_called() + + def test_invalid_numeric_options_are_rejected_before_opening_video(self): + """Catch video capture opening before invalid size, FPS or depth options fail.""" + video = self.video_path() + for options in ({"size": (0, 8)}, {"fps": 0}, {"depth_m": float("nan")}): + with (self.subTest(options=options), + mock.patch.object(sim.cv2, "VideoCapture") as open_video): + with self.assertRaises(ValueError): + sim.SimSource(video, **options) + open_video.assert_not_called() + + def test_single_image_repeats_without_loop(self): + """Catch a directly supplied image stopping after its first read.""" + source = self.source() + for _ in range(3): + color, _, _ = source.read() + self.assertEqual(int(color[3, 3, 0]), 90) + + def test_folders_ignore_image_named_directories_and_preserve_order_and_eof(self): + """Catch unwanted folder entries, wrong frame order or missing end signals.""" + folder = self.root / "frames" + (folder / "00.png").mkdir(parents=True) + self.write_image("frames/02.png", np.full((4, 8, 3), 60, np.uint8)) + self.write_image("frames/01.png", np.full((4, 8, 3), 30, np.uint8)) + (folder / "notes.txt").write_text("Ignored.", encoding="utf-8") + source = self.source(folder) + self.assertEqual(int(source.read()[0][3, 3, 0]), 30) + self.assertEqual(int(source.read()[0][3, 3, 0]), 60) + self.assertIsNone(source.read()) + self.assertIsNone(source.read()) + + def test_folder_loop_restarts_in_filename_order(self): + """Catch loops that fail to restart at the first image in filename order.""" + folder = self.root / "frames" + self.write_image("frames/02.png", np.full((4, 8, 3), 60, np.uint8)) + self.write_image("frames/01.png", np.full((4, 8, 3), 30, np.uint8)) + source = self.source(folder, loop=True) + self.assertEqual([int(source.read()[0][3, 3, 0]) for _ in range(5)], + [30, 60, 30, 60, 30]) + + def test_single_image_folder_ends_or_loops_as_requested(self): + """Catch a one-image folder repeating without --loop or not repeating with it.""" + folder = self.root / "frames" + self.write_image("frames/01.png", np.full((4, 8, 3), 30, np.uint8)) + source = self.source(folder) + self.assertEqual(int(source.read()[0][3, 3, 0]), 30) + self.assertIsNone(source.read()) + looping = self.source(folder, loop=True) + self.assertEqual([int(looping.read()[0][3, 3, 0]) for _ in range(3)], + [30, 30, 30]) + + def test_folder_with_no_image_files_is_rejected(self): + """Catch image-named directories being mistaken for usable image files.""" + folder = self.root / "frames" + (folder / "only-a-directory.png").mkdir(parents=True) + with self.assertRaises(ValueError): + sim.SimSource(folder) + + def test_corrupt_image_reports_its_filename_instead_of_end_of_media(self): + """Catch corrupt images silently ending playback without naming the broken file.""" + self.write_image("frames/01.png", np.full((4, 8, 3), 30, np.uint8)) + (self.root / "frames" / "02.png").write_text("Broken image.", encoding="utf-8") + self.write_image("frames/03.png", np.full((4, 8, 3), 60, np.uint8)) + source = self.source(self.root / "frames") + self.assertIsNotNone(source.read()) + with self.assertRaisesRegex(ValueError, "02[.]png"): + source.read() + + def test_returned_depth_does_not_mutate_subsequent_frames(self): + """Catch later frames being corrupted when a caller edits an earlier depth array.""" + depth_path = self.write_image("depth.png", np.full((4, 8), 1200, np.uint16)) + for options in ({"depth_m": 1.2}, {"depth_path": depth_path}): + with self.subTest(options=options): + source = self.source(**options) + _, first, _ = source.read() + first[:] = 999 + _, second, _ = source.read() + self.assertEqual(int(second[3, 3]), 1200) + self.assertEqual(int(second[0, 0]), 0) + self.assertFalse(np.shares_memory(first, second)) + + def test_failed_video_open_releases_the_capture(self): + """Catch a failed video open leaving its capture unreleased.""" + capture = mock.Mock() + capture.isOpened.return_value = False + with (mock.patch.object(sim.cv2, "VideoCapture", return_value=capture), + self.assertRaises(ValueError)): + sim.SimSource(self.video_path()) + capture.release.assert_called_once_with() + + def test_video_end_of_media_and_stop_release(self): + """Catch unrequested video looping or a capture left open after stop().""" + capture = mock.Mock() + capture.isOpened.return_value = True + capture.read.side_effect = [(True, np.full((4, 8, 3), 70, np.uint8)), + (False, None)] + with mock.patch.object(sim.cv2, "VideoCapture", return_value=capture): + source = sim.SimSource(self.video_path(), size=(8, 8)) + source.period = 0 + try: + self.assertEqual(int(source.read()[0][3, 3, 0]), 70) + self.assertIsNone(source.read()) + capture.set.assert_not_called() + finally: + source.stop() + capture.release.assert_called_once_with() + + def test_video_loop_seeks_to_start_and_retries(self): + """Catch video looping that fails to seek back and retry after a read failure.""" + frame = np.full((4, 8, 3), 70, np.uint8) + capture = mock.Mock() + capture.isOpened.return_value = True + capture.read.side_effect = [(True, frame), (False, None), (True, frame)] + with mock.patch.object(sim.cv2, "VideoCapture", return_value=capture): + source = self.source(self.video_path(), loop=True) + self.assertIsNotNone(source.read()) + self.assertIsNotNone(source.read()) + capture.set.assert_called_once_with(cv2.CAP_PROP_POS_FRAMES, 0) + + def test_video_with_no_decodable_frames_stops_even_when_looping(self): + """Catch endless retries when a looping video still has no readable frame.""" + capture = mock.Mock() + capture.isOpened.return_value = True + capture.read.side_effect = [(False, None), (False, None)] + with mock.patch.object(sim.cv2, "VideoCapture", return_value=capture): + source = self.source(self.video_path(), loop=True) + self.assertIsNone(source.read()) + capture.set.assert_called_once_with(cv2.CAP_PROP_POS_FRAMES, 0) + + def test_pacing_uses_monotonic_time_and_waits_only_for_remaining_period(self): + """Catch wall-clock pacing, excessive waiting or sleeping when a frame is late.""" + source = sim.SimSource(self.image, size=(8, 8), fps=10) + self.addCleanup(source.stop) + clock = [10.0] + + def advance_clock(delay): + clock[0] += delay + + with (mock.patch.object(sim.time, "monotonic", side_effect=lambda: clock[0]), + mock.patch.object(sim.time, "sleep", side_effect=advance_clock) as sleep, + mock.patch.object(sim.time, "time", side_effect=AssertionError( + "Frame pacing must not depend on wall-clock corrections"))): + self.assertIsNotNone(source.read()) + sleep.assert_not_called() + clock[0] += 0.04 + self.assertIsNotNone(source.read()) + sleep.assert_called_once() + self.assertAlmostEqual(sleep.call_args.args[0], 0.06) + clock[0] += 0.2 + self.assertIsNotNone(source.read()) + self.assertEqual(sleep.call_count, 1) + + +class DeprojectTests(unittest.TestCase): + """One formula check with chosen camera parameters; no real calibration.""" + + def test_camera_axes_and_known_off_center_point(self): + """Known pixels and 2 m depth give expected right/down/forward camera coordinates.""" + intrinsics = sim.Intrinsics(640, 480, 400.0, 200.0, 320.0, 240.0) + self.assertEqual(sim.deproject(intrinsics, (320, 240), 2.0), [0.0, 0.0, 2.0]) + self.assertEqual(sim.deproject(intrinsics, (420, 340), 2.0), [0.5, 1.0, 2.0]) + self.assertEqual(sim.deproject(intrinsics, (220, 140), 2.0), [-0.5, -1.0, 2.0]) + + +if __name__ == "__main__": + unittest.main()