feat(perception): prepare blind detector review handoff
This commit is contained in:
@@ -0,0 +1,286 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from k1link.compute import e46_review_handoff as handoff
|
||||
from k1link.compute import e48_detector_truth_seal as e48
|
||||
|
||||
|
||||
def _canonical(value: object) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode()
|
||||
|
||||
|
||||
def _write_json(path: Path, value: object) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(_canonical(value) + b"\n")
|
||||
|
||||
|
||||
def _fixture(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> tuple[Path, Path, SimpleNamespace]:
|
||||
truth_root = tmp_path / ("e46-detector-truth-island-" + "a" * 64)
|
||||
evaluation_root = tmp_path / ("evaluation-pack-" + "b" * 64)
|
||||
truth_root.mkdir()
|
||||
image_root = evaluation_root / "images" / "valid-fov-fill"
|
||||
image_root.mkdir(parents=True)
|
||||
references: list[dict[str, Any]] = []
|
||||
review_images: list[dict[str, Any]] = []
|
||||
for sequence, image_id in enumerate((2, 5), start=1):
|
||||
frame_index = sequence * 10
|
||||
name = f"image-{image_id:03d}-frame-{frame_index:06d}.png"
|
||||
path = image_root / name
|
||||
Image.new("RGB", (800, 600), (sequence * 30, 10, 20)).save(path)
|
||||
digest = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
relative = f"images/valid-fov-fill/{name}"
|
||||
reference = {
|
||||
"schema_version": "missioncore.e46-truth-island-image-reference/v1",
|
||||
"truth_island_sequence": sequence,
|
||||
"image_id": image_id,
|
||||
"frame_index": frame_index,
|
||||
"session_seconds": float(sequence),
|
||||
"role": "anchor",
|
||||
"group_id": f"anchor-{sequence}",
|
||||
"source_path": relative,
|
||||
"sha256": digest,
|
||||
"byte_length": path.stat().st_size,
|
||||
}
|
||||
references.append(reference)
|
||||
review_images.append(
|
||||
{
|
||||
"truth_island_sequence": sequence,
|
||||
"image_id": image_id,
|
||||
"frame_index": frame_index,
|
||||
"session_seconds": float(sequence),
|
||||
"role": "anchor",
|
||||
"group_id": f"anchor-{sequence}",
|
||||
"source_path": relative,
|
||||
"source_sha256": digest,
|
||||
"review_state": "pending",
|
||||
"hard_negative": None,
|
||||
"objects": [],
|
||||
"notes": None,
|
||||
}
|
||||
)
|
||||
_write_json(
|
||||
evaluation_root / "manifest.json",
|
||||
{"generation_id": evaluation_root.name},
|
||||
)
|
||||
(truth_root / "image-references.jsonl").write_bytes(
|
||||
b"".join(_canonical(row) + b"\n" for row in references)
|
||||
)
|
||||
_write_json(
|
||||
truth_root / "blind-contract.json",
|
||||
{
|
||||
"annotation": {"classes": ["person", "car"]},
|
||||
"reviewer_package": {
|
||||
"model_predictions_included": False,
|
||||
"model_prelabels_included": False,
|
||||
},
|
||||
},
|
||||
)
|
||||
_write_json(
|
||||
truth_root / "review-template.json",
|
||||
{
|
||||
"schema_version": "missioncore.e46-detector-review-template/v1",
|
||||
"truth_island_id": truth_root.name,
|
||||
"state": "prepared-unreviewed-no-prelabels",
|
||||
"reviewer_id": None,
|
||||
"review_round": None,
|
||||
"images": review_images,
|
||||
"acceptance": None,
|
||||
},
|
||||
)
|
||||
manifest = {
|
||||
"identity": {
|
||||
"source": {"evaluation_pack_id": evaluation_root.name},
|
||||
}
|
||||
}
|
||||
_write_json(truth_root / "manifest.json", manifest)
|
||||
truth = SimpleNamespace(
|
||||
result_id=truth_root.name,
|
||||
result_root=truth_root,
|
||||
manifest=manifest,
|
||||
report={"status": "prepared-awaiting-independent-human-review"},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
handoff,
|
||||
"read_e46_detector_truth_island",
|
||||
lambda _root: truth,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
e48,
|
||||
"read_e46_detector_truth_island",
|
||||
lambda _root: truth,
|
||||
)
|
||||
return truth_root, evaluation_root, truth
|
||||
|
||||
|
||||
def test_handoff_is_blind_reproducible_and_converts_cvat(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
truth_root, evaluation_root, _truth = _fixture(tmp_path, monkeypatch)
|
||||
result = handoff.build_e46_review_handoff(
|
||||
truth_island_root=truth_root,
|
||||
evaluation_pack_root=evaluation_root,
|
||||
output_root=tmp_path / "handoffs",
|
||||
)
|
||||
repeated = handoff.build_e46_review_handoff(
|
||||
truth_island_root=truth_root,
|
||||
evaluation_pack_root=evaluation_root,
|
||||
output_root=tmp_path / "handoffs",
|
||||
)
|
||||
|
||||
assert repeated.result_id == result.result_id
|
||||
assert result.manifest["identity"]["frame_count"] == 2
|
||||
with zipfile.ZipFile(
|
||||
result.result_root / "reviewer-a" / "cvat-empty-coco.zip"
|
||||
) as archive:
|
||||
coco = json.loads(archive.read("annotations/instances_default.json"))
|
||||
assert coco["annotations"] == []
|
||||
assert "predictions" not in json.dumps(coco).lower()
|
||||
|
||||
source_rows = [
|
||||
json.loads(line)
|
||||
for line in (
|
||||
result.result_root / "reviewer-a" / "source-map.jsonl"
|
||||
).read_text().splitlines()
|
||||
]
|
||||
cvat = {
|
||||
"images": [
|
||||
{
|
||||
"id": index,
|
||||
"file_name": row["file_name"],
|
||||
"width": 800,
|
||||
"height": 600,
|
||||
}
|
||||
for index, row in enumerate(source_rows, start=11)
|
||||
],
|
||||
"categories": [
|
||||
{"id": 1, "name": "person"},
|
||||
{"id": 2, "name": "car"},
|
||||
],
|
||||
"annotations": [
|
||||
{
|
||||
"id": 1,
|
||||
"image_id": 11,
|
||||
"category_id": 2,
|
||||
"bbox": [10.0, 20.0, 100.0, 200.0],
|
||||
"attributes": {
|
||||
"occluded": False,
|
||||
"truncated": True,
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
cvat_path = tmp_path / "review-a.json"
|
||||
_write_json(cvat_path, cvat)
|
||||
output_path = tmp_path / "e48-review-a.json"
|
||||
document = handoff.convert_cvat_coco_to_e48_review(
|
||||
handoff_root=result.result_root,
|
||||
truth_island_root=truth_root,
|
||||
reviewer_slot="reviewer-a",
|
||||
reviewer_id="human-reviewer-a",
|
||||
cvat_export_path=cvat_path,
|
||||
submitted_at_utc="2026-07-29T20:00:00Z",
|
||||
output_path=output_path,
|
||||
)
|
||||
|
||||
assert document["state"] == "completed-independent-no-model-assistance"
|
||||
assert document["images"][0]["objects"][0]["box_xyxy"] == [
|
||||
10.0,
|
||||
20.0,
|
||||
110.0,
|
||||
220.0,
|
||||
]
|
||||
assert document["images"][1]["hard_negative"] is True
|
||||
assert e48.validate_e48_detector_review_submission(
|
||||
truth_island_root=truth_root,
|
||||
review_path=output_path,
|
||||
)["reviewer_id"] == "human-reviewer-a"
|
||||
|
||||
|
||||
def test_handoff_rejects_changed_source_image(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
truth_root, evaluation_root, _truth = _fixture(tmp_path, monkeypatch)
|
||||
source = next(
|
||||
(evaluation_root / "images" / "valid-fov-fill").glob("*.png")
|
||||
)
|
||||
source.write_bytes(b"changed")
|
||||
|
||||
with pytest.raises(handoff.E46ReviewHandoffError, match="identity changed"):
|
||||
handoff.build_e46_review_handoff(
|
||||
truth_island_root=truth_root,
|
||||
evaluation_pack_root=evaluation_root,
|
||||
output_root=tmp_path / "handoffs",
|
||||
)
|
||||
|
||||
|
||||
def test_converter_requires_explicit_occlusion_and_truncation(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
truth_root, evaluation_root, _truth = _fixture(tmp_path, monkeypatch)
|
||||
result = handoff.build_e46_review_handoff(
|
||||
truth_island_root=truth_root,
|
||||
evaluation_pack_root=evaluation_root,
|
||||
output_root=tmp_path / "handoffs",
|
||||
)
|
||||
source_rows = [
|
||||
json.loads(line)
|
||||
for line in (
|
||||
result.result_root / "reviewer-a" / "source-map.jsonl"
|
||||
).read_text().splitlines()
|
||||
]
|
||||
cvat = {
|
||||
"images": [
|
||||
{
|
||||
"id": index,
|
||||
"file_name": row["file_name"],
|
||||
"width": 800,
|
||||
"height": 600,
|
||||
}
|
||||
for index, row in enumerate(source_rows, start=1)
|
||||
],
|
||||
"categories": [{"id": 1, "name": "car"}],
|
||||
"annotations": [
|
||||
{
|
||||
"id": 1,
|
||||
"image_id": 1,
|
||||
"category_id": 1,
|
||||
"bbox": [10.0, 20.0, 100.0, 200.0],
|
||||
}
|
||||
],
|
||||
}
|
||||
cvat_path = tmp_path / "invalid.json"
|
||||
_write_json(cvat_path, cvat)
|
||||
|
||||
with pytest.raises(
|
||||
handoff.E46ReviewHandoffError,
|
||||
match="occluded flag is missing",
|
||||
):
|
||||
handoff.convert_cvat_coco_to_e48_review(
|
||||
handoff_root=result.result_root,
|
||||
truth_island_root=truth_root,
|
||||
reviewer_slot="reviewer-a",
|
||||
reviewer_id="human-reviewer-a",
|
||||
cvat_export_path=cvat_path,
|
||||
submitted_at_utc="2026-07-29T20:00:00Z",
|
||||
output_path=tmp_path / "invalid-review.json",
|
||||
)
|
||||
Reference in New Issue
Block a user