ros-streamer 0.3.0__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.
- ros_stream_viewer/__init__.py +13 -0
- ros_stream_viewer/config.py +365 -0
- ros_stream_viewer/services/__init__.py +3 -0
- ros_stream_viewer/services/stream_service.py +31 -0
- ros_stream_viewer/services/viewer_service.py +852 -0
- ros_stream_viewer/utils/__init__.py +3 -0
- ros_stream_viewer/utils/logger.py +39 -0
- ros_stream_viewer/utils/thread_helpper.py +96 -0
- ros_stream_viewer/viewer.py +391 -0
- ros_streamer-0.3.0.dist-info/METADATA +154 -0
- ros_streamer-0.3.0.dist-info/RECORD +15 -0
- ros_streamer-0.3.0.dist-info/WHEEL +5 -0
- ros_streamer-0.3.0.dist-info/entry_points.txt +4 -0
- ros_streamer-0.3.0.dist-info/licenses/LICENSE +202 -0
- ros_streamer-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""ros_stream_viewer: ZMQ 카메라 스트림 뷰어."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
# 설치된 배포 메타데이터에서 버전을 읽어 pyproject와 어긋나지 않게 한다.
|
|
9
|
+
__version__ = version("ros-streamer")
|
|
10
|
+
except PackageNotFoundError: # 소스 트리에서 직접 실행하는 경우
|
|
11
|
+
__version__ = "0.1.0"
|
|
12
|
+
|
|
13
|
+
__all__ = ["__version__"]
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
"""wpyconf(import name: ``wconfig``) 기반 설정 계층.
|
|
2
|
+
|
|
3
|
+
우선순위(높은 것이 낮은 것을 덮어씀):
|
|
4
|
+
|
|
5
|
+
CLI 플래그 > 환경변수 > .env > 설정 파일 > 코드 기본값
|
|
6
|
+
|
|
7
|
+
환경변수 prefix는 ``ROS_STREAMER``이며, 중첩 키는 ``__``로 구분한다.
|
|
8
|
+
|
|
9
|
+
ROS_STREAMER_STREAM__HOST=10.0.0.2
|
|
10
|
+
ROS_STREAMER_STREAM__PORT=15555
|
|
11
|
+
ROS_STREAMER_STREAM__TYPE=compressed
|
|
12
|
+
ROS_STREAMER_LOGGING__LEVEL=DEBUG
|
|
13
|
+
|
|
14
|
+
설정 파일은 ``--config PATH``로 지정하며 JSON/TOML/YAML을 지원한다.
|
|
15
|
+
|
|
16
|
+
값 해석 규약
|
|
17
|
+
* 환경변수/``.env``/설정 파일 값은 문자열로 들어오므로 타입별로 명시적으로
|
|
18
|
+
변환한다. 특히 불리언은 ``true/false/1/0/yes/no/on/off``만 인정한다
|
|
19
|
+
(``bool("false") is True`` 같은 함정을 피한다).
|
|
20
|
+
* 해석 자체가 불가능한 값은 기본값으로 폴백한다.
|
|
21
|
+
* 해석됐지만 규약을 벗어난 값(포트 범위 밖, 0 이하 해상도 등)은
|
|
22
|
+
:class:`StreamConfigError`를 올려 사용법/설정 오류(종료 코드 2)로 처리한다.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from collections.abc import Mapping
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
ENV_PREFIX = "ROS_STREAMER"
|
|
32
|
+
|
|
33
|
+
#: 문자열을 불리언으로 해석할 때 허용하는 표기(wpycli ``_parse_bool``과 동일).
|
|
34
|
+
_TRUE_STRINGS = frozenset({"1", "true", "t", "yes", "y", "on"})
|
|
35
|
+
_FALSE_STRINGS = frozenset({"0", "false", "f", "no", "n", "off"})
|
|
36
|
+
|
|
37
|
+
MIN_PORT = 1
|
|
38
|
+
MAX_PORT = 65535
|
|
39
|
+
MIN_DIMENSION = 1
|
|
40
|
+
MAX_DIMENSION = 32768
|
|
41
|
+
MAX_TIMEOUT_SECONDS = 3600.0
|
|
42
|
+
MAX_DURATION_SECONDS = 86400.0
|
|
43
|
+
MAX_FRAMES_LIMIT = 1_000_000_000
|
|
44
|
+
|
|
45
|
+
#: ``stream.type`` 허용값. ``auto``는 매직 바이트로 자동 판별한다.
|
|
46
|
+
STREAM_TYPES = ("auto", "raw", "compressed")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class StreamConfigError(ValueError):
|
|
50
|
+
"""해석은 됐지만 규약을 벗어난 설정값(사용법/설정 오류, 종료 코드 2)."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
DEFAULTS: dict[str, Any] = {
|
|
54
|
+
"stream": {
|
|
55
|
+
"host": "localhost",
|
|
56
|
+
# 서버 포트 규약: base(5555)=raw, base+1(5556)=compressed.
|
|
57
|
+
"port": 5556,
|
|
58
|
+
"width": 640,
|
|
59
|
+
"height": 480,
|
|
60
|
+
"type": "auto",
|
|
61
|
+
"connect_timeout": 5.0,
|
|
62
|
+
"stall_timeout": 3.0,
|
|
63
|
+
"max_frames": 0,
|
|
64
|
+
"duration": 0.0,
|
|
65
|
+
"snapshot_dir": ".",
|
|
66
|
+
"frame_log": False,
|
|
67
|
+
"network_info": True,
|
|
68
|
+
"headless": False,
|
|
69
|
+
},
|
|
70
|
+
"output": {
|
|
71
|
+
# 기계 판독 출력. 결과는 stdout, 로그는 stderr로 나간다.
|
|
72
|
+
"json": False,
|
|
73
|
+
},
|
|
74
|
+
"logging": {
|
|
75
|
+
"level": "INFO",
|
|
76
|
+
"file": None,
|
|
77
|
+
# wpycli ``LoggingSettings``가 ``logging.max_bytes``/``backup_count``
|
|
78
|
+
# 키로 읽어 로그 로테이션에 사용한다.
|
|
79
|
+
"max_bytes": 10 * 1024 * 1024,
|
|
80
|
+
"backup_count": 5,
|
|
81
|
+
},
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True)
|
|
86
|
+
class StreamOptions:
|
|
87
|
+
"""뷰어 서비스에 전달되는 해석된 옵션."""
|
|
88
|
+
|
|
89
|
+
host: str
|
|
90
|
+
port: int
|
|
91
|
+
width: int
|
|
92
|
+
height: int
|
|
93
|
+
stream_type: str
|
|
94
|
+
connect_timeout: float
|
|
95
|
+
stall_timeout: float
|
|
96
|
+
max_frames: int
|
|
97
|
+
duration: float
|
|
98
|
+
snapshot_dir: str
|
|
99
|
+
frame_log: bool
|
|
100
|
+
network_info: bool
|
|
101
|
+
headless: bool
|
|
102
|
+
|
|
103
|
+
def service_kwargs(self) -> dict[str, Any]:
|
|
104
|
+
"""``StreamService`` 생성자 인자로 변환한다."""
|
|
105
|
+
return {
|
|
106
|
+
"host": self.host,
|
|
107
|
+
"port": self.port,
|
|
108
|
+
"width": self.width,
|
|
109
|
+
"height": self.height,
|
|
110
|
+
"stream_type": self.stream_type,
|
|
111
|
+
"connect_timeout": self.connect_timeout,
|
|
112
|
+
"stall_timeout": self.stall_timeout,
|
|
113
|
+
"max_frames": self.max_frames,
|
|
114
|
+
"duration": self.duration,
|
|
115
|
+
"snapshot_dir": self.snapshot_dir,
|
|
116
|
+
"is_frame": self.frame_log,
|
|
117
|
+
"show_network_info": self.network_info,
|
|
118
|
+
"headless": self.headless,
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass(frozen=True)
|
|
123
|
+
class OutputOptions:
|
|
124
|
+
"""출력 관련 해석된 옵션."""
|
|
125
|
+
|
|
126
|
+
json: bool
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# -- 값 해석 헬퍼 ---------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _resolve(
|
|
133
|
+
config: Any,
|
|
134
|
+
flags: Mapping[str, Any],
|
|
135
|
+
*,
|
|
136
|
+
flag: str,
|
|
137
|
+
key: str,
|
|
138
|
+
default: Any,
|
|
139
|
+
) -> Any:
|
|
140
|
+
"""플래그가 명시되면 플래그를, 아니면 설정을, 아니면 기본값을 반환한다.
|
|
141
|
+
|
|
142
|
+
설정에 명시적 ``null``이 있으면 기본값으로 본다.
|
|
143
|
+
"""
|
|
144
|
+
value = flags.get(flag)
|
|
145
|
+
if value is not None:
|
|
146
|
+
return value
|
|
147
|
+
if config is not None:
|
|
148
|
+
found = config.get(key, default)
|
|
149
|
+
if found is not None:
|
|
150
|
+
return found
|
|
151
|
+
return default
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _as_int(value: Any, default: int) -> int:
|
|
155
|
+
"""설정/플래그 값을 정수로 변환한다. 변환 실패 시 기본값을 쓴다."""
|
|
156
|
+
if isinstance(value, bool):
|
|
157
|
+
return default
|
|
158
|
+
try:
|
|
159
|
+
return int(value)
|
|
160
|
+
except (TypeError, ValueError):
|
|
161
|
+
return default
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _as_float(value: Any, default: float) -> float:
|
|
165
|
+
"""설정/플래그 값을 실수로 변환한다. 변환 실패 시 기본값을 쓴다."""
|
|
166
|
+
if isinstance(value, bool):
|
|
167
|
+
return default
|
|
168
|
+
try:
|
|
169
|
+
return float(value)
|
|
170
|
+
except (TypeError, ValueError):
|
|
171
|
+
return default
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _as_bool(value: Any, default: bool) -> bool:
|
|
175
|
+
"""설정/플래그 값을 불리언으로 변환한다.
|
|
176
|
+
|
|
177
|
+
``bool("false") == True``이므로 문자열/정수를 명시적으로 해석한다.
|
|
178
|
+
해석할 수 없으면 기본값을 쓴다.
|
|
179
|
+
"""
|
|
180
|
+
if isinstance(value, bool):
|
|
181
|
+
return value
|
|
182
|
+
if isinstance(value, int):
|
|
183
|
+
return bool(value) if value in (0, 1) else default
|
|
184
|
+
if isinstance(value, str):
|
|
185
|
+
normalized = value.strip().lower()
|
|
186
|
+
if normalized in _TRUE_STRINGS:
|
|
187
|
+
return True
|
|
188
|
+
if normalized in _FALSE_STRINGS:
|
|
189
|
+
return False
|
|
190
|
+
return default
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _int_in_range(
|
|
194
|
+
value: Any,
|
|
195
|
+
*,
|
|
196
|
+
default: int,
|
|
197
|
+
minimum: int,
|
|
198
|
+
maximum: int,
|
|
199
|
+
field: str,
|
|
200
|
+
) -> int:
|
|
201
|
+
number = _as_int(value, default)
|
|
202
|
+
if not minimum <= number <= maximum:
|
|
203
|
+
raise StreamConfigError(
|
|
204
|
+
f"{field} 값이 허용 범위를 벗어났습니다: {number} (허용 범위 {minimum}~{maximum})"
|
|
205
|
+
)
|
|
206
|
+
return number
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _float_in_range(
|
|
210
|
+
value: Any,
|
|
211
|
+
*,
|
|
212
|
+
default: float,
|
|
213
|
+
minimum: float,
|
|
214
|
+
maximum: float,
|
|
215
|
+
field: str,
|
|
216
|
+
) -> float:
|
|
217
|
+
number = _as_float(value, default)
|
|
218
|
+
if not minimum <= number <= maximum:
|
|
219
|
+
raise StreamConfigError(
|
|
220
|
+
f"{field} 값이 허용 범위를 벗어났습니다: {number} (허용 범위 {minimum}~{maximum})"
|
|
221
|
+
)
|
|
222
|
+
return number
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _choice(value: Any, allowed: tuple[str, ...], field: str) -> str:
|
|
226
|
+
text = str(value).strip().lower()
|
|
227
|
+
if text not in allowed:
|
|
228
|
+
raise StreamConfigError(
|
|
229
|
+
f"{field} 값이 올바르지 않습니다: {value!r} (허용: {', '.join(allowed)})"
|
|
230
|
+
)
|
|
231
|
+
return text
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _non_empty(value: Any, field: str) -> str:
|
|
235
|
+
text = str(value).strip()
|
|
236
|
+
if not text:
|
|
237
|
+
raise StreamConfigError(f"{field} 값이 비어 있습니다.")
|
|
238
|
+
return text
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def resolve_stream_options(
|
|
242
|
+
config: Any,
|
|
243
|
+
flags: Mapping[str, Any],
|
|
244
|
+
) -> StreamOptions:
|
|
245
|
+
"""플래그·설정·기본값을 병합해 ``StreamOptions``를 만든다.
|
|
246
|
+
|
|
247
|
+
Raises:
|
|
248
|
+
StreamConfigError: 해석된 값이 허용 범위/허용 목록을 벗어난 경우.
|
|
249
|
+
"""
|
|
250
|
+
section = "stream"
|
|
251
|
+
defaults = DEFAULTS[section]
|
|
252
|
+
|
|
253
|
+
def value(flag: str | None, key: str) -> Any:
|
|
254
|
+
return _resolve(
|
|
255
|
+
config,
|
|
256
|
+
flags,
|
|
257
|
+
flag=flag or key.replace("_", "-"),
|
|
258
|
+
key=f"{section}.{key}",
|
|
259
|
+
default=defaults[key],
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
return StreamOptions(
|
|
263
|
+
host=_non_empty(value("host", "host"), f"{section}.host"),
|
|
264
|
+
port=_int_in_range(
|
|
265
|
+
value("port", "port"),
|
|
266
|
+
default=defaults["port"],
|
|
267
|
+
minimum=MIN_PORT,
|
|
268
|
+
maximum=MAX_PORT,
|
|
269
|
+
field=f"{section}.port",
|
|
270
|
+
),
|
|
271
|
+
width=_int_in_range(
|
|
272
|
+
value("width", "width"),
|
|
273
|
+
default=defaults["width"],
|
|
274
|
+
minimum=MIN_DIMENSION,
|
|
275
|
+
maximum=MAX_DIMENSION,
|
|
276
|
+
field=f"{section}.width",
|
|
277
|
+
),
|
|
278
|
+
height=_int_in_range(
|
|
279
|
+
value("height", "height"),
|
|
280
|
+
default=defaults["height"],
|
|
281
|
+
minimum=MIN_DIMENSION,
|
|
282
|
+
maximum=MAX_DIMENSION,
|
|
283
|
+
field=f"{section}.height",
|
|
284
|
+
),
|
|
285
|
+
stream_type=_choice(
|
|
286
|
+
value("stream-type", "type"),
|
|
287
|
+
STREAM_TYPES,
|
|
288
|
+
f"{section}.type",
|
|
289
|
+
),
|
|
290
|
+
connect_timeout=_float_in_range(
|
|
291
|
+
value("connect-timeout", "connect_timeout"),
|
|
292
|
+
default=defaults["connect_timeout"],
|
|
293
|
+
minimum=0.0,
|
|
294
|
+
maximum=MAX_TIMEOUT_SECONDS,
|
|
295
|
+
field=f"{section}.connect_timeout",
|
|
296
|
+
),
|
|
297
|
+
stall_timeout=_float_in_range(
|
|
298
|
+
value("stall-timeout", "stall_timeout"),
|
|
299
|
+
default=defaults["stall_timeout"],
|
|
300
|
+
minimum=0.0,
|
|
301
|
+
maximum=MAX_TIMEOUT_SECONDS,
|
|
302
|
+
field=f"{section}.stall_timeout",
|
|
303
|
+
),
|
|
304
|
+
max_frames=_int_in_range(
|
|
305
|
+
value("max-frames", "max_frames"),
|
|
306
|
+
default=defaults["max_frames"],
|
|
307
|
+
minimum=0,
|
|
308
|
+
maximum=MAX_FRAMES_LIMIT,
|
|
309
|
+
field=f"{section}.max_frames",
|
|
310
|
+
),
|
|
311
|
+
duration=_float_in_range(
|
|
312
|
+
value("duration", "duration"),
|
|
313
|
+
default=defaults["duration"],
|
|
314
|
+
minimum=0.0,
|
|
315
|
+
maximum=MAX_DURATION_SECONDS,
|
|
316
|
+
field=f"{section}.duration",
|
|
317
|
+
),
|
|
318
|
+
snapshot_dir=_non_empty(
|
|
319
|
+
value("snapshot-dir", "snapshot_dir"),
|
|
320
|
+
f"{section}.snapshot_dir",
|
|
321
|
+
),
|
|
322
|
+
frame_log=_as_bool(value("frame-log", "frame_log"), defaults["frame_log"]),
|
|
323
|
+
network_info=_as_bool(
|
|
324
|
+
value("network-info", "network_info"),
|
|
325
|
+
defaults["network_info"],
|
|
326
|
+
),
|
|
327
|
+
headless=_as_bool(value("headless", "headless"), defaults["headless"]),
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def resolve_output_options(
|
|
332
|
+
config: Any,
|
|
333
|
+
flags: Mapping[str, Any],
|
|
334
|
+
) -> OutputOptions:
|
|
335
|
+
"""출력 관련 플래그·설정·기본값을 병합한다."""
|
|
336
|
+
defaults = DEFAULTS["output"]
|
|
337
|
+
return OutputOptions(
|
|
338
|
+
json=_as_bool(
|
|
339
|
+
_resolve(
|
|
340
|
+
config,
|
|
341
|
+
flags,
|
|
342
|
+
flag="json",
|
|
343
|
+
key="output.json",
|
|
344
|
+
default=defaults["json"],
|
|
345
|
+
),
|
|
346
|
+
defaults["json"],
|
|
347
|
+
)
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def load_viewer_config(
|
|
352
|
+
*,
|
|
353
|
+
files: tuple[str, ...] = (),
|
|
354
|
+
dotenv: str | None = ".env",
|
|
355
|
+
) -> Any:
|
|
356
|
+
"""wpyconf ``Config``를 직접 로드한다(라이브러리 사용 시)."""
|
|
357
|
+
from wconfig import load_config
|
|
358
|
+
|
|
359
|
+
return load_config(
|
|
360
|
+
defaults=DEFAULTS,
|
|
361
|
+
files=files,
|
|
362
|
+
dotenv=dotenv,
|
|
363
|
+
env_prefix=ENV_PREFIX,
|
|
364
|
+
env_nested_delimiter="__",
|
|
365
|
+
)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""단순 스트림 표시 서비스."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from numpy.typing import NDArray
|
|
9
|
+
|
|
10
|
+
from ros_stream_viewer.services.viewer_service import BaseStreamViewerService
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class StreamService(BaseStreamViewerService):
|
|
14
|
+
"""수신한 프레임을 가공 없이 화면에 표시한다.
|
|
15
|
+
|
|
16
|
+
옵션은 ``StreamOptions.service_kwargs()``가 만든 키를 그대로 받아
|
|
17
|
+
:class:`BaseStreamViewerService`로 전달한다(시그니처 중복/불일치 방지).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
logger: logging.Logger | None = None,
|
|
23
|
+
**options: Any,
|
|
24
|
+
) -> None:
|
|
25
|
+
super().__init__(logger=logger, **options)
|
|
26
|
+
|
|
27
|
+
def _process_frame(self, frame: NDArray) -> NDArray:
|
|
28
|
+
return frame
|
|
29
|
+
|
|
30
|
+
def _get_window_name(self) -> str:
|
|
31
|
+
return "frame"
|