1964 lines
75 KiB
Python
1964 lines
75 KiB
Python
#!/usr/bin/env python3
|
||
"""Export Medulla DObject lidar + RTK recordings to sharded NumPy NPZ / pickle.
|
||
|
||
The default NPZ path uses only the Python standard library; NumPy is required
|
||
only when consuming the exported files, not while exporting them.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import array
|
||
import bisect
|
||
import csv
|
||
import io
|
||
import json
|
||
import math
|
||
import os
|
||
import pickle
|
||
import re
|
||
import struct
|
||
import sys
|
||
import time
|
||
import zipfile
|
||
from contextlib import nullcontext
|
||
from dataclasses import asdict, dataclass
|
||
from datetime import datetime, timedelta, timezone
|
||
from pathlib import Path
|
||
from typing import BinaryIO, Iterable, Iterator, Sequence
|
||
|
||
|
||
RECORD_RE = re.compile(
|
||
r"^\[(?P<log_time>[^]]+)\].*?DObject `(?P<name>[^`]+)` post "
|
||
r"len=(?P<len>\d+)B, id:(?P<id>[0-9A-Fa-f]+), tic:(?P<tic>\d+), "
|
||
r"@(?P<file>[^:]+):(?P<offset>\d+)"
|
||
)
|
||
GPS_TEXT_RE = re.compile(
|
||
r"^\[(?P<log_time>[^]]+)\].*?\$GPS-POST-Z>text\)>(?P<body>.+)$"
|
||
)
|
||
POINT_STRUCT = struct.Struct("<5f")
|
||
DOTNET_UNIX_EPOCH_TICKS = 621355968000000000
|
||
TICKS_PER_SECOND = 10_000_000
|
||
TICKS_PER_DAY = 864_000_000_000
|
||
NS_PER_TICK = 100
|
||
FORMAT_VERSION = "medulla-lidar3d-rtk-v2"
|
||
RTK_OBJECT_NAMES = ("GPS-POST-Z", "rtk", "GPS-POST")
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class RecordRef:
|
||
sequence: int
|
||
object_name: str
|
||
log_time: str
|
||
source_log: str
|
||
source_dorec: str
|
||
source_offset: int
|
||
payload_length: int
|
||
log_record_id: str
|
||
dotnet_ticks: int
|
||
|
||
|
||
@dataclass
|
||
class RtkSample:
|
||
index: int
|
||
source: str
|
||
object_name: str
|
||
dotnet_ticks: int
|
||
timestamp_iso_local: str
|
||
unix_time_ns: int
|
||
log_time: str
|
||
record_id: str
|
||
source_log: str
|
||
source_dorec: str
|
||
source_offset: int
|
||
payload_length: int
|
||
counter: int | None = None
|
||
lat: float | None = None
|
||
lon: float | None = None
|
||
alt_m: float | None = None
|
||
raw_heading_deg: float | None = None
|
||
vehicle_heading_deg: float | None = None
|
||
fix: int | None = None
|
||
sat: int | None = None
|
||
position_valid: bool | None = None
|
||
heading_valid: bool | None = None
|
||
heading_solution: str | None = None
|
||
position_time: str | None = None
|
||
heading_time: str | None = None
|
||
last_line: str | None = None
|
||
device_name: str | None = None
|
||
device_stamp_hex: str | None = None
|
||
raw_text: str | None = None
|
||
parse_error: str | None = None
|
||
|
||
|
||
def parse_args() -> argparse.Namespace:
|
||
parser = argparse.ArgumentParser(
|
||
description=(
|
||
"Export Medulla frontlidar dlog records with matched RTK/GPS-POST-Z "
|
||
"to per-frame NPZ or pickle files."
|
||
)
|
||
)
|
||
parser.add_argument("--dlog", required=True, help="Directory containing dobject/ and dobject_recording/.")
|
||
parser.add_argument("--out", required=True, help="Output dataset directory.")
|
||
parser.add_argument("--object", default="frontlidar", help="Lidar DObject name; default: frontlidar.")
|
||
parser.add_argument("--format", choices=("npz", "pickle"), default="npz")
|
||
parser.add_argument("--timezone", default="+08:00", help="Fixed offset used to interpret DateTime.Now.Ticks.")
|
||
parser.add_argument("--stride", type=int, default=1, help="Export every Nth lidar frame.")
|
||
parser.add_argument("--max-frames", type=int, default=0, help="0 exports all selected frames.")
|
||
parser.add_argument("--resume", action="store_true", help="Keep already exported frame files.")
|
||
parser.add_argument("--compress", action="store_true", help="Use ZIP deflate level 1 for NPZ files.")
|
||
parser.add_argument(
|
||
"--include-xyz",
|
||
choices=("none", "sensor", "cart", "both"),
|
||
default="none",
|
||
help="Optionally generate XYZ arrays. Raw N x 5 data is always exported.",
|
||
)
|
||
parser.add_argument("--x", type=float, default=736.0, help="Lidar X in vehicle frame, mm.")
|
||
parser.add_argument("--y", type=float, default=0.0, help="Lidar Y in vehicle frame, mm.")
|
||
parser.add_argument("--z", type=float, default=0.0, help="Lidar Z in vehicle frame, mm.")
|
||
parser.add_argument("--yaw", type=float, default=0.0, help="Yaw in degrees.")
|
||
parser.add_argument("--pitch", type=float, default=0.0, help="Pitch in degrees.")
|
||
parser.add_argument("--roll", type=float, default=0.0, help="Roll in degrees.")
|
||
parser.add_argument(
|
||
"--rtk-max-dt-ms",
|
||
type=float,
|
||
default=1000.0,
|
||
help=(
|
||
"Fail rtk_time_alignment when an interior frame's nearest |dt| exceeds this many "
|
||
"milliseconds. Edge frames before the first / after the last RTK are reported "
|
||
"separately and do not fail the check."
|
||
),
|
||
)
|
||
parser.add_argument(
|
||
"--skip-rtk",
|
||
action="store_true",
|
||
help="Export lidar only (no per-frame RTK matching).",
|
||
)
|
||
parser.add_argument(
|
||
"--rtk-sidecars",
|
||
action="store_true",
|
||
help=(
|
||
"Also write rtk/ sidecars (gps_post_z / rtk_binary / gps_post / text). "
|
||
"Default is off; per-frame NPZ still embeds matched RTK fields."
|
||
),
|
||
)
|
||
parser.add_argument(
|
||
"--write-reports",
|
||
action="store_true",
|
||
help=(
|
||
"Write audit files under reports/ (metadata.json, manifest.csv, "
|
||
"rtk_match.csv, validation_report.json). Default package only has "
|
||
"frames/ and README.md."
|
||
),
|
||
)
|
||
args = parser.parse_args()
|
||
if args.stride < 1:
|
||
parser.error("--stride must be >= 1")
|
||
if args.max_frames < 0:
|
||
parser.error("--max-frames must be >= 0")
|
||
if args.rtk_max_dt_ms < 0:
|
||
parser.error("--rtk-max-dt-ms must be >= 0")
|
||
if args.skip_rtk and args.rtk_sidecars:
|
||
parser.error("--rtk-sidecars cannot be used with --skip-rtk")
|
||
parse_timezone(args.timezone)
|
||
return args
|
||
|
||
|
||
def resolve_dlog_root(value: str) -> Path:
|
||
root = Path(value).expanduser().resolve()
|
||
if (root / "dobject").is_dir() and (root / "dobject_recording").is_dir():
|
||
return root
|
||
child = root / "dlog"
|
||
if (child / "dobject").is_dir() and (child / "dobject_recording").is_dir():
|
||
return child
|
||
raise FileNotFoundError(f"{root} does not contain dobject and dobject_recording")
|
||
|
||
|
||
def parse_timezone(text: str) -> timezone:
|
||
match = re.fullmatch(r"([+-])(\d{2}):(\d{2})", text.strip())
|
||
if not match:
|
||
raise ValueError(f"invalid timezone offset: {text!r}")
|
||
minutes = int(match.group(2)) * 60 + int(match.group(3))
|
||
if match.group(1) == "-":
|
||
minutes = -minutes
|
||
return timezone(timedelta(minutes=minutes))
|
||
|
||
|
||
def timezone_minutes(tz: timezone) -> int:
|
||
return int(tz.utcoffset(None).total_seconds() // 60)
|
||
|
||
|
||
def dotnet_ticks_to_values(ticks: int, tz: timezone) -> tuple[str, int]:
|
||
days, remainder = divmod(ticks, TICKS_PER_DAY)
|
||
seconds, subsecond_ticks = divmod(remainder, TICKS_PER_SECOND)
|
||
local_dt = datetime(1, 1, 1) + timedelta(days=days, seconds=seconds)
|
||
fraction = f"{subsecond_ticks:07d}"
|
||
offset = tz.utcoffset(None)
|
||
sign = "+" if offset >= timedelta(0) else "-"
|
||
total_minutes = abs(int(offset.total_seconds() // 60))
|
||
iso = f"{local_dt:%Y-%m-%dT%H:%M:%S}.{fraction}{sign}{total_minutes // 60:02d}:{total_minutes % 60:02d}"
|
||
offset_ticks = int(offset.total_seconds()) * TICKS_PER_SECOND
|
||
unix_ns = (ticks - DOTNET_UNIX_EPOCH_TICKS - offset_ticks) * NS_PER_TICK
|
||
return iso, unix_ns
|
||
|
||
|
||
def discover_records(dlog_root: Path, object_name: str) -> list[RecordRef]:
|
||
pending: list[tuple[str, str, str, int, int, str, int, str]] = []
|
||
for log_path in sorted((dlog_root / "dobject").rglob("*.log")):
|
||
relative_log = log_path.relative_to(dlog_root).as_posix()
|
||
with log_path.open("r", encoding="utf-8", errors="replace") as stream:
|
||
for line in stream:
|
||
match = RECORD_RE.search(line)
|
||
if not match or match.group("name").casefold() != object_name.casefold():
|
||
continue
|
||
pending.append(
|
||
(
|
||
match.group("name"),
|
||
match.group("log_time"),
|
||
relative_log,
|
||
int(match.group("offset")),
|
||
int(match.group("len")),
|
||
match.group("id").upper(),
|
||
int(match.group("tic")),
|
||
match.group("file"),
|
||
)
|
||
)
|
||
pending.sort(key=lambda item: (item[6], item[7].casefold(), item[3]))
|
||
seen: set[tuple[str, int, int]] = set()
|
||
records: list[RecordRef] = []
|
||
for item in pending:
|
||
key = (item[7].casefold(), item[3], item[6])
|
||
if key in seen:
|
||
continue
|
||
seen.add(key)
|
||
records.append(
|
||
RecordRef(
|
||
sequence=len(records),
|
||
object_name=item[0],
|
||
log_time=item[1],
|
||
source_log=item[2],
|
||
source_dorec=item[7],
|
||
source_offset=item[3],
|
||
payload_length=item[4],
|
||
log_record_id=item[5],
|
||
dotnet_ticks=item[6],
|
||
)
|
||
)
|
||
return records
|
||
|
||
|
||
def index_dorec_files(dlog_root: Path) -> dict[str, list[Path]]:
|
||
result: dict[str, list[Path]] = {}
|
||
for path in (dlog_root / "dobject_recording").rglob("*.dorec"):
|
||
result.setdefault(path.name.casefold(), []).append(path)
|
||
return result
|
||
|
||
|
||
def choose_dorec(index: dict[str, list[Path]], name: str) -> Path:
|
||
matches = index.get(Path(name).name.casefold(), [])
|
||
if not matches:
|
||
raise FileNotFoundError(f"missing recording file: {name}")
|
||
if len(matches) > 1:
|
||
raise RuntimeError(f"ambiguous recording file {name}: {matches}")
|
||
return matches[0]
|
||
|
||
|
||
def read_exact(stream: BinaryIO, size: int) -> bytes:
|
||
data = stream.read(size)
|
||
if len(data) != size:
|
||
raise EOFError(f"expected {size} bytes, got {len(data)}")
|
||
return data
|
||
|
||
|
||
def read_record(path: Path, record: RecordRef) -> tuple[dict[str, object], bytes]:
|
||
with path.open("rb") as stream:
|
||
stream.seek(record.source_offset)
|
||
name_length = read_exact(stream, 1)[0]
|
||
name = read_exact(stream, name_length).decode("ascii")
|
||
ticks = struct.unpack("<q", read_exact(stream, 8))[0]
|
||
id_length = read_exact(stream, 1)[0]
|
||
id_bytes = read_exact(stream, id_length)
|
||
payload_length = struct.unpack("<i", read_exact(stream, 4))[0]
|
||
payload = read_exact(stream, payload_length)
|
||
|
||
try:
|
||
record_id = id_bytes.decode("ascii")
|
||
except UnicodeDecodeError:
|
||
record_id = id_bytes.hex().upper()
|
||
if name != record.object_name:
|
||
raise ValueError(f"name mismatch: log={record.object_name}, dorec={name}")
|
||
if ticks != record.dotnet_ticks:
|
||
raise ValueError(f"tick mismatch: log={record.dotnet_ticks}, dorec={ticks}")
|
||
if payload_length != record.payload_length:
|
||
raise ValueError(f"payload mismatch: log={record.payload_length}, dorec={payload_length}")
|
||
if record_id.upper() != record.log_record_id.upper():
|
||
raise ValueError(f"record id mismatch: log={record.log_record_id}, dorec={record_id}")
|
||
return {
|
||
"record_id": record_id,
|
||
"record_id_bytes_hex": id_bytes.hex().upper(),
|
||
"payload_length": payload_length,
|
||
}, payload
|
||
|
||
|
||
def parse_lidar_payload(payload: bytes) -> tuple[int, int, bytes]:
|
||
if len(payload) < 8:
|
||
raise ValueError(f"payload too short: {len(payload)}")
|
||
frame_counter, point_count = struct.unpack_from("<ii", payload, 0)
|
||
if point_count < 0:
|
||
raise ValueError(f"negative point count: {point_count}")
|
||
expected = 8 + point_count * POINT_STRUCT.size
|
||
if expected != len(payload):
|
||
raise ValueError(f"lidar payload length mismatch: expected={expected}, actual={len(payload)}")
|
||
return frame_counter, point_count, payload[8:]
|
||
|
||
|
||
def read_len_prefixed_ascii(data: bytes, pos: int) -> tuple[str, int]:
|
||
if pos >= len(data):
|
||
raise ValueError("truncated length-prefixed string")
|
||
length = data[pos]
|
||
pos += 1
|
||
end = pos + length
|
||
if end > len(data):
|
||
raise ValueError("truncated length-prefixed string body")
|
||
return data[pos:end].decode("ascii", errors="replace"), end
|
||
|
||
|
||
def parse_bool_text(value: str) -> bool | None:
|
||
lowered = value.strip().casefold()
|
||
if lowered in ("true", "1", "yes"):
|
||
return True
|
||
if lowered in ("false", "0", "no"):
|
||
return False
|
||
return None
|
||
|
||
|
||
def parse_kv_payload(text: str, separators: Sequence[str] = ("=", ":")) -> dict[str, str]:
|
||
result: dict[str, str] = {}
|
||
for part in text.replace("\r", "").replace("\n", ",").split(","):
|
||
part = part.strip()
|
||
if not part:
|
||
continue
|
||
key = value = None
|
||
for sep in separators:
|
||
if sep in part:
|
||
key, value = part.split(sep, 1)
|
||
break
|
||
if key is None or value is None:
|
||
continue
|
||
result[key.strip()] = value.strip()
|
||
return result
|
||
|
||
|
||
def coerce_int(value: str | None) -> int | None:
|
||
if value is None or value == "":
|
||
return None
|
||
try:
|
||
return int(float(value))
|
||
except ValueError:
|
||
return None
|
||
|
||
|
||
def coerce_float(value: str | None) -> float | None:
|
||
if value is None or value == "":
|
||
return None
|
||
try:
|
||
return float(value)
|
||
except ValueError:
|
||
return None
|
||
|
||
|
||
def parse_gps_post_z_text(text: str) -> dict[str, object]:
|
||
fields = parse_kv_payload(text, separators=("=", ":"))
|
||
alt = fields.get("alt", fields.get("h"))
|
||
vehicle_heading = fields.get("vehicleHeading", fields.get("th"))
|
||
return {
|
||
"counter": coerce_int(fields.get("counter")),
|
||
"lat": coerce_float(fields.get("lat")),
|
||
"lon": coerce_float(fields.get("lon")),
|
||
"alt_m": coerce_float(alt),
|
||
"raw_heading_deg": coerce_float(fields.get("rawHeading")),
|
||
"vehicle_heading_deg": coerce_float(vehicle_heading),
|
||
"fix": coerce_int(fields.get("fix")),
|
||
"sat": coerce_int(fields.get("sat")),
|
||
"position_valid": parse_bool_text(fields.get("positionValid", "")),
|
||
"heading_valid": parse_bool_text(fields.get("headingValid", "")),
|
||
"heading_solution": fields.get("headingSolution"),
|
||
"position_time": fields.get("positionTime"),
|
||
"heading_time": fields.get("headingTime"),
|
||
"last_line": fields.get("lastLine"),
|
||
"time_field": fields.get("time"),
|
||
"raw_fields": fields,
|
||
}
|
||
|
||
|
||
def parse_rtk_binary_payload(payload: bytes) -> dict[str, object]:
|
||
pos = 0
|
||
device_name, pos = read_len_prefixed_ascii(payload, pos)
|
||
version, counter, unknown0 = struct.unpack_from("<iii", payload, pos)
|
||
pos += 12
|
||
device_stamp = payload[pos : pos + 8]
|
||
pos += 8
|
||
position_time, pos = read_len_prefixed_ascii(payload, pos)
|
||
heading_time, pos = read_len_prefixed_ascii(payload, pos)
|
||
lat, lon, alt, reserved0, reserved1 = struct.unpack_from("<ddddd", payload, pos)
|
||
pos += 40
|
||
fix, sat = struct.unpack_from("<ii", payload, pos)
|
||
pos += 8
|
||
if pos >= len(payload):
|
||
raise ValueError("rtk payload truncated before validity flags")
|
||
position_valid = bool(payload[pos])
|
||
pos += 1
|
||
raw_heading, vehicle_heading = struct.unpack_from("<dd", payload, pos)
|
||
pos += 16
|
||
if pos >= len(payload):
|
||
raise ValueError("rtk payload truncated before heading validity")
|
||
heading_valid = bool(payload[pos])
|
||
pos += 1
|
||
heading_solution, pos = read_len_prefixed_ascii(payload, pos)
|
||
has_nmea = bool(payload[pos]) if pos < len(payload) else False
|
||
if pos < len(payload):
|
||
pos += 1
|
||
last_line = None
|
||
if has_nmea and pos < len(payload):
|
||
last_line, pos = read_len_prefixed_ascii(payload, pos)
|
||
return {
|
||
"device_name": device_name,
|
||
"version": version,
|
||
"counter": counter,
|
||
"unknown0": unknown0,
|
||
"device_stamp_hex": device_stamp.hex().upper(),
|
||
"position_time": position_time,
|
||
"heading_time": heading_time,
|
||
"lat": lat,
|
||
"lon": lon,
|
||
"alt_m": alt,
|
||
"reserved0": reserved0,
|
||
"reserved1": reserved1,
|
||
"fix": fix,
|
||
"sat": sat,
|
||
"position_valid": position_valid,
|
||
"raw_heading_deg": raw_heading,
|
||
"vehicle_heading_deg": vehicle_heading,
|
||
"heading_valid": heading_valid,
|
||
"heading_solution": heading_solution,
|
||
"last_line": last_line,
|
||
"bytes_consumed": pos,
|
||
"payload_length": len(payload),
|
||
}
|
||
|
||
|
||
def parse_gps_post_payload(payload: bytes) -> dict[str, object]:
|
||
pos = 0
|
||
name, pos = read_len_prefixed_ascii(payload, pos)
|
||
if pos + 20 > len(payload):
|
||
raise ValueError("GPS-POST payload too short")
|
||
counter, unknown0 = struct.unpack_from("<ii", payload, pos)
|
||
pos += 8
|
||
flags = payload[pos : pos + 4]
|
||
pos += 4
|
||
stamp = payload[pos : pos + 8]
|
||
pos += 8
|
||
floats = []
|
||
while pos + 4 <= len(payload):
|
||
floats.append(struct.unpack_from("<f", payload, pos)[0])
|
||
pos += 4
|
||
return {
|
||
"device_name": name,
|
||
"counter": counter,
|
||
"unknown0": unknown0,
|
||
"flags_hex": flags.hex().upper(),
|
||
"device_stamp_hex": stamp.hex().upper(),
|
||
"floats_f32": floats,
|
||
"payload_hex": payload.hex().upper(),
|
||
}
|
||
|
||
|
||
def rotation_matrix(yaw_deg: float, pitch_deg: float, roll_deg: float) -> tuple[float, ...]:
|
||
yaw, pitch, roll = map(math.radians, (yaw_deg, pitch_deg, roll_deg))
|
||
cy, sy = math.cos(yaw), math.sin(yaw)
|
||
cp, sp = math.cos(pitch), math.sin(pitch)
|
||
cr, sr = math.cos(roll), math.sin(roll)
|
||
return (
|
||
cy * cp,
|
||
-sy * cr + cy * sp * sr,
|
||
sy * sr + cy * sp * cr,
|
||
sy * cp,
|
||
cy * cr + sy * sp * sr,
|
||
-cy * sr + sy * sp * cr,
|
||
-sp,
|
||
cp * sr,
|
||
cp * cr,
|
||
)
|
||
|
||
|
||
def generate_xyz(
|
||
raw_points: bytes,
|
||
mode: str,
|
||
translation: tuple[float, float, float],
|
||
rotation: tuple[float, ...],
|
||
) -> tuple[bytes | None, bytes | None]:
|
||
need_sensor = mode in ("sensor", "both")
|
||
need_cart = mode in ("cart", "both")
|
||
sensor = array.array("f") if need_sensor else None
|
||
cart = array.array("f") if need_cart else None
|
||
tx, ty, tz = translation
|
||
r = rotation
|
||
for distance, azimuth, altitude, _intensity, _progression in POINT_STRUCT.iter_unpack(raw_points):
|
||
alt = math.radians(altitude)
|
||
azi = math.radians(azimuth)
|
||
cos_alt = math.cos(alt)
|
||
sx = distance * cos_alt * math.cos(azi)
|
||
sy = distance * cos_alt * math.sin(azi)
|
||
sz = distance * math.sin(alt)
|
||
if sensor is not None:
|
||
sensor.extend((sx, sy, sz))
|
||
if cart is not None:
|
||
cart.extend(
|
||
(
|
||
r[0] * sx + r[1] * sy + r[2] * sz + tx,
|
||
r[3] * sx + r[4] * sy + r[5] * sz + ty,
|
||
r[6] * sx + r[7] * sy + r[8] * sz + tz,
|
||
)
|
||
)
|
||
if sys.byteorder != "little":
|
||
if sensor is not None:
|
||
sensor.byteswap()
|
||
if cart is not None:
|
||
cart.byteswap()
|
||
return (sensor.tobytes() if sensor is not None else None, cart.tobytes() if cart is not None else None)
|
||
|
||
|
||
def numpy_header(descr: str, shape: Sequence[int]) -> bytes:
|
||
shape_text = "(" + ", ".join(str(value) for value in shape)
|
||
if len(shape) == 1:
|
||
shape_text += ","
|
||
shape_text += ")"
|
||
header = f"{{'descr': '{descr}', 'fortran_order': False, 'shape': {shape_text}, }}"
|
||
encoded = header.encode("latin-1")
|
||
padding = (-((10 + len(encoded) + 1) % 16)) % 16
|
||
encoded += b" " * padding + b"\n"
|
||
if len(encoded) > 65535:
|
||
raise ValueError("NPY v1 header is too long")
|
||
return b"\x93NUMPY\x01\x00" + struct.pack("<H", len(encoded)) + encoded
|
||
|
||
|
||
def write_npy_entry(
|
||
archive: zipfile.ZipFile,
|
||
name: str,
|
||
descr: str,
|
||
shape: Sequence[int],
|
||
data: bytes,
|
||
) -> None:
|
||
with archive.open(name + ".npy", "w", force_zip64=True) as entry:
|
||
entry.write(numpy_header(descr, shape))
|
||
entry.write(data)
|
||
|
||
|
||
def scalar_bytes(fmt: str, value: int | float) -> bytes:
|
||
return struct.pack(fmt, value)
|
||
|
||
|
||
def open_npz_writer(path: Path, compress: bool) -> zipfile.ZipFile:
|
||
compression = zipfile.ZIP_DEFLATED if compress else zipfile.ZIP_STORED
|
||
kwargs: dict[str, object] = {"compression": compression, "allowZip64": True}
|
||
if compress:
|
||
kwargs["compresslevel"] = 1
|
||
return zipfile.ZipFile(path, "w", **kwargs)
|
||
|
||
|
||
def pack_f64_array(values: Sequence[float]) -> bytes:
|
||
data = array.array("d", values)
|
||
if sys.byteorder != "little":
|
||
data.byteswap()
|
||
return data.tobytes()
|
||
|
||
|
||
def pack_i8_array(values: Sequence[int]) -> bytes:
|
||
data = array.array("q", values)
|
||
if sys.byteorder != "little":
|
||
data.byteswap()
|
||
return data.tobytes()
|
||
|
||
|
||
def pack_i4_array(values: Sequence[int]) -> bytes:
|
||
data = array.array("i", values)
|
||
if sys.byteorder != "little":
|
||
data.byteswap()
|
||
return data.tobytes()
|
||
|
||
|
||
def pack_u1_bools(values: Sequence[bool | None]) -> bytes:
|
||
return bytes(1 if value else 0 for value in values)
|
||
|
||
|
||
def export_npz(
|
||
path: Path,
|
||
metadata: dict[str, object],
|
||
raw_points: bytes,
|
||
point_count: int,
|
||
xyz_sensor: bytes | None,
|
||
xyz_cart: bytes | None,
|
||
compress: bool,
|
||
rtk_arrays: dict[str, tuple[str, Sequence[int], bytes]] | None = None,
|
||
) -> None:
|
||
temp = path.with_suffix(path.suffix + ".tmp")
|
||
with open_npz_writer(temp, compress) as archive:
|
||
write_npy_entry(archive, "points_raw", "<f4", (point_count, 5), raw_points)
|
||
write_npy_entry(archive, "frame_counter", "<i4", (1,), scalar_bytes("<i", metadata["frame_counter"]))
|
||
write_npy_entry(archive, "point_count", "<i4", (1,), scalar_bytes("<i", point_count))
|
||
write_npy_entry(archive, "dotnet_ticks", "<i8", (1,), scalar_bytes("<q", metadata["dotnet_ticks"]))
|
||
write_npy_entry(archive, "unix_time_ns", "<i8", (1,), scalar_bytes("<q", metadata["unix_time_ns"]))
|
||
write_npy_entry(archive, "source_offset", "<i8", (1,), scalar_bytes("<q", metadata["source_offset"]))
|
||
write_npy_entry(archive, "payload_length", "<i4", (1,), scalar_bytes("<i", metadata["payload_length"]))
|
||
metadata_bytes = json.dumps(metadata, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
||
write_npy_entry(archive, "metadata_json_utf8", "|u1", (len(metadata_bytes),), metadata_bytes)
|
||
if xyz_sensor is not None:
|
||
write_npy_entry(archive, "xyz_sensor_mm", "<f4", (point_count, 3), xyz_sensor)
|
||
if xyz_cart is not None:
|
||
write_npy_entry(archive, "xyz_cart_mm", "<f4", (point_count, 3), xyz_cart)
|
||
if rtk_arrays:
|
||
for name, (descr, shape, data) in rtk_arrays.items():
|
||
write_npy_entry(archive, name, descr, shape, data)
|
||
os.replace(temp, path)
|
||
|
||
|
||
def export_pickle(
|
||
path: Path,
|
||
metadata: dict[str, object],
|
||
raw_points: bytes,
|
||
point_count: int,
|
||
xyz_sensor: bytes | None,
|
||
xyz_cart: bytes | None,
|
||
) -> None:
|
||
points = array.array("f")
|
||
points.frombytes(raw_points)
|
||
if sys.byteorder != "little":
|
||
points.byteswap()
|
||
frame: dict[str, object] = {
|
||
"metadata": metadata,
|
||
"point_columns": ("d_mm", "azimuth_deg", "altitude_deg", "intensity", "progression"),
|
||
"points_raw_flat_f32": points,
|
||
"points_raw_shape": (point_count, 5),
|
||
}
|
||
for key, raw in (("xyz_sensor_mm_flat_f32", xyz_sensor), ("xyz_cart_mm_flat_f32", xyz_cart)):
|
||
if raw is not None:
|
||
values = array.array("f")
|
||
values.frombytes(raw)
|
||
if sys.byteorder != "little":
|
||
values.byteswap()
|
||
frame[key] = values
|
||
frame[key.replace("_flat_f32", "_shape")] = (point_count, 3)
|
||
temp = path.with_suffix(path.suffix + ".tmp")
|
||
with temp.open("wb") as stream:
|
||
pickle.dump(frame, stream, protocol=5)
|
||
os.replace(temp, path)
|
||
|
||
|
||
def frame_filename(record: RecordRef, frame_counter: int, extension: str) -> str:
|
||
return f"{record.object_name}_{record.sequence:06d}_{record.dotnet_ticks}_frame{frame_counter:010d}.{extension}"
|
||
|
||
|
||
def manifest_fields(include_rtk: bool) -> list[str]:
|
||
fields = [
|
||
"sequence",
|
||
"status",
|
||
"object_name",
|
||
"dotnet_ticks",
|
||
"timestamp_iso_local",
|
||
"unix_time_ns",
|
||
"log_time",
|
||
"record_id",
|
||
"record_id_bytes_hex",
|
||
"frame_counter",
|
||
"point_count",
|
||
"payload_length",
|
||
"source_log",
|
||
"source_dorec",
|
||
"source_offset",
|
||
"output_file",
|
||
"error",
|
||
]
|
||
if include_rtk:
|
||
fields.extend(
|
||
[
|
||
"rtk_matched",
|
||
"rtk_nearest_index",
|
||
"rtk_prev_index",
|
||
"rtk_next_index",
|
||
"rtk_dt_ns",
|
||
"rtk_prev_dt_ns",
|
||
"rtk_next_dt_ns",
|
||
"rtk_lat",
|
||
"rtk_lon",
|
||
"rtk_alt_m",
|
||
"rtk_vehicle_heading_deg",
|
||
"rtk_raw_heading_deg",
|
||
"rtk_fix",
|
||
"rtk_sat",
|
||
"rtk_position_valid",
|
||
"rtk_heading_valid",
|
||
"rtk_heading_solution",
|
||
]
|
||
)
|
||
return fields
|
||
|
||
|
||
def nan_if_none(value: float | None) -> float:
|
||
return float("nan") if value is None else float(value)
|
||
|
||
|
||
def int_or_sentinel(value: int | None, sentinel: int = -1) -> int:
|
||
return sentinel if value is None else int(value)
|
||
|
||
|
||
def bool_or_false(value: bool | None) -> bool:
|
||
return bool(value)
|
||
|
||
|
||
def sample_to_public_dict(sample: RtkSample) -> dict[str, object]:
|
||
data = asdict(sample)
|
||
return data
|
||
|
||
|
||
def write_jsonl(path: Path, rows: Iterable[dict[str, object]]) -> int:
|
||
count = 0
|
||
with path.open("w", encoding="utf-8") as stream:
|
||
for row in rows:
|
||
stream.write(json.dumps(row, ensure_ascii=False, separators=(",", ":")))
|
||
stream.write("\n")
|
||
count += 1
|
||
return count
|
||
|
||
|
||
def write_csv_rows(path: Path, fieldnames: Sequence[str], rows: Iterable[dict[str, object]]) -> int:
|
||
count = 0
|
||
with path.open("w", newline="", encoding="utf-8-sig") as stream:
|
||
writer = csv.DictWriter(stream, fieldnames=list(fieldnames), extrasaction="ignore")
|
||
writer.writeheader()
|
||
for row in rows:
|
||
writer.writerow({key: row.get(key, "") for key in fieldnames})
|
||
count += 1
|
||
return count
|
||
|
||
|
||
def export_rtk_table_npz(path: Path, samples: Sequence[RtkSample], compress: bool) -> None:
|
||
n = len(samples)
|
||
temp = path.with_suffix(path.suffix + ".tmp")
|
||
with open_npz_writer(temp, compress) as archive:
|
||
write_npy_entry(archive, "index", "<i4", (n,), pack_i4_array([s.index for s in samples]))
|
||
write_npy_entry(archive, "dotnet_ticks", "<i8", (n,), pack_i8_array([s.dotnet_ticks for s in samples]))
|
||
write_npy_entry(archive, "unix_time_ns", "<i8", (n,), pack_i8_array([s.unix_time_ns for s in samples]))
|
||
write_npy_entry(archive, "counter", "<i4", (n,), pack_i4_array([int_or_sentinel(s.counter) for s in samples]))
|
||
write_npy_entry(archive, "lat", "<f8", (n,), pack_f64_array([nan_if_none(s.lat) for s in samples]))
|
||
write_npy_entry(archive, "lon", "<f8", (n,), pack_f64_array([nan_if_none(s.lon) for s in samples]))
|
||
write_npy_entry(archive, "alt_m", "<f8", (n,), pack_f64_array([nan_if_none(s.alt_m) for s in samples]))
|
||
write_npy_entry(
|
||
archive,
|
||
"raw_heading_deg",
|
||
"<f8",
|
||
(n,),
|
||
pack_f64_array([nan_if_none(s.raw_heading_deg) for s in samples]),
|
||
)
|
||
write_npy_entry(
|
||
archive,
|
||
"vehicle_heading_deg",
|
||
"<f8",
|
||
(n,),
|
||
pack_f64_array([nan_if_none(s.vehicle_heading_deg) for s in samples]),
|
||
)
|
||
write_npy_entry(archive, "fix", "<i4", (n,), pack_i4_array([int_or_sentinel(s.fix) for s in samples]))
|
||
write_npy_entry(archive, "sat", "<i4", (n,), pack_i4_array([int_or_sentinel(s.sat) for s in samples]))
|
||
write_npy_entry(
|
||
archive,
|
||
"position_valid",
|
||
"|u1",
|
||
(n,),
|
||
pack_u1_bools([s.position_valid for s in samples]),
|
||
)
|
||
write_npy_entry(
|
||
archive,
|
||
"heading_valid",
|
||
"|u1",
|
||
(n,),
|
||
pack_u1_bools([s.heading_valid for s in samples]),
|
||
)
|
||
write_npy_entry(
|
||
archive,
|
||
"source_offset",
|
||
"<i8",
|
||
(n,),
|
||
pack_i8_array([s.source_offset for s in samples]),
|
||
)
|
||
write_npy_entry(
|
||
archive,
|
||
"payload_length",
|
||
"<i4",
|
||
(n,),
|
||
pack_i4_array([s.payload_length for s in samples]),
|
||
)
|
||
meta_bytes = json.dumps(
|
||
[sample_to_public_dict(sample) for sample in samples],
|
||
ensure_ascii=False,
|
||
separators=(",", ":"),
|
||
).encode("utf-8")
|
||
write_npy_entry(archive, "records_json_utf8", "|u1", (len(meta_bytes),), meta_bytes)
|
||
os.replace(temp, path)
|
||
|
||
|
||
def load_gps_post_z_samples(
|
||
root: Path,
|
||
dorec_index: dict[str, list[Path]],
|
||
tz: timezone,
|
||
timezone_text: str,
|
||
) -> tuple[list[RtkSample], dict[str, int]]:
|
||
records = discover_records(root, "GPS-POST-Z")
|
||
samples: list[RtkSample] = []
|
||
stats = {
|
||
"discovered": len(records),
|
||
"parsed_ok": 0,
|
||
"parse_errors": 0,
|
||
"read_errors": 0,
|
||
}
|
||
for record in records:
|
||
timestamp_iso, unix_ns = dotnet_ticks_to_values(record.dotnet_ticks, tz)
|
||
sample = RtkSample(
|
||
index=len(samples),
|
||
source="dobject",
|
||
object_name=record.object_name,
|
||
dotnet_ticks=record.dotnet_ticks,
|
||
timestamp_iso_local=timestamp_iso,
|
||
unix_time_ns=unix_ns,
|
||
log_time=record.log_time,
|
||
record_id=record.log_record_id,
|
||
source_log=record.source_log,
|
||
source_dorec=record.source_dorec,
|
||
source_offset=record.source_offset,
|
||
payload_length=record.payload_length,
|
||
)
|
||
try:
|
||
dorec_path = choose_dorec(dorec_index, record.source_dorec)
|
||
record_meta, payload = read_record(dorec_path, record)
|
||
sample.record_id = str(record_meta["record_id"])
|
||
text = payload.decode("utf-8", errors="replace")
|
||
sample.raw_text = text
|
||
parsed = parse_gps_post_z_text(text)
|
||
sample.counter = parsed["counter"] # type: ignore[assignment]
|
||
sample.lat = parsed["lat"] # type: ignore[assignment]
|
||
sample.lon = parsed["lon"] # type: ignore[assignment]
|
||
sample.alt_m = parsed["alt_m"] # type: ignore[assignment]
|
||
sample.raw_heading_deg = parsed["raw_heading_deg"] # type: ignore[assignment]
|
||
sample.vehicle_heading_deg = parsed["vehicle_heading_deg"] # type: ignore[assignment]
|
||
sample.fix = parsed["fix"] # type: ignore[assignment]
|
||
sample.sat = parsed["sat"] # type: ignore[assignment]
|
||
sample.position_valid = parsed["position_valid"] # type: ignore[assignment]
|
||
sample.heading_valid = parsed["heading_valid"] # type: ignore[assignment]
|
||
sample.heading_solution = parsed["heading_solution"] # type: ignore[assignment]
|
||
sample.position_time = parsed["position_time"] # type: ignore[assignment]
|
||
sample.heading_time = parsed["heading_time"] # type: ignore[assignment]
|
||
sample.last_line = parsed["last_line"] # type: ignore[assignment]
|
||
stats["parsed_ok"] += 1
|
||
except Exception as exc:
|
||
sample.parse_error = f"{type(exc).__name__}: {exc}"
|
||
if "read" in type(exc).__name__.casefold() or "mismatch" in str(exc).casefold():
|
||
stats["read_errors"] += 1
|
||
else:
|
||
stats["parse_errors"] += 1
|
||
samples.append(sample)
|
||
_ = timezone_text
|
||
return samples, stats
|
||
|
||
|
||
def load_rtk_binary_samples(
|
||
root: Path,
|
||
dorec_index: dict[str, list[Path]],
|
||
tz: timezone,
|
||
) -> tuple[list[RtkSample], dict[str, int]]:
|
||
records = discover_records(root, "rtk")
|
||
samples: list[RtkSample] = []
|
||
stats = {"discovered": len(records), "parsed_ok": 0, "parse_errors": 0, "read_errors": 0}
|
||
for record in records:
|
||
timestamp_iso, unix_ns = dotnet_ticks_to_values(record.dotnet_ticks, tz)
|
||
sample = RtkSample(
|
||
index=len(samples),
|
||
source="dobject",
|
||
object_name=record.object_name,
|
||
dotnet_ticks=record.dotnet_ticks,
|
||
timestamp_iso_local=timestamp_iso,
|
||
unix_time_ns=unix_ns,
|
||
log_time=record.log_time,
|
||
record_id=record.log_record_id,
|
||
source_log=record.source_log,
|
||
source_dorec=record.source_dorec,
|
||
source_offset=record.source_offset,
|
||
payload_length=record.payload_length,
|
||
)
|
||
try:
|
||
dorec_path = choose_dorec(dorec_index, record.source_dorec)
|
||
record_meta, payload = read_record(dorec_path, record)
|
||
sample.record_id = str(record_meta["record_id"])
|
||
parsed = parse_rtk_binary_payload(payload)
|
||
sample.device_name = str(parsed["device_name"])
|
||
sample.device_stamp_hex = str(parsed["device_stamp_hex"])
|
||
sample.counter = int(parsed["counter"]) # type: ignore[arg-type]
|
||
sample.lat = float(parsed["lat"]) # type: ignore[arg-type]
|
||
sample.lon = float(parsed["lon"]) # type: ignore[arg-type]
|
||
sample.alt_m = float(parsed["alt_m"]) # type: ignore[arg-type]
|
||
sample.raw_heading_deg = float(parsed["raw_heading_deg"]) # type: ignore[arg-type]
|
||
sample.vehicle_heading_deg = float(parsed["vehicle_heading_deg"]) # type: ignore[arg-type]
|
||
sample.fix = int(parsed["fix"]) # type: ignore[arg-type]
|
||
sample.sat = int(parsed["sat"]) # type: ignore[arg-type]
|
||
sample.position_valid = bool(parsed["position_valid"])
|
||
sample.heading_valid = bool(parsed["heading_valid"])
|
||
sample.heading_solution = str(parsed["heading_solution"])
|
||
sample.position_time = str(parsed["position_time"])
|
||
sample.heading_time = str(parsed["heading_time"])
|
||
sample.last_line = parsed["last_line"] # type: ignore[assignment]
|
||
sample.raw_text = json.dumps(parsed, ensure_ascii=False, separators=(",", ":"))
|
||
stats["parsed_ok"] += 1
|
||
except Exception as exc:
|
||
sample.parse_error = f"{type(exc).__name__}: {exc}"
|
||
stats["parse_errors"] += 1
|
||
samples.append(sample)
|
||
return samples, stats
|
||
|
||
|
||
def load_gps_post_samples(
|
||
root: Path,
|
||
dorec_index: dict[str, list[Path]],
|
||
tz: timezone,
|
||
) -> tuple[list[dict[str, object]], dict[str, int]]:
|
||
records = discover_records(root, "GPS-POST")
|
||
rows: list[dict[str, object]] = []
|
||
stats = {"discovered": len(records), "parsed_ok": 0, "parse_errors": 0, "read_errors": 0}
|
||
for index, record in enumerate(records):
|
||
timestamp_iso, unix_ns = dotnet_ticks_to_values(record.dotnet_ticks, tz)
|
||
row: dict[str, object] = {
|
||
"index": index,
|
||
"object_name": record.object_name,
|
||
"dotnet_ticks": record.dotnet_ticks,
|
||
"timestamp_iso_local": timestamp_iso,
|
||
"unix_time_ns": unix_ns,
|
||
"log_time": record.log_time,
|
||
"record_id": record.log_record_id,
|
||
"source_log": record.source_log,
|
||
"source_dorec": record.source_dorec,
|
||
"source_offset": record.source_offset,
|
||
"payload_length": record.payload_length,
|
||
"parse_error": "",
|
||
}
|
||
try:
|
||
dorec_path = choose_dorec(dorec_index, record.source_dorec)
|
||
record_meta, payload = read_record(dorec_path, record)
|
||
row["record_id"] = record_meta["record_id"]
|
||
parsed = parse_gps_post_payload(payload)
|
||
row.update(parsed)
|
||
stats["parsed_ok"] += 1
|
||
except Exception as exc:
|
||
row["parse_error"] = f"{type(exc).__name__}: {exc}"
|
||
stats["parse_errors"] += 1
|
||
rows.append(row)
|
||
return rows, stats
|
||
|
||
|
||
def load_gps_post_z_text_logs(root: Path) -> tuple[list[dict[str, object]], dict[str, int]]:
|
||
rows: list[dict[str, object]] = []
|
||
stats = {"files": 0, "lines": 0, "parsed_ok": 0, "parse_errors": 0}
|
||
text_root = root / "GPS-POST-Z"
|
||
if not text_root.is_dir():
|
||
return rows, stats
|
||
for log_path in sorted(text_root.rglob("*.log")):
|
||
stats["files"] += 1
|
||
relative = log_path.relative_to(root).as_posix()
|
||
with log_path.open("r", encoding="utf-8", errors="replace") as stream:
|
||
for line_no, line in enumerate(stream, start=1):
|
||
match = GPS_TEXT_RE.search(line)
|
||
if not match:
|
||
continue
|
||
stats["lines"] += 1
|
||
body = match.group("body").strip()
|
||
row: dict[str, object] = {
|
||
"index": len(rows),
|
||
"source": "GPS-POST-Z-text-log",
|
||
"source_log": relative,
|
||
"line_no": line_no,
|
||
"log_time": match.group("log_time"),
|
||
"raw_text": body,
|
||
"parse_error": "",
|
||
}
|
||
try:
|
||
parsed = parse_gps_post_z_text(body)
|
||
row.update(
|
||
{
|
||
"lat": parsed["lat"],
|
||
"lon": parsed["lon"],
|
||
"alt_m": parsed["alt_m"],
|
||
"raw_heading_deg": parsed["raw_heading_deg"],
|
||
"vehicle_heading_deg": parsed["vehicle_heading_deg"],
|
||
"fix": parsed["fix"],
|
||
"sat": parsed["sat"],
|
||
"position_valid": parsed["position_valid"],
|
||
"heading_valid": parsed["heading_valid"],
|
||
"heading_solution": parsed["heading_solution"],
|
||
"position_time": parsed["position_time"],
|
||
"heading_time": parsed["heading_time"],
|
||
}
|
||
)
|
||
stats["parsed_ok"] += 1
|
||
except Exception as exc:
|
||
row["parse_error"] = f"{type(exc).__name__}: {exc}"
|
||
stats["parse_errors"] += 1
|
||
rows.append(row)
|
||
return rows, stats
|
||
|
||
|
||
def match_rtk(unix_time_ns: int, samples: Sequence[RtkSample]) -> dict[str, object]:
|
||
if not samples:
|
||
return {
|
||
"matched": False,
|
||
"nearest_index": -1,
|
||
"prev_index": -1,
|
||
"next_index": -1,
|
||
"dt_ns": None,
|
||
"prev_dt_ns": None,
|
||
"next_dt_ns": None,
|
||
"sample": None,
|
||
}
|
||
ticks = [sample.unix_time_ns for sample in samples]
|
||
pos = bisect.bisect_left(ticks, unix_time_ns)
|
||
if pos < len(samples) and ticks[pos] == unix_time_ns:
|
||
nearest = prev_index = next_index = pos
|
||
else:
|
||
candidates: list[int] = []
|
||
if pos < len(samples):
|
||
candidates.append(pos)
|
||
if pos > 0:
|
||
candidates.append(pos - 1)
|
||
nearest = min(candidates, key=lambda idx: (abs(ticks[idx] - unix_time_ns), idx))
|
||
prev_index = pos - 1 if pos > 0 else -1
|
||
next_index = pos if pos < len(samples) else -1
|
||
sample = samples[nearest]
|
||
prev_dt = None if prev_index < 0 else unix_time_ns - samples[prev_index].unix_time_ns
|
||
next_dt = None if next_index < 0 else samples[next_index].unix_time_ns - unix_time_ns
|
||
return {
|
||
"matched": True,
|
||
"nearest_index": nearest,
|
||
"prev_index": prev_index,
|
||
"next_index": next_index,
|
||
"dt_ns": unix_time_ns - sample.unix_time_ns,
|
||
"prev_dt_ns": prev_dt,
|
||
"next_dt_ns": next_dt,
|
||
"sample": sample,
|
||
}
|
||
|
||
|
||
def rtk_frame_arrays(match: dict[str, object]) -> dict[str, tuple[str, Sequence[int], bytes]]:
|
||
sample: RtkSample | None = match["sample"] # type: ignore[assignment]
|
||
return {
|
||
"rtk_nearest_index": ("<i4", (1,), scalar_bytes("<i", int(match["nearest_index"]))),
|
||
"rtk_prev_index": ("<i4", (1,), scalar_bytes("<i", int(match["prev_index"]))),
|
||
"rtk_next_index": ("<i4", (1,), scalar_bytes("<i", int(match["next_index"]))),
|
||
"rtk_dt_ns": (
|
||
"<i8",
|
||
(1,),
|
||
scalar_bytes("<q", 0 if match["dt_ns"] is None else int(match["dt_ns"])),
|
||
),
|
||
"rtk_prev_dt_ns": (
|
||
"<i8",
|
||
(1,),
|
||
scalar_bytes("<q", 0 if match["prev_dt_ns"] is None else int(match["prev_dt_ns"])),
|
||
),
|
||
"rtk_next_dt_ns": (
|
||
"<i8",
|
||
(1,),
|
||
scalar_bytes("<q", 0 if match["next_dt_ns"] is None else int(match["next_dt_ns"])),
|
||
),
|
||
"rtk_lat": ("<f8", (1,), scalar_bytes("<d", nan_if_none(None if sample is None else sample.lat))),
|
||
"rtk_lon": ("<f8", (1,), scalar_bytes("<d", nan_if_none(None if sample is None else sample.lon))),
|
||
"rtk_alt_m": ("<f8", (1,), scalar_bytes("<d", nan_if_none(None if sample is None else sample.alt_m))),
|
||
"rtk_raw_heading_deg": (
|
||
"<f8",
|
||
(1,),
|
||
scalar_bytes("<d", nan_if_none(None if sample is None else sample.raw_heading_deg)),
|
||
),
|
||
"rtk_vehicle_heading_deg": (
|
||
"<f8",
|
||
(1,),
|
||
scalar_bytes("<d", nan_if_none(None if sample is None else sample.vehicle_heading_deg)),
|
||
),
|
||
"rtk_fix": ("<i4", (1,), scalar_bytes("<i", int_or_sentinel(None if sample is None else sample.fix))),
|
||
"rtk_sat": ("<i4", (1,), scalar_bytes("<i", int_or_sentinel(None if sample is None else sample.sat))),
|
||
"rtk_position_valid": (
|
||
"|u1",
|
||
(1,),
|
||
bytes((1 if sample and sample.position_valid else 0,)),
|
||
),
|
||
"rtk_heading_valid": (
|
||
"|u1",
|
||
(1,),
|
||
bytes((1 if sample and sample.heading_valid else 0,)),
|
||
),
|
||
}
|
||
|
||
|
||
def export_rtk_sidecars(
|
||
output: Path,
|
||
compress: bool,
|
||
gps_post_z: Sequence[RtkSample],
|
||
rtk_binary: Sequence[RtkSample],
|
||
gps_post_rows: Sequence[dict[str, object]],
|
||
text_rows: Sequence[dict[str, object]],
|
||
) -> dict[str, object]:
|
||
rtk_dir = output / "rtk"
|
||
rtk_dir.mkdir(exist_ok=True)
|
||
written: dict[str, object] = {"directory": "rtk"}
|
||
|
||
export_rtk_table_npz(rtk_dir / "gps_post_z.npz", gps_post_z, compress)
|
||
gps_dicts = [sample_to_public_dict(sample) for sample in gps_post_z]
|
||
write_jsonl(rtk_dir / "gps_post_z.jsonl", gps_dicts)
|
||
write_csv_rows(
|
||
rtk_dir / "gps_post_z.csv",
|
||
[
|
||
"index",
|
||
"dotnet_ticks",
|
||
"timestamp_iso_local",
|
||
"unix_time_ns",
|
||
"log_time",
|
||
"record_id",
|
||
"counter",
|
||
"lat",
|
||
"lon",
|
||
"alt_m",
|
||
"raw_heading_deg",
|
||
"vehicle_heading_deg",
|
||
"fix",
|
||
"sat",
|
||
"position_valid",
|
||
"heading_valid",
|
||
"heading_solution",
|
||
"position_time",
|
||
"heading_time",
|
||
"last_line",
|
||
"source_log",
|
||
"source_dorec",
|
||
"source_offset",
|
||
"payload_length",
|
||
"parse_error",
|
||
"raw_text",
|
||
],
|
||
gps_dicts,
|
||
)
|
||
written["gps_post_z"] = {
|
||
"npz": "rtk/gps_post_z.npz",
|
||
"csv": "rtk/gps_post_z.csv",
|
||
"jsonl": "rtk/gps_post_z.jsonl",
|
||
"count": len(gps_post_z),
|
||
}
|
||
|
||
export_rtk_table_npz(rtk_dir / "rtk_binary.npz", rtk_binary, compress)
|
||
rtk_dicts = [sample_to_public_dict(sample) for sample in rtk_binary]
|
||
write_jsonl(rtk_dir / "rtk_binary.jsonl", rtk_dicts)
|
||
write_csv_rows(
|
||
rtk_dir / "rtk_binary.csv",
|
||
[
|
||
"index",
|
||
"dotnet_ticks",
|
||
"timestamp_iso_local",
|
||
"unix_time_ns",
|
||
"log_time",
|
||
"record_id",
|
||
"device_name",
|
||
"device_stamp_hex",
|
||
"counter",
|
||
"lat",
|
||
"lon",
|
||
"alt_m",
|
||
"raw_heading_deg",
|
||
"vehicle_heading_deg",
|
||
"fix",
|
||
"sat",
|
||
"position_valid",
|
||
"heading_valid",
|
||
"heading_solution",
|
||
"position_time",
|
||
"heading_time",
|
||
"last_line",
|
||
"source_log",
|
||
"source_dorec",
|
||
"source_offset",
|
||
"payload_length",
|
||
"parse_error",
|
||
],
|
||
rtk_dicts,
|
||
)
|
||
written["rtk_binary"] = {
|
||
"npz": "rtk/rtk_binary.npz",
|
||
"csv": "rtk/rtk_binary.csv",
|
||
"jsonl": "rtk/rtk_binary.jsonl",
|
||
"count": len(rtk_binary),
|
||
}
|
||
|
||
write_jsonl(rtk_dir / "gps_post.jsonl", gps_post_rows)
|
||
write_csv_rows(
|
||
rtk_dir / "gps_post.csv",
|
||
[
|
||
"index",
|
||
"dotnet_ticks",
|
||
"timestamp_iso_local",
|
||
"unix_time_ns",
|
||
"log_time",
|
||
"record_id",
|
||
"device_name",
|
||
"counter",
|
||
"unknown0",
|
||
"flags_hex",
|
||
"device_stamp_hex",
|
||
"source_log",
|
||
"source_dorec",
|
||
"source_offset",
|
||
"payload_length",
|
||
"payload_hex",
|
||
"parse_error",
|
||
],
|
||
gps_post_rows,
|
||
)
|
||
# Compact NPZ for GPS-POST counters / stamps only.
|
||
temp = (rtk_dir / "gps_post.npz").with_suffix(".npz.tmp")
|
||
with open_npz_writer(temp, compress) as archive:
|
||
n = len(gps_post_rows)
|
||
write_npy_entry(
|
||
archive,
|
||
"index",
|
||
"<i4",
|
||
(n,),
|
||
pack_i4_array([int(row["index"]) for row in gps_post_rows]),
|
||
)
|
||
write_npy_entry(
|
||
archive,
|
||
"dotnet_ticks",
|
||
"<i8",
|
||
(n,),
|
||
pack_i8_array([int(row["dotnet_ticks"]) for row in gps_post_rows]),
|
||
)
|
||
write_npy_entry(
|
||
archive,
|
||
"unix_time_ns",
|
||
"<i8",
|
||
(n,),
|
||
pack_i8_array([int(row["unix_time_ns"]) for row in gps_post_rows]),
|
||
)
|
||
write_npy_entry(
|
||
archive,
|
||
"counter",
|
||
"<i4",
|
||
(n,),
|
||
pack_i4_array([int(row.get("counter", -1) or -1) for row in gps_post_rows]),
|
||
)
|
||
meta_bytes = json.dumps(list(gps_post_rows), ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
||
write_npy_entry(archive, "records_json_utf8", "|u1", (len(meta_bytes),), meta_bytes)
|
||
os.replace(temp, rtk_dir / "gps_post.npz")
|
||
written["gps_post"] = {
|
||
"npz": "rtk/gps_post.npz",
|
||
"csv": "rtk/gps_post.csv",
|
||
"jsonl": "rtk/gps_post.jsonl",
|
||
"count": len(gps_post_rows),
|
||
}
|
||
|
||
write_jsonl(rtk_dir / "gps_post_z_text.jsonl", text_rows)
|
||
write_csv_rows(
|
||
rtk_dir / "gps_post_z_text.csv",
|
||
[
|
||
"index",
|
||
"source_log",
|
||
"line_no",
|
||
"log_time",
|
||
"lat",
|
||
"lon",
|
||
"alt_m",
|
||
"raw_heading_deg",
|
||
"vehicle_heading_deg",
|
||
"fix",
|
||
"sat",
|
||
"position_valid",
|
||
"heading_valid",
|
||
"heading_solution",
|
||
"position_time",
|
||
"heading_time",
|
||
"raw_text",
|
||
"parse_error",
|
||
],
|
||
text_rows,
|
||
)
|
||
written["gps_post_z_text"] = {
|
||
"csv": "rtk/gps_post_z_text.csv",
|
||
"jsonl": "rtk/gps_post_z_text.jsonl",
|
||
"count": len(text_rows),
|
||
}
|
||
return written
|
||
|
||
|
||
def build_validation_report(
|
||
*,
|
||
lidar_stats: dict[str, object],
|
||
gps_post_z_stats: dict[str, int],
|
||
rtk_binary_stats: dict[str, int],
|
||
gps_post_stats: dict[str, int],
|
||
text_stats: dict[str, int],
|
||
match_dts_ns: Sequence[int],
|
||
interior_dts_ns: Sequence[int],
|
||
edge_frames: int,
|
||
matched_frames: int,
|
||
unmatched_frames: int,
|
||
rtk_max_dt_ms: float,
|
||
rtk_sidecars: bool,
|
||
) -> dict[str, object]:
|
||
abs_dts = [abs(value) for value in match_dts_ns]
|
||
abs_interior = [abs(value) for value in interior_dts_ns]
|
||
threshold_ns = rtk_max_dt_ms * 1_000_000.0
|
||
over_threshold = sum(1 for value in abs_interior if value > threshold_ns)
|
||
checks: list[dict[str, object]] = [
|
||
{
|
||
"name": "lidar_payload_length",
|
||
"ok": lidar_stats.get("frames_with_errors", 0) == 0,
|
||
"detail": "Each lidar payload must equal 8 + point_count * 20.",
|
||
},
|
||
{
|
||
"name": "gps_post_z_parse",
|
||
"ok": gps_post_z_stats.get("parse_errors", 0) == 0
|
||
and gps_post_z_stats.get("read_errors", 0) == 0,
|
||
"detail": "All GPS-POST-Z dobject payloads should parse.",
|
||
},
|
||
{
|
||
"name": "lidar_rtk_coverage",
|
||
"ok": unmatched_frames == 0,
|
||
"detail": "Every exported lidar frame should have a nearest GPS-POST-Z sample.",
|
||
},
|
||
{
|
||
"name": "rtk_time_alignment",
|
||
"ok": over_threshold == 0,
|
||
"detail": (
|
||
f"Interior frames (with both prev/next RTK) should have nearest |dt| "
|
||
f"<= {rtk_max_dt_ms} ms."
|
||
),
|
||
},
|
||
]
|
||
if rtk_sidecars:
|
||
checks.insert(
|
||
2,
|
||
{
|
||
"name": "rtk_binary_parse",
|
||
"ok": rtk_binary_stats.get("parse_errors", 0) == 0,
|
||
"detail": "All rtk binary payloads should parse as UNICORE_N4_RTK_V1.",
|
||
},
|
||
)
|
||
report = {
|
||
"format_version": FORMAT_VERSION,
|
||
"generated_at": datetime.now().astimezone().isoformat(),
|
||
"lidar": lidar_stats,
|
||
"rtk_sidecars": rtk_sidecars,
|
||
"rtk_sources": {
|
||
"GPS-POST-Z_dobject": gps_post_z_stats,
|
||
"rtk_dobject": rtk_binary_stats if rtk_sidecars else {"skipped": True},
|
||
"GPS-POST_dobject": gps_post_stats if rtk_sidecars else {"skipped": True},
|
||
"GPS-POST-Z_text_log": text_stats if rtk_sidecars else {"skipped": True},
|
||
},
|
||
"matching": {
|
||
"primary_source": "GPS-POST-Z",
|
||
"matched_frames": matched_frames,
|
||
"unmatched_frames": unmatched_frames,
|
||
"edge_frames_outside_rtk_span": edge_frames,
|
||
"rtk_max_dt_ms_threshold": rtk_max_dt_ms,
|
||
"interior_frames_over_threshold": over_threshold,
|
||
"dt_ns": {
|
||
"count": len(abs_dts),
|
||
"min": min(match_dts_ns) if match_dts_ns else None,
|
||
"max": max(match_dts_ns) if match_dts_ns else None,
|
||
"abs_min": min(abs_dts) if abs_dts else None,
|
||
"abs_max": max(abs_dts) if abs_dts else None,
|
||
"abs_mean": (sum(abs_dts) / len(abs_dts)) if abs_dts else None,
|
||
},
|
||
"interior_dt_ns": {
|
||
"count": len(abs_interior),
|
||
"abs_min": min(abs_interior) if abs_interior else None,
|
||
"abs_max": max(abs_interior) if abs_interior else None,
|
||
"abs_mean": (sum(abs_interior) / len(abs_interior)) if abs_interior else None,
|
||
},
|
||
},
|
||
"checks": checks,
|
||
}
|
||
report["ok"] = all(bool(check["ok"]) for check in checks)
|
||
return report
|
||
|
||
|
||
def write_package_readme(
|
||
path: Path,
|
||
*,
|
||
format_version: str,
|
||
frame_count: int,
|
||
include_rtk: bool,
|
||
timezone_text: str,
|
||
) -> None:
|
||
rtk_note = (
|
||
"每帧 NPZ 已嵌入最近 RTK:`rtk_lat` / `rtk_lon` / `rtk_alt_m` / "
|
||
"`rtk_vehicle_heading_deg` / `rtk_fix` / `rtk_sat` 等。\n"
|
||
if include_rtk
|
||
else "本包未嵌入 RTK(导出时使用了 `--skip-rtk`)。\n"
|
||
)
|
||
text = """# FrontLidar 数据集
|
||
|
||
格式版本:`@@FORMAT_VERSION@@`
|
||
帧数:`@@FRAME_COUNT@@`
|
||
时区:`@@TIMEZONE@@`
|
||
|
||
## 目录
|
||
|
||
```text
|
||
.
|
||
README.md # 本说明
|
||
frames/ # 逐帧 NPZ
|
||
frontlidar_<seq>_<ticks>_frame<counter>.npz
|
||
```
|
||
|
||
直接打包本目录即可分发。
|
||
|
||
## 依赖
|
||
|
||
```powershell
|
||
pip install numpy
|
||
```
|
||
|
||
## 快速读取
|
||
|
||
```python
|
||
import json
|
||
from pathlib import Path
|
||
import numpy as np
|
||
|
||
root = Path(__file__).resolve().parent # 或改成数据集路径
|
||
frame_path = next(sorted((root / "frames").glob("*.npz")))
|
||
|
||
with np.load(frame_path, allow_pickle=False) as f:
|
||
points = f["points_raw"] # float32 (N, 5)
|
||
unix_ns = int(f["unix_time_ns"][0])
|
||
meta = json.loads(f["metadata_json_utf8"].tobytes().decode("utf-8"))
|
||
|
||
# 列: d_mm, azimuth_deg, altitude_deg, intensity, progression
|
||
d_mm, az, alt, intensity, prog = (points[:, i] for i in range(5))
|
||
|
||
# RTK(若导出时未 --skip-rtk)
|
||
if "rtk_lat" in f.files:
|
||
lat = float(f["rtk_lat"][0])
|
||
lon = float(f["rtk_lon"][0])
|
||
heading = float(f["rtk_vehicle_heading_deg"][0])
|
||
dt_ms = int(f["rtk_dt_ns"][0]) / 1e6
|
||
print(lat, lon, heading, dt_ms, meta.get("rtk", {}).get("heading_solution"))
|
||
```
|
||
|
||
@@RTK_NOTE@@
|
||
## 点云列
|
||
|
||
| 列 | 字段 | 单位 |
|
||
|---:|---|---|
|
||
| 0 | d_mm | mm |
|
||
| 1 | azimuth_deg | ° |
|
||
| 2 | altitude_deg | ° |
|
||
| 3 | intensity | 设备定义 |
|
||
| 4 | progression | 0–1 |
|
||
|
||
极坐标转传感器 XYZ(mm):
|
||
|
||
```python
|
||
az = np.deg2rad(points[:, 1])
|
||
alt = np.deg2rad(points[:, 2])
|
||
d = points[:, 0]
|
||
xyz = np.column_stack((
|
||
d * np.cos(alt) * np.cos(az),
|
||
d * np.cos(alt) * np.sin(az),
|
||
d * np.sin(alt),
|
||
)).astype(np.float32)
|
||
```
|
||
|
||
## 时间戳
|
||
|
||
- `dotnet_ticks`:原始权威时间
|
||
- `unix_time_ns`:按导出时区转换的 Unix 纳秒
|
||
- 标量字段均为 shape `(1,)`,用 `[0]` 取出
|
||
|
||
## 批量遍历
|
||
|
||
```python
|
||
for path in sorted((root / "frames").glob("*.npz")):
|
||
with np.load(path, allow_pickle=False) as f:
|
||
pts = f["points_raw"]
|
||
# ...
|
||
```
|
||
|
||
更多字段说明见导出仓库中的 `FRONTLIDAR_NPZ_READ.md`(若一并提供)。
|
||
"""
|
||
text = (
|
||
text.replace("@@FORMAT_VERSION@@", format_version)
|
||
.replace("@@FRAME_COUNT@@", str(frame_count))
|
||
.replace("@@TIMEZONE@@", timezone_text)
|
||
.replace("@@RTK_NOTE@@", rtk_note)
|
||
)
|
||
path.write_text(text, encoding="utf-8")
|
||
|
||
|
||
def export_dataset(args: argparse.Namespace) -> int:
|
||
started = time.time()
|
||
root = resolve_dlog_root(args.dlog)
|
||
output = Path(args.out).expanduser().resolve()
|
||
output.mkdir(parents=True, exist_ok=True)
|
||
frames_dir = output / "frames"
|
||
frames_dir.mkdir(exist_ok=True)
|
||
tz = parse_timezone(args.timezone)
|
||
records = discover_records(root, args.object)
|
||
selected = records[:: args.stride]
|
||
if args.max_frames:
|
||
selected = selected[: args.max_frames]
|
||
if not selected:
|
||
raise RuntimeError(f"no DObject records named {args.object!r} found under {root}")
|
||
dorec_index = index_dorec_files(root)
|
||
rotation = rotation_matrix(args.yaw, args.pitch, args.roll)
|
||
translation = (args.x, args.y, args.z)
|
||
extension = "npz" if args.format == "npz" else "pkl"
|
||
include_rtk = not args.skip_rtk
|
||
rtk_sidecars = bool(args.rtk_sidecars)
|
||
|
||
gps_post_z: list[RtkSample] = []
|
||
rtk_binary: list[RtkSample] = []
|
||
gps_post_rows: list[dict[str, object]] = []
|
||
text_rows: list[dict[str, object]] = []
|
||
gps_post_z_stats = {"discovered": 0, "parsed_ok": 0, "parse_errors": 0, "read_errors": 0}
|
||
rtk_binary_stats = {"discovered": 0, "parsed_ok": 0, "parse_errors": 0, "read_errors": 0}
|
||
gps_post_stats = {"discovered": 0, "parsed_ok": 0, "parse_errors": 0, "read_errors": 0}
|
||
text_stats = {"files": 0, "lines": 0, "parsed_ok": 0, "parse_errors": 0}
|
||
rtk_outputs: dict[str, object] = {}
|
||
|
||
if include_rtk:
|
||
print("Loading GPS-POST-Z for per-frame RTK matching...", flush=True)
|
||
gps_post_z, gps_post_z_stats = load_gps_post_z_samples(root, dorec_index, tz, args.timezone)
|
||
if rtk_sidecars:
|
||
print("Loading RTK sidecars...", flush=True)
|
||
rtk_binary, rtk_binary_stats = load_rtk_binary_samples(root, dorec_index, tz)
|
||
gps_post_rows, gps_post_stats = load_gps_post_samples(root, dorec_index, tz)
|
||
text_rows, text_stats = load_gps_post_z_text_logs(root)
|
||
rtk_outputs = export_rtk_sidecars(
|
||
output,
|
||
bool(args.compress and args.format == "npz"),
|
||
gps_post_z,
|
||
rtk_binary,
|
||
gps_post_rows,
|
||
text_rows,
|
||
)
|
||
print(
|
||
f"RTK loaded: GPS-POST-Z={len(gps_post_z)} rtk={len(rtk_binary)} "
|
||
f"GPS-POST={len(gps_post_rows)} text={len(text_rows)} (sidecars on)",
|
||
flush=True,
|
||
)
|
||
else:
|
||
print(
|
||
f"RTK loaded: GPS-POST-Z={len(gps_post_z)} (sidecars off; use --rtk-sidecars to write rtk/)",
|
||
flush=True,
|
||
)
|
||
|
||
write_reports = bool(args.write_reports)
|
||
reports_dir = output / "reports"
|
||
if write_reports:
|
||
reports_dir.mkdir(exist_ok=True)
|
||
manifest_tmp = reports_dir / "manifest.partial.csv"
|
||
manifest_final = reports_dir / "manifest.csv"
|
||
match_tmp = reports_dir / "rtk_match.partial.csv"
|
||
match_final = reports_dir / "rtk_match.csv"
|
||
manifest_stream_cm: object = manifest_tmp.open("w", newline="", encoding="utf-8-sig")
|
||
match_stream = (
|
||
match_tmp.open("w", newline="", encoding="utf-8-sig") if include_rtk else None
|
||
)
|
||
else:
|
||
manifest_tmp = manifest_final = match_tmp = match_final = None
|
||
manifest_stream_cm = nullcontext(io.StringIO())
|
||
match_stream = io.StringIO() if include_rtk else None
|
||
|
||
exported = skipped = resumed = total_points = 0
|
||
matched_frames = unmatched_frames = edge_frames = 0
|
||
match_dts_ns: list[int] = []
|
||
interior_dts_ns: list[int] = []
|
||
match_fieldnames = [
|
||
"sequence",
|
||
"lidar_unix_time_ns",
|
||
"lidar_dotnet_ticks",
|
||
"output_file",
|
||
"rtk_matched",
|
||
"rtk_nearest_index",
|
||
"rtk_prev_index",
|
||
"rtk_next_index",
|
||
"rtk_dt_ns",
|
||
"rtk_prev_dt_ns",
|
||
"rtk_next_dt_ns",
|
||
"rtk_dotnet_ticks",
|
||
"rtk_unix_time_ns",
|
||
"rtk_lat",
|
||
"rtk_lon",
|
||
"rtk_alt_m",
|
||
"rtk_raw_heading_deg",
|
||
"rtk_vehicle_heading_deg",
|
||
"rtk_fix",
|
||
"rtk_sat",
|
||
"rtk_position_valid",
|
||
"rtk_heading_valid",
|
||
"rtk_heading_solution",
|
||
"rtk_position_time",
|
||
"rtk_heading_time",
|
||
"rtk_last_line",
|
||
"rtk_source_dorec",
|
||
"rtk_source_offset",
|
||
]
|
||
|
||
match_writer = csv.DictWriter(match_stream, fieldnames=match_fieldnames) if match_stream else None
|
||
if match_writer:
|
||
match_writer.writeheader()
|
||
|
||
with manifest_stream_cm as manifest_stream:
|
||
writer = csv.DictWriter(manifest_stream, fieldnames=manifest_fields(include_rtk))
|
||
writer.writeheader()
|
||
for selected_index, record in enumerate(selected):
|
||
row: dict[str, object] = {
|
||
"sequence": record.sequence,
|
||
"status": "error",
|
||
"object_name": record.object_name,
|
||
"dotnet_ticks": record.dotnet_ticks,
|
||
"timestamp_iso_local": "",
|
||
"unix_time_ns": "",
|
||
"log_time": record.log_time,
|
||
"record_id": record.log_record_id,
|
||
"record_id_bytes_hex": "",
|
||
"frame_counter": "",
|
||
"point_count": "",
|
||
"payload_length": record.payload_length,
|
||
"source_log": record.source_log,
|
||
"source_dorec": record.source_dorec,
|
||
"source_offset": record.source_offset,
|
||
"output_file": "",
|
||
"error": "",
|
||
}
|
||
if include_rtk:
|
||
row.update(
|
||
{
|
||
"rtk_matched": False,
|
||
"rtk_nearest_index": -1,
|
||
"rtk_prev_index": -1,
|
||
"rtk_next_index": -1,
|
||
"rtk_dt_ns": "",
|
||
"rtk_prev_dt_ns": "",
|
||
"rtk_next_dt_ns": "",
|
||
"rtk_lat": "",
|
||
"rtk_lon": "",
|
||
"rtk_alt_m": "",
|
||
"rtk_vehicle_heading_deg": "",
|
||
"rtk_raw_heading_deg": "",
|
||
"rtk_fix": "",
|
||
"rtk_sat": "",
|
||
"rtk_position_valid": "",
|
||
"rtk_heading_valid": "",
|
||
"rtk_heading_solution": "",
|
||
}
|
||
)
|
||
try:
|
||
dorec_path = choose_dorec(dorec_index, record.source_dorec)
|
||
record_meta, payload = read_record(dorec_path, record)
|
||
frame_counter, point_count, raw_points = parse_lidar_payload(payload)
|
||
timestamp_iso, unix_ns = dotnet_ticks_to_values(record.dotnet_ticks, tz)
|
||
filename = frame_filename(record, frame_counter, extension)
|
||
relative_output = (Path("frames") / filename).as_posix()
|
||
frame_path = output / relative_output
|
||
metadata: dict[str, object] = {
|
||
"format_version": FORMAT_VERSION,
|
||
"sequence": record.sequence,
|
||
"object_name": record.object_name,
|
||
"dotnet_ticks": record.dotnet_ticks,
|
||
"timestamp_iso_local": timestamp_iso,
|
||
"unix_time_ns": unix_ns,
|
||
"timezone": args.timezone,
|
||
"log_time": record.log_time,
|
||
"record_id": record_meta["record_id"],
|
||
"record_id_bytes_hex": record_meta["record_id_bytes_hex"],
|
||
"frame_counter": frame_counter,
|
||
"point_count": point_count,
|
||
"payload_length": record_meta["payload_length"],
|
||
"source_log": record.source_log,
|
||
"source_dorec": record.source_dorec,
|
||
"source_offset": record.source_offset,
|
||
"point_columns": ["d_mm", "azimuth_deg", "altitude_deg", "intensity", "progression"],
|
||
"extrinsic": {
|
||
"translation_mm": list(translation),
|
||
"yaw_pitch_roll_deg": [args.yaw, args.pitch, args.roll],
|
||
"rotation_row_major": list(rotation),
|
||
},
|
||
}
|
||
rtk_arrays = None
|
||
match_info: dict[str, object] | None = None
|
||
if include_rtk:
|
||
match_info = match_rtk(unix_ns, gps_post_z)
|
||
sample: RtkSample | None = match_info["sample"] # type: ignore[assignment]
|
||
if match_info["matched"]:
|
||
matched_frames += 1
|
||
dt_ns = int(match_info["dt_ns"])
|
||
match_dts_ns.append(dt_ns)
|
||
prev_i = int(match_info["prev_index"])
|
||
next_i = int(match_info["next_index"])
|
||
if prev_i < 0 or next_i < 0:
|
||
edge_frames += 1
|
||
else:
|
||
interior_dts_ns.append(dt_ns)
|
||
else:
|
||
unmatched_frames += 1
|
||
rtk_meta = {
|
||
"matched": bool(match_info["matched"]),
|
||
"nearest_index": match_info["nearest_index"],
|
||
"prev_index": match_info["prev_index"],
|
||
"next_index": match_info["next_index"],
|
||
"dt_ns": match_info["dt_ns"],
|
||
"prev_dt_ns": match_info["prev_dt_ns"],
|
||
"next_dt_ns": match_info["next_dt_ns"],
|
||
"primary_source": "GPS-POST-Z",
|
||
}
|
||
if sample is not None:
|
||
rtk_meta.update(
|
||
{
|
||
"dotnet_ticks": sample.dotnet_ticks,
|
||
"unix_time_ns": sample.unix_time_ns,
|
||
"timestamp_iso_local": sample.timestamp_iso_local,
|
||
"lat": sample.lat,
|
||
"lon": sample.lon,
|
||
"alt_m": sample.alt_m,
|
||
"raw_heading_deg": sample.raw_heading_deg,
|
||
"vehicle_heading_deg": sample.vehicle_heading_deg,
|
||
"fix": sample.fix,
|
||
"sat": sample.sat,
|
||
"position_valid": sample.position_valid,
|
||
"heading_valid": sample.heading_valid,
|
||
"heading_solution": sample.heading_solution,
|
||
"position_time": sample.position_time,
|
||
"heading_time": sample.heading_time,
|
||
"last_line": sample.last_line,
|
||
"counter": sample.counter,
|
||
"record_id": sample.record_id,
|
||
"source_dorec": sample.source_dorec,
|
||
"source_offset": sample.source_offset,
|
||
}
|
||
)
|
||
metadata["rtk"] = rtk_meta
|
||
rtk_arrays = rtk_frame_arrays(match_info)
|
||
row.update(
|
||
{
|
||
"rtk_matched": bool(match_info["matched"]),
|
||
"rtk_nearest_index": match_info["nearest_index"],
|
||
"rtk_prev_index": match_info["prev_index"],
|
||
"rtk_next_index": match_info["next_index"],
|
||
"rtk_dt_ns": "" if match_info["dt_ns"] is None else match_info["dt_ns"],
|
||
"rtk_prev_dt_ns": "" if match_info["prev_dt_ns"] is None else match_info["prev_dt_ns"],
|
||
"rtk_next_dt_ns": "" if match_info["next_dt_ns"] is None else match_info["next_dt_ns"],
|
||
"rtk_lat": "" if sample is None or sample.lat is None else sample.lat,
|
||
"rtk_lon": "" if sample is None or sample.lon is None else sample.lon,
|
||
"rtk_alt_m": "" if sample is None or sample.alt_m is None else sample.alt_m,
|
||
"rtk_vehicle_heading_deg": (
|
||
"" if sample is None or sample.vehicle_heading_deg is None else sample.vehicle_heading_deg
|
||
),
|
||
"rtk_raw_heading_deg": (
|
||
"" if sample is None or sample.raw_heading_deg is None else sample.raw_heading_deg
|
||
),
|
||
"rtk_fix": "" if sample is None or sample.fix is None else sample.fix,
|
||
"rtk_sat": "" if sample is None or sample.sat is None else sample.sat,
|
||
"rtk_position_valid": "" if sample is None else sample.position_valid,
|
||
"rtk_heading_valid": "" if sample is None else sample.heading_valid,
|
||
"rtk_heading_solution": "" if sample is None else sample.heading_solution,
|
||
}
|
||
)
|
||
if match_writer is not None:
|
||
match_writer.writerow(
|
||
{
|
||
"sequence": record.sequence,
|
||
"lidar_unix_time_ns": unix_ns,
|
||
"lidar_dotnet_ticks": record.dotnet_ticks,
|
||
"output_file": relative_output,
|
||
"rtk_matched": bool(match_info["matched"]),
|
||
"rtk_nearest_index": match_info["nearest_index"],
|
||
"rtk_prev_index": match_info["prev_index"],
|
||
"rtk_next_index": match_info["next_index"],
|
||
"rtk_dt_ns": "" if match_info["dt_ns"] is None else match_info["dt_ns"],
|
||
"rtk_prev_dt_ns": "" if match_info["prev_dt_ns"] is None else match_info["prev_dt_ns"],
|
||
"rtk_next_dt_ns": "" if match_info["next_dt_ns"] is None else match_info["next_dt_ns"],
|
||
"rtk_dotnet_ticks": "" if sample is None else sample.dotnet_ticks,
|
||
"rtk_unix_time_ns": "" if sample is None else sample.unix_time_ns,
|
||
"rtk_lat": "" if sample is None else sample.lat,
|
||
"rtk_lon": "" if sample is None else sample.lon,
|
||
"rtk_alt_m": "" if sample is None else sample.alt_m,
|
||
"rtk_raw_heading_deg": "" if sample is None else sample.raw_heading_deg,
|
||
"rtk_vehicle_heading_deg": "" if sample is None else sample.vehicle_heading_deg,
|
||
"rtk_fix": "" if sample is None else sample.fix,
|
||
"rtk_sat": "" if sample is None else sample.sat,
|
||
"rtk_position_valid": "" if sample is None else sample.position_valid,
|
||
"rtk_heading_valid": "" if sample is None else sample.heading_valid,
|
||
"rtk_heading_solution": "" if sample is None else sample.heading_solution,
|
||
"rtk_position_time": "" if sample is None else sample.position_time,
|
||
"rtk_heading_time": "" if sample is None else sample.heading_time,
|
||
"rtk_last_line": "" if sample is None else sample.last_line,
|
||
"rtk_source_dorec": "" if sample is None else sample.source_dorec,
|
||
"rtk_source_offset": "" if sample is None else sample.source_offset,
|
||
}
|
||
)
|
||
|
||
row.update(
|
||
{
|
||
"timestamp_iso_local": timestamp_iso,
|
||
"unix_time_ns": unix_ns,
|
||
"record_id": record_meta["record_id"],
|
||
"record_id_bytes_hex": record_meta["record_id_bytes_hex"],
|
||
"frame_counter": frame_counter,
|
||
"point_count": point_count,
|
||
"output_file": relative_output,
|
||
}
|
||
)
|
||
if args.resume and frame_path.exists():
|
||
row["status"] = "resumed"
|
||
resumed += 1
|
||
else:
|
||
xyz_sensor, xyz_cart = generate_xyz(raw_points, args.include_xyz, translation, rotation)
|
||
if args.format == "npz":
|
||
export_npz(
|
||
frame_path,
|
||
metadata,
|
||
raw_points,
|
||
point_count,
|
||
xyz_sensor,
|
||
xyz_cart,
|
||
args.compress,
|
||
rtk_arrays=rtk_arrays,
|
||
)
|
||
else:
|
||
export_pickle(frame_path, metadata, raw_points, point_count, xyz_sensor, xyz_cart)
|
||
row["status"] = "exported"
|
||
exported += 1
|
||
total_points += point_count
|
||
except Exception as exc:
|
||
skipped += 1
|
||
row["error"] = f"{type(exc).__name__}: {exc}"
|
||
writer.writerow(row)
|
||
if (selected_index + 1) % 25 == 0 or selected_index + 1 == len(selected):
|
||
manifest_stream.flush()
|
||
if match_stream is not None:
|
||
match_stream.flush()
|
||
print(
|
||
f"[{selected_index + 1}/{len(selected)}] exported={exported} resumed={resumed} "
|
||
f"errors={skipped} points={total_points}",
|
||
flush=True,
|
||
)
|
||
|
||
if match_stream is not None:
|
||
match_stream.close()
|
||
if write_reports and match_tmp is not None and match_final is not None:
|
||
os.replace(match_tmp, match_final)
|
||
if write_reports and manifest_tmp is not None and manifest_final is not None:
|
||
os.replace(manifest_tmp, manifest_final)
|
||
|
||
lidar_stats = {
|
||
"records_discovered": len(records),
|
||
"records_selected": len(selected),
|
||
"frames_exported": exported,
|
||
"frames_resumed": resumed,
|
||
"frames_with_errors": skipped,
|
||
"total_points_in_manifest": total_points,
|
||
}
|
||
validation = build_validation_report(
|
||
lidar_stats=lidar_stats,
|
||
gps_post_z_stats=gps_post_z_stats,
|
||
rtk_binary_stats=rtk_binary_stats,
|
||
gps_post_stats=gps_post_stats,
|
||
text_stats=text_stats,
|
||
match_dts_ns=match_dts_ns,
|
||
interior_dts_ns=interior_dts_ns,
|
||
edge_frames=edge_frames,
|
||
matched_frames=matched_frames,
|
||
unmatched_frames=unmatched_frames,
|
||
rtk_max_dt_ms=args.rtk_max_dt_ms,
|
||
rtk_sidecars=rtk_sidecars,
|
||
)
|
||
|
||
write_package_readme(
|
||
output / "README.md",
|
||
format_version=FORMAT_VERSION,
|
||
frame_count=exported + resumed,
|
||
include_rtk=include_rtk,
|
||
timezone_text=args.timezone,
|
||
)
|
||
|
||
metadata = {
|
||
"format_version": FORMAT_VERSION,
|
||
"generated_at": datetime.now().astimezone().isoformat(),
|
||
"source_dlog": str(root),
|
||
"object_name": args.object,
|
||
"output_format": args.format,
|
||
"npz_compressed": bool(args.compress and args.format == "npz"),
|
||
"include_xyz": args.include_xyz,
|
||
"include_rtk": include_rtk,
|
||
"rtk_sidecars": rtk_sidecars,
|
||
"write_reports": write_reports,
|
||
"timezone": args.timezone,
|
||
"timezone_offset_minutes": timezone_minutes(tz),
|
||
"rtk_max_dt_ms": args.rtk_max_dt_ms,
|
||
"rtk_objects": list(RTK_OBJECT_NAMES),
|
||
"rtk_outputs": rtk_outputs if rtk_sidecars else None,
|
||
"package_contents": ["README.md", "frames/"]
|
||
+ (["rtk/"] if rtk_sidecars else [])
|
||
+ (["reports/"] if write_reports else []),
|
||
**lidar_stats,
|
||
"point_columns": [
|
||
{"name": "d_mm", "dtype": "float32", "unit": "mm"},
|
||
{"name": "azimuth_deg", "dtype": "float32", "unit": "degree"},
|
||
{"name": "altitude_deg", "dtype": "float32", "unit": "degree"},
|
||
{"name": "intensity", "dtype": "float32", "unit": "device-specific"},
|
||
{"name": "progression", "dtype": "float32", "unit": "scan fraction"},
|
||
],
|
||
"extrinsic": {
|
||
"translation_mm": list(translation),
|
||
"yaw_pitch_roll_deg": [args.yaw, args.pitch, args.roll],
|
||
"rotation_row_major": list(rotation),
|
||
},
|
||
"validation_ok": validation["ok"],
|
||
"duration_seconds": round(time.time() - started, 3),
|
||
}
|
||
if write_reports:
|
||
with (reports_dir / "metadata.json").open("w", encoding="utf-8") as stream:
|
||
json.dump(metadata, stream, ensure_ascii=False, indent=2)
|
||
stream.write("\n")
|
||
with (reports_dir / "validation_report.json").open("w", encoding="utf-8") as stream:
|
||
json.dump(validation, stream, ensure_ascii=False, indent=2)
|
||
stream.write("\n")
|
||
|
||
print(json.dumps(metadata, ensure_ascii=False, indent=2), flush=True)
|
||
print(
|
||
json.dumps(
|
||
{"validation_ok": validation["ok"], "checks": validation["checks"]},
|
||
ensure_ascii=False,
|
||
indent=2,
|
||
),
|
||
flush=True,
|
||
)
|
||
if skipped != 0:
|
||
return 1
|
||
if include_rtk and not validation["ok"]:
|
||
return 1
|
||
return 0
|
||
|
||
|
||
def main() -> int:
|
||
try:
|
||
return export_dataset(parse_args())
|
||
except Exception as exc:
|
||
print(f"ERROR: {type(exc).__name__}: {exc}", file=sys.stderr)
|
||
return 2
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|