blanchon/opencs2_dataset_wds
OpenCS2 - POV Renders WebDataset Browse with the OpenCS2 Viewer - every match, map and round, with all 10 player POVs synced on one timeline. Tick-aligned Counter-Strike 2 POV training clips, rendered from blanchon/cs2_dataset_demo. Each sample is one player's perspective for one round; ten POVs per round share the same tick clock. Per POV round: Video - 1280x720 @ 32 fps, near-lossless H.264, faststart, muxed with audio. Audio - per-player stereo, mixed from that player's… See the full description on the dataset page: https://huggingface.co/datasets/blanchon/opencs2_dataset_wds.
OpenCS2 - POV Renders WebDataset

Browse with the OpenCS2 Viewer - every match, map and round, with all 10 player POVs synced on one timeline.
Tick-aligned Counter-Strike 2 POV training clips, rendered from `blanchon/cs2_dataset_demo`. Each sample is one player's perspective for one round; ten POVs per round share the same tick clock.
Per POV round:
- Video - 1280x720 @ 32 fps, near-lossless H.264, faststart, muxed with audio.
- Audio - per-player stereo, mixed from that player's position and orientation.
- Inputs - every tick: keys, mouse delta, view angles, fire/jump/use, weapon switches.
- World state - every tick: player position, velocity, view, health, armor, weapon, alive flag.
This repo is the WebDataset packaging of `blanchon/opencs2_dataset`: the same POV rounds, grouped into large uncompressed tar shards with byte-offset indexes for fast streaming and sparse random access.
The loose-file version is also mirrored as a Hugging Face Storage Bucket: `hf://buckets/blanchon/opencs2_dataset`.
Current build: 165,270 POV samples (2974.2 POV video hours, 528.0 synced round-timeline hours) across 2,574 uncompressed tar shards.
The lightweight preview WebDataset is separate: `blanchon/opencs2_dataset_preview_wds`.
Usage
The media-heavy training data is in tar shards; metadata/configs stay as parquet so filtering is cheap before media access.
Layout
shards/
opencs2-<match_id>-<map_name>-<shard>.train.tar
index/
wids_train.json # WIDS shard descriptor
wds_samples.parquet # one row per POV sample, with tar byte offsets
wds_shards.parquet # one row per tar shard
matches.parquet # one row per rendered match/map
rounds.parquet # one row per round
pov_rounds.parquet # one row per player POV round
events/
kills.parquet
duels.parquet
clip_events.parquet
round_player.parquet
metadata/
enums.parquet
ticks/
match_id=<id>/map_name=<map>/ticks.parquetThe tar shards are plain .tar, not .tar.gz, so byte offsets stay valid. A sample member set looks like:
2391545-de_anubis-r01-p00.mp4
2391545-de_anubis-r01-p00.ticks.parquet
2391545-de_anubis-r01-p00.jsonParquet Tables
String-like filter columns are dictionary encoded where useful, and most have a matching *_id column for fast integer joins or enum-based modeling. Player identity is always player_slot (0..9), not Steam ID or username.
Tick column t is the timestamp in the POV video. event_seconds is already on the POV video timeline. You can seek media directly with event_video_seconds = event_seconds, or join event tick against the selected POV ticks.parquet and use tick column t. Use media_bytes or mp4_size to estimate download cost before touching media.
Install
uv pip install duckdb pyarrow pandas requests huggingface_hub torch torchcodec pillow webdataset widsFor metadata-only work you only need duckdb, pyarrow, and huggingface_hub.
Filter Without Downloading Media
Use DuckDB over the parquet files. This only downloads the selected parquet row groups, not MP4s.
import duckdb
con = duckdb.connect()
con.sql("INSTALL httpfs; LOAD httpfs;")
awp_1v1 = con.sql("""
SELECT
d.match_id,
d.map_name,
d.round,
d.winner_player_slot AS player_slot,
d.event_seconds AS event_table_seconds,
d.event_seconds AS event_video_seconds,
d.weapon,
s.shard_path,
s.mp4_offset,
s.mp4_size,
s.ticks_offset,
s.ticks_size
FROM 'hf://datasets/blanchon/opencs2_dataset_wds/events/duels.parquet' AS d
JOIN 'hf://datasets/blanchon/opencs2_dataset_wds/index/wds_samples.parquet' AS s
ON d.match_id = s.match_id
AND d.map_name = s.map_name
AND d.round = s.round
AND d.winner_player_slot = s.player_slot
WHERE d.weapon = 'awp'
AND d.is_1v1_before
""").df()
print(awp_1v1.head())
print("estimated MP4 bytes:", int(awp_1v1["mp4_size"].sum()))Other useful filters:
-- Long Mirage rounds with a bomb plant.
SELECT *
FROM 'hf://datasets/blanchon/opencs2_dataset_wds/index/rounds.parquet'
WHERE map_name = 'de_mirage' AND has_bomb_plant AND round_duration_s > 60;
-- All kills through smoke, with killer POV.
SELECT k.*, s.shard_path, s.mp4_offset, s.mp4_size
FROM 'hf://datasets/blanchon/opencs2_dataset_wds/events/kills.parquet' k
JOIN 'hf://datasets/blanchon/opencs2_dataset_wds/index/wds_samples.parquet' s
ON k.match_id = s.match_id
AND k.map_name = s.map_name
AND k.round = s.round
AND k.attacker_player_slot = s.player_slot
WHERE k.through_smoke;Verified Clip Recipes
[!TIP] These filters were tested by exporting 10 local examples each. For kill-derived examples, center the clip on event_seconds.Common helper:
This helper writes video-only MP4 clips through TorchCodec. It decodes the selected range as a PyTorch uint8 tensor, then encodes it back to H.264 MP4.
import json
import re
from pathlib import Path
import duckdb
from huggingface_hub import hf_hub_url
from PIL import Image
from torchcodec.decoders import VideoDecoder
from torchcodec.encoders import VideoEncoder
REPO = "blanchon/opencs2_dataset_wds"
OUT = Path("opencs2_examples")
FPS = 32.0
def hf_path_to_url(path):
repo_id, revision, filename = re.match(r"hf://datasets/([^@]+)@([^/]+)/(.+)", path).groups()
return hf_hub_url(repo_id=repo_id, repo_type="dataset", revision=revision, filename=filename)
def open_mp4(row):
return hf_path_to_url(row["video_path"])
def save_clip(row, name, before=5.0, after=5.0):
center = float(row["event_video_seconds"])
start = max(0.0, center - before)
stop = min(float(row["duration_s"]), center + after)
out = OUT / name / f"{row['event_id']}.mp4"
out.parent.mkdir(parents=True, exist_ok=True)
frames = VideoDecoder(
open_mp4(row),
seek_mode="approximate",
dimension_order="NCHW",
).get_frames_played_in_range(start_seconds=start, stop_seconds=stop, fps=FPS)
VideoEncoder(frames.data, frame_rate=FPS).to_file(
out,
codec="libx264",
pixel_format="yuv420p",
crf=20,
preset="veryfast",
extra_options={"x264-params": "keyint=32:min-keyint=1:scenecut=0:open-gop=0"},
)
return out
def save_png(frame_hwc, path):
Image.fromarray(frame_hwc.cpu().numpy()).save(path)
def save_frame_pair(row, name):
out = OUT / name / f"{row['media_id']}-{int(row['tick'])}"
out.mkdir(parents=True, exist_ok=True)
frames = VideoDecoder(
open_mp4(row),
seek_mode="approximate",
dimension_order="NHWC",
).get_frames_played_at(seconds=[float(row["t"]), float(row["next_t"])])
frame_t = frames.data[0]
frame_t1 = frames.data[1]
save_png(frame_t, out / "frame_t.png")
save_png(frame_t1, out / "frame_t_plus_1.png")
tick_t = {k: v for k, v in row.items() if not k.startswith("next_") and k != "video_path"}
tick_t_plus_1 = {**tick_t, "tick": int(row["next_tick"]), "t": float(row["next_t"])}
(out / "tick_t.json").write_text(json.dumps(tick_t, indent=2, default=str) + "\n")
(out / "tick_t_plus_1.json").write_text(json.dumps(tick_t_plus_1, indent=2, default=str) + "\n")
return out
con = duckdb.connect()
con.sql("INSTALL httpfs; LOAD httpfs;")<details> <summary><strong>AWP 1v1 duel, winner POV</strong></summary>
rows = con.sql("""
SELECT d.duel_id AS event_id, d.event_seconds AS event_video_seconds,
d.weapon, d.distance, d.headshot, p.duration_s,
struct_extract(p.video, 'path') AS video_path
FROM 'hf://datasets/blanchon/opencs2_dataset_wds/events/duels.parquet' d
JOIN 'hf://datasets/blanchon/opencs2_dataset_wds/index/pov_rounds.parquet' p
ON d.match_id=p.match_id AND d.map_name=p.map_name AND d.round=p.round
AND d.winner_player_slot=p.player_slot
WHERE d.weapon='awp' AND d.is_1v1_before
AND p.duration_s >= d.event_seconds + 5.0
ORDER BY d.event_seconds
LIMIT 10
""").df()
for row in rows.to_dict("records"):
save_clip(row, "awp_1v1_duel")</details>
<details> <summary><strong>Kill through smoke, attacker POV</strong></summary>
rows = con.sql("""
SELECT k.kill_id AS event_id, k.event_seconds AS event_video_seconds,
k.weapon, k.distance, k.headshot, p.duration_s,
struct_extract(p.video, 'path') AS video_path
FROM 'hf://datasets/blanchon/opencs2_dataset_wds/events/kills.parquet' k
JOIN 'hf://datasets/blanchon/opencs2_dataset_wds/index/pov_rounds.parquet' p
ON k.match_id=p.match_id AND k.map_name=p.map_name AND k.round=p.round
AND k.attacker_player_slot=p.player_slot
WHERE k.through_smoke
AND p.duration_s >= k.event_seconds + 5.0
ORDER BY k.event_seconds
LIMIT 10
""").df()
for row in rows.to_dict("records"):
save_clip(row, "kill_through_smoke")</details>
<details> <summary><strong>Noscope or wallbang highlight</strong></summary>
rows = con.sql("""
SELECT k.kill_id AS event_id, k.event_seconds AS event_video_seconds,
k.weapon, k.noscope, k.wallbang, k.penetrated, p.duration_s,
struct_extract(p.video, 'path') AS video_path
FROM 'hf://datasets/blanchon/opencs2_dataset_wds/events/kills.parquet' k
JOIN 'hf://datasets/blanchon/opencs2_dataset_wds/index/pov_rounds.parquet' p
ON k.match_id=p.match_id AND k.map_name=p.map_name AND k.round=p.round
AND k.attacker_player_slot=p.player_slot
WHERE (k.noscope OR k.wallbang OR k.penetrated > 0)
AND p.duration_s >= k.event_seconds + 5.0
ORDER BY k.noscope DESC, k.wallbang DESC, k.penetrated DESC
LIMIT 10
""").df()
for row in rows.to_dict("records"):
save_clip(row, "noscope_wallbang")</details>
<details> <summary><strong>Knife kill</strong></summary>
rows = con.sql("""
SELECT k.kill_id AS event_id, k.event_seconds AS event_video_seconds,
k.weapon, p.duration_s, struct_extract(p.video, 'path') AS video_path
FROM 'hf://datasets/blanchon/opencs2_dataset_wds/events/kills.parquet' k
JOIN 'hf://datasets/blanchon/opencs2_dataset_wds/index/pov_rounds.parquet' p
ON k.match_id=p.match_id AND k.map_name=p.map_name AND k.round=p.round
AND k.attacker_player_slot=p.player_slot
WHERE (lower(k.weapon_class)='knife' OR lower(k.weapon) LIKE '%knife%'
OR lower(k.weapon) LIKE '%bayonet%' OR lower(k.weapon) LIKE '%karambit%')
AND p.duration_s >= k.event_seconds + 5.0
LIMIT 10
""").df()
for row in rows.to_dict("records"):
save_clip(row, "knife_kill")</details>
<details> <summary><strong>Five kills by the same player in under 10 seconds</strong></summary>
rows = con.sql("""
WITH streaks AS (
SELECT match_id, map_name, round, attacker_player_slot AS player_slot,
COUNT(*) AS n_kills,
MIN(event_seconds) AS first_kill_video_seconds,
MAX(event_seconds) AS last_kill_video_seconds
FROM 'hf://datasets/blanchon/opencs2_dataset_wds/events/kills.parquet'
GROUP BY match_id, map_name, round, attacker_player_slot
HAVING COUNT(*) >= 5 AND MAX(event_seconds) - MIN(event_seconds) < 10.0
)
SELECT concat('streak-', s.match_id, '-', s.map_name, '-r', s.round, '-p', s.player_slot) AS event_id,
s.first_kill_video_seconds AS event_video_seconds,
s.last_kill_video_seconds, s.n_kills, p.duration_s,
struct_extract(p.video, 'path') AS video_path
FROM streaks s
JOIN 'hf://datasets/blanchon/opencs2_dataset_wds/index/pov_rounds.parquet' p
ON s.match_id=p.match_id AND s.map_name=p.map_name AND s.round=p.round
AND s.player_slot=p.player_slot
ORDER BY s.last_kill_video_seconds - s.first_kill_video_seconds
LIMIT 10
""").df()
for row in rows.to_dict("records"):
save_clip(row, "five_kills_under_10s", before=2.0, after=row["last_kill_video_seconds"] - row["event_video_seconds"] + 2.0)</details>
<details> <summary><strong>Very long distance kill</strong></summary>
rows = con.sql("""
SELECT k.kill_id AS event_id, k.event_seconds AS event_video_seconds,
k.weapon, k.distance, p.duration_s, struct_extract(p.video, 'path') AS video_path
FROM 'hf://datasets/blanchon/opencs2_dataset_wds/events/kills.parquet' k
JOIN 'hf://datasets/blanchon/opencs2_dataset_wds/index/pov_rounds.parquet' p
ON k.match_id=p.match_id AND k.map_name=p.map_name AND k.round=p.round
AND k.attacker_player_slot=p.player_slot
WHERE k.distance IS NOT NULL
AND p.duration_s >= k.event_seconds + 5.0
ORDER BY k.distance DESC
LIMIT 10
""").df()
for row in rows.to_dict("records"):
save_clip(row, "long_distance_kill")</details>
<details> <summary><strong>Specific map position, video clip</strong></summary>
rows = con.sql("""
WITH t AS (
SELECT DISTINCT ON (media_id) media_id, match_id, map_name, round, player_slot,
t AS event_video_seconds, x, y, z, input_weapon
FROM 'hf://datasets/blanchon/opencs2_dataset_wds/ticks/match_id=2391545/map_name=de_anubis/ticks.parquet'
WHERE is_alive AND tick % 64 = 0 AND t >= 5.0
AND x BETWEEN -875 AND -625 AND y BETWEEN 125 AND 375
ORDER BY media_id, t
)
SELECT concat('pos-', t.media_id, '-', round(t.event_video_seconds, 2)) AS event_id,
t.*, p.duration_s, struct_extract(p.video, 'path') AS video_path
FROM t
JOIN 'hf://datasets/blanchon/opencs2_dataset_wds/index/pov_rounds.parquet' p
ON t.media_id=p.media_id
LIMIT 10
""").df()
for row in rows.to_dict("records"):
save_clip(row, "position_based_clip")</details>
<details> <summary><strong>boostingtopplayer: higher player POV</strong></summary>
Use ticks to find a higher player above a nearby lower player for multiple consecutive ticks. This is a heuristic, so visually inspect results.
-- Core condition used in the verified examples:
xy_distance < 36
z_delta BETWEEN 45 AND 90
abs(top.velocity_z) < 12
abs(lower.velocity_z) < 12
support_ticks >= 16</details>
<details> <summary><strong>framepairpreview: frame pair at a specific position</strong></summary>
rows = con.sql("""
WITH ticks AS (
SELECT media_id, match_id, map_name, round, player_slot, tick, t, x, y, z
FROM 'hf://datasets/blanchon/opencs2_dataset_wds/ticks/match_id=2391545/map_name=de_anubis/ticks.parquet'
WHERE is_alive AND t > 5.0
),
anchors AS (
SELECT * FROM ticks
WHERE tick % 64 = 0
AND x BETWEEN -875 AND -625 AND y BETWEEN 125 AND 375
),
pairs AS (
SELECT DISTINCT ON (a.media_id) a.*, b.tick AS next_tick, b.t AS next_t
FROM anchors a JOIN ticks b ON a.media_id=b.media_id AND a.tick + 2 = b.tick
ORDER BY a.media_id, a.t
)
SELECT pairs.*, struct_extract(p.video, 'path') AS video_path
FROM pairs
JOIN 'hf://datasets/blanchon/opencs2_dataset_wds/index/pov_rounds.parquet' p
ON pairs.media_id=p.media_id
LIMIT 10
""").df()
for row in rows.to_dict("records"):
save_frame_pair(row, "frame_pair_preview")</details>
Read One POV Or One Timestamp
index/wds_samples.parquet stores the byte range of each MP4 inside its tar shard. The MP4 bytes are identical to a standalone MP4; the reader below shifts all HTTP range requests by the tar member offset.
import io
import os
import requests
from huggingface_hub import hf_hub_url
from torchcodec.decoders import AudioDecoder, VideoDecoder
REPO = "blanchon/opencs2_dataset_wds"
class HfTarMember(io.RawIOBase):
def __init__(self, shard_url, offset, size, token=None, session=None):
self.shard_url = shard_url
self.offset = int(offset)
self.size = int(size)
self.pos = 0
self.session = session or requests.Session()
self.headers = {}
token = token or os.environ.get("HF_TOKEN")
if token:
self.headers["Authorization"] = f"Bearer {token}"
def readable(self):
return True
def seekable(self):
return True
def tell(self):
return self.pos
def seek(self, offset, whence=io.SEEK_SET):
if whence == io.SEEK_SET:
self.pos = offset
elif whence == io.SEEK_CUR:
self.pos += offset
elif whence == io.SEEK_END:
self.pos = self.size + offset
self.pos = max(0, min(self.pos, self.size))
return self.pos
def read(self, n=-1):
if self.pos >= self.size:
return b""
if n is None or n < 0:
n = self.size - self.pos
n = min(n, self.size - self.pos)
start = self.offset + self.pos
stop = start + n - 1
headers = dict(self.headers)
headers["Range"] = f"bytes={start}-{stop}"
r = self.session.get(self.shard_url, headers=headers, timeout=60)
r.raise_for_status()
data = r.content
self.pos += len(data)
return data
def member_url(row):
return hf_hub_url(REPO, row["shard_path"], repo_type="dataset")
def open_mp4(row):
return HfTarMember(member_url(row), row["mp4_offset"], row["mp4_size"])
# row can come from DuckDB, pandas, or pyarrow.
row = awp_1v1.iloc[0].to_dict()
start = max(0.0, row["event_video_seconds"] - 5.0)
stop = row["event_video_seconds"] + 5.0
video = VideoDecoder(open_mp4(row), seek_mode="approximate", dimension_order="NHWC")
clip = video.get_frames_played_in_range(start_seconds=start, stop_seconds=stop)
audio = AudioDecoder(open_mp4(row))
samples = audio.get_samples_played_in_range(start_seconds=start, stop_seconds=stop)For a browser viewer, use the same shard_path, mp4_offset, and mp4_size: create a URL source for the shard, slice [mp4_offset, mp4_offset + mp4_size), then give the slice to the MP4 demuxer.
Read The Tick Sidecar
Each WDS sample also contains its per-POV tick parquet. Fetch it by range from the same tar shard:
import pyarrow as pa
import pyarrow.parquet as pq
def read_member_bytes(row, offset_col, size_col):
f = HfTarMember(member_url(row), row[offset_col], row[size_col])
return f.read()
tick_bytes = read_member_bytes(row, "ticks_offset", "ticks_size")
ticks = pq.read_table(pa.BufferReader(tick_bytes)).to_pandas()For global filtering by position, weapon, or health across many samples, use the external ticks/match_id=<id>/map_name=<map>/ticks.parquet files instead. They are map-level parquet indexes and avoid opening tar shards during the filtering phase.
SELECT media_id, round, player_slot, tick, t, x, y, z, input_weapon
FROM 'hf://datasets/blanchon/opencs2_dataset_wds/ticks/match_id=2391545/map_name=de_anubis/ticks.parquet'
WHERE x BETWEEN -500 AND 500
AND y BETWEEN -2000 AND -1200
AND is_alive;Frame Pair Samples
For (frame_t, tick_t, frame_t+1, tick_t+1), use the video WDS and decode frames on demand. This keeps storage smaller than a pre-extracted frame dataset while preserving arbitrary temporal access.
import numpy as np
t0 = 12.0
t1 = t0 + 1.0 / 32.0
tick0 = ticks.iloc[(ticks["t"] - t0).abs().argmin()]
tick1 = ticks.iloc[(ticks["t"] - t1).abs().argmin()]
frames = VideoDecoder(open_mp4(row), seek_mode="approximate", dimension_order="NHWC").get_frames_played_at(
seconds=[float(tick0["t"]), float(tick1["t"])]
)
frame_t = frames.data[0]
frame_t1 = frames.data[1]For throughput, sample several timestamps from the same POV and call get_frames_played_at() once with the full timestamp list; reopening the decoder for each frame pair is much slower.
High-Throughput Training
Use the parquet tables to build the sample set first, then feed only selected shards/samples to the loader. The fastest pattern depends on access shape:
- full or mostly-full rounds: use WebDataset/WIDS with a local NVMe shard cache;
- sparse 10 second clips: use
wds_samples.parquetbyte offsets and TorchCodec range reads; - frame pairs: group many requested timestamps by
media_id, decode them in batches, then shuffle the emitted pairs.
Recommended randomness strategy:
- shuffle shards each epoch;
- shuffle samples within each shard;
- keep a bounded cross-shard sample buffer, for example 64-256 samples per worker;
- group nearby timestamps from the same
media_idbefore decoding, then shuffle outputs after decode.
WIDS descriptor:
from huggingface_hub import hf_hub_url
import wids
index_url = hf_hub_url(
"blanchon/opencs2_dataset_wds",
"index/wids_train.json",
repo_type="dataset",
)
ds = wids.ShardListDataset(
index_url,
cache_dir="/local_nvme/opencs2_wids",
lru_size=16,
)
sample = ds[0]Classic streaming WebDataset:
import pyarrow.parquet as pq
import webdataset as wds
from huggingface_hub import hf_hub_download, hf_hub_url
repo = "blanchon/opencs2_dataset_wds"
index_path = hf_hub_download(repo, "index/wds_shards.parquet", repo_type="dataset")
shard_paths = pq.read_table(index_path, columns=["shard_path"]).column("shard_path").to_pylist()
urls = [hf_hub_url(repo, path, repo_type="dataset") for path in shard_paths]
dataset = (
wds.WebDataset(urls, shardshuffle=True)
.shuffle(128)
)For this dataset, prefer the explicit shard list from index/wds_shards.parquet or index/wids_train.json over a brace pattern: shard names include match IDs and map names.
Downloading
Metadata only:
hf download blanchon/opencs2_dataset_wds --repo-type dataset \
--include "index/*.parquet" \
--include "events/*.parquet" \
--include "metadata/*.parquet"One shard:
hf download blanchon/opencs2_dataset_wds --repo-type dataset \
--include "shards/opencs2-2391545-de_anubis-000000.train.tar"For programmatic URL construction, use huggingface_hub.hf_hub_url() for range reads and DuckDB hf://datasets/... URLs for parquet scans.
Creation
Built from HLTV .dem files with a headless CS2 recorder. The recorder replays demos, captures all 10 player POVs, validates tick/frame boundaries, muxes audio into the MP4, and writes typed parquet sidecars. This WebDataset repo repackages the round-based media from blanchon/opencs2_dataset into large tar shards plus byte-offset indexes to avoid the many-small-files problem.
Licensing
.dem source data is mirrored from HLTV; downstream use is bound by the original tournament terms. Renders and metadata are released as CC-BY-4.0.
Citation
@misc{blanchon2026opencs2,
author = {Julien Blanchon},
title = {OpenCS2 Dataset},
year = {2026},
publisher = {Hugging Face},
howpublished = {\url{https://github.com/julien-blanchon/opencs2-dataset}}
}