Spaces:
Sleeping
Sleeping
| """ | |
| 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" | |
| 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 | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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 [] | |
| 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 [] | |
| 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 [] | |
| 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 | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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 [] | |
| 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 [] | |
| 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 | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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 [] | |
| 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 | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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 | |
| 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 | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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 [] | |
| 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 [] | |
| 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 [] | |
| 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 [] | |
| 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 [] | |
| 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 [] | |
| 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 | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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 | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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 | |
| 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 | |
| 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 | |
| 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 | |
| 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 |