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