blanchon/opencs2_panorama
OpenCS2 — Panorama Dataset 360° panoramas captured in-engine from Counter-Strike 2 at positions sampled from real player movement in HLTV demos. For each position the dataset ships six cube-map faces (90° HFOV, 1024×1024) and the stitched 4096×2048 equirectangular preview, plus the camera pose in Source 2 hammer-unit coordinates (the same convention as blanchon/opencs2_dataset). Stat Value Maps de_ancient, de_anubis, de_dust2, de_inferno, de_mirage, de_nuke… See the full description on the dataset page: https://huggingface.co/datasets/blanchon/opencs2_panorama.
OpenCS2 — Panorama Dataset
360° panoramas captured in-engine from Counter-Strike 2 at positions sampled from real player movement in HLTV demos. For each position the dataset ships six cube-map faces (90° HFOV, 1024×1024) and the stitched 4096×2048 equirectangular preview, plus the camera pose in Source 2 hammer-unit coordinates (the same convention as `blanchon/opencs2_dataset`).
Configs
Both configs cover the same 1,392 positions and share global_id so they can be joined with global_id / (map, point_id).
Quick start
from datasets import load_dataset
# Stitched equirectangular preview, one row per position
pano = load_dataset("blanchon/opencs2_panorama", split="train")
ex = pano[0]
ex["equirect"] # PIL.JpegImageFile, 4096x2048
ex["map"], ex["point_id"] # 'de_ancient', 'p000'
ex["position_x_hu"], ex["position_y_hu"], ex["position_z_hu"] # hammer units
ex["yaw_offset_degrees"] # rotation of the whole rig about +z
# Individual cube tiles with intrinsics
faces = load_dataset("blanchon/opencs2_panorama", "faces", split="train")
faces.filter(lambda r: r["map"] == "de_dust2" and r["point_id"] == "p000")Schema
panoramas (default config)
One row per panorama position. The equirect image is the v360-stitched cube map (c3x2:e:in_forder=rludfb) at 4096×2048.
faces config
Six rows per panorama position (one per cube face). The image column is a 1024×1024 90°-FOV pinhole render along the face direction.
Coordinate system
Same convention as the world tables in `blanchon/opencs2_dataset` — Source 2 / Hammer units (1 unit ≈ 1.905 cm).
- Axes:
+xeast,+ynorth,+zup - Yaw: zero looks down
+x; positive rotation is CCW seen from above - Pitch: zero is horizontal; positive pitch tilts the camera down (CS2 convention)
- Pitch range:
[-89.9°, 89.9°](poles are clamped to avoid singular faces)
A machine-readable copy of the coordinate spec lives in `metadata/coordinate_system.json`.
Cube-face layout
Each panorama is a 6-face cube map. The 4 horizontal faces (front, right, back, left) cover yaw with a 90° HFOV; up and down cover the poles by pitching ±89.9°.
The stitched equirect is produced with FFmpeg's v360 filter using the input order right, left, up, down, front, back (v360=c3x2:e:in_forder=rludfb). See scripts/stitch-panoramas-local.py in the OpenCS2 source repo for the exact pipeline.
How positions were chosen
For each map, player XYZ samples were gathered from real HLTV demos and binned into an occupancy grid. The dataset stores the median XYZ of each cell — every panorama position is therefore a location a real player actually stood at, with realistic eye height and ground clearance. Map sizes vary so dedust2 ships 250 points, denuke 142, and the others 200.
Repository layout
opencs2_panorama/
├── README.md
├── data/
│ ├── panoramas/<map>.parquet # 1 row per position, equirect preview
│ └── faces/<map>.parquet # 6 rows per position, per-face intrinsics
└── metadata/
└── coordinate_system.jsonLoading recipes
Equirect viewer / 360° rendering
from datasets import load_dataset
ds = load_dataset("blanchon/opencs2_panorama", split="train")
row = ds[0]
row["equirect"].save("preview.jpg")Drop preview.jpg into any equirect viewer (three.js EquirectangularReflectionMapping, A-Frame <a-sky>, Pannellum, etc.).
Per-face training with intrinsics
from datasets import load_dataset
faces = load_dataset("blanchon/opencs2_panorama", "faces", split="train")
dust2 = faces.filter(lambda r: r["map"] == "de_dust2")
# Reconstruct a pinhole intrinsic matrix from the FOV
import math, numpy as np
def K_from_fov(fov_h_deg, fov_v_deg, w, h):
fx = (w / 2) / math.tan(math.radians(fov_h_deg) / 2)
fy = (h / 2) / math.tan(math.radians(fov_v_deg) / 2)
return np.array([[fx, 0, w / 2], [0, fy, h / 2], [0, 0, 1]])
row = dust2[0]
K = K_from_fov(row["fov_horizontal_degrees"], row["fov_vertical_degrees"],
row["width_pixels"], row["height_pixels"])Pairing with opencs2_dataset world ticks
Positions are in the same hammer-unit frame as the per-tick x/y/z in rounds/match_id=…/…/player=PP/ticks.parquet inside `blanchon/opencs2_dataset`, so you can directly look up the nearest panorama for any player position without any coordinate transform.
End-to-end verification: pair a real POV frame with its nearest panorama
A full recipe that takes a single tick from a real round and produces a side-by-side comparison (player POV frame ⇄ nearest equirect ⇄ best matching cube face). This is how the dataset was sanity-checked end-to-end.
1. Pick a POV row and load its ticks parquet.
import numpy as np, pyarrow.parquet as pq
from huggingface_hub import hf_hub_download
# One POV row from opencs2_dataset (match 2391547, round 1, T-side player 0, de_dust2).
ticks = pq.read_table(hf_hub_download(
repo_id="blanchon/opencs2_dataset", repo_type="dataset",
filename="rounds/match_id=2391547/map_name=de_dust2/round=01/player=00/ticks.parquet",
), columns=["tick", "t", "is_alive", "x", "y", "z", "pitch", "yaw"]).to_pandas()2. Find the tick where the player is closest to a panorama position.
pano = pq.read_table(hf_hub_download(
repo_id="blanchon/opencs2_panorama", repo_type="dataset",
filename="data/panoramas/de_dust2.parquet",
), columns=["point_id", "position_x_hu", "position_y_hu", "position_z_hu"]).to_pandas()
alive = ticks[ticks["is_alive"]].copy()
pxyz = pano[["position_x_hu", "position_y_hu", "position_z_hu"]].to_numpy()
txyz = alive[["x", "y", "z"]].to_numpy()
d = np.linalg.norm(txyz[:, None, :] - pxyz[None, :, :], axis=-1)
near_idx, near_dist = d.argmin(axis=1), d.min(axis=1)
alive["nearest_point_id"] = pano.iloc[near_idx]["point_id"].values
alive["nearest_dist_hu"] = near_dist
best = alive.sort_values("nearest_dist_hu").iloc[0]
print(best[["tick", "t", "x", "y", "z", "yaw", "pitch",
"nearest_point_id", "nearest_dist_hu"]])For this row, the closest hit is p155 at ~46 hu (≈88 cm).
3. Pick the matching cube face by angular distance to the player view.
import math
def angular_distance(yaw_a, pitch_a, yaw_b, pitch_b):
la, lo = math.radians(-pitch_a), math.radians(yaw_a)
lb, lo2 = math.radians(-pitch_b), math.radians(yaw_b)
d = math.sin(la)*math.sin(lb) + math.cos(la)*math.cos(lb)*math.cos(lo - lo2)
return math.degrees(math.acos(max(-1.0, min(1.0, d))))
faces = pq.read_table(hf_hub_download(
repo_id="blanchon/opencs2_panorama", repo_type="dataset",
filename="data/faces/de_dust2.parquet",
), columns=["point_id", "face", "yaw_degrees", "pitch_degrees"]).to_pandas()
mine = faces[faces["point_id"] == best["nearest_point_id"]].copy()
mine["angle_to_player"] = mine.apply(
lambda r: angular_distance(best["yaw"], best["pitch"],
r["yaw_degrees"], r["pitch_degrees"]), axis=1)
print(mine.sort_values("angle_to_player")[["face", "yaw_degrees", "pitch_degrees", "angle_to_player"]])
# -> 'left' wins (player yaw 101.5° vs face yaw 90°, angle 16.96°)4. Extract the POV frame and render a virtual flat view from the equirect at the same `(yaw, pitch)`.
import subprocess
from huggingface_hub import hf_hub_download
vid = hf_hub_download(
repo_id="blanchon/opencs2_dataset", repo_type="dataset",
filename="rounds/match_id=2391547/map_name=de_dust2/round=01/player=00/video.mp4",
)
subprocess.run(["ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
"-ss", f"{best['t']:.4f}", "-i", vid,
"-frames:v", "1", "-q:v", "2", "pov_frame.jpg"], check=True)
# Reproject the equirect to a flat 110° hfov view from the player's look direction.
# Note: v360 yaw/pitch are flipped vs CS2 (we look "into" the sphere), and ffmpeg's
# expression parser eats characters past negative values, so wrap them in parens.
yaw_ff, pitch_ff = -float(best["yaw"]), -float(best["pitch"])
subprocess.run(["ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
"-i", "nearest_equirect.jpg",
"-vf", f"v360=e:flat:h_fov=110:v_fov=73:yaw=({yaw_ff}):pitch=({pitch_ff}):w=1280:h=720:interp=cubic",
"-frames:v", "1", "-q:v", "2", "virtual_flat.jpg"], check=True)The flat view rendered from the equirect should show the same wall, prop and skyline geometry as the POV frame, with a small parallax shift equal to the residual distance between the player and the panorama position.
5. (Optional) Mark the look direction on the equirect.
The equirect is stitched with v360=c3x2:e:in_forder=rludfb, which places CS2 yaw 0 (front face) at the horizontal centre. Convert any CS2 (yaw, pitch) to equirect pixel coords with:
def yaw_pitch_to_equirect_xy(yaw, pitch, w, h):
yaw_norm = ((yaw + 180.0) % 360.0) - 180.0 # -> [-180, 180)
x = int(((-yaw_norm + 180.0) / 360.0) * w) % w # CCW yaw moves left in equirect
y = int(((pitch + 90.0) / 180.0) * h)
return x, yThat's the full pipeline used to validate this dataset against blanchon/opencs2_dataset. Reusable scripts ship with the source repo:
scripts/build-panorama-hf-dataset.py— builds the parquets and pushes to HF.scripts/build-panorama-verify-grid.py— composes the comparison image.
License
CC-BY-4.0. Rendered frames come from Counter-Strike 2; underlying demos are pro matches scraped from HLTV and remain subject to the original tournament terms.
Citation
@misc{opencs2_panorama,
author = {Blanchon, Julien},
title = {OpenCS2 Panorama: in-engine 360° captures of CS2 maps at real player positions},
year = {2026},
howpublished = {\\url{https://huggingface.co/datasets/blanchon/opencs2_panorama}}
}