CoolFace
Datasetpublic

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.

sourceHugging Facecc-by-4.0updated 4mo agoView on Hugging Face
0likes10downloads
Dataset Card

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`).

StatValue
Mapsdeancient, deanubis, dedust2, deinferno, demirage, denuke, de_overpass
Panorama positions1,392 (200 per map, 250 for dedust2, 142 for denuke)
Cube faces8,352 (6 per position: front/right/back/left/up/down)
Face resolution1024×1024, 90° HFOV/VFOV, JPEG
Equirect resolution4096×2048, JPEG
EngineSource 2 — CS2 build 1.41.4.1 (protocol 14141)
Rendererh264_vulkan (NVENC fallback)
SamplingMedian XYZ per occupancy-grid cell across pro demos — every point is reachable

Configs

ConfigRows per positionUse when
panoramas (def)1You want one full-sphere preview + camera pose per position.
faces6You want each cube tile separately, with its own intrinsics.

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

python
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.

ColumnTypeNotes
global_idstring"<map>/<point_id>", unique across the dataset
mapstringe.g. de_dust2
point_idstringe.g. p000
position_x_hufloat32Source 2 hammer units, +x = east
position_y_hufloat32hammer units, +y = north
position_z_hufloat32hammer units, +z = up
yaw_offset_degreesfloat32Rotation of the rig about +z (currently 0 for all points)
equirectImage4096×2048 equirectangular JPEG
equirect_width_pixelsint324096
equirect_height_pixelsint322048
equirect_projectionstring"equirectangular"
face_size_pixelsint32Resolution of each cube face (1024)
n_facesint326
captured_at_utctimestampWhen the panorama was rendered
cs2_buildstringe.g. "1.41.4.1 (protocol 14141)"
rendererstringe.g. "h264_vulkan (NVENC fallback)"

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.

ColumnTypeNotes
global_idstringSame as in panoramas
map, point_idstring
facestringfront / right / back / left / up / down
face_indexint320..5 in the same order as above
imageImage1024×1024 JPEG
position_x_hu/y/zfloat32Camera position (same for all 6 faces of one point)
yaw_offset_degreesfloat32Rig yaw offset
face_yaw_degreesfloat32Yaw of this face relative to the rig (front=0, right=270, back=180, left=90, up/down=0)
yaw_degreesfloat32Absolute camera yaw = (yaw_offset + face_yaw) mod 360
pitch_degreesfloat32Camera pitch (±89.9 for up/down faces, 0 for the four horizontal faces)
fov_horizontal_degreesfloat3290
fov_vertical_degreesfloat3290
width_pixelsint321024
height_pixelsint321024
captured_at_utctimestamp
cs2_build, rendererstring

Coordinate system

Same convention as the world tables in `blanchon/opencs2_dataset` — Source 2 / Hammer units (1 unit ≈ 1.905 cm).

  • —Axes: +x east, +y north, +z up
  • —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°.

Faceyaw (deg)pitch (deg)
front00
right2700
back1800
left900
up0-89.9
down0+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.json

Loading recipes

Equirect viewer / 360° rendering

python
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

python
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.

python
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.

python
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.

python
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)`.

python
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:

python
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, y

That'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

bibtex
@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}}
}