基于8*8激光雷达测距的快递自动计费系统

2026-09-235

一、项目介绍

不知道你在生活中有没有注意到过这样的场景,当我们在快递寄件点,经常会出现工作人员把快递包裹放到电子秤上称重,再拿尺子分别测量长、宽、高,最后根据尺寸和重量判断计费重量。如果寄件高峰期连续来了很多包裹,反复进行“称重—测量—计算”不仅耗时,也容易因为人工读数产生误差。于是我想,能不能把这些步骤集中到一台设备上?工作人员只需要把包裹放到检测平台上,设备就能自动获取包裹尺寸和重量,并把结果用于后续计费重量和运费估算。

本项目以行空板 K10为主控,在包裹上方安装一个矩阵激光测距传感器,利用 64 个测距区域判断包裹的位置和轮廓,再通过背景差分和几何换算得到包裹的长、宽、高;再在平台下方加一个重量传感器,负责获取实际重量。最终,系统将尺寸与重量数据结合,得到体积重量和参考计费重量,并通过网页进行显示和运费估算。

基于8*8激光雷达测距的快递自动计费系统_image_1.webp

二、项目效果

三、项目制作准备

所需硬件

接线关系

使用4pin线将I2C扩展模块接在行空板K10的I2C接口,使用杜邦线将8×8矩阵激光测距传感器和HX711重量传感器分别连接到I2C扩展模块上。基于8*8激光雷达测距的快递自动计费系统_image_2.webp

所需软件

我们需要先下载安装Arduino IDE,并完成行空板 K10 的 Arduino 开发环境配置,只有安装好行空板 K10 对应的 BSP,Arduino IDE 才能识别行空板 K10,并完成程序的编译和上传。具体安装过程可参考:https://www.unihiker.com.cn/wiki/k10/ArduinoIDE_prepare

导入库

  1. 同时我们还需要导入8*8矩阵激光测距传感器库文件(附件1),提供直接读取64点数据功能,导入方法为:Sketch(项目)→ Include Library(导入库)→ Add .ZIP Library(添加 .ZIP 库)。
  2. 同样还需要导入 重量传感器库文件(附件2)。该库用于完成行空板 K10 与重量传感器之间的通信,并提供初始化、去皮和重量读取等功能。
  3. 基于8*8激光雷达测距的快递自动计费系统_image_3.webp

四、项目制作步骤基于8*8激光雷达测距的快递自动计费系统_image_4.webp

整个项目的实现是围绕“如何自动获得快递计费所需的完整数据”来设计整个系统。传统方式通常需要人工使用卷尺测量长、宽、高,再单独称重;本项目则利用 8×8 ToF 深度矩阵一次获得 64 个区域的距离信息,再结合传感器视场角和深度信息计算长、宽、高,相比单点激光测距,可以得到更完整的空间尺寸信息。重量传感器则负责获取包裹实际重量,两类传感器分别解决“包裹有多大”和“包裹有多重”的问题,并在行空板 K10 中进行数据融合,根据长、宽、高计算体积重量,再与实际重量比较得到计费重量。

随后行空板 K10 通过 USB 串口将尺寸和重量结果发送到网页端,网页利用 Web Serial API 读取数据,并提供寄收地址、快递公司等信息填写功能,再根据计费重量以及预设的首重、续重和地区价格规则计算预估运费。整个项目由“深度测量—重量采集—数据融合—网页交互—运费估算”几个部分组成,形成了从自动测量到实际业务应用的完整流程。

基于8*8激光雷达测距的快递自动计费系统_image_5.webp

第一阶段:快递尺寸测量

我们先利用8×8 矩阵激光测距传感器的测距功能获取测量区域内 64 个位置的距离信息,再由行空板 K10 对这些深度数据进行处理,判断包裹所在区域,并计算出长、宽、高。为了让测量过程更加直观,行空板K10 屏幕还会同步显示 8×8 检测区域以及当前测得的尺寸。完成这一部分后,系统就可以在不使用直尺的情况下,自动得到包裹的三维尺寸。

基于8*8激光雷达测距的快递自动计费系统_image_6.webp

整个尺寸测量实际上分成两个部分:高度通过空平台距离与箱顶距离的差值得到,而长和宽则通过包裹边界在 8×8 深度图中的位置,结合视场角和箱顶距离进行几何投影得到。

空场景学习

在进行放置快递包裹之前,系统需要先进行一次空场景学习,用来记录传感器到空平台的基准距离。程序启动后,8*8矩阵激光测距传感器会连续采集多帧深度数据,并分别记录 64 个测距区域的距离,通过中值处理得到较稳定的背景深度。完成学习后,这 64 个背景距离就会作为后续判断包裹是否出现以及计算包裹高度的参考数据。

// -------------------- 空场景学习 --------------------
void startBackgroundLearning() {
    backgroundReady = false;
    backgroundCount = 0;
    // 清空历史背景数据
    memset(backgroundDepth, 0, sizeof(backgroundDepth));
    memset(backgroundSamples, 0, sizeof(backgroundSamples));

    Serial.println("开始空场景学习,请保持平台为空");
}
// 连续采集多帧空平台深度数据
void addBackgroundFrame(const uint16_t *frame) {
    if (backgroundCount >= BACKGROUND_FRAMES) return;
    uint8_t validCount = 0;
    for (uint8_t i = 0; i < ZONES; ++i) {
        backgroundSamples[backgroundCount][i] = frame[i];

        // 只统计有效距离点
        if (validDistance(frame[i])) {
            ++validCount;
        }
    }
    // 有效测距点足够时才保留这一帧
    if (validCount >= BACKGROUND_VALID_ZONES_MIN) {
        ++backgroundCount;
    }
    // 收集足够帧后完成背景学习
    if (backgroundCount >= BACKGROUND_FRAMES) {
        finishBackgroundLearning();
    }
}

深度距离转换成长宽高

在完成空场景学习后,把快递放到平台上后,系统测得传感器到快递中心顶部的距离,例如为 350 mm。与之前空场景学习时传感器到空平台距离为500mm相比较,因此快递高度可以直接由两者之差得快递的高度150mm。

之后从 8×8 深度数据中找到包裹的左右、上下边界,再根据边界在传感器视场中的位置换算成对应角度。例如左右边界相对于中心分别约为 -15.9° 和 15.9°,而箱顶中心距离为 350 mm,利用三角投影关系可算得宽度大约200mm,同理当上下边界相对于中心分别约为 -23.2° 和 23.2°时,可算得长度大约300mm。

宽 = Ztop × (tanθR - tanθL)
   = 350 × [tan(15.9°) - tan(-15.9°)]
   ≈ 200 mm
   
长度 = 350 × |tan(23.2°) - tan(-23.2°)|
     ≈ 350 × 0.8594
     ≈ 300.8 mm

快速实现

硬件连接完成后,行空板K10通过USB线连接电脑,在 Arduino IDE 中选择行空板 K10 作为开发板,并选择对应的串口,然后打开完整的尺寸测量程序进行上传。

基于8*8激光雷达测距的快递自动计费系统_image_7.webp

基于8*8激光雷达测距的快递自动计费系统_image_8.webp

上传代码后,点击向右箭头的图标,开始运行并上传

基于8*8激光雷达测距的快递自动计费系统_image_9.webp

尺寸测量实现完整代码

程序首先根据背景深度与当前深度的差值提取包裹区域,并保留最大的连续区域,减少噪声点干扰;随后分别统计横向和纵向的置信度分布,通过边缘插值得到包裹在 8×8 网格中的左右、上下边界。再结合传感器 的 60° 视场角,将网格边界转换为空间角度,并利用测得的包裹顶部距离,通过三角投影计算实际长、宽;包裹高度则由空平台背景距离与当前包裹顶部距离之差得到。最终将较大的水平尺寸作为长度、较小的作为宽度,并输出长、宽、高三项测量结果。

#include <Arduino.h>
#include <math.h>
#include <string.h>
#include "unihiker_k10.h"
#include "DFRobot_MatrixLidar.h"

UNIHIKER_K10 k10;
DFRobot_MatrixLidar_I2C tof(0x33);

// ---------- 参数 ----------
constexpr uint8_t GRID=8, ZONES=64;
constexpr float FOV_X_DEG=60.0f, FOV_Y_DEG=60.0f;

// ---------- 尺寸标定 ----------
// 标准箱实际尺寸:300 × 200 × 150 mm
// 当前稳定测量约:261 × 214 × 156 mm
// 只修正最终长/宽/高,不改变背景学习、前景提取和8×8色块显示。
constexpr float LENGTH_SCALE = 300.0f / 261.0f; // ≈1.1494
constexpr float WIDTH_SCALE  = 200.0f / 214.0f; // ≈0.9346
constexpr float HEIGHT_SCALE = 150.0f / 156.0f; // ≈0.9615
constexpr uint16_t MIN_VALID_MM=20, MAX_VALID_MM=3900;

constexpr float FOREGROUND_START_MM=35.0f;
constexpr float FOREGROUND_FULL_MM=100.0f;
constexpr float MASK_LEVEL=0.35f;
constexpr float EDGE_MIN_LEVEL=0.50f, EDGE_FRACTION=0.60f;
constexpr uint8_t MIN_COMPONENT_ZONES=3;
constexpr float MIN_PACKAGE_HEIGHT_MM=45.0f;

constexpr uint8_t BACKGROUND_FRAMES=36;
constexpr uint8_t BACKGROUND_MIN_FRAMES=28;
constexpr uint8_t BACKGROUND_VALID_ZONES_MIN=40;
constexpr uint32_t BACKGROUND_TIMEOUT_MS=6000;

constexpr uint8_t DEPTH_FILTER_FRAMES=3;
constexpr uint8_t DETECT_CONFIRM_FRAMES=2;
constexpr uint8_t REMOVE_CONFIRM_FRAMES=4;
constexpr uint8_t MEASURE_FILTER_FRAMES=3;
constexpr uint32_t FRAME_INTERVAL_MS=60;
constexpr uint32_t DISPLAY_INTERVAL_MS=120;

constexpr uint8_t SCREEN_DIR=2;
constexpr int SCREEN_W=240, SCREEN_H=320;
constexpr int MAP_CELL=20, MAP_SIZE=MAP_CELL*GRID;
constexpr int MAP_X=(SCREEN_W-MAP_SIZE)/2, MAP_Y=40;

constexpr uint32_t C_BG=0xFFFFFF, C_PANEL=0xE8F0F7, C_BORDER=0xC5D3E0;
constexpr uint32_t C_TEXT=0x1F2937, C_MUTED=0x64748B;
constexpr uint32_t C_BLUE=0x24A7F2, C_GREEN=0x28D17C;
constexpr uint32_t C_DONE_GREEN=0x58E878, C_YELLOW=0xF4D447, C_RED=0xFF5B64;
constexpr uint32_t C_HEAT_FRAME=0x123A5B, C_HEAT_GRID=0x0A2B43;
constexpr bool ROTATE_MATRIX_180=true;

// ---------- 数据 ----------
uint16_t rawDepth[ZONES]={0}, filteredDepth[ZONES]={0}, backgroundDepth[ZONES]={0};
uint16_t backgroundSamples[BACKGROUND_FRAMES][ZONES]={{0}};
uint16_t depthHistory[DEPTH_FILTER_FRAMES][ZONES]={{0}};

uint8_t backgroundCount=0, historyCount=0, historyHead=0;
bool backgroundReady=false;
float learningProgressDisplay=0;
uint32_t backgroundLearningStartMs=0;

float confidenceMap[ZONES]={0};
bool componentMask[ZONES]={false};

struct Measurement{
  float lengthMm=0, widthMm=0, heightMm=0, distanceMm=0;
  uint8_t zoneCount=0;
  bool valid=false;
};

Measurement measurementHistory[MEASURE_FILTER_FRAMES], displayedMeasurement;
uint8_t measurementCount=0, measurementHead=0;
bool packagePresent=false;
uint8_t detectedFrames=0, emptyFrames=0;
uint32_t lastFrameMs=0, lastDisplayMs=0, lastSerialMs=0;
bool previousButtonA=false;

// ---------- 通用 ----------
bool validDistance(uint16_t mm){ return mm>=MIN_VALID_MM && mm<MAX_VALID_MM; }
float clamp01(float v){ return v<0?0:(v>1?1:v); }

float zoneAngleRad(uint8_t i,float fov){
  return (((i+0.5f)/GRID)-0.5f)*fov*DEG_TO_RAD;
}

float axialDistanceMm(uint16_t radial,uint8_t row,uint8_t col){
  float tx=tanf(zoneAngleRad(col,FOV_X_DEG));
  float ty=tanf(zoneAngleRad(row,FOV_Y_DEG));
  return radial/sqrtf(1.0f+tx*tx+ty*ty);
}

template<typename T>
void insertionSort(T *a,uint8_t n){
  for(uint8_t i=1;i<n;i++){
    T key=a[i]; int j=i-1;
    while(j>=0 && a[j]>key){ a[j+1]=a[j]; j--; }
    a[j+1]=key;
  }
}

float medianFloat(float *a,uint8_t n){
  if(!n) return 0;
  insertionSort(a,n);
  return (n&1)?a[n/2]:0.5f*(a[n/2-1]+a[n/2]);
}

uint16_t medianU16(uint16_t *a,uint8_t n){
  if(!n) return 0;
  insertionSort(a,n);
  return (n&1)?a[n/2]:(uint16_t)(((uint32_t)a[n/2-1]+a[n/2])/2);
}

uint8_t displayIndex(uint8_t row,uint8_t col){
  return ROTATE_MATRIX_180 ? (GRID-1-row)*GRID+(GRID-1-col) : row*GRID+col;
}

void setRgb(uint32_t c){ k10.rgb->setRangeColor(0,2,c); }

// ---------- 背景学习 ----------
void resetMeasurementState(){
  packagePresent=false;
  detectedFrames=emptyFrames=measurementCount=measurementHead=0;
  displayedMeasurement=Measurement();
  memset(componentMask,0,sizeof(componentMask));
}

void startBackgroundLearning(){
  backgroundReady=false;
  backgroundCount=historyCount=historyHead=0;
  learningProgressDisplay=0;
  backgroundLearningStartMs=millis();
  memset(backgroundDepth,0,sizeof(backgroundDepth));
  memset(backgroundSamples,0,sizeof(backgroundSamples));
  memset(depthHistory,0,sizeof(depthHistory));
  resetMeasurementState();
  setRgb(C_YELLOW);
  Serial.println("Background learning started. Keep platform empty.");
}

void finishBackgroundLearning(){
  for(uint8_t z=0;z<ZONES;z++){
    uint16_t values[BACKGROUND_FRAMES];
    uint8_t n=0;
    for(uint8_t f=0;f<backgroundCount;f++){
      uint16_t v=backgroundSamples[f][z];
      if(validDistance(v)) values[n++]=v;
    }
    backgroundDepth[z]=(n>=BACKGROUND_FRAMES/2)?medianU16(values,n):0;
  }
  backgroundReady=true;
  historyCount=historyHead=0;
  setRgb(C_BLUE);
  Serial.println("Background learning complete.");
}

void addBackgroundFrame(const uint16_t *frame){
  if(backgroundCount>=BACKGROUND_FRAMES) return;

  uint8_t valid=0;
  for(uint8_t i=0;i<ZONES;i++){
    backgroundSamples[backgroundCount][i]=frame[i];
    if(validDistance(frame[i])) valid++;
  }
  if(valid>=BACKGROUND_VALID_ZONES_MIN) backgroundCount++;

  if(backgroundCount>=BACKGROUND_FRAMES){
    finishBackgroundLearning();
  }else if(millis()-backgroundLearningStartMs>=BACKGROUND_TIMEOUT_MS &&
           backgroundCount>=BACKGROUND_MIN_FRAMES){
    finishBackgroundLearning();
  }
}

// ---------- 深度滤波 ----------
void updateDepthMedian(const uint16_t *frame){
  memcpy(depthHistory[historyHead],frame,sizeof(uint16_t)*ZONES);
  historyHead=(historyHead+1)%DEPTH_FILTER_FRAMES;
  if(historyCount<DEPTH_FILTER_FRAMES) historyCount++;

  for(uint8_t z=0;z<ZONES;z++){
    uint16_t values[DEPTH_FILTER_FRAMES];
    uint8_t n=0;
    for(uint8_t h=0;h<historyCount;h++){
      uint16_t v=depthHistory[h][z];
      if(validDistance(v)) values[n++]=v;
    }
    filteredDepth[z]=medianU16(values,n);
  }
}

// ---------- 前景提取 ----------
void buildConfidenceMap(){
  for(uint8_t row=0;row<GRID;row++){
    for(uint8_t col=0;col<GRID;col++){
      uint8_t i=row*GRID+col;
      if(!validDistance(backgroundDepth[i]) || !validDistance(filteredDepth[i])){
        confidenceMap[i]=0;
        continue;
      }
      float bg=axialDistanceMm(backgroundDepth[i],row,col);
      float now=axialDistanceMm(filteredDepth[i],row,col);
      confidenceMap[i]=clamp01((bg-now-FOREGROUND_START_MM)/
                               (FOREGROUND_FULL_MM-FOREGROUND_START_MM));
    }
  }
}

uint8_t keepLargestComponent(){
  bool visited[ZONES]={false}, best[ZONES]={false};
  uint8_t bestCount=0;
  const int dr[8]={-1,-1,-1,0,0,1,1,1};
  const int dc[8]={-1,0,1,-1,1,-1,0,1};

  for(uint8_t seed=0;seed<ZONES;seed++){
    if(visited[seed] || confidenceMap[seed]<MASK_LEVEL) continue;

    uint8_t q[ZONES], members[ZONES], head=0, tail=0, count=0;
    q[tail++]=seed; visited[seed]=true;

    while(head<tail){
      uint8_t cur=q[head++];
      members[count++]=cur;
      int row=cur/GRID, col=cur%GRID;

      for(uint8_t n=0;n<8;n++){
        int nr=row+dr[n], nc=col+dc[n];
        if(nr<0 || nr>=GRID || nc<0 || nc>=GRID) continue;
        uint8_t next=nr*GRID+nc;
        if(!visited[next] && confidenceMap[next]>=MASK_LEVEL){
          visited[next]=true;
          q[tail++]=next;
        }
      }
    }

    if(count>bestCount){
      memset(best,0,sizeof(best));
      for(uint8_t i=0;i<count;i++) best[members[i]]=true;
      bestCount=count;
    }
  }

  memcpy(componentMask,best,sizeof(componentMask));
  return bestCount;
}

bool profileEdges(const float p[GRID],float &left,float &right){
  float peak=0;
  for(uint8_t i=0;i<GRID;i++) peak=max(peak,p[i]);
  if(peak<EDGE_MIN_LEVEL) return false;

  float level=max(EDGE_MIN_LEVEL,peak*EDGE_FRACTION);
  int first=-1,last=-1;
  for(uint8_t i=0;i<GRID;i++){
    if(p[i]>=level){ if(first<0) first=i; last=i; }
  }
  if(first<0) return false;

  float inX=first+0.5f, outX=first-0.5f;
  float inV=p[first], outV=first>0?p[first-1]:0;
  float den=inV-outV;
  left=fabsf(den)>0.0001f ? outX+(level-outV)/den : (float)first;

  inX=last+0.5f; outX=last+1.5f;
  inV=p[last]; outV=last<GRID-1?p[last+1]:0;
  den=outV-inV;
  right=fabsf(den)>0.0001f ? inX+(level-inV)/den : (float)(last+1);

  left=constrain(left,0.0f,(float)GRID);
  right=constrain(right,0.0f,(float)GRID);
  return right>left;
}

float gridBoundaryAngle(float b,float fov){
  return ((b/GRID)-0.5f)*fov*DEG_TO_RAD;
}

// ---------- 长宽高 ----------
Measurement analyzeForeground(){
  Measurement r;
  buildConfidenceMap();
  uint8_t zones=keepLargestComponent();
  if(zones<MIN_COMPONENT_ZONES) return r;

  float colP[GRID]={0}, rowP[GRID]={0};
  float allZ[ZONES], allH[ZONES], allR[ZONES];
  float coreZ[ZONES], coreH[ZONES], coreR[ZONES];
  uint8_t allN=0, coreN=0;

  for(uint8_t row=0;row<GRID;row++){
    for(uint8_t col=0;col<GRID;col++){
      uint8_t i=row*GRID+col;
      if(!componentMask[i]) continue;

      float c=confidenceMap[i];
      colP[col]+=c; rowP[row]+=c;

      float z=axialDistanceMm(filteredDepth[i],row,col);
      float bg=axialDistanceMm(backgroundDepth[i],row,col);
      float h=bg-z;

      allZ[allN]=z; allH[allN]=h; allR[allN]=filteredDepth[i]; allN++;
      if(c>=0.72f){
        coreZ[coreN]=z; coreH[coreN]=h; coreR[coreN]=filteredDepth[i]; coreN++;
      }
    }
  }
  if(!allN) return r;

  float maxCol=0,maxRow=0;
  for(uint8_t i=0;i<GRID;i++){
    maxCol=max(maxCol,colP[i]);
    maxRow=max(maxRow,rowP[i]);
  }
  if(maxCol<=0.0001f || maxRow<=0.0001f) return r;

  for(uint8_t i=0;i<GRID;i++){ colP[i]/=maxCol; rowP[i]/=maxRow; }

  float left,right,top,bottom;
  if(!profileEdges(colP,left,right) || !profileEdges(rowP,top,bottom)) return r;

  bool useCore=coreN>=2;
  float topZ=useCore?medianFloat(coreZ,coreN):medianFloat(allZ,allN);
  float height=useCore?medianFloat(coreH,coreN):medianFloat(allH,allN);
  float radial=useCore?medianFloat(coreR,coreN):medianFloat(allR,allN);
  if(topZ<=0 || height<MIN_PACKAGE_HEIGHT_MM) return r;

  float aL=gridBoundaryAngle(left,FOV_X_DEG);
  float aR=gridBoundaryAngle(right,FOV_X_DEG);
  float aT=gridBoundaryAngle(top,FOV_Y_DEG);
  float aB=gridBoundaryAngle(bottom,FOV_Y_DEG);

  float x=topZ*fabsf(tanf(aR)-tanf(aL));
  float y=topZ*fabsf(tanf(aB)-tanf(aT));

  // 原始几何结果
  const float rawLength=max(x,y);
  const float rawWidth=min(x,y);

  // 针对当前固定安装结构做三轴标定
  r.lengthMm=rawLength*LENGTH_SCALE;
  r.widthMm=rawWidth*WIDTH_SCALE;
  r.heightMm=height*HEIGHT_SCALE;
  r.distanceMm=radial;
  r.zoneCount=zones;
  r.valid=r.lengthMm>=20.0f && r.widthMm>=20.0f;
  return r;
}

void addMeasurement(const Measurement &m){
  if(displayedMeasurement.valid &&
     (fabsf(m.lengthMm-displayedMeasurement.lengthMm)>45 ||
      fabsf(m.widthMm-displayedMeasurement.widthMm)>45 ||
      fabsf(m.heightMm-displayedMeasurement.heightMm)>35)){
    measurementCount=measurementHead=0;
  }

  measurementHistory[measurementHead]=m;
  measurementHead=(measurementHead+1)%MEASURE_FILTER_FRAMES;
  if(measurementCount<MEASURE_FILTER_FRAMES) measurementCount++;

  float L[MEASURE_FILTER_FRAMES],W[MEASURE_FILTER_FRAMES];
  float H[MEASURE_FILTER_FRAMES],D[MEASURE_FILTER_FRAMES];
  for(uint8_t i=0;i<measurementCount;i++){
    L[i]=measurementHistory[i].lengthMm;
    W[i]=measurementHistory[i].widthMm;
    H[i]=measurementHistory[i].heightMm;
    D[i]=measurementHistory[i].distanceMm;
  }

  float l=medianFloat(L,measurementCount);
  float w=medianFloat(W,measurementCount);
  float h=medianFloat(H,measurementCount);
  float d=medianFloat(D,measurementCount);

  if(!displayedMeasurement.valid){
    displayedMeasurement.lengthMm=l;
    displayedMeasurement.widthMm=w;
    displayedMeasurement.heightMm=h;
    displayedMeasurement.distanceMm=d;
  }else{
    if(fabsf(l-displayedMeasurement.lengthMm)>2.5f)
      displayedMeasurement.lengthMm=0.35f*displayedMeasurement.lengthMm+0.65f*l;
    if(fabsf(w-displayedMeasurement.widthMm)>2.5f)
      displayedMeasurement.widthMm=0.35f*displayedMeasurement.widthMm+0.65f*w;
    if(fabsf(h-displayedMeasurement.heightMm)>2.0f)
      displayedMeasurement.heightMm=0.30f*displayedMeasurement.heightMm+0.70f*h;
    if(fabsf(d-displayedMeasurement.distanceMm)>2.0f)
      displayedMeasurement.distanceMm=0.35f*displayedMeasurement.distanceMm+0.65f*d;
  }

  displayedMeasurement.zoneCount=m.zoneCount;
  displayedMeasurement.valid=true;
}

void updatePresenceState(const Measurement &m){
  if(m.valid){
    emptyFrames=0;
    if(detectedFrames<DETECT_CONFIRM_FRAMES) detectedFrames++;
    if(!packagePresent) setRgb(C_YELLOW);

    if(detectedFrames>=DETECT_CONFIRM_FRAMES){
      if(!packagePresent){
        packagePresent=true;
        measurementCount=measurementHead=0;
        setRgb(C_GREEN);
      }
      addMeasurement(m);
    }
  }else{
    detectedFrames=0;
    if(packagePresent){
      if(emptyFrames<REMOVE_CONFIRM_FRAMES) emptyFrames++;
      if(emptyFrames>=REMOVE_CONFIRM_FRAMES){
        resetMeasurementState();
        setRgb(C_BLUE);
      }
    }else setRgb(C_BLUE);
  }
}

// ---------- UI ----------
void fillScreen(uint32_t c){
  k10.canvas->canvasRectangle(0,0,SCREEN_W,SCREEN_H,c,c,true);
}

void drawText(const String &s,int x,int y,uint32_t c,
              Canvas::eFontSize_t font=Canvas::eCNAndENFont16){
  k10.canvas->canvasText(s,x,y,c,font,30,false);
}

String cm(float mm){ return String(mm/10.0f,1); }

uint32_t matrixCellColor(uint8_t i){
  if(!packagePresent && detectedFrames==0) return C_BLUE;
  if(!packagePresent) return componentMask[i]?C_YELLOW:C_BLUE;
  return componentMask[i]?C_DONE_GREEN:C_BLUE;
}

void drawStateMatrix(){
  k10.canvas->canvasSetLineWidth(1);
  k10.canvas->canvasRectangle(MAP_X-2,MAP_Y-2,MAP_SIZE+4,MAP_SIZE+4,
                              C_HEAT_FRAME,C_HEAT_FRAME,true);

  for(uint8_t row=0;row<GRID;row++){
    for(uint8_t col=0;col<GRID;col++){
      uint8_t i=displayIndex(row,col);
      int x=MAP_X+col*MAP_CELL, y=MAP_Y+row*MAP_CELL;
      uint32_t c=matrixCellColor(i);
      k10.canvas->canvasRectangle(x,y,MAP_CELL,MAP_CELL,C_HEAT_GRID,C_HEAT_GRID,true);
      k10.canvas->canvasRectangle(x+1,y+1,MAP_CELL-2,MAP_CELL-2,c,c,true);
    }
  }
}

void drawLearningScreen(){
  fillScreen(C_BG);
  drawText("正在学习",72,72,C_BLUE,Canvas::eCNAndENFont24);
  drawText("请保持平台空置",56,132,C_MUTED);

  float target=backgroundCount*100.0f/BACKGROUND_FRAMES;
  learningProgressDisplay+=(target-learningProgressDisplay)*0.32f;
  if(fabsf(target-learningProgressDisplay)<0.2f) learningProgressDisplay=target;
  int p=constrain((int)(learningProgressDisplay+0.5f),0,100);

  k10.canvas->canvasRectangle(32,190,176,14,C_BORDER,C_PANEL,true);
  k10.canvas->canvasRectangle(34,192,172*p/100,10,C_BLUE,C_BLUE,true);
  drawText(String(p)+"%",p>=100?86:94,220,C_TEXT,Canvas::eCNAndENFont24);
  k10.canvas->updateCanvas();
}

void drawMainScreen(){
  if(lastDisplayMs && millis()-lastDisplayMs<DISPLAY_INTERVAL_MS) return;
  lastDisplayMs=millis();

  fillScreen(C_BG);
  drawText("包裹尺寸",72,7,C_TEXT,Canvas::eCNAndENFont24);
  drawStateMatrix();

  String s="-- x -- x -- cm";
  if(packagePresent && displayedMeasurement.valid){
    s=cm(displayedMeasurement.lengthMm)+" x "+
      cm(displayedMeasurement.widthMm)+" x "+
      cm(displayedMeasurement.heightMm)+" cm";
  }
  int x=s.length()<=16?22:(s.length()<=20?12:6);
  drawText(s,x,202,C_TEXT,Canvas::eCNAndENFont24);
  k10.canvas->updateCanvas();
}

void drawSensorError(){
  fillScreen(C_BG);
  drawText("传感器错误",60,82,C_RED,Canvas::eCNAndENFont24);
  drawText("SEN0628 未连接",54,142,C_TEXT);
  drawText("请检查 I2C 接线",58,178,C_MUTED);
  k10.canvas->updateCanvas();
}

void handleButtonA(){
  bool a=k10.buttonA->isPressed();
  if(a && !previousButtonA) startBackgroundLearning();
  previousButtonA=a;
}

// ---------- 串口 ----------
void printDiagnostics(const Measurement &m){
  if(millis()-lastSerialMs<500) return;
  lastSerialMs=millis();

  Serial.print("state=");
  Serial.print(!backgroundReady?"LEARNING":(packagePresent?"PRESENT":"WAITING"));
  Serial.print(" zones=");
  Serial.print(m.zoneCount);
  Serial.print(" L/W/H(mm)=");

  if(displayedMeasurement.valid){
    Serial.print(displayedMeasurement.lengthMm,1); Serial.print("/");
    Serial.print(displayedMeasurement.widthMm,1);  Serial.print("/");
    Serial.println(displayedMeasurement.heightMm,1);
  }else{
    Serial.println("--/--/--");
  }
}

// ---------- 初始化 ----------
void setup(){
  Serial.begin(115200);
  delay(1000);

  k10.begin();
  k10.initScreen(SCREEN_DIR);
  k10.creatCanvas();
  k10.setScreenBackground(C_BG);
  k10.rgb->brightness(20);

  fillScreen(C_BG);
  drawText("正在连接传感器",42,120,C_BLUE,Canvas::eCNAndENFont24);
  k10.canvas->updateCanvas();

  int r=tof.begin();
  Serial.print("tof.begin result = "); Serial.println(r);
  if(r!=0){
    drawSensorError();
    while(1) delay(100);
  }

  r=tof.setRangingMode(eMatrix_8X8);
  Serial.print("setRangingMode result = "); Serial.println(r);
  if(r!=0){
    drawSensorError();
    while(1) delay(100);
  }

  Serial.println("SEN0628 8x8 ready.");
  delay(300);
  startBackgroundLearning();
}

// ---------- 主循环 ----------
void loop(){
  handleButtonA();

  uint32_t now=millis();
  if(now-lastFrameMs<FRAME_INTERVAL_MS){ delay(2); return; }
  lastFrameMs=now;

  if(tof.getAllData(rawDepth)!=0){
    Serial.println("getAllData failed");
    delay(20);
    return;
  }

  if(!backgroundReady){
    addBackgroundFrame(rawDepth);
    drawLearningScreen();
    return;
  }

  updateDepthMedian(rawDepth);

  Measurement current;
  if(historyCount>=DEPTH_FILTER_FRAMES) current=analyzeForeground();

  updatePresenceState(current);
  drawMainScreen();
  printDiagnostics(current);
}

运行效果

由8*8矩阵激光传感器所测得数据,将深度距离转换为长宽高后为(29.6cm×19.4cm×14.9cm),快递的实际尺寸是(30cm×20cm×15cm),都保持在1cm以内的误差,具有真实可靠性。

基于8*8激光雷达测距的快递自动计费系统_image_10.webp

第二阶段:加入重量检测

前面已经利用 8*8矩阵激光测距传感器完成了对快递长、宽、高的自动测量,但在实际寄件过程中,仅有尺寸数据还不足以确定最终的计费重量。因为快递计费通常还需要考虑包裹的实际重量,对于体积较大但重量较轻的包裹,还可能按照体积重量进行计算。因此,下一步在现有尺寸测量功能的基础上加入重量传感器,让行空板K10在测得长、宽、高的同时获取包裹实际重量,再将尺寸和重量数据结合起来,完成体积重量和计费重量的计算。

基于8*8激光雷达测距的快递自动计费系统_image_11.webp

重量检测部分采用I²C 重量传感器。程序启动后首先完成传感器初始化和空载去皮,随后周期性读取重量数据。考虑到秤台受机械振动和传感器噪声影响,单次读数容易出现波动,因此程序先对传感器进行多次采样,再使用 5 点中值滤波去除瞬时异常值,同时设置零点死区,将小于 5 g 的微小波动直接归零,从而获得更加稳定的实际重量。

重量传感器代码:

// -------------------- 重量传感器初始化 --------------------
void initializeScale() {
    // 初始化 HX711 I2C 重量传感器
    scaleConnected = scaleI2C.begin();

    if (!scaleConnected) {
        Serial.println("HX711 not detected");
        return;
    }

    // 设置校准系数并进行空载去皮
    scaleI2C.setCalibration(HX711_CALIBRATION_VALUE);
    scaleI2C.peel();

    // 清空重量滤波状态
    resetWeightFilterState();

    Serial.println("HX711 I2C ready and tared.");
}

// -------------------- 重量读取 --------------------
void updateWeight() {
    if (!scaleConnected) return;

    // 读取4次重量并取传感器内部平均值
    float grams = fabsf(scaleI2C.readWeight(4));

    // 小于5g认为是零点抖动
    if (grams < WEIGHT_ZERO_BAND_G) {
        grams = 0.0f;
    }

    // 保存到历史窗口,后续进行中值滤波
    weightHistoryG[weightHistoryHead] = grams;
    weightHistoryHead =
        (weightHistoryHead + 1) % WEIGHT_MEDIAN_WINDOW;

    if (weightHistoryCount < WEIGHT_MEDIAN_WINDOW) {
        ++weightHistoryCount;
    }

    // 得到抗异常值后的重量
    const float robustG = robustWeightMedian();
}

在实际快递计费中,会存在体积重量和实际重量,其中体积重量由测得的快递“长、宽、高”单位先换算成厘米,之后算得快递体积(单位:CM3),再由快递的体积除以体积系数6000得到,然后再和快递的真实重量(单位:KG)进行比较,取较大的一个作为计费重量,然后在按照设定的计费规则向上取整。

计费重量来源代码:

// 将实际重量由克转换成千克。
const float actualWeightKg =
    max(0.0f, filteredWeightG) /
    1000.0f;

// 长度由mm转换成cm。
const float lengthCm =
    displayedMeasurement.lengthMm /
    10.0f;
// 宽度由mm转换成cm。
const float widthCm =
    displayedMeasurement.widthMm /
    10.0f;
// 高度由mm转换成cm。
const float heightCm =
    displayedMeasurement.heightMm /
    10.0f;


// 根据项目当前设置的体积系数6000,
// 计算体积重量。
displayedVolumeWeightKg =
    (lengthCm *
     widthCm *
     heightCm) /
    VOLUME_DIVISOR;


// 实际重量和体积重量中取较大者,
// 再按照程序设定的计费单位向上取整。
displayedChargeableKg =
    roundChargeWeight(
        max(
            actualWeightKg,
            displayedVolumeWeightKg
        )
    );

运行效果

加入重量传感器之后,测得快递的真实重量为0.87kg,而所测得快递尺寸数据为(29.5cm×20.0cm×15.1cm)(每次测量时可能会因为放的位置不一样,所以测量结果会存在比较微小的波动,但并不影响最后预估收费),所以体积重量为(29.5×20.0×15.1÷6000)1.49kg。

基于8*8激光雷达测距的快递自动计费系统_image_12.webp

第三阶段:通过网页获取寄件价格

我们已经完成了包裹长、宽、高以及实际重量的自动采集,行空板 K10 也能够在屏幕上直接显示测量结果。在实际寄快递时,后面还需要填写寄件人和收件人信息、选择快递公司,并根据包裹尺寸和重量进一步估算运费。因此,在完成尺寸与重量检测之后,我又采用 HTML、CSS 和 JavaScript 加入了实时网页端,将行空板 K10 采集到的数据通过 USB 串口继续发送到电脑,网页使用浏览器 Web Serial API 直接读取并解析串口数据,再将测量结果实时显示在页面中。用户随后填写寄收地址并选择快递公司,网页根据计费重量匹配预设的首重、续重和地区价格规则,计算预估运费。

基于8*8激光雷达测距的快递自动计费系统_image_13.webp

我们可以下载html文件(附件3),双击打开之后,点击串口连接(此时Arduino IDE里的串口一定要关闭),然后行空板K10的实时数据数据就会显示在这个界面上,然后我们可以填写收寄地址及用户信息,并选择快递承运商和以及服务模式。

基于8*8激光雷达测距的快递自动计费系统_image_14.webp

第四阶段:获取预估价格

根据常见快递公司平台公布的常规寄件报价,做一个相对应的价格表,对应不同的收费标准,采用的是首重+续重的标准,首重1KG,当超出首重时,按续重收费,续重费用随着不同承运商和收寄地址远近而不同,续重不足1KG的按1KG算。当用户选择要寄件的快递公司时,系统会自动计算相应的预估报价。

基于8*8激光雷达测距的快递自动计费系统_image_15.webp

当前项目主要用于原型验证,因此采用本地价格表模拟报价,后续可进一步接入快递公司的官方接口或第三方物流 API,实现真实运费查询和下单。使整个项目从“自动测量”进一步扩展为“测量 + 信息填写 + 运费估算”的完整流程。

基于8*8激光雷达测距的快递自动计费系统_image_16.webp

完整代码

你也可以根据下方完整代码(或者下载附件4),实现整个功能!

#include <Arduino.h>
#include <Wire.h>
#include <math.h>
#include <string.h>
#include "unihiker_k10.h"
#include "DFRobot_MatrixLidar.h"
#include "DFRobot_HX711_I2C.h"

UNIHIKER_K10 k10;

// -------------------- 硬件参数 --------------------
static constexpr uint8_t HX711_I2C_ADDRESS = 0x64;

// I2C 模块的校准值通常由模块自动校准得到;这里使用库的默认值。
static constexpr float HX711_CALIBRATION_VALUE = 2236.0f;
static constexpr float WEIGHT_ZERO_BAND_G = 5.0f;

// -------------------- 重量换算规则 --------------------
// 体积重单位:kg = 长(cm) × 宽(cm) × 高(cm) / 6000。
// 参考计费重:实际重量与体积重量取较大值,再按 0.5 kg 向上取整。
// 注意:不计算运费。真实运费还需要寄件地、收件地、快递公司等信息。
static constexpr float VOLUME_DIVISOR = 6000.0f;
static constexpr float CHARGE_UNIT_KG = 0.5f;
static constexpr float WEIGHT_STABLE_DELTA_G = 3.0f;
static constexpr uint8_t WEIGHT_STABLE_FRAMES = 4;
static constexpr uint32_t WEIGHT_READ_INTERVAL_MS = 100;
static constexpr uint8_t WEIGHT_MEDIAN_WINDOW = 5;
static constexpr float WEIGHT_FAST_JUMP_G = 35.0f;
static constexpr float WEIGHT_MEDIUM_JUMP_G = 10.0f;
static constexpr float WEIGHT_FAST_ALPHA = 0.78f;
static constexpr float WEIGHT_MEDIUM_ALPHA = 0.48f;
static constexpr float WEIGHT_SLOW_ALPHA = 0.22f;
static constexpr float WEIGHT_LOCK_BAND_G = 8.0f;

// 已验证通过:SEN0628 I2C 地址固定为 0x33。
DFRobot_MatrixLidar_I2C tof(0x33);

// -------------------- 深度与几何参数 --------------------
static constexpr uint8_t GRID = 8;
static constexpr uint8_t ZONES = GRID * GRID;
static constexpr float FOV_X_DEG = 60.0f;
static constexpr float FOV_Y_DEG = 60.0f;

static constexpr uint16_t MIN_VALID_MM = 20;
static constexpr uint16_t MAX_VALID_MM = 3900;
static constexpr float FOREGROUND_START_MM = 35.0f;
static constexpr float FOREGROUND_FULL_MM  = 100.0f;
static constexpr float MASK_LEVEL = 0.35f;
static constexpr float EDGE_MIN_LEVEL = 0.50f;
static constexpr float EDGE_FRACTION = 0.60f;
static constexpr uint8_t MIN_COMPONENT_ZONES = 3;
static constexpr float MIN_PACKAGE_HEIGHT_MM = 45.0f;

static constexpr uint8_t BACKGROUND_FRAMES = 36;
static constexpr uint8_t BACKGROUND_MIN_FRAMES = 28;
static constexpr uint8_t BACKGROUND_VALID_ZONES_MIN = 40;
static constexpr uint32_t BACKGROUND_TIMEOUT_MS = 6000;
static constexpr uint8_t DEPTH_FILTER_FRAMES = 3;
static constexpr uint8_t DETECT_CONFIRM_FRAMES = 2;
static constexpr uint8_t REMOVE_CONFIRM_FRAMES = 4;
static constexpr uint8_t MEASURE_FILTER_FRAMES = 3;
static constexpr uint32_t FRAME_INTERVAL_MS = 60;
static constexpr uint32_t DISPLAY_INTERVAL_MS = 120;

// 某些安装方向会使矩阵画面上下左右颠倒;尺寸不受影响,只影响热图方向。
static constexpr bool ROTATE_MATRIX_180 = true;

// -------------------- 屏幕参数 --------------------
static constexpr uint8_t SCREEN_DIR = 2;
static constexpr int SCREEN_W = 240;
static constexpr int SCREEN_H = 320;
static constexpr int MAP_CELL = 20;
static constexpr int MAP_SIZE = MAP_CELL * GRID;
static constexpr int MAP_X = (SCREEN_W - MAP_SIZE) / 2;
static constexpr int MAP_Y = 40;

static constexpr uint32_t C_BG       = 0xFFFFFF;
static constexpr uint32_t C_HEADER   = 0xFFFFFF;
static constexpr uint32_t C_PANEL    = 0xE8F0F7;
static constexpr uint32_t C_BORDER   = 0xC5D3E0;
static constexpr uint32_t C_TEXT     = 0x1F2937;
static constexpr uint32_t C_MUTED    = 0x64748B;
static constexpr uint32_t C_GRID     = 0x149FE6;
static constexpr uint32_t C_BLUE     = 0x24A7F2;
static constexpr uint32_t C_GREEN    = 0x28D17C;
static constexpr uint32_t C_DONE_GREEN = 0x58E878;
static constexpr uint32_t C_YELLOW   = 0xF4D447;
static constexpr uint32_t C_RED      = 0xFF5B64;
static constexpr uint32_t C_INVALID  = 0x12486B;
static constexpr uint32_t C_HEAT_FRAME = 0x123A5B;
static constexpr uint32_t C_HEAT_GRID = 0x0A2B43;

DFRobot_HX711_I2C scaleI2C(&Wire, HX711_I2C_ADDRESS);
bool scaleConnected = false;
float filteredWeightG = 0.0f;
float previousWeightG = 0.0f;
float previousRobustWeightG = 0.0f;
float stableWeightG = 0.0f;
float weightHistoryG[WEIGHT_MEDIAN_WINDOW] = {0};
uint8_t weightHistoryCount = 0;
uint8_t weightHistoryHead = 0;
uint8_t weightStableFrames = 0;
bool weightLocked = false;
float displayedChargeableKg = 0.0f;
float displayedVolumeWeightKg = 0.0f;

// -------------------- 数据与状态 --------------------
uint16_t rawDepth[ZONES] = {0};
uint16_t filteredDepth[ZONES] = {0};
uint16_t backgroundDepth[ZONES] = {0};
uint16_t backgroundSamples[BACKGROUND_FRAMES][ZONES] = {{0}};
uint16_t depthHistory[DEPTH_FILTER_FRAMES][ZONES] = {{0}};

uint8_t backgroundCount = 0;
uint8_t historyCount = 0;
uint8_t historyHead = 0;
bool backgroundReady = false;
float learningProgressDisplay = 0.0f;
uint32_t backgroundLearningStartMs = 0;

float confidenceMap[ZONES] = {0};
bool componentMask[ZONES] = {false};

struct Measurement {
  float lengthMm = 0;
  float widthMm = 0;
  float heightMm = 0;
  float distanceMm = 0;
  uint8_t zoneCount = 0;
  bool valid = false;
};

Measurement measurementHistory[MEASURE_FILTER_FRAMES];
uint8_t measurementCount = 0;
uint8_t measurementHead = 0;
Measurement displayedMeasurement;

bool packagePresent = false;
uint8_t detectedFrames = 0;
uint8_t emptyFrames = 0;
uint32_t lastFrameMs = 0;
uint32_t lastDisplayMs = 0;
uint32_t lastSerialMs = 0;
bool previousButtonA = false;
bool previousButtonB = false;

// -------------------- 通用工具 --------------------
bool validDistance(uint16_t mm) {
  return mm >= MIN_VALID_MM && mm < MAX_VALID_MM;
}

float clamp01(float value) {
  if (value < 0.0f) return 0.0f;
  if (value > 1.0f) return 1.0f;
  return value;
}

float zoneAngleRad(uint8_t index, float fovDeg) {
  const float normalizedCenter = (index + 0.5f) / GRID;
  return (normalizedCenter - 0.5f) * fovDeg * DEG_TO_RAD;
}

float axialDistanceMm(uint16_t radialMm, uint8_t row, uint8_t col) {
  const float tx = tanf(zoneAngleRad(col, FOV_X_DEG));
  const float ty = tanf(zoneAngleRad(row, FOV_Y_DEG));
  return radialMm / sqrtf(1.0f + tx * tx + ty * ty);
}

template <typename T>
void insertionSort(T *values, uint8_t count) {
  for (uint8_t i = 1; i < count; ++i) {
    const T key = values[i];
    int j = i - 1;
    while (j >= 0 && values[j] > key) {
      values[j + 1] = values[j];
      --j;
    }
    values[j + 1] = key;
  }
}

float medianFloat(float *values, uint8_t count) {
  if (count == 0) return 0.0f;
  insertionSort(values, count);
  if (count & 1U) return values[count / 2];
  return 0.5f * (values[count / 2 - 1] + values[count / 2]);
}

uint16_t medianU16(uint16_t *values, uint8_t count) {
  if (count == 0) return 0;
  insertionSort(values, count);
  if (count & 1U) return values[count / 2];
  return static_cast<uint16_t>((static_cast<uint32_t>(values[count / 2 - 1]) +
                                values[count / 2]) / 2U);
}

uint8_t displayIndex(uint8_t row, uint8_t col) {
  if (ROTATE_MATRIX_180) return (GRID - 1 - row) * GRID + (GRID - 1 - col);
  return row * GRID + col;
}

void setRgb(uint32_t color) {
  k10.rgb->setRangeColor(0, 2, color);
}

// -------------------- 背景与深度滤波 --------------------
void resetMeasurementState() {
  packagePresent = false;
  detectedFrames = 0;
  emptyFrames = 0;
  measurementCount = 0;
  measurementHead = 0;
  displayedMeasurement = Measurement();
  displayedChargeableKg = 0.0f;
  displayedVolumeWeightKg = 0.0f;
  weightStableFrames = 0;
}

void startBackgroundLearning() {
  backgroundReady = false;
  backgroundCount = 0;
  learningProgressDisplay = 0.0f;
  backgroundLearningStartMs = millis();
  historyCount = 0;
  historyHead = 0;
  memset(backgroundDepth, 0, sizeof(backgroundDepth));
  memset(backgroundSamples, 0, sizeof(backgroundSamples));
  memset(depthHistory, 0, sizeof(depthHistory));
  resetMeasurementState();
  setRgb(C_YELLOW);
  Serial.println("Background learning started. Keep the platform empty.");
}

void finishBackgroundLearning() {
  for (uint8_t zone = 0; zone < ZONES; ++zone) {
    uint16_t values[BACKGROUND_FRAMES];
    uint8_t count = 0;
    for (uint8_t frame = 0; frame < backgroundCount; ++frame) {
      const uint16_t value = backgroundSamples[frame][zone];
      if (validDistance(value)) values[count++] = value;
    }
    backgroundDepth[zone] = count >= BACKGROUND_FRAMES / 2
                              ? medianU16(values, count)
                              : 0;
  }
  backgroundReady = true;
  historyCount = 0;
  historyHead = 0;
  setRgb(C_BLUE);
  Serial.println("Background learning complete.");
}

void addBackgroundFrame(const uint16_t *frame) {
  if (backgroundCount >= BACKGROUND_FRAMES) return;

  uint8_t validCount = 0;
  for (uint8_t i = 0; i < ZONES; ++i) {
    backgroundSamples[backgroundCount][i] = frame[i];
    if (validDistance(frame[i])) ++validCount;
  }

  // 丢弃大量无效点的整帧,防止通信瞬态污染背景。
  // 原版本要求 48/64 个有效点,个别环境下最后几帧会反复被丢弃,
  // 进度容易停在 90% 以上。这里适当放宽到 40/64,同时增加超时保护。
  if (validCount >= BACKGROUND_VALID_ZONES_MIN) {
    ++backgroundCount;
  }

  if (backgroundCount >= BACKGROUND_FRAMES) {
    finishBackgroundLearning();
    return;
  }

  // 正常情况下 36 帧很快即可完成;如果最后少量帧受干扰,
  // 只要已经收集到足够的背景帧,6 秒后直接使用现有样本完成学习。
  if (backgroundLearningStartMs != 0 &&
      millis() - backgroundLearningStartMs >= BACKGROUND_TIMEOUT_MS &&
      backgroundCount >= BACKGROUND_MIN_FRAMES) {
    finishBackgroundLearning();
  }
}

void updateDepthMedian(const uint16_t *frame) {
  memcpy(depthHistory[historyHead], frame, sizeof(uint16_t) * ZONES);
  historyHead = (historyHead + 1) % DEPTH_FILTER_FRAMES;
  if (historyCount < DEPTH_FILTER_FRAMES) ++historyCount;

  for (uint8_t zone = 0; zone < ZONES; ++zone) {
    uint16_t values[DEPTH_FILTER_FRAMES];
    uint8_t count = 0;
    for (uint8_t h = 0; h < historyCount; ++h) {
      const uint16_t value = depthHistory[h][zone];
      if (validDistance(value)) values[count++] = value;
    }
    filteredDepth[zone] = medianU16(values, count);
  }
}

// -------------------- 前景提取与几何计算 --------------------
void buildConfidenceMap() {
  for (uint8_t row = 0; row < GRID; ++row) {
    for (uint8_t col = 0; col < GRID; ++col) {
      const uint8_t i = row * GRID + col;
      if (!validDistance(backgroundDepth[i]) || !validDistance(filteredDepth[i])) {
        confidenceMap[i] = 0.0f;
        continue;
      }

      const float backgroundZ = axialDistanceMm(backgroundDepth[i], row, col);
      const float currentZ = axialDistanceMm(filteredDepth[i], row, col);
      const float deltaZ = backgroundZ - currentZ;
      confidenceMap[i] = clamp01((deltaZ - FOREGROUND_START_MM) /
                                 (FOREGROUND_FULL_MM - FOREGROUND_START_MM));
    }
  }
}

uint8_t keepLargestComponent() {
  bool visited[ZONES] = {false};
  bool bestMask[ZONES] = {false};
  uint8_t bestCount = 0;

  for (uint8_t seed = 0; seed < ZONES; ++seed) {
    if (visited[seed] || confidenceMap[seed] < MASK_LEVEL) continue;

    uint8_t queue[ZONES];
    uint8_t members[ZONES];
    uint8_t head = 0;
    uint8_t tail = 0;
    uint8_t memberCount = 0;
    queue[tail++] = seed;
    visited[seed] = true;

    while (head < tail) {
      const uint8_t current = queue[head++];
      members[memberCount++] = current;
      const int row = current / GRID;
      const int col = current % GRID;
      const int dr[8] = {-1, -1, -1, 0, 0, 1, 1, 1};
      const int dc[8] = {-1,  0,  1,-1, 1,-1, 0, 1};

      for (uint8_t n = 0; n < 8; ++n) {
        const int nr = row + dr[n];
        const int nc = col + dc[n];
        if (nr < 0 || nr >= GRID || nc < 0 || nc >= GRID) continue;
        const uint8_t next = nr * GRID + nc;
        if (!visited[next] && confidenceMap[next] >= MASK_LEVEL) {
          visited[next] = true;
          queue[tail++] = next;
        }
      }
    }

    if (memberCount > bestCount) {
      memset(bestMask, 0, sizeof(bestMask));
      for (uint8_t i = 0; i < memberCount; ++i) bestMask[members[i]] = true;
      bestCount = memberCount;
    }
  }

  memcpy(componentMask, bestMask, sizeof(componentMask));
  return bestCount;
}

bool profileEdges(const float profile[GRID], float &left, float &right) {
  float peak = 0.0f;
  for (uint8_t i = 0; i < GRID; ++i) peak = max(peak, profile[i]);
  if (peak < EDGE_MIN_LEVEL) return false;

  // Use a stricter interior threshold for the measured boundary. The
  // connected-component mask stays permissive for presence detection, while
  // weak halo cells at the edge are excluded from L/W estimation.
  const float level = max(EDGE_MIN_LEVEL, peak * EDGE_FRACTION);
  int first = -1;
  int last = -1;
  for (uint8_t i = 0; i < GRID; ++i) {
    if (profile[i] >= level) {
      if (first < 0) first = i;
      last = i;
    }
  }
  if (first < 0) return false;

  // 轮廓样本位于格子中心。和相邻背景值做线性插值,得到 0..8 的连续边界。
  const float leftInnerX = first + 0.5f;
  const float leftOuterX = first - 0.5f;
  const float leftInnerV = profile[first];
  const float leftOuterV = first > 0 ? profile[first - 1] : 0.0f;
  const float leftDen = leftInnerV - leftOuterV;
  left = fabsf(leftDen) > 0.0001f
           ? leftOuterX + (level - leftOuterV) / leftDen
           : static_cast<float>(first);

  const float rightInnerX = last + 0.5f;
  const float rightOuterX = last + 1.5f;
  const float rightInnerV = profile[last];
  const float rightOuterV = last < GRID - 1 ? profile[last + 1] : 0.0f;
  const float rightDen = rightOuterV - rightInnerV;
  right = fabsf(rightDen) > 0.0001f
            ? rightInnerX + (level - rightInnerV) / rightDen
            : static_cast<float>(last + 1);

  left = constrain(left, 0.0f, static_cast<float>(GRID));
  right = constrain(right, 0.0f, static_cast<float>(GRID));
  return right > left;
}

float gridBoundaryAngle(float boundary, float fovDeg) {
  return ((boundary / GRID) - 0.5f) * fovDeg * DEG_TO_RAD;
}

Measurement analyzeForeground() {
  Measurement result;

  buildConfidenceMap();
  const uint8_t componentCount = keepLargestComponent();
  if (componentCount < MIN_COMPONENT_ZONES) return result;

  // A summed profile is more stable than taking only the strongest pixel in
  // each row/column. This especially helps when the same box is placed on a
  // different face and occupies fewer 8x8 zones.
  float columnProfile[GRID] = {0};
  float rowProfile[GRID] = {0};

  float allTopZ[ZONES];
  float allHeight[ZONES];
  float allRadial[ZONES];
  uint8_t allCount = 0;

  // Core points are less affected by mixed pixels on package edges.
  float coreTopZ[ZONES];
  float coreHeight[ZONES];
  float coreRadial[ZONES];
  uint8_t coreCount = 0;

  for (uint8_t row = 0; row < GRID; ++row) {
    for (uint8_t col = 0; col < GRID; ++col) {
      const uint8_t i = row * GRID + col;
      if (!componentMask[i]) continue;

      const float confidence = confidenceMap[i];

      columnProfile[col] += confidence;
      rowProfile[row] += confidence;

      const float currentZ =
          axialDistanceMm(filteredDepth[i], row, col);
      const float backgroundZ =
          axialDistanceMm(backgroundDepth[i], row, col);
      const float h = backgroundZ - currentZ;

      allTopZ[allCount] = currentZ;
      allHeight[allCount] = h;
      allRadial[allCount] = filteredDepth[i];
      ++allCount;

      // Prefer the solid interior of the top face for Z/height estimation.
      if (confidence >= 0.72f) {
        coreTopZ[coreCount] = currentZ;
        coreHeight[coreCount] = h;
        coreRadial[coreCount] = filteredDepth[i];
        ++coreCount;
      }
    }
  }

  if (allCount == 0) return result;

  // Normalize each 1D profile to 0..1 before sub-cell edge interpolation.
  float maxColumn = 0.0f;
  float maxRow = 0.0f;

  for (uint8_t i = 0; i < GRID; ++i) {
    maxColumn = max(maxColumn, columnProfile[i]);
    maxRow = max(maxRow, rowProfile[i]);
  }

  if (maxColumn <= 0.0001f || maxRow <= 0.0001f) return result;

  for (uint8_t i = 0; i < GRID; ++i) {
    columnProfile[i] /= maxColumn;
    rowProfile[i] /= maxRow;
  }

  float left = 0, right = 0, top = 0, bottom = 0;

  if (!profileEdges(columnProfile, left, right) ||
      !profileEdges(rowProfile, top, bottom)) {
    return result;
  }

  // If at least two solid interior zones exist, use them for top distance and
  // height. Otherwise fall back to the whole connected component.
  const bool useCore = coreCount >= 2;

  const float topZ =
      useCore ? medianFloat(coreTopZ, coreCount)
              : medianFloat(allTopZ, allCount);

  const float height =
      useCore ? medianFloat(coreHeight, coreCount)
              : medianFloat(allHeight, allCount);

  const float radial =
      useCore ? medianFloat(coreRadial, coreCount)
              : medianFloat(allRadial, allCount);

  if (topZ <= 0.0f || height < MIN_PACKAGE_HEIGHT_MM) return result;

  const float angleLeft =
      gridBoundaryAngle(left, FOV_X_DEG);
  const float angleRight =
      gridBoundaryAngle(right, FOV_X_DEG);
  const float angleTop =
      gridBoundaryAngle(top, FOV_Y_DEG);
  const float angleBottom =
      gridBoundaryAngle(bottom, FOV_Y_DEG);

  const float sizeX =
      topZ * fabsf(tanf(angleRight) - tanf(angleLeft));

  const float sizeY =
      topZ * fabsf(tanf(angleBottom) - tanf(angleTop));

  result.lengthMm = max(sizeX, sizeY);
  result.widthMm = min(sizeX, sizeY);
  result.heightMm = height;
  result.distanceMm = radial;
  result.zoneCount = componentCount;

  result.valid =
      result.lengthMm >= 20.0f &&
      result.widthMm >= 20.0f;

  return result;
}

void addMeasurement(const Measurement &measurement) {
  // If the parcel has clearly changed orientation, discard the old short
  // history so the display does not drag the previous pose for several frames.
  if (displayedMeasurement.valid) {
    const float dL =
        fabsf(measurement.lengthMm - displayedMeasurement.lengthMm);
    const float dW =
        fabsf(measurement.widthMm - displayedMeasurement.widthMm);
    const float dH =
        fabsf(measurement.heightMm - displayedMeasurement.heightMm);

    if (dL > 45.0f || dW > 45.0f || dH > 35.0f) {
      measurementCount = 0;
      measurementHead = 0;
    }
  }

  measurementHistory[measurementHead] = measurement;
  measurementHead = (measurementHead + 1) % MEASURE_FILTER_FRAMES;

  if (measurementCount < MEASURE_FILTER_FRAMES) {
    ++measurementCount;
  }

  float lengths[MEASURE_FILTER_FRAMES];
  float widths[MEASURE_FILTER_FRAMES];
  float heights[MEASURE_FILTER_FRAMES];
  float distances[MEASURE_FILTER_FRAMES];

  for (uint8_t i = 0; i < measurementCount; ++i) {
    lengths[i] = measurementHistory[i].lengthMm;
    widths[i] = measurementHistory[i].widthMm;
    heights[i] = measurementHistory[i].heightMm;
    distances[i] = measurementHistory[i].distanceMm;
  }

  const float newLength =
      medianFloat(lengths, measurementCount);
  const float newWidth =
      medianFloat(widths, measurementCount);
  const float newHeight =
      medianFloat(heights, measurementCount);
  const float newDistance =
      medianFloat(distances, measurementCount);

  if (!displayedMeasurement.valid) {
    displayedMeasurement.lengthMm = newLength;
    displayedMeasurement.widthMm = newWidth;
    displayedMeasurement.heightMm = newHeight;
    displayedMeasurement.distanceMm = newDistance;
  } else {
    // Deadband suppresses tiny 8x8 edge jitter, while larger changes respond
    // immediately enough for flipping the same parcel onto another face.
    if (fabsf(newLength - displayedMeasurement.lengthMm) > 2.5f) {
      displayedMeasurement.lengthMm =
          0.35f * displayedMeasurement.lengthMm +
          0.65f * newLength;
    }

    if (fabsf(newWidth - displayedMeasurement.widthMm) > 2.5f) {
      displayedMeasurement.widthMm =
          0.35f * displayedMeasurement.widthMm +
          0.65f * newWidth;
    }

    if (fabsf(newHeight - displayedMeasurement.heightMm) > 2.0f) {
      displayedMeasurement.heightMm =
          0.30f * displayedMeasurement.heightMm +
          0.70f * newHeight;
    }

    if (fabsf(newDistance - displayedMeasurement.distanceMm) > 2.0f) {
      displayedMeasurement.distanceMm =
          0.35f * displayedMeasurement.distanceMm +
          0.65f * newDistance;
    }
  }

  displayedMeasurement.zoneCount = measurement.zoneCount;
  displayedMeasurement.valid = true;
}

void updatePresenceState(const Measurement &current) {
  if (current.valid) {
    emptyFrames = 0;
    if (detectedFrames < DETECT_CONFIRM_FRAMES) ++detectedFrames;
    if (detectedFrames >= DETECT_CONFIRM_FRAMES) {
      if (!packagePresent) {
        packagePresent = true;
        measurementCount = 0;
        measurementHead = 0;
        setRgb(C_GREEN);
      }
      // Keep updating while the parcel remains in view. Removal is confirmed
      // only after several invalid frames below.
      addMeasurement(current);
    }
  } else {
    detectedFrames = 0;
    if (packagePresent) {
      if (emptyFrames < REMOVE_CONFIRM_FRAMES) ++emptyFrames;
      if (emptyFrames >= REMOVE_CONFIRM_FRAMES) {
        resetMeasurementState();
        memset(componentMask, 0, sizeof(componentMask));
        setRgb(C_BLUE);
      }
    }
  }
}

// -------------------- Weight --------------------
void resetWeightFilterState() {
  filteredWeightG = 0.0f;
  previousWeightG = 0.0f;
  previousRobustWeightG = 0.0f;
  stableWeightG = 0.0f;

  weightHistoryCount = 0;
  weightHistoryHead = 0;
  weightStableFrames = 0;
  weightLocked = false;

  for (uint8_t i = 0; i < WEIGHT_MEDIAN_WINDOW; ++i) {
    weightHistoryG[i] = 0.0f;
  }
}

float robustWeightMedian() {
  if (weightHistoryCount == 0) return 0.0f;

  float values[WEIGHT_MEDIAN_WINDOW];

  for (uint8_t i = 0; i < weightHistoryCount; ++i) {
    values[i] = weightHistoryG[i];
  }

  return medianFloat(values, weightHistoryCount);
}

void initializeScale() {
  scaleConnected = scaleI2C.begin();

  if (!scaleConnected) {
    Serial.println("HX711 not detected; dimensions remain available.");
    return;
  }

  scaleI2C.setCalibration(HX711_CALIBRATION_VALUE);
  scaleI2C.peel();
  resetWeightFilterState();

  Serial.println("HX711 I2C ready and tared.");
}

void updateWeight() {
  if (!scaleConnected) return;

  static uint32_t lastWeightReadMs = 0;
  const uint32_t now = millis();

  if (lastWeightReadMs != 0 &&
      now - lastWeightReadMs < WEIGHT_READ_INTERVAL_MS) {
    return;
  }

  lastWeightReadMs = now;

  // Four-corner platform: use a small sensor-side average first, then a
  // 5-sample median to reject mechanical spikes from the four feet.
  float grams = fabsf(scaleI2C.readWeight(4));

  if (grams < WEIGHT_ZERO_BAND_G) {
    grams = 0.0f;
  }

  weightHistoryG[weightHistoryHead] = grams;
  weightHistoryHead =
      (weightHistoryHead + 1) % WEIGHT_MEDIAN_WINDOW;

  if (weightHistoryCount < WEIGHT_MEDIAN_WINDOW) {
    ++weightHistoryCount;
  }

  const float robustG = robustWeightMedian();

  // If a previously locked reading changes meaningfully, immediately unlock.
  if (weightLocked &&
      fabsf(robustG - stableWeightG) > WEIGHT_LOCK_BAND_G) {
    weightLocked = false;
    weightStableFrames = 0;
  }

  if (!weightLocked) {
    if (weightHistoryCount == 1) {
      filteredWeightG = robustG;
    } else {
      const float diff =
          fabsf(robustG - filteredWeightG);

      float alpha = WEIGHT_SLOW_ALPHA;

      if (diff >= WEIGHT_FAST_JUMP_G) {
        alpha = WEIGHT_FAST_ALPHA;
      } else if (diff >= WEIGHT_MEDIUM_JUMP_G) {
        alpha = WEIGHT_MEDIUM_ALPHA;
      }

      filteredWeightG +=
          alpha * (robustG - filteredWeightG);
    }

    if (filteredWeightG < WEIGHT_ZERO_BAND_G) {
      filteredWeightG = 0.0f;
    }

    // Stability is judged from the robust sensor value, not from the EMA.
    // This avoids locking while the displayed value is still slowly climbing.
    if (weightHistoryCount >= 3 &&
        fabsf(robustG - previousRobustWeightG)
            <= WEIGHT_STABLE_DELTA_G) {

      if (weightStableFrames < WEIGHT_STABLE_FRAMES) {
        ++weightStableFrames;
      }
    } else {
      weightStableFrames = 0;
    }

    if (weightStableFrames >= WEIGHT_STABLE_FRAMES) {
      stableWeightG = filteredWeightG;
      weightLocked = true;
      filteredWeightG = stableWeightG;
    }
  } else {
    // Hold a stable number instead of letting the display creep by 1 g.
    filteredWeightG = stableWeightG;
  }

  previousRobustWeightG = robustG;
  previousWeightG = filteredWeightG;
}

// -------------------- Display: Simple UI V4 --------------------
void fillScreen(uint32_t color) {
  k10.canvas->canvasRectangle(0, 0, SCREEN_W, SCREEN_H, color, color, true);
}

void drawText(const String &text, int x, int y, uint32_t color,
              Canvas::eFontSize_t font = Canvas::eCNAndENFont16) {
  k10.canvas->canvasText(text, x, y, color, font, 30, false);
}

String formatWeight() {
  if (!scaleConnected) return "--";
  return String(max(0.0f, filteredWeightG) / 1000.0f, 2) + " kg";
}

String formatCmNumber(float mm) {
  return String(mm / 10.0f, 1);
}

uint32_t matrixCellColor(uint8_t rawIndex) {
  // Waiting: all cells are blue.
  if (!packagePresent && detectedFrames == 0) {
    return C_BLUE;
  }

  // Scanning: detected package cells are yellow.
  if (!packagePresent && detectedFrames > 0) {
    return componentMask[rawIndex] ? C_YELLOW : C_BLUE;
  }

  // Completed: detected package cells are green.
  if (packagePresent) {
    return componentMask[rawIndex] ? C_DONE_GREEN : C_BLUE;
  }

  return C_BLUE;
}

void drawStateMatrix() {
  // Keep the visual style close to the earlier 8x8 heatmap:
  // solid-filled cells with only a thin dark grid between them.
  const int frameX = MAP_X - 2;
  const int frameY = MAP_Y - 2;

  k10.canvas->canvasSetLineWidth(1);

  k10.canvas->canvasRectangle(
      frameX, frameY, MAP_SIZE + 4, MAP_SIZE + 4,
      C_HEAT_FRAME, C_HEAT_FRAME, true
  );

  for (uint8_t row = 0; row < GRID; ++row) {
    for (uint8_t col = 0; col < GRID; ++col) {
      const uint8_t rawIndex = displayIndex(row, col);
      const int x = MAP_X + col * MAP_CELL;
      const int y = MAP_Y + row * MAP_CELL;
      const uint32_t fillColor = matrixCellColor(rawIndex);

      // Dark outer cell.
      k10.canvas->canvasRectangle(
          x, y, MAP_CELL, MAP_CELL,
          C_HEAT_GRID, C_HEAT_GRID, true
      );

      // Fill almost the entire cell, leaving a 1 px grid line.
      k10.canvas->canvasRectangle(
          x + 1, y + 1, MAP_CELL - 2, MAP_CELL - 2,
          fillColor, fillColor, true
      );
    }
  }
}

void drawTitle() {
  drawText("包裹尺寸", 72, 7, C_TEXT, Canvas::eCNAndENFont24);
}

void drawLearningScreen() {
  fillScreen(C_BG);

  // 学习页面不显示“包裹尺寸”,只保留校准提示。
  drawText("正在学习", 72, 72,
           C_BLUE, Canvas::eCNAndENFont24);

  // 16 号中英文字体下居中显示。
  drawText("请保持平台空置", 56, 132,
           C_MUTED, Canvas::eCNAndENFont16);

  const float targetProgress =
      backgroundCount * 100.0f / BACKGROUND_FRAMES;

  learningProgressDisplay +=
      (targetProgress - learningProgressDisplay) * 0.32f;

  if (fabsf(targetProgress - learningProgressDisplay) < 0.2f) {
    learningProgressDisplay = targetProgress;
  }

  int progress =
      static_cast<int>(learningProgressDisplay + 0.5f);

  if (backgroundReady) progress = 100;
  progress = constrain(progress, 0, 100);

  k10.canvas->canvasRectangle(
      32, 190, 176, 14,
      C_BORDER, C_PANEL, true
  );

  k10.canvas->canvasRectangle(
      34, 192,
      172 * progress / 100,
      10,
      C_BLUE, C_BLUE, true
  );

  drawText(
      String(progress) + "%",
      progress >= 100 ? 86 : 94,
      220,
      C_TEXT,
      Canvas::eCNAndENFont24
  );

  k10.canvas->updateCanvas();
}

float roundChargeWeight(float kg) {
  if (kg <= 0.0f) return 0.0f;
  return ceilf(kg / CHARGE_UNIT_KG - 0.0001f) * CHARGE_UNIT_KG;
}

void updateBilling() {
  if (!packagePresent || !displayedMeasurement.valid) {
    displayedChargeableKg = 0.0f;
    displayedVolumeWeightKg = 0.0f;
    return;
  }

  const float actualWeightKg =
      max(0.0f, filteredWeightG) / 1000.0f;

  const float lengthCm =
      displayedMeasurement.lengthMm / 10.0f;
  const float widthCm =
      displayedMeasurement.widthMm / 10.0f;
  const float heightCm =
      displayedMeasurement.heightMm / 10.0f;

  displayedVolumeWeightKg =
      (lengthCm * widthCm * heightCm) / VOLUME_DIVISOR;

  displayedChargeableKg =
      roundChargeWeight(
          max(actualWeightKg, displayedVolumeWeightKg)
      );
}

void drawDimensionLine() {
  String sizeText = "-- x -- x -- cm";

  if (packagePresent && displayedMeasurement.valid) {
    sizeText =
        formatCmNumber(displayedMeasurement.lengthMm) +
        " x " +
        formatCmNumber(displayedMeasurement.widthMm) +
        " x " +
        formatCmNumber(displayedMeasurement.heightMm) +
        " cm";
  }

  // Keep the whole dimension string inside the screen.
  // Use a smaller horizontal offset and center according to text length.
  int x = 6;

  if (sizeText.length() <= 16) {
    x = 22;
  } else if (sizeText.length() <= 20) {
    x = 12;
  }

  drawText(
      sizeText,
      x, 202,
      C_TEXT,
      Canvas::eCNAndENFont24
  );
}

void drawWeightCards() {
  // Left card: volumetric weight.
  k10.canvas->canvasRectangle(
      12, 245, 102, 62,
      C_PANEL, C_PANEL, true
  );

  drawText("体积重量", 29, 252, C_MUTED);

  if (packagePresent && displayedMeasurement.valid) {
    drawText(
        String(displayedVolumeWeightKg, 2) + " kg",
        20, 274, C_TEXT, Canvas::eCNAndENFont24
    );
  } else {
    drawText(
        "--",
        49, 274, C_MUTED, Canvas::eCNAndENFont24
    );
  }

  // Right card: actual weight.
  k10.canvas->canvasRectangle(
      126, 245, 102, 62,
      0xECFBF3, 0xECFBF3, true
  );

  drawText("实际重量", 143, 252, C_MUTED);

  drawText(
      formatWeight(),
      143, 274,
      scaleConnected ? C_GREEN : C_RED,
      Canvas::eCNAndENFont24
  );
}

void drawMainScreen(const Measurement &current) {
  (void)current;

  const uint32_t now = millis();

  if (lastDisplayMs != 0 &&
      now - lastDisplayMs < DISPLAY_INTERVAL_MS) {
    return;
  }

  lastDisplayMs = now;

  fillScreen(C_BG);
  drawTitle();
  drawStateMatrix();
  drawDimensionLine();
  drawWeightCards();

  k10.canvas->updateCanvas();
}

void drawSensorError() {
  fillScreen(C_BG);

  drawText(
      "传感器错误",
      60, 82,
      C_RED,
      Canvas::eCNAndENFont24
  );

  drawText("SEN0628 未连接", 54, 142, C_TEXT);
  drawText("请检查 I2C 接线", 58, 178, C_MUTED);
  drawText("正在自动重试", 67, 214, C_MUTED);

  k10.canvas->updateCanvas();
}

// -------------------- 初始化与按键 --------------------
void handleButtons() {
  const bool buttonA = k10.buttonA->isPressed();
  const bool buttonB = k10.buttonB->isPressed();

  if (buttonA && !previousButtonA) startBackgroundLearning();

  if (buttonB && !previousButtonB && scaleConnected) {
    fillScreen(C_BG);
    drawText("重量去皮", 72, 128, C_YELLOW, Canvas::eCNAndENFont24);
    drawText("请保持平台空置", 56, 178, C_TEXT);
    k10.canvas->updateCanvas();
    scaleI2C.peel();
    scaleConnected = true;
    resetWeightFilterState();
    Serial.println("HX711 tare complete.");
  }

  previousButtonA = buttonA;
  previousButtonB = buttonB;
}

void printDiagnostics(const Measurement &current) {
  if (millis() - lastSerialMs < 500) return;
  lastSerialMs = millis();

  Serial.print("state=");
  Serial.print(backgroundReady ? (packagePresent ? "PRESENT" : "WAITING") : "LEARNING");
  Serial.print(" zones=");
  Serial.print(current.zoneCount);
  Serial.print(" L/W/H(mm)=");

  if (displayedMeasurement.valid) {
    Serial.print(displayedMeasurement.lengthMm, 1);
    Serial.print('/');
    Serial.print(displayedMeasurement.widthMm, 1);
    Serial.print('/');
    Serial.print(displayedMeasurement.heightMm, 1);
  } else {
    Serial.print("--/--/--");
  }

  Serial.print(" weight(g)=");
  Serial.print(filteredWeightG, 1);
  Serial.print(" volumeWeight(kg)=");
  Serial.print(displayedVolumeWeightKg, 2);
  Serial.print(" chargeWeight(kg)=");
  Serial.print(displayedChargeableKg, 1);
  Serial.println();
}

void setup() {
  Serial.begin(115200);
  delay(1000);
  Serial.println();
  Serial.println("K10 + SEN0628 + HX711 Parcel System");

  k10.begin();
  k10.initScreen(SCREEN_DIR);
  k10.creatCanvas();
  k10.setScreenBackground(C_BG);
  k10.rgb->brightness(20);
  setRgb(C_YELLOW);

  // SEN0628:严格使用之前在 Arduino IDE 中已经验证成功的语法
  int beginResult = tof.begin();
  Serial.print("tof.begin result = ");
  Serial.println(beginResult);

  if (beginResult != 0) {
    Serial.println("SEN0628 BEGIN FAILED");
    drawSensorError();
    while (1) delay(100);
  }
  Serial.println("SEN0628 BEGIN SUCCESS");

  int modeResult = tof.setRangingMode(eMatrix_8X8);
  Serial.print("setRangingMode result = ");
  Serial.println(modeResult);

  if (modeResult != 0) {
    Serial.println("8x8 MODE FAILED");
    drawSensorError();
    while (1) delay(100);
  }
  Serial.println("8x8 MODE SUCCESS");

  initializeScale();
  startBackgroundLearning();
}

void loop() {
  handleButtons();
  updateWeight();

  const uint32_t now = millis();
  if (now - lastFrameMs < FRAME_INTERVAL_MS) {
    delay(2);
    return;
  }
  lastFrameMs = now;

  uint8_t readResult = tof.getAllData(rawDepth);
  if (readResult != 0) {
    Serial.print("getAllData failed: ");
    Serial.println(readResult);
    delay(20);
    return;
  }

  if (!backgroundReady) {
    addBackgroundFrame(rawDepth);
    drawLearningScreen();
    return;
  }

  updateDepthMedian(rawDepth);
  Measurement current;
  if (historyCount >= 3) current = analyzeForeground();
  updatePresenceState(current);
  updateBilling();
  drawMainScreen(current);
  printDiagnostics(current);
}

五、总结

我基于行空板 K10、8×8 矩阵激光测距传感器和重量传感器,实现了一套集 包裹尺寸测量、重量检测、计费重量计算以及网页运费估价 于一体的智能快递测量系统。设备启动后会先进行空场景学习,建立平台的 8×8 背景深度数据;当包裹放入测量区域后,通过背景差分识别包裹区域,再结合边界位置、传感器视场角和深度数据计算包裹的长、宽、高,同时由重量传感器获取实际重量。

这个项目也让我进一步理解了 8×8 ToF 阵列并不只是简单读取 64 个距离值,更重要的是如何通过 空场学习、深度滤波、背景差分、边界提取和三角投影,把这些离散的距离数据转换成具有实际意义的尺寸信息。后续还可以加入摄像头视觉识别、更高分辨率深度传感器或真实物流报价接口,让整套系统进一步向实际的智能寄件终端靠近。

 

 

 

创作许可协议

本项目采用 CC BY(署名) 进行许可。

所需材料/产品

加载全部

    行空板K10 AI智能体编程主板,支持ESP-Claw/DeepSeek/MCP/边缘计算

    行空板K10 AI智能体编程主板,支持ESP-Claw/DeepSeek/MCP/边缘计算

    行空板K10 AI智能体编程主板,支持ESP-Claw/DeepSeek/MCP/边缘计算

    8x8矩阵激光测距传感器 - 64点ToF测距模块,探测距离达3.5米

    8x8矩阵激光测距传感器 - 64点ToF测距模块,探测距离达3.5米

    8x8矩阵激光测距传感器 - 64点ToF测距模块,探测距离达3.5米

相关推荐

评论(0)
- 没有更多了 -

创作许可协议

本项目采用 CC BY(署名) 进行许可。

相关推荐

所需材料/产品

行空板K10 AI智能体编程主板,支持ESP-Claw/DeepSeek/MCP/边缘计算

行空板K10 AI智能体编程主板,支持ESP-Claw/DeepSeek/MCP/边缘计算

行空板K10 AI智能体编程主板,支持ESP-Claw/DeepSeek/MCP/边缘计算

8x8矩阵激光测距传感器 - 64点ToF测距模块,探测距离达3.5米

8x8矩阵激光测距传感器 - 64点ToF测距模块,探测距离达3.5米

8x8矩阵激光测距传感器 - 64点ToF测距模块,探测距离达3.5米