antioch-py 2.0.6__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.

Potentially problematic release.


This version of antioch-py might be problematic. Click here for more details.

Files changed (99) hide show
  1. antioch/__init__.py +0 -0
  2. antioch/message.py +87 -0
  3. antioch/module/__init__.py +53 -0
  4. antioch/module/clock.py +62 -0
  5. antioch/module/execution.py +278 -0
  6. antioch/module/input.py +127 -0
  7. antioch/module/module.py +218 -0
  8. antioch/module/node.py +357 -0
  9. antioch/module/token.py +42 -0
  10. antioch/session/__init__.py +150 -0
  11. antioch/session/ark.py +504 -0
  12. antioch/session/asset.py +65 -0
  13. antioch/session/error.py +80 -0
  14. antioch/session/record.py +158 -0
  15. antioch/session/scene.py +1521 -0
  16. antioch/session/session.py +220 -0
  17. antioch/session/task.py +323 -0
  18. antioch/session/views/__init__.py +40 -0
  19. antioch/session/views/animation.py +189 -0
  20. antioch/session/views/articulation.py +245 -0
  21. antioch/session/views/basis_curve.py +186 -0
  22. antioch/session/views/camera.py +92 -0
  23. antioch/session/views/collision.py +75 -0
  24. antioch/session/views/geometry.py +74 -0
  25. antioch/session/views/ground_plane.py +63 -0
  26. antioch/session/views/imu.py +73 -0
  27. antioch/session/views/joint.py +64 -0
  28. antioch/session/views/light.py +175 -0
  29. antioch/session/views/pir_sensor.py +140 -0
  30. antioch/session/views/radar.py +73 -0
  31. antioch/session/views/rigid_body.py +282 -0
  32. antioch/session/views/xform.py +119 -0
  33. antioch_py-2.0.6.dist-info/METADATA +115 -0
  34. antioch_py-2.0.6.dist-info/RECORD +99 -0
  35. antioch_py-2.0.6.dist-info/WHEEL +5 -0
  36. antioch_py-2.0.6.dist-info/entry_points.txt +2 -0
  37. antioch_py-2.0.6.dist-info/top_level.txt +2 -0
  38. common/__init__.py +0 -0
  39. common/ark/__init__.py +60 -0
  40. common/ark/ark.py +128 -0
  41. common/ark/hardware.py +121 -0
  42. common/ark/kinematics.py +31 -0
  43. common/ark/module.py +85 -0
  44. common/ark/node.py +94 -0
  45. common/ark/scheduler.py +439 -0
  46. common/ark/sim.py +33 -0
  47. common/assets/__init__.py +3 -0
  48. common/constants.py +47 -0
  49. common/core/__init__.py +52 -0
  50. common/core/agent.py +296 -0
  51. common/core/auth.py +305 -0
  52. common/core/registry.py +331 -0
  53. common/core/task.py +36 -0
  54. common/message/__init__.py +59 -0
  55. common/message/annotation.py +89 -0
  56. common/message/array.py +500 -0
  57. common/message/base.py +517 -0
  58. common/message/camera.py +91 -0
  59. common/message/color.py +139 -0
  60. common/message/frame.py +50 -0
  61. common/message/image.py +171 -0
  62. common/message/imu.py +14 -0
  63. common/message/joint.py +47 -0
  64. common/message/log.py +31 -0
  65. common/message/pir.py +16 -0
  66. common/message/point.py +109 -0
  67. common/message/point_cloud.py +63 -0
  68. common/message/pose.py +148 -0
  69. common/message/quaternion.py +273 -0
  70. common/message/radar.py +58 -0
  71. common/message/types.py +37 -0
  72. common/message/vector.py +786 -0
  73. common/rome/__init__.py +9 -0
  74. common/rome/client.py +430 -0
  75. common/rome/error.py +16 -0
  76. common/session/__init__.py +54 -0
  77. common/session/environment.py +31 -0
  78. common/session/sim.py +240 -0
  79. common/session/views/__init__.py +263 -0
  80. common/session/views/animation.py +73 -0
  81. common/session/views/articulation.py +184 -0
  82. common/session/views/basis_curve.py +102 -0
  83. common/session/views/camera.py +147 -0
  84. common/session/views/collision.py +59 -0
  85. common/session/views/geometry.py +102 -0
  86. common/session/views/ground_plane.py +41 -0
  87. common/session/views/imu.py +66 -0
  88. common/session/views/joint.py +81 -0
  89. common/session/views/light.py +96 -0
  90. common/session/views/pir_sensor.py +115 -0
  91. common/session/views/radar.py +82 -0
  92. common/session/views/rigid_body.py +236 -0
  93. common/session/views/viewport.py +21 -0
  94. common/session/views/xform.py +39 -0
  95. common/utils/__init__.py +4 -0
  96. common/utils/comms.py +571 -0
  97. common/utils/logger.py +123 -0
  98. common/utils/time.py +42 -0
  99. common/utils/usd.py +12 -0
common/message/base.py ADDED
@@ -0,0 +1,517 @@
1
+ import json
2
+ import re
3
+ from abc import ABC
4
+ from typing import Any, ClassVar, TypeVar
5
+
6
+ import ormsgpack
7
+ import yaml
8
+ from pydantic import BaseModel, ValidationError
9
+
10
+ T = TypeVar("T", bound="Message")
11
+
12
+
13
+ class MessageError(Exception):
14
+ """
15
+ Base exception for Message errors.
16
+ """
17
+
18
+
19
+ class SerializationError(MessageError):
20
+ """
21
+ Raised when serialization (pack, to_json) fails.
22
+ """
23
+
24
+
25
+ class DeserializationError(MessageError):
26
+ """
27
+ Raised when deserialization (unpack, from_json) fails.
28
+ """
29
+
30
+
31
+ class FileAccessError(MessageError):
32
+ """
33
+ Raised when file operations fail.
34
+ """
35
+
36
+
37
+ class MismatchError(MessageError):
38
+ """
39
+ Raised when type doesn't match expected type or deserialization fails.
40
+ """
41
+
42
+ def __init__(self, expected: str | None, actual: str | None, details: str | None = None):
43
+ self.expected = expected
44
+ self.actual = actual
45
+ self.details = details
46
+
47
+ expected_str = "<untyped>" if expected is None else f"'{expected}'"
48
+ actual_str = "<untyped>" if actual is None else f"'{actual}'"
49
+ message = f"Type mismatch: expected {expected_str}, got {actual_str}"
50
+ if details is not None:
51
+ message += f" - {details}"
52
+
53
+ super().__init__(message)
54
+
55
+
56
+ class Message(BaseModel, ABC):
57
+ """
58
+ Base class for user-defined data types supporting serialization and deserialization.
59
+
60
+ To add a type identifier, simply set a '_type' class variable. Otherwise, the message will
61
+ have type=null in the serialized format.
62
+
63
+ ```python
64
+ class MyMessage(Message):
65
+ _type = "my_message"
66
+ field1: str
67
+ field2: int
68
+ ```
69
+ """
70
+
71
+ _type: ClassVar[str | None] = None
72
+
73
+ model_config = {
74
+ "extra": "forbid",
75
+ "frozen": False,
76
+ "arbitrary_types_allowed": True,
77
+ }
78
+
79
+ def __init_subclass__(cls, **kwargs):
80
+ """
81
+ Validate type format when subclass is defined.
82
+ """
83
+
84
+ super().__init_subclass__(**kwargs)
85
+ if cls._type is not None and not re.compile(r"^[a-z0-9][a-z0-9\-_/]*$").match(cls._type):
86
+ raise ValueError(
87
+ f"Invalid type '{cls._type}' for {cls.__name__} (must be lowercase alphanumeric with hyphens, underscores, or slashes)"
88
+ )
89
+
90
+ def __str__(self) -> str:
91
+ """
92
+ Return a clean string representation of the message.
93
+
94
+ :return: A formatted string showing the message type and field values.
95
+ """
96
+
97
+ # Create key=value pairs for all fields
98
+ field_strs = []
99
+ for field_name, field_value in self:
100
+ formatted_value = f'"{field_value}"' if isinstance(field_value, str) else str(field_value)
101
+ field_strs.append(f"{field_name}={formatted_value}")
102
+
103
+ return f"<{self.__class__.__name__}: {' '.join(field_strs)}>"
104
+
105
+ def __repr__(self) -> str:
106
+ """
107
+ Return a detailed representation that can reconstruct the object.
108
+
109
+ :return: A string representation suitable for debugging.
110
+ """
111
+
112
+ return super().__repr__()
113
+
114
+ def __hash__(self) -> int:
115
+ """
116
+ Make Message hashable for use in sets and as dict keys.
117
+
118
+ :return: Hash value based on class type, type, and field values.
119
+ """
120
+
121
+ field_items = sorted(self.model_dump().items())
122
+ return hash((self.__class__.__name__, self.get_type(), tuple(field_items)))
123
+
124
+ def __eq__(self, other: Any) -> bool:
125
+ """
126
+ Compare messages based on class, type identifier, and field values.
127
+
128
+ :param other: The other object to compare with.
129
+ :return: True if messages are equal, False otherwise.
130
+ """
131
+
132
+ if not isinstance(other, self.__class__):
133
+ return False
134
+ if self.get_type() != other.get_type():
135
+ return False
136
+
137
+ return self.model_dump() == other.model_dump()
138
+
139
+ @classmethod
140
+ def get_type(cls) -> str | None:
141
+ """
142
+ Get the type identifier for this message class.
143
+
144
+ :return: The type string if set, None otherwise.
145
+ """
146
+
147
+ return cls._type
148
+
149
+ @staticmethod
150
+ def pack_json(data: dict[str, Any]) -> bytes:
151
+ """
152
+ Pack an arbitrary dict into a message envelope with null type.
153
+
154
+ This allows flexible telemetry without requiring Message implementations.
155
+ The dict is wrapped in an envelope with type=None and serialized to MessagePack.
156
+
157
+ :param data: Dictionary to pack.
158
+ :return: The MessagePack bytes.
159
+ :raises SerializationError: If serialization fails.
160
+ """
161
+
162
+ try:
163
+ envelope = {"type": None, "data": data}
164
+ return ormsgpack.packb(envelope)
165
+ except Exception as e:
166
+ raise SerializationError(f"Failed to serialize dict: {e}") from None
167
+
168
+ @classmethod
169
+ def unpack(cls: type[T], data: bytes) -> T:
170
+ """
171
+ Deserialize a message from bytes using MessagePack.
172
+
173
+ This validates that the type in the envelope matches the expected type
174
+ for this class.
175
+
176
+ :param data: The MessagePack bytes to deserialize.
177
+ :return: The deserialized message.
178
+ :raises DeserializationError: If deserialization fails.
179
+ :raises MismatchError: If the type doesn't match.
180
+ """
181
+
182
+ try:
183
+ envelope = ormsgpack.unpackb(data)
184
+ cls._validate_envelope(envelope)
185
+
186
+ # Check type matches
187
+ if envelope["type"] != cls.get_type():
188
+ raise MismatchError(expected=cls.get_type(), actual=envelope["type"])
189
+
190
+ return cls(**envelope["data"])
191
+ except (MismatchError, DeserializationError):
192
+ raise
193
+ except ValidationError as e:
194
+ first_error = e.errors()[0]
195
+ field_path = " -> ".join(str(loc) for loc in first_error["loc"])
196
+ error_msg = first_error["msg"]
197
+ raise DeserializationError(f"Validation failed at {field_path}: {error_msg}") from None
198
+ except Exception as e:
199
+ if "msgpack" in str(e).lower() or "unpackb" in str(e):
200
+ raise DeserializationError("Failed to deserialize message") from None
201
+ raise DeserializationError(f"Failed to deserialize message: {e}") from None
202
+
203
+ @classmethod
204
+ def from_json(cls: type[T], json_str: str) -> T:
205
+ """
206
+ Deserialize a message from a JSON string.
207
+
208
+ This validates that the type in the envelope matches the expected type.
209
+
210
+ :param json_str: The JSON string to deserialize.
211
+ :return: The deserialized message.
212
+ :raises DeserializationError: If deserialization fails or JSON is invalid.
213
+ :raises MismatchError: If the type doesn't match.
214
+ """
215
+
216
+ try:
217
+ envelope = json.loads(json_str)
218
+ cls._validate_envelope(envelope)
219
+
220
+ # Check type matches
221
+ if envelope["type"] != cls.get_type():
222
+ raise MismatchError(expected=cls.get_type(), actual=envelope["type"])
223
+
224
+ return cls(**envelope["data"])
225
+ except json.JSONDecodeError:
226
+ raise DeserializationError("Invalid JSON format") from None
227
+ except (MismatchError, DeserializationError):
228
+ raise
229
+ except ValidationError as e:
230
+ first_error = e.errors()[0]
231
+ field_path = " -> ".join(str(loc) for loc in first_error["loc"])
232
+ error_msg = first_error["msg"]
233
+ raise DeserializationError(f"Validation failed at {field_path}: {error_msg}") from None
234
+ except Exception as e:
235
+ raise DeserializationError(f"Failed to deserialize from JSON: {e}") from None
236
+
237
+ @classmethod
238
+ def from_yaml(cls: type[T], yaml_str: str) -> T:
239
+ """
240
+ Deserialize a message from a YAML string.
241
+
242
+ This validates that the type in the envelope matches the expected type.
243
+
244
+ :param yaml_str: The YAML string to deserialize.
245
+ :return: The deserialized message.
246
+ :raises DeserializationError: If deserialization fails or YAML is invalid.
247
+ :raises MismatchError: If the type doesn't match.
248
+ """
249
+
250
+ try:
251
+ envelope = yaml.safe_load(yaml_str)
252
+ cls._validate_envelope(envelope)
253
+
254
+ # Check type matches
255
+ if envelope["type"] != cls.get_type():
256
+ raise MismatchError(expected=cls.get_type(), actual=envelope["type"])
257
+
258
+ return cls(**envelope["data"])
259
+ except yaml.YAMLError as e:
260
+ raise DeserializationError(f"Invalid YAML format: {e}") from None
261
+ except (MismatchError, DeserializationError):
262
+ raise
263
+ except ValidationError as e:
264
+ first_error = e.errors()[0]
265
+ field_path = " -> ".join(str(loc) for loc in first_error["loc"])
266
+ error_msg = first_error["msg"]
267
+ raise DeserializationError(f"Validation failed at {field_path}: {error_msg}") from None
268
+ except Exception as e:
269
+ raise DeserializationError(f"Failed to deserialize from YAML: {e}") from None
270
+
271
+ @classmethod
272
+ def load(cls: type[T], file_path: str, format: str | None = None) -> T:
273
+ """
274
+ Load a message from a file.
275
+
276
+ The format is determined by the file extension if not specified.
277
+ Supported formats: json, yaml, msgpack
278
+
279
+ :param file_path: Path to load the file from.
280
+ :param format: Optional format override ('json', 'yaml', 'msgpack').
281
+ :return: The loaded message.
282
+ :raises FileAccessError: If the file cannot be read.
283
+ :raises DeserializationError: If deserialization fails.
284
+ :raises MismatchError: If the type doesn't match.
285
+ """
286
+
287
+ # Determine format from extension if not specified
288
+ if format is None:
289
+ if file_path.endswith(".json"):
290
+ format = "json"
291
+ elif file_path.endswith((".yaml", ".yml")):
292
+ format = "yaml"
293
+ elif file_path.endswith((".msgpack", ".mp")):
294
+ format = "msgpack"
295
+ else:
296
+ raise FileAccessError(f"Cannot determine format from extension: {file_path}")
297
+
298
+ try:
299
+ # Read and deserialize based on format
300
+ if format == "json":
301
+ with open(file_path) as f:
302
+ return cls.from_json(f.read())
303
+ elif format == "yaml":
304
+ with open(file_path) as f:
305
+ return cls.from_yaml(f.read())
306
+ elif format == "msgpack":
307
+ with open(file_path, "rb") as f:
308
+ return cls.unpack(f.read())
309
+ else:
310
+ raise FileAccessError(f"Unsupported format: {format}")
311
+ except (DeserializationError, MismatchError):
312
+ raise
313
+ except FileNotFoundError:
314
+ raise FileAccessError(f"File not found: {file_path}") from None
315
+ except Exception as e:
316
+ raise FileAccessError(f"Failed to load from {file_path}: {e}") from None
317
+
318
+ @staticmethod
319
+ def extract_type(data: bytes) -> str | None:
320
+ """
321
+ Extract the type identifier from a serialized message without full deserialization.
322
+
323
+ This is useful for dynamic message routing where you need to determine the message
324
+ type before deciding how to deserialize it.
325
+
326
+ :param data: The MessagePack bytes to extract type from.
327
+ :return: The type string, or None if no type is set.
328
+ :raises DeserializationError: If the data cannot be parsed.
329
+
330
+ Example:
331
+ ```python
332
+ packed = some_message.pack()
333
+ msg_type = Message.extract_type(packed)
334
+ match msg_type:
335
+ case "user_profile":
336
+ profile = UserProfile.unpack(packed)
337
+ case "order_update":
338
+ order = OrderUpdate.unpack(packed)
339
+ case _:
340
+ raise ValueError(f"Unknown message type: {msg_type}")
341
+ ```
342
+ """
343
+
344
+ try:
345
+ envelope = ormsgpack.unpackb(data)
346
+ Message._validate_envelope(envelope)
347
+ return envelope["type"]
348
+ except (ValueError, TypeError) as e:
349
+ raise DeserializationError(f"Failed to extract message type: {e}") from None
350
+ except RecursionError:
351
+ raise DeserializationError("Message structure too deeply nested") from None
352
+
353
+ @staticmethod
354
+ def extract_type_from_json(json_str: str) -> str | None:
355
+ """
356
+ Extract the type identifier from a JSON string without full deserialization.
357
+
358
+ This is the JSON equivalent of extract_type for when messages are
359
+ serialized as JSON instead of MessagePack.
360
+
361
+ :param json_str: The JSON string to extract type from.
362
+ :return: The type string, or None if no type is set.
363
+ :raises DeserializationError: If the JSON cannot be parsed.
364
+ """
365
+
366
+ try:
367
+ data = json.loads(json_str)
368
+ Message._validate_envelope(data)
369
+ return data["type"]
370
+ except json.JSONDecodeError:
371
+ raise DeserializationError("Invalid JSON format") from None
372
+
373
+ @staticmethod
374
+ def extract_data_as_json(data: bytes) -> dict:
375
+ """
376
+ Extract the data field from a serialized message as a dictionary.
377
+
378
+ Useful for generic message handling where the exact type is unknown.
379
+
380
+ :param data: The MessagePack bytes to extract data from.
381
+ :return: The data field as a dictionary.
382
+ :raises DeserializationError: If the data cannot be parsed.
383
+ """
384
+
385
+ try:
386
+ envelope = ormsgpack.unpackb(data)
387
+ Message._validate_envelope(envelope)
388
+ return envelope["data"]
389
+ except Exception as e:
390
+ raise DeserializationError(f"Failed to extract message data: {e}") from None
391
+
392
+ def pack(self) -> bytes:
393
+ """
394
+ Serialize the message to bytes using MessagePack.
395
+
396
+ The message is wrapped in an envelope with the type identifier before
397
+ serialization to match the Rust format.
398
+
399
+ :return: The MessagePack bytes.
400
+ :raises SerializationError: If serialization fails.
401
+ """
402
+
403
+ try:
404
+ envelope = {
405
+ "type": self.get_type(),
406
+ "data": self.model_dump(mode="python", by_alias=True),
407
+ }
408
+ return ormsgpack.packb(envelope)
409
+ except Exception as e:
410
+ raise SerializationError(f"Failed to serialize message: {e}") from None
411
+
412
+ def to_json(self, indent: int | None = None) -> str:
413
+ """
414
+ Serialize the message to a JSON string.
415
+
416
+ The message is wrapped in an envelope with the type identifier.
417
+
418
+ :param indent: Number of spaces to indent for pretty printing.
419
+ :return: The JSON string.
420
+ :raises SerializationError: If serialization fails.
421
+ """
422
+
423
+ try:
424
+ envelope = {
425
+ "type": self.get_type(),
426
+ "data": self.model_dump(mode="python", by_alias=True),
427
+ }
428
+ return json.dumps(envelope, indent=indent)
429
+ except Exception as e:
430
+ raise SerializationError(f"Failed to serialize to JSON: {e}") from e
431
+
432
+ def to_yaml(self) -> str:
433
+ """
434
+ Serialize the message to a YAML string.
435
+
436
+ The message is wrapped in an envelope with the type identifier.
437
+
438
+ :return: The YAML string.
439
+ :raises SerializationError: If serialization fails.
440
+ """
441
+
442
+ try:
443
+ return yaml.dump(self.to_dict(), default_flow_style=False, sort_keys=False)
444
+ except Exception as e:
445
+ raise SerializationError(f"Failed to serialize to YAML: {e}") from None
446
+
447
+ def to_dict(self) -> dict[str, Any]:
448
+ """
449
+ Serialize with standard envelope structure.
450
+
451
+ :return: The serialized object.
452
+ """
453
+
454
+ return {
455
+ "type": self.get_type(),
456
+ "data": self.model_dump(mode="python", by_alias=True),
457
+ }
458
+
459
+ def save(self, file_path: str, format: str | None = None) -> None:
460
+ """
461
+ Save the message to a file.
462
+
463
+ The format is determined by the file extension if not specified.
464
+ Supported formats: json, yaml, msgpack
465
+
466
+ :param file_path: Path to save the file to.
467
+ :param format: Optional format override ('json', 'yaml', 'msgpack').
468
+ :raises FileAccessError: If the file cannot be written.
469
+ :raises SerializationError: If serialization fails.
470
+ """
471
+
472
+ # Determine format from extension if not specified
473
+ if format is None:
474
+ if file_path.endswith(".json"):
475
+ format = "json"
476
+ elif file_path.endswith((".yaml", ".yml")):
477
+ format = "yaml"
478
+ elif file_path.endswith((".msgpack", ".mp")):
479
+ format = "msgpack"
480
+ else:
481
+ raise FileAccessError(f"Cannot determine format from extension: {file_path}")
482
+
483
+ try:
484
+ # Serialize based on format
485
+ if format == "json":
486
+ content = self.to_json(indent=2)
487
+ mode = "w"
488
+ elif format == "yaml":
489
+ content = self.to_yaml()
490
+ mode = "w"
491
+ elif format == "msgpack":
492
+ content = self.pack()
493
+ mode = "wb"
494
+ else:
495
+ raise FileAccessError(f"Unsupported format: {format}")
496
+
497
+ # Write to file
498
+ with open(file_path, mode) as f:
499
+ f.write(content)
500
+ except SerializationError:
501
+ raise
502
+ except Exception as e:
503
+ raise FileAccessError(f"Failed to save to {file_path}: {e}") from None
504
+
505
+ @staticmethod
506
+ def _validate_envelope(envelope: Any) -> None:
507
+ """
508
+ Validate envelope structure.
509
+
510
+ :param envelope: The envelope to validate.
511
+ :raises DeserializationError: If envelope structure is invalid.
512
+ """
513
+
514
+ if not isinstance(envelope, dict):
515
+ raise DeserializationError("Invalid message format: expected dictionary")
516
+ if "type" not in envelope or "data" not in envelope:
517
+ raise DeserializationError("Invalid message format: missing 'type' or 'data' field")
@@ -0,0 +1,91 @@
1
+ import numpy as np
2
+ from pydantic import Field
3
+
4
+ from common.message.base import Message
5
+
6
+
7
+ class CameraInfo(Message):
8
+ """
9
+ Complete camera information including intrinsics, distortion, and projection.
10
+
11
+ Follows standard camera calibration conventions with support for various
12
+ distortion models and projection operations.
13
+ """
14
+
15
+ _type = "antioch/camera_info"
16
+
17
+ # Image dimensions
18
+ width: int = Field(description="Image width in pixels")
19
+ height: int = Field(description="Image height in pixels")
20
+
21
+ # Intrinsic parameters
22
+ fx: float = Field(description="Focal length in x (pixels)")
23
+ fy: float = Field(description="Focal length in y (pixels)")
24
+ cx: float = Field(description="Principal point x (pixels)")
25
+ cy: float = Field(description="Principal point y (pixels)")
26
+
27
+ # Distortion model and coefficients
28
+ distortion_model: str = Field(default="pinhole", description="Distortion model name")
29
+ distortion_coefficients: list[float] = Field(default_factory=list, description="Distortion coefficients")
30
+
31
+ # Frame information
32
+ frame_id: str = Field(default="camera_optical_frame", description="Camera coordinate frame")
33
+
34
+ @property
35
+ def intrinsics_matrix(self) -> np.ndarray:
36
+ """
37
+ Get the 3x3 camera intrinsics matrix K.
38
+
39
+ Returns:
40
+ [[fx, 0, cx],
41
+ [ 0, fy, cy],
42
+ [ 0, 0, 1]]
43
+ """
44
+
45
+ return np.array([[self.fx, 0.0, self.cx], [0.0, self.fy, self.cy], [0.0, 0.0, 1.0]])
46
+
47
+ def unproject_pixel(self, u: float, v: float, depth: float) -> np.ndarray:
48
+ """
49
+ Unproject a pixel coordinate to 3D point using the camera intrinsics.
50
+
51
+ Note: This assumes no distortion. For distorted images, undistort first.
52
+
53
+ :param u: Pixel x-coordinate.
54
+ :param v: Pixel y-coordinate.
55
+ :param depth: Depth value at the pixel in meters.
56
+ :return: 3D point [x, y, z] in camera frame.
57
+ """
58
+
59
+ x = (u - self.cx) * depth / self.fx
60
+ y = (v - self.cy) * depth / self.fy
61
+ return np.array([x, y, depth])
62
+
63
+ def project_point(self, point: np.ndarray) -> tuple[float, float]:
64
+ """
65
+ Project a 3D point to pixel coordinates.
66
+
67
+ Note: This assumes no distortion. For accurate projection with distortion,
68
+ additional processing is required.
69
+
70
+ :param point: 3D point [x, y, z] in camera frame.
71
+ :return: Pixel coordinates (u, v).
72
+ :raises ValueError: If point is behind camera.
73
+ """
74
+
75
+ if point[2] <= 0:
76
+ raise ValueError("Point must be in front of camera (z > 0)")
77
+
78
+ u = self.fx * point[0] / point[2] + self.cx
79
+ v = self.fy * point[1] / point[2] + self.cy
80
+ return (u, v)
81
+
82
+ def is_point_visible(self, u: float, v: float) -> bool:
83
+ """
84
+ Check if a pixel coordinate is within the image bounds.
85
+
86
+ :param u: Pixel x-coordinate.
87
+ :param v: Pixel y-coordinate.
88
+ :return: True if point is visible in the image.
89
+ """
90
+
91
+ return 0 <= u < self.width and 0 <= v < self.height