고객으로부터 PX4 ULog 파일(`.ulg`)과 차트 요구사항을 받아, 시계열 어노테이터용 **dm_schema JSON**을 만드는 전체 과정을 다룹니다.
:::note[용어]
이 문서에서 사용하는 트랙, 채널 등의 용어 정의는 [시계열 어노테이터 스키마 — 용어 정의](/annotator-schema/시계열#용어-정의)를 참고하세요.
:::
## 전체 흐름
```mermaid
flowchart TD
subgraph 고객 제공물
A["① ULG 파일들 (.ulg)"]
B["② 차트 그룹 정의서<br/>(어떤 센서를 묶을지, 그룹 순서, 채널 이름)"]
end
subgraph 내부 작업
C["③ 트랙 설정 파일 작성<br/>(track-config.yaml)<br/>⏱ 고객별 1회"]
D["④ 변환 스크립트 실행<br/>(ulg2dm.py)<br/>⏱ 파일 수만큼 반복"]
E["⑤ 스키마 검증<br/>(validate_dm_schema.py)<br/>⏱ 변환 후 매번"]
end
subgraph 결과물
F["⑥ dm_schema JSON<br/>(어노테이터에 로드 가능)"]
end
B --> C
A --> D
C --> D
D --> E
E -->|✓ 통과| F
E -->|✗ 오류| D
```
| 구분 | 빈도 | 설명 |
|---|---|---|
| 트랙 설정 파일 작성 | 고객별 **1회** | 고객 요구사항을 YAML로 변환 |
| 변환 스크립트 실행 | ULG 파일 수만큼 **반복** | 공통 스크립트, 설정만 교체 |
| 스키마 검증 | 변환 후 **매번** | 결과 JSON이 올바른지 확인 |
---
## 사전 준비
### Python 패키지 설치
**Python 3.8 이상**이 필요합니다.
| 패키지 | 용도 |
|---|---|
| `pyulog` | PX4 ULog 바이너리 파일을 파싱하여 채널 데이터를 추출 |
| `pyyaml` | 트랙 설정 파일(YAML)을 읽기 위해 사용 |
| `numpy` | 서로 다른 샘플레이트의 센서 데이터를 공통 타임그리드로 리샘플링(선형 보간) |
```bash
python -m venv .venv && source .venv/bin/activate
pip install pyulog pyyaml numpy
```
### 샘플 파일 다운로드
실습에 필요한 파일을 다운로드합니다:
| 파일 | 설명 | 다운로드 |
|---|---|---|
| `log_55_2026-2-11-15-59-02.ulg` | PX4 비행 로그 샘플 (12MB) | [다운로드](/samples/log_55_2026-2-11-15-59-02.ulg) |
| `ulg2dm.py` | ULG → dm_schema 변환 스크립트 | [다운로드](/samples/ulg2dm.py) |
| `track-config.yaml` | 트랙 설정 파일 예시 | [다운로드](/samples/track-config.yaml) |
| `validate_dm_schema.py` | dm_schema 검증 스크립트 | [다운로드](/samples/validate_dm_schema.py) |
---
## 1단계: 고객 요구사항 분석
### 고객이 제공하는 것
1. **Raw ULG 파일** — PX4 오토파일럿이 기록한 바이너리 센서 로그
2. **차트 그룹 정의** — 어떤 센서값들을 함께 보여줄지 (예: 엑셀, 문서, 구두)
고객 요구사항 예시:
> 1. 자세 (roll, pitch, yaw 각도)
> 2. GPS 위치 (위도, 경도, 고도)
> 3. 로컬 위치 (NED 좌표계 x, y, z)
> 4. 배터리 (전압, 전류)
> 5. 모터 출력 (motor 0~3)
### ULG 파일의 채널 목록 확인
고객의 요구사항을 YAML 설정 파일로 변환하려면, ULG 파일에 **어떤 채널이 존재하는지** 먼저 확인해야 합니다:
```bash
python ulg2dm.py --input log_55_2026-2-11-15-59-02.ulg --list-topics
```
출력 예시:
```
파일: log_55_2026-2-11-15-59-02.ulg
시간: 286.6초
토픽 수: 61
actuator_motors[0] (2866 samples, ~10.0Hz)
- timestamp_sample
- control[0]
- control[1]
- control[2]
- control[3]
...
battery_status[0] (1434 samples, ~5.0Hz)
- voltage_v
- voltage_filtered_v
- current_a
...
vehicle_attitude_setpoint[0] (5731 samples, ~20.0Hz)
- roll_body
- pitch_body
- yaw_body
...
vehicle_gps_position[0] (1433 samples, ~5.0Hz)
- lat
- lon
- alt
...
```
:::tip[채널 이름은 PX4 메시지 규격]
채널 이름과 필드 이름은 [PX4 uORB 메시지 정의](https://docs.px4.io/main/en/msg_docs/)에서 확인할 수 있습니다. 고객이 "roll 각도"라고 하면 `vehicle_attitude_setpoint.roll_body` 또는 `vehicle_attitude.q[0~3]` 중 어떤 것인지 확인이 필요합니다.
:::
---
## 2단계: 트랙 설정 파일 작성
고객의 차트 그룹 정의를 `track-config.yaml` 형식으로 변환합니다. **고객별 1회만 작성**하면 됩니다.
### YAML 형식
:::note[샘플레이트(sample rate)란?]
1초에 몇 번 데이터를 기록하는지를 나타내는 값입니다. 단위는 **Hz**(헤르츠)입니다.
예를 들어 `10Hz`는 1초에 10번(0.1초 간격), `1Hz`는 1초에 1번 데이터를 기록합니다.
ULG 파일에서는 채널마다 샘플레이트가 다릅니다 (GPS 5Hz, 자세 20Hz 등). 변환 시 모든 채널을 하나의 공통 샘플레이트로 맞추는 과정이 필요합니다.
:::
```yaml
# 공통 설정
sample_rate: 10 # 모든 채널을 이 Hz로 리샘플링
time_axis:
origin: absolute # absolute | relative | boot (아래 설명 참고)
format: "HH:mm:ss" # Day.js 포맷 문자열
timezone: UTC # 고객이 원하는 타임존 (ULG에는 타임존 정보 없음)
# 차트 그룹 정의 (순서대로 표시됨)
tracks:
- id: attitude # 트랙 고유 ID (kebab-case)
name: "자세 (Attitude)" # 화면 표시 이름
chart_type: line # line | scatter | psd | fft
channels:
- topic: vehicle_attitude_setpoint # ULG 채널 이름
field: roll_body # 채널 내 필드 이름
name: "Roll" # 화면 표시 이름
unit: "rad" # 변환 후 단위
color: "#42a5f5" # 채널 색상
scale: 1.0 # 값 변환 계수 (선택, 기본값 1.0)
```
:::note[`origin` 옵션]
| 값 | 의미 |
|---|---|
| `absolute` | Unix Epoch 기준 밀리초 **(어노테이터 기본 작동 기준)** |
| `relative` | 시작 시간으로부터의 경과 밀리초 |
| `boot` | 시스템 부팅 시간 기준 밀리초 |
어노테이터는 `absolute`만 지원합니다. 변환 스크립트는 GPS UTC 시각을 이용해 항상 `absolute`로 변환합니다.
:::
:::caution[`scale` — 단위 변환이 필요한 경우]
PX4의 일부 채널은 raw 값의 단위가 직관적이지 않습니다. `scale` 필드로 변환할 수 있습니다:
- GPS lat/lon: raw 값은 `degE7` (1e-7도) → `scale: 1e-7`로 deg 변환
- GPS alt: raw 값은 `mm` → `scale: 0.001`로 m 변환
- `scale`을 지정하지 않으면 기본값 `1.0` (변환 없음)
:::
### 매핑 규칙
| 고객 요구사항 | YAML 설정 |
|---|---|
| "자세 차트에 roll, pitch, yaw" | 1개 track, 3개 channels |
| "GPS 위치 차트" | 1개 track, lat/lon/alt channels |
| 차트 순서 | tracks 배열 순서 = 화면 표시 순서 |
| 차트 이름 | `track.name` |
| 센서값 이름 | `channel.name` |
### 주의사항
:::caution[채널별 샘플레이트가 다릅니다]
ULG 파일에서 각 채널은 독립적인 샘플레이트로 기록됩니다 (예: attitude 20Hz, GPS 5Hz, battery 5Hz).
변환 스크립트는 `sample_rate`에 지정된 값으로 **모든 채널을 선형 보간(리샘플링)** 합니다.
- 원본보다 높은 sample_rate: 보간된 값이 추가됨 (해상도 향상 아님)
- 원본보다 낮은 sample_rate: 데이터가 간략해짐 (파일 크기 감소)
- **권장**: 가장 낮은 채널의 샘플레이트 이상으로 설정 (보통 5~10Hz)
:::
---
## 3단계: 변환 실행
### 단일 파일 변환
```bash
python ulg2dm.py \
--input log_55_2026-2-11-15-59-02.ulg \
--config track-config.yaml \
--output output.json
```
출력:
```
변환 완료: output.json
시간: 286.6초, 샘플: 2867, 트랙: 5, 채널: 15
```
### 일괄 변환
```bash
python ulg2dm.py \
--input-dir ./ulg_files/ \
--config track-config.yaml \
--output-dir ./json_output/
```
### 존재하지 않는 채널/필드 처리
설정 파일에 적은 채널이나 필드가 ULG 파일에 없으면 경고를 출력하고 해당 채널을 건너뜁니다:
```
[경고] 건너뛴 채널 (1개):
- sensor_baro.pressure (채널 없음)
```
---
## 4단계: 결과 검증
변환된 JSON이 시계열 어노테이터 스키마에 맞는지 검증합니다.
### 검증 실행
```bash
python validate_dm_schema.py output.json
```
성공 시:
```
============================================================
검증: output.json
============================================================
✓ 검증 통과
시간: 286.6초
샘플: 2867개 @ 10Hz
트랙: 5개
채널: 15개
```
### 오류 발생 시
검증 스크립트는 **어디가 왜 잘못되었는지**와 **해결 방법**을 함께 안내합니다:
```
============================================================
검증: broken.json
============================================================
✗ 3개 오류 발견:
✗ [channels.attitude__roll] 길이(100)가 timestamps 길이(2867)와 불일치합니다.
→ 모든 채널의 값 배열은 timestamps와 동일한 길이여야 합니다.
✗ [channelMeta.attitude__roll.color] 필수 필드 'color'가 없습니다.
✗ [meta.duration] duration(100.0s)이 startTime/endTime 차이(286.6s)와 불일치합니다.
→ duration = (endTime - startTime) / 1000 이어야 합니다.
```
### 일괄 검증
```bash
python validate_dm_schema.py --dir ./json_output/
```
---
## 변환 결과 JSON 구조
변환 결과는 [시계열 어노테이터 스키마](/annotator-schema/시계열)에 정의된 형식을 따릅니다.
```jsonc
{
"meta": {
"startTime": 1613693139911, // epoch_ms
"endTime": 1613693426511,
"duration": 286.6, // 초
"nSamples": 2867,
"sampleRate": 10, // Hz
"timeAxis": {
"origin": "absolute",
"format": "HH:mm:ss",
"timezone": "UTC"
}
},
"timestamps": [1613693139911, 1613693140011, ...], // epoch_ms 배열
"tracks": [
{
"id": "attitude",
"name": "자세 (Attitude)",
"chartType": "line",
"channels": [
"vehicle_attitude_setpoint__roll_body",
"vehicle_attitude_setpoint__pitch_body",
"vehicle_attitude_setpoint__yaw_body"
]
}
// ...
],
"channels": {
"vehicle_attitude_setpoint__roll_body": [0.01, 0.012, ...],
// ...
},
"channelMeta": {
"vehicle_attitude_setpoint__roll_body": {
"name": "Roll",
"unit": "rad",
"color": "#42a5f5",
"chartType": "line"
}
// ...
}
}
```
:::note[채널 ID 네이밍 규칙]
채널 ID는 `{채널명}__{필드명}` 형식으로 자동 생성됩니다. 배열 인덱스의 대괄호는 언더스코어로 치환됩니다.
- `vehicle_attitude_setpoint` + `roll_body` → `vehicle_attitude_setpoint__roll_body`
- `actuator_motors` + `control[0]` → `actuator_motors__control_0`
:::
---
## 부록: 자주 사용하는 PX4 채널
| 채널 | 주요 필드 | 용도 |
|---|---|---|
| `vehicle_attitude_setpoint` | `roll_body`, `pitch_body`, `yaw_body` | 자세 설정값 |
| `vehicle_attitude` | `q[0]~q[3]` | 자세 쿼터니언 |
| `vehicle_local_position` | `x`, `y`, `z`, `vx`, `vy`, `vz` | 로컬 위치/속도 (NED) |
| `vehicle_global_position` | `lat`, `lon`, `alt` | 글로벌 위치 (EKF 융합, **lat/lon은 deg 단위**) |
| `vehicle_gps_position` | `lat`, `lon`, `alt`, `vel_n_m_s` | GPS 원시 데이터 (**lat/lon은 degE7, alt는 mm**. `scale` 필요) |
| `sensor_gps` | `lat`, `lon`, `alt`, `vel_n_m_s` | PX4 v1.14+ 에서 `vehicle_gps_position` 대신 사용 |
| `battery_status` | `voltage_v`, `current_a`, `discharged_mah` | 배터리 |
| `actuator_motors` | `control[0]~[3]` | 모터 출력 |
| `actuator_outputs` | `output[0]~[7]` | PWM 출력 |
| `sensor_combined` | `gyro_rad[0~2]`, `accelerometer_m_s2[0~2]` | IMU 원시 |
| `vehicle_acceleration` | `xyz[0]`, `xyz[1]`, `xyz[2]` | 가속도 |
| `vehicle_angular_velocity` | `xyz[0]`, `xyz[1]`, `xyz[2]` | 각속도 |
| `cpuload` | `load`, `ram_usage` | 시스템 리소스 |
:::tip[전체 채널 레퍼런스]
PX4 메시지 전체 목록은 [PX4 uORB Message Reference](https://docs.px4.io/main/en/msg_docs/)에서 확인할 수 있습니다.
:::