diff --git a/trap/lines.py b/trap/lines.py index b5ccef1..624990e 100644 --- a/trap/lines.py +++ b/trap/lines.py @@ -4,12 +4,14 @@ from abc import ABC, abstractmethod import copy from dataclasses import dataclass from enum import Enum, IntEnum +from functools import partial import math from pathlib import Path import time -from typing import Dict, List, Optional, Tuple +from typing import Callable, Dict, List, NamedTuple, Optional, Tuple, Type, TypeVar import numpy as np +from pyparsing import Opt import shapely import shapely.ops from simplification.cutil import simplify_coords_idx, simplify_coords_vw_idx @@ -117,6 +119,13 @@ class RenderableLine(): if not is_last: points.append(RenderablePoint(line.coords[-1], color.as_faded(0))) return RenderableLine(points) + + @classmethod + def from_linestring(cls, ls: shapely.geometry.LineString, color: SrgbaColor) -> RenderableLine: + points: List[RenderablePoint] = [] + for coord in ls.coords: + points.append(RenderablePoint(coord, color)) + return RenderableLine(points) @dataclass @@ -466,7 +475,7 @@ class LineAnimator(StaticLine): raise RuntimeError("Not Implemented") def is_ready(self): - return self.ready and self.target.is_ready() + return (self.ready or self.skip) and self.target.is_ready() def start(self): self.target.start() @@ -475,7 +484,7 @@ class LineAnimator(StaticLine): return True def running_for(self): - if self.start: + if self.start_t: return time.time() - self.start_t return 0. @@ -497,7 +506,7 @@ class AppendableLineAnimator(LineAnimator): def apply(self, target_line, dt: DeltaT) -> RenderableLine: if len(target_line) == 0: # nothing to draw yet - return RenderableLine() + return RenderableLine([]) @@ -563,6 +572,37 @@ class FadeOutLine(LineAnimator): point.color = point.color.as_faded(self.alpha) return target_line + +class FadeOutJitterLine(LineAnimator): + """ + Fade the line providing an alpha, 1 by default + """ + def __init__(self, target_line = None, frequency=.7, t_factor = 1.0): + """ + To make shape static over time, set t_factor = 0 + """ + super().__init__(target_line) + self.alpha = 1 + + self.frequency = frequency + self.t_factor = t_factor + self.ready = True # filter holds no state, so always ready + + def set_alpha(self, alpha: float): + self.alpha = min(1, max(0, alpha)) + + + def apply(self, target_line: RenderableLine, dt: DeltaT) -> RenderableLine: + + for i, point in enumerate(target_line.points): + + noise = abs(snoise2(i * self.frequency, self.running_for() * self.t_factor % 1000, octaves=1)) + + alpha = np.clip(noise + 2 * self.alpha, 1, 2) - 1 + + point.color = point.color.as_faded(alpha) + + return target_line class CropLine(LineAnimator): @@ -739,16 +779,46 @@ class NoiseLine(LineAnimator): return RenderableLine(points) class SegmentLine(LineAnimator): - def __init__(self, target_line = None): + def __init__(self, target_line = None, length = 0.3, duration = 1., anim_f: Optional[Callable] = None): super().__init__(target_line) + self.length = length + self.duration = duration + self.anim_f = anim_f or partial(self.anim_arrive, length=self.length) + + @classmethod + def anim_arrive(cls, t: float, ls: shapely.geometry.LineString, length: float): + t = 1-t # reverse + start_pos = t * ls.length + end_pos = start_pos + length + return (start_pos, end_pos) + + @classmethod + def anim_grow(cls, t: float, ls: shapely.geometry.LineString, reverse=False): + if reverse: + return (ls.length * t, ls.length) + else: + return (0, ls.length * t) + def apply(self, target_line: RenderableLine, dt: DeltaT): if len(target_line) < 2: + self.ready = True return target_line - i = self.running_for() + ls = target_line.as_linestring() + + t = min(self.running_for()/self.duration, 1) + + self.ready = t >= 1 + + start_pos, end_pos = self.anim_f(t, ls) + + line = shapely.ops.substring(ls, start_pos, end_pos) + + return RenderableLine.from_linestring(line, target_line.points[0].color) + + - return super().apply(target_line, dt) class DashedLine(LineAnimator): """ @@ -808,7 +878,8 @@ class DashedLine(LineAnimator): multilinestring = self.dashed_line(ls, self.dash_len, self.gap_len, self.t_factor * self.running_for(), self.loop_offset) - self.ready = not bool(len(multilinestring.geoms)) + # when looping, it is always ready, otherwise, only if totally gone + self.ready = self.loop_offset or not bool(len(multilinestring.geoms)) color = target_line.points[0].color @@ -864,4 +935,80 @@ class LineStringIncrementingDistanceOffset(): segment_length = line.coords[i+1].distance(line.coords[i]) start_at = cumulative_length cumulative_length += float(segment_length) - yield (i, i+1), (start_at, cumulative_length) \ No newline at end of file + yield (i, i+1), (start_at, cumulative_length) + + + +L = TypeVar('U', bound=StaticLine) +class LineAnimationStack(): + def __init__(self, line: StaticLine): + self.stack: Dict[Type[StaticLine], StaticLine] = {} + + self.root = line + self.stack[line.__class__] = line + self.tail = line + + def get(self, anim: Type[L]) -> L: + """ + Get item from the stack. E.g. `line.get(NoiseLine)` + """ + if anim in self.stack: + return self.stack[anim] + + raise RuntimeError(f"Not in line stack {anim.__name__}") + + @property + def last(self): + return self.tail + + def add(self, line: LineAnimator): + if line.__class__ in self.stack: + raise RuntimeError(f"Cannot add twice {line.__class__.__name__}") + + self.stack[line.__class__] = line + self.tail = line + + def as_renderable_line(self, dt: DeltaT): + return self.tail.as_renderable_line(dt) + + + def start(self): + return self.tail.start() + + def is_ready(self): + return self.tail.is_ready() + + +class LineAnimationSequenceStep(NamedTuple): + line: LineAnimator + ready_function: Callable + +class LineAnimationSequence(): + """ + WORK IN PROGRESS + """ + def __init__(self, target_line: StaticLine): + self.seq: List[LineAnimationSequenceStep] = [] + self.ready = False + self.idx = 0 + self.target = target_line + + def steps(self): + for step in self.seq: + yield step + + @classmethod + def ready_if_ready(cls, line: LineAnimator): + return line.is_ready() + + @classmethod + def ready_after(cls, line: LineAnimator, duration: DeltaT): + return line.running_for() >= duration + + + def start(self): + self.start_at = time.time() + self.idx = 0 + + def as_renderable_line(self, dt: DeltaT): + return self.seq[self.idx].as_renderable_line(dt) diff --git a/trap/stage.py b/trap/stage.py index f3526aa..c143150 100644 --- a/trap/stage.py +++ b/trap/stage.py @@ -2,17 +2,18 @@ from argparse import ArgumentParser from collections import defaultdict from dataclasses import dataclass from enum import Enum +from functools import partial import logging import time import threading -from typing import Dict, List, Optional +from typing import Dict, List, Optional, Type, TypeVar import zmq from trap.anomaly import DiffSegment, calc_anomaly, calculate_loitering_scores from trap.base import DataclassJSONEncoder, Frame, ProjectedTrack, Track from trap.counter import CounterSender -from trap.lines import AppendableLine, AppendableLineAnimator, Coordinate, CropLine, DashedLine, DeltaT, FadeOutLine, FadedTailLine, NoiseLine, RenderableLayers, RenderableLine, RenderableLines, SimplifyMethod, SrgbaColor, StaticLine, load_lines_from_svg +from trap.lines import AppendableLine, AppendableLineAnimator, Coordinate, CropLine, DashedLine, DeltaT, FadeOutJitterLine, FadeOutLine, FadedTailLine, LineAnimationStack, LineAnimator, NoiseLine, RenderableLayers, RenderableLine, RenderableLines, SegmentLine, SimplifyMethod, SrgbaColor, StaticLine, load_lines_from_svg from trap.node import Node @@ -27,7 +28,7 @@ OPTION_TRACK_NOISE = False TRACK_ASSUMED_FPS = 12 TAKEOVER_FADEOUT = 3 -LOST_FADEOUT = 3 +LOST_FADEOUT = 2 # seconds PREDICTION_INTERVAL: float|None = 20 # frames PREDICTION_FADE_IN: float = 3 PREDICTION_FADE_SLOPE: float = -10 @@ -238,6 +239,8 @@ class Scenario: self.update_state() + + class DrawnScenario(Scenario): """ @@ -254,47 +257,81 @@ class DrawnScenario(Scenario): self.last_update_t = time.perf_counter() history_color = SrgbaColor(1.,0.,1.,1.) - self.line_history = StaticLine([], history_color) - self.line_history_smooth = AppendableLineAnimator(self.line_history, draw_decay_speed=25) - self.line_history_capped = CropLine(self.line_history_smooth, self.MAX_HISTORY) - self.line_history_disappearing = FadedTailLine(self.line_history_capped, TRACK_FADE_AFTER_DURATION * TRACK_ASSUMED_FPS, TRACK_END_FADE) - self.line_history_noisy = NoiseLine(self.line_history_disappearing, amplitude=0, t_factor=.3) - self.line_history_faded = FadeOutLine(self.line_history_noisy) - self.line_history_drawn = self.line_history_faded - + history = StaticLine([], history_color) + self.line_history = LineAnimationStack(history) + self.line_history.add(AppendableLineAnimator(self.line_history.tail, draw_decay_speed=25)) + self.line_history.add(CropLine(self.line_history.tail, self.MAX_HISTORY)) + self.line_history.add(FadedTailLine(self.line_history.tail, TRACK_FADE_AFTER_DURATION * TRACK_ASSUMED_FPS, TRACK_END_FADE)) + self.line_history.add(NoiseLine(self.line_history.tail, amplitude=0, t_factor=.3)) + self.line_history.add(FadeOutJitterLine(self.line_history.tail, frequency=5, t_factor=.5)) + self.active_ptrack: Optional[ProjectedTrack] = None self.prediction_color = SrgbaColor(0,1,0,1) - self.line_prediction =StaticLine([], self.prediction_color) - self.line_prediction_dashed = DashedLine(self.line_prediction, t_factor=8) - self.line_prediction_dashed.skip = True - self.line_prediction_faded = FadeOutLine(self.line_prediction_dashed) - self.line_prediction_drawn = self.line_prediction_faded + self.line_prediction = LineAnimationStack(StaticLine([], self.prediction_color)) + self.line_prediction.add(SegmentLine(self.line_prediction.tail, duration=.5)) + self.line_prediction.add(DashedLine(self.line_prediction.tail, t_factor=4, loop_offset=True)) + self.line_prediction.get(DashedLine).skip = True + self.line_prediction.add(FadeOutLine(self.line_prediction.tail)) + # self.line_prediction_drawn = self.line_prediction_faded def update(self): super().update() if self.track: - self.line_history.points = self.track.projected_history + self.line_history.root.points = self.track.projected_history if len(self.prediction_tracks): # TODO: only when animation is ready for it? or collect lines if not self.active_ptrack: self.active_ptrack = self.prediction_tracks[-1] + self.line_prediction.start() # reset positions + elif self.active_ptrack._track.updated_at < self.prediction_tracks[-1]._track.updated_at: - # logger.debug('pending change?', self.line_prediction_drawn.is_ready()) - # switch only if ready - if self.line_prediction_drawn.is_ready(): + # switch only if drawing animation is ready + if self.line_prediction.is_ready(): self.active_ptrack = self.prediction_tracks[-1] - self.line_prediction_drawn.start() # reset positions + self.line_prediction.get(SegmentLine).anim_f = partial(SegmentLine.anim_arrive, length=.3) + self.line_prediction.get(SegmentLine).duration = .5 + self.line_prediction.get(DashedLine).skip = True + # print('restart') + self.line_prediction.start() # reset positions + # print(self.line_prediction.get(SegmentLine).running_for()) + else: + if self.line_prediction.is_ready(): + # little hack: check is dashedline skips, to only run this once per animation: + if self.line_prediction.get(DashedLine).skip: + # no new yet, but ready with anim, start stage 2 + self.line_prediction.get(SegmentLine).anim_f = partial(SegmentLine.anim_grow) + self.line_prediction.get(SegmentLine).duration = 1 + # self.line_prediction.get(SegmentLine).skip = True + self.line_prediction.get(DashedLine).skip = False + self.line_prediction.start() + else: + self.line_prediction.get(SegmentLine).anim_f = partial(SegmentLine.anim_grow, reverse=True) + self.line_prediction.get(SegmentLine).duration = 2 + self.line_prediction.get(SegmentLine).start() + + # self.line_prediction_dashed.set_offset_t(self.active_ptrack._track.track_update_dt() * 4) - self.line_prediction.points = self.active_ptrack._track.predictions[0] + self.line_prediction.root.points = self.active_ptrack._track.predictions[0] - if self.scene is ScenarioScene.CORRECTED_PREDICTION: - self.line_prediction_dashed.skip = False + if self.scene is ScenarioScene.LOITERING: + # special case: PLAY + transition = min(1, (time.time() - self.state_change_at)/1.4) + # TODO: transition fade, using to_alpha(), so it can fade back in again: + self.line_history.get(FadeOutJitterLine).set_alpha(1 - transition) + if transition > .999: + # fetch lines nearby + pass + elif self.scene is ScenarioScene.PLAY: + # special case: PLAY + pass + # if self.scene is ScenarioScene.CORRECTED_PREDICTION: + # self.line_prediction.get(DashedLine).skip = False @@ -303,21 +340,21 @@ class DrawnScenario(Scenario): # 1) history, fade out when lost - self.line_history_faded.set_alpha(1-self.lost_factor()) - self.line_prediction_faded.set_alpha(1-self.lost_factor()) - self.line_history_noisy.amplitude = self.lost_factor() + self.line_history.get(FadeOutJitterLine).set_alpha(1-self.lost_factor()) + self.line_prediction.get(FadeOutLine).set_alpha(1-self.lost_factor()) + self.line_history.get(NoiseLine).amplitude = self.lost_factor() # fade out history after max duration, given in frames track_age_in_frames = self.track_age() * TRACK_ASSUMED_FPS - self.line_history_disappearing.set_frame_offset(track_age_in_frames) + self.line_history.get(FadedTailLine).set_frame_offset(track_age_in_frames) # 2) also fade-out when moving into loitering mode. # when fading out is done, start drawing historical data - history_line = self.line_history_drawn.as_renderable_line(dt) - prediction_line = self.line_prediction_drawn.as_renderable_line(dt) + history_line = self.line_history.as_renderable_line(dt) + prediction_line = self.line_prediction.as_renderable_line(dt) # print(history_line) # print(self.track_id, len(self.line_history.points), len(history_line))