Skip to content

Lane Classifier

Lane role classification from a 10-minute position heatmap.

Efficiency calculation

lane_efficiency_pct uses a fixed denominator of 4948:

  • Creep gold over first 10 minutes: 3448
  • Passive gold (1.5/sec): 900
  • Starting gold: 600

Formula used by match_builder (truncating to integer): int(lane_total_gold / 4948 * 100)

Note: lane_total_gold is cumulative total earned gold at 10 minutes (including the starting 600 gold).

gem.extractors.lane

Lane role classification from a 10-minute position heatmap.

Classifies a player's lane role (safe, mid, off, jungle, roaming) by aggregating their lane_pos heatmap into coarse lane zones and finding the dominant zone. This matches OpenDota's approach: each grid cell is mapped to one of five zones; the hero is classified by whichever zone accumulates the most dwell ticks; if the dominant zone covers less than _ZONE_DOMINANCE_FRAC of total ticks the hero is classified as roaming.

The coordinate system and map bounds are calibrated against Game_map_7.40.jpg: - Radiant fountain: (9684, 9684) — bottom-left - Dire fountain: (23120, 22350) — top-right - Map X range: 7563–25900, Y range: 7800–25600

No reference implementation exists in refs/; OpenDota performs lane classification server-side as a post-processing step on the lane_pos heatmap.

classify_lane(lane_pos: dict[str, int], team: int) -> int

Classify a player's lane role from their 10-minute position heatmap.

Aggregates the lane_pos heatmap into coarse lane zones and finds the dominant zone. If the dominant zone covers less than _ZONE_DOMINANCE_FRAC (45 %) of total dwell ticks the hero is classified as roaming (role 5).

Lane roles mirror OpenDota's convention: 1 = safe lane, 2 = mid, 3 = off lane, 4 = jungle, 5 = roaming, 0 = unknown.

The Dire safe lane is the Radiant off-lane side (top-left) and vice versa.

Parameters:

Name Type Description Default
lane_pos dict[str, int]

Dwell-tick counts keyed by "gx_gy" (64-unit grid cells), restricted to the first 10 game-minutes.

required
team int

Team number (2=Radiant, 3=Dire).

required

Returns:

Type Description
int

Lane role integer: 1=safe, 2=mid, 3=off, 4=jungle, 5=roaming, 0=unknown.

Source code in src/gem/extractors/lane.py
def classify_lane(lane_pos: dict[str, int], team: int) -> int:
    """Classify a player's lane role from their 10-minute position heatmap.

    Aggregates the ``lane_pos`` heatmap into coarse lane zones and finds the
    dominant zone.  If the dominant zone covers less than
    ``_ZONE_DOMINANCE_FRAC`` (45 %) of total dwell ticks the hero is
    classified as roaming (role 5).

    Lane roles mirror OpenDota's convention:
      1 = safe lane, 2 = mid, 3 = off lane, 4 = jungle, 5 = roaming, 0 = unknown.

    The Dire safe lane is the Radiant off-lane side (top-left) and vice versa.

    Args:
        lane_pos: Dwell-tick counts keyed by ``"gx_gy"`` (64-unit grid cells),
            restricted to the first 10 game-minutes.
        team: Team number (2=Radiant, 3=Dire).

    Returns:
        Lane role integer: 1=safe, 2=mid, 3=off, 4=jungle, 5=roaming, 0=unknown.
    """
    if not lane_pos:
        return 0

    zones = _zone_counts(lane_pos)
    total = sum(zones.values())
    if total == 0:
        return 0

    dominant_zone = max(zones, key=lambda z: zones[z])
    dominant_count = zones[dominant_zone]

    # Roaming: no single lane zone accounts for enough of the hero's time
    if dominant_count / total < _ZONE_DOMINANCE_FRAC:
        return 5

    # Map dominant zone to role number (team-dependent for safe/off)
    if dominant_zone == _ZONE_MID:
        return 2

    if team == 2:  # Radiant
        if dominant_zone == _ZONE_SAFE_R:
            return 1
        if dominant_zone == _ZONE_OFF_R:
            return 3
    else:  # Dire: zones are mirrored
        if dominant_zone == _ZONE_OFF_R:
            return 1
        if dominant_zone == _ZONE_SAFE_R:
            return 3

    if dominant_zone == _ZONE_JUNGLE:
        return 4

    return 0