PYTHON · STEP-BY-STEP LESSON

Restoration with a fidelity and gradient energy

I write a small part, check it, and then add the next part.

Download the notebook ↓ Open in Colab ↗

All Python notebook lessons →

The calculation, one step at a time

I show the purpose of each notebook cell beside its code and recorded result. The controls move through those results; they do not execute Python in the browser. The notebook remains available for running or changing the calculation.

Loading the lesson…

I add a reference-image penalty to the double-well and gradient energy. r is the grayscale reference and u the evolving signed intensity. lambda=40 weights the reference penalty, kappa=0.02 weights gradients, L=0.05 is Allen–Cahn mobility and M=0.2 is Cahn–Hilliard mobility. The time step is 0.02. All quantities are dimensionless. The block-scrambling prelude is a display operation, not PDE evolution.

I develop this calculation in small steps. I run a cell, inspect its result and test it before adding the next part. Run the cells in order in Jupyter. A code cell defines a part of the calculation or carries it out; defining a function does not run it.

I permit free noncommercial teaching, learning and demonstrations with acknowledgment. Research and commercial use require my written permission. Earlier licensed releases retain their permissions. Read the code-use terms.

Copyright (c) 2026 Saswata Bhattacharyya.

Step 1: Prepare the Python environment

I install the packages listed in the next cell once in the same environment as the notebook. In Jupyter or Colab, remove the comment marker from the installation line below if a package is missing. NumPy stores arrays and evaluates numerical operations. Matplotlib displays plots. Pillow reads and writes images. This installation does not change the model parameters.

# %pip install numpy "pillow>=10.1" matplotlib
import numpy as np
assert np.isfinite(np.array([0.0, 1.0])).all()
print("NumPy is ready:", np.__version__)
NumPy is ready: 2.1.3

Step 2: Read the image and check its dimensions

I download my public portrait once, or replace this path with my own image. An RGB array has two position axes and one channel axis. The three channels are red, green and blue.

from pathlib import Path
from urllib.request import urlretrieve

work = Path("lesson-data")
work.mkdir(exist_ok=True)

def get_file(relative_path):
    # I download a public example only when its local copy is missing.
    destination = work / Path(relative_path).name
    if not destination.exists():
        urlretrieve("https://saswataiith.github.io/" + relative_path, destination)
    return destination

portrait = get_file("assets/images/saswata-landscape.png")
output = "portrait-flows-results"

Step 3: Prepare the next part of the calculation

I define the following operations. These are the same operations as in the original source, written on separate lines so their order is clear. Inspect their values before running the next cell.

import sys, json
from pathlib import Path
import numpy as np
from PIL import Image, ImageDraw, ImageFont

Step 4: A portable display font

I try the original font, then DejaVu Sans, then Pillow's default font. The font changes labels on the image only; it does not change a numerical field.

def portrait_font(size):
    fonts = [Path('/') / 'System' / 'Library' / 'Fonts' / 'Helvetica.ttc', 'DejaVuSans.ttf']
    for font in fonts:
        try:
            return ImageFont.truetype(str(font), size)
        except OSError:
            pass
    return ImageFont.load_default(size=size)

Step 5: Run the complete example

I keep the original algorithm, parameters, seed and output conventions. I inspect the saved diagnostics before interpreting the pictures. A finite run is not evidence of convergence or equilibrium.

Read this complete operation (55 lines)
def run(path, output):
    out = Path(output)
    out.mkdir(parents=True, exist_ok=True)
    ref = np.asarray(Image.open(path).convert('L').resize((256, 256)), dtype=float) / 127.5 - 1
    rng = np.random.default_rng(19)
    blocks = ref.reshape(16, 16, 16, 16).transpose(0, 2, 1, 3).reshape(256, 16, 16)
    damaged = blocks[rng.permutation(256)].reshape(16, 16, 16, 16).transpose(0, 2, 1, 3).reshape(256, 256)
    ac = damaged.copy()
    ch = damaged.copy()
    n = 256
    dt = 0.02
    lam = 40
    kappa = 0.02
    L = 0.05
    M = 0.2
    k = 2 * np.pi * np.fft.fftfreq(n, d=32 / n)
    k2 = k[:, None] ** 2 + k[None, :] ** 2
    rhat = np.fft.fft2(ref)
    times = set(np.unique(np.round(np.geomspace(1, 1000, 90)).astype(int)))
    records = []
    counter = 0
    font = portrait_font(24)

    def field(a):
        return Image.fromarray(np.uint8(np.clip((a + 1) * 127.5, 0, 255))).resize((512, 512), Image.Resampling.NEAREST).convert('RGB')

    def frame(a, b, label):
        nonlocal counter
        im = Image.new('RGB', (1600, 660), '#f5f7f2')
        d = ImageDraw.Draw(im)
        for x, img, title in [(16, ref, 'Original reference'), (544, a, 'Allen-Cahn'), (1072, b, 'Cahn-Hilliard')]:
            d.text((x, 15), title, fill='#173d40', font=font)
            im.paste(field(img), (x, 60))
        d.text((16, 590), label, fill='#173d40', font=font)
        d.text((16, 625), 'Reference-assisted restoration | 256 x 256 | grayscale intensity, not material composition', fill='#173d40', font=font)
        im.save(out / f'frame-{counter:03d}.png')
        counter += 1
    for a in np.linspace(0, 1, 17):
        frame((1 - a) * ref + a * damaged, (1 - a) * ref + a * damaged, 'Disintegration prelude: block scrambling (not PDE evolution)')

    def energy(u):
        return np.mean((u * u - 1) ** 2 / 4 + lam * (u - ref) ** 2 / 2) + kappa / 2 * np.sum(k2 * np.abs(np.fft.fft2(u)) ** 2) / n ** 4
    for step in range(1001):
        if step == 0 or step in times:
            records.append([step * dt, ac.mean(), ch.mean(), np.mean((ac - ref) ** 2), np.mean((ch - ref) ** 2), energy(ac), energy(ch)])
            frame(ac, ch, f'Restoration time {step * dt:.2f} | L=0.05, M=0.2 | full original retained as target')
        if step == 1000:
            break
        ac = np.fft.ifft2((np.fft.fft2(ac) - dt * L * np.fft.fft2(ac ** 3 - ac) + dt * L * lam * rhat) / (1 + dt * L * (kappa * k2 + lam))).real
        ch = np.fft.ifft2((np.fft.fft2(ch) - dt * M * k2 * np.fft.fft2(ch ** 3 - ch) + dt * M * k2 * lam * rhat) / (1 + dt * M * k2 * (kappa * k2 + lam))).real
    assert np.isfinite(ac).all() and np.isfinite(ch).all()
    assert abs(ch.mean() - damaged.mean()) < 1e-10
    np.savetxt(out / 'diagnostics.csv', records, delimiter=',', header='time,AC_mean,CH_mean,AC_mse,CH_mse,AC_energy,CH_energy', comments='')
    Image.open(out / 'frame-016.png').save(out / 'poster.png')
    print('frames', counter, 'MSE initial/final AC CH', records[0][3:5], records[-1][3:5], 'CH mean drift', abs(ch.mean() - ref.mean()))
run(portrait, output)
frames 87 MSE initial/final AC CH [np.float64(0.3286614710988562), np.float64(0.3286614710988562)] [np.float64(8.463655851368943e-05), np.float64(6.81408479589557e-05)] CH mean drift 1.942890293094024e-16

Step 6: Review and extend the calculation

I first restart the kernel and run all the required cells. Then I change one input, predict its effect and compare the result with that prediction. A successful check establishes the tested property; it does not establish convergence or equilibrium. I retain the complete script for command-line use. The notebook embeds the calculation directly and does not need that script to run.

Read the notebook execution checks → · More Python, Julia and C lessons →