First upload, 18 controller version
This commit is contained in:
324
app/core/pattern_engine.py
Normal file
324
app/core/pattern_engine.py
Normal file
@@ -0,0 +1,324 @@
|
||||
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}
|
||||
Reference in New Issue
Block a user