streetview-tools 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- streetview_tools-0.1.0/.gitignore +22 -0
- streetview_tools-0.1.0/LICENSE +21 -0
- streetview_tools-0.1.0/PKG-INFO +119 -0
- streetview_tools-0.1.0/README.md +90 -0
- streetview_tools-0.1.0/pyproject.toml +47 -0
- streetview_tools-0.1.0/src/streetview_tools/__init__.py +4 -0
- streetview_tools-0.1.0/src/streetview_tools/_downloader.py +144 -0
- streetview_tools-0.1.0/src/streetview_tools/_geometry.py +48 -0
- streetview_tools-0.1.0/src/streetview_tools/_http.py +10 -0
- streetview_tools-0.1.0/src/streetview_tools/google.py +245 -0
- streetview_tools-0.1.0/src/streetview_tools/kakao.py +166 -0
- streetview_tools-0.1.0/src/streetview_tools/naver.py +223 -0
- streetview_tools-0.1.0/src/streetview_tools/py.typed +0 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
.eggs/
|
|
6
|
+
|
|
7
|
+
# Build artifacts
|
|
8
|
+
build/
|
|
9
|
+
dist/
|
|
10
|
+
|
|
11
|
+
# Virtual environments
|
|
12
|
+
.venv/
|
|
13
|
+
venv/
|
|
14
|
+
|
|
15
|
+
# Tooling caches
|
|
16
|
+
.pytest_cache/
|
|
17
|
+
.ruff_cache/
|
|
18
|
+
.mypy_cache/
|
|
19
|
+
|
|
20
|
+
# OS
|
|
21
|
+
.DS_Store
|
|
22
|
+
Thumbs.db
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 AngLaboratory
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, 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,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: streetview-tools
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Retrieve street-view panorama metadata and download panorama images
|
|
5
|
+
Project-URL: Homepage, https://github.com/AngLaboratory/streetview-tools
|
|
6
|
+
Project-URL: Source, https://github.com/AngLaboratory/streetview-tools
|
|
7
|
+
Project-URL: Issues, https://github.com/AngLaboratory/streetview-tools/issues
|
|
8
|
+
Author-email: AngLaboratory <anglaboratory@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: panoid,panorama,roadview,street-view,streetview
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Multimedia :: Graphics
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.9
|
|
24
|
+
Requires-Dist: numpy>=1.21
|
|
25
|
+
Requires-Dist: pillow>=9.0
|
|
26
|
+
Requires-Dist: py360convert>=1.0.4
|
|
27
|
+
Requires-Dist: requests>=2.25
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# streetview-tools
|
|
31
|
+
|
|
32
|
+
Retrieve panorama metadata and download panorama images from Google Street View,
|
|
33
|
+
Naver Roadview, and Kakao Roadview.
|
|
34
|
+
|
|
35
|
+
## Installation
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install streetview-tools
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Usage
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from streetview_tools import google
|
|
45
|
+
|
|
46
|
+
pano = google.get_pano_info(panoid="PANORAMA_ID")
|
|
47
|
+
google.save_img(
|
|
48
|
+
panoid="PANORAMA_ID",
|
|
49
|
+
file_name="output.png",
|
|
50
|
+
width=2048,
|
|
51
|
+
)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The same functions are available from `streetview_tools.naver` and
|
|
55
|
+
`streetview_tools.kakao`.
|
|
56
|
+
|
|
57
|
+
Google and Kakao also provide `save_img_face()` for saving a square direction
|
|
58
|
+
crop from their equirectangular panorama images. The supported directions are
|
|
59
|
+
`l`, `f`, `r`, `b`, `d`, and `u`, and the default filename is
|
|
60
|
+
`PANORAMA_ID_<direction>.png`.
|
|
61
|
+
|
|
62
|
+
For Naver cubic panoramas, a single direction can be saved as a square image:
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from streetview_tools import naver
|
|
66
|
+
|
|
67
|
+
naver.save_img_face(
|
|
68
|
+
panoid="PANORAMA_ID",
|
|
69
|
+
direction="u",
|
|
70
|
+
width=800,
|
|
71
|
+
)
|
|
72
|
+
# PANORAMA_ID_u.png
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Valid directions are `l`, `f`, `r`, `b`, `d`, and `u`. A direction can also be
|
|
76
|
+
provided as a list, which returns or saves one image per direction:
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
images = naver.get_img_face(
|
|
80
|
+
panoid="PANORAMA_ID",
|
|
81
|
+
direction=["l", "f", "r"],
|
|
82
|
+
width=800,
|
|
83
|
+
)
|
|
84
|
+
paths = naver.save_img_face(
|
|
85
|
+
panoid="PANORAMA_ID",
|
|
86
|
+
direction=["l", "f", "r"],
|
|
87
|
+
width=800,
|
|
88
|
+
)
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Each function returns a list for face requests. `get_img()` returns one PIL
|
|
92
|
+
image, while `save_img()` returns one output path.
|
|
93
|
+
|
|
94
|
+
The same `save_img_face()` function can convert Naver equirectangular images by
|
|
95
|
+
passing `proj_type="equirect"`. Google and Kakao use the same common
|
|
96
|
+
equirectangular-to-cubemap conversion internally.
|
|
97
|
+
|
|
98
|
+
`width` and `height` are optional. If either is provided, the output is always
|
|
99
|
+
2:1. When both are provided, `width` takes precedence. For example,
|
|
100
|
+
`width=1024, height=900` produces a `1024 x 512` image, while `height=512`
|
|
101
|
+
produces a `1024 x 512` image. An odd width is rounded down to the nearest
|
|
102
|
+
even width so the final image can remain exactly 2:1.
|
|
103
|
+
|
|
104
|
+
When `width` is provided, the smallest available zoom that is at least as wide
|
|
105
|
+
as the requested output is downloaded first, then resized to the final size.
|
|
106
|
+
Without `width`, each service's default zoom is used. `zoom` is an internal
|
|
107
|
+
implementation detail and is not part of the public function arguments.
|
|
108
|
+
|
|
109
|
+
This package accesses public web endpoints used by each map service. Endpoint
|
|
110
|
+
formats and availability may change independently of this package.
|
|
111
|
+
|
|
112
|
+
Pass `save_json=True` to `save_img()` or `save_img_face()` to save the
|
|
113
|
+
`get_pano_info()` result beside the image. For example, `pano.png` produces
|
|
114
|
+
`pano.json`.
|
|
115
|
+
|
|
116
|
+
For `save_img_face()`, the JSON `angle` is adjusted for the selected direction:
|
|
117
|
+
`f` keeps the original angle, `r` adds 90 degrees, `l` subtracts 90 degrees,
|
|
118
|
+
and `b` adds 180 degrees. Angles are normalized to 0-360 degrees; `u` and `d`
|
|
119
|
+
keep the original horizontal angle.
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# streetview-tools
|
|
2
|
+
|
|
3
|
+
Retrieve panorama metadata and download panorama images from Google Street View,
|
|
4
|
+
Naver Roadview, and Kakao Roadview.
|
|
5
|
+
|
|
6
|
+
## Installation
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pip install streetview-tools
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Usage
|
|
13
|
+
|
|
14
|
+
```python
|
|
15
|
+
from streetview_tools import google
|
|
16
|
+
|
|
17
|
+
pano = google.get_pano_info(panoid="PANORAMA_ID")
|
|
18
|
+
google.save_img(
|
|
19
|
+
panoid="PANORAMA_ID",
|
|
20
|
+
file_name="output.png",
|
|
21
|
+
width=2048,
|
|
22
|
+
)
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The same functions are available from `streetview_tools.naver` and
|
|
26
|
+
`streetview_tools.kakao`.
|
|
27
|
+
|
|
28
|
+
Google and Kakao also provide `save_img_face()` for saving a square direction
|
|
29
|
+
crop from their equirectangular panorama images. The supported directions are
|
|
30
|
+
`l`, `f`, `r`, `b`, `d`, and `u`, and the default filename is
|
|
31
|
+
`PANORAMA_ID_<direction>.png`.
|
|
32
|
+
|
|
33
|
+
For Naver cubic panoramas, a single direction can be saved as a square image:
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from streetview_tools import naver
|
|
37
|
+
|
|
38
|
+
naver.save_img_face(
|
|
39
|
+
panoid="PANORAMA_ID",
|
|
40
|
+
direction="u",
|
|
41
|
+
width=800,
|
|
42
|
+
)
|
|
43
|
+
# PANORAMA_ID_u.png
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Valid directions are `l`, `f`, `r`, `b`, `d`, and `u`. A direction can also be
|
|
47
|
+
provided as a list, which returns or saves one image per direction:
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
images = naver.get_img_face(
|
|
51
|
+
panoid="PANORAMA_ID",
|
|
52
|
+
direction=["l", "f", "r"],
|
|
53
|
+
width=800,
|
|
54
|
+
)
|
|
55
|
+
paths = naver.save_img_face(
|
|
56
|
+
panoid="PANORAMA_ID",
|
|
57
|
+
direction=["l", "f", "r"],
|
|
58
|
+
width=800,
|
|
59
|
+
)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Each function returns a list for face requests. `get_img()` returns one PIL
|
|
63
|
+
image, while `save_img()` returns one output path.
|
|
64
|
+
|
|
65
|
+
The same `save_img_face()` function can convert Naver equirectangular images by
|
|
66
|
+
passing `proj_type="equirect"`. Google and Kakao use the same common
|
|
67
|
+
equirectangular-to-cubemap conversion internally.
|
|
68
|
+
|
|
69
|
+
`width` and `height` are optional. If either is provided, the output is always
|
|
70
|
+
2:1. When both are provided, `width` takes precedence. For example,
|
|
71
|
+
`width=1024, height=900` produces a `1024 x 512` image, while `height=512`
|
|
72
|
+
produces a `1024 x 512` image. An odd width is rounded down to the nearest
|
|
73
|
+
even width so the final image can remain exactly 2:1.
|
|
74
|
+
|
|
75
|
+
When `width` is provided, the smallest available zoom that is at least as wide
|
|
76
|
+
as the requested output is downloaded first, then resized to the final size.
|
|
77
|
+
Without `width`, each service's default zoom is used. `zoom` is an internal
|
|
78
|
+
implementation detail and is not part of the public function arguments.
|
|
79
|
+
|
|
80
|
+
This package accesses public web endpoints used by each map service. Endpoint
|
|
81
|
+
formats and availability may change independently of this package.
|
|
82
|
+
|
|
83
|
+
Pass `save_json=True` to `save_img()` or `save_img_face()` to save the
|
|
84
|
+
`get_pano_info()` result beside the image. For example, `pano.png` produces
|
|
85
|
+
`pano.json`.
|
|
86
|
+
|
|
87
|
+
For `save_img_face()`, the JSON `angle` is adjusted for the selected direction:
|
|
88
|
+
`f` keeps the original angle, `r` adds 90 degrees, `l` subtracts 90 degrees,
|
|
89
|
+
and `b` adds 180 degrees. Angles are normalized to 0-360 degrees; `u` and `d`
|
|
90
|
+
keep the original horizontal angle.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "streetview-tools"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Retrieve street-view panorama metadata and download panorama images"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "AngLaboratory", email = "anglaboratory@gmail.com" }]
|
|
14
|
+
keywords = ["streetview", "street-view", "panorama", "panoid", "roadview"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.9",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"Topic :: Multimedia :: Graphics",
|
|
26
|
+
"Typing :: Typed",
|
|
27
|
+
]
|
|
28
|
+
dependencies = [
|
|
29
|
+
"Pillow>=9.0",
|
|
30
|
+
"numpy>=1.21",
|
|
31
|
+
"py360convert>=1.0.4",
|
|
32
|
+
"requests>=2.25",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[project.urls]
|
|
36
|
+
Homepage = "https://github.com/AngLaboratory/streetview-tools"
|
|
37
|
+
Source = "https://github.com/AngLaboratory/streetview-tools"
|
|
38
|
+
Issues = "https://github.com/AngLaboratory/streetview-tools/issues"
|
|
39
|
+
|
|
40
|
+
[tool.hatch.build.targets.wheel]
|
|
41
|
+
packages = ["src/streetview_tools"]
|
|
42
|
+
|
|
43
|
+
[tool.hatch.build.targets.sdist]
|
|
44
|
+
include = ["src/", "tests/", "README.md", "LICENSE"]
|
|
45
|
+
|
|
46
|
+
[tool.pytest.ini_options]
|
|
47
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
2
|
+
from copy import deepcopy
|
|
3
|
+
from io import BytesIO
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import requests
|
|
8
|
+
import numpy as np
|
|
9
|
+
import py360convert
|
|
10
|
+
from PIL import Image
|
|
11
|
+
|
|
12
|
+
from ._http import REQUEST_HEADERS
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def download_tiles(
|
|
16
|
+
tile_sources,
|
|
17
|
+
output_path,
|
|
18
|
+
crop_box=None,
|
|
19
|
+
width=None,
|
|
20
|
+
height=None,
|
|
21
|
+
max_workers=8,
|
|
22
|
+
):
|
|
23
|
+
canvas = download_tiles_image(tile_sources, crop_box=crop_box)
|
|
24
|
+
|
|
25
|
+
target_size = normalize_size(width, height)
|
|
26
|
+
if target_size:
|
|
27
|
+
canvas = canvas.resize(target_size, Image.Resampling.LANCZOS)
|
|
28
|
+
|
|
29
|
+
output_path = Path(output_path)
|
|
30
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
canvas.save(output_path, "PNG")
|
|
32
|
+
return output_path
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def download_tiles_image(tile_sources, crop_box=None, max_workers=8):
|
|
36
|
+
"""Download and merge tiles into a PIL image without saving it."""
|
|
37
|
+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
38
|
+
images = list(executor.map(_load_image, (item["src"] for item in tile_sources)))
|
|
39
|
+
|
|
40
|
+
canvas_width = max(item["x"] + image.width for item, image in zip(tile_sources, images))
|
|
41
|
+
canvas_height = max(item["y"] + image.height for item, image in zip(tile_sources, images))
|
|
42
|
+
canvas = Image.new("RGB", (canvas_width, canvas_height))
|
|
43
|
+
|
|
44
|
+
for item, image in zip(tile_sources, images):
|
|
45
|
+
canvas.paste(image.convert("RGB"), (item["x"], item["y"]))
|
|
46
|
+
|
|
47
|
+
if crop_box:
|
|
48
|
+
canvas = canvas.crop(tuple(crop_box))
|
|
49
|
+
return canvas
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def equirect_to_face(image, direction, face_size=None):
|
|
53
|
+
"""Convert an equirectangular PIL image into one cubemap face."""
|
|
54
|
+
direction = str(direction).lower().strip()
|
|
55
|
+
if direction not in "lfrbdu" or len(direction) != 1:
|
|
56
|
+
raise ValueError("direction must be one of: l, f, r, b, d, u")
|
|
57
|
+
|
|
58
|
+
if face_size is None:
|
|
59
|
+
face_size = image.height // 2
|
|
60
|
+
if face_size <= 0:
|
|
61
|
+
raise ValueError("face_size must be positive")
|
|
62
|
+
if image.width != image.height * 2:
|
|
63
|
+
raise ValueError("equirectangular image must have a 2:1 aspect ratio")
|
|
64
|
+
|
|
65
|
+
faces = py360convert.e2c(
|
|
66
|
+
np.asarray(image.convert("RGB")),
|
|
67
|
+
face_w=face_size,
|
|
68
|
+
mode="bilinear",
|
|
69
|
+
cube_format="list",
|
|
70
|
+
)
|
|
71
|
+
face_index = {"f": 0, "r": 1, "b": 2, "l": 3, "u": 4, "d": 5}[direction]
|
|
72
|
+
return Image.fromarray(faces[face_index].astype(np.uint8), mode="RGB")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def save_image(image, output_path):
|
|
76
|
+
"""Save a PIL image and create its parent directory when needed."""
|
|
77
|
+
output_path = Path(output_path)
|
|
78
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
79
|
+
image.save(output_path, "PNG")
|
|
80
|
+
return output_path
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def save_pano_info_json(pano_info, image_path):
|
|
84
|
+
"""Save panorama metadata beside an image using the same file stem."""
|
|
85
|
+
json_path = Path(image_path).with_suffix(".json")
|
|
86
|
+
json_path.parent.mkdir(parents=True, exist_ok=True)
|
|
87
|
+
with json_path.open("w", encoding="utf-8") as file:
|
|
88
|
+
json.dump(pano_info, file, ensure_ascii=False, indent=2)
|
|
89
|
+
return json_path
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def adjust_face_angle(pano_info, direction):
|
|
93
|
+
"""Return metadata with its camera angle adjusted for a face direction."""
|
|
94
|
+
direction = str(direction).lower().strip()
|
|
95
|
+
offsets = {"f": 0, "r": 90, "b": 180, "l": -90, "u": 0, "d": 0}
|
|
96
|
+
if direction not in offsets:
|
|
97
|
+
raise ValueError("direction must be one of: l, f, r, b, d, u")
|
|
98
|
+
|
|
99
|
+
adjusted_info = deepcopy(pano_info)
|
|
100
|
+
angle = float(adjusted_info.get("angle", 0))
|
|
101
|
+
adjusted_info["angle"] = round((angle + offsets[direction]) % 360, 1)
|
|
102
|
+
return adjusted_info
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def normalize_size(width=None, height=None):
|
|
106
|
+
"""Return a 2:1 output size, using width as the source of truth."""
|
|
107
|
+
if width is None and height is None:
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
if width is not None:
|
|
111
|
+
width = _validate_dimension(width, "width")
|
|
112
|
+
width -= width % 2
|
|
113
|
+
width = max(2, width)
|
|
114
|
+
return width, width // 2
|
|
115
|
+
|
|
116
|
+
height = _validate_dimension(height, "height")
|
|
117
|
+
return height * 2, height
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def select_zoom(width, zoom_sizes, default_zoom):
|
|
121
|
+
"""Choose the smallest zoom whose source width meets ``width``."""
|
|
122
|
+
if width is None:
|
|
123
|
+
return default_zoom
|
|
124
|
+
|
|
125
|
+
target_width = _validate_dimension(width, "width")
|
|
126
|
+
for zoom, source_width in sorted(zoom_sizes.items()):
|
|
127
|
+
if source_width >= target_width:
|
|
128
|
+
return zoom
|
|
129
|
+
return max(zoom_sizes)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _validate_dimension(value, name):
|
|
133
|
+
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
|
|
134
|
+
raise ValueError(f"{name} must be a positive integer")
|
|
135
|
+
return value
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _load_image(url):
|
|
139
|
+
response = requests.get(url, headers=REQUEST_HEADERS, timeout=10)
|
|
140
|
+
response.raise_for_status()
|
|
141
|
+
if not response.headers.get("Content-Type", "").startswith("image/"):
|
|
142
|
+
raise ValueError(f"Response is not an image: {url}")
|
|
143
|
+
with Image.open(BytesIO(response.content)) as image:
|
|
144
|
+
return image.copy()
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import math
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def add_spot_info(base_pano):
|
|
5
|
+
base_lat = base_pano["lat"]
|
|
6
|
+
base_lon = base_pano["lon"]
|
|
7
|
+
base_angle = float(base_pano.get("angle", 0))
|
|
8
|
+
spots = []
|
|
9
|
+
|
|
10
|
+
for spot in base_pano.get("spot", []):
|
|
11
|
+
bearing = calc_bearing(base_lat, base_lon, spot["lat"], spot["lon"])
|
|
12
|
+
is_front = resolve_position(base_angle, bearing)
|
|
13
|
+
spots.append({
|
|
14
|
+
**spot,
|
|
15
|
+
"distance_m": calc_distance_meter(
|
|
16
|
+
base_lat, base_lon, spot["lat"], spot["lon"]
|
|
17
|
+
),
|
|
18
|
+
"isFrontTF": is_front,
|
|
19
|
+
"position": "front" if is_front else "back",
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
base_pano["spot"] = spots
|
|
23
|
+
return base_pano
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def calc_distance_meter(lat1, lon1, lat2, lon2):
|
|
27
|
+
radius = 6371000
|
|
28
|
+
lat1, lat2 = math.radians(lat1), math.radians(lat2)
|
|
29
|
+
dlat = lat2 - lat1
|
|
30
|
+
dlon = math.radians(lon2 - lon1)
|
|
31
|
+
value = (
|
|
32
|
+
math.sin(dlat / 2) ** 2
|
|
33
|
+
+ math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2) ** 2
|
|
34
|
+
)
|
|
35
|
+
return round(2 * radius * math.asin(math.sqrt(value)), 4)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def calc_bearing(lat1, lon1, lat2, lon2):
|
|
39
|
+
lat1, lat2 = math.radians(lat1), math.radians(lat2)
|
|
40
|
+
dlon = math.radians(lon2 - lon1)
|
|
41
|
+
x = math.sin(dlon) * math.cos(lat2)
|
|
42
|
+
y = math.cos(lat1) * math.sin(lat2) - math.sin(lat1) * math.cos(lat2) * math.cos(dlon)
|
|
43
|
+
return round((math.degrees(math.atan2(x, y)) + 360) % 360, 4)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def resolve_position(base_angle, target_bearing):
|
|
47
|
+
difference = (target_bearing - base_angle + 180) % 360 - 180
|
|
48
|
+
return -90 <= difference <= 90
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
REQUEST_HEADERS = {
|
|
2
|
+
"User-Agent": (
|
|
3
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
|
4
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
5
|
+
"Chrome/153.0.0.0 Safari/537.36"
|
|
6
|
+
),
|
|
7
|
+
"Accept": "*/*",
|
|
8
|
+
"Accept-Language": "ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7",
|
|
9
|
+
"Referer": "https://www.google.com/",
|
|
10
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from urllib.parse import quote
|
|
4
|
+
|
|
5
|
+
import requests
|
|
6
|
+
|
|
7
|
+
from ._downloader import (
|
|
8
|
+
download_tiles_image,
|
|
9
|
+
equirect_to_face,
|
|
10
|
+
adjust_face_angle,
|
|
11
|
+
save_image,
|
|
12
|
+
save_pano_info_json,
|
|
13
|
+
normalize_size,
|
|
14
|
+
select_zoom,
|
|
15
|
+
)
|
|
16
|
+
from ._geometry import add_spot_info
|
|
17
|
+
from ._http import REQUEST_HEADERS
|
|
18
|
+
|
|
19
|
+
_CACHE = {}
|
|
20
|
+
_IMG_BLOCK_SIZE = 512
|
|
21
|
+
_FACE_DIRECTIONS = "lfrbdu"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_pano_info(panoid, *, spot=False):
|
|
25
|
+
"""Return Google Street View metadata for ``panoid``.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
panoid: Google panorama identifier.
|
|
29
|
+
spot: Include nearby panorama information when ``True``.
|
|
30
|
+
"""
|
|
31
|
+
panoid = str(panoid).strip()
|
|
32
|
+
if not panoid:
|
|
33
|
+
raise ValueError("panoid must not be empty")
|
|
34
|
+
|
|
35
|
+
if panoid in _CACHE and (not spot or _CACHE[panoid].get("spot")):
|
|
36
|
+
return _CACHE[panoid]
|
|
37
|
+
|
|
38
|
+
response = requests.get(
|
|
39
|
+
_get_url(panoid, spot=spot),
|
|
40
|
+
headers=REQUEST_HEADERS,
|
|
41
|
+
timeout=10,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
response.raise_for_status()
|
|
45
|
+
|
|
46
|
+
panorama = _parse_response(response.text, panoid=panoid)
|
|
47
|
+
_CACHE[panoid] = panorama
|
|
48
|
+
return panorama
|
|
49
|
+
|
|
50
|
+
def get_img(
|
|
51
|
+
panoid,
|
|
52
|
+
*,
|
|
53
|
+
width=None,
|
|
54
|
+
height=None,
|
|
55
|
+
):
|
|
56
|
+
"""Return a Google equirectangular panorama as a PIL image.
|
|
57
|
+
|
|
58
|
+
``width`` selects the smallest source zoom that is at least as wide as
|
|
59
|
+
requested, then the image is resized to the requested 2:1 dimensions.
|
|
60
|
+
When ``width`` is omitted, Google's default zoom is used.
|
|
61
|
+
"""
|
|
62
|
+
panoid = str(panoid).strip()
|
|
63
|
+
zoom_seed = get_pano_info(panoid)["others"]["zoom_seed"]
|
|
64
|
+
|
|
65
|
+
zoom = select_zoom(
|
|
66
|
+
width,
|
|
67
|
+
{level: zoom_seed * (2 ** level) * 2 for level in range(6)},
|
|
68
|
+
default_zoom=3,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
y_size = zoom_seed * (2 ** zoom)
|
|
72
|
+
x_size = y_size * 2
|
|
73
|
+
tiles = [
|
|
74
|
+
{"x": x * _IMG_BLOCK_SIZE, "y": y * _IMG_BLOCK_SIZE,
|
|
75
|
+
"src": _tile_url(panoid, zoom, x, y)}
|
|
76
|
+
for y in range((y_size + _IMG_BLOCK_SIZE - 1) // _IMG_BLOCK_SIZE)
|
|
77
|
+
for x in range((x_size + _IMG_BLOCK_SIZE - 1) // _IMG_BLOCK_SIZE)
|
|
78
|
+
]
|
|
79
|
+
image = download_tiles_image(tiles, crop_box=[0, 0, x_size, y_size])
|
|
80
|
+
target_size = normalize_size(width, height)
|
|
81
|
+
if target_size:
|
|
82
|
+
image = image.resize(target_size)
|
|
83
|
+
return image
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def save_img(
|
|
87
|
+
panoid,
|
|
88
|
+
*,
|
|
89
|
+
file_name=None,
|
|
90
|
+
width=None,
|
|
91
|
+
height=None,
|
|
92
|
+
save_json=False,
|
|
93
|
+
):
|
|
94
|
+
"""Download and save a Google panorama PNG, optionally with JSON metadata."""
|
|
95
|
+
panoid = str(panoid).strip()
|
|
96
|
+
image = get_img(
|
|
97
|
+
panoid,
|
|
98
|
+
width=width,
|
|
99
|
+
height=height,
|
|
100
|
+
)
|
|
101
|
+
output_path = save_image(image, file_name or f"{panoid}.png")
|
|
102
|
+
if save_json:
|
|
103
|
+
save_pano_info_json(get_pano_info(panoid), output_path)
|
|
104
|
+
return output_path
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def get_img_face(
|
|
108
|
+
panoid,
|
|
109
|
+
direction,
|
|
110
|
+
*,
|
|
111
|
+
width=None,
|
|
112
|
+
):
|
|
113
|
+
"""Return square Google panorama faces as PIL images.
|
|
114
|
+
|
|
115
|
+
``direction`` may be one direction or a sequence of ``l``, ``f``, ``r``,
|
|
116
|
+
``b``, ``d``, and ``u``. ``width`` selects the smallest face source zoom
|
|
117
|
+
at least that wide; omitted width uses Google's default zoom.
|
|
118
|
+
"""
|
|
119
|
+
panoid = str(panoid).strip()
|
|
120
|
+
directions = _normalize_directions(direction)
|
|
121
|
+
zoom_seed = get_pano_info(panoid)["others"]["zoom_seed"]
|
|
122
|
+
|
|
123
|
+
zoom = select_zoom(
|
|
124
|
+
width,
|
|
125
|
+
{level: zoom_seed * (2 ** level) // 2 for level in range(6)},
|
|
126
|
+
default_zoom=3,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
panorama_height = zoom_seed * (2 ** zoom)
|
|
130
|
+
panorama_width = panorama_height * 2
|
|
131
|
+
tiles = [
|
|
132
|
+
{"x": x * _IMG_BLOCK_SIZE, "y": y * _IMG_BLOCK_SIZE,
|
|
133
|
+
"src": _tile_url(panoid, zoom, x, y)}
|
|
134
|
+
for y in range((panorama_height + _IMG_BLOCK_SIZE - 1) // _IMG_BLOCK_SIZE)
|
|
135
|
+
for x in range((panorama_width + _IMG_BLOCK_SIZE - 1) // _IMG_BLOCK_SIZE)
|
|
136
|
+
]
|
|
137
|
+
panorama = download_tiles_image(tiles, crop_box=[0, 0, panorama_width, panorama_height])
|
|
138
|
+
|
|
139
|
+
images = [equirect_to_face(panorama, item) for item in directions]
|
|
140
|
+
if width is not None:
|
|
141
|
+
target_size = normalize_size(width)[0]
|
|
142
|
+
images = [image.resize((target_size, target_size)) for image in images]
|
|
143
|
+
return images
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def save_img_face(
|
|
147
|
+
panoid,
|
|
148
|
+
direction,
|
|
149
|
+
*,
|
|
150
|
+
file_name=None,
|
|
151
|
+
width=None,
|
|
152
|
+
save_json=False,
|
|
153
|
+
):
|
|
154
|
+
"""Download and save square Google panorama face PNG files."""
|
|
155
|
+
directions = _normalize_directions(direction)
|
|
156
|
+
images = get_img_face(
|
|
157
|
+
panoid, directions, width=width
|
|
158
|
+
)
|
|
159
|
+
pano_info = get_pano_info(panoid) if save_json else None
|
|
160
|
+
output_paths = []
|
|
161
|
+
for item, image in zip(directions, images):
|
|
162
|
+
output_path = save_image(
|
|
163
|
+
image, _face_file_name(panoid, item, file_name, len(directions))
|
|
164
|
+
)
|
|
165
|
+
if pano_info is not None:
|
|
166
|
+
save_pano_info_json(adjust_face_angle(pano_info, item), output_path)
|
|
167
|
+
output_paths.append(output_path)
|
|
168
|
+
return output_paths
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _normalize_directions(direction):
|
|
172
|
+
directions = [direction] if isinstance(direction, str) else list(direction)
|
|
173
|
+
directions = [str(item).lower().strip() for item in directions]
|
|
174
|
+
if not directions or any(item not in _FACE_DIRECTIONS for item in directions):
|
|
175
|
+
raise ValueError("direction must contain only: l, f, r, b, d, u")
|
|
176
|
+
return directions
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _face_file_name(panoid, direction, file_name, count):
|
|
180
|
+
if not file_name:
|
|
181
|
+
return f"{panoid}_{direction}.png"
|
|
182
|
+
if count == 1:
|
|
183
|
+
return file_name
|
|
184
|
+
path = Path(file_name)
|
|
185
|
+
return path.with_name(f"{path.stem}_{direction}{path.suffix or '.png'}")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _get_url(panoid, *, spot):
|
|
189
|
+
spot_call = "!1e6" if spot else "!1e1"
|
|
190
|
+
e_code = "10" if len(panoid) in (43, 44) else "2"
|
|
191
|
+
|
|
192
|
+
return (
|
|
193
|
+
"https://www.google.com/maps/photometa/v1?authuser=0&hl=en&gl=us&pb="
|
|
194
|
+
"!1m4!1smaps_sv.tactile!11m2!2m1!1b1!2m2!1sen!2sus"
|
|
195
|
+
f"!3m3!1m2!1e{e_code}!2s{quote(panoid)}"
|
|
196
|
+
f"!4m57!1e1!1e2!1e4!1e1!1e1!1e1!1e1{spot_call}"
|
|
197
|
+
"!2m1!1e1!4m1!1i48!5m1!1e1!5m1!1e2!6m1!1e1!6m1!1e2"
|
|
198
|
+
"!9m36!1m3!1e6!2b0!3e0!1m3!1e6!2b0!3e1!1m3!1e6!2b0!3e2"
|
|
199
|
+
"!1m3!1e6!2b0!3e3!1m3!1e6!2b0!3e4!1m3!1e6!2b0!3e5"
|
|
200
|
+
"!1m3!1e6!2b0!3e6!1m3!1e6!2b0!3e7!1m3!1e6!2b0!3e8"
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _parse_response(text, panoid=""):
|
|
205
|
+
try:
|
|
206
|
+
parsed = json.loads(text[5:])
|
|
207
|
+
data = parsed[1][0]
|
|
208
|
+
except (json.JSONDecodeError, IndexError, TypeError):
|
|
209
|
+
raise ValueError(f"Invalid response from Google Street View for '{panoid}'")
|
|
210
|
+
|
|
211
|
+
if not data or len(data) < 6 or data[2] is None or data[5] is None:
|
|
212
|
+
target_id = panoid or (data[1][1] if len(data) > 1 and len(data[1]) > 1 else "unknown")
|
|
213
|
+
raise ValueError(f"Google Street View panorama not found or unavailable: '{target_id}'")
|
|
214
|
+
|
|
215
|
+
addresses = data[3][2] if data[3] is not None and len(data[3]) > 2 else []
|
|
216
|
+
date_str = ""
|
|
217
|
+
if len(data) > 6 and data[6] and len(data[6]) > 7 and data[6][7]:
|
|
218
|
+
date_str = f"{data[6][7][0]}-{data[6][7][1]:02d}-01"
|
|
219
|
+
|
|
220
|
+
panorama = {
|
|
221
|
+
"id": data[1][1],
|
|
222
|
+
"date": date_str,
|
|
223
|
+
"lon": float(data[5][0][1][0][3]),
|
|
224
|
+
"lat": float(data[5][0][1][0][2]),
|
|
225
|
+
"angle": round(float(data[5][0][1][2][0]), 1),
|
|
226
|
+
"addr1": addresses[1][0] if len(addresses) > 1 else (addresses[0][0] if addresses else ""),
|
|
227
|
+
"addr2": addresses[0][0] if len(addresses) > 1 else "",
|
|
228
|
+
"others": {"zoom_seed": data[2][3][0][0][0][0]},
|
|
229
|
+
"spot": [],
|
|
230
|
+
}
|
|
231
|
+
if len(data[5][0]) > 3 and data[5][0][3]:
|
|
232
|
+
for item in data[5][0][3][0]:
|
|
233
|
+
if len(item) >= 3 and item[0][1] != panorama["id"] and item[2] is not None:
|
|
234
|
+
panorama["spot"].append({
|
|
235
|
+
"id": item[0][1],
|
|
236
|
+
"lon": item[2][0][3],
|
|
237
|
+
"lat": item[2][0][2],
|
|
238
|
+
"x": item[2][0][3],
|
|
239
|
+
"y": item[2][0][2],
|
|
240
|
+
})
|
|
241
|
+
return add_spot_info(panorama)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _tile_url(panoid, zoom, x, y):
|
|
245
|
+
return f"https://streetviewpixels-pa.googleapis.com/v1/tile?cb_client=maps_sv.tactile&zoom={zoom}&panoid={panoid}&x={x}&y={y}"
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import requests
|
|
4
|
+
|
|
5
|
+
from ._downloader import (
|
|
6
|
+
download_tiles_image,
|
|
7
|
+
equirect_to_face,
|
|
8
|
+
adjust_face_angle,
|
|
9
|
+
save_image,
|
|
10
|
+
save_pano_info_json,
|
|
11
|
+
normalize_size,
|
|
12
|
+
select_zoom,
|
|
13
|
+
)
|
|
14
|
+
from ._geometry import add_spot_info
|
|
15
|
+
from ._http import REQUEST_HEADERS
|
|
16
|
+
|
|
17
|
+
_CACHE = {}
|
|
18
|
+
_FACE_DIRECTIONS = "lfrbdu"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_pano_info(panoid, *, spot=False):
|
|
22
|
+
"""Return Kakao Roadview metadata for ``panoid``.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
panoid: Kakao panorama identifier.
|
|
26
|
+
spot: Include nearby panorama information when ``True``.
|
|
27
|
+
"""
|
|
28
|
+
panoid = str(panoid).strip()
|
|
29
|
+
if not panoid:
|
|
30
|
+
raise ValueError("panoid must not be empty")
|
|
31
|
+
if panoid in _CACHE and (not spot or _CACHE[panoid].get("spot")):
|
|
32
|
+
return _CACHE[panoid]
|
|
33
|
+
|
|
34
|
+
response = requests.get(
|
|
35
|
+
f"https://rv.map.kakao.com/roadview-search/v2/node/{panoid}",
|
|
36
|
+
headers=REQUEST_HEADERS,
|
|
37
|
+
timeout=10,
|
|
38
|
+
)
|
|
39
|
+
response.raise_for_status()
|
|
40
|
+
data = response.json()["street_view"]["street"]
|
|
41
|
+
panorama = {
|
|
42
|
+
"id": data["id"], "date": data["shot_date"],
|
|
43
|
+
"lon": float(data["wgsx"]), "lat": float(data["wgsy"]),
|
|
44
|
+
"angle": float(data["angle"]), "addr1": data["addr"],
|
|
45
|
+
"addr2": data["st_name"], "others": {"img_path": data["img_path"]}, "spot": [],
|
|
46
|
+
}
|
|
47
|
+
for spot in data.get("spot", []):
|
|
48
|
+
if spot["id"] != panorama["id"]:
|
|
49
|
+
panorama["spot"].append({"id": spot["id"], "lon": spot["wgsx"], "lat": spot["wgsy"]})
|
|
50
|
+
panorama = add_spot_info(panorama)
|
|
51
|
+
_CACHE[panoid] = panorama
|
|
52
|
+
return panorama
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def get_img(
|
|
56
|
+
panoid,
|
|
57
|
+
*,
|
|
58
|
+
width=None,
|
|
59
|
+
height=None,
|
|
60
|
+
):
|
|
61
|
+
"""Return a Kakao Roadview panorama as a PIL image.
|
|
62
|
+
|
|
63
|
+
``width`` selects the smallest source zoom at least as wide as requested;
|
|
64
|
+
without it, Kakao's default zoom is used.
|
|
65
|
+
"""
|
|
66
|
+
panorama = get_pano_info(panoid)
|
|
67
|
+
zoom = select_zoom(
|
|
68
|
+
width,
|
|
69
|
+
{0: 1280, 1: 4096},
|
|
70
|
+
default_zoom=1,
|
|
71
|
+
)
|
|
72
|
+
image_path = panorama["others"]["img_path"]
|
|
73
|
+
if zoom == 0:
|
|
74
|
+
tiles = [{"x": 0, "y": 0, "src": f"https://map.kakaocdn.net/map_roadview{image_path}.jpg"}]
|
|
75
|
+
crop_box = [0, 0, 1280, 640]
|
|
76
|
+
else:
|
|
77
|
+
tiles = [{"x": x * 512, "y": y * 512,
|
|
78
|
+
"src": _tile_url(image_path, tile_number)}
|
|
79
|
+
for tile_number, (y, x) in enumerate(
|
|
80
|
+
((y, x) for y in range(4) for x in range(8)), 1
|
|
81
|
+
)]
|
|
82
|
+
crop_box = [0, 0, 4096, 2048]
|
|
83
|
+
image = download_tiles_image(
|
|
84
|
+
tiles,
|
|
85
|
+
crop_box,
|
|
86
|
+
)
|
|
87
|
+
target_size = normalize_size(width, height)
|
|
88
|
+
if target_size:
|
|
89
|
+
image = image.resize(target_size)
|
|
90
|
+
return image
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def save_img(
|
|
94
|
+
panoid,
|
|
95
|
+
*,
|
|
96
|
+
file_name=None,
|
|
97
|
+
width=None,
|
|
98
|
+
height=None,
|
|
99
|
+
save_json=False,
|
|
100
|
+
):
|
|
101
|
+
"""Download and save a Kakao panorama PNG, optionally with JSON metadata."""
|
|
102
|
+
image = get_img(panoid, width=width, height=height)
|
|
103
|
+
output_path = save_image(image, file_name or f"{panoid}.png")
|
|
104
|
+
if save_json:
|
|
105
|
+
save_pano_info_json(get_pano_info(panoid), output_path)
|
|
106
|
+
return output_path
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def get_img_face(panoid, direction, *, width=None):
|
|
110
|
+
"""Return Kakao panorama direction faces as PIL images.
|
|
111
|
+
|
|
112
|
+
``direction`` may be one direction or a sequence of ``l``, ``f``, ``r``,
|
|
113
|
+
``b``, ``d``, and ``u``. ``width`` controls the final square face size.
|
|
114
|
+
"""
|
|
115
|
+
panoid = str(panoid).strip()
|
|
116
|
+
directions = [direction] if isinstance(direction, str) else list(direction)
|
|
117
|
+
directions = [str(item).lower().strip() for item in directions]
|
|
118
|
+
if not directions or any(item not in _FACE_DIRECTIONS for item in directions):
|
|
119
|
+
raise ValueError("direction must contain only: l, f, r, b, d, u")
|
|
120
|
+
panorama_width, panorama_height = 4096, 2048
|
|
121
|
+
face_size = panorama_width // 4
|
|
122
|
+
image_path = get_pano_info(panoid)["others"]["img_path"]
|
|
123
|
+
|
|
124
|
+
tiles = [
|
|
125
|
+
{"x": x * 512, "y": y * 512,
|
|
126
|
+
"src": _tile_url(image_path, tile_number)}
|
|
127
|
+
for tile_number, (y, x) in enumerate(
|
|
128
|
+
((y, x) for y in range(4) for x in range(8)), 1
|
|
129
|
+
)
|
|
130
|
+
]
|
|
131
|
+
|
|
132
|
+
panorama_image = download_tiles_image(tiles)
|
|
133
|
+
images = [
|
|
134
|
+
equirect_to_face(panorama_image, item, face_size=face_size)
|
|
135
|
+
for item in directions
|
|
136
|
+
]
|
|
137
|
+
if width is not None:
|
|
138
|
+
images = [image.resize((width, width)) for image in images]
|
|
139
|
+
return images
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def save_img_face(panoid, direction, *, file_name=None, width=None, save_json=False):
|
|
143
|
+
"""Download and save Kakao panorama face PNG files."""
|
|
144
|
+
directions = [direction] if isinstance(direction, str) else list(direction)
|
|
145
|
+
directions = [str(item).lower().strip() for item in directions]
|
|
146
|
+
images = get_img_face(panoid, directions, width=width)
|
|
147
|
+
pano_info = get_pano_info(panoid) if save_json else None
|
|
148
|
+
output_paths = []
|
|
149
|
+
for item, image in zip(directions, images):
|
|
150
|
+
if file_name and len(directions) == 1:
|
|
151
|
+
output_name = file_name
|
|
152
|
+
elif file_name:
|
|
153
|
+
path = Path(file_name)
|
|
154
|
+
output_name = path.with_name(f"{path.stem}_{item}{path.suffix or '.png'}")
|
|
155
|
+
else:
|
|
156
|
+
output_name = f"{panoid}_{item}.png"
|
|
157
|
+
output_path = save_image(image, output_name)
|
|
158
|
+
if pano_info is not None:
|
|
159
|
+
save_pano_info_json(adjust_face_angle(pano_info, item), output_path)
|
|
160
|
+
output_paths.append(output_path)
|
|
161
|
+
return output_paths
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _tile_url(image_path, tile_number):
|
|
165
|
+
suffix = image_path[image_path.rfind("/"):]
|
|
166
|
+
return f"https://map.kakaocdn.net/map_roadview{image_path}{suffix}_{tile_number:02d}.jpg"
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import requests
|
|
4
|
+
|
|
5
|
+
from ._downloader import (
|
|
6
|
+
download_tiles_image,
|
|
7
|
+
equirect_to_face,
|
|
8
|
+
adjust_face_angle,
|
|
9
|
+
save_image,
|
|
10
|
+
save_pano_info_json,
|
|
11
|
+
normalize_size,
|
|
12
|
+
select_zoom,
|
|
13
|
+
)
|
|
14
|
+
from ._geometry import add_spot_info
|
|
15
|
+
from ._http import REQUEST_HEADERS
|
|
16
|
+
|
|
17
|
+
_CACHE = {}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_pano_info(panoid, *, spot=False):
|
|
21
|
+
"""Return Naver Roadview metadata for ``panoid``.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
panoid: Naver panorama identifier.
|
|
25
|
+
spot: Include nearby panorama information when ``True``.
|
|
26
|
+
"""
|
|
27
|
+
panoid = str(panoid).strip()
|
|
28
|
+
if not panoid:
|
|
29
|
+
raise ValueError("panoid must not be empty")
|
|
30
|
+
if panoid in _CACHE and (not spot or _CACHE[panoid].get("spot")):
|
|
31
|
+
return _CACHE[panoid]
|
|
32
|
+
|
|
33
|
+
response = requests.get(
|
|
34
|
+
f"https://panorama.map.naver.com/metadataV3/basic/{panoid}?lang=ko",
|
|
35
|
+
headers=REQUEST_HEADERS,
|
|
36
|
+
timeout=10,
|
|
37
|
+
)
|
|
38
|
+
response.raise_for_status()
|
|
39
|
+
data = response.json()
|
|
40
|
+
panorama = {
|
|
41
|
+
"id": data["id"], "date": data["info"]["photodate"],
|
|
42
|
+
"lon": float(data["longitude"]), "lat": float(data["latitude"]),
|
|
43
|
+
"angle": float(data["camera_angle"][1]),
|
|
44
|
+
"addr1": data["info"]["description"], "addr2": data["info"]["title"],
|
|
45
|
+
"others": {"proj_type": data["proj_type"]}, "spot": [],
|
|
46
|
+
}
|
|
47
|
+
for link in data.get("links", []):
|
|
48
|
+
if link["id"] != panorama["id"]:
|
|
49
|
+
panorama["spot"].append({"id": link["id"], "lon": link["longitude"], "lat": link["latitude"]})
|
|
50
|
+
panorama = add_spot_info(panorama)
|
|
51
|
+
_CACHE[panoid] = panorama
|
|
52
|
+
return panorama
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def get_img(
|
|
56
|
+
panoid,
|
|
57
|
+
*,
|
|
58
|
+
proj_type="cubic",
|
|
59
|
+
width=None,
|
|
60
|
+
height=None,
|
|
61
|
+
):
|
|
62
|
+
"""Return a Naver panorama as a PIL image.
|
|
63
|
+
|
|
64
|
+
``width`` selects the smallest available source zoom at least as wide as
|
|
65
|
+
requested. When omitted, the default zoom for the selected projection is
|
|
66
|
+
used.
|
|
67
|
+
"""
|
|
68
|
+
panoid = str(panoid).strip()
|
|
69
|
+
pano_info = None
|
|
70
|
+
if proj_type == "equirect":
|
|
71
|
+
pano_info = get_pano_info(panoid)
|
|
72
|
+
if pano_info["others"]["proj_type"] != "equirect":
|
|
73
|
+
raise ValueError("This panorama does not support equirect projection")
|
|
74
|
+
zoom = select_zoom(
|
|
75
|
+
width,
|
|
76
|
+
{0: 1024, 1: 2048, 2: 4096, 3: 8192},
|
|
77
|
+
default_zoom=2,
|
|
78
|
+
)
|
|
79
|
+
if zoom == 0:
|
|
80
|
+
tiles = [{"x": 0, "y": 0, "src": _equirect_url(panoid, zoom, None, None)}]
|
|
81
|
+
else:
|
|
82
|
+
tiles = [{"x": x * 512, "y": y * 512, "src": _equirect_url(panoid, zoom, x, y)}
|
|
83
|
+
for y in range(2 ** zoom) for x in range(2 ** (zoom + 1))]
|
|
84
|
+
size = (1024 * 2 ** zoom, 512 * 2 ** zoom)
|
|
85
|
+
else:
|
|
86
|
+
zoom = select_zoom(
|
|
87
|
+
width,
|
|
88
|
+
{0: 1536, 1: 6144},
|
|
89
|
+
default_zoom=1,
|
|
90
|
+
)
|
|
91
|
+
if zoom == 0:
|
|
92
|
+
tiles, size = [{"x": 0, "y": 0, "src": _cubic_url(panoid, zoom, None, None, None)}], (1536, 256)
|
|
93
|
+
else:
|
|
94
|
+
faces = "lfrbdu"
|
|
95
|
+
tiles = [{"x": x * 512 + face_index * 1024, "y": y * 512,
|
|
96
|
+
"src": _cubic_url(panoid, zoom, face, x, y)}
|
|
97
|
+
for face_index, face in enumerate(faces) for y in range(2) for x in range(2)]
|
|
98
|
+
size = (6144, 1024)
|
|
99
|
+
image = download_tiles_image(
|
|
100
|
+
tiles,
|
|
101
|
+
[0, 0, *size],
|
|
102
|
+
)
|
|
103
|
+
target_size = normalize_size(width, height)
|
|
104
|
+
if target_size:
|
|
105
|
+
image = image.resize(target_size)
|
|
106
|
+
return image
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def save_img(
|
|
110
|
+
panoid,
|
|
111
|
+
*,
|
|
112
|
+
file_name=None,
|
|
113
|
+
proj_type="cubic",
|
|
114
|
+
width=None,
|
|
115
|
+
height=None,
|
|
116
|
+
save_json=False,
|
|
117
|
+
):
|
|
118
|
+
"""Download and save a Naver panorama PNG, optionally with JSON metadata."""
|
|
119
|
+
image = get_img(
|
|
120
|
+
panoid, proj_type=proj_type, width=width, height=height
|
|
121
|
+
)
|
|
122
|
+
output_path = save_image(image, file_name or f"{panoid}.png")
|
|
123
|
+
if save_json:
|
|
124
|
+
save_pano_info_json(get_pano_info(panoid), output_path)
|
|
125
|
+
return output_path
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def get_img_face(
|
|
129
|
+
panoid,
|
|
130
|
+
direction,
|
|
131
|
+
*,
|
|
132
|
+
proj_type="cubic",
|
|
133
|
+
width=None,
|
|
134
|
+
):
|
|
135
|
+
"""Return Naver panorama direction faces as PIL images.
|
|
136
|
+
|
|
137
|
+
``direction`` must be one of ``l``, ``f``, ``r``, ``b``, ``d``, or ``u``.
|
|
138
|
+
Cubic panoramas use their direction-specific tiles; equirectangular
|
|
139
|
+
panoramas are converted to a cubemap face with the common converter.
|
|
140
|
+
"""
|
|
141
|
+
panoid = str(panoid).strip()
|
|
142
|
+
directions = [direction] if isinstance(direction, str) else list(direction)
|
|
143
|
+
directions = [str(item).lower().strip() for item in directions]
|
|
144
|
+
if not directions or any(item not in "lfrbdu" for item in directions):
|
|
145
|
+
raise ValueError("direction must contain only: l, f, r, b, d, u")
|
|
146
|
+
|
|
147
|
+
if proj_type == "equirect":
|
|
148
|
+
pano_info = get_pano_info(panoid)
|
|
149
|
+
if pano_info["others"]["proj_type"] != "equirect":
|
|
150
|
+
raise ValueError("This panorama does not support equirect projection")
|
|
151
|
+
zoom = select_zoom(width, {level: 512 * 2 ** level for level in range(4)}, 2)
|
|
152
|
+
tiles = (
|
|
153
|
+
[{"x": 0, "y": 0, "src": _equirect_url(panoid, zoom, None, None)}]
|
|
154
|
+
if zoom == 0
|
|
155
|
+
else [
|
|
156
|
+
{"x": x * 512, "y": y * 512,
|
|
157
|
+
"src": _equirect_url(panoid, zoom, x, y)}
|
|
158
|
+
for y in range(2 ** zoom)
|
|
159
|
+
for x in range(2 ** (zoom + 1))
|
|
160
|
+
]
|
|
161
|
+
)
|
|
162
|
+
panorama = download_tiles_image(tiles)
|
|
163
|
+
images = [
|
|
164
|
+
equirect_to_face(panorama, item, face_size=panorama.height // 2)
|
|
165
|
+
for item in directions
|
|
166
|
+
]
|
|
167
|
+
if width is not None:
|
|
168
|
+
images = [image.resize((width, width)) for image in images]
|
|
169
|
+
return images
|
|
170
|
+
|
|
171
|
+
zoom = 1
|
|
172
|
+
images = []
|
|
173
|
+
for item in directions:
|
|
174
|
+
tiles = [
|
|
175
|
+
{"x": x * 512, "y": y * 512, "src": _cubic_url(panoid, zoom, item, x, y)}
|
|
176
|
+
for y in range(2)
|
|
177
|
+
for x in range(2)
|
|
178
|
+
]
|
|
179
|
+
image = download_tiles_image(tiles, crop_box=[0, 0, 1024, 1024])
|
|
180
|
+
images.append(image.resize((width, width)) if width else image)
|
|
181
|
+
return images
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def save_img_face(
|
|
185
|
+
panoid,
|
|
186
|
+
direction,
|
|
187
|
+
*,
|
|
188
|
+
file_name=None,
|
|
189
|
+
proj_type="cubic",
|
|
190
|
+
width=None,
|
|
191
|
+
save_json=False,
|
|
192
|
+
):
|
|
193
|
+
"""Download and save Naver panorama face PNG files."""
|
|
194
|
+
directions = [direction] if isinstance(direction, str) else list(direction)
|
|
195
|
+
directions = [str(item).lower().strip() for item in directions]
|
|
196
|
+
images = get_img_face(panoid, directions, proj_type=proj_type, width=width)
|
|
197
|
+
pano_info = get_pano_info(panoid) if save_json else None
|
|
198
|
+
output_paths = []
|
|
199
|
+
for item, image in zip(directions, images):
|
|
200
|
+
if file_name and len(directions) == 1:
|
|
201
|
+
output_name = file_name
|
|
202
|
+
elif file_name:
|
|
203
|
+
path = Path(file_name)
|
|
204
|
+
output_name = path.with_name(f"{path.stem}_{item}{path.suffix or '.png'}")
|
|
205
|
+
else:
|
|
206
|
+
output_name = f"{panoid}_{item}.png"
|
|
207
|
+
output_path = save_image(image, output_name)
|
|
208
|
+
if pano_info is not None:
|
|
209
|
+
save_pano_info_json(adjust_face_angle(pano_info, item), output_path)
|
|
210
|
+
output_paths.append(output_path)
|
|
211
|
+
return output_paths
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _equirect_url(panoid, zoom, x, y):
|
|
215
|
+
if zoom == 0:
|
|
216
|
+
return f"https://panorama.pstatic.net/imageV3/{panoid}/P"
|
|
217
|
+
return f"https://panorama.pstatic.net/imageV3/{panoid}/{zoom - 1}/{x + 1}/{y + 1}"
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _cubic_url(panoid, zoom, face, x, y):
|
|
221
|
+
if zoom == 0:
|
|
222
|
+
return f"https://panorama.pstatic.net/image/{panoid}/512/P"
|
|
223
|
+
return f"https://panorama.pstatic.net/image/{panoid}/512/M/{face}/{x + 1}/{y + 1}"
|
|
File without changes
|