A sharper map, and aircraft that fade rather than blink out
Three things asked for, and a fourth found while doing them. The map looked like a photograph of a map, and did so twice over. The window fetched at its own pixel size but for a box a third larger in each direction -- the margin added to stop the ground blinking -- and then cut the middle out, so every pixel was enlarged by two thirds. It now asks for enough pixels to cover the bigger box at the window's own detail. Underneath that, both the window and the animation took the nearest source pixel: the tile mosaic is commonly half again the size of the picture, so most of every tile was thrown away and what survived was the aliasing. Both now average the source pixels that fall in each output cell, done as the difference of a running total rather than a loop. An aircraft that goes quiet now fades instead of vanishing. Taking it off between one frame and the next says it stopped existing; fading says it stopped talking, which is what happened. It fades where it was last actually seen and never along a reckoned track, because the reason for giving up on it is that where it would be by now is a guess. --fade sets how long, and it is in the menu. The window has alpha and fades smoothly; an indexed picture cannot blend, so the animation gained a fourth ramp at a seventh of full and fades in four steps, which at a second apart reads as a fade. A trail fades with the aircraft it belongs to, and the box goes before the symbol does. The aircraft's country of registration carries a flag now as well as the two ends of its route, from the register where one answered and from the address block otherwise. And the fourth: the window's header counted an aircraft that had gone quiet as overhead, which was saying more than had been heard. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
This commit is contained in:
parent
df080f6571
commit
87b0954f0c
17 changed files with 1609 additions and 101 deletions
|
|
@ -4,7 +4,9 @@ Nothing here touches the network. The tiles are made up in the test and fed
|
|||
in through the same door the real ones come through, and the PNGs are built
|
||||
here from the specification rather than by the decoder they are testing.
|
||||
"""
|
||||
import json
|
||||
import struct
|
||||
import time
|
||||
import zlib
|
||||
|
||||
import numpy as np
|
||||
|
|
@ -418,7 +420,7 @@ def test_the_tile_server_can_be_pointed_somewhere_else(tmp_path, monkeypatch):
|
|||
|
||||
def test_the_map_goes_under_the_picture_and_is_credited(tmp_path):
|
||||
out = fm.animate(two_aircraft(), tmp_path / "on-the-map.png", width=400,
|
||||
ground=True, fetch=gradient_tile)
|
||||
ground=True, fetch=gradient_tile, airports=False)
|
||||
assert out is not None and out.ground
|
||||
assert "on the map" in out.summary()
|
||||
from bandsaunter.images import PNG_SIGNATURE
|
||||
|
|
@ -457,3 +459,221 @@ def test_the_credit_is_written_on_the_picture_itself():
|
|||
levels, credit = fm.ground_for(view, fetch=gradient_tile)
|
||||
base = fm.background(view, ground=levels, attribution=credit)
|
||||
assert _has_text(base, "OPENSTREETMAP")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# What aerodromes are under the picture
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _overpass(elements):
|
||||
"""Stand in for the map data, in the shape it really answers with."""
|
||||
def ask(box, url, timeout):
|
||||
return {"elements": elements}
|
||||
return ask
|
||||
|
||||
|
||||
def _node(code=None, name="", lat=32.1, lon=-110.9, ref=None, kind="node"):
|
||||
tags = {"aeroway": "aerodrome", "name": name}
|
||||
if code:
|
||||
tags["icao"] = code
|
||||
if ref:
|
||||
tags["ref"] = ref
|
||||
element = {"type": kind, "tags": tags}
|
||||
if kind == "node":
|
||||
element.update({"lat": lat, "lon": lon})
|
||||
else:
|
||||
element["center"] = {"lat": lat, "lon": lon}
|
||||
return element
|
||||
|
||||
|
||||
def test_the_aerodromes_in_a_box_come_back_with_a_code_and_a_place(tmp_path):
|
||||
got = bm.airports_in(32.0, -111.2, 32.4, -110.7, cache=tmp_path / "a.json",
|
||||
ask=_overpass([_node("KTUS", "Tucson International")]))
|
||||
assert len(got) == 1
|
||||
assert got[0]["code"] == "KTUS"
|
||||
assert got[0]["latitude"] == pytest.approx(32.1)
|
||||
|
||||
|
||||
def test_the_big_airports_are_relations_and_must_be_asked_for():
|
||||
"""Tucson International and Davis-Monthan are both relations; asking
|
||||
only for nodes and ways finds every airstrip in the county and misses
|
||||
the two the county is known for."""
|
||||
import inspect
|
||||
|
||||
source = inspect.getsource(bm._ask_overpass)
|
||||
for kind in ("node", "way", "relation"):
|
||||
assert f'{kind}["aeroway"="aerodrome"]' in source, kind
|
||||
|
||||
|
||||
def test_a_relation_is_placed_by_its_middle(tmp_path):
|
||||
got = bm.airports_in(32.0, -111.2, 32.4, -110.7, cache=tmp_path / "a.json",
|
||||
ask=_overpass([_node("KDMA", "Davis-Monthan",
|
||||
kind="relation")]))
|
||||
assert got and got[0]["code"] == "KDMA"
|
||||
|
||||
|
||||
def test_a_landing_strip_with_no_real_code_is_left_off(tmp_path):
|
||||
"""A local identifier like 14AZ names nothing anybody would recognise,
|
||||
and turns a map into a list of airstrips."""
|
||||
got = bm.airports_in(32.0, -111.2, 32.4, -110.7, cache=tmp_path / "a.json",
|
||||
ask=_overpass([_node(None, "Ruby Star", ref="14AZ"),
|
||||
_node(None, "Nowhere",
|
||||
ref="MX-0492"),
|
||||
_node(None, "Unnamed strip")]))
|
||||
assert got == []
|
||||
|
||||
|
||||
def test_a_four_letter_reference_is_good_enough(tmp_path):
|
||||
got = bm.airports_in(32.0, -111.2, 32.4, -110.7, cache=tmp_path / "a.json",
|
||||
ask=_overpass([_node(None, "Somewhere", ref="EGLL")]))
|
||||
assert [a["code"] for a in got] == ["EGLL"]
|
||||
|
||||
|
||||
def test_one_airport_tagged_twice_is_marked_once(tmp_path):
|
||||
"""A point for the terminal and an outline for the field: marking both
|
||||
writes the name over itself."""
|
||||
got = bm.airports_in(32.0, -111.2, 32.4, -110.7, cache=tmp_path / "a.json",
|
||||
ask=_overpass([_node("KFHU", "Sierra Vista"),
|
||||
_node("KFHU", "Sierra Vista",
|
||||
kind="relation")]))
|
||||
assert [a["code"] for a in got] == ["KFHU"]
|
||||
|
||||
|
||||
def test_the_ones_with_real_codes_come_first(tmp_path):
|
||||
got = bm.airports_in(32.0, -111.2, 32.4, -110.7, cache=tmp_path / "a.json",
|
||||
ask=_overpass([_node(None, "Strip", ref="ZZZZ"),
|
||||
_node("KTUS", "Tucson")]))
|
||||
assert [a["code"] for a in got][0] == "KTUS"
|
||||
|
||||
|
||||
def test_a_view_full_of_airstrips_is_capped(tmp_path):
|
||||
many = [_node(f"K{i:03d}"[:4], f"Strip {i}") for i in range(200)]
|
||||
got = bm.airports_in(32.0, -111.2, 32.4, -110.7, cache=tmp_path / "a.json",
|
||||
ask=_overpass(many))
|
||||
assert len(got) <= bm.MOST_AIRPORTS
|
||||
|
||||
|
||||
def test_the_answer_is_kept_so_the_question_is_asked_once(tmp_path):
|
||||
"""A runway does not move, and the service being asked is a volunteer
|
||||
one."""
|
||||
asked = []
|
||||
|
||||
def ask(box, url, timeout):
|
||||
asked.append(box)
|
||||
return {"elements": [_node("KTUS", "Tucson")]}
|
||||
|
||||
where = tmp_path / "a.json"
|
||||
first = bm.airports_in(32.0, -111.2, 32.4, -110.7, cache=where, ask=ask)
|
||||
second = bm.airports_in(32.0, -111.2, 32.4, -110.7, cache=where, ask=ask)
|
||||
assert first == second
|
||||
assert len(asked) == 1, "asked twice for the same piece of the world"
|
||||
|
||||
|
||||
def test_an_answer_from_an_older_question_is_asked_again(tmp_path):
|
||||
where = tmp_path / "a.json"
|
||||
where.write_text(json.dumps({"fetched_at": time.time(), "version": 1,
|
||||
"airports": [{"code": "OLD"}]}))
|
||||
got = bm.airports_in(32.0, -111.2, 32.4, -110.7, cache=where,
|
||||
ask=_overpass([_node("KTUS", "Tucson")]))
|
||||
assert [a["code"] for a in got] == ["KTUS"]
|
||||
|
||||
|
||||
def test_no_network_means_no_airports_rather_than_no_map(tmp_path):
|
||||
def refuse(box, url, timeout):
|
||||
raise OSError("no route to host")
|
||||
|
||||
assert bm.airports_in(32.0, -111.2, 32.4, -110.7,
|
||||
cache=tmp_path / "a.json", ask=refuse) == []
|
||||
|
||||
|
||||
def test_nonsense_from_the_service_is_survived(tmp_path):
|
||||
for answer in ({}, {"elements": None}, {"elements": [{"tags": None}]},
|
||||
{"elements": [{"tags": {"icao": "KTUS"}}]}):
|
||||
got = bm.airports_in(32.0, -111.2, 32.4, -110.7,
|
||||
cache=tmp_path / f"{id(answer)}.json",
|
||||
ask=lambda b, u, t, a=answer: a)
|
||||
assert got == []
|
||||
|
||||
|
||||
def test_the_map_asks_for_the_airports_under_it():
|
||||
"""A route names where its aircraft are going; the airports underneath
|
||||
are what say where on the map you are looking."""
|
||||
view = fm.fit(two_aircraft(), width=500)
|
||||
got = fm.local_airports(view, ask=_overpass([_node("EGLL", "Heathrow",
|
||||
lat=view.south + 0.1,
|
||||
lon=view.west + 0.1)]))
|
||||
assert got and got[0][0] == "EGLL"
|
||||
|
||||
|
||||
def test_a_service_that_is_not_there_costs_the_airports_and_nothing_else():
|
||||
def refuse(box, url, timeout):
|
||||
raise OSError("no")
|
||||
|
||||
view = fm.fit(two_aircraft(), width=500)
|
||||
assert fm.local_airports(view, ask=refuse) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# How sharp the map is
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_detail_is_averaged_down_rather_than_thrown_away():
|
||||
"""Taking the nearest source pixel keeps a fraction of a tile and turns
|
||||
the rest into aliasing: hard, broken lettering and roads that come and
|
||||
go along their length. A checkerboard is half black and half white, so
|
||||
averaging it lands in the middle and sampling it lands at one end."""
|
||||
fine = np.tile(np.array([0.0, 255.0], dtype=np.float32), 128)[None, :]
|
||||
out = bm._resample(fine, np.linspace(0, 256, 41), axis=1)
|
||||
assert out.shape == (1, 40)
|
||||
assert abs(float(out.mean()) - 127.5) < 4.0
|
||||
assert float(out.max()) < 160.0 and float(out.min()) > 95.0
|
||||
|
||||
|
||||
def test_averaging_takes_the_whole_cell_and_no_more():
|
||||
"""Each output cell is the mean of the source pixels under it."""
|
||||
values = np.arange(100, dtype=np.float32)[None, :]
|
||||
out = bm._resample(values, np.linspace(0, 100, 11), axis=1)
|
||||
assert out.shape == (1, 10)
|
||||
for i in range(10):
|
||||
assert abs(float(out[0, i]) - (i * 10 + 4.5)) < 0.01
|
||||
|
||||
|
||||
def test_averaging_works_the_other_way_up_too():
|
||||
values = np.arange(60, dtype=np.float32)[:, None]
|
||||
out = bm._resample(values, np.linspace(0, 60, 7), axis=0)
|
||||
assert out.shape == (6, 1)
|
||||
assert float(out[0, 0]) < float(out[-1, 0])
|
||||
|
||||
|
||||
def test_a_cell_smaller_than_a_source_pixel_takes_that_pixel():
|
||||
"""Zoomed in past the tiles, a cell covers less than one of them."""
|
||||
values = np.array([[10.0, 20.0, 30.0]], dtype=np.float32)
|
||||
out = bm._resample(values, np.linspace(0, 3, 10), axis=1)
|
||||
assert out.shape == (1, 9)
|
||||
assert set(np.round(out[0]).astype(int)) <= {10, 20, 30}
|
||||
|
||||
|
||||
def test_a_gradient_still_comes_out_as_a_gradient():
|
||||
south, west, north, east = tile_bounds(9, 81, 178)
|
||||
levels = bm.ground_under(south, west, north, east, 64, 64, shades=32,
|
||||
fetch=gradient_tile, zoom=9, pause=0)
|
||||
rows = levels.mean(axis=1)
|
||||
assert (np.diff(rows) <= 0.51).all(), "the gradient came out lumpy"
|
||||
|
||||
|
||||
def test_averaging_is_the_same_shape_as_the_picture_asked_for():
|
||||
south, west, north, east = tile_bounds(9, 81, 178)
|
||||
for width, height in ((40, 40), (137, 91), (300, 200), (17, 5)):
|
||||
levels = bm.ground_under(south, west, north, east, width, height,
|
||||
shades=32, fetch=gradient_tile, zoom=9,
|
||||
pause=0)
|
||||
assert levels.shape == (height, width)
|
||||
|
||||
|
||||
def test_a_map_asked_for_at_more_detail_than_the_tiles_hold_still_works():
|
||||
"""Zoomed in past the tiles, a cell covers less than one source pixel."""
|
||||
south, west, north, east = tile_bounds(9, 81, 178)
|
||||
levels = bm.ground_under(south, west, north, east, 2000, 2000, shades=32,
|
||||
fetch=gradient_tile, zoom=9, pause=0)
|
||||
assert levels.shape == (2000, 2000)
|
||||
assert levels[0].mean() != levels[-1].mean()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue