2026 华秋杯 AI 开源硬件创新设计大赛:昉·星光2 × Hailo-8L 头部追踪实测提交贴

原创2026-09-30 09:00:01浏览4
4
Star

一、前几章内容

阶段

做了什么

关键实测

平台打通

给昉·星光2(JH7110,riscv64)挂上 Hailo-8L(13 TOPS INT8),用 HailoRT 4.19.0 + TAPPAS 3.30.0 的 GStreamer 元素拼流水线

yolov8m 纯 NPU 6.53 FPS / 硬件延迟 162.4 ms;极轻量的 SSD MobileNetV1 达 367 FPS

瓶颈定位

定量分析"慢在哪里"

PCIe Gen2 x1 带宽只用了 1.8%;真正的约束是主机侧前处理的 CPU(61.6%)——该 CPU 指令集没有 v 向量扩展

人脸识别

板端推理 → 本机对齐 → 板端比对的两段式流水线

SCRFD 2.5g 21.50 FPS / 46.5 ms;10 张盲测命中 2 张(0.980 / 0.662),不命中的最高 0.344

这几篇已发布,细节不重复。

二、头像追踪

人脸识别解决"这是谁",下一个问题是"他在看哪儿"。我做了一次统一协议采集:一次录完、四个方案一起评,免得每个方案单独采一遍。

图 :四个方案的定案,以及它直接决定了最终原型里的哪些参数。

三、过程

实时原型的链路是:板端逐帧导出特征 → PC 桥接 → 浏览器里一颗球跟着头动。链路不难,难的是控制律。

图 :同一段 366 秒录制重放。旧的红线根本不单调——头几乎不动时球已在右边,头转到满幅时球只走到约 1/3 屏。

正解是把幅度和平滑拆成两个独立的量:幅度交给标定(算出目标位置),平滑交给限速。两条控制律因此共用同一个目标位置,只在"怎么走过去"上不同。

四、最终成品:切水果游戏

到这一步追踪已经"能用了",但"能用"很含糊——曲线好看不代表人用着舒服。

选切水果,是因为它把追踪质量直接变成可感知的结果:切得中就是切得中,没有中间地带。而且它一次考核了所有环节:延迟大 → 追不上;抖动大 → 空挥;幅度不够 → 够不到屏幕边;检出掉 → 贴边的水果切不到。

五:效果

六、结论

  1. 这套组合是可用的。 RISC-V + Hailo-8L 在 1280×720 下稳定输出约 25 FPS 的逐帧检测,足够驱动实时的头部追踪。

  2. 眼球控制这条路,用现成的人脸关键点模型走不通。 横向眼动信号只有头动的 1/38,不是算法问题,是 5 个关键点在物理上量不到。

  3. 头姿控制的关键是"把幅度和平滑拆开"。 凡是参考量取自被控对象自身的控制律,幅度必然天生封顶;要幅度对得上,必须有明确的目标位置。

七、主要代码

检测:


set -u

TAPPAS=/usr/share/tappas
POSTDIR=$TAPPAS/apps/h8/gstreamer/libs/post_processes
RESDIR=$TAPPAS/apps/h8/gstreamer/vf2/cascading_networks/resources

FUNC_OVERRIDE=""
SO_OVERRIDE=""

# --------- 模型组合 ---------
USE_LIGHTFACE=0
# ★ 重要:TAPPAS 各后处理库都导出一个通用入口 `filter`(C 符号)。
#   所以 function-name 一律写 filter。
scrfd_cfg() {
    HEF=${SCRFD_HEF:-/home/user/scrfd_2.5g.hef}
    # 系统自带的 libscrfd_post.so 硬编码层名 conv46/47/48|conv52/53/54|conv55/56/57,
    # 与 Model Zoo 版 HEF(conv42/43/44|conv49/50/51|conv55/56/57)不符 → 首帧 SIGSEGV。
    # 因此默认用打过层名补丁的副本(见 diag/patch_scrfd_so.py),系统原文件未改动。
    if [ -f /home/user/libscrfd_post_fixed.so ]; then
        SO=${SO_OVERRIDE:-/home/user/libscrfd_post_fixed.so}
    else
        SO=${SO_OVERRIDE:-$POSTDIR/libscrfd_post.so}
    fi
    FUNC=${FUNC_OVERRIDE:-scrfd_2_5g}
    CFG=${SCRFD_CFG:-/home/user/scrfd.json}
    MODEL_NAME="scrfd_2.5g"
}
lightface_cfg() {
    HEF=$RESDIR/lightface_slim.hef
    SO=${SO_OVERRIDE:-$POSTDIR/libface_detection_post.so}
    FUNC=${FUNC_OVERRIDE:-filter}
    CFG=${LIGHTFACE_CFG:-/home/user/lightface.json}
    MODEL_NAME="lightface_slim (已废)"
}

BATCH=1

MODE=video
VIDEO=$RESDIR/face_detection.mp4
IMAGE=""
IMG_SEC=2
DEV=/dev/video4
FRAMES=300
SNAP=0
SHOW=0
PRINT_ONLY=0
EXPORT=0
#   显式翻转
#   摄像头源默认 180(本板物理倒装)。采集类文件源必须显式 --flip 180,
#   否则送进 SCRFD 的是倒置人脸 → 检出率 0% 且不报任何错(本板踩过两次)。
FLIP=""
SNAPDIR=/tmp/faceframes
JSONOUT=/tmp/face.json
LOG=/tmp/face_run.log

Q="queue leaky=no max-size-buffers=30 max-size-bytes=0 max-size-time=0"

usage() {
    sed -n '/^#  用法:/,/^# =====/p' "$0" | sed 's/^# \{0,1\}//'
    exit 0
}

while [ $# -gt 0 ]; do
    case "$1" in
        --cam)        MODE=cam ;;
        --image)      MODE=image; shift; IMAGE="$1" ;;
        --video)      MODE=video; shift; VIDEO="$1" ;;
        --dev)        shift; DEV="$1" ;;
        --frames)     shift; FRAMES="$1" ;;
        --snap)       shift; SNAP="$1" ;;
        --export)     EXPORT=1 ;;
        --hef)        shift; SCRFD_HEF="$1" ;;
        --cfg)        shift; SCRFD_CFG="$1" ;;
        --func)       shift; FUNC_OVERRIDE="$1" ;;
        --so)         shift; SO_OVERRIDE="$1" ;;
        --show)       SHOW=1 ;;
        --print)      PRINT_ONLY=1 ;;
        --flip)       shift; FLIP="$1" ;;
        --lightface)  USE_LIGHTFACE=1 ;;
        -h|--help)    usage ;;
        *) echo "未知参数:$1(用 --help 看用法)"; exit 1 ;;
    esac
    shift
done

if [ "$USE_LIGHTFACE" = "1" ]; then lightface_cfg; else scrfd_cfg; fi

# ---------- 翻转参数校验----------

case "${FLIP:-}" in
    ""|identity|90r|180|90l|horiz|vert|ul-lr|ur-ll|auto) ;;
    *) echo "[错误] --flip 取值非法:$FLIP"
       echo "        可用:identity|90r|180|90l|horiz|vert|ul-lr|ur-ll|auto"
       exit 1 ;;
esac

# ---------- 前置检查 ----------
[ -f "$HEF" ] || { echo "[错误] 找不到模型 $HEF"; exit 1; }
[ -f "$SO" ]  || { echo "[错误] 找不到后处理库 $SO"; exit 1; }
[ -f "$CFG" ] || { echo "[错误] 找不到 config $CFG"; exit 1; }

if [ "$MODE" = "image" ]; then
    # 单张照片:hailonet 需要连续帧流(单帧会卡在 PREROLLING),
    # 所以先用板端 ffmpeg 把它循环成一小段视频再走 --video 逻辑。
    [ -f "$IMAGE" ] || { echo "[错误] 找不到图片 $IMAGE"; exit 1; }
    mkdir -p /tmp/eimg
    VIDEO=/tmp/eimg/one.mp4
    # H.264 要求宽高均为偶数,而人像照片常带奇数边(如 443×452)→ 先裁到偶数
    if ! ffmpeg -y -loglevel error -loop 1 -i "$IMAGE" -t "$IMG_SEC" -r 30 \
            -vf "crop=trunc(iw/2)*2:trunc(ih/2)*2" \
            -c:v libx264 -preset ultrafast -pix_fmt yuv420p "$VIDEO"; then
        echo "[错误] 图片转视频失败(建议 JPG/PNG,HEIC 需先转换)"
        exit 1
    fi
    echo "  [图片] $(basename "$IMAGE") -> ${IMG_SEC}s@30fps 视频($(du -h "$VIDEO" | cut -f1))"
    MODE=video
    [ "$SNAP" -eq 0 ] && SNAP=1          # 单图默认至少留 1 张带框结果
    SRC="filesrc location=$VIDEO name=src_0 ! decodebin"
    SRC_DESC="$(basename "$IMAGE")(单张图片)"
elif [ "$MODE" = "cam" ]; then
    [ -e "$DEV" ] || { echo "[错误] 摄像头节点不存在:$DEV"; exit 1; }
    SRC="v4l2src device=$DEV name=src_0 ! videoflip video-direction=${FLIP:-180}"
    SRC_DESC="$DEV(USB 摄像头,翻转 ${FLIP:-180})"
else
    [ -f "$VIDEO" ] || { echo "[错误] 找不到视频 $VIDEO"; exit 1; }
    SRC="filesrc location=$VIDEO name=src_0 ! decodebin"
    if [ -n "${FLIP:-}" ]; then
        # 文件源默认不翻转(保持旧行为);采集类素材是倒装的,必须显式 --flip 180
        # videoconvert 兜底:decodebin 可能吐 NV12,而 videoflip 不认全部格式
        SRC="$SRC ! videoconvert n-threads=2 ! videoflip video-direction=$FLIP"
        SRC_DESC="$(basename "$VIDEO")(翻转 $FLIP)"
    else
        SRC_DESC="$(basename "$VIDEO")"
    fi
fi

# ---------- 人脸检测流水线 ----------

CORE="$Q ! videoscale qos=false n-threads=2 ! video/x-raw,pixel-aspect-ratio=1/1 \
 ! $Q ! videoconvert n-threads=2 qos=false \
 ! $Q ! hailonet hef-path=$HEF batch-size=$BATCH \
 ! $Q ! hailofilter function-name=$FUNC so-path=$SO config-path=$CFG qos=false"

if [ "$EXPORT" = "1" ]; then
    # hailoexportfile 是 BaseTransform(sink+src 都有),可直接串进链路;
    # 属性名是 location,不是 file-path;它没有 num-buffers,会在 EOS 时一次性落盘。
    rm -f "$JSONOUT"
    CORE="$CORE ! $Q ! hailoexportfile location=$JSONOUT"
fi

CORE="$CORE ! $Q ! hailooverlay qos=false \
 ! $Q ! videoconvert n-threads=2 qos=false ! $Q"

if [ "$SNAP" -gt 0 ]; then
    mkdir -p "$SNAPDIR"; rm -f "$SNAPDIR"/f*.jpg
    # 注意:multifilesink 没有 num-buffers 属性(只有 max-files,达上限循环删旧),
    # 因此这里的 N 是"最终保留 N 张",实际处理的仍是整段视频。
    SINK="jpegenc quality=85 ! multifilesink location=$SNAPDIR/f%03d.jpg max-files=$SNAP"
elif [ "$SHOW" = "1" ]; then
    export DISPLAY=${DISPLAY:-:10}
    SINK="fpsdisplaysink video-sink=clutterautovideosink text-overlay=false"
elif [ "$FRAMES" -gt 0 ]; then
    SINK="fakesink sync=false num-buffers=$FRAMES"
else
    SINK="fakesink sync=false"
fi

PIPELINE="gst-launch-1.0 -e $SRC ! $CORE ! $SINK"

echo "=============================================="
echo " 人脸检测 · $MODEL_NAME · Hailo-8L"
echo "=============================================="
echo "  模型       : $(basename "$HEF")  ($(du -h "$HEF" | cut -f1))"
echo "  后处理     : $(basename "$SO")"
echo "  function   : $FUNC"
echo "  config     : $(basename "$CFG")"
echo "  输入源     : $SRC_DESC"
if [ "$SNAP" -gt 0 ]; then
    echo "  输出       : 保存 $SNAP 张带框帧 -> $SNAPDIR/"
elif [ "$SHOW" = "1" ]; then
    echo "  输出       : 显示窗口 (DISPLAY=$DISPLAY)"
else
    echo "  输出       : fakesink(计时模式)"
fi
[ "$EXPORT" = "1" ] && echo "  对象导出   : $JSONOUT"
echo "----------------------------------------------"

if [ "$PRINT_ONLY" = "1" ]; then
    echo "$PIPELINE"
    exit 0
fi

T0=$(date +%s%N)
# ── 安全网 ────────────────────────────────────────────────────
# gst-launch 在段错误时会"Captured SIGSEGV + Spinning"而不退出,
# 从而一直占着 /dev/hailo0 → 下次运行报 HAILO_OUT_OF_PHYSICAL_DEVICES(74)。
# 两道保险:① 开跑前清掉残留(-x 精确匹配进程名,不能用 -f,否则会连自己也杀掉);
#          ② 用 timeout 包住,卡死时自动收尸。
pkill -9 -x gst-launch-1.0 2>/dev/null && { echo "  [清理] 发现残留 gst-launch,已强杀"; sleep 1; }
# 超时秒数:外部显式给了 RUNSEC 就用它(摄像头等无 EOS 的源必须靠它收尾)
if [ -z "${RUNSEC:-}" ]; then
    RUNSEC=$(( FRAMES / 8 + 60 ))
    # --snap 模式用 multifilesink(没有 num-buffers),会把整段视频跑完,所以要放宽
    if [ "$SNAP" -gt 0 ] && [ "$RUNSEC" -lt 200 ]; then RUNSEC=200; fi
fi
eval "timeout --signal=KILL -k 5 ${RUNSEC}s $PIPELINE" > "$LOG" 2>&1
RC=$?
T1=$(date +%s%N)
WALL=$(awk -v a=$T0 -v b=$T1 'BEGIN{printf "%.3f", (b-a)/1e9}')
[ "$RC" = "137" ] && { echo "  [!] 超时(${RUNSEC}s)被强杀——pipeline 很可能卡死"; pkill -9 -x gst-launch-1.0 2>/dev/null; }

echo
if [ "$SNAP" -gt 0 ]; then
    N=$(ls -1 "$SNAPDIR"/f*.jpg 2>/dev/null | wc -l)
    echo "  已保存帧数 : $N 张 -> $SNAPDIR/"
    echo "  墙钟耗时   : $WALL 秒"
    [ "$N" -gt 0 ] && echo "  平均帧率   : $(awk -v n=$N -v w=$WALL 'BEGIN{printf "%.2f", n/w}') FPS(含启动开销)"
else
    DUR=$(grep -o 'Execution ended after [^ ]*' "$LOG" | head -1 | awk '{print $4}')
    if [ -n "${DUR:-}" ]; then
        # 注意:-v 传进来的是标量,不能写 t[1];必须用 split 拆
        SEC=$(awk -v t="$DUR" 'BEGIN{split(t, a, ":"); print a[1]*3600 + a[2]*60 + a[3]}')
        echo "  gst 自报耗时: $DUR($SEC 秒,不含启动)"
        # ★ FRAMES=0 表示"跑完整段视频"(采集素材走这条),此处不能拿 0 做除数,
        #   否则会打出 `awk: division by zero attempted` —— 看着像报错,实际无害。
        #   帧数改由下方「导出帧数」给出。
        if [ "$FRAMES" -gt 0 ]; then
            echo "  实测帧数   : $FRAMES 帧"
            echo "  平均帧率   : $(awk -v n=$FRAMES -v d=$SEC 'BEGIN{printf "%.2f", n/d}') FPS"
            echo "  平均延迟   : $(awk -v n=$FRAMES -v d=$SEC 'BEGIN{printf "%.1f", d/n*1000}') ms/帧"
        else
            echo "  实测帧数   : 整段视频(--frames 0)→ 帧数与帧率见下方「导出帧数」"
        fi
    else
        echo "  墙钟耗时   : $WALL 秒"
        [ "$FRAMES" -gt 0 ] && echo "  平均帧率   : $(awk -v n=$FRAMES -v w=$WALL 'BEGIN{printf "%.2f", n/w}') FPS(含启动开销)"
    fi
fi

if [ "$EXPORT" = "1" ] && [ -f "$JSONOUT" ]; then
    echo "  导出 JSON  : $JSONOUT ($(du -h "$JSONOUT" | cut -f1))"
    python3 - "$JSONOUT" <<'PYEOF' 2>/dev/null || true
import json, sys
d = json.load(open(sys.argv[1]))
c = [len(x.get("HailoROI", {}).get("SubObjects", [])) for x in d]
print("  导出帧数   : %d 帧" % len(c))
if c:
    print("  总检测数   : %d" % sum(c))
    print("  每帧检测   : 最小 %d / 最大 %d / 平均 %.2f" % (min(c), max(c), sum(c) / len(c)))
    cf = [s["HailoDetection"]["confidence"]
          for x in d[:200] for s in x.get("HailoROI", {}).get("SubObjects", [])
          if "HailoDetection" in s]
    if cf:
        cf.sort()
        print("  置信度     : 最小 %.3f / 中位 %.3f / 最大 %.3f  (唯一值 %d 个)"
              % (cf[0], cf[len(cf)//2], cf[-1], len(set(round(v,3) for v in cf))))
PYEOF
fi

echo "----------------------------------------------"
ERRPAT='error|failed|segmentation|undefined symbol|caught sigsegv|spinning'
ERRS=$(grep -ciE "$ERRPAT" "$LOG" 2>/dev/null)
ERRS=${ERRS:-0}
if [ "$ERRS" -gt 0 ]; then
    echo "  [警告] 日志里有 $ERRS 行疑似错误,前 5 行:"
    grep -iE "$ERRPAT" "$LOG" | head -5 | sed 's/^/     /'
    echo "  完整日志:$LOG"
    exit $RC
fi
echo "  [OK] 运行期间没有 ERROR / 段错误"
echo "  完整日志:$LOG"
exit $RC

特征提取

set -u

TAPPAS=/usr/share/tappas
POSTDIR=$TAPPAS/apps/h8/gstreamer/libs/post_processes

HEF_REC=${HEF_REC:-/home/user/arcface_mobilefacenet.hef}
SO_REC=${SO_REC:-$POSTDIR/libface_recognition_post.so}
FUNC_REC=${FUNC_REC:-arcface_nv12}

CROPDIR=/tmp/aln
VIDEO=""
MODE=none
SNAP=0
PRINT_ONLY=0
FRAMES=0
PER_SEC=${PER_SEC:-1}
JSONOUT=/tmp/embed.json
SNAPDIR=/tmp/embed_frames
LOG=/tmp/embed_run.log
Q="queue leaky=no max-size-buffers=3 max-size-bytes=0 max-size-time=0"

usage() { sed -n '2,26p' "$0" | sed 's/^# \{0,1\}//'; exit 0; }

while [ $# -gt 0 ]; do
    case "$1" in
        --video) MODE=video; shift; VIDEO="$1" ;;
        --crops) MODE=crops; shift; CROPDIR="$1" ;;
        --snap)  shift; SNAP="$1" ;;
        --per-sec) shift; PER_SEC="$1" ;;
        --print) PRINT_ONLY=1 ;;
        -h|--help) usage ;;
        *) echo "未知参数:$1"; exit 1 ;;
    esac
    shift
done

for f in "$HEF_REC" "$SO_REC"; do
    [ -f "$f" ] || { echo "[错误] 缺少文件:$f"; exit 1; }
done

HERE=$(cd "$(dirname "$0")" && pwd)
if [ "$MODE" = "crops" ]; then
    N=$(ls "$CROPDIR"/*.png "$CROPDIR"/*.jpg 2>/dev/null | wc -l)
    [ "$N" -gt 0 ] || { echo "[错误] $CROPDIR 下没有图片"; exit 1; }
    # 每张图占 PER_SEC 秒(默认 1s=30 帧)。用 112 letterbox,避免拉伸变形。
    bash "$HERE/e-face-enroll-build.sh" "$CROPDIR" /tmp/embed_src.mp4 112 "$PER_SEC" || exit 1
    VIDEO=/tmp/embed_src.mp4
    FRAMES=$((N * 30 * PER_SEC))
    SRC_DESC="$N 张对齐小脸 -> 视频"
elif [ "$MODE" = "video" ]; then
    [ -f "$VIDEO" ] || { echo "[错误] 找不到视频 $VIDEO"; exit 1; }
    SRC_DESC="$(basename "$VIDEO")"
else
    echo "需要 --video 或 --crops(用 --help 看用法)"
    exit 1
fi

[ "$FRAMES" -eq 0 ] && FRAMES=300

CORE="videoscale qos=false ! $Q ! \
hailonet hef-path=$HEF_REC batch-size=1 vdevice-key=1 ! $Q ! \
hailofilter name=recognition so-path=$SO_REC function-name=$FUNC_REC qos=false ! $Q ! \
hailoexportfile location=$JSONOUT ! $Q ! videoconvert n-threads=2 qos=false ! $Q"

if [ "$SNAP" -gt 0 ]; then
    mkdir -p "$SNAPDIR"; rm -f "$SNAPDIR"/f*.jpg
    SINK="jpegenc quality=90 ! multifilesink location=$SNAPDIR/f%03d.jpg max-files=$SNAP"
else
    SINK="fakesink sync=false"
fi

PIPELINE="gst-launch-1.0 -e filesrc location=$VIDEO name=src_0 ! decodebin ! videoconvert qos=false ! $CORE ! $SINK"

echo "=============================================="
echo " 只跑 ArcFace 提特征 · Hailo-8L"
echo "=============================================="
echo "  模型   : $(basename "$HEF_REC") / function=$FUNC_REC"
echo "  输入   : $SRC_DESC"
echo "  输出   : $JSONOUT"
echo "----------------------------------------------"

if [ "$PRINT_ONLY" = "1" ]; then echo "$PIPELINE"; exit 0; fi

pkill -9 -x gst-launch-1.0 2>/dev/null; sleep 1
rm -f "$JSONOUT"
RUNSEC=${RUNSEC:-$(( FRAMES / 10 + 40 ))}
T0=$(date +%s%N)
timeout --signal=KILL -k 5 "$RUNSEC" bash -c "$PIPELINE" > "$LOG" 2>&1
RC=$?
T1=$(date +%s%N)
WALL=$(awk -v a=$T0 -v b=$T1 'BEGIN{printf "%.3f", (b-a)/1e9}')

if grep -q 'Caught SIGSEGV' "$LOG" 2>/dev/null; then
    echo "  [失败] 段错误,日志:$LOG"; grep -iE 'error|symbol' "$LOG" | head -5; exit 1
fi
if [ "$RC" = "137" ]; then
    echo "  [警告] 达到 ${RUNSEC}s 上限被强杀(未收到 EOS)"
fi

echo "  墙钟耗时   : $WALL 秒"
if [ -f "$JSONOUT" ]; then
    echo "  导出 JSON  : $JSONOUT ($(du -h "$JSONOUT" | cut -f1))"
    python3 - <<PY
import json
try:
    d = json.load(open("$JSONOUT"))
    n = sum(1 for fr in d if "HailoMatrix" in json.dumps(fr)[:100000] or "HailoMatrix" in str(fr)[:100000])
    print("  帧数       : %d" % len(d))
except Exception as e:
    print("  JSON 解析失败: %s" % e)
PY
fi
if [ "$SNAP" -gt 0 ]; then
    echo "  输入帧     : $(ls "$SNAPDIR"/*.jpg 2>/dev/null | wc -l) 张 -> $SNAPDIR/"
fi
grep -E 'Got EOS|Execution ended' "$LOG" | sed 's/^/  /'
exit $RC

头部追踪

set -u

TAPPAS=/usr/share/tappas
POSTDIR=$TAPPAS/apps/h8/gstreamer/libs/post_processes
HEF=${SCRFD_HEF:-/home/user/scrfd_2.5g.hef}
if [ -f /home/user/libscrfd_post_fixed.so ]; then
    SO=${SCRFD_SO:-/home/user/libscrfd_post_fixed.so}
else
    SO=${SCRFD_SO:-$POSTDIR/libscrfd_post.so}
fi
FUNC=scrfd_2_5g
CFG=${SCRFD_CFG:-/home/user/scrfd.json}
DEV=${DEV:-/dev/video4}
ZMQADDR=${GAZE_LIVE_ZMQ:-tcp://\*:9501}

MODE=live
VIDEO=""
SEC=1800
N=150
FLIP=180

while [ $# -gt 0 ]; do
    case "$1" in
        --sec)  shift; SEC="$1" ;;
        --flip) shift; FLIP="$1" ;;
        --file) shift; MODE=file; VIDEO="$1" ;;
        --n)    shift; N="$1" ;;
        --dev)  shift; DEV="$1" ;;
        --zmq)  shift; ZMQADDR="$1" ;;
        -h|--help) sed -n '2,30p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
        *) echo "未知参数:$1"; exit 1 ;;
    esac
    shift
done

[ -f "$HEF" ] || { echo "[错误] 找不到模型 $HEF"; exit 1; }

flip_seg() {
    case "$1" in
        none|identity) echo "" ;;
        horiz)         echo "videoflip video-direction=horiz ! " ;;
        vert)          echo "videoflip video-direction=vert ! " ;;
        180)           echo "videoflip video-direction=180 ! " ;;
        *) echo "[错误] 未知 flip:$1(可用 none|horiz|vert|180)" >&2; exit 1 ;;
    esac
}

# 共同主干(hailofilter 之后各模式尾部不同)
CORE="videoscale qos=false n-threads=2 ! video/x-raw,pixel-aspect-ratio=1/1 ! \
queue leaky=downstream max-size-buffers=2 max-size-bytes=0 max-size-time=0 ! \
videoconvert n-threads=2 qos=false ! \
queue leaky=downstream max-size-buffers=2 max-size-bytes=0 max-size-time=0 ! \
hailonet hef-path=$HEF batch-size=1 ! \
queue leaky=downstream max-size-buffers=2 max-size-bytes=0 max-size-time=0 ! \
hailofilter function-name=$FUNC so-path=$SO config-path=$CFG qos=false ! \
hailoexportzmq address=$ZMQADDR qos=false"

echo "===== 实时特征导出 · scrfd_2.5g · Hailo-8L ====="
echo "  ZMQ 地址 : $ZMQADDR(逐帧 HailoObjects JSON)"

pkill -9 -x gst-launch-1.0 2>/dev/null && { echo "  [清理] 强杀残留 gst"; sleep 1; }

if [ "$MODE" = "file" ]; then
    [ -f "$VIDEO" ] || { echo "[错误] 找不到视频 $VIDEO"; exit 1; }
    echo "  模式     : 文件源 $VIDEO($N 帧)"
    PIPELINE="gst-launch-1.0 -e filesrc location=$VIDEO ! decodebin ! $CORE ! \
fakesink sync=false num-buffers=$N"
    eval "timeout --signal=KILL -k 5 $((N / 8 + 120))s $PIPELINE" > /tmp/gaze_live.log 2>&1
    RC=$?
else
    [ -e "$DEV" ] || { echo "[错误] 摄像头不存在: $DEV"; exit 1; }
    FL=$(flip_seg "$FLIP")
    echo "  模式     : 实时 $DEV(翻转 ${FLIP}),运行 ${SEC}s"
    PIPELINE="gst-launch-1.0 -e v4l2src device=$DEV ! ${FL}\
queue leaky=downstream max-size-buffers=2 max-size-bytes=0 max-size-time=0 ! $CORE ! \
fakesink sync=false"
    eval "timeout --signal=KILL -k 5 ${SEC}s $PIPELINE" > /tmp/gaze_live.log 2>&1
    RC=$?
fi

echo "  gst 退出码: $RC"
[ "$RC" = "137" ] && echo "  [i] 到达时长上限被强杀(正常收尾方式)"
echo "===== 错误扫描 ====="
ERR=$(grep -ciE 'error|failed|segmentation|undefined symbol|caught sigsegv|spinning' /tmp/gaze_live.log 2>/dev/null)
ERR=${ERR:-0}
if [ "$ERR" -gt 0 ]; then
    grep -iE 'error|failed|segmentation|undefined symbol|caught sigsegv|spinning' /tmp/gaze_live.log | head -6 | sed 's/^/  /'
    exit 1
fi
echo "  无 ERROR / 段错误(完整日志 /tmp/gaze_live.log)"
echo "DONE"