식물도 인간처럼 병듭니다. 피부 감염으로 발진이 생기듯, 곰팡이나 병원체 감염 시 식물의 잎이 누렇게 변하거나 반점이 생깁니다. 머신러닝을 활용해 잎의 색상을 스캔하고, 정상/비정상 색상을 판별하는 모델을 훈련시킬 수 있습니다.
하드웨어 구성
이 프로젝트의 두뇌는 아두이노 나노 33 BLE 센스입니다. 선택 이유는:
강력한 내장 센서 세트: 9DoF IMU, APDS-9960 (색상, 제스처, 근접, 밝기), 마이크, 온습도/기압 복합 센서
식물 잎 위를 이동하며 측정하기 위해 스테퍼 모터 2개와 DRV8825 드라이버 2개 사용
스캐닝 릭 조립체완성된 식물 건강 스캐너
TinyML 환경 설정 (Edge Impulse)
Edge Impulse에 내장된 아두이노 나노 33 BLE 센스 센서 중 가속도계와 마이크만 지원하므로, APDS-9960 색상 센서 데이터를 사용하려면 데이터 포워더(Data Forwarder)를 사용해야 합니다.
새 프로젝트 생성
Node.js와 NPM 설치 후 npm install -g edge-impulse-cli로 CLI 설치
PATH 환경 변수에 설치 경로 추가 (필요시)
edge-impulse-data-forwarder 실행 후 작동 확인, Ctrl+C로 종료
데이터 포워더 실행 화면
색상 읽기 (APDS-9960)
APDS-9960은 적외선을 표면에 반사시켜 흡수되지 않은 파장(색상)을 읽습니다. Arduino_APDS9960 라이브러리 설치 후 APDS.readColor()로 RGB 값과 근접도 읽기. 각 RGB 성분을 0~65535 값에서 합계 대비 비율(0~1)로 정규화합니다.
스캐닝 릭 (3D 프린팅)
리드 스크류를 시계/반시계 방향으로 회전시켜 X, Y 축으로 블록을 이동하며 잎의 여러 위치를 스캔합니다. Fusion 360으로 설계했으며 주요 렌더링 이미지:
전체 어셈블리 렌더링X축/Y축 분해도상세 치수
X축이 Y축 위에 얹혀져 있어 상단 블록이 양축 이동 가능. Y축에 추가 V-휠로 스테퍼 모터 무게 지지. PLA 필라멘트, 45% 인필로 출력.
3D 프린팅된 부품들
데이터 수집
시작 시 스테퍼 모터 위치를 모르므로, 리미트 스위치에 닿을 때까지 단계적으로 이동해 원점(홈) 설정. APDS-9960 초기화 후 바운딩 박스(두 대각 꼭짓점) 내 랜덤 포인트 선택, 해당 위치로 이동하며 중간 색상 데이터 수집.
색상 데이터 처리 및 전송
APDS.readColor()로 RGB 읽기 → 합계 계산 → 각 성분 비율 계산 → Serial.printf()로 "R비율,G비율,B비율\n" 형식으로 USB 전송. 데이터 포워더가 이를 Edge Impulse 클라우드로 전송, 라벨(정상/비정상) 부여해 훈련 데이터로 저장.
Edge Impulse 데이터 수집 화면수집된 색상 데이터 샘플
모델 훈련
훈련 데이터 수집 완료 후, 정상/비정상 잎을 분류하는 모델 생성. 임펄스 구성: 3축 시계열 → 스펙트럴 분석 블록 → Keras 블록.
임펄스 구조: 스펙트럴 분석 + Keras 분류기스펙트럴 특성 생성 결과
테스트
비정상 잎으로 새 테스트 데이터 수집 후 평가. 모델 정확도 약 63%. 테스트 특성 통과 시 대부분 올바르게 분류됨.
모델 테스트 결과 (정확도 63%)
훈련 데이터 추가와 학습 속도 조절로 정확도 향상 가능.
코드
leafReader.ino (메인 스케치)
#include
#include
#include
#include "pinDefs.h"
int r, g, b, c, p;
float sum;
AccelStepper xStepper(AccelStepper::DRIVER, STEPPER_1_STEP, STEPPER_1_DIR);
AccelStepper yStepper(AccelStepper::DRIVER, STEPPER_2_STEP, STEPPER_2_DIR);
MultiStepper steppers;
// 스캔 영역 바운딩 박스 (mm 단위, 스텝으로 변환됨)
const long boundingBox[2][2] = { {0, 0}, {40, 40} };
void setup() {
Serial.begin(115200);
while(!Serial);
if(!APDS.begin()) {
Serial.println("Could not init APDS9960");
while(1);
}
pinMode(X_AXIS_HOMING_SW, INPUT_PULLUP);
pinMode(Y_AXIS_HOMING_SW, INPUT_PULLUP);
xStepper.setPinsInverted(X_AXIS_DIR);
yStepper.setPinsInverted(Y_AXIS_DIR);
xStepper.setMaxSpeed(150);
yStepper.setMaxSpeed(150);
steppers.addStepper(xStepper);
steppers.addStepper(yStepper);
homeMotors();
}
void loop() {
long randomPos[2];
randomPos[0] = random(boundingBox[0][0], boundingBox[1][0]) * STEPS_PER_MM;
randomPos[1] = random(boundingBox[0][1], boundingBox[1][1]) * STEPS_PER_MM;
steppers.moveTo(randomPos);
while(steppers.run()) {
if(!APDS.colorAvailable() || !APDS.proximityAvailable()) continue;
APDS.readColor(r, g, b, c);
sum = r + g + b;
p = APDS.readProximity();
// 근접 센서 감지되고, 명확한 색상 데이터일 때만 전송
if(!p && c > 10 && sum >= 0) {
float rr = r / sum;
float gr = g / sum;
float br = b / sum;
Serial.printf("%1.3f,%1.3f,%1.3f\n", rr, gr, br);
}
}
}
void homeMotors() {
// X축 홈
while(digitalRead(X_AXIS_HOMING_SW)) xStepper.move(-1);
// Y축 홈
while(digitalRead(Y_AXIS_HOMING_SW)) yStepper.move(-1);
xStepper.setCurrentPosition(0);
yStepper.setCurrentPosition(0);
}