Video-ORA-4B / code /eval /task /segmentation /post_sam2.py
lyhisme's picture
Add 4B model card, benchmark assets, and release code
0185029 verified
Raw
History Blame Contribute Delete
16.3 kB
#!/usr/bin/env python3
"""
Thin project-local wrapper around OneThinker's SAM2 segmentation post-process.
The OneThinker script contains the full image/video mask generation and metric
logic. This wrapper only injects paths from CLI args so evaluation can be run
from this repository without editing the third-party checkout.
"""
from __future__ import annotations
import argparse
import importlib.util
import json
import multiprocessing.spawn as mp_spawn
import os
import sys
import tempfile
from pathlib import Path
from typing import Any
DEFAULT_ONETHINKER_SCRIPT = str(
Path(__file__).resolve().parents[3]
/ "third_party"
/ "OneThinker"
/ "Evaluation"
/ "Eval"
/ "seg_post_sam2.py"
)
def sam2_config_name(
value: str,
package_root: str | Path | None = None,
) -> str:
"""Convert an installed SAM2 YAML path to Hydra's package-relative name."""
path = Path(value).expanduser()
if not path.is_absolute():
return path.as_posix()
if package_root is None:
try:
import sam2
except ImportError:
return str(path)
package_root = Path(sam2.__file__).resolve().parent
try:
return path.resolve().relative_to(Path(package_root).resolve()).as_posix()
except ValueError:
return str(path)
def load_module(script_path: str):
path = Path(script_path)
if not path.is_file():
raise FileNotFoundError(f"OneThinker SAM2 script not found: {path}")
# Use the real filename as the module name so multiprocessing "spawn"
# workers can import it from path.parent when unpickling worker_run.
module_name = path.stem
spec = importlib.util.spec_from_file_location(module_name, path)
if spec is None or spec.loader is None:
raise RuntimeError(f"Failed to load module spec from {path}")
module = importlib.util.module_from_spec(spec)
sys.path.insert(0, str(path.parent))
# multiprocessing pickles functions by module name; dynamic modules must be
# registered so child processes can resolve worker_run.
sys.modules[module_name] = module
spec.loader.exec_module(module)
return module
def _results(payload: Any) -> list[dict[str, Any]]:
if isinstance(payload, dict) and isinstance(payload.get("results"), list):
return payload["results"]
if isinstance(payload, list):
return payload
raise ValueError("SAM2 input must be a list or an object containing `results`.")
def _segmentation_output(sample: dict[str, Any]) -> dict[str, Any] | None:
output = sample.get("segmentation_output")
if isinstance(output, dict):
return output
task_payload = sample.get("task_payload")
if isinstance(task_payload, dict):
output = task_payload.get("segmentation_output")
if isinstance(output, dict):
sample["segmentation_output"] = output
return output
return None
def _compressed_rle(
size: Any,
counts: list[Any],
cocomask: Any,
) -> dict[str, Any] | None:
if (
not isinstance(size, (list, tuple))
or len(size) != 2
or not all(isinstance(value, (int, float)) for value in size)
):
return None
height, width = (int(size[0]), int(size[1]))
if height <= 0 or width <= 0:
return None
encoded = cocomask.frPyObjects(
{"size": [height, width], "counts": counts},
height,
width,
)
if isinstance(encoded, list):
encoded = cocomask.merge(encoded)
encoded_counts = encoded["counts"]
if isinstance(encoded_counts, bytes):
encoded_counts = encoded_counts.decode("utf-8")
return {"size": [height, width], "counts": encoded_counts}
def normalize_missing_rle_counts(payload: Any, cocomask: Any) -> int:
"""Repair mask annotations that encode empty masks or polygons without RLE counts."""
repaired = 0
for sample in _results(payload):
segmentation = _segmentation_output(sample)
if segmentation is None:
continue
rle = segmentation.get("segmentation_rle")
if sample.get("data_type") == "video":
if not isinstance(rle, dict):
continue
for frame, frame_rle in list(rle.items()):
if not isinstance(frame_rle, dict):
continue
counts = frame_rle.get("counts")
if counts is not None and not isinstance(counts, list):
continue
size = frame_rle.get("size")
if (
counts is None
and (
not isinstance(size, (list, tuple))
or len(size) != 2
)
):
continue
replacement = _compressed_rle(
size,
counts
if isinstance(counts, list)
else [int(size[0]) * int(size[1])],
cocomask,
)
if replacement is not None:
rle[frame] = replacement
repaired += 1
continue
if not isinstance(rle, dict):
continue
size = rle.get("size")
counts = rle.get("counts")
if isinstance(counts, list):
replacement = _compressed_rle(size, counts, cocomask)
if replacement is not None:
segmentation["segmentation_rle"] = replacement
repaired += 1
continue
if counts is not None:
continue
polygons = segmentation.get("segmentation_polygon")
if polygons:
if isinstance(polygons, list) and polygons and isinstance(
polygons[0], (int, float)
):
polygons = [polygons]
if (
isinstance(size, (list, tuple))
and len(size) == 2
and polygons
):
height, width = int(size[0]), int(size[1])
encoded = cocomask.frPyObjects(polygons, height, width)
if isinstance(encoded, list):
encoded = cocomask.merge(encoded)
counts = encoded["counts"]
if isinstance(counts, bytes):
counts = counts.decode("utf-8")
segmentation["segmentation_rle"] = {
"size": [height, width],
"counts": counts,
}
repaired += 1
continue
replacement = None
if isinstance(size, (list, tuple)) and len(size) == 2:
replacement = _compressed_rle(
size,
[int(size[0]) * int(size[1])],
cocomask,
)
if replacement is not None:
segmentation["segmentation_rle"] = replacement
repaired += 1
return repaired
def _sam2_output_path(input_json: str | Path) -> Path:
path = Path(input_json)
return path.with_name(f"{path.stem}_sam2.json")
def _aggregate_rewards(payload: dict[str, Any]) -> None:
ok_items = [
row
for row in _results(payload)
if row.get("status") == "ok" and isinstance(row.get("reward"), dict)
]
video_j: list[float] = []
video_f: list[float] = []
video_jf: list[float] = []
image_iou: list[float] = []
total_inter = 0
total_union = 0
for row in ok_items:
reward = row["reward"]
if all(key in reward for key in ("J", "F", "J&F")):
video_j.append(float(reward["J"]))
video_f.append(float(reward["F"]))
video_jf.append(float(reward["J&F"]))
if "IoU" in reward:
image_iou.append(float(reward["IoU"]))
total_inter += int(reward.get("inter", 0))
total_union += int(reward.get("union", 0))
averages: dict[str, float] = {}
if video_j:
averages.update(
{
"video/J": sum(video_j) / len(video_j),
"video/F": sum(video_f) / len(video_f),
"video/J&F": sum(video_jf) / len(video_jf),
}
)
if image_iou:
giou = sum(image_iou) / len(image_iou)
averages.update(
{
"image/IoU": giou,
"image/gIoU": giou,
"image/cIoU": (
float(total_inter) / float(total_union) if total_union else 0.0
),
}
)
payload["avg_rewards"] = averages
def _write_json_atomic(path: Path, payload: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
fd, temporary = tempfile.mkstemp(
dir=path.parent,
prefix=f".{path.name}.",
suffix=".tmp",
)
try:
with os.fdopen(fd, "w", encoding="utf-8") as stream:
json.dump(payload, stream, ensure_ascii=False, indent=2)
os.replace(temporary, path)
except BaseException:
try:
os.unlink(temporary)
except FileNotFoundError:
pass
raise
def _run_upstream(
module: Any,
*,
input_json: Path,
viz_ratio: float,
normalize: bool,
) -> Path:
original_load = module.json.load
if normalize:
def load_and_normalize(*args: Any, **kwargs: Any) -> Any:
payload = original_load(*args, **kwargs)
repaired = normalize_missing_rle_counts(payload, module.cocomask)
if repaired:
print(f"[INFO] normalized {repaired} mask RLE entries without counts")
return payload
module.json.load = load_and_normalize
try:
sys.argv = [
str(Path(module.__file__).name),
"--input_json",
str(input_json),
"--viz_ratio",
str(viz_ratio),
]
module.main()
finally:
module.json.load = original_load
return _sam2_output_path(input_json)
def _retry_errors(
module: Any,
*,
output_path: Path,
attempts: int,
viz_ratio: float,
) -> tuple[dict[str, Any], int]:
with output_path.open(encoding="utf-8") as stream:
payload = json.load(stream)
retried = 0
result_fields = (
"reward",
"status",
"viz_key_pred",
"viz_key_gt",
"viz_pred_video",
"viz_gt_video",
)
module.WORKERS_PER_GPU = 1
for attempt in range(1, attempts + 1):
failed = [
row
for row in _results(payload)
if str(row.get("status", "")).startswith("error:")
]
if not failed:
break
retried += len(failed)
retry_payload = {"results": failed}
repaired = normalize_missing_rle_counts(retry_payload, module.cocomask)
if repaired:
print(f"[INFO] normalized {repaired} mask RLE entries without counts")
print(
f"[INFO] retrying {len(failed)} SAM2 errors "
f"(attempt {attempt}/{attempts}, workers_per_gpu=1)"
)
with tempfile.TemporaryDirectory(
dir=output_path.parent,
prefix=".sam2-retry-",
) as temporary_dir:
retry_input = Path(temporary_dir) / "retry.json"
with retry_input.open("w", encoding="utf-8") as stream:
json.dump(retry_payload, stream, ensure_ascii=False)
retry_output = _run_upstream(
module,
input_json=retry_input,
viz_ratio=viz_ratio,
normalize=False,
)
with retry_output.open(encoding="utf-8") as stream:
retry_result = json.load(stream)
retried_by_id = {
row.get("problem_id"): row for row in _results(retry_result)
}
for row in _results(payload):
retry_row = retried_by_id.get(row.get("problem_id"))
if retry_row is not None:
for field in result_fields:
row[field] = retry_row.get(field)
_aggregate_rewards(payload)
_write_json_atomic(output_path, payload)
return payload, retried
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--input_json", required=True, help="Prediction JSON from eval_seg_vllm.py.")
parser.add_argument("--data_root", required=True, help="Root joined with each record's relative `path`.")
parser.add_argument("--sam2_ckpt", required=True)
parser.add_argument("--sam2_cfg", required=True)
parser.add_argument("--onethinker_script", default=DEFAULT_ONETHINKER_SCRIPT)
parser.add_argument("--num_gpus", type=int, default=None)
parser.add_argument("--workers_per_gpu", type=int, default=None)
parser.add_argument("--pre_extract_threads", type=int, default=None)
parser.add_argument("--epoch_size", type=int, default=None)
parser.add_argument("--viz_ratio", type=float, default=0.0)
parser.add_argument(
"--retry_attempts",
type=int,
default=1,
help="Retry per-sample SAM2 errors with one worker per GPU.",
)
parser.add_argument(
"--retry_existing_errors",
action="store_true",
help="Retry errors in an existing sibling *_sam2.json without rerunning all samples.",
)
args = parser.parse_args()
# Python's spawn start method calls os.getcwd() for every new worker pool.
# If the command was launched from a transient directory, later epochs can
# fail with FileNotFoundError. Pin cwd to the project root.
project_dir = Path(__file__).resolve().parents[3]
os.chdir(project_dir)
original_get_preparation_data = mp_spawn.get_preparation_data
def get_preparation_data(name):
try:
return original_get_preparation_data(name)
except FileNotFoundError:
os.chdir(project_dir)
return original_get_preparation_data(name)
mp_spawn.get_preparation_data = get_preparation_data
config_name = sam2_config_name(args.sam2_cfg)
os.environ["SAM2_DATA_ROOT"] = args.data_root
os.environ["SAM2_CKPT"] = args.sam2_ckpt
os.environ["SAM2_CFG"] = config_name
module = load_module(args.onethinker_script)
module.DATA_ROOT = args.data_root
module.SAM2_CKPT = args.sam2_ckpt
module.SAM2_CFG = config_name
if args.num_gpus is not None:
module.NUM_GPUS = args.num_gpus
if args.workers_per_gpu is not None:
module.WORKERS_PER_GPU = args.workers_per_gpu
if args.pre_extract_threads is not None:
module.PRE_EXTRACT_THREADS = args.pre_extract_threads
if args.epoch_size is not None:
module.EPOCH_SIZE = args.epoch_size
sys.argv = [
str(Path(args.onethinker_script).name),
"--input_json",
args.input_json,
"--viz_ratio",
str(args.viz_ratio),
]
os.environ.setdefault(
"PYTORCH_CUDA_ALLOC_CONF",
"expandable_segments:True,max_split_size_mb:64,garbage_collection_threshold:0.8",
)
module.mp.set_start_method("spawn", force=True)
input_path = Path(args.input_json).resolve()
output_path = _sam2_output_path(input_path)
if not args.retry_existing_errors:
output_path = _run_upstream(
module,
input_json=input_path,
viz_ratio=args.viz_ratio,
normalize=True,
)
elif not output_path.is_file():
raise FileNotFoundError(f"Existing SAM2 result not found: {output_path}")
payload, retried = _retry_errors(
module,
output_path=output_path,
attempts=max(0, args.retry_attempts),
viz_ratio=args.viz_ratio,
)
failed = [row for row in _results(payload) if row.get("status") != "ok"]
print(
f"[INFO] final SAM2 status: ok={len(_results(payload)) - len(failed)} "
f"/ total={len(_results(payload))}; retried={retried}"
)
if failed:
examples = ", ".join(
f"{row.get('problem_id')}={row.get('status')}" for row in failed[:5]
)
raise RuntimeError(
f"SAM2 post-processing left {len(failed)} failed samples: {examples}"
)
if __name__ == "__main__":
main()