Physics-Based X-ray Simulation

No access to the X-ray equipment that shot our images? Invert the physics on a real image instead of simulating from a CAD model that doesn’t exist.
computer-vision
synthetic-data
X-ray
physics
Author

Hasan Goni

Published

October 26, 2026

Modified

October 26, 2026

Note

Images removed. This post’s figures showed frames from GDXray or outputs of models trained on them. GDXray is free for research and education only, and redistribution is not allowed, so the figures are gone. The text, code, numbers and plots are unchanged. The new synthetic void dataset is what this blog shows from now on.

Tip

This post is part of the Synthetic Data Series series.

0.1 The problem

Industrial X-ray inspection has a data problem that’s the mirror image of most computer vision problems: good images are everywhere, bad ones are rare. A production line that’s doing its job produces mostly defect-free welds, castings, and solder joints – which is great for the factory and terrible for training a defect detector. Rare defect classes (a hairline crack, a small void, a bent lead) might show up a handful of times a year, nowhere near enough to teach a model what one looks like in every lighting, material, and geometry combination it needs to generalize across.

The standard workaround is manual: an engineer crops a real defect out of one image and pastes it into a good image, maybe blends the edges a bit so it doesn’t look glued on. It works, sort of, and it’s the Part 4 baseline this series will come back to. But it has an honest failure mode – paste a crack from an image lit one way onto an image lit another way and the physics doesn’t match: the crack’s local contrast and noise texture belong to the source image, not the target.

This post asks a more literal question first: what if we didn’t paste pixels, but instead undid the physics that produced them, edited the thing the physics actually operates on, and then re-ran the physics forward again? No CAD model of the part, no access to an actual X-ray source or detector – none of that is available here. What is available is a real public dataset (GDXray) of real X-ray images with real pixel statistics baked into them, which turns out to be enough to fit every physical parameter we need directly from the pixels themselves.

0.2 Explain it like I’m five

Physics-based synthesis, sketched out.

Think of an X-ray image like a shadow puppet show. The light (X-rays) shines through your hand (the part being inspected) and lands on the wall (the detector) as a shadow. Thicker or denser parts of your hand block more light, so they cast a darker shadow; thin gaps let more light through, so they’re brighter.

Now say you want a shadow of a hand with a cut in one finger, but you don’t have a real cut hand to shine light through. Here’s the trick: take a photo of the shadow you already have (a real X-ray image), and mathematically work backwards to guess how thick the hand must have been at every point to cast that exact shadow (step 2 in the diagram – inverting the physics gets you a “thickness map”). Now you can edit the thickness map directly – carve a thin groove into it right where you want the cut to be (step 4, editing inside a mask). Then you shine the (simulated) light through your edited thickness map all over again and see what shadow it casts now (step 5 – re-render, add the blur and graininess a real detector would add). You end up with a brand-new shadow that has a scar in exactly the shape you drew, and because you know exactly which pixels you carved, you get a perfect answer key (a segmentation mask) completely for free (step 6) – for the scar you drew, at least; if the original hand already had a scratch on it, that one is still there and still not on your answer key. No new hand needed – just a shadow, some math, and an eraser.

0.3 Explain it like I’m a data scientist

The physics is the Beer-Lambert law, the same attenuation model that governs how light dims passing through any absorbing medium:

\[I = I_0 \, e^{-\mu t}\]

where \(I_0\) is the unattenuated beam intensity, \(\mu\) is the material’s linear attenuation coefficient, and \(t\) is the material thickness the beam travels through. A single 2D X-ray projection measures \(I\) at every pixel and nothing else – \(\mu\) and \(t\) never appear separately in that equation, only as the product \(\mu t\) in the exponent. Given one image, there is no way to recover \(\mu\) (what the material is) and \(t\) (how thick it is) individually; you’d need either a known material (so \(\mu\) is fixed and \(t\) falls out) or multiple views / multiple X-ray energies (spectral or tomographic imaging) to separate them.

Concretely: a thin, very dense void and a thick, mildly-attenuating gap can produce the exact same pixel intensity if their \(\mu t\) products match. Since we’re working from single 2D projections of GDXray images with no access to a CAD model or known material properties, the honest move is to stop pretending we can recover \(\mu\) and \(t\) separately, and instead work entirely in attenuation-depth units – the combined quantity \(\mu t\) (call it \(d\), in “attenuation lengths”). Rewriting Beer-Lambert in terms of \(d\):

\[I = I_0 \, e^{-d} \qquad\Longleftrightarrow\qquad d = -\ln(I / I_0)\]

That’s the entire invertible core of the pipeline: given a real image and a fitted \(I_0\), invert every pixel to its attenuation depth; edit the attenuation-depth map (a defect that removes material lowers \(d\) locally, since less material means less attenuation and hence a brighter pixel – or raises it, if the defect is itself an attenuating inclusion, more on that sign in the calibration section below); then forward-render the edited depth map back to intensity with the same equation run the other way. Everything downstream of that inversion – blur, photon noise – is modeled and calibrated separately, and is covered next.

0.4 Jargon buster

Term Plain-English meaning
Attenuation depth (mu·t) How much an X-ray beam is dimmed passing through material – the only thing a single 2D X-ray image actually tells you, since attenuation coefficient (mu) and thickness (t) can’t be separated without spectral or multi-view data
Beer-Lambert law The physics equation for that dimming: I = I0 * exp(-mu*t)
Photon-transfer curve Plotting noise variance against signal mean to characterize a photon-limited (Poisson) sensor
Line-spread function How a sharp edge gets smeared out by geometric and detector blur – fitting a Gaussian to it recovers the blur width
GDXray Public X-ray dataset of castings, welds, baggage, and more, with defect ground truth

0.5 Architecture

The pipeline lives in a small synth_xray package, traced here from the real function calls in generate_examples.py and synth_xray/pipeline.py:

Code
flowchart TB
    subgraph DATA["synth_xray.data"]
        DL["download_and_extract('Welds', ...)\n(gdown, Google Drive mirror --\nGDXray's original Dropbox links are dead)"]
        DL --> SD["find_series_dirs\n-> series_dir (W0001)"]
        SD --> GT["find_groundtruth_file\n-> ground_truth.txt"]
        SD --> IMGS["find_images_in_series\n-> images[0] = defect image,\nimages[1] = base image"]
    end

    subgraph GROUNDTRUTH["synth_xray.groundtruth"]
        GT --> PB["parse_gdxray_bboxes\n(col_min col_max row_min row_max)\n-> dict keyed by image_id"]
        PB --> BM["bbox_to_mask\n(boxes for images[0]'s image_id)\n-> roi_mask"]
        IMGS --> RO["refine_mask_otsu\n(defect_image, roi_mask) -> defect_mask"]
        BM --> RO
    end

    subgraph CALIBRATION["synth_xray.calibration (fit from real pixels)"]
        IMGS --> I0["estimate_I0\n(99th percentile of defect image) -> I0\n(cancels out of the round trip)"]
        RO --> DELTA["fit_defect_attenuation_depth\n(defect image; background = 25 px ring\naround roi_mask, not whole image)\n-> delta"]
        IMGS --> NOISE["fit_noise_model\n(base image, dense 20x20 patches)\n-> a, b"]
        IMGS --> BLUR["fit_blur_sigma\n(base image edge profile)\n-> raw sigma"]
    end

    BLUR -->|"raw sigma"| CLAMP["generate_examples.py (script, not library):\naccept [0.5, 5] px,\nfallback 1.5 px if out of range"]

    subgraph EDIT["synth_xray.defect_edit"]
        CRACK["generate_crack_mask\n(random-walk crack shape)\n-> synthetic_mask"]
    end

    subgraph PIPELINE["synth_xray.pipeline.synthesize_defect_image"]
        INV["physics.invert_to_attenuation_depth\n(clean_image arg = base image, I0) -> depth"]
        EDITD["defect_edit.edit_attenuation_depth\n(depth, synthetic_mask, delta) -> edited_depth"]
        REND["physics.render_xray\n(edited_depth, I0) -> rendered"]
        BLURF["physics.apply_gaussian_blur\n(rendered, blur_sigma) -> blurred"]
        NOISEF["physics.apply_photon_noise\n(blurred, gain) -> synthetic_image"]
        INV --> EDITD --> REND --> BLURF --> NOISEF
    end

    IMGS -->|"images[1] (base image)"| INV
    I0 --> INV
    I0 --> REND
    DELTA --> EDITD
    CRACK --> EDITD
    NOISE --> NOISEF
    CLAMP -->|blur_sigma| BLURF
    NOISEF --> OUT["synthetic_image, synthetic_mask\n(free ground truth)"]

    style PIPELINE fill:#2c5,color:#000
    style CLAMP fill:#eb4,color:#000

flowchart TB
    subgraph DATA["synth_xray.data"]
        DL["download_and_extract('Welds', ...)\n(gdown, Google Drive mirror --\nGDXray's original Dropbox links are dead)"]
        DL --> SD["find_series_dirs\n-> series_dir (W0001)"]
        SD --> GT["find_groundtruth_file\n-> ground_truth.txt"]
        SD --> IMGS["find_images_in_series\n-> images[0] = defect image,\nimages[1] = base image"]
    end

    subgraph GROUNDTRUTH["synth_xray.groundtruth"]
        GT --> PB["parse_gdxray_bboxes\n(col_min col_max row_min row_max)\n-> dict keyed by image_id"]
        PB --> BM["bbox_to_mask\n(boxes for images[0]'s image_id)\n-> roi_mask"]
        IMGS --> RO["refine_mask_otsu\n(defect_image, roi_mask) -> defect_mask"]
        BM --> RO
    end

    subgraph CALIBRATION["synth_xray.calibration (fit from real pixels)"]
        IMGS --> I0["estimate_I0\n(99th percentile of defect image) -> I0\n(cancels out of the round trip)"]
        RO --> DELTA["fit_defect_attenuation_depth\n(defect image; background = 25 px ring\naround roi_mask, not whole image)\n-> delta"]
        IMGS --> NOISE["fit_noise_model\n(base image, dense 20x20 patches)\n-> a, b"]
        IMGS --> BLUR["fit_blur_sigma\n(base image edge profile)\n-> raw sigma"]
    end

    BLUR -->|"raw sigma"| CLAMP["generate_examples.py (script, not library):\naccept [0.5, 5] px,\nfallback 1.5 px if out of range"]

    subgraph EDIT["synth_xray.defect_edit"]
        CRACK["generate_crack_mask\n(random-walk crack shape)\n-> synthetic_mask"]
    end

    subgraph PIPELINE["synth_xray.pipeline.synthesize_defect_image"]
        INV["physics.invert_to_attenuation_depth\n(clean_image arg = base image, I0) -> depth"]
        EDITD["defect_edit.edit_attenuation_depth\n(depth, synthetic_mask, delta) -> edited_depth"]
        REND["physics.render_xray\n(edited_depth, I0) -> rendered"]
        BLURF["physics.apply_gaussian_blur\n(rendered, blur_sigma) -> blurred"]
        NOISEF["physics.apply_photon_noise\n(blurred, gain) -> synthetic_image"]
        INV --> EDITD --> REND --> BLURF --> NOISEF
    end

    IMGS -->|"images[1] (base image)"| INV
    I0 --> INV
    I0 --> REND
    DELTA --> EDITD
    CRACK --> EDITD
    NOISE --> NOISEF
    CLAMP -->|blur_sigma| BLURF
    NOISEF --> OUT["synthetic_image, synthetic_mask\n(free ground truth)"]

    style PIPELINE fill:#2c5,color:#000
    style CLAMP fill:#eb4,color:#000

synthesize_defect_image itself is a five-line function – invert_to_attenuation_depth on the whole image, edit_attenuation_depth inside the mask, render_xray on the whole edited depth map, then blur and noise applied globally. That “whole image” detail matters and comes back in the results section below.

0.6 Calibrating from real images

Every number the pipeline needs is fit from real pixel statistics on a real GDXray image, not assumed or hand-picked. The series used is W0001 from GDXray’s Welds group – the first weld radiograph series that ships a ground_truth.txt file.

Two of that series’ ten radiographs get used, and which parameter comes from which image turns out to matter more than it looks:

  • W0001_0001.png is the defect image. It has annotated ground-truth defect boxes, so it’s what the defect physics – the attenuation-depth signature delta – is fit from. You need a real, annotated defect to measure what a defect actually does to attenuation depth, and that’s the one thing this image is for.
  • W0001_0002.png is the base image: the radiograph the synthetic crack gets injected into. So it’s what the sensor and acquisition characteristics – the noise model and the blur width – are fit from. Those are properties of one specific capture (exposure, dose, detector settings), not of defect physics.

Getting that split wrong is easy and quietly expensive. An earlier version of this pipeline fit everything, noise and blur included, on the defect image and then applied it to the base image. The output was measurably 3.7x grainier than its own source (median local standard deviation over 20x20 tiles: 5.90 versus the base image’s 1.58) – a synthetic image that looks like a different X-ray machine took it. Fitting noise and blur where they’ll actually be applied brings that to 1.56x (2.47 versus 1.58), which is about as close as you get while still adding a fresh, honest noise realization on top.

Running generate_examples.py prints the actual calibration report:

series: W0001
defect image (delta fit from): W0001_0001.png
base image (noise/blur fit from, synthesized onto): W0001_0002.png
fitted I0: 245.00
fitted attenuation-depth delta: -0.3494
fitted noise model: variance = 0.036 * mean + 0.441
fitted blur sigma: 2.121 px

A few things worth calling out, because they’re the kind of thing that looks wrong until you check it against the real image:

  • \(I_0 = 245\), close to the top of the 0-255 range. That’s consistent with the 99th-percentile brightest pixels in this image being the bright weld-bead band, close to saturation – a physically sane place for the “unattenuated beam” estimate to land. It’s also the one parameter whose exact value doesn’t change the output: render_xray and invert_to_attenuation_depth are exact algebraic inverses (\(I_0 e^{-(-\ln(I/I_0))} = I\)), so \(I_0\) cancels straight out of the round trip, and delta is a difference of two log-depths, so it cancels there too. \(I_0\) only sets the scale of the intermediate depth map.
  • delta is negative (-0.3494). The docstring intuition for a void/crack is “material missing → less attenuation → brighter pixel,” which would be a positive delta. This particular weld image’s defects are small pores that render darker than their surroundings, not brighter – confirmed by eye (a string of small dark dots along the bright band in the real image) as well as by the fit. edit_attenuation_depth handles a negative delta correctly (it increases attenuation depth under the mask, darkening those pixels), so this isn’t a bug to fix – it’s the real, dataset-specific polarity, and a good reminder that “defects are brighter” is a convenient default, not a law of physics.
  • What counts as “background” for that delta fit is a real choice, not a detail. delta is the median attenuation depth of the background minus that of the defect, and the obvious “background” – every pixel outside the defect box – is wrong here for the same reason the noise quadrants below are wrong: these panoramas have a strong large-scale brightness gradient, so a whole-image median averages the dark plate and the bright weld bead together and has nothing to do with the material immediately around this defect. Measured on the real image, a whole-image background gives delta = -0.4925; a local ring dilated 25 px around the defect box gives -0.3494. Same polarity, roughly 1.4x less magnitude. The value does keep drifting as you tighten the ring (-0.2412 at 10 px, -0.1974 at 5 px), which is worth saying out loud: there is no single “true” delta here, only a background reference you pick and then state. A 25 px ring is the compromise used – local enough to share the defect’s gradient context, wide enough (2,720 px) for a stable median.
  • The noise model needed a fix to the naive approach. The obvious way to sample “flat” patches for a photon-transfer-curve fit is a 2x2 grid of quadrants. These images have a strong, slow brightness gradient (the bright weld-bead band sweeping across an otherwise dark plate), so each quadrant patch spans most of that gradient – its variance reflects the gradient, not pixel-level photon noise. Fitting on quadrants predicted a standard deviation roughly an order of magnitude above the image’s real local standard deviation; applying that everywhere made the synthetic image visibly over-grainy. Switching to a dense grid of small 20x20 patches (each one small enough to sit inside a single locally-flat region) gives the reported variance = 0.036*mean + 0.441, which predicts a standard deviation of about 2.1 gray levels at the base image’s typical brightness – close to its measured 1.58, with the small remaining gap being real large-scale structure that even 20x20 patches can’t entirely dodge.
  • Blur sigma needed a sanity clamp, in both directions. There is no genuinely sharp step edge in these images to measure, and the step-edge fit fails in two opposite ways depending on where you put the profile: on a profile riding the large-scale gradient it returns 10-40 px (a blur that large would smear the synthetic crack, about 11x11 px, down to invisibility), and on a locally flat profile it degenerates to a near-zero sigma that scipy can’t even estimate a covariance for (i.e. no detector unsharpness at all). Neither is a measurement. Real detector/geometric unsharpness at this resolution is a sub-pixel-to-few-pixel affair, so anything outside [0.5, 5] px is treated as a failed fit and falls back to a constant 1.5 px. The profile actually used – taken through the site on the base image where the crack is about to be injected, which is where the blur has to be right – lands at 2.121 px, inside the plausible band, so it’s a real fit rather than the fallback.

Three more real-world details worth flagging rather than hiding, since they’re the kind of thing that trips up anyone reproducing this: GDXray’s original Dropbox download links are entirely dead (verified across all five groups); the working source is a maintainer-posted Google Drive replacement, downloaded with gdown. And GDXray’s ground-truth text files order their columns as image_id col_min col_max row_min row_max – initially assumed to be image_id row_min col_min row_max col_max, which silently produced nonsense boxes until it was caught by checking parsed box extents against each image’s real pixel dimensions and cross-checking against a third-party open-source GDXray parser. A third, subtler one: a series ships a single ground_truth.txt for all its images, with the image each box belongs to given only by a leading image_id column. Returning a flat list of boxes from that file works right up until the file isn’t sorted the way you assumed, so parse_gdxray_bboxes returns a dict keyed by image_id and callers ask for the boxes of the image they actually loaded (file W0001_000N.png is image_id N – verified by checking every one of the 10 ids’ coordinate extents against its same-numbered PNG’s real dimensions). All three are the unglamorous parts of working with a real, imperfectly-maintained public dataset, and they’re handled in synth_xray/data.py and synth_xray/groundtruth.py.

0.7 Real code

The package is a small uv-managed project at notebooks/synthetic-data-xray/, four modules doing one job each.

notebooks/synthetic-data-xray/src/synth_xray/physics.py
"""Beer-Lambert forward/inverse rendering, blur, and photon-noise models."""
import numpy as np
from scipy.ndimage import gaussian_filter


def render_xray(attenuation_depth: np.ndarray, I0: float) -> np.ndarray:
    """Forward-render an attenuation-depth map (mu*t, in attenuation lengths) to intensity."""
    return I0 * np.exp(-attenuation_depth)


def invert_to_attenuation_depth(image: np.ndarray, I0: float, eps: float = 1e-6) -> np.ndarray:
    """Invert Beer-Lambert to recover the attenuation-depth map implied by a real image."""
    ratio = np.clip(image / I0, eps, None)
    return -np.log(ratio)


def apply_gaussian_blur(image: np.ndarray, sigma: float) -> np.ndarray:
    """Apply detector/geometric-unsharpness blur as a Gaussian filter."""
    if sigma <= 0:
        return image
    return gaussian_filter(image, sigma=sigma)


def apply_photon_noise(
    image: np.ndarray, gain: float, rng: np.random.Generator | None = None
) -> np.ndarray:
    """Apply photon-limited (Poisson) noise: variance scales with signal, per `gain` electrons/count."""
    rng = rng if rng is not None else np.random.default_rng()
    photon_counts = np.clip(image, 0, None) / gain
    noisy_counts = rng.poisson(photon_counts).astype(np.float64)
    return noisy_counts * gain
notebooks/synthetic-data-xray/src/synth_xray/calibration.py
"""Fitting physics-model parameters from real GDXray image statistics."""
import numpy as np
from scipy.optimize import curve_fit

from synth_xray.physics import invert_to_attenuation_depth


def estimate_I0(image: np.ndarray, percentile: float = 99.0) -> float:
    """Estimate the unattenuated beam intensity as a high percentile of the image."""
    return float(np.percentile(image, percentile))


def fit_defect_attenuation_depth(
    image: np.ndarray, defect_mask: np.ndarray, background_mask: np.ndarray, I0: float
) -> float:
    """Fit the attenuation-depth difference (background - defect) a real defect represents.

    Positive for a void/crack (material missing -> less attenuation -> brighter pixels).
    """
    depth = invert_to_attenuation_depth(image, I0)
    background_depth = float(np.median(depth[background_mask]))
    defect_depth = float(np.median(depth[defect_mask]))
    return background_depth - defect_depth


def fit_noise_model(image: np.ndarray, patch_masks: list[np.ndarray]) -> tuple[float, float]:
    """Fit variance = a*mean + b (photon-transfer curve) across flat patches."""
    means = np.array([image[mask].mean() for mask in patch_masks])
    variances = np.array([image[mask].var() for mask in patch_masks])
    a, b = np.polyfit(means, variances, deg=1)
    return float(a), float(b)


def _gaussian_cdf_edge(x: np.ndarray, amplitude: float, center: float, sigma: float, offset: float) -> np.ndarray:
    from scipy.special import erf
    return offset + amplitude * 0.5 * (1 + erf((x - center) / (sigma * np.sqrt(2))))


def fit_blur_sigma(edge_profile: np.ndarray) -> float:
    """Fit a Gaussian-blurred step edge to a 1D intensity profile; return sigma in pixels."""
    x = np.arange(edge_profile.size, dtype=float)
    amplitude_guess = edge_profile[-1] - edge_profile[0]
    center_guess = x[np.argmin(np.abs(edge_profile - edge_profile.mean()))]
    p0 = [amplitude_guess, center_guess, 2.0, edge_profile[0]]
    popt, _ = curve_fit(_gaussian_cdf_edge, x, edge_profile, p0=p0, maxfev=5000)
    return float(abs(popt[2]))
notebooks/synthetic-data-xray/src/synth_xray/defect_edit.py
"""Synthetic defect shape generation and attenuation-depth map editing."""
import numpy as np
from skimage.draw import disk


def generate_crack_mask(
    image_shape: tuple[int, int],
    start: tuple[int, int],
    length: int,
    thickness: int = 1,
    rng: np.random.Generator | None = None,
) -> np.ndarray:
    """Generate a thin random-walk crack-shaped mask starting at `start`."""
    rng = rng if rng is not None else np.random.default_rng()
    mask = np.zeros(image_shape, dtype=bool)
    row, col = start
    radius = max(thickness - 1, 0)

    # Mark the starting position
    if radius > 0:
        rr, cc = disk((row, col), radius + 1, shape=image_shape)
        mask[rr, cc] = True
    else:
        mask[row, col] = True

    for _ in range(length):
        row = int(np.clip(row + rng.integers(-1, 2), 0, image_shape[0] - 1))
        col = int(np.clip(col + rng.integers(-1, 2), 0, image_shape[1] - 1))
        if radius > 0:
            rr, cc = disk((row, col), radius + 1, shape=image_shape)
            mask[rr, cc] = True
        else:
            mask[row, col] = True
    return mask


def edit_attenuation_depth(attenuation_depth: np.ndarray, mask: np.ndarray, delta: float) -> np.ndarray:
    """Reduce attenuation depth under `mask` by `delta` (a void/crack: material is missing)."""
    edited = attenuation_depth.copy()
    edited[mask] = np.clip(edited[mask] - delta, 0.0, None)
    return edited
notebooks/synthetic-data-xray/src/synth_xray/pipeline.py
"""End-to-end physics-based synthetic defect image generation."""
import numpy as np

from synth_xray.physics import (
    render_xray,
    invert_to_attenuation_depth,
    apply_gaussian_blur,
    apply_photon_noise,
)
from synth_xray.defect_edit import edit_attenuation_depth


def synthesize_defect_image(
    clean_image: np.ndarray,
    I0: float,
    defect_mask: np.ndarray,
    delta: float,
    noise_gain: float,
    blur_sigma: float,
    rng: np.random.Generator | None = None,
) -> tuple[np.ndarray, np.ndarray]:
    """Invert a real clean image to attenuation depth, inject a defect, and re-render.

    Steps: invert -> edit under mask -> forward render -> blur -> photon noise.
    Returns (synthetic_image, defect_mask) so the mask travels with its image as
    free ground truth.
    """
    depth = invert_to_attenuation_depth(clean_image, I0)
    edited_depth = edit_attenuation_depth(depth, defect_mask, delta)
    rendered = render_xray(edited_depth, I0)
    blurred = apply_gaussian_blur(rendered, blur_sigma)
    noisy = apply_photon_noise(blurred, gain=noise_gain, rng=rng)
    return noisy, defect_mask

Every function above is real, tested code from notebooks/synthetic-data-xray/src/synth_xray/ (25/25 tests passing), run for real against the downloaded GDXray Welds data by notebooks/synthetic-data-xray/generate_examples.py to produce the images below.

0.8 Results

[Figure removed: it showed GDXray pixels, or a model output made from them, and the GDXray licence does not allow redistribution.]

Each of these is a 500x300 crop out of the full ~4900-pixel-wide panorama – a crop, not a resize: same pixel values, same noise realization, just a smaller window, chosen so the file is small enough for quarto publish without touching a single pixel value (resizing, tried first, quietly shifted the very noise statistics this post is about – see the caveat below).

The synthetic image holds up at a glance: the overall gradient structure (dark plate, bright weld-bead band) matches the base image, the naturally-occurring real pore dots that were already in the base image pass straight through the invert-edit-render round trip, and the grain in this crop sits at 2.30 median local standard deviation against the base crop’s 1.62 – a 1.42x ratio, in the same range as the 1.56x measured on the full images (versus the 3.74x of the earlier version that fit noise on the wrong image). Zooming into the injected crack’s location shows a visible dark blob distinct from the surrounding noise texture: in the rendered image before blur and noise it carries about 26 gray levels of contrast, and in this crop the mask pixels average 78.3 against a surrounding-window mean of 89.6 with a local standard deviation of 2.1 – roughly a 5.5-sigma separation, so it survives the noise it’s buried in. The crack itself is small (an 11x11 pixel random walk) relative to the full ~4900-pixel-wide panorama, so it reads as a small, easy-to-miss local flaw rather than a dramatic feature – which is actually consistent with how this dataset’s own real pore defects look.

That’s a lot of prose promising a “visible dark blob” that isn’t actually visible in the crop above – at 500x300 pixels, an 11x11 pixel feature is a handful of screen pixels, easy to miss scrolling past. Here it is at 200x120, native resolution (no interpolation, so the noise texture itself is unchanged from the full crop above – just fewer surrounding pixels to get lost in):

[Figure removed: it showed GDXray pixels, or a model output made from them, and the GDXray licence does not allow redistribution.]

The “real base image” is not actually a clean image, and that limits the free-ground-truth claim. It’s worth being blunt about this, because the pitch for physics-based synthesis is “you get a perfect segmentation mask for free” and that sentence has a footnote. There is no defect-free radiograph in GDXray’s W0001 series: all ten images carry annotated defects, 641 boxes between them, and W0001_0002.png – the base image the crack is injected into, and the one synthesize_defect_image politely calls clean_image – has 37 real annotated defects of its own. So the mask this pipeline hands back is ground truth for the one synthetic crack it just drew, and nothing else. The 37 pre-existing real defects in the same frame are still there, still unlabeled by this mask, and a detector trained on that mask alone would be told they are background. In practice you’d merge the synthetic mask with the base image’s own shipped ground truth, or start from a base image you’ve actually verified is clean. Neither is free, and the honest version of the claim is narrower than the headline: the mask is free for the defect you injected.

A second honest caveat that’s important not to gloss over: this is not a surgical, local edit. synthesize_defect_image inverts, re-renders, re-blurs, and re-samples fresh Poisson noise for the entire image, not just the pixels under the defect mask. The signal (attenuation depth) outside the mask is unchanged, but the noise realization outside the mask is a fresh random draw, not a copy of the base image’s own noise – so the base/synthetic pair differs everywhere at low magnitude, not only where the defect was injected. In practice that difference is small enough not to be visually obvious next to the actual defect, but it means a naive pixel-diff between base and synthetic images is not itself a ground-truth defect mask – the real mask returned by synthesize_defect_image is what to use, not a diff.

Where the physics-only approach visibly falls short: the injected crack is geometrically correct (a thin random-walk shape with the right local contrast and noise texture) but has no learned notion of what a real crack or pore looks like beyond that – no branching, no texture beyond what the blur and noise models provide, no correlation with the surrounding material’s actual microstructure. It’s a physically-consistent placeholder shape, not a learned one. That’s exactly the bar Part 2’s diffusion fine-tune needs to clear: can a model that’s actually seen real defect textures do better than physics alone at the shape and texture of the defect, while still respecting the same underlying image statistics this post calibrated by hand?

One more real-world detail: the three full-resolution panoramas (up to ~1.9MB for the synthetic one) were too large for quarto publish’s upload limit. Downscaling them was the first fix tried and it was wrong – resampling blends neighboring pixels together, and since the noise this post spends several paragraphs getting right lives entirely in how much adjacent pixels differ, a resize quietly changed the very numbers quoted above. Cropping instead – keeping the original pixels, just fewer of them – gets the file size down without touching a single value, which is why the images above are 500x300 windows rather than the full panorama.

0.9 What’s next

Part 2 fine-tunes a mask-conditioned diffusion model on the same real GDXray defect crops – does learning the texture from data beat re-rendering it from physics?

Reuse

Citation

BibTeX citation:
@online{goni2026,
  author = {Goni, Hasan},
  title = {Physics-Based {X-ray} {Simulation}},
  date = {2026-10-26},
  langid = {en}
}
For attribution, please cite this work as:
Goni, Hasan. 2026. “Physics-Based X-Ray Simulation.” October 26.
Free email course · 7 days

Industrial Vision Foundations

AI can write the code, but someone still has to know which method fits a real inspection line. One short lesson a day for 7 days on the classical computer vision techniques that still run production systems.

Runnable code in every lesson. No spam, unsubscribe any time.

Questions, corrections or feedback?

Found a mistake, or want a topic covered next? Leave a comment below (sign in with GitHub), or write to hasanme1412@gmail.com. I read everything.