|
| 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