Skip to content

Commit b7c835d

Browse files
author
Davide Faconti
committed
updated plots
1 parent 8d6685d commit b7c835d

3 files changed

Lines changed: 262 additions & 0 deletions

File tree

compression_ratio.png

-17 Bytes
Loading

compression_time.png

612 Bytes
Loading

scripts/regenerate_readme_plots.py

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
#!/usr/bin/env python3
2+
"""Regenerate README compression-ratio + compression-time PNGs.
3+
4+
For each .mcap in DATA/:
5+
* ZSTD-only baseline -- ZSTD-3 compress the raw PointCloud2 .data payload
6+
in Python, accumulate compressed size and wall-clock time.
7+
* Cloudini-V5 + ZSTD -- parse `mcap_codec_benchmark --zstd --mode V5`
8+
output for total compressed size and encode throughput.
9+
10+
Outputs compression_ratio.png and compression_time.png at repo root.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
import re
16+
import subprocess
17+
import time
18+
from pathlib import Path
19+
20+
import matplotlib.pyplot as plt
21+
import numpy as np
22+
import zstandard as zstd
23+
from mcap.reader import make_reader
24+
from mcap_ros2.decoder import DecoderFactory
25+
26+
27+
REPO_ROOT = Path(__file__).resolve().parent.parent
28+
DATA_DIR = REPO_ROOT / "DATA"
29+
BENCH = REPO_ROOT / "build_release" / "tools" / "mcap_codec_benchmark"
30+
MAX_MESSAGES = 50 # per topic, matches a typical README sample run
31+
ZSTD_LEVEL = 3
32+
33+
34+
def discover_bags() -> list[Path]:
35+
bags = sorted(p for p in DATA_DIR.glob("*.mcap") if "_encoded" not in p.name)
36+
if not bags:
37+
raise SystemExit(f"No source mcaps in {DATA_DIR}")
38+
return bags
39+
40+
41+
def measure_zstd_only(bag: Path) -> dict:
42+
"""Encode + decode each PointCloud2 .data payload with ZSTD-3 in Python.
43+
44+
Returns totals across up to MAX_MESSAGES messages per topic.
45+
"""
46+
raw_total = 0
47+
comp_total = 0
48+
enc_elapsed = 0.0
49+
dec_elapsed = 0.0
50+
msg_total = 0
51+
per_topic_count: dict[str, int] = {}
52+
cctx = zstd.ZstdCompressor(level=ZSTD_LEVEL)
53+
dctx = zstd.ZstdDecompressor()
54+
55+
with open(bag, "rb") as f:
56+
reader = make_reader(f, decoder_factories=[DecoderFactory()])
57+
for schema, channel, _msg, ros_msg in reader.iter_decoded_messages():
58+
if not schema or "PointCloud2" not in schema.name:
59+
continue
60+
count = per_topic_count.get(channel.topic, 0)
61+
if count >= MAX_MESSAGES:
62+
continue
63+
per_topic_count[channel.topic] = count + 1
64+
msg_total += 1
65+
66+
payload = bytes(ros_msg.data)
67+
raw_total += len(payload)
68+
t0 = time.perf_counter()
69+
comp = cctx.compress(payload)
70+
enc_elapsed += time.perf_counter() - t0
71+
comp_total += len(comp)
72+
t1 = time.perf_counter()
73+
dctx.decompress(comp)
74+
dec_elapsed += time.perf_counter() - t1
75+
76+
return {
77+
"raw_bytes": raw_total,
78+
"comp_bytes": comp_total,
79+
"enc_s": enc_elapsed,
80+
"dec_s": dec_elapsed,
81+
"messages": msg_total,
82+
}
83+
84+
85+
_RE_V5 = re.compile(
86+
r"^\s*V5\s+([\d.]+)\s+([\d.]+)%\s+(\d+)\s+(\d+)\s*$", re.M
87+
)
88+
_RE_RAW = re.compile(r"raw=([\d.]+)\s*MiB")
89+
_RE_MSGS = re.compile(r"messages=(\d+)\s+points=")
90+
91+
92+
def measure_cloudini(bag: Path) -> dict[str, float]:
93+
"""Run mcap_codec_benchmark and parse the V5 row.
94+
95+
The tool prints one block per PointCloud2 topic. We sum across topics
96+
so multi-topic bags (e.g. nav_from_dock with 4 lidars) aggregate
97+
correctly.
98+
"""
99+
cmd = [
100+
str(BENCH), str(bag),
101+
"--max-messages", str(MAX_MESSAGES),
102+
"--zstd",
103+
]
104+
out = subprocess.run(cmd, capture_output=True, text=True, check=True).stdout
105+
106+
raw_mib_total = sum(float(m) for m in _RE_RAW.findall(out))
107+
if raw_mib_total == 0:
108+
raise RuntimeError(f"No PointCloud2 topic parsed from {bag.name}")
109+
110+
out_mib_total = 0.0
111+
enc_mb_s_weighted = 0.0 # weight by raw MiB so a topic with more data dominates
112+
raw_blocks = _RE_RAW.findall(out)
113+
v5_rows = _RE_V5.findall(out)
114+
if len(raw_blocks) != len(v5_rows):
115+
raise RuntimeError(f"Block/row mismatch parsing {bag.name}: "
116+
f"{len(raw_blocks)} raw / {len(v5_rows)} V5")
117+
for raw_str, (out_mib, _ratio, enc_mb_s, _dec_mb_s) in zip(raw_blocks, v5_rows):
118+
raw_mib = float(raw_str)
119+
out_mib_total += float(out_mib)
120+
enc_mb_s_weighted += float(enc_mb_s) * raw_mib
121+
122+
enc_mb_s_avg = enc_mb_s_weighted / raw_mib_total
123+
raw_bytes_total = int(raw_mib_total * (1 << 20))
124+
comp_bytes_total = int(out_mib_total * (1 << 20))
125+
# encode throughput is MB/s of raw input (decimal MB per the tool); convert
126+
# to wall seconds by dividing total raw decimal-MB by MB/s.
127+
elapsed = (raw_bytes_total / 1e6) / enc_mb_s_avg
128+
129+
# Decode side: weight Dec MB/s by raw MiB the same way as Enc.
130+
dec_mb_s_weighted = 0.0
131+
for raw_str, (_o, _r, _e, dec_mb_s) in zip(raw_blocks, v5_rows):
132+
dec_mb_s_weighted += float(dec_mb_s) * float(raw_str)
133+
dec_mb_s_avg = dec_mb_s_weighted / raw_mib_total
134+
dec_elapsed = (raw_bytes_total / 1e6) / dec_mb_s_avg
135+
136+
msg_total = sum(int(m) for m in _RE_MSGS.findall(out))
137+
return {
138+
"raw_bytes": raw_bytes_total,
139+
"comp_bytes": comp_bytes_total,
140+
"enc_s": elapsed,
141+
"dec_s": dec_elapsed,
142+
"messages": msg_total,
143+
}
144+
145+
146+
def main() -> None:
147+
if not BENCH.exists():
148+
raise SystemExit(f"{BENCH} not found - build cloudini first")
149+
150+
bags = discover_bags()
151+
rows = []
152+
for bag in bags:
153+
print(f"=== {bag.name} ===")
154+
z = measure_zstd_only(bag)
155+
c = measure_cloudini(bag)
156+
# Throughput in MB/s of *raw* input -- higher is better. Same denominator
157+
# convention as mcap_codec_benchmark so cross-checking lines up.
158+
z_enc_mbs = (z["raw_bytes"] / 1e6) / max(z["enc_s"], 1e-9)
159+
z_dec_mbs = (z["raw_bytes"] / 1e6) / max(z["dec_s"], 1e-9)
160+
c_enc_mbs = (c["raw_bytes"] / 1e6) / max(c["enc_s"], 1e-9)
161+
c_dec_mbs = (c["raw_bytes"] / 1e6) / max(c["dec_s"], 1e-9)
162+
rows.append({
163+
"bag": bag.stem,
164+
"raw_bytes_zstd": z["raw_bytes"],
165+
"zstd_only_bytes": z["comp_bytes"],
166+
"zstd_only_enc_mbs": z_enc_mbs,
167+
"zstd_only_dec_mbs": z_dec_mbs,
168+
"raw_bytes_cloudini": c["raw_bytes"],
169+
"cloudini_bytes": c["comp_bytes"],
170+
"cloudini_enc_mbs": c_enc_mbs,
171+
"cloudini_dec_mbs": c_dec_mbs,
172+
})
173+
print(f" ZSTD-only: {z['comp_bytes']/z['raw_bytes']:.1%} ratio, "
174+
f"enc {z_enc_mbs:.0f} MB/s / dec {z_dec_mbs:.0f} MB/s")
175+
print(f" Cloudini+ZSTD: {c['comp_bytes']/c['raw_bytes']:.1%} ratio, "
176+
f"enc {c_enc_mbs:.0f} MB/s / dec {c_dec_mbs:.0f} MB/s")
177+
178+
rows.sort(key=lambda r: r["zstd_only_bytes"] / r["raw_bytes_zstd"])
179+
print("\nOrdered by ZSTD-only ratio (ascending):")
180+
for i, r in enumerate(rows, 1):
181+
r["name"] = f"sample{i}"
182+
ratio = r["zstd_only_bytes"] / r["raw_bytes_zstd"]
183+
print(f" {r['name']:8s} <- {r['bag']:18s} ZSTD ratio {ratio:.1%}")
184+
185+
plot_ratio(rows)
186+
plot_time(rows)
187+
print("\nWrote compression_ratio.png and compression_time.png")
188+
189+
190+
def plot_ratio(rows: list[dict]) -> None:
191+
names = [r["name"] for r in rows]
192+
zstd_only = [r["zstd_only_bytes"] / r["raw_bytes_zstd"] for r in rows]
193+
cloudini = [r["cloudini_bytes"] / r["raw_bytes_cloudini"] for r in rows]
194+
original = [1.0] * len(rows)
195+
196+
fig, (ax_top, ax_bot) = plt.subplots(2, 1, figsize=(11, 6.5),
197+
gridspec_kw={"height_ratios": [1, 1]})
198+
199+
x = np.arange(len(names))
200+
w = 0.27
201+
202+
ax_top.bar(x - w, original, width=w, label="Original size", color="#f5c518")
203+
ax_top.bar(x, zstd_only, width=w, label="ZSTD only", color="#3870c1")
204+
ax_top.bar(x + w, cloudini, width=w, label="Cloudini+ZSTD", color="#d8362a")
205+
ax_top.set_xticks(x); ax_top.set_xticklabels(names)
206+
ax_top.set_ylim(0, 1.1)
207+
ax_top.set_yticks(np.arange(0, 1.01, 0.25))
208+
ax_top.legend(loc="upper center", ncol=3, frameon=False,
209+
bbox_to_anchor=(0.5, 1.12))
210+
ax_top.grid(axis="y", linestyle="-", linewidth=0.5, color="#dddddd")
211+
ax_top.set_axisbelow(True)
212+
ax_top.spines["top"].set_visible(False)
213+
ax_top.spines["right"].set_visible(False)
214+
215+
# zoomed view: just the two non-trivial bars
216+
w2 = 0.35
217+
ax_bot.bar(x - w2/2, zstd_only, width=w2, label="ZSTD only", color="#3870c1")
218+
ax_bot.bar(x + w2/2, cloudini, width=w2, label="Cloudini+ZSTD", color="#d8362a")
219+
ax_bot.set_xticks(x); ax_bot.set_xticklabels(names)
220+
ymax = max(max(zstd_only), max(cloudini)) * 1.25
221+
ax_bot.set_ylim(0, ymax)
222+
ax_bot.legend(loc="upper center", ncol=2, frameon=False,
223+
bbox_to_anchor=(0.5, 1.12))
224+
ax_bot.grid(axis="y", linestyle="-", linewidth=0.5, color="#dddddd")
225+
ax_bot.set_axisbelow(True)
226+
ax_bot.spines["top"].set_visible(False)
227+
ax_bot.spines["right"].set_visible(False)
228+
229+
fig.tight_layout()
230+
fig.savefig(REPO_ROOT / "compression_ratio.png", dpi=130,
231+
bbox_inches="tight", facecolor="white")
232+
plt.close(fig)
233+
234+
235+
def plot_time(rows: list[dict]) -> None:
236+
names = [r["name"] for r in rows]
237+
enc_zstd = [r["zstd_only_enc_mbs"] for r in rows]
238+
enc_cloud = [r["cloudini_enc_mbs"] for r in rows]
239+
240+
fig, ax = plt.subplots(figsize=(11, 3.8))
241+
x = np.arange(len(names))
242+
w = 0.35
243+
ax.bar(x - w/2, enc_zstd, width=w, label="ZSTD only", color="#3870c1")
244+
ax.bar(x + w/2, enc_cloud, width=w, label="Cloudini+ZSTD", color="#d8362a")
245+
ax.set_xticks(x); ax.set_xticklabels(names)
246+
ax.set_title("Compression throughput (MB/s, higher is better)",
247+
loc="left", fontsize=11, color="#444")
248+
ax.legend(loc="upper center", ncol=2, frameon=False,
249+
bbox_to_anchor=(0.5, 1.18))
250+
ax.grid(axis="y", linestyle="-", linewidth=0.5, color="#dddddd")
251+
ax.set_axisbelow(True)
252+
ax.spines["top"].set_visible(False)
253+
ax.spines["right"].set_visible(False)
254+
255+
fig.tight_layout()
256+
fig.savefig(REPO_ROOT / "compression_time.png", dpi=130,
257+
bbox_inches="tight", facecolor="white")
258+
plt.close(fig)
259+
260+
261+
if __name__ == "__main__":
262+
main()

0 commit comments

Comments
 (0)