huri-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- huri_cli/__init__.py +1 -0
- huri_cli/__main__.py +3 -0
- huri_cli/audio.py +160 -0
- huri_cli/main.py +159 -0
- huri_cli/senders.py +61 -0
- huri_cli/viewer.py +90 -0
- huri_cli-0.1.0.dist-info/METADATA +896 -0
- huri_cli-0.1.0.dist-info/RECORD +12 -0
- huri_cli-0.1.0.dist-info/WHEEL +5 -0
- huri_cli-0.1.0.dist-info/entry_points.txt +2 -0
- huri_cli-0.1.0.dist-info/licenses/LICENSE +674 -0
- huri_cli-0.1.0.dist-info/top_level.txt +1 -0
huri_cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Reference command-line client for HuRI, built on the client SDK."""
|
huri_cli/__main__.py
ADDED
huri_cli/audio.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import wave
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Any, Callable, List, Optional
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
from huri_events.audio import Audio
|
|
10
|
+
from scipy.signal import resample
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger("huri_cli.audio")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _open_output(sample_rate: int) -> Any:
|
|
16
|
+
import sounddevice as sd
|
|
17
|
+
|
|
18
|
+
stream = sd.OutputStream(samplerate=sample_rate, channels=1, dtype="int16")
|
|
19
|
+
stream.start()
|
|
20
|
+
return stream
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AudioPlayer:
|
|
24
|
+
"""Plays `huri.audio.out` on the default output device, optionally saving
|
|
25
|
+
each utterance as a WAV file.
|
|
26
|
+
|
|
27
|
+
The SDK hands chunks over in order, one at a time; the blocking device
|
|
28
|
+
write runs in a worker thread so the event loop keeps receiving meanwhile.
|
|
29
|
+
The device is opened on the first chunk, at the TTS sample rate when the
|
|
30
|
+
device supports it, else at `sample_rate` with resampling.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
save_audio_dir: str = "",
|
|
36
|
+
sample_rate: int = 48000,
|
|
37
|
+
incoming_sample_rate: int = 16000,
|
|
38
|
+
open_output: Callable[[int], Any] = _open_output,
|
|
39
|
+
):
|
|
40
|
+
self.sample_rate = sample_rate
|
|
41
|
+
self.incoming_sample_rate = incoming_sample_rate
|
|
42
|
+
self._open_output = open_output
|
|
43
|
+
self.stream: Any = None
|
|
44
|
+
self._stream_sr: Optional[int] = None
|
|
45
|
+
self._resample_fallback = False
|
|
46
|
+
|
|
47
|
+
self.save_audio_dir = save_audio_dir
|
|
48
|
+
self._audio_buf: List[np.ndarray] = []
|
|
49
|
+
self._audio_sr: Optional[int] = None
|
|
50
|
+
self._audio_idx = 0
|
|
51
|
+
if save_audio_dir:
|
|
52
|
+
os.makedirs(save_audio_dir, exist_ok=True)
|
|
53
|
+
|
|
54
|
+
def _resample(self, audio: np.ndarray) -> np.ndarray:
|
|
55
|
+
"""Resample float32 audio to the output device's rate."""
|
|
56
|
+
return np.asarray(
|
|
57
|
+
resample(
|
|
58
|
+
audio,
|
|
59
|
+
int(len(audio) * self.sample_rate / self.incoming_sample_rate),
|
|
60
|
+
),
|
|
61
|
+
dtype=np.float32,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def _ensure_stream_for(self, sample_rate: int) -> None:
|
|
65
|
+
"""Run the output device at the TTS rate when it can."""
|
|
66
|
+
if sample_rate == self._stream_sr:
|
|
67
|
+
return
|
|
68
|
+
if self._resample_fallback:
|
|
69
|
+
if self.stream is None:
|
|
70
|
+
self.stream = self._open_output(self.sample_rate)
|
|
71
|
+
self._stream_sr = self.sample_rate
|
|
72
|
+
return
|
|
73
|
+
try:
|
|
74
|
+
stream = self._open_output(sample_rate)
|
|
75
|
+
except Exception as e: # noqa: BLE001 - device capability probe
|
|
76
|
+
logger.warning(
|
|
77
|
+
"output device will not run at %sHz (%s); resampling to %sHz "
|
|
78
|
+
"instead (expect chunk clicks)",
|
|
79
|
+
sample_rate,
|
|
80
|
+
e,
|
|
81
|
+
self.sample_rate,
|
|
82
|
+
)
|
|
83
|
+
self._resample_fallback = True
|
|
84
|
+
self._ensure_stream_for(sample_rate)
|
|
85
|
+
return
|
|
86
|
+
self.close()
|
|
87
|
+
self.stream = stream
|
|
88
|
+
self._stream_sr = sample_rate
|
|
89
|
+
logger.info("audio output opened at %sHz", sample_rate)
|
|
90
|
+
|
|
91
|
+
def close(self) -> None:
|
|
92
|
+
if self.stream is not None:
|
|
93
|
+
try:
|
|
94
|
+
self.stream.stop()
|
|
95
|
+
self.stream.close()
|
|
96
|
+
except Exception: # noqa: BLE001
|
|
97
|
+
pass
|
|
98
|
+
self.stream = None
|
|
99
|
+
|
|
100
|
+
def _collect_audio(self, samples: np.ndarray, sample_rate: int, end: bool) -> None:
|
|
101
|
+
if samples.size:
|
|
102
|
+
self._audio_buf.append(samples)
|
|
103
|
+
self._audio_sr = sample_rate
|
|
104
|
+
if end:
|
|
105
|
+
self._flush_audio()
|
|
106
|
+
|
|
107
|
+
def _flush_audio(self) -> None:
|
|
108
|
+
if not self._audio_buf or self._audio_sr is None:
|
|
109
|
+
self._audio_buf = []
|
|
110
|
+
return
|
|
111
|
+
audio = np.concatenate(self._audio_buf)
|
|
112
|
+
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
113
|
+
path = os.path.join(
|
|
114
|
+
self.save_audio_dir, f"utt-{self._audio_idx:03d}-{stamp}.wav"
|
|
115
|
+
)
|
|
116
|
+
self._write_wav(path, audio, self._audio_sr)
|
|
117
|
+
logger.info(
|
|
118
|
+
"saved audio: %s (%d samples, ~%.2fs @ %dHz)",
|
|
119
|
+
path,
|
|
120
|
+
audio.size,
|
|
121
|
+
audio.size / self._audio_sr,
|
|
122
|
+
self._audio_sr,
|
|
123
|
+
)
|
|
124
|
+
self._audio_idx += 1
|
|
125
|
+
self._audio_buf = []
|
|
126
|
+
|
|
127
|
+
@staticmethod
|
|
128
|
+
def _write_wav(path: str, audio: np.ndarray, sample_rate: int) -> None:
|
|
129
|
+
# float32 [-1, 1] -> 16-bit PCM, clipped to avoid wraparound on overshoot.
|
|
130
|
+
pcm = np.clip(audio, -1.0, 1.0)
|
|
131
|
+
pcm = (pcm * 32767.0).astype("<i2")
|
|
132
|
+
with wave.open(path, "wb") as wav:
|
|
133
|
+
wav.setnchannels(1)
|
|
134
|
+
wav.setsampwidth(2)
|
|
135
|
+
wav.setframerate(sample_rate)
|
|
136
|
+
wav.writeframes(pcm.tobytes())
|
|
137
|
+
|
|
138
|
+
async def __call__(self, data: Audio) -> None:
|
|
139
|
+
logger.debug(
|
|
140
|
+
"audio: pts=%.3fs samples=%d @ %dHz end=%s",
|
|
141
|
+
data.pts,
|
|
142
|
+
data.data.size,
|
|
143
|
+
data.sample_rate,
|
|
144
|
+
bool(data.end),
|
|
145
|
+
)
|
|
146
|
+
samples = np.asarray(data.data, dtype=np.float32)
|
|
147
|
+
if samples.size:
|
|
148
|
+
self.incoming_sample_rate = data.sample_rate
|
|
149
|
+
try:
|
|
150
|
+
self._ensure_stream_for(data.sample_rate)
|
|
151
|
+
if self._stream_sr != data.sample_rate:
|
|
152
|
+
samples = self._resample(samples)
|
|
153
|
+
pcm = (np.clip(samples, -1.0, 1.0) * 32767.0).astype(np.int16)
|
|
154
|
+
await asyncio.to_thread(self.stream.write, pcm.reshape(-1, 1))
|
|
155
|
+
except Exception as e: # noqa: BLE001 - never kill the session on audio
|
|
156
|
+
logger.error("audio playback failed: %s: %s", type(e).__name__, e)
|
|
157
|
+
|
|
158
|
+
if self.save_audio_dir:
|
|
159
|
+
# The incoming chunk, not the resampled one: saved at its own rate.
|
|
160
|
+
self._collect_audio(data.data, data.sample_rate, bool(data.end))
|
huri_cli/main.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""huri-cli: talk to a HuRI server from a terminal.
|
|
2
|
+
|
|
3
|
+
huri-cli --config config/client_full.yaml [--log-level DEBUG] [--log-file cli.log]
|
|
4
|
+
|
|
5
|
+
The client config's `send:` and `subscribe:` pick among the topics below; its
|
|
6
|
+
`cli:` section holds the device settings:
|
|
7
|
+
|
|
8
|
+
cli: # every key is optional; these are the defaults
|
|
9
|
+
audio_in: {sample_rate: 16000, frame_duration: 0.030}
|
|
10
|
+
audio_out: {sample_rate: 48000, incoming_sample_rate: 16000, save_audio_dir: ""}
|
|
11
|
+
motion: {save_motion_dir: ""}
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import asyncio
|
|
16
|
+
import logging
|
|
17
|
+
from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional, Tuple, Type
|
|
18
|
+
|
|
19
|
+
from huri_core.core.client import (
|
|
20
|
+
HuRIClient,
|
|
21
|
+
HuRISessionError,
|
|
22
|
+
load_raw_client_config,
|
|
23
|
+
)
|
|
24
|
+
from huri_core.core.dataclasses.config import ClientConfig
|
|
25
|
+
from huri_core.core.events import EventData
|
|
26
|
+
from huri_events.audio import Audio
|
|
27
|
+
from huri_events.motion import Motion
|
|
28
|
+
from huri_events.question import RAGQuestion
|
|
29
|
+
from huri_events.text import Token
|
|
30
|
+
|
|
31
|
+
from .audio import AudioPlayer
|
|
32
|
+
from .senders import send_microphone, send_prompt
|
|
33
|
+
from .viewer import MotionViewer
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger("huri_cli")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def print_question(data: RAGQuestion) -> None:
|
|
39
|
+
print(
|
|
40
|
+
"<<", data.transcript.text, f"(emotion: {data.emotion})" if data.emotion else ""
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
async def print_token(data: Token) -> None:
|
|
45
|
+
"""The RAG's streamed reply, token by token; newline at end of utterance."""
|
|
46
|
+
if data.end:
|
|
47
|
+
print()
|
|
48
|
+
else:
|
|
49
|
+
print(data.text, end="", flush=True)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
Receiver = Callable[[Any], Awaitable[None]]
|
|
53
|
+
|
|
54
|
+
# topic -> (event class, factory building the handler from the `cli:` settings)
|
|
55
|
+
RECEIVERS: Dict[str, Tuple[Type[EventData], Callable[[Mapping], Receiver]]] = {
|
|
56
|
+
"huri.audio.out": (Audio, lambda cli: AudioPlayer(**cli.get("audio_out", {}))),
|
|
57
|
+
"huri.motion": (Motion, lambda cli: MotionViewer(**cli.get("motion", {}))),
|
|
58
|
+
"huri.question": (RAGQuestion, lambda cli: print_question),
|
|
59
|
+
"huri.token": (Token, lambda cli: print_token),
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
# topic -> coroutine sending it until it returns or is cancelled
|
|
63
|
+
SENDERS: Dict[str, Callable[[HuRIClient, str, Mapping], Awaitable[None]]] = {
|
|
64
|
+
"huri.audio.in": lambda client, topic, cli: send_microphone(
|
|
65
|
+
client, topic, **cli.get("audio_in", {})
|
|
66
|
+
),
|
|
67
|
+
"huri.question": lambda client, topic, cli: send_prompt(client, topic),
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def build_client(config: ClientConfig, cli: Mapping) -> HuRIClient:
|
|
72
|
+
unsupported = [t for t in config.send if t not in SENDERS]
|
|
73
|
+
unsupported += [t for t in config.subscribe if t not in RECEIVERS]
|
|
74
|
+
if unsupported:
|
|
75
|
+
raise ValueError(
|
|
76
|
+
f"huri-cli cannot handle topic(s) {unsupported}. It sends "
|
|
77
|
+
f"{sorted(SENDERS)} and receives {sorted(RECEIVERS)}"
|
|
78
|
+
)
|
|
79
|
+
client = HuRIClient.from_config(config)
|
|
80
|
+
for topic in config.subscribe:
|
|
81
|
+
event_cls, make = RECEIVERS[topic]
|
|
82
|
+
client.on(topic, event_cls, make(cli))
|
|
83
|
+
return client
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
async def run(config: ClientConfig, cli: Optional[Mapping] = None) -> int:
|
|
87
|
+
cli = cli or {}
|
|
88
|
+
client = build_client(config, cli)
|
|
89
|
+
try:
|
|
90
|
+
await client.connect()
|
|
91
|
+
except HuRISessionError as e:
|
|
92
|
+
logger.error("HuRI rejected this client config: %s", e.error)
|
|
93
|
+
if e.server_modules:
|
|
94
|
+
logger.error(" server registered : %s", ", ".join(e.server_modules))
|
|
95
|
+
logger.error(" this config wants : %s", ", ".join(config.modules))
|
|
96
|
+
return 1
|
|
97
|
+
except OSError as e:
|
|
98
|
+
logger.error(
|
|
99
|
+
"cannot reach HuRI at %s: %s. Is the server deployed? (with "
|
|
100
|
+
"huri-modules' installer: .huri-local/status.sh; replica logs in "
|
|
101
|
+
"/tmp/ray/session_latest/logs/serve/)",
|
|
102
|
+
config.huri_url,
|
|
103
|
+
e,
|
|
104
|
+
)
|
|
105
|
+
return 1
|
|
106
|
+
logger.info("session started, user_id=%s", client.user_id)
|
|
107
|
+
try:
|
|
108
|
+
loops: List[asyncio.Future] = [
|
|
109
|
+
asyncio.ensure_future(SENDERS[topic](client, topic, cli))
|
|
110
|
+
for topic in config.send
|
|
111
|
+
]
|
|
112
|
+
closed = asyncio.ensure_future(client.wait_closed())
|
|
113
|
+
# The session ends when a sender returns (`\exit` at the prompt) or
|
|
114
|
+
# the server closes the connection.
|
|
115
|
+
done, pending = await asyncio.wait(
|
|
116
|
+
loops + [closed], return_when=asyncio.FIRST_COMPLETED
|
|
117
|
+
)
|
|
118
|
+
for task in pending:
|
|
119
|
+
task.cancel()
|
|
120
|
+
for task in done:
|
|
121
|
+
if task is not closed and task.exception() is not None:
|
|
122
|
+
logger.error("sender failed: %r", task.exception())
|
|
123
|
+
return 1
|
|
124
|
+
if closed in done:
|
|
125
|
+
logger.info("the server closed the session")
|
|
126
|
+
return 0
|
|
127
|
+
finally:
|
|
128
|
+
await client.close()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def setup_logging(level: str, log_file: Optional[str]) -> None:
|
|
132
|
+
handlers: List[logging.Handler] = [logging.StreamHandler()]
|
|
133
|
+
if log_file:
|
|
134
|
+
handlers.append(logging.FileHandler(log_file))
|
|
135
|
+
logging.basicConfig(
|
|
136
|
+
level=level.upper(),
|
|
137
|
+
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
|
|
138
|
+
datefmt="%H:%M:%S",
|
|
139
|
+
handlers=handlers,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def main(argv: Optional[List[str]] = None) -> None:
|
|
144
|
+
parser = argparse.ArgumentParser(
|
|
145
|
+
prog="huri-cli", description=__doc__.split("\n\n")[0]
|
|
146
|
+
)
|
|
147
|
+
parser.add_argument("--config", required=True, help="client config file (YAML)")
|
|
148
|
+
parser.add_argument("--log-level", default="INFO", help="DEBUG, INFO, WARNING...")
|
|
149
|
+
parser.add_argument("--log-file", help="also write the logs to this file")
|
|
150
|
+
args = parser.parse_args(argv)
|
|
151
|
+
|
|
152
|
+
setup_logging(args.log_level, args.log_file)
|
|
153
|
+
raw = load_raw_client_config(args.config)
|
|
154
|
+
config = ClientConfig.from_dict(raw)
|
|
155
|
+
try:
|
|
156
|
+
code = asyncio.run(run(config, dict(raw.get("cli") or {})))
|
|
157
|
+
except KeyboardInterrupt:
|
|
158
|
+
code = 0
|
|
159
|
+
raise SystemExit(code)
|
huri_cli/senders.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
from huri_core.core.client import HuRIClient
|
|
6
|
+
from huri_core.core.events import BytesEvent
|
|
7
|
+
from huri_events.question import RAGQuestion
|
|
8
|
+
from huri_events.speech import Transcript
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger("huri_cli")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
async def send_microphone(
|
|
14
|
+
client: HuRIClient,
|
|
15
|
+
topic: str,
|
|
16
|
+
sample_rate: int = 16000,
|
|
17
|
+
frame_duration: float = 0.030,
|
|
18
|
+
) -> None:
|
|
19
|
+
"""Streams 16-bit mono microphone frames until cancelled."""
|
|
20
|
+
|
|
21
|
+
import sounddevice as sd
|
|
22
|
+
|
|
23
|
+
loop = asyncio.get_running_loop()
|
|
24
|
+
queue: asyncio.Queue[np.ndarray] = asyncio.Queue()
|
|
25
|
+
|
|
26
|
+
def callback(indata: np.ndarray, frames, time, status):
|
|
27
|
+
if status:
|
|
28
|
+
logger.warning("microphone: %s", status)
|
|
29
|
+
loop.call_soon_threadsafe(queue.put_nowait, indata.copy())
|
|
30
|
+
|
|
31
|
+
logger.info("microphone: %s", sd.query_devices(kind="input")["name"])
|
|
32
|
+
with sd.InputStream(
|
|
33
|
+
samplerate=sample_rate,
|
|
34
|
+
channels=1,
|
|
35
|
+
dtype="int16",
|
|
36
|
+
callback=callback,
|
|
37
|
+
blocksize=int(sample_rate * frame_duration),
|
|
38
|
+
):
|
|
39
|
+
while True:
|
|
40
|
+
chunk = await queue.get()
|
|
41
|
+
await client.send(topic, BytesEvent(data=chunk.tobytes()))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
async def send_prompt(client: HuRIClient, topic: str) -> None:
|
|
45
|
+
"""Sends each typed line as a question; returns on `\\exit` or EOF."""
|
|
46
|
+
|
|
47
|
+
from prompt_toolkit import PromptSession
|
|
48
|
+
from prompt_toolkit.patch_stdout import patch_stdout
|
|
49
|
+
|
|
50
|
+
print("'\\exit' or CTRL+D/C to exit.")
|
|
51
|
+
session: PromptSession = PromptSession()
|
|
52
|
+
try:
|
|
53
|
+
while True:
|
|
54
|
+
with patch_stdout():
|
|
55
|
+
text = await session.prompt_async(">> ")
|
|
56
|
+
if text == "\\exit":
|
|
57
|
+
return
|
|
58
|
+
if text.strip():
|
|
59
|
+
await client.send(topic, RAGQuestion(Transcript(text, True), None))
|
|
60
|
+
except (EOFError, KeyboardInterrupt):
|
|
61
|
+
pass
|
huri_cli/viewer.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""A terminal view of the gesture stream (`huri.motion`).
|
|
2
|
+
|
|
3
|
+
Each Motion chunk carries SMPL-X poses: 55 joints x 3 axis-angle values per
|
|
4
|
+
frame. The viewer groups the joints by body part and shows how much each part
|
|
5
|
+
moves (mean absolute change of its joint rotations between frames), so one can
|
|
6
|
+
see at a glance whether gestures follow the speech. Optionally every chunk is
|
|
7
|
+
saved as .npz for offline rendering with the EMAGE/SMPL-X tooling.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
from typing import Dict, List, Optional, Sequence
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
from huri_events.motion import Motion
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger("huri_cli.motion")
|
|
18
|
+
|
|
19
|
+
# SMPL-X joint indices (55-joint body + jaw/eyes + hands layout).
|
|
20
|
+
BODY_PARTS: Dict[str, Sequence[int]] = {
|
|
21
|
+
"head": (12, 15, 22, 23, 24),
|
|
22
|
+
"torso": (0, 3, 6, 9),
|
|
23
|
+
"l_arm": (13, 16, 18, 20),
|
|
24
|
+
"r_arm": (14, 17, 19, 21),
|
|
25
|
+
"hands": tuple(range(25, 55)),
|
|
26
|
+
"legs": (1, 2, 4, 5, 7, 8, 10, 11),
|
|
27
|
+
}
|
|
28
|
+
_BAR = " ▁▂▃▄▅▆▇█"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def part_activity(
|
|
32
|
+
poses: np.ndarray, previous: Optional[np.ndarray] = None
|
|
33
|
+
) -> Dict[str, float]:
|
|
34
|
+
"""Mean absolute rotation change per frame (radians) of each body part."""
|
|
35
|
+
|
|
36
|
+
frames = poses.reshape(len(poses), 55, 3)
|
|
37
|
+
if previous is not None:
|
|
38
|
+
frames = np.concatenate([previous.reshape(1, 55, 3), frames])
|
|
39
|
+
if len(frames) < 2:
|
|
40
|
+
return {part: 0.0 for part in BODY_PARTS}
|
|
41
|
+
delta = np.abs(np.diff(frames, axis=0)) # (t-1, 55, 3)
|
|
42
|
+
return {
|
|
43
|
+
part: float(delta[:, list(joints), :].mean())
|
|
44
|
+
for part, joints in BODY_PARTS.items()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def bar(value: float, full_scale: float = 0.05) -> str:
|
|
49
|
+
level = min(int(value / full_scale * (len(_BAR) - 1)), len(_BAR) - 1)
|
|
50
|
+
return _BAR[max(level, 0)]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class MotionViewer:
|
|
54
|
+
"""Logs one line per Motion chunk: timing plus a bar per body part."""
|
|
55
|
+
|
|
56
|
+
def __init__(self, save_motion_dir: str = "", full_scale: float = 0.05):
|
|
57
|
+
self.full_scale = full_scale
|
|
58
|
+
self.save_motion_dir = save_motion_dir
|
|
59
|
+
self._last_pose: Optional[np.ndarray] = None
|
|
60
|
+
self._index = 0
|
|
61
|
+
if save_motion_dir:
|
|
62
|
+
os.makedirs(save_motion_dir, exist_ok=True)
|
|
63
|
+
|
|
64
|
+
def render(self, motion: Motion) -> str:
|
|
65
|
+
activity = part_activity(motion.poses, self._last_pose)
|
|
66
|
+
parts: List[str] = [
|
|
67
|
+
f"{part} {bar(value, self.full_scale)}" for part, value in activity.items()
|
|
68
|
+
]
|
|
69
|
+
n = motion.poses.shape[0]
|
|
70
|
+
return (
|
|
71
|
+
f"motion pts={motion.pts:7.3f}s {n:3d}f @{motion.fps}fps "
|
|
72
|
+
f"({n / max(motion.fps, 1):.2f}s) | " + " ".join(parts)
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
async def __call__(self, motion: Motion) -> None:
|
|
76
|
+
if motion.poses.shape[0] == 0:
|
|
77
|
+
return
|
|
78
|
+
logger.info(self.render(motion))
|
|
79
|
+
self._last_pose = motion.poses[-1]
|
|
80
|
+
if self.save_motion_dir:
|
|
81
|
+
path = os.path.join(self.save_motion_dir, f"motion-{self._index:04d}.npz")
|
|
82
|
+
np.savez(
|
|
83
|
+
path,
|
|
84
|
+
poses=motion.poses,
|
|
85
|
+
expressions=motion.expressions,
|
|
86
|
+
trans=motion.trans,
|
|
87
|
+
fps=motion.fps,
|
|
88
|
+
pts=motion.pts,
|
|
89
|
+
)
|
|
90
|
+
self._index += 1
|