diff --git a/docs/fern/components/BlogLanding.tsx b/docs/fern/components/BlogLanding.tsx
index b5f8a1b7cf95..9cd3a972a802 100644
--- a/docs/fern/components/BlogLanding.tsx
+++ b/docs/fern/components/BlogLanding.tsx
@@ -4,6 +4,16 @@
*/
const ARTICLES = [
+ {
+ title: "Faster LLM Inference Recovery with Dynamo Shadow Engines",
+ description:
+ "A pre-initialized shadow engine shares GPU-resident weights with the active one and takes over in seconds, turning a three-node cascade from a 150-second outage into 17 failed requests.",
+ href: "/dynamo/dev/digest/dynamo-bulwark",
+ date: "August 13, 2026",
+ readTime: "11 min read",
+ category: "Engineering",
+ art: "bulwark",
+ },
{
title: "DynoSim: Simulating the Pareto Frontier",
description:
diff --git a/docs/fern/components/BlogStyles.tsx b/docs/fern/components/BlogStyles.tsx
index b223f6814a8a..a765cace3409 100644
--- a/docs/fern/components/BlogStyles.tsx
+++ b/docs/fern/components/BlogStyles.tsx
@@ -179,6 +179,8 @@ article:has(.dynamo-blog-home) > header {
.dynamo-blog-art--indexer { background: linear-gradient(145deg, #25120b, #a43d13 52%, #180903); }
+.dynamo-blog-art--bulwark { background: linear-gradient(145deg, #10200a, #4c7a12 52%, #070f04); }
+
.dynamo-blog-art__grid {
position: absolute;
opacity: 0.72;
@@ -585,6 +587,9 @@ article:has(.dynamo-blog-article) pre {
}
/* Date-first archive labels, inspired by editorial blog indexes. */
+body:has(.dynamo-blog-home) #fern-sidebar .fern-sidebar-group-level-1 a[href$="/dynamo-bulwark"]::before,
+body:has(.dynamo-blog-article) #fern-sidebar .fern-sidebar-group-level-1 a[href$="/dynamo-bulwark"]::before { content: "AUG 13"; }
+
body:has(.dynamo-blog-home) #fern-sidebar .fern-sidebar-group-level-1 a[href$="/dynosim-pareto-frontier"]::before,
body:has(.dynamo-blog-article) #fern-sidebar .fern-sidebar-group-level-1 a[href$="/dynosim-pareto-frontier"]::before { content: "MAY 29"; }
@@ -960,6 +965,9 @@ body:has(.dynamo-blog-article) #fern-sidebar a[href*="/digest/"]::before {
content: "" !important;
}
+body:has(.dynamo-blog-home) #fern-sidebar a[href$="/dynamo-bulwark"] .fern-sidebar-link-title-inner::before,
+body:has(.dynamo-blog-article) #fern-sidebar a[href$="/dynamo-bulwark"] .fern-sidebar-link-title-inner::before { content: "AUG 13"; }
+
body:has(.dynamo-blog-home) #fern-sidebar a[href$="/dynosim-pareto-frontier"] .fern-sidebar-link-title-inner::before,
body:has(.dynamo-blog-article) #fern-sidebar a[href$="/dynosim-pareto-frontier"] .fern-sidebar-link-title-inner::before { content: "MAY 29"; }
diff --git a/docs/fern/index.yml b/docs/fern/index.yml
index ffb4b21fe2ee..bcca28d3427c 100644
--- a/docs/fern/index.yml
+++ b/docs/fern/index.yml
@@ -1193,6 +1193,9 @@ navigation:
contents:
# Keep this archive in reverse chronological order. Publication dates are
# rendered in each article header and on the custom landing page.
+ - page: "Dynamo Bulwark"
+ path: pages/blog/2026/dynamo-bulwark.mdx
+ slug: dynamo-bulwark
- page: "DynoSim Pareto Frontier"
path: pages/blog/2026/dynosim-pareto-frontier.mdx
slug: dynosim-pareto-frontier
diff --git a/docs/fern/pages/blog/2026/dynamo-bulwark.mdx b/docs/fern/pages/blog/2026/dynamo-bulwark.mdx
new file mode 100644
index 000000000000..cf8cf3ca8ed6
--- /dev/null
+++ b/docs/fern/pages/blog/2026/dynamo-bulwark.mdx
@@ -0,0 +1,243 @@
+---
+# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+title: "Faster LLM Inference Recovery with Dynamo Shadow Engines"
+sidebar-title: Dynamo Shadow Engines
+subtitle: "Mohammed Abdulwahhab, Schwinn Saereesitthipitak, [Vikram Sharma Mailthody](https://developer.nvidia.com/blog/author/vmailthody/), [Maksim Khadkevich](https://developer.nvidia.com/blog/author/mkhadkevich/) — August 2026"
+description: "A pre-initialized shadow engine shares GPU-resident weights with the active one, so an engine crash recovers in seconds instead of paying a full weight reload."
+keywords: engine failover, GPU memory service, GMS, shadow engines, LLM inference, vLLM, fault tolerance, Dynamo
+last-updated: August 13, 2026
+hide-page-actions: true
+---
+
+import { BlogStyles } from "@/components/BlogStyles";
+import { BlogArticleMeta } from "@/components/BlogArticleMeta";
+
+
+
+
+
+When an LLM engine process fails, the standard recovery path involves a cold restart. This requires loading weights into HBM from storage, compiling kernels, and capturing CUDA graphs. For large models, this initialization period can last several minutes, during which surviving workers are strained to compensate for the loss.
+
+**Shadow Engine Failover** keeps a second engine process, the *shadow*, fully initialized and idle on the same GPUs as the active one. What makes this possible is the **GPU Memory Service (GMS)**, which enables zero-copy weight sharing between the engines, so the shadow adds no weight memory of its own. When the active process fails, the shadow takes over within seconds. Re-initialization still occurs, but it happens in the background, entirely off the serving path. Shadow failover is available in Dynamo today as a [preview feature](../../developer-guide/knowledge-base/kubernetes/kubernetes-operator/shadow-engine-failover.md).
+
+We measured the impact by forcing a two-minute cascade failure across all three engines of a three-node Kimi-K2.6 deployment. Without failover, the system suffered 409 failed requests and a 150-second total outage. With shadow failover, only 17 requests failed, and the service remained continuous throughout the cascade. Below, we show how the GPU Memory Service makes warm standby possible, walk through the failover sequence, and go through those measurements.
+
+
+
BASELINE (no failover)
SHADOW FAILOVER
+
+
+
+
+
+
+
+Per-user decode rate through a cascade of three engine kills, 60 s apart, on a 3-worker Kimi-K2.6 deployment. The empty stretch in the baseline chart is 150 s in which no request completed anywhere. With shadow failover the series never breaks.
+
+
+---
+
+## Background and Challenges
+
+Recoverable software faults are common for LLM engines in production, including process crashes, recoverable CUDA errors, and transient collective failures. In these instances, the hardware, drivers, and node remain healthy; only the process holding the corrupted state is lost, and a fresh engine can typically run on the same GPUs without further issues.
+
+So why can't that fresh engine skip the initialization cost? Two problems stand in the way:
+
+- **Weights are tied to the engine process.** GPU memory is linked to the engine's CUDA context, which is itself tied to the engine process. When the process exits, the driver releases all resources, including weights already resident in GPU memory. Consequently, any incoming engine process must repeat the full weight-loading procedure.
+- **Some initialization states are non-transferable.** NCCL and `torch.distributed` communicators bind to the specific running process, and CUDA graphs are fixed to the virtual addresses present during capture. This state cannot be handed off from a previous engine and must be re-executed during every restart.
+
+Shadow failover addresses each with a targeted optimization: decoupling weight lifetime from the engine process, and prepaying non-transferable initialization ahead of any failure.
+
+---
+
+## How shadow failover works
+
+### GPU Memory Service
+
+The **GPU Memory Service (GMS)** manages specific memory regions, such as weights, independently of the engine process. By using a process distinct from the engine to own these regions, weights remain resident in memory even as engines are restarted. This allows a new engine on the same GPU to attach to existing memory.
+
+GMS is a per-GPU sidecar that owns physical GPU memory on behalf of inference engines. It is mostly dormant and has no CUDA context of its own; it allocates physical pages, hands out handles to them, and arbitrates which engines may read or write at any given moment. Engines connect, import handles, and map the underlying pages at virtual addresses in their own CUDA contexts. Mapping happens once, at startup, and GMS is not involved in any access afterwards.
+
+This functionality is built on the [CUDA Virtual Memory Management API](https://developer.nvidia.com/blog/introducing-low-level-gpu-virtual-memory-management/), which allows physical GPU memory and its associated virtual addresses to have independent lifetimes. Since physical allocations are reference-counted, they survive as long as any process maintains a mapping. Two engines mapping the same weight tensor access the same physical bytes, each using virtual addresses local to its respective context. A kernel reading a weight dereferences an ordinary pointer into the same HBM the weight would have occupied anyway, so a GMS-backed read costs no more than an engine-allocated one.
+
+
+
+
+
+
+Each engine maps the weights into its own address space, but there is only one physical copy in HBM. GMS holds the allocation and hands out the handles; it does not sit between an engine and the memory it reads.
+
+
+This architecture provides two primary benefits. First, weights persist beyond engine failure. While the kernel removes the failed engine's CUDA context, the GMS reference ensures the physical pages stay resident so a fresh engine can immediately map them. Second, weights can be shared between concurrent engines; therefore, a secondary engine on the same GPU incurs zero marginal weight cost.
+
+Integration into inference frameworks is narrow. vLLM, SGLang, and TensorRT-LLM each plug GMS in through a custom [`torch.cuda.CUDAPluggableAllocator`](https://docs.pytorch.org/docs/stable/generated/torch.cuda.CUDAPluggableAllocator.html) bound to the weight memory pool. From inside the engine, weights remain ordinary `torch.Tensor`s. Adopting GMS is essentially flipping a flag at startup.
+
+
+GMS is not limited to weights. Extending it to cover the KV cache is an active line of work,
+not a capability available today. The goal is for a promoted shadow to map the outgoing
+engine's cache instead of rebuilding it from traffic.
+
+
+### Shadow engines
+
+A shadow engine is a fully initialized engine process, idle and co-resident on the same GPUs as the active one. That zero marginal weight cost is what makes it possible at all: without sharing, the second engine would need a second full copy of the weights, which for any model worth serving exceeds what HBM can hold.
+
+A shadow runs through the same startup path as an active engine. On each of its GPUs it connects to the local GMS and imports the weight mappings, establishes communicators (NCCL, and NIXL for KV transfer between workers), captures CUDA graphs, and performs any warmup necessary. By the end of startup it is ready to serve. Then, instead of serving, it parks: it releases the materializable parts of its memory and blocks waiting for its turn.
+
+What a shadow has prepaid by the time it parks:
+
+- **CUDA context, captured graphs, and communicators.** This non-transferable state is ready the moment the shadow engine is activated, as it cannot be inherited from a previous process.
+- **Weight mappings.** The GMS handles are already imported, so wake is a remap into addresses the engine already knows.
+
+What it has deferred:
+
+- **KV cache materialization.** The largest reclaimable allocation an engine holds. The shadow reserves the address range but leaves it without physical backing while parked, and materializes it on promotion.
+
+So a parked shadow's standing cost is its context, captured graphs, and communicators, and nothing else. It holds no weights of its own and no KV cache, which leaves it small enough to sit alongside an active engine on the same devices. That co-residency is what makes failover within seconds possible.
+
+### The failover worker
+
+
+
+
+
+
+
+
+
+Left: the internals of one failover worker. Right: a fleet of them behind a single router. The pair layout is internal to the worker, so router, frontend, and orchestrator need no changes to benefit from failover.
+
+
+These foundations are integrated into a single deployable unit called the **failover worker**. This pod consists of two engine containers, a GMS sidecar to mediate GPU memory access, and a shared lock to elect the active engine.
+
+At steady state, one engine holds the lock and is awake: connected to GMS, KV cache materialized, registered as discoverable with the frontend router. The other is dormant, fully initialized and connected to GMS, but holding no KV cache and blocked on the lock. From the router's perspective there is one inference endpoint per worker. The shadow is invisible until a failover promotes it.
+
+---
+
+## Failover in depth
+
+### Sequence
+
+A failover worker moves through four phases from steady state back to steady state.
+
+
+
+
+
+
+The four phases of a failover. Active and shadow roles swap between engine A and engine B, and the worker returns to steady state without either engine reloading weights.
+
+
+- **T₀ Steady.** Engine A holds the lock and is awake, registered with the router. Engine B is dormant, blocked on the lock.
+- **T₁ Failure.** Engine A's process exits, either because it crashed outright or because a liveness probe found it hung and killed it. Either way the kernel releases its lock as the process is reaped. The worker is briefly unroutable until the shadow registers.
+- **T₂ Cutover.** Engine B acquires the lock, wakes, remaps weights through GMS, materializes its KV cache, and re-registers with the router. Engine A's container is restarted by the orchestrator.
+- **T₃ Restarted.** Engine A finishes initialization and enters the shadow state. Steady state, with the roles swapped.
+
+The shadow's advantage is that it enters T₂ already initialized. The only work on the critical path is acquiring the lock, remapping weights, and materializing the KV cache.
+
+### Synchronization
+
+The worker requires both mutual exclusion, ensuring only one engine is awake at a time, and reliable release to ensure the standby engine takes over if the active one fails. A POSIX `flock` on a shared file provides these guarantees. When the active process exits due to a shutdown, segfault, or `SIGKILL`, the kernel reaps its file descriptors, and the shadow engine acquires the lock to begin serving.
+
+Each engine's startup path is therefore a short leader election:
+
+```py
+await engine.initialize() # weight load, torch.compile, autotune, CUDA graph capture
+...
+# put the engine to sleep while we wait on the lock
+await engine.sleep()
+lock = FlockFailoverLock(lock_path)
+await lock.acquire(engine_id=engine.id) # wait on the lock to wake
+await engine.wake()
+```
+
+A deadlocked engine whose process is still alive falls to the Kubernetes liveness probe, which cascades to a `SIGKILL` and trips the same kernel-managed release.
+
+### Memory accounting
+
+Fitting two engine processes on one GPU without exhausting HBM takes careful accounting across the lifecycle.
+
+
+
+
+
+
+Weights are allocated once by GMS and mapped by every engine, so they never appear twice. The KV cache belongs to whichever engine is active, released on failure and materialized again by the engine that takes over. Buffers and captured graphs are the only cost a parked engine carries.
+
+
+- **Weights.** Allocated once by GMS and mapped read-only by every engine in the worker, never duplicated.
+- **KV cache.** Held only by the active engine today: materialized when it wakes, released when it dies, freeing the region for the shadow to take over.
+- **Buffers and graphs.** NCCL buffers, the CUDA context, captured graphs. Held by each engine even while dormant, and the whole of a parked shadow's standing cost.
+
+
+## Benchmarks
+
+### Setup
+
+We ran three failover workers serving Kimi-K2.6 quantized to NVFP4 on B200 nodes: one worker per node, eight GPUs each, TP=8, 256K max context, prefix caching on, and MLA prefill running on FlashInfer. The load is a code-agent trace with heavy session-level KV reuse: 100k input tokens and 1k output tokens per request, at concurrency 24.
+
+Both arms run identical engine builds and configuration, so the only difference is the failover feature. The baseline has failover off, and each killed worker cold-restarts. The shadow failover arm has shadow mode on, and each worker pod holds a pre-initialized shadow. That parked shadow held about 6.2 GB per GPU: its CUDA context, NCCL buffers, and captured graphs, with no weights or KV cache of its own.
+
+We injected the fault only after the workload reached its steady-state operating point: three engine kills, one per worker, 60 seconds apart. Sixty seconds is well short of a Kimi-K2.6 cold start, so on the baseline arm the third worker dies long before the first has recovered, and the fleet is left with no serving capacity at all.
+
+We classify each request as *working*, *slow* (completed but outside the original SLA of 5 s to first token), or *truly-failed* (returned an error or never completed).
+
+### Results
+
+
+
BASELINE (no failover)
SHADOW FAILOVER
+
+
+
+
+
+
+
+Request outcomes in 30 s bins. Baseline loses 409 requests and completes nothing for roughly 150 s; shadow failover loses 17 and completes requests in every bin. Note the different y-axes: the baseline peaks near 200 per bin because its failures all land at once, while shadow failover never leaves its normal ~30.
+
+
+
+
BASELINE (no failover)
SHADOW FAILOVER
+
+
+
+
+
+
+
+TTFT per request with a 30 s rolling mean, on a shared 0–25 s axis. The baseline's spike is the recovery, not the failure: the cold-restarted workers come back and inherit the whole queued backlog at once. Shadow failover has no backlog to drain, so its worst window sits near the 5 s SLA line rather than four times above it.
+
+
+| Metric | Baseline | Shadow failover |
+|---|---|---|
+| Serving through the cascade | blackout, ~150 s | continuous |
+| Truly-failed requests | 409 | 17 |
+| Requests completed | 867 | 1,017 |
+| Requests inside the original SLA | 699 | 907 |
+
+Shadow failover fundamentally changes the failure profile:
+
+- **Request health.** Failures dropped from 409 to just 17, with no complete blackout. The 17 fall inside the cutover window: between the active engine's process exiting and the shadow registering with the router, the worker is briefly unroutable, and requests in flight or arriving in that gap are dropped.
+- **Decode rate.** The fleet sustained performance throughout the cascade, maintaining a steady ~70 tok/s/user compared to the baseline's complete drop-off.
+- **TTFT stability.** During recovery, shadow failover kept worst-case TTFT within manageable bounds, avoiding the severe latency spikes caused by the baseline's massive backlog of queued requests.
+
+---
+
+## Caveats and what's next
+
+- Shadow failover addresses common engine process failures but does not cover hardware, node, or multi-node failures, which still rely on standard rescheduling.
+- It requires Dynamic Resource Allocation (DRA) on Kubernetes, so the cluster needs Kubernetes 1.34 or newer with DRA enabled and the NVIDIA GPU DRA driver installed.
+- Because promoted shadows start with empty KV caches, the post-cutover TTFT experiences a slight bump. Carrying cache state across a promotion, both the prefix-cache index and the cache memory itself, is the active line of work.
+- vLLM is the primary supported backend, with SGLang and TensorRT-LLM available experimentally.
+
+To try it: [Shadow Engine Failover](../../developer-guide/knowledge-base/kubernetes/kubernetes-operator/shadow-engine-failover.md) covers the deployment workflow, the [vLLM failover example](https://github.com/ai-dynamo/dynamo/blob/main/examples/backends/vllm/deploy/agg_failover.yaml) is a complete manifest, and the [Kubernetes quickstart](../../kubernetes/getting-started/quickstart.mdx) gets you to a running deployment first. Shadow failover is being built in the open in [ai-dynamo/dynamo](https://github.com/ai-dynamo/dynamo), and issues and questions are welcome.
diff --git a/docs/fern/pages/blog/_assets/bulwark/images/bench-decode-baseline.png b/docs/fern/pages/blog/_assets/bulwark/images/bench-decode-baseline.png
new file mode 100644
index 000000000000..ec8aa9297f46
Binary files /dev/null and b/docs/fern/pages/blog/_assets/bulwark/images/bench-decode-baseline.png differ
diff --git a/docs/fern/pages/blog/_assets/bulwark/images/bench-decode-failover.png b/docs/fern/pages/blog/_assets/bulwark/images/bench-decode-failover.png
new file mode 100644
index 000000000000..e88d4befb78e
Binary files /dev/null and b/docs/fern/pages/blog/_assets/bulwark/images/bench-decode-failover.png differ
diff --git a/docs/fern/pages/blog/_assets/bulwark/images/bench-outcome-baseline.png b/docs/fern/pages/blog/_assets/bulwark/images/bench-outcome-baseline.png
new file mode 100644
index 000000000000..8b15831eb0eb
Binary files /dev/null and b/docs/fern/pages/blog/_assets/bulwark/images/bench-outcome-baseline.png differ
diff --git a/docs/fern/pages/blog/_assets/bulwark/images/bench-outcome-failover.png b/docs/fern/pages/blog/_assets/bulwark/images/bench-outcome-failover.png
new file mode 100644
index 000000000000..99a3429b01bc
Binary files /dev/null and b/docs/fern/pages/blog/_assets/bulwark/images/bench-outcome-failover.png differ
diff --git a/docs/fern/pages/blog/_assets/bulwark/images/bench-ttft-baseline.png b/docs/fern/pages/blog/_assets/bulwark/images/bench-ttft-baseline.png
new file mode 100644
index 000000000000..2703c8692260
Binary files /dev/null and b/docs/fern/pages/blog/_assets/bulwark/images/bench-ttft-baseline.png differ
diff --git a/docs/fern/pages/blog/_assets/bulwark/images/bench-ttft-failover.png b/docs/fern/pages/blog/_assets/bulwark/images/bench-ttft-failover.png
new file mode 100644
index 000000000000..edf9e1a6cbb9
Binary files /dev/null and b/docs/fern/pages/blog/_assets/bulwark/images/bench-ttft-failover.png differ
diff --git a/docs/fern/pages/blog/_assets/bulwark/images/failover-timeline.svg b/docs/fern/pages/blog/_assets/bulwark/images/failover-timeline.svg
new file mode 100644
index 000000000000..02039cb8c9d7
--- /dev/null
+++ b/docs/fern/pages/blog/_assets/bulwark/images/failover-timeline.svg
@@ -0,0 +1,98 @@
+
+
diff --git a/docs/fern/pages/blog/_assets/bulwark/images/fleet-overview.svg b/docs/fern/pages/blog/_assets/bulwark/images/fleet-overview.svg
new file mode 100644
index 000000000000..443b72dfae18
--- /dev/null
+++ b/docs/fern/pages/blog/_assets/bulwark/images/fleet-overview.svg
@@ -0,0 +1,88 @@
+
+
diff --git a/docs/fern/pages/blog/_assets/bulwark/images/gms-overview.svg b/docs/fern/pages/blog/_assets/bulwark/images/gms-overview.svg
new file mode 100644
index 000000000000..2b86be580066
--- /dev/null
+++ b/docs/fern/pages/blog/_assets/bulwark/images/gms-overview.svg
@@ -0,0 +1,65 @@
+
+
diff --git a/docs/fern/pages/blog/_assets/bulwark/images/gpu-memory.svg b/docs/fern/pages/blog/_assets/bulwark/images/gpu-memory.svg
new file mode 100644
index 000000000000..76c798ec6532
--- /dev/null
+++ b/docs/fern/pages/blog/_assets/bulwark/images/gpu-memory.svg
@@ -0,0 +1,121 @@
+
+
diff --git a/docs/fern/pages/blog/_assets/bulwark/images/layout-overview.svg b/docs/fern/pages/blog/_assets/bulwark/images/layout-overview.svg
new file mode 100644
index 000000000000..88b08036cf25
--- /dev/null
+++ b/docs/fern/pages/blog/_assets/bulwark/images/layout-overview.svg
@@ -0,0 +1,38 @@
+
+
diff --git a/docs/fern/pages/blog/_assets/bulwark/tools/README.md b/docs/fern/pages/blog/_assets/bulwark/tools/README.md
new file mode 100644
index 000000000000..d5d62a40a648
--- /dev/null
+++ b/docs/fern/pages/blog/_assets/bulwark/tools/README.md
@@ -0,0 +1,25 @@
+
+
+# Bulwark figure sources
+
+## Architecture figures
+
+`images/layout-overview.svg`, `images/fleet-overview.svg`, `images/failover-timeline.svg`,
+`images/gpu-memory.svg`, and `images/aliased-kv-cache.svg` are hand-authored against the
+Dynamo Dark token set (`#000000` canvas, `#76b900` accent). Edit the SVG directly.
+
+## Benchmark figures
+
+`images/bench-*.png` come from the 3-node Kimi-K2.6 cascade run described in the post. The
+harness emits Matplotlib charts on a light canvas; `darkify.py` remaps them onto the dark
+canvas the rest of the site uses. The transform inverts lightness in HLS space and leaves hue
+and saturation alone, so no data point, axis, or label is altered — only its color.
+
+```bash
+python3 darkify.py .png # writes _dark.png alongside the input
+```
+
+Requires `numpy` and `pillow`.
diff --git a/docs/fern/pages/blog/_assets/bulwark/tools/darkify.py b/docs/fern/pages/blog/_assets/bulwark/tools/darkify.py
new file mode 100644
index 000000000000..6193aa8782c1
--- /dev/null
+++ b/docs/fern/pages/blog/_assets/bulwark/tools/darkify.py
@@ -0,0 +1,61 @@
+# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+"""Move a light-canvas Matplotlib chart onto the dark canvas the docs site uses.
+
+Lightness is inverted in HLS space while hue and saturation are preserved, so white
+becomes black, black text becomes white, and the plotted series keep their identity.
+Only pixel color changes; no data, axis, or label is touched.
+"""
+
+import sys
+
+import numpy as np
+from PIL import Image
+
+
+def darkify(src, dst):
+ rgb = np.asarray(Image.open(src).convert("RGB")).astype(np.float32) / 255.0
+ r, g, b = rgb[..., 0], rgb[..., 1], rgb[..., 2]
+ mx, mn = rgb.max(-1), rgb.min(-1)
+ chroma = mx - mn
+ lightness = (mx + mn) / 2.0
+
+ saturated = chroma > 1e-6
+ sat = np.zeros_like(lightness)
+ sat[saturated] = np.where(
+ lightness[saturated] < 0.5,
+ chroma[saturated] / (mx + mn)[saturated],
+ chroma[saturated] / (2.0 - mx - mn)[saturated],
+ )
+
+ hue = np.zeros_like(lightness)
+ sector = saturated & (mx == r)
+ hue[sector] = ((g - b)[sector] / chroma[sector]) % 6
+ sector = saturated & (mx == g)
+ hue[sector] = ((b - r)[sector] / chroma[sector]) + 2
+ sector = saturated & (mx == b)
+ hue[sector] = ((r - g)[sector] / chroma[sector]) + 4
+ hue /= 6.0
+
+ inverted = 1.0 - lightness
+ c = (1 - np.abs(2 * inverted - 1)) * sat
+ x = c * (1 - np.abs((hue * 6) % 2 - 1))
+ m = inverted - c / 2
+ sextant = np.floor(hue * 6).astype(int) % 6
+ z = np.zeros_like(c)
+ conds = [sextant == i for i in range(6)]
+ out = np.stack(
+ [
+ np.select(conds, [c, x, z, z, x, c]),
+ np.select(conds, [x, c, c, x, z, z]),
+ np.select(conds, [z, z, x, c, c, x]),
+ ],
+ axis=-1,
+ ) + m[..., None]
+
+ Image.fromarray((np.clip(out, 0, 1) * 255).astype(np.uint8)).save(dst)
+
+
+if __name__ == "__main__":
+ for path in sys.argv[1:]:
+ darkify(path, path.replace(".png", "_dark.png"))