Files
RFP_Infinity-Vis/app/core/pattern_engine.py

325 lines
14 KiB
Python

from __future__ import annotations
import math
import time
from typing import cast
from app.config.models import InfinityMirrorConfig, SegmentConfig, TileConfig
from app.core.colors import choose_pair, darken, label_contrast, sample_palette
from app.core.geometry import segment_led_positions
from app.core.pattern_compat import normalize_pattern_request
from app.core.types import PatternParameters, PreviewFrame, RGBColor, TileFrame, TilePatternSample
from app.patterns.base import PatternContext, PatternDescriptor, PatternRegistry
from app.patterns.builtin import built_in_patterns
class PatternEngine:
def __init__(self) -> None:
self.registry = PatternRegistry(built_in_patterns())
self._last_time = time.perf_counter()
self._previous_samples: dict[str, TilePatternSample] = {}
def descriptors(self) -> list[PatternDescriptor]:
return self.registry.descriptors()
def render_frame(
self,
config: InfinityMirrorConfig,
pattern_id: str,
params: PatternParameters,
utility_mode: str = "none",
selected_tile_id: str | None = None,
timestamp: float | None = None,
tempo_bpm: float = 60.0,
tempo_phase: float | None = None,
) -> PreviewFrame:
params = params.sanitized()
pattern_id, normalized_params = normalize_pattern_request(
pattern_id,
params,
rows=config.logical_display.rows,
cols=config.logical_display.cols,
)
if normalized_params is not None:
params = normalized_params
timestamp = time.perf_counter() if timestamp is None else timestamp
if tempo_phase is None:
tempo_phase = timestamp * max(0.05, float(tempo_bpm) / 60.0)
delta = max(1.0 / 120.0, timestamp - self._last_time)
self._last_time = timestamp
if utility_mode != "none":
temporal_profile = "direct"
samples = self._render_utility_frame(config, params, utility_mode, selected_tile_id, timestamp)
else:
descriptor = self.registry.get(pattern_id).descriptor
temporal_profile = descriptor.temporal_profile
samples = self.registry.get(pattern_id).render(
PatternContext(
config=config,
params=params,
time_s=timestamp,
tempo_bpm=tempo_bpm,
tempo_phase=tempo_phase,
)
)
blend_alpha = 1.0 - math.exp(-delta * (10.0 * (1.0 - params.fade) + 1.0))
frame_tiles: dict[str, TileFrame] = {}
for tile in config.sorted_tiles():
sample = samples.get(tile.tile_id, self._fallback_sample(tile))
blended = sample if temporal_profile == "direct" else self._blend_sample(tile.tile_id, sample, blend_alpha)
self._previous_samples[tile.tile_id] = blended
brightness = params.brightness * tile.brightness_factor if tile.enabled else 0.04
fill = blended.fill_color.scaled(brightness)
glow = blended.glow_color.scaled(brightness)
rim = blended.rim_color.scaled(brightness)
metadata = self._scale_metadata(blended.metadata, brightness)
frame_tiles[tile.tile_id] = TileFrame(
tile_id=tile.tile_id,
row=tile.row,
col=tile.col,
fill_color=fill,
glow_color=glow,
rim_color=rim,
label_color=label_contrast(fill),
intensity=blended.intensity,
enabled=tile.enabled,
led_pixels=self._build_led_pixels(
tile,
fill,
rim,
timestamp,
max(0.05, tempo_bpm / 60.0),
config.defaults.tile_behavior,
metadata,
),
metadata=metadata,
)
if params.color_mode == "palette":
background_start = darken(sample_palette(params.palette, 0.78), 0.86)
background_end = darken(sample_palette(params.palette, 0.22), 0.94)
else:
primary, secondary = choose_pair(
params.color_mode,
params.primary_color,
params.secondary_color,
params.palette,
0.22,
)
background_start = darken(secondary.mix(primary, 0.18), 0.78)
background_end = darken(primary.mix(secondary, 0.12), 0.92)
return PreviewFrame(
timestamp=timestamp,
pattern_id=pattern_id,
utility_mode=utility_mode,
background_start=background_start,
background_end=background_end,
tiles=frame_tiles,
)
def _blend_sample(self, tile_id: str, sample: TilePatternSample, blend_alpha: float) -> TilePatternSample:
previous = self._previous_samples.get(tile_id, sample)
return TilePatternSample(
fill_color=previous.fill_color.mix(sample.fill_color, blend_alpha),
glow_color=previous.glow_color.mix(sample.glow_color, blend_alpha),
rim_color=previous.rim_color.mix(sample.rim_color, blend_alpha),
label_color=previous.label_color.mix(sample.label_color, blend_alpha),
intensity=previous.intensity + (sample.intensity - previous.intensity) * blend_alpha,
metadata=sample.metadata,
)
def _fallback_sample(self, tile: TileConfig) -> TilePatternSample:
color = RGBColor(0.08, 0.12, 0.16)
return TilePatternSample(
fill_color=color,
glow_color=color.mix(RGBColor.white(), 0.12),
rim_color=color.mix(RGBColor.white(), 0.22),
label_color=RGBColor.white(),
intensity=0.2,
)
def _scale_metadata(self, metadata: dict[str, object], brightness: float) -> dict[str, object]:
if not metadata:
return {}
scaled = dict(metadata)
diagonal_split = metadata.get("diagonal_split")
if isinstance(diagonal_split, dict):
color_a = diagonal_split.get("color_a")
color_b = diagonal_split.get("color_b")
scaled_a = color_a.scaled(brightness) if isinstance(color_a, RGBColor) else RGBColor.black()
scaled_b = color_b.scaled(brightness) if isinstance(color_b, RGBColor) else RGBColor.black()
scaled["diagonal_split"] = {
**diagonal_split,
"color_a": scaled_a,
"color_b": scaled_b,
}
led_pixels = metadata.get("led_pixels")
if isinstance(led_pixels, dict):
scaled_led_pixels: dict[str, list[RGBColor]] = {}
for segment_name, segment_colors in led_pixels.items():
if not isinstance(segment_name, str) or not isinstance(segment_colors, list):
continue
scaled_segment: list[RGBColor] = []
for color in segment_colors:
if not isinstance(color, RGBColor):
continue
scaled_segment.append(color.scaled(brightness))
scaled_led_pixels[segment_name] = scaled_segment
scaled["led_pixels"] = scaled_led_pixels
return scaled
def _build_led_pixels(
self,
tile: TileConfig,
fill_color: RGBColor,
rim_color: RGBColor,
timestamp: float,
tempo_hz: float,
tile_behavior: str,
metadata: dict[str, object],
) -> dict[str, list[RGBColor]]:
led_pixels = metadata.get("led_pixels")
if isinstance(led_pixels, dict):
return self._build_metadata_led_pixels(tile, cast(dict[str, list[RGBColor]], led_pixels))
diagonal_split = metadata.get("diagonal_split")
if isinstance(diagonal_split, dict):
return self._build_diagonal_split_pixels(tile, diagonal_split)
pixels: dict[str, list[RGBColor]] = {}
if tile_behavior == "solid_color_per_tile":
for segment in tile.segments:
pixels[segment.name] = [fill_color for _ in range(segment.led_count)]
return pixels
for segment in tile.segments:
segment_pixels: list[RGBColor] = []
for index in range(segment.led_count):
pulse = 0.04 * math.sin((timestamp * tempo_hz * 3.0) + index * 0.15 + tile.row * 0.9 + tile.col * 0.55)
amount = 0.05 + max(0.0, pulse)
segment_pixels.append(fill_color.mix(rim_color, amount))
if segment.reverse:
segment_pixels.reverse()
pixels[segment.name] = segment_pixels
return pixels
def _build_metadata_led_pixels(self, tile: TileConfig, led_pixels: dict[str, list[RGBColor]]) -> dict[str, list[RGBColor]]:
pixels: dict[str, list[RGBColor]] = {}
for segment in tile.segments:
colors = list(led_pixels.get(segment.name, []))
if len(colors) < segment.led_count:
colors.extend([RGBColor.black()] * (segment.led_count - len(colors)))
pixels[segment.name] = colors[: segment.led_count]
return pixels
def _build_diagonal_split_pixels(self, tile: TileConfig, diagonal_split: dict[str, object]) -> dict[str, list[RGBColor]]:
orientation = str(diagonal_split.get("orientation", "slash"))
color_a = diagonal_split.get("color_a")
color_b = diagonal_split.get("color_b")
if not isinstance(color_a, RGBColor) or not isinstance(color_b, RGBColor):
return {}
pixels: dict[str, list[RGBColor]] = {}
for segment in tile.segments:
segment_pixels: list[RGBColor] = []
for x_pos, y_pos in segment_led_positions(tile, segment):
if orientation == "backslash":
color = color_a if y_pos <= x_pos else color_b
else:
color = color_a if y_pos <= 1.0 - x_pos else color_b
segment_pixels.append(color)
pixels[segment.name] = segment_pixels
return pixels
def _render_utility_frame(
self,
config: InfinityMirrorConfig,
params: PatternParameters,
utility_mode: str,
selected_tile_id: str | None,
timestamp: float,
) -> dict[str, TilePatternSample]:
tiles = config.sorted_tiles()
blank = TilePatternSample(
fill_color=RGBColor.black(),
glow_color=RGBColor(0.04, 0.06, 0.07),
rim_color=RGBColor(0.08, 0.1, 0.12),
label_color=RGBColor.white(),
intensity=0.1,
)
if utility_mode == "blackout":
return {tile.tile_id: blank for tile in tiles}
if utility_mode == "identify":
active_index = int(timestamp * 2.0) % max(1, len(tiles))
result = {tile.tile_id: blank for tile in tiles}
active = tiles[active_index]
result[active.tile_id] = TilePatternSample(
fill_color=RGBColor.white().scaled(0.9),
glow_color=RGBColor(1.0, 0.85, 0.4),
rim_color=RGBColor.white(),
label_color=RGBColor.black(),
intensity=1.0,
metadata={"active": True},
)
return result
if utility_mode == "single_tile":
result = {tile.tile_id: blank for tile in tiles}
if selected_tile_id and selected_tile_id in result:
result[selected_tile_id] = TilePatternSample(
fill_color=RGBColor.white(),
glow_color=RGBColor(0.9, 0.96, 1.0),
rim_color=RGBColor.white(),
label_color=RGBColor.black(),
intensity=1.0,
metadata={"active": True},
)
return result
if utility_mode == "row_test":
palette = [sample_palette(params.palette, row / max(1, config.logical_display.rows - 1)) for row in range(config.logical_display.rows)]
return {
tile.tile_id: TilePatternSample(
fill_color=palette[tile.row - 1].scaled(0.95),
glow_color=palette[tile.row - 1].mix(RGBColor.white(), 0.2),
rim_color=palette[tile.row - 1].mix(RGBColor.white(), 0.3),
label_color=RGBColor.black() if tile.row == 1 else RGBColor.white(),
intensity=0.9,
)
for tile in tiles
}
if utility_mode == "column_test":
palette = [sample_palette(params.palette, col / max(1, config.logical_display.cols - 1)) for col in range(config.logical_display.cols)]
return {
tile.tile_id: TilePatternSample(
fill_color=palette[tile.col - 1].scaled(0.95),
glow_color=palette[tile.col - 1].mix(RGBColor.white(), 0.2),
rim_color=palette[tile.col - 1].mix(RGBColor.white(), 0.3),
label_color=RGBColor.white(),
intensity=0.9,
)
for tile in tiles
}
if utility_mode == "checker_test":
return {
tile.tile_id: TilePatternSample(
fill_color=(RGBColor.white() if (tile.row + tile.col) % 2 == 0 else RGBColor(0.05, 0.08, 0.1)),
glow_color=RGBColor(0.8, 0.9, 1.0) if (tile.row + tile.col) % 2 == 0 else RGBColor(0.08, 0.1, 0.14),
rim_color=RGBColor.white() if (tile.row + tile.col) % 2 == 0 else RGBColor(0.12, 0.16, 0.18),
label_color=RGBColor.black() if (tile.row + tile.col) % 2 == 0 else RGBColor.white(),
intensity=1.0 if (tile.row + tile.col) % 2 == 0 else 0.15,
)
for tile in tiles
}
return {tile.tile_id: blank for tile in tiles}