├── web
├── __init__.py
├── update_tasks.py
├── v1_init_tables.sql
├── config.py
├── update_tasks.sql
├── crs.py
├── templates
│ ├── list.html
│ └── browse.html
├── dashed_draw.py
├── models.py
├── wms.py
├── main.py
└── db.py
├── .gitignore
├── setup.cfg
├── requirements.txt
├── README.md
├── run.sh
├── proxy_worker.py
└── LICENSE
/web/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.swp
2 | __pycache__/
3 | .venv/
4 | config_local.py
5 |
--------------------------------------------------------------------------------
/setup.cfg:
--------------------------------------------------------------------------------
1 | [flake8]
2 | max-line-length = 99
3 |
4 | [mypy]
5 | ignore_missing_imports = true
6 | check_untyped_defs = true
7 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | fastapi
2 | uvicorn[standard]
3 | gunicorn
4 | psycopg[binary,pool]
5 | Pillow
6 | jinja2
7 | aiohttp
8 | authlib
9 | itsdangerous
10 | httpx
11 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # GeoScribble
2 |
3 | A simple server that receives and returns lines and labels inside a bounding box.
4 | No authentication, no antispam, use at your own risk. Produces GeoJSON and WMS output.
5 |
6 | # Author and License
7 |
8 | Written by Ilya Zverev, published under ISC License.
9 |
--------------------------------------------------------------------------------
/web/update_tasks.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import logging
3 | from .db import init_database, update_tasks
4 |
5 |
6 | async def update():
7 | await init_database()
8 | await update_tasks()
9 |
10 |
11 | if __name__ == '__main__':
12 | logging.basicConfig(
13 | level=logging.INFO, format='[%(levelname)s] %(message)s')
14 | asyncio.run(update())
15 |
--------------------------------------------------------------------------------
/run.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | set -e -u
3 | export PGDATABASE="${PGDATABASE:-}"
4 | export PGUSER="${PGUSER:-$(whoami)}"
5 | export PGHOST=localhost
6 |
7 | HERE="$(dirname "$0")"
8 | VENV="${VENV:-$HERE/.venv}"
9 |
10 | # Creating a virtual environment if it doesn't exist
11 | if [ ! -d "$VENV" ]; then
12 | python3 -m venv "$VENV"
13 | "$VENV"/bin/pip install -r "$HERE/web/requirements.txt"
14 | fi
15 |
16 | "$VENV/bin/uvicorn" --host 0.0.0.0 --port 8000 web.main:app
17 |
--------------------------------------------------------------------------------
/proxy_worker.py:
--------------------------------------------------------------------------------
1 | from uvicorn.workers import UvicornWorker
2 |
3 |
4 | class ProxyUvicornWorker(UvicornWorker):
5 | CONFIG_KWARGS = {
6 | "loop": "auto", # Use 'auto' to automatically choose the best loop, 'uvloop' can be specified for performance.
7 | "http": "auto", # Use 'auto' to automatically choose the best HTTP protocol support, 'httptools' can be specified.
8 | "lifespan": "on", # 'on' to enable lifespan support.
9 | "proxy_headers": True, # Corresponding to `--proxy-headers`
10 | "forwarded_allow_ips": "*", # Corresponding to `--forwarded-allow-ips=*`
11 | }
12 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | ISC License
2 |
3 | Copyright 2023 Ilya Zverev
4 |
5 | Permission to use, copy, modify, and/or distribute this software
6 | for any purpose with or without fee is hereby granted, provided
7 | that the above copyright notice and this permission notice appear in all copies.
8 |
9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
10 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
11 | AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL,
12 | DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER
13 | RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT,
14 | NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH
15 | THE USE OR PERFORMANCE OF THIS SOFTWARE.
16 |
--------------------------------------------------------------------------------
/web/v1_init_tables.sql:
--------------------------------------------------------------------------------
1 | create table if not exists scribbles (
2 | scribble_id serial primary key,
3 | geom geometry not null,
4 | username text not null,
5 | user_id integer not null,
6 | editor text not null,
7 | created timestamp with time zone not null default now(),
8 |
9 | style text,
10 | color text,
11 | thin boolean not null default true,
12 | dashed boolean not null default false,
13 | label text,
14 |
15 | deleted timestamp with time zone,
16 | deleted_by_id integer
17 | );
18 |
19 | create index if not exists scribbles_idx_geom on scribbles using gist (geom);
20 | create index if not exists scribbles_idx_created on scribbles (created);
21 | create index if not exists scribbles_idx_user_id on scribbles (user_id);
22 |
23 | create table if not exists tasks (
24 | task_id serial primary key,
25 | location geometry not null,
26 | location_str text,
27 | scribbles integer not null,
28 | username text not null,
29 | user_id integer not null,
30 | created timestamp with time zone not null,
31 | processed timestamp with time zone,
32 | processed_by_id integer
33 | );
34 |
35 | create index if not exists tasks_idx_location on tasks (location);
36 | create index if not exists tasks_idx_created on tasks (created);
37 | create index if not exists tasks_idx_user_id on tasks (user_id);
38 |
--------------------------------------------------------------------------------
/web/config.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | BASE_URL = os.getenv('BASE_URL', '')
4 |
5 | # Set to localhost or a socket path.
6 | PG_HOST = os.getenv('PGHOST', '/var/run/postgresql')
7 |
8 | # Might want to use a proper user.
9 | PG_USER = os.getenv('PGUSER', 'postgres')
10 |
11 | # These are pretty much standard.
12 | PG_PORT = os.getenv('PGPORT', '5432')
13 | PG_DATABASE = os.getenv('PGDATABASE', '')
14 |
15 | # Geoscribble-specific numbers
16 | MAX_POINTS = int(os.getenv('MAX_POINTS', '100'))
17 | MAX_LENGTH = int(os.getenv('MAX_LENGTH', '5000')) # in meters
18 | DEFAULT_AGE = int(os.getenv('DEFAULT_AGE', '91')) # three months
19 |
20 | # For bbox requests
21 | MAX_COORD_SPAN = float(os.getenv('MAX_COORD_SPAN', '0.3'))
22 | MAX_IMAGE_WIDTH = int(os.getenv('MAX_IMAGE_WIDTH', '3000'))
23 | MAX_IMAGE_HEIGHT = int(os.getenv('MAX_IMAGE_HEIGHT', '2000'))
24 |
25 | # Fonts for labels
26 | FONT = os.getenv('FONT', '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf')
27 |
28 | # Email for geocoder
29 | EMAIL = os.getenv('EMAIL', 'geoscribble@example.com')
30 | MAX_GEOCODE = int(os.getenv('MAX_GEOCODE', '20'))
31 |
32 | # For OpenStreetMap authentication
33 | SECRET_KEY = os.getenv('SECRET_KEY', 'whatever')
34 | OAUTH_ID = os.getenv('OAUTH_ID', '')
35 | OAUTH_SECRET = os.getenv('OAUTH_SECRET', '')
36 |
37 | try:
38 | from .config_local import *
39 | except ImportError:
40 | pass
41 |
--------------------------------------------------------------------------------
/web/update_tasks.sql:
--------------------------------------------------------------------------------
1 | with start_date_table as (
2 | -- Get the starting date for new clusters.
3 | select coalesce(date_trunc('day', max(created)) - interval '1 day', timestamp '2024-01-01 00:00Z') as start_date from tasks
4 | )
5 |
6 | , clusters as (
7 | -- Clusterize scribbles.
8 | select ST_ClusterDBSCAN(geom, 0.02, 1) over (partition by user_id, date_trunc('day', created)) as cid,
9 | username, user_id, created, geom
10 | from scribbles, start_date_table
11 | where deleted is null and created >= start_date
12 | )
13 |
14 | , new_tasks as (
15 | -- Group clusters into tasks.
16 | select count(*) as cnt, max(username) username, user_id, max(created) created, ST_GeometricMedian(ST_Collect(ST_Centroid(geom))) loc
17 | from clusters group by cid, user_id, date_trunc('day', created)
18 |
19 | ), existing as (
20 | -- Find new tasks to merge with existing tasks.
21 | select n.*, t.task_id from new_tasks n left join tasks t on n.user_id = t.user_id and date_trunc('day', n.created) = date_trunc('day', t.created) and ST_DWithin(n.loc, t.location, 0.02)
22 |
23 | ), inserting as (
24 | -- Insert new tasks.
25 | insert into tasks (location, scribbles, username, user_id, created)
26 | select loc, cnt, username, user_id, created
27 | from existing where task_id is null
28 | order by created
29 |
30 | ), updating as (
31 | -- Update old tasks.
32 | update tasks
33 | set location = e.loc, created = e.created, scribbles = e.cnt
34 | from existing e where tasks.task_id = e.task_id
35 | )
36 |
37 | -- Return some debug information.
38 | select * from existing, start_date_table
39 | order by created;
40 |
--------------------------------------------------------------------------------
/web/crs.py:
--------------------------------------------------------------------------------
1 | from math import radians, degrees, cos, tan, log, pi, asin, tanh
2 |
3 |
4 | __all__ = ['BaseCRS', 'CRS_LIST', 'BBox']
5 |
6 |
7 | EARTH_RADIUS = 6378137
8 |
9 |
10 | class BaseCRS:
11 | def coords_to_pixel(self, lon: float, lat: float) -> tuple[float, float]:
12 | """Always returns (x, y)."""
13 | return lon, lat
14 |
15 | def pixel_to_coords(self, x: float, y: float) -> tuple[float, float]:
16 | """Returns either (lon, lat) or (lat, lon) depending on CRS."""
17 | return x, y
18 |
19 | @property
20 | def flip(self):
21 | return False
22 |
23 |
24 | class CRS_4326(BaseCRS):
25 | pass
26 |
27 |
28 | class CRS_3857(BaseCRS):
29 | def coords_to_pixel(self, lon: float, lat: float) -> tuple[float, float]:
30 | rlat = radians(lat)
31 | x = radians(lon)
32 | if lat > 85:
33 | y = pi * 2
34 | elif lat < -85:
35 | y = -pi * 2
36 | else:
37 | y = log(tan(rlat) + (1/cos(rlat)))
38 | return x * EARTH_RADIUS, y * EARTH_RADIUS
39 |
40 | def pixel_to_coords(self, x: float, y: float) -> tuple[float, float]:
41 | fx = x / EARTH_RADIUS
42 | fy = asin(tanh(y / EARTH_RADIUS))
43 | return degrees(fx), degrees(fy)
44 |
45 | @property
46 | def flip(self):
47 | return True
48 |
49 |
50 | CRS_LIST: dict[str, BaseCRS] = {
51 | 'EPSG:4326': CRS_4326(),
52 | 'EPSG:3857': CRS_3857(),
53 | }
54 |
55 |
56 | class BBox:
57 | def __init__(self, crs: BaseCRS, proj_bbox: list[float]):
58 | self.crs = crs
59 | self.x = proj_bbox[0]
60 | self.y = proj_bbox[1]
61 | self.w = proj_bbox[2] - proj_bbox[0]
62 | self.h = proj_bbox[3] - proj_bbox[1]
63 |
64 | def to_pixel(self, lonlat: tuple[float, float]) -> tuple[float, float]:
65 | proj = self.crs.coords_to_pixel(lonlat[0], lonlat[1])
66 | x, y = (proj[0] - self.x) / self.w, (proj[1] - self.y) / self.h
67 | if self.crs.flip:
68 | y = 1 - y
69 | return x, y
70 |
71 | def to_4326(self) -> list[float]:
72 | return [
73 | *self.crs.pixel_to_coords(self.x, self.y),
74 | *self.crs.pixel_to_coords(self.x + self.w, self.y + self.h),
75 | ]
76 |
--------------------------------------------------------------------------------
/web/templates/list.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | GeoScribble edits
5 |
6 |
7 |
8 |
9 | GeoScribble edits
10 |
11 | {% if not username %}
12 |
15 | {% else %}
16 |
22 | {% endif %}
23 |
24 |
25 | {% for edit in edits %}
26 |
27 | | {{ '+' if edit.processed else '' }} |
28 | {{ edit.username }} |
29 | {{ edit.created | format_date }} |
30 | ({{ edit.scribbles }}) |
31 |
32 | map
33 | josm
34 | id
35 | rapid
36 | |
37 | {% if username %}
38 |
39 | {% if not edit.processed %}
40 |
44 | {% else %}
45 | Not done yet
46 | {% endif %}
47 | |
48 | {% endif %}
49 | {{ edit.location_str or '' }} |
50 |
51 | {% endfor %}
52 |
53 |
54 |
55 |
--------------------------------------------------------------------------------
/web/templates/browse.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | GeoScribbles
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
57 |
58 |
59 |
--------------------------------------------------------------------------------
/web/dashed_draw.py:
--------------------------------------------------------------------------------
1 | import math
2 | from PIL import ImageDraw
3 |
4 |
5 | # Copied from https://stackoverflow.com/a/65893631/1297601
6 | class DashedImageDraw(ImageDraw.ImageDraw):
7 |
8 | def thick_line(self, xy, direction, fill=None, width=0):
9 | # xy – Sequence of 2-tuples like [(x, y), (x, y), ...]
10 | # direction – Sequence of 2-tuples like [(x, y), (x, y), ...]
11 | if xy[0] != xy[1]:
12 | self.line(xy, fill=fill, width=width)
13 | else:
14 | x1, y1 = xy[0]
15 | dx1, dy1 = direction[0]
16 | dx2, dy2 = direction[1]
17 | if dy2 - dy1 < 0:
18 | x1 -= 1
19 | if dx2 - dx1 < 0:
20 | y1 -= 1
21 | if dy2 - dy1 != 0:
22 | if dx2 - dx1 != 0:
23 | k = - (dx2 - dx1)/(dy2 - dy1)
24 | a = 1 / math.sqrt(1 + k**2)
25 | b = (width*a - 1) / 2
26 | else:
27 | k = 0
28 | b = (width - 1)/2
29 | x3 = x1 - math.floor(b)
30 | y3 = y1 - int(k*b)
31 | x4 = x1 + math.ceil(b)
32 | y4 = y1 + int(k*b)
33 | else:
34 | x3 = x1
35 | y3 = y1 - math.floor((width - 1)/2)
36 | x4 = x1
37 | y4 = y1 + math.ceil((width - 1)/2)
38 | self.line([(x3, y3), (x4, y4)], fill=fill, width=1)
39 | return
40 |
41 | def dashed_line(self, xy, dash=(2, 2), fill=None, width=0):
42 | # xy – Sequence of 2-tuples like [(x, y), (x, y), ...]
43 | for i in range(len(xy) - 1):
44 | x1, y1 = xy[i]
45 | x2, y2 = xy[i + 1]
46 | x_length = x2 - x1
47 | y_length = y2 - y1
48 | length = math.sqrt(x_length**2 + y_length**2)
49 | dash_enabled = True
50 | postion = 0
51 | while length > 0 and postion <= length:
52 | for dash_step in dash:
53 | if postion > length:
54 | break
55 | if dash_enabled:
56 | start = postion/length
57 | end = min((postion + dash_step - 1) / length, 1)
58 | self.thick_line([(round(x1 + start*x_length),
59 | round(y1 + start*y_length)),
60 | (round(x1 + end*x_length),
61 | round(y1 + end*y_length))],
62 | xy, fill, width)
63 | dash_enabled = not dash_enabled
64 | postion += dash_step
65 | return
66 |
--------------------------------------------------------------------------------
/web/models.py:
--------------------------------------------------------------------------------
1 | from math import radians, cos, sqrt
2 | from typing import Annotated, Optional
3 | from annotated_types import Len
4 | from pydantic import BaseModel, Field, PastDatetime
5 | from pydantic.functional_validators import AfterValidator
6 | from . import config
7 |
8 |
9 | class Identification(BaseModel):
10 | username: Annotated[str, Field(description='OSM user name')]
11 | user_id: Annotated[int, Field(
12 | gt=0, description='OSM user id', examples=[1234])]
13 | editor: Annotated[str, Field(
14 | description='Editor that uploaded the element', examples=['Every Door'])]
15 |
16 |
17 | def distance(p1: tuple[float, float], p2: tuple[float, float]) -> float:
18 | l1 = radians(p1[1])
19 | l2 = radians(p2[1])
20 | f1 = radians(p1[0])
21 | f2 = radians(p2[0])
22 | x = (l2 - l1) * cos((f1 + f2) / 2)
23 | y = f2 - f1
24 | return sqrt(x * x + y * y) * 6371000
25 |
26 |
27 | def validate_length(points: list[tuple[float, float]]):
28 | length = 0.0
29 | for i in range(1, len(points)):
30 | length += distance(points[i-1], points[i])
31 | assert length <= config.MAX_LENGTH, (
32 | f'Length of a scribble should be under {config.MAX_LENGTH} meters')
33 | return points
34 |
35 |
36 | class NewScribble(Identification):
37 | style: Annotated[str, Field(examples=['track'])]
38 | color: Annotated[Optional[str], Field(
39 | pattern='^[0-9a-fA-F]{6}$', description='Color in hex format RRGGBB')] = None
40 | dashed: bool = False
41 | thin: bool = True
42 | points: Annotated[list[tuple[float, float]],
43 | Len(min_length=2, max_length=config.MAX_POINTS),
44 | AfterValidator(validate_length),
45 | Field(
46 | description='Points as (lon, lat) for a line',
47 | examples=[[[10.1, 55.2], [10.2, 55.1]]])
48 | ]
49 |
50 |
51 | class NewLabel(Identification):
52 | location: Annotated[tuple[float, float], Field(examples=[[10.1, 55.2]])]
53 | text: Annotated[str, Field(
54 | min_length=1, max_length=40, examples=['fence'])]
55 | color: Annotated[Optional[str], Field(
56 | pattern='^[0-9a-fA-F]{6}$', description='Color in hex format RRGGBB')] = None
57 |
58 |
59 | class Deletion(Identification):
60 | id: Annotated[int, Field(
61 | description='Unique id of the scribble', examples=[45, 91])]
62 | deleted: Annotated[bool, Field(
63 | description='Should be true for deleting an element')]
64 |
65 |
66 | class Scribble(NewScribble):
67 | id: Annotated[int, Field(
68 | description='Unique id of the scribble', examples=[45, 46])]
69 | created: PastDatetime
70 |
71 |
72 | class Label(NewLabel):
73 | id: Annotated[int, Field(
74 | description='Unique id of the scribble', examples=[46])]
75 | created: PastDatetime
76 |
77 |
78 | class Box(BaseModel):
79 | box: Annotated[list[float], Field(description='Bounding box')]
80 | minage: int
81 |
82 |
83 | class Task(BaseModel):
84 | id: Annotated[int, Field(
85 | description='Unique id of the task', examples=[21])]
86 | location: Annotated[tuple[float, float], Field(examples=[[10.1, 55.2]])]
87 | location_str: Annotated[Optional[str], Field(
88 | description='Country and city of the task')]
89 | scribbles: Annotated[int, Field(
90 | description='Count of scribbles in this task', examples=[1])]
91 | username: Annotated[str, Field(description='OSM user name')]
92 | user_id: Annotated[int, Field(
93 | gt=0, description='OSM user id', examples=[1234])]
94 | created: PastDatetime
95 | processed: Optional[PastDatetime]
96 | processed_by_id: Annotated[Optional[int], Field(
97 | gt=0, description='OSM user who closed the task', examples=[1234])]
98 |
99 |
100 | class Feature(BaseModel):
101 | f_type: Annotated[str, Field("Feature", serialization_alias='type')]
102 | geometry: dict
103 | properties: dict
104 |
105 |
106 | class FeatureCollection(BaseModel):
107 | features: list[Feature]
108 |
--------------------------------------------------------------------------------
/web/wms.py:
--------------------------------------------------------------------------------
1 | from fastapi import HTTPException
2 | from io import BytesIO
3 | from . import config
4 | from .crs import CRS_LIST, BBox
5 | from .models import Scribble, Label, Box
6 | from .db import query
7 | from .dashed_draw import DashedImageDraw
8 | from PIL import Image, ImageDraw, ImageFont
9 | from typing import Union
10 |
11 |
12 | def get_capabilities(endpoint: str) -> str:
13 | srs = '\n'.join([f'{k}' for k in CRS_LIST.keys()])
14 | xml = """
15 |
16 |
17 | WMS
18 | GeoScribbles
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 | application/vnd.ogc.wms_xml
27 |
28 |
29 |
31 |
32 |
33 |
34 |
35 | image/png
36 |
37 |
38 |
40 |
41 |
42 |
43 |
44 |
45 |
46 | application/vnd.ogc.se_xml
47 | application/vnd.ogc.se_inimage
48 | application/vnd.ogc.se_blank
49 |
50 |
51 |
52 |
53 |
54 | GeoScribbles
55 | {srs}
56 |
57 |
59 |
60 | scribbles
61 | Scribbles and labels
62 |
63 |
64 | latest
65 | Scribbles less than a month old
66 |
67 |
68 |
69 |
70 | """.format(url=str(endpoint).rstrip('/'), srs=srs)
71 | return xml
72 |
73 |
74 | async def get_map(params: dict[str, str]) -> bytes:
75 | # Fist get CRS because everything depends on it.
76 | crs = CRS_LIST.get(params.get('crs', params.get('srs', '')).upper())
77 | if not crs:
78 | raise HTTPException(422, 'Unsupported CRS')
79 |
80 | # Now parse width and height and check they're inside max values.
81 | try:
82 | width = int(params['width'])
83 | height = int(params['height'])
84 | except ValueError:
85 | raise HTTPException(422, 'Width and height should be integer numbers')
86 | if width < 100 or width > config.MAX_IMAGE_WIDTH:
87 | raise HTTPException(422, f'Max width is {config.MAX_IMAGE_WIDTH}')
88 | if height < 100 or height > config.MAX_IMAGE_HEIGHT:
89 | raise HTTPException(422, f'Max height is {config.MAX_IMAGE_HEIGHT}')
90 |
91 | # Bounding box - reproject to 4326.
92 | try:
93 | bbox = [float(p) for p in params['bbox'].split(',')]
94 | except ValueError:
95 | bbox = []
96 | if len(bbox) != 4:
97 | raise HTTPException(422, 'Expecting 4 numbers for bbox')
98 |
99 | bbox_obj = BBox(crs, bbox)
100 | maxage = 21 if 'latest' in params.get('layers', '') else None
101 |
102 | try:
103 | user_id: int | None = int(params.get('user_id', ''))
104 | except ValueError:
105 | user_id = None
106 |
107 | scribbles = await query(
108 | bbox_obj.to_4326(), maxage=maxage,
109 | user_id=user_id, username=params.get('username'),
110 | )
111 | out = Image.new('RGBA', (width, height))
112 | render_image(out, bbox_obj, scribbles)
113 | content = BytesIO()
114 | out.save(content, 'PNG')
115 | return content.getvalue()
116 |
117 |
118 | def render_image(image: Image, bbox: BBox, scribbles: list[Union[Scribble, Label]]):
119 | age_colors = {
120 | 3: '#ffffb2',
121 | 7: '#fed976',
122 | 14: '#feb24c',
123 | 30: '#fd8d3c',
124 | 61: '#f03b20',
125 | 1000: '#bd0026',
126 | }
127 | draw = DashedImageDraw(image)
128 | for s in scribbles:
129 | if isinstance(s, Scribble):
130 | coords = [bbox.to_pixel(c) for c in s.points]
131 | coords = [(round(c[0] * image.width), round(c[1] * image.height)) for c in coords]
132 | width = 3 if s.thin else 5
133 | if s.dashed:
134 | draw.dashed_line(coords, (10, 10), fill=f'#{s.color}', width=width)
135 | else:
136 | draw.line(coords, fill=f'#{s.color}', width=width)
137 | elif isinstance(s, Box):
138 | x1, y1 = bbox.to_pixel((s.box[0], s.box[1]))
139 | x2, y2 = bbox.to_pixel((s.box[2], s.box[3]))
140 | xy = [min(x1, x2), min(y1, y2), max(x1, x2), max(y1, y2)]
141 | color = '#ffffff' # not used
142 | for k in sorted(age_colors.keys()):
143 | if s.minage <= k or k == 1000:
144 | color = age_colors[k]
145 | break
146 | draw.rectangle(xy, fill=color, width=0)
147 | for s in scribbles:
148 | # Drawing labels always after geometries
149 | if isinstance(s, Label):
150 | coord = bbox.to_pixel(s.location)
151 | coord = (round(coord[0] * image.width), round(coord[1] * image.height))
152 | r = 3
153 | elcoord = [
154 | (coord[0] - r, coord[1] - r),
155 | (coord[0] + r, coord[1] + r),
156 | ]
157 | draw.ellipse(elcoord, outline='#000000', fill='#e0ffe0', width=1)
158 | try:
159 | font = ImageFont.truetype(config.FONT, size=14)
160 | except OSError:
161 | font = ImageFont.load_default()
162 | # Draw semi-transparent background
163 | expand = 3
164 | torig = [coord[0] + expand, coord[1] - expand]
165 | tbox = font.getbbox(s.text)
166 | tbounds = [
167 | tbox[0] + torig[0] - expand, -tbox[3] + torig[1] - expand,
168 | tbox[2] + torig[0] + expand, tbox[1] + torig[1] + expand,
169 | ]
170 | draw.rounded_rectangle(tbounds, 5, fill='#00000050')
171 | draw.text(torig, s.text, fill='#ffffff', font=font, anchor='lb')
172 |
--------------------------------------------------------------------------------
/web/main.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import os
3 | from typing import Annotated, Union, Optional
4 | from fastapi import FastAPI, Query, HTTPException, Response, Request
5 | from fastapi.middleware.cors import CORSMiddleware
6 | from fastapi.responses import HTMLResponse, RedirectResponse
7 | from fastapi.templating import Jinja2Templates
8 | from starlette.middleware.sessions import SessionMiddleware
9 | from authlib.integrations.starlette_client import OAuth
10 | from authlib.common.errors import AuthlibBaseError
11 | from xml.etree import ElementTree as etree
12 | from . import config
13 | from .models import (
14 | NewScribble, NewLabel, Deletion, Scribble, Label,
15 | FeatureCollection, Feature, Box, Task,
16 | )
17 | from .db import (
18 | init_database, query, get_cursor,
19 | insert_scribble, insert_label, delete_scribble,
20 | list_tasks, mark_processed,
21 | )
22 | from .wms import get_map, get_capabilities
23 |
24 |
25 | logging.basicConfig(level=logging.INFO, format='[%(levelname)s] %(message)s')
26 | app = FastAPI()
27 | app.add_middleware(CORSMiddleware, allow_origins=['*'])
28 | app.add_middleware(SessionMiddleware, secret_key=config.SECRET_KEY, max_age=3600*24*365)
29 | templates = Jinja2Templates(directory=os.path.join(os.path.dirname(__file__), 'templates'))
30 |
31 | oauth = OAuth()
32 | oauth.register(
33 | 'openstreetmap',
34 | api_base_url='https://api.openstreetmap.org/api/0.6/',
35 | access_token_url='https://www.openstreetmap.org/oauth2/token',
36 | authorize_url='https://www.openstreetmap.org/oauth2/authorize',
37 | client_id=config.OAUTH_ID,
38 | client_secret=config.OAUTH_SECRET,
39 | client_kwargs={'scope': 'read_prefs'},
40 | )
41 |
42 |
43 | @app.on_event('startup')
44 | async def startup():
45 | await init_database()
46 |
47 |
48 | @app.get('/map', response_class=HTMLResponse, include_in_schema=False)
49 | async def root(request: Request):
50 | return templates.TemplateResponse(request=request, name='browse.html')
51 |
52 |
53 | def format_date(d) -> str:
54 | return d.strftime('%d %b %H:%M')
55 |
56 |
57 | templates.env.filters['format_date'] = format_date
58 |
59 |
60 | @app.get('/', response_class=HTMLResponse, include_in_schema=False)
61 | async def list_edits(request: Request):
62 | user_id = request.session.get('user_id')
63 | nofilter = bool(request.session.get('nofilter'))
64 | edits = await list_tasks(user_id=None if nofilter else user_id)
65 |
66 | context = {
67 | 'edits': edits,
68 | 'username': request.session.get('username'),
69 | 'nofilter': nofilter,
70 | }
71 | return templates.TemplateResponse(
72 | request=request, name='list.html', context=context)
73 |
74 |
75 | @app.get('/task_process', include_in_schema=False)
76 | async def process_task(task_id: int, request: Request):
77 | user_id = request.session.get('user_id')
78 | if user_id:
79 | await mark_processed(task_id, user_id)
80 | return RedirectResponse(request.url_for('list_edits'))
81 |
82 |
83 | @app.get('/task_unprocess', include_in_schema=False)
84 | async def unprocess_task(task_id: int, request: Request):
85 | user_id = request.session.get('user_id')
86 | if user_id:
87 | await mark_processed(task_id, None)
88 | return RedirectResponse(request.url_for('list_edits'))
89 |
90 |
91 | @app.get('/toggle_filter', include_in_schema=False)
92 | async def toggle_filter(request: Request):
93 | request.session['nofilter'] = not request.session.get('nofilter')
94 | return RedirectResponse(request.url_for('list_edits'))
95 |
96 |
97 | @app.get("/login", include_in_schema=False)
98 | async def login_via_osm(request: Request):
99 | redirect_uri = request.url_for('auth_via_osm')
100 | return await oauth.openstreetmap.authorize_redirect(request, redirect_uri)
101 |
102 |
103 | @app.get("/auth", include_in_schema=False)
104 | async def auth_via_osm(request: Request):
105 | try:
106 | token = await oauth.openstreetmap.authorize_access_token(request)
107 | except AuthlibBaseError:
108 | return HTMLResponse('Denied. Go back.')
109 |
110 | response = await oauth.openstreetmap.get('user/details', token=token)
111 | user_details = etree.fromstring(response.content)
112 | request.session['username'] = user_details[0].get('display_name')
113 | request.session['user_id'] = int(user_details[0].get('id') or 1)
114 |
115 | return RedirectResponse(request.url_for('list_edits'))
116 |
117 |
118 | @app.get('/logout', include_in_schema=False)
119 | async def logout(request: Request):
120 | request.session.pop('username')
121 | request.session.pop('user_id')
122 | return RedirectResponse(request.url_for('list_edits'), 302)
123 |
124 |
125 | @app.get('/tasks')
126 | async def tasks(
127 | bbox: Annotated[Optional[str], Query(pattern=r'^-?\d+(?:\.\d+)?(,-?\d+(?:\.\d+)?){3}$')],
128 | username: Optional[str] = None, user_id: Optional[int] = None,
129 | maxage: Optional[int] = None) -> list[Task]:
130 | """List tasks (grouped scribbles) by user and date."""
131 | box = None if not bbox else [float(part.strip()) for part in bbox.split(',')]
132 | return await list_tasks(box, username, user_id, maxage)
133 |
134 |
135 | @app.get('/scribbles')
136 | async def scribbles(
137 | bbox: Annotated[str, Query(pattern=r'^-?\d+(?:\.\d+)?(,-?\d+(?:\.\d+)?){3}$')],
138 | username: Optional[str] = None, user_id: Optional[int] = None,
139 | maxage: Optional[int] = None) -> list[Union[Scribble, Label, Box]]:
140 | """Return scribbles for a given area, in a raw json format."""
141 | box = [float(part.strip()) for part in bbox.split(',')]
142 | return await query(box, username, user_id, None, maxage)
143 |
144 |
145 | @app.get('/geojson')
146 | async def geojson(
147 | bbox: Annotated[str, Query(pattern=r'^-?\d+(?:\.\d+)?(,-?\d+(?:\.\d+)?){3}$')],
148 | username: Optional[str] = None, user_id: Optional[str] = None,
149 | maxage: Optional[int] = None) -> FeatureCollection:
150 | """Return scribbles for a given area as GeoJSON."""
151 | scr = await scribbles(bbox, username, user_id, maxage)
152 | features = []
153 | for s in scr:
154 | if isinstance(s, Scribble):
155 | features.append(Feature(
156 | f_type='Feature',
157 | geometry={'type': 'LineString', 'coordinates': s.points},
158 | properties={
159 | 'type': 'scribble',
160 | 'id': s.id,
161 | 'style': s.style,
162 | 'color': None if not s.color else f'#{s.color}',
163 | 'dashed': s.dashed,
164 | 'thin': s.thin,
165 | 'userName': s.username,
166 | 'userId': s.user_id,
167 | 'editor': s.editor,
168 | 'created': s.created,
169 | }
170 | ))
171 | elif isinstance(s, Label):
172 | features.append(Feature(
173 | f_type='Feature',
174 | geometry={'type': 'Point', 'coordinates': s.location},
175 | properties={
176 | 'type': 'label',
177 | 'id': s.id,
178 | 'color': None if not s.color else f'#{s.color}',
179 | 'text': s.text,
180 | 'username': s.username,
181 | 'userId': s.user_id,
182 | 'editor': s.editor,
183 | 'created': s.created,
184 | }
185 | ))
186 | elif isinstance(s, Box):
187 | features.append(Feature(
188 | f_type='Feature',
189 | geometry={
190 | 'type': 'Polygon',
191 | 'coordinates': [[
192 | [s.box[0], s.box[1]],
193 | [s.box[2], s.box[1]],
194 | [s.box[2], s.box[3]],
195 | [s.box[0], s.box[3]],
196 | [s.box[0], s.box[1]],
197 | ]],
198 | },
199 | properties={
200 | 'type': 'box',
201 | 'minAge': s.minage,
202 | },
203 | ))
204 | return FeatureCollection(features=features)
205 |
206 |
207 | @app.get('/wms')
208 | async def wms(request: Request):
209 | """WMS endpoint for editors."""
210 | params = {k.lower(): v for k, v in request.query_params.items()}
211 | if params.get('request') == 'GetCapabilities':
212 | if params.get('service', 'WMS').lower() != 'wms':
213 | raise HTTPException(422, "Please use WMS for service")
214 | base_url = config.BASE_URL or request.scope.get('root_path') or request.base_url
215 | xml = get_capabilities(base_url) # TODO: url behind proxy
216 | return Response(content=xml, media_type='application/xml')
217 | elif params.get('request') == 'GetMap':
218 | if any([k not in params for k in ('format', 'bbox', 'width', 'height', 'layers')]):
219 | raise HTTPException(422, "Missing parameter for GetMap")
220 | if params.get('format') != 'image/png':
221 | raise HTTPException(422, "GetMap supports only PNG images")
222 | data = await get_map(params)
223 | return Response(content=data, media_type='image/png')
224 | else:
225 | raise HTTPException(422, "This server supports only GetCapabilities and GetMap")
226 |
227 |
228 | @app.post('/upload')
229 | async def put_scribbles(
230 | scribbles: list[Union[NewScribble, NewLabel, Deletion]]
231 | ) -> list[Optional[int]]:
232 | """Batch upload scribbles, labels, and deletions."""
233 | # Check that at least the user is the same
234 | for i in range(1, len(scribbles)):
235 | if (scribbles[i].user_id != scribbles[0].user_id or
236 | scribbles[i].username != scribbles[0].username or
237 | scribbles[i].editor != scribbles[0].editor):
238 | raise HTTPException(401, "User and editor should be the same for all elements")
239 |
240 | new_ids: list[Optional[int]] = []
241 | async with get_cursor(True) as cur:
242 | for s in scribbles:
243 | if isinstance(s, NewScribble):
244 | new_ids.append(await insert_scribble(cur, s))
245 | elif isinstance(s, NewLabel):
246 | new_ids.append(await insert_label(cur, s))
247 | elif isinstance(s, Deletion):
248 | new_ids.append(await delete_scribble(cur, s))
249 | return new_ids
250 |
251 |
252 | @app.put('/new')
253 | async def put_one_scribble(scribble: Union[NewScribble, NewLabel]) -> int:
254 | """Upload one scribble or label."""
255 | async with get_cursor(True) as cur:
256 | if isinstance(scribble, NewScribble):
257 | return await insert_scribble(cur, scribble)
258 | elif isinstance(scribble, NewLabel):
259 | return await insert_label(cur, scribble)
260 | raise HTTPException(401) # TODO: proper error
261 |
--------------------------------------------------------------------------------
/web/db.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import aiohttp
3 | import os
4 | import json
5 | import logging
6 | from datetime import datetime, timedelta
7 | from contextlib import asynccontextmanager
8 | from psycopg_pool import AsyncConnectionPool
9 | from psycopg.rows import dict_row
10 | from typing import Union, Optional
11 | from . import config
12 | from .models import Scribble, Label, NewLabel, NewScribble, Deletion, Box, Task
13 |
14 |
15 | pool = AsyncConnectionPool(
16 | kwargs={
17 | 'host': config.PG_HOST,
18 | 'port': config.PG_PORT,
19 | 'dbname': config.PG_DATABASE,
20 | 'user': config.PG_USER,
21 | 'row_factory': dict_row,
22 | },
23 | open=False,
24 | )
25 |
26 |
27 | async def check_connections():
28 | while True:
29 | await asyncio.sleep(600)
30 | await pool.check()
31 |
32 |
33 | @asynccontextmanager
34 | async def get_cursor(commit: bool = False):
35 | async with pool.connection() as conn:
36 | cursor = conn.cursor()
37 | try:
38 | yield cursor
39 | if commit:
40 | await conn.commit()
41 | finally:
42 | await cursor.close()
43 |
44 |
45 | async def create_table():
46 | async with get_cursor(True) as cur:
47 | await cur.execute(
48 | "select 1 from pg_tables where schemaname='public' and tablename='tasks'")
49 | if not await cur.fetchone():
50 | # Table is missing, run the script
51 | filename = os.path.join(os.path.dirname(__file__), 'v1_init_tables.sql')
52 | with open(filename, 'r') as f:
53 | sql = f.read()
54 | await cur.execute(sql)
55 |
56 |
57 | async def init_database():
58 | await pool.open()
59 | asyncio.create_task(check_connections())
60 | await create_table()
61 |
62 |
63 | def bbox_too_big(box: list[float]) -> bool:
64 | if (abs(box[2] - box[0]) > config.MAX_COORD_SPAN or
65 | abs(box[3] - box[1]) > config.MAX_COORD_SPAN):
66 | return True
67 | return False
68 |
69 |
70 | def geohash_digits(box: list[float]) -> int:
71 | # area in square degrees.
72 | area = abs(box[2] - box[0]) * abs(box[3] - box[1])
73 | if area > 1000:
74 | return 3
75 | if area > 40:
76 | return 4
77 | return 5
78 |
79 |
80 | async def query(bbox: list[float], username: Optional[str] = None,
81 | user_id: Optional[int] = None,
82 | editor: Optional[str] = None,
83 | maxage: Optional[int] = None
84 | ) -> list[Union[Scribble, Label, Box]]:
85 | age = maxage or config.DEFAULT_AGE
86 | result: list[Union[Scribble, Label]] = []
87 | async with get_cursor() as cur:
88 | params = [*bbox, timedelta(days=age)]
89 | add_queries = []
90 |
91 | if username:
92 | add_queries.append('and username = %s')
93 | params.append(username)
94 | if user_id:
95 | add_queries.append('and user_id = %s')
96 | params.append(user_id)
97 | if editor:
98 | add_queries.append('and editor = %s')
99 | params.append(editor)
100 |
101 | overview = bbox_too_big(bbox)
102 | if not overview:
103 | sql = """select *, ST_AsGeoJSON(geom) json from scribbles
104 | where ST_Intersects(geom, ST_MakeEnvelope(%s, %s, %s, %s, 4326))
105 | and created >= now() - %s and deleted is null
106 | {q}""".format(q=' '.join(add_queries))
107 | else:
108 | params.insert(0, geohash_digits(bbox))
109 | sql = """with t as (
110 | select ST_GeoHash(geom, %s) hash, min(now()-created) age
111 | from scribbles
112 | where geom && ST_MakeEnvelope(%s, %s, %s, %s, 4326)
113 | and created >= now() - %s and deleted is null
114 | {q} group by 1)
115 | select ST_AsGeoJSON(ST_GeomFromGeoHash(hash)) json,
116 | extract(day from age) age from t
117 | """.format(q=' '.join(add_queries))
118 |
119 | await cur.execute(sql, params)
120 |
121 | async for row in cur:
122 | geom = json.loads(row['json'])
123 | if overview:
124 | c = geom['coordinates'][0]
125 | result.append(Box(
126 | minage=row['age'],
127 | box=[c[0][0], c[0][1], c[2][0], c[2][1]],
128 | ))
129 | elif geom['type'] == 'Point':
130 | result.append(Label(
131 | id=row['scribble_id'],
132 | created=row['created'],
133 | username=row['username'],
134 | user_id=row['user_id'],
135 | editor=row['editor'] or '',
136 | location=(geom['coordinates'][0],
137 | geom['coordinates'][1]),
138 | color=row['color'],
139 | text=row['label'],
140 | ))
141 | else:
142 | result.append(Scribble(
143 | id=row['scribble_id'],
144 | created=row['created'],
145 | username=row['username'],
146 | user_id=row['user_id'],
147 | editor=row['editor'] or '',
148 | style=row['style'],
149 | color=row['color'],
150 | dashed=row['dashed'],
151 | thin=row['thin'],
152 | points=geom['coordinates'],
153 | ))
154 | return result
155 |
156 |
157 | async def insert_scribble(cur, s: NewScribble) -> int:
158 | await cur.execute(
159 | """insert into scribbles
160 | (user_id, username, editor, style, color, thin, dashed, geom)
161 | values (%s, %s, %s, %s, %s, %s, %s, ST_SetSRID(ST_GeomFromGeoJSON(%s), 4326))
162 | returning scribble_id""",
163 | (s.user_id, s.username, s.editor, s.style, s.color, s.thin, s.dashed,
164 | json.dumps({'type': 'LineString', 'coordinates': s.points})))
165 | return (await cur.fetchone())['scribble_id']
166 |
167 |
168 | async def insert_label(cur, s: NewLabel) -> int:
169 | await cur.execute(
170 | """insert into scribbles
171 | (user_id, username, editor, color, label, geom)
172 | values (%s, %s, %s, %s, %s, ST_Point(%s, %s, 4326))
173 | returning scribble_id""",
174 | (s.user_id, s.username, s.editor, s.color, s.text, *s.location))
175 | return (await cur.fetchone())['scribble_id']
176 |
177 |
178 | async def delete_scribble(cur, s: Deletion) -> int:
179 | await cur.execute(
180 | "update scribbles set deleted = now(), deleted_by_id = %s where scribble_id = %s",
181 | (s.user_id, s.id))
182 | return s.id
183 |
184 |
185 | async def reverse_geocode(lon: float, lat: float) -> Optional[str]:
186 | async with aiohttp.ClientSession() as session:
187 | endpoint = 'https://nominatim.openstreetmap.org/reverse'
188 | params = {
189 | 'format': 'jsonv2',
190 | 'lat': lat,
191 | 'lon': lon,
192 | 'accept-language': 'en',
193 | 'zoom': '12',
194 | 'layer': 'address',
195 | 'email': config.EMAIL,
196 | }
197 | async with session.get(endpoint, params=params) as response:
198 | if response.status == 200:
199 | data = await response.json()
200 | return data.get('display_name')
201 | else:
202 | logging.warn('Could not geocode %s: %s',
203 | response.url, await response.text())
204 | return None
205 |
206 |
207 | async def update_tasks() -> None:
208 | async with get_cursor(True) as cur:
209 | # Run the script from the file.
210 | filename = os.path.join(os.path.dirname(__file__), 'update_tasks.sql')
211 | with open(filename, 'r') as f:
212 | sql = f.read()
213 | await cur.execute(sql)
214 |
215 | # Get max task_id for reverse geocoding.
216 | await cur.execute("select max(task_id) t from tasks where location_str is not null")
217 | last_geocoded = (await cur.fetchone())['t'] or 0
218 |
219 | # Reverse geocode the new tasks.
220 | await cur.execute(
221 | "select task_id, ST_X(location) lon, ST_Y(location) lat "
222 | "from tasks where task_id > %s and task_id <= %s",
223 | (last_geocoded, last_geocoded + config.MAX_GEOCODE))
224 | locs: list[tuple[str, int]] = []
225 | async for row in cur:
226 | loc = await reverse_geocode(row['lon'], row['lat'])
227 | if loc:
228 | locs.append((loc, row['task_id']))
229 | await asyncio.sleep(1.2)
230 | await cur.executemany("update tasks set location_str = %s where task_id = %s", locs)
231 |
232 |
233 | async def list_tasks(bbox: Optional[list[float]] = None,
234 | username: Optional[str] = None, user_id: Optional[int] = None,
235 | maxage: Optional[int] = None,
236 | since: Optional[datetime] = None, limit: int = 100) -> list[Task]:
237 | age = maxage or config.DEFAULT_AGE
238 | if not since:
239 | since = datetime.now() - timedelta(days=age)
240 |
241 | params: list = [since]
242 | add_queries: list[str] = []
243 |
244 | if bbox:
245 | add_queries.append('and ST_Intersects(location, ST_MakeEnvelope(%s, %s, %s, %s, 4326))')
246 | params.extend(bbox)
247 | if username:
248 | add_queries.append('and username = %s')
249 | params.append(username)
250 | if user_id:
251 | add_queries.append('and user_id = %s')
252 | params.append(user_id)
253 |
254 | sql = """select *, ST_AsGeoJSON(location) json from tasks
255 | where created >= %s {q} order by created desc limit {limit}""".format(
256 | q=' '.join(add_queries), limit=limit)
257 |
258 | result: list[Task] = []
259 | async with get_cursor() as cur:
260 | await cur.execute(sql, params)
261 | async for row in cur:
262 | geom = json.loads(row['json'])
263 | result.append(Task(
264 | id=row['task_id'],
265 | location=(geom['coordinates'][0],
266 | geom['coordinates'][1]),
267 | location_str=row['location_str'],
268 | scribbles=row['scribbles'],
269 | username=row['username'],
270 | user_id=row['user_id'],
271 | created=row['created'],
272 | processed=row['processed'],
273 | processed_by_id=row['processed_by_id'],
274 | ))
275 |
276 | return result
277 |
278 |
279 | async def mark_processed(task_id: int, user_id: Optional[int]) -> None:
280 | async with get_cursor(True) as cur:
281 | if user_id:
282 | sql = """update tasks set processed = now(), processed_by_id = %(u)s
283 | where task_id = %(t)s"""
284 | else:
285 | sql = """update tasks set processed = null, processed_by_id = null
286 | where task_id = %(t)s"""
287 | await cur.execute(sql, {'t': task_id, 'u': user_id})
288 |
--------------------------------------------------------------------------------