메뉴 전체보기

회원메뉴

8x8 LiDAR와 UNIHIKER K10으로 소포 요금 산정 스테이션 만들기 > 게시판

본문 바로가기

쇼핑몰 검색

NO. 3481
제목 : 8x8 LiDAR와 UNIHIKER K10으로 소포 요금 산정 스테이션 만들기
2026-09-30 10:48

소개: 8x8 LiDAR와 UNIHIKER K10으로 소포 요금 산정 스테이션 만들기


성수기 택배 접수처에서 직원들이 모든 소포에 대해 같은 작업을 반복하는 모습을 보았습니다. 소포를 저울에 올리고, 자로 길이·너비·높이를 측정한 다음, 요금 산정 중량을 추정했습니다. 느린 작업이었고 수동 측정은 쉽게 틀릴 수 있었습니다. 저는 이 모든 과정을 하나의 장치에 통합하고 싶었습니다. 그래서 UNIHIKER K10으로 자동 소포 측정 스테이션을 만들었습니다. 소포를 플랫폼에 올리면 위쪽의 8x8 매트릭스 LiDAR ToF 센서가 길이·너비·높이를 측정하고, 아래의 로드셀이 실제 중량을 측정합니다. 시스템은 부피 중량과 기준 요금 산정 중량을 계산한 후, 내장 웹 페이지에 예상 배송비를 표시합니다. 수동 계량도, 자도, 서류 작업도 필요 없습니다. 같은 스테이션을 직접 만들어 소포를 한 번 올리는 것만으로 모든 수치를 확인할 수 있습니다.




STEP 1. 준비물


준비물


1개: UNIHIKER K10

1개: Matrix Laser Distance Measurement Sensor (8x8)

1개: HX711 I2C weight sensor / load cell

1개: Gravity: I2C HUB


배선 연결

4핀 케이블을 사용해 I2C Extensions 모듈을 UNIHIKER K10의 I2C 인터페이스에 연결합니다. Dupont 전선을 사용해 8x8 matrix tof 3D distance sensor와 HX711 weight sensor를 각각 I2C Extensions 모듈에 연결합니다.




STEP 2. 두 라이브러리 설치


두 라이브러리 설치


먼저 Arduino IDE를 다운로드하여 설치하고 UNIHIKER UNIHIKER K10용 Arduino 개발 환경 구성을 완료합니다. UNIHIKER UNIHIKER K10에 해당하는 BSP를 설치해야 Arduino IDE가 UNIHIKER UNIHIKER K10을 인식하고 프로그램 컴파일 및 업로드를 완료할 수 있습니다. 구체적인 설치 과정은 다음을 참조하세요: https://www.unihiker.com.cn/wiki/k10/ArduinoIDE_prepare.

먼저 첨부 파일에 포함된 두 라이브러리를 Arduino 라이브러리 폴더에 설치합니다. 파일은 DFRobot_MatrixLidar.zip과 DFRobot_HX711_I2C-master.zip입니다. 이 라이브러리는 Sensor와 weight sensor를 위한 DFRobot 공식 배포판입니다. 이 라이브러리가 없으면 스케치가 컴파일되지 않습니다.




STEP 3. 소포 치수 측정


소포 치수 측정


먼저 8×8 matrix laser distance measurement Sensor의 거리 측정 기능을 사용해 측정 영역 내 64개 위치의 거리 정보를 얻습니다. 그런 다음 UNIHIKER UNIHIKER K10이 이 깊이 데이터를 처리해 소포의 위치를 파악하고 길이·너비·높이를 계산합니다. 측정 과정을 더 직관적으로 확인할 수 있도록 UNIHIKER K10 화면에는 8×8 감지 영역과 현재 측정된 치수가 실시간으로 표시됩니다. 이 과정을 마치면 자를 사용하지 않고도 시스템이 소포의 3차원 치수를 자동으로 측정할 수 있습니다.

전체 치수 측정은 실제로 두 부분으로 나뉩니다. 높이는 빈 플랫폼 거리와 상자 윗면 거리의 차이로 구하고, 길이와 너비는 8×8 깊이 맵에서 얻은 소포 경계 위치에 시야각과 상자 윗면 거리를 결합한 기하학적 투영으로 계산합니다.




STEP 4. 빈 장면 학습


소포를 올리기 전에 시스템은 빈 장면 학습을 수행해 Sensor에서 빈 플랫폼까지의 기준 거리를 기록해야 합니다. 프로그램이 시작되면 8\*8 matrix laser distance measurement Sensor가 여러 프레임의 깊이 데이터를 연속으로 수집하고, 64개 측정 구역 각각의 거리를 기록합니다. 그런 다음 중앙값 처리를 통해 비교적 안정적인 배경 깊이를 얻습니다. 학습이 완료되면 이 64개의 배경 거리가 이후 소포가 나타났는지 판단하고 소포 높이를 계산하기 위한 기준 데이터로 사용됩니다.


// -------------------- Background Learning --------------------
void startBackgroundLearning() {
backgroundReady = false;
backgroundCount = 0;

// Clear previous background data
memset(backgroundDepth, 0, sizeof(backgroundDepth));
memset(backgroundSamples, 0, sizeof(backgroundSamples));

Serial.println("Starting background learning. Please keep the platform empty.");
}

// Continuously collect multiple frames of empty-platform depth data
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];

// Count only valid distance points
if (validDistance(frame[i])) {
++validCount;
}
}

// Keep this frame only when enough valid distance points are available
if (validCount >= BACKGROUND_VALID_ZONES_MIN) {
++backgroundCount;
}

// Finish background learning after enough frames have been collected
if (backgroundCount >= BACKGROUND_FRAMES) {
finishBackgroundLearning();
}
}




STEP 5. 깊이 거리를 길이·너비·높이로 변환


깊이 거리를 길이·너비·높이로 변환


깊이 거리를 길이·너비·높이로 변환


깊이 거리를 길이·너비·높이로 변환


빈 장면 학습을 완료한 후 소포를 플랫폼에 올립니다. 시스템은 Sensor에서 소포 상단 중앙까지의 거리를 측정하며, 예를 들어 13.8 in입니다. 이전에 학습한 빈 플랫폼 거리 500 mm와 비교하면 소포 높이는 다음 차이로 바로 구할 수 있습니다: 5.9 in.

그런 다음 8×8 깊이 데이터에서 소포의 왼쪽·오른쪽·위쪽·아래쪽 경계를 찾고, Sensor의 시야각에서 경계 위치를 해당 각도로 변환합니다. 예를 들어 중심을 기준으로 왼쪽과 오른쪽 경계는 대략 다음과 같습니다: \-15\.9° 및 15\.9°, 상자 윗면 중앙까지의 거리는 13.8 in입니다. 삼각함수 투영을 사용하면 너비는 약 7.9 in으로 계산됩니다. 마찬가지로 중심을 기준으로 위쪽과 아래쪽 경계가 대략 다음과 같다면: \-23\.2° 및 23\.2°길이는 약 11.8 in로 계산할 수 있습니다.


wide = Ztop × (tanθR - tanθL)
= 13.8 × [tan(15.9°) - tan(-15.9°)]
≈ 7.9 mm

Length = 13.8 × |tan(23.2°) - tan(-23.2°)|
≈ 13.8 × 0.8594
≈ 11.8 mm


하드웨어 연결이 완료되면 USB 케이블을 통해 UNIHIKER K10을 컴퓨터에 연결하고, Arduino IDE에서 개발 보드로 UNIHIKER K10을 선택한 다음 해당 직렬 포트를 선택합니다. 그런 다음 전체 치수 측정 프로그램을 열어 업로드합니다.

코드를 업로드한 후 오른쪽 화살표 아이콘을 클릭하여 실행 및 업로드를 시작합니다.

본문이 길어 아래 덧글 2개로 계속됩니다.


원문: https://www.instructables.com/Build-a-Parcel-Billing-Station-With-8x8-LiDAR-and-/

좋아요 0
게시판

본문 계속 1/2




STEP 6. 치수 측정을 위한 전체 코드


치수 측정을 위한 전체 코드


프로그램은 먼저 배경 깊이와 현재 깊이의 차이를 기준으로 소포 영역을 추출하고, 노이즈 간섭을 줄이기 위해 가장 큰 연결 성분을 유지합니다. 그런 다음 수평 및 수직 신뢰도 분포를 각각 계산하고, 에지 보간을 통해 8×8 그리드에서 소포의 왼쪽, 오른쪽, 위쪽, 아래쪽 경계를 구합니다. Sensor의 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);

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

// ---------- Dimension Calibration ----------
// Reference box size: 300 × 200 × 150 mm
// Current stable measurement: about 261 × 214 × 156 mm
// Correct only the final L/W/H values; keep background learning, foreground extraction, and the 8×8 display unchanged.
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;

// ---------- Data ----------
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;

// ---------- Utilities ----------
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); }

// ---------- Background Learning ----------
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();
}
}

// ---------- Depth Filtering ----------
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);
}
}

// ---------- Foreground Extraction ----------
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;
}

// ---------- Length / Width / Height ----------
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));

// Raw geometric result
const float rawLength=max(x,y);
const float rawWidth=min(x,y);

// Apply three-axis calibration for the current fixed installation
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 inch(float mm){ return String(mm/25.4f,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("Learning",76,72,C_BLUE,Canvas::eCNAndENFont24);
drawText("Keep platform empty",39,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("Package Size",48,7,C_TEXT,Canvas::eCNAndENFont24);
drawStateMatrix();

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

void drawSensorError(){
fillScreen(C_BG);
drawText("Sensor Error",48,82,C_RED,Canvas::eCNAndENFont24);
drawText("SEN0628 not found",45,142,C_TEXT);
drawText("Check I2C wiring",50,178,C_MUTED);
k10.canvas->updateCanvas();
}

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

// ---------- Serial Output ----------
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("--/--/--");
}
}

// ---------- Initialization ----------
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("Connecting sensor",26,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();
}

// ---------- Main Loop ----------
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);
}


실행 결과:

8x8 matrix tof 3D distance sensor로 측정한 데이터를 기준으로 깊이 거리를 길이, 너비, 높이로 변환한 값은 (12.0 in x 6.3 in x 5.9 in)입니다. 소포의 실제 치수는 (11.8 in x 7.9 in x 5.9 in)이며, 두 값 모두 오차가 매우 작아 실제 환경에서의 신뢰성을 보여 줍니다.

본문 계속 2/2




STEP 7. 이전에 무게 감지 기능 추가


이전에 무게 감지 기능 추가


이전에 무게 감지 기능 추가


8x8 matrix tof 3D distance sensor를 사용하여 소포의 길이, 너비, 높이를 자동으로 측정했습니다. 그러나 실제 배송 과정에서는 최종 청구 중량을 결정하기에 치수 데이터만으로는 충분하지 않습니다. 일반적으로 배송 요금 산정 시 소포의 실제 중량도 고려해야 하기 때문입니다. 부피는 크지만 무게가 가벼운 소포에는 부피 중량을 사용해 계산하기도 합니다. 따라서 다음 단계에서는 기존 치수 측정 기능에 무게 감지 기능을 추가하여, UNIHIKER K10이 소포의 길이·너비·높이를 측정하는 동시에 실제 중량도 얻도록 합니다. 그런 다음 치수 데이터와 중량 데이터를 결합하여 부피 중량과 청구 중량을 계산합니다.

무게 감지 부분에는 I2C weight sensor를 사용합니다. 프로그램이 시작되면 먼저 센서 초기화와 무부하 영점 조정을 수행한 후, 주기적으로 중량 데이터를 읽습니다. 저울 플랫폼은 기계적 진동과 센서 노이즈의 영향을 받으므로 단일 측정값은 변동하기 쉽습니다. 따라서 프로그램은 먼저 센서에서 여러 샘플을 수집한 다음 5점 median filter를 사용하여 순간적인 이상값을 제거합니다. 또한 5 g 미만의 변동은 바로 0으로 처리하는 zero dead zone을 설정하여 더 안정적인 실제 중량을 얻습니다.


무게 센서 코드:

// -------------------- Weight Sensor Initialization --------------------
void initializeScale() {
// Initialize the HX711 I2C weight sensor
scaleConnected = scaleI2C.begin();

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

// Set the calibration factor and tare the empty platform
scaleI2C.setCalibration(HX711_CALIBRATION_VALUE);
scaleI2C.peel();

// Reset the weight filter state
resetWeightFilterState();

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

// -------------------- Weight Reading --------------------
void updateWeight() {
if (!scaleConnected) return;

// Read the weight 4 times and use the sensor's internal average
float grams = fabsf(scaleI2C.readWeight(4));

// Treat values below 5 g as zero-point noise
if (grams < WEIGHT_ZERO_BAND_G) {
grams = 0.0f;
}

// Store the value in the history window for median filtering
weightHistoryG[weightHistoryHead] = grams;
weightHistoryHead =
(weightHistoryHead + 1) % WEIGHT_MEDIAN_WINDOW;

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

// Get the robust weight value after removing outliers
const float robustG = robustWeightMedian();
}


실제 특송 배송 요금 산정에는 부피 중량과 실제 중량이 사용됩니다. 부피 중량은 먼저 측정한 소포의 "길이, 너비, 높이" 단위를 인치로 변환한 다음, 소포의 부피(unit: in3)를 계산하고, 그 부피를 부피 중량 환산 계수 166으로 나누어 구합니다. 그런 다음 이를 소포의 실제 중량(unit: lb)과 비교하여 더 큰 값을 청구 중량으로 선택하고, 설정된 요금 산정 규칙에 따라 올림 처리합니다.


청구 중량 소스 코드:

// Convert the actual weight from grams to pounds.
const float actualWeightLb =
max(0.0f, filteredWeightG) /
453.59237f;

// Convert measured dimensions from millimeters to inches.
const float lengthIn =
displayedMeasurement.lengthMm /
25.4f;

const float widthIn =
displayedMeasurement.widthMm /
25.4f;

const float heightIn =
displayedMeasurement.heightMm /
25.4f;

// Round each dimension up to the next whole inch
// for dimensional-weight calculation.
const float billingLengthIn = ceilf(lengthIn);
const float billingWidthIn = ceilf(widthIn);
const float billingHeightIn = ceilf(heightIn);

// Calculate dimensional weight using
// the 139 in³/lb divisor.
const float rawDimensionalWeightLb =
(billingLengthIn *
billingWidthIn *
billingHeightIn) /
139.0f;

// Round dimensional weight up to the next whole pound.
displayedVolumeWeightLb =
ceilf(rawDimensionalWeightLb);

// Round actual weight up to the next whole pound.
const float billingActualWeightLb =
ceilf(actualWeightLb);

// Use the greater of actual weight
// and dimensional weight.
displayedChargeableLb =
max(
billingActualWeightLb,
displayedVolumeWeightLb
);


실행 결과

무게 센서를 추가한 후 측정된 소포의 실제 중량은 1.91 lb이고, 측정된 소포 치수는 (11.6 in x 6.7 in x 6.0 in)입니다(배치 위치에 따라 각 측정값에 약간의 변동이 있을 수 있지만 최종 예상 요금에는 영향을 주지 않습니다). 따라서 부피 중량은 (11.6 x 6.7 x 6 / 166) = 2.81 lb입니다.




STEP 8. 웹 인터페이스 제공


웹 인터페이스 제공


웹 인터페이스 제공


소포의 길이, 너비, 높이 및 실제 중량을 자동으로 수집하는 기능을 완성했으며, UNIHIKER K10은 측정 결과를 화면에 직접 표시할 수도 있습니다. 실제 특송 배송에서는 발신인과 수취인 정보를 입력하고, 배송 업체를 선택하며, 소포의 치수와 중량을 기준으로 배송 요금을 추가로 추정해야 합니다. 따라서 치수 및 무게 감지 기능을 완성한 후 HTML, CSS 및 JavaScript를 사용하여 실시간 웹페이지를 추가했습니다. UNIHIKER K10이 수집한 데이터는 USB 직렬 통신을 통해 컴퓨터로 전송되며, 웹페이지는 브라우저의 Web Serial API를 사용하여 직렬 데이터를 직접 읽고 해석한 다음 측정 결과를 페이지에 실시간으로 표시합니다. 이후 사용자는 발신인 및 수취인 주소를 입력하고 배송 업체를 선택하며, 웹페이지는 청구 중량을 기준으로 사전 설정된 기본 중량, 추가 중량 및 지역별 요금 규칙을 적용하여 예상 배송 요금을 계산합니다.

HTML 파일(첨부 파일 3)을 다운로드하여 더블클릭으로 연 다음, 직렬 연결을 클릭합니다(이때 Arduino IDE의 직렬 포트는 닫혀 있어야 합니다). 그러면 UNIHIKER K10의 실시간 데이터가 이 인터페이스에 표시됩니다. 그런 다음 발신인과 수취인의 주소 및 사용자 정보를 입력하고, 배송 운송업체와 서비스 방식을 선택할 수 있습니다.




STEP 9. 측정 과정 이해하기


측정 과정 이해하기


측정 과정 이해하기


일반 택배 업체 플랫폼에 게시된 정기 배송 견적을 바탕으로, 다양한 요금 기준에 맞춘 가격표를 작성합니다. 기본 중량과 추가 중량 기준을 사용하며 기본 중량은 1KG입니다. 기본 중량을 초과하면 추가 중량 요금이 부과됩니다. 추가 중량 요금은 운송업체와 발송지 및 수취인 주소 간 거리에 따라 달라집니다. 1KG 미만의 추가 중량도 1KG으로 계산합니다. 사용자가 배송에 이용할 택배 업체를 선택하면 시스템이 해당 예상 견적을 자동으로 계산합니다.

현재 프로젝트는 주로 프로토타입 검증에 사용되므로, 견적을 시뮬레이션하기 위해 로컬 가격표를 사용합니다. 향후에는 택배 업체의 공식 인터페이스나 타사 물류 API를 추가로 연동하여 실제 배송비 조회와 주문 접수를 지원할 수 있습니다. 이를 통해 전체 Project를 "자동 측정"에서 "측정 + 정보 입력 + 배송비 추정"의 완전한 워크플로로 확장할 수 있습니다.




STEP 10. 최종 Vedio


최종 Vedio


저장소 용량을 작게 유지하기 위해 데모 동영상은 여기에 커밋하지 않고 YouTube에 업로드했습니다. 동영상에는 빈 플랫폼 부팅, 소포 배치, 웹 페이지에서 치수·중량·예상 비용이 업데이트되는 전체 흐름이 담겨 있습니다.




STEP 11. 결론


이번 빌드를 통해 8x8 ToF 어레이는 단순히 64개의 거리 값을 읽는 장치가 아니라는 점을 배웠습니다. 중요한 것은 빈 공간 학습, 깊이 필터링, 배경 제거, 경계 추출, 삼각 투영을 통해 이러한 개별 측정값을 의미 있는 치수 정보로 변환하는 것입니다. 다음 단계로는 카메라 기반 시각 인식을 추가하여 소포의 형태를 인식하고, 실제 물류 견적 인터페이스를 연동할 예정입니다. 한 가지 주의할 점은 시작 시 플랫폼을 비워 두어야 한다는 것입니다. 부팅 과정에서 빈 배경을 학습하므로, 플랫폼이 비어 있지 않으면 정확하게 측정할 수 없습니다.




STEP 12. 첨부 파일


첨부 파일: https://drive.google.com/drive/folders/1btSuBPqDJof-xcGMCDH1s1BZ9CQzvF6o?usp=drive_link

GitHub 링크: https://github.com/mengyali878-jpg/automated-delivery-billing-system

회사명 공방유니언 주소 각 공방 주소 참조
사업자 등록번호 0000 대표 공방유니언 전화 019-60105846 팩스 0000 이메일 gongbangunion@gmail.com
통신판매업신고번호 0000 개인정보관리책임자 공방유니언
Copyright © 2017 공방유니언. All Rights Reserved.