WordPress-添加侧边媒体栏

其实很简单 做一个全局悬浮块就行
代码在最下面

1

2

3

105-110行内容改成客户的联系方式
4
219行改图标是的形状的 圆形50% 正方形0% 圆角8%-16%

5

6

7

8


<!-- 引入字体图标库 -->
<link rel="stylesheet" href="https://at.alicdn.com/t/c/font_4210690_bfpz45tuycu.css">

<!-- 侧边栏:用于展示联系信息及快捷操作 -->
<aside class="contact-sidebar">
    <!-- 侧边栏隐藏/显示控制按钮 -->
    <div class="contact-sidebar-hide icon-box" onclick="toggleSidebar()">
        <i id="hidetoright" class="icon iconfont icon-arrowrighttoright"></i>
    </div>

    <!-- 手机号联系入口(WhatsApp) -->
    <div class="contact-sidebar-phone icon-box">
        <a class="a-open" 
           href="" 
           target="_blank" 
           rel="noopener"
           id="whatsappLink">
             <i class="icon iconfont icon-whatsapp"></i>
        </a>
        <div class="phone">
            <span class="show-phone" id="phoneDisplay"></span>
        </div>
    </div>

    <!-- 邮箱联系入口 -->
    <div class="contact-sidebar-email icon-box">
        <a class="a-open" href="" id="emailLink">
             <i class="icon iconfont icon-mail"></i>
        </a>
        <div class="email">
            <span class="show-email" id="emailDisplay"></span>
        </div>
    </div>

    <!-- 二维码展示(暂未启用)
    <div class="contact-sidebar-code icon-box">
        <a class="a-open" href="#" target="_blank" rel="noopener">
            <i class="icon iconfont icon-weixin"></i>
        </a>
        <div class="code">
            <img class="show-code" src="../../images/code.png" alt="微信二维码">
        </div>
    </div>
    -->

    <!-- 回到顶部按钮(暂未启用)
    <div class="contact-sidebar-totop icon-box" onclick="toTop()">
        <i class="icon fa fa-light fa-arrow-up fa-lg"></i>
    </div>
    -->
</aside>

<!-- 底部栏:移动端联系信息展示 -->
<footer class="footerbar" id="footerbar">
    <!-- 底部栏隐藏/显示控制按钮 -->
    <div class="foot-hide foot-box" onclick="toggleFooter()">
        <i id="hidetoleft" class="icon iconfont icon-arrowlefttoleft"></i>
    </div>

    <!-- 底部栏手机号入口 -->
    <div class="foot-phone foot-box">
        <a class="a-open" 
           href="" 
           target="_blank" 
           rel="noopener"
           id="footerWhatsappLink">
             <i class="icon iconfont icon-whatsapp"></i>
        </a>
        <div class="show-footer_phone">
            <span class="show-phone" id="footerPhoneDisplay"></span>
        </div>
    </div>

    <!-- 底部栏邮箱入口 -->
    <div class="foot-email foot-box">
        <a class="a-open" 
           href="" 
           target="_blank" 
           rel="noopener"
           id="footerEmailLink">
             <i class="icon iconfont icon-mail"></i>
        </a>
        <div class="show-footer_email">
            <span class="show-email" id="footerEmailDisplay"></span>
        </div>
    </div>

    <!-- 底部栏二维码(暂未启用)
    <div class="foot-code foot-box">
        <i class="icon iconfont icon-erweima"></i>
        <div class="show-footer_code">
            <img src="../../images/code.png" alt="联系客服二维码">
        </div>
    </div>
    -->

    <!-- 底部栏回到顶部(暂未启用)
    <div class="foot-totop foot-box" onclick="toTop()">
        <i class="icon-foot fa fa-light fa-arrow-up fa-lg "></i>
    </div>
    -->
</footer>

<script>
    // 配置信息 - 在此处修改联系方式,所有引用处会自动更新
    const contactConfig = {
        phone: '8613800000000',       // 电话号码(用于链接)
        phoneDisplay: '+86 15862618383', // 显示的电话号码
        email: 'hello@example.com' // 邮箱地址
    };

    // 初始化联系方式
    function initContactInfo() {
        // 更新侧边栏联系方式
        document.getElementById('whatsappLink').href = `https://api.whatsapp.com/send?phone=${contactConfig.phone}`;
        document.getElementById('phoneDisplay').textContent = contactConfig.phoneDisplay;
        document.getElementById('emailLink').href = `mailto:${contactConfig.email}`;
        document.getElementById('emailDisplay').textContent = contactConfig.email;

        // 更新底部栏联系方式
        document.getElementById('footerWhatsappLink').href = `https://api.whatsapp.com/send?phone=${contactConfig.phone}`;
        document.getElementById('footerPhoneDisplay').textContent = contactConfig.phoneDisplay;
        document.getElementById('footerEmailLink').href = `mailto:${contactConfig.email}`;
        document.getElementById('footerEmailDisplay').textContent = contactConfig.email;
    }

    // 页面加载完成后初始化联系方式
    window.addEventListener('DOMContentLoaded', initContactInfo);

    // 侧边栏切换状态计数器(用于控制图标旋转角度)
    let sidebarToggleCount = 0;
    // 底部栏切换状态计数器
    let footerToggleCount = 0;

    /**
     * 切换侧边栏显示/隐藏状态
     * 1. 控制切换按钮的旋转动画
     * 2. 控制联系信息的平移显示/隐藏
     */
    function toggleSidebar() {
        // 获取DOM元素
        const toggleIcon = document.getElementById('hidetoright');
        const whatsappItem = document.querySelector('.contact-sidebar-phone');
        const emailItem = document.querySelector('.contact-sidebar-email');
        const codeItem = document.querySelector('.contact-sidebar-code');

        // 更新旋转角度(每次点击旋转180度)
        sidebarToggleCount += 180;
        toggleIcon.style.transform = `translate(-50%, -50%) rotate(${sidebarToggleCount}deg)`;

        // 切换显示/隐藏状态
        const isHidden = whatsappItem.style.transform === 'translateX(100px)';
        if (isHidden) {
            // 显示元素
            whatsappItem.style.transform = 'translateX(0)';
            emailItem.style.transform = 'translateX(0)';
            if (codeItem) codeItem.style.transform = 'translateX(0)';
        } else {
            // 隐藏元素
            whatsappItem.style.transform = 'translateX(100px)';
            emailItem.style.transform = 'translateX(100px)';
            if (codeItem) codeItem.style.transform = 'translateX(100px)';
        }
    }

    /**
     * 切换底部栏显示/隐藏状态
     * 1. 控制切换按钮的旋转动画
     * 2. 控制联系信息的平移显示/隐藏
     */
    function toggleFooter() {
        // 获取DOM元素
        const toggleIcon = document.getElementById('hidetoleft');
        const whatsappItem = document.querySelector('.foot-phone');
        const emailItem = document.querySelector('.foot-email');
        const codeItem = document.querySelector('.foot-code');

        // 更新旋转角度(每次点击旋转180度)
        footerToggleCount += 180;
        toggleIcon.style.transform = `translate(-50%, -50%) rotate(${footerToggleCount}deg)`;

        // 切换显示/隐藏状态
        const isHidden = whatsappItem.style.transform === 'translateX(-1000px)';
        if (isHidden) {
            // 显示元素
            whatsappItem.style.transform = 'translateX(0)';
            emailItem.style.transform = 'translateX(0)';
            if (codeItem) codeItem.style.transform = 'translateX(0)';
        } else {
            // 隐藏元素
            whatsappItem.style.transform = 'translateX(-1000px)';
            emailItem.style.transform = 'translateX(-1000px)';
            if (codeItem) codeItem.style.transform = 'translateX(-1000px)';
        }
    }

    /**
     * 回到页面顶部功能
     * 兼容不同浏览器的滚动高度获取方式
     */
    function toTop() {
        // 现代浏览器
        if (window.scrollTo) {
            window.scrollTo(0, 0);
        } else {
            // 兼容旧浏览器
            document.body.scrollTop = 0;
            document.documentElement.scrollTop = 0;
        }
    }
</script>
<style>
/* 图标容器基础样式 */
.icon-box {
    width: 50px;
    height: 50px;
    /* 圆形边框颜色 (当前注释掉) */
    /* border: 2px solid #c9c9c9; */
    border-radius: 50%;
    position: relative;
    /* 圆形框内背景色 */
    background-color: rgba(255, 255, 255, 0.25);
    margin-bottom: 5px;
}

/* 图标通用样式 */
.icon {
    /* 侧边图标颜色 */
    color: #fff;
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    transform-origin: 50% 50%;
}

/* 特定图标样式调整 */
.icon-arrowrighttoright {
    font-size: 30px !important;
}

.icon-mail {
    font-size: 20px !important;
    top: 47%; /* 微调邮件图标垂直位置 */
}

.icon-whatsapp {
    font-size: 20px !important;
}

.icon-weixin {
    font-size: 30px;
}

/* 侧边栏元素颜色定义 */
.contact-sidebar-hide {
    /* hide侧边按钮背景颜色 */
    background-color: #1C5E9A;
}

.contact-sidebar-phone {
    /* 电话图标背景颜色 */
    background-color: #1C5E9A;
}

.contact-sidebar-email {
    /* 邮箱图标背景颜色 */
    background-color: #1C5E9A;
}

.contact-sidebar-code {
    /* 二维码图标背景颜色 */
    background-color: #1C5E9A;
}

/* 底部栏元素样式 */
.foot-hide {
    /* 底部隐藏按钮背景颜色 */
    background-color: #1C5E9A;
    width: 60px !important;
    height: 50px; /* 明确高度,确保图标显示完整 */
    z-index: 9999; /* 确保在其他元素上方显示 */
}

.foot-phone,
.foot-email,
.foot-code {
    background-color: #1C5E9A;
    transition: all 1s ease; /* 平滑过渡动画 */
}

/* 底部栏容器样式 */
.foot-box {
    /* 底部边框颜色 (当前注释掉) */
    /* border: 2px solid #fff; */
    margin: 0px -1px 0px 0px;
    width: 50%;
    cursor: pointer; /* 鼠标悬停显示手型 */
    position: relative;
}

/* 底部栏元素边框调整 */
.foot-box:nth-child(2),
.foot-box:nth-child(3),
.foot-box:nth-child(4) {
    border-left: 0px;
}

/* 底部栏整体样式 */
.footerbar {
    z-index: 9999; /* 确保在页面内容上方显示 */
    position: fixed;
    left: 0;
    bottom: 0;
    width: 100%;
    height: 50px;
    text-align: center;
    display: flex;
    justify-content: space-around;
    transition: all 0.3s linear; /* 平滑过渡效果 */
}

/* 底部栏图标样式 */
.icon-foot {
    /* 底部图标颜色 */
    color: rgb(86, 86, 243);
    position: absolute;
    left: 50%;
    top: 50%;
    transform: translate(-50%, -50%);
}

/* 弹出信息样式 */
.code > .show-code,
.email > .show-email,
.phone > .show-phone {
    /* 侧边栏弹出文本大小 */
    font-size: 18px;
    white-space: nowrap; /* 防止文本换行 */
    padding: 0px 20px;
}

/* 侧边栏整体样式 */
.contact-sidebar {
    position: fixed;
    z-index: 9999; /* 确保在页面内容上方显示 */
    right: 0;
    bottom: 15%;
    padding: 6px 6px 0;
    border-radius: 20px 0 0 20px; /* 右侧圆角 */
}

/* 侧边栏元素动画效果 */
.contact-sidebar-phone {
    transition: all 1.2s; /* 电话图标的过渡时间 */
}

.contact-sidebar-email {
    transition: all 0.9s; /* 邮箱图标的过渡时间 */
}

.contact-sidebar-code {
    transition: all 0.6s; /* 二维码图标的过渡时间 */
}

.icon-arrowlefttoleft {
    font-size: 30px !important; /* 增大底部箭头大小,与侧边栏箭头保持一致 */
}
    
.icon-arrowlefttoleft,
.icon-arrowrighttoright {
    transition: all 0.3s; /* 箭头图标的过渡时间 */
}

/* 侧边栏内部元素样式 */
.contact-sidebar > div {
    cursor: pointer; /* 鼠标悬停显示手型 */
    text-align: center;
}

.contact-sidebar > div h3 {
    font-size: 15px;
    font-weight: 500;
    color: #fff;
}

/* 侧边栏弹出框样式 */
.contact-sidebar > div .code,
.contact-sidebar > div .email,
.contact-sidebar > div .phone,
.contact-sidebar > div .hide {
    padding: 6px;
    align-items: center;
    display: none; /* 默认隐藏 */
    position: absolute;
    z-index: 9999; /* 确保在图标上方显示 */
    top: 15%;
    right: 70px; /* 显示在图标左侧 */
    border-radius: 8px;
    box-shadow: 8px 8px 8px 8px rgba(88, 95, 110, 0.22); /* 阴影效果 */
    background-color: #f7f7f7;
}

/* 底部栏弹出框样式 */
.footerbar > div .show-footer_code,
.footerbar > div .show-footer_email,
.footerbar > div .show-footer_phone {
    padding: 6px;
    align-items: center;
    display: none; /* 默认隐藏 */
    position: absolute;
    z-index: 9999; /* 确保在图标上方显示 */
    bottom: 110%; /* 显示在图标上方 */
    font-size: 10px;
    border-radius: 8px;
    box-shadow: 8px 8px 8px 8px rgba(88, 95, 110, 0.22); /* 阴影效果 */
    background-color: #f7f7f7;
}

/* 侧边栏弹出框箭头样式 */
.contact-sidebar > div .email:after,
.contact-sidebar > div .phone:after,
.contact-sidebar > div .code:after {
    position: absolute;
    top: 50%;
    left: 100%; /* 箭头显示在右侧 */
    content: '';
    transform: translateY(-50%);
    border-width: 5px;
    border-style: solid;
    border-color: transparent transparent transparent #ffffff; /* 三角形箭头 */
}

/* 悬停显示弹出框 */
.contact-sidebar-email:hover .email,
.contact-sidebar-phone:hover .phone,
.contact-sidebar-code:hover .code,
.foot-email:hover .show-footer_email,
.foot-phone:hover .show-footer_phone,
.foot-code:hover .show-footer_code {
    display: block;
}

/* 二维码图片样式 */
.show-footer_code > img,
.code > img {
    width: 80px !important;
}

/* 电话弹出框宽度 */
.phone {
    width: 200px;
}

/* 链接样式 */
.a-lg {
    width: 100px;
    height: 100px;
}

.a-open {
    display: block;
    height: 50px;
    border-radius: 50px;
}

/* 响应式显示控制 */
@media (min-width: 770px) {
    /* 大屏幕隐藏底部栏 */
    .footerbar {
        display: none;
    }
}

@media (max-width: 770px) {
    /* 小屏幕隐藏侧边栏 */
    .contact-sidebar {
        display: none;
    }
}
</style>

相关文章

用 Astro 做工业官网

我用 Astro 做了 LIANXIN 的工业机械官网,里面有产品目录、工厂展示,也接了 Directus 新闻和 AI 产品顾问。做这种网站,很多选择都落在同一个问题上:哪些内容应该直接送到用户面前,哪些功能等用到的时候再启动。 机器图片、参数表、介绍文字适合提前生成;新闻要能随时更新;轮播、咨询和视频又各有自己的交互。把这些边界拆清楚,后面的实现会顺很多。 官网现用的工厂主题海报。来源:LIANXIN 官网。 页面先到,交互按需启动 Astro 比较合我胃口的一点,是它允许我从 HTML 开始组织页面。产品介绍和图片这些内容先渲染出来,需要状态和事件的地方再接 React。装了 React 集成,也不用把整页都做成客户端应用。 本站导航用 client:load,页面加载时就激活;产品轮播用 client:visible,进入视口再加载和水合。两种方式都可以先输出 HTML,区别在于何时让组件在浏览器里运行。具体触发时机可以看 Astro 的客户端指令文档。 FAQ 更进一步,用了项目自己注册的 client:interaction。鼠标移入、触摸按下或键盘聚焦时开始加载。这里有个小坑:用户可能在加载结束前就点了按钮,所以指令会拦住这次点击,等组件就绪再补发。普通链接照常跳转。省 JavaScript 可以,别把第一次点击也省了。 新闻更新走另一条路。列表和详情按请求读取 Directus,首页的最新新闻则交给 server:defer。首页先返回主体和占位内容,新闻区块随后单独请求 HTML。这样 CMS 响应慢一些,也不会拖住整个首页。这部分需要服务端,我用 Node adapter 的 standalone 模式运行站点。 Astro 官方原理图,说明静态页面与动态区块如何分别取得内容。来源:Astro 5.0,&copy; Astro;此图并非本站部署架构图。 产品页和 AI 共用一份资料 产品有不同系列,每个型号又有图片、参数和应用说明。如果这些东西散在页面里,改一项参数就得挨个找。我把产品整理成 TypeScript 数据,再让页面读取它。型号详情用同一份模板,通过 getStaticPaths() 生成路由。 这是详情页里的路径生成部分,省略了导入和页面模板。 export function getStaticPaths() { return products.map((product) => ({ params: { category: product.category, slug: product.slug, }, props: { product, category: getCategory(product.category), }, })); } 参数表读这份数据,页面里的 Product 结构化数据也读它。AI 产品知识同样从这里生成,避免官网改了规格,顾问还在背旧版本。数据模型里保留型号、分类、图片和规格这些明确字段,页面只负责把它们展示出来。 AI 后端是独立的 Cloudflare Worker。它根据问题里的型号、别名和当前页面检索资料,最多选六份交给 DeepSeek,再检查回答附带的来源 ID 是否属于这批资料。浏览器拿到的是回答和对应的站内链接,模型密钥留在 Worker。 这种检索方式规模小、规则直接,适合目前这批产品。它能约束资料来源,不能保证模型每句话都对,具体选型和报价仍要人工确认。另外,共用源文件不等于自动同步上线。产品资料改动后,主站和顾问 Worker 都需要重新构建部署,这个步骤不能漏。 图片讲构图,视频讲时机 工业设备本来就长,外面再套几层大边框,能看清的机器只会越来越小。产品图这里用 object-fit: contain,保留完整外形;标题、卖点和按钮放到旁边,让图片有足够的展示面积。轮播控制留在左右两侧,翻图时不用往下找。 IBM 80E 的展示图,机身轮廓完整保留。来源:LIANXIN 对应型号页。 首页海报则准备了横、竖两版,用 <picture> 按 max-aspect-ratio: 1/1 切换。选图依据是视口形状,竖屏平板也能得到竖版构图。首张海报优先加载,后面的图延迟加载。文案目前也在海报里,换来的排版控制更直接,代价是改文字和做多语言都要重新出图。 视频的处理更干脆。工厂介绍先展示封面,视频地址放进 data-src,点击播放时才赋给 src。访问者只是看看页面,就不用顺手下载一段几分钟的视频。 播放函数中的关键动作如下,省略了加载提示、超时和错误处理。 if (!video.hasAttribute("src")) { video.src = video.dataset.src!; } void video.play(); 这一块用原生 <video> 和一个自定义元素管理播放状态,就够用了。网络慢时显示加载提示,失败后给出重试按钮和原视频链接;离开组件时中止事件监听并暂停视频。用户至少能知道点下去之后发生了什么,也有办法继续操作。

2026/9/7·技术分享
用 Astro 做工业官网

用 Android 做疲劳检测

我做了一个 Android 疲劳提醒应用,用前置摄像头观察闭眼和张嘴,在手机上完成检测。模型接进来以后,真正需要想清楚的是后面的判断。眨一下眼就报警,提醒很快会变成噪声;摄像头看不清了还显示正常,又会让人误解系统的能力。 先把眼睛和嘴巴量出来 项目用的是 ML Kit Face Detection,模型随 APK 打包,安装后不用再等模型下载。它提供人脸轮廓、嘴部关键点和睁眼概率,疲劳规则由应用自己实现。这样检测和判定就能分开,调整持续时间不用动相机代码。 模型负责提取人脸信息,什么时候提醒,由后面的规则决定。插画来自 Google ML Kit 官方文档。 眼睛用 EAR,也就是眼睛的纵横比。从眼轮廓里选六个点,把两段上下眼睑距离相加,再除以两倍眼宽。计算式是 EAR =(上下一组眼睑距离 + 另一组眼睑距离)&divide;(2 &times; 眼宽)。闭眼时高度变小,EAR 通常也会下降。用比例可以减轻人脸远近带来的尺度变化,但侧脸、遮挡和关键点偏移仍然会影响结果。 Google ML Kit 的官方轮廓示例,点图可查看原图。眼睑和嘴唇的点要分清,本文的 MAR 量的是唇外沿。这是官方示例,不是本应用实测截图。图片来源。 嘴巴用 MAR。这个项目取上唇外上沿中心到下唇外下沿中心的距离,除以左右嘴角距离。公式看着简单,取点位置却不能含糊。量唇外沿和量嘴巴内部开口,算出来的数不会一样,网上找到一个阈值直接抄过来,很可能对不上。 眼部还保留了 ML Kit 的睁眼概率,与 EAR 做 OR 判断,任一路满足条件就进入对应计时。概率这一路要求左右眼的值都有效。这样能让两路信号各自参与,但也意味着单路误差就可能触发候选;它们来自同一个检测器,不能当成两套独立证据。嘴部单独判断,眼睛暂时不可用时,有效的 MAR 仍然可以参与检测。 一次越线,还不能算一个动作 单帧只知道这一刻眼睛有多窄、嘴张得有多大。要把眨眼、短暂张嘴和持续动作分开,还得加上时间。当前普通闭眼条件是 EAR &le; 0.22,或两眼概率都有效时任一眼低于 0.35,持续 800 ms 后进入 WARNING。更强的闭眼条件是 0 < EAR < 0.17,或两眼概率都低于 0.35,持续 400 ms 就进入 DANGER,且优先级更高。 哈欠候选用的是双阈值。MAR 超过 0.40 才开始计时,开始后只要不低于 0.35 就继续,累计达到 800 ms 才提醒。假设数值在 0.41、0.39、0.42 之间晃,单一阈值会让计时反复归零。嘴还张着,计时器已经替你闭上了。 代码里的关键分支只有几行。下面节选自判定逻辑,省略了无效值检查和后续计时。 val yawning = mar != null && if (highMarStartMs == null) { mar > config.marYawnEnterThreshold // 0.40 } else { mar >= config.marYawnExitThreshold // 0.35 } 时间取相机帧的时间戳,不取模型回调完成的时刻。否则推理这次慢一点、下次快一点,也会混进动作时长。相邻有效观测间隔超过 1.5 秒时,未完成的计时清零,从当前帧重新开始。中间没有画面,系统就没有依据把两次张嘴拼成一次连续动作。这个间隔也是工程取舍,不能证明采样之间发生了什么。 看不清,就别继续报正常 判定器除了 NORMAL、WARNING 和 DANGER,还留了 DEGRADED。没有人脸、眼嘴信号全部无效,或检测到黑暗画面、相机异常时,就清除未完成计时,显示当前无法可靠检测的原因。NORMAL 也只表示当前有效信号没有达到提醒条件,不能解释成驾驶员一定清醒。 相机侧通过 acquireLatestImage() 取最新分析帧,当前会话已有推理任务时,新帧直接释放,避免越算越落后。停止检测或切到后台时,会话编号递增;旧任务即使晚点返回,也不能覆盖新的状态。重新开始检测后,同样要从新画面重新计时。 声音和振动只由 DANGER 触发,WARNING 只更新界面。危险告警做了约两秒冷却,并用不重放历史事件的 SharedFlow 发送;界面只在前台恢复状态下接收,暂停时停止提示。否则回到应用突然补响一声,提醒的就成了过去的自己。 目前这套实现仍是固定阈值的研究原型,没有个人校准。持续张嘴也可能来自说话,睁眼概率还有正脸角度限制。当前手机端只在模拟器上验证了安装、相机链路和前后台切换,真人识别、覆盖层精度与声音振动还需要目标手机实测。接下来更值得做的是用正常说话、短张嘴、自然哈欠和闭眼录像,在静止环境里检验误报与漏报;这套原型还不能当作经过道路验证的驾驶安全系统。

2026/9/7·技术分享
用 Android 做疲劳检测

MediaPipe 手势识别的几个坑

这次用鲁班猫 3 做了个离线手势识别,系统是 Android 14。摄像头看到手就画出骨架,比个 OK,自动保存一张带骨架的截图。 模型直接用 Google 的 MediaPipe。它已经能找出手部的 21 个关键点,还带七种静态手势分类。自己要处理的是相机、画面坐标,以及识别结果怎么变成实际操作。 模型用现成的 Android 用 Kotlin 写,相机接 CameraX,识别接 MediaPipe Tasks。依赖用这几项。 def cameraxVersion = '1.4.2' implementation "androidx.camera:camera-core:$cameraxVersion" implementation "androidx.camera:camera-camera2:$cameraxVersion" implementation "androidx.camera:camera-lifecycle:$cameraxVersion" implementation 'com.google.mediapipe:tasks-vision:1.0.0' 把官方模型放进 app/src/main/assets,创建识别器时指定这个文件,运行模式设为 LIVE_STREAM,通过回调拿结果。官方 Android 示例已经有完整接法,照着看比从空白工程硬猜快。 模型跟着 APK 一起打包,推理在板子上完成,运行时只需要相机权限。 底图来自 MediaPipe 官方测试图片,这是本项目的板端测试输出。红点是关节点,蓝线标出拇指,其余连线用绿色。 相机别开两路 CameraX 常见的接法是 Preview 显示画面,ImageAnalysis 给模型送帧。但在我这块 RK3576 板子的固件上,两路一起开会卡住。 最后只保留一条 ImageAnalysis,预览和识别共用它。拿到 1920&times;1080 的图像后,处理行步幅,把最长边缩到 640 像素,再统一旋转和镜像,送进模型。 帧策略用 KEEP_ONLY_LATEST。处理不过来就丢旧帧,别让识别排队。当前代码还限制了至少 100 ms 才提交一帧,所以提交速率最高约每秒 10 帧,实际显示速度还得看推理回调。 ImageProxy 用完一定要 close,跳过帧和异常分支也一样。 缓冲区占着不放,相机后面就可能没帧了。 另外,板子上的 CAM0、Android 的 Camera ID 0 和 /dev/video0 要分别核对。这个项目同时检查物理 m00_ 模块、逻辑 ID 0 和当前固件的 FRONT 属性,条件不满足就停。这套映射只针对已经验证过的板子和固件。 骨架为什么会飘 这个问题得从坐标和时间两头查。 坐标上,模型给的是归一化关键点,预览用的却是 centerCrop。图片会先放大,再裁掉超出显示区域的部分。直接拿关键点乘屏幕宽高,当然对不上。 把关键点投到屏幕上,要和预览用同一个缩放比例和裁切偏移。下面的 x、y 是模型返回的归一化坐标,srcW、srcH 是模型输入图像的宽高。 scale = max(viewW / srcW, viewH / srcH) offsetX = (viewW - srcW * scale) / 2 offsetY = (viewH - srcH * scale) / 2 screenX = x * srcW * scale + offsetX screenY = y * srcH * scale + offsetY 这些除法要用浮点数。旋转和镜像也统一在推理前处理,别让预览和模型各算各的方向。 时间上,手已经移走了,上一帧的结果才回来。把旧骨架叠到新画面,多少有点灵魂出窍。 我的处理是从结果回调里取出对应的输入图片,和这次关键点一起更新到界面。画面和骨架必须属于同一帧。 代价是显示会等识别结果,预览流畅度也跟着受限。 还有一个容易漏的判断。分类结果是 None,只代表没认出支持的手势。只要关键点还在,骨架就继续画;没检测到手、结果过期或者切到后台时,再清掉。 OK 截图要防连拍 官方这七种分类没有 OK,我用现有关键点补了一层几何判断。拇指和食指指尖靠近,食指弯曲,另外三根手指伸直,才算匹配。 距离按掌部尺寸归一化,再结合关节角度判断。用固定像素距离的话,手往前伸一点,阈值就不合适了。这里用的是按输入宽高还原后的 2D 坐标,和画面里的骨架保持一致。 然后才是触发控制。视频每帧都可能认出 OK,你一直举着手,就一直存图,相册受不了这个。 判断 当前条件 确认手势 最近最多 5 帧中至少 4 帧强匹配,当前帧也满足,窗口持续至少 300 ms 触发后锁定 保持 OK 只拍一张 允许再拍 离开 OK 至少 600 ms,两次触发至少间隔 1.5 秒 触发条件严一点,保持条件宽一点,减少边界抖动。采样断开超过 400 ms,就清掉旧的观察窗口。这些是当前配置,实际触发时间还受帧率影响。 截图直接合成当前画面和同帧骨架,再用 MediaStore 存成 PNG。只画这两层,状态栏和提示框就不会混进去。编码和写盘放到独立线程,别堵着相机识别。 保存的是当前显示画面的合成图,输入已经缩小过,不能当成传感器原图。 这版配置的还是 Delegate.CPU。RK3576 有 NPU,但应用得接入 RKNN 才用得上。RKNN 的官方流程包括模型转换和运行时接入,前后处理也要重新对齐,光改一个配置没用。

2026/9/7·技术分享
MediaPipe 手势识别的几个坑
听歌