diff --git a/pyproject.toml b/pyproject.toml index 51122e8..6b49d09 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ dependencies = [ "distinctipy>=1.3.4", "gdown>=6.1.0", "joblib>=1.5.2", + "laspy[lazrs]>=2.6", "numpy", "polars>=1.43.2", "pyyaml>=6.0", diff --git a/python/evalio/datasets/__init__.py b/python/evalio/datasets/__init__.py index 344b5c4..c0840c2 100644 --- a/python/evalio/datasets/__init__.py +++ b/python/evalio/datasets/__init__.py @@ -8,6 +8,7 @@ from .helipr import HeLiPR from .hilti_2022 import Hilti2022 from .loaders import RawDataIter, RosbagIter +from .mandeye import Mandeye from .multi_campus import MultiCampus from .newer_college_2020 import NewerCollege2020 from .newer_college_2021 import NewerCollege2021 @@ -43,6 +44,7 @@ "HeLiPR", "Hilti2022", "InvalidDatasetConfig", + "Mandeye", "MultiCampus", "NewerCollege2020", "NewerCollege2021", diff --git a/python/evalio/datasets/mandeye.py b/python/evalio/datasets/mandeye.py new file mode 100644 index 0000000..bffe032 --- /dev/null +++ b/python/evalio/datasets/mandeye.py @@ -0,0 +1,298 @@ +import urllib +import urllib.request +from collections.abc import Iterator, Sequence +from dataclasses import dataclass +from enum import auto +from pathlib import Path +from typing import Any, Optional + +import numpy as np +from tqdm.rich import tqdm + +from evalio.datasets.loaders import RawDataIter +from evalio.types import ( + SE3, + SO3, + Duration, + ImuMeasurement, + ImuParams, + LidarMeasurement, + LidarParams, + Point, + Stamp, + Trajectory, +) + +from .base import ( + Dataset, + DatasetIterator, +) + +""" +All sequences are recorded with the same Mandeye handheld scanner, a Livox Mid-360 with its built-in IMU. +Data is stored in the raw Mandeye chunk layout: lidarNNNN.laz and imuNNNN.csv on a shared clock. +""" + + +# https://github.com/pytorch/vision/blob/fc746372bedce81ecd53732ee101e536ae3afec1/torchvision/datasets/utils.py#L27 +def _urlretrieve(url: str, filename: Path, chunk_size: int = 1024 * 32) -> None: + with ( + urllib.request.urlopen( + urllib.request.Request(url, headers={"User-Agent": "evalio"}) + ) as response, + open(filename, "wb") as fh, + tqdm( + total=response.length, unit="B", unit_scale=True, dynamic_ncols=True + ) as pbar, + ): + while chunk := response.read(chunk_size): + fh.write(chunk) + pbar.update(len(chunk)) + + +@dataclass +class _SequenceInfo: + hf_repo: str + # folder of the chunk files inside the Hugging Face repo + subdir: str + num_laz: int + num_csv: int + environment: str + # TUM trajectory (HDMapping step 3 export), path inside the repo; stored next to the chunks locally + gt_file: Optional[str] = None + quick_len: Optional[int] = None + + +# Adding a recording is one entry here plus a member in Mandeye, e.g. the headered IMU csv / ns stamps of +# MichalPelka/MandeyeMappingLoopClosure (subdir="", 67 laz, 66 csv) are handled by the loaders below +_SEQUENCES = { + "underground_parking": _SequenceInfo( + hf_repo="MichalPelka/UndegroundParkingDataset", + subdir="mandeye", + num_laz=25, + num_csv=25, + environment="Underground Parking", + gt_file="ground-truth/trajectory.tum", + quick_len=2724, + ), +} + +_IMU_ID = 0 +_SCAN_PERIOD_NS = 100_000_000 +_G = 9.81 + + +def _load_imu(file: Path) -> np.ndarray: + """Rows of (stamp [ns], gyro [rad/s], accel [g]) for IMU id 0. + + Handles both the headered format and the legacy headerless "ts gx gy gz ax ay az [imuId [unix]]". + """ + with open(file) as f: + first = f.readline().split() + if first and first[0] == "timestamp": + col = {name: i for i, name in enumerate(first)} + names = ["timestamp", "gyroX", "gyroY", "gyroZ", "accX", "accY", "accZ"] + data = np.loadtxt(file, skiprows=1, ndmin=2) + if data.size == 0: + return np.zeros((0, 7)) + if "imuId" in col: + data = data[data[:, col["imuId"]] == _IMU_ID] + return data[:, [col[n] for n in names]] + + data = np.loadtxt(file, ndmin=2) + if data.size == 0: + return np.zeros((0, 7)) + if data.shape[1] > 7: + data = data[data[:, 7] == _IMU_ID] + return data[:, :7] + + +def _load_laz(file: Path) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Time-sorted (stamps [ns], xyz, intensity) of a Mandeye chunk.""" + import laspy # pyright: ignore[reportMissingTypeStubs] + + las: Any = laspy.read(file) # pyright: ignore[reportUnknownMemberType] + t = np.asarray(las.gps_time, dtype=np.float64) + xyz = np.stack([np.asarray(las.x), np.asarray(las.y), np.asarray(las.z)], axis=1) + intensity = np.asarray(las.intensity, dtype=np.float64) + + keep = t != 0 + t, xyz, intensity = t[keep], xyz[keep], intensity[keep] + # A chunk covers a few seconds, so a span below 1000 means seconds rather than nanoseconds + if t.size > 0 and t.max() - t.min() < 1e3: + t = t * 1e9 + stamps = np.round(t).astype(np.int64) + + order = np.argsort(stamps, kind="stable") + return stamps[order], xyz[order], intensity[order] + + +class Mandeye(Dataset): + """Handheld datasets recorded with the [Mandeye](https://github.com/JanuszBedkowski/mandeye_controller) open-hardware scanner, built around a Livox Mid-360 and its internal IMU. + + The raw data is stored in chunks of a few seconds, which are cut into 0.1 s scans. The underground parking sequence is accompanied by a terrestrial laser scan of the garage. + """ + + underground_parking = auto() + + @property + def _info(self) -> _SequenceInfo: + return _SEQUENCES[self.seq_name] + + def _laz_files(self) -> list[str]: + return [f"lidar{i:04d}.laz" for i in range(self._info.num_laz)] + + def _csv_files(self) -> list[str]: + return [f"imu{i:04d}.csv" for i in range(self._info.num_csv)] + + # ------------------------- For loading data ------------------------- # + def data_iter(self) -> DatasetIterator: + # Load in all IMU data + imu = np.concatenate([_load_imu(self.folder / f) for f in self._csv_files()]) + imu = imu[np.argsort(imu[:, 0], kind="stable")] + stamps = imu[:, 0].astype(np.int64) + imu = imu[np.concatenate([[True], np.diff(stamps) > 0])] + + def imu_iter() -> Iterator[ImuMeasurement]: + for row in imu: + yield ImuMeasurement( + Stamp.from_nsec(int(row[0])), row[1:4], row[4:7] * _G + ) + + laz_files = [self.folder / f for f in self._laz_files()] + + def lidar_iter() -> Iterator[LidarMeasurement]: + def scan( + start: int, t: np.ndarray, xyz: np.ndarray, intensity: np.ndarray + ) -> LidarMeasurement: + stamp = Stamp.from_nsec(start) + xyz_l: list[list[float]] = xyz.tolist() + intensity_l: list[float] = intensity.tolist() + t_l: list[int] = t.tolist() + points = [ + Point( + x=p[0], + y=p[1], + z=p[2], + intensity=i, + t=Duration.from_nsec(int(s - start)), + ) + for p, i, s in zip(xyz_l, intensity_l, t_l) + ] + return LidarMeasurement(stamp, points) + + start: Optional[int] = None + # Points of the last unfinished scan, carried over into the next chunk + rest = (np.zeros(0, np.int64), np.zeros((0, 3)), np.zeros(0)) + for file in laz_files: + t, xyz, intensity = _load_laz(file) + t = np.concatenate([rest[0], t]) + xyz = np.concatenate([rest[1], xyz]) + intensity = np.concatenate([rest[2], intensity]) + if t.size == 0: + continue + if start is None: + start = int(t[0]) + + edges = np.arange(start, int(t[-1]) + 1, _SCAN_PERIOD_NS) + idx = np.searchsorted(t, edges) + for k in range(len(edges) - 1): + a, b = idx[k], idx[k + 1] + if b > a: + yield scan(int(edges[k]), t[a:b], xyz[a:b], intensity[a:b]) + start = int(edges[-1]) + a = idx[-1] + rest = (t[a:], xyz[a:], intensity[a:]) + + if start is not None and rest[0].size > 0: + yield scan(start, *rest) + + return RawDataIter( + lidar_iter(), + imu_iter(), + self.quick_len() or 0, + ) + + def ground_truth_raw(self) -> Optional[Trajectory]: + if self._info.gt_file is None: + return None + return Trajectory.from_csv( + self.folder / Path(self._info.gt_file).name, + ["sec", "x", "y", "z", "qx", "qy", "qz", "qw"], + delimiter=" ", + ) + + # ------------------------- For loading params ------------------------- # + def imu_T_lidar(self) -> SE3: + # IMU position in the Livox Mid-360 point cloud frame, from the Livox spec sheet + return SE3(SO3.identity(), np.array([-0.011, -0.02329, 0.04412])) + + def imu_T_gt(self) -> SE3: + # Ground truth is computed by HDMapping, which uses the lidar frame + return self.imu_T_lidar() + + def imu_params(self) -> ImuParams: + # ICM40609 inside the Mid-360, noise values are approximate + return ImuParams( + gyro=1.745e-3, + accel=1.5e-2, + gyro_bias=1e-5, + accel_bias=1e-4, + bias_init=1e-8, + integration=1e-8, + gravity=np.array([0, 0, -_G]), + rate=200.0, + brand="TDK", + model="ICM40609", + ) + + def lidar_params(self) -> LidarParams: + return LidarParams( + num_rows=4, + num_columns=5000, + min_range=0.1, + max_range=70.0, + rate=10.0, + brand="Livox", + model="Mid-360", + ) + + # ------------------------- dataset info ------------------------- # + @staticmethod + def url() -> str: + return "https://huggingface.co/MichalPelka" + + def environment(self) -> str: + return self._info.environment + + def vehicle(self) -> str: + return "Handheld" + + def quick_len(self) -> Optional[int]: + return self._info.quick_len + + # ------------------------- For downloading ------------------------- # + def files(self) -> Sequence[str | Path]: + out = self._laz_files() + self._csv_files() + if self._info.gt_file is not None: + out.append(Path(self._info.gt_file).name) + return out + + def download(self): + info = self._info + base = f"https://huggingface.co/datasets/{info.hf_repo}/resolve/main/" + sub = f"{info.subdir}/" if info.subdir else "" + remote = [sub + f for f in self._laz_files() + self._csv_files()] + if info.gt_file is not None: + remote.append(info.gt_file) + + print(f"Downloading to {self.folder}...") + self.folder.mkdir(parents=True, exist_ok=True) + for r in remote: + path = self.folder / Path(r).name + if path.exists(): + continue + print(f"Downloading {r}...") + tmp = path.with_suffix(path.suffix + ".part") + _urlretrieve(base + r, tmp) + tmp.rename(path) diff --git a/tests/data/imu_mandeye.pkl b/tests/data/imu_mandeye.pkl new file mode 100644 index 0000000..08ec1d6 Binary files /dev/null and b/tests/data/imu_mandeye.pkl differ diff --git a/tests/data/lidar_mandeye.pkl b/tests/data/lidar_mandeye.pkl new file mode 100644 index 0000000..b6754dd Binary files /dev/null and b/tests/data/lidar_mandeye.pkl differ diff --git a/uv.lock b/uv.lock index d204095..de6d65e 100644 --- a/uv.lock +++ b/uv.lock @@ -319,6 +319,7 @@ dependencies = [ { name = "distinctipy" }, { name = "gdown" }, { name = "joblib" }, + { name = "laspy", extra = ["lazrs"] }, { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "polars" }, @@ -361,6 +362,7 @@ requires-dist = [ { name = "distinctipy", specifier = ">=1.3.4" }, { name = "gdown", specifier = ">=6.1.0" }, { name = "joblib", specifier = ">=1.5.2" }, + { name = "laspy", extras = ["lazrs"], specifier = ">=2.6" }, { name = "numpy" }, { name = "polars", specifier = ">=1.43.2" }, { name = "pyyaml", specifier = ">=6.0" }, @@ -496,6 +498,56 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/37/7c/67ada9621b3c93caa75aba1a0b651dae988b22cdea93b33e7e49261364f5/joblib_stubs-1.5.3.1.20260117-py3-none-any.whl", hash = "sha256:ef9f570617b232263c8166f5b4351a5dcc90669d8b07e7e2cd7b25c3b0f26d5f", size = 21358, upload-time = "2026-01-17T11:16:44.327Z" }, ] +[[package]] +name = "laspy" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9c/5d/5a540ac25dcc57fa77691363bb753363b192a3759aefef4b40e1250ddb17/laspy-2.7.0.tar.gz", hash = "sha256:f56feb5445e75d6ff12ee814aab6a35339290e75264ff277c6bf553f3025a3f5", size = 1929624, upload-time = "2026-01-14T22:18:42.856Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/fb/ae27ebca327117e35cc717a3a87afdcbbf10d5aac52e405d16ebe777357e/laspy-2.7.0-py3-none-any.whl", hash = "sha256:15f5344c62a1023461996bdf5d1ba5fdd813e96694a524fee712931134f3792f", size = 86056, upload-time = "2026-01-14T22:18:40.779Z" }, +] + +[package.optional-dependencies] +lazrs = [ + { name = "lazrs" }, +] + +[[package]] +name = "lazrs" +version = "0.8.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/32/8d/98a802eda6478aa14132f330cc92baa2730200b40716aedfc2ed4aaaf895/lazrs-0.8.2.tar.gz", hash = "sha256:80a30ad1798a9fd58e84f238ca23f5002555ba16553e2afedafa6bde494229e2", size = 9866, upload-time = "2026-07-27T10:12:19.727Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/f0/eae983763e5babc3b42731dbb75de1aef17dcf8bf8885e5913827c3a2bb6/lazrs-0.8.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ddeea17cd291fbf5e6a18e24440d25a7389d5a4cc3930466421a688464f5f188", size = 564910, upload-time = "2026-07-27T10:06:47.418Z" }, + { url = "https://files.pythonhosted.org/packages/97/1e/8b80f896277a815280ab4fdf3d7cdbb98c01199ecd4ef16047252904c35a/lazrs-0.8.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b5029d4a889f0a2eafc76857c37f9bb96dd223addd3e252339b66e88f9a0178f", size = 567279, upload-time = "2026-07-27T10:06:56.081Z" }, + { url = "https://files.pythonhosted.org/packages/b6/05/d5563a6b17b9ac1ccdff1db381dc9e6ac3ef0d6303fd9be00812906178b5/lazrs-0.8.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fec70f58e6c4193f3816ec55a51bd3de4c7f2010572e880f8fcaae8eccd0a563", size = 634191, upload-time = "2026-07-27T10:07:05.24Z" }, + { url = "https://files.pythonhosted.org/packages/41/ac/9236762ec15bc7b90061c8daa3256c480d2238107fb76da1bfdfb62c6c3d/lazrs-0.8.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:34d84a4e5874c03b986e7b69b1c972c5dfe023537a1ac4c198e3012f0042bb0a", size = 631025, upload-time = "2026-07-27T10:07:14.482Z" }, + { url = "https://files.pythonhosted.org/packages/1f/a2/9e995ee520e3fdb508ec27d4374ab8cf43b7e441e50c536805235ee91282/lazrs-0.8.2-cp311-cp311-win32.whl", hash = "sha256:de20b66623bfcfd390112a4f43a2840405b1a4005bbaf512d1abe45d02845741", size = 414905, upload-time = "2026-07-27T10:07:21.33Z" }, + { url = "https://files.pythonhosted.org/packages/d6/56/8c6cffc62bce1639d8793cb1e4f49f6475b931bd61e3061dc69e17d5b35c/lazrs-0.8.2-cp311-cp311-win_amd64.whl", hash = "sha256:29e49fb839ee82f014495cf79bb43559b64696b3440db878e340b4aab0b53df9", size = 436884, upload-time = "2026-07-27T10:07:28.246Z" }, + { url = "https://files.pythonhosted.org/packages/f0/5f/6efa6fa7bde5cfee3af5311015fb61d1a48522f6f10ccd6d292fc41f3cfe/lazrs-0.8.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:99ea3de5796d6c651d23ee0b130ad55477c49939cfc7a371f2f23cd1945db762", size = 564149, upload-time = "2026-07-27T10:07:36.82Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f6/cb15b15156413d0a69cc6e0b83ff74e096139acfeb3b6eb8eb82d96a7d02/lazrs-0.8.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7b6d4680a75cf0b8594cbec3e4c0db1c839f78876750eeffdfbbb6684a487746", size = 561429, upload-time = "2026-07-27T10:07:45.878Z" }, + { url = "https://files.pythonhosted.org/packages/c2/82/b0024b0755b72718b09c5cbe441db3886b26e4c57fec6ddf4a6aac47d171/lazrs-0.8.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:237bdb78596dc0a2f7a953833787d7cb65da39025723caa922bb81e2a1391224", size = 629393, upload-time = "2026-07-27T10:07:55.011Z" }, + { url = "https://files.pythonhosted.org/packages/16/e1/15266daf710ebcea7b45676d5ce040bb8136396f0d5b8706df4290473e9b/lazrs-0.8.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e7d82b5ea4cb0e503970a619fb792dbbcdcb2cd8c3adf9c08fab3405abc8e60", size = 626911, upload-time = "2026-07-27T10:08:03.916Z" }, + { url = "https://files.pythonhosted.org/packages/ef/96/b7495f396371c91de26e46d18103d48b976b0b03148497b78573d5bf814f/lazrs-0.8.2-cp312-cp312-win32.whl", hash = "sha256:ad1dd71a70f3e93f2e9f52bad8cfddf981bde3245a9819d62a6af6fed60a23e8", size = 411690, upload-time = "2026-07-27T10:08:10.344Z" }, + { url = "https://files.pythonhosted.org/packages/51/5d/03adf33c24c9c0bceeb234c44bcaa665aa72fc9ce8f5b64ac8b7c19d4378/lazrs-0.8.2-cp312-cp312-win_amd64.whl", hash = "sha256:213803fbaaf734d5ff3c886bb4a2d173ea24a31383c3cda5383b9bc16e8f0635", size = 433133, upload-time = "2026-07-27T10:08:17.122Z" }, + { url = "https://files.pythonhosted.org/packages/6b/2b/8bcddceadc0395950723c2ea4c66861a5fbd65d18aed5dc6e3f79ce74a4c/lazrs-0.8.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:d9d16f33fefee7642894cf4735e29f0f693f74d26f8c6a822d60982d26900e09", size = 563678, upload-time = "2026-07-27T10:08:25.402Z" }, + { url = "https://files.pythonhosted.org/packages/61/af/0cbdabcefa8b0055f0ee03faa5ea1c56f6bd3d4f954ba5ed6306904cd9c4/lazrs-0.8.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:014805e852a1dfd7d53d55561fb6c331ba59aacfc9a4832b4d9f936c8552753a", size = 561288, upload-time = "2026-07-27T10:08:33.48Z" }, + { url = "https://files.pythonhosted.org/packages/50/ee/1a9a30a17839f18b53dbe417853569df7200d370f0de36768c0d6e8e48bf/lazrs-0.8.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6907aa7f277ead6572a8d72f0ab0ebe9e23ae79960a0f3676fc50144d0fb7a0b", size = 629455, upload-time = "2026-07-27T10:08:42.372Z" }, + { url = "https://files.pythonhosted.org/packages/24/27/80f1529cda878c0c4e051162e15cd1a6c7107d35a344ee49658d89a165f2/lazrs-0.8.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b7b5de291b1c6892bda5a3cec6a06ab582ed5bff513066cc3ba947ae1739a87", size = 627238, upload-time = "2026-07-27T10:08:52.281Z" }, + { url = "https://files.pythonhosted.org/packages/a4/b3/c3d6401a8c49f19f8721b719dd8d0cc4a2bbbd8d56e1439b65d3b12b1b34/lazrs-0.8.2-cp313-cp313-win32.whl", hash = "sha256:21aa211d20a80729564b40f8c56d01d1f150332e29bac50270800839af0b45da", size = 411623, upload-time = "2026-07-27T10:08:58.958Z" }, + { url = "https://files.pythonhosted.org/packages/74/da/20d2d48acbe0335d09483960df20de81c3b83e1bc5deaff7ae08d04cae15/lazrs-0.8.2-cp313-cp313-win_amd64.whl", hash = "sha256:f76330be981c0385841e3d11428c7808b3527c0e762b6e664b5c446c3477d6bd", size = 432742, upload-time = "2026-07-27T10:09:05.775Z" }, + { url = "https://files.pythonhosted.org/packages/71/b6/a02b5f95d2c4915cb458e1bba72f74c6d6bd8d54a7bcffc36d65f92789fa/lazrs-0.8.2-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c62115d323a2985682cf0c8a7d5154dde00ecd09803e1df077eefe2a6cbc13ee", size = 565667, upload-time = "2026-07-27T10:09:13.577Z" }, + { url = "https://files.pythonhosted.org/packages/82/d4/dfcba8ae853858df8a495e891de9a9873fe2e65dba12dd0ea771f3abbba8/lazrs-0.8.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a7f5fd1ec5b56901344fafba9ffb6fb26754445e2bbdcb2dfbbc492b82924924", size = 561890, upload-time = "2026-07-27T10:09:21.345Z" }, + { url = "https://files.pythonhosted.org/packages/2e/df/def3dc0b44d25f9e959a42e94d0795d11b9ce1ca3689541da021605aed51/lazrs-0.8.2-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40806a2dcac89560fb41936c87ec0ab5ecda95c9622b141e9bfd1d70c6e5438c", size = 629913, upload-time = "2026-07-27T10:09:29.736Z" }, + { url = "https://files.pythonhosted.org/packages/9b/e5/ea06052f3432ece2e3c7eefc49d44296234faf01a93f13a6241f13f9be59/lazrs-0.8.2-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f481e27206db60e3f9d1d057c37d4c2ecc6e2af21e4c6b60b3a9fa347062aa50", size = 627518, upload-time = "2026-07-27T10:09:38.525Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b7/382263697ea1bf2fa5da64b3d3ea5518fce8a459270602dc9e7dc4fc3209/lazrs-0.8.2-cp314-cp314-win32.whl", hash = "sha256:e852e87af20972d8b685855ebd41bcd1b600dc5fa78ee4810dbfe920669e91e4", size = 411669, upload-time = "2026-07-27T10:09:44.967Z" }, + { url = "https://files.pythonhosted.org/packages/48/97/53ad8cd78e966baf78557f1a29711d273a55a5c4dc0d05853500930b25d6/lazrs-0.8.2-cp314-cp314-win_amd64.whl", hash = "sha256:dd5a462888178d5cbaa2dd30b1b2d99f164c0254791c987d2f47081229e92fb3", size = 433621, upload-time = "2026-07-27T10:09:52.221Z" }, +] + [[package]] name = "lz4" version = "4.4.5"