Embedded Blog — Embedded 공부 공간입니다.
'Embedded' 태그의 글 목록
// list

Embedded

[2026 ABC 프로젝트 멘토링 1기] 프로젝트 7주차

2026 ABC 프로젝트 멘토링

최종 시연 영상 제작: 긴 복도 현장 이슈 수정


이번 주 목표

  6주차까지 알고리즘 고도화와 회귀 28/28 PASS를 마쳤습니다. 발표는 사전에 촬영한 시연 영상을 재생하는 방식으로 진행하기로 했고, 촬영 장소는 랩실 앞 긴 복도로 정했습니다.

  시뮬레이션 28종이 통과했어도 실제 복도에서 라즈베리파이와 LiDAR를 바닥에 놓고 영상을 뽑아내는 건 전혀 다른 문제였습니다. 이슈가 발생 순서대로 터졌고, 하나씩 수정하면서 최종 영상을 확보했습니다.


1. 반복 촬영 시 배경 재학습 대기 — SIGUSR1 리셋

증상

  촬영 한 번에 50프레임(5초) 배경 학습이 완료되어야 감지가 시작됩니다. 테이크가 실패할 때마다 시연자가 복도 밖으로 나간 뒤 다시 5초를 기다려야 했습니다. 촬영 리듬이 계속 끊겼고, 기다리는 동안 Unity 화면에 warming_up 표시가 계속 떠 있어 영상 편집 시 매번 잘라내야 했습니다.

수정

  SIGUSR1 시그널로 배경 즉시 리셋 기능을 추가했습니다. 시그널을 받으면 ScanProcessor와 BackgroundFilter의 학습 상태를 초기화하고 배경 학습 카운터를 0으로 리셋합니다.

// main.cpp
static std::atomic<bool> g_reset_bg{false};

void HandleSigUsr1(int) { g_reset_bg.store(true); }
// signal(SIGUSR1, HandleSigUsr1); — 초기화 시 등록

// 메인 루프
if (g_reset_bg.exchange(false)) {
    processor.ResetBackground();
    bg_filter.Reset();
    operational_frame_count = 0;
    std::cerr << "[RESET] 배경 학습 재시작\n";
}

  두 번째 터미널에서 다음 명령 하나로 즉시 리셋했습니다.

kill -USR1 $(pgrep rplidar_app)

  테이크 실패 → 복도 정리 → 리셋 명령 → 5초 대기 → 재촬영 흐름이 완성됐고, 서비스 재시작 없이 배경만 골라서 초기화할 수 있어 촬영 속도가 크게 올라갔습니다.


2. 서보 zone 경계에서 oscillation — Hysteresis 추가

증상

  시연자가 복도 중앙보다 약간 오른쪽으로 걸어올 때 서보가 zone 2(45°)와 zone 3(90°) 사이에서 계속 오락가락했습니다. 촬영 영상에서 서보가 좌우로 미세하게 진동하는 모습이 그대로 찍혔습니다.

원인

  5주차에서 구현한 SuspectToZone()은 LiDAR 좌표를 각도로 변환하여 가장 가까운 zone으로 스냅합니다. 시연자가 복도를 걸을 때 LiDAR가 측정하는 각도에 프레임마다 ±2~3° 노이즈가 섞이고, 이 노이즈가 45° 경계를 계속 넘나들면서 zone이 매 프레임 바뀌었습니다.

프레임 N:   angle = 47° → zone 2 → 서보 45°로 이동
프레임 N+1: angle = 43° → zone 3 → 서보 90°로 이동
프레임 N+2: angle = 46° → zone 2 → 서보 45°로 이동 ...

수정

  이전 zone에서 벗어나려면 ±10° hysteresis band를 초과해야만 zone이 바뀌도록 했습니다.

// servo_controller.cpp
int ServoController::ComputeTargetZone(double x_mm, double y_mm) {
    int raw_zone = SuspectToZone(x_mm, y_mm, cfg_.servo_mirror);
    if (raw_zone == current_zone_) return current_zone_;

    // zone 경계에서 ±10° 이내면 현재 zone 유지
    double angle = std::atan2(x_mm, std::max(std::abs(y_mm), 1.0))
                   * 180.0 / kPi + 90.0;
    double zone_center = raw_zone * 45.0;
    if (std::abs(angle - zone_center) < kZoneHysteresisDeg) {
        return current_zone_;  // 경계 구간, 현재 유지
    }
    return raw_zone;
}

  Hysteresis 적용 후 서보가 경계 근방에서도 안정적으로 한 zone에 머물렀고, 영상에서 진동 없이 부드럽게 추적하는 모습이 나왔습니다.


3. 서보 오버슛 — 이동 속도 제한 (Slew Rate)

증상

  suspect 위치가 복도 한쪽 끝에서 반대쪽으로 크게 이동할 때(예: zone 1 → zone 5, 0° → 180°) 서보가 한 번에 전각도를 회전하면서 관성으로 목표를 지나쳤다가 되돌아오는 오버슛이 발생했습니다. 정착하는 도중 서보가 흔들리는 모습이 영상에 찍혔습니다.

수정

  MoveToAngle() 안에서 이동 각도를 최대 15°스텝으로 제한하는 슬루 레이트를 적용했습니다.

// servo_controller.cpp
void ServoController::MoveToAngle(int target_deg) {
    constexpr int kMaxStepDeg = 15;
    constexpr int kStepMs     = 20;

    while (current_angle_ != target_deg) {
        int diff = target_deg - current_angle_;
        current_angle_ += std::clamp(diff, -kMaxStepDeg, kMaxStepDeg);
        SendAngleToArduino(current_angle_);
        std::this_thread::sleep_for(std::chrono::milliseconds(kStepMs));
    }
    std::this_thread::sleep_for(std::chrono::milliseconds(60));  // 정착 대기
}

  180° 전각도 이동 시 이동 시간이 약 80ms → 320ms로 늘었지만 오버슛이 완전히 사라졌고, 영상에서 서보가 부드럽게 회전하는 모습이 나왔습니다. Arduino 펌웨어 수정 없이 임베디드 측에서만 각도를 분할하여 보내는 방식으로 해결했습니다.


4. 원거리 진입 Tentative 구간 — Unity에 사람이 늦게 나타남

증상

  시연 시나리오는 시연자가 복도 끝(약 12m 지점)에서 LiDAR 쪽으로 걸어오는 것이었습니다. 그런데 영상을 보면 시연자가 복도에 들어서 3~4걸음을 걷고 나서야 Unity 화면에 사람 오브젝트가 나타났습니다. 실제 사람이 먼저 움직이고 Unity가 뒤늦게 반응하는 것처럼 보여 시연 타이밍이 어색했습니다.

원인

  5주차에서 도입한 Tentative → Confirmed 승격 게이트가 원인이었습니다. 신규 트랙은 tentative_confirm_frames(기본 8프레임) 동안 안정 관측이 확인돼야 Confirmed로 승격되어 Unity로 전송됩니다. 복도 끝 12m 거리에서는 LiDAR 점 밀도가 충분해 클러스터 자체는 잘 생성됐지만, 8프레임(0.8초) 동안 Unity에 아무것도 안 나타났습니다.

수정

  ECOWARDEN_TENTATIVE_CONFIRM_FRAMES 환경변수를 추가했습니다. 시연 환경처럼 LiDAR 배치가 안정적이고 노이즈가 낮은 경우 프레임 수를 줄여 즉각적인 반응을 얻을 수 있게 했습니다.

# 시연용: 8프레임 → 4프레임으로 단축
sudo env ECOWARDEN_TENTATIVE_CONFIRM_FRAMES=4 ./build/rplidar_app
// cluster_tracker.cpp
if (tr.state == TrackState::Tentative) {
    if (++tr.tentative_count >= params_.tentative_confirm_frames) {
        tr.state = TrackState::Confirmed;
    }
    continue;  // Confirmed 전까지 Unity 전송 제외
}

  4프레임(0.4초)으로 단축한 결과 시연자가 복도에 들어서자마자 Unity에 사람이 나타나게 됐습니다. 회귀 시나리오에서는 기본값(8)을 유지하여 회귀 통과에 영향을 주지 않았습니다.


5. humanPrefab 잔상 — departure 이벤트 재전송

증상

  촬영한 영상을 검토하니 투기 확정 후 Unity 화면에서 이전 시연자의 humanPrefab이 사라지지 않고 투기된 쓰레기 옆에 그대로 남아 있는 컷이 여러 군데 있었습니다. LiDAR 측에서는 트랙이 정상 삭제됐지만 Unity가 departure 이벤트를 받지 못한 것이었습니다.

원인

  departure는 events[]에 담겨 단 한 번만 전송되는 단발성 메시지입니다. UDP 패킷 손실이 이 프레임에 겹치면 Unity가 이벤트를 영영 받지 못했습니다.

수정

  departure 이벤트를 이후 5프레임 동안 매 FRAME 패킷에 포함하여 반복 전송했습니다.

// json_packet_sender.cpp
struct PendingDeparture { int track_id; int remaining; };
std::vector<PendingDeparture> pending_departures_;

void JsonPacketSender::Send(const Payload& payload) {
    for (const auto& evt : payload.events)
        if (evt.type == "departure")
            pending_departures_.push_back({evt.track_id, 5});

    Payload enriched = payload;
    for (auto& pd : pending_departures_) {
        enriched.events.push_back({.type = "departure", .track_id = pd.track_id});
        pd.remaining--;
    }
    std::erase_if(pending_departures_, [](const auto& pd){ return pd.remaining <= 0; });

    udp_.Send(Serialize(enriched));
}

  Unity는 이미 삭제된 ID의 departure를 중복 수신해도 무시합니다. 수정 후 humanPrefab 잔상이 영상에서 사라졌습니다.


6. LiDAR USB 연결 끊김 — 재연결 루프

증상

  촬영 중반에 LiDAR가 갑자기 점구름을 내보내지 않았습니다. Unity 화면도 멈추고, 터미널 로그에 grabScanDataHq 타임아웃 에러가 찍혔습니다. USB 케이블을 뽑았다 꽂으니 다시 동작했지만, 기존 코드는 초기화 시 한 번만 연결을 시도해서 끊기면 바로 프로세스가 종료됐습니다. systemd가 5초 후 재시작했지만 그 사이 배경 학습이 처음부터 다시 시작돼 또 대기가 생겼습니다.

수정

  메인 루프 안에서 LiDAR 연결 상태를 확인하고, 끊기면 재연결을 시도하는 루프를 추가했습니다.

// main.cpp — 메인 루프 상단
if (!lidar.IsConnected()) {
    std::cerr << "[LIDAR] 연결 끊김 — 재연결 시도\n";
    lidar.Stop();
    std::this_thread::sleep_for(std::chrono::seconds(2));
    if (!lidar.Start()) {
        std::this_thread::sleep_for(std::chrono::seconds(3));
        continue;  // 성공할 때까지 반복
    }
    std::cerr << "[LIDAR] 재연결 성공\n";
    // 배경 스냅샷이 유효하면 복원, 아니면 재학습
    operational_frame_count = 0;
    continue;
}

  RplidarS2::IsConnected()는 내부 드라이버 상태를 확인합니다.

// rplidar_s2.cpp
bool RplidarS2::IsConnected() const {
    if (!driver_) return false;
    sl_lidar_response_device_health_t health;
    return SL_IS_OK(driver_->getHealth(health)) &&
           health.status != SL_LIDAR_STATUS_ERROR;
}

  재연결 성공 후 배경 재학습 없이 바로 이어서 촬영할 수 있어 끊김 후 복구 시간이 크게 줄었습니다.


7. 라즈베리파이 5 발열 — 촬영 후반 루프 느려짐

증상

  촬영을 30분 이상 반복하다 보니 후반 테이크에서 Unity 화면이 눈에 띄게 뚝뚝 끊겼습니다. 로그를 보니 루프 주기가 안정적인 100ms에서 150~200ms로 늘어나 있었습니다. vcgencmd measure_temp 명령으로 CPU 온도를 확인하니 82°C — 라즈베리파이 5의 온도 제한(85°C)에 근접하여 CPU 클럭이 자동으로 낮아진 상태였습니다.

원인

  촬영 장소가 복도여서 환기가 되기는 했지만, LiDAR 드라이버·DBSCAN·JSON 직렬화·서보 Slew Rate 스텝 루프가 모두 메인 루프에 집중되어 CPU를 지속적으로 점유했습니다. 라즈베리파이 5는 기본 CPU governor가 ondemand라서 상시 고부하에서 클럭을 올렸다가 열 제한에 걸리면 다운클럭하는 패턴이 반복됐습니다.

수정

① CPU governor를 performance로 고정

# setup.sh에 추가
echo performance | sudo tee /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor

  ondemand는 부하에 반응하여 클럭을 올리는 과정 자체에 레이턴시가 생깁니다. performance로 고정하면 클럭이 항상 최대치에서 시작되어 오히려 발열 피크가 낮아집니다.

② 루프 주기 경고 로그 추가

// main.cpp — 루프 끝
auto loop_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
    std::chrono::steady_clock::now() - loop_start).count();
if (loop_ms > 120) {
    std::cerr << "[WARN] 루프 지연 " << loop_ms << "ms (목표 100ms)\n";
}

  경고 로그 덕분에 "언제부터 느려지기 시작했는지"를 촬영 중에 바로 알 수 있었습니다. 두 조치 이후 1시간 촬영에서도 루프 주기가 100±5ms로 안정됐습니다.


8. 최종 시연 영상 결과

  7가지 이슈를 수정한 뒤 랩실 복도에서 최종 영상을 촬영했습니다.

반복 촬영 배경 대기 재시작 없이 리셋 수단 없음 SIGUSR1 배경 즉시 리셋
서보 zone 경계 oscillation 각도 노이즈가 zone 경계를 넘나듦 Hysteresis ±10°
서보 오버슛 흔들림 전각도 즉시 이동 Slew Rate 15°/스텝
Unity 사람 늦게 나타남 Tentative 확정 8프레임 환경변수로 4프레임으로 단축
humanPrefab 잔상 departure 단발 전송 5프레임 반복 전송
LiDAR USB 끊김 재연결 로직 없음 재연결 루프 + 상태 확인
후반 루프 느려짐 RPi5 발열 다운클럭 CPU governor performance 고정

  시연 시나리오(시연자 진입 → 봉투 투기 → 서보 조준 → Unity 투기 확정 표시)가 처음부터 끝까지 끊김 없이 담긴 영상으로 발표를 마쳤습니다.


9. 이번 주 회고

잘된 점

  • SIGUSR1 배경 리셋은 처음에 빠른 임시방편으로 만들었지만, 결과적으로 "촬영 현장에서 재시작 없이 상태를 초기화하는" 운영 도구로 계속 유용하게 쓰였습니다. 외부 시그널로 내부 상태를 제어하는 패턴은 이후 다른 파라미터 핫리로드에도 같은 방식을 쓸 수 있을 것 같습니다.
  • 환경변수(ECOWARDEN_TENTATIVE_CONFIRM_FRAMES)로 Tentative 프레임을 조정한 것처럼, "시연 전용 튜닝"과 "회귀 기본값"을 코드 변경 없이 분리할 수 있어서 안전했습니다. 회귀를 건드리지 않고 영상 품질을 올릴 수 있었습니다.
  • Hysteresis와 Slew Rate는 각각 작은 수정이었지만, 영상에서 서보의 시각적 완성도를 크게 끌어올렸습니다. 알고리즘 정확도만큼 "보이는 것"의 질도 중요하다는 것을 다시 확인했습니다.

어려웠던 점

  • 회귀 28종이 전부 통과한 상태에서도 실제 촬영에 들어가니 새 이슈가 7개나 나왔습니다. 시뮬레이션은 데이터를 정해진 순서로 주입하기 때문에 "여러 번 재촬영", "USB 케이블 스트레스", "장시간 발열" 같은 운영 환경 이슈는 테스트 범위 밖이었습니다. 실제 하드웨어와 현장 환경을 포함한 E2E 테스트의 필요성을 체감했습니다.
  • Tentative 프레임을 줄이면 빠른 반응을 얻는 대신 노이즈 클러스터가 잠깐 Unity에 나타날 수 있는 트레이드오프가 있었습니다. 복도 환경이 깨끗했기 때문에 4프레임이 안전했지만, 노이즈가 많은 환경에서는 다시 늘려야 합니다. 파라미터 하나의 적정값이 환경에 따라 크게 달라진다는 점을 다시 확인했습니다.

[2026 ABC 프로젝트 멘토링 1기] 프로젝트 6주차

2026 ABC 프로젝트 멘토링

서보 카메라 도입 · PersonGroup · Source Lock 3조건


이번 주 목표

  4주차까지 MVP가 통합되어 라즈베리파이에서 10Hz 루프가 안정적으로 동작하지만, 실외·근거리 환경에서 다음 한계가 드러났습니다.

  1. 카메라가 한 방향만 바라보고 있어 사람·투기물이 화각을 벗어나면 증거 사진 품질이 떨어짐
  2. 사람 1명이 다리 보폭 때문에 2개 객체로 분리되어 디지털 트윈에 두 명처럼 보임
  3. 정지한 물체 앞을 새로 지나가는 행인이 투기 주체로 오판되어 오탐 발생

  이 세 문제는 단일 파라미터 조정으로는 해결되지 않았고, 새로운 모듈/추상화 도입이 필요했습니다.

  • SG90 서보 + Arduino USB serial 백엔드로 5-zone 카메라 조준
  • 사람별 사진 세션(captures/person_zones/person_<id>/) + 확정 시 dump bundle 이동
  • 다리 anti-phase 보행을 1명으로 묶는 PersonGroup / ObjectCandidate 추상화
  • 투기 확정 주체를 생성 시점에 고정하는 Source Lock + Birth Gate + Direct Evidence 3조건

1. 서보 카메라 시스템 — 5-zone 조준

왜 서보가 필요했나

  4주차에서는 카메라가 정면 한 방향만 촬영했습니다. LiDAR 화각은 360°(전방 180° 사용)인 반면 카메라는 약 60° 화각에 불과해, 측면에 발생한 투기 의심 위치가 사진의 가장자리에 걸리거나 아예 화각을 벗어나는 일이 잦았습니다. 카메라를 상시 회전시키면 모터가 발열하므로, suspect 좌표가 들어올 때만 그 방향으로 조준하는 이벤트 기반 구조를 택했습니다.

Arduino USB serial 백엔드 — 라즈베리파이 5 PWM 회피

  라즈베리파이 5는 RP1 GPIO로 변경되면서 sysfs PWM 경로(/sys/class/pwm/pwmchip2/pwm0)가 디스트로별로 불안정합니다. 특히 dtoverlay=pwm-pi5 적용 여부와 dtparam=audio=on이 PWM 채널을 선점하는 충돌이 자주 보고
됐습니다. 이를 회피하기 위해 Arduino Nano를 USB serial로 연결하여 PWM 신호 생성을 별도 모듈화했습니다.

[Raspberry Pi 5]                       [Arduino Nano]              [SG90 Servo]
  ServoController        USB serial      servo_zone_controller       PWM 50Hz
  ───────────────────► /dev/ttyACM0 ────► Servo.write(angle) ─────► 0~180°
  "A 90\n"  (115200)              "OK 90\n"
A <angle>\n" 텍스트를 보내고 "OK <angle>

  라즈베리파이는 응답을 1.5초 데드라인으로 기다립니다. 응답이 없으면 카메라-only fallback으로 떨어져 LiDAR 추적 자체는 멈추지 않게 했습니다. PWM sysfs 백엔드(backend=pwm)는 환경변수로 선택할 수 있도록 남겨 RPi3/4 호환을 유지했습니다

Suspect → Zone 매핑

  LiDAR 센서 좌표(x_mm, y_mm)를 5-zone(0/45/90/135/180°) 중 하나로 변환합니다.

// include/servo_zone.h
inline int SuspectToZone(double x_mm, double y_mm, bool servo_mirror) {
    const double forward_mm = std::max(std::abs(y_mm), 1.0);
    double angle = std::atan2(x_mm, forward_mm) * 180.0 / kPi + 90.0;
    angle = std::clamp(angle, 0.0, 180.0);
    if (servo_mirror) angle = 180.0 - angle;
    return std::clamp(static_cast<int>(std::round(angle / 45.0)), 0, 4);
}

  servo_mirror(ECOWARDEN_SERVO_MIRROR)는 서보가 LiDAR 옆에 거꾸로 장착됐을 때 좌우를 보정하는 환경변수입니다. 매 현장마다 본체 코드를 다시 빌드하지 않도록 분리했습니다.

Worker thread + 비동기 큐

  SuspectCallback이 카메라 캡처를 직접 호출하면 4주차에서 격리한 비동기 흐름이 다시 막힙니다. ServoController는 자체 worker thread와 PendingJob 큐를 가지고, 메인 루프는 OnSuspect() / OnPersonDetected()로 좌표만 등록합니다.

LiDAR 10Hz 루프
   │ OnSuspect(evt)         ─┐
   │ OnPersonDetected(...)  ─┼─▶ pending(Job) + cv.notify_one()
   │                          │
   └ (즉시 리턴, 블로킹 없음) │
                              ▼
                    ServoController worker thread
                              │
                       MoveToAngle() (settle 80ms)
                       camera.CaptureToFilePath()
                       suspect cache 갱신

  Suspect job이 큐에 있을 때 같은 종류 person job이 들어오면 덮어쓰기 대신 무시하여, 확정 직전 사진이 행인 사진으로 교체되는 것을 막았습니다.

사람별 사진 세션 + dump bundle 승격

  PIR/LiDAR가 사람 트랙을 만들면 OnPersonDetected()가 호출되어 그 사람 ID 폴더(captures/person_zones/person_<id>/) 안에 zone별 사진을 적립합니다. person_capture_interval_ms=2000, person_session_timeout_ms=3000으로 zone 변경/시간 경과 시에만 새 사진을 찍어 디스크 부담을 제어합니다.

  해당 사람이 투기 확정으로 승격되면 PromoteSuspectToDump()가 호출되어 폴더 자체를 dump bundle(captures/dumps/<ts>_<id>/person_<id>/)로 이동합니다. 이 구조 덕분에 확정 1건당 동일 사람의 zone 5장 + suspect/confirm 2장 + meta.json이 한 폴더에 모이므로, 사후 단속관이 동선을 바로 재구성할 수 있습니다.

captures/
├── person_zones/person_17/           ← 진행 중인 사람 세션
│   ├── 20260502T013015_zone_2.jpg
│   └── 20260502T013017_zone_3.jpg
└── dumps/20260502T013024_42/          ← 확정 후 이동
    ├── suspect.jpg
    ├── confirm.jpg
    ├── meta.json
    └── person_17/                     ← 폴더째 승격
        ├── 20260502T013015_zone_2.jpg
        └── 20260502T013017_zone_3.jpg

Arduino 펌웨어 (servo_zone_controller.ino)

  Arduino 측은 단순한 라인 파서입니다. "A 135" 또는 "135"를 받으면 0~180 범위 검사 후 Servo.write()를 호출하고 "OK 135"를 회신합니다. 범위 밖이면 "ERR RANGE"로 회신해 호스트가 즉시 알 수 있습니다. 부팅 시 90°(정중앙)로 초기화하므로 서보 위치가 알 수 없는 상태로 남지 않습니다.


2. PersonGroup — 보폭으로 분리된 다리를 1명으로 묶기

문제

  15cm 높이로 LiDAR를 설치하면 사람 양다리가 별도 클러스터로 잡힙니다. 두 다리는 서로 반대 위상(anti-phase)으로 움직이기 때문에 순간 속도 dot product가 음수가 되어, 단순 속도 일치 그룹화로는 묶이지 않습니다. 결과적으로 한 명의 사람이 디지털 트윈에 두 명처럼 표시되거나, 보폭이 벌어지는 순간 새 ID가 발급되어 잔상이 떠다녔습니다.

설계

  PersonEntity 컨테이너를 도입하여 leg track 1~2개를 하나의 사람으로 보존합니다. 그룹 조건은 위치/폭 우선이며 속도 부호는 강제하지 않습니다.

PersonEntity {
  representative_id            // 디지털 트윈 송신용 합성 ID
  leg_tracks[1..2]             // 묶인 leg track ID
  hold_frames                  // 일시적으로 끊겨도 유지하는 카운터
  last_centroid, last_width
}

핵심 매개변수

person_group_pair_radius_mm 600 anti-phase 다리쌍 기본 반경
person_group_new_pair_max_width_mm 650 신규 쌍 생성 최대 폭 (좁은 보폭)
person_group_wide_pair_radius_mm 1500 넓은 보폭 신규 쌍 허용 (Y gap 게이트 동반)
person_group_wide_pair_max_depth_gap_mm 500 넓은 쌍의 Y축 깊이 차이 상한 — 두 사람 오병합 방지
person_group_attach_radius_mm 1600 기존 그룹에 single-leg attach 반경
person_group_rejoin_radius_mm 1500 held leg 재결합 반경
person_group_hold_frames 14~18 그룹 깨진 후에도 유지하는 프레임 수

  좁은 보폭(new_pair_max_width≤650)과 넓은 보폭(wide_pair_radius=1500 + Y gap≤500) 두 케이스를 분리한 것이 핵심입니다. 넓은 보폭만 허용하면 가까운 두 사람이 한 그룹으로 오병합되고, 좁은 케이스만 허용하면 큰 사람이 두 명으로 split됩니다. Y축 깊이 게이트가 두 케이스의 균형을 잡습니다.

ObjectCandidate — 정지 소형 잔상 보호

  PersonGroup에 묶이지 않은 정지 소형 트랙(width ≤ 250mm, stationary_count ≥ 3)은 is_object_candidate=true로 표시하여 디지털 트윈 normal 송신에서 제외합니다. 이는 사람 옆에 작은 점 객체가 같이 보이는 현상을 차단함과 동시에, 추적은 그대로 유지하여 이후 PersonGroup에 attach되거나 투기 후보로 승격될 길을 열어둡니다.

Tentative → Confirmed 승격 게이트

  신규 트랙은 TrackState::Tentative로 시작해 일정 안정 관측 후에만 Confirmed로 승격됩니다. Tentative 동안에는 디지털 트윈 송신을 보류하여, 1~2 프레임 반사 spike가 객체로 표시되는 경로 자체를 막습니다. PersonGroup attach 시에는 group hold 카운터로 짧은 끊김을 흡수하므로, 승격 전 잡음과 승격 후 그룹화가 한 컨테이너 안에서 일관되게 처리됩니다.

Departure 이벤트로 잔상 ID 제거

  그룹 hold 중에 hidden 처리되는 secondary leg, 송신에서 제외된 object_candidate, lost 트랙은 departure 이벤트를 별도로 발송합니다. 이 이벤트가 없으면 디지털 트윈 측 humanPrefab이 이전 ID를 그대로 들고 있다가 재생성 시 두 명처럼 보이는 잔상이 생깁니다.

{ "type": "departure", "track_id": 17, "timestamp": 1712290800000 }

  person_track_id 필드는 내부 leg ID 대신 source_person_entity_id(group 대표)를 우선 사용해 디지털 트윈 그룹 ID와 정합을 유지합니다.


3. Source Lock + Birth Gate + Direct Evidence

문제

  기존 확정 로직은 "사람 트랙이 일정 거리 이상 이탈"만 검사했습니다. 이 때문에 오래 정지하던 물체 앞을 새로 지나가는 행인이 확정 트리거를 만족시켜 오탐을 만들었습니다. 단속 영상으로 자주 사용되는 위치(자판기 옆, 벤치 옆)에서 특히 빈번했습니다.

설계

  dump candidate가 생성되는 순간의 source person을 트랙 필드에 영구 기록하고, 이후 다른 사람의 이탈은 확정 근거로 인정하지 않습니다.

struct Track {
  // ...
  int    source_person_entity_id   = -1;   // 생성 시점 source 그룹
  int    birth_frame               = -1;   // 트랙 생성 프레임
  bool   newly_created_after_source = false;
  bool   direct_dump_evidence      = false; // 분열/폭 감소 등 직접 증거
};

3조건 동시 요구

  확정 경로 진입에 다음 세 조건을 모두 요구합니다.

source_locked 후보 생성 시점 source가 식별됐고, 이후 그 source(또는 그룹)만 확정에 관여
newly_created_after_source source 출현 이후 새로 생긴 트랙임을 확인 (오래된 정지 물체 차단)
direct_dump_evidence 너비 급감/클러스터 분열/source 궤적 위 신규 등 직접 증거 1건 이상

  세 조건은 현장 튜닝으로 끄지 않는다가 운영 기준입니다. 이 셋을 우회하면 passerby 오탐이 다시 열리는 것을 회귀에서 직접 확인했기 때문입니다.

Source Lost 경로

  source 트랙이 일정 프레임 매칭에 실패하면 source_lost_confirm_frames(기본 8) 경로로 별도 확정합니다. 이는 source가 화각을 빠르게 벗어나는 정상 시나리오를 위한 것이며, 동일하게 direct_dump_evidence 1건 이상을 같이 요구해 오탐 가능성을 막았습니다.


4. 이번 주 회고

잘된 점

  • 서보를 Arduino USB serial로 별도 모듈화한 결정 덕분에 RPi5의 PWM 호환성 문제(GPIO12 alt-pin, audio overlay 충돌)를 우회했습니다. 본체 코드는 "A 90\n" 텍스트만 보내면 되므로, 다음에 다른 보드(예: ESP32)로 바꿔도 펌웨어만 교체하면 됩니다.
  • 사람별 사진 세션과 dump bundle 폴더 이동은 단속관 측에서 "이 투기를 한 사람이 직전 어떤 zone들을 지나왔는지"를 한 폴더에서 확인할 수 있게 했습니다. 추가 메타데이터 없이 폴더 구조만으로 포렌식 흐름이 잡혔습니다.
  • 다리쌍 묶음을 별도 추상화(PersonGroup)로 끌어올린 덕분에 디지털 트윈 송신 규약, 잔상 억제, 확정 source 식별이 한 컨테이너에서 일관되게 처리됐습니다. 분기문이 줄고 게이트 추가가 쉬워졌습니다.
  • Source Lock + Birth Gate + Direct Evidence 3조건은 "오탐을 줄이려고 다른 무언가를 강하게 만들면 미탐이 늘어나는" 트레이드오프를 끊어냈습니다. 이후 fast confirm 등 추가 경로를 도입해도 이 3조건을 그대로 요구해 안전성이 유지됐습니다.

어려웠던 점

  • 서보 worker thread와 메인 루프 사이의 우선순위 결정이 까다로웠습니다. suspect job이 도착했는데 person job이 큐에 있으면 어느 쪽을 우선해야 할지 — 결국 suspect 우선 + person 무시(덮어쓰기 금지)로 정했지만, 사람 사진 세션이 끊기는 부작용이 있어 현장 모니터링을 하면수 수정했습니다.
  • 보폭 / 두 사람 오병합 / 쓰레기-사람 분리 세 요구가 서로 반대 방향으로 작용해, PersonGroup 반경 한 값을 결정하는 데 여러 회귀를 동시에 만족시켜야 했습니다. Y축 깊이 게이트와 single-leg attach를 분리한 후에야 균형이 잡혔습니다.
  • "확정 직전에 보이는 객체 타입"을 일관되게 정의하지 않으면 디지털 트윈 측에서 잔상 humanPrefab과 신규 dumped object가 동시에 떠다니는 시각적 혼란이 발생합니다. JSON departure 이벤트를 추가하면서 이 규약을 코드와 문서 양쪽에 못 박았습니다.

[2026 ABC 프로젝트 멘토링 1기] 프로젝트 5주차

2026 ABC 프로젝트 멘토링

MVP 통합 및 배포 자동화


이번 주 목표

  • LiDAR · Camera · PIR · 네트워크 전체 통합으로 MVP 개발 완성
  • 카메라 모듈(PIMPL 패턴, 비동기 캡처, Base64 인코딩) 구현
  • EventNotifier로 비동기 HTTP POST + 파일 기반 재전송 큐 구현
  • UDP 바이너리 프로토콜 추가로 Unity 전송 최적화
  • systemd 서비스로 부팅 시 자동 실행
  • 실기기 배포 후 고도화 작업으로 이어질 병목 지점 측정

1. 카메라 모듈 설계 (CameraModule)

PIMPL 패턴으로 OpenCV 의존성 격리

  카메라 관련 헤더를 시스템 전체에 전파하지 않기 위해 PIMPL(Pointer to Implementation) 패턴을 적용했습니다.camera_module.h는 struct Impl을 전방 선언만 하고, OpenCV 타입>은 camera_module.cpp 내부에서만 접근합니다.

// camera_module.h (헤더)
class CameraModule {
    struct Impl;  // 전방 선언만
    std::unique_ptr<Impl> impl_;
};

// camera_module.cpp (구현)
struct CameraModule::Impl {
#ifdef USE_OPENCV
    cv::VideoCapture cap;
    cv::Mat          latest_frame;
#endif
};

  USE_OPENCV 빌드 플래그가 꺼진 환경(macOS 개발 머신)에서도 링크 에러 없이 빌드됩니다.

비동기 프레임 버퍼링

  LiDAR 메인 루프(10Hz)와 카메라(30fps)는 독립적인 속도로 동작합니다. 카메라 캡처를 메인 루프에서 직접 호출하면 LiDAR 처리가 블로킹됩니다. 이를 해결하기 위해 별도 스레드에서 최신 프레임을 지속적으로 버퍼에 유지합니다.

void CameraModule::CaptureLoop() {
    cv::Mat temp_frame;
    while (running_) {
        if (impl_->cap.read(temp_frame)) {
            std::lock_guard<std::mutex> lock(frame_mutex_);
            temp_frame.copyTo(impl_->latest_frame);
            frame_ready_ = true;
            frame_cv_.notify_all();
        }
        std::this_thread::sleep_for(std::chrono::milliseconds(10));
    }
}

  투기 감지 이벤트 발생 시 CaptureToFile()(증거 파일 저장) 또는 CaptureBase64()(서버 전송용 인코딩)를 메인 스레드에서 논블로킹으로 호출할 수 있습니다.

Base64 인코딩 최적화

  기존 Base64 구현은 문자 단위로 += 연산을 반복하여 동적 메모리 할당이 과도했습니다. 출력 크기(4 × ⌈n/3⌉)를 미리 계산하고 reserve()로 단일 할당 후 처리하도록 수정했습니다.

size_t out_len = 4 * ((in_len + 2) / 3);
std::string ret;
ret.reserve(out_len);  // 단일 할당

  JPEG 품질은 의심 단계 촬영(파일 저장)에서는 90, 확정 단계 전송용에서는 80으로 구분합니다. 전송 데이터 크기와 증거 품질의 균형을 맞춘 설정입니다.


2. 이벤트 전송 (EventNotifier)

비동기 전송 큐

  투기 확정 이벤트 발생 시 HTTP POST를 메인 루프에서 직접 호출하면 LiDAR 10Hz 루프가 네트워크 지연에 영향을 받습니다. EventNotifier는 독립 전송 스레드를 보유하여 이벤트를 >큐에 넣기만 하면 됩니다.

메인 루프 ──EnqueueJson(json)──▶ send_queue_  (mutex)
                                        │
                              SendLoop 스레드                                        │
                               HttpPost(json)──▶ FastAPI HTTPS
                                   │
                              실패 시 ──▶ pending_queue_ + 파일 큐

libcurl HTTPS POST

  인증 헤더 X-API-Key를 포함한 HTTPS POST를 libcurl로 구현했습니다. curl 핸들은 매 요청마다 생성/해제하여 멀티스레드 안전성을 확보했습니다.

curl_easy_setopt(curl, CURLOPT_URL, cfg_.endpoint_url.c_str());
curl_easy_setopt(curl, CURLOPT_POST, 1L);
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, json_body.c_str());
curl_easy_setopt(curl, CURLOPT_TIMEOUT_MS, timeout);
curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L);   // 멀티스레드 안전

  CURLOPT_NOSIGNAL은 멀티스레드 환경에서 libcurl이 SIGALRM을 사용하지 않도록 하는 필수 설정입니다.

파일 기반 재전송 큐 (JSONL)

  전송 실패 시 데이터 손실을 방지하기 위해 event_queue.jsonl 파일에 실패한 이벤트를 기록합니다. 재전송 전용 스레드(RetryLoop)가 주기적으로 파일을 읽어 재시도합니다.

event_queue.jsonl 구조:
{"person_id":5,"object_id":6,"event_time":...}\n
{"person_id":7,"object_id":8,"event_time":...}\n

  재전송 전 중복 제거(std::sort + std::unique)를 수행하여 와이파이 끊김 후 복구 시 동일 이벤트가 중복 전송되지 않습니다.

Dry-Run 모드

  개발·테스트 환경에서 실제 전송 없이 동작을 검증할 수 있도록 환경변수로 제어합니다.

ECOWARDEN_DRY_RUN=1 ./rplidar_app
# [NOTIFIER] ⚠ DRY-RUN MODE — 모든 이벤트가 drop됩니다.

 


3. 메인 루프 통합 (main.cpp)

초기화 순서

// 1. 하드웨어 초기화
RplidarS2 lidar(cfg);        lidar.Start();
CameraModule camera(0);      camera.Start();
PirSensor pir_l(17);         pir_l.Init();
PirSensor pir_r(27);         pir_r.Init();

// 2. 처리 모듈 초기화
ScanProcessor processor(fp, dp);
BackgroundFilter bg_filter(bgp);
ClusterTracker tracker(tp);
TrackClassifier classifier;

// 3. 네트워크 모듈 초기화
EventNotifier notifier(nc);  notifier.StartRetryThread();
JsonPacketSender json_sender(unity_ip, unity_port);
UdpSender viz_sender("127.0.0.1", 9090);

투기 의심 시점 콜백

  투기 의심(DumpingSuspectEvent) 발생 시 확정을 기다리지 않고 즉시 사진을 촬영합니다. 3초(30프레임) 대기 후에는 주체가 이미 사라지므로, 증거 확보를 위해 선제적으로 촬영합니
다.

tracker.SetSuspectCallback([&camera, &suspect_photos](const DumpingSuspectEvent& evt) {
    std::string path = camera.CaptureToFile();
    if (!path.empty()) {
        suspect_photos[evt.object_track_id] = path;
    }
});

10Hz 메인 루프 구조

while (g_running) {
    lidar.GetScanFrame(scan_frame);

    processor.Process(scan_frame, clusters);            // 3주차 5단계 파이프라인
    processor.LearnStaticBackground(scan, blocked);     // EMA 학습

    bool operational = bg_filter.Apply(clusters);       // 적응형 배경
    if (!operational) { /* 학습 중, 시각화 데이터만 전송 */ continue; }

    pir_l_active = pir_l.Read();
    pir_r_active = pir_r.Read();

    tracker.Update(clusters, cart_points);              // 2주차 추적 FSM
    classifier.Classify(tracker.GetTracksMutable(), …); // 분류

    json_sender.Send(tracks, events, sys_state);   // Unity JSON UDP
    viz_sender.Send(tracks, clusters);             // 시각화기 JSON UDP
    udp.SendClusters(tracked_clusters);            // Unity 바이너리 UDP
}

  g_running 플래그는 SIGINT/SIGTERM 핸들러에서 0으로 설정됩니다. Ctrl+C 시 정상 종료되며 notifier 재전송 스레드도 안전하게 join됩니다.


4. UDP 전송 계층 — 바이너리 프로토콜 추가

  JSON 전송 규약(필드 의미·system_state 정의 등)은 3주차 통신 데이터 모델 섹션에서 이미 정립했습니다. 이번 주는 동일 데이터를 UDP 위에서 어떻게 효율적으로 운반할지에 집중했습니다.

JSON과 바이너리 이중 전송

  Unity는 JSON과 바이너리를 동시에 수신합니다. JSON은 가독성·확장성, 바이너리는 직렬화 비용·패킷 크기에서 각각 강점이 있어 용도를 분리했습니다.

 

Unity JSON 5005 JSON (tracked_only=true) 트랙 위치, 카테고리, 이벤트
Unity Binary 5005 Packed struct 원시 클러스터·점구름 (시각 효과)
시각화기 9090 JSON (전체) Python 디버그 시각화

바이너리 패킷 레이아웃

PacketHeader (20 bytes)
  magic[4] + version + type + count + frame_id + timestamp

PointItem (9 bytes per point)
  x_mm(int16) + y_mm(int16) + intensity(uint8) + distance_mm(uint16) + flags

ClusterItem (25 bytes per cluster)
  centroid_x(float) + centroid_y(float) + width(float)
  + track_id(uint32) + state + type

  같은 트랙 1개를 표현할 때 JSON은 약 80B, 바이너리는 25B로 약 1/3 크기입니다. 8,192점 시각화 시 JSON은 MTU(1500B) 한계로 다중 프래그먼트가 강제되지만, 바이너리는 9 × 8192 ≈ 72KB를 50개 프래그먼트로 자동 분할 전송합니다.

MTU 자동 프래그먼트

1프레임 데이터가 1500B 초과 시:
  ├─ Header에 fragment_index, fragment_total 기록
  ├─ 1400B 단위로 분할
  └─ 수신 측이 frame_id로 재조립

  UDP는 신뢰성이 없지만 LiDAR 점구름은 매 100ms 갱신되므로 손실된 프래그먼트는 다음 프레임으로 빠르게 보충됩니다. 재전송 메커니즘 없이도 시각적으로 문제없는 안정성을 얻었습니다.

메시지 타입 — FRAME / STATE / HEARTBEAT

  평시에는 FRAME 메시지만 흐르지만, Unity가 "데이터 끊김"과 "의도적 대기"를 구분할 수 있도록 두 가지 보조 타입을 추가했습니다.

타입 발송 시점 의도

FRAME 매 프레임(10Hz) 정상 운용 데이터
STATE 시스템 상태 전환 즉시 warming_up→active 등 1회성 알림
HEARTBEAT Standby 중 1Hz 살아 있음 신호

  이 분리 덕분에 Unity 측에서 "10Hz 데이터가 갑자기 끊겼다"와 "Standby로 전환되어 1Hz heartbeat만 온다"를 명확히 식별할 수 있습니다.


5. systemd 서비스 배포

  라즈베리파이가 부팅될 때마다 수동으로 바이너리를 실행하는 것은 비현실적입니다. ecowarden.service를 작성하여 systemd에 등록했습니다.

# deploy/ecowarden.service
[Unit]
Description=EcoWarden LiDAR Dumping Detection
After=network.target

[Service]
Type=simple
ExecStart=/usr/local/bin/rplidar_app \
    /dev/ttyUSB0 \
    https://api.ecowarden.systems/api/dumping-event \
    192.168.20.6:5005
Environment=ECOWARDEN_DRY_RUN=0
Restart=on-failure
RestartSec=5
NoNewPrivileges=yes
ProtectSystem=strict

[Install]
WantedBy=multi-user.target

  NoNewPrivileges와 ProtectSystem=strict로 프로세스가 불필요한 시스템 권한을 갖지 않도록 격리했습니다. Restart=on-failure로 예외 종료 시 5초 후 자동 재시작됩니다.

sudo cp deploy/ecowarden.service /etc/systemd/system/
sudo systemctl enable ecowarden
sudo systemctl start ecowarden
sudo systemctl status ecowarden

자동화 설치 스크립트 (setup.sh)

  의존성 설치부터 udev 규칙(1주차에서 정의), 서비스 등록까지 한 번에 처리합니다.

sudo apt install -y libcurl4-openssl-dev libgpiod-dev python3-pygame
sudo udevadm control --reload-rules
mkdir build && cd build && cmake .. && make -j$(nproc)
sudo systemctl enable --now ecowarden

 


6. 성능 프로파일링 기준선 (PhaseProfiler)

  10Hz 루프의 각 단계별 처리 시간을 측정하는 phase_profiler.h를 구현했습니다. 라즈베리파이 5 실기기에서 측정한 초기 기준선입니다.

Phase 평균 처리 시간

TemporalFilter < 1ms
DBSCAN 2~5ms
ClusterTracker 1~3ms
JSON 직렬화 < 1ms
전체 루프 < 10ms (목표 100ms의 10%)

  목표 주기인 100ms 대비 10% 이내로 처리되어 향후 분류기·전력 관리 모듈을 추가해도 충분한 여유가 있음을 확인했습니다. 단계별 세부 프로파일링은 5주차 시뮬레이션 환경에서 더 정밀하게 진행합니다.


7. 이번 주 회고

잘된 점

  • PIMPL 패턴으로 OpenCV 의존성을 완벽히 격리하여 macOS 개발 환경에서도 빌드가 통과됩니다.
  • 비동기 카메라 버퍼링과 비동기 HTTP 전송 큐 덕분에 10Hz LiDAR 루프가 외부 I/O 지연에 전혀 영향받지 않는 구조가 완성됐습니다.
  • JSONL 파일 큐를 통해 와이파이 단절 상황에서도 이벤트 데이터가 손실되지 않습니다.
  • 바이너리 프로토콜 추가로 점구름 시각화에서 JSON 대비 1/3 패킷 크기를 달성했습니다.

어려웠던 점

  • libcurl의 CURLOPT_NOSIGNAL 옵션을 빠뜨렸다가 멀티스레드 환경에서 랜덤한 SIGALRM 크래시가 발생했습니다. 임베디드 환경에서 libcurl 멀티스레드 주의사항은 문서를 꼼꼼히 읽어야 한다는 것을 배웠습니다.
  • UDP는 connectionless이므로 Unity가 꺼진 상태에서도 전송 측에 에러가 없습니다. 처음에는 Unity 연결 상태를 체크하는 코드를 넣었다가, 불필요한 복잡성이라고 판단하여 제거했습니다
  • systemd의 ProtectSystem=strict가 /dev/ttyUSB0 접근을 막는 경우가 있어, udev 규칙과 디바이스 권한을 별도로 설정해야 했습니다.

[2026 ABC 프로젝트 멘토링 1기] 프로젝트 4주차

2026 ABC 프로젝트 멘토링

점구름 파이프라인 구현 & 통신 데이터 구조 정립


이전 주차의 한계: 걷기만 하는 인형 상태

  2주차에서 RPLiDAR S2 원시 점구름을 수신하고, Unity에 휴먼 에셋을 도입하여 LiDAR 좌표로 3D 캐릭터를 이동시키는 데 성공했습니다. 그러나 두 가지 근본적인 문제가 남아 있었습니다.

  문제 1 (임베디드) — 원시 점구름을 필터링 없이 전송했기 때문에, 햇빛 반사·센서 노이즈로 인한 허상 클러스터가 Unity에 전달되어 유령 캐릭터가 순간적으로 생성됐다가 사라지는 현상이 발생했습니다.

  문제 2 (통신 규격) — 매 프레임 위치를 갱신하는 일반 객체 정보와 특정 순간에만 발생하는 사건(투기) 정보가 분리되지 않았습니다. 결과적으로 쓰레기 투기 감지 이벤트를 수신 측에 명확하게 전달할 수 없는 상태였습니다.

이번 주는 이 두 문제를 각자의 영역에서 해결했습니다.


1. 통신 데이터 모델 설계 — '상태(State)'와 '사건(Event)' 분리

설계 원칙

  매 프레임 전송되는 위치 정보(objects)와 특정 조건이 충족될 때만 발생하는 사건 정보(events)를 동일한 JSON 패킷 안에서 명확히 분리했습니다. 이 구분 덕분에 수신 측은 events 배열이 비어 있으면 캐릭터 이동만 처리하고, events에 데이터가 있을 때만 추가 로직을 실행할 수 있습니다.

임베디드 → 수신 측 전송 JSON 구조 (C++ 송신 측)

{
    "type": "FRAME",
    "frame_id": 42,
    "timestamp": 1712290800000,
    "system_state": "active",
    "objects": [
        {
            "id": 1,
            "x": 1200.0,
            "y": -350.0,
            "type": "normal",
            "width": 380.0,
            "category": "human"
        }
    ],
    "tracks": [
        {
            "id": 1,
            "x": 1200.5,
            "y": -350.2,
            "state": "moving",
            "is_dumped_item": false,
            "is_dump_suspect": false
        }
    ],
    "points": [],
    "events": [
        {
            "type": "dumping",
            "person_track_id": 1,
            "person_x": 1200.0,
            "person_y": -350.0,
            "object_track_id": 2,
            "object_x": 900.0,
            "object_y": -200.0,
            "timestamp": 1712290800000
        }
    ]
}
  • objects — 매 프레임 전송. 추적 확인된 객체만 포함(tracked_only=true). 노이즈·고스트 클러스터는 트랙 연결 전까지 여기 나타나지 않음.
  • events — 투기 확정 시에만 배열에 데이터가 담김. 평시에는 [].
  • system_state — "active" / "warming_up" / "standby" / "validating". 수신 측이 상태별 UI를 표시하는 데 사용.

2. 좌표계 규약 — 센서 중심 mm 단위

  임베디드는 LiDAR 센서를 원점으로 한 mm 단위 2D 직교좌표를 그대로 전송합니다.

LiDAR 극좌표 (angle, distance_mm)
  → ToCartesian()
  → (x_mm, y_mm) : 센서 중심 직교좌표 (mm)
  → JSON objects[].x, objects[].y 로 전송

  수신 측이 이 좌표를 어떤 월드 좌표계로 변환하는지는 수신 측의 책임입니다. 임베디드는 씬 배치·스케일·카메라 위치와 완전히 독립적으로 동작합니다. 단위 변환(mm → m 등)과 월드 좌표 매핑은 수신 측이 전담합니다.


3. 투기 판정 조건

  임베디드가 events[]에 "dumping" 항목을 채워 넣는 조건은 다음과 같습니다.

물체 정지 지속 30프레임 (3초) 잠깐 내려놓기가 아닌 실제 투기 판단
주체 이탈 거리 1,000mm 이상 투기 후 현장 이탈 확인
category 필터 Human 트랙만 주체 허용 동물·무생물 트랙이 투기 주체로 오판되지 않음

  조건을 모두 충족하는 순간 한 번만 events에 추가합니다. 이후 같은 이벤트가 중복 발송되지 않도록 dump_confirmed_ 플래그로 관리합니다.


4. 임베디드 점구름 처리 파이프라인

  Unity에 도달하기 전에 임베디드 측에서 노이즈·허상 클러스터를 제거하는 5단계 파이프라인을 구현했습니다. 이 파이프라인이 없으면 Unity에 매 프레임 수십 개의 허상 객체가 전달됩니다.

Raw ScanFrame (8,192 HQ nodes)
  │
  ├─ [Step 0] ApplyTemporalFilter   ← 슬롯별 ±300mm 급변 클램프
  ├─ [Step 1] FilterNoise           ← intensity, distance, FOV 게이트
  ├─ [Step 2] FilterStaticBackground← 벽·고정 구조물 제거 (EMA 모델 대비 100mm 마진)
  ├─ [Step 3] ToCartesian           ← 극좌표 → 직교좌표 (mm)
  ├─ [Step 4] ClusterDBSCAN        ← ε=100mm, min_pts=5 (근거리 ≤4m는 3)
  └─ [Step 5] MergeClusters        ← 다리 분리 방지 (350mm, Union-Find)
       ↓
   ClusterTracker → tracks[]
       ↓
  tracked_only=true → Unity objects[] 전송

  마지막 tracked_only=true가 핵심입니다. 트랙이 연결되지 않은 클러스터(노이즈·일시적 허상)는 objects[]에 포함되지 않아 Unity에 도달하지 않습니다.

Step 1 — Temporal Filter (단일 프레임 스파이크 차단)

  0.1° 분해능 3,600개 슬롯 각각에 이전 프레임 거리값을 보존하고, 현재 값이 300mm 이상 급변하면 이전 값으로 대체합니다.

if (slot_prev_[idx] != 0) {
    int32_t diff = static_cast<int32_t>(pt.distance_mm)
                 - static_cast<int32_t>(slot_prev_[idx]);
    if (std::abs(diff) > kEmaOutlierClampMm)
        pt.distance_mm = slot_prev_[idx];
}
slot_prev_[idx] = pt.distance_mm;

Step 2 — 정적 배경 학습 (EMA 모델)

  벽·구조물 거리를 EMA로 학습한 뒤, 그보다 100mm 이상 가까운 점만 전경으로 통과시킵니다. 세 가지 안전장치로 오염을 방지합니다.

Pending Buffer 5프레임 누적 평균 후 슬롯 확정 — 1~4프레임 스파이크가 배경 슬롯을 오염시키지 못함
Outlier Clamp |new − old| > 300mm 이면 갱신 건너뜀
계수 분리 학습 9:10 (τ≈1s) / 운용 99:100 (τ≈10s)

Step 3 — DBSCAN 클러스터링

- 파라미터 값 의미

ε 100mm 이웃으로 인정하는 최대 거리
min_pts 5 (원거리) 코어 포인트 조건
min_pts 3 (≤4m) 근거리 소형 물체 허용

  그리드 기반 공간 인덱스(셀 크기 = ε)로 이웃 탐색을 O(n)으로 가속했습니다. 라즈베리파이 5 기준 프레임당 평균 처리 시간 <5ms.

Step 4 — 다리 클러스터 병합 (MergeClusters)

  두 다리가 DBSCAN에서 별도 클러스터로 분리되는 경우를 centroid 간 거리 350mm 이내 시 병합으로 해결합니다. 사람 몸과 옆 물건이 병합되지 않도록 폭 비율 가드(2.5 초과 시 병합 차단)를 추가했습니다.


5. 이번 주 회고

잘된 점

  • 데이터 규격 분리로 위치 갱신과 이벤트 처리 경로가 명확히 분리됐습니다. 이후 departure, dumping 외에 새로운 이벤트 타입을 추가해도 기존 코드를 건드리지 않아도 됩니다.
  • tracked_only + 5단계 파이프라인 조합으로 허상 클러스터가 Unity에 도달하는 경로를 완전 차단했습니다.
  • 임베디드가 mm 단위 센서 좌표를 그대로 전송하는 규약을 명확히 정해 씬 레이아웃 변경에 완전히 무관한 구조가 됐습니다.

어려웠던 점

  • 임베디드 JSON 필드(width, category, system_state 등)의 의미와 단위를 명확하게 정의해야 수신 측이 올바르게 파싱할 수 있었습니다. 필드 정의를 문서화하여 양측이 동일한 규격을 참조하도록 했습니다.
  • EMA 학습 계수와 Pending 프레임 수의 균형 조정이 까다로웠습니다. 너무 빠르게 학습하면 정지한 사람이 배경으로 흡수되고, 너무 느리면 고정 물체가 계속 전경으로 검출됩니다.

[2026 ABC 프로젝트 멘토링 1기] 프로젝트 3주차

2026 ABC 프로젝트 멘토링

객체 추적 및 투기 탐지


이번 주 목표

  • 프레임 간 클러스터 매칭으로 연속 추적 (Tracking)
  • 칼만 필터로 위치 예측 및 노이즈 평활
  • 트랙 상태머신 설계 (Moving → Stationary → Departed → Lost)
  • 6단계 쓰레기 투기 탐지 파이프라인 구현

1. 추적(Tracking) 개요

왜 추적이 필요한가?

  LiDAR는 매 프레임 독립적인 점구름을 주는데 "이 클러스터가 이전 프레임의 어떤 클러스터와 같은 물체인가?"를 판단해야
사람의 이동 경로를 파악하고, 투기 행위를 검출할 수 있기 때문입니다.

단계별 매칭 전략

[1단계] 탐욕적 최근접 매칭 (Greedy Nearest-Neighbor)
  ├ 매칭 성공 → 트랙 업데이트
  └ 매칭 실패 (거리 > 임계값)
      │
      ▼
[2단계] 칼만 필터 예측 위치 기반 매칭
  ├ 매칭 성공 → KF 예측이 맞았으므로 업데이트
  └ 매칭 실패 → 미매칭 클러스터 → 새 트랙 생성

1단계: 탐욕적 최근접 매칭

  모든 (트랙, 클러스터) 쌍의 거리를 계산하고, 가장 가까운 쌍부터 매칭합니다.

// 모든 쌍의 거리 계산
std::vector<MatchPair> pairs;
for (track : tracks) {
    for (cluster : clusters) {
        double d = distance(track.position, cluster.centroid);
        if (d < max_match_distance)  // 400mm
            pairs.push_back({track.id, cluster_idx, d});
    }
}
// 거리 오름차순 정렬 후 탐욕 매칭
std::sort(pairs.begin(), pairs.end());

2단계: 칼만 필터 폴백

  1단계에서 매칭되지 않은 트랙이 있다면, 칼만 필터의 예측 위치를 기준으로 남은 클러스터와 재매칭을 시도합니다. 이는 사람이 빠르게 움직여서 이전 위치와 현재 위치의 간격이 클 때 유효합니다.


2. 칼만 필터 (Kalman Filter)

상태 벡터

  2D 등속도(Constant Velocity) 모델을 사용합니다.

상태: x = [px, py, vx, vy]ᵀ   (위치 + 속도, 4차원)
관측: z = [px, py]ᵀ            (위치만 관측, 2차원)

시스템 모델

상태 전이:  F = | 1 0 dt 0 |    (등속도 모델)
                | 0 1 0 dt |
                | 0 0 1  0 |
                | 0 0 0  1 |

관측 행렬:  H = | 1 0 0 0 |    (위치만 관측)
                | 0 1 0 0 |

프로세스 노이즈: Q (가속 불확실성)
관측 노이즈:     R (LiDAR 측정 노이즈)

예측-업데이트 사이클

[예측 단계]
  x̂⁻ = F × x̂      (상태 예측)
  P⁻ = F × P × Fᵀ + Q  (공분산 예측)

[업데이트 단계]
  y = z - H × x̂⁻        (잔차: 관측 - 예측)
  S = H × P⁻ × Hᵀ + R   (잔차 공분산)
  K = P⁻ × Hᵀ × S⁻¹     (칼만 이득)
  x̂ = x̂⁻ + K × y        (상태 보정)
  P = (I - K × H) × P⁻   (공분산 보정)  ← 주의: 이 형태는 수치 불안정

Joseph Form (수치 안정성)

  표준 공분산 업데이트 P = (I - KH)P는 부동소수점 오차 누적으로 공분산 행렬이 음의 정부호(negative definite)가 될 수 있습니다. 이를 방지하기 위해 Joseph Form을 사용합니다.

P = (I - KH) × P⁻ × (I - KH)ᵀ + K × R × Kᵀ

이 형태는 항상 양의 반정부호(positive semi-definite)를 보장합니다.


3. 트랙 상태머신

           ┌──────────────────────┐
           │                      │
           ▼                      │
     ┌──────────┐  속도 < 임계값  ┌──────────────┐
 ──▶ │  Moving  │ ──────────────▶ │  Stationary  │
     └──────────┘                 └──────────────┘
           ▲                            │
           │ 속도 > 임계값              │ 매칭 실패 연속 N프레임
           │                            ▼
           │                     ┌──────────┐
           │                     │ Departed │ ─── 투기 체크
           │                     └──────────┘
           │                            │
           │                            │ 추가 M프레임 매칭 실패
           │                            ▼
           │                     ┌──────────┐
           └─────────────────────│   Lost   │ ─── 트랙 삭제
                                 └──────────┘

 

  • 상태 조건 의미
Moving 속도 > 30mm/frame 이동 중인 사람
Stationary 속도 < 30mm/frame 연속 멈춰 서 있는 사람
Departed 매칭 실패 시작 사람이 자리를 떠남
Lost Departed 후 일정 시간 경과 추적 포기 → 삭제

누적 이동 거리 (Cumulative Distance)

매 프레임마다 이전 위치와 현재 위치의 거리를 누적합니다. 이 값이 500mm 미만이면 "충분히 걸어온 사람"으로 보지 않으므로 투기 탐지에서 제외됩니다. (제자리에서 물건을 내려놓는 것은 투기가 아님)


4. 6단계 쓰레기 투기 탐지 파이프라인

전체 흐름

사람 트랙 + 미매칭 클러스터
    │
    ▼
[Stage 1] 궤적 매칭 ──── 사람이 지나간 경로 ±600mm 내에 새 클러스터?
    │
    ▼
[Stage 2] 너비 감소 ──── 사람 클러스터가 갑자기 80mm 이상 좁아졌나?
    │
    ▼
[Stage 3] 분열 감지 ──── 1개 클러스터가 2개로 분리됐나?
    │
    ▼
[Stage 4] 다중 신호 검증 ── 1개 이상 신호 + 궤적 매칭 필수
    │
    ▼
[Stage 5] 크기 일관성 ──── 의심 클러스터의 너비 σ ≤ 50mm?
    │
    ▼
[Stage 6] 확인 대기 ──── 30프레임(3초) 동안 클러스터 유지?
    │                     + 사람 600mm 이상 이탈?
    ▼
[Stage 6.5] 핫스팟 필터 ── 같은 위치에서 최근 5분 내 탐지 이력?
    │
    ▼
✅ 투기 확정 (DumpingEvent)

Stage 1: 궤적 매칭

  사람의 이동 경로를 position_history(deque)에 저장합니다. 새로 나타난 미매칭 클러스터가 이 궤적 경로의 600mm 이내에 있으면 "사람이 지나가면서 무언가를 놓고 갔을 가능성"으로 판단합니다

for (const auto& hist_pos : track.position_history) {
    double d = distance(new_cluster.centroid, hist_pos);
    if (d < 600.0) {
        trajectory_match = true;
        break;
    }
}

Stage 2: 너비 감소 감지

  사람이 물건을 들고 있으면 LiDAR에서 보이는 너비가 큽니다. 물건을 내려놓으면 너비가 갑자기 줄어듭니다.

프레임 N:   너비 450mm (사람 + 가방)
프레임 N+1: 너비 350mm (사람만)
→ 차이 = 100mm > 임계값(80mm) → 너비 감소 신호 발생

Stage 3: 분열 감지

  하나의 트랙이 추적하던 클러스터가 두 개로 분리되면, 사람과 물건이 분리된 것으로 가정합니다.

Stage 4: 다중 신호 검증

  궤적 매칭만으로는 오탐이 많습니다(사람이 지나가기만 해도 발동). 반드시 궤적 매칭 + 1개 이상의 추가 신호가 있어야 투기 의심으로 승격합니다.

궤적 매칭 alone       → ❌ 부족
궤적 매칭 + 너비 감소  → ✅ 의심 승격
궤적 매칭 + 분열 감지  → ✅ 의심 승격

Stage 5: 크기 일관성

  의심 클러스터의 너비를 N프레임 동안 관찰하여 표준편차가 50mm 이하인지 확인합니다. 사람의 다리가 분리된 경우, 프레임마다 너비가 크게 변동하므로 σ > 50mm이 되어 걸러집니다.

Stage 6: 확인 대기

  의심 클러스터가 30프레임(약 3초) 동안 제자리에 유지되고, 사람 트랙이 해당 위치에서 600mm 이상 멀어졌을 때 최종 확정합니다.

Stage 6.5: 핫스팟 필터

  같은 위치(500mm 반경)에서 최근 5분 내에 이미 투기가 감지되었다면 중복 탐지를 방지합니다.

struct HotspotEntry {
    double x_mm, y_mm;
    uint32_t frames_remaining;  // TTL: 3000프레임 ≈ 5분
};

 


5. 이벤트 출력

DumpingSuspectEvent (즉시 발생)

  Stage 4를 통과하면 즉시 카메라로 사진을 촬영합니다. 아직 확정은 아니지만, 3초 후에는 사람이 이미 사라질 수 있으므로 증거 확보 차원에서 선제 촬영합니다.

DumpingEvent (3초 후 확정)

  Stage 6까지 통과하면 확정 이벤트가 발생합니다. 이때 다시 한 번 카메라로 촬영하고, Base64 인코딩하여 HTTP POST로 백엔드 서버에 전송합니다.


6. 실전 배포 후 발견된 추적 한계점

  초기 구현 이후 실제 환경에 배포하면서 추적 시스템의 세 가지 구조적 한계가 드러났었습니다. 다음은 각 문제와 최종 수정입니다.

문제 1: 정지 시 트랙 소멸 

증상: 사람이 멈추면 1.5초 후 트랙이 사라지고, 다시 이동하면 새 ID가 부여

원인: 동적 배경 필터(BackgroundFilter)가 정지한 사람 클러스터를 배경으로 서서히 흡수, 흡수되면 매칭 클러스터가 없어지고 lost_count가 증가하여 lost_age_limit=15(1.5초) 이내에 삭제

수정: 모든 활성 트랙 위치를 배경 필터에 피드백하여 추적 중인 객체가 배경으로 등록되지 않도록 차단, 사람 트랙(was_moving=true && cumulative_dist≥500mm)은 TTL을 3배(15→45프레임, 4.5초)로 확장


문제 2: 정적 배경 EMA 학습 흡수 

증상: 가만히 서 있을 때 LiDAR 반사 점이 사라지고, 다시 움직이면 새 ID가 발급 (P0-3 수정 후에도 재발)

원인: v8에서는 동적 배경(BackgroundFilter)만 보호, 그러나 파이프라인 전반부의 정적 배경 필터(ScanProcessor::FilterStaticBackground)의 EMA 학습이 정지 사람의 거리를 벽 깊이로 흡수하는 근본 원인이 남아 있었음

파이프라인 순서:
  FilterNoise → FilterStaticBackground ← [v10] EMA 학습 차단
              → DBSCAN → MergeClusters
              → BackgroundFilter       ← [v8] 추적 보호
              → ClusterTracker         ← [v10] ID 복구 계승

수정

  • LearnStaticBackground에 blocked_slots_3600 파라미터 추가
  • 추적 중인 트랙의 centroid → 극좌표 각도 → ±3° blocked → EMA 학습 건너뜀
  • 트랙 복구 시 new_track.id = recovered.id로 원본 ID 계승
  • recovery_max_lost_frames: 3 → 10 (1초 내 복구 허용)

문제 3: DBSCAN 미달로 정지 트랙 삭제

증상: 멀리 있는 사람이 가만히 서 있으면 점이 사라짐, 거리와 상관없이 정지 1.5초~4.5초 후 트랙 삭제

원인: DBSCAN min_points=5 조건이 원거리에서 점 밀도가 낮아지면 충족되지 않아 클러스터 자체가 생성되지 않음, 클러스터가 없으면 매칭 실패 → lost_count 증가 → TTL 초과 삭제. Stationary 상태도 이 경로를 막지 않음

수정:

// 정지 그레이스: DBSCAN이 실패해도 raw 점이 예측 위치 ±250mm 내에 있으면 유지
if (HasNearbyPoints(tr.kf.GetX(), tr.kf.GetY(),
                    params_.stationary_grace_radius_mm, *cart_points)) {
    scratch_track_matched_[t] = true;
    continue;
}
lost_count++;

// Stationary 전용 TTL 100프레임 (10초)
} else if (tr.state == TrackState::Stationary && tr.was_moving) {
    limit = params_.stationary_lost_age_limit;
}

7. 이번 주 회고

잘된 점

  • 2단계 매칭으로 빠른 이동에도 트랙이 끊기지 않는 안정적 추적 달성했습니다.
  • 6단계 파이프라인으로 단일 신호 의존성을 제거하여 오탐률 대폭 감소했습니다.
  • 핫스팟 시스템으로 동일 위치 반복 탐지 문제 해결했습니다.

어려웠던 점

  • 투기 탐지의 Stage 간 파라미터 의존성이 복잡하여 튜닝에 오래 걸렸습니다.
  • 다리 분리 vs 실제 투기를 구분하는 것이 가장 큰 난관이였습니다. (너비 변동 체크로 해결했지만 완벽하지 않음)
  • 칼만 필터의 프로세스 노이즈(Q) 설정이 지나치게 크면 추적이 발산하고, 너무 작으면 빠른 움직임을 따라가지 못하는 트레이드오프 문제가 있었습니다.

 

[2026 ABC 프로젝트 멘토링 1기] 프로젝트 2주차

2026 ABC 프로젝트 멘토링

LiDAR 하드웨어 인터페이스


이번 주 목표

  • RPLiDAR S2 센서와 시리얼 통신 연결
  • HQ 스캔 모드로 점구름 데이터 수신
  • PIR 모션 센서 GPIO 인터페이스 구현
  • 빌드 시스템(CMake) 구성 및 크로스플랫폼 대응

1. RPLiDAR S2 센서 개요

항목 사양

방식 ToF (Time of Flight)
측정 범위 0.05m ~ 30m
스캔 주파수 10Hz (초당 10회전)
각도 분해능 ~0.1° (8192 HQ 노드/회전)
인터페이스 USB → UART 변환, 1Mbps
출력 포맷 HQ 노드 (angle_z_q14, dist_mm_q2, quality)

선택 이유

  기존의 삼각측량 방식(A1/A2 시리즈)보다 원거리 정확도가 높은 ToF 방식을 채택했습니다. 30m의 넓은 탐지 범위를 제공하면서도 합리적인 가격대를 유지하며, 제공되는 C++ SDK가 라즈베리파이 환경을 공식 지원하여 통합이 용이했기 때문입니다.


2. 시리얼 통신 연결

디바이스 경로

Linux (Raspberry Pi): /dev/ttyUSB0
macOS (개발용):       /dev/cu.usbserial-*

연결 흐름

1. createLidarDriver() → ILidarDriver 인스턴스 생성
2. createSerialPortChannel("/dev/ttyUSB0", 1000000) → 1Mbps 채널
3. driver->connect(channel) → 물리 연결
4. driver->getDeviceInfo() → 펌웨어 버전, 시리얼 번호 확인
5. driver->getHealth() → 상태 체크 (Good/Warning/Error)
   └ Error 시: driver->reset() → 재시도
6. driver->startScanExpress(false, scanMode.id) → HQ 스캔 시작

핵심 코드 (rplidar_s2.cpp)

bool RplidarS2::Start() {
    driver_ = *sl::createLidarDriver();
    auto channel = *sl::createSerialPortChannel(
        cfg_.serial_port, cfg_.baudrate);  // 1Mbps
    driver_->connect(channel);

    // 헬스 체크 — Error 상태면 리셋 후 재시도
    sl_lidar_response_device_health_t health;
    driver_->getHealth(health);
    if (health.status == SL_LIDAR_STATUS_ERROR) {
        driver_->reset();
        usleep(2000000);  // 2초 대기
    }

    // HQ 스캔 모드로 시작
    std::vector<sl::LidarScanMode> modes;
    driver_->getAllSupportedScanModes(modes);
    driver_->startScanExpress(false, modes[0].id);
}

HQ 노드 데이터 파싱

  SDK로부터 수신되는 sl_lidar_response_measurement_node_hq_t 구조체 데이터는 Fixed-point(고정소수점) 방식으로 인코딩되어 있어 변환 공식이 필요합니다.

angle_deg   = node.angle_z_q14 × 90.0 / 16384.0
distance_mm = node.dist_mm_q2  / 4.0
intensity   = node.quality      (0~255)
bool RplidarS2::GetScanFrame(std::vector<ScanPoint>& out) {
    sl_lidar_response_measurement_node_hq_t nodes[8192];
    size_t count = 8192;
    driver_->grabScanDataHq(nodes, count);
    driver_->ascendScanData(nodes, count);  // 각도 순 정렬

    out.resize(count);
    for (size_t i = 0; i < count; ++i) {
        out[i].angle_deg   = nodes[i].angle_z_q14 * 90.0f / 16384.0f;
        out[i].distance_mm = nodes[i].dist_mm_q2  / 4.0f;
        out[i].intensity   = nodes[i].quality;
    }
}

트러블슈팅: USB 권한

  라즈베리파이 재부팅 시 /dev/ttyUSB0에 대한 접근 권한이 상실되는 문제를 해결하기 위해 다음과 같이 udev 규칙을 설정하여 영구적인 권한을 부여했습니다.

# 일회성
sudo chmod 666 /dev/ttyUSB0

# 영구 설정 (udev 규칙)
echo 'SUBSYSTEM=="tty", ATTRS{idVendor}=="10c4", MODE="0666"' \
  | sudo tee /etc/udev/rules.d/99-rplidar.rules
sudo udevadm control --reload-rules

 


3. PIR 모션 센서 인터페이스

  보조 센서인 Panasonic PaPIRs 센서는 별도의 라이브러리 의존성을 줄이기 위해 Linux의 sysfs GPIO 방식을 사용하여 구현했습니다.

센서 사양

모델 Panasonic PaPIRs EKMC160111
수량 2개 (GPIO 17, 27)
출력 디지털 HIGH/LOW
인터페이스 Linux sysfs GPIO

GPIO 제어 (Linux sysfs)

/sys/class/gpio/export         ← 핀 번호 쓰기
/sys/class/gpio/gpio17/direction ← "in"
/sys/class/gpio/gpio17/value   ← '0' 또는 '1' 읽기
bool PirSensor::Init() {
    // 1. GPIO export
    std::ofstream("/sys/class/gpio/export") << pin_;
    // 2. 방향 설정
    std::ofstream(base + "/direction") << "in";
    // 3. value 파일 열어두기 (반복 open 방지)
    value_fd_ = open((base + "/value").c_str(), O_RDONLY);
}

bool PirSensor::Read() {
    lseek(value_fd_, 0, SEEK_SET);  // 파일 처음으로
    char c;
    read(value_fd_, &c, 1);
    return (c == '1');
}

디바운싱 전략

  PIR 센서의 특성상 노이즈로 인한 오탐이 발생할 수 있습니다. 이를 방지하기 위해 연속 3프레임 이상 동일한 값이 확인될 때만 상태를 전환하고, 감지 후에는 일정 시간 상태를 유지하는 소프트웨어 디바운스를 적용했습니다.

debounce_frames = 3   → 3연속 같은 값이어야 상태 전환
holdoff_frames  = 50  → HIGH 감지 후 최소 50프레임 유지

비-Linux 폴백

  macOS 등 sysfs가 존재하지 않는 환경에서는 개발 흐름이 끊기지 않도록 항상 true를 반환하도록 처리했습니다.


4. 빌드 시스템 (CMake)

  프로젝트의 확장성과 재현성을 위해 CMake 기반의 빌드 시스템을 구축했습니다. FetchContent를 활용하여 nlohmann/json과 같은 외부 라이브러리를 자동으로 관리하며, 용도에 따라 실행 바이너리와 테스트용 타겟을 분리했습니다.

의존성 구조

CMakeLists.txt
├── RPLiDAR SDK (thirdparty/rplidar_sdk/)
│   ├── Linux:  arch/linux/*.cpp
│   ├── macOS:  arch/macOS/*.cpp
│   └── Win32:  arch/win32/*.cpp
├── nlohmann/json (FetchContent v3.11.3)
├── libcurl (find_package)
└── OpenCV (optional, 카메라 모듈)

빌드 타겟

rplidar_app 프로덕션 실행 바이너리
rplidar_sim 하드웨어 없이 시뮬레이션
check_lidar LiDAR USB 연결 테스트
check_camera 카메라 초기화 테스트
mkdir build && cd build
cmake .. -DCMAKE_BUILD_TYPE=Release
make -j$(nproc)

 


5. 이번 주 회고

잘된 점

  • HQ 스캔 모드를 통해 프레임당 8,192개의 정밀한 노드 데이터를 안정적으로 확보했습니다.
  • 별도의 무거운 라이브러리 없이 sysfs만으로 PIR 센서 제어 로직을 통합했습니다.
  • CMake를 통한 의존성 자동화로 팀원 간 동일한 빌드 환경을 구축했습니다.

어려웠던 점

  • 고정소수점 데이터를 파싱하는 과정에서 비트 연산과 스케일 상수에 대한 이해가 필요함을 체감했습니다.
  • 플랫폼별 SDK 소스 코드가 상이하여 CMake 조건부 컴파일 구성에 많은 시간을 할애했습니다.
  • 향후 추상화 계층을 더 견고히 설계할 계획입니다.

 

IoT 센서로 공공시설 혼잡도 예측하기: 2025년 달서 전국대학생 AI활용 아이디어 콘테스트 회고

2025년 달서 전국대학생 AI활용 아이디어 콘테스트

 

 

도서관, 체육센터 같은 공공시설은 특정 시간대에 이용자가 몰리면 대기가 길어지고 민원이 발생합니다.

그런데 현재 대부분의 공공시설에는 혼잡 상황을 사전에 예측하거나 실시간으로 알려주는 시스템이 없습니다.

2025년 달서 전국 대학생 AI활용 아이디어 콘테스트에 AICrowdFlow라는 시스템을 제안하며 이 문제를 다뤘습니다.

이 글에서는 시스템의 기술 구조를 정리하고, 첫 공모전에서 겪은 실전 경험을 공유합니다.


프라이버시 보존형 센서 설계

혼잡도를 측정하는 가장 직관적인 방법은 CCTV와 얼굴 인식입니다. 그러나 공공시설에 카메라 기반 감시 체계를 도입하면 개인정보 이슈가 불가피합니다. AICrowdFlow는 영상 데이터 없이 혼잡도를 추정하는 방식을 채택했습니다.

 

사용한 센서 구성은 다음과 같습니다.

  • 문 개폐 센서: 자석식 리드 스위치(reed switch) 또는 적외선 센서로 출입 횟수를 카운트합니다. 리드 스위치는 자석이 접근하면 내부 접점이 붙어 회로가 닫히는 단순한 소자로, GPIO 한 핀으로 읽을 수 있습니다. 누가 들어왔는지는 모르지만 몇 명이 드나들었는지는 파악할 수 있어 프라이버시를 보존하면서 인원 흐름을 추정할 수 있습니다.
  • Wi-Fi 프로브 카운터: Wi-Fi 접속 기기 수를 집계합니다. 스마트폰 대부분이 Wi-Fi를 켜두기 때문에 접속 기기 수와 실제 이용자 수을 이용한 통계적 보정 모델을 통해 인원수를 추정하는 지표로 활용 합니다.
  • 환경 센서: 온도, 습도, 소음 레벨을 측정합니다. 사람이 많아지면 실내 온도와 소음이 올라가므로 간접 지표로 활용합니다.

이 센서 조합의 핵심은 개인 식별 정보를 일절 수집하지 않는다는 점입니다. CCTV, GPS, 얼굴 인식 기술을 배제하면서도 복수의 간접 지표를 교차 활용해 혼잡도를 추정합니다.


시스템 아키텍처: 센서에서 예측까지

데이터 흐름은 세 단계로 구성됩니다.

1단계 — 센서 데이터 수집

각 공공시설에 설치된 IoT 센서가 문 개폐 횟수, Wi-Fi 기기 수, 온도·소음 데이터를 주기적으로 수집합니다. 센서 노드는 MCU(Microcontroller Unit)에 연결되어 있고, 수집된 데이터를 게이트웨이(gateway)를 통해 서버로 전송합니다.

시계열 데이터(time-series data) 란 시간 순서에 따라 기록된 연속적인 데이터 수열입니다. 센서가 10분 간격으로 값을 보내면, 하루 치 데이터는 144개의 시간 단위 샘플로 구성됩니다. 혼잡도 예측에서 시계열 분석이 중요한 이유는 요일·시간대별 패턴이 반복되기 때문입니다. 

2단계 — AI 혼잡도 예측

수집된 시계열 데이터를 기반으로 AI 모델이 향후 혼잡도를 예측합니다. 예를 들어 현재 오후 1시라면 오후 3시의 예상 혼잡도를 미리 산출합니다. 모델 학습에는 과거 센서 데이터, 요일, 공휴일 여부, 날씨 등을 피처(feature)로 사용합니다.

3단계 — 시민 안내

예측 결과를 모바일 앱이나 웹으로 제공합니다. 혼잡 예상 시설에 대해서는 인근 대체 시설을 자동으로 추천합니다. "오후 3시 ○○도서관은 매우 혼잡 예상 — 인근 △△도서관 이용을 권장합니다" 같은 형태입니다.


실무 적용 포인트

이 시스템을 실제로 구축한다면 고려해야 할 사항들입니다.

  • 센서 비용: 리드 스위치, 온습도 센서(DHT22 등), 소음 센서 등 저가형 센서를 활용한 저비용 구축이 가능합니다. Wi-Fi 프로브 카운팅은 기존 AP(Access Point) 로그를 활용하면 추가 하드웨어 없이 가능합니다.
  • 통신 방식: 시설 내부에 Wi-Fi가 이미 있다면 센서 노드도 Wi-Fi로 데이터를 전송하면 됩니다. 전원 공급이 어려운 위치라면 LoRa나 BLE(Bluetooth Low Energy) 기반 저전력 통신을 고려합니다.
  • MAC 랜덤화 보정: iOS 14, Android 10 이후 기기는 기본적으로 MAC 주소를 랜덤화합니다. Wi-Fi 기기 수 기반 추정 시 이 점을 반영한 보정 로직이 필요합니다.
  • 확장 대상: 도서관, 체육센터, 복지관 외에 무더위 쉼터, 재난 대피소, 박물관, 축제장 등 사람이 몰리는 공공 공간이면 어디든 적용 가능합니다.

공모전 회고: 첫 대회에서 배운 것

112팀이 참가한 이 대회에서 1차 서류심사(적합성·창의성·효과성 각 20점)를 통과해 12명의 최종 심사 대상자에 선정되었습니다. 2차 발표심사(40점)는 2025년 8월 8일 달서구청에서 진행되었습니다. 최종 성적은 대상, 금상 다음인 은상으로 최종 공동 3위를 하였습니다.

2025년 달서 전국대학생 AI활용 아이디어 콘테스트 은상 수상

 

첫 공모전을 통해 얻었던 교훈입니다.

  • 질의응답 준비의 어려움: 혼자 준비하면 자기 관점의 예상 질문만 나옵니다. 예상치 못한 질문이 나왔을 때 즉석 대응이 어려웠습니다.기술적 타당성 검증을 위해 다양한 도메인(운영 주체, 실사용자, 시스템 관리자 등)의 관점에서 질의응답 시나리오를 구축해야 함을 배웠습니다. 또한 1인 수행 프로젝트의 특성상 발생할 수 있는 '확증 편향(Confirmation Bias)'을 경계하며, 객관적인 피드백 경로를 확보하는 것이 중요하다는 점을 배웠습니다. 
  • 발표 시간 관리: 5분이라는 시간 제한 안에 문제 제기 → 시스템 소개 → 기대효과 → 활용성을 모두 담아야 합니다. 기술 상세를 깊이 들어가면 시간이 부족하고, 너무 개괄적이면 차별성이 드러나지 않습니다.
  • 준비량 대비 전달력: 첫 대회라는 긴장감이 영향을 미쳤고, 준비한 것보다 실제 발표에서 전달력이 떨어졌습니다. 반복 리허설의 중요성을 체감하였고, 이를 통해 기술 피칭에서는 하드웨어 스펙 나열보다 사용자 경험(UX) 중심의 스토리텔링이 더 강력한 전달력을 가짐을 깨달았습니다. 

마무리

AICrowdFlow는 프라이버시를 보존하면서 IoT 센서 데이터와 시계열 AI 분석으로 공공시설 혼잡도를 예측하는 시스템입니다. 기술적으로는 센서 선정, 통신 방식, MAC 랜덤화 보정이 실제 구현의 핵심 과제입니다. 첫 공모전 경험을 통해 혼자서 전 과정을 수행하는 것의 한계와 질의응답 준비의 중요성을 배웠습니다.

[2026 ABC 프로젝트 멘토링 1기] 프로젝트 1주차

2026 ABC 프로젝트 멘토링

 

2026 ABC 프로젝트 멘토링 1기 과정의 첫 번째 주차가 시작되었습니다.

이번 주에는 본격적인 개발에 앞서 Claude를 활용한 협업 도구 활용법 학습과

팀 빌딩 및 역할 분담이 진행되었습니다.

1주차의 주요 활동 내용을 정리합니다.


0주차 사전직무교육 - Claude와 Speckit을 활용한 고도화된 개발 워크플로우

ABC 프로젝트 멘토링 사전직무 교육

  이번 사전 직무 교육의 핵심은 AI를 단순한 질답 도구가 아닌, 설계와 구현을 함께하는 협업 파트너로 정의하는 것이었습니다. 특히 Claude와 Speckit 프롬프트 엔지니어링을 활용한 체계적인 프로세스는 기존의 주먹구구식 개발 방식에서 벗어나 문서화와 검증이 동반되는 실무형 워크플로우를 경험하게 해주었습니다.

 

Claude code 단계별 핵심 프로세스 요약

  1. 의도 명세 및 계획 (/speckit.specify, /speckit.plan)
    • intent-specify.md와 intent-plan.md 파일을 참조하여 프로젝트의 요구사항을 명확히 정의합니다. 이는 개발 과정에서 발생할 수 있는 소통 오류를 사전에 방지하는 역할을 합니다.
  2. AI와의 상호 검증 (/speckit.clarify)
    • 가장 인상 깊었던 단계로, AI가 수립한 계획이 사용자의 실제 의도와 일치하는지 AI가 역으로 질문을 던지게 합니다. 이를 통해 계획의 논리적 허점을 보완하고 설계의 정밀도를 높일 수 있었습니다.
  3. 시스템 시각화 (Sequence Diagram)
    • spec.md, plan.md, tasks.md를 기반으로 시스템 흐름을 시각화합니다. 색상을 배제하고 구조적 시인성에 집중하여, 전체 아키텍처를 한눈에 파악할 수 있는 다이어그램을 생성했습니다.
  4. 구현 및 통합 (/speckit.implement)
    • 작성된 명세와 작업을 참조하여 실제 코드를 구현합니다. 결과물은 Serena를 통해 요약하며, Git에 머지(Merge)하여 형상 관리를 수행합니다. 이후 다시 자료조사를 거쳐 루프를 도는 반복적 개선 과정을 학습했습니다.

이러한 방식은 개발 속도뿐만 아니라 코드의 일관성과 유지보수성 측면에서 매우 효율적이라는 점을 체감할 수 있었습니다.


팀 빌딩 및 전략적 역할 분담

  교육 이후에는 프로젝트를 함께 완수할 팀원들과 첫 회의를 진행했습니다. 저희 팀은 각자의 전문 분야를 살려 시스템의 안정성과 보안성을 극대화하는 방향으로 역할을 나누었습니다.

팀 구성 및 R&R(Roles and Responsibilities)

직책 이름 주요 역할 및 담당 업무
팀장 김승연 센서 인터페이스 개발 및 데이터 전처리
팀원1 김주형 소프트웨어 커널 요새화 및 RBAC 접근 관리 시스템 설계
팀원2 박유진 유니티(Unity)를 활용한 실시간 데이터 시각화 구현
팀원3 양희찬 데이터베이스 요새화 및 안정적인 백엔드 서버 개발

 

  이번 프로젝트에서 저는 팀장을 맡아 팀의 전체적인 방향성을 조율하게 되었습니다. 아울러 기술적으로는 시스템의 최전방이라 할 수 있는 센서 인터페이스 개발과 데이터 전처리를 담당합니다. 현장에서 수집되는 로우 데이터(Raw Data)를 얼마나 정교하게 처리하느냐에 따라 김주형 팀원의 보안 시스템과 박유진 팀원의 시각화 결과물의 퀄리티가 결정되는 만큼, 데이터의 무결성과 처리 효율성에 집중하여 개발에 임할 예정입니다.


1주차를 마치며

  첫 주차는 단순한 기술 습득을 넘어, 팀원들과의 호흡을 맞추고 실무적인 개발 문화를 익히는 귀중한 시간이었습니다. 특히 '요새화'라는 키워드 아래 각 파트가 유기적으로 연결되는 구조를 설계하며 프로젝트에 대한 확신을 가질 수 있었습니다. 다음 주부터는 본격적으로 센서 데이터를 정의하고, 이를 효율적으로 처리하기 위한 인터페이스 설계 단계에 돌입합니다. 팀장으로서 책임감을 가지고 성공적인 프로젝트의 기반을 닦아 나가겠습니다.

master UTF-8 HTML HIBACHI Ln 1, Col 1