mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-08 13:33:42 +08:00
486 lines
26 KiB
Python
486 lines
26 KiB
Python
import gzip
|
|
import re
|
|
import struct
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
import unittest
|
|
from contextlib import closing, contextmanager
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import numpy as np
|
|
import requests
|
|
|
|
import openpilot.cereal.messaging as messaging
|
|
from openpilot.common.prefix import OpenpilotPrefix
|
|
from openpilot.selfdrive.navigation.mapsd import MapsD
|
|
from openpilot.selfdrive.navigation.navd import REROUTE_COUNTER_MIN, ROUTE_RESEND_PERIOD, RouteEngine, set_banner
|
|
from openpilot.selfdrive.navigation.pmtiles import PMTiles, decode_directory, tile_id
|
|
from openpilot.selfdrive.navigation.renderer import MAJOR, MEDIUM, MINOR, SIZE, STREET, TRACK, MapRenderer, draw_segments, mercator, project, stroke_widths
|
|
from openpilot.selfdrive.navigation.route import RouteTracker
|
|
from openpilot.selfdrive.navigation.tiles import RemoteTiles
|
|
from openpilot.selfdrive.navigation.vector_tile import decode_lines, decode_roads
|
|
|
|
METER = 1 / 111319.49079327358 # degrees of latitude
|
|
|
|
|
|
def draw_line(image, points, intensity, width, **kwargs):
|
|
draw_segments(image, points[:-1], points[1:], width / 2, intensity, **kwargs)
|
|
|
|
|
|
def varint(value):
|
|
output = bytearray()
|
|
while value > 127:
|
|
output.append((value & 127) | 128)
|
|
value >>= 7
|
|
output.append(value)
|
|
return bytes(output)
|
|
|
|
|
|
def field(number, value):
|
|
if isinstance(value, bytes):
|
|
return varint(number << 3 | 2) + varint(len(value)) + value
|
|
return varint(number << 3) + varint(value)
|
|
|
|
|
|
def road_tile(crossing=None):
|
|
# One vertical line, from (2048, 0) to (2048, 4096), and one non-road layer. With crossing='bridge' or 'tunnel',
|
|
# a horizontal line from (1024, 2048) to (3072, 2048) at that level.
|
|
geometry = b''.join(varint(v) for v in [9, 4096, 0, 10, 0, 8192])
|
|
feature = field(2, b'\x00\x00') + field(3, 2) + field(4, geometry)
|
|
roads = field(15, 2) + field(1, b'roads') + field(2, feature)
|
|
if crossing is not None:
|
|
geometry = b''.join(varint(v) for v in [9, 2048, 4096, 10, 4096, 0])
|
|
roads += field(2, field(2, b'\x00\x00\x01\x01') + field(3, 2) + field(4, geometry))
|
|
roads += field(3, b'kind') + field(3, f'is_{crossing}'.encode()) + field(4, field(1, b'minor_road')) + field(4, field(7, 1)) + field(5, 4096)
|
|
return field(3, field(1, b'buildings')) + field(3, roads)
|
|
|
|
|
|
def make_archive(payload=None, leaf=False, run=1, max_zoom=0):
|
|
"""A PMTiles archive with one zoom-zero tile, in the root directory or in a leaf directory."""
|
|
tile = gzip.compress(road_tile() if payload is None else payload)
|
|
directory = gzip.compress(varint(1) + varint(0) + varint(run) + varint(len(tile)) + varint(1))
|
|
child = directory if leaf else b''
|
|
root = gzip.compress(varint(1) + varint(0) + varint(0) + varint(len(child)) + varint(1)) if leaf else directory
|
|
metadata = gzip.compress(b'{"vector_layers":[{"id":"roads"}]}')
|
|
header = bytearray(127)
|
|
header[:8] = b'PMTiles\x03'
|
|
offsets = [127, 127 + len(root), 127 + len(root) + len(metadata), 127 + len(root) + len(metadata) + len(child)]
|
|
struct.pack_into('<11Q', header, 8, offsets[0], len(root), offsets[1], len(metadata), offsets[2], len(child), offsets[3], len(tile), 1, 1, 1)
|
|
header[96:102] = bytes([1, 2, 2, 1, 0, max_zoom])
|
|
return bytes(header + root + metadata + child + tile)
|
|
|
|
|
|
@contextmanager
|
|
def local_archive(data):
|
|
"""A PMTiles reader over in-memory bytes, bypassing the tile server."""
|
|
source = MagicMock(size=len(data))
|
|
source.read.side_effect = lambda offset, length: data[offset:offset + length]
|
|
with closing(PMTiles(source)) as archive:
|
|
yield archive
|
|
|
|
|
|
def mapbox_response(steps):
|
|
response = MagicMock(status_code=200)
|
|
response.json.return_value = {'routes': [{'legs': [{'steps': steps}]}]}
|
|
return response
|
|
|
|
|
|
def route_engine(sm, pm, values):
|
|
params = MagicMock()
|
|
params.get.side_effect = values.get
|
|
params.remove.side_effect = lambda key: values.pop(key, None)
|
|
with patch('openpilot.selfdrive.navigation.navd.Api') as api:
|
|
api.return_value.get_token.return_value = 'test-token'
|
|
return RouteEngine(sm, pm, params=params)
|
|
|
|
|
|
class RangeHandler(BaseHTTPRequestHandler):
|
|
def do_GET(self):
|
|
self.server.requests.append(dict(self.headers))
|
|
if self.headers.get('If-Match', self.server.etag) != self.server.etag:
|
|
self.send_error(412)
|
|
return
|
|
if self.server.ignore_range:
|
|
self.send_response(200)
|
|
self.end_headers()
|
|
return
|
|
offset, end = map(int, re.fullmatch(r'bytes=(\d+)-(\d+)', self.headers['Range']).groups())
|
|
data = self.server.archive[offset:end + 1]
|
|
self.send_response(206)
|
|
self.send_header('Content-Range', f'bytes {offset}-{end}/{len(self.server.archive)}')
|
|
self.send_header('Content-Length', str(len(data)))
|
|
self.send_header('ETag', self.server.etag)
|
|
self.end_headers()
|
|
self.wfile.write(data)
|
|
|
|
def log_message(self, format: str, *args: object) -> None: # noqa: A002 # stdlib override
|
|
pass
|
|
|
|
|
|
class TileServerCase(unittest.TestCase):
|
|
"""Serves a leaf-directory archive over HTTP range requests, with the comma API and tile cache stubbed."""
|
|
|
|
def setUp(self):
|
|
self.directory = tempfile.TemporaryDirectory()
|
|
self.addCleanup(self.directory.cleanup)
|
|
self.cache = Path(self.directory.name) / 'navigation' / 'tiles'
|
|
self.server = ThreadingHTTPServer(('127.0.0.1', 0), RangeHandler)
|
|
self.server.archive = make_archive(leaf=True)
|
|
self.server.etag = '"test-version"'
|
|
self.server.ignore_range = False
|
|
self.server.requests = []
|
|
threading.Thread(target=self.server.serve_forever, kwargs={'poll_interval': 0.01}, daemon=True).start()
|
|
self.addCleanup(self.server.server_close)
|
|
self.addCleanup(self.server.shutdown)
|
|
self.api = MagicMock(dongle_id='test-dongle')
|
|
self.api.get_token.return_value = 'device-token'
|
|
self.url = f'http://127.0.0.1:{self.server.server_port}/planet.pmtiles?sig=secret'
|
|
self.api.get.return_value.json.return_value = {'url': self.url, 'expires': time.time() + 86400} # noqa: TID251
|
|
for name, value in [('Api', MagicMock(return_value=self.api)), ('Params', MagicMock()), ('PC', True)]:
|
|
self.enterContext(patch(f'openpilot.selfdrive.navigation.tiles.{name}', value))
|
|
self.enterContext(patch('openpilot.selfdrive.navigation.tiles.Paths.comma_home', return_value=self.directory.name))
|
|
|
|
|
|
class TestEndToEnd(TileServerCase):
|
|
def test_navd_to_map_frames(self):
|
|
"""navd routes a destination over messaging; mapsd fetches tiles over HTTP and renders map frames."""
|
|
destination = {'latitude': 0.01, 'longitude': 0}
|
|
self.enterContext(patch('requests.get', return_value=mapbox_response([
|
|
{'geometry': {'coordinates': [[0, -0.01], [0, 0.01]]}, 'distance': 2224, 'duration': 100, 'bannerInstructions': []}])))
|
|
with OpenpilotPrefix():
|
|
pm = messaging.PubMaster(['navInstruction', 'navRoute', 'gpsLocationExternal'])
|
|
sm = messaging.SubMaster(['gpsLocation', 'gpsLocationExternal', 'navRoute'])
|
|
values = {'NavDestination': dict(destination)}
|
|
navd = route_engine(messaging.SubMaster(['gpsLocation', 'gpsLocationExternal']), pm, values)
|
|
mapsd = MapsD()
|
|
self.addCleanup(mapsd.close)
|
|
|
|
def frame(fix=True):
|
|
gps = messaging.new_message('gpsLocationExternal', valid=True)
|
|
gps.gpsLocationExternal.hasFix = fix
|
|
pm.send('gpsLocationExternal', gps)
|
|
navd.update()
|
|
sm.update(0)
|
|
return mapsd.update(sm)
|
|
|
|
image, timestamp = frame()
|
|
# The route is centered over the white road, with the ego arrow three quarters down.
|
|
self.assertEqual((image[128, 128], image[128, 131], image[188, 128], image[195, 128]), (128, 255, 255, 0))
|
|
self.assertEqual(timestamp, sm.logMonoTime['gpsLocationExternal'])
|
|
fetched = len(self.server.requests)
|
|
tracker = mapsd.tracker
|
|
navd.send_route() # the same route again, as a restarted mapsd would get it
|
|
frame()
|
|
self.assertIs(mapsd.tracker, tracker) # progress along it is kept
|
|
values.pop('NavDestination')
|
|
self.assertFalse(frame()[0].any()) # black without a route
|
|
values['NavDestination'] = dict(destination)
|
|
self.assertEqual(frame()[0][128, 128], 128)
|
|
self.assertEqual(len(self.server.requests), fetched) # the second route is served from the cache
|
|
image, timestamp = frame(fix=False)
|
|
self.assertFalse(image.any() or timestamp) # black, not stale, without a fix
|
|
self.api.get.assert_called_once()
|
|
self.assertTrue(all('Range' in headers for headers in self.server.requests))
|
|
self.assertTrue(all(headers['If-Match'] == self.server.etag for headers in self.server.requests[1:]))
|
|
self.assertTrue(all(b'secret' not in p.read_bytes() for p in self.cache.iterdir()))
|
|
# The archive is replaced on the server and the cache evicted: the next fetch fails, the map goes black,
|
|
# and after a delay a fresh reader starts over on the new version.
|
|
self.server.etag = '"new-version"'
|
|
for cached in self.cache.iterdir():
|
|
cached.unlink()
|
|
values.pop('NavDestination')
|
|
frame()
|
|
values['NavDestination'] = dict(destination)
|
|
image, timestamp = frame()
|
|
self.assertFalse(image.any() or timestamp)
|
|
fetched = len(self.server.requests)
|
|
self.assertFalse(frame()[1]) # not retried yet
|
|
self.assertEqual(len(self.server.requests), fetched)
|
|
mapsd.retry_at = 0
|
|
self.assertEqual(frame()[0][128, 128], 128)
|
|
self.assertEqual((self.cache / 'version').read_text().split()[0], '"new-version"')
|
|
|
|
|
|
class TestRemoteTiles(TileServerCase):
|
|
def test_shared_cache_and_refresh(self):
|
|
with closing(PMTiles(RemoteTiles())) as archive:
|
|
archive.source.expires = 0 # the signed URL is refreshed before the next fetch
|
|
self.assertEqual(archive.get(0, 0, 0), road_tile())
|
|
self.assertEqual(archive.get(0, 0, 0), road_tile())
|
|
self.assertEqual(self.api.get.call_count, 2)
|
|
count = len(self.server.requests)
|
|
with closing(PMTiles(RemoteTiles())) as archive:
|
|
self.assertEqual(archive.get(0, 0, 0), road_tile())
|
|
# A second reader revalidates only the header; directories and tiles are shared on disk.
|
|
self.assertEqual(len(self.server.requests), count + 1)
|
|
with patch('openpilot.selfdrive.navigation.tiles.CACHE_LIMIT', 1), closing(RemoteTiles()) as source:
|
|
source.read(1, 2) # an uncached range is downloaded, then the cache is trimmed to the limit
|
|
self.assertEqual([p.name for p in self.cache.iterdir()], ['version'])
|
|
|
|
def test_prefetch_merges_nearby_ranges(self):
|
|
with patch('openpilot.selfdrive.navigation.tiles.MERGE_GAP', 16), closing(RemoteTiles()) as source:
|
|
count = len(self.server.requests)
|
|
source.prefetch([(20, 10), (0, 10), (60, 10), (20, 10)])
|
|
self.assertEqual(len(self.server.requests), count + 2) # 0-29 in one request, 60-69 in another
|
|
self.assertEqual(self.server.requests[-2]['Range'], 'bytes=0-29')
|
|
for offset in (0, 20, 60):
|
|
self.assertEqual(source.read(offset, 10), self.server.archive[offset:offset + 10])
|
|
self.assertEqual(len(self.server.requests), count + 2)
|
|
|
|
def test_failures(self):
|
|
with closing(PMTiles(RemoteTiles())) as archive:
|
|
self.server.etag = '"new-version"'
|
|
with self.assertRaisesRegex(OSError, '412'): # the archive changed under an open reader
|
|
archive.get(0, 0, 0)
|
|
with closing(PMTiles(RemoteTiles())) as archive: # a new reader starts over on the new version
|
|
self.assertEqual(archive.get(0, 0, 0), road_tile())
|
|
self.server.ignore_range = True
|
|
with self.assertRaisesRegex(OSError, '200'):
|
|
RemoteTiles()
|
|
self.api.get.return_value.raise_for_status.side_effect = requests.HTTPError(self.url)
|
|
with self.assertRaisesRegex(OSError, '^Unable to fetch map tiles$'): # the signed URL stays out of the error
|
|
RemoteTiles()
|
|
|
|
|
|
class TestRouteEngine(unittest.TestCase):
|
|
def setUp(self):
|
|
self.pm = MagicMock()
|
|
self.values = {'NavDestination': {'latitude': 0, 'longitude': 0.002}, 'LanguageSetting': 'main_en'}
|
|
self.sm = MagicMock()
|
|
self.sm.valid = dict.fromkeys(('gpsLocation', 'gpsLocationExternal'), True)
|
|
self.sm.logMonoTime = dict.fromkeys(self.sm.valid, time.monotonic_ns())
|
|
self.sm.recv_time = dict.fromkeys(self.sm.valid, time.monotonic())
|
|
self.sm.__getitem__.return_value = SimpleNamespace(hasFix=True, latitude=0, longitude=0, bearingDeg=90)
|
|
self.engine = route_engine(self.sm, self.pm, self.values)
|
|
self.engine.last_position, self.engine.last_bearing = (0, 0), 90
|
|
distance = 111.195 # Mapbox's length of 0.001 degrees at the equator
|
|
self.response = mapbox_response([
|
|
{'geometry': {'coordinates': [[0, 0], [0.0005, 0], [0.001, 0]]}, 'distance': distance, 'duration': 10,
|
|
'bannerInstructions': [{'distanceAlongGeometry': 100, 'primary': {'text': 'Turn right', 'type': 'turn', 'modifier': 'right'}}]},
|
|
{'geometry': {'coordinates': [[0.001, 0], [0.002, 0]]}, 'distance': distance, 'duration': 10, 'bannerInstructions': []},
|
|
])
|
|
|
|
def last_message(self, service):
|
|
return next(call.args[1] for call in reversed(self.pm.send.call_args_list) if call.args[0] == service)
|
|
|
|
def load_route(self):
|
|
with patch('requests.get', return_value=self.response) as get:
|
|
self.engine.calculate_route((0.002, 0))
|
|
self.engine.send_route()
|
|
return get
|
|
|
|
def test_request_and_instruction(self):
|
|
get = self.load_route()
|
|
self.assertEqual(get.call_args.args[0], 'https://api.commadotai.com/v1/nav/directions/v5/mapbox/driving-traffic/0,0;0.002,0')
|
|
options = get.call_args.kwargs['params']
|
|
self.assertEqual((options['access_token'], options['bearings'], options['language']), ('test-token', '90,90;', 'en'))
|
|
self.assertEqual(len(self.last_message('navRoute').navRoute.coordinates), 5)
|
|
self.engine.send_instruction()
|
|
message = self.last_message('navInstruction')
|
|
self.assertTrue(message.valid)
|
|
instruction = message.navInstruction.to_dict()
|
|
self.assertEqual((instruction['maneuverPrimaryText'], instruction['maneuverModifier'], instruction['timeRemaining']), ('Turn right', 'right', 20))
|
|
self.engine.last_position = (0.0007, 0) # on the step's second segment
|
|
self.engine.send_instruction()
|
|
self.assertAlmostEqual(self.last_message('navInstruction').navInstruction.timeRemaining, 13, delta=0.05)
|
|
self.engine.last_position = (0.001, 0) # at the maneuver
|
|
self.engine.send_instruction()
|
|
self.assertAlmostEqual(self.last_message('navInstruction').navInstruction.maneuverDistance, 0, delta=0.01)
|
|
|
|
def test_banner_selection(self):
|
|
banners = [{'distanceAlongGeometry': 100, 'primary': {'text': 'Turn right', 'type': 'turn', 'modifier': 'right'}},
|
|
{'distanceAlongGeometry': 50, 'primary': {'text': 'Turn right'}, 'secondary': {'text': 'onto Main St'}}]
|
|
# The last banner still ahead of the ego, else the first: 120 m, 70 m and 30 m from the maneuver.
|
|
for distance, expected in [(120, ('Turn right', '', False)), (70, ('Turn right', '', True)), (30, ('Turn right', 'onto Main St', True))]:
|
|
instruction = messaging.new_message('navInstruction').navInstruction
|
|
set_banner(instruction, banners, distance)
|
|
self.assertEqual((instruction.maneuverPrimaryText, instruction.maneuverSecondaryText, instruction.showFull), expected)
|
|
|
|
def test_route_sent_on_change_and_periodically(self):
|
|
sent = lambda: sum(call.args[0] == 'navRoute' for call in self.pm.send.call_args_list) # noqa: E731
|
|
self.sm.frame = 1
|
|
with patch('requests.get', return_value=self.response):
|
|
self.engine.update() # the route to a new destination is sent once
|
|
self.engine.update()
|
|
self.assertEqual(sent(), 1)
|
|
self.sm.frame = ROUTE_RESEND_PERIOD # and again periodically, for a mapsd that started after it
|
|
self.engine.update()
|
|
self.assertEqual(sent(), 2)
|
|
|
|
def test_maneuvers_and_rerouting(self):
|
|
self.load_route()
|
|
self.engine.last_position = (0, 0.001) # 111 m off route: reroute after a few consecutive misses
|
|
for _ in range(REROUTE_COUNTER_MIN):
|
|
self.assertFalse(self.engine.should_recompute())
|
|
self.assertTrue(self.engine.should_recompute())
|
|
with patch('requests.get') as get:
|
|
self.engine.recompute_route(gps_ok=False) # but not while the GPS drifts in a tunnel
|
|
get.assert_not_called()
|
|
with patch('requests.get', return_value=self.response) as get:
|
|
self.engine.recompute_route(gps_ok=True)
|
|
self.engine.reroute_counter = REROUTE_COUNTER_MIN # still off route, but an immediate second recompute is held back
|
|
self.engine.recompute_route(gps_ok=True)
|
|
self.assertEqual(get.call_count, 1)
|
|
self.engine.last_position = (0.0012, 0) # past the first maneuver
|
|
self.engine.send_instruction()
|
|
self.assertEqual(self.engine.step_idx, 1)
|
|
self.engine.last_position = (0.00215, 0) # 17 m past the destination: arrived, but the route is kept nearby
|
|
self.engine.send_instruction()
|
|
self.assertIsNotNone(self.engine.route)
|
|
self.engine.last_position = (0.0024, 0) # driving away from the destination
|
|
self.engine.send_instruction()
|
|
self.assertIsNone(self.engine.route)
|
|
self.assertNotIn('NavDestination', self.values)
|
|
|
|
def test_failed_request_clears_route(self):
|
|
self.load_route()
|
|
self.response.raise_for_status.side_effect = requests.HTTPError('test failure')
|
|
self.load_route()
|
|
self.assertIsNone(self.engine.route)
|
|
self.assertEqual(len(self.last_message('navRoute').navRoute.coordinates), 0)
|
|
self.engine.send_instruction()
|
|
self.assertFalse(self.last_message('navInstruction').valid)
|
|
|
|
def test_gps_source(self):
|
|
fixes = {'gpsLocation': SimpleNamespace(hasFix=True, latitude=1, longitude=1, bearingDeg=180),
|
|
'gpsLocationExternal': SimpleNamespace(hasFix=True, latitude=2, longitude=2, bearingDeg=0)}
|
|
self.sm.__getitem__.side_effect = fixes.get
|
|
self.sm.logMonoTime = {'gpsLocation': 2, 'gpsLocationExternal': 1} # replayed logs are judged by receipt time
|
|
with patch('requests.get', return_value=self.response):
|
|
self.engine.update()
|
|
self.assertEqual((self.engine.last_position, self.engine.last_bearing), ((1, 1), 180)) # the newest fix wins
|
|
self.sm.valid['gpsLocation'] = False
|
|
self.engine.update()
|
|
self.assertEqual(self.engine.last_position, (2, 2)) # unless it is invalid
|
|
fixes['gpsLocationExternal'] = SimpleNamespace(hasFix=True, latitude=3, longitude=3, bearingDeg=0)
|
|
self.sm.recv_time = dict.fromkeys(self.sm.valid, time.monotonic() - 3) # or stale
|
|
self.engine.update()
|
|
self.assertEqual(self.engine.last_position, (2, 2))
|
|
|
|
|
|
class TestTiles(unittest.TestCase):
|
|
def test_pmtiles_format(self):
|
|
self.assertEqual([tile_id(1, x, y) for x, y in [(0, 0), (0, 1), (1, 1), (1, 0)]], [1, 2, 3, 4])
|
|
self.assertEqual(sorted(tile_id(3, x, y) for x in range(8) for y in range(8)), list(range(21, 85)))
|
|
# IDs 5, 10; runs 3, 1; lengths 4, 6; the second offset follows the first blob.
|
|
data = bytes([2, 5, 5, 3, 1, 4, 6, 1, 0])
|
|
self.assertEqual(decode_directory(data), ([5, 10], [(3, 4, 0), (1, 6, 4)]))
|
|
for invalid in (b'\x80', b'\x01\x00\x01\x01\x00', data + b'\x00'):
|
|
with self.assertRaises(ValueError):
|
|
decode_directory(invalid)
|
|
with local_archive(make_archive(run=3, max_zoom=1)) as archive: # the zoom-zero tile is also addressed by IDs 1 and 2
|
|
self.assertEqual(archive.get(1, 0, 1), road_tile())
|
|
self.assertIsNone(archive.get(1, 1, 1))
|
|
with self.assertRaises(ValueError), local_archive(make_archive()[:-1]):
|
|
pass
|
|
|
|
def test_vector_tiles(self):
|
|
roads = decode_roads(road_tile())
|
|
self.assertEqual((len(roads), roads[0].kind), (1, 'minor_road'))
|
|
np.testing.assert_array_equal(roads[0].points, [[0.5, 0], [0.5, 1]])
|
|
lines = decode_lines([9, 20, 20, 10, 10, 0, 9, 9, 9, 10, 0, 10])
|
|
np.testing.assert_array_equal(lines[0], [[10, 10], [15, 10]])
|
|
np.testing.assert_array_equal(lines[1], [[10, 5], [10, 10]])
|
|
for data in (b'\x1a\xff', b'\x00', b'\x1a\x02\x0a\x04'):
|
|
with self.assertRaises(ValueError):
|
|
decode_roads(data)
|
|
for commands in ([10, 1, 1], [9, 0], [9, 0, 0, 15]):
|
|
with self.assertRaises(ValueError):
|
|
decode_lines(commands)
|
|
|
|
|
|
class TestRendering(unittest.TestCase):
|
|
def test_projection(self):
|
|
# At the equator, 64m ahead is the image center and the car is 64px below it, at 1m per pixel.
|
|
points = mercator(np.array([[0, 0], [0, 64 * METER], [64 * METER, 0]]))
|
|
np.testing.assert_allclose(project(points, 0, 0, 0), [[128, 192], [128, 128], [192, 192]], atol=1e-5)
|
|
np.testing.assert_allclose(project(points, 0, 0, 90), [[128, 192], [64, 192], [128, 128]], atol=1e-5)
|
|
pixels = project(mercator(np.array([[-179.999, 0]])), 179.999, 0, 90) # across the antimeridian
|
|
self.assertAlmostEqual(pixels[0, 1], 192 - 222.6389816, places=5)
|
|
|
|
def test_strokes(self):
|
|
image = np.zeros((SIZE, SIZE), dtype=np.uint8)
|
|
draw_line(image, np.array([[-1000, 128], [1000, 128]]), 255, 8)
|
|
self.assertTrue(np.all(image[128] == 255))
|
|
draw_line(image, np.array([[128, 0], [128, 256]]), 128, 5, overlay=True) # the grey route replaces the white road
|
|
self.assertEqual((image[128, 128], image[128, 131]), (128, 255))
|
|
before = image.copy()
|
|
draw_line(image, np.array([[-1000, -1000], [-500, -500]]), 255, 5)
|
|
np.testing.assert_array_equal(image, before)
|
|
draw_line(image, np.array([[40, 40], [40, 40]]), 255, 5) # a zero-length segment is still a dot
|
|
self.assertEqual(image[40, 40], 255)
|
|
|
|
def test_crossings(self):
|
|
route = np.array([[0.01, 0], [0.01, 0.01]]) # off to the side, out of the way of the probes
|
|
images = {}
|
|
for crossing in ('bridge', 'tunnel'):
|
|
with local_archive(make_archive(road_tile(crossing))) as archive:
|
|
images[crossing] = MapRenderer(archive).render(0, -40 * METER, 0, route)[0] # the crossing 40m ahead
|
|
# The horizontal road crosses the vertical one 40px above the ego, at row 152. A bridge severs the road beneath
|
|
# just outside its own stroke; a tunnel passes underneath untouched. The cut has butt caps, so the
|
|
# bridge's own end is not wrapped in black.
|
|
self.assertEqual((images['bridge'][146, 128], images['bridge'][152, 128], images['bridge'][152, 200], images['bridge'][140, 128]), (0, 255, 255, 255))
|
|
self.assertEqual((images['tunnel'][146, 128], images['tunnel'][152, 200]), (255, 255))
|
|
# The ground road cuts over the tunnel beneath it, and the tunnel is drawn without its casing.
|
|
self.assertEqual((images['tunnel'][152, 133], images['tunnel'][147, 200]), (0, 0))
|
|
|
|
def test_road_widths(self):
|
|
# Pinned at the style zoom of 1 m per pixel: 16.26 at the equator and 15.26 at 60 degrees. Tunnels have no casing.
|
|
styles, levels = np.array([MAJOR, MEDIUM, STREET, MINOR, TRACK, STREET]), np.array([0, 0, 0, 0, 0, -1])
|
|
np.testing.assert_allclose(stroke_widths(16.256, styles, levels), [19.02, 15.48, 9.86, 6.70, 7.02, 7.89], atol=0.01)
|
|
np.testing.assert_allclose(stroke_widths(15.256, styles, levels), [13.52, 10.78, 6.39, 4.16, 4.58, 4.62], atol=0.01)
|
|
|
|
def test_route_tiles(self):
|
|
route = np.array([[0, -0.01], [0, 0.01]]) # 2.2 km north along the prime meridian
|
|
with local_archive(make_archive(max_zoom=15)) as archive:
|
|
renderer = MapRenderer(archive)
|
|
tiles = renderer.route_tiles(route)
|
|
# Both columns beside the meridian, and the rows within the view radius of the route's ends.
|
|
self.assertEqual({x for x, _ in tiles}, {16383, 16384})
|
|
self.assertEqual({y for _, y in tiles}, set(range(16382, 16386)))
|
|
|
|
def test_validity_and_display(self):
|
|
route = np.array([[0, -0.01], [0, 0.01]])
|
|
with local_archive(make_archive()) as archive:
|
|
renderer = MapRenderer(archive)
|
|
self.assertTrue(renderer.render(0, 0, 0, route)[1])
|
|
self.assertFalse(renderer.render(0.01, 0, 0, route)[1]) # no road near the ego
|
|
with patch.object(archive, 'get', return_value=None): # a missing tile
|
|
image, valid = MapRenderer(archive).render(0, 0, 0, route)
|
|
self.assertFalse(valid)
|
|
self.assertEqual(image[128, 128], 128) # the route is still drawn
|
|
with local_archive(make_archive(field(3, field(1, b'buildings')))) as archive: # tiles without roads
|
|
self.assertFalse(MapRenderer(archive).render(0, 0, 0, route)[1])
|
|
|
|
|
|
class TestRouteTracking(unittest.TestCase):
|
|
def test_snap_heading_and_window(self):
|
|
m = METER
|
|
tracker = RouteTracker(np.array([[0, 0], [0, 200 * m], [200 * m, 200 * m]])) # north 200m, then east 200m
|
|
longitude, latitude, bearing = tracker.snap(3 * m, 100 * m) # 3m right of the first leg
|
|
self.assertAlmostEqual(tracker.progress, 100, delta=0.5)
|
|
self.assertAlmostEqual(longitude, 0) # lateral error removed
|
|
self.assertAlmostEqual(latitude, 100 * m)
|
|
self.assertAlmostEqual(bearing, 0, places=3)
|
|
self.assertAlmostEqual(tracker.snap(0, 200 * m)[2], 45, places=2) # at the corner the +-50m chord is mid-turn
|
|
self.assertAlmostEqual(tracker.snap(0, 150 * m)[2], 45, places=2) # progress never decreases
|
|
tracker.snap(100 * m, 200 * m) # progress 300: the window ahead no longer includes the first leg
|
|
self.assertAlmostEqual(tracker.snap(150 * m, 200 * m)[0], 150 * m) # so the window's offset is applied
|
|
self.assertAlmostEqual(tracker.snap(200 * m, 200 * m)[2], 90, places=2) # the chord clips at the route's end
|
|
# Out and back along the same road: a fix near the far leg stays on the outbound leg within the window,
|
|
# while a fresh tracker searches anywhere and lands on the return leg.
|
|
route = np.array([[0, 0], [0, 1000 * m], [10 * m, 1000 * m], [10 * m, 0]])
|
|
tracker = RouteTracker(route)
|
|
tracker.snap(1 * m, 100 * m)
|
|
self.assertAlmostEqual(tracker.snap(9 * m, 150 * m)[2], 0, places=3)
|
|
self.assertAlmostEqual(tracker.progress, 150, delta=0.5)
|
|
self.assertAlmostEqual(RouteTracker(route).snap(9 * m, 150 * m)[2], 180, places=3)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|