t2av_eval / backend /split_annotation.py
Ying Huang
Prolific return handling.
95c825f
Raw History Blame Contribute Delete
8.96 kB
"""Helpers for the separate event/time annotation workflow.
The module is deliberately independent of FastAPI and Hugging Face I/O so
the partial-answer rules can be checked locally.
"""
from fastapi import HTTPException
from datetime import datetime, timezone
EVENT_ROLE = "event"
TIME_ROLE = "temporal"
ROLES = (EVENT_ROLE, TIME_ROLE)
QUALIFICATION_RESERVATION_STATUSES = {"in_progress", "retry_ready", "qualified"}
COUNTED_QUALIFICATION_STATUSES = {
"in_progress", "retry_ready", "qualified",
}
def qualification_passed(state):
"""Only a successfully qualified participant may start the main task."""
return isinstance(state, dict) and state.get("status") == "qualified"
def qualification_reservation_expired(state, now, ttl_seconds):
"""Whether an unfinished qualification has been idle beyond its allowed time."""
if (not isinstance(state, dict)
or state.get("status") not in QUALIFICATION_RESERVATION_STATUSES):
return False
timestamp = state.get("updated_at") or state.get("created_at")
if not isinstance(timestamp, str):
return False
try:
updated_at = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
if updated_at.tzinfo is None:
updated_at = updated_at.replace(tzinfo=timezone.utc)
except ValueError:
return False
return (now - updated_at).total_seconds() >= ttl_seconds
def task_assignment_expired(state, now, ttl_seconds):
"""Whether an incomplete split task exceeded its absolute time limit."""
if not isinstance(state, dict) or state.get("status") != "in_progress":
return False
assignment = state.get("assignment")
timestamp = assignment.get("created_at") if isinstance(assignment, dict) else None
if not isinstance(timestamp, str):
timestamp = state.get("created_at")
if not isinstance(timestamp, str):
return False
try:
started_at = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
if started_at.tzinfo is None:
started_at = started_at.replace(tzinfo=timezone.utc)
except ValueError:
return False
return (now - started_at).total_seconds() >= ttl_seconds
def role_occupancy(allocation, qualification_states, now, ttl_seconds):
"""Count task members and live qualification reservations by role."""
counts = {role: 0 for role in ROLES}
assigned_ids = set()
for group in (allocation or {}).get("groups", []):
members = group.get("members", {})
if not isinstance(members, dict):
continue
for participant_id, role in members.items():
if role in counts and participant_id not in assigned_ids:
counts[role] += 1
assigned_ids.add(participant_id)
for participant_id, state in (qualification_states or {}).items():
if (participant_id in assigned_ids
or not isinstance(state, dict)
or state.get("task_timeout_released")
or state.get("status") not in COUNTED_QUALIFICATION_STATUSES
or qualification_reservation_expired(state, now, ttl_seconds)):
continue
role = state.get("annotation_role") if isinstance(state, dict) else None
if role in counts:
counts[role] += 1
return counts
def choose_available_role(counts, capacity_per_role, rng):
"""Balance roles while never assigning beyond a role's remaining capacity."""
candidates = [role for role in ROLES if counts.get(role, 0) < capacity_per_role]
if not candidates:
return None
least_occupied = min(counts.get(role, 0) for role in candidates)
return rng.choice([role for role in candidates if counts.get(role, 0) == least_occupied])
def validate_role_responses(item, responses, role):
"""Validate only the fields assigned to this role."""
if role not in ROLES:
raise HTTPException(status_code=422, detail="Invalid annotation role")
if not isinstance(responses, dict):
raise HTTPException(status_code=422, detail="Invalid annotation responses")
events = responses.get("events")
video = responses.get("video")
if not isinstance(events, dict):
raise HTTPException(status_code=422, detail="Invalid event responses")
missing = []
valid = {"PASS", "FAIL"}
for event in item.get("events", []):
event_id = event.get("id")
answer = events.get(event_id)
if not isinstance(answer, dict):
missing.append(event_id)
continue
if role == EVENT_ROLE:
axes = ("v",) if event.get("modality") == "silent_action" else ("a", "v", "avb")
for axis in axes:
value = answer.get(axis)
if not isinstance(value, dict) or value.get("label") not in valid:
missing.append(f"{event_id}.{axis}")
else:
value = answer.get("tcCount")
count = value.get("value") if isinstance(value, dict) else None
try:
number = int(count)
valid_count = not isinstance(count, bool) and number >= 0
if isinstance(count, float):
valid_count = valid_count and count.is_integer()
elif isinstance(count, str):
valid_count = valid_count and count.isdigit()
except (TypeError, ValueError):
valid_count = False
if not valid_count:
missing.append(f"{event_id}.tcCount")
if role == TIME_ROLE:
relation = video.get("tcRel") if isinstance(video, dict) else None
if not isinstance(relation, dict) or relation.get("label") not in valid:
missing.append("video.tcRel")
if missing:
raise HTTPException(
status_code=422,
detail=f"Complete all required fields before saving: {', '.join(missing)}",
)
def required_event_counts(item):
events = item.get("events", [])
counts = {event["id"]: 1 for event in events}
constraint = item.get("temporal_constraint") or {}
value = constraint.get("value") or {}
relation = constraint.get("relation_type")
if relation == "sync_count":
for event_id in value.get("events", []):
counts[event_id] = value.get("count", 1)
elif relation == "order_count":
counts.update(value.get("event_counts") or {})
elif relation == "count" and value.get("event"):
counts[value["event"]] = value.get("count", 1)
return counts
def aggregate_role_axes(item, responses, role):
"""Return the hidden-GT axes measured by a role for qualification."""
event_answers = responses["events"]
def aggregate(axis, include):
values = [event_answers[event["id"]][axis]["label"]
for event in item.get("events", []) if include(event)]
return "PASS" if values and all(value == "PASS" for value in values) else "FAIL"
if role == EVENT_ROLE:
has_audio = lambda event: event.get("modality") != "silent_action"
return {
"A": aggregate("a", has_audio),
"V": aggregate("v", lambda _event: True),
"AVB": aggregate("avb", has_audio),
}
counts = required_event_counts(item)
matches = all(
int(event_answers[event_id]["tcCount"]["value"]) == int(expected)
for event_id, expected in counts.items()
)
return {
"TCREL": responses["video"]["tcRel"]["label"],
"TCCNT": "PASS" if matches else "FAIL",
}
def score_role_answer(item, responses, expected, role):
actual = aggregate_role_axes(item, responses, role)
correct = sum(actual[axis] == expected[axis] for axis in actual)
return correct, len(actual)
def merge_role_responses(item, event_responses, temporal_responses):
"""Combine one event part and one temporal part, applying hierarchy rules.
The input temporal part contains observed raw counts. The returned answer
follows the existing full-answer shape for downstream analysis.
"""
events = {}
event_failed = False
for event in item.get("events", []):
event_id = event["id"]
event_part = event_responses["events"][event_id]
time_part = temporal_responses["events"][event_id]
combined = {key: value for key, value in event_part.items() if key in {"a", "v", "avb"}}
count = time_part["tcCount"]
failed = any(
isinstance(value, dict) and value.get("label") == "FAIL"
for key, value in combined.items() if key in {"a", "v", "avb"}
)
if failed:
event_failed = True
count = {**count, "value": 0}
combined["tcCount"] = count
events[event_id] = combined
tc_rel = temporal_responses["video"]["tcRel"]
if event_failed:
tc_rel = {**tc_rel, "label": "FAIL"}
return {"events": events, "video": {"tcRel": tc_rel}}