feat: heatmap time decay also in addition of placements
This commit is contained in:
@@ -12,6 +12,7 @@ from games.decorators import require_game_enabled
|
||||
from .canvas import read_canvas
|
||||
from .gif import build_evolution_gif
|
||||
from .heatmap import (
|
||||
DecayMode,
|
||||
build_heatmap_decay_gif,
|
||||
build_heatmap_evolution_gif,
|
||||
build_heatmap_png,
|
||||
@@ -85,17 +86,20 @@ def heatmap_decay_gif(
|
||||
request: HttpRequest,
|
||||
event_path: str,
|
||||
players: str = "",
|
||||
half_life: int | None = None,
|
||||
half_life: float | None = None,
|
||||
mode: DecayMode = "placements",
|
||||
):
|
||||
"""Admin-only: the heatmap over time, but heat decays so pixels nobody
|
||||
touches anymore fade back to white.
|
||||
|
||||
`players` is an optional comma-separated list of usernames to only
|
||||
count those players' placements (combined). `half_life` is in
|
||||
placements, defaults to 10 frames' worth."""
|
||||
count those players' placements (combined). `mode` is "placements"
|
||||
(decay per placement, `half_life` in placements, defaults to 10 frames'
|
||||
worth) or "time" (decay over time, `half_life` in minutes, defaults
|
||||
to 60)."""
|
||||
usernames = [name.strip() for name in players.split(",") if name.strip()]
|
||||
return HttpResponse(
|
||||
build_heatmap_decay_gif(request.event, usernames, half_life),
|
||||
build_heatmap_decay_gif(request.event, usernames, half_life, mode),
|
||||
content_type="image/gif",
|
||||
)
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ where the heat decays so only recent activity stays red.
|
||||
|
||||
import math
|
||||
from io import BytesIO
|
||||
from typing import Literal
|
||||
|
||||
from django.db.models import Count
|
||||
from PIL import Image
|
||||
@@ -98,44 +99,74 @@ def build_heatmap_evolution_gif(
|
||||
return _save_gif(frames)
|
||||
|
||||
|
||||
DecayMode = Literal["placements", "time"]
|
||||
|
||||
DEFAULT_HALF_LIFE_MINUTES = 60
|
||||
|
||||
|
||||
def build_heatmap_decay_gif(
|
||||
event: Event, usernames: list[str] | None = None, half_life: int | None = None
|
||||
event: Event,
|
||||
usernames: list[str] | None = None,
|
||||
half_life: float | None = None,
|
||||
mode: DecayMode = "placements",
|
||||
) -> bytes:
|
||||
"""Like build_heatmap_evolution_gif, but every pixel's heat decays
|
||||
exponentially, so a pixel nobody touches anymore fades back to white
|
||||
and the GIF shows where the activity is *right now*.
|
||||
|
||||
`half_life` is in placements (after that many more placements anywhere
|
||||
on the board, a pixel's heat is halved). Defaults to 10 frames' worth.
|
||||
`mode` picks what drives the decay (and the frame spacing):
|
||||
- "placements": `half_life` is in placements (after that many more
|
||||
placements anywhere on the board, a pixel's heat is halved), defaults
|
||||
to 10 frames' worth. Frames are one every N placements, so quiet
|
||||
periods take almost no frames and nothing fades during them.
|
||||
- "time": `half_life` is in minutes, defaults to an hour. Frames are
|
||||
evenly spaced in time between the first and last placement, so quiet
|
||||
periods actually show the board fading out.
|
||||
|
||||
Every frame shares the same color scale (the hottest pixel across the
|
||||
whole replay), so a quiet period actually looks quiet."""
|
||||
place_settings = PlaceSettings.current(event)
|
||||
width, height = place_settings.width, place_settings.height
|
||||
|
||||
placements = [
|
||||
(x, y)
|
||||
for x, y in _placements(event, usernames)
|
||||
rows = [
|
||||
(placed_at, x, y)
|
||||
for placed_at, x, y in _placements(event, usernames)
|
||||
.order_by("placed_at")
|
||||
.values_list("x", "y")
|
||||
.values_list("placed_at", "x", "y")
|
||||
if x < width and y < height
|
||||
]
|
||||
|
||||
step = max(1, len(placements) // MAX_FRAMES)
|
||||
decay = 0.5 ** (1 / max(1, half_life or step * 10))
|
||||
# Both modes boil down to a "clock" per placement, the clock values to
|
||||
# snapshot a frame at, and a half-life in that clock's unit.
|
||||
if mode == "time":
|
||||
clocks = [placed_at.timestamp() for placed_at, _, _ in rows]
|
||||
frame_clocks = _evenly_spaced(clocks, MAX_FRAMES)
|
||||
half_life_clock = (half_life or DEFAULT_HALF_LIFE_MINUTES) * 60
|
||||
else:
|
||||
clocks = list(range(len(rows)))
|
||||
step = max(1, len(rows) // MAX_FRAMES)
|
||||
frame_clocks = [i for i in clocks if i % step == 0 or i == len(rows) - 1]
|
||||
half_life_clock = half_life or step * 10
|
||||
decay = 0.5 ** (1 / max(1e-9, half_life_clock))
|
||||
|
||||
# Decay is applied lazily: each pixel stores its heat as of the
|
||||
# placement index it was last touched at, so a placement is O(1)
|
||||
# instead of decaying the whole grid every time.
|
||||
# Decay is applied lazily: each pixel stores its heat as of the clock it
|
||||
# was last touched at, so a placement is O(1) instead of decaying the
|
||||
# whole grid every time.
|
||||
heat = [0.0] * (width * height)
|
||||
touched_at = [0] * (width * height)
|
||||
touched_at = [0.0] * (width * height)
|
||||
snapshots: list[list[float]] = []
|
||||
for i, (x, y) in enumerate(placements):
|
||||
index = y * width + x
|
||||
heat[index] = heat[index] * decay ** (i - touched_at[index]) + 1
|
||||
touched_at[index] = i
|
||||
if i % step == 0 or i == len(placements) - 1:
|
||||
snapshots.append([h * decay ** (i - t) for h, t in zip(heat, touched_at)])
|
||||
placement_index = 0
|
||||
for frame_clock in frame_clocks:
|
||||
while placement_index < len(rows) and clocks[placement_index] <= frame_clock:
|
||||
_, x, y = rows[placement_index]
|
||||
clock = clocks[placement_index]
|
||||
index = y * width + x
|
||||
heat[index] = heat[index] * decay ** (clock - touched_at[index]) + 1
|
||||
touched_at[index] = clock
|
||||
placement_index += 1
|
||||
snapshots.append(
|
||||
[h * decay ** (frame_clock - t) for h, t in zip(heat, touched_at)]
|
||||
)
|
||||
|
||||
if not snapshots:
|
||||
snapshots.append(heat)
|
||||
@@ -151,6 +182,17 @@ def build_heatmap_decay_gif(
|
||||
return _save_gif(frames)
|
||||
|
||||
|
||||
def _evenly_spaced(clocks: list[float], count: int) -> list[float]:
|
||||
"""`count` clock values evenly spread from the first to the last one
|
||||
(just the one if they're all the same, none if `clocks` is empty)."""
|
||||
if not clocks:
|
||||
return []
|
||||
first, last = clocks[0], clocks[-1]
|
||||
if first == last:
|
||||
return [first]
|
||||
return [first + (last - first) * i / (count - 1) for i in range(count)]
|
||||
|
||||
|
||||
def _save_gif(frames: list[Image.Image]) -> bytes:
|
||||
buffer = BytesIO()
|
||||
durations = [FRAME_DURATION_MS] * len(frames)
|
||||
|
||||
@@ -969,8 +969,10 @@ export const placeApiHeatmapDecayGifQueryKey = (options: Options<PlaceApiHeatmap
|
||||
* touches anymore fade back to white.
|
||||
*
|
||||
* `players` is an optional comma-separated list of usernames to only
|
||||
* count those players' placements (combined). `half_life` is in
|
||||
* placements, defaults to 10 frames' worth.
|
||||
* count those players' placements (combined). `mode` is "placements"
|
||||
* (decay per placement, `half_life` in placements, defaults to 10 frames'
|
||||
* worth) or "time" (decay over time, `half_life` in minutes, defaults
|
||||
* to 60).
|
||||
*/
|
||||
export const placeApiHeatmapDecayGifOptions = (options: Options<PlaceApiHeatmapDecayGifData>) => queryOptions<unknown, DefaultError, unknown, ReturnType<typeof placeApiHeatmapDecayGifQueryKey>>({
|
||||
queryFn: async ({ queryKey, signal }) => {
|
||||
|
||||
@@ -399,8 +399,10 @@ export const placeApiHeatmapEvolutionGif = <ThrowOnError extends boolean = false
|
||||
* touches anymore fade back to white.
|
||||
*
|
||||
* `players` is an optional comma-separated list of usernames to only
|
||||
* count those players' placements (combined). `half_life` is in
|
||||
* placements, defaults to 10 frames' worth.
|
||||
* count those players' placements (combined). `mode` is "placements"
|
||||
* (decay per placement, `half_life` in placements, defaults to 10 frames'
|
||||
* worth) or "time" (decay over time, `half_life` in minutes, defaults
|
||||
* to 60).
|
||||
*/
|
||||
export const placeApiHeatmapDecayGif = <ThrowOnError extends boolean = false>(options: Options<PlaceApiHeatmapDecayGifData, ThrowOnError>) => (options.client ?? client).get<PlaceApiHeatmapDecayGifResponses, unknown, ThrowOnError>({
|
||||
security: [{
|
||||
|
||||
@@ -2808,6 +2808,10 @@ export type PlaceApiHeatmapDecayGifData = {
|
||||
* Half Life
|
||||
*/
|
||||
half_life?: number | null;
|
||||
/**
|
||||
* Mode
|
||||
*/
|
||||
mode?: 'placements' | 'time';
|
||||
};
|
||||
url: '/api/events/{event_path}/place/heatmap-decay.gif';
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user