diff --git a/effect/decoherence/README.md b/effect/decoherence/README.md new file mode 100644 index 0000000..37a4d08 --- /dev/null +++ b/effect/decoherence/README.md @@ -0,0 +1,160 @@ +# Quantum Decoherence Brush + +> *Every real quantum computer is slowly losing its mind. This brush makes that visible.* + +When you paint with this brush, your stroke colors dissolve — not randomly, but through the exact two noise channels that limit every real NISQ device today. **Amplitude damping (T1)** pulls colors toward an environment bath. **Phase damping (T2)** scrambles their hue independently, without touching the brightness. Together, they turn a single brushstroke into a visual record of quantum memory fading away. + +--- + +## How it works + +When you drag a stroke, the brush divides the path into segments. Each segment samples the colors underneath it and computes a single representative color. That color gets encoded as a **qubit state on the Bloch sphere** — hue becomes the azimuthal angle φ, lightness becomes the polar angle θ: + +``` +φ = 2π · circmean(hue) → qc.ry(θ, i) then qc.rz(φ, i) +θ = π · mean(lightness) +``` + +Your chosen **Environment Color** is converted to Bloch angles (φ_env, θ_env) and used to steer the T1 relaxation target inside the circuit — not pre-encoded on the ancilla, but applied through controlled rotations in each Trotter step. + +Then the decoherence circuit runs. It couples each color-qubit to two dedicated ancillae (one for T1, one for T2) over several Trotter steps, applying amplitude and phase damping incrementally. After the circuit finishes, the brush measures ⟨X⟩, ⟨Y⟩, ⟨Z⟩ for each qubit, reconstructs the new Bloch angles, and uses the angular shifts to update the hue and lightness of every pixel in that segment. The result gets blended with the original canvas according to **Strength**. + +--- + +## The physics behind the circuit + +### T1 — Amplitude damping + +T1 is the time it takes for an excited qubit to release its energy to the environment and relax toward equilibrium. On the Bloch sphere, this shows up as the state vector contracting toward a fixed point — whichever state the environment "wants" the qubit to be in. + +The brush implements T1 using the Stinespring dilation for amplitude damping. The T1 ancilla starts in |0⟩. For each qubit and each Trotter step: + +```python +amp_rotation = 2 * arcsin(sqrt(per_step_amp)) # exact Stinespring: sin(θ/2) = sqrt(γ) + +qc.cry(amp_rotation, control=qubit_i, target=t1_ancilla) # partial energy transfer to bath +qc.cx(control=t1_ancilla, target=qubit_i) # bath feeds back into qubit +qc.cry(per_step_amp * env_θ, control=t1_ancilla, target=qubit_i) +qc.crz(per_step_amp * env_φ, control=t1_ancilla, target=qubit_i) +``` + +The `CRY(2·arcsin(√γ)) + CX` pair on a |0⟩ ancilla is the exact Stinespring unitary for amplitude damping: tracing out the ancilla yields Kraus operators K₀ = [[1,0],[0,√(1-γ)]], K₁ = [[0,√γ],[0,0]]. The final `CRY/CRZ` rotations steer relaxation toward your chosen **Environment Color** rather than the default |0⟩ ground state — making this a generalized thermal bath rather than just energy loss to zero. + +**Visually:** colors drift toward the bath color. Like a photograph left in sunlight, they slowly forget what they were and settle toward the environment. + +### T2 — Phase damping + +T2 is faster and stranger than T1. It destroys the *phase* of a quantum state — the off-diagonal coherences in the density matrix — without necessarily changing the energy at all. The qubit gradually loses track of where it is around the Bloch sphere equator, even while its north–south position stays the same. + +The brush implements T2 using the Stinespring dilation for the phase damping channel. A dedicated T2 ancilla starts in |0⟩. For each qubit and each Trotter step: + +```python +phase_rotation = 2 * arcsin(sqrt(per_step_phase)) # exact Stinespring: sin(θ/2) = sqrt(λ) + +qc.cry(phase_rotation, control=qubit_i, target=t2_ancilla) +``` + +`CRY(2·arcsin(√λ))` controlled on qubit_i entangles the qubit with the T2 ancilla. Tracing out the ancilla yields the phase damping channel: ρ₀₀ and ρ₁₁ unchanged, ρ₀₁ and ρ₁₀ multiplied by √(1−λ) per step. This is pure Bloch-vector XY contraction — the Z component (lightness) is invariant, but the off-diagonal coherences (hue identity) decay. No energy is exchanged. + +**Visually:** at high Phase Rate, colors lose their hue identity and trend toward desaturated/neutral tones while brightness stays intact. + +### Trotterization — why take multiple steps? + +Instead of applying the full T1 and T2 at once, the brush applies them in small, interleaved increments: + +``` +for step in range(Steps): + apply T1 increment to each qubit + apply T2 increment to each qubit +``` + +This is a Trotterized Lindbladian evolution — the same technique used in quantum simulation to approximate continuous-time evolution with discrete gate sequences. More steps means each increment is smaller and more accurate; it also means colors evolve further into the decoherence regime. With fewer steps and high rates, you get abrupt jumps. With many steps and lower rates, the color change feels more gradual and continuous. + +### Reading the result + +After the circuit runs, three Pauli observables are measured for each qubit: + +``` +⟨X⟩, ⟨Y⟩, ⟨Z⟩ → φ_new = arctan2(⟨Y⟩, ⟨X⟩) + θ_new = arctan2(√(⟨X⟩² + ⟨Y⟩²), ⟨Z⟩) +``` + +This reconstructs the Bloch vector position without needing full state tomography. The difference between the old and new angles (Δφ, Δθ) is then applied as an HLS offset to all the pixels in that path segment, and blended with the original using **Strength**. + +--- + +## Why a classical brush can't do this + +There are two things this brush does that have no classical equivalent. + +**Bloch sphere contraction.** When you apply the T1 or T2 Stinespring unitary and trace out the ancilla, the Bloch vector gets shorter — |⟨X⟩² + ⟨Y⟩² + ⟨Z⟩²| < 1. This is the signature of a mixed quantum state, and it cannot come from any unitary operation or any classical color manipulation. It only happens because information genuinely flows from the qubit into the environment. + +**Independent two-channel contraction.** T1 contracts the full Bloch vector toward the bath fixed point (all three components change). T2 contracts only the XY components (Z/lightness is invariant). These are two separate Stinespring unitaries on two separate ancillae, running simultaneously in each Trotter step. No classical color blend can produce both contractions independently with the right Kraus structure. + +**Curved color trajectories from entanglement.** The `CRY + CX` coupling (T1) temporarily entangles each color-qubit with the T1 ancilla. This creates a color evolution path on the Bloch sphere shaped by quantum correlations between system and bath — not a straight interpolation toward the target. Different environment colors produce qualitatively different trajectories, not just different endpoints. + +--- + +## Parameters + +| Parameter | What it controls physically | What you see | +|-----------|----------------------------|--------------| +| **Radius** | Brush footprint and number of path segments (= qubits) | Stroke width and color granularity | +| **Amplitude Rate** | T1 coupling strength γ per Trotter step | Colors fade toward the Environment Color | +| **Phase Rate** | T2 dephasing rate λ per Trotter step | Hue rotates and smears along the stroke | +| **Steps** | Trotter depth — how far the evolution runs | How deep into the decoherence regime | +| **Environment Color** | The thermal bath equilibrium state | The color the stroke slowly forgets toward | +| **Strength** | Blend between original and quantum-evolved pixels | How much the decoherence overrides the canvas | + +T1 and T2 are completely independent parameters here, just as they are on real hardware. You can have strong energy loss with almost no dephasing, or heavy phase scrambling with barely any energy change — two very different visual effects from the same physical picture. + +--- + +## Screenshots + +| Before | Light decoherence (T1=0.2, T2=0.1) | Heavy decoherence (T1=0.9, T2=0.8) | +|--------|-------------------------------------|--------------------------------------| +| ![Before](screenshots/before.png) | ![Light](screenshots/after_light.png) | ![Heavy](screenshots/after_heavy.png) | + +![All three modes side by side](screenshots/comparison.png) + +--- + +## Why I made this + +Most brushes in this collection model quantum algorithms — Heisenberg spin evolution, VQE chemistry, quantum drops. They're about what quantum computers *do*. + +This one is about what they *can't escape*. Decoherence isn't a side effect you can patch away; it's the fundamental reason we're still in the NISQ era. T1 and T2 are the first two numbers reported for any new qubit — they define what's possible before anything else is considered. I wanted to make a brush that didn't treat decoherence as an enemy to suppress, but as a physical process worth rendering directly. + +The two-channel structure gives you something you can't get from a single noise model: **two independent dimensions of forgetting**. T2 scrambles the hue — the *identity* of the color — before T1 has time to drain the energy. At high T2, low T1, you get chromatic chaos on a stable brightness background. At high T1, low T2, you get clean fade with the hue mostly intact. These aren't just visual variations; they correspond to genuinely different physical regimes that quantum engineers spend real effort distinguishing. + +The result is a brush for painting impermanence. Strokes that don't stay. Colors that remember where they came from, but only partially, and for a limited time. + +--- + +## References + +**Open-system quantum mechanics and noise channels** +- Nielsen & Chuang, *Quantum Computation and Quantum Information* (2000), Chapter 8: Quantum noise and quantum operations — the foundational text for Kraus operators, amplitude damping, and Stinespring dilation. +- Preskill, *Lecture Notes on Quantum Computation*, Ch. 3: Foundations II — Measurement and Evolution — [preskill.caltech.edu/ph229](https://preskill.caltech.edu/ph229/) ([PDF](https://preskill.caltech.edu/ph229/notes/chap3.pdf)) — T1/T2 definitions, Bloch sphere geometry, quantum channels. + +**NISQ-era context** +- Preskill, *Quantum Computing in the NISQ Era and Beyond* (2018) — [arXiv:1801.00862](https://arxiv.org/abs/1801.00862) — why coherence times are the central constraint on near-term hardware. +- Krantz et al., *A Quantum Engineer's Guide to Superconducting Qubits* (2019) — [arXiv:1904.06560](https://arxiv.org/abs/1904.06560) — how T1 and T2 are actually measured and what limits them in practice. + +**Trotterization** +- Childs et al., *A Theory of Trotter Error* (2021) — [arXiv:1912.08854](https://arxiv.org/abs/1912.08854) — theoretical basis for the interleaved step decomposition used in the circuit. + +**Color–quantum state mapping** +- Bloch sphere color encoding follows `color_to_spherical` in `effect/utils.py`: hue → azimuthal angle φ, lightness → polar angle θ. +- Circular mean for hue: Fisher, *Statistical Analysis of Circular Data* (1993) — the correct way to average an angular quantity like hue across a region. + +**Quantum Brush** +- Park, *QuantumBrush* (2025) — [arXiv:2509.01442](https://arxiv.org/abs/2509.01442) — the paper this brush contributes to. +- Circuit structure and Pauli estimator pattern adapted from `effect/damping/`, `effect/qdrop/`, and `effect/heisenbrush/`. + +--- + +## License + +Apache License 2.0 — same as the Quantum Brush project. diff --git a/effect/decoherence/__init__.py b/effect/decoherence/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/effect/decoherence/decoherence.py b/effect/decoherence/decoherence.py new file mode 100644 index 0000000..f93406a --- /dev/null +++ b/effect/decoherence/decoherence.py @@ -0,0 +1,273 @@ +""" +Quantum Decoherence Brush Effect Module. + +Models open-system quantum dynamics on stroke colors using amplitude damping +(T1 energy relaxation) and phase damping (T2 dephasing). Each path segment +is encoded as a qubit state; repeated damping steps simulate how quantum +information dissolves into an environment on NISQ hardware. +""" + +import numpy as np +from qiskit import QuantumCircuit +from qiskit.quantum_info import Pauli, SparsePauliOp +import importlib.util +from scipy.stats import circmean + +spec = importlib.util.spec_from_file_location("utils", "effect/utils.py") +utils = importlib.util.module_from_spec(spec) +spec.loader.exec_module(utils) + + +def build_decoherence_circuit(initial_angles, env_angles, amp_rate, phase_rate, steps): + """ + Build a quantum circuit applying Trotterized amplitude and phase damping. + + T1 (amplitude damping): ancilla-coupled Stinespring dilation. T1_ancilla starts in + |0>, CRY(2*arcsin(sqrt(gamma))) + CX implements the exact Kraus map; CRY/CRZ after + the CX steer relaxation toward the environment color rather than pure |0> ground state. + + T2 (phase damping): separate T2_ancilla starts in |0>, CRY(2*arcsin(sqrt(lambda))) + controlled on each qubit contracts the off-diagonal density-matrix elements by + sqrt(1-lambda) per step without changing the Z/lightness component. + + Both ancillae are shared across all data qubits per step (same single-ancilla + approximation used in damping.py). Circuit size: n_qubits + 2. + """ + n_qubits = len(initial_angles) + t1_ancilla = n_qubits + t2_ancilla = n_qubits + 1 + env_phi, env_theta = env_angles + + # Ancillae default to |0> — do NOT pre-rotate t1_ancilla to env state; + # env-steering is handled by the CRY/CRZ gates inside the Trotter loop. + qc = QuantumCircuit(n_qubits + 2) + + for i, (phi, theta) in enumerate(initial_angles): + qc.ry(theta, i) + qc.rz(phi, i) + + per_step_amp = amp_rate / max(steps, 1) + per_step_phase = phase_rate / max(steps, 1) + # Stinespring rotation: sin(theta/2) = sqrt(gamma) => theta = 2*arcsin(sqrt(gamma)) + amp_rotation = 2 * np.arcsin(np.sqrt(np.clip(per_step_amp, 0.0, 1.0))) + phase_rotation = 2 * np.arcsin(np.sqrt(np.clip(per_step_phase, 0.0, 1.0))) + + for _ in range(steps): + for i in range(n_qubits): + if amp_rate > 0: + # T1: Stinespring unitary for amplitude damping toward env color + qc.cry(amp_rotation, control_qubit=i, target_qubit=t1_ancilla) + qc.cx(control_qubit=t1_ancilla, target_qubit=i) + qc.cry(per_step_amp * env_theta, control_qubit=t1_ancilla, target_qubit=i) + qc.crz(per_step_amp * env_phi, control_qubit=t1_ancilla, target_qubit=i) + + if phase_rate > 0: + # T2: Stinespring unitary for phase damping (contracts XY Bloch components) + qc.cry(phase_rotation, control_qubit=i, target_qubit=t2_ancilla) + + return qc + + +def run_decoherence_circuit(initial_angles, env_angles, amp_rate, phase_rate, steps, params=None): + """ + Run decoherence simulation on quantum hardware simulator. + + Args: + initial_angles: List of (phi, theta) Bloch angles per segment + env_angles: (phi, theta) of the environment bath + amp_rate: Amplitude damping strength in [0, 1] + phase_rate: Phase damping strength in [0, 1] + steps: Number of Trotter damping iterations + + Returns: + List of (phi, theta) after decoherence evolution + """ + try: + num_qubits = len(initial_angles) + print(f"Decoherence: {num_qubits} qubits, amp={amp_rate}, phase={phase_rate}, steps={steps}") + print(f"initial angles: {initial_angles}") + print(f"environment angles: {env_angles}") + + qc = build_decoherence_circuit(initial_angles, env_angles, amp_rate, phase_rate, steps) + + # Circuit has n_qubits + 2 qubits (T1 ancilla + T2 ancilla); Pauli strings + # must be length n_qubits + 2 with ancilla positions as 'I'. + ops = [ + SparsePauliOp(Pauli('I' * (num_qubits + 1 - i) + p + 'I' * i)) + for p in ['X', 'Y', 'Z'] + for i in range(num_qubits) + ] + + p = params or {} + obs = utils.run_estimator( + qc, ops, + backend=p.get("backend"), + hw=p.get("hw"), + cost_estimate_out=p.get("cost_accumulator"), + ) + + x_expectations = obs[:num_qubits] + y_expectations = obs[num_qubits:2 * num_qubits] + z_expectations = obs[2 * num_qubits:] + + # phi = arctan2(Y, X), theta = arctan2(r_xy, Z) + phi_expectations = [np.arctan2(y, x) % (2 * np.pi) for x, y in zip(x_expectations, y_expectations)] + theta_expectations = [np.arctan2(np.sqrt(x ** 2 + y ** 2), z) for x, y, z in zip(x_expectations, y_expectations, z_expectations)] + + final_angles = list(zip(phi_expectations, theta_expectations)) + print(f"final angles: {final_angles}") + return final_angles + + except Exception as e: + print(f"Quantum simulation failed: {e}") + raise + + +def classical_decoherence(initial_angles, env_angles, amp_rate, phase_rate, steps): + """ + Classical fallback when qubit count exceeds simulator budget. + + T1 (amplitude damping): hue and lightness interpolate toward the environment + color — both channels decay because amplitude damping contracts the full Bloch + vector toward the thermal-bath fixed point. + + T2 (phase damping): hue decays toward the maximally-mixed azimuthal value (0.5) + without a target color, mirroring Bloch-vector XY contraction. Lightness is + unchanged by T2 (Z component is preserved under pure dephasing). + + ponytail: scalar HLS can't represent Bloch-vector shortening (mixed states). + This is the best single-color approximation. Upgrade: per-pixel density matrix. + """ + env_phi, env_theta = env_angles + env_h = env_phi / (2 * np.pi) + env_l = env_theta / np.pi + + final_angles = [] + per_step_amp = amp_rate / max(steps, 1) + per_step_phase = phase_rate / max(steps, 1) + + for phi, theta in initial_angles: + h = phi / (2 * np.pi) + lum = theta / np.pi + + for _ in range(steps): + # T1: both hue and lightness drift toward env (full Bloch-vector relaxation) + h = (1 - per_step_amp) * h + per_step_amp * env_h + h = h % 1.0 + lum = (1 - per_step_amp) * lum + per_step_amp * env_l + + # T2: hue loses azimuthal coherence (no env target), lightness unchanged + h = (1 - per_step_phase) * h + per_step_phase * 0.5 + h = h % 1.0 + + final_angles.append((h * 2 * np.pi, lum * np.pi)) + + return final_angles + + +# The only thing that you need to change is this function +def run(params): + """ + Executes the decoherence effect pipeline based on the provided parameters. + + Args: + parameters (dict): A dictionary containing all the relevant data. + + Returns: + Image: the new numpy array of RGBA values or None if the effect failed + """ + + image = params["stroke_input"]["image_rgba"].copy() + assert image.shape[-1] == 4, "Image must be RGBA format" + + height = image.shape[0] + width = image.shape[1] + + path = params["stroke_input"]["path"] + if len(path) == 0: + return image + + radius = params["user_input"]["Radius"] + assert radius > 0, "Radius must be greater than 0" + + strength = params["user_input"]["Strength"] + assert 0.0 <= strength <= 1.0, "Strength must be between 0 and 1" + + amp_rate = params["user_input"]["Amplitude Rate"] + assert 0.0 <= amp_rate <= 1.0, "Amplitude Rate must be between 0 and 1" + + phase_rate = params["user_input"]["Phase Rate"] + assert 0.0 <= phase_rate <= 1.0, "Phase Rate must be between 0 and 1" + + steps = params["user_input"]["Steps"] + assert steps >= 1, "Steps must be at least 1" + + env_color = params["user_input"]["Environment Color"] + env_phi, env_theta, _ = utils.color_to_spherical(env_color) + env_angles = (env_phi, env_theta) + print(f"Environment color: {env_color}, angles: {env_angles}") + + n_segments = min(int(round(np.interp(radius, [1, 100], [2, 8]))), len(path)) + split_paths = np.array_split(path, n_segments) + + initial_angles = [] + pixels = [] + for lines in split_paths: + region, distance = utils.points_within_radius( + lines, radius, border=(height, width), return_distance=True + ) + + if len(region) == 0: + continue + + selection = image[region[:, 0], region[:, 1]] + selection = selection.astype(np.float32) / 255.0 + selection_hls = utils.rgb_to_hls(selection) + + phi = circmean(2 * np.pi * selection_hls[..., 0]) + theta = np.pi * np.mean(selection_hls[..., 1], axis=0) + + initial_angles.append((phi, theta)) + pixels.append((region, distance, selection_hls)) + + if len(initial_angles) == 0: + return image + + use_quantum = len(initial_angles) <= 8 + + if use_quantum: + final_angles = run_decoherence_circuit( + initial_angles, env_angles, amp_rate, phase_rate, steps, params=params + ) + else: + print(f"Using classical fallback for {len(initial_angles)} segments") + final_angles = classical_decoherence( + initial_angles, env_angles, amp_rate, phase_rate, steps + ) + + for i, (region, distance, selection_hls) in enumerate(pixels): + new_phi, new_theta = final_angles[i] + old_phi, old_theta = initial_angles[i] + + offset_h = (new_phi - old_phi) / (2 * np.pi) + offset_l = (new_theta - old_theta) / np.pi + + shifted_hls = selection_hls.copy() + shifted_hls[..., 0] = (shifted_hls[..., 0] + offset_h) % 1 + shifted_hls[..., 1] += offset_l + shifted_hls = np.clip(shifted_hls, 0, 1) + + blended_hls = (1 - strength) * selection_hls + strength * shifted_hls + blended_hls = np.clip(blended_hls, 0, 1) + + blended_rgb = utils.hls_to_rgb(blended_hls) + + new_patch = image[region[:, 0], region[:, 1]].astype(np.float32) / 255 + new_patch[..., :3] = blended_rgb[..., :3] + + image[region[:, 0], region[:, 1]] = utils.apply_patch_to_image( + image[region[:, 0], region[:, 1]], new_patch, blur=True, distance=distance + ) + + print("Decoherence effect applied successfully") + return image diff --git a/effect/decoherence/decoherence_requirements.json b/effect/decoherence/decoherence_requirements.json new file mode 100644 index 0000000..04862e4 --- /dev/null +++ b/effect/decoherence/decoherence_requirements.json @@ -0,0 +1,57 @@ +{ + "name": "Quantum Decoherence", + "id": "decoherence", + "author": "QuantumBrush Contributor", + "version": "1.0.0", + "long_description": "This brush models open-system quantum dynamics on your stroke colors. Each segment along the path is encoded as a qubit on the Bloch sphere. Amplitude damping (T1) relaxes colors toward an environment bath, while phase damping (T2) smears hue coherence without transferring energy.\n\nRepeated damping steps along the stroke simulate how quantum information dissolves on NISQ hardware — producing ghost trails, faded memory, and dephased color halos.\n\n - Radius controls brush size and how many path segments become qubits\n - Environment Color is the thermal bath the stroke relaxes toward\n - Amplitude Rate sets T1-style energy loss\n - Phase Rate sets T2-style coherence loss\n - Steps sets how many damping iterations are applied\n - Strength blends quantum-evolved colors with the original canvas", + "description": "Fade stroke colors through amplitude and phase damping channels, modeling NISQ decoherence as artistic memory loss.", + "dependencies": { + "numpy": ">=2.1.0", + "qiskit": ">=1.0.0", + "qiskit_aer": ">=0.17.0", + "scipy": ">=1.10.0" + }, + "user_input": { + "Radius": { + "type": "int", + "min": 1, + "max": 100, + "default": 15 + }, + "Strength": { + "type": "float", + "min": 0.0, + "max": 1.0, + "default": 0.6 + }, + "Environment Color": { + "type": "color", + "default": "#808080" + }, + "Amplitude Rate": { + "type": "float", + "min": 0.0, + "max": 1.0, + "default": 0.5 + }, + "Phase Rate": { + "type": "float", + "min": 0.0, + "max": 1.0, + "default": 0.3 + }, + "Steps": { + "type": "int", + "min": 1, + "max": 20, + "default": 5 + } + }, + "stroke_input": { + "image_rgba": "array", + "path": "array" + }, + "flags": { + "smooth_path": true + } +}