diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..7885c51 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,36 @@ +name: CI + +# Controls when the action will run. Triggers the workflow on push or pull request +# events but only for the master branch +on: + push: + branches: [ master ] + paths-ignore: + - '**.md' + - 'examples/**' + pull_request: + branches: [ master ] + paths-ignore: + - '**.md' + - 'examples/**' + +# A workflow run is made up of one or more jobs that can run sequentially or in parallel +jobs: + # This workflow contains a single job called "build" + build: + # The type of runner that the job will run on + runs-on: ubuntu-latest + + # Steps represent a sequence of tasks that will be executed as part of the job + steps: + - uses: actions/checkout@v2 + + - name: Build-Push-DockerHub + uses: docker/build-push-action@v1 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + repository: justb4/mapproxy + tags: latest,2.0.2-2 + tag_with_ref: false + tag_with_sha: false diff --git a/.gitignore b/.gitignore index 6d5d30a..bd26bc0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ # Created by .ignore support plugin (hsz.mobi) mapproxy/ +cache/ .idea/ +log/ + diff --git a/Dockerfile b/Dockerfile index 5e185fc..023ec14 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,39 +1,77 @@ -FROM python:3.5 -MAINTAINER Arne Schubert +FROM python:3.11-slim-bookworm -ENV MAPPROXY_VERSION 1.11.0 -ENV MAPPROXY_PROCESSES 4 -ENV MAPPROXY_THREADS 2 +# Notes by Just van den Broecke +# July 2020 +# The original image (from 2019), based on Debian Buster Python3 was around 1GB. +# Slimmed down to 294MB by: +# - using Debian Slim image. +# - Using https://github.com/geopython/pygeoapi/blob/master/Dockerfile as example. +# - avoided building wheels by installing python- packages +# - removing build dependency packages. +# +# Upgrade notes: Debian bullseye-slim (follow up from Buster) has Python 3.8 +# Currently compat problem with MP 1.12.0 because of "cgi" packages +# See issue: https://github.com/mapproxy/mapproxy/issues/462 +# like wsgi-plugin-python3 (needs to wait) +# +# Upgrade notes: in bullseye: use libproj19 uwsgi-plugin-python3 (i.s.o. pip3 uwsgi) +# --plugin /usr/lib/uwsgi/plugins/python3_plugin.so in uwsgi command and remove --wsgi-disable-file-wrapper + +# May 2024 +# * Python 3.11 and MapProxy 2.0.2 +# * upgrade Base image to python:3.11-slim-bookworm +# * drop support for EPSG:900913 +# * patch TMS demo HTML + +LABEL original_developer="Arne Schubert " +LABEL contributor="Just van den Broecke " + +# Build ARGS +ARG TZ="Europe/Amsterdam" +ARG LOCALE="en_US.UTF-8" +# Only adds 1MB and handy tools +ARG ADD_DEB_PACKAGES="curl xsltproc libxml2-utils patch" +ARG ADD_PIP_PACKAGES="" +ARG MAPPROXY_VERSION="2.0.2" + +# ENV settings +ENV MAPPROXY_PROCESSES="4" \ + MAPPROXY_THREADS="2" \ + UWSGI_EXTRA_OPTIONS="" \ + DEBIAN_FRONTEND="noninteractive" \ + PROJ_DATA="/usr/share/proj" \ + PYTHONPATH="/usr/lib/python3/dist-packages" \ + DEB_BUILD_DEPS="build-essential libpcre2-dev" \ + DEB_PACKAGES="python3-pil python3-yaml python3-pyproj libgeos-dev python3-lxml libgdal-dev python3-shapely libxml2-dev libxslt-dev uwsgi-plugin-python3 ${ADD_DEB_PACKAGES}" \ + PIP_PACKAGES="uwsgi requests geojson watchdog MapProxy==${MAPPROXY_VERSION} ${ADD_PIP_PACKAGES}" RUN set -x \ - && apt-get update \ - && DEBIAN_FRONTEND=noninteractive apt-get install --no-install-recommends -y \ - python-imaging \ - python-yaml \ - libproj12 \ - libgeos-dev \ - python-lxml \ - libgdal-dev \ - build-essential \ - python-dev \ - libjpeg-dev \ - zlib1g-dev \ - libfreetype6-dev \ - python-virtualenv \ - && rm -rf /var/lib/apt/lists/* \ + && apt update \ + && apt install --no-install-recommends -y ${DEB_BUILD_DEPS} ${DEB_PACKAGES} ${ADD_DEB_PACKAGES} \ && useradd -ms /bin/bash mapproxy \ && mkdir -p /mapproxy \ && chown mapproxy /mapproxy \ - && pip install --upgrade pip \ - && pip install Shapely Pillow requests geojson uwsgi MapProxy==$MAPPROXY_VERSION \ - && mkdir -p /docker-entrypoint-initmapproxy.d + && pip3 install ${PIP_PACKAGES} ${ADD_PIP_PACKAGES} \ + && mkdir -p /docker-entrypoint-initmapproxy.d \ + && pip3 uninstall --yes wheel \ + && pip3 cache purge \ + && ln -sf /usr/share/zoneinfo/${TZ} /etc/localtime \ + && apt-get remove --yes --purge ${DEB_BUILD_DEPS} \ + && apt-get --yes --purge autoremove \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +COPY patches/ /patches +RUN cd /patches && ./apply.sh && cd - COPY docker-entrypoint.sh / ENTRYPOINT ["/docker-entrypoint.sh"] CMD ["mapproxy"] USER mapproxy -VOLUME ["/mapproxy"] + +# Why needed? See examples. +# VOLUME ["/mapproxy"] EXPOSE 8080 # Stats EXPOSE 9191 diff --git a/README.md b/README.md index 27b18a2..598d312 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,16 @@ -# Mapproxy for Docker +# MapProxy for Docker -MapProxy docker image from the [YAGA Development-Team](https://yagajs.org) +![GitHub license](https://img.shields.io/github/license/justb4/docker-mapproxy) +![GitHub release](https://img.shields.io/github/release/justb4/docker-mapproxy.svg) +![Docker Pulls](https://img.shields.io/docker/pulls/justb4/mapproxy.svg) + +MapProxy Docker Image from the [YAGA Development-Team](https://github.com/yagajs). +Adapted by [justb4](https://github.com/justb4) to latest MP version, small Docker image and extended examples. +Find image on [Docker Hub](https://hub.docker.com/repository/docker/justb4/mapproxy). ## Supported tags -* `1.11.0`, `1.11`, `1`, `latest` -* `1.11.0-alpine`, `1.11-alpine`, `1-alpine`, `alpine` -* `1.10.4`, `1.10` -* `1.10.4-alpine`, `1.10-alpine` -* `1.10.3` -* `1.10.3-alpine` -* `1.10.2` -* `1.10.2-alpine` -* `1.10.1` -* `1.10.1-alpine` -* `1.10.0` -* `1.10.0-alpine` -* `1.9.1`, `1.9` -* `1.9.1-alpine`, `1.9-alpine` -* `1.9.0` -* `1.9.0-alpine` -* `1.8.2`, `1.8` -* `1.8.2-alpine`, `1.8-alpine` -* `1.8.1` -* `1.8.1-alpine` -* `1.8.0` -* `1.8.0-alpine` -* `1.7.1`, `1.7` -* `1.7.1-alpine`, `1.7-alpine` -* `1.7.0` -* `1.7.0-alpine` +See [Docker Hub](https://hub.docker.com/repository/docker/justb4/mapproxy) ## What is MapProxy @@ -38,43 +19,104 @@ data from existing map services and serves any desktop or web GIS client. ## Run container -You can run the container with a command like this: +See the examples, these use `docker-compose`, more convenient than `docker run` commands: + +* [default](examples/default) - default out-of-the-box example +* [standard](examples/standard) - mapproxy [config](examples/standard/config/mapproxy.yaml) with some facilities like GeoPackage tile cache, custom grid etc + +The second example should give you a nice starter. + +But you can run the container with standard `docker`: ```bash -docker run -v /path/to/mapproxy:/mapproxy -p 8080:8080 yagajs/mapproxy +docker run -v /path/to/mapproxy:/mapproxy -p 8080:8080 justb4/mapproxy ``` *It is optional, but recommended to add a volume. Within the volume mapproxy get the configuration, or create one automatically. Cached tiles will be stored also into this volume.* -The container normally runs in [http-socket-mode](http://uwsgi-docs.readthedocs.io/en/latest/HTTP.html). If you will not -run the image behind a HTTP-Proxy, like [Nginx](http://nginx.org/), you can run it in direct http-mode by running: +The container normally runs in [http-socket-mode](http://uwsgi-docs.readthedocs.io/en/latest/HTTP.html). If you are not +running the image behind an HTTP-Proxy, like [Nginx](http://nginx.org/), you should run it in direct `http-mode` by running: ```bash -docker run -v /path/to/mapproxy:/mapproxy -p 8080:8080 yagajs/mapproxy mapproxy http +docker run -v /path/to/mapproxy:/mapproxy -p 8080:8080 justb4/mapproxy mapproxy http ``` ### Environment variables * `MAPPROXY_PROCESSES` default: 4 * `MAPPROXY_THREADS` default: 2 +* `UWSGI_EXTRA_OPTIONS` extra `uwsgi` commandline options e.g. `"--disable-logging --stats 0.0.0.0:9191"`, default empty + +### Run as local user + +In some cases, especially when using mounted volumes, you may get permission issues on directories and (log-) files. +You can also have the Docker Container run as your local user (id) i.s.o. `mapproxy`. But never run as user root! + +In a `docker-compose.yml` you may set: `user: ${HOST_UID_GID}`. This env var can be generated: `export HOST_UID_GID="$(id -u):$(id -g)"`, in your +local environment or a start script. + +See the [docker-compose.yml in examples/standard](examples/standard/docker-compose.yml) and +[start.sh there](examples/standard/start.sh). + +## Seeding + +The image also allows arbitrary commands like for seeding: +```bash + +docker exec -it mapproxy mapproxy-seed -f /mapproxy/mapproxy.yaml -s /mapproxy/seed.yaml --seed myseed1 + +``` + +## Proj Version Info + +Proj in `/usr/lib` from `python3-proj` package. + +``` +$ /usr/bin/pyproj -v + +pyproj info: + pyproj: 3.4.1 + PROJ: 9.1.1 + data dir: /usr/share/proj +user_data_dir: /tmp/proj +PROJ DATA (recommended version): 1.12 +PROJ Database: 1.2 +EPSG Database: v10.076 [2022-08-31] +ESRI Database: ArcGIS Pro 3.0 [2022-07-09] +IGNF Database: 3.1.0 [2019-05-24] + +System: + python: 3.11.2 (main, Mar 13 2023, 12:18:29) [GCC 12.2.0] +executable: /usr/bin/python3 + machine: Linux-6.6.22-linuxkit-aarch64-with-glibc2.36 + +Python deps: + certifi: 2022.9.24 + Cython: None +setuptools: None + pip: None + +``` ## Enhance the image You can put a `mapproxy.yaml` into the `/docker-entrypoint-initmapproxy.d` folder on the image. On startup this will be used as MapProxy configuration. Attention, this will override an existing configuration in the volume! -Additional you can put shell-scripts, with `.sh`-suffix in that folder. They get executed on container startup. +In addition, you can put shell-scripts, with `.sh`-suffix in that folder. They get executed on container startup. You should use the `mapproxy` user within the container, especially not `root`! +You can also add extra packages in build args: `ADD_DEB_PACKAGES` and `ADD_PIP_PACKAGES`. + ## Contributing You are invited to contribute new features, fixes, or updates, large or small; we are always thrilled to receive pull requests, and do our best to process them as fast as we can. Before you start to code, we recommend discussing your plans through a -[GitHub issue](https://github.com/yagajs/docker-mapproxy/issues), especially for more ambitious contributions. +[GitHub issue](https://github.com/justb4/docker-mapproxy/issues), especially for more ambitious contributions. This gives other contributors a chance to point you in the right direction, give you feedback on your design, and help you find out if someone else is working on the same thing. diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index eb0d3a0..5ac0d87 100755 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -18,15 +18,25 @@ if [ "$1" = 'mapproxy' ]; then if [ ! -f /mapproxy/app.py ] ;then mapproxy-util create -t wsgi-app -f /mapproxy/mapproxy.yaml /mapproxy/app.py fi - echo "Start mapproxy" # --wsgi-disable-file-wrapper is required because of https://github.com/unbit/uwsgi/issues/1126 + # Note: JvdB June 30, 2020: seems fixed but need Debian Bullseye: + # https://github.com/unbit/uwsgi/pull/2069, --wsgi-disable-file-wrapper even gives startup-error in Debian Bullseye... + # Also to wait for Bullseye: --plugin /usr/lib/uwsgi/plugins/python3_plugin.so + UWSGI_PROTO="http-socket" if [ "$2" = 'http' ]; then - exec uwsgi --wsgi-disable-file-wrapper --http 0.0.0.0:8080 --wsgi-file /mapproxy/app.py --master --enable-threads --processes $MAPPROXY_PROCESSES --threads $MAPPROXY_THREADS --stats 0.0.0.0:9191 - exit + UWSGI_PROTO="http" fi - - exec uwsgi --wsgi-disable-file-wrapper --http-socket 0.0.0.0:8080 --wsgi-file /mapproxy/app.py --master --enable-threads --processes $MAPPROXY_PROCESSES --threads $MAPPROXY_THREADS --stats 0.0.0.0:9191 + echo "Start MapProxy with $MAPPROXY_PROCESSES processes and $MAPPROXY_THREADS threads, proto=$UWSGI_PROTO UWSGI_EXTRA_OPTIONS=$UWSGI_EXTRA_OPTIONS" + exec uwsgi \ + --plugin /usr/lib/uwsgi/plugins/python3_plugin.so \ + --$UWSGI_PROTO 0.0.0.0:8080 \ + --wsgi-file /mapproxy/app.py \ + --master \ + --enable-threads \ + --processes $MAPPROXY_PROCESSES \ + --threads $MAPPROXY_THREADS \ + $UWSGI_EXTRA_OPTIONS exit fi diff --git a/examples/default/README.md b/examples/default/README.md new file mode 100644 index 0000000..ec50175 --- /dev/null +++ b/examples/default/README.md @@ -0,0 +1,17 @@ +# Example - Default + +No configuration. Uses all defaults to have something working out-of-the-box. +Uses `docker-compose`. + +How to use: + +``` +./build.sh +./start.sh +browse to http://localhost:8085/demo +./stop.sh +``` + +The local directory `./mapproxy` will be populated with a default config and MP WGSI app. +You can delete the directory `./mapproxy` after use or use that as a starter for your app. +See the other examples for more adavanced setup. diff --git a/examples/default/build.sh b/examples/default/build.sh new file mode 100755 index 0000000..250e4a7 --- /dev/null +++ b/examples/default/build.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +docker-compose build diff --git a/examples/default/docker-compose.yml b/examples/default/docker-compose.yml new file mode 100644 index 0000000..0fc03c2 --- /dev/null +++ b/examples/default/docker-compose.yml @@ -0,0 +1,19 @@ +# Example docker-compose file, adapt for your setup +services: + + mapproxy: + + image: justb4/mapproxy:latest + build: ../../. + + container_name: mapproxy + + environment: + - MAPPROXY_PROCESSES=4 + - MAPPROXY_THREADS=2 + + ports: + - "8085:8080" + +# volumes: +# - ./mapproxy:/mapproxy:rw diff --git a/examples/default/start.sh b/examples/default/start.sh new file mode 100755 index 0000000..6a64712 --- /dev/null +++ b/examples/default/start.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +mkdir -p mapproxy +docker-compose rm --force --stop +docker-compose up -d diff --git a/examples/default/stop.sh b/examples/default/stop.sh new file mode 100755 index 0000000..c3c52dc --- /dev/null +++ b/examples/default/stop.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +docker-compose rm --force --stop diff --git a/examples/standard/README.md b/examples/standard/README.md new file mode 100644 index 0000000..aba1fee --- /dev/null +++ b/examples/standard/README.md @@ -0,0 +1,48 @@ +# Example - Standard + +A more standard example more matching real-life use. +It uses: + +* Open Dutch Aerial maps +Uses `docker-compose`. + +How to use: + +``` +./build.sh +./start.sh +browse to http://localhost:8085/demo/?tms_layer=dutch_aerial&format=jpeg&srs=EPSG%3A28992 +./stop.sh +``` + +The local directory `./config` will be mapped to the MP Container `/mapproxy` dir. +The `cache` directory will be mapped to the `/mapproxy_cache` dir. +Also we enabled logging into the local `log/` dir using [log.ini](config/log.ini) +After running you can remove the `log` and `cache` dirs. + +## Alternaive + +Using `docker run`: + +```bash +docker run -v $PWD/config:/mapproxy -v $PWD/cache:/mapproxy_cache -p 8085:8080 justb4/mapproxy mapproxy http + +``` +## Seeding + +The image also allows arbitrary commands like seeding: + +Un running container: + +```bash + +docker exec -it mapproxy mapproxy-seed -f /mapproxy/mapproxy.yaml -s /mapproxy/seed.yaml --seed myseed1 + +``` + +or standalone + +```bash +docker run -v $PWD/config:/mapproxy -v $PWD/cache:/mapproxy_cache -p 8085:8080 justb4/mapproxy \ + mapproxy-seed -f /mapproxy/mapproxy.yaml -s /mapproxy/seed.yaml --seed myseed1 +``` diff --git a/examples/standard/build.sh b/examples/standard/build.sh new file mode 100755 index 0000000..250e4a7 --- /dev/null +++ b/examples/standard/build.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +docker-compose build diff --git a/examples/standard/config/app.py b/examples/standard/config/app.py new file mode 100644 index 0000000..8af6daf --- /dev/null +++ b/examples/standard/config/app.py @@ -0,0 +1,15 @@ +# WSGI module for use with Apache mod_wsgi or gunicorn + +# # uncomment the following lines for logging +# # create a log.ini with `mapproxy-util create -t log-ini` +# from logging.config import fileConfig +# import os.path +# fileConfig(r'/mapproxy/log.ini', {'here': os.path.dirname(__file__)}) + +import logging.config +from mapproxy.wsgiapp import make_wsgi_app + +# Setup logging +logging.config.fileConfig(r'/mapproxy/log.ini') + +application = make_wsgi_app(r'/mapproxy/mapproxy.yaml') diff --git a/examples/standard/config/log.ini b/examples/standard/config/log.ini new file mode 100644 index 0000000..1e41145 --- /dev/null +++ b/examples/standard/config/log.ini @@ -0,0 +1,45 @@ +# +# CRITICAL 50 +# ERROR 40 +# WARNING 30 +# INFO 20 +# DEBUG 10 +# NOTSET + +[loggers] +keys=root,source_requests + +[handlers] +keys=mapproxy,source_requests + +[formatters] +keys=default,requests + +[logger_root] +level=DEBUG +handlers=mapproxy + +[logger_source_requests] +level=DEBUG +qualname=mapproxy.source.request +# propagate=0 -> do not show up in logger_root +propagate=0 +handlers=source_requests + +[handler_mapproxy] +class=FileHandler +formatter=default +# args=(r"%(here)s/mapproxy.log", "a") +args=('/mapproxy_log/client.log','a') + +[handler_source_requests] +class=FileHandler +formatter=requests +# args=(r"%(here)s/source-requests.log", "a") +args=('/mapproxy_log/source-requests.log','a') + +[formatter_default] +format=%(asctime)s - %(levelname)s - %(name)s - %(message)s + +[formatter_requests] +format=[%(asctime)s] %(message)s diff --git a/examples/standard/config/mapproxy.yaml b/examples/standard/config/mapproxy.yaml new file mode 100644 index 0000000..ec432e4 --- /dev/null +++ b/examples/standard/config/mapproxy.yaml @@ -0,0 +1,89 @@ +# More standard MapProxy configuration showing: +# +# - GeoPackage as tile cache +# - custom grid (using Dutch standard tiling grid) +# - multiple tilegrids/caches + +services: + demo: + tms: + # origin: 'nw' + use_grid_names: false + kml: + use_grid_names: true + wmts: + kvp: true + restful: true + restful_template: '/{Layer}/{TileMatrixSet}/{TileMatrix}/{TileCol}/{TileRow}.{Format}' + wms: + srs: [ 'EPSG:4326', 'EPSG:3857', 'EPSG:28992' ] + md: + title: MapProxy WMS Proxy + abstract: This is a minimal MapProxy example. + +layers: + - name: osm + title: Omniscale OSM WMS - osm.omniscale.net + sources: [osm_cache] + + - name: dutch_aerial + title: Dutch public aerial Map - by Dutch Kadaster-PDOK - RGB 7.5cm resolution - latest + sources: [dutch_aerial_cache] + +caches: + osm_cache: + grids: [webmercator] + sources: [osm_wms] + + dutch_aerial_cache: + # Store res 0-12 (RD) and 0-17 (merc, 18 levels) + grids: [dutch_tile_grid, webmercator] + sources: [dutch_aerial_wms] + format: image/jpeg + meta_buffer: 0 + meta_size: [4,4] + use_direct_from_res: 0.30 + cache: + type: geopackage + filename: gpkg/openlufo.gpkg + levels: false + +sources: + osm_wms: + type: wms + req: + url: https://maps.omniscale.net/v2/demo/style.default/service? + layers: osm + + dutch_aerial_wms: + type: wms + req: + url: https://service.pdok.nl/hwh/luchtfotorgb/wms/v1_0? + layers: Actueel_orthoHR + format: image/jpeg + transparent: false + coverage: + # Extent of 37.5 px/km map + bbox: [-285401.92,22598.08,595401.92,903401.92] + srs: 'EPSG:28992' + supported_srs: ['EPSG:28992', 'EPSG:4326', 'EPSG:3857'] + +grids: + webmercator: + base: GLOBAL_WEBMERCATOR + + dutch_tile_grid: + origin: 'sw' + tile_size: [256, 256] + srs: 'EPSG:28992' + bbox: [-285401.920, 22598.080, 595401.920, 903401.920] + bbox_srs: 'EPSG:28992' + # 17 levels 0-16 + res: [3440.64, 1720.32, 860.16, 430.08, 215.04, 107.52, 53.76, 26.88, 13.44, 6.72, 3.36, 1.68, 0.84, 0.42, 0.21, 0.105, 0.0525] + +globals: + cache: + # where to store the cached images + base_dir: '/mapproxy_cache' + # where to store lockfiles + lock_dir: '/mapproxy_cache/locks' diff --git a/examples/standard/config/seed.yaml b/examples/standard/config/seed.yaml new file mode 100644 index 0000000..bec7649 --- /dev/null +++ b/examples/standard/config/seed.yaml @@ -0,0 +1,27 @@ +# --------------------------------------- +# MapProxy example seeding configuration. +# --------------------------------------- +# +# This is a minimal MapProxy seeding configuration. +# See full_seed_example.yaml and the documentation for more options. +# + +seeds: + myseed1: + caches: [osm_cache] + # grids: [] + # coverages: [] + levels: + to: 10 + refresh_before: + time: 2013-10-10T12:35:00 + +cleanups: + myclean1: + caches: [osm_cache] + remove_before: + days: 14 + levels: + from: 11 + +coverages: diff --git a/examples/standard/docker-compose.yml b/examples/standard/docker-compose.yml new file mode 100644 index 0000000..81bce4d --- /dev/null +++ b/examples/standard/docker-compose.yml @@ -0,0 +1,24 @@ +# Example docker-compose file, adapt for your setup +services: + + mapproxy: + + image: justb4/mapproxy:latest + build: ../../. + + # See start.sh + user: ${HOST_UID_GID} + + container_name: mapproxy + + environment: + - MAPPROXY_PROCESSES=4 + - MAPPROXY_THREADS=2 + + ports: + - "8085:8080" + + volumes: + - ./config:/mapproxy:rw + - ./cache:/mapproxy_cache:rw + - ./log:/mapproxy_log:rw diff --git a/examples/standard/start.sh b/examples/standard/start.sh new file mode 100755 index 0000000..3e1ebb1 --- /dev/null +++ b/examples/standard/start.sh @@ -0,0 +1,9 @@ +#!/bin/bash + +# Run as this/your local user +export HOST_UID_GID="$(id -u):$(id -g)" + +mkdir -p cache +mkdir -p log +docker-compose rm --force --stop +docker-compose up -d diff --git a/examples/standard/stop.sh b/examples/standard/stop.sh new file mode 100755 index 0000000..c3c52dc --- /dev/null +++ b/examples/standard/stop.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +docker-compose rm --force --stop diff --git a/patches/01-srs-iter/README.md b/patches/01-srs-iter/README.md new file mode 100644 index 0000000..0dd2dd1 --- /dev/null +++ b/patches/01-srs-iter/README.md @@ -0,0 +1,15 @@ +# What this solves +In some cases the demo page renders with 'internal error' +See issue: https://github.com/mapproxy/mapproxy/issues/430 +Error in uwsgi output + +Update: fixed by https://github.com/mapproxy/mapproxy/pull/420 +And in 1.13.2 version. Skipping in [apply.sh](apply.sh), but keeping the fix if ever needed. + +# commands + +``` +diff -u srs-1.12.0.py srs-master.py > srs-iter.patch +patch /usr/local/lib/python3.7/dist-packages/mapproxy/srs.py srs-iter.patch + +``` \ No newline at end of file diff --git a/patches/01-srs-iter/apply.sh b/patches/01-srs-iter/apply.sh new file mode 100755 index 0000000..cb50a0f --- /dev/null +++ b/patches/01-srs-iter/apply.sh @@ -0,0 +1,3 @@ +#!/bin/bash +set -x +echo "SKIP: patch /usr/local/lib/python3.7/dist-packages/mapproxy/srs.py srs-iter.patch" diff --git a/patches/01-srs-iter/srs-1.12.0.py b/patches/01-srs-iter/srs-1.12.0.py new file mode 100644 index 0000000..b003e75 --- /dev/null +++ b/patches/01-srs-iter/srs-1.12.0.py @@ -0,0 +1,455 @@ +# -*- coding: utf-8 -*- +# This file is part of the MapProxy project. +# Copyright (C) 2010 Omniscale +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Spatial reference systems and transformation of coordinates. +""" +from __future__ import division + +import math +import threading +from mapproxy.compat.itertools import izip +from mapproxy.compat import string_type +from mapproxy.proj import Proj, transform, set_datapath +from mapproxy.config import base_config + +import logging +log_system = logging.getLogger('mapproxy.system') +log_proj = logging.getLogger('mapproxy.proj') + +def get_epsg_num(epsg_code): + """ + >>> get_epsg_num('ePsG:4326') + 4326 + >>> get_epsg_num(4313) + 4313 + >>> get_epsg_num('31466') + 31466 + """ + if isinstance(epsg_code, string_type): + if ':' in epsg_code: + epsg_code = int(epsg_code.split(':')[1]) + else: + epsg_code = int(epsg_code) + return epsg_code + +def _clean_srs_code(code): + """ + >>> _clean_srs_code(4326) + 'EPSG:4326' + >>> _clean_srs_code('31466') + 'EPSG:31466' + >>> _clean_srs_code('crs:84') + 'CRS:84' + """ + if isinstance(code, string_type) and ':' in code: + return code.upper() + else: + return 'EPSG:' + str(code) + +class TransformationError(Exception): + pass + +_proj_initialized = False +def _init_proj(): + global _proj_initialized + if not _proj_initialized and 'proj_data_dir' in base_config().srs: + proj_data_dir = base_config().srs['proj_data_dir'] + if proj_data_dir is None: + _proj_initialized = True + return + log_system.info('loading proj data from %s', proj_data_dir) + set_datapath(proj_data_dir) + _proj_initialized = True + +_thread_local = threading.local() +def SRS(srs_code): + _init_proj() + if isinstance(srs_code, _SRS): + return srs_code + + srs_code = _clean_srs_code(srs_code) + + if not hasattr(_thread_local, 'srs_cache'): + _thread_local.srs_cache = {} + + if srs_code in _thread_local.srs_cache: + return _thread_local.srs_cache[srs_code] + else: + srs = _SRS(srs_code) + _thread_local.srs_cache[srs_code] = srs + return srs + +WEBMERCATOR_EPSG = set(('EPSG:900913', 'EPSG:3857', + 'EPSG:102100', 'EPSG:102113')) + +class _SRS(object): + # http://trac.openlayers.org/wiki/SphericalMercator + proj_init = { + 'EPSG:4326': lambda: Proj('+proj=longlat +ellps=WGS84 +datum=WGS84 +no_defs +over'), + 'CRS:84': lambda: Proj('+proj=longlat +ellps=WGS84 +datum=WGS84 +no_defs +over'), + } + for _epsg in WEBMERCATOR_EPSG: + proj_init[_epsg] = lambda: Proj( + '+proj=merc +a=6378137 +b=6378137 +lat_ts=0.0 ' + '+lon_0=0.0 +x_0=0.0 +y_0=0 +k=1.0 +units=m ' + '+nadgrids=@null +no_defs +over') + + """ + This class represents a Spatial Reference System. + """ + def __init__(self, srs_code): + """ + Create a new SRS with the given `srs_code` code. + """ + self.srs_code = srs_code + + init = _SRS.proj_init.get(srs_code, None) + if init is not None: + self.proj = init() + else: + epsg_num = get_epsg_num(srs_code) + self.proj = Proj(init='epsg:%d' % epsg_num) + + def transform_to(self, other_srs, points): + """ + :type points: ``(x, y)`` or ``[(x1, y1), (x2, y2), …]`` + + >>> srs1 = SRS(4326) + >>> srs2 = SRS(900913) + >>> [str(round(x, 5)) for x in srs1.transform_to(srs2, (8.22, 53.15))] + ['915046.21432', '7010792.20171'] + >>> srs1.transform_to(srs1, (8.25, 53.5)) + (8.25, 53.5) + >>> [(str(round(x, 5)), str(round(y, 5))) for x, y in + ... srs1.transform_to(srs2, [(8.2, 53.1), (8.22, 53.15), (8.3, 53.2)])] + ... #doctest: +NORMALIZE_WHITESPACE + [('912819.8245', '7001516.67745'), + ('915046.21432', '7010792.20171'), + ('923951.77358', '7020078.53264')] + """ + if self == other_srs: + return points + if isinstance(points[0], (int, float)) and 2 >= len(points) <= 3: + return transform(self.proj, other_srs.proj, *points) + + x = [p[0] for p in points] + y = [p[1] for p in points] + transf_pts = transform(self.proj, other_srs.proj, x, y) + return izip(transf_pts[0], transf_pts[1]) + + def transform_bbox_to(self, other_srs, bbox, with_points=16): + """ + + :param with_points: the number of points to use for the transformation. + A bbox transformation with only two or four points may cut off some + parts due to distortions. + + >>> ['%.3f' % x for x in + ... SRS(4326).transform_bbox_to(SRS(900913), (-180.0, -90.0, 180.0, 90.0))] + ['-20037508.343', '-147730762.670', '20037508.343', '147730758.195'] + >>> ['%.5f' % x for x in + ... SRS(4326).transform_bbox_to(SRS(900913), (8.2, 53.1, 8.3, 53.2))] + ['912819.82450', '7001516.67745', '923951.77358', '7020078.53264'] + >>> SRS(4326).transform_bbox_to(SRS(4326), (8.25, 53.0, 8.5, 53.75)) + (8.25, 53.0, 8.5, 53.75) + """ + if self == other_srs: + return bbox + bbox = self.align_bbox(bbox) + points = generate_envelope_points(bbox, with_points) + transf_pts = self.transform_to(other_srs, points) + result = calculate_bbox(transf_pts) + + log_proj.debug('transformed from %r to %r (%s -> %s)' % + (self, other_srs, bbox, result)) + + return result + + def align_bbox(self, bbox): + """ + Align bbox to reasonable values to prevent errors in transformations. + E.g. transformations from EPSG:4326 with lat=90 or -90 will fail, so + we subtract a tiny delta. + + At the moment only EPSG:4326 bbox will be modifyed. + + >>> bbox = SRS(4326).align_bbox((-180, -90, 180, 90)) + >>> -90 < bbox[1] < -89.99999998 + True + >>> 90 > bbox[3] > 89.99999998 + True + """ + # TODO should not be needed anymore since we transform with +over + # still a few tests depend on the rounding behavior of this + if self.srs_code == 'EPSG:4326': + delta = 0.00000001 + (minx, miny, maxx, maxy) = bbox + if abs(miny - -90.0) < 1e-6: + miny = -90.0 + delta + if abs(maxy - 90.0) < 1e-6: + maxy = 90.0 - delta + bbox = minx, miny, maxx, maxy + return bbox + + @property + def is_latlong(self): + """ + >>> SRS(4326).is_latlong + True + >>> SRS(31466).is_latlong + False + """ + return self.proj.is_latlong() + + @property + def is_axis_order_ne(self): + """ + Returns `True` if the axis order is North, then East + (i.e. y/x or lat/lon). + + >>> SRS(4326).is_axis_order_ne + True + >>> SRS('CRS:84').is_axis_order_ne + False + >>> SRS(31468).is_axis_order_ne + True + >>> SRS(31463).is_axis_order_ne + False + >>> SRS(25831).is_axis_order_ne + False + """ + if self.srs_code in base_config().srs.axis_order_ne: + return True + if self.srs_code in base_config().srs.axis_order_en: + return False + if self.is_latlong: + return True + return False + + @property + def is_axis_order_en(self): + """ + Returns `True` if the axis order is East then North + (i.e. x/y or lon/lat). + """ + return not self.is_axis_order_ne + + def __eq__(self, other): + """ + >>> SRS(4326) == SRS("EpsG:4326") + True + >>> SRS(4326) == SRS("4326") + True + >>> SRS(4326) == SRS(3857) + False + """ + if isinstance(other, _SRS): + return self.proj.srs == other.proj.srs + else: + return NotImplemented + + def __ne__(self, other): + """ + >>> SRS(3857) != SRS(3857) + False + >>> SRS(4326) != SRS(900913) + True + """ + equal_result = self.__eq__(other) + if equal_result is NotImplemented: + return NotImplemented + else: + return not equal_result + + def __str__(self): + #pylint: disable-msg=E1101 + return "SRS %s ('%s')" % (self.srs_code, self.proj.srs) + + def __repr__(self): + """ + >>> repr(SRS(4326)) + "SRS('EPSG:4326')" + """ + return "SRS('%s')" % (self.srs_code,) + + def __hash__(self): + return hash(self.srs_code) + + +def generate_envelope_points(bbox, n): + """ + Generates points that form a linestring around a given bbox. + + @param bbox: bbox to generate linestring for + @param n: the number of points to generate around the bbox + + >>> generate_envelope_points((10.0, 5.0, 20.0, 15.0), 4) + [(10.0, 5.0), (20.0, 5.0), (20.0, 15.0), (10.0, 15.0)] + >>> generate_envelope_points((10.0, 5.0, 20.0, 15.0), 8) + ... #doctest: +NORMALIZE_WHITESPACE + [(10.0, 5.0), (15.0, 5.0), (20.0, 5.0), (20.0, 10.0),\ + (20.0, 15.0), (15.0, 15.0), (10.0, 15.0), (10.0, 10.0)] + """ + (minx, miny, maxx, maxy) = bbox + if n <= 4: + n = 0 + else: + n = int(math.ceil((n - 4) / 4.0)) + + width = maxx - minx + height = maxy - miny + + minx, maxx = min(minx, maxx), max(minx, maxx) + miny, maxy = min(miny, maxy), max(miny, maxy) + + n += 1 + xstep = width / n + ystep = height / n + result = [] + for i in range(n+1): + result.append((minx + i*xstep, miny)) + for i in range(1, n): + result.append((maxx, miny + i*ystep)) + for i in range(n, -1, -1): + result.append((minx + i*xstep, maxy)) + for i in range(n-1, 0, -1): + result.append((minx, miny + i*ystep)) + return result + +def calculate_bbox(points): + """ + Calculates the bbox of a list of points. + + >>> calculate_bbox([(-5, 20), (3, 8), (99, 0)]) + (-5, 0, 99, 20) + + @param points: list of points [(x0, y0), (x1, y2), ...] + @returns: bbox of the input points. + """ + points = list(points) + # points can be INF for invalid transformations, filter out + try: + minx = min(p[0] for p in points if p[0] != float('inf')) + miny = min(p[1] for p in points if p[1] != float('inf')) + maxx = max(p[0] for p in points if p[0] != float('inf')) + maxy = max(p[1] for p in points if p[1] != float('inf')) + return (minx, miny, maxx, maxy) + except ValueError: # min/max are called with empty list when everything is inf + raise TransformationError() + +def merge_bbox(bbox1, bbox2): + """ + Merge two bboxes. + + >>> merge_bbox((-10, 20, 0, 30), (30, -20, 90, 10)) + (-10, -20, 90, 30) + + """ + minx = min(bbox1[0], bbox2[0]) + miny = min(bbox1[1], bbox2[1]) + maxx = max(bbox1[2], bbox2[2]) + maxy = max(bbox1[3], bbox2[3]) + return (minx, miny, maxx, maxy) + +def bbox_equals(src_bbox, dst_bbox, x_delta=None, y_delta=None): + """ + Compares two bbox and checks if they are equal, or nearly equal. + + :param x_delta: how precise the comparison should be. + should be reasonable small, like a tenth of a pixel. + defaults to 1/1.000.000th of the width. + :type x_delta: bbox units + + >>> src_bbox = (939258.20356824622, 6887893.4928338043, + ... 1095801.2374962866, 7044436.5267618448) + >>> dst_bbox = (939258.20260000182, 6887893.4908000007, + ... 1095801.2365000017, 7044436.5247000009) + >>> bbox_equals(src_bbox, dst_bbox, 61.1, 61.1) + True + >>> bbox_equals(src_bbox, dst_bbox, 0.0001) + False + """ + if x_delta is None: + x_delta = abs(src_bbox[0] - src_bbox[2]) / 1000000.0 + if y_delta is None: + y_delta = x_delta + return (abs(src_bbox[0] - dst_bbox[0]) < x_delta and + abs(src_bbox[1] - dst_bbox[1]) < x_delta and + abs(src_bbox[2] - dst_bbox[2]) < y_delta and + abs(src_bbox[3] - dst_bbox[3]) < y_delta) + +def make_lin_transf(src_bbox, dst_bbox): + """ + Create a transformation function that transforms linear between two + plane coordinate systems. + One needs to be cartesian (0, 0 at the lower left, x goes up) and one + needs to be an image coordinate system (0, 0 at the top left, x goes down). + + :return: function that takes src x/y and returns dest x/y coordinates + + >>> transf = make_lin_transf((7, 50, 8, 51), (0, 0, 500, 400)) + >>> transf((7.5, 50.5)) + (250.0, 200.0) + >>> transf((7.0, 50.0)) + (0.0, 400.0) + >>> transf = make_lin_transf((7, 50, 8, 51), (200, 300, 700, 700)) + >>> transf((7.5, 50.5)) + (450.0, 500.0) + """ + func = lambda x_y: (dst_bbox[0] + (x_y[0] - src_bbox[0]) * + (dst_bbox[2]-dst_bbox[0]) / (src_bbox[2] - src_bbox[0]), + dst_bbox[1] + (src_bbox[3] - x_y[1]) * + (dst_bbox[3]-dst_bbox[1]) / (src_bbox[3] - src_bbox[1])) + return func + + +class PreferredSrcSRS(object): + def __init__(self): + self.target_proj = {} + + def add(self, target, prefered_srs): + self.target_proj[target] = prefered_srs + + def preferred_src(self, target, available_src): + if not available_src: + raise ValueError("no available src SRS") + if target in available_src: + return target + if target in self.target_proj: + for preferred in self.target_proj[target]: + if preferred in available_src: + return preferred + + for avail in available_src: + if avail.is_latlong == target.is_latlong: + return avail + return available_src[0] + +class SupportedSRS(object): + def __init__(self, supported_srs, preferred_srs=None): + self.supported_srs = supported_srs + self.preferred_srs = preferred_srs or PreferredSrcSRS() + + def __contains__(self, srs): + return srs in self.supported_srs + + def best_srs(self, target): + return self.preferred_srs.preferred_src(target, self.supported_srs) + + def __eq__(self, other): + # .prefered_srs is set global, so we only compare .supported_srs + return self.supported_srs == other.supported_srs \ No newline at end of file diff --git a/patches/01-srs-iter/srs-iter.patch b/patches/01-srs-iter/srs-iter.patch new file mode 100644 index 0000000..e6d30aa --- /dev/null +++ b/patches/01-srs-iter/srs-iter.patch @@ -0,0 +1,12 @@ +--- srs-1.12.0.py 2020-09-16 15:10:18.000000000 +0200 ++++ srs-master.py 2020-09-16 15:16:47.000000000 +0200 +@@ -444,6 +444,9 @@ + self.supported_srs = supported_srs + self.preferred_srs = preferred_srs or PreferredSrcSRS() + ++ def __iter__(self): ++ return iter(self.supported_srs) ++ + def __contains__(self, srs): + return srs in self.supported_srs + diff --git a/patches/01-srs-iter/srs-master.py b/patches/01-srs-iter/srs-master.py new file mode 100644 index 0000000..26f5375 --- /dev/null +++ b/patches/01-srs-iter/srs-master.py @@ -0,0 +1,458 @@ +# -*- coding: utf-8 -*- +# This file is part of the MapProxy project. +# Copyright (C) 2010 Omniscale +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Spatial reference systems and transformation of coordinates. +""" +from __future__ import division + +import math +import threading +from mapproxy.compat.itertools import izip +from mapproxy.compat import string_type +from mapproxy.proj import Proj, transform, set_datapath +from mapproxy.config import base_config + +import logging +log_system = logging.getLogger('mapproxy.system') +log_proj = logging.getLogger('mapproxy.proj') + +def get_epsg_num(epsg_code): + """ + >>> get_epsg_num('ePsG:4326') + 4326 + >>> get_epsg_num(4313) + 4313 + >>> get_epsg_num('31466') + 31466 + """ + if isinstance(epsg_code, string_type): + if ':' in epsg_code: + epsg_code = int(epsg_code.split(':')[1]) + else: + epsg_code = int(epsg_code) + return epsg_code + +def _clean_srs_code(code): + """ + >>> _clean_srs_code(4326) + 'EPSG:4326' + >>> _clean_srs_code('31466') + 'EPSG:31466' + >>> _clean_srs_code('crs:84') + 'CRS:84' + """ + if isinstance(code, string_type) and ':' in code: + return code.upper() + else: + return 'EPSG:' + str(code) + +class TransformationError(Exception): + pass + +_proj_initialized = False +def _init_proj(): + global _proj_initialized + if not _proj_initialized and 'proj_data_dir' in base_config().srs: + proj_data_dir = base_config().srs['proj_data_dir'] + if proj_data_dir is None: + _proj_initialized = True + return + log_system.info('loading proj data from %s', proj_data_dir) + set_datapath(proj_data_dir) + _proj_initialized = True + +_thread_local = threading.local() +def SRS(srs_code): + _init_proj() + if isinstance(srs_code, _SRS): + return srs_code + + srs_code = _clean_srs_code(srs_code) + + if not hasattr(_thread_local, 'srs_cache'): + _thread_local.srs_cache = {} + + if srs_code in _thread_local.srs_cache: + return _thread_local.srs_cache[srs_code] + else: + srs = _SRS(srs_code) + _thread_local.srs_cache[srs_code] = srs + return srs + +WEBMERCATOR_EPSG = set(('EPSG:900913', 'EPSG:3857', + 'EPSG:102100', 'EPSG:102113')) + +class _SRS(object): + # http://trac.openlayers.org/wiki/SphericalMercator + proj_init = { + 'EPSG:4326': lambda: Proj('+proj=longlat +ellps=WGS84 +datum=WGS84 +no_defs +over'), + 'CRS:84': lambda: Proj('+proj=longlat +ellps=WGS84 +datum=WGS84 +no_defs +over'), + } + for _epsg in WEBMERCATOR_EPSG: + proj_init[_epsg] = lambda: Proj( + '+proj=merc +a=6378137 +b=6378137 +lat_ts=0.0 ' + '+lon_0=0.0 +x_0=0.0 +y_0=0 +k=1.0 +units=m ' + '+nadgrids=@null +no_defs +over') + + """ + This class represents a Spatial Reference System. + """ + def __init__(self, srs_code): + """ + Create a new SRS with the given `srs_code` code. + """ + self.srs_code = srs_code + + init = _SRS.proj_init.get(srs_code, None) + if init is not None: + self.proj = init() + else: + epsg_num = get_epsg_num(srs_code) + self.proj = Proj(init='epsg:%d' % epsg_num) + + def transform_to(self, other_srs, points): + """ + :type points: ``(x, y)`` or ``[(x1, y1), (x2, y2), …]`` + + >>> srs1 = SRS(4326) + >>> srs2 = SRS(900913) + >>> [str(round(x, 5)) for x in srs1.transform_to(srs2, (8.22, 53.15))] + ['915046.21432', '7010792.20171'] + >>> srs1.transform_to(srs1, (8.25, 53.5)) + (8.25, 53.5) + >>> [(str(round(x, 5)), str(round(y, 5))) for x, y in + ... srs1.transform_to(srs2, [(8.2, 53.1), (8.22, 53.15), (8.3, 53.2)])] + ... #doctest: +NORMALIZE_WHITESPACE + [('912819.8245', '7001516.67745'), + ('915046.21432', '7010792.20171'), + ('923951.77358', '7020078.53264')] + """ + if self == other_srs: + return points + if isinstance(points[0], (int, float)) and 2 >= len(points) <= 3: + return transform(self.proj, other_srs.proj, *points) + + x = [p[0] for p in points] + y = [p[1] for p in points] + transf_pts = transform(self.proj, other_srs.proj, x, y) + return izip(transf_pts[0], transf_pts[1]) + + def transform_bbox_to(self, other_srs, bbox, with_points=16): + """ + + :param with_points: the number of points to use for the transformation. + A bbox transformation with only two or four points may cut off some + parts due to distortions. + + >>> ['%.3f' % x for x in + ... SRS(4326).transform_bbox_to(SRS(900913), (-180.0, -90.0, 180.0, 90.0))] + ['-20037508.343', '-147730762.670', '20037508.343', '147730758.195'] + >>> ['%.5f' % x for x in + ... SRS(4326).transform_bbox_to(SRS(900913), (8.2, 53.1, 8.3, 53.2))] + ['912819.82450', '7001516.67745', '923951.77358', '7020078.53264'] + >>> SRS(4326).transform_bbox_to(SRS(4326), (8.25, 53.0, 8.5, 53.75)) + (8.25, 53.0, 8.5, 53.75) + """ + if self == other_srs: + return bbox + bbox = self.align_bbox(bbox) + points = generate_envelope_points(bbox, with_points) + transf_pts = self.transform_to(other_srs, points) + result = calculate_bbox(transf_pts) + + log_proj.debug('transformed from %r to %r (%s -> %s)' % + (self, other_srs, bbox, result)) + + return result + + def align_bbox(self, bbox): + """ + Align bbox to reasonable values to prevent errors in transformations. + E.g. transformations from EPSG:4326 with lat=90 or -90 will fail, so + we subtract a tiny delta. + + At the moment only EPSG:4326 bbox will be modifyed. + + >>> bbox = SRS(4326).align_bbox((-180, -90, 180, 90)) + >>> -90 < bbox[1] < -89.99999998 + True + >>> 90 > bbox[3] > 89.99999998 + True + """ + # TODO should not be needed anymore since we transform with +over + # still a few tests depend on the rounding behavior of this + if self.srs_code == 'EPSG:4326': + delta = 0.00000001 + (minx, miny, maxx, maxy) = bbox + if abs(miny - -90.0) < 1e-6: + miny = -90.0 + delta + if abs(maxy - 90.0) < 1e-6: + maxy = 90.0 - delta + bbox = minx, miny, maxx, maxy + return bbox + + @property + def is_latlong(self): + """ + >>> SRS(4326).is_latlong + True + >>> SRS(31466).is_latlong + False + """ + return self.proj.is_latlong() + + @property + def is_axis_order_ne(self): + """ + Returns `True` if the axis order is North, then East + (i.e. y/x or lat/lon). + + >>> SRS(4326).is_axis_order_ne + True + >>> SRS('CRS:84').is_axis_order_ne + False + >>> SRS(31468).is_axis_order_ne + True + >>> SRS(31463).is_axis_order_ne + False + >>> SRS(25831).is_axis_order_ne + False + """ + if self.srs_code in base_config().srs.axis_order_ne: + return True + if self.srs_code in base_config().srs.axis_order_en: + return False + if self.is_latlong: + return True + return False + + @property + def is_axis_order_en(self): + """ + Returns `True` if the axis order is East then North + (i.e. x/y or lon/lat). + """ + return not self.is_axis_order_ne + + def __eq__(self, other): + """ + >>> SRS(4326) == SRS("EpsG:4326") + True + >>> SRS(4326) == SRS("4326") + True + >>> SRS(4326) == SRS(3857) + False + """ + if isinstance(other, _SRS): + return self.proj.srs == other.proj.srs + else: + return NotImplemented + + def __ne__(self, other): + """ + >>> SRS(3857) != SRS(3857) + False + >>> SRS(4326) != SRS(900913) + True + """ + equal_result = self.__eq__(other) + if equal_result is NotImplemented: + return NotImplemented + else: + return not equal_result + + def __str__(self): + #pylint: disable-msg=E1101 + return "SRS %s ('%s')" % (self.srs_code, self.proj.srs) + + def __repr__(self): + """ + >>> repr(SRS(4326)) + "SRS('EPSG:4326')" + """ + return "SRS('%s')" % (self.srs_code,) + + def __hash__(self): + return hash(self.srs_code) + + +def generate_envelope_points(bbox, n): + """ + Generates points that form a linestring around a given bbox. + + @param bbox: bbox to generate linestring for + @param n: the number of points to generate around the bbox + + >>> generate_envelope_points((10.0, 5.0, 20.0, 15.0), 4) + [(10.0, 5.0), (20.0, 5.0), (20.0, 15.0), (10.0, 15.0)] + >>> generate_envelope_points((10.0, 5.0, 20.0, 15.0), 8) + ... #doctest: +NORMALIZE_WHITESPACE + [(10.0, 5.0), (15.0, 5.0), (20.0, 5.0), (20.0, 10.0),\ + (20.0, 15.0), (15.0, 15.0), (10.0, 15.0), (10.0, 10.0)] + """ + (minx, miny, maxx, maxy) = bbox + if n <= 4: + n = 0 + else: + n = int(math.ceil((n - 4) / 4.0)) + + width = maxx - minx + height = maxy - miny + + minx, maxx = min(minx, maxx), max(minx, maxx) + miny, maxy = min(miny, maxy), max(miny, maxy) + + n += 1 + xstep = width / n + ystep = height / n + result = [] + for i in range(n+1): + result.append((minx + i*xstep, miny)) + for i in range(1, n): + result.append((maxx, miny + i*ystep)) + for i in range(n, -1, -1): + result.append((minx + i*xstep, maxy)) + for i in range(n-1, 0, -1): + result.append((minx, miny + i*ystep)) + return result + +def calculate_bbox(points): + """ + Calculates the bbox of a list of points. + + >>> calculate_bbox([(-5, 20), (3, 8), (99, 0)]) + (-5, 0, 99, 20) + + @param points: list of points [(x0, y0), (x1, y2), ...] + @returns: bbox of the input points. + """ + points = list(points) + # points can be INF for invalid transformations, filter out + try: + minx = min(p[0] for p in points if p[0] != float('inf')) + miny = min(p[1] for p in points if p[1] != float('inf')) + maxx = max(p[0] for p in points if p[0] != float('inf')) + maxy = max(p[1] for p in points if p[1] != float('inf')) + return (minx, miny, maxx, maxy) + except ValueError: # min/max are called with empty list when everything is inf + raise TransformationError() + +def merge_bbox(bbox1, bbox2): + """ + Merge two bboxes. + + >>> merge_bbox((-10, 20, 0, 30), (30, -20, 90, 10)) + (-10, -20, 90, 30) + + """ + minx = min(bbox1[0], bbox2[0]) + miny = min(bbox1[1], bbox2[1]) + maxx = max(bbox1[2], bbox2[2]) + maxy = max(bbox1[3], bbox2[3]) + return (minx, miny, maxx, maxy) + +def bbox_equals(src_bbox, dst_bbox, x_delta=None, y_delta=None): + """ + Compares two bbox and checks if they are equal, or nearly equal. + + :param x_delta: how precise the comparison should be. + should be reasonable small, like a tenth of a pixel. + defaults to 1/1.000.000th of the width. + :type x_delta: bbox units + + >>> src_bbox = (939258.20356824622, 6887893.4928338043, + ... 1095801.2374962866, 7044436.5267618448) + >>> dst_bbox = (939258.20260000182, 6887893.4908000007, + ... 1095801.2365000017, 7044436.5247000009) + >>> bbox_equals(src_bbox, dst_bbox, 61.1, 61.1) + True + >>> bbox_equals(src_bbox, dst_bbox, 0.0001) + False + """ + if x_delta is None: + x_delta = abs(src_bbox[0] - src_bbox[2]) / 1000000.0 + if y_delta is None: + y_delta = x_delta + return (abs(src_bbox[0] - dst_bbox[0]) < x_delta and + abs(src_bbox[1] - dst_bbox[1]) < x_delta and + abs(src_bbox[2] - dst_bbox[2]) < y_delta and + abs(src_bbox[3] - dst_bbox[3]) < y_delta) + +def make_lin_transf(src_bbox, dst_bbox): + """ + Create a transformation function that transforms linear between two + plane coordinate systems. + One needs to be cartesian (0, 0 at the lower left, x goes up) and one + needs to be an image coordinate system (0, 0 at the top left, x goes down). + + :return: function that takes src x/y and returns dest x/y coordinates + + >>> transf = make_lin_transf((7, 50, 8, 51), (0, 0, 500, 400)) + >>> transf((7.5, 50.5)) + (250.0, 200.0) + >>> transf((7.0, 50.0)) + (0.0, 400.0) + >>> transf = make_lin_transf((7, 50, 8, 51), (200, 300, 700, 700)) + >>> transf((7.5, 50.5)) + (450.0, 500.0) + """ + func = lambda x_y: (dst_bbox[0] + (x_y[0] - src_bbox[0]) * + (dst_bbox[2]-dst_bbox[0]) / (src_bbox[2] - src_bbox[0]), + dst_bbox[1] + (src_bbox[3] - x_y[1]) * + (dst_bbox[3]-dst_bbox[1]) / (src_bbox[3] - src_bbox[1])) + return func + + +class PreferredSrcSRS(object): + def __init__(self): + self.target_proj = {} + + def add(self, target, prefered_srs): + self.target_proj[target] = prefered_srs + + def preferred_src(self, target, available_src): + if not available_src: + raise ValueError("no available src SRS") + if target in available_src: + return target + if target in self.target_proj: + for preferred in self.target_proj[target]: + if preferred in available_src: + return preferred + + for avail in available_src: + if avail.is_latlong == target.is_latlong: + return avail + return available_src[0] + +class SupportedSRS(object): + def __init__(self, supported_srs, preferred_srs=None): + self.supported_srs = supported_srs + self.preferred_srs = preferred_srs or PreferredSrcSRS() + + def __iter__(self): + return iter(self.supported_srs) + + def __contains__(self, srs): + return srs in self.supported_srs + + def best_srs(self, target): + return self.preferred_srs.preferred_src(target, self.supported_srs) + + def __eq__(self, other): + # .prefered_srs is set global, so we only compare .supported_srs + return self.supported_srs == other.supported_srs \ No newline at end of file diff --git a/patches/02-epsg-900913/README.md b/patches/02-epsg-900913/README.md new file mode 100644 index 0000000..0ae483a --- /dev/null +++ b/patches/02-epsg-900913/README.md @@ -0,0 +1,9 @@ +# Skipped +Skipping this patch starting with 1.16.0 version. +Skipping in [apply.sh](apply.sh), but keeping the fix if ever needed. + +## What this solved +Nasty fix: append proj Google Web Merc projection EPSG:900913 +to /usr/share/proj/epsg for legacy MP configs (clients use /EPSG:900913/ in URLs!) +Upgrade notes: Debian bullseye-slim and proj.6: will +need SQLite for proj.db i.s.o. /usr/share/proj/epsg!! diff --git a/patches/02-epsg-900913/apply.sh b/patches/02-epsg-900913/apply.sh new file mode 100755 index 0000000..30562d4 --- /dev/null +++ b/patches/02-epsg-900913/apply.sh @@ -0,0 +1,6 @@ +#!/bin/bash +set -x +echo "SKIP: patch for EPSG:900913 support" + +# GOOGLE_WEB_MERC_EPSG="<900913> +proj=merc +a=6378137 +b=6378137 +lat_ts=0.0 +lon_0=0.0 +x_0=0.0 +y_0=0 +k=1.0 +units=m +nadgrids=@null +wktext +no_defs <>" +# echo "${GOOGLE_WEB_MERC_EPSG}" >> /usr/share/proj/epsg diff --git a/patches/03-wmts-tms-demo/README.md b/patches/03-wmts-tms-demo/README.md new file mode 100644 index 0000000..0baa7d2 --- /dev/null +++ b/patches/03-wmts-tms-demo/README.md @@ -0,0 +1,35 @@ +# What this solves +The TMS demo code uses the OpenLayers XYZ class (i.s.o. TMS) class after the OL upgrade. +But the TMS service needs the `/1.0.0/` string in its path and custom tilegrid settings. + +This patches the code for the TMS demo HTML template: +[mapproxy/service/templates/demo/tms_demo.html](tms_demo-2.0.2.html). + +Also: Using tile size from the grid i.s.o. the default 256x256 in the WMTS demo HTML template +for [mapproxy/service/templates/demo/wmts_demo.html](wmts_demo-2.0.2.html). + +# Related PR + +See https://github.com/mapproxy/mapproxy/pull/931 . +Once this is available in a version, this patch can be removed. + +# Commands + +The version from `master` fetched on May 24, 2024 which is the version in MapProxy 2.0.2. + +TMS: +``` +diff -u tms_demo-2.0.2.html tms_demo-patched.html > tms-demo.patch + +# to be independent of Python version and MP install location +patch $(find /usr -type f -name tms_demo.html) tms-demo.patch +``` + + +WMTS: +``` +diff -u wmts_demo-2.0.2.html wmts_demo-patched.html > wmts-demo.patch + +# to be independent of Python version and MP install location +patch $(find /usr -type f -name wmts_demo.html) wmts-demo.patch +``` \ No newline at end of file diff --git a/patches/03-wmts-tms-demo/apply.sh b/patches/03-wmts-tms-demo/apply.sh new file mode 100755 index 0000000..bac41b4 --- /dev/null +++ b/patches/03-wmts-tms-demo/apply.sh @@ -0,0 +1,7 @@ +#!/bin/bash +set -x +echo "DO: patch $(find /usr -type f -name tms_demo.html) tms_demo.patch" +patch $(find /usr -type f -name tms_demo.html) tms-demo.patch + +echo "DO: patch $(find /usr -type f -name wmts_demo.html) wmts_demo.patch" +patch $(find /usr -type f -name wmts_demo.html) wmts-demo.patch diff --git a/patches/03-wmts-tms-demo/tms-demo.patch b/patches/03-wmts-tms-demo/tms-demo.patch new file mode 100644 index 0000000..7fb101e --- /dev/null +++ b/patches/03-wmts-tms-demo/tms-demo.patch @@ -0,0 +1,47 @@ +--- tms_demo-2.0.2.html 2024-05-24 16:29:29 ++++ tms_demo-patched.html 2024-05-26 16:12:21 +@@ -1,7 +1,7 @@ + {{py: + from html import escape + import textwrap +- ++import json + wrapper = textwrap.TextWrapper(replace_whitespace=False, width=90, + break_long_words=False) + +@@ -20,9 +20,13 @@ + + + + +{{enddef}} +

Layer Preview - {{layer.name}}

+
+ + + + +
Coordinate SystemImage format
+ + + + {{format}}
+
+
+ + +

Bounding Box

+

{{', '.join(str(s) for s in layer.grid.bbox)}}

+

Level and Resolutions

+ + + {{for level, res in layer.grid.tile_sets}} + + {{endfor}} +
LevelResolution
{{level}}{{res}}
diff --git a/patches/03-wmts-tms-demo/tms_demo-patched.html b/patches/03-wmts-tms-demo/tms_demo-patched.html new file mode 100644 index 0000000..079702c --- /dev/null +++ b/patches/03-wmts-tms-demo/tms_demo-patched.html @@ -0,0 +1,117 @@ +{{py: +from html import escape +import textwrap +import json +wrapper = textwrap.TextWrapper(replace_whitespace=False, width=90, + break_long_words=False) + +def approx_bbox(layer, srs): + from mapproxy.srs import SRS + extent = layer.md['extent'].bbox_for(SRS(srs)) + return ', '.join(map(lambda x: '%.2f' % x, extent)) + +menu_title= "TMS %s %s"%(layer.name, srs) +jscript_functions=None +}} +{{def jscript_openlayers}} + + + + +{{enddef}} +

Layer Preview - {{layer.name}}

+
+ + + + +
Coordinate SystemImage format
+ + + + {{format}}
+
+
+ + +

Bounding Box

+

{{', '.join(str(s) for s in layer.grid.bbox)}}

+

Level and Resolutions

+ + + {{for level, res in layer.grid.tile_sets}} + + {{endfor}} +
LevelResolution
{{level}}{{res}}
diff --git a/patches/03-wmts-tms-demo/wmts-demo.patch b/patches/03-wmts-tms-demo/wmts-demo.patch new file mode 100644 index 0000000..e22b5a6 --- /dev/null +++ b/patches/03-wmts-tms-demo/wmts-demo.patch @@ -0,0 +1,43 @@ +--- wmts_demo-2.0.2.html 2024-05-26 16:36:07 ++++ wmts_demo-patched.html 2024-05-26 16:47:04 +@@ -1,6 +1,7 @@ + {{py: + from html import escape + import textwrap ++import json + + wrapper = textwrap.TextWrapper(replace_whitespace=False, width=90, + break_long_words=False) +@@ -20,7 +21,7 @@ + + + + +{{enddef}} +

Layer Preview - {{layer.name}}

+ + + + +
Coordinate SystemImage format
+ + + + {{layer.format}}
+
+ + +

Bounding Box

+

{{', '.join(str(s) for s in layer.extent.bbox)}}

diff --git a/patches/03-wmts-tms-demo/wmts_demo-patched.html b/patches/03-wmts-tms-demo/wmts_demo-patched.html new file mode 100644 index 0000000..d2a849d --- /dev/null +++ b/patches/03-wmts-tms-demo/wmts_demo-patched.html @@ -0,0 +1,118 @@ +{{py: +from html import escape +import textwrap +import json + +wrapper = textwrap.TextWrapper(replace_whitespace=False, width=90, + break_long_words=False) + +def approx_bbox(layer, srs): + from mapproxy.srs import SRS + extent = layer.md['extent'].bbox_for(SRS(srs)) + return ', '.join(map(lambda x: '%.2f' % x, extent)) + +menu_title= "WMTS %s %s"%(layer.name, srs) +jscript_functions=None +}} +{{def jscript_openlayers}} + + + + +{{enddef}} +

Layer Preview - {{layer.name}}

+ + + + +
Coordinate SystemImage format
+ + + + {{layer.format}}
+
+ + +

Bounding Box

+

{{', '.join(str(s) for s in layer.extent.bbox)}}

diff --git a/patches/README.md b/patches/README.md new file mode 100644 index 0000000..a601674 --- /dev/null +++ b/patches/README.md @@ -0,0 +1,5 @@ +# Patches +In some cases we need to make "patches" like to .py files or other resources. + +# Examples +https://www.thegeekstuff.com/2014/12/patch-command-examples/ diff --git a/patches/apply.sh b/patches/apply.sh new file mode 100755 index 0000000..dc4e239 --- /dev/null +++ b/patches/apply.sh @@ -0,0 +1,8 @@ +#!/bin/bash +for patch in $(ls) +do + if [ -d "${patch}" ] + then + cd ${patch} && ./apply.sh && cd - + fi +done