🛣️

TypeScriptで作る自動運転UI

2025/01/08に公開

こんにちは!チューリングでソフトウェアエンジニアをしている太田です。

自動運転システムの開発を手がけるチューリングでは、大規模な GPU クラスタでトレーニングされたモデルが日々リリースされ、実車環境でのテストが行われています。

自動運転と聞くと、ハードウェア寄りの技術を連想するかもしれませんが、チューリングの自動運転開発においてWeb系の技術もさまざまな場面で活用されています。

近年、UI開発においてWebブラウザが利用される場面が広がっています。チューリングの自動運転システムにおいても例外でなく、Webフロントエンドの技術を用いてUI開発が進められています。この記事では、チューリングの自動運転システムのUIに焦点を当てて、その背景や構成をコードとともに解説します。

https://github.com/turingmotors/webui-sample

1. E2Eモデル

チューリングでは「E2E(End-to-End)」と呼ばれる自動運転モデルを開発しています。カメラ画像を入力として走行経路を出力するモデルです。従来の自動運転システムの多くは、個別のタスクに特化したさまざまなモジュールを組み合わせて作られていました。一方、我々は入力から出力までひとつのモデルで推論するアプローチを採っています。

Fig E2Eモデルの概念図

End to End 方式は、モジュールが少ないため、ソフトウェアの構成は比較的単純になります。

しかし、UI開発においてはひとつの問題があります。従来のシステムでは、各モジュールの出力を見ることで、システムの状態を知ることができました。しかし、E2E モデルは全体が一つのブラックボックスになっているため、外部からはプロセスを直接覗き見ることができません。

そのため、チューリングのE2Eモデルは、経路計画以外にも、物体認識や車線認識も行うようになっています。これらはメインタスクである経路計画に対してサブタスクと呼ばれています。車を制御するにはメインタスクの出力だけで十分です。しかし、メインタスクだけではモデルがどのように周囲を認識し、何をもとに行動を判断しているか理解するのが難しいです。サブタスクを出力させることで、モデルがどのように周囲を認識をしているか理解することができます。

自動運転のUIはメインタスクに加えてサブタスクを表示します。これは数学の途中式のようなものです。サブタスクという途中式があることで、メインタスクの経路が当てずっぽうではなく、きちんと周囲を理解して求められていることを示します。自動運転モデルのUIは、モデルと人間の間に立ち、モデルの意図を伝える必要があります。

https://zenn.dev/turing_motors/articles/afe034665577f7

2. ソフトウェアの構成

さて、少々概念的な話になりましたが、ここからは自動運転システムのUIを、サンプルコードを見ながら体感していただこうと思います。

チューリングのWeb周りのシステムは、以下のような構成になっています:

  1. バックエンド:Pythonで作られたsocket.ioサーバ。自動運転モデルの推論結果や車両の状態を収集し、フロントエンドに送信します。
  2. フロントエンド:React ベースで構築された Web UI。カメラ画像、モデルの推論結果、ナビなどを表示します。

3. バックエンド:socket.io

まず、自動運転システム内に、フロントエンドと通信するためのサーバを設置します。このサーバは、UIで表示したい情報(カメラ画像、自動運転モデルの推論結果、車の状態)をシステムから収集し、クライアントにsocket.ioで送信します。

サーバはpythonで作られているため、本記事のサンプル用にTypeScript + denoでダミーサーバを実装しました。https://github.com/turingmotors/webui-sample/blob/main/server/src/main.ts

このサーバは、次の Msg 型のメッセージを socket.io で送信します。

export type Msg =
  | { kind: "camera"; data: Camera }
  | { kind: "model"; data: Model }
  | { kind: "car"; data: Car };

送信するデータは3種類あります。

export type Camera = { id: number; jpeg: ArrayBuffer };

1つ目はカメラ画像です。IDはカメラ画像に振られる連続した番号です。画像データはJPEGにエンコードしArrayBufferとして送信します。

export type Model = { path: XYZ[]; road: Road[]; bbox: BBox[] };
type Road = { kind: "Center" | "Edge"; path: XYZ[] };
type BBox = { kind: "Car" | "Human"; size: XYZ; rot: Quat; pos: XYZ };

type XYZ = { x: number; y: number; z: number };
type Quat = { w: number; i: number; j: number; k: number };

2つ目はモデルの推論結果です。pathは経路計画、roadは車線認識、bboxは物体認識です。経路は自車の位置を原点としたxyz座標の点列で表現されます。車線も同様の座標系で表現さえます。物体はサイズ、回転、位置を用いて表現されます。

export type Car = { loc: Geo; blinker: { left: boolean; right: boolean } };

type Geo = { lat: number; lng: number; alt: number };

3つ目は車の状態です。実際にはより多くの状態を表示する必要がありますが、このサンプルコードでは2つに絞っています。locはGPSの座標、blinkerはウィンカーの状態を示します。

サーバは次のコマンドで起動します。

cd server
deno run

4. クライアント:React

Reactを使ってフロントエンドを実装します。

import { useSocketIO } from "../state/msg";
import { Camera } from "./Camera/Camera";
import { Nav } from "./Nav/Nav";
import { Model } from "./Model/Model";
import style from "./App.module.css";

export const App = () => {
  useSocketIO("http://localhost:3000");
  return (
    <div className={style.app}>
      <Camera />
      <Model />
      <Nav />
    </div>
  );
};

useSocketIO はメッセージを受信するためのhookです。Camera はカメラ画像、Model はモデルの推論結果を、Navはナビゲーション用の地図を、描画するコンポーネントです。最終的には次のようなUIが出来上がります。

メッセージの受信

Socket.IO を使ったリアルタイム通信と、Jotai を使った状態管理を組み合わせた React のカスタムフックです。WebSocket サーバーから受信したデータを、種別(カメラ、車、モデル)ごとに分類し、Recoilを用いてWebアプリ全体に共有します。

import { Msg, Camera, Car, Model } from "../type/msg";

export const lastCamAtom = atom<Camera | null>(null);
export const lastCarAtom = atom<Car | null>(null);
export const lastModelAtom = atom<Model | null>(null);

export const useSocketIO = (url: string) => {
  const setLastCar = useSetAtom(lastCarAtom);
  const setLastCam = useSetAtom(lastCamAtom);
  const setLastModel = useSetAtom(lastModelAtom);

  useEffect(() => {
    const socket = socketIOClient(url);
    socket.on("connect", () => {
      console.log("Connected: ", url);
    });

    socket.on("disconnect", async () => {
      console.log("Disconnected: ", url);
    });

    socket.on("msg", (data) => {
      console.log("msg", data);
      if (data.kind) {
        const msg = data as Msg;
        if (msg.kind === "camera") setLastCam(msg.data);
        if (msg.kind === "model") setLastModel(msg.data);
        if (msg.kind === "car") setLastCar(msg.data);
      }
    });

    return () => {
      socket.off("connect");
      socket.off("disconnect");
      socket.off("cereal");
      socket.off("monitoringResult");
      socket.disconnect();
    };
  }, [setLastCam, setLastCar, setLastModel, url]);
};

最初に3つのatomが定義されています。これらは受信したデータ(カメラ、車、モデル)を保持するためのものです。

カメラ画像の表示:Canvas

カメラ画像のJPEGが次々と送られてきます。これを canvas に描画して表示します。

export const Camera = () => {
  const camera = useRecoilValue(lastCamState);
  const canvasRef = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    if (camera && canvasRef.current) {
      const ctx = canvasRef.current.getContext("2d");
      const img = new Image();
      img.onload = () => {
        if (ctx) {
          ctx.drawImage(img, 0, 0);
        }
      };
      img.src = `data:image/jpeg;base64,${camera.jpeg}`;
    }
  }, [camera]);

  return (
    <div>
      <canvas ref={canvasRef} />
    </div>
  );
};

モデル出力の描画:Three.js

モデルの出力をもとに、車の周囲の空間を3D で描画します。react-three-fiber を使用して、宣言的に3Dを描画しています。

export const Model: FC = () => {
  const model = useModel();
  const car = useCar();

  return (
    <Canvas>
      <CameraControls />
      <PerspectiveCamera
        makeDefault
        zoom={1}
        position={[0, -27, 24]}
        rotation={[Math.PI / 3.7, 0, 0]}
      />
      <ambientLight intensity={3} />
      <gridHelper
        args={[200, 200, "lightgray", "gray"]}
        rotation={[Math.PI / 2, 0, 0]}
        position={[0, 0, 0]}
      />
      <axesHelper args={[5]} />
      <EgoCar
        leftBlinker={car?.blinker.left}
        rightBlinker={car?.blinker.right}
      />
      {model && <PathView path={model.path} />}
      {model && model.bbox.map((bbox) => <BBoxView bbox={bbox} />)}
      {model && model.road.map((road) => <RoadView road={road} />)}
    </Canvas>
  );
};

EgoCarは自車を表示するコンポーネントです。blinkerプロパティでウインカーのON/OFFを切り替えられます。

const EgoCar: FC<{ leftBlinker?: boolean; rightBlinker?: boolean }> = ({
  leftBlinker,
  rightBlinker,
}) => {
  return (
    <mesh position={[0, 0, 1]}>
      <boxGeometry args={[2, 4, 2]} />
      <meshStandardMaterial color="gray" />
      {leftBlinker && (
        <mesh position={[-0.75, -2, 0]}>
          <boxGeometry args={[0.5, 0.1, 0.1]} />
          <meshStandardMaterial color="orange" />
        </mesh>
      )}
      {rightBlinker && (
        <mesh position={[0.75, -2, 0]}>
          <boxGeometry args={[0.5, 0.1, 0.1]} />
          <meshStandardMaterial color="orange" />
        </mesh>
      )}
    </mesh>
  );
};

BBoxは認識した物体を表示するコンポーネントです。

const BBoxView: FC<{ bbox: BBox }> = ({ bbox }) => {
  const pos = [bbox.pos.x, bbox.pos.y, bbox.pos.z] as Vector3;
  const rot = [bbox.rot.w, bbox.rot.i, bbox.rot.j, bbox.rot.k] as Quaternion;
  const size = [bbox.size.x, bbox.size.y, bbox.size.z];
  return (
    <mesh position={pos} quaternion={rot}>
      <lineSegments>
        <edgesGeometry args={[new BoxGeometry(...size)]} />
        <lineBasicMaterial color="green" linewidth={8} />
      </lineSegments>
    </mesh>
  );
};

PathViewは自車の経路を、RoadViewは車線を表示するコンポーネントです。

const PathView: FC<{ path: Path }> = ({ path }) => {
  return (
    <Line
      points={path.map((p) => [p.x, p.y, p.z] as Vector3)}
      color={"red"}
      lineWidth={4}
    />
  );
};

const RoadView: FC<{ road: Road }> = ({ road }) => {
  const COLORS = { Center: "yellow", Edge: "white" };
  return (
    <Line
      points={road.path.map((p) => [p.x, p.y, p.z] as Vector3)}
      color={COLORS[road.kind]}
      lineWidth={4}
    />
  );
};

ナビ:MapLibre

地図の描画には mapbox のフォークである MapLibre GL JS を使用しています。

export const Nav = () => {
  const car = useRecoilValue(lastCarState);

  const [track, setTrack] = useState(true);

  const [viewState, setViewState] = useState({
    latitude: 35.61855046346434,
    longitude: 139.73201705274556,
    bearing: 0,
    zoom: 17,
    pitch: 60,
  });

  useEffect(() => {
    if (car && track) {
      setViewState((prevState) => ({
        ...prevState,
        latitude: car.loc.lat,
        longitude: car.loc.lng,
      }));
    }
  }, [car]);

  return (
    <Map
      {...viewState}
      onMove={(e) => {
        setViewState(e.viewState);
        setTrack(false);
      }}
      mapStyle={mapStyle as MapStyle}
    >
      {car && (
        <Marker latitude={car.loc.lat} longitude={car.loc.lng}>
          <div className={style.marker} />
        </Marker>
      )}
      <NavigationControl />
      <button className={style.button} onClick={() => setTrack(true)}>
        Track
      </button>
    </Map>
  );
};

useEffectで車の移動に合わせて地図を移動させています。また、ボタンで車を追跡するか否かを切り替えるようにしています。

5. おわりに

チューリングで開発している自動運転システムは、ハードウェアからソフトウェアまで、バックエンドからフロントエンドまで、幅広い技術から成り立っています。その幅広さゆえ、今までに遭遇したことがないような新鮮な課題が無数にあります。

Web開発には確立されたベストプラクティスや豊富なフレームワークがあります。一方で、自動運転ソフトウェアの開発はまだ未知の領域で、ベストプラクティスを模索している段階です。

チューリングでは、混沌の中から答えを探し出すような自動運転のソフトウェア開発を一緒に挑戦する仲間を募集中です。興味がある方は、ぜひ採用ページをご覧ください!

Tech Blog - Turing

Discussion