├── .gitignore ├── LICENSE.md ├── Pipfile ├── Pipfile.lock ├── README.md ├── circle.yml ├── config.py ├── server.py ├── test.py ├── wsgi_server.py └── zaloa.py /.gitignore: -------------------------------------------------------------------------------- 1 | zaloa.zip 2 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Mapzen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | flask = "*" 8 | pillow = "*" 9 | flask-caching = "*" 10 | flask-cors = "*" 11 | requests = "*" 12 | "boto3" = "*" 13 | 14 | [dev-packages] 15 | zappa = "*" 16 | 17 | [requires] 18 | python_version = "3.6" 19 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "b44d659542e6fe28da146975381b78e8120e70b18360cb702adef0a3b2cdaa14" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.6" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "boto3": { 20 | "hashes": [ 21 | "sha256:08f268d6eb3347061384e144121dcca1e454a7a8b6c8424a23d3a312cdebab68", 22 | "sha256:ce462e7505c03c3e6708ce6f264ac43d478886082af703ff69c502592df5d4f3" 23 | ], 24 | "index": "pypi", 25 | "version": "==1.7.58" 26 | }, 27 | "botocore": { 28 | "hashes": [ 29 | "sha256:17a88a578161dc12ecf14950afa93a354cf009380977921f7f52891acc5e751a", 30 | "sha256:e0e6b6d1fdbce81c28151136ee919d2cdeee13041559710cd5c93d7e4035a455" 31 | ], 32 | "version": "==1.10.58" 33 | }, 34 | "certifi": { 35 | "hashes": [ 36 | "sha256:13e698f54293db9f89122b0581843a782ad0934a4fe0172d2a980ba77fc61bb7", 37 | "sha256:9fa520c1bacfb634fa7af20a76bcbd3d5fb390481724c597da32c719a7dca4b0" 38 | ], 39 | "version": "==2018.4.16" 40 | }, 41 | "chardet": { 42 | "hashes": [ 43 | "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", 44 | "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" 45 | ], 46 | "version": "==3.0.4" 47 | }, 48 | "click": { 49 | "hashes": [ 50 | "sha256:29f99fc6125fbc931b758dc053b3114e55c77a6e4c6c3a2674a2dc986016381d", 51 | "sha256:f15516df478d5a56180fbf80e68f206010e6d160fc39fa508b65e035fd75130b" 52 | ], 53 | "version": "==6.7" 54 | }, 55 | "docutils": { 56 | "hashes": [ 57 | "sha256:02aec4bd92ab067f6ff27a38a38a41173bf01bed8f89157768c1573f53e474a6", 58 | "sha256:51e64ef2ebfb29cae1faa133b3710143496eca21c530f3f71424d77687764274", 59 | "sha256:7a4bd47eaf6596e1295ecb11361139febe29b084a87bf005bf899f9a42edc3c6" 60 | ], 61 | "version": "==0.14" 62 | }, 63 | "flask": { 64 | "hashes": [ 65 | "sha256:2271c0070dbcb5275fad4a82e29f23ab92682dc45f9dfbc22c02ba9b9322ce48", 66 | "sha256:a080b744b7e345ccfcbc77954861cb05b3c63786e93f2b3875e0913d44b43f05" 67 | ], 68 | "index": "pypi", 69 | "version": "==1.0.2" 70 | }, 71 | "flask-caching": { 72 | "hashes": [ 73 | "sha256:44fe827c6cc519d48fb0945fa05ae3d128af9a98f2a6e71d4702fd512534f227", 74 | "sha256:e34f24631ba240e09fe6241e1bf652863e0cff06a1a94598e23be526bc2e4985" 75 | ], 76 | "index": "pypi", 77 | "version": "==1.4.0" 78 | }, 79 | "flask-cors": { 80 | "hashes": [ 81 | "sha256:e4c8fc15d3e4b4cce6d3b325f2bab91e0e09811a61f50d7a53493bc44242a4f1", 82 | "sha256:ecc016c5b32fa5da813ec8d272941cfddf5f6bba9060c405a70285415cbf24c9" 83 | ], 84 | "index": "pypi", 85 | "version": "==3.0.6" 86 | }, 87 | "idna": { 88 | "hashes": [ 89 | "sha256:156a6814fb5ac1fc6850fb002e0852d56c0c8d2531923a51032d1b70760e186e", 90 | "sha256:684a38a6f903c1d71d6d5fac066b58d7768af4de2b832e426ec79c30daa94a16" 91 | ], 92 | "version": "==2.7" 93 | }, 94 | "itsdangerous": { 95 | "hashes": [ 96 | "sha256:cbb3fcf8d3e33df861709ecaf89d9e6629cff0a217bc2848f1b41cd30d360519" 97 | ], 98 | "version": "==0.24" 99 | }, 100 | "jinja2": { 101 | "hashes": [ 102 | "sha256:74c935a1b8bb9a3947c50a54766a969d4846290e1e788ea44c1392163723c3bd", 103 | "sha256:f84be1bb0040caca4cea721fcbbbbd61f9be9464ca236387158b0feea01914a4" 104 | ], 105 | "version": "==2.10" 106 | }, 107 | "jmespath": { 108 | "hashes": [ 109 | "sha256:6a81d4c9aa62caf061cb517b4d9ad1dd300374cd4706997aff9cd6aedd61fc64", 110 | "sha256:f11b4461f425740a1d908e9a3f7365c3d2e569f6ca68a2ff8bc5bcd9676edd63" 111 | ], 112 | "version": "==0.9.3" 113 | }, 114 | "markupsafe": { 115 | "hashes": [ 116 | "sha256:a6be69091dac236ea9c6bc7d012beab42010fa914c459791d627dad4910eb665" 117 | ], 118 | "version": "==1.0" 119 | }, 120 | "pillow": { 121 | "hashes": [ 122 | "sha256:00def5b638994f888d1058e4d17c86dec8e1113c3741a0a8a659039aec59a83a", 123 | "sha256:026449b64e559226cdb8e6d8c931b5965d8fc90ec18ebbb0baa04c5b36503c72", 124 | "sha256:03dbb224ee196ef30ed2156d41b579143e1efeb422974719a5392fc035e4f574", 125 | "sha256:03eb0e04f929c102ae24bc436bf1c0c60a4e63b07ebd388e84d8b219df3e6acd", 126 | "sha256:087b0551ce2d19b3f092f2b5f071a065f7379e748867d070b29999cc83db15e3", 127 | "sha256:091a0656688d85fd6e10f49a73fa3ab9b37dbfcb2151f5a3ab17f8b879f467ee", 128 | "sha256:0f3e2d0a9966161b7dfd06d147f901d72c3a88ea1a833359b92193b8e1f68e1c", 129 | "sha256:114398d0e073b93e1d7da5b5ab92ff4b83c0180625c8031911425e51f4365d2e", 130 | "sha256:1be66b9a89e367e7d20d6cae419794997921fe105090fafd86ef39e20a3baab2", 131 | "sha256:1c5e93c40d4ce8cb133d3b105a869be6fa767e703f6eb1003eb4b90583e08a59", 132 | "sha256:1e977a3ed998a599bda5021fb2c2889060617627d3ae228297a529a082a3cd5c", 133 | "sha256:22cf3406d135cfcc13ec6228ade774c8461e125c940e80455f500638429be273", 134 | "sha256:24adccf1e834f82718c7fc8e3ec1093738da95144b8b1e44c99d5fc7d3e9c554", 135 | "sha256:2a3e362c97a5e6a259ee9cd66553292a1f8928a5bdfa3622fdb1501570834612", 136 | "sha256:3518f9fc666cbc58a5c1f48a6a23e9e6ceef69665eab43cdad5144de9383e72c", 137 | "sha256:3709339f4619e8c9b00f53079e40b964f43c5af61fb89a923fe24437167298bb", 138 | "sha256:3832e26ecbc9d8a500821e3a1d3765bda99d04ae29ffbb2efba49f5f788dc934", 139 | "sha256:452d159024faf37cc080537df308e8fa0026076eb38eb75185d96ed9642bd6d7", 140 | "sha256:4fd1f0c2dc02aaec729d91c92cd85a2df0289d88e9f68d1e8faba750bb9c4786", 141 | "sha256:4fda62030f2c515b6e2e673c57caa55cb04026a81968f3128aae10fc28e5cc27", 142 | "sha256:5044d75a68b49ce36a813c82d8201384207112d5d81643937fc758c05302f05b", 143 | "sha256:522184556921512ec484cb93bd84e0bab915d0ac5a372d49571c241a7f73db62", 144 | "sha256:5914cff11f3e920626da48e564be6818831713a3087586302444b9c70e8552d9", 145 | "sha256:653d48fe46378f40e3c2b892be88d8440efbb2c9df78559da44c63ad5ecb4142", 146 | "sha256:6661a7908d68c4a133e03dac8178287aa20a99f841ea90beeb98a233ae3fd710", 147 | "sha256:6735a7e560df6f0deb78246a6fe056cf2ae392ba2dc060ea8a6f2535aec924f1", 148 | "sha256:6d26a475a19cb294225738f5c974b3a24599438a67a30ed2d25638f012668026", 149 | "sha256:791f07fe13937e65285f9ef30664ddf0e10a0230bdb236751fa0ca67725740dd", 150 | "sha256:79258a8df3e309a54c7ef2ef4a59bb8e28f7e4a8992a3ad17c24b1889ced44f3", 151 | "sha256:7d74c20b8f1c3e99d3f781d3b8ff5abfefdd7363d61e23bdeba9992ff32cc4b4", 152 | "sha256:81918afeafc16ba5d9d0d4e9445905f21aac969a4ebb6f2bff4b9886da100f4b", 153 | "sha256:8194d913ca1f459377c8a4ed8f9b7ad750068b8e0e3f3f9c6963fcc87a84515f", 154 | "sha256:84d5d31200b11b3c76fab853b89ac898bf2d05c8b3da07c1fcc23feb06359d6e", 155 | "sha256:989981db57abffb52026b114c9a1f114c7142860a6d30a352d28f8cbf186500b", 156 | "sha256:a3d7511d3fad1618a82299aab71a5fceee5c015653a77ffea75ced9ef917e71a", 157 | "sha256:a4a6ac01b8c2f9d2d83719f193e6dea493e18445ce5bfd743d739174daa974d9", 158 | "sha256:acb90eb6c7ed6526551a78211d84c81e33082a35642ff5fe57489abc14e6bf6e", 159 | "sha256:b3ef168d4d6fd4fa6685aef7c91400f59f7ab1c0da734541f7031699741fb23f", 160 | "sha256:c1c5792b6e74bbf2af0f8e892272c2a6c48efa895903211f11b8342e03129fea", 161 | "sha256:c5dcb5a56aebb8a8f2585042b2f5c496d7624f0bcfe248f0cc33ceb2fd8d39e7", 162 | "sha256:d16f90810106822833a19bdb24c7cb766959acf791ca0edf5edfec674d55c8ee", 163 | "sha256:dcdc9cd9880027688007ff8f7c8e7ae6f24e81fae33bfd18d1e691e7bda4855f", 164 | "sha256:e2807aad4565d8de15391a9548f97818a14ef32624015c7bf3095171e314445e", 165 | "sha256:e2bed4a04e2ca1050bb5f00865cf2f83c0b92fd62454d9244f690fcd842e27a4", 166 | "sha256:e87a527c06319428007e8c30511e1f0ce035cb7f14bb4793b003ed532c3b9333", 167 | "sha256:ebcfc33a6c34984086451e230253bc33727bd17b4cdc4b39ec03032c3a6fc9e9", 168 | "sha256:f63e420180cbe22ff6e32558b612e75f50616fc111c5e095a4631946c782e109", 169 | "sha256:f7717eb360d40e7598c30cc44b33d98f79c468d9279379b66c1e28c568e0bf47", 170 | "sha256:f8582e1ab155302ea9ef1235441a0214919f4f79c4c7c21833ce9eec58181781", 171 | "sha256:f8b3d413c5a8f84b12cd4c5df1d8e211777c9852c6be3ee9c094b626644d3eab" 172 | ], 173 | "index": "pypi", 174 | "version": "==5.2.0" 175 | }, 176 | "python-dateutil": { 177 | "hashes": [ 178 | "sha256:1adb80e7a782c12e52ef9a8182bebeb73f1d7e24e374397af06fb4956c8dc5c0", 179 | "sha256:e27001de32f627c22380a688bcc43ce83504a7bc5da472209b4c70f02829f0b8" 180 | ], 181 | "markers": "python_version >= '2.7'", 182 | "version": "==2.7.3" 183 | }, 184 | "requests": { 185 | "hashes": [ 186 | "sha256:63b52e3c866428a224f97cab011de738c36aec0185aa91cfacd418b5d58911d1", 187 | "sha256:ec22d826a36ed72a7358ff3fe56cbd4ba69dd7a6718ffd450ff0e9df7a47ce6a" 188 | ], 189 | "index": "pypi", 190 | "version": "==2.19.1" 191 | }, 192 | "s3transfer": { 193 | "hashes": [ 194 | "sha256:90dc18e028989c609146e241ea153250be451e05ecc0c2832565231dacdf59c1", 195 | "sha256:c7a9ec356982d5e9ab2d4b46391a7d6a950e2b04c472419f5fdec70cc0ada72f" 196 | ], 197 | "version": "==0.1.13" 198 | }, 199 | "six": { 200 | "hashes": [ 201 | "sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9", 202 | "sha256:832dc0e10feb1aa2c68dcc57dbb658f1c7e65b9b61af69048abc87a2db00a0eb" 203 | ], 204 | "version": "==1.11.0" 205 | }, 206 | "urllib3": { 207 | "hashes": [ 208 | "sha256:a68ac5e15e76e7e5dd2b8f94007233e01effe3e50e8daddf69acfd81cb686baf", 209 | "sha256:b5725a0bd4ba422ab0e66e89e030c806576753ea3ee08554382c14e685d117b5" 210 | ], 211 | "version": "==1.23" 212 | }, 213 | "werkzeug": { 214 | "hashes": [ 215 | "sha256:c3fd7a7d41976d9f44db327260e263132466836cef6f91512889ed60ad26557c", 216 | "sha256:d5da73735293558eb1651ee2fddc4d0dedcfa06538b8813a2e20011583c9e49b" 217 | ], 218 | "version": "==0.14.1" 219 | } 220 | }, 221 | "develop": { 222 | "argcomplete": { 223 | "hashes": [ 224 | "sha256:c079ceb0b72d4d4e03531ed77e6071babb9d42c3f790d7def2c41295b4990b44", 225 | "sha256:d97b7f3cfaa4e494ad59ed6d04c938fc5ed69b590bd8f53274e258fb1119bd1b" 226 | ], 227 | "version": "==1.9.3" 228 | }, 229 | "base58": { 230 | "hashes": [ 231 | "sha256:93fa54b615a7c406701a56e3d11c3a5defdbcd371f36c0452f1ac77623e42d16", 232 | "sha256:c5fe8b00fab798b4a3393da6235bdecb143db505833e3f979890f7c6fc99f651" 233 | ], 234 | "version": "==1.0.0" 235 | }, 236 | "boto3": { 237 | "hashes": [ 238 | "sha256:08f268d6eb3347061384e144121dcca1e454a7a8b6c8424a23d3a312cdebab68", 239 | "sha256:ce462e7505c03c3e6708ce6f264ac43d478886082af703ff69c502592df5d4f3" 240 | ], 241 | "index": "pypi", 242 | "version": "==1.7.58" 243 | }, 244 | "botocore": { 245 | "hashes": [ 246 | "sha256:17a88a578161dc12ecf14950afa93a354cf009380977921f7f52891acc5e751a", 247 | "sha256:e0e6b6d1fdbce81c28151136ee919d2cdeee13041559710cd5c93d7e4035a455" 248 | ], 249 | "version": "==1.10.58" 250 | }, 251 | "certifi": { 252 | "hashes": [ 253 | "sha256:13e698f54293db9f89122b0581843a782ad0934a4fe0172d2a980ba77fc61bb7", 254 | "sha256:9fa520c1bacfb634fa7af20a76bcbd3d5fb390481724c597da32c719a7dca4b0" 255 | ], 256 | "version": "==2018.4.16" 257 | }, 258 | "cfn-flip": { 259 | "hashes": [ 260 | "sha256:9c61039c71995ab204c005ec46d47d0f7a109e9f1b6d63569397f8bc648a8151" 261 | ], 262 | "version": "==1.0.3" 263 | }, 264 | "chardet": { 265 | "hashes": [ 266 | "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", 267 | "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" 268 | ], 269 | "version": "==3.0.4" 270 | }, 271 | "click": { 272 | "hashes": [ 273 | "sha256:29f99fc6125fbc931b758dc053b3114e55c77a6e4c6c3a2674a2dc986016381d", 274 | "sha256:f15516df478d5a56180fbf80e68f206010e6d160fc39fa508b65e035fd75130b" 275 | ], 276 | "version": "==6.7" 277 | }, 278 | "docutils": { 279 | "hashes": [ 280 | "sha256:02aec4bd92ab067f6ff27a38a38a41173bf01bed8f89157768c1573f53e474a6", 281 | "sha256:51e64ef2ebfb29cae1faa133b3710143496eca21c530f3f71424d77687764274", 282 | "sha256:7a4bd47eaf6596e1295ecb11361139febe29b084a87bf005bf899f9a42edc3c6" 283 | ], 284 | "version": "==0.14" 285 | }, 286 | "durationpy": { 287 | "hashes": [ 288 | "sha256:5ef9416b527b50d722f34655becfb75e49228eb82f87b855ed1911b3314b5408" 289 | ], 290 | "version": "==0.5" 291 | }, 292 | "future": { 293 | "hashes": [ 294 | "sha256:e39ced1ab767b5936646cedba8bcce582398233d6a627067d4c6a454c90cfedb" 295 | ], 296 | "version": "==0.16.0" 297 | }, 298 | "futures": { 299 | "hashes": [ 300 | "sha256:9ec02aa7d674acb8618afb127e27fde7fc68994c0437ad759fa094a574adb265", 301 | "sha256:ec0a6cb848cc212002b9828c3e34c675e0c9ff6741dc445cab6fdd4e1085d1f1" 302 | ], 303 | "version": "==3.2.0" 304 | }, 305 | "hjson": { 306 | "hashes": [ 307 | "sha256:1d1727faa6aaef2973921877125a3ab7c5f6d34b93233179d01770f41fab51f9" 308 | ], 309 | "version": "==3.0.1" 310 | }, 311 | "idna": { 312 | "hashes": [ 313 | "sha256:156a6814fb5ac1fc6850fb002e0852d56c0c8d2531923a51032d1b70760e186e", 314 | "sha256:684a38a6f903c1d71d6d5fac066b58d7768af4de2b832e426ec79c30daa94a16" 315 | ], 316 | "version": "==2.7" 317 | }, 318 | "jmespath": { 319 | "hashes": [ 320 | "sha256:6a81d4c9aa62caf061cb517b4d9ad1dd300374cd4706997aff9cd6aedd61fc64", 321 | "sha256:f11b4461f425740a1d908e9a3f7365c3d2e569f6ca68a2ff8bc5bcd9676edd63" 322 | ], 323 | "version": "==0.9.3" 324 | }, 325 | "kappa": { 326 | "hashes": [ 327 | "sha256:4b5b372872f25d619e427e04282551048dc975a107385b076b3ffc6406a15833", 328 | "sha256:4d6b7b3accce4a0aaaac92b36237a6304f0f2fffbbe3caea3f7c9f52d12c9989" 329 | ], 330 | "version": "==0.6.0" 331 | }, 332 | "lambda-packages": { 333 | "hashes": [ 334 | "sha256:b5e3b81ecef5f7c1b0903b5c40813536ba2343a33868a567e4e4ff1e26243406" 335 | ], 336 | "version": "==0.20.0" 337 | }, 338 | "placebo": { 339 | "hashes": [ 340 | "sha256:8aa848b892924786fa5e37e75524e8ec039b7d54860d35c51ffb4ed3e30590c5" 341 | ], 342 | "version": "==0.8.1" 343 | }, 344 | "python-dateutil": { 345 | "hashes": [ 346 | "sha256:1adb80e7a782c12e52ef9a8182bebeb73f1d7e24e374397af06fb4956c8dc5c0", 347 | "sha256:e27001de32f627c22380a688bcc43ce83504a7bc5da472209b4c70f02829f0b8" 348 | ], 349 | "markers": "python_version >= '2.7'", 350 | "version": "==2.7.3" 351 | }, 352 | "python-slugify": { 353 | "hashes": [ 354 | "sha256:57a385df7a1c6dbd15f7666eaff0ff29d3f60363b228b1197c5308ed3ba5f824", 355 | "sha256:c3733135d3b184196fdb8844f6a74bbfb9cf6720d1dcce3254bdc434353f938f" 356 | ], 357 | "version": "==1.2.4" 358 | }, 359 | "pyyaml": { 360 | "hashes": [ 361 | "sha256:0c507b7f74b3d2dd4d1322ec8a94794927305ab4cebbe89cc47fe5e81541e6e8", 362 | "sha256:16b20e970597e051997d90dc2cddc713a2876c47e3d92d59ee198700c5427736", 363 | "sha256:3262c96a1ca437e7e4763e2843746588a965426550f3797a79fca9c6199c431f", 364 | "sha256:326420cbb492172dec84b0f65c80942de6cedb5233c413dd824483989c000608", 365 | "sha256:4474f8ea030b5127225b8894d626bb66c01cda098d47a2b0d3429b6700af9fd8", 366 | "sha256:592766c6303207a20efc445587778322d7f73b161bd994f227adaa341ba212ab", 367 | "sha256:5ac82e411044fb129bae5cfbeb3ba626acb2af31a8d17d175004b70862a741a7", 368 | "sha256:5f84523c076ad14ff5e6c037fe1c89a7f73a3e04cf0377cb4d017014976433f3", 369 | "sha256:827dc04b8fa7d07c44de11fabbc888e627fa8293b695e0f99cb544fdfa1bf0d1", 370 | "sha256:b4c423ab23291d3945ac61346feeb9a0dc4184999ede5e7c43e1ffb975130ae6", 371 | "sha256:bc6bced57f826ca7cb5125a10b23fd0f2fff3b7c4701d64c439a300ce665fff8", 372 | "sha256:c01b880ec30b5a6e6aa67b09a2fe3fb30473008c85cd6a67359a1b15ed6d83a4", 373 | "sha256:ca233c64c6e40eaa6c66ef97058cdc80e8d0157a443655baa1b2966e812807ca", 374 | "sha256:e863072cdf4c72eebf179342c94e6989c67185842d9997960b3e69290b2fa269" 375 | ], 376 | "version": "==3.12" 377 | }, 378 | "requests": { 379 | "hashes": [ 380 | "sha256:63b52e3c866428a224f97cab011de738c36aec0185aa91cfacd418b5d58911d1", 381 | "sha256:ec22d826a36ed72a7358ff3fe56cbd4ba69dd7a6718ffd450ff0e9df7a47ce6a" 382 | ], 383 | "index": "pypi", 384 | "version": "==2.19.1" 385 | }, 386 | "s3transfer": { 387 | "hashes": [ 388 | "sha256:90dc18e028989c609146e241ea153250be451e05ecc0c2832565231dacdf59c1", 389 | "sha256:c7a9ec356982d5e9ab2d4b46391a7d6a950e2b04c472419f5fdec70cc0ada72f" 390 | ], 391 | "version": "==0.1.13" 392 | }, 393 | "six": { 394 | "hashes": [ 395 | "sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9", 396 | "sha256:832dc0e10feb1aa2c68dcc57dbb658f1c7e65b9b61af69048abc87a2db00a0eb" 397 | ], 398 | "version": "==1.11.0" 399 | }, 400 | "toml": { 401 | "hashes": [ 402 | "sha256:8e86bd6ce8cc11b9620cb637466453d94f5d57ad86f17e98a98d1f73e3baab2d" 403 | ], 404 | "version": "==0.9.4" 405 | }, 406 | "tqdm": { 407 | "hashes": [ 408 | "sha256:ba650e08b8b102923a05896bf9d7e1c9cdc20b484156df0511a4bbf1f6b6f89b", 409 | "sha256:fa6d2ea6285f56e75d7efe9259805deadc450f16066a1f82ad0629ea9be2cd0f" 410 | ], 411 | "version": "==4.19.1" 412 | }, 413 | "troposphere": { 414 | "hashes": [ 415 | "sha256:aecc32359326634c9911ae4bea05d308822b827787926dcc038c153410ce380b" 416 | ], 417 | "version": "==2.3.1" 418 | }, 419 | "unidecode": { 420 | "hashes": [ 421 | "sha256:72f49d3729f3d8f5799f710b97c1451c5163102e76d64d20e170aedbbd923582", 422 | "sha256:8c33dd588e0c9bc22a76eaa0c715a5434851f726131bd44a6c26471746efabf5" 423 | ], 424 | "version": "==1.0.22" 425 | }, 426 | "urllib3": { 427 | "hashes": [ 428 | "sha256:a68ac5e15e76e7e5dd2b8f94007233e01effe3e50e8daddf69acfd81cb686baf", 429 | "sha256:b5725a0bd4ba422ab0e66e89e030c806576753ea3ee08554382c14e685d117b5" 430 | ], 431 | "version": "==1.23" 432 | }, 433 | "werkzeug": { 434 | "hashes": [ 435 | "sha256:c3fd7a7d41976d9f44db327260e263132466836cef6f91512889ed60ad26557c", 436 | "sha256:d5da73735293558eb1651ee2fddc4d0dedcfa06538b8813a2e20011583c9e49b" 437 | ], 438 | "version": "==0.14.1" 439 | }, 440 | "wheel": { 441 | "hashes": [ 442 | "sha256:0a2e54558a0628f2145d2fc822137e322412115173e8a2ddbe1c9024338ae83c", 443 | "sha256:80044e51ec5bbf6c894ba0bc48d26a8c20a9ba629f4ca19ea26ecfcf87685f5f" 444 | ], 445 | "version": "==0.31.1" 446 | }, 447 | "wsgi-request-logger": { 448 | "hashes": [ 449 | "sha256:445d7ec52799562f812006394d0b4a7064b37084c6ea6bd74ea7a2136c97ed83" 450 | ], 451 | "version": "==0.4.6" 452 | }, 453 | "zappa": { 454 | "hashes": [ 455 | "sha256:cb70195801efb8ae50c78d5180640afe1bcb9317d3c11da97b6d3588b90aea89", 456 | "sha256:d841b2ca57c80c2fe5cd9098763ab26f59580658838eb77fcdefeed894543d45" 457 | ], 458 | "index": "pypi", 459 | "version": "==0.46.1" 460 | } 461 | } 462 | } 463 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Zaloa 2 | 3 | Zaloa takes input terrain tiles, and merges them together. 4 | 5 | It is designed to be run as middleware to dynamically generate alternate tile sizes from 256px sources. 6 | 7 | ## Sizes and tilesets 8 | 9 | Zaloa uses two tilesets as its source: 10 | 11 | 1. terrarium 12 | 2. normal 13 | 14 | Supports 512, and buffered variants of 256, namely 260 and 516. The variants have a 2 pixel buffer on each edge. 15 | 16 | ## Tile Generation 17 | 18 | The larger tiles are generated by sourcing the relevant neighbors from the 256px sources. 19 | 20 | In the diagrams below, `o` is the source tile, and the `x`s represent the neighbors used. 21 | 22 | ### 512 23 | 24 | ``` 25 | o x 26 | x x 27 | ``` 28 | 29 | ### 260 30 | 31 | ``` 32 | x x x 33 | x o x 34 | x x x 35 | ``` 36 | 37 | ### 516 38 | 39 | ``` 40 | x x x x 41 | x O o x 42 | x o o x 43 | x x x x 44 | ``` 45 | 46 | ## Edge Cases 47 | 48 | When on the "edge", there isn't a neighboring tile to source. Zaloa's behavior is: 49 | 50 | * column edge: wrap around and use the tile from the other "side". 51 | 52 | eg: The left neighbor of 2/0/2 will be 2/3/2 53 | 54 | * row edge: the source tile is "copied" into the necessary location. 55 | 56 | eg: The top neighbor of 2/2/0 is 2/2/0 itself, ie the top 2 rows of pixels are re-used as the buffer. 57 | 58 | ## Development 59 | 60 | We use [Pipenv](http://pipenv.readthedocs.io/en/latest/) to manage dependencies. To develop on this software, you'll need to get [pipenv installed first](http://pipenv.readthedocs.io/en/latest/install/#installing-pipenv). Once you have pipenv installed, you can install the dependencies: 61 | 62 | ``` 63 | pipenv sync 64 | ``` 65 | 66 | If you update the `Pipfile` or want to update the dependency versions, you can run `pipenv install --dev`. This recalculates all the dependency versions, so may throw up dependency version conflicts which aren't present in the existing `Pipfile.lock`. 67 | 68 | With the dependencies installed, you can enter the virtual environment so your dependencies are used: 69 | 70 | ``` 71 | pipenv shell 72 | ``` 73 | 74 | ## Configuration 75 | 76 | The important bits of configuration are set in `config.py` using environment variables: 77 | 78 | | Environment Variable Name | Description | 79 | |---|---| 80 | `TILES_FETCH_METHOD` | (`s3` or `http`) Specifies which method you want to use when requesting terrain tiles. 81 | `TILES_S3_BUCKET` | Specifies the S3 bucket to use when requesting terrain tiles (if the fetch method is `s3`). 82 | `TILES_HTTP_PREFIX` | Specifies the HTTP prefix to use when requesting terrain tiles (if the fetch method is `http`). 83 | 84 | ## Running locally 85 | 86 | Once you have the dependencies installed as described above, you can use the Flask command line tool to run the server locally. 87 | 88 | ``` 89 | FLASK_DEBUG=true FLASK_APP=wsgi_server.py flask run 90 | ``` 91 | 92 | The `FLASK_` environment variables specified before the `flask run` command are used to enable debug mode (`FLASK_DEBUG`) and to tell Flask's command line helper where the app code is (`FLASK_APP`). You can also include the other environment variables from the Configuration section here, too. When I run this locally to develop I run: 93 | 94 | ``` 95 | AWS_PROFILE=nextzen \ 96 | FLASK_DEBUG=true \ 97 | FLASK_APP=wsgi_server.py \ 98 | TILES_FETCH_METHOD=s3 \ 99 | TILES_S3_BUCKET=elevation-tiles-prod \ 100 | flask run 101 | ``` 102 | 103 | ## Deploying 104 | 105 | This server can run in a normal WSGI environment (on Heroku, with gunicorn, etc.) but it was designed with Lambda in mind. We use [Zappa](https://github.com/Miserlou/Zappa) to coordinate the package and deploy to Lambda. To get this to lambda, I ran: 106 | 107 | 1. Setup Zappa for your account and AWS environment: 108 | 109 | ``` 110 | zappa init 111 | ``` 112 | 113 | 1. Ask Zappa to deploy to your environment: 114 | 115 | ``` 116 | zappa deploy dev 117 | ``` 118 | 119 | 1. Once Zappa deploys your code, it will not work until you set the configuration variables mentioned in the Configuration section above. You can set those via [your Zappa configuration file](https://github.com/Miserlou/Zappa#remote-environment-variables) or on the [AWS Lambda console](https://console.aws.amazon.com/lambda/home). 120 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | dependencies: 2 | pre: 3 | - pip install pipenv flake8 4 | - pipenv sync 5 | test: 6 | override: 7 | - flake8 zaloa.py test.py 8 | - pipenv run python test.py 9 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | CORS_SEND_WILDCARD = True 5 | 6 | # The max age for tiles returned by this service. Clients (both browsers and intermediate caches like CloudFront) will 7 | # cache the tile this many seconds before checking with the origin to get a new tile. 8 | # http://werkzeug.pocoo.org/docs/0.14/datastructures/#werkzeug.datastructures.ResponseCacheControl.max_age 9 | CACHE_MAX_AGE = int(os.environ.get("CACHE_MAX_AGE", '1200')) 10 | 11 | # The "shared" max age for tiles returned by this service. When an object is beyond this age in a shared cache (like CloudFront), 12 | # the shared cache should check with the origin to see if the object was updated. In general, this number should be smaller than 13 | # the max age set above. 14 | # http://werkzeug.pocoo.org/docs/0.14/datastructures/#werkzeug.datastructures.ResponseCacheControl.s_maxage 15 | SHARED_CACHE_MAX_AGE = int(os.environ.get("SHARED_CACHE_MAX_AGE", '600')) 16 | 17 | CACHE_TYPE = os.environ.get('CACHE_TYPE', 'null') 18 | CACHE_NO_NULL_WARNING = True 19 | # Expose some of the caching config via environment variables 20 | # so we can have more freedom to configure this in-situ. 21 | CACHE_REDIS_URL = os.environ.get('CACHE_REDIS_URL') 22 | CACHE_THRESHOLD = int(os.environ.get('CACHE_THRESHOLD')) if os.environ.get('CACHE_THRESHOLD') else None 23 | CACHE_KEY_PREFIX = os.environ.get('CACHE_KEY_PREFIX') 24 | CACHE_DIR = os.environ.get('CACHE_DIR') 25 | 26 | # This can be 's3' or 'http' 27 | TILES_FETCH_METHOD = os.environ.get('TILES_FETCH_METHOD') 28 | TILES_S3_BUCKET = os.environ.get("TILES_S3_BUCKET") 29 | TILES_HTTP_PREFIX = os.environ.get("TILES_HTTP_PREFIX") 30 | REQUESTER_PAYS = os.environ.get("REQUESTER_PAYS", 'false') == 'true' 31 | -------------------------------------------------------------------------------- /server.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import math 3 | import sys 4 | import time 5 | from collections import namedtuple 6 | from io import BytesIO 7 | from flask import Blueprint, Flask, current_app, make_response, render_template, request, abort 8 | from flask_caching import Cache 9 | from flask_cors import CORS 10 | from zaloa import ( 11 | generate_coordinates_512, 12 | generate_coordinates_256, 13 | generate_coordinates_260, 14 | generate_coordinates_516, 15 | is_tile_valid, 16 | process_tile, 17 | ImageReducer, 18 | S3TileFetcher, 19 | HttpTileFetcher, 20 | Tile, 21 | ) 22 | 23 | 24 | tile_bp = Blueprint('tiles', __name__) 25 | cache = Cache() 26 | 27 | 28 | def create_app(): 29 | app = Flask(__name__) 30 | app.config.from_object('config') 31 | CORS(app) 32 | cache.init_app(app) 33 | 34 | @app.before_first_request 35 | def setup_logging(): 36 | if not app.debug: 37 | # In production mode, add log handler to sys.stderr. 38 | app.logger.addHandler(logging.StreamHandler()) 39 | app.logger.setLevel(logging.INFO) 40 | 41 | fetch_type = app.config.get('TILES_FETCH_METHOD') 42 | assert fetch_type in ('s3', 'http'), "Fetch method must be s3 or http" 43 | 44 | app.register_blueprint(tile_bp) 45 | 46 | return app 47 | 48 | 49 | @tile_bp.route('/tilezen/terrain/v1/////.png') 50 | @tile_bp.route('/tilezen/terrain/v1////.png') 51 | def handle_tile(z, x, y, tileset, tilesize=None): 52 | tilesize = tilesize or 256 53 | 54 | if tilesize not in (256, 260, 512, 516): 55 | return abort(404, 'Invalid tilesize') 56 | 57 | if tileset not in ('terrarium', 'normal'): 58 | return abort(404, 'Invalid tileset') 59 | 60 | if not is_tile_valid(z, x, y): 61 | return abort(404, 'Invalid tile coordinate') 62 | 63 | if tilesize != 260 and z == 15: 64 | return abort(404, 'Invalid zoom') 65 | 66 | tile = Tile(z, x, y) 67 | 68 | image_reducer = ImageReducer(tilesize) 69 | 70 | # both terrarium and normal tiles follow the same 71 | # coordinate generation strategy. They just point to a 72 | # different location for the source data 73 | if tilesize == 512: 74 | coords_generator = generate_coordinates_512 75 | elif tilesize == 256: 76 | coords_generator = generate_coordinates_256 77 | elif tilesize == 260: 78 | coords_generator = generate_coordinates_260 79 | elif tilesize == 516: 80 | coords_generator = generate_coordinates_516 81 | else: 82 | abort(500, 'tileset/tilesize combination unimplemented') 83 | 84 | fetch_type = current_app.config.get('TILES_FETCH_METHOD') 85 | if fetch_type == 's3': 86 | import boto3 87 | bucket = current_app.config.get('TILES_S3_BUCKET') 88 | s3_client = boto3.client('s3') 89 | tile_fetcher = S3TileFetcher(s3_client, bucket) 90 | elif fetch_type == 'http': 91 | import requests 92 | url_prefix = current_app.config.get('TILES_HTTP_PREFIX') 93 | tile_fetcher = HttpTileFetcher(requests, url_prefix) 94 | 95 | image_bytes, timing_metadata, tile_coords = process_tile( 96 | coords_generator, tile_fetcher, image_reducer, tileset, 97 | tile) 98 | 99 | resp = make_response(image_bytes) 100 | resp.content_type = 'image/png' 101 | return resp 102 | 103 | 104 | @tile_bp.route('/health_check') 105 | def health_check(): 106 | handle_tile(0, 0, 0, 'terrarium', tilesize=256) 107 | return 'OK' 108 | -------------------------------------------------------------------------------- /test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | 4 | class CoordsGeneratorTest(unittest.TestCase): 5 | 6 | def test_512(self): 7 | from zaloa import Tile 8 | from zaloa import generate_coordinates_512 9 | tile = Tile(0, 0, 0) 10 | all_coords = generate_coordinates_512(tile) 11 | just_tile_coords = [x.tile for x in all_coords] 12 | exp_coords = [ 13 | Tile(1, 0, 0), 14 | Tile(1, 1, 0), 15 | Tile(1, 0, 1), 16 | Tile(1, 1, 1), 17 | ] 18 | self.assertEqual(exp_coords, just_tile_coords) 19 | 20 | def test_260(self): 21 | from zaloa import Tile 22 | from zaloa import generate_coordinates_260 23 | tile = Tile(2, 1, 1) 24 | all_coords = generate_coordinates_260(tile) 25 | just_tile_coords = [x.tile for x in all_coords] 26 | exp_coords = [ 27 | Tile(2, 0, 0), Tile(2, 1, 0), Tile(2, 2, 0), 28 | Tile(2, 0, 1), Tile(2, 1, 1), Tile(2, 2, 1), 29 | Tile(2, 0, 2), Tile(2, 1, 2), Tile(2, 2, 2), 30 | ] 31 | self.assertEqual(exp_coords, just_tile_coords) 32 | 33 | def test_516(self): 34 | from zaloa import Tile 35 | from zaloa import generate_coordinates_516 36 | tile = Tile(2, 1, 1) 37 | all_coords = generate_coordinates_516(tile) 38 | just_tile_coords = [x.tile for x in all_coords] 39 | exp_coords = [ 40 | Tile(3, 1, 1), Tile(3, 2, 1), Tile(3, 3, 1), Tile(3, 4, 1), 41 | Tile(3, 1, 2), Tile(3, 2, 2), Tile(3, 3, 2), Tile(3, 4, 2), 42 | Tile(3, 1, 3), Tile(3, 2, 3), Tile(3, 3, 3), Tile(3, 4, 3), 43 | Tile(3, 1, 4), Tile(3, 2, 4), Tile(3, 3, 4), Tile(3, 4, 4), 44 | ] 45 | self.assertEqual(exp_coords, just_tile_coords) 46 | 47 | def test_edge_260_topleft(self): 48 | from zaloa import Tile 49 | from zaloa import generate_coordinates_260 50 | tile = Tile(2, 0, 0) 51 | all_coords = generate_coordinates_260(tile) 52 | nw, n, ne, w, c, e, sw, s, se = all_coords 53 | self.assertEqual(nw.tile, Tile(2, 3, 0)) 54 | self.assertEqual(n.tile, Tile(2, 0, 0)) 55 | self.assertEqual(ne.tile, Tile(2, 1, 0)) 56 | self.assertEqual(w.tile, Tile(2, 3, 0)) 57 | self.assertEqual(c.tile, Tile(2, 0, 0)) 58 | self.assertEqual(e.tile, Tile(2, 1, 0)) 59 | self.assertEqual(nw.image_spec.crop_bounds, (254, 0, 256, 2)) 60 | self.assertEqual(n.image_spec.crop_bounds, (0, 0, 256, 2)) 61 | self.assertEqual(ne.image_spec.crop_bounds, (0, 0, 2, 2)) 62 | 63 | def test_edge_260_topmid(self): 64 | from zaloa import Tile 65 | from zaloa import generate_coordinates_260 66 | tile = Tile(2, 1, 0) 67 | all_coords = generate_coordinates_260(tile) 68 | nw, n, ne, w, c, e, sw, s, se = all_coords 69 | self.assertEqual(nw.tile, Tile(2, 0, 0)) 70 | self.assertEqual(n.tile, Tile(2, 1, 0)) 71 | self.assertEqual(ne.tile, Tile(2, 2, 0)) 72 | self.assertEqual(w.tile, Tile(2, 0, 0)) 73 | self.assertEqual(c.tile, Tile(2, 1, 0)) 74 | self.assertEqual(e.tile, Tile(2, 2, 0)) 75 | self.assertEqual(nw.image_spec.crop_bounds, (254, 0, 256, 2)) 76 | self.assertEqual(n.image_spec.crop_bounds, (0, 0, 256, 2)) 77 | self.assertEqual(ne.image_spec.crop_bounds, (0, 0, 2, 2)) 78 | 79 | def test_edge_260_topright(self): 80 | from zaloa import Tile 81 | from zaloa import generate_coordinates_260 82 | tile = Tile(2, 3, 0) 83 | all_coords = generate_coordinates_260(tile) 84 | nw, n, ne, w, c, e, sw, s, se = all_coords 85 | self.assertEqual(nw.tile, Tile(2, 2, 0)) 86 | self.assertEqual(n.tile, Tile(2, 3, 0)) 87 | self.assertEqual(ne.tile, Tile(2, 0, 0)) 88 | self.assertEqual(w.tile, Tile(2, 2, 0)) 89 | self.assertEqual(c.tile, Tile(2, 3, 0)) 90 | self.assertEqual(e.tile, Tile(2, 0, 0)) 91 | self.assertEqual(nw.image_spec.crop_bounds, (254, 0, 256, 2)) 92 | self.assertEqual(n.image_spec.crop_bounds, (0, 0, 256, 2)) 93 | self.assertEqual(ne.image_spec.crop_bounds, (0, 0, 2, 2)) 94 | 95 | def test_edge_260_botleft(self): 96 | from zaloa import Tile 97 | from zaloa import generate_coordinates_260 98 | tile = Tile(2, 0, 3) 99 | all_coords = generate_coordinates_260(tile) 100 | nw, n, ne, w, c, e, sw, s, se = all_coords 101 | self.assertEqual(sw.tile, Tile(2, 3, 3)) 102 | self.assertEqual(s.tile, Tile(2, 0, 3)) 103 | self.assertEqual(se.tile, Tile(2, 1, 3)) 104 | self.assertEqual(w.tile, Tile(2, 3, 3)) 105 | self.assertEqual(c.tile, Tile(2, 0, 3)) 106 | self.assertEqual(e.tile, Tile(2, 1, 3)) 107 | self.assertEqual(sw.image_spec.crop_bounds, (254, 254, 256, 256)) 108 | self.assertEqual(s.image_spec.crop_bounds, (0, 254, 256, 256)) 109 | self.assertEqual(se.image_spec.crop_bounds, (0, 254, 2, 256)) 110 | 111 | def test_edge_260_botmid(self): 112 | from zaloa import Tile 113 | from zaloa import generate_coordinates_260 114 | tile = Tile(2, 2, 3) 115 | all_coords = generate_coordinates_260(tile) 116 | nw, n, ne, w, c, e, sw, s, se = all_coords 117 | self.assertEqual(sw.tile, Tile(2, 1, 3)) 118 | self.assertEqual(s.tile, Tile(2, 2, 3)) 119 | self.assertEqual(se.tile, Tile(2, 3, 3)) 120 | self.assertEqual(w.tile, Tile(2, 1, 3)) 121 | self.assertEqual(c.tile, Tile(2, 2, 3)) 122 | self.assertEqual(e.tile, Tile(2, 3, 3)) 123 | self.assertEqual(sw.image_spec.crop_bounds, (254, 254, 256, 256)) 124 | self.assertEqual(s.image_spec.crop_bounds, (0, 254, 256, 256)) 125 | self.assertEqual(se.image_spec.crop_bounds, (0, 254, 2, 256)) 126 | 127 | def test_edge_260_botright(self): 128 | from zaloa import Tile 129 | from zaloa import generate_coordinates_260 130 | tile = Tile(2, 3, 3) 131 | all_coords = generate_coordinates_260(tile) 132 | nw, n, ne, w, c, e, sw, s, se = all_coords 133 | self.assertEqual(sw.tile, Tile(2, 2, 3)) 134 | self.assertEqual(s.tile, Tile(2, 3, 3)) 135 | self.assertEqual(se.tile, Tile(2, 0, 3)) 136 | self.assertEqual(w.tile, Tile(2, 2, 3)) 137 | self.assertEqual(c.tile, Tile(2, 3, 3)) 138 | self.assertEqual(e.tile, Tile(2, 0, 3)) 139 | self.assertEqual(sw.image_spec.crop_bounds, (254, 254, 256, 256)) 140 | self.assertEqual(s.image_spec.crop_bounds, (0, 254, 256, 256)) 141 | self.assertEqual(se.image_spec.crop_bounds, (0, 254, 2, 256)) 142 | 143 | def test_edge_260_midleft(self): 144 | from zaloa import Tile 145 | from zaloa import generate_coordinates_260 146 | tile = Tile(2, 0, 2) 147 | all_coords = generate_coordinates_260(tile) 148 | nw, n, ne, w, c, e, sw, s, se = all_coords 149 | self.assertEqual(w.tile, Tile(2, 3, 2)) 150 | self.assertEqual(c.tile, Tile(2, 0, 2)) 151 | self.assertEqual(e.tile, Tile(2, 1, 2)) 152 | self.assertEqual(w.image_spec.crop_bounds, (254, 0, 256, 256)) 153 | self.assertIsNone(c.image_spec.crop_bounds) 154 | self.assertEqual(e.image_spec.crop_bounds, (0, 0, 2, 256)) 155 | 156 | def test_edge_260_midright(self): 157 | from zaloa import Tile 158 | from zaloa import generate_coordinates_260 159 | tile = Tile(2, 3, 1) 160 | all_coords = generate_coordinates_260(tile) 161 | nw, n, ne, w, c, e, sw, s, se = all_coords 162 | self.assertEqual(w.tile, Tile(2, 2, 1)) 163 | self.assertEqual(c.tile, Tile(2, 3, 1)) 164 | self.assertEqual(e.tile, Tile(2, 0, 1)) 165 | self.assertEqual(w.image_spec.crop_bounds, (254, 0, 256, 256)) 166 | self.assertIsNone(c.image_spec.crop_bounds) 167 | self.assertEqual(e.image_spec.crop_bounds, (0, 0, 2, 256)) 168 | 169 | def test_edge_516_topleft(self): 170 | from zaloa import Tile 171 | from zaloa import generate_coordinates_516 172 | tile = Tile(2, 0, 0) 173 | coords = generate_coordinates_516(tile) 174 | self.assertEqual(coords[0].tile, Tile(3, 7, 0)) 175 | self.assertEqual(coords[1].tile, Tile(3, 0, 0)) 176 | self.assertEqual(coords[2].tile, Tile(3, 1, 0)) 177 | self.assertEqual(coords[3].tile, Tile(3, 2, 0)) 178 | self.assertEqual(coords[4].tile, Tile(3, 7, 0)) 179 | self.assertEqual(coords[5].tile, Tile(3, 0, 0)) 180 | self.assertEqual(coords[6].tile, Tile(3, 1, 0)) 181 | self.assertEqual(coords[7].tile, Tile(3, 2, 0)) 182 | self.assertEqual(coords[0].image_spec.crop_bounds, (254, 0, 256, 2)) 183 | self.assertEqual(coords[1].image_spec.crop_bounds, (0, 0, 256, 2)) 184 | self.assertEqual(coords[2].image_spec.crop_bounds, (0, 0, 256, 2)) 185 | self.assertEqual(coords[3].image_spec.crop_bounds, (0, 0, 2, 2)) 186 | 187 | def test_edge_516_topmid(self): 188 | from zaloa import Tile 189 | from zaloa import generate_coordinates_516 190 | tile = Tile(2, 2, 0) 191 | coords = generate_coordinates_516(tile) 192 | self.assertEqual(coords[0].tile, Tile(3, 3, 0)) 193 | self.assertEqual(coords[1].tile, Tile(3, 4, 0)) 194 | self.assertEqual(coords[2].tile, Tile(3, 5, 0)) 195 | self.assertEqual(coords[3].tile, Tile(3, 6, 0)) 196 | self.assertEqual(coords[4].tile, Tile(3, 3, 0)) 197 | self.assertEqual(coords[5].tile, Tile(3, 4, 0)) 198 | self.assertEqual(coords[6].tile, Tile(3, 5, 0)) 199 | self.assertEqual(coords[7].tile, Tile(3, 6, 0)) 200 | self.assertEqual(coords[0].image_spec.crop_bounds, (254, 0, 256, 2)) 201 | self.assertEqual(coords[1].image_spec.crop_bounds, (0, 0, 256, 2)) 202 | self.assertEqual(coords[2].image_spec.crop_bounds, (0, 0, 256, 2)) 203 | self.assertEqual(coords[3].image_spec.crop_bounds, (0, 0, 2, 2)) 204 | 205 | def test_edge_516_topright(self): 206 | from zaloa import Tile 207 | from zaloa import generate_coordinates_516 208 | tile = Tile(2, 3, 0) 209 | coords = generate_coordinates_516(tile) 210 | self.assertEqual(coords[0].tile, Tile(3, 5, 0)) 211 | self.assertEqual(coords[1].tile, Tile(3, 6, 0)) 212 | self.assertEqual(coords[2].tile, Tile(3, 7, 0)) 213 | self.assertEqual(coords[3].tile, Tile(3, 0, 0)) 214 | self.assertEqual(coords[4].tile, Tile(3, 5, 0)) 215 | self.assertEqual(coords[5].tile, Tile(3, 6, 0)) 216 | self.assertEqual(coords[6].tile, Tile(3, 7, 0)) 217 | self.assertEqual(coords[7].tile, Tile(3, 0, 0)) 218 | self.assertEqual(coords[0].tile, Tile(3, 5, 0)) 219 | self.assertEqual(coords[1].tile, Tile(3, 6, 0)) 220 | self.assertEqual(coords[2].tile, Tile(3, 7, 0)) 221 | self.assertEqual(coords[3].tile, Tile(3, 0, 0)) 222 | self.assertEqual(coords[0].image_spec.crop_bounds, (254, 0, 256, 2)) 223 | self.assertEqual(coords[1].image_spec.crop_bounds, (0, 0, 256, 2)) 224 | self.assertEqual(coords[2].image_spec.crop_bounds, (0, 0, 256, 2)) 225 | self.assertEqual(coords[3].image_spec.crop_bounds, (0, 0, 2, 2)) 226 | 227 | def test_edge_516_midleft(self): 228 | from zaloa import Tile 229 | from zaloa import generate_coordinates_516 230 | tile = Tile(2, 0, 3) 231 | coords = generate_coordinates_516(tile) 232 | self.assertEqual(coords[4].tile, Tile(3, 7, 6)) 233 | self.assertEqual(coords[5].tile, Tile(3, 0, 6)) 234 | self.assertEqual(coords[6].tile, Tile(3, 1, 6)) 235 | self.assertEqual(coords[7].tile, Tile(3, 2, 6)) 236 | self.assertEqual(coords[8].tile, Tile(3, 7, 7)) 237 | self.assertEqual(coords[9].tile, Tile(3, 0, 7)) 238 | self.assertEqual(coords[10].tile, Tile(3, 1, 7)) 239 | self.assertEqual(coords[11].tile, Tile(3, 2, 7)) 240 | self.assertEqual(coords[4].image_spec.crop_bounds, (254, 0, 256, 256)) 241 | self.assertEqual(coords[5].image_spec.crop_bounds, None) 242 | self.assertEqual(coords[6].image_spec.crop_bounds, None) 243 | self.assertEqual(coords[7].image_spec.crop_bounds, (0, 0, 2, 256)) 244 | self.assertEqual(coords[8].image_spec.crop_bounds, (254, 0, 256, 256)) 245 | self.assertEqual(coords[9].image_spec.crop_bounds, None) 246 | self.assertEqual(coords[10].image_spec.crop_bounds, None) 247 | self.assertEqual(coords[11].image_spec.crop_bounds, (0, 0, 2, 256)) 248 | 249 | def test_edge_516_midright(self): 250 | from zaloa import Tile 251 | from zaloa import generate_coordinates_516 252 | tile = Tile(2, 3, 2) 253 | coords = generate_coordinates_516(tile) 254 | self.assertEqual(coords[4].tile, Tile(3, 5, 4)) 255 | self.assertEqual(coords[5].tile, Tile(3, 6, 4)) 256 | self.assertEqual(coords[6].tile, Tile(3, 7, 4)) 257 | self.assertEqual(coords[7].tile, Tile(3, 0, 4)) 258 | self.assertEqual(coords[8].tile, Tile(3, 5, 5)) 259 | self.assertEqual(coords[9].tile, Tile(3, 6, 5)) 260 | self.assertEqual(coords[10].tile, Tile(3, 7, 5)) 261 | self.assertEqual(coords[11].tile, Tile(3, 0, 5)) 262 | self.assertEqual(coords[4].image_spec.crop_bounds, (254, 0, 256, 256)) 263 | self.assertEqual(coords[5].image_spec.crop_bounds, None) 264 | self.assertEqual(coords[6].image_spec.crop_bounds, None) 265 | self.assertEqual(coords[7].image_spec.crop_bounds, (0, 0, 2, 256)) 266 | self.assertEqual(coords[8].image_spec.crop_bounds, (254, 0, 256, 256)) 267 | self.assertEqual(coords[9].image_spec.crop_bounds, None) 268 | self.assertEqual(coords[10].image_spec.crop_bounds, None) 269 | self.assertEqual(coords[11].image_spec.crop_bounds, (0, 0, 2, 256)) 270 | 271 | def test_edge_516_botleft(self): 272 | from zaloa import Tile 273 | from zaloa import generate_coordinates_516 274 | tile = Tile(2, 0, 3) 275 | coords = generate_coordinates_516(tile) 276 | self.assertEqual(coords[8].tile, Tile(3, 7, 7)) 277 | self.assertEqual(coords[9].tile, Tile(3, 0, 7)) 278 | self.assertEqual(coords[10].tile, Tile(3, 1, 7)) 279 | self.assertEqual(coords[11].tile, Tile(3, 2, 7)) 280 | self.assertEqual(coords[12].tile, Tile(3, 7, 7)) 281 | self.assertEqual(coords[13].tile, Tile(3, 0, 7)) 282 | self.assertEqual(coords[14].tile, Tile(3, 1, 7)) 283 | self.assertEqual(coords[15].tile, Tile(3, 2, 7)) 284 | self.assertEqual(coords[12].image_spec.crop_bounds, 285 | (254, 254, 256, 256)) 286 | self.assertEqual(coords[13].image_spec.crop_bounds, 287 | (0, 254, 256, 256)) 288 | self.assertEqual(coords[14].image_spec.crop_bounds, 289 | (0, 254, 256, 256)) 290 | self.assertEqual(coords[15].image_spec.crop_bounds, 291 | (0, 254, 2, 256)) 292 | 293 | def test_edge_516_botmid(self): 294 | from zaloa import Tile 295 | from zaloa import generate_coordinates_516 296 | tile = Tile(2, 1, 3) 297 | coords = generate_coordinates_516(tile) 298 | self.assertEqual(coords[8].tile, Tile(3, 1, 7)) 299 | self.assertEqual(coords[9].tile, Tile(3, 2, 7)) 300 | self.assertEqual(coords[10].tile, Tile(3, 3, 7)) 301 | self.assertEqual(coords[11].tile, Tile(3, 4, 7)) 302 | self.assertEqual(coords[12].tile, Tile(3, 1, 7)) 303 | self.assertEqual(coords[13].tile, Tile(3, 2, 7)) 304 | self.assertEqual(coords[14].tile, Tile(3, 3, 7)) 305 | self.assertEqual(coords[15].tile, Tile(3, 4, 7)) 306 | self.assertEqual(coords[12].image_spec.crop_bounds, 307 | (254, 254, 256, 256)) 308 | self.assertEqual(coords[13].image_spec.crop_bounds, 309 | (0, 254, 256, 256)) 310 | self.assertEqual(coords[14].image_spec.crop_bounds, 311 | (0, 254, 256, 256)) 312 | self.assertEqual(coords[15].image_spec.crop_bounds, 313 | (0, 254, 2, 256)) 314 | 315 | def test_edge_516_botright(self): 316 | from zaloa import Tile 317 | from zaloa import generate_coordinates_516 318 | tile = Tile(2, 3, 3) 319 | coords = generate_coordinates_516(tile) 320 | self.assertEqual(coords[8].tile, Tile(3, 5, 7)) 321 | self.assertEqual(coords[9].tile, Tile(3, 6, 7)) 322 | self.assertEqual(coords[10].tile, Tile(3, 7, 7)) 323 | self.assertEqual(coords[11].tile, Tile(3, 0, 7)) 324 | self.assertEqual(coords[12].tile, Tile(3, 5, 7)) 325 | self.assertEqual(coords[13].tile, Tile(3, 6, 7)) 326 | self.assertEqual(coords[14].tile, Tile(3, 7, 7)) 327 | self.assertEqual(coords[15].tile, Tile(3, 0, 7)) 328 | self.assertEqual(coords[12].image_spec.crop_bounds, 329 | (254, 254, 256, 256)) 330 | self.assertEqual(coords[13].image_spec.crop_bounds, 331 | (0, 254, 256, 256)) 332 | self.assertEqual(coords[14].image_spec.crop_bounds, 333 | (0, 254, 256, 256)) 334 | self.assertEqual(coords[15].image_spec.crop_bounds, 335 | (0, 254, 2, 256)) 336 | 337 | 338 | class S3FetchTest(unittest.TestCase): 339 | 340 | def test_success(self): 341 | 342 | class StubS3Client(object): 343 | 344 | def get_object(self, **kwargs): 345 | self.kwargs = kwargs 346 | from io import BytesIO 347 | return dict( 348 | Body=BytesIO(b'image data'), 349 | ) 350 | 351 | from zaloa import S3TileFetcher 352 | bucket = 'fake-bucket' 353 | tileset = 'terrarium' 354 | stub_s3_client = StubS3Client() 355 | s3_tile_fetcher = S3TileFetcher(stub_s3_client, bucket) 356 | from zaloa import Tile 357 | fetch_result = s3_tile_fetcher(tileset, Tile(3, 2, 1)) 358 | self.assertEqual(b'image data', fetch_result.image_bytes) 359 | self.assertEqual('fake-bucket', stub_s3_client.kwargs['Bucket']) 360 | self.assertEqual('terrarium/3/2/1.png', stub_s3_client.kwargs['Key']) 361 | 362 | def test_missing(self): 363 | 364 | class StubS3Exception(Exception): 365 | 366 | def __init__(self, *args, **kwargs): 367 | super(StubS3Exception, self).__init__(*args, **kwargs) 368 | self.response = dict( 369 | Error=dict( 370 | Code='NoSuchKey', 371 | ), 372 | ) 373 | 374 | class StubS3Client(object): 375 | 376 | def get_object(self, **kwargs): 377 | raise StubS3Exception('test missing tile') 378 | 379 | from zaloa import S3TileFetcher 380 | bucket = 'fake-bucket' 381 | tileset = 'terrarium' 382 | stub_s3_client = StubS3Client() 383 | s3_tile_fetcher = S3TileFetcher(stub_s3_client, bucket) 384 | from zaloa import Tile 385 | from zaloa import MissingTileException 386 | with self.assertRaises(MissingTileException) as cm: 387 | s3_tile_fetcher(tileset, Tile(3, 2, 1)) 388 | self.assertEqual(Tile(3, 2, 1), cm.exception.tile) 389 | 390 | def test_unknown_exception(self): 391 | 392 | class StubS3Exception(Exception): 393 | 394 | def __init__(self, message): 395 | super(StubS3Exception, self).__init__() 396 | self.message = message 397 | 398 | class StubS3Client(object): 399 | 400 | def get_object(self, **kwargs): 401 | raise StubS3Exception('unknown exception') 402 | 403 | from zaloa import S3TileFetcher 404 | bucket = 'fake-bucket' 405 | tileset = 'terrarium' 406 | stub_s3_client = StubS3Client() 407 | s3_tile_fetcher = S3TileFetcher(stub_s3_client, bucket) 408 | from zaloa import Tile 409 | from zaloa import MissingTileException 410 | with self.assertRaises(Exception) as cm: 411 | s3_tile_fetcher(tileset, Tile(3, 2, 1)) 412 | self.assertFalse(isinstance(cm.exception, MissingTileException)) 413 | self.assertEqual('unknown exception', cm.exception.message) 414 | 415 | 416 | class HttpFetchTest(unittest.TestCase): 417 | 418 | def test_success(self): 419 | 420 | class StubHttpResponse(object): 421 | 422 | def __init__(self, status_code, content): 423 | self.status_code = status_code 424 | self.content = content 425 | 426 | class StubHttpClient(object): 427 | 428 | def get(self, url): 429 | self.url = url 430 | return StubHttpResponse(200, 'image data') 431 | 432 | from zaloa import HttpTileFetcher 433 | tileset = 'terrarium' 434 | stub_http_client = StubHttpClient() 435 | url_prefix = 'http://foo' 436 | http_tile_fetcher = HttpTileFetcher(stub_http_client, url_prefix) 437 | from zaloa import Tile 438 | fetch_result = http_tile_fetcher(tileset, Tile(3, 2, 1)) 439 | self.assertEqual('image data', fetch_result.image_bytes) 440 | self.assertEqual( 441 | 'http://foo/terrarium/3/2/1.png', stub_http_client.url) 442 | 443 | def test_missing(self): 444 | 445 | class StubHttpResponse(object): 446 | 447 | def __init__(self, status_code): 448 | self.status_code = status_code 449 | 450 | class StubHttpClient(object): 451 | 452 | def get(self, url): 453 | return StubHttpResponse(404) 454 | 455 | from zaloa import HttpTileFetcher 456 | tileset = 'terrarium' 457 | stub_http_client = StubHttpClient() 458 | url_prefix = 'http://foo' 459 | http_tile_fetcher = HttpTileFetcher(stub_http_client, url_prefix) 460 | from zaloa import Tile 461 | from zaloa import MissingTileException 462 | with self.assertRaises(MissingTileException) as cm: 463 | http_tile_fetcher(tileset, Tile(3, 2, 1)) 464 | self.assertEqual(Tile(3, 2, 1), cm.exception.tile) 465 | 466 | def test_unknown_exception(self): 467 | 468 | class StubHttpException(Exception): 469 | 470 | def __init__(self, message): 471 | super(StubHttpException, self).__init__() 472 | self.message = message 473 | 474 | class StubHttpClient(object): 475 | 476 | def get(self, url): 477 | raise StubHttpException('unknown exception') 478 | 479 | from zaloa import HttpTileFetcher 480 | tileset = 'terrarium' 481 | stub_http_client = StubHttpClient() 482 | url_prefix = 'http://foo' 483 | http_tile_fetcher = HttpTileFetcher(stub_http_client, url_prefix) 484 | from zaloa import Tile 485 | from zaloa import MissingTileException 486 | with self.assertRaises(Exception) as cm: 487 | http_tile_fetcher(tileset, Tile(3, 2, 1)) 488 | self.assertFalse(isinstance(cm.exception, MissingTileException)) 489 | self.assertEqual('unknown exception', cm.exception.message) 490 | 491 | 492 | class ProcessTileTest(unittest.TestCase): 493 | 494 | def test_basic_invocation(self): 495 | from zaloa import process_tile 496 | from zaloa import generate_coordinates_512 497 | from zaloa import Tile 498 | 499 | def stub_fetch(tileset, tile): 500 | from zaloa import FetchResult 501 | return FetchResult('image data', tile) 502 | 503 | class StubImageReducer(object): 504 | 505 | def create_initial_state(self): 506 | return None 507 | 508 | def reduce(self, image_state, image_input): 509 | pass 510 | 511 | def finalize(self, image_state): 512 | return 'combined image data' 513 | 514 | stub_reducer = StubImageReducer() 515 | 516 | response, metadata, tiles = process_tile( 517 | generate_coordinates_512, 518 | stub_fetch, 519 | stub_reducer, 520 | 'terrarium', 521 | Tile(0, 0, 0), 522 | ) 523 | 524 | self.assertEqual('combined image data', response) 525 | 526 | def _gen_stub_image(self, color): 527 | from PIL import Image 528 | im = Image.new('RGB', (256, 256)) 529 | for y in range(256): 530 | for x in range(256): 531 | im.putpixel((x, y), color) 532 | from io import BytesIO 533 | fp = BytesIO() 534 | im.save(fp, format='PNG') 535 | return fp.getvalue() 536 | 537 | def test_validity_512(self): 538 | from zaloa import process_tile 539 | from zaloa import generate_coordinates_512 540 | from zaloa import Tile 541 | 542 | def stub_fetch(tileset, tile): 543 | # return back 544 | # r g 545 | # b w 546 | # oriented around test coordinate of 2/1/1 547 | from zaloa import FetchResult 548 | if tile == Tile(3, 2, 2): 549 | # nw 550 | color = 255, 0, 0 551 | elif tile == Tile(3, 3, 2): 552 | # ne 553 | color = 0, 255, 0 554 | elif tile == Tile(3, 2, 3): 555 | # sw 556 | color = 0, 0, 255 557 | elif tile == Tile(3, 3, 3): 558 | # se 559 | color = 255, 255, 255 560 | else: 561 | assert not 'Invalid tile coordinate: %s' % tile 562 | image_bytes = self._gen_stub_image(color) 563 | return FetchResult(image_bytes, tile) 564 | 565 | from zaloa import ImageReducer 566 | image_reducer = ImageReducer(512) 567 | 568 | image_bytes, metadata, tiles = process_tile( 569 | generate_coordinates_512, 570 | stub_fetch, 571 | image_reducer, 572 | 'terrarium', 573 | Tile(2, 1, 1), 574 | ) 575 | 576 | from io import BytesIO 577 | fp = BytesIO(image_bytes) 578 | from PIL import Image 579 | im = Image.open(fp) 580 | 581 | # nw -> red 582 | # ne -> green 583 | # sw -> blue 584 | # se -> white 585 | expectations = ( 586 | ((0, 0, 256, 256), (255, 0, 0, 255)), 587 | ((256, 0, 512, 256), (0, 255, 0, 255)), 588 | ((0, 256, 256, 512), (0, 0, 255, 255)), 589 | ((256, 256, 512, 512), (255, 255, 255, 255)), 590 | ) 591 | for region_bounds, color in expectations: 592 | for y in range(region_bounds[1], region_bounds[3]): 593 | for x in range(region_bounds[0], region_bounds[2]): 594 | pixel = im.getpixel((x, y)) 595 | self.assertEqual(color, pixel) 596 | 597 | def test_validity_260(self): 598 | from zaloa import process_tile 599 | from zaloa import generate_coordinates_260 600 | from zaloa import Tile 601 | 602 | def stub_fetch(tileset, tile): 603 | # return back 604 | # r r r 605 | # g w g 606 | # b b b 607 | # oriented around test coordinate of 2/1/1 608 | from zaloa import FetchResult 609 | if tile in (Tile(2, 0, 0), Tile(2, 1, 0), Tile(2, 2, 0)): 610 | # top row 611 | color = 255, 0, 0 612 | elif tile in (Tile(2, 0, 1), Tile(2, 2, 1)): 613 | # left and right edges 614 | color = 0, 255, 0 615 | elif tile == Tile(2, 1, 1): 616 | # center tile 617 | color = 255, 255, 255 618 | elif tile in (Tile(2, 0, 2), Tile(2, 1, 2), Tile(2, 2, 2)): 619 | # bottom row 620 | color = 0, 0, 255 621 | else: 622 | assert not 'Invalid tile coordinate: %s' % tile 623 | image_bytes = self._gen_stub_image(color) 624 | return FetchResult(image_bytes, tile) 625 | 626 | from zaloa import ImageReducer 627 | image_reducer = ImageReducer(260) 628 | 629 | image_bytes, metadata, tiles = process_tile( 630 | generate_coordinates_260, 631 | stub_fetch, 632 | image_reducer, 633 | 'terrarium', 634 | Tile(2, 1, 1), 635 | ) 636 | 637 | from io import BytesIO 638 | fp = BytesIO(image_bytes) 639 | from PIL import Image 640 | im = Image.open(fp) 641 | 642 | # top 2 rows should be red 643 | # left and right 2 columns should be green 644 | # center should be white 645 | # bottom 2 rows should be blue 646 | expectations = ( 647 | # top rows red 648 | ((0, 0, 260, 2), (255, 0, 0, 255)), 649 | 650 | # left cols green 651 | ((0, 2, 2, 258), (0, 255, 0, 255)), 652 | # right cols green 653 | ((258, 2, 260, 258), (0, 255, 0, 255)), 654 | 655 | # center white 656 | ((2, 2, 258, 258), (255, 255, 255, 255)), 657 | 658 | # bottom rows blue 659 | ((0, 258, 260, 260), (0, 0, 255, 255)), 660 | ) 661 | for region_bounds, color in expectations: 662 | for y in range(region_bounds[1], region_bounds[3]): 663 | for x in range(region_bounds[0], region_bounds[2]): 664 | pixel = im.getpixel((x, y)) 665 | self.assertEqual(color, pixel) 666 | 667 | def test_validity_516(self): 668 | from zaloa import process_tile 669 | from zaloa import generate_coordinates_516 670 | from zaloa import Tile 671 | 672 | def stub_fetch(tileset, tile): 673 | from zaloa import FetchResult 674 | 675 | # return back 676 | # r r r r 677 | # g w w g 678 | # g w w g 679 | # b b b b 680 | 681 | # oriented around test coordinate of 2/1/1 682 | # 2/1/1 -> 3/2/2 (top left corner) 683 | 684 | # 3/1/1 3/2/1 3/3/1 3/4/1 685 | # 3/1/2 3/2/2 3/3/2 3/4/2 686 | # 3/1/3 3/2/3 3/3/3 3/4/3 687 | # 3/1/4 3/2/4 3/3/4 3/4/4 688 | 689 | if tile in (Tile(3, 1, 1), Tile(3, 2, 1), 690 | Tile(3, 3, 1), Tile(3, 4, 1)): 691 | # top row 692 | color = 255, 0, 0 693 | elif tile in (Tile(3, 1, 2), Tile(3, 1, 3), 694 | Tile(3, 4, 2), Tile(3, 4, 3)): 695 | # left and right edges 696 | color = 0, 255, 0 697 | elif tile in (Tile(3, 2, 2), Tile(3, 3, 2), 698 | Tile(3, 2, 3), Tile(3, 3, 3)): 699 | # center tiles 700 | color = 255, 255, 255 701 | elif tile in (Tile(3, 1, 4), Tile(3, 2, 4), 702 | Tile(3, 3, 4), Tile(3, 4, 4)): 703 | # bottom row 704 | color = 0, 0, 255 705 | else: 706 | assert not 'Invalid tile coordinate: %s' % tile 707 | image_bytes = self._gen_stub_image(color) 708 | return FetchResult(image_bytes, tile) 709 | 710 | from zaloa import ImageReducer 711 | image_reducer = ImageReducer(516) 712 | 713 | image_bytes, metadata, tiles = process_tile( 714 | generate_coordinates_516, 715 | stub_fetch, 716 | image_reducer, 717 | 'terrarium', 718 | Tile(2, 1, 1), 719 | ) 720 | 721 | from io import BytesIO 722 | fp = BytesIO(image_bytes) 723 | from PIL import Image 724 | im = Image.open(fp) 725 | 726 | # top 2 rows should be red 727 | # left and right 2 columns should be green 728 | # center should be white 729 | # bottom 2 rows should be blue 730 | expectations = ( 731 | # top rows red 732 | ((0, 0, 516, 2), (255, 0, 0, 255)), 733 | 734 | # left cols green 735 | ((0, 2, 2, 514), (0, 255, 0, 255)), 736 | # right cols green 737 | ((514, 2, 516, 514), (0, 255, 0, 255)), 738 | 739 | # center white 740 | ((2, 2, 514, 514), (255, 255, 255, 255)), 741 | 742 | # bottom rows blue 743 | ((0, 514, 516, 516), (0, 0, 255, 255)), 744 | ) 745 | for region_bounds, color in expectations: 746 | for y in range(region_bounds[1], region_bounds[3]): 747 | for x in range(region_bounds[0], region_bounds[2]): 748 | pixel = im.getpixel((x, y)) 749 | self.assertEqual(color, pixel) 750 | 751 | 752 | if __name__ == '__main__': 753 | unittest.main() 754 | -------------------------------------------------------------------------------- /wsgi_server.py: -------------------------------------------------------------------------------- 1 | from server import create_app 2 | 3 | app = create_app() 4 | -------------------------------------------------------------------------------- /zaloa.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | 3 | from collections import namedtuple 4 | from io import BytesIO 5 | from PIL import Image 6 | from time import time 7 | import math 8 | import queue 9 | import threading 10 | 11 | 12 | def is_tile_valid(z, x, y): 13 | if z < 0 or x < 0 or y < 0: 14 | return False 15 | if z > 15: 16 | return False 17 | x_y_limit = int(math.pow(2, z)) 18 | if x >= x_y_limit or y >= x_y_limit: 19 | return False 20 | return True 21 | 22 | 23 | class Tile(object): 24 | """Simple container for a tile coordinate""" 25 | 26 | def __init__(self, z, x, y): 27 | assert is_tile_valid(z, x, y) 28 | self.z = z 29 | self.x = x 30 | self.y = y 31 | 32 | def __str__(self): 33 | return '%d/%d/%d' % (self.z, self.x, self.y) 34 | 35 | def __repr__(self): 36 | return str(self) 37 | 38 | def __eq__(self, that): 39 | return (self.z == that.z and 40 | self.x == that.x and 41 | self.y == that.y) 42 | 43 | 44 | # TODO fetchresult can grow to contain response caching headers 45 | FetchResult = namedtuple('FetchResult', 'image_bytes tile') 46 | 47 | # image specification defines the image placement of the source in the 48 | # final destination 49 | # location is the PIL coordinate where the image gets pasted, ie local 50 | # to the resulting merged image 51 | # the crop_bounds is minx,miny,maxx,maxy and is local to the original 52 | # image 53 | ImageSpec = namedtuple('ImageSpec', 'location crop_bounds') 54 | 55 | TileCoordinates = namedtuple('TileCoordinates', 'tile image_spec') 56 | ImageInput = namedtuple('ImageInput', 'image_bytes image_spec tile') 57 | PathParseResult = namedtuple('PathParseResult', 58 | 'not_found_reason tileset tilesize tile') 59 | 60 | 61 | class MissingTileException(Exception): 62 | """ 63 | Required tile missing 64 | 65 | Particular type of exception to represent a tile we expected to be 66 | there is missing 67 | """ 68 | 69 | def __init__(self, tile): 70 | super(MissingTileException, self).__init__('Missing tile: %s' % tile) 71 | self.tile = tile 72 | 73 | 74 | def invalid_parse_result(reason): 75 | return PathParseResult(reason, None, None, None) 76 | 77 | 78 | def make_s3_key(tileset, tile): 79 | s3_key = '%s/%s.png' % (tileset, tile) 80 | return s3_key 81 | 82 | 83 | class S3TileFetcher(object): 84 | """Fetch the source tile data""" 85 | 86 | def __init__(self, s3_client, bucket): 87 | self.s3_client = s3_client 88 | self.bucket = bucket 89 | 90 | def __call__(self, tileset, tile): 91 | s3_key = make_s3_key(tileset, tile) 92 | try: 93 | resp = self.s3_client.get_object( 94 | Bucket=self.bucket, 95 | Key=s3_key, 96 | ) 97 | body_file = resp['Body'] 98 | image_bytes = body_file.read() 99 | body_file.close() 100 | # TODO caching response headers 101 | return FetchResult(image_bytes, tile) 102 | except Exception as e: 103 | try: 104 | err_code = e.response.get('Error', {}).get('Code') 105 | except Exception: 106 | err_code = None 107 | 108 | if err_code == 'NoSuchKey': 109 | # opt to return these more specifically as an exception 110 | # we want to early out in all cases, but we might 111 | # want to know about missing tiles in particular 112 | raise MissingTileException(tile) 113 | else: 114 | # re-raise the original exception 115 | raise e 116 | 117 | 118 | class HttpTileFetcher(object): 119 | 120 | def __init__(self, http_client, url_prefix): 121 | self.http_client = http_client 122 | self.url_prefix = url_prefix 123 | 124 | def __call__(self, tileset, tile): 125 | url = '%s/%s/%s.png' % (self.url_prefix, tileset, tile) 126 | resp = self.http_client.get(url) 127 | if resp.status_code == 404: 128 | raise MissingTileException(tile) 129 | return FetchResult(resp.content, tile) 130 | 131 | 132 | class ImageReducer(object): 133 | """Combine or reduce multiple source images into one""" 134 | 135 | def __init__(self, tilesize): 136 | self.tilesize = tilesize 137 | assert tilesize in (512, 516, 256, 260) 138 | 139 | def create_initial_state(self): 140 | image_state = Image.new('RGBA', (self.tilesize, self.tilesize)) 141 | return image_state 142 | 143 | def reduce(self, image_state, image_input): 144 | tile_fp = BytesIO(image_input.image_bytes) 145 | image_spec = image_input.image_spec 146 | image = Image.open(tile_fp) 147 | if image_spec.crop_bounds: 148 | image = image.crop(image_spec.crop_bounds) 149 | image_state.paste(image, image_spec.location) 150 | 151 | def finalize(self, image_state): 152 | out_fp = BytesIO() 153 | image_state.save(out_fp, format='PNG') 154 | image_bytes = out_fp.getvalue() 155 | return image_bytes 156 | 157 | 158 | class time_block(object): 159 | """Convenience to capture timing information""" 160 | 161 | def __init__(self, timing, metadata_key): 162 | self.timing = timing 163 | self.metadata_key = metadata_key 164 | self.start = None 165 | 166 | def __enter__(self): 167 | self.start = time() 168 | 169 | def __exit__(self, exc_type, exc_val, exc_tb): 170 | stop = time() 171 | duration = stop - self.start 172 | self.timing[self.metadata_key] = duration 173 | suppress_exception = False 174 | return suppress_exception 175 | 176 | 177 | def img_pos(x, y): 178 | pos = x, y 179 | crop = None 180 | return ImageSpec(pos, crop) 181 | 182 | 183 | def generate_coordinates_256(tile): 184 | tile_coordinates = ( 185 | TileCoordinates(tile, img_pos(0, 0)), 186 | ) 187 | return tile_coordinates 188 | 189 | 190 | def generate_coordinates_512(tile): 191 | zp1 = tile.z + 1 192 | dbl_x = tile.x * 2 193 | dbl_y = tile.y * 2 194 | # see ImageSpec description above for coordinate meaning 195 | 196 | tile_coordinates = ( 197 | TileCoordinates(Tile(zp1, dbl_x, dbl_y), img_pos(0, 0)), 198 | TileCoordinates(Tile(zp1, dbl_x+1, dbl_y), img_pos(256, 0)), 199 | TileCoordinates(Tile(zp1, dbl_x, dbl_y+1), img_pos(0, 256)), 200 | TileCoordinates(Tile(zp1, dbl_x+1, dbl_y+1), img_pos(256, 256)), 201 | ) 202 | return tile_coordinates 203 | 204 | 205 | def generate_coordinates_260(tile): 206 | """ 207 | generate a 3x3 grid with the source tile in the center 208 | 209 | x x x 210 | x o x 211 | x x x 212 | 213 | """ 214 | 215 | # see ImageSpec description above for coordinate meaning 216 | 217 | tile_coordinates = [] 218 | 219 | x_y_max = int(math.pow(2, tile.z)) - 1 220 | 221 | # NOTE: using a north, east, south, west naming scheme 222 | # top row placement positions 223 | loc_nw, loc_n, loc_ne = (0, 0), (2, 0), (258, 0) 224 | # mid row placement positions 225 | loc_w, loc_c, loc_e = (0, 2), (2, 2), (258, 2) 226 | # bot row placement positions 227 | loc_sw, loc_s, loc_se = (0, 258), (2, 258), (258, 258) 228 | 229 | # set the top row tiles to account for edge cases 230 | top_y = 0 if tile.y == 0 else tile.y-1 231 | if tile.x == 0: 232 | nw_tile = Tile(tile.z, x_y_max, top_y) 233 | else: 234 | nw_tile = Tile(tile.z, tile.x-1, top_y) 235 | n_tile = Tile(tile.z, tile.x, top_y) 236 | if tile.x == x_y_max: 237 | ne_tile = Tile(tile.z, 0, top_y) 238 | else: 239 | ne_tile = Tile(tile.z, tile.x+1, top_y) 240 | 241 | # set the mid row of tiles 242 | if tile.x == 0: 243 | w_tile = Tile(tile.z, x_y_max, tile.y) 244 | else: 245 | w_tile = Tile(tile.z, tile.x-1, tile.y) 246 | c_tile = Tile(tile.z, tile.x, tile.y) 247 | if tile.x == x_y_max: 248 | e_tile = Tile(tile.z, 0, tile.y) 249 | else: 250 | e_tile = Tile(tile.z, tile.x+1, tile.y) 251 | 252 | # set the bot row of tiles 253 | bot_y = x_y_max if tile.y == x_y_max else tile.y+1 254 | if tile.x == 0: 255 | sw_tile = Tile(tile.z, x_y_max, bot_y) 256 | else: 257 | sw_tile = Tile(tile.z, tile.x-1, bot_y) 258 | s_tile = Tile(tile.z, tile.x, bot_y) 259 | if tile.x == x_y_max: 260 | se_tile = Tile(tile.z, 0, bot_y) 261 | else: 262 | se_tile = Tile(tile.z, tile.x+1, bot_y) 263 | 264 | # relevant tiles are set appropriately 265 | # now we need to figure out the parts that are cropped from each 266 | # if we are the top or bot, we need to invert the piece that gets cropped 267 | if tile.y == 0: 268 | # the tiles will be set to be the top row 269 | # we'll be extracting the top bounds from these 270 | top_crop_bounds = ( 271 | (254, 0, 256, 2), 272 | (0, 0, 256, 2), 273 | (0, 0, 2, 2), 274 | ) 275 | else: 276 | # we are not the top row 277 | # we'll be extracting the bot bounds from the row above us 278 | top_crop_bounds = ( 279 | (254, 254, 256, 256), 280 | (0, 254, 256, 256), 281 | (0, 254, 2, 256), 282 | ) 283 | mid_crop_bounds = ( 284 | (254, 0, 256, 256), 285 | None, 286 | (0, 0, 2, 256), 287 | ) 288 | if tile.y == x_y_max: 289 | # the tiles will be set to the bot row 290 | # we'll be extrating the bot bounds from these 291 | bot_crop_bounds = ( 292 | (254, 254, 256, 256), 293 | (0, 254, 256, 256), 294 | (0, 254, 2, 256), 295 | ) 296 | else: 297 | # we are not the bot row 298 | # we'll be extracting the top bounds from the row below us 299 | bot_crop_bounds = ( 300 | (254, 0, 256, 2), 301 | (0, 0, 256, 2), 302 | (0, 0, 2, 2), 303 | ) 304 | 305 | # the tiles, locations, and bounds are now all assembled 306 | # weave them together to generate the list of all tile coordinates 307 | all_tiles = ( 308 | nw_tile, n_tile, ne_tile, 309 | w_tile, c_tile, e_tile, 310 | sw_tile, s_tile, se_tile, 311 | ) 312 | all_locs = ( 313 | loc_nw, loc_n, loc_ne, 314 | loc_w, loc_c, loc_e, 315 | loc_sw, loc_s, loc_se, 316 | ) 317 | all_bounds = (list(top_crop_bounds) + 318 | list(mid_crop_bounds) + 319 | list(bot_crop_bounds)) 320 | 321 | for tile, loc, crop_bounds in zip(all_tiles, all_locs, all_bounds): 322 | tc = TileCoordinates(tile, ImageSpec(loc, crop_bounds)) 323 | tile_coordinates.append(tc) 324 | 325 | return tile_coordinates 326 | 327 | 328 | def generate_coordinates_516(tile): 329 | """ 330 | generate a 4x4 grid with the source tiles being the 4 in the middle 331 | 332 | The source tile is zoomed in one, which generates 4 tiles. Then 333 | the border around these 4 is used. 334 | 335 | x x x x 336 | x O o x 337 | x o o x 338 | x x x x 339 | 340 | """ 341 | 342 | tile_coordinates = [] 343 | 344 | # pre-bump the coordinates to the next highest zoom 345 | z = tile.z + 1 346 | x = tile.x * 2 347 | y = tile.y * 2 348 | 349 | x_y_max = int(math.pow(2, z)) - 1 350 | 351 | # see ImageSpec description above for coordinate meaning 352 | 353 | # NOTE: using a row/col scheme to organize the values 354 | 355 | # these are the origin locations where the images will be placed 356 | locations = ( 357 | # first row 358 | (0, 0), (2, 0), (258, 0), (514, 0), 359 | # second row 360 | (0, 2), (2, 2), (258, 2), (514, 2), 361 | # third row 362 | (0, 258), (2, 258), (258, 258), (514, 258), 363 | # fourth row 364 | (0, 514), (2, 514), (258, 514), (514, 514), 365 | ) 366 | 367 | # set the row tiles to account for edge cases 368 | tiles = [] 369 | for y_iter in range(y-1, y+3): 370 | 371 | if y_iter < 0: 372 | y_val = 0 373 | elif y_iter > x_y_max: 374 | y_val = x_y_max 375 | else: 376 | y_val = y_iter 377 | 378 | for x_iter in range(x-1, x+3): 379 | 380 | x_val = x_iter 381 | if x_iter < 0: 382 | x_val = x_y_max 383 | elif x_iter > x_y_max: 384 | x_val = 0 385 | 386 | tiles.append(Tile(z, x_val, y_val)) 387 | 388 | assert(len(tiles) == 16) 389 | 390 | # set the crop bounds for each 391 | if y == 0: 392 | top_row_crop_bounds = ( 393 | (254, 0, 256, 2), 394 | (0, 0, 256, 2), 395 | (0, 0, 256, 2), 396 | (0, 0, 2, 2), 397 | ) 398 | else: 399 | top_row_crop_bounds = ( 400 | (254, 254, 256, 256), 401 | (0, 254, 256, 256), 402 | (0, 254, 256, 256), 403 | (0, 254, 2, 256), 404 | ) 405 | mid_rows_crop_bounds = ( 406 | (254, 0, 256, 256), 407 | None, 408 | None, 409 | (0, 0, 2, 256), 410 | ) 411 | if y+1 == x_y_max: 412 | bot_row_crop_bounds = ( 413 | (254, 254, 256, 256), 414 | (0, 254, 256, 256), 415 | (0, 254, 256, 256), 416 | (0, 254, 2, 256), 417 | ) 418 | else: 419 | bot_row_crop_bounds = ( 420 | (254, 0, 256, 2), 421 | (0, 0, 256, 2), 422 | (0, 0, 256, 2), 423 | (0, 0, 2, 2), 424 | ) 425 | 426 | all_crop_bounds = ( 427 | list(top_row_crop_bounds) + 428 | list(mid_rows_crop_bounds) + 429 | list(mid_rows_crop_bounds) + 430 | list(bot_row_crop_bounds)) 431 | 432 | for tile, loc, crop_bounds in zip(tiles, locations, all_crop_bounds): 433 | tc = TileCoordinates(tile, ImageSpec(loc, crop_bounds)) 434 | tile_coordinates.append(tc) 435 | 436 | return tile_coordinates 437 | 438 | 439 | def fetch_tiles_single_thread( 440 | tile_fetcher, tileset, all_tile_coords, timing_fetch): 441 | image_inputs = [] 442 | # TODO support cache headers for 304 responses? 443 | with time_block(timing_fetch, 'total'): 444 | for tile_coords in all_tile_coords: 445 | tile = tile_coords.tile 446 | with time_block(timing_fetch, str(tile)): 447 | fetch_result = tile_fetcher(tileset, tile) 448 | 449 | image_input = ImageInput( 450 | fetch_result.image_bytes, tile_coords.image_spec, tile) 451 | image_inputs.append(image_input) 452 | return image_inputs 453 | 454 | 455 | def _time_and_fetch(tile_fetcher, tileset, tile_coords, timing_fetch, queue): 456 | try: 457 | with time_block(timing_fetch, str(tile_coords.tile)): 458 | fetch_result = tile_fetcher(tileset, tile_coords.tile) 459 | except Exception as e: 460 | fetch_result = e 461 | queue.put((fetch_result, tile_coords.image_spec)) 462 | 463 | 464 | def fetch_tiles_multi_threaded( 465 | tile_fetcher, tileset, all_tile_coords, timing_fetch): 466 | image_inputs = [] 467 | threads = [] 468 | fetch_results_queue = queue.Queue(len(all_tile_coords)) 469 | error = None 470 | with time_block(timing_fetch, 'total'): 471 | for tile_coords in all_tile_coords: 472 | thread_args = ( 473 | tile_fetcher, tileset, tile_coords, timing_fetch, 474 | fetch_results_queue) 475 | t = threading.Thread( 476 | target=_time_and_fetch, 477 | args=thread_args) 478 | t.start() 479 | threads.append(t) 480 | 481 | for t in threads: 482 | t.join() 483 | 484 | for i in range(len(threads)): 485 | fetch_result, image_spec = fetch_results_queue.get() 486 | if isinstance(fetch_result, Exception): 487 | error = fetch_result 488 | else: 489 | image_input = ImageInput( 490 | fetch_result.image_bytes, image_spec, fetch_result.tile) 491 | image_inputs.append(image_input) 492 | if error is None: 493 | return image_inputs 494 | else: 495 | raise error 496 | 497 | 498 | def process_tile(coords_generator, tile_fetcher, image_reducer, tileset, tile): 499 | timing_fetch = {} 500 | timing_process = {} 501 | timing_metadata = dict( 502 | fetch=timing_fetch, 503 | process=timing_process, 504 | ) 505 | 506 | with time_block(timing_metadata, 'coords-gen'): 507 | all_tile_coords = coords_generator(tile) 508 | 509 | # image_inputs = fetch_tiles_single_thread( 510 | # tile_fetcher, tileset, all_tile_coords, timing_fetch) 511 | image_inputs = fetch_tiles_multi_threaded( 512 | tile_fetcher, tileset, all_tile_coords, timing_fetch) 513 | 514 | with time_block(timing_process, 'total'): 515 | image_state = image_reducer.create_initial_state() 516 | for image_input in image_inputs: 517 | with time_block(timing_process, str(image_input.tile)): 518 | image_reducer.reduce(image_state, image_input) 519 | 520 | with time_block(timing_metadata, 'save'): 521 | image_bytes = image_reducer.finalize(image_state) 522 | 523 | return image_bytes, timing_metadata, all_tile_coords 524 | --------------------------------------------------------------------------------