awcs-scripts 1.0.1__py3-none-any.whl
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.
- awcs_scripts/__init__.py +3 -0
- awcs_scripts/api.py +66 -0
- awcs_scripts/cli.py +264 -0
- awcs_scripts/config.py +71 -0
- awcs_scripts/mrmap/__init__.py +1 -0
- awcs_scripts/mrmap/mrmap-to-bg.py +434 -0
- awcs_scripts/mrmap/mrmap-to-raster.py +174 -0
- awcs_scripts-1.0.1.dist-info/METADATA +109 -0
- awcs_scripts-1.0.1.dist-info/RECORD +12 -0
- awcs_scripts-1.0.1.dist-info/WHEEL +4 -0
- awcs_scripts-1.0.1.dist-info/entry_points.txt +2 -0
- awcs_scripts-1.0.1.dist-info/licenses/LICENSE +17 -0
awcs_scripts/__init__.py
ADDED
awcs_scripts/api.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""ARMS HTTP client. The API key is sent as X-Api-Key."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
_API_PREFIX = "/api/v1"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ApiError(Exception):
|
|
13
|
+
def __init__(self, status_code: int, body: str) -> None:
|
|
14
|
+
self.status_code = status_code
|
|
15
|
+
self.body = body
|
|
16
|
+
super().__init__(f"HTTP {status_code}")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def origin_of(url: str) -> str:
|
|
20
|
+
"""Return the site origin. A trailing /api/v1 is stripped so it is not doubled."""
|
|
21
|
+
value = url.strip().rstrip("/")
|
|
22
|
+
if value.endswith(_API_PREFIX):
|
|
23
|
+
value = value[: -len(_API_PREFIX)].rstrip("/")
|
|
24
|
+
return value
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Client:
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
url: str,
|
|
31
|
+
apikey: str,
|
|
32
|
+
*,
|
|
33
|
+
transport: httpx.BaseTransport | None = None,
|
|
34
|
+
timeout: float = 120,
|
|
35
|
+
) -> None:
|
|
36
|
+
self.origin = origin_of(url)
|
|
37
|
+
self._client = httpx.Client(
|
|
38
|
+
base_url=self.origin + _API_PREFIX,
|
|
39
|
+
headers={"X-Api-Key": apikey},
|
|
40
|
+
transport=transport,
|
|
41
|
+
timeout=timeout,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
def close(self) -> None:
|
|
45
|
+
self._client.close()
|
|
46
|
+
|
|
47
|
+
def __enter__(self) -> Client:
|
|
48
|
+
return self
|
|
49
|
+
|
|
50
|
+
def __exit__(self, *args: object) -> None:
|
|
51
|
+
self.close()
|
|
52
|
+
|
|
53
|
+
def request(
|
|
54
|
+
self,
|
|
55
|
+
method: str,
|
|
56
|
+
path: str,
|
|
57
|
+
*,
|
|
58
|
+
params: dict[str, Any] | None = None,
|
|
59
|
+
json: Any = None,
|
|
60
|
+
data: dict[str, Any] | None = None,
|
|
61
|
+
files: dict[str, Any] | None = None,
|
|
62
|
+
) -> httpx.Response:
|
|
63
|
+
response = self._client.request(method, path, params=params, json=json, data=data, files=files)
|
|
64
|
+
if response.status_code >= 400:
|
|
65
|
+
raise ApiError(response.status_code, response.text)
|
|
66
|
+
return response
|
awcs_scripts/cli.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""Single `awcs` command for map tools and a slice of the ARMS API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import importlib.util
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
from urllib.parse import quote
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from awcs_scripts.api import ApiError, Client, origin_of
|
|
16
|
+
from awcs_scripts.config import ConfigError, load_profile, save_profile
|
|
17
|
+
|
|
18
|
+
_TOOLS = Path(__file__).resolve().parent / "mrmap"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _load_tool(filename: str):
|
|
22
|
+
path = _TOOLS / filename
|
|
23
|
+
spec = importlib.util.spec_from_file_location(filename.removesuffix(".py").replace("-", "_"), path)
|
|
24
|
+
if spec is None or spec.loader is None:
|
|
25
|
+
raise ImportError(f"cannot load {path}")
|
|
26
|
+
module = importlib.util.module_from_spec(spec)
|
|
27
|
+
spec.loader.exec_module(module)
|
|
28
|
+
return module
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _fail(message: str) -> None:
|
|
32
|
+
print(message, file=sys.stderr)
|
|
33
|
+
raise SystemExit(1)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _emit(response: httpx.Response) -> None:
|
|
37
|
+
if not response.content:
|
|
38
|
+
return
|
|
39
|
+
print(json.dumps(response.json(), ensure_ascii=False, indent=2))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _open_client() -> Client:
|
|
43
|
+
try:
|
|
44
|
+
url, apikey = load_profile()
|
|
45
|
+
except ConfigError as exc:
|
|
46
|
+
_fail(str(exc))
|
|
47
|
+
return Client(url, apikey)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _params_object(raw: str | None) -> dict[str, Any] | None:
|
|
51
|
+
if raw is None:
|
|
52
|
+
return None
|
|
53
|
+
payload = json.loads(raw)
|
|
54
|
+
if not isinstance(payload, dict):
|
|
55
|
+
_fail("--params must be a JSON object")
|
|
56
|
+
return payload
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _list_params(args: argparse.Namespace) -> dict[str, Any]:
|
|
60
|
+
params: dict[str, Any] = {}
|
|
61
|
+
if args.page is not None:
|
|
62
|
+
params["page"] = args.page
|
|
63
|
+
if args.per_page is not None:
|
|
64
|
+
params["per_page"] = args.per_page
|
|
65
|
+
if args.filter is not None:
|
|
66
|
+
params["filter"] = args.filter
|
|
67
|
+
if args.sort_by is not None:
|
|
68
|
+
params["sort_by"] = args.sort_by
|
|
69
|
+
return params
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _add_list_flags(parser: argparse.ArgumentParser) -> None:
|
|
73
|
+
parser.add_argument("--page", type=int)
|
|
74
|
+
parser.add_argument("--per_page", type=int)
|
|
75
|
+
parser.add_argument("--filter")
|
|
76
|
+
parser.add_argument("--sort_by")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _cmd_login(args: argparse.Namespace) -> None:
|
|
80
|
+
origin = origin_of(args.url)
|
|
81
|
+
with Client(origin, args.apikey) as client:
|
|
82
|
+
response = client.request("GET", "/users/me")
|
|
83
|
+
path = save_profile(origin, args.apikey)
|
|
84
|
+
_emit(response)
|
|
85
|
+
print(f"saved {path}", file=sys.stderr)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _cmd_users_list(args: argparse.Namespace) -> None:
|
|
89
|
+
with _open_client() as client:
|
|
90
|
+
_emit(client.request("GET", "/users", params=_list_params(args)))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _cmd_users_me(_args: argparse.Namespace) -> None:
|
|
94
|
+
with _open_client() as client:
|
|
95
|
+
_emit(client.request("GET", "/users/me"))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _cmd_templates_list(args: argparse.Namespace) -> None:
|
|
99
|
+
with _open_client() as client:
|
|
100
|
+
_emit(client.request("GET", "/mission_templates", params=_list_params(args)))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _cmd_templates_execute(args: argparse.Namespace) -> None:
|
|
104
|
+
body = _params_object(args.params)
|
|
105
|
+
if body is None:
|
|
106
|
+
body = {}
|
|
107
|
+
target = quote(args.id_or_code, safe="")
|
|
108
|
+
with _open_client() as client:
|
|
109
|
+
_emit(client.request("POST", f"/mission_templates/{target}/execute", json=body))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _cmd_robots_list(args: argparse.Namespace) -> None:
|
|
113
|
+
with _open_client() as client:
|
|
114
|
+
_emit(client.request("GET", "/robots", params=_list_params(args)))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _cmd_missions_list(args: argparse.Namespace) -> None:
|
|
118
|
+
with _open_client() as client:
|
|
119
|
+
_emit(client.request("GET", "/missions", params=_list_params(args)))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _cmd_missions_create(args: argparse.Namespace) -> None:
|
|
123
|
+
if args.template_id is not None:
|
|
124
|
+
body: dict[str, Any] = {"template_id": args.template_id}
|
|
125
|
+
else:
|
|
126
|
+
body = {"template_code": args.template_code}
|
|
127
|
+
params = _params_object(args.params)
|
|
128
|
+
if params is not None:
|
|
129
|
+
body["params"] = params
|
|
130
|
+
with _open_client() as client:
|
|
131
|
+
_emit(client.request("POST", "/missions", json=body))
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _cmd_projects_list(args: argparse.Namespace) -> None:
|
|
135
|
+
with _open_client() as client:
|
|
136
|
+
_emit(client.request("GET", "/projects", params=_list_params(args)))
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _cmd_projects_create(args: argparse.Namespace) -> None:
|
|
140
|
+
lif_path = args.lif_json.expanduser()
|
|
141
|
+
data = {"manufacturer": args.manufacturer, "project_version": args.project_version}
|
|
142
|
+
if args.description is not None:
|
|
143
|
+
data["description"] = args.description
|
|
144
|
+
params = {"synchronous": "true"} if args.synchronous else None
|
|
145
|
+
with _open_client() as client:
|
|
146
|
+
with lif_path.open("rb") as handle:
|
|
147
|
+
files = {"lif_json": (lif_path.name, handle, "application/json")}
|
|
148
|
+
_emit(client.request("POST", "/projects", params=params, data=data, files=files))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _cmd_projects_activate(args: argparse.Namespace) -> None:
|
|
152
|
+
with _open_client() as client:
|
|
153
|
+
_emit(client.request("POST", f"/projects/{args.id}/activate"))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
_TOOL_COMMANDS = {
|
|
157
|
+
"mrmap-to-raster": "mrmap-to-raster.py",
|
|
158
|
+
"mrmap-to-bg": "mrmap-to-bg.py",
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _add_tool(sub: argparse._SubParsersAction, name: str, help_text: str) -> None:
|
|
163
|
+
# The real argv is handled in main() before parse_args, so --help reaches the tool.
|
|
164
|
+
# The subparser exists so `awcs help` lists the command.
|
|
165
|
+
sub.add_parser(name, help=help_text)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
169
|
+
parser = argparse.ArgumentParser(prog="awcs", description="AWCS API client")
|
|
170
|
+
sub = parser.add_subparsers(dest="command")
|
|
171
|
+
|
|
172
|
+
help_parser = sub.add_parser("help", help="Show this help")
|
|
173
|
+
help_parser.set_defaults(func=lambda _args: parser.print_help())
|
|
174
|
+
|
|
175
|
+
_add_tool(sub, "mrmap-to-raster", "Rasterize a .mrmap to PNG, GeoTIFF, and JSON")
|
|
176
|
+
_add_tool(sub, "mrmap-to-bg", "Set a .mrmap raster as a LIF layout background")
|
|
177
|
+
|
|
178
|
+
login = sub.add_parser("login", help="Check an API key and save the profile")
|
|
179
|
+
login.add_argument("--url", required=True, help="ARMS origin, for example https://dev.wcs.automacon.ru")
|
|
180
|
+
login.add_argument("--apikey", required=True)
|
|
181
|
+
login.set_defaults(func=_cmd_login)
|
|
182
|
+
|
|
183
|
+
users = sub.add_parser("users", help="Users")
|
|
184
|
+
users_sub = users.add_subparsers(dest="users_command", required=True)
|
|
185
|
+
users_list = users_sub.add_parser("list", help="GET /users")
|
|
186
|
+
_add_list_flags(users_list)
|
|
187
|
+
users_list.set_defaults(func=_cmd_users_list)
|
|
188
|
+
users_me = users_sub.add_parser("me", help="GET /users/me")
|
|
189
|
+
users_me.set_defaults(func=_cmd_users_me)
|
|
190
|
+
|
|
191
|
+
templates = sub.add_parser("mission_templates", help="Mission templates")
|
|
192
|
+
templates_sub = templates.add_subparsers(dest="mission_templates_command", required=True)
|
|
193
|
+
templates_list = templates_sub.add_parser("list", help="GET /mission_templates")
|
|
194
|
+
_add_list_flags(templates_list)
|
|
195
|
+
templates_list.set_defaults(func=_cmd_templates_list)
|
|
196
|
+
templates_execute = templates_sub.add_parser("execute", help="POST /mission_templates/{id}/execute")
|
|
197
|
+
templates_execute.add_argument("id_or_code", help="Numeric id or template code")
|
|
198
|
+
templates_execute.add_argument("--params", help="JSON object sent as the request body")
|
|
199
|
+
templates_execute.set_defaults(func=_cmd_templates_execute)
|
|
200
|
+
|
|
201
|
+
robots = sub.add_parser("robots", help="Robots")
|
|
202
|
+
robots_sub = robots.add_subparsers(dest="robots_command", required=True)
|
|
203
|
+
robots_list = robots_sub.add_parser("list", help="GET /robots")
|
|
204
|
+
_add_list_flags(robots_list)
|
|
205
|
+
robots_list.set_defaults(func=_cmd_robots_list)
|
|
206
|
+
|
|
207
|
+
missions = sub.add_parser("missions", help="Missions")
|
|
208
|
+
missions_sub = missions.add_subparsers(dest="missions_command", required=True)
|
|
209
|
+
missions_list = missions_sub.add_parser("list", help="GET /missions")
|
|
210
|
+
_add_list_flags(missions_list)
|
|
211
|
+
missions_list.set_defaults(func=_cmd_missions_list)
|
|
212
|
+
missions_create = missions_sub.add_parser("create", help="POST /missions")
|
|
213
|
+
template = missions_create.add_mutually_exclusive_group(required=True)
|
|
214
|
+
template.add_argument("--template_id", type=int)
|
|
215
|
+
template.add_argument("--template_code")
|
|
216
|
+
missions_create.add_argument("--params", help="JSON object stored in params")
|
|
217
|
+
missions_create.set_defaults(func=_cmd_missions_create)
|
|
218
|
+
|
|
219
|
+
projects = sub.add_parser("projects", help="Navigation projects")
|
|
220
|
+
projects_sub = projects.add_subparsers(dest="projects_command", required=True)
|
|
221
|
+
projects_list = projects_sub.add_parser("list", help="GET /projects")
|
|
222
|
+
_add_list_flags(projects_list)
|
|
223
|
+
projects_list.set_defaults(func=_cmd_projects_list)
|
|
224
|
+
projects_create = projects_sub.add_parser("create", help="POST /projects with a LIF file")
|
|
225
|
+
projects_create.add_argument("--lif_json", type=Path, required=True, help="Path to a .lif file")
|
|
226
|
+
projects_create.add_argument("--manufacturer", "-m", required=True)
|
|
227
|
+
projects_create.add_argument("--project_version", "--version", "-v", required=True, dest="project_version")
|
|
228
|
+
projects_create.add_argument("--description", "--desc", dest="description")
|
|
229
|
+
projects_create.add_argument("--synchronous", "--sync", action="store_true", dest="synchronous")
|
|
230
|
+
projects_create.set_defaults(func=_cmd_projects_create)
|
|
231
|
+
projects_activate = projects_sub.add_parser("activate", help="POST /projects/{id}/activate")
|
|
232
|
+
projects_activate.add_argument("id", type=int)
|
|
233
|
+
projects_activate.set_defaults(func=_cmd_projects_activate)
|
|
234
|
+
|
|
235
|
+
return parser
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def main(argv: list[str] | None = None) -> None:
|
|
239
|
+
args_list = list(sys.argv[1:] if argv is None else argv)
|
|
240
|
+
if args_list and args_list[0] in _TOOL_COMMANDS:
|
|
241
|
+
_load_tool(_TOOL_COMMANDS[args_list[0]]).main(args_list[1:])
|
|
242
|
+
return
|
|
243
|
+
parser = build_parser()
|
|
244
|
+
args = parser.parse_args(args_list)
|
|
245
|
+
func = getattr(args, "func", None)
|
|
246
|
+
if func is None:
|
|
247
|
+
parser.print_help()
|
|
248
|
+
return
|
|
249
|
+
try:
|
|
250
|
+
func(args)
|
|
251
|
+
except ApiError as exc:
|
|
252
|
+
_fail(exc.body or f"HTTP {exc.status_code}")
|
|
253
|
+
except ConfigError as exc:
|
|
254
|
+
_fail(str(exc))
|
|
255
|
+
except json.JSONDecodeError as exc:
|
|
256
|
+
_fail(f"invalid JSON: {exc}")
|
|
257
|
+
except httpx.HTTPError as exc:
|
|
258
|
+
_fail(str(exc))
|
|
259
|
+
except OSError as exc:
|
|
260
|
+
_fail(str(exc))
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
if __name__ == "__main__":
|
|
264
|
+
main()
|
awcs_scripts/config.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Profile with the ARMS origin and API key, stored outside the repository."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import configparser
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
_SECTION = "default"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ConfigError(Exception):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def config_path() -> Path:
|
|
18
|
+
override = os.environ.get("AWCS_CONFIG")
|
|
19
|
+
if override:
|
|
20
|
+
return Path(override).expanduser()
|
|
21
|
+
if os.name == "nt":
|
|
22
|
+
appdata = os.environ.get("APPDATA")
|
|
23
|
+
if appdata:
|
|
24
|
+
return Path(appdata) / "awcs" / "config.ini"
|
|
25
|
+
return Path.home() / ".config" / "awcs" / "config.ini"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def load_profile() -> tuple[str, str]:
|
|
29
|
+
path = config_path()
|
|
30
|
+
if not path.is_file():
|
|
31
|
+
raise ConfigError(f"no profile at {path}; run awcs login")
|
|
32
|
+
parser = configparser.ConfigParser()
|
|
33
|
+
parser.read(path, encoding="utf-8")
|
|
34
|
+
if not parser.has_section(_SECTION):
|
|
35
|
+
raise ConfigError(f"profile {path} has no [{_SECTION}] section")
|
|
36
|
+
url = parser.get(_SECTION, "url", fallback="").strip()
|
|
37
|
+
apikey = parser.get(_SECTION, "apikey", fallback="").strip()
|
|
38
|
+
if not url or not apikey:
|
|
39
|
+
raise ConfigError(f"profile {path} is missing url or apikey")
|
|
40
|
+
return url, apikey
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def save_profile(url: str, apikey: str) -> Path:
|
|
44
|
+
path = config_path()
|
|
45
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
parser = configparser.ConfigParser()
|
|
47
|
+
parser[_SECTION] = {"url": url, "apikey": apikey}
|
|
48
|
+
with path.open("w", encoding="utf-8", newline="\n") as handle:
|
|
49
|
+
parser.write(handle)
|
|
50
|
+
_user_only(path)
|
|
51
|
+
return path
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _user_only(path: Path) -> None:
|
|
55
|
+
try:
|
|
56
|
+
os.chmod(path, 0o600)
|
|
57
|
+
except OSError:
|
|
58
|
+
pass
|
|
59
|
+
if os.name != "nt":
|
|
60
|
+
return
|
|
61
|
+
user = os.environ.get("USERNAME")
|
|
62
|
+
if not user:
|
|
63
|
+
return
|
|
64
|
+
try:
|
|
65
|
+
subprocess.run(
|
|
66
|
+
["icacls", str(path), "/inheritance:r", "/grant:r", f"{user}:F"],
|
|
67
|
+
check=False,
|
|
68
|
+
capture_output=True,
|
|
69
|
+
)
|
|
70
|
+
except OSError:
|
|
71
|
+
pass
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""MAIRUI map tools. Command modules use hyphenated filenames and are loaded by path."""
|
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
"""Put a MAIRUI .mrmap raster into one LIF layout's backgroundImage.
|
|
2
|
+
|
|
3
|
+
Only the backgroundImage object of the chosen layout is rewritten. Every other
|
|
4
|
+
byte of the .lif file stays as it was, including node coordinates.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
awcs mrmap-to-bg MAP.mrmap MAP.lif --layout_id LAYOUT_ID [--resolution METERS] [--output OUT.lif]
|
|
8
|
+
|
|
9
|
+
The raster uses resolution_2d from the .mrmap (override with --resolution).
|
|
10
|
+
Without --output the .lif is overwritten. Node coordinates are not edited.
|
|
11
|
+
|
|
12
|
+
awcs mrmap-to-bg MM_before_BODY01_shift.mrmap map_5.0.69.lif --layout_id mapmain
|
|
13
|
+
awcs mrmap-to-bg MM_before_BODY01_shift.mrmap map_5.0.69.lif --layout_id mapmain --output map_with_bg.lif
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import base64
|
|
20
|
+
import importlib.util
|
|
21
|
+
import io
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import zipfile
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from PIL import Image
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _load_raster():
|
|
31
|
+
path = Path(__file__).with_name("mrmap-to-raster.py")
|
|
32
|
+
spec = importlib.util.spec_from_file_location("mrmap_to_raster", path)
|
|
33
|
+
if spec is None or spec.loader is None:
|
|
34
|
+
raise ImportError(f"cannot load {path}")
|
|
35
|
+
module = importlib.util.module_from_spec(spec)
|
|
36
|
+
spec.loader.exec_module(module)
|
|
37
|
+
return module
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
_raster = _load_raster()
|
|
41
|
+
load_head = _raster.load_head
|
|
42
|
+
rasterize = _raster.rasterize
|
|
43
|
+
|
|
44
|
+
_WS = " \t\r\n"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class LifEditError(ValueError):
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _skip_string(text: str, start: int) -> int:
|
|
52
|
+
"""Return the index just after the closing quote. start points at the opening quote."""
|
|
53
|
+
i = start + 1
|
|
54
|
+
n = len(text)
|
|
55
|
+
while i < n:
|
|
56
|
+
quote = text.find('"', i)
|
|
57
|
+
if quote < 0:
|
|
58
|
+
raise LifEditError(f"unterminated string at {start}")
|
|
59
|
+
slashes = 0
|
|
60
|
+
j = quote - 1
|
|
61
|
+
while j >= start and text[j] == "\\":
|
|
62
|
+
slashes += 1
|
|
63
|
+
j -= 1
|
|
64
|
+
if slashes % 2 == 0:
|
|
65
|
+
return quote + 1
|
|
66
|
+
i = quote + 1
|
|
67
|
+
raise LifEditError(f"unterminated string at {start}")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _skip_number(text: str, start: int) -> int:
|
|
71
|
+
i = start + 1
|
|
72
|
+
n = len(text)
|
|
73
|
+
while i < n and text[i] in "0123456789eE+-.":
|
|
74
|
+
i += 1
|
|
75
|
+
return i
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _value_done(stack: list[dict]) -> None:
|
|
79
|
+
if stack and stack[-1]["kind"] == "obj" and stack[-1].get("expect") == "value":
|
|
80
|
+
stack[-1]["expect"] = "comma"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def find_layout_spans(text: str, layout_id: str) -> list[tuple[int, int]]:
|
|
84
|
+
"""Return [start, end) brace spans of objects whose layoutId equals layout_id."""
|
|
85
|
+
spans: list[tuple[int, int]] = []
|
|
86
|
+
stack: list[dict] = []
|
|
87
|
+
i = 0
|
|
88
|
+
n = len(text)
|
|
89
|
+
while i < n:
|
|
90
|
+
char = text[i]
|
|
91
|
+
if char in _WS:
|
|
92
|
+
i += 1
|
|
93
|
+
continue
|
|
94
|
+
if char == "{":
|
|
95
|
+
stack.append({"kind": "obj", "start": i, "expect": "key", "match": False})
|
|
96
|
+
i += 1
|
|
97
|
+
continue
|
|
98
|
+
if char == "[":
|
|
99
|
+
stack.append({"kind": "arr", "start": i})
|
|
100
|
+
i += 1
|
|
101
|
+
continue
|
|
102
|
+
if char == "}":
|
|
103
|
+
if not stack or stack[-1]["kind"] != "obj":
|
|
104
|
+
raise LifEditError(f"unexpected closing brace at {i}")
|
|
105
|
+
frame = stack.pop()
|
|
106
|
+
end = i + 1
|
|
107
|
+
if frame["match"]:
|
|
108
|
+
spans.append((frame["start"], end))
|
|
109
|
+
_value_done(stack)
|
|
110
|
+
i = end
|
|
111
|
+
continue
|
|
112
|
+
if char == "]":
|
|
113
|
+
if not stack or stack[-1]["kind"] != "arr":
|
|
114
|
+
raise LifEditError(f"unexpected closing bracket at {i}")
|
|
115
|
+
stack.pop()
|
|
116
|
+
_value_done(stack)
|
|
117
|
+
i += 1
|
|
118
|
+
continue
|
|
119
|
+
if char == ",":
|
|
120
|
+
if stack and stack[-1]["kind"] == "obj":
|
|
121
|
+
stack[-1]["expect"] = "key"
|
|
122
|
+
i += 1
|
|
123
|
+
continue
|
|
124
|
+
if char == ":":
|
|
125
|
+
if not stack or stack[-1]["kind"] != "obj" or stack[-1].get("expect") != "colon":
|
|
126
|
+
raise LifEditError(f"unexpected colon at {i}")
|
|
127
|
+
stack[-1]["expect"] = "value"
|
|
128
|
+
i += 1
|
|
129
|
+
continue
|
|
130
|
+
if char == '"':
|
|
131
|
+
end = _skip_string(text, i)
|
|
132
|
+
if stack and stack[-1]["kind"] == "obj":
|
|
133
|
+
expect = stack[-1].get("expect")
|
|
134
|
+
if expect == "key":
|
|
135
|
+
stack[-1]["key"] = json.loads(text[i:end])
|
|
136
|
+
stack[-1]["expect"] = "colon"
|
|
137
|
+
elif expect == "value":
|
|
138
|
+
if stack[-1].get("key") == "layoutId" and json.loads(text[i:end]) == layout_id:
|
|
139
|
+
stack[-1]["match"] = True
|
|
140
|
+
_value_done(stack)
|
|
141
|
+
else:
|
|
142
|
+
raise LifEditError(f"unexpected string at {i}")
|
|
143
|
+
i = end
|
|
144
|
+
continue
|
|
145
|
+
if char == "-" or char.isdigit():
|
|
146
|
+
end = _skip_number(text, i)
|
|
147
|
+
if stack and stack[-1]["kind"] == "obj" and stack[-1].get("expect") != "value":
|
|
148
|
+
raise LifEditError(f"unexpected number at {i}")
|
|
149
|
+
_value_done(stack)
|
|
150
|
+
i = end
|
|
151
|
+
continue
|
|
152
|
+
word = next((item for item in ("true", "false", "null") if text.startswith(item, i)), None)
|
|
153
|
+
if word is None:
|
|
154
|
+
raise LifEditError(f"unexpected character {char!r} at {i}")
|
|
155
|
+
if stack and stack[-1]["kind"] == "obj" and stack[-1].get("expect") != "value":
|
|
156
|
+
raise LifEditError(f"unexpected literal at {i}")
|
|
157
|
+
_value_done(stack)
|
|
158
|
+
i += len(word)
|
|
159
|
+
if stack:
|
|
160
|
+
raise LifEditError("unbalanced JSON")
|
|
161
|
+
return spans
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _skip_container(text: str, start: int) -> int:
|
|
165
|
+
"""Return the index just after the container that starts at start."""
|
|
166
|
+
opener = text[start]
|
|
167
|
+
closer = "}" if opener == "{" else "]"
|
|
168
|
+
if opener not in "{[":
|
|
169
|
+
raise LifEditError(f"expected container at {start}")
|
|
170
|
+
depth = 0
|
|
171
|
+
i = start
|
|
172
|
+
n = len(text)
|
|
173
|
+
while i < n:
|
|
174
|
+
char = text[i]
|
|
175
|
+
if char == '"':
|
|
176
|
+
i = _skip_string(text, i)
|
|
177
|
+
continue
|
|
178
|
+
if char == opener:
|
|
179
|
+
depth += 1
|
|
180
|
+
elif char == closer:
|
|
181
|
+
depth -= 1
|
|
182
|
+
if depth == 0:
|
|
183
|
+
return i + 1
|
|
184
|
+
i += 1
|
|
185
|
+
raise LifEditError(f"unbalanced container at {start}")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def find_member(text: str, obj_start: int, obj_end: int, name: str) -> tuple[int, int, int] | None:
|
|
189
|
+
"""Find a direct child of the object. Returns (key_quote, value_start, value_end)."""
|
|
190
|
+
i = obj_start + 1
|
|
191
|
+
depth = 1
|
|
192
|
+
expect = "key"
|
|
193
|
+
key = None
|
|
194
|
+
key_at = None
|
|
195
|
+
while i < obj_end and depth:
|
|
196
|
+
char = text[i]
|
|
197
|
+
if char in _WS:
|
|
198
|
+
i += 1
|
|
199
|
+
continue
|
|
200
|
+
if char == "{":
|
|
201
|
+
if depth == 1 and expect == "value" and key == name:
|
|
202
|
+
return key_at, i, _skip_container(text, i)
|
|
203
|
+
depth += 1
|
|
204
|
+
i += 1
|
|
205
|
+
continue
|
|
206
|
+
if char == "[":
|
|
207
|
+
if depth == 1 and expect == "value" and key == name:
|
|
208
|
+
return key_at, i, _skip_container(text, i)
|
|
209
|
+
depth += 1
|
|
210
|
+
i += 1
|
|
211
|
+
continue
|
|
212
|
+
if char in "}]":
|
|
213
|
+
depth -= 1
|
|
214
|
+
i += 1
|
|
215
|
+
if depth == 1:
|
|
216
|
+
expect = "comma"
|
|
217
|
+
continue
|
|
218
|
+
if char == ",":
|
|
219
|
+
if depth == 1:
|
|
220
|
+
expect = "key"
|
|
221
|
+
i += 1
|
|
222
|
+
continue
|
|
223
|
+
if char == ":":
|
|
224
|
+
if depth == 1:
|
|
225
|
+
expect = "value"
|
|
226
|
+
i += 1
|
|
227
|
+
continue
|
|
228
|
+
if char == '"':
|
|
229
|
+
end = _skip_string(text, i)
|
|
230
|
+
if depth == 1 and expect == "key":
|
|
231
|
+
key = json.loads(text[i:end])
|
|
232
|
+
key_at = i
|
|
233
|
+
expect = "colon"
|
|
234
|
+
elif depth == 1 and expect == "value":
|
|
235
|
+
if key == name:
|
|
236
|
+
return key_at, i, end
|
|
237
|
+
expect = "comma"
|
|
238
|
+
i = end
|
|
239
|
+
continue
|
|
240
|
+
if char == "-" or char.isdigit():
|
|
241
|
+
end = _skip_number(text, i)
|
|
242
|
+
if depth == 1 and expect == "value" and key == name:
|
|
243
|
+
return key_at, i, end
|
|
244
|
+
if depth == 1 and expect == "value":
|
|
245
|
+
expect = "comma"
|
|
246
|
+
i = end
|
|
247
|
+
continue
|
|
248
|
+
word = next((item for item in ("true", "false", "null") if text.startswith(item, i)), None)
|
|
249
|
+
if word is None:
|
|
250
|
+
raise LifEditError(f"unexpected character {char!r} at {i}")
|
|
251
|
+
if depth == 1 and expect == "value" and key == name:
|
|
252
|
+
return key_at, i, i + len(word)
|
|
253
|
+
if depth == 1 and expect == "value":
|
|
254
|
+
expect = "comma"
|
|
255
|
+
i += len(word)
|
|
256
|
+
return None
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _line_indent(text: str, index: int) -> str:
|
|
260
|
+
line_start = text.rfind("\n", 0, index) + 1
|
|
261
|
+
indent = text[line_start:index]
|
|
262
|
+
if indent.strip():
|
|
263
|
+
raise LifEditError(f"key at {index} is not at the start of its line")
|
|
264
|
+
return indent
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def format_background(meta: dict, png_bytes: bytes, key_indent: str, include_key_indent: bool) -> str:
|
|
268
|
+
field_indent = key_indent + " "
|
|
269
|
+
uri = "data:image/png;base64," + base64.b64encode(png_bytes).decode("ascii")
|
|
270
|
+
first = '"backgroundImage": {'
|
|
271
|
+
if include_key_indent:
|
|
272
|
+
first = key_indent + first
|
|
273
|
+
lines = [
|
|
274
|
+
first,
|
|
275
|
+
f'{field_indent}"image": {json.dumps(uri)},',
|
|
276
|
+
f'{field_indent}"x": {json.dumps(meta["x"])},',
|
|
277
|
+
f'{field_indent}"y": {json.dumps(meta["y"])},',
|
|
278
|
+
f'{field_indent}"width": {json.dumps(meta["width"])},',
|
|
279
|
+
f'{field_indent}"height": {json.dumps(meta["height"])},',
|
|
280
|
+
f'{field_indent}"naturalWidth": {json.dumps(meta["naturalWidth"])},',
|
|
281
|
+
f'{field_indent}"naturalHeight": {json.dumps(meta["naturalHeight"])}',
|
|
282
|
+
f"{key_indent}}}",
|
|
283
|
+
]
|
|
284
|
+
return "\n".join(lines)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def replace_background(text: str, layout_id: str, meta: dict, png_bytes: bytes) -> tuple[str, tuple[int, int]]:
|
|
288
|
+
"""Return new text and the [start, end) span in the original that was replaced.
|
|
289
|
+
|
|
290
|
+
The span covers only the edited region. Prefix and suffix are copied unchanged.
|
|
291
|
+
For an insert, the span is the whitespace immediately before the layout's closing brace.
|
|
292
|
+
"""
|
|
293
|
+
spans = find_layout_spans(text, layout_id)
|
|
294
|
+
if not spans:
|
|
295
|
+
raise LifEditError(f"layoutId {layout_id!r} not found")
|
|
296
|
+
if len(spans) > 1:
|
|
297
|
+
raise LifEditError(f"layoutId {layout_id!r} is not unique ({len(spans)} matches)")
|
|
298
|
+
obj_start, obj_end = spans[0]
|
|
299
|
+
member = find_member(text, obj_start, obj_end, "backgroundImage")
|
|
300
|
+
if member is not None:
|
|
301
|
+
key_at, _value_start, value_end = member
|
|
302
|
+
key_indent = _line_indent(text, key_at)
|
|
303
|
+
block = format_background(meta, png_bytes, key_indent, include_key_indent=False)
|
|
304
|
+
new_text = text[:key_at] + block + text[value_end:]
|
|
305
|
+
return new_text, (key_at, value_end)
|
|
306
|
+
|
|
307
|
+
layout_id_member = find_member(text, obj_start, obj_end, "layoutId")
|
|
308
|
+
if layout_id_member is None:
|
|
309
|
+
raise LifEditError(f"layout object for {layout_id!r} has no layoutId key")
|
|
310
|
+
key_indent = _line_indent(text, layout_id_member[0])
|
|
311
|
+
block = format_background(meta, png_bytes, key_indent, include_key_indent=True)
|
|
312
|
+
closing_line = text.rfind("\n", obj_start, obj_end - 1) + 1
|
|
313
|
+
content_end = closing_line - 1
|
|
314
|
+
while content_end > obj_start and text[content_end] in " \t":
|
|
315
|
+
content_end -= 1
|
|
316
|
+
# content_end is the last newline before the closing brace, or the last content char.
|
|
317
|
+
if text[content_end] == "\n":
|
|
318
|
+
last = content_end - 1
|
|
319
|
+
while last > obj_start and text[last] in " \t":
|
|
320
|
+
last -= 1
|
|
321
|
+
else:
|
|
322
|
+
last = content_end
|
|
323
|
+
if text[last] == "{":
|
|
324
|
+
new_text = text[: last + 1] + "\n" + block + "\n" + text[closing_line:]
|
|
325
|
+
return new_text, (last + 1, closing_line)
|
|
326
|
+
new_text = text[: last + 1] + ",\n" + block + "\n" + text[closing_line:]
|
|
327
|
+
return new_text, (last + 1, closing_line)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def render_png(mrmap_path: Path, resolution: float | None) -> tuple[bytes, dict]:
|
|
331
|
+
with zipfile.ZipFile(mrmap_path) as archive:
|
|
332
|
+
head = load_head(archive)
|
|
333
|
+
if head.get("magic") != "MAIRUI":
|
|
334
|
+
raise LifEditError(f"unexpected map magic: {head.get('magic')!r}")
|
|
335
|
+
if resolution is None:
|
|
336
|
+
resolution = float(head["resolution_2d"])
|
|
337
|
+
if resolution <= 0:
|
|
338
|
+
raise LifEditError(f"resolution must be positive, got {resolution}")
|
|
339
|
+
image, info = rasterize(archive, head, resolution)
|
|
340
|
+
buffer = io.BytesIO()
|
|
341
|
+
Image.fromarray(image, mode="L").save(buffer, format="PNG")
|
|
342
|
+
info["png"] = buffer.getvalue()
|
|
343
|
+
return info["png"], info
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _check_background(data: dict, layout_id: str, meta: dict, png_bytes: bytes) -> None:
|
|
347
|
+
matches = [layout for layout in data["layouts"] if layout.get("layoutId") == layout_id]
|
|
348
|
+
if len(matches) != 1:
|
|
349
|
+
raise LifEditError(f"parsed layout count for {layout_id!r} is {len(matches)}")
|
|
350
|
+
background = matches[0].get("backgroundImage")
|
|
351
|
+
if not isinstance(background, dict):
|
|
352
|
+
raise LifEditError("backgroundImage missing after edit")
|
|
353
|
+
for key in ("x", "y", "width", "height", "naturalWidth", "naturalHeight"):
|
|
354
|
+
if background.get(key) != meta[key]:
|
|
355
|
+
raise LifEditError(f"{key} is {background.get(key)!r}, expected {meta[key]!r}")
|
|
356
|
+
prefix = "data:image/png;base64,"
|
|
357
|
+
image = background.get("image")
|
|
358
|
+
if not isinstance(image, str) or not image.startswith(prefix):
|
|
359
|
+
raise LifEditError("background image is not a png data uri")
|
|
360
|
+
decoded = base64.b64decode(image[len(prefix):], validate=True)
|
|
361
|
+
if decoded != png_bytes:
|
|
362
|
+
raise LifEditError("embedded png does not match the raster")
|
|
363
|
+
with Image.open(io.BytesIO(decoded)) as picture:
|
|
364
|
+
if picture.size != (meta["naturalWidth"], meta["naturalHeight"]):
|
|
365
|
+
raise LifEditError(f"png size {picture.size} does not match natural size")
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def apply_background(
|
|
369
|
+
mrmap_path: Path,
|
|
370
|
+
lif_path: Path,
|
|
371
|
+
layout_id: str,
|
|
372
|
+
output_path: Path | None,
|
|
373
|
+
resolution: float | None,
|
|
374
|
+
) -> dict:
|
|
375
|
+
png_bytes, info = render_png(mrmap_path, resolution)
|
|
376
|
+
original = lif_path.read_bytes()
|
|
377
|
+
text = original.decode("utf-8")
|
|
378
|
+
new_text, (old_start, old_end) = replace_background(text, layout_id, info["meta"], png_bytes)
|
|
379
|
+
suffix_len = len(text) - old_end
|
|
380
|
+
if new_text[:old_start] != text[:old_start]:
|
|
381
|
+
raise LifEditError("edit touched text before backgroundImage")
|
|
382
|
+
if suffix_len and new_text[-suffix_len:] != text[old_end:]:
|
|
383
|
+
raise LifEditError("edit touched text after backgroundImage")
|
|
384
|
+
if suffix_len and len(new_text) < old_start + suffix_len:
|
|
385
|
+
raise LifEditError("edit overlapped the unchanged suffix")
|
|
386
|
+
parsed = json.loads(new_text)
|
|
387
|
+
_check_background(parsed, layout_id, info["meta"], png_bytes)
|
|
388
|
+
new_bytes = new_text.encode("utf-8")
|
|
389
|
+
destination = output_path or lif_path
|
|
390
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
391
|
+
temporary = destination.with_suffix(destination.suffix + ".tmp")
|
|
392
|
+
temporary.write_bytes(new_bytes)
|
|
393
|
+
os.replace(temporary, destination)
|
|
394
|
+
info["output"] = destination
|
|
395
|
+
info["unchanged_prefix"] = old_start
|
|
396
|
+
info["unchanged_suffix"] = suffix_len
|
|
397
|
+
return info
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def main(argv: list[str] | None = None) -> None:
|
|
401
|
+
parser = argparse.ArgumentParser(
|
|
402
|
+
prog="awcs mrmap-to-bg",
|
|
403
|
+
description="Rasterize a .mrmap and set it as a LIF layout backgroundImage.",
|
|
404
|
+
)
|
|
405
|
+
parser.add_argument("mrmap", type=Path, help="path to a .mrmap file")
|
|
406
|
+
parser.add_argument("lif", type=Path, help="path to a .lif file")
|
|
407
|
+
parser.add_argument("--layout_id", required=True, help="layoutId whose backgroundImage is replaced")
|
|
408
|
+
parser.add_argument("--resolution", type=float, default=None, help="meters per pixel (default: resolution_2d)")
|
|
409
|
+
parser.add_argument("--output", type=Path, default=None, help="output .lif (default: overwrite the input)")
|
|
410
|
+
args = parser.parse_args(argv)
|
|
411
|
+
info = apply_background(
|
|
412
|
+
args.mrmap.expanduser().resolve(),
|
|
413
|
+
args.lif.expanduser().resolve(),
|
|
414
|
+
args.layout_id,
|
|
415
|
+
args.output.expanduser().resolve() if args.output else None,
|
|
416
|
+
args.resolution,
|
|
417
|
+
)
|
|
418
|
+
meta = info["meta"]
|
|
419
|
+
print(f"layout {args.layout_id}")
|
|
420
|
+
print(f"resolution_m {info['resolution']}")
|
|
421
|
+
print(f"pixels {meta['naturalWidth']} x {meta['naturalHeight']}")
|
|
422
|
+
print(f"x {meta['x']}")
|
|
423
|
+
print(f"y {meta['y']}")
|
|
424
|
+
print(f"width {meta['width']}")
|
|
425
|
+
print(f"height {meta['height']}")
|
|
426
|
+
print(f"points_kept {info['kept']}")
|
|
427
|
+
print(f"points_dropped {info['dropped']}")
|
|
428
|
+
print(f"unchanged_prefix {info['unchanged_prefix']}")
|
|
429
|
+
print(f"unchanged_suffix {info['unchanged_suffix']}")
|
|
430
|
+
print(f"output {info['output']}")
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
if __name__ == "__main__":
|
|
434
|
+
main()
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Rasterize a MAIRUI .mrmap point cloud into PNG, GeoTIFF, and placement JSON.
|
|
2
|
+
|
|
3
|
+
World frame of each submap point (x, y), yaw in radians:
|
|
4
|
+
|
|
5
|
+
wx = origin_x + cos(yaw) * x - sin(yaw) * y
|
|
6
|
+
wy = origin_y + sin(yaw) * x + cos(yaw) * y
|
|
7
|
+
|
|
8
|
+
The raster is anchored at bbox_min (lower-left). Pixel (0, 0) is the upper-left
|
|
9
|
+
corner of the image, so world Y decreases as the row index increases.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import json
|
|
16
|
+
import math
|
|
17
|
+
import zipfile
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
import tifffile
|
|
22
|
+
from PIL import Image
|
|
23
|
+
|
|
24
|
+
_DATA_BINARY = b"DATA binary\n"
|
|
25
|
+
_POINT_STRIDE = 16 # x, y, z, intensity as float32
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def pixel_count(span: float, resolution: float) -> int:
|
|
29
|
+
count = int(math.ceil(span / resolution))
|
|
30
|
+
if count > 0 and (count - 1) * resolution >= span:
|
|
31
|
+
count -= 1
|
|
32
|
+
return max(count, 1)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def load_head(archive: zipfile.ZipFile) -> dict:
|
|
36
|
+
return json.loads(archive.read("map_head.json"))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def iter_world_xy(archive: zipfile.ZipFile, submap: dict):
|
|
40
|
+
raw = archive.read(submap["pc_entry"])
|
|
41
|
+
start = raw.find(_DATA_BINARY)
|
|
42
|
+
if start < 0:
|
|
43
|
+
raise ValueError(f"{submap['pc_entry']}: expected 'DATA binary'")
|
|
44
|
+
payload = raw[start + len(_DATA_BINARY) :]
|
|
45
|
+
payload = payload[: len(payload) - (len(payload) % _POINT_STRIDE)]
|
|
46
|
+
if not payload:
|
|
47
|
+
return np.empty((0, 2), dtype=np.float64)
|
|
48
|
+
local = np.frombuffer(payload, dtype="<f4").reshape(-1, 4)
|
|
49
|
+
yaw = math.radians(float(submap["origin_yaw_deg"]))
|
|
50
|
+
cos_yaw = math.cos(yaw)
|
|
51
|
+
sin_yaw = math.sin(yaw)
|
|
52
|
+
origin_x = float(submap["origin_x"])
|
|
53
|
+
origin_y = float(submap["origin_y"])
|
|
54
|
+
lx = local[:, 0].astype(np.float64, copy=False)
|
|
55
|
+
ly = local[:, 1].astype(np.float64, copy=False)
|
|
56
|
+
world = np.empty((lx.shape[0], 2), dtype=np.float64)
|
|
57
|
+
world[:, 0] = origin_x + cos_yaw * lx - sin_yaw * ly
|
|
58
|
+
world[:, 1] = origin_y + sin_yaw * lx + cos_yaw * ly
|
|
59
|
+
return world
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def rasterize(archive: zipfile.ZipFile, head: dict, resolution: float) -> tuple[np.ndarray, dict]:
|
|
63
|
+
xmin, ymin = (float(v) for v in head["bbox_min_xy"])
|
|
64
|
+
xmax, ymax_bbox = (float(v) for v in head["bbox_max_xy"])
|
|
65
|
+
cols = pixel_count(xmax - xmin, resolution)
|
|
66
|
+
rows = pixel_count(ymax_bbox - ymin, resolution)
|
|
67
|
+
width = cols * resolution
|
|
68
|
+
height = rows * resolution
|
|
69
|
+
top = ymin + height
|
|
70
|
+
image = np.full((rows, cols), 255, dtype=np.uint8)
|
|
71
|
+
kept = 0
|
|
72
|
+
dropped = 0
|
|
73
|
+
for submap in head["submaps_meta"]:
|
|
74
|
+
world = iter_world_xy(archive, submap)
|
|
75
|
+
if world.size == 0:
|
|
76
|
+
continue
|
|
77
|
+
col = np.floor((world[:, 0] - xmin) / resolution).astype(np.int64)
|
|
78
|
+
row = np.floor((top - world[:, 1]) / resolution).astype(np.int64)
|
|
79
|
+
inside = (
|
|
80
|
+
(world[:, 0] >= xmin)
|
|
81
|
+
& (world[:, 0] <= xmin + width)
|
|
82
|
+
& (world[:, 1] >= ymin)
|
|
83
|
+
& (world[:, 1] <= top)
|
|
84
|
+
)
|
|
85
|
+
col = np.clip(col, 0, cols - 1)
|
|
86
|
+
row = np.clip(row, 0, rows - 1)
|
|
87
|
+
if np.any(inside):
|
|
88
|
+
image[row[inside], col[inside]] = 0
|
|
89
|
+
kept += int(np.count_nonzero(inside))
|
|
90
|
+
dropped += int(np.count_nonzero(~inside))
|
|
91
|
+
meta = {
|
|
92
|
+
"x": xmin,
|
|
93
|
+
"y": ymin,
|
|
94
|
+
"width": width,
|
|
95
|
+
"height": height,
|
|
96
|
+
"naturalWidth": cols,
|
|
97
|
+
"naturalHeight": rows,
|
|
98
|
+
}
|
|
99
|
+
return image, {"meta": meta, "resolution": resolution, "kept": kept, "dropped": dropped, "top": top}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def write_geotiff(path: Path, image: np.ndarray, meta: dict, resolution: float) -> None:
|
|
103
|
+
"""Upper-left corner of pixel (0, 0) is (x, y + height). Y decreases downward."""
|
|
104
|
+
top = meta["y"] + meta["height"]
|
|
105
|
+
geokeys = (
|
|
106
|
+
1, 1, 0, 2,
|
|
107
|
+
1024, 0, 1, 1, # GTModelTypeGeoKey = ModelTypeProjected
|
|
108
|
+
1025, 0, 1, 1, # GTRasterTypeGeoKey = RasterPixelIsArea
|
|
109
|
+
)
|
|
110
|
+
tifffile.imwrite(
|
|
111
|
+
path,
|
|
112
|
+
image,
|
|
113
|
+
photometric="minisblack",
|
|
114
|
+
extratags=[
|
|
115
|
+
(33550, "d", 3, (float(resolution), float(resolution), 0.0), True),
|
|
116
|
+
(33922, "d", 6, (0.0, 0.0, 0.0, float(meta["x"]), float(top), 0.0), True),
|
|
117
|
+
(34735, "H", len(geokeys), geokeys, True),
|
|
118
|
+
],
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def convert(mrmap_path: Path, out_dir: Path, resolution: float | None) -> dict:
|
|
123
|
+
with zipfile.ZipFile(mrmap_path) as archive:
|
|
124
|
+
head = load_head(archive)
|
|
125
|
+
if head.get("magic") != "MAIRUI":
|
|
126
|
+
raise ValueError(f"unexpected map magic: {head.get('magic')!r}")
|
|
127
|
+
if resolution is None:
|
|
128
|
+
resolution = float(head["resolution_2d"])
|
|
129
|
+
if resolution <= 0:
|
|
130
|
+
raise ValueError(f"resolution must be positive, got {resolution}")
|
|
131
|
+
image, info = rasterize(archive, head, resolution)
|
|
132
|
+
|
|
133
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
134
|
+
stem = mrmap_path.stem
|
|
135
|
+
png_path = out_dir / f"{stem}.png"
|
|
136
|
+
tif_path = out_dir / f"{stem}.tif"
|
|
137
|
+
json_path = out_dir / f"{stem}.json"
|
|
138
|
+
meta = info["meta"]
|
|
139
|
+
|
|
140
|
+
Image.fromarray(image, mode="L").save(png_path)
|
|
141
|
+
write_geotiff(tif_path, image, meta, info["resolution"])
|
|
142
|
+
json_path.write_text(json.dumps(meta, indent=2) + "\n", encoding="utf-8")
|
|
143
|
+
|
|
144
|
+
info["paths"] = {"png": png_path, "tif": tif_path, "json": json_path}
|
|
145
|
+
return info
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def main(argv: list[str] | None = None) -> None:
|
|
149
|
+
parser = argparse.ArgumentParser(
|
|
150
|
+
prog="awcs mrmap-to-raster",
|
|
151
|
+
description="Convert a MAIRUI .mrmap to PNG, GeoTIFF, and placement JSON.",
|
|
152
|
+
)
|
|
153
|
+
parser.add_argument("mrmap", type=Path, help="path to a .mrmap file")
|
|
154
|
+
parser.add_argument("--resolution", type=float, default=None, help="meters per pixel (default: resolution_2d)")
|
|
155
|
+
parser.add_argument("--out-dir", type=Path, default=None, help="output directory (default: next to the input)")
|
|
156
|
+
args = parser.parse_args(argv)
|
|
157
|
+
mrmap_path = args.mrmap.expanduser().resolve()
|
|
158
|
+
out_dir = (args.out_dir or mrmap_path.parent).expanduser().resolve()
|
|
159
|
+
info = convert(mrmap_path, out_dir, args.resolution)
|
|
160
|
+
meta = info["meta"]
|
|
161
|
+
print(f"resolution_m {info['resolution']}")
|
|
162
|
+
print(f"pixels {meta['naturalWidth']} x {meta['naturalHeight']}")
|
|
163
|
+
print(f"x {meta['x']}")
|
|
164
|
+
print(f"y {meta['y']}")
|
|
165
|
+
print(f"width {meta['width']}")
|
|
166
|
+
print(f"height {meta['height']}")
|
|
167
|
+
print(f"points_kept {info['kept']}")
|
|
168
|
+
print(f"points_dropped {info['dropped']}")
|
|
169
|
+
for kind, path in info["paths"].items():
|
|
170
|
+
print(f"{kind} {path}")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
if __name__ == "__main__":
|
|
174
|
+
main()
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: awcs-scripts
|
|
3
|
+
Version: 1.0.1
|
|
4
|
+
Summary: Command line scripts for WCS maps and the ARMS API
|
|
5
|
+
Project-URL: Repository, https://gitlab.sprydev.ru/automacon/agv/wcs/awcs_scripts
|
|
6
|
+
Author: AWCS
|
|
7
|
+
License: Apache-2.0
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Requires-Dist: httpx
|
|
11
|
+
Requires-Dist: numpy
|
|
12
|
+
Requires-Dist: pillow
|
|
13
|
+
Requires-Dist: tifffile
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# awcs-scripts
|
|
17
|
+
|
|
18
|
+
Скрипты для карт WCS и части ARMS API. Пакет называется `awcs-scripts`, команда после установки — `awcs`.
|
|
19
|
+
|
|
20
|
+
## Публикация в PyPI
|
|
21
|
+
|
|
22
|
+
Выкладка происходит только по тегу `vX.Y.Z`. Обычный push и merge request пакет не публикуют: CI прогоняет тесты и собирает wheel. Версию берёт джоба `publish` из тега (`v0.1.0` → `0.1.0`), поле `version` в `pyproject.toml` на публикацию не влияет.
|
|
23
|
+
|
|
24
|
+
Токен в репозиторий не входит. Его нужно завести один раз:
|
|
25
|
+
|
|
26
|
+
1. На [pypi.org](https://pypi.org) создать API-токен с правом загрузки. Для первой выкладки имя `awcs-scripts` должно быть свободно: проект создаётся при первой успешной загрузке. До этого нужен токен уровня аккаунта, после первой публикации его можно ограничить этим проектом.
|
|
27
|
+
2. В GitLab, в CI/CD-переменных этого репозитория, добавить `UV_PUBLISH_TOKEN` со значением токена и включить **Masked**. **Protected** включать только если теги `v*` тоже protected: иначе на теге переменная в джобу не попадёт и `uv publish` упадёт без токена.
|
|
28
|
+
|
|
29
|
+
Тег и отправка:
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
git tag v0.1.0
|
|
33
|
+
git push origin v0.1.0
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Репозиторий закрытый, а `uv publish` пишет на публичный PyPI. После зелёного пайплайна wheel доступен по имени пакета.
|
|
37
|
+
|
|
38
|
+
## Установка
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
uv tool install awcs-scripts
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
До первого тега те же подкоманды запускаются из git. Репозиторий закрытый, доступ к GitLab уже должен быть настроен, токен в команду не подставляется:
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
uvx --from git+https://gitlab.sprydev.ru/automacon/agv/wcs/awcs_scripts.git awcs help
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Из клона репозитория, без публикации:
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
uv tool install .
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
После этого команда `awcs` доступна в системе. Для разработки в том же клоне:
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
uv sync
|
|
60
|
+
uv run awcs help
|
|
61
|
+
uv build
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Удаление
|
|
65
|
+
|
|
66
|
+
Снимает команду `awcs`, поставленную через `uv tool install` из клона или с PyPI:
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
uv tool uninstall awcs-scripts
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Профиль с адресом и ключом эта команда не трогает. Его файл лежит по `AWCS_CONFIG`, иначе `%APPDATA%\awcs\config.ini` на Windows и `~/.config/awcs/config.ini` в остальных системах. Чтобы убрать и ключ, удалите этот файл.
|
|
73
|
+
|
|
74
|
+
## Команды
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
awcs help
|
|
78
|
+
awcs mrmap-to-raster path/to/map.mrmap --out-dir path/to/out
|
|
79
|
+
awcs mrmap-to-bg path/to/map.mrmap path/to/layout.lif --layout_id layoutId --resolution 0.05 --output path/to/layout.out.lif
|
|
80
|
+
awcs login --url https://dev.wcs.automacon.ru --apikey <apikey>
|
|
81
|
+
awcs users list
|
|
82
|
+
awcs users me
|
|
83
|
+
awcs mission_templates list
|
|
84
|
+
awcs robots list
|
|
85
|
+
awcs missions list
|
|
86
|
+
awcs missions create --template_code PickPallet --params "{\"zone\":\"A\"}"
|
|
87
|
+
awcs mission_templates execute PickPallet --params "{\"zone\":\"A\"}"
|
|
88
|
+
awcs projects list
|
|
89
|
+
awcs projects create --lif_json path/to/layout.lif -m MAIRUI -v 1.0.0 --desc "line 1" --sync
|
|
90
|
+
awcs projects activate 12
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Разовый запуск после публикации:
|
|
94
|
+
|
|
95
|
+
```
|
|
96
|
+
uvx --from awcs-scripts awcs help
|
|
97
|
+
uvx --from awcs-scripts awcs mrmap-to-raster path/to/map.mrmap --resolution 0.05 --out-dir path/to/out
|
|
98
|
+
uvx --from awcs-scripts awcs users me
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
`awcs mrmap-to-raster` пишет PNG, GeoTIFF и JSON. `--resolution` задаёт метры на пиксель, по умолчанию берётся `resolution_2d` из карты. `awcs mrmap-to-bg` подставляет этот растр в `backgroundImage` одного layout. Без `--output` исходный `.lif` перезаписывается.
|
|
102
|
+
|
|
103
|
+
`awcs login` проверяет ключ через `GET /users/me` и только после ответа 200 сохраняет origin и ключ. Профиль лежит вне репозитория: путь из `AWCS_CONFIG`, иначе `%APPDATA%\awcs\config.ini` на Windows и `~/.config/awcs/config.ini` в остальных системах. Ключ в вывод не печатается. Клиент сам дописывает `/api/v1` и шлёт заголовок `X-Api-Key`.
|
|
104
|
+
|
|
105
|
+
У списков есть `--page`, `--per_page`, `--filter`, `--sort_by`. `awcs missions create` принимает `--template_id` или `--template_code` и необязательный `--params`. `awcs mission_templates execute` отправляет `--params` плоским JSON-объектом. `awcs projects create` загружает LIF как `multipart/form-data`: `-m` это `--manufacturer`, `-v` и `--version` это `--project_version`, `--desc` это `--description`, `--sync` это `--synchronous`.
|
|
106
|
+
|
|
107
|
+
## Разработка
|
|
108
|
+
|
|
109
|
+
Карта живёт в `src/awcs_scripts/mrmap/`. Имена файлов совпадают с подкомандами: `mrmap-to-raster.py` и `mrmap-to-bg.py`. Будущие shell-скрипты кладутся в `bash/` и тоже вызываются только через `awcs`.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
awcs_scripts/__init__.py,sha256=qZwBaSA9sxWB32dGuygTwqLWo36eHDhPwoH1mi1aluw,68
|
|
2
|
+
awcs_scripts/api.py,sha256=4z_OD2qxGKiPUYPi5yDu95f7AD2ZILfF9_eiJ-CnXvc,1743
|
|
3
|
+
awcs_scripts/cli.py,sha256=qvwO0UBzL3NuGJa-JXBKScBVUvJ-ycXfsUAOxiU1tkw,10086
|
|
4
|
+
awcs_scripts/config.py,sha256=hLft4fmTk3K97Qt51pfEgPltYnPhL09raPgjh0wP1tI,1978
|
|
5
|
+
awcs_scripts/mrmap/__init__.py,sha256=2UI1KO_YhOO1HCOHV-Q9FJm5Fxup_jE5DI00IGHPDDQ,89
|
|
6
|
+
awcs_scripts/mrmap/mrmap-to-bg.py,sha256=26H4oOyaF-FPELd_mLMIvJ8MG_XDZX7-8EKsERdHhwE,16508
|
|
7
|
+
awcs_scripts/mrmap/mrmap-to-raster.py,sha256=VlCib6Z84CEiRoobQaNvZ16ebtJT3ZcTe9iE1KhEQiY,6383
|
|
8
|
+
awcs_scripts-1.0.1.dist-info/METADATA,sha256=cqj8JvOTEYOrVWN7ExAEVhkNxKRTkT10WwexutljSUA,6100
|
|
9
|
+
awcs_scripts-1.0.1.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
10
|
+
awcs_scripts-1.0.1.dist-info/entry_points.txt,sha256=dgDy55MUX47MYoo2R-ocEYevBZbFKqI468upVmAUiIs,47
|
|
11
|
+
awcs_scripts-1.0.1.dist-info/licenses/LICENSE,sha256=AQ59EbGYZeqWXEy59FCXjUH5KbOoAFcGjqbGzkWJzHg,733
|
|
12
|
+
awcs_scripts-1.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
Copyright 2026 AWCS
|
|
6
|
+
|
|
7
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
8
|
+
you may not use this file except in compliance with the License.
|
|
9
|
+
You may obtain a copy of the License at
|
|
10
|
+
|
|
11
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
12
|
+
|
|
13
|
+
Unless required by applicable law or agreed to in writing, software
|
|
14
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
15
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
16
|
+
See the License for the specific language governing permissions and
|
|
17
|
+
limitations under the License.
|