Files
L'électron rare 6fe98c6b00
CI build oscope-of / build-check (push) Has been cancelled
feat(viz): continuous frame quality intensity
Add gesture_quality() pure helper (hand_display.py) that maps a slot's
plausibility, facing, and proximity to a score in [0,1]. Renderer uses
it to modulate panel frame brightness (0.25+0.75*q) and stroke count
(1..3 passes at q≥0, ≥0.5, ≥0.85 / status==3). Status still drives
hue (pid 7/8/9). New gesture_slot_quality field in State written
alongside gesture_slot_status each tick.

Add gauge_segments() for X/Y position gauges around each panel: a
horizontal rail below and a vertical rail on the outer side, each with
a bold two-tick notch at the hand's normalised cx/cy (mirror-aware for
X). Dim rails (conf 0.25) when slot absent. Renderer reads hand_feats
L/R directly from state.

11 new tests (6 gesture_quality + 5 gauge_segments); suite 430 passed.
2026-07-02 16:34:08 +02:00

564 lines
25 KiB
Python

"""Action-head publisher : reads state.persons_smplx / persons_body3d,
runs ActionHead per pid, emits /pose/action and /pose/kin via pose_bridge.
Stand-alone thread to avoid touching multi_hmr_worker.py while it
iterates. Polls state at ~30 Hz, deduplicates by smplx_last_t.
"""
from __future__ import annotations
import logging
import threading
import time
from pathlib import Path
from typing import Any
import numpy as np
from data_only_viz.action_head import (
ActionHead,
EXPR_DIM,
HANDS_KP_DIMS,
HANDS_KP_PER_HAND,
HANDS_KP_TOTAL,
J3D_FINGERS,
J3D_FINGERS_PER_HAND,
LABELS,
)
from data_only_viz.hand_display import gesture_quality, hand_plausible, HandPersistenceGate
from data_only_viz.hand_slots import route_hands
LOG = logging.getLogger("action_head_pub")
DEFAULT_CKPT = (
Path.home() / ".cache" / "av-live-action" / "checkpoints" / "action_head.pt"
)
# Canonical SMPL-X fingertip vertex IDs from smplx.vertex_ids.SMPLX_VERTEX_IDS.
# Order : L thumb, L index, L middle, L ring, L pinky,
# R thumb, R index, R middle, R ring, R pinky.
SMPLX_FINGERTIP_VERTS: tuple[int, ...] = (
5361, 4933, 5058, 5169, 5286, # L : lthumb, lindex, lmiddle, lring, lpinky
8079, 7669, 7794, 7905, 8022, # R : rthumb, rindex, rmiddle, rring, rpinky
)
# 32 vertex indices on the 10475-vertex SMPL-X mesh:
# 22 body (UNCHANGED from v1) + 10 fingertips.
# NOTE: approximate vertex anchors -- real SMPL-X joints come from
# J_regressor @ v3d, but loading the regressor here is avoided for
# live OSC performance. Action-head training must use the same anchors.
SMPLX_JOINT_ANCHOR_VERTS: tuple[int, ...] = (
# 22 body (UNCHANGED indices, same vertex IDs as before)
8204, 3992, 6677, 3500, 3469, 6394, 3279, 3327, 6736, 3074,
8846, 8889, 8848, 1300, 4660, 8964, 3013, 6470, 1602, 5083,
2114, 5559,
# 10 fingertips
*SMPLX_FINGERTIP_VERTS,
)
assert len(SMPLX_JOINT_ANCHOR_VERTS) == 32
# Mouth-open: distance between two lip vertices on SMPL-X mesh.
# vert 8970 (upper outer lip), 8855 (lower outer lip) -- approximate.
SMPLX_UPPER_LIP_VERT: int = 8970
SMPLX_LOWER_LIP_VERT: int = 8855
# MediaPipe FaceMesh inner-mouth landmark indices.
# 13 = upper inner mid, 14 = lower inner mid.
MEDIAPIPE_LIP_UPPER_INNER: int = 13
MEDIAPIPE_LIP_LOWER_INNER: int = 14
# MediaPipe HAND fingertip indices (21-kp hand model).
MEDIAPIPE_HAND_FINGERTIPS: tuple[int, ...] = (4, 8, 12, 16, 20)
# MediaPipe 33-landmark indices mapped into the 22-joint slot order.
# NOTE: approximate mapping -- spine joints reuse hip/shoulder anchors.
# https://developers.google.com/mediapipe/solutions/vision/pose_landmarker
MEDIAPIPE_TO_22: tuple[int, ...] = (
24, 23, 24, 23, 25, 26, 11, 27, 28, 11,
31, 32, 0, 11, 12, 0, 11, 12, 13, 14, 15, 16,
)
class ActionHeadPublisher(threading.Thread):
"""Thread that polls state, runs ActionHead per pid, emits OSC."""
def __init__(self, state: Any, bridge: Any,
ckpt_path: Path | None = DEFAULT_CKPT,
period_s: float = 1.0 / 30.0) -> None:
super().__init__(daemon=True, name="action-head-pub")
self.state = state
self.bridge = bridge
self.period = period_s
try:
ckpt = ckpt_path if (ckpt_path and ckpt_path.exists()) else None
self.head = ActionHead(ckpt_path=ckpt, device="cpu")
LOG.info("action_head loaded ckpt=%s",
ckpt if ckpt else "<random init>")
except Exception as e:
LOG.warning("action_head init failed: %s", e)
self.head = None
self._stop = threading.Event()
self._last_smplx_t = 0.0
self._last_body_t = 0.0
self._last_pids: set[int] = set()
from data_only_viz.hand_features import HandFeatureExtractor
self._hand_ext = HandFeatureExtractor()
self._init_finger_piano()
def stop(self) -> None:
self._stop.set()
def run(self) -> None:
if self.head is None:
LOG.warning("publisher exiting: no action_head")
return
LOG.info("publisher started")
while not self._stop.is_set():
t0 = time.perf_counter()
try:
self._tick(t0)
except Exception:
LOG.exception("publisher tick failed")
dt = time.perf_counter() - t0
if dt < self.period:
time.sleep(self.period - dt)
LOG.info("publisher stopped")
def _tick(self, t_now: float) -> None:
persons32, source_t, source_tag, is_new = self._read_sources()
if not is_new:
return
if "smplx" in source_tag:
self._last_smplx_t = source_t
else:
self._last_body_t = source_t
current_pids: set[int] = set()
if persons32:
for pid, j3d, expr_np, mouth, hands_kp42 in persons32:
current_pids.add(pid)
label, probs, kin = self.head.step(pid, j3d, expr=expr_np,
mouth_open=mouth,
hands_kp=hands_kp42)
idx = LABELS.index(label)
self.bridge.send_action(pid, idx, probs, t_now, force=True)
self.bridge.send_kin(pid, kin, t_now, force=True)
self.bridge.send_mouth(pid, mouth)
if pid not in self._last_pids:
self.bridge.send_enter(pid=pid)
for gone in self._last_pids - current_pids:
self.head.forget(gone)
self.bridge.send_leave(pid=gone)
self._last_pids = current_pids
if getattr(self, "_stab", None) is not None:
self._stab_slotted = self._step_stab()
# Reset finger-strike state for slots that just transitioned from
# "held replay" back to a real hand, preventing phantom strikes from
# the compressed motion delta accumulated during the hold window.
for i, resumed in enumerate(self._stab.resumed_flags()):
if resumed:
self._finger_det.reset_slot(i)
else:
self._stab_slotted = None
self._emit_hands(t_now)
self._emit_fingers(t_now)
self._emit_pinch(t_now)
self._update_gesture_slot_status()
def _emit_hands(self, t_now: float) -> None:
"""Lock state, extract hand features, emit /pose/hands once per tick."""
if getattr(self, "_tick_hands", None) is not None:
# Fast path: validated by _step_stab this tick (audit R3/R4: single lock).
hands = self._tick_hands
chirality = self._tick_chir
mirror = self._tick_mirror
else:
# Fallback for standalone calls (unit tests bypassing _tick).
with self.state.lock():
hands = list(getattr(self.state, "persons_hands", None) or [])
chirality = list(getattr(self.state, "persons_hands_chirality", None) or [])
mirror = bool(getattr(self.state, "mirror_2d", False))
if not hands and getattr(self.state, "hands_present", False):
lkp = getattr(self.state, "left_hand_kp", None)
rkp = getattr(self.state, "right_hand_kp", None)
hands = [h for h in (lkp, rkp) if h is not None and len(h) > 0]
chirality = []
# Inline plausibility gate only (no persistence gate in this path).
conf_min = getattr(self, "_gesture_conf_min", 0.0)
def _plausible_fb(h):
try:
return hand_plausible(h, conf_min=conf_min)
except (AttributeError, TypeError):
return True
plausible_mask = [_plausible_fb(h) for h in hands]
hands = [h for h, ok in zip(hands, plausible_mask) if ok]
chirality = [c for c, ok in zip(chirality, plausible_mask) if ok]
stab = getattr(self, "_stab", None)
fist_enabled = stab.active_flags() if stab is not None else (True, True)
feats = self._hand_ext.step(hands, chirality or None,
swap=getattr(self, "_hand_swap_lr", False),
fist_enabled=fist_enabled,
mirror=mirror)
with self.state.lock():
self.state.hand_feats = feats
self.bridge.send_hands(feats, t_now)
def _init_finger_piano(self) -> None:
"""Read FINGER_* env config and build the strike + pinch detectors."""
from data_only_viz.config import VizConfig
from data_only_viz.finger_strike import (
FingerStrikeDetector, PinchDetector,
)
cfg = VizConfig.from_env()
self._finger_enabled = cfg.finger_piano
# Pinch detection is gated independently of the air-piano finger-strike:
# the matrix global actions want pinches WITHOUT the finger-strike "piano".
self._pinch_enabled = cfg.pinch_enable
self._finger_det = FingerStrikeDetector(
vel_thresh=cfg.finger_strike_vel,
refractory_ms=cfg.finger_strike_refractory_ms,
)
self._pinch_det = PinchDetector(
ratio_on=cfg.pinch_ratio_on,
ratio_off=cfg.pinch_ratio_off,
refractory_ms=cfg.pinch_refractory_ms,
margin=cfg.pinch_margin,
ext_ratio=cfg.pinch_ext_ratio,
ext_min=cfg.pinch_ext_min,
debounce_frames=cfg.pinch_debounce_frames,
)
self._finger_dbg = cfg.finger_debug
# auto = iPhone Vision hands if fresh, else MediaPipe. Or force
# "iphone" / "mediapipe".
self._finger_source = cfg.finger_source
self._fdbg_n = 0
# Safety knob: invert Vision chirality interpretation.
# Chirality validated correct live 2026-07-02; keep False unless a
# future iPhone app build flips it.
self._hand_swap_lr = cfg.hand_swap_lr
self._hand_near_min = cfg.hand_near_min
self._hand_near_off = cfg.hand_near_off
self._hand_hold_frames = cfg.hand_hold_frames
from data_only_viz.hand_slots import GestureSlotStabilizer
self._stab = GestureSlotStabilizer(
hold_frames=cfg.hand_hold_frames,
near_on=cfg.hand_near_min,
near_off=cfg.hand_near_off,
face_min=cfg.hand_face_min,
)
self._gesture_conf_min = cfg.hand_conf_min
# Gesture-path gate stepped at ~30 Hz (publisher tick) — correctly
# cadenced, unlike the renderer's 60 fps instance (audit R6).
self._gesture_gate = HandPersistenceGate(
min_frames=cfg.hand_persist_frames, grace=cfg.hand_persist_grace
)
self._tick_hands: list | None = None
self._tick_chir: list = []
self._tick_mirror: bool = False
self._tick_raw_slotted: list = [None, None]
def _pick_hands(self) -> tuple[list, list, str, bool, bool]:
"""Return (hands, chirality, source_label, ip_fresh, mirror).
mirror is read from state.mirror_2d under the lock so it is consistent
with the chirality dropout decision made in the same tick. Callers
pass it through to route_hands so the cx fallback slot assignment stays
coherent with the renderer's panel layout when chirality is absent.
"""
with self.state.lock():
mp_hands = list(getattr(self.state, "persons_hands", None) or [])
ip_hands = list(getattr(self.state, "persons_hands_iphone", None) or [])
ip_t = getattr(self.state, "persons_hands_iphone_t", 0.0)
ip_chir = list(getattr(self.state, "persons_hands_chirality", None) or [])
mirror = bool(getattr(self.state, "mirror_2d", False))
ip_fresh = bool(ip_hands) and (time.perf_counter() - ip_t) < 0.5
src = getattr(self, "_finger_source", "auto")
if src == "iphone":
return ip_hands, ip_chir, "iphone", ip_fresh, mirror
if src == "mediapipe":
return mp_hands, [], "mediapipe", ip_fresh, mirror
if ip_fresh:
return ip_hands, ip_chir, "iphone", ip_fresh, mirror
return mp_hands, [], "mediapipe", ip_fresh, mirror
def _step_stab(self) -> list:
"""Pick hands, apply quality gates, route, step gesture stabilizer.
Called once per tick from _tick() to share the result across all
three gesture emitters. Also callable directly from emitters when
_stab_slotted is not yet set (e.g. in unit tests that call emitters
without going through _tick).
"""
hands, chir, _, _, mirror = self._pick_hands()
# 1. Plausibility gate: reject out-of-frame / undersized / low-conf ghosts.
# Raw numpy-array hands (no .x/.y/.c protocol) are passed through unchanged
# so _build_hands_map / action-head model paths keep working.
def _plausible(h):
try:
return hand_plausible(h, conf_min=self._gesture_conf_min)
except (AttributeError, TypeError):
return True
plausible_mask = [_plausible(h) for h in hands]
plausible_hands = [h for h, ok in zip(hands, plausible_mask) if ok]
plausible_chir = [c for c, ok in zip(chir, plausible_mask) if ok]
# 2. Establishment gate: anti-ghost temporal filter; same chirality mask
# keeps chir[i] aligned with plausible_hands[i] through both filters.
established_mask = self._gesture_gate.step(plausible_hands)
self._tick_hands = [h for h, ok in zip(plausible_hands, established_mask) if ok]
self._tick_chir = [c for c, ok in zip(plausible_chir, established_mask) if ok]
self._tick_mirror = mirror
raw = route_hands(
self._tick_hands, self._tick_chir or None,
swap=getattr(self, "_hand_swap_lr", False),
near_min=0.0,
mirror=mirror,
)
self._tick_raw_slotted = list(raw) # save pre-stabilizer routing for status
return self._stab.step(raw)
def _emit_fingers(self, t_now: float) -> None:
"""Detect finger strikes (air-piano) and emit /pose/finger."""
if not getattr(self, "_finger_enabled", False):
return
hands, chirality, used, ip_fresh, _ = self._pick_hands()
slotted = getattr(self, "_stab_slotted", None)
if slotted is None:
slotted = self._step_stab()
events = self._finger_det.step(slotted, t_now)
if getattr(self, "_finger_dbg", False):
self._fdbg_n += 1
if events or self._fdbg_n % 30 == 0:
LOG.info("fingers dbg: src=%s hands=%d strikes=%d (ip_fresh=%s)",
used, len(hands), len(events), ip_fresh)
for ev in events:
self.bridge.send_finger(ev)
def _emit_pinch(self, t_now: float) -> None:
"""Detect thumb-to-finger pinches (matrix global actions) and emit
/pose/pinch. Gated by PINCH_ENABLE, independently of the air-piano."""
if not getattr(self, "_pinch_enabled", False):
return
hands, chirality, used, _ip, _ = self._pick_hands()
slotted = getattr(self, "_stab_slotted", None)
if slotted is None:
slotted = self._step_stab()
for ev in self._pinch_det.step(slotted, t_now):
if getattr(self, "_finger_dbg", False):
LOG.info("pinch dbg: src=%s hand=%d finger=%d",
used, ev.hand, ev.finger)
self.bridge.send_pinch(ev)
def _update_gesture_slot_status(self) -> None:
"""Compute per-slot gesture status and quality, write both to state.
Status values: 0=absent, 1=detected(plausible+established, not armed),
2=armed(near+facing), 3=pinch engaged.
Quality ∈ [0, 1]: continuous intensity for frame brightness + thickness.
Called once per tick after all emitters have run.
"""
stab = getattr(self, "_stab", None)
if stab is None:
return
pinch_det = getattr(self, "_pinch_det", None)
raw = getattr(self, "_tick_raw_slotted", [None, None])
active = stab.active_flags()
engaged = pinch_det.engaged_slots() if pinch_det is not None else (False, False)
status = [0, 0]
quality = [0.0, 0.0]
face_min = stab._face_min
near_on = stab._near_on
for slot in range(2):
if engaged[slot]:
status[slot] = 3
elif active[slot]:
status[slot] = 2
elif raw[slot] is not None:
status[slot] = 1
quality[slot] = gesture_quality(
raw[slot],
face_min=face_min,
near_on=near_on,
engaged=bool(engaged[slot]),
)
with self.state.lock():
self.state.gesture_slot_status = status
self.state.gesture_slot_quality = quality
def _read_sources(self) -> tuple[
list[tuple[int, np.ndarray, np.ndarray, float, np.ndarray]] | None,
float, str, bool,
]:
"""Return (persons32, source_t, source_tag, is_new).
Each person entry is (pid, j3d32, expr10, mouth_open, hands_kp42x3).
is_new is True when the timestamp advanced (even if person list
is empty), so _tick can still run the purge loop.
"""
with self.state.lock():
persons_smplx = getattr(self.state, "persons_smplx", None)
t_smplx = getattr(self.state, "smplx_last_t", 0.0)
persons_b3d = getattr(self.state, "persons_body3d", None)
ids_b3d = getattr(self.state, "persons_body_ids", None)
persons_face = getattr(self.state, "persons_face", None)
ids_face = getattr(self.state, "persons_face_ids", None)
persons_hands = getattr(self.state, "persons_hands", None)
ids_hands = getattr(self.state, "persons_hands_ids", None)
t_body = getattr(self.state, "pose_last_t", 0.0)
# Build pid -> hands_kp(42, 3) map from MediaPipe persons_hands.
hands_by_pid: dict[int, np.ndarray] = self._build_hands_map(
persons_hands or [], ids_hands or [],
)
# Build pid -> mouth_open scalar from MediaPipe persons_face lips.
face_mouth_by_pid: dict[int, float] = self._build_face_mouth_map(
persons_face or [], ids_face or [],
)
# SMPL-X path (preferred)
if t_smplx > self._last_smplx_t:
out: list[tuple[int, np.ndarray, np.ndarray, float, np.ndarray]] = []
for i, p in enumerate(persons_smplx or []):
# Support both SMPLXPerson dataclass (multi_hmr_worker, field
# names: pid / vertices_3d / expression) and legacy dict format
# (keys: "pid" / "v3d" / "expression").
_is_dict = isinstance(p, dict)
pid = int(p.get("pid", i) if _is_dict else p.pid)
v3d = p.get("v3d") if _is_dict else p.vertices_3d
if v3d is None:
continue
# CoreMLArray wraps a numpy array but has no __array__
# protocol; unwrap via .numpy() before np.asarray.
if hasattr(v3d, "numpy") and not isinstance(v3d, np.ndarray):
v3d = v3d.numpy()
v3d_np = np.asarray(v3d, dtype=np.float32)
if v3d_np.shape[0] < max(SMPLX_JOINT_ANCHOR_VERTS) + 1:
continue
j3d32 = v3d_np[list(SMPLX_JOINT_ANCHOR_VERTS)].astype(np.float32)
# expression — field name matches in both dict and dataclass
expr = p.get("expression") if _is_dict else p.expression
if expr is not None:
if hasattr(expr, "numpy") and not isinstance(expr, np.ndarray):
expr = expr.numpy()
expr_np = np.asarray(expr, dtype=np.float32).flatten()
else:
expr_np = np.zeros(EXPR_DIM, dtype=np.float32)
# mouth_open: prefer MediaPipe face lips, fallback SMPL-X v3d.
if pid in face_mouth_by_pid:
mouth = face_mouth_by_pid[pid]
elif v3d_np.shape[0] > max(SMPLX_UPPER_LIP_VERT, SMPLX_LOWER_LIP_VERT):
mouth = float(np.linalg.norm(
v3d_np[SMPLX_UPPER_LIP_VERT] - v3d_np[SMPLX_LOWER_LIP_VERT]
))
else:
mouth = 0.0
hands_kp42 = hands_by_pid.get(
pid, np.zeros((HANDS_KP_TOTAL, HANDS_KP_DIMS), dtype=np.float32)
)
out.append((pid, j3d32, expr_np, mouth, hands_kp42))
return out or None, t_smplx, "smplx", True
# MediaPipe body3d fallback
if t_body > self._last_body_t:
ids = ids_b3d or list(range(len(persons_b3d or [])))
out = []
for i, body in enumerate(persons_b3d or []):
pid = int(ids[i]) if i < len(ids) else i
arr = self._kp_list_to_array(body)
if arr is None or arr.shape[0] < 33:
continue
body22 = arr[list(MEDIAPIPE_TO_22)].astype(np.float32)
# fingertips from persons_hands if available
tips = np.zeros((J3D_FINGERS, 3), dtype=np.float32)
hands_kp42 = hands_by_pid.get(
pid, np.zeros((HANDS_KP_TOTAL, HANDS_KP_DIMS), dtype=np.float32)
)
# extract fingertips from hands_kp42 (idx 4,8,12,16,20 each side)
for side_idx in (0, 1):
base = side_idx * HANDS_KP_PER_HAND
for k, mp_tip in enumerate(MEDIAPIPE_HAND_FINGERTIPS):
if base + mp_tip < hands_kp42.shape[0]:
tips[side_idx * J3D_FINGERS_PER_HAND + k] = \
hands_kp42[base + mp_tip]
j3d32 = np.concatenate([body22, tips], axis=0)
mouth = face_mouth_by_pid.get(pid, 0.0)
expr_np = np.zeros(EXPR_DIM, dtype=np.float32)
out.append((pid, j3d32, expr_np, mouth, hands_kp42))
return out or None, t_body, "body3d", True
return None, 0.0, "", False
def _build_hands_map(self, persons_hands: list,
ids_hands: list) -> dict[int, np.ndarray]:
"""Combine left+right hand kp arrays per pid into a single (42, 3) array.
persons_hands is a flat list ; ids_hands maps each hand-list entry to a
pid (and odd/even index indicates which side). When the user's pipeline
keeps a different convention, this helper makes the best effort and
pads zeros for missing sides.
"""
out: dict[int, np.ndarray] = {}
for hi, hkp in enumerate(persons_hands):
if hkp is None:
continue
pid_raw = ids_hands[hi] if hi < len(ids_hands) else hi
try:
pid = int(pid_raw)
except (TypeError, ValueError):
pid = hi
side = hi % 2 # 0 = L, 1 = R
arr = self._kp_list_to_array(hkp)
if arr is None or arr.shape[0] < HANDS_KP_PER_HAND:
continue
slot = out.setdefault(
pid, np.zeros((HANDS_KP_TOTAL, HANDS_KP_DIMS), dtype=np.float32)
)
base = side * HANDS_KP_PER_HAND
slot[base:base + HANDS_KP_PER_HAND] = arr[:HANDS_KP_PER_HAND]
return out
def _build_face_mouth_map(self, persons_face: list,
ids_face: list) -> dict[int, float]:
"""Compute mouth_open = norm(upper_inner_lip - lower_inner_lip) per pid."""
out: dict[int, float] = {}
for fi, fkp in enumerate(persons_face):
if fkp is None:
continue
arr = self._kp_list_to_array(fkp)
if arr is None or arr.shape[0] <= MEDIAPIPE_LIP_LOWER_INNER:
continue
upper = arr[MEDIAPIPE_LIP_UPPER_INNER]
lower = arr[MEDIAPIPE_LIP_LOWER_INNER]
mouth = float(np.linalg.norm(upper - lower))
try:
pid = int(ids_face[fi]) if fi < len(ids_face) else fi
except (TypeError, ValueError):
pid = fi
out[pid] = mouth
return out
@staticmethod
def _kp_list_to_array(body: Any) -> np.ndarray | None:
"""Best-effort conversion of a body keypoint list to (N, 3) array.
Handles attribute objects (the Kp3D dataclass exposes .x/.y/.z) and
indexable rows. Kp3D is NOT subscriptable, so the index fallback must
not be evaluated eagerly (the old `getattr(kp, "x", kp[0])` raised
TypeError on Kp3D and silently returned None — breaking the body3d /
MediaPipe-only path that feeds action-head when no SMPL-X is present)."""
if body is None:
return None
if isinstance(body, np.ndarray):
return body
rows = []
for kp in body:
if hasattr(kp, "x"):
rows.append((kp.x, kp.y, getattr(kp, "z", 0.0)))
else:
try:
rows.append((kp[0], kp[1], kp[2] if len(kp) > 2 else 0.0))
except (TypeError, IndexError):
return None
if not rows:
return None
return np.asarray(rows, dtype=np.float32)