diff --git a/docs/cli/t4sanity.md b/docs/cli/t4sanity.md index e18d694..3a6a583 100644 --- a/docs/cli/t4sanity.md +++ b/docs/cli/t4sanity.md @@ -1,23 +1,23 @@ -`t4sanity` performs sanity checks on T4 datasets, reporting any issues in a structured format. -It checks the dataset directories and versions, tries to load them using the `Tier4` library, and reports any exceptions or warnings. +`t4sanity` performs sanity checks on T4 datasets, reporting any issues regarding the [dataset requirements](../schema/requirement.md). ```shell $ t4sanity -h Usage: t4sanity [OPTIONS] DB_PARENT -╭─ Arguments ──────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ * db_parent TEXT Path to parent directory of the databases [default: None] [required] │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ -╭─ Options ────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ --version -v Show the application version and exit. │ -│ --output -o TEXT Path to output JSON file. [default: None] │ -│ --revision -rv TEXT Specify if you want to load the specific version. [default: None] │ -│ --include-warning -iw Indicates whether to report any warnings. │ -│ --install-completion Install completion for the current shell. │ -│ --show-completion Show completion for the current shell, to copy it or customize the installation. │ -│ --help -h Show this message and exit. │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭─ Arguments ───────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ * data_root TEXT Path to root directory of a dataset. [default: None] [required] │ +╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭─ Options ─────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ --version -v Show the application version and exit. │ +│ --output -o TEXT Path to output JSON file. │ +│ --revision -rv TEXT Specify if you want to check the specific version. │ +│ --exclude -e TEXT Exclude specific rules or rule groups. │ +│ --include-warning -iw Indicates whether to report any warnings. │ +│ --install-completion Install completion for the current shell. │ +│ --show-completion Show completion for the current shell, to copy it or customize the installation. │ +│ --help -h Show this message and exit. │ +╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ``` ## Shell Completion @@ -33,58 +33,36 @@ t4sanity --install-completion As an example, we have the following the dataset structure: ```shell - -├── dataset1 -│ └── -│ ├── annotation -│ ├── data -| ... -├── dataset2 -│ ├── annotation -│ ├── data -| ... -... + +├── +│ ├── annotation +│ ├── data +| ... ``` Then, you can run sanity checks with `t4sanity `: -```shell ->>>Sanity checking...: 1it [00:00, 9.70it/s] -✅ No exceptions occurred!! -``` - -### Exclude Warnings - -To run sanity check ignoring warnings, providing the path to the parent directory of the datasets: - ```shell $ t4sanity ->>>Sanity checking...: 2it [00:00, 18.69it/s] -⚠️ Encountered some exceptions!! -+-----------+---------+--------+------------------------------------------------------------------------------------------------+ -| DatasetID | Version | Status | Message | -+-----------+---------+--------+------------------------------------------------------------------------------------------------+ -| dataset1 | 2 | ERROR | bbox must be (xmin, ymin, xmax, ymax) and xmin <= xmax && ymin <= ymax: (1532, 198, 1440, 265) | -| dataset2 | 1 | OK | | -+-----------+---------+--------+------------------------------------------------------------------------------------------------+ -``` - -### Include Warnings - -To run sanity check and report any warnings, use the `-iw; --include-warning` option: +>>>Sanity checking...: 1it [00:00, 9.70it/s] -```shell -$ t4sanity -iw - ->>>Sanity checking...: 2it [00:00, 21.54it/s] -⚠️ Encountered some exceptions!! -+-----------+---------+---------+------------------------------------------------------------------------------------------------+ -| DatasetID | Version | Status | Message | -+-----------+---------+---------+------------------------------------------------------------------------------------------------+ -| dataset1 | 2 | ERROR | bbox must be (xmin, ymin, xmax, ymax) and xmin <= xmax && ymin <= ymax: (1532, 198, 1440, 265) | -| dataset2 | 1 | WARNING | Category token is empty for surface ann: 0c15d9c143fb2723c16ac7e0c735b0a8 | -+-----------+---------+---------+------------------------------------------------------------------------------------------------+ +=== DatasetID: dataset1 === + STR001: ✅ + STR002: ✅ + STR003: ✅ + STR004: ✅ + STR005: ✅ + STR006: ✅ + STR007: ✅ + STR008: ✅ + ... + ++-----------+---------+---------+-------+---------+----------+-------+ +| DatasetID | Version | Status | Rules | Success | Failures | Skips | ++-----------+---------+---------+-------+---------+----------+-------+ +| dataset1 | 0 | SUCCESS | 44 | 44 | 0 | 0 | ++-----------+---------+---------+-------+---------+----------+-------+ ``` ### Dump Results as JSON @@ -92,27 +70,32 @@ $ t4sanity -iw To dump results into JSON, use the `-o; --output` option: ```shell -$ t4sanity -o results.json - ->>>Sanity checking...: 2it [00:00, 21.54it/s] -... +t4sanity -o result.json ``` -Then a JSON file named `results.json` will be generated: +Then a JSON file named `result.json` will be generated as follows: ```json -[ - { - "dataset_id": "dataset1", - "version": 2, - "status": "ERROR", - "message": "bbox must be (xmin, ymin, xmax, ymax) and xmin <= xmax && ymin <= ymax: (1532, 198, 1440, 265)" - }, - { - "dataset_id": "dataset2", - "version": 1, - "status": "WARNING", - "message": "Category token is empty for surface ann: 0c15d9c143fb2723c16ac7e0c735b0a8" - } -] +{ + "dataset_id": "", + "version": , + "reports": [ + { + "id": "", + "name": "", + "description": "", + "status": "", + "reasons": "<[, , ...]: [str; N] | null>" // Failure or skipped reasons, null if success + }, + ] +} +``` + +### Exclude Checks + +With `-e; --excludes` option enables us to exclude specific checks by specifying the **rule IDs or groups**: + +```shell +# Exclude STR001 and all FMT-relevant rules +t4sanity -e STR001 -e FMT ``` diff --git a/docs/schema/requirement.md b/docs/schema/requirement.md new file mode 100644 index 0000000..9988179 --- /dev/null +++ b/docs/schema/requirement.md @@ -0,0 +1,75 @@ +# Dataset Requirements + +## Structure (`STR`) + +| ID | Name | Severity | Description | +| -------- | ----------------------------- | -------- | -------------------------------------------------------------------- | +| `STR001` | `version-dir-presence` | `Warn` | `version/` directory exists under the dataset root directory. | +| `STR002` | `annotation-dir-presence` | `Error` | `annotation/` directory exists under the dataset root directory. | +| `STR003` | `data-dir-presence` | `Error` | `data/` directory exists under the dataset root directory. | +| `STR004` | `map-dir-presence` | `Error` | `map/` directory exists under the dataset root directory. | +| `STR005` | `bag-dir-presence` | `Error` | `input_bag/` directory exists under the dataset root directory. | +| `STR006` | `status-file-presence` | `Error` | `status.json` file exists under the dataset root directory. | +| `STR007` | `schema-files-presence` | `Error` | Mandatory schema JSON files exist under the `annotation/` directory. | +| `STR008` | `lanelet-file-presence` | `Warn` | `lanelet2_map.osm` file exists under the `map/` directory. | +| `STR009` | `pointcloud-map-dir-presence` | `Warn` | `pointcloud_map.pcd` directory exists under the `map/` directory. | + +## Schema Record (`REC`) + +| ID | Name | Severity | Description | +| -------- | ----------------------------- | -------- | --------------------------------------- | +| `REC001` | `scene-single` | `Error` | `Scene` record is a single. | +| `REC002` | `sample-not-empty` | `Error` | `Sample` record is not empty. | +| `REC003` | `sample-data-not-empty` | `Error` | `SampleData` record is not empty. | +| `REC004` | `ego-pose-not-empty` | `Error` | `EgoPose` record is not empty. | +| `REC005` | `calibrated-sensor-non-empty` | `Error` | `CalibratedSensor` record is not empty. | +| `REC006` | `instance-not-empty` | `Error` | `Instance` record is not empty. | + +## Reference (`REF`) + +| ID | Name | Severity | Description | +| -------- | ------------------------------------- | -------- | ------------------------------------------------------------------------- | +| `REF001` | `scene-to-log` | `Error` | `Scene.log_token` refers to `Log` record. | +| `REF002` | `scene-to-first-sample` | `Error` | `Scene.first_sample_token` refers to `Sample` record. | +| `REF003` | `scene-to-last-sample` | `Error` | `Scene.last_sample_token` refers to `Sample` record. | +| `REF004` | `sample-to-scene` | `Error` | `Sample.scene_token` refers to `Scene` record. | +| `REF005` | `sample-data-to-sample` | `Error` | `SampleData.sample_token` refers to `Sample` record. | +| `REF006` | `sample-data-to-ego-pose` | `Error` | `SampleData.ego_pose_token` refers to `EgoPose` record. | +| `REF007` | `sample-data-to-calibrated-sensor` | `Error` | `SampleData.calibrated_sensor_token` refers to `CalibratedSensor` record. | +| `REF008` | `calibrated-sensor-to-sensor` | `Error` | `CalibratedSensor.sensor_token` refers to `Sensor` record. | +| `REF009` | `instance-to-category` | `Error` | `Instance.category_token` refers to `Category` record. | +| `REF010` | `instance-to-first-sample-annotation` | `Error` | `Instance.first_annotation_token` refers to `SampleAnnotation` record. | +| `REF011` | `instance-to-last-sample-annotation` | `Error` | `Instance.last_annotation_token` refers to `SampleAnnotation` record. | +| `REF012` | `lidarseg-to-sample-data` | `Error` | `LidarSeg.sample_data_token` refers to `SampleData` record. | +| `REF013` | `sample-data-filename-presence` | `Error` | `SampleData.filename` exists. | +| `REF014` | `sample-data-info-filename-presence` | `Error` | `SampleData.info_filename` exists if it is not `None`. | +| `REF015` | `lidarseg-filename-presence` | `Error` | `LidarSeg.filename` exists if `lidarseg.json` exists. | + +## Format (`FMT`) + +| ID | Name | Severity | Description | +| -------- | ------------------------- | -------- | ------------------------------------------------- | +| `FMT001` | `attribute-field` | `Error` | All types of `Attribute` fields are valid. | +| `FMT002` | `calibrated-sensor-field` | `Error` | All types of `CalibratedSensor` fields are valid. | +| `FMT003` | `category-field` | `Error` | All types of `Category` fields are valid. | +| `FMT004` | `ego-pose-field` | `Error` | All types of `EgoPose` fields are valid. | +| `FMT005` | `instance-field` | `Error` | All types of `Instance` fields are valid. | +| `FMT006` | `log-field` | `Error` | All types of `Log` fields are valid. | +| `FMT007` | `map-field` | `Error` | All types of `Map` fields are valid. | +| `FMT008` | `sample-field` | `Error` | All types of `Sample` fields are valid. | +| `FMT009` | `sample-annotation-field` | `Error` | All types of `SampleAnnotation` fields are valid. | +| `FMT010` | `sample-data-field` | `Error` | All types of `SampleData` fields are valid. | +| `FMT011` | `scene-field` | `Error` | All types of `Scene` fields are valid. | +| `FMT012` | `sensor-field` | `Error` | All types of `Sensor` fields are valid. | +| `FMT013` | `visibility-field` | `Error` | All types of `Visibility` fields are valid. | +| `FMT014` | `lidarseg-field` | `Error` | All types of `Lidarseg` fields are valid. | +| `FMT015` | `object-ann-field` | `Error` | All types of `ObjectAnn` fields are valid. | +| `FMT016` | `surface-ann-field` | `Error` | All types of `SurfaceAnn` fields are valid. | +| `FMT017` | `keypoint-field` | `Error` | All types of `Keypoint` fields are valid. | +| `FMT018` | `vehicle-state-field` | `Error` | All types of `VehicleState` fields are valid. | + +## Tier4 Instance (`TIV`) + +| ID | Name | Severity | Description | +| -------- | ------------ | -------- | ----------------------------------------------- | +| `TIV001` | `load-tier4` | `Error` | Ensure `Tier4` instance is loaded successfully. | diff --git a/mkdocs.yaml b/mkdocs.yaml index eaccda8..b8a559c 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -10,6 +10,7 @@ nav: - Home: schema/index.md - Schema Tables: schema/table.md - Sensor Data: schema/data.md + - Requirements: schema/requirement.md - Tutorials: - Initialization: tutorials/initialize.md - Visualization: tutorials/render.md diff --git a/pyproject.toml b/pyproject.toml index 4d3831a..7ff0d0d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,7 @@ dependencies = [ "typer>=0.15.3", "tabulate>=0.9.0", "tqdm>=4.67.1", + "returns>=0.26.0", ] [dependency-groups] diff --git a/t4_devkit/cli/sanity.py b/t4_devkit/cli/sanity.py index 72400b4..63847c5 100644 --- a/t4_devkit/cli/sanity.py +++ b/t4_devkit/cli/sanity.py @@ -1,14 +1,10 @@ from __future__ import annotations -from pathlib import Path - import typer -from tabulate import tabulate -from tqdm import tqdm from t4_devkit.common.io import save_json -from t4_devkit.common.sanity import DBException, sanity_check -from t4_devkit.common.serialize import serialize_dataclasses +from t4_devkit.common.serialize import serialize_dataclass +from t4_devkit.sanity import print_sanity_result, sanity_check from .version import version_callback @@ -20,18 +16,6 @@ ) -def _run_sanity_check( - db_parent: str, - *, - revision: str | None = None, - include_warning: bool = False, -) -> list[DBException]: - return [ - sanity_check(db_root, revision=revision, include_warning=include_warning) - for db_root in tqdm(Path(db_parent).glob("*"), desc=">>>Sanity checking...") - ] - - @cli.command() def main( version: bool = typer.Option( @@ -42,25 +26,27 @@ def main( callback=version_callback, is_eager=True, ), - db_parent: str = typer.Argument(..., help="Path to parent directory of the databases."), + data_root: str = typer.Argument(..., help="Path to root directory of a dataset."), output: str | None = typer.Option(None, "-o", "--output", help="Path to output JSON file."), revision: str | None = typer.Option( None, "-rv", "--revision", help="Specify if you want to check the specific version." ), + excludes: list[str] | None = typer.Option( + None, "-e", "--exclude", help="Exclude specific rules or rule groups." + ), include_warning: bool = typer.Option( False, "-iw", "--include-warning", help="Indicates whether to report any warnings." ), ) -> None: - exceptions = _run_sanity_check(db_parent, revision=revision, include_warning=include_warning) + result = sanity_check( + data_root=data_root, + revision=revision, + excludes=excludes, + include_warning=include_warning, + ) - if all(e.is_ok() for e in exceptions): - print("✅ No exceptions occurred!!") - else: - print("⚠️ Encountered some exceptions!!") - headers = ["DatasetID", "Version", "Status", "Message"] - table = [[e.dataset_id, e.version, e.status, e.message] for e in exceptions] - print(tabulate(table, headers=headers, tablefmt="pretty")) + print_sanity_result(result) if output: - serialized = serialize_dataclasses(exceptions) + serialized = serialize_dataclass(result) save_json(serialized, output) diff --git a/t4_devkit/sanity/__init__.py b/t4_devkit/sanity/__init__.py new file mode 100644 index 0000000..7a7a3ca --- /dev/null +++ b/t4_devkit/sanity/__init__.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +from .format import * # noqa +from .record import * # noqa +from .reference import * # noqa +from .registry import * # noqa +from .result import * # noqa +from .run import * # noqa +from .structure import * # noqa +from .tier4 import * # noqa diff --git a/t4_devkit/sanity/checker.py b/t4_devkit/sanity/checker.py new file mode 100644 index 0000000..f434b0c --- /dev/null +++ b/t4_devkit/sanity/checker.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, NewType + +from returns.maybe import Maybe, Nothing, Some + +from .result import make_failure, make_success, make_skipped + +if TYPE_CHECKING: + from .context import SanityContext + from .result import Reason, Report + + +RuleID = NewType("RuleID", str) +RuleName = NewType("RuleName", str) + + +class Checker(ABC): + """Base class for sanity checkers.""" + + name: RuleName + description: str + + def __init__(self, id: RuleID) -> None: + self.id = id + + def __call__(self, context: SanityContext) -> Report: + match self.can_skip(context): + case Some(skip): + return make_skipped(self.id, self.name, self.description, skip) + + reasons = self.check(context) + if reasons: + return make_failure(self.id, self.name, self.description, reasons) + else: + return make_success(self.id, self.name, self.description) + + def can_skip(self, _: SanityContext) -> Maybe[Reason]: + """Return a skip reason if the checker should be skipped.""" + return Nothing + + @abstractmethod + def check(self, context: SanityContext) -> list[Reason]: + pass diff --git a/t4_devkit/sanity/context.py b/t4_devkit/sanity/context.py new file mode 100644 index 0000000..2ba355f --- /dev/null +++ b/t4_devkit/sanity/context.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +from pathlib import Path + +from attrs import define +from returns.maybe import Maybe +from returns.pipeline import is_successful +from returns.result import Result, safe +from typing_extensions import Self + +from t4_devkit import DBMetadata, load_metadata +from t4_devkit.schema.name import SchemaName + + +@define +class SanityContext: + metadata: Maybe[DBMetadata] + + @classmethod + def from_path(cls, data_root: str, revision: str | None = None) -> Self: + metadata_result = _load_metadata_safe(data_root, revision=revision) + metadata = metadata_result.unwrap() if is_successful(metadata_result) else None + return cls(Maybe.from_optional(metadata)) + + @property + def data_root(self) -> Maybe[Path]: + """Return the path to dataset root directory.""" + return self.metadata.map(lambda m: Path(m.data_root)) + + @property + def dataset_id(self) -> Maybe[str]: + """Return the dataset ID.""" + return self.metadata.map(lambda m: m.dataset_id) + + @property + def version(self) -> Maybe[str]: + """Return the dataset version.""" + return self.metadata.bind_optional(lambda m: m.version) + + @property + def annotation_dir(self) -> Maybe[Path]: + """Return the path to annotation directory, which is 'annotation'.""" + return self.metadata.map(lambda m: Path(m.data_root).joinpath("annotation")) + + @property + def sensor_data_dir(self) -> Maybe[Path]: + """Return the path to sensor data directory, which is 'data'.""" + return self.metadata.map(lambda m: Path(m.data_root).joinpath("data")) + + @property + def map_dir(self) -> Maybe[Path]: + """Return the path to map directory, which is 'map'.""" + return self.metadata.map(lambda m: Path(m.data_root).joinpath("map")) + + @property + def bag_dir(self) -> Maybe[Path]: + """Return the path to bag directory, which is 'input_bag'.""" + return self.metadata.map(lambda m: Path(m.data_root).joinpath("input_bag")) + + @property + def status_json(self) -> Maybe[Path]: + """Return the path to status JSON file, which is 'status.json'.""" + return self.metadata.map(lambda m: Path(m.data_root).joinpath("status.json")) + + def to_schema_file(self, schema: SchemaName) -> Maybe[Path]: + """Convert schema name to file path, which is /annotation/.json.""" + return self.annotation_dir.map(lambda ann: ann.joinpath(schema.filename)) + + +@safe +def _load_metadata_safe( + data_root: str, + revision: str | None = None, +) -> Result[DBMetadata, Exception]: + return load_metadata(data_root, revision=revision) diff --git a/t4_devkit/sanity/format/__init__.py b/t4_devkit/sanity/format/__init__.py new file mode 100644 index 0000000..be5cabd --- /dev/null +++ b/t4_devkit/sanity/format/__init__.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from .fmt001 import * # noqa +from .fmt002 import * # noqa +from .fmt003 import * # noqa +from .fmt004 import * # noqa +from .fmt005 import * # noqa +from .fmt006 import * # noqa +from .fmt007 import * # noqa +from .fmt008 import * # noqa +from .fmt009 import * # noqa +from .fmt010 import * # noqa +from .fmt011 import * # noqa +from .fmt012 import * # noqa +from .fmt013 import * # noqa +from .fmt014 import * # noqa +from .fmt015 import * # noqa +from .fmt016 import * # noqa +from .fmt017 import * # noqa +from .fmt018 import * # noqa diff --git a/t4_devkit/sanity/format/base.py b/t4_devkit/sanity/format/base.py new file mode 100644 index 0000000..3670eb1 --- /dev/null +++ b/t4_devkit/sanity/format/base.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from returns.maybe import Maybe, Nothing, Some +from returns.pipeline import is_successful +from returns.result import Result, safe + +from t4_devkit.schema import SCHEMAS, SchemaBase, SchemaName + +from ..checker import Checker +from ..result import Reason +from ..safety import load_json_safe + +if TYPE_CHECKING: + from ..context import SanityContext + + +class FieldTypeChecker(Checker): + """Base class for format checkers. + + Attributes: + name (RuleName): The name of the rule. + description (str): The description of the rule. + schema (SchemaName): The schema name to check. + """ + + schema: SchemaName + + def can_skip(self, context: SanityContext) -> Maybe[Reason]: + match context.to_schema_file(self.schema): + case Some(x): + if not x.exists() and not self.schema.is_optional(): + return Maybe.from_value(Reason(f"No '{self.schema.filename}' found")) + return Nothing + case _: + return Nothing + + def check(self, context: SanityContext) -> list[Reason]: + filepath = context.to_schema_file(self.schema).unwrap() + + if self.schema.is_optional() and not filepath.exists(): + return [] + + records = load_json_safe(filepath) + return _build_records(self.schema, records.unwrap()) + + +def _build_records(schema: SchemaName, records: list[dict]) -> list[Reason]: + module = SCHEMAS.get(schema) + failures = [] + for record in records: + conversion = _safe_from_dict(module, record) + if not is_successful(conversion): + failures.append(Reason(f"[{schema.name}] {record['token']}: {conversion.failure()}")) + return failures + + +@safe +def _safe_from_dict(module: type[SchemaBase], record: dict) -> Result[SchemaBase, Exception]: + return module.from_dict(record) diff --git a/t4_devkit/sanity/format/fmt001.py b/t4_devkit/sanity/format/fmt001.py new file mode 100644 index 0000000..b156ee0 --- /dev/null +++ b/t4_devkit/sanity/format/fmt001.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from t4_devkit.schema import SchemaName + +from ..checker import RuleID, RuleName +from ..registry import CHECKERS +from .base import FieldTypeChecker + + +__all__ = ["FMT001"] + + +@CHECKERS.register(RuleID("FMT001")) +class FMT001(FieldTypeChecker): + """A checker of FMT001.""" + + name = RuleName("attribute-field") + description = "All types of 'Attribute' fields are valid." + schema = SchemaName.ATTRIBUTE diff --git a/t4_devkit/sanity/format/fmt002.py b/t4_devkit/sanity/format/fmt002.py new file mode 100644 index 0000000..240498b --- /dev/null +++ b/t4_devkit/sanity/format/fmt002.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from t4_devkit.schema import SchemaName + +from ..checker import RuleID, RuleName +from ..registry import CHECKERS +from .base import FieldTypeChecker + + +__all__ = ["FMT002"] + + +@CHECKERS.register(RuleID("FMT002")) +class FMT002(FieldTypeChecker): + """A checker of FMT002.""" + + name = RuleName("calibrated-sensor-field") + description = "All types of 'CalibratedSensor' fields are valid." + schema = SchemaName.CALIBRATED_SENSOR diff --git a/t4_devkit/sanity/format/fmt003.py b/t4_devkit/sanity/format/fmt003.py new file mode 100644 index 0000000..8baf068 --- /dev/null +++ b/t4_devkit/sanity/format/fmt003.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from t4_devkit.schema import SchemaName + +from ..checker import RuleID, RuleName +from ..registry import CHECKERS +from .base import FieldTypeChecker + + +__all__ = ["FMT003"] + + +@CHECKERS.register(RuleID("FMT003")) +class FMT003(FieldTypeChecker): + """A checker of FMT003.""" + + name = RuleName("category-field") + description = "All types of 'Category' fields are valid." + schema = SchemaName.CATEGORY diff --git a/t4_devkit/sanity/format/fmt004.py b/t4_devkit/sanity/format/fmt004.py new file mode 100644 index 0000000..685f650 --- /dev/null +++ b/t4_devkit/sanity/format/fmt004.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from t4_devkit.schema import SchemaName + +from ..checker import RuleID, RuleName +from ..registry import CHECKERS +from .base import FieldTypeChecker + + +__all__ = ["FMT004"] + + +@CHECKERS.register(RuleID("FMT004")) +class FMT004(FieldTypeChecker): + """A checker of FMT004.""" + + name = RuleName("ego-pose-field") + description = "All types of 'EgoPose' fields are valid." + schema = SchemaName.EGO_POSE diff --git a/t4_devkit/sanity/format/fmt005.py b/t4_devkit/sanity/format/fmt005.py new file mode 100644 index 0000000..0610280 --- /dev/null +++ b/t4_devkit/sanity/format/fmt005.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from t4_devkit.schema import SchemaName + +from ..checker import RuleID, RuleName +from ..registry import CHECKERS +from .base import FieldTypeChecker + + +__all__ = ["FMT005"] + + +@CHECKERS.register(RuleID("FMT005")) +class FMT005(FieldTypeChecker): + """A checker of FMT005.""" + + name = RuleName("instance-field") + description = "All types of 'Instance' fields are valid." + schema = SchemaName.INSTANCE diff --git a/t4_devkit/sanity/format/fmt006.py b/t4_devkit/sanity/format/fmt006.py new file mode 100644 index 0000000..b35ed1e --- /dev/null +++ b/t4_devkit/sanity/format/fmt006.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from t4_devkit.schema import SchemaName + +from ..checker import RuleID, RuleName +from ..registry import CHECKERS +from .base import FieldTypeChecker + +__all__ = ["FMT006"] + + +@CHECKERS.register(RuleID("FMT006")) +class FMT006(FieldTypeChecker): + """A checker of FMT006.""" + + name = RuleName("log-field") + description = "All types of 'Log' fields are valid." + schema = SchemaName.LOG diff --git a/t4_devkit/sanity/format/fmt007.py b/t4_devkit/sanity/format/fmt007.py new file mode 100644 index 0000000..a62ef8d --- /dev/null +++ b/t4_devkit/sanity/format/fmt007.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from t4_devkit.schema import SchemaName + +from ..checker import RuleID, RuleName +from ..registry import CHECKERS +from .base import FieldTypeChecker + +__all__ = ["FMT007"] + + +@CHECKERS.register(RuleID("FMT007")) +class FMT007(FieldTypeChecker): + """A checker of FMT007.""" + + name = RuleName("map-field") + description = "All types of 'Map' fields are valid." + schema = SchemaName.MAP diff --git a/t4_devkit/sanity/format/fmt008.py b/t4_devkit/sanity/format/fmt008.py new file mode 100644 index 0000000..7bc978e --- /dev/null +++ b/t4_devkit/sanity/format/fmt008.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from t4_devkit.schema import SchemaName + +from ..checker import RuleID, RuleName +from ..registry import CHECKERS +from .base import FieldTypeChecker + + +__all__ = ["FMT008"] + + +@CHECKERS.register(RuleID("FMT008")) +class FMT008(FieldTypeChecker): + """A checker of FMT008.""" + + name = RuleName("sample-field") + description = "All types of 'Sample' fields are valid." + schema = SchemaName.SAMPLE diff --git a/t4_devkit/sanity/format/fmt009.py b/t4_devkit/sanity/format/fmt009.py new file mode 100644 index 0000000..9211e44 --- /dev/null +++ b/t4_devkit/sanity/format/fmt009.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from t4_devkit.schema import SchemaName + +from ..checker import RuleID, RuleName +from ..registry import CHECKERS +from .base import FieldTypeChecker + + +__all__ = ["FMT009"] + + +@CHECKERS.register(RuleID("FMT009")) +class FMT009(FieldTypeChecker): + """A checker of FMT009.""" + + name = RuleName("sample-annotation-field") + description = "All types of 'SampleAnnotation' fields are valid." + schema = SchemaName.SAMPLE_ANNOTATION diff --git a/t4_devkit/sanity/format/fmt010.py b/t4_devkit/sanity/format/fmt010.py new file mode 100644 index 0000000..0235bc4 --- /dev/null +++ b/t4_devkit/sanity/format/fmt010.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from t4_devkit.schema import SchemaName + +from ..checker import RuleID, RuleName +from ..registry import CHECKERS +from .base import FieldTypeChecker + + +__all__ = ["FMT010"] + + +@CHECKERS.register(RuleID("FMT010")) +class FMT010(FieldTypeChecker): + """A checker of FMT010.""" + + name = RuleName("sample-data-field") + description = "All types of 'SampleData' fields are valid." + schema = SchemaName.SAMPLE_DATA diff --git a/t4_devkit/sanity/format/fmt011.py b/t4_devkit/sanity/format/fmt011.py new file mode 100644 index 0000000..612b3de --- /dev/null +++ b/t4_devkit/sanity/format/fmt011.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from t4_devkit.schema import SchemaName + +from ..checker import RuleID, RuleName +from ..registry import CHECKERS +from .base import FieldTypeChecker + + +__all__ = ["FMT011"] + + +@CHECKERS.register(RuleID("FMT011")) +class FMT011(FieldTypeChecker): + """A checker of FMT011.""" + + name = RuleName("scene-field") + description = "All types of 'Scene' fields are valid." + schema = SchemaName.SCENE diff --git a/t4_devkit/sanity/format/fmt012.py b/t4_devkit/sanity/format/fmt012.py new file mode 100644 index 0000000..d98169f --- /dev/null +++ b/t4_devkit/sanity/format/fmt012.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from t4_devkit.schema import SchemaName + +from ..checker import RuleID, RuleName +from ..registry import CHECKERS +from .base import FieldTypeChecker + + +__all__ = ["FMT012"] + + +@CHECKERS.register(RuleID("FMT012")) +class FMT012(FieldTypeChecker): + """A checker of FMT012.""" + + name = RuleName("sensor-field") + description = "All types of 'Sensor' fields are valid." + schema = SchemaName.SENSOR diff --git a/t4_devkit/sanity/format/fmt013.py b/t4_devkit/sanity/format/fmt013.py new file mode 100644 index 0000000..3318f72 --- /dev/null +++ b/t4_devkit/sanity/format/fmt013.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from t4_devkit.schema import SchemaName + +from ..checker import RuleID, RuleName +from ..registry import CHECKERS +from .base import FieldTypeChecker + + +__all__ = ["FMT013"] + + +@CHECKERS.register(RuleID("FMT013")) +class FMT013(FieldTypeChecker): + """A checker of FMT013.""" + + name = RuleName("visibility-field") + description = "All types of 'Visibility' fields are valid." + schema = SchemaName.VISIBILITY diff --git a/t4_devkit/sanity/format/fmt014.py b/t4_devkit/sanity/format/fmt014.py new file mode 100644 index 0000000..85fa09c --- /dev/null +++ b/t4_devkit/sanity/format/fmt014.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from t4_devkit.schema import SchemaName + +from ..checker import RuleID, RuleName +from ..registry import CHECKERS +from .base import FieldTypeChecker + + +__all__ = ["FMT014"] + + +@CHECKERS.register(RuleID("FMT014")) +class FMT014(FieldTypeChecker): + """A checker of FMT014.""" + + name = RuleName("lidarseg-field") + description = "All types of 'LidarSeg' fields are valid." + schema = SchemaName.LIDARSEG diff --git a/t4_devkit/sanity/format/fmt015.py b/t4_devkit/sanity/format/fmt015.py new file mode 100644 index 0000000..02b61fb --- /dev/null +++ b/t4_devkit/sanity/format/fmt015.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from t4_devkit.schema import SchemaName + +from ..checker import RuleID, RuleName +from ..registry import CHECKERS +from .base import FieldTypeChecker + + +__all__ = ["FMT015"] + + +@CHECKERS.register(RuleID("FMT015")) +class FMT015(FieldTypeChecker): + """A checker of FMT015.""" + + name = RuleName("object-ann-field") + description = "All types of 'ObjectAnn' fields are valid." + schema = SchemaName.OBJECT_ANN diff --git a/t4_devkit/sanity/format/fmt016.py b/t4_devkit/sanity/format/fmt016.py new file mode 100644 index 0000000..59bc770 --- /dev/null +++ b/t4_devkit/sanity/format/fmt016.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from t4_devkit.schema import SchemaName + +from ..checker import RuleID, RuleName +from ..registry import CHECKERS +from .base import FieldTypeChecker + + +__all__ = ["FMT016"] + + +@CHECKERS.register(RuleID("FMT016")) +class FMT016(FieldTypeChecker): + """A checker of FMT016.""" + + name = RuleName("surface-ann-field") + description = "All types of 'SurfaceAnn' fields are valid." + schema = SchemaName.SURFACE_ANN diff --git a/t4_devkit/sanity/format/fmt017.py b/t4_devkit/sanity/format/fmt017.py new file mode 100644 index 0000000..ea5727b --- /dev/null +++ b/t4_devkit/sanity/format/fmt017.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from t4_devkit.schema import SchemaName + +from ..checker import RuleID, RuleName +from ..registry import CHECKERS +from .base import FieldTypeChecker + + +__all__ = ["FMT017"] + + +@CHECKERS.register(RuleID("FMT017")) +class FMT017(FieldTypeChecker): + """A checker of FMT017.""" + + name = RuleName("keypoint-field") + description = "All types of 'Keypoint' fields are valid." + schema = SchemaName.KEYPOINT diff --git a/t4_devkit/sanity/format/fmt018.py b/t4_devkit/sanity/format/fmt018.py new file mode 100644 index 0000000..6e4df61 --- /dev/null +++ b/t4_devkit/sanity/format/fmt018.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from t4_devkit.schema import SchemaName + +from ..checker import RuleID, RuleName +from ..registry import CHECKERS +from .base import FieldTypeChecker + + +__all__ = ["FMT018"] + + +@CHECKERS.register(RuleID("FMT018")) +class FMT018(FieldTypeChecker): + """A checker of FMT018.""" + + name = RuleName("vehicle-state-field") + description = "All types of 'VehicleState' fields are valid." + schema = SchemaName.VEHICLE_STATE diff --git a/t4_devkit/sanity/record/__init__.py b/t4_devkit/sanity/record/__init__.py new file mode 100644 index 0000000..a4980bd --- /dev/null +++ b/t4_devkit/sanity/record/__init__.py @@ -0,0 +1,8 @@ +from __future__ import annotations + +from .rec001 import * # noqa +from .rec002 import * # noqa +from .rec003 import * # noqa +from .rec004 import * # noqa +from .rec005 import * # noqa +from .rec006 import * # noqa diff --git a/t4_devkit/sanity/record/base.py b/t4_devkit/sanity/record/base.py new file mode 100644 index 0000000..e172355 --- /dev/null +++ b/t4_devkit/sanity/record/base.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from abc import abstractmethod +from typing import TYPE_CHECKING + +from returns.maybe import Maybe, Nothing, Some + +from t4_devkit.schema import SchemaName + +from ..checker import Checker +from ..result import Reason +from ..safety import load_json_safe + +if TYPE_CHECKING: + from ..context import SanityContext + + +class RecordCountChecker(Checker): + """Base class for record count checkers. + + Attributes: + name (RuleName): The name of the rule. + description (str): The description of the rule. + schema (SchemaName): The schema name to check. + """ + + schema: SchemaName + + def can_skip(self, context: SanityContext) -> Maybe[Reason]: + match context.to_schema_file(self.schema): + case Some(x): + if not x.exists(): + return Maybe.from_value(Reason(f"Missing {self.schema.filename}")) + else: + return Nothing + case _: + return Maybe.from_value(Reason("Missing 'annotation' directory path")) + + def check(self, context: SanityContext) -> list[Reason]: + filepath = context.to_schema_file(self.schema).unwrap() + records = load_json_safe(filepath).unwrap() + return self.check_count(records) + + @abstractmethod + def check_count(self, records: list[dict]) -> list[Reason]: + """Check the count of records. + + Args: + records (list[dict]): The list of records to check. + + Returns: + A list of reasons for any issues found, otherwise an empty list. + """ + pass diff --git a/t4_devkit/sanity/record/rec001.py b/t4_devkit/sanity/record/rec001.py new file mode 100644 index 0000000..2923595 --- /dev/null +++ b/t4_devkit/sanity/record/rec001.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from t4_devkit.schema import SchemaName + +from ..checker import RuleID, RuleName +from ..registry import CHECKERS +from ..result import Reason +from .base import RecordCountChecker + + +__all__ = ["REC001"] + + +@CHECKERS.register(RuleID("REC001")) +class REC001(RecordCountChecker): + """A checker of REC001.""" + + name = RuleName("scene-single") + description = "'Scene' record is a single." + schema = SchemaName.SCENE + + def check_count(self, records: list[dict]) -> list[Reason]: + num_scene = len(records) + return ( + [] + if num_scene == 1 + else [Reason(f"'Scene' must contain exactly one element: {num_scene}")] + ) diff --git a/t4_devkit/sanity/record/rec002.py b/t4_devkit/sanity/record/rec002.py new file mode 100644 index 0000000..0d802c9 --- /dev/null +++ b/t4_devkit/sanity/record/rec002.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from t4_devkit.schema import SchemaName + +from ..checker import RuleID, RuleName +from ..registry import CHECKERS +from ..result import Reason +from .base import RecordCountChecker + + +__all__ = ["REC002"] + + +@CHECKERS.register(RuleID("REC002")) +class REC002(RecordCountChecker): + """A checker of REC002.""" + + name = RuleName("sample-not-empty") + description = "'Sample' record is not empty." + schema = SchemaName.SAMPLE + + def check_count(self, records: list[dict]) -> list[Reason]: + num_sample = len(records) + return [Reason("'Sample' record must not be empty")] if num_sample == 0 else [] diff --git a/t4_devkit/sanity/record/rec003.py b/t4_devkit/sanity/record/rec003.py new file mode 100644 index 0000000..e11301b --- /dev/null +++ b/t4_devkit/sanity/record/rec003.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from t4_devkit.schema import SchemaName + +from ..checker import RuleID, RuleName +from ..registry import CHECKERS +from ..result import Reason +from .base import RecordCountChecker + + +__all__ = ["REC003"] + + +@CHECKERS.register(RuleID("REC003")) +class REC003(RecordCountChecker): + """A checker of REC003.""" + + name = RuleName("sample-data-not-empty") + description = "'SampleData' record is not empty." + schema = SchemaName.SAMPLE_DATA + + def check_count(self, records: list[dict]) -> list[Reason]: + num_sample_data = len(records) + return [Reason("'SampleData' record must not be empty")] if num_sample_data == 0 else [] diff --git a/t4_devkit/sanity/record/rec004.py b/t4_devkit/sanity/record/rec004.py new file mode 100644 index 0000000..048fdfa --- /dev/null +++ b/t4_devkit/sanity/record/rec004.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from t4_devkit.schema import SchemaName + +from ..checker import RuleID, RuleName +from ..registry import CHECKERS +from ..result import Reason +from .base import RecordCountChecker + + +__all__ = ["REC004"] + + +@CHECKERS.register(RuleID("REC004")) +class REC004(RecordCountChecker): + """A checker of REC004.""" + + name = RuleName("ego-pose-not-empty") + description = "'EgoPose' record is not empty." + schema = SchemaName.EGO_POSE + + def check_count(self, records: list[dict]) -> list[Reason]: + num_ego_pose = len(records) + return [Reason("'EgoPose' record must not be empty")] if num_ego_pose == 0 else [] diff --git a/t4_devkit/sanity/record/rec005.py b/t4_devkit/sanity/record/rec005.py new file mode 100644 index 0000000..9464e44 --- /dev/null +++ b/t4_devkit/sanity/record/rec005.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from t4_devkit.schema import SchemaName + +from ..checker import RuleID, RuleName +from ..registry import CHECKERS +from ..result import Reason +from .base import RecordCountChecker + + +__all__ = ["REC005"] + + +@CHECKERS.register(RuleID("REC005")) +class REC005(RecordCountChecker): + """A checker of REC005.""" + + name = RuleName("calibrated-sensor-not-empty") + description = "'CalibratedSensor' record is not empty." + schema = SchemaName.CALIBRATED_SENSOR + + def check_count(self, records: list[dict]) -> list[Reason]: + num_calibrated_sensor = len(records) + return ( + [Reason("'CalibratedSensor' record must not be empty")] + if num_calibrated_sensor == 0 + else [] + ) diff --git a/t4_devkit/sanity/record/rec006.py b/t4_devkit/sanity/record/rec006.py new file mode 100644 index 0000000..802d659 --- /dev/null +++ b/t4_devkit/sanity/record/rec006.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from t4_devkit.schema import SchemaName + +from ..checker import RuleID, RuleName +from ..registry import CHECKERS +from ..result import Reason +from .base import RecordCountChecker + + +__all__ = ["REC006"] + + +@CHECKERS.register(RuleID("REC006")) +class REC006(RecordCountChecker): + """A checker of REC006.""" + + name = RuleName("instance-not-empty") + description = "'Instance' record is not empty." + schema = SchemaName.INSTANCE + + def check_count(self, records: list[dict]) -> list[Reason]: + num_instance = len(records) + return [Reason("'Instance' record must not be empty")] if num_instance == 0 else [] diff --git a/t4_devkit/sanity/reference/__init__.py b/t4_devkit/sanity/reference/__init__.py new file mode 100644 index 0000000..4aefae1 --- /dev/null +++ b/t4_devkit/sanity/reference/__init__.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from .ref001 import * # noqa +from .ref002 import * # noqa +from .ref003 import * # noqa +from .ref004 import * # noqa +from .ref005 import * # noqa +from .ref006 import * # noqa +from .ref007 import * # noqa +from .ref008 import * # noqa +from .ref009 import * # noqa +from .ref010 import * # noqa +from .ref011 import * # noqa +from .ref012 import * # noqa +from .ref013 import * # noqa +from .ref014 import * # noqa +from .ref015 import * # noqa diff --git a/t4_devkit/sanity/reference/base.py b/t4_devkit/sanity/reference/base.py new file mode 100644 index 0000000..f7a8ea4 --- /dev/null +++ b/t4_devkit/sanity/reference/base.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from returns.maybe import Maybe, Nothing, Some + +from ..checker import Checker +from ..result import Reason +from ..safety import load_json_safe + +if TYPE_CHECKING: + from t4_devkit.schema import SchemaName + + from ..context import SanityContext + + +class RecordReferenceChecker(Checker): + """Base class for record reference checkers. + + Attributes: + name (RuleName): The name of the rule. + description (str): The description of the rule. + source (SchemaName): The source schema name. + target (SchemaName): The target schema name. + reference (str): The reference token name in the source record. + """ + + source: SchemaName + target: SchemaName + reference: str + + def can_skip(self, context: SanityContext) -> Maybe[Reason]: + source_file = context.to_schema_file(self.source) + target_file = context.to_schema_file(self.target) + match (source_file, target_file): + case Some(x), Some(y): + if not x.exists(): + return Maybe.from_value(Reason(f"Missing {self.source.filename}")) + elif not y.exists(): + return Maybe.from_value(Reason(f"Missing {self.target.filename}")) + else: + return Nothing + case _: + return Maybe.from_value(Reason("Missing 'annotation' directory path")) + + def check(self, context: SanityContext) -> list[Reason]: + source_file = context.to_schema_file(self.source).unwrap() + target_file = context.to_schema_file(self.target).unwrap() + source_records = load_json_safe(source_file).unwrap() + target_tokens = [item["token"] for item in load_json_safe(target_file).unwrap()] + return [ + Reason( + f"No reference to '{self.source.value}.{self.reference}': {record[self.reference]}" + ) + for record in source_records + if record[self.reference] not in target_tokens + and self.is_additional_condition_ok(record) + ] + + def is_additional_condition_ok(self, record: dict[str, Any]) -> bool: + """Return True if the additional condition is met. + + Args: + record: The record to check. + + Returns: + True if the additional condition is met, False otherwise. + """ + return True + + +class FileReferenceChecker(Checker): + """Base class for file reference checkers. + + Attributes: + name (RuleName): The name of the rule. + description (str): The description of the rule. + schema (SchemaName): The schema name to check. + """ + + schema: SchemaName + + def can_skip(self, context: SanityContext) -> Maybe[Reason]: + filepath = context.to_schema_file(self.schema) + match filepath: + case Some(x): + return Nothing if x.exists() else Maybe.from_value(Reason(f"Missing {x}")) + case _: + return Maybe.from_value(Reason("Missing 'annotation' directory path")) diff --git a/t4_devkit/sanity/reference/ref001.py b/t4_devkit/sanity/reference/ref001.py new file mode 100644 index 0000000..dcce036 --- /dev/null +++ b/t4_devkit/sanity/reference/ref001.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from t4_devkit.schema import SchemaName + +from ..checker import RuleID, RuleName +from ..registry import CHECKERS +from .base import RecordReferenceChecker + + +__all__ = ["REF001"] + + +@CHECKERS.register(RuleID("REF001")) +class REF001(RecordReferenceChecker): + """A checker of REF001.""" + + name = RuleName("scene-to-log") + description = "'Scene.log_token' refers to 'Log' record." + source = SchemaName.SCENE + target = SchemaName.LOG + reference = "log_token" diff --git a/t4_devkit/sanity/reference/ref002.py b/t4_devkit/sanity/reference/ref002.py new file mode 100644 index 0000000..e4fc05f --- /dev/null +++ b/t4_devkit/sanity/reference/ref002.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from t4_devkit.schema import SchemaName + +from ..checker import RuleID, RuleName +from ..registry import CHECKERS +from .base import RecordReferenceChecker + + +__all__ = ["REF002"] + + +@CHECKERS.register(RuleID("REF002")) +class REF002(RecordReferenceChecker): + """A checker of REF002.""" + + name = RuleName("scene-to-first-sample") + description = "'Scene.first_sample_token' refers to 'Sample' record." + source = SchemaName.SCENE + target = SchemaName.SAMPLE + reference = "first_sample_token" diff --git a/t4_devkit/sanity/reference/ref003.py b/t4_devkit/sanity/reference/ref003.py new file mode 100644 index 0000000..1a16684 --- /dev/null +++ b/t4_devkit/sanity/reference/ref003.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from t4_devkit.schema import SchemaName + +from ..checker import RuleID, RuleName +from ..registry import CHECKERS +from .base import RecordReferenceChecker + + +__all__ = ["REF003"] + + +@CHECKERS.register(RuleID("REF003")) +class REF003(RecordReferenceChecker): + """A checker of REF003.""" + + name = RuleName("scene-to-last-sample") + description = "'Scene.last_sample_token' refers to 'Sample' record." + source = SchemaName.SCENE + target = SchemaName.SAMPLE + reference = "last_sample_token" diff --git a/t4_devkit/sanity/reference/ref004.py b/t4_devkit/sanity/reference/ref004.py new file mode 100644 index 0000000..1176553 --- /dev/null +++ b/t4_devkit/sanity/reference/ref004.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from t4_devkit.schema import SchemaName + +from ..checker import RuleID, RuleName +from ..registry import CHECKERS +from .base import RecordReferenceChecker + + +__all__ = ["REF004"] + + +@CHECKERS.register(RuleID("REF004")) +class REF004(RecordReferenceChecker): + """A checker of REF004.""" + + name = RuleName("sample-to-scene") + description = "'Sample.scene_token' refers to 'Scene' record." + source = SchemaName.SAMPLE + target = SchemaName.SCENE + reference = "scene_token" diff --git a/t4_devkit/sanity/reference/ref005.py b/t4_devkit/sanity/reference/ref005.py new file mode 100644 index 0000000..6bc4c40 --- /dev/null +++ b/t4_devkit/sanity/reference/ref005.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from typing_extensions import Any + +from t4_devkit.schema import SchemaName + +from ..checker import RuleID, RuleName +from ..registry import CHECKERS +from .base import RecordReferenceChecker + + +__all__ = ["REF005"] + + +@CHECKERS.register(RuleID("REF005")) +class REF005(RecordReferenceChecker): + """A checker of REF005.""" + + name = RuleName("sample-data-to-sample") + description = "'SampleData.sample_token' refers to 'Sample' record." + source = SchemaName.SAMPLE_DATA + target = SchemaName.SAMPLE + reference = "sample_token" + + def is_additional_condition_ok(self, record: dict[str, Any]) -> bool: + return record["is_valid"] diff --git a/t4_devkit/sanity/reference/ref006.py b/t4_devkit/sanity/reference/ref006.py new file mode 100644 index 0000000..f16b5f3 --- /dev/null +++ b/t4_devkit/sanity/reference/ref006.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from t4_devkit.schema import SchemaName + +from ..checker import RuleID, RuleName +from ..registry import CHECKERS +from .base import RecordReferenceChecker + +__all__ = ["REF006"] + + +@CHECKERS.register(RuleID("REF006")) +class REF006(RecordReferenceChecker): + """A checker of REF006.""" + + name = RuleName("sample-data-to-ego-pose") + description = "'SampleData.ego_pose_token' refers to 'EgoPose' record." + source = SchemaName.SAMPLE_DATA + target = SchemaName.EGO_POSE + reference = "ego_pose_token" diff --git a/t4_devkit/sanity/reference/ref007.py b/t4_devkit/sanity/reference/ref007.py new file mode 100644 index 0000000..35b2e08 --- /dev/null +++ b/t4_devkit/sanity/reference/ref007.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from t4_devkit.schema import SchemaName + +from ..checker import RuleID, RuleName +from ..registry import CHECKERS +from .base import RecordReferenceChecker + +__all__ = ["REF007"] + + +@CHECKERS.register(RuleID("REF007")) +class REF007(RecordReferenceChecker): + """A checker of REF007.""" + + name = RuleName("sample-data-to-calibrated-sensor") + description = "'SampleData.calibrated_sensor_token' refers to 'CalibratedSensor' record." + source = SchemaName.SAMPLE_DATA + target = SchemaName.CALIBRATED_SENSOR + reference = "calibrated_sensor_token" diff --git a/t4_devkit/sanity/reference/ref008.py b/t4_devkit/sanity/reference/ref008.py new file mode 100644 index 0000000..da3e969 --- /dev/null +++ b/t4_devkit/sanity/reference/ref008.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from t4_devkit.schema import SchemaName + +from ..checker import RuleID, RuleName +from ..registry import CHECKERS +from .base import RecordReferenceChecker + + +__all__ = ["REF008"] + + +@CHECKERS.register(RuleID("REF008")) +class REF008(RecordReferenceChecker): + """A checker of REF008.""" + + name = RuleName("calibrated-sensor-to-sensor") + description = "'CalibratedSensor.sensor_token' refers to 'Sensor' record." + source = SchemaName.CALIBRATED_SENSOR + target = SchemaName.SENSOR + reference = "sensor_token" diff --git a/t4_devkit/sanity/reference/ref009.py b/t4_devkit/sanity/reference/ref009.py new file mode 100644 index 0000000..cb1218f --- /dev/null +++ b/t4_devkit/sanity/reference/ref009.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from t4_devkit.schema import SchemaName + +from ..checker import RuleID, RuleName +from ..registry import CHECKERS +from .base import RecordReferenceChecker + + +__all__ = ["REF009"] + + +@CHECKERS.register(RuleID("REF009")) +class REF009(RecordReferenceChecker): + """A checker of REF009.""" + + name = RuleName("instance-to-category") + description = "'Instance.category_token' refers to 'Category' record." + source = SchemaName.INSTANCE + target = SchemaName.CATEGORY + reference = "category_token" diff --git a/t4_devkit/sanity/reference/ref010.py b/t4_devkit/sanity/reference/ref010.py new file mode 100644 index 0000000..7ce58cb --- /dev/null +++ b/t4_devkit/sanity/reference/ref010.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from t4_devkit.schema import SchemaName + +from ..checker import RuleID, RuleName +from ..registry import CHECKERS +from .base import RecordReferenceChecker + + +__all__ = ["REF010"] + + +@CHECKERS.register(RuleID("REF010")) +class REF010(RecordReferenceChecker): + """A checker of REF010.""" + + name = RuleName("instance-to-first-sample-annotation") + description = "'Instance.first_annotation_token' refers to 'SampleAnnotation' record." + source = SchemaName.INSTANCE + target = SchemaName.SAMPLE_ANNOTATION + reference = "first_annotation_token" diff --git a/t4_devkit/sanity/reference/ref011.py b/t4_devkit/sanity/reference/ref011.py new file mode 100644 index 0000000..a374a35 --- /dev/null +++ b/t4_devkit/sanity/reference/ref011.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from t4_devkit.schema import SchemaName + +from ..checker import RuleID, RuleName +from ..registry import CHECKERS +from .base import RecordReferenceChecker + + +__all__ = ["REF011"] + + +@CHECKERS.register(RuleID("REF011")) +class REF011(RecordReferenceChecker): + """A checker of REF011.""" + + name = RuleName("instance-to-last-sample-annotation") + description = "'Instance.last_annotation_token' refers to 'SampleAnnotation' record." + source = SchemaName.INSTANCE + target = SchemaName.SAMPLE_ANNOTATION + reference = "last_annotation_token" diff --git a/t4_devkit/sanity/reference/ref012.py b/t4_devkit/sanity/reference/ref012.py new file mode 100644 index 0000000..f786993 --- /dev/null +++ b/t4_devkit/sanity/reference/ref012.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from t4_devkit.schema import SchemaName + +from ..checker import RuleID, RuleName +from ..registry import CHECKERS +from .base import RecordReferenceChecker + + +__all__ = ["REF012"] + + +@CHECKERS.register(RuleID("REF012")) +class REF012(RecordReferenceChecker): + """A checker of REF012.""" + + name = RuleName("lidarset-to-sample-data") + description = "'LidarSeg.sample_data_token' refers to 'SampleData' record." + source = SchemaName.LIDARSEG + target = SchemaName.SAMPLE_DATA + reference = "sample_data_token" diff --git a/t4_devkit/sanity/reference/ref013.py b/t4_devkit/sanity/reference/ref013.py new file mode 100644 index 0000000..dd5b2e1 --- /dev/null +++ b/t4_devkit/sanity/reference/ref013.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from t4_devkit.schema import SchemaName + +from ..checker import RuleID, RuleName +from ..registry import CHECKERS +from ..result import Reason +from ..safety import load_json_safe +from .base import FileReferenceChecker + +if TYPE_CHECKING: + from ..context import SanityContext + + +__all__ = ["REF013"] + + +@CHECKERS.register(RuleID("REF013")) +class REF013(FileReferenceChecker): + """A checker of REF013.""" + + name = RuleName("sample-data-filename-presence") + description = "'SampleData.filename' exists." + schema = SchemaName.SAMPLE_DATA + + def check(self, context: SanityContext) -> list[Reason]: + filepath = context.to_schema_file(self.schema).unwrap() + records = load_json_safe(filepath).unwrap() + data_root = context.data_root.unwrap() + return [ + Reason(f"File not found: {record['filename']}") + for record in records + if not data_root.joinpath(record["filename"]).exists() + ] diff --git a/t4_devkit/sanity/reference/ref014.py b/t4_devkit/sanity/reference/ref014.py new file mode 100644 index 0000000..227e7f1 --- /dev/null +++ b/t4_devkit/sanity/reference/ref014.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from t4_devkit.schema import SchemaName + +from ..checker import RuleID, RuleName +from ..registry import CHECKERS +from ..result import Reason +from ..safety import load_json_safe +from .base import FileReferenceChecker + +if TYPE_CHECKING: + from ..context import SanityContext + + +__all__ = ["REF014"] + + +@CHECKERS.register(RuleID("REF014")) +class REF014(FileReferenceChecker): + """A checker of REF014.""" + + name = RuleName("sample-data-filename-presence") + description = "'SampleData.filename' exists." + schema = SchemaName.SAMPLE_DATA + + def check(self, context: SanityContext) -> list[Reason]: + filepath = context.to_schema_file(self.schema).unwrap() + records = load_json_safe(filepath).unwrap() + data_root = context.data_root.unwrap() + return [ + Reason(f"File not found: {record['info_filename']}") + for record in records + if record.get("info_filename") is not None + and not data_root.joinpath(record["info_filename"]).exists() + ] diff --git a/t4_devkit/sanity/reference/ref015.py b/t4_devkit/sanity/reference/ref015.py new file mode 100644 index 0000000..2bfaf9b --- /dev/null +++ b/t4_devkit/sanity/reference/ref015.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from t4_devkit.schema import SchemaName + +from ..checker import RuleID, RuleName +from ..registry import CHECKERS +from ..result import Reason +from ..safety import load_json_safe +from .base import FileReferenceChecker + +if TYPE_CHECKING: + from ..context import SanityContext + + +__all__ = ["REF015"] + + +@CHECKERS.register(RuleID("REF015")) +class REF015(FileReferenceChecker): + """A checker of REF015.""" + + name = RuleName("lidarseg-filename-presence") + description = "'LidarSeg.filename' exists." + schema = SchemaName.LIDARSEG + + def check(self, context: SanityContext) -> list[Reason]: + filepath = context.to_schema_file(self.schema).unwrap() + records = load_json_safe(filepath).unwrap() + data_root = context.data_root.unwrap() + return [ + Reason(f"File not found: {record['filename']}") + for record in records + if not data_root.joinpath(record["filename"]).exists() + ] diff --git a/t4_devkit/sanity/registry.py b/t4_devkit/sanity/registry.py new file mode 100644 index 0000000..1bcd388 --- /dev/null +++ b/t4_devkit/sanity/registry.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import inspect +from collections.abc import Callable, Sequence +from enum import Enum, unique + +from .checker import Checker, RuleID + + +@unique +class RuleGroup(Enum): + STRUCTURE = "STR" + RECORD = "REC" + REFERENCE = "REF" + FORMAT = "FMT" + TIER4 = "TIV" + + @classmethod + def values(cls) -> list[str]: + """Return a list of all rule group values.""" + return [group.value for group in cls] + + @classmethod + def to_group(cls, id: RuleID) -> RuleGroup | None: + """Convert a rule ID to a rule group. + + Args: + id (RuleID): The ID of the rule. + + Returns: + The rule group if the rule ID belongs to any rule group, otherwise None. + """ + for g in RuleGroup: + if g.value in id: + return g + return None + + +class CheckerRegistry(dict[RuleGroup, dict[RuleID, type[Checker]]]): + def register(self, id: RuleID) -> Callable: + """Register a checker class. + + Args: + id (RuleID): The ID of the rule. + + Returns: + A decorator function that registers the checker class. + """ + group = RuleGroup.to_group(id) + + if group is None: + raise ValueError(f"'{id}' doesn't belong to any rule groups: {RuleGroup.values()}") + + def _register_decorator(module: type[Checker]) -> type[Checker]: + self._add_module(module, group, id) + return module + + return _register_decorator + + def _add_module(self, module: type[Checker], group: RuleGroup, id: RuleID) -> None: + if not inspect.isclass(module): + raise TypeError(f"module must be a class, but got {type(module)}.") + + if group not in self: + self[group] = {} + + if id in self[group]: + raise ValueError(f"'{id}' has already been registered.") + + self[group][id] = module + + def build(self, excludes: Sequence[str] | None = None) -> list[Checker]: + """Build a list of checkers from the registry. + + Args: + excludes (Sequence[str] | None, optional): A list of rule IDs or rule groups to exclude. + + Returns: + A list of checkers. + """ + if excludes is None: + excludes = [] + + return [ + checker(id) + for group, values in self.items() + for id, checker in values.items() + if id not in excludes and group.value not in excludes + ] + + +CHECKERS = CheckerRegistry() diff --git a/t4_devkit/sanity/result.py b/t4_devkit/sanity/result.py new file mode 100644 index 0000000..e29d0b1 --- /dev/null +++ b/t4_devkit/sanity/result.py @@ -0,0 +1,167 @@ +from __future__ import annotations + +from enum import Enum +from typing import TYPE_CHECKING, NewType + +from attrs import define, field +from tabulate import tabulate +from typing_extensions import Self + +if TYPE_CHECKING: + from .checker import RuleID, RuleName + from .context import SanityContext + +__all__ = ["Status", "Report", "SanityResult", "print_sanity_result"] + + +class Status(str, Enum): + """Status of a report.""" + + SUCCESS = "SUCCESS" + FAILURE = "FAILURE" + SKIPPED = "SKIPPED" + + def is_success(self) -> bool: + """Check if the status is success.""" + return self == Status.SUCCESS + + def is_failure(self) -> bool: + """Check if the status is failure.""" + return self == Status.FAILURE + + def is_skipped(self) -> bool: + """Check if the status is skipped.""" + return self == Status.SKIPPED + + +Reason = NewType("Reason", str) + + +@define +class Report: + """A report for a rule. + + Attributes: + id (RuleID): The ID of the rule. + name (RuleName): The name of the rule. + description (str): The description of the rule. + status (Status): The status of the report. + reasons (list[Reason] | None): The list of reasons for the report if the report is a failure or skipped. + """ + + id: RuleID + name: RuleName + description: str + status: Status + reasons: list[Reason] | None = field(default=None) + + def __attrs_post_init__(self) -> None: + if self.is_success(): + assert self.reasons is None, "Success report cannot have reasons" + else: + assert self.reasons is not None, "Failure report must have reasons" + + def is_success(self) -> bool: + return self.status == Status.SUCCESS + + def is_failure(self) -> bool: + return self.status == Status.FAILURE + + def is_skipped(self) -> bool: + return self.status == Status.SKIPPED + + +def make_success(id: RuleID, name: RuleName, description: str) -> Report: + """Make a success report for the given rule.""" + return Report(id, name, description, Status.SUCCESS) + + +def make_skipped(id: RuleID, name: RuleName, description: str, reason: Reason) -> Report: + """Make a skipped report for the given rule.""" + return Report(id, name, description, Status.SKIPPED, [reason]) + + +def make_failure(id: RuleID, name: RuleName, description: str, reasons: list[Reason]) -> Report: + """Make a failure report for the given rule.""" + return Report(id, name, description, Status.FAILURE, reasons) + + +@define +class SanityResult: + """The result of a Sanity check. + + Attributes: + dataset_id (str): The ID of the dataset. + version (str | None): The version of the dataset. + reports (list[Report]): The list of reports. + """ + + dataset_id: str + version: str | None + reports: list[Report] + + @classmethod + def from_context(cls, context: SanityContext, reports: list[Report]) -> Self: + """Create a SanityResult from a SanityContext and a list of reports. + + Args: + context (SanityContext): The SanityContext to use. + reports (list[Report]): The list of reports to include in the result. + + Returns: + The created SanityResult. + """ + return cls( + dataset_id=context.dataset_id.value_or("UNKNOWN"), + version=context.version.value_or(None), + reports=reports, + ) + + def __repr__(self) -> str: + string = f"=== DatasetID: {self.dataset_id} ===\n" + for report in self.reports: + if report.is_failure(): + string += f"\033[31m {report.id}:\033[0m\n" + for reason in report.reasons or []: + string += f"\033[31m - {reason}\033[0m\n" + elif report.is_skipped(): + string += f"\033[33m {report.id}:\033[0m\n" + for reason in report.reasons or []: + string += f"\033[33m - {reason}\033[0m\n" + else: + string += f"\033[32m {report.id}: ✅\033[0m\n" + return string + + +def print_sanity_result(result: SanityResult) -> None: + """Print detailed and summary results of a sanity check. + + Args: + result (SanityResult): The result of a sanity check. + """ + # print detailed result + print(result) + + # print summary result + success = sum(1 for rp in result.reports if rp.is_success()) + failures = sum(1 for rp in result.reports if rp.is_failure()) + skips = sum(1 for rp in result.reports if rp.is_skipped()) + summary_rows = [ + [ + result.dataset_id, + result.version, + "\033[31mFAILURE\033[0m" if failures > 0 else "\033[32mSUCCESS\033[0m", + len(result.reports), + success, + failures, + skips, + ] + ] + + print( + tabulate( + summary_rows, + headers=["DatasetID", "Version", "Status", "Rules", "Success", "Failures", "Skips"], + tablefmt="pretty", + ), + ) diff --git a/t4_devkit/sanity/run.py b/t4_devkit/sanity/run.py new file mode 100644 index 0000000..cd62ab9 --- /dev/null +++ b/t4_devkit/sanity/run.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import warnings +from typing import Sequence + +from .context import SanityContext +from .registry import CHECKERS +from .result import SanityResult + +__all__ = ["sanity_check"] + + +def sanity_check( + data_root: str, + revision: str | None = None, + *, + include_warning: bool = False, + excludes: Sequence[str] | None = None, +) -> SanityResult: + """Run sanity checks on the given data root. + + Args: + data_root (str): The root directory of the data. + revision (str | None, optional): The revision to check. If None, the latest revision is used. + include_warning (bool, optional): Whether to include warning checks. + excludes (Sequence[str] | None, optional): A list of rule names or groups to exclude. + + Returns: + A SanityResult object. + """ + with warnings.catch_warnings(): + if include_warning: + warnings.simplefilter("error") + else: + warnings.simplefilter("ignore") + + context = SanityContext.from_path(data_root, revision=revision) + + checkers = CHECKERS.build(excludes=excludes) + reports = [checker(context) for checker in checkers] + + return SanityResult.from_context(context, reports) diff --git a/t4_devkit/sanity/safety.py b/t4_devkit/sanity/safety.py new file mode 100644 index 0000000..1aa146f --- /dev/null +++ b/t4_devkit/sanity/safety.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from returns.result import Result, safe + +from t4_devkit.common.io import load_json +from t4_devkit import Tier4 + + +@safe +def load_json_safe(filename: str) -> Result[list[dict], Exception]: + """Load JSON file safely.""" + return load_json(filename) + + +@safe +def init_tier4_safe(data_root: str, revision: str | None = None) -> Result[Tier4, Exception]: + """Initialize Tier4 instance safely.""" + return Tier4(data_root, revision=revision, verbose=False) diff --git a/t4_devkit/sanity/structure/__init__.py b/t4_devkit/sanity/structure/__init__.py new file mode 100644 index 0000000..0d1224d --- /dev/null +++ b/t4_devkit/sanity/structure/__init__.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from .str001 import * # noqa +from .str002 import * # noqa +from .str003 import * # noqa +from .str004 import * # noqa +from .str005 import * # noqa +from .str006 import * # noqa +from .str007 import * # noqa +from .str008 import * # noqa +from .str009 import * # noqa diff --git a/t4_devkit/sanity/structure/str001.py b/t4_devkit/sanity/structure/str001.py new file mode 100644 index 0000000..7a0dbf3 --- /dev/null +++ b/t4_devkit/sanity/structure/str001.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from returns.maybe import Some + +from ..checker import Checker, RuleID, RuleName +from ..registry import CHECKERS +from ..result import Reason + +if TYPE_CHECKING: + from ..context import SanityContext + + +__all__ = ["STR001"] + + +@CHECKERS.register(RuleID("STR001")) +class STR001(Checker): + """A checker of STR001.""" + + name = RuleName("version-dir-presence") + description = "'version/' directory exists under the dataset root directory." + + def check(self, context: SanityContext) -> list[Reason]: + match context.version: + case Some(_): + return [] + case _: + return [Reason("'version' directory doesn't exist")] diff --git a/t4_devkit/sanity/structure/str002.py b/t4_devkit/sanity/structure/str002.py new file mode 100644 index 0000000..356f13a --- /dev/null +++ b/t4_devkit/sanity/structure/str002.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from returns.maybe import Some + +from ..checker import Checker, RuleID, RuleName +from ..registry import CHECKERS +from ..result import Reason + +if TYPE_CHECKING: + from ..context import SanityContext + + +__all__ = ["STR002"] + + +@CHECKERS.register(RuleID("STR002")) +class STR002(Checker): + """A checker of STR002.""" + + name = RuleName("annotation-dir-presence") + description = "'annotation/' directory exists under the dataset root directory." + + def check(self, context: SanityContext) -> list[Reason]: + match context.annotation_dir: + case Some(x): + return ( + [] + if x.exists() + else [Reason(f"Path to 'annotation' not found: {x.as_posix()}")] + ) + case _: + return [Reason("dataset directory doesn't contain 'annotation' directory")] diff --git a/t4_devkit/sanity/structure/str003.py b/t4_devkit/sanity/structure/str003.py new file mode 100644 index 0000000..b3a012f --- /dev/null +++ b/t4_devkit/sanity/structure/str003.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from returns.maybe import Some + +from ..checker import Checker, RuleID, RuleName +from ..registry import CHECKERS +from ..result import Reason + +if TYPE_CHECKING: + from ..context import SanityContext + + +__all__ = ["STR003"] + + +@CHECKERS.register(RuleID("STR003")) +class STR003(Checker): + """A checker of STR003.""" + + name = RuleName("data-dir-presence") + description = "'data/' directory exists under the dataset root directory." + + def check(self, context: SanityContext) -> list[Reason]: + match context.sensor_data_dir: + case Some(x): + return ( + [] + if x.exists() + else [Reason(f"Path to 'data' directory not found: {x.as_posix()}")] + ) + case _: + return [Reason("dataset directory doesn't contain 'data' directory")] diff --git a/t4_devkit/sanity/structure/str004.py b/t4_devkit/sanity/structure/str004.py new file mode 100644 index 0000000..b13c9f9 --- /dev/null +++ b/t4_devkit/sanity/structure/str004.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from returns.maybe import Some + +from ..checker import Checker, RuleID, RuleName +from ..registry import CHECKERS +from ..result import Reason + +if TYPE_CHECKING: + from ..context import SanityContext + + +__all__ = ["STR004"] + + +@CHECKERS.register(RuleID("STR004")) +class STR004(Checker): + """A checker of STR004.""" + + name = RuleName("map-dir-presence") + description = "'map/' directory exists under the dataset root directory." + + def check(self, context: SanityContext) -> list[Reason]: + match context.map_dir: + case Some(x): + return [] if x.exists() else [Reason(f"Path to 'map' not found: {x.as_posix()}")] + case _: + return [Reason("dataset directory doesn't contain 'map' directory")] diff --git a/t4_devkit/sanity/structure/str005.py b/t4_devkit/sanity/structure/str005.py new file mode 100644 index 0000000..6bd43e8 --- /dev/null +++ b/t4_devkit/sanity/structure/str005.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from returns.maybe import Some + +from ..checker import Checker, RuleID, RuleName +from ..registry import CHECKERS +from ..result import Reason + +if TYPE_CHECKING: + from ..context import SanityContext + + +__all__ = ["STR005"] + + +@CHECKERS.register(RuleID("STR005")) +class STR005(Checker): + """A checker of STR005.""" + + name = RuleName("bag-dir-presence") + description = "'input_bag/' directory exists under the dataset root directory." + + def check(self, context: SanityContext) -> list[Reason]: + match context.bag_dir: + case Some(x): + return ( + [] if x.exists() else [Reason(f"Path to 'input_bag' not found: {x.as_posix()}")] + ) + case _: + return [Reason("dataset directory doesn't contain 'input_bag' directory")] diff --git a/t4_devkit/sanity/structure/str006.py b/t4_devkit/sanity/structure/str006.py new file mode 100644 index 0000000..992e94a --- /dev/null +++ b/t4_devkit/sanity/structure/str006.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from returns.maybe import Some + +from ..checker import Checker, RuleID, RuleName +from ..registry import CHECKERS +from ..result import Reason + +if TYPE_CHECKING: + from ..context import SanityContext + + +__all__ = ["STR006"] + + +@CHECKERS.register(RuleID("STR006")) +class STR006(Checker): + """A checker of STR006.""" + + name = RuleName("status-json-presence") + description = "'status.json' file exists under the dataset root directory." + + def check(self, context: SanityContext) -> list[Reason]: + match context.status_json: + case Some(x): + return ( + [] + if x.exists() + else [Reason(f"Path to 'status.json' not found: {x.as_posix()}")] + ) + case _: + return [Reason("dataset directory doesn't contain 'status.json' file")] diff --git a/t4_devkit/sanity/structure/str007.py b/t4_devkit/sanity/structure/str007.py new file mode 100644 index 0000000..fac9bbf --- /dev/null +++ b/t4_devkit/sanity/structure/str007.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from returns.maybe import Some + +from t4_devkit.schema import SchemaName + +from ..checker import Checker, RuleID, RuleName +from ..registry import CHECKERS +from ..result import Reason + +if TYPE_CHECKING: + from ..context import SanityContext + + +__all__ = ["STR007"] + + +@CHECKERS.register(RuleID("STR007")) +class STR007(Checker): + """A checker of STR007.""" + + name = RuleName("schema-file-presence") + description = "Mandatory schema JSON files exist under the `annotation/` directory." + + def check(self, context: SanityContext) -> list[Reason]: + failures = [] + for schema in SchemaName: + match context.to_schema_file(schema): + case Some(x): + if not x.exists() and not schema.is_optional(): + failures.append(Reason(f"schema file '{schema.filename}' not found")) + case _: + failures.append(Reason(f"schema file '{schema.filename}' not found")) + return failures diff --git a/t4_devkit/sanity/structure/str008.py b/t4_devkit/sanity/structure/str008.py new file mode 100644 index 0000000..196fa78 --- /dev/null +++ b/t4_devkit/sanity/structure/str008.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from returns.maybe import Some + +from ..checker import Checker, RuleID, RuleName +from ..registry import CHECKERS +from ..result import Reason + +if TYPE_CHECKING: + from ..context import SanityContext + +__all__ = ["STR008"] + + +@CHECKERS.register(RuleID("STR008")) +class STR008(Checker): + """A checker of STR008.""" + + name = RuleName("lanelet-file-presence") + description = "'lanelet2_map.osm' file exists under the 'map/' directory." + + def check(self, context: SanityContext) -> list[Reason]: + match context.map_dir: + case Some(x): + if not x.exists(): + return [Reason(f"Path to 'map' directory not found: {x.as_posix()}")] + lanelet_file = x.joinpath("lanelet2_map.osm") + return ( + [Reason(f"Lanelet2 map file not found: {lanelet_file.as_posix()}")] + if not lanelet_file.exists() + else [] + ) + case _: + return [Reason("dataset directory doesn't contain 'map' directory")] diff --git a/t4_devkit/sanity/structure/str009.py b/t4_devkit/sanity/structure/str009.py new file mode 100644 index 0000000..df23c9c --- /dev/null +++ b/t4_devkit/sanity/structure/str009.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from returns.maybe import Some + +from ..checker import Checker, RuleID, RuleName +from ..registry import CHECKERS +from ..result import Reason + +if TYPE_CHECKING: + from ..context import SanityContext + +__all__ = ["STR009"] + + +@CHECKERS.register(RuleID("STR009")) +class STR009(Checker): + """A checker of STR009.""" + + name = RuleName("pointcloud-map-dir-presence") + description = "'pointcloud_map.pcd' directory exists under the 'map/' directory." + + def check(self, context: SanityContext) -> list[Reason]: + match context.map_dir: + case Some(x): + if not x.exists(): + return [Reason(f"Path to 'map' directory not found: {x.as_posix()}")] + pointcloud_map_dir = x.joinpath("pointcloud_map.pcd") + return ( + [Reason(f"PCD map directory not found: {pointcloud_map_dir.as_posix()}")] + if not pointcloud_map_dir.exists() + else [] + ) + case _: + return [Reason("dataset directory doesn't contain 'map' directory")] diff --git a/t4_devkit/sanity/tier4/__init__.py b/t4_devkit/sanity/tier4/__init__.py new file mode 100644 index 0000000..92b85ff --- /dev/null +++ b/t4_devkit/sanity/tier4/__init__.py @@ -0,0 +1,3 @@ +from __future__ import annotations + +from .tiv001 import * # noqa diff --git a/t4_devkit/sanity/tier4/tiv001.py b/t4_devkit/sanity/tier4/tiv001.py new file mode 100644 index 0000000..0ee376b --- /dev/null +++ b/t4_devkit/sanity/tier4/tiv001.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from returns.pipeline import is_successful +from returns.result import Result, safe +from returns.maybe import Maybe, Nothing, Some + +from ..checker import Checker, RuleID, RuleName +from ..result import Reason +from ..registry import CHECKERS +from t4_devkit import Tier4 + +if TYPE_CHECKING: + from ..context import SanityContext + +__all__ = ["TIV001"] + + +@CHECKERS.register(RuleID("TIV001")) +class TIV001(Checker): + """A checker for TIV001.""" + + name = RuleName("load-tier4") + description = "Ensure 'Tier4' instance is loaded successfully." + + def can_skip(self, context: SanityContext) -> Maybe[Reason]: + match context.data_root: + case Some(x): + if not x.exists(): + return Maybe.from_value(Reason(f"'{x.as_posix()}' not found")) + return Nothing + case _: + return Nothing + + def check(self, context: SanityContext) -> list[Reason]: + result = _load_tier4_safe(context) + return ( + [] if is_successful(result) else [Reason(f"Failed to load Tier4: {result.failure()}")] + ) + + +@safe +def _load_tier4_safe(context: SanityContext) -> Result[Tier4, Exception]: + data_root = context.data_root.unwrap() + revision = context.version.value_or(None) + data_root = data_root.as_posix() if revision is None else data_root.parent.as_posix() + return Tier4(data_root, revision=revision, verbose=False)