5
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Deck.glのScatterplotLayerで1000万ポイント描画!

Posted at

image.png

これは MIERUNE Advent Calendar 2025 の10日目の記事です。
昨日は @darshu さんによる 並進型地すべりを定量的に検出するQGISプラグイン
でした。

Deck.glというライブラリがあります。

元々Uberが開発したライブラリで、とにかく大規模データを綺麗なビジュアルで描画するのがものすっごい強いライブラリです。

最近ではLonboardというPythonのJupyterで利用できる地理情報描画ライブラリの中で採用され、分析しながら100万件程度のポイントデータをサクッと描画することができるようになっていて驚きました。

ですが、そちらはJupyter側・Python側の制約など色々により、Deck.gl本来の実力を発揮できていないっぽいんですよね。
まだまだ本気を出していない。そんな雰囲気を感じます。

なので今回はDeck.gl本体を使ってとにかくたくさんポイントを表示させてみます!!!

僕のマシンがApple M1 Maxの64GB RAMなので参考にならない場合もあるかもしれませんが、結構たくさんのポイントを描画することができたのでお付き合い下さい!

ざっくりサマリー

だいたい、以下のようなことをしました。

  • ScatterplotLayerで1000万ポイントを描画する
  • データ転送にParquet・Arrowを使い、Web Workerでbinary attributeに変換して利用してみる
  • CPUでポイント処理するのと、GPUで処理する速度の違いを見てみる

データ作成

まずはPythonで適当にデータを作りましょう。
今回はpyarrow.parquetを利用してダミーデータを1000万ポイント生成しました。

from pathlib import Path
import numpy as np
import pyarrow as pa
import pyarrow.parquet as pq

N_POINTS = 10_000_000
RNG = np.random.default_rng(123)

CENTER_LON, CENTER_LAT = 139.76, 35.68
LON_STD, LAT_STD = 0.12, 0.10
ALT_STD = 40.0

lon = CENTER_LON + RNG.normal(0, LON_STD, size=N_POINTS).astype(np.float32)
lat = CENTER_LAT + RNG.normal(0, LAT_STD, size=N_POINTS).astype(np.float32)
alt = RNG.normal(0, ALT_STD, size=N_POINTS).astype(np.float32)
ids = np.arange(N_POINTS, dtype=np.int32)

table = pa.table(
    {
        "id": pa.array(ids, type=pa.int32()),
        "lon": pa.array(lon, type=pa.float32()),
        "lat": pa.array(lat, type=pa.float32()),
        "alt": pa.array(alt, type=pa.float32()),
    }
)

out_path = Path(__file__).resolve().parent.parent / "static" / "points_static.parquet"
out_path.parent.mkdir(parents=True, exist_ok=True)

pq.write_table(table, out_path)
print(f"wrote {N_POINTS:,} static rows to {out_path}")

実行すると、このような140MB程度のファイルが出力されます。

image.png

ちなみにこれはhyparquet demoというサイトで、1000万レコードのParquetのデータもさくっと見れちゃう優れものです。

ワーカー内でhyparquetを利用してパースする

今回はまずParquetのデータを作成しました。
シンプルなものなので1000万レコードあっても140MB程度で済んでいます。
多分CSVやJSONなら酷いことになっていると思いますが、parquetは圧縮が効くので良いですねー。

で、今回はマシン内にデータを配置するのでほぼ無視できますが、実際にはネットワーク上からフェッチしてくることになると思います。
普通に考えれば、これだけ大量のデータの場合はタイル化すると思うんですが、今回はあくまで実験のためどかっとフェッチして全ポイント描画させていきます。

とはいえ流石にダウンロードとパースでメインスレッドを占有したくないのでワーカーに逃すくらいはしてあげようと思います。

ネットワーク上のParquetへフェッチするにはDuckDB-Wasmを使うと楽なんですが、Wasmのバイナリサイズが30MBほどあり重量級です。どうしようかなーと思ったんですが、同僚に「軽量なhyparqetおすすめやで」って言われたので使ってみることにしました。

hyparqetを利用して、このようなワーカーを建てました。URLを受け取って、取得した配列の長さと実際の配列を返却します。

今回はParquetを全量fetchしてからパースをしています。データはローカルにあるのであまり気にしていなかったんですが、140MBのファイルを全てfetchするのは無謀なので、どうにかする必要があるでしょう。

import { parquetMetadataAsync, parquetRead } from 'hyparquet';

type WorkerRequest = {
	url: string;
};

type WorkerResponse = {
	length: number;
	positions: Float32Array;
};

self.onmessage = async (event: MessageEvent<WorkerRequest>) => {
	const { url } = event.data;
	await handleParquet(url);
};

async function handleParquet(url: string) {
	const response = await fetch(url);
	const buf = await response.arrayBuffer();

	const metadata = await parquetMetadataAsync(buf);
	const columns = ['lon', 'lat', 'alt'];

	const length = Number(metadata.num_rows);
	if (!Number.isFinite(length) || length <= 0) throw new Error('invalid row count');

	const positions = new Float32Array(length * 3);

	await parquetRead({
		file: buf,
		columns,
		onChunk(chunk) {
			const { columnName, columnData, rowStart, rowEnd } = chunk;
			const len = rowEnd - rowStart;

			if (columnName === 'lon') {
				for (let i = 0; i < len; i++) {
					const idx = (rowStart + i) * 3;
					positions[idx] = columnData[i];
				}
			} else if (columnName === 'lat') {
				for (let i = 0; i < len; i++) {
					const idx = (rowStart + i) * 3 + 1;
					positions[idx] = columnData[i];
				}
			} else if (columnName === 'alt') {
				for (let i = 0; i < len; i++) {
					const idx = (rowStart + i) * 3 + 2;
					positions[idx] = columnData[i];
				}
			}
		}
	});

	const payload: WorkerResponse = {
		length,
		positions
	};

	const transfer: Transferable[] = [positions.buffer];

	self.postMessage(payload, transfer);
}

メインスレッドからParquetのURLを受け取り、ポイント長とインターリーブした座標([x0, y0, z0, x1, y1, z1, ...])を返却します。
(エラー処理などは基本全くしていません)

type WorkerRequest = {
	url: string;
};

type WorkerResponse = {
	length: number;
	positions: Float32Array;
};

ScatterplotLayerで1000万ポイントを描画する

描画にはDeck.glのScatterplotLayerを利用します。
散布図を作るためのレイヤーで、シンプルな円がたくさん表示されます。
PointCloudLayerというのもあるんですが、それとの比較はまた別記事で…

Deck.glは通常、以下のReactでの実装例のようにdata(JSONなど)を対象として、getPositionプロパティに座標を返す関数を定義してあげることで描画することができます。
getPositionにはdata変数がforループで1要素ずつ渡されるので、dataの構造に合わせて、自由に座標を取り出す関数を書いてあげることで、全頂点読み取り後に描画がされます。

import React from 'react';
import {DeckGL} from '@deck.gl/react';
import {ScatterplotLayer} from '@deck.gl/layers';
import type {PickingInfo} from '@deck.gl/core';

type BartStation = {
  name: string;
  entries: number;
  exits: number;
  coordinates: [longitude: number, latitude: number];
};

function App() {
  const layer = new ScatterplotLayer<BartStation>({
    id: 'ScatterplotLayer',
    data: 'https://raw.githubusercontent.com/visgl/deck.gl-data/master/website/bart-stations.json',
    
    stroked: true,
    getPosition: (d: BartStation) => d.coordinates,
    getRadius: (d: BartStation) => Math.sqrt(d.exits),
    getFillColor: [255, 140, 0],
    getLineColor: [0, 0, 0],
    getLineWidth: 10,
    radiusScale: 6,
    pickable: true
  });

  return <DeckGL
    initialViewState={{
      longitude: -122.4,
      latitude: 37.74,
      zoom: 11
    }}
    controller
    getTooltip={({object}: PickingInfo<BartStation>) => object && object.name}
    layers={[layer]}
  />;
}

binary attributeでの高速描画

上記の方法は、全てのデータに対してCPUを利用してループで座標を描画する方法でした。
ただ、deck.glは「バイナリ属性」を直接渡すことで、非常に大量のポイントを扱えます。
というのを、こちらの記事で知りました。

ScatterplotLayerに対して以下のような形で直接typed arrayとlengthを渡すことで、余計なループ処理を行わず直接GPUを使って描画できるようです。

data: {
  length,
  attributes: {
    getPosition: { value: positions, size: 3 },
    getFillColor: { value: colors, size: 4 }
  }
}

ということで、今回はこんなコードを書いてみて、通常の描画とバイナリ属性を利用した描画だとどのくらい処理速度が違うんだろうか、というのをみてみました!
(慣れてる都合上、ReactじゃなくてSvelteで書いています。)

<script lang="ts">
	import { onDestroy, onMount } from 'svelte';
	import { Deck, MapView } from '@deck.gl/core';
	import type { MapViewState } from '@deck.gl/core';
	import { ScatterplotLayer } from '@deck.gl/layers';

	type WorkerData = {
		length: number;
		positions: Float32Array;
	};

	let deckBinary: Deck | null = null;
	let deckObject: Deck | null = null;
	let worker: Worker | null = null;

	let timings = {
		binary: { buildMs: 0, renderMs: 0 },
		object: { buildMs: 0, renderMs: 0 }
	};
	let counts = { binary: 0, object: 0 };

	const initialViewState: MapViewState = {
		longitude: 139.76,
		latitude: 35.68,
		zoom: 9,
		pitch: 0,
		bearing: 0
	};

	const makeBinaryLayer = (data: WorkerData) =>
		new ScatterplotLayer({
			id: 'binary',
			data: {
				length: data.length,
				attributes: {
					getPosition: { value: data.positions, size: 3 }
				}
			},
			getFillColor: () => [180, 70, 255, 200],
			getRadius: 180,
			radiusUnits: 'meters',
			pickable: false,
			stroked: false
		});

	const makeObjectLayer = (data: { positions: Float32Array; length: number }) =>
		new ScatterplotLayer({
			id: 'object',
			data: data,
			getPosition: (_, { index, data, target }) => {
				const p = index * 3;
				target[0] = data.positions[p];
				target[1] = data.positions[p + 1];
				target[2] = data.positions[p + 2];
				return target;
			},
			getFillColor: () => [180, 70, 255, 200],
			getRadius: 180,
			radiusUnits: 'meters',
			pickable: false,
			stroked: false
		});

	onMount(() => {
		deckBinary = new Deck({
			views: [new MapView({ repeat: true })],
			initialViewState: initialViewState as any,
			controller: true,
			parent: document.getElementById('deck-binary') as HTMLDivElement
		});

		deckObject = new Deck({
			views: [new MapView({ repeat: true })],
			initialViewState: initialViewState as any,
			controller: true,
			parent: document.getElementById('deck-object') as HTMLDivElement
		});

		worker = new Worker(new URL('../../lib/parquet-worker.ts', import.meta.url), {
			type: 'module'
		});

		worker.onmessage = ({ data }: MessageEvent<WorkerData>) => {
			counts.binary = data.length;
			const startBinary = performance.now();
			let binaryFirstFrame = false;
			deckBinary?.setProps({
				layers: [makeBinaryLayer(data)],
				onAfterRender: () => {
					if (binaryFirstFrame) return;
					binaryFirstFrame = true;
					timings.binary.renderMs = performance.now() - startBinary;
				}
			});

			const accessorData = { positions: data.positions, length: data.length };
			timings.object.buildMs = 0;
			counts.object = data.length;
			const startObject = performance.now();
			let objectFirstFrame = false;
			deckObject?.setProps({
				layers: [makeObjectLayer(accessorData)],
				onAfterRender: () => {
					if (objectFirstFrame) return;
					objectFirstFrame = true;
					timings.object.renderMs = performance.now() - startObject;
				}
			});
		};

		worker.postMessage({ url: '/points_static.parquet' });
	});

	onDestroy(() => {
		worker?.terminate();
		deckBinary?.finalize();
		deckObject?.finalize();
	});
</script>

<svelte:head>
	<title>Parquetをバイナリ属性で表示</title>
</svelte:head>

<main class="min-h-screen space-y-4 p-4">
	<section class="space-y-1">
		<h1 class="text-xl font-semibold">Parquetをバイナリ属性で表示</h1>
		<p class="text-sm text-slate-500">
			1000万ポイントのParquetをバイナリ属性とaccessor利用で描画し、初回描画の経過時間を比較
		</p>
	</section>

	<section class="grid gap-4 md:grid-cols-2">
		<article class="space-y-2">
			<h2 class="font-semibold">A. バイナリ属性</h2>
			<div class="text-sm text-slate-600">
				<p>点数: {counts.binary.toLocaleString()}</p>
				<p>変換時間: {timings.binary.buildMs.toFixed(2)} ms</p>
				<p>初回描画まで: {timings.binary.renderMs.toFixed(2)} ms</p>
			</div>
			<div
				id="deck-binary"
				class="relative h-[65vh] w-full rounded-lg border border-slate-200 overflow-hidden"
			></div>
		</article>

		<article class="space-y-2">
			<h2 class="font-semibold">B. accessor利用</h2>
			<div class="text-sm text-slate-600">
				<p>点数: {counts.object.toLocaleString()}</p>
				<p>変換時間: {timings.object.buildMs.toFixed(2)} ms</p>
				<p>初回描画まで: {timings.object.renderMs.toFixed(2)} ms</p>
			</div>
			<div
				id="deck-object"
				class="relative h-[65vh] w-full rounded-lg border border-slate-200 overflow-hidden"
			></div>
		</article>
	</section>
</main>

accessorを利用しての描画方法は公式に良さげな実装方法があったのでそちらを利用しています。

const DATA = {src: binaryData, length: binaryData.length / 6}

const layer = new ScatterplotLayer({
  data: DATA,
  getPosition: (_, {index, data, target}) => {
    target[0] = data.src[index * 6];
    target[1] = data.src[index * 6 + 1];
    target[2] = 0;
    return target;
  },
  getRadius: (_, {index, data}) => {
    return data.src[index * 6 + 2];
  },
  getFillColor: (_, {index, data, target}) => {
    target[0] = data.src[index * 6 + 3];
    target[1] = data.src[index * 6 + 4];
    target[2] = data.src[index * 6 + 5];
    target[3] = 255;
    return target;
  }
})

結果的に、冒頭のような感じになりました!

image.png

A. バイナリ属性

  • 初回描画まで: 279.90 ms

B. accessor利用

  • 初回描画まで: 1235.50 ms

だいたい、1/5くらいの時間で描画できてそうですね!早い!

合計2000万ポイント描画されているわけで、「ほんまに…?ちゃんと全数描画されてる…?」とちょっと疑問に思っている部分もあり、要検証ではありますが、全数見えているのであればいづれにしろ超高速だし、描画後もサクサク動いているのでDeck.gl最高やなーと思いました。

ちなみに、今回はParquetを読み取って自前でTyped Arrayを作成してますが、以下の記事にあるように最初からArrowでデータを作成して、@geoarrow/deck.gl-layersを利用して描画することでもっと早くなるんだろうなーと思いました。

おわりに

ということでParquetに格納された1000万ポイントのデータを効率よく描画してみましたー!
「大規模データの描画 = とりあえずDeck.gl」で良いなーというのがよくわかったのでよかったです。
PointCloudLayerとの違いとか、移動体のアニメーションとかその辺りも今後やっていこうと思いますー!

5
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?