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.
@@ -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,3 @@
1
+ from .stream_service import StreamService
2
+
3
+ __all__ = ["StreamService"]
@@ -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"