snflwr59のアイコン画像
snflwr59 2026年01月29日作成 (2026年01月29日更新) © MIT
製作品 製作品 閲覧数 28
snflwr59 2026年01月29日作成 (2026年01月29日更新) © MIT 製作品 製作品 閲覧数 28

OrbitCapture - 簡単3Dスキャンシステム

 OrbitCapture - 簡単3Dスキャンシステム

OrbitCapture - 簡単3Dスキャンシステム(Spresenseコンテスト2025)

OrbitCaptureLogo

概要/Summary

本作品は、誰でも簡単に自分の作品やアイテムを3Dデータ化できる3Dスキャンシステムを目指した。

"アナログなものづくりとデジタルなものづくりを、もっと自由に行き来したい"という考えのもと、360度撮影から3Dデータ化までを全自動化し、誰でも簡単に「リアルをデジタルに変換する体験」を提供することを目的としている。

外観


設計/Design

ハードウェア構成

主要部品

  • Spresense Main Board
  • Spresense HDRカメラボード
  • ステッピングモータ: 28BYJ-48
  • モーター用フランジ
  • モータドライバ: DRV8835
  • PC
  • モバイルバッテリー(モーター給電用)
  • USBケーブル, MDF板, フラワースタンド, 結束バンド, 等100円ショップで調達

ハードウェア・ブロック図
BlockDiagram

通信プロトコル

  • Spresense ↔ PC:シリアルバイナリプロトコル(115200 baud)

    • コマンド送受信:テキスト(SNAP, MOVE, EXIT)
    • 画像転送:バイナリ(マジックシーケンス + サイズ + JPEG)
  • 内部制御

    • カメラ制御:V4L2 ioctl(/dev/video
    • モーター制御:FIFO IPC(/dev/stepper0

Detail

カメラレンズ
HDRカメラに付属のカメラレンズは被写界深度が浅く、背景がボケる傾向がある。
今回の用途では画角も不足気味であったため、付属レンズでの試行以外に別途M8、M12レンズでも試行した。
試行に当たり、カメラレンズホルダを3Dプリンタで作成し、M8P0.5、M12P0.5のレンズを装着した。
3Dプリンタで作成したレンズホルダはある程度IR光を透過してしまったため、そのままでは画像が赤みがかってしまう。
対策として、レンズホルダを黒ビニールテープで巻くことで改善したことが確認できた。

Spresense を使うメリット

  • マルチコアCPU:モータとカメラを同時制御
  • HDRカメラ対応:照明の影響を抑え、きれいに撮影できる

ソフトウェア構成

  • 開発環境: Spresense SDK (NuttX RTOS)
  • 使用言語: C (Spresense側) / Python 3.x (PC側)

撮影シーケンス
Sequence

詳細プロセス

  1. PC側ではPythonスクリプトが撮影枚数を指定して起動される
  2. Spresense側ではstepperdデーモンがバックグラウンドで待機している
  3. 撮影ループでは以下が繰り返される:
    • 画像撮影(HDRカメラでJPEG画像取得)
    • PCへ画像を転送
    • モータ回転(次の角度へ移動)
  4. PC側ではタイムスタンプ付きフォルダに画像が自動保存される
  5. 撮影後、ホストPCまたはGoogle ColabでCOLMAPによる3D再構成や3D Gaussian Splatingなどにより3次元モデルが生成される

動作結果

実行結果

撮影例:1周64枚(5.625°刻み)で約15分である。
example1
example2
example3

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

COLMAP結果
COLMAP_Frustum

3D Gaussian Splatting結果
LEGO
https://gsplat.org/viewer/60kqb
frog
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; }

通信フロー

  1. PC(orbit_scan.py)が "SNAP\n" を送信する
  2. Spresenseがカメラ画像をキャプチャする
  3. バイナリプロトコルで画像を転送される
  4. 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次元モデル生成までの一連のワークフローが実装できた。

今後の展開

今後、以下の点での改善を検討している:

  1. 撮影条件の改善:現在のハードウェア構成では被写体とカメラの距離がほぼ一定になってしまうため、3次元復元にはあまり適切とは言い難い。カメラの配置やターンテーブルの構成を改善し、より多角的な視点からの撮影を実現したい。

  2. ソフトウェアワークフローの改善:ワンクリックでの完全自動化には至っていない。GUI付きアプリケーションにより、撮影から3Dモデル生成までの全自動化を進めたい。

  3. 3DGSの品質向上:現在作成した3D Gaussian Splattingは粗い品質である。また、高い解像感をローカルマシンで実現するにはマシンスペックが要求される。クラウド環境の利用により、ローカルマシンへの要求を下げながら高品質な3Dモデルを生成する仕組みを構築したい。

参考資料

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