VideoAnnotation / core /sop_rules.py
swapnakumbar12's picture
Create core/sop_rules.py
a6a3a2b verified
Raw
History Blame Contribute Delete
18.9 kB
"""
SOP Rule Engine β€” Tapi Tag Ontology Phase II
QC checks and error messages derived directly from the official SOP (page 12).
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from .tapi_ontology import (
WEATHER_LABELS, TIME_OF_DAY_LABELS, VISIBILITY_LABELS,
SETTING_AREA_LABELS, SETTING_INFRASTRUCTURE_LABELS, SETTING_NATURE_LABELS,
ROAD_PROPERTY_LABELS, EVENT_LABELS,
EGO_MOVEMENT_LABELS, VEHICLE_MOVEMENT_LABELS,
PEDESTRIAN_MOVEMENT_LABELS, ANIMAL_MOVEMENT_LABELS,
ACTOR_VEHICLE_LABELS, ACTOR_PROPERTY_LABELS,
ACTOR_ANIMAL_LABELS, ACTOR_PEDESTRIAN_LABELS,
TRAFFIC_SIGNAL_LABELS, TRAFFIC_SIGN_LABELS,
ALL_VALID_LABELS, URBAN_RESIDENTIAL, EVENT_MAX_SECONDS,
)
class Severity(str, Enum):
ERROR = "error"
WARNING = "warning"
@dataclass
class RuleViolation:
rule_id: str
severity: Severity
message: str
frame: int
track_id: str | None = None
details: dict = field(default_factory=dict)
# ── Rule registry ────────────────────────────
RULES: dict[str, dict] = {}
def rule(rule_id: str, description: str, severity: Severity = Severity.ERROR):
def decorator(fn):
RULES[rule_id] = {"fn": fn, "description": description, "severity": severity}
return fn
return decorator
# ════════════════════════════════════════════════
# ENVIRONMENT β€” SOP page 12
# ════════════════════════════════════════════════
@rule("ENV-01", "Missing Environment label for any frames")
def check_env_missing(ann: dict, fi: int, ctx: dict) -> list[RuleViolation]:
labels = _labels(ann)
env = WEATHER_LABELS | TIME_OF_DAY_LABELS | VISIBILITY_LABELS
if not any(l in env for l in labels):
return [_v("ENV-01", f"Missing Environment label: frame {fi}", fi)]
return []
@rule("ENV-02", "Contradictory annotation within Environment labels based on Time of Day")
def check_tod_change(ann: dict, fi: int, ctx: dict) -> list[RuleViolation]:
labels = _labels(ann)
tod = [l for l in labels if l in TIME_OF_DAY_LABELS]
if len(tod) > 1:
return [_v("ENV-02",
f"Found multiple values for Time of Day: Environment Label Time of Day cannot change within a task "
f"(frame {fi}): {tod}", fi)]
saved = ctx.get("task_tod")
if tod:
if saved is None:
ctx["task_tod"] = tod[0]
elif tod[0] != saved:
return [_v("ENV-02",
f"Environment label Time of Day cannot change within a task "
f"(was '{saved}', now '{tod[0]}' at frame {fi})", fi)]
return []
@rule("ENV-03", "Contradictory annotation within Environment labels based on Weather")
def check_weather_conflict(ann: dict, fi: int, ctx: dict) -> list[RuleViolation]:
labels = _labels(ann)
weather = [l for l in labels if l in WEATHER_LABELS]
vis = [l for l in labels if l in VISIBILITY_LABELS]
if len(weather) > 1:
return [_v("ENV-03",
f"Environment label cannot have multiple Weather values (frame {fi}): {weather}", fi)]
if weather and vis:
w = weather[0]
v = vis[0]
if w == "clear" and v == "poor visibility from weather":
return [_v("ENV-03",
f"Environment label '{v}' cannot be set if Weather is '{w}' (frame {fi})", fi)]
if w == "foggy" and v == "clear":
return [_v("ENV-03",
f"Environment label Visibility 'clear' cannot be set if Weather is 'foggy' (frame {fi})", fi)]
return []
@rule("ENV-04", "Environment labels overlapping for any frames")
def check_env_overlap(ann: dict, fi: int, ctx: dict) -> list[RuleViolation]:
labels = _labels(ann)
vis = [l for l in labels if l in VISIBILITY_LABELS]
if len(vis) > 1:
return [_v("ENV-04",
f"Environment labels overlapping: frame {fi} β€” multiple Visibility values: {vis}", fi)]
return []
# ════════════════════════════════════════════════
# SETTING β€” SOP page 12
# ════════════════════════════════════════════════
@rule("SET-01", "Labels for Setting is missing for any frames")
def check_setting_missing(ann: dict, fi: int, ctx: dict) -> list[RuleViolation]:
labels = _labels(ann)
all_set = SETTING_AREA_LABELS | SETTING_INFRASTRUCTURE_LABELS | SETTING_NATURE_LABELS
if not any(l in all_set for l in labels):
return [_v("SET-01", f"Missing Setting label: frame {fi}", fi)]
return []
@rule("SET-02", "Setting labels are overlapping for any frames")
def check_setting_overlap(ann: dict, fi: int, ctx: dict) -> list[RuleViolation]:
labels = _labels(ann)
area = [l for l in labels if l in SETTING_AREA_LABELS]
if len(area) > 1:
return [_v("SET-02",
f"Setting labels overlapping: frame {fi} β€” multiple Area values: {area}", fi)]
return []
@rule("SET-03", "Setting Area Urban/Residential must also have 'buildings' infrastructure")
def check_urban_buildings(ann: dict, fi: int, ctx: dict) -> list[RuleViolation]:
labels = _labels(ann)
area = next((l for l in labels if l in SETTING_AREA_LABELS), None)
if area in URBAN_RESIDENTIAL and "buildings" not in labels:
return [_v("SET-03",
f"Setting error: {area.title()}/Residential area must have 'buildings' selected (frame {fi})", fi)]
return []
# ════════════════════════════════════════════════
# ROAD TOPOLOGY β€” SOP page 12
# ════════════════════════════════════════════════
@rule("ROAD-01", "Labels for Road Topology is missing for any frames")
def check_road_missing(ann: dict, fi: int, ctx: dict) -> list[RuleViolation]:
labels = _labels(ann)
has_road = any(l in ROAD_PROPERTY_LABELS or l == "total lanes" for l in labels)
if not has_road:
return [_v("ROAD-01", f"Missing Road Topology label: frame {fi}", fi)]
return []
@rule("ROAD-02", "Road Topology labels are overlapping for any frames")
def check_road_overlap(ann: dict, fi: int, ctx: dict) -> list[RuleViolation]:
labels = _labels(ann)
props = [l for l in labels if l in ROAD_PROPERTY_LABELS]
# median-separated is structural, should not mix with wet/snow etc.
if "median-separated" in props and len(props) > 3:
return [_v("ROAD-02",
f"Road Topology labels overlapping: frame {fi} β€” check combinations {props}",
fi, severity=Severity.WARNING)]
return []
# ════════════════════════════════════════════════
# EVENT β€” SOP page 12
# ════════════════════════════════════════════════
@rule("EVT-01", "Length of an Event label is greater than 5 seconds")
def check_event_duration(ann: dict, fi: int, ctx: dict) -> list[RuleViolation]:
fps = ctx.get("fps", 25)
max_frames = int(EVENT_MAX_SECONDS * fps)
violations = []
event_start: dict = ctx.setdefault("event_start", {})
for obj in ann.get("objects", []):
label = _n(obj.get("label", ""))
tid = obj.get("track_id", label)
if label in EVENT_LABELS:
if tid not in event_start:
event_start[tid] = fi
elif (fi - event_start[tid]) > max_frames:
violations.append(_v("EVT-01",
f"Length of the event tag (frame {event_start[tid]} - {fi}) is longer than 5 seconds. "
f"Please make sure timestamps are accurate.", fi, tid))
return violations
@rule("EVT-02", "Missing traffic signal static object during event frames")
def check_event_signal(ann: dict, fi: int, ctx: dict) -> list[RuleViolation]:
labels = _labels(ann)
has_event = any(l in EVENT_LABELS for l in labels)
has_signal = any(l in TRAFFIC_SIGNAL_LABELS for l in labels)
if has_event and not has_signal:
return [_v("EVT-02",
f"Missing traffic signal when event happens (frame {fi}) β€” "
f"static object 'traffic signal' must be present during event frames", fi)]
return []
# ════════════════════════════════════════════════
# MOVEMENT β€” EGO β€” SOP page 12
# ════════════════════════════════════════════════
@rule("EGO-01", "Labels for Movements with the Ego-Car is missing for more than 10 consecutive frames")
def check_ego_missing(ann: dict, fi: int, ctx: dict) -> list[RuleViolation]:
labels = _labels(ann)
has_ego = any(l in EGO_MOVEMENT_LABELS for l in labels)
streak: int = ctx.get("ego_miss_streak", 0)
if not has_ego:
ctx["ego_miss_streak"] = streak + 1
if ctx["ego_miss_streak"] >= 10:
return [_v("EGO-01",
f"Missing labels for ego car: frame {fi - ctx['ego_miss_streak'] + 1} - {fi}", fi)]
else:
ctx["ego_miss_streak"] = 0
return []
@rule("EGO-02", "Ego parked overlapping with other Ego-Movement labels")
def check_ego_parked_overlap(ann: dict, fi: int, ctx: dict) -> list[RuleViolation]:
labels = _labels(ann)
ego = [l for l in labels if l in EGO_MOVEMENT_LABELS]
parked = [l for l in ego if "ego parked" in l or l == "ego parking"]
non_parked = [l for l in ego if "ego parked" not in l and l != "ego parking"]
if parked and non_parked:
return [_v("EGO-02",
f"'{parked[0]}' overlapping with other Ego-Movement label '{non_parked[0]}' (frame {fi})", fi)]
return []
@rule("EGO-03", "Ego waits overlapping with other Ego-Movement labels")
def check_ego_waits_overlap(ann: dict, fi: int, ctx: dict) -> list[RuleViolation]:
labels = _labels(ann)
ego = [l for l in labels if l in EGO_MOVEMENT_LABELS]
waits = [l for l in ego if l.startswith("ego waits")]
non_waits = [l for l in ego if not l.startswith("ego waits")]
if waits and non_waits:
return [_v("EGO-03",
f"'Ego waits...' overlapping with other Ego-Movement label (frame {fi}): "
f"'{waits[0]}' + '{non_waits[0]}'", fi)]
return []
@rule("EGO-04", "Ego merges conflicting labels (e.g. merges into + merges out of same place)")
def check_ego_merges_conflict(ann: dict, fi: int, ctx: dict) -> list[RuleViolation]:
labels = _labels(ann)
merges = [l for l in labels if l in EGO_MOVEMENT_LABELS and "ego merges" in l]
into_l = [l for l in merges if "merges into" in l]
out_l = [l for l in merges if "merges out of" in l]
if into_l and out_l:
return [_v("EGO-04",
f"Ego merges overlapping with conflicting label (frame {fi}): "
f"'{into_l[0]}' + '{out_l[0]}'", fi)]
return []
@rule("EGO-05", "Ego turns conflicting labels (left + right at same time)")
def check_ego_turns_conflict(ann: dict, fi: int, ctx: dict) -> list[RuleViolation]:
labels = _labels(ann)
turns = [l for l in labels if l in EGO_MOVEMENT_LABELS and "ego turns" in l]
lefts = [l for l in turns if "left" in l]
rights = [l for l in turns if "right" in l]
if lefts and rights:
return [_v("EGO-05",
f"'Ego turns...' overlapping with conflicting label (frame {fi}): "
f"'{lefts[0]}' + '{rights[0]}'", fi)]
return []
@rule("EGO-06", "Ego changes lane right and left simultaneously")
def check_ego_lane_change(ann: dict, fi: int, ctx: dict) -> list[RuleViolation]:
labels = _labels(ann)
right = "ego changes lane right out of ego lane" in labels
left = "ego changes lane left out of ego lane" in labels
if right and left:
return [_v("EGO-06",
f"'Ego changes lane right' overlapping with 'Ego changes lane left' (frame {fi})", fi)]
return []
@rule("EGO-07", "Ego merges into Rotary and Ego merges out of Rotary cannot overlap")
def check_ego_rotary(ann: dict, fi: int, ctx: dict) -> list[RuleViolation]:
labels = _labels(ann)
if "ego merges into rotary" in labels and "ego merges out of rotary" in labels:
return [_v("EGO-07",
f"'Ego merges into Rotary' overlapping with 'Ego merges out of Rotary' (frame {fi})", fi)]
return []
# ════════════════════════════════════════════════
# GENERAL MOVEMENT β€” SOP page 12
# ════════════════════════════════════════════════
@rule("MOV-01", "Label used for multiple annotations within same timeline")
def check_duplicate_timeline(ann: dict, fi: int, ctx: dict) -> list[RuleViolation]:
count: dict[str, int] = {}
for obj in ann.get("objects", []):
l = _n(obj.get("label", ""))
count[l] = count.get(l, 0) + 1
violations = []
for label, n in count.items():
if n > 1:
violations.append(_v("MOV-01",
f"'{label}' at frame {fi} used for multiple annotations within the same timeline. "
f"Create a separate annotation for each movement.", fi))
return violations
# ════════════════════════════════════════════════
# BBOX / LABEL VALIDATION
# ════════════════════════════════════════════════
@rule("BOX-01", "Bounding box too large or too small", Severity.WARNING)
def check_box_size(ann: dict, fi: int, ctx: dict) -> list[RuleViolation]:
W = ctx.get("frame_width", 1920)
H = ctx.get("frame_height", 1080)
area = W * H
violations = []
for obj in [o for o in ann.get("objects", []) if o.get("track_id")]:
bb = obj.get("bbox")
if not bb or len(bb) < 4:
continue
x, y, w, h = bb
ratio = (w * h) / area
tid = obj.get("track_id")
if ratio > 0.90:
violations.append(_v("BOX-01",
f"Bounding box too large β€” covers {ratio*100:.1f}% of frame (frame {fi})",
fi, tid, Severity.WARNING))
elif 0 < w * h < area * 0.0001:
violations.append(_v("BOX-01",
f"Bounding box extremely small β€” possible noise annotation (frame {fi})",
fi, tid, Severity.WARNING))
return violations
@rule("BOX-02", "Bounding box outside frame boundary")
def check_out_of_bounds(ann: dict, fi: int, ctx: dict) -> list[RuleViolation]:
W = ctx.get("frame_width", 1920)
H = ctx.get("frame_height", 1080)
violations = []
for obj in ann.get("objects", []):
bb = obj.get("bbox")
if not bb or len(bb) < 4:
continue
x, y, w, h = bb
if x < 0 or y < 0 or (x + w) > W or (y + h) > H:
violations.append(_v("BOX-02",
f"Bounding box extends outside frame boundary (frame {fi})",
fi, obj.get("track_id")))
return violations
@rule("LBL-01", "Label not in Tapi Tag Ontology Phase II")
def check_valid_label(ann: dict, fi: int, ctx: dict) -> list[RuleViolation]:
violations = []
for obj in ann.get("objects", []):
label = _n(obj.get("label", ""))
if label and label not in ALL_VALID_LABELS:
violations.append(_v("LBL-01",
f"Unknown label '{obj.get('label')}' β€” not in Tapi Tag Ontology Phase II (frame {fi})",
fi, obj.get("track_id")))
return violations
@rule("TRK-01", "Track ID label must remain consistent across frames")
def check_track_consistency(ann: dict, fi: int, ctx: dict) -> list[RuleViolation]:
history: dict = ctx.setdefault("track_history", {})
violations = []
for obj in ann.get("objects", []):
tid = obj.get("track_id")
label = _n(obj.get("label", ""))
if not tid:
continue
if tid not in history:
history[tid] = label
elif history[tid] != label:
violations.append(_v("TRK-01",
f"ID {tid} label changed: '{history[tid]}' β†’ '{label}' (frame {fi})",
fi, tid))
return violations
@rule("TRK-02", "Duplicate bounding boxes for same object in same frame")
def check_duplicates(ann: dict, fi: int, ctx: dict) -> list[RuleViolation]:
# Only check actor objects (those with track_id)
objects = [o for o in ann.get("objects", []) if o.get("track_id")]
seen: list = []
violations = []
for obj in objects:
bb = obj.get("bbox")
if not bb or len(bb) < 4:
continue
for s in seen:
if _iou(bb, s) > 0.70:
violations.append(_v("TRK-02",
f"Duplicate annotation detected (IoU > 0.70) (frame {fi})",
fi, obj.get("track_id")))
seen.append(bb)
return violations
# ════════════════════════════════════════════════
# Helpers
# ════════════════════════════════════════════════
def _n(s: str) -> str:
return s.strip().lower()
def _labels(ann: dict) -> list[str]:
return [_n(o.get("label", "")) for o in ann.get("objects", [])]
def _v(rule_id: str, message: str, frame: int,
track_id: str | None = None,
severity: Severity = Severity.ERROR) -> RuleViolation:
return RuleViolation(rule_id=rule_id, severity=severity,
message=message, frame=frame, track_id=track_id)
def _iou(a: list, b: list) -> float:
x1,y1,w1,h1 = a[:4]; x2,y2,w2,h2 = b[:4]
ix = max(0, min(x1+w1,x2+w2) - max(x1,x2))
iy = max(0, min(y1+h1,y2+h2) - max(y1,y2))
inter = ix*iy
union = w1*h1 + w2*h2 - inter
return inter/union if union > 0 else 0.0
def run_all_rules(ann: dict, frame_idx: int, ctx: dict) -> list[RuleViolation]:
violations: list[RuleViolation] = []
for meta in RULES.values():
try:
violations.extend(meta["fn"](ann, frame_idx, ctx))
except Exception:
pass
return violations