chore: import upstream snapshot with attribution
CPU tests Workflow / Testing (ubuntu-latest, 3.12) (push) Failing after 1s
CPU tests Workflow / Testing (ubuntu-latest, 3.13) (push) Failing after 0s
Mypy Type Check / Type Check (push) Failing after 0s
Docs/Test WorkFlow / Test docs build (push) Failing after 1s
PR Conflict Labeler / labeling (push) Failing after 1s
Dependency resolution / Resolve [tflite] extra — Python 3.12 (push) Failing after 0s
Smoke Tests / try-all-models (ubuntu-latest, 3.10) (push) Failing after 0s
Smoke Tests / try-all-models (ubuntu-latest, 3.13) (push) Failing after 1s
CPU tests Workflow / build-pkg (push) Failing after 1s
CPU tests Workflow / Testing (ubuntu-latest, 3.10) (push) Failing after 0s
CPU tests Workflow / Testing (ubuntu-latest, 3.11) (push) Failing after 0s
Smoke Tests / try-all-models (macos-latest, 3.10) (push) Has been cancelled
Smoke Tests / try-all-models (macos-latest, 3.13) (push) Has been cancelled
Smoke Tests / try-all-models (windows-latest, 3.10) (push) Has been cancelled
Smoke Tests / try-all-models (windows-latest, 3.13) (push) Has been cancelled
CPU tests Workflow / Testing (macos-latest, 3.10) (push) Has been cancelled
CPU tests Workflow / Testing (macos-latest, 3.13) (push) Has been cancelled
CPU tests Workflow / Testing (windows-latest, 3.10) (push) Has been cancelled
CPU tests Workflow / Testing (windows-latest, 3.13) (push) Has been cancelled
CPU tests Workflow / testing-guardian (push) Has been cancelled
GPU tests Workflow / Testing (push) Has been cancelled
CPU tests Workflow / Testing (ubuntu-latest, 3.12) (push) Failing after 1s
CPU tests Workflow / Testing (ubuntu-latest, 3.13) (push) Failing after 0s
Mypy Type Check / Type Check (push) Failing after 0s
Docs/Test WorkFlow / Test docs build (push) Failing after 1s
PR Conflict Labeler / labeling (push) Failing after 1s
Dependency resolution / Resolve [tflite] extra — Python 3.12 (push) Failing after 0s
Smoke Tests / try-all-models (ubuntu-latest, 3.10) (push) Failing after 0s
Smoke Tests / try-all-models (ubuntu-latest, 3.13) (push) Failing after 1s
CPU tests Workflow / build-pkg (push) Failing after 1s
CPU tests Workflow / Testing (ubuntu-latest, 3.10) (push) Failing after 0s
CPU tests Workflow / Testing (ubuntu-latest, 3.11) (push) Failing after 0s
Smoke Tests / try-all-models (macos-latest, 3.10) (push) Has been cancelled
Smoke Tests / try-all-models (macos-latest, 3.13) (push) Has been cancelled
Smoke Tests / try-all-models (windows-latest, 3.10) (push) Has been cancelled
Smoke Tests / try-all-models (windows-latest, 3.13) (push) Has been cancelled
CPU tests Workflow / Testing (macos-latest, 3.10) (push) Has been cancelled
CPU tests Workflow / Testing (macos-latest, 3.13) (push) Has been cancelled
CPU tests Workflow / Testing (windows-latest, 3.10) (push) Has been cancelled
CPU tests Workflow / Testing (windows-latest, 3.13) (push) Has been cancelled
CPU tests Workflow / testing-guardian (push) Has been cancelled
GPU tests Workflow / Testing (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,435 @@
|
||||
# ------------------------------------------------------------------------
|
||||
# RF-DETR
|
||||
# Copyright (c) 2025 Roboflow. All Rights Reserved.
|
||||
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
|
||||
# ------------------------------------------------------------------------
|
||||
"""Tests for private COCO keypoint schema inference helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from rfdetr.datasets._keypoint_schema import (
|
||||
CocoKeypointSchema,
|
||||
YoloKeypointSchema,
|
||||
_infer_keypoint_flip_pairs_from_names,
|
||||
_merge_category_keypoint_flip_pairs,
|
||||
infer_coco_keypoint_schema,
|
||||
infer_yolo_keypoint_schema,
|
||||
)
|
||||
|
||||
|
||||
def _write_coco_annotations(
|
||||
path: Path,
|
||||
*,
|
||||
categories: list[dict],
|
||||
annotations: list[dict] | None = None,
|
||||
) -> None:
|
||||
"""Write a minimal COCO annotation file.
|
||||
|
||||
Args:
|
||||
path: Destination JSON path.
|
||||
categories: COCO category objects.
|
||||
annotations: Optional COCO annotation objects.
|
||||
|
||||
Returns:
|
||||
``None``.
|
||||
|
||||
Raises:
|
||||
OSError: If the file cannot be written.
|
||||
|
||||
Example:
|
||||
>>> import tempfile
|
||||
>>> output = Path(tempfile.mkdtemp()) / "annotations.json"
|
||||
>>> _write_coco_annotations(output, categories=[{"id": 0, "name": "person"}])
|
||||
>>> output.exists()
|
||||
True
|
||||
"""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(
|
||||
json.dumps({"images": [], "annotations": annotations or [], "categories": categories}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def test_infer_coco_keypoint_schema_uses_declared_category_keypoints(tmp_path: Path) -> None:
|
||||
"""Declared category keypoints should produce a category-aligned keypoint schema."""
|
||||
annotation_path = tmp_path / "annotations.json"
|
||||
_write_coco_annotations(
|
||||
annotation_path,
|
||||
categories=[{"id": 0, "name": "person", "keypoints": ["nose", "left_eye"], "skeleton": []}],
|
||||
)
|
||||
|
||||
schema = infer_coco_keypoint_schema(annotation_path)
|
||||
|
||||
assert schema == CocoKeypointSchema(
|
||||
class_names=["person"],
|
||||
num_keypoints_per_class=[2],
|
||||
keypoint_oks_sigmas=[0.1, 0.1],
|
||||
)
|
||||
|
||||
|
||||
def test_infer_coco_keypoint_schema_infers_left_right_flip_pairs(tmp_path: Path) -> None:
|
||||
"""COCO category keypoint names should infer horizontal flip pairs when unambiguous."""
|
||||
annotation_path = tmp_path / "annotations.json"
|
||||
_write_coco_annotations(
|
||||
annotation_path,
|
||||
categories=[
|
||||
{
|
||||
"id": 0,
|
||||
"name": "person",
|
||||
"keypoints": ["nose", "left_eye", "right_eye", "left_wrist", "right_wrist"],
|
||||
"skeleton": [],
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
schema = infer_coco_keypoint_schema(annotation_path)
|
||||
|
||||
assert schema.keypoint_flip_pairs == [1, 2, 3, 4]
|
||||
|
||||
|
||||
def test_infer_coco_keypoint_schema_does_not_invent_missing_mirror_pairs(tmp_path: Path) -> None:
|
||||
"""A left/right token without its counterpart should keep the keypoint slots unswapped."""
|
||||
annotation_path = tmp_path / "annotations.json"
|
||||
_write_coco_annotations(
|
||||
annotation_path,
|
||||
categories=[
|
||||
{
|
||||
"id": 0,
|
||||
"name": "person",
|
||||
"keypoints": ["nose", "left_eye", "left_wrist"],
|
||||
"skeleton": [],
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
schema = infer_coco_keypoint_schema(annotation_path)
|
||||
|
||||
assert schema.num_keypoints_per_class == [3]
|
||||
assert schema.keypoint_flip_pairs == []
|
||||
|
||||
|
||||
def test_infer_coco_keypoint_schema_drops_pairs_when_keypoint_categories_disagree(tmp_path: Path) -> None:
|
||||
"""A global flip-pair list is unsafe when keypoint classes use different slot layouts."""
|
||||
annotation_path = tmp_path / "annotations.json"
|
||||
_write_coco_annotations(
|
||||
annotation_path,
|
||||
categories=[
|
||||
{
|
||||
"id": 0,
|
||||
"name": "standing_person",
|
||||
"keypoints": ["left_eye", "right_eye", "nose"],
|
||||
"skeleton": [],
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"name": "seated_person",
|
||||
"keypoints": ["nose", "left_eye", "right_eye"],
|
||||
"skeleton": [],
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
schema = infer_coco_keypoint_schema(annotation_path)
|
||||
|
||||
assert schema.num_keypoints_per_class == [3, 3]
|
||||
assert schema.keypoint_flip_pairs == []
|
||||
|
||||
|
||||
def test_infer_coco_keypoint_schema_uses_annotation_keypoints_when_category_metadata_is_missing(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Annotation keypoint arrays should define the count when category metadata is absent."""
|
||||
annotation_path = tmp_path / "annotations.json"
|
||||
_write_coco_annotations(
|
||||
annotation_path,
|
||||
categories=[{"id": 7, "name": "pose"}],
|
||||
annotations=[{"id": 1, "image_id": 1, "category_id": 7, "keypoints": [1, 2, 2, 3, 4, 1]}],
|
||||
)
|
||||
|
||||
schema = infer_coco_keypoint_schema(annotation_path, keypoint_oks_sigma=0.1)
|
||||
|
||||
assert schema.class_names == ["pose"]
|
||||
assert schema.num_keypoints_per_class == [2]
|
||||
assert schema.keypoint_oks_sigmas == [0.1, 0.1]
|
||||
|
||||
|
||||
def test_infer_coco_keypoint_schema_places_detection_only_categories_in_free_slots(tmp_path: Path) -> None:
|
||||
"""Detection-only categories should stay category-aligned with zero keypoint counts."""
|
||||
annotation_path = tmp_path / "annotations.json"
|
||||
_write_coco_annotations(
|
||||
annotation_path,
|
||||
categories=[
|
||||
{"id": 0, "name": "person", "keypoints": ["nose", "left_eye"]},
|
||||
{"id": 1, "name": "helmet"},
|
||||
{"id": 2, "name": "vest"},
|
||||
],
|
||||
)
|
||||
|
||||
schema = infer_coco_keypoint_schema(annotation_path)
|
||||
|
||||
assert schema.class_names == ["person", "helmet", "vest"]
|
||||
assert schema.num_keypoints_per_class == [2, 0, 0]
|
||||
assert schema.keypoint_oks_sigmas == [0.1, 0.1]
|
||||
|
||||
|
||||
def test_infer_coco_keypoint_schema_supports_multiple_keypoint_categories_with_same_count(tmp_path: Path) -> None:
|
||||
"""Multiple keypoint classes with the same keypoint count should stay category-aligned."""
|
||||
annotation_path = tmp_path / "annotations.json"
|
||||
_write_coco_annotations(
|
||||
annotation_path,
|
||||
categories=[
|
||||
{"id": 3, "name": "adult", "keypoints": ["head", "foot"]},
|
||||
{"id": 9, "name": "child", "keypoints": ["head", "foot"]},
|
||||
],
|
||||
)
|
||||
|
||||
schema = infer_coco_keypoint_schema(annotation_path)
|
||||
|
||||
assert schema.class_names == ["adult", "child"]
|
||||
assert schema.num_keypoints_per_class == [2, 2]
|
||||
assert schema.keypoint_oks_sigmas == [0.1, 0.1]
|
||||
|
||||
|
||||
def test_infer_coco_keypoint_schema_rejects_missing_keypoints(tmp_path: Path) -> None:
|
||||
"""Detection-only COCO files should fail fast instead of silently training without keypoints."""
|
||||
annotation_path = tmp_path / "annotations.json"
|
||||
_write_coco_annotations(annotation_path, categories=[{"id": 0, "name": "person"}])
|
||||
|
||||
with pytest.raises(ValueError, match="has no keypoint metadata"):
|
||||
infer_coco_keypoint_schema(annotation_path)
|
||||
|
||||
|
||||
def test_infer_coco_keypoint_schema_supports_mixed_keypoint_counts(tmp_path: Path) -> None:
|
||||
"""Different keypoint counts are represented per class and padded later by the dataset."""
|
||||
annotation_path = tmp_path / "annotations.json"
|
||||
_write_coco_annotations(
|
||||
annotation_path,
|
||||
categories=[
|
||||
{"id": 0, "name": "person", "keypoints": ["nose"]},
|
||||
{"id": 1, "name": "animal", "keypoints": ["head", "tail"]},
|
||||
],
|
||||
)
|
||||
|
||||
schema = infer_coco_keypoint_schema(annotation_path)
|
||||
|
||||
assert schema.class_names == ["person", "animal"]
|
||||
assert schema.num_keypoints_per_class == [1, 2]
|
||||
assert schema.keypoint_oks_sigmas == [0.1, 0.1]
|
||||
|
||||
|
||||
def test_infer_coco_keypoint_schema_rejects_malformed_annotation_keypoint_length(tmp_path: Path) -> None:
|
||||
"""COCO keypoint arrays must be flattened ``x, y, visibility`` triples."""
|
||||
annotation_path = tmp_path / "annotations.json"
|
||||
_write_coco_annotations(
|
||||
annotation_path,
|
||||
categories=[{"id": 0, "name": "person"}],
|
||||
annotations=[{"id": 1, "image_id": 1, "category_id": 0, "keypoints": [1, 2]}],
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="length divisible by 3"):
|
||||
infer_coco_keypoint_schema(annotation_path)
|
||||
|
||||
|
||||
def test_infer_coco_keypoint_schema_raises_file_not_found(tmp_path: Path) -> None:
|
||||
"""Non-existent annotation file should raise FileNotFoundError before any parsing."""
|
||||
missing = tmp_path / "does_not_exist.json"
|
||||
|
||||
with pytest.raises(FileNotFoundError):
|
||||
infer_coco_keypoint_schema(missing)
|
||||
|
||||
|
||||
def test_infer_coco_keypoint_schema_raises_key_error_for_missing_categories_key(tmp_path: Path) -> None:
|
||||
"""JSON file missing the 'categories' key should raise KeyError."""
|
||||
annotation_path = tmp_path / "no_categories.json"
|
||||
annotation_path.write_text('{"images": [], "annotations": []}', encoding="utf-8")
|
||||
|
||||
with pytest.raises(KeyError):
|
||||
infer_coco_keypoint_schema(annotation_path)
|
||||
|
||||
|
||||
def test_infer_coco_keypoint_schema_raises_value_error_for_list_root(tmp_path: Path) -> None:
|
||||
"""JSON file whose root is a list (not an object) should raise ValueError."""
|
||||
annotation_path = tmp_path / "list_root.json"
|
||||
annotation_path.write_text("[]", encoding="utf-8")
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
infer_coco_keypoint_schema(annotation_path)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"counts,expected",
|
||||
[
|
||||
pytest.param([0, 17, 25], [17, 25], id="leading-zero-filtered"),
|
||||
pytest.param([0, 0], [], id="all-zero-returns-empty"),
|
||||
pytest.param([5, 17], [5, 17], id="all-nonzero-returned-unchanged"),
|
||||
pytest.param([], [], id="empty-input-returns-empty"),
|
||||
],
|
||||
)
|
||||
def test_active_keypoint_counts_filters_zeros(counts: list[int], expected: list[int]) -> None:
|
||||
"""active_keypoint_counts should return only positive counts in schema order."""
|
||||
from rfdetr.datasets._keypoint_schema import active_keypoint_counts
|
||||
|
||||
result = active_keypoint_counts(counts)
|
||||
|
||||
assert result == expected, f"active_keypoint_counts({counts!r}) = {result!r}, expected {expected!r}"
|
||||
|
||||
|
||||
def test_infer_yolo_keypoint_schema_reads_pose_yaml_metadata(tmp_path: Path) -> None:
|
||||
"""YOLO pose YAML should define class names, keypoint count, names, and flip pairs."""
|
||||
data_file = tmp_path / "data.yaml"
|
||||
data_file.write_text(
|
||||
"names:\n 0: person\nkpt_shape: [2, 3]\nflip_idx: [0, 1]\nkpt_names:\n 0:\n - left_eye\n - right_eye\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
schema = infer_yolo_keypoint_schema(data_file)
|
||||
|
||||
assert schema == YoloKeypointSchema(
|
||||
class_names=["person"],
|
||||
num_keypoints_per_class=[2],
|
||||
keypoint_oks_sigmas=[0.1, 0.1],
|
||||
keypoint_names=["left_eye", "right_eye"],
|
||||
flip_idx=[0, 1],
|
||||
keypoint_dim=3,
|
||||
)
|
||||
|
||||
|
||||
def test_infer_yolo_keypoint_schema_rejects_detection_yaml(tmp_path: Path) -> None:
|
||||
"""Detection-only YOLO YAML should fail fast in keypoint schema inference."""
|
||||
data_file = tmp_path / "data.yaml"
|
||||
data_file.write_text("names:\n - person\n", encoding="utf-8")
|
||||
|
||||
with pytest.raises(ValueError, match="kpt_shape"):
|
||||
infer_yolo_keypoint_schema(data_file)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kpt_shape", ["[17, 1]", "[0, 3]", "[17]", "[17, 4]"])
|
||||
def test_infer_yolo_keypoint_schema_rejects_invalid_kpt_shape(tmp_path: Path, kpt_shape: str) -> None:
|
||||
"""YOLO pose kpt_shape must be [positive_count, 2_or_3]."""
|
||||
data_file = tmp_path / "data.yaml"
|
||||
data_file.write_text(f"names:\n - person\nkpt_shape: {kpt_shape}\n", encoding="utf-8")
|
||||
|
||||
with pytest.raises(ValueError, match="kpt_shape"):
|
||||
infer_yolo_keypoint_schema(data_file)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"flip_idx_text, expected_match",
|
||||
[
|
||||
pytest.param("[0, 5]", "permutation", id="out_of_range"),
|
||||
pytest.param("[0, 0]", "permutation", id="duplicate"),
|
||||
pytest.param("[0]", "integer indexes", id="wrong_length"),
|
||||
],
|
||||
)
|
||||
def test_infer_yolo_keypoint_schema_rejects_invalid_flip_idx(
|
||||
tmp_path: Path, flip_idx_text: str, expected_match: str
|
||||
) -> None:
|
||||
"""flip_idx must be a valid permutation of 0..N-1 matching kpt_shape count."""
|
||||
data_file = tmp_path / "data.yaml"
|
||||
data_file.write_text(
|
||||
f"names:\n 0: person\nkpt_shape: [2, 3]\nflip_idx: {flip_idx_text}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
with pytest.raises(ValueError, match=expected_match):
|
||||
infer_yolo_keypoint_schema(data_file)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _infer_keypoint_flip_pairs_from_names edge cases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"names,expected",
|
||||
[
|
||||
pytest.param([], [], id="empty-input"),
|
||||
pytest.param(["left_eye"], [], id="single-directional-no-mirror"),
|
||||
pytest.param(["Left-Eye", "left_eye"], [], id="duplicate-normalized-names"),
|
||||
pytest.param(["left_right_wrist"], [], id="two-directional-tokens-ambiguous"),
|
||||
pytest.param(["nose", "left_eye", "right_eye"], [1, 2], id="standard-coco-pair"),
|
||||
],
|
||||
)
|
||||
def test_infer_keypoint_flip_pairs_from_names_edge_cases(names: list[str], expected: list[int]) -> None:
|
||||
"""Edge-case inputs should return the expected flat pair list without raising."""
|
||||
assert _infer_keypoint_flip_pairs_from_names(names) == expected
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _merge_category_keypoint_flip_pairs success path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_merge_category_keypoint_flip_pairs_returns_common_pairs_when_all_agree() -> None:
|
||||
"""All-agreeing categories should return the shared pair list."""
|
||||
assert _merge_category_keypoint_flip_pairs([[1, 2], [1, 2], [1, 2]]) == [1, 2]
|
||||
|
||||
|
||||
def test_merge_category_keypoint_flip_pairs_single_category() -> None:
|
||||
"""Single-category input should return that category's pairs unchanged."""
|
||||
assert _merge_category_keypoint_flip_pairs([[3, 5, 1, 2]]) == [3, 5, 1, 2]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# YoloKeypointSchema.keypoint_flip_pairs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_infer_yolo_keypoint_schema_populates_keypoint_flip_pairs(tmp_path: Path) -> None:
|
||||
"""YOLO flip_idx should produce an equivalent keypoint_flip_pairs list on the schema."""
|
||||
data_file = tmp_path / "data.yaml"
|
||||
data_file.write_text(
|
||||
"names:\n 0: person\nkpt_shape: [3, 3]\nflip_idx: [0, 2, 1]\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
schema = infer_yolo_keypoint_schema(data_file)
|
||||
|
||||
assert schema.flip_idx == [0, 2, 1]
|
||||
assert schema.keypoint_flip_pairs == [1, 2]
|
||||
|
||||
|
||||
def test_infer_yolo_keypoint_schema_empty_flip_idx_gives_empty_pairs(tmp_path: Path) -> None:
|
||||
"""Missing flip_idx should result in an empty keypoint_flip_pairs list."""
|
||||
data_file = tmp_path / "data.yaml"
|
||||
data_file.write_text(
|
||||
"names:\n 0: person\nkpt_shape: [2, 3]\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
schema = infer_yolo_keypoint_schema(data_file)
|
||||
|
||||
assert schema.flip_idx == []
|
||||
assert schema.keypoint_flip_pairs == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public re-export from rfdetr.datasets
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_infer_coco_keypoint_schema_importable_from_datasets_package(tmp_path: Path) -> None:
|
||||
"""infer_coco_keypoint_schema should be importable from the public rfdetr.datasets package."""
|
||||
from rfdetr.datasets import infer_coco_keypoint_schema as public_fn
|
||||
|
||||
annotation_path = tmp_path / "annotations.json"
|
||||
_write_coco_annotations(
|
||||
annotation_path,
|
||||
categories=[{"id": 0, "name": "person", "keypoints": ["nose", "left_eye", "right_eye"], "skeleton": []}],
|
||||
)
|
||||
schema = public_fn(annotation_path)
|
||||
assert schema.keypoint_flip_pairs == [1, 2]
|
||||
|
||||
|
||||
def test_infer_yolo_keypoint_schema_importable_from_datasets_package(tmp_path: Path) -> None:
|
||||
"""infer_yolo_keypoint_schema should be importable from the public rfdetr.datasets package."""
|
||||
from rfdetr.datasets import infer_yolo_keypoint_schema as public_fn
|
||||
|
||||
data_file = tmp_path / "data.yaml"
|
||||
data_file.write_text("names:\n 0: person\nkpt_shape: [1, 3]\n", encoding="utf-8")
|
||||
schema = public_fn(data_file)
|
||||
assert schema.num_keypoints_per_class == [1]
|
||||
Reference in New Issue
Block a user