File size: 15,286 Bytes
ceea884
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
"""๋…ผ๋ฌธ์‹ ์•„ํ‚คํ…์ฒ˜ ๋„์‹ ๋‘ ์žฅ โ€” make_panels.py ์˜ ์‹ค์ œ ๋ฐ์ดํ„ฐ ํŒจ๋„์„ ํ•ฉ์นœ๋‹ค.

fig_viewpoint_model.png : ๋ฉ€ํ‹ฐ๋ทฐ ์‹œ์  ๋ณ€ํ™˜ ๋ชจ๋ธ (๊ตฌ๊ฐ„๋ณ„ 3DGS ํ•™์Šต โ†’ ๋ฆฌ๊ทธ ๋ณ€ํ™˜ ๋ Œ๋”)
fig_engine_pipeline.png : Vulkan ์‹œ๋ฎฌ๋ ˆ์ด์…˜ ์—”์ง„ (CPU ์‹œ๋ฎฌ๋ ˆ์ด์…˜ ๋ฃจํ”„ + GPU ๋ Œ๋” ๊ทธ๋ž˜ํ”„)
์ฐธ๊ณ ํ•œ ๋„์‹ ํ˜•์‹: 3DGS (Kerbl 2023) ์ตœ์ ํ™” ํŒŒ์ดํ”„๋ผ์ธ, HUGSIM ยท OmniRe ๊ฐœ์š”๋„ โ€” ๋‹จ๊ณ„๋งˆ๋‹ค ์‹ค์ œ ๋ฐ์ดํ„ฐ ๊ทธ๋ฆผ, ๋ฐ์ดํ„ฐ ํ๋ฆ„ ํ™”์‚ดํ‘œ,
gradient ๋˜๋Œ๋ฆผ ํ™”์‚ดํ‘œ.
"""

import json
import os
import sys
from pathlib import Path

import matplotlib

matplotlib.use("Agg")
import matplotlib.pyplot as plt  # noqa: E402
from matplotlib.patches import Circle, FancyArrowPatch, FancyBboxPatch, Polygon, Rectangle  # noqa: E402
from PIL import Image  # noqa: E402

P = Path(sys.argv[1])  # ํŒจ๋„
OUT = Path(sys.argv[2])
EXTRA = Path(sys.argv[3])  # sensors_sim.png, drive_6cam_still.jpg ๊ฐ€ ์žˆ๋Š” ๊ณณ
ROOT = Path(__file__).resolve().parents[3]
plt.rcParams["font.family"] = ["NanumBarunGothic", "DejaVu Sans"]  # ์ˆ˜์‹ ๊ธฐํ˜ธ(รŽ, แต€, ์•„๋ž˜์ฒจ์ž, โˆ’)๋Š” DejaVu ๋กœ
plt.rcParams["axes.unicode_minus"] = False
NAVY, BLUE, LIGHT, PALE, GRAY, ORANGE, GREEN = "#1F3B70", "#4472C4", "#DCE6F4", "#F4F7FB", "#404040", "#ED7D31", "#5B9B4C"


class Canvas:
    def __init__(self, w, h):
        self.W, self.H = w, h
        self.fig = plt.figure(figsize=(w, h), facecolor="white")

    def ax(self, x, y, w, h):  # ์œ„์ชฝ ๊ธฐ์ค€ (y = ์œ„์—์„œ inch)
        a = self.fig.add_axes([x / self.W, 1 - (y + h) / self.H, w / self.W, h / self.H])
        a.axis("off")
        return a

    def img(self, path, x, y, w, h, border=NAVY, lw=1.2, title=None, tsize=11, crop=None):
        im = Image.open(path).convert("RGB")
        if crop:
            im = im.crop(crop)
        a = self.ax(x, y, w, h)
        a.imshow(im, aspect="auto")
        a.set_xlim(0, im.width)
        a.set_ylim(im.height, 0)
        if border:
            self.rect(x, y, w, h, fc="none", ec=border, lw=lw, r=0.0)
        if title:
            self.text(x + w / 2, y - 0.12, title, size=tsize, bold=True, color=NAVY)
        return a

    def _fig_xy(self, x, y):
        return x / self.W, 1 - y / self.H

    def rect(self, x, y, w, h, fc=PALE, ec=LIGHT, lw=1.0, r=0.08, ls="-", z=0):
        bx = FancyBboxPatch(self._fig_xy(x, y + h), w / self.W, h / self.H,
                            boxstyle=f"round,pad=0,rounding_size={r / self.W}" if r else "square,pad=0",
                            transform=self.fig.transFigure, fc=fc, ec=ec, lw=lw, ls=ls, zorder=z)
        self.fig.add_artist(bx)
        return bx

    def text(self, x, y, s, size=11, bold=False, color=GRAY, ha="center", va="center", style="normal", z=8):
        self.fig.text(*self._fig_xy(x, y), s, fontsize=size, fontweight="bold" if bold else "normal", color=color,
                      ha=ha, va=va, fontstyle=style, zorder=z)

    def arrow(self, x0, y0, x1, y1, color=BLUE, lw=2.2, ls="-", rad=0.0, head=14):
        a = FancyArrowPatch(self._fig_xy(x0, y0), self._fig_xy(x1, y1), transform=self.fig.transFigure,
                            arrowstyle=f"-|>,head_length={head / 20},head_width={head / 34}", color=color, lw=lw,
                            ls=ls, connectionstyle=f"arc3,rad={rad}", mutation_scale=10, zorder=5)
        self.fig.add_artist(a)

    def chip(self, x, y, s, fc=NAVY, color="white", size=10, w=None, h=0.3):
        w = w or (0.085 * len(s) * size / 10 + 0.22)
        self.rect(x - w / 2, y - h / 2, w, h, fc=fc, ec="none", r=0.12, z=6)
        self.text(x, y, s, size=size, bold=True, color=color)

    def save(self, path):
        self.fig.savefig(path, dpi=220, facecolor="white")
        plt.close(self.fig)


def split(path, h_in, cut_in):
    """(a) ยท (b) ๋ ๋ฅผ ๋”ฐ๋กœ ์ €์žฅ โ€” ์Šฌ๋ผ์ด๋“œ์—์„œ ํฌ๊ฒŒ ๋ณด์ด๊ฒŒ."""
    im = Image.open(path)
    y = int(im.height * cut_in / h_in)
    im.crop((0, 0, im.width, y)).save(str(path).replace(".png", "_a.png"))
    im.crop((0, y, im.width, im.height)).save(str(path).replace(".png", "_b.png"))


def stack(c, paths, x, y, w, h, dx=0.16, dy=0.16):
    """๊ฒน์นœ ์‚ฌ์ง„ ๋ฌถ์Œ (๋ฉ€ํ‹ฐ๋ทฐ ์ž…๋ ฅ)."""
    for i, p in enumerate(paths):
        c.img(p, x + i * dx, y + i * dy, w, h, border="white", lw=1.5)
        c.rect(x + i * dx, y + i * dy, w, h, fc="none", ec=NAVY, lw=0.8, r=0)


# ================================================================== ๊ทธ๋ฆผ 1: ๋ฉ€ํ‹ฐ๋ทฐ ์‹œ์  ๋ณ€ํ™˜ ๋ชจ๋ธ
c = Canvas(16, 8.6)
c.text(0.25, 0.3, "(a) ๊ตฌ๊ฐ„๋ณ„ 3DGS ์žฅ๋ฉด ํ•™์Šต โ€” CUDA ยท gsplat", size=15, bold=True, color=NAVY, ha="left")
c.rect(0.15, 0.5, 15.7, 4.55, fc="#FAFBFD", ec=LIGHT, lw=1.2, r=0.12)
# ์ž…๋ ฅ
stack(c, [P / "gt_CAM_FRONT_LEFT.jpg", P / "gt_CAM_FRONT_RIGHT.jpg", P / "gt_CAM_FRONT.jpg"], 0.35, 0.95, 2.2, 1.24)
c.text(1.5, 2.65, "๋ฉ€ํ‹ฐ๋ทฐ ์‚ฌ์ง„ (6์บ , 800ร—450)", size=11, bold=True, color=NAVY)
c.img(P / "lidar_init.png", 0.35, 2.95, 2.55, 1.43)
c.text(1.62, 4.55, "์ •์  ๋ผ์ด๋‹ค ๋ˆ„์  (์•ฝ 400 sweep)", size=11, bold=True, color=NAVY)
c.text(1.62, 4.82, "์ฐจ๋Ÿ‰ ยท ์‚ฌ๋žŒ ๋ฐ•์Šค ์•ˆ ์  ์ œ๊ฑฐ", size=9.5, color=GRAY)
c.arrow(2.95, 3.65, 3.45, 2.7, color=GRAY, lw=1.6)
c.chip(3.0, 3.1, "์ดˆ๊ธฐํ™”", fc=GRAY, size=9)
# 3D ๊ฐ€์šฐ์‹œ์•ˆ
c.img(P / "gaussians_3d.png", 3.5, 1.05, 3.0, 1.69, title="3D ๊ฐ€์šฐ์‹œ์•ˆ ์žฅ๋ฉด G")
c.text(5.0, 3.0, "G = { ฮผ, ฮฃ = RSSแต€Rแต€, ฮฑ, SH (3์ฐจ) } ร— โ‰ค 200๋งŒ", size=10, color=GRAY)
c.text(5.0, 3.3, "MCMC: ๋‚ฎ์€ ฮฑ ์žฌ๋ฐฐ์น˜ + ์œ„์น˜ ์žก์Œ", size=10, color=GRAY)
c.arrow(6.55, 1.9, 7.25, 1.9)
c.chip(6.9, 1.6, r"w2cยท$\Delta$, K", fc=BLUE, size=8.5, w=0.95)
# ๋ฏธ๋ถ„ ๋ž˜์Šคํ„ฐ๋ผ์ด์ €
c.img(P / "splats_tiles.png", 7.3, 1.05, 3.0, 1.69, title="๋ฏธ๋ถ„ ๋ž˜์Šคํ„ฐ๋ผ์ด์ €")
c.text(8.8, 3.0, "ํˆฌ์˜  ฮฃโ€ฒ = J W ฮฃ Wแต€ Jแต€", size=10, color=GRAY)
c.text(8.8, 3.3, "16ร—16 ํƒ€์ผ ๊นŠ์ด ์ •๋ ฌ โ†’ ฮฑ ๋ธ”๋ Œ๋”ฉ", size=10, color=GRAY)
c.text(8.8, 3.6, "C = ฮฃ cแตข ฮฑแตข ฮ  (1 โˆ’ ฮฑโฑผ)", size=10, color=GRAY)
c.arrow(10.35, 1.9, 10.85, 1.9)
# ๋ Œ๋” ์ถœ๋ ฅ
c.img(P / "render_front.jpg", 10.9, 0.95, 2.1, 1.18, title="๋ Œ๋”  รŽ + (1โˆ’ฮฑ)ยทS(d)")
c.img(P / "render_depth.png", 10.9, 2.45, 2.1, 1.18)
c.text(11.95, 3.83, "๊ธฐ๋Œ€ ๊นŠ์ด D", size=10.5, bold=True, color=NAVY)
c.arrow(13.05, 1.55, 13.45, 1.55)
c.arrow(13.05, 3.05, 13.45, 3.05)
# ๊ฐ๋… ์‹ ํ˜ธ + ์†์‹ค
c.img(P / "masks.png", 13.5, 0.95, 2.1, 1.18, title="์‹ค์ œ ์‚ฌ์ง„ ยท ๊ฐ€๋ฆผ ๋งˆ์Šคํฌ")
c.img(P / "lidar_depth.png", 13.5, 2.45, 2.1, 1.18)
c.text(14.55, 3.83, "๋ผ์ด๋‹ค ๊นŠ์ด D_lidar", size=10.5, bold=True, color=NAVY)
c.rect(9.0, 4.15, 6.65, 0.62, fc=NAVY, ec="none", r=0.1)
c.text(12.32, 4.46, "L = 0.8ยทL1 + 0.2ยทD-SSIM + 0.1ยทL_depth + 0.05ยท(L_sky + L_surf) + 0.01ยท(แพฑ + sฬ„)", size=10.5, bold=True, color="white")
# gradient ๋˜๋Œ๋ฆผ
c.arrow(9.0, 4.46, 5.0, 3.55, color=ORANGE, lw=2.2, ls="--", rad=-0.08)
c.text(6.7, 4.55, r"$\nabla$L (Adam) โ†’ G ยท $\Delta$ (์นด๋ฉ”๋ผ๋ณ„ SE3) ยท ์ƒ‰ ์•„ํ•€ ยท ํ•˜๋Š˜ ํ๋ธŒ๋งต", size=10.5, bold=True, color=ORANGE)
c.text(14.55, 4.95, "๋นจ๊ฐ•: ์ฐจ๋Ÿ‰ยท์‚ฌ๋žŒ (์†์‹ค ์ œ์™ธ)   ํŒŒ๋ž‘: ํ•˜๋Š˜ (ฮฑ โ†’ 0)", size=9, color=GRAY)

# (b) ์‹œ์  ๋ณ€ํ™˜ ๋ Œ๋”
y0 = 5.35
c.text(0.25, y0, "(b) ๋ฉ€ํ‹ฐ๋ทฐ ์‹œ์  ๋ณ€ํ™˜ ๋ Œ๋” โ€” Vulkan ์˜คํ”„์Šคํฌ๋ฆฐ", size=15, bold=True, color=NAVY, ha="left")
c.rect(0.15, y0 + 0.2, 15.7, 3.15, fc="#FFF9F4", ec="#F6D3B8", lw=1.2, r=0.12)
c.img(P / "rig_transform.png", 0.45, y0 + 0.5, 2.25, 2.46, border=None)
c.text(1.57, y0 + 3.08, "๋ฆฌ๊ทธ ๋ณ€ํ˜• T (6์บ  ๊ณตํ†ต)", size=11, bold=True, color=NAVY)
c.text(3.75, y0 + 1.25, "Rโ€ฒ = Rz(yaw)ยทRยทRx(โˆ’pitch)ยทRz(roll)", size=10, color=GRAY)
c.text(3.75, y0 + 1.55, "tโ€ฒ = t + shift   (cam โ†’ ego)", size=10, color=GRAY)
c.text(3.75, y0 + 1.85, "์ž์ฐจ ๊ถค์  ยท ์‹œ๊ฐ์€ ๊ทธ๋Œ€๋กœ", size=10, color=GRAY)
c.arrow(2.75, y0 + 2.2, 4.6, y0 + 2.2, color=ORANGE)
c.img(P / "segments_bev.png", 4.7, y0 + 0.45, 2.3, 2.3, border=None)
c.text(5.85, y0 + 3.0, "ํ”„๋ ˆ์ž„๋งˆ๋‹ค ๋‹ด๋‹น ๊ตฌ๊ฐ„ ๋ชจ๋ธ", size=11, bold=True, color=NAVY)
c.arrow(7.05, y0 + 1.6, 7.55, y0 + 1.6, color=ORANGE)
lay = [["CAM_FRONT_LEFT", "CAM_FRONT", "CAM_FRONT_RIGHT"], ["CAM_BACK_RIGHT", "CAM_BACK", "CAM_BACK_LEFT"]]
for r, row in enumerate(lay):
    for k, cam in enumerate(row):
        c.img(P / f"render_{cam}.jpg", 7.6 + k * 1.22, y0 + 0.55 + r * 0.7, 1.18, 0.66, border="white", lw=0.5)
c.text(9.4, y0 + 2.15, "6์บ  ๋™์‹œ ๋ Œ๋” (์ƒ‰ ๋ณด์ • ์—†์Œ = ํ‰๊ท  ์นด๋ฉ”๋ผ ์ƒ‰)", size=10.5, bold=True, color=NAVY)
c.arrow(11.3, y0 + 1.25, 11.8, y0 + 1.25, color=ORANGE)
# ๋ณ€ํ˜• 9์ข… (๊ฐ™์€ ์”ฌ ยท ์•ž ์นด๋ฉ”๋ผ)
var = ["left_1m", "left_0.5m", "right_0.5m", "right_1m", "high_0.4m", "low_0.3m", "pitch_down_3", "yaw_left_4", "yaw_right_4"]
meta = json.load(open(ROOT / "outputs/aug/map/scene-0402/left_1m/meta.json"))
fs = [s for s in meta["samples"] if s["cam"] == "CAM_FRONT"]
smp = fs[len(fs) * 3 // 8]
name = os.path.basename(smp["original_filename"])
for i, v in enumerate(var):
    r, k = divmod(i, 3)
    c.img(ROOT / f"outputs/aug/map/scene-0402/{v}/CAM_FRONT/{name}", 11.9 + k * 1.3, y0 + 0.42 + r * 0.8, 1.26, 0.71, border="white", lw=0.5)
    c.text(11.9 + k * 1.3 + 0.63, y0 + 0.42 + r * 0.8 + 0.62, v, size=7.5, bold=True, color="white")
c.text(13.85, y0 + 2.92, "์ƒˆ ์‹œ์  6์บ  ร— 9์ข… + meta.json (์ƒˆ calibrated_sensor)", size=10.5, bold=True, color=NAVY)
c.save(OUT / "fig_viewpoint_model.png")
split(OUT / "fig_viewpoint_model.png", 8.6, 5.15)

# ================================================================== ๊ทธ๋ฆผ 2: Vulkan ์‹œ๋ฎฌ๋ ˆ์ด์…˜ ์—”์ง„
c = Canvas(16, 8.45)
c.text(0.25, 0.3, "(a) ์‹œ๋ฎฌ๋ ˆ์ด์…˜ ๋ฃจํ”„ โ€” CPU, ๋งค ํ”„๋ ˆ์ž„ (closed-loop)", size=15, bold=True, color=NAVY, ha="left")
c.rect(0.15, 0.5, 15.7, 3.25, fc="#FAFBFD", ec=LIGHT, lw=1.2, r=0.12)
xs = [0.45, 3.0, 5.55, 8.1, 10.65, 13.2]
yt, bw, bh = 0.95, 2.2, 1.6
titles = ["โ‘  ์ œ์–ด ์ž…๋ ฅ", "โ‘ก ์ž์ฐจ ๋™์—ญํ•™", "โ‘ข ์ถฉ๋Œ ํŒ์ •", "โ‘ฃ ๊ตฌ๊ฐ„ ์ŠคํŠธ๋ฆฌ๋ฐ", "โ‘ค ์„ผ์„œ ๋ ˆ์ด์บ์ŠคํŠธ", "โ‘ฅ ๊ธฐ๋ก ยท ๊ด€์ธก"]
subs = ["ํ‚ค๋ณด๋“œ ยท ์Šคํฌ๋ฆฝํŠธ (50 ms)\nVLA ๊ฐ€ ๋“ค์–ด์˜ฌ ์ž๋ฆฌ", "ํ‚ค๋„ค๋งˆํ‹ฑ ์ž์ „๊ฑฐ ๋ชจ๋ธ\n10 ms ํ•˜์œ„ ์Šคํ…", "์žฅ์• ๋ฌผ ๊ฒฉ์ž 0.2 m ยท ์—ฐ์„\n์Šค์นจ 2์  ยท ํƒˆ์ถœ ํŒ์ •",
        "๊ฐ€๊นŒ์šด ๋‹ด๋‹น ๊ถค์  ยท 3 m ํžˆ์Šคํ…Œ๋ฆฌ์‹œ์Šค\n์ด์›ƒ 3๊ตฌ๊ฐ„ ๋น„๋™๊ธฐ ๋กœ๋“œ", "๋…ธ๋ฉด ์ •์ฐฉ โ†’ ๋ผ์ด๋‹ค 32ch ยท ๋ ˆ์ด๋” 5๋Œ€\n๋ณต์…€ ์›”๋“œ DDA (4 ms)", "drive_log.csv ยท 6์บ  PNG\n๋ผ์ด๋‹ค .bin ยท ๋ ˆ์ด๋” .txt"]
for i, x in enumerate(xs):
    c.rect(x, yt, bw, bh, fc="white", ec=LIGHT, lw=1.0, r=0.08)
    c.text(x + bw / 2, yt + bh + 0.22, titles[i], size=11.5, bold=True, color=NAVY)
    c.text(x + bw / 2, yt + bh + 0.62, subs[i], size=9, color=GRAY)
    if i < 5:
        c.arrow(x + bw + 0.03, yt + bh / 2, xs[i + 1] - 0.03, yt + bh / 2, lw=2)
# โ‘  ์šด์ „๋Œ€ ์•„์ด์ฝ˜
a = c.ax(xs[0], yt, bw, bh)
a.set_xlim(-1.4, 1.4)
a.set_ylim(-1.0, 1.0)
a.set_aspect("equal")
a.add_patch(Circle((0, 0), 0.75, fill=False, lw=6, color=NAVY))
a.add_patch(Circle((0, 0), 0.18, color=NAVY))
for ang in (90, 210, 330):
    import numpy as np

    a.plot([0, 0.72 * np.cos(np.radians(ang))], [0, 0.72 * np.sin(np.radians(ang))], color=NAVY, lw=5)
a.text(0, -0.93, "throttle ยท brake ยท steer", ha="center", fontsize=8.5, color=GRAY)
# โ‘ก ์ž์ „๊ฑฐ ๋ชจ๋ธ ๊ทธ๋ฆผ
a = c.ax(xs[1], yt, bw, bh)
a.set_xlim(-1.6, 1.6)
a.set_ylim(-1.15, 1.15)
a.set_aspect("equal")
a.add_patch(Rectangle((-0.45, -0.95), 0.9, 1.9, fc=LIGHT, ec=NAVY, lw=1.5))
for wx, wy, rot in ((-0.45, -0.6, 0), (0.45, -0.6, 0), (-0.45, 0.6, 18), (0.45, 0.6, 18)):
    t = matplotlib.transforms.Affine2D().rotate_deg_around(wx, wy, rot) + a.transData
    a.add_patch(Rectangle((wx - 0.08, wy - 0.22), 0.16, 0.44, fc=NAVY, ec="none", transform=t))
a.plot([0, 0], [-0.6, 0.6], color=ORANGE, lw=2)
a.annotate("", xy=(0.55, 1.05), xytext=(0, 0.6), arrowprops=dict(arrowstyle="->", color=ORANGE, lw=1.8))
a.text(0.75, 0.95, "ฮด", fontsize=13, color=ORANGE, fontweight="bold")
a.text(0.12, 0.0, "L", fontsize=12, color=ORANGE, fontweight="bold")
a.text(-1.5, -1.1, "แบaw = vยทtan ฮด / L", fontsize=9, color=GRAY)
c.img(P / "obstacles_crop.png", xs[2] + 0.3, yt + 0.02, 1.6, 1.56, border=None)
c.img(P / "segments_bev.png", xs[3] + 0.3, yt + 0.02, 1.6, 1.56, border=None)
c.img(EXTRA / "sensors_sim.png", xs[4] + 0.05, yt + 0.03, 2.1, 1.54, border=None)
c.img(P / "drive_log.png", xs[5] + 0.05, yt + 0.2, 2.1, 1.25, border=None)
c.arrow(xs[5] + bw / 2, yt - 0.02, xs[0] + bw / 2, yt - 0.02, color=ORANGE, lw=2, ls="--", rad=0.06)
c.text(7.6, 0.72, "๋‹ค์Œ ํ”„๋ ˆ์ž„์˜ ๊ด€์ธก โ†’ ๋‹ค์‹œ โ‘  (๋ชจ๋ธ์˜ ์ œ์–ด๊ฐ€ ๋‹ค์Œ ๊ด€์ธก์„ ๋ฐ”๊พผ๋‹ค)", size=10, bold=True, color=ORANGE)

# (b) GPU ๋ Œ๋” ๊ทธ๋ž˜ํ”„
y0 = 4.05
c.text(0.25, y0, "(b) ๋ Œ๋” ๊ทธ๋ž˜ํ”„ โ€” GPU, Vulkan (configs/render_graph.json, ๋ฐฐ๋ฆฌ์–ด ์ž๋™)", size=15, bold=True, color=NAVY, ha="left")
c.rect(0.15, y0 + 0.2, 15.7, 3.95, fc="#F7F9FD", ec=LIGHT, lw=1.2, r=0.12)
c.arrow(13.2, 3.8, 13.2, y0 + 0.62, color=GRAY, lw=1.8)
c.text(13.05, y0 + 0.42, "CPU โ†’ GPU: ์นด๋ฉ”๋ผ UBO ยท ๋™์  ์„  (์„ผ์„œ ์  ยท ์ž์ฐจ ๋ฐ•์Šค) ยท ๊ตฌ๊ฐ„ ๊ต์ฒด", size=9.5, color=GRAY, ha="right")
bufs = [("gaussians", P / "gaussians_3d.png", "๊ฐ€์šฐ์‹œ์•ˆ SSBO\n(๊ตฌ๊ฐ„ ๋ชจ๋ธ, โ‰ค 200๋งŒ)"),
        ("splats ยท keys", P / "splats.png", "ํ™”๋ฉด ํƒ€์› ยท SH ์ƒ‰\n๋ณด์ด๋Š” ๊ฒƒ๋งŒ ์••์ถ•"),
        ("keys (์ •๋ ฌ)", P / "splats_sorted.png", "๊นŠ์ด ์ˆœ์„œ\n(์ƒ‰ = ์ •๋ ฌ ์ˆœ์œ„)"),
        ("background", P / "sky_panorama.jpg", "ํ•™์Šต ํ•˜๋Š˜ ํ๋ธŒ๋งต\nยท IBL"),
        ("color ยท depth_acc", P / "render_front.jpg", "RGBA16F ์ƒ‰\n+ rg32f ๊ฑฐ๋ฆฌ ๋ˆ„์ "),
        ("post โ†’ ํ™”๋ฉด", EXTRA / "drive_6cam_still.jpg", "6์บ  ยท 3์ธ์นญ ยท BEV\nPNG ์“ฐ๊ธฐ ์Šค๋ ˆ๋“œ")]
passes = [("preprocess", "compute", "2.0 ms"), ("sort", "radix 8bitร—4", "0.44 ms"), ("background", "raster", ""),
          ("scene", "raster (๊ฐ„์ ‘)", "4.4 ms"), ("weather ยท fxaa", "compute", "")]
bx0, bw2, bh2, gap = 0.35, 1.8, 1.02, 0.92
for i, (nm, path, sub) in enumerate(bufs):
    x = bx0 + i * (bw2 + gap)
    c.img(path, x, y0 + 0.95, bw2, bh2, title=nm, tsize=10.5)
    c.text(x + bw2 / 2, y0 + 2.35, sub, size=9, color=GRAY)
    if i == 4:
        c.img(P / "render_depth.png", x, y0 + 2.75, bw2, bh2)
        c.text(x + bw2 / 2, y0 + 3.95, "depth_acc (rg32f ๊ฑฐ๋ฆฌ)", size=8.5, color=GRAY)
    if i < 5:
        pn, kind, tms = passes[i]
        cx = x + bw2 + gap / 2
        c.arrow(x + bw2 + 0.04, y0 + 1.5, x + bw2 + gap - 0.04, y0 + 1.5, color=NAVY, lw=2)
        c.chip(cx, y0 + 1.2, pn, fc=NAVY, size=8, w=gap + 0.06, h=0.26)
        c.text(cx, y0 + 1.85, kind, size=8, bold=True, color=ORANGE)
        if tms:
            c.text(cx, y0 + 2.07, tms, size=8, color=GRAY)
c.rect(0.4, y0 + 2.85, 9.9, 1.1, fc="white", ec=LIGHT, lw=1, r=0.08)
c.text(0.6, y0 + 3.1, "bindless โ€” ์…ฐ์ด๋”๊ฐ€ ์Šฌ๋กฏ ๋ฒˆํ˜ธ๋กœ SSBO ยท ์ด๋ฏธ์ง€๋ฅผ ๊ณ ๋ฅธ๋‹ค (์ƒˆ ํŒจ์Šค์— ๋””์Šคํฌ๋ฆฝํ„ฐ ์ฝ”๋“œ ๋ถˆํ•„์š”)", size=9.5, color=GRAY, ha="left")
c.text(0.6, y0 + 3.4, "๋ Œ๋” ๊ทธ๋ž˜ํ”„ โ€” ํŒจ์Šค๋งˆ๋‹ค uses (์ž์› ยท ์šฉ๋„) ์„ ์–ธ โ†’ ๋ฐฐ๋ฆฌ์–ด ยท ๋ ˆ์ด์•„์›ƒ ์ „ํ™˜ ์ž๋™ ์‚ฝ์ž…", size=9.5, color=GRAY, ha="left")
c.text(0.6, y0 + 3.7, "์…ฐ์ด๋” ๊ทธ๋ž˜ํ”„ โ€” ํ›„์ฒ˜๋ฆฌ ๋…ธ๋“œ (JSON) โ†’ GLSL ์ƒ์„ฑ ยท ๋ฆฌํ”Œ๋ ‰์…˜ ยท Tracy ํŒจ์Šค๋ณ„ GPU ์‹œ๊ฐ„", size=9.5, color=GRAY, ha="left")
c.save(OUT / "fig_engine_pipeline.png")
split(OUT / "fig_engine_pipeline.png", 8.45, 3.79)
print("saved", OUT)