More animations for predictions
This commit is contained in:
parent
e6d1457320
commit
afe5accb9c
2 changed files with 223 additions and 39 deletions
165
trap/lines.py
165
trap/lines.py
|
|
@ -4,12 +4,14 @@ from abc import ABC, abstractmethod
|
||||||
import copy
|
import copy
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from enum import Enum, IntEnum
|
from enum import Enum, IntEnum
|
||||||
|
from functools import partial
|
||||||
import math
|
import math
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import time
|
import time
|
||||||
from typing import Dict, List, Optional, Tuple
|
from typing import Callable, Dict, List, NamedTuple, Optional, Tuple, Type, TypeVar
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
|
from pyparsing import Opt
|
||||||
import shapely
|
import shapely
|
||||||
import shapely.ops
|
import shapely.ops
|
||||||
from simplification.cutil import simplify_coords_idx, simplify_coords_vw_idx
|
from simplification.cutil import simplify_coords_idx, simplify_coords_vw_idx
|
||||||
|
|
@ -117,6 +119,13 @@ class RenderableLine():
|
||||||
if not is_last:
|
if not is_last:
|
||||||
points.append(RenderablePoint(line.coords[-1], color.as_faded(0)))
|
points.append(RenderablePoint(line.coords[-1], color.as_faded(0)))
|
||||||
return RenderableLine(points)
|
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
|
@dataclass
|
||||||
|
|
@ -466,7 +475,7 @@ class LineAnimator(StaticLine):
|
||||||
raise RuntimeError("Not Implemented")
|
raise RuntimeError("Not Implemented")
|
||||||
|
|
||||||
def is_ready(self):
|
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):
|
def start(self):
|
||||||
self.target.start()
|
self.target.start()
|
||||||
|
|
@ -475,7 +484,7 @@ class LineAnimator(StaticLine):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def running_for(self):
|
def running_for(self):
|
||||||
if self.start:
|
if self.start_t:
|
||||||
return time.time() - self.start_t
|
return time.time() - self.start_t
|
||||||
return 0.
|
return 0.
|
||||||
|
|
||||||
|
|
@ -497,7 +506,7 @@ class AppendableLineAnimator(LineAnimator):
|
||||||
def apply(self, target_line, dt: DeltaT) -> RenderableLine:
|
def apply(self, target_line, dt: DeltaT) -> RenderableLine:
|
||||||
if len(target_line) == 0:
|
if len(target_line) == 0:
|
||||||
# nothing to draw yet
|
# nothing to draw yet
|
||||||
return RenderableLine()
|
return RenderableLine([])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -563,6 +572,37 @@ class FadeOutLine(LineAnimator):
|
||||||
point.color = point.color.as_faded(self.alpha)
|
point.color = point.color.as_faded(self.alpha)
|
||||||
|
|
||||||
return target_line
|
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):
|
class CropLine(LineAnimator):
|
||||||
|
|
@ -739,16 +779,46 @@ class NoiseLine(LineAnimator):
|
||||||
return RenderableLine(points)
|
return RenderableLine(points)
|
||||||
|
|
||||||
class SegmentLine(LineAnimator):
|
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)
|
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):
|
def apply(self, target_line: RenderableLine, dt: DeltaT):
|
||||||
if len(target_line) < 2:
|
if len(target_line) < 2:
|
||||||
|
self.ready = True
|
||||||
return target_line
|
return target_line
|
||||||
i = self.running_for()
|
|
||||||
ls = target_line.as_linestring()
|
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):
|
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)
|
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
|
color = target_line.points[0].color
|
||||||
|
|
||||||
|
|
@ -864,4 +935,80 @@ class LineStringIncrementingDistanceOffset():
|
||||||
segment_length = line.coords[i+1].distance(line.coords[i])
|
segment_length = line.coords[i+1].distance(line.coords[i])
|
||||||
start_at = cumulative_length
|
start_at = cumulative_length
|
||||||
cumulative_length += float(segment_length)
|
cumulative_length += float(segment_length)
|
||||||
yield (i, i+1), (start_at, cumulative_length)
|
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)
|
||||||
|
|
|
||||||
|
|
@ -2,17 +2,18 @@ from argparse import ArgumentParser
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
from functools import partial
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
import threading
|
import threading
|
||||||
from typing import Dict, List, Optional
|
from typing import Dict, List, Optional, Type, TypeVar
|
||||||
|
|
||||||
import zmq
|
import zmq
|
||||||
|
|
||||||
from trap.anomaly import DiffSegment, calc_anomaly, calculate_loitering_scores
|
from trap.anomaly import DiffSegment, calc_anomaly, calculate_loitering_scores
|
||||||
from trap.base import DataclassJSONEncoder, Frame, ProjectedTrack, Track
|
from trap.base import DataclassJSONEncoder, Frame, ProjectedTrack, Track
|
||||||
from trap.counter import CounterSender
|
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
|
from trap.node import Node
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -27,7 +28,7 @@ OPTION_TRACK_NOISE = False
|
||||||
TRACK_ASSUMED_FPS = 12
|
TRACK_ASSUMED_FPS = 12
|
||||||
|
|
||||||
TAKEOVER_FADEOUT = 3
|
TAKEOVER_FADEOUT = 3
|
||||||
LOST_FADEOUT = 3
|
LOST_FADEOUT = 2 # seconds
|
||||||
PREDICTION_INTERVAL: float|None = 20 # frames
|
PREDICTION_INTERVAL: float|None = 20 # frames
|
||||||
PREDICTION_FADE_IN: float = 3
|
PREDICTION_FADE_IN: float = 3
|
||||||
PREDICTION_FADE_SLOPE: float = -10
|
PREDICTION_FADE_SLOPE: float = -10
|
||||||
|
|
@ -238,6 +239,8 @@ class Scenario:
|
||||||
|
|
||||||
self.update_state()
|
self.update_state()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class DrawnScenario(Scenario):
|
class DrawnScenario(Scenario):
|
||||||
"""
|
"""
|
||||||
|
|
@ -254,47 +257,81 @@ class DrawnScenario(Scenario):
|
||||||
self.last_update_t = time.perf_counter()
|
self.last_update_t = time.perf_counter()
|
||||||
|
|
||||||
history_color = SrgbaColor(1.,0.,1.,1.)
|
history_color = SrgbaColor(1.,0.,1.,1.)
|
||||||
self.line_history = StaticLine([], history_color)
|
history = StaticLine([], history_color)
|
||||||
self.line_history_smooth = AppendableLineAnimator(self.line_history, draw_decay_speed=25)
|
self.line_history = LineAnimationStack(history)
|
||||||
self.line_history_capped = CropLine(self.line_history_smooth, self.MAX_HISTORY)
|
self.line_history.add(AppendableLineAnimator(self.line_history.tail, draw_decay_speed=25))
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
|
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.active_ptrack: Optional[ProjectedTrack] = None
|
||||||
self.prediction_color = SrgbaColor(0,1,0,1)
|
self.prediction_color = SrgbaColor(0,1,0,1)
|
||||||
self.line_prediction =StaticLine([], self.prediction_color)
|
self.line_prediction = LineAnimationStack(StaticLine([], self.prediction_color))
|
||||||
self.line_prediction_dashed = DashedLine(self.line_prediction, t_factor=8)
|
self.line_prediction.add(SegmentLine(self.line_prediction.tail, duration=.5))
|
||||||
self.line_prediction_dashed.skip = True
|
self.line_prediction.add(DashedLine(self.line_prediction.tail, t_factor=4, loop_offset=True))
|
||||||
self.line_prediction_faded = FadeOutLine(self.line_prediction_dashed)
|
self.line_prediction.get(DashedLine).skip = True
|
||||||
self.line_prediction_drawn = self.line_prediction_faded
|
self.line_prediction.add(FadeOutLine(self.line_prediction.tail))
|
||||||
|
# self.line_prediction_drawn = self.line_prediction_faded
|
||||||
|
|
||||||
|
|
||||||
def update(self):
|
def update(self):
|
||||||
super().update()
|
super().update()
|
||||||
if self.track:
|
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):
|
if len(self.prediction_tracks):
|
||||||
# TODO: only when animation is ready for it? or collect lines
|
# TODO: only when animation is ready for it? or collect lines
|
||||||
if not self.active_ptrack:
|
if not self.active_ptrack:
|
||||||
self.active_ptrack = self.prediction_tracks[-1]
|
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:
|
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 drawing animation is ready
|
||||||
# switch only if ready
|
if self.line_prediction.is_ready():
|
||||||
if self.line_prediction_drawn.is_ready():
|
|
||||||
self.active_ptrack = self.prediction_tracks[-1]
|
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_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:
|
if self.scene is ScenarioScene.LOITERING:
|
||||||
self.line_prediction_dashed.skip = False
|
# 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
|
# 1) history, fade out when lost
|
||||||
self.line_history_faded.set_alpha(1-self.lost_factor())
|
self.line_history.get(FadeOutJitterLine).set_alpha(1-self.lost_factor())
|
||||||
self.line_prediction_faded.set_alpha(1-self.lost_factor())
|
self.line_prediction.get(FadeOutLine).set_alpha(1-self.lost_factor())
|
||||||
self.line_history_noisy.amplitude = self.lost_factor()
|
self.line_history.get(NoiseLine).amplitude = self.lost_factor()
|
||||||
|
|
||||||
# fade out history after max duration, given in frames
|
# fade out history after max duration, given in frames
|
||||||
track_age_in_frames = self.track_age() * TRACK_ASSUMED_FPS
|
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.
|
# 2) also fade-out when moving into loitering mode.
|
||||||
# when fading out is done, start drawing historical data
|
# when fading out is done, start drawing historical data
|
||||||
|
|
||||||
history_line = self.line_history_drawn.as_renderable_line(dt)
|
history_line = self.line_history.as_renderable_line(dt)
|
||||||
prediction_line = self.line_prediction_drawn.as_renderable_line(dt)
|
prediction_line = self.line_prediction.as_renderable_line(dt)
|
||||||
|
|
||||||
# print(history_line)
|
# print(history_line)
|
||||||
# print(self.track_id, len(self.line_history.points), len(history_line))
|
# print(self.track_id, len(self.line_history.points), len(history_line))
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue