OrbitCapture - 簡単3Dスキャンシステム(Spresenseコンテスト2025)
概要/Summary
本作品は、誰でも簡単に自分の作品やアイテムを3Dデータ化できる3Dスキャンシステムを目指した。
"アナログなものづくりとデジタルなものづくりを、もっと自由に行き来したい"という考えのもと、360度撮影から3Dデータ化までを全自動化し、誰でも簡単に「リアルをデジタルに変換する体験」を提供することを目的としている。
設計/Design
ハードウェア構成
主要部品:
- Spresense Main Board
- Spresense HDRカメラボード
- ステッピングモータ: 28BYJ-48
- モーター用フランジ
- モータドライバ: DRV8835
- PC
- モバイルバッテリー(モーター給電用)
- USBケーブル, MDF板, フラワースタンド, 結束バンド, 等100円ショップで調達
通信プロトコル:
-
Spresense
PC:シリアルバイナリプロトコル(115200 baud)- コマンド送受信:テキスト(SNAP, MOVE, EXIT)
- 画像転送:バイナリ(マジックシーケンス + サイズ + JPEG)
-
内部制御:
- カメラ制御:V4L2 ioctl(
/dev/video) - モーター制御:FIFO IPC(
/dev/stepper0)
- カメラ制御:V4L2 ioctl(
HDRカメラに付属のカメラレンズは被写界深度が浅く、背景がボケる傾向がある。
今回の用途では画角も不足気味であったため、付属レンズでの試行以外に別途M8、M12レンズでも試行した。
試行に当たり、カメラレンズホルダを3Dプリンタで作成し、M8P0.5、M12P0.5のレンズを装着した。
3Dプリンタで作成したレンズホルダはある程度IR光を透過してしまったため、そのままでは画像が赤みがかってしまう。
対策として、レンズホルダを黒ビニールテープで巻くことで改善したことが確認できた。
Spresense を使うメリット
- マルチコアCPU:モータとカメラを同時制御
- HDRカメラ対応:照明の影響を抑え、きれいに撮影できる
ソフトウェア構成
- 開発環境: Spresense SDK (NuttX RTOS)
- 使用言語: C (Spresense側) / Python 3.x (PC側)
詳細プロセス:
- PC側ではPythonスクリプトが撮影枚数を指定して起動される
- Spresense側では
stepperdデーモンがバックグラウンドで待機している - 撮影ループでは以下が繰り返される:
- 画像撮影(HDRカメラでJPEG画像取得)
- PCへ画像を転送
- モータ回転(次の角度へ移動)
- PC側ではタイムスタンプ付きフォルダに画像が自動保存される
- 撮影後、ホストPCまたはGoogle ColabでCOLMAPによる3D再構成や3D Gaussian Splatingなどにより3次元モデルが生成される
動作結果
実行結果
PC側ログ出力例:
23:45:12 [INFO] Connecting to /dev/ttyUSB0 at 115200 baud...
23:45:12 [INFO] Connection established and prompt found.
23:45:12 [INFO] Starting scan: 24 shots over 360 degrees.
23:45:12 [INFO] --- Shot 1/24 at 0.0° ---
23:45:12 [INFO] Triggering capture...
23:45:14 [INFO] Receiving image: 127456 bytes
23:45:15 [INFO] Progress: 25%
23:45:16 [INFO] Progress: 50%
23:45:17 [INFO] Progress: 75%
23:45:18 [INFO] Capture complete. Saved to 0121_2345/shot_000_0deg.jpg
23:45:18 [INFO] Moving 85 steps CW (delay: 10000us)
...
保存された画像群:
0121_2345/
├── shot_000_0deg.jpg
├── shot_001_15deg.jpg
├── shot_002_30deg.jpg
...
└── shot_023_345deg.jpg
3次元モデル作成シーケンス
COLMAP、opensplatを使用した例を示す。
$DATA_PATH = "./data"
$env:QT_QPA_PLATFORM_PLUGIN_PATH="C:\colmap-x64-windows-nocuda\plugins\platforms"
[特徴点の抽出]
colmap feature_extractor `
--image_path "$DATA_PATH/original_images" `
--database_path "$DATA_PATH/database.db" `
--ImageReader.single_camera 1 `
--ImageReader.camera_model OPENCV
[特徴点のマッチング]
colmap exhaustive_matcher `
--database_path "$DATA_PATH/database.db" `
--FeatureMatching.use_gpu 0
[バンドル調整]
colmap mapper `
--image_path "$DATA_PATH/original_images/" `
--database_path "$DATA_PATH/database.db" `
--output_path "$DATA_PATH/sfm"
[歪み補正]
colmap image_undistorter `
--image_path "$DATA_PATH/original_images" `
--input_path "$DATA_PATH/sfm/0" `
--output_path "$DATA_PATH/undistort_tmp" `
--output_type COLMAP
mkdir ./data/sparse/0
move ./data/undistort_tmp/sparse/*.bin ./data/sparse/0
xcopy ./data/undistort_tmp/images ./data/images /E /Y
rmdir /S /Q ./data/undistort_tmp
[3DGSの生成]
opensplat.exe $DATA_PATH -n 2000
3D Gaussian Splatting結果
https://gsplat.org/viewer/60kqb
https://gsplat.org/viewer/uif7u
ソースコード
本システムはハードウェア制御層(Spresense側)とホスト制御層(PC側)に分けられている。
ハードウェア制御層(Spresense SDK)
camera_api.h / camera_api.c - HDRカメラ制御
概要:Spresense HDRカメラをV4L2(Video4Linux2)APIで制御する。
主要な初期化処理:
int camera_init(void)
{
// V4L2デバイス初期化
video_initialize(VIDEO_DEV_PATH);
g_cam_fd = open(VIDEO_DEV_PATH, 0);
// バッファ要求:ユーザーメモリ mode
struct v4l2_requestbuffers req;
req.type = V4L2_BUF_TYPE_STILL_CAPTURE;
req.memory = V4L2_MEMORY_USERPTR; // アプリ側で確保
req.count = STILL_BUFNUM;
req.mode = V4L2_BUF_MODE_FIFO;
ioctl(g_cam_fd, VIDIOC_REQBUFS, &req);
// フォーマット設定:JPEG + QuadVGA (1600x1200)
struct v4l2_format fmt;
fmt.type = V4L2_BUF_TYPE_STILL_CAPTURE;
fmt.fmt.pix.width = CAPTURE_WIDTH; // 1600
fmt.fmt.pix.height = CAPTURE_HEIGHT; // 1200
fmt.fmt.pix.pixelformat = V4L2_PIX_FMT_JPEG;
ioctl(g_cam_fd, VIDIOC_S_FMT, &fmt);
// JPEG品質設定(95 = 高品質)
struct v4l2_ext_controls ctrls;
struct v4l2_ext_control ctrl;
ctrls.ctrl_class = V4L2_CTRL_CLASS_JPEG;
ctrl.id = V4L2_CID_JPEG_COMPRESSION_QUALITY;
ctrl.value = JPEG_QUALITY; // 95
ioctl(g_cam_fd, VIDIOC_S_EXT_CTRLS, &ctrls);
return 0;
}
キャプチャー処理:
int camera_capture(uint8_t *buf, size_t buf_size, size_t *captured_size)
{
struct v4l2_buffer vbuf;
// バッファキューに追加
vbuf.type = V4L2_BUF_TYPE_STILL_CAPTURE;
vbuf.memory = V4L2_MEMORY_USERPTR;
vbuf.m.userptr = (unsigned long)buf;
vbuf.length = buf_size;
ioctl(g_cam_fd, VIDIOC_QBUF, &vbuf);
// 撮影開始
ioctl(g_cam_fd, VIDIOC_TAKEPICT_START, 0);
// 撮影完了まで待機(ブロッキング)
ioctl(g_cam_fd, VIDIOC_DQBUF, &vbuf);
// 撮影完了
ioctl(g_cam_fd, VIDIOC_TAKEPICT_STOP, 0);
*captured_size = vbuf.bytesused;
return 0;
}
特徴:
- V4L2の標準API使用で再現性が高い
- JPEG品質95で高品質撮影である
- メモリ効率化:1MB バッファ(キャプチャ後は即座に送信される)
stepper_api.h / stepper_api.c - モーター制御API
概要:FIFO(名前付きパイプ)経由でプロセス間通信(IPC)し、stepperdデーモンにコマンドを送信する。
インターフェース定義:
struct stepper_cmd {
int steps; // ステップ数
int delay_us; // ステップ間隔(マイクロ秒)
int direction; // 方向: 1(時計回り), -1(反時計回り)
};
int stepper_init(void); // 初期化:FIFOへの接続テスト
int stepper_move(int steps, int delay_us, int direction);
実装:
int stepper_move(int steps, int delay_us, int direction)
{
struct stepper_cmd cmd;
cmd.steps = steps;
cmd.delay_us = delay_us;
cmd.direction = direction;
// FIFOをオープン(非ブロッキング)
int fd = open(FIFO_PATH, O_WRONLY);
if (fd < 0) {
perror("FIFO open failed. Is stepperd running?");
return -errno;
}
// コマンド構造体をバイナリで送信
int wlen = write(fd, &cmd, sizeof(cmd));
close(fd);
if (wlen != sizeof(cmd)) {
return -1; // 書き込み失敗
}
return 0;
}
利点:
- プロセス間通信で分離性が高い
- コマンド送信は非ブロッキングである
stepperdはバックグラウンドで独立稼働する
orbit_capture_main.c - メインアプリケーション
構造:対話型コマンド・インタープリタで、PC側からのコマンド(SNAP, MOVE, EXIT)を受け取り処理する。
int orbit_capture_main(int argc, char *argv[])
{
char line[MAX_LINE];
uint8_t *img_buf;
size_t captured_size;
printf("OrbitCapture starting...\n");
printf("Ready for commands (MOVE, SNAP, EXIT)\n");
// カメラ初期化
camera_init();
img_buf = (uint8_t *)memalign(32, FRAMEBUFFER_SIZE);
while (1) {
printf("OC> ");
fflush(stdout);
if (fgets(line, MAX_LINE, stdin) == NULL) break;
char *cmd = strtok(line, " \n\r");
if (!cmd) continue;
if (strcmp(cmd, "MOVE") == 0) {
// MOVE <steps> <delay_us> <direction>
int steps = atoi(strtok(NULL, " "));
int delay = atoi(strtok(NULL, " "));
int dir = atoi(strtok(NULL, " "));
stepper_move(steps, delay, dir);
}
else if (strcmp(cmd, "SNAP") == 0) {
// 画像撮影
int ret = camera_capture(img_buf, FRAMEBUFFER_SIZE, &captured_size);
if (ret == 0) {
fprintf(stderr, "Sending image... (%zu bytes)\n", captured_size);
fflush(stdout); // テキストを送信してから...
camera_send_image(img_buf, captured_size); // ...バイナリ送信
}
}
else if (strcmp(cmd, "EXIT") == 0) {
break;
}
}
free(img_buf);
camera_uninit();
printf("OrbitCapture finished.\n");
return 0;
}
通信フロー:
- PC(orbit_scan.py)が
"SNAP\n"を送信する - Spresenseがカメラ画像をキャプチャする
- バイナリプロトコルで画像を転送される
- PC側でプロンプト受信まで待機される
バイナリ画像転送プロトコル
PC ←→ Spresense の画像転送は、以下のバイナリプロトコルで実現される。
フォーマット:
[Magic (4 bytes)] [Size (4 bytes)] [JPEG Data (variable)]
0xBEEFCAFE uint32_t LE image_size bytes
Spresense側実装(camera_api.c):
void camera_send_image(uint8_t *buf, size_t size)
{
struct termios tio, saved_tio;
int fd = 1; // stdout
// シリアルオプション変更:LF->CRLF変換を無効化
tcgetattr(fd, &tio);
saved_tio = tio;
tio.c_oflag &= ~ONLCR; // 重要:バイナリ転送時に改行展開されない
tcsetattr(fd, TCSANOW, &tio);
// マジックシーケンス送信
const uint8_t marker[4] = {0xBE, 0xEF, 0xCA, 0xFE};
write(fd, marker, 4);
// サイズ送信(リトルエンディアン)
uint32_t sz = (uint32_t)size;
write(fd, &sz, 4);
// 画像データ送信
write(fd, buf, size);
// 設定を復元
tcdrain(fd);
tcsetattr(fd, TCSANOW, &saved_tio);
}
ホスト制御層(Python)
orbit_scan.py - PC側シーケンス制御
シリアル通信の初期化:
class OrbitController:
def __init__(self, port, baud):
self.ser = serial.Serial(port, baud, timeout=40)
self.ser.reset_input_buffer()
time.sleep(0.2)
# プロンプト検出まで待機
self.ser.write(b"\n")
self.ser.read_until(b"OC>")
logger.info("Connection established and prompt found.")
画像受信の実装:
def capture(self, filename):
logger.info("Triggering capture...")
self.ser.write(b"SNAP\n")
# ステップ1: マジックシーケンス検出
MAGIC = b'\xBE\xEF\xCA\xFE'
buffer = b''
while True:
chunk = self.ser.read(self.ser.in_waiting or 1)
buffer += chunk
magic_pos = buffer.find(MAGIC)
if magic_pos != -1:
# マジック後の残りデータを確保
extra = buffer[magic_pos + len(MAGIC):]
break
# ステップ2: イメージサイズ読み取り(4 bytes, little-endian)
if len(extra) < 4:
extra += self.ser.read(4 - len(extra))
img_size = int.from_bytes(extra[:4], byteorder='little')
logger.info(f"Receiving image: {img_size} bytes")
# ステップ3: 画像データ受信(プログレス表示付き)
img_data = extra[4:]
remaining = img_size - len(img_data)
last_report = 0
while remaining > 0:
chunk = self.ser.read(min(remaining, 4096))
if not chunk:
logger.error("Connection lost during transfer")
break
img_data += chunk
remaining -= len(chunk)
# 25% 刻みでプログレス表示
progress = int((1 - remaining / img_size) * 100)
if progress >= last_report + 25:
logger.info(f"Progress: {progress}%")
last_report = progress
# ステップ4: ファイル保存
with open(filename, 'wb') as f:
f.write(img_data)
# プロンプト復帰まで待機
self.ser.read_until(b"OC>")
logger.info(f"Capture complete. Saved to {filename}")
return True
撮影ループ:
def main():
parser = argparse.ArgumentParser()
parser.add_argument('shots', type=int, help='撮影枚数')
parser.add_argument('--output', default=datetime.now().strftime('%m%d_%H%M'))
args = parser.parse_args()
ctrl = OrbitController('/dev/ttyUSB0', 115200)
os.makedirs(args.output, exist_ok=True)
steps_per_segment = STEPS_PER_REV // args.shots # 2048 / shots
current_step_pos = 0
for i in range(args.shots):
angle = (360 / STEPS_PER_REV) * current_step_pos
filename = os.path.join(args.output, f"shot_{i:03d}_{int(angle)}deg.jpg")
logger.info(f"--- Shot {i+1}/{args.shots} at {angle:.1f}° ---")
ctrl.capture(filename)
# 次の角度へ回転
if i < args.shots - 1:
ctrl.move(steps_per_segment, STEP_DELAY_US, 1)
current_step_pos += steps_per_segment
# モーター振動収束を待つ
travel_time = (steps_per_segment * STEP_DELAY_US) / 1000000.0
time.sleep(travel_time + 0.1)
logger.info("Scan completed successfully.")
ctrl.close()
主要な工夫:
- タイムアウト処理:シリアルタイムアウト40秒に設定される
- マジックシーケンス:確実な同期(0xBEEFCAFEで検出される)
- プログレス表示:25% 刻みで進捗が確認される
- 動的スリープ:モーター回転時間を計算して待機される
実装のポイント
| コンポーネント | 技術 | 目的 |
|---|---|---|
| camera_api | V4L2 API | 標準デバイスドライバー経由でHDRカメラ制御 |
| stepper_api | FIFO IPC | モーターコマンドをバックグラウンド処理 |
| バイナリプロトコル | マジックシーケンス + サイズ | 確実な画像同期・転送 |
| シリアル通信 | termios | バイナリモードで改行展開を防止 |
| エラーハンドリング | errno + タイムアウト | 接続失敗・転送失敗を検出 |
まとめ
達成したこと
本作品では、Spresense の特長を活かして、操作が簡単な3D撮影システム「OrbitCapture」が開発された。Spresense Main BoardとHDRカメラボードを用いたハードウェア、および対話型コマンドインタープリタとPythonスクリプトを組み合わせたソフトウェアのプロトタイピングに成功した。360度自動撮影からバイナリ画像転送プロトコル、さらには3D Gaussian Splattingによる3次元モデル生成までの一連のワークフローが実装できた。
今後の展開
今後、以下の点での改善を検討している:
-
撮影条件の改善:現在のハードウェア構成では被写体とカメラの距離がほぼ一定になってしまうため、3次元復元にはあまり適切とは言い難い。カメラの配置やターンテーブルの構成を改善し、より多角的な視点からの撮影を実現したい。
-
ソフトウェアワークフローの改善:ワンクリックでの完全自動化には至っていない。GUI付きアプリケーションにより、撮影から3Dモデル生成までの全自動化を進めたい。
-
3DGSの品質向上:現在作成した3D Gaussian Splattingは粗い品質である。また、高い解像感をローカルマシンで実現するにはマシンスペックが要求される。クラウド環境の利用により、ローカルマシンへの要求を下げながら高品質な3Dモデルを生成する仕組みを構築したい。


-
snflwr59
さんが
2026/01/29
に
編集
をしました。
(メッセージ: 初版)
-
snflwr59
さんが
2026/01/29
に
編集
をしました。
(メッセージ: Mermaid代替で図を挿入)
-
snflwr59
さんが
2026/01/29
に
編集
をしました。
(メッセージ: 章番号が重複してしまうので削除)
-
snflwr59
さんが
2026/01/29
に
編集
をしました。
(メッセージ: 見出し修正)
ログインしてコメントを投稿する