Simulate Image Using Spatially Variant PSF
R2026bThis example shows how to simulate the image that a lens system forms of a flat scene. The simulation reproduces geometric distortion, lateral chromatic aberration, and spatially varying aberration blur. The approach separates geometry from blur: you warp the scene per wavelength to apply distortion and lateral color, then convolve with chief-ray-centered point spread functions (PSFs) that carry only residual blur [1].
Load and Configure Optical System
Import a wide-angle lens by using the zmximport function. This lens produces pronounced barrel distortion and lateral chromatic aberration toward the field corners. The 440, 510, and 650 nanometer wavelengths represent the blue, green, and red channels, respectively. Attach a 512-by-384 sensor with a 20-micrometer pixel pitch and a 4:3 landscape orientation by using the addImageSensor function.
opsys = zmximport("WideAngleLens.zmx");
opsys.Wavelengths = [440,510,650];
opsys.addImageSensor(PixelSize=20,Resolution=[512,384]);Create Object Plane
Define the scene as a 210-by-297 mm physical object. Compute the half field of view by using the halfFieldOfView function, then derive the object distance so that the scene height fills 80% of the sensor. Focus the optical system at the computed distance by using the focus method with a fieldPoint object that specifies the on-axis position. Set the circular object plane radius larger than the scene half-diagonal to ensure all backward-traced chief rays intersect the plane.
sceneHeightMM = 210; sceneWidthMM = 297; fillFactor = 0.8; halfFOV = halfFieldOfView(opsys); objectDistance = (sceneHeightMM / 2) / fillFactor / tand(halfFOV); opsys.focus(FieldPoint=fieldPoint(Position=[0 0 -objectDistance], ... ReferenceFrame="Global")); objectPlaneRadius = max(sceneHeightMM,sceneWidthMM) / fillFactor; opsys.ObjectPlane = optics.component.ObjectPlane( ... Position=[0 0 -objectDistance], ... Shape=optics.shape.Circular(objectPlaneRadius));
Prepare Scene
Resize the scene to fill 80% of the sensor rows, preserving the physical aspect ratio for the width in pixels. Center the scene on the output array with zero-padding to represent empty space beyond the object.
outputCols = 512; outputRows = 384; pixelSizeMM = 20e-3; sceneImg = imread("sevilla.jpg"); sceneRowsPx = round(outputRows * fillFactor); sceneColsPx = round(sceneRowsPx * sceneWidthMM / sceneHeightMM); sceneImg = imresize(sceneImg,[sceneRowsPx,sceneColsPx]); padSize = [outputRows,outputCols] - size(sceneImg,[1 2]); scene = padarray(padarray(sceneImg,floor(padSize/2),0,"pre"), ... ceil(padSize/2),0,"post"); imageshow(scene);

Trace Sample Chief Rays
Define a grid of sample field points in the image reference frame by using the fieldPoint function, and trace their chief rays to the object plane by using the traceChiefRay function. The sample grid spans the full sensor so that the distortion map covers every output pixel without extrapolation. Choose an odd number of sample rows for symmetric placement with a center point. Derive the number of columns from the aspect ratio.
numSampleRows = 9; numSampleCols = 13; numChannels = numel(opsys.Wavelengths); Dx = outputCols / numSampleCols; Dy = outputRows / numSampleRows; halfCols = floor(numSampleCols/2); halfRows = floor(numSampleRows/2); sampleX = (-halfCols:halfCols) * Dx * pixelSizeMM; sampleY = (-halfRows:halfRows) * Dy * pixelSizeMM; [sampleGridX,sampleGridY] = meshgrid(sampleX,sampleY); sampleFP = fieldPoint(Position=[sampleGridX(:),sampleGridY(:)], ... ReferenceFrame="Image"); chiefRays = traceChiefRay(opsys,FieldPoints=sampleFP(:)); numSamples = numSampleRows * numSampleCols; hitPos = NaN(numSamples,2,numChannels); for wIdx = 1:numChannels rays = chiefRays(:,wIdx); valid = arrayfun(@(r) r.RayData.NumRays > 0,rays); origins = cell2mat(arrayfun(@(r) r.RayData.Origin(1:2),rays(valid),UniformOutput=false)); hitPos(valid,:,wIdx) = origins; end
Visualize Sample Intersection Points
Plot sample intersection positions on the object plane, colored by wavelength. The separation between wavelength markers at each sample position shows lateral chromatic aberration, which increases toward the field edges.
wavelengthColors = [0 0 1; 0 1 0; 1 0 0]; figure hold on for wIdx = 1:numChannels scatter(hitPos(:,1,wIdx),hitPos(:,2,wIdx), ... 36,wavelengthColors(wIdx,:),"filled") end hold off xlabel("x (mm)"); ylabel("y (mm)") title("Chief Ray Intersection Points on Object Plane") legend(string(opsys.Wavelengths) + " nm",Location="bestoutside") axis equal padded

Create Distortion Displacement Field
Interpolate the object-plane intersection positions to every output pixel and compute the difference between actual and ideal positions as a pixel displacement field. Fit the paraxial magnification from the first wavelength to define the ideal reference grid. Warp each color channel independently to reproduce geometric distortion and lateral chromatic aberration. The displacement encodes image inversion from negative magnification, which a 180-degree rotation corrects in a later step.
sensorW = outputCols * pixelSizeMM; sensorH = outputRows * pixelSizeMM;
Compute the paraxial magnification from the first wavelength.
centerIdx = sub2ind([numSampleRows,numSampleCols], ...
ceil(numSampleRows/2),ceil(numSampleCols/2));
origin = hitPos(centerIdx,:,1);
valid = ~isnan(hitPos(:,2,1));
magY = sampleGridY(valid) \ (hitPos(valid,2,1) - origin(2));Create the object-space reference grid.
maxY = origin(2) + magY * (-sensorH/2);
minY = origin(2) + magY * ( sensorH/2);
xRef = linspace(origin(1) + minY*outputCols/outputRows, ...
origin(1) + maxY*outputCols/outputRows,outputCols);
yRef = linspace(minY,maxY,outputRows);
[refGridX,refGridY] = meshgrid(xRef,yRef);
pxObj = (xRef(end) - xRef(1)) / (outputCols - 1);
pyObj = (yRef(end) - yRef(1)) / (outputRows - 1);Create the full sensor grid for interpolation.
[sensorGridX,sensorGridY] = meshgrid(linspace(-sensorW/2,sensorW/2,outputCols), ...
linspace(-sensorH/2,sensorH/2,outputRows));Warp Scene with Distortion
Apply the displacement field to warp each color channel independently, reproducing geometric distortion and lateral chromatic aberration in a single step. Correct the image inversion arising from negative magnification by using the rot90 function.
warpedScene = zeros(outputRows,outputCols,numChannels,"single"); for wIdx = 1:numChannels hx = reshape(hitPos(:,1,wIdx),[numSampleRows,numSampleCols]); hy = reshape(hitPos(:,2,wIdx),[numSampleRows,numSampleCols]); dx = (interp2(sampleGridX,sampleGridY,hx,sensorGridX,sensorGridY) - refGridX) / pxObj; dy = (interp2(sampleGridX,sampleGridY,hy,sensorGridX,sensorGridY) - refGridY) / pyObj; dx = fillNaN(dx); dy = fillNaN(dy); % Wavelength order [B G R] maps to channel order [R G B] = [3 2 1] ch = numChannels - wIdx + 1; warpedScene(:,:,ch) = rot90(single(imwarp(scene(:,:,ch),cat(3,dx,dy))),2); end imageshow(warpedScene / 255)

Compute Point Spread Functions
Compute the PSFs on a coarse 5-by-7 sub-grid across the sensor by using the psf function, rather than at every output pixel, because neighboring pixels share nearly the same aberration. Set the Method name-value argument to "Geometric" to trace ray fans through the system. Compute PSFs at half the sensor pixel size for finer aberration detail, then box-average down to sensor scale. For diffraction-accurate blur, set Method to "Huygens" instead. A GPU is recommended when you use "Huygens".
numPsfRows = 5; numPsfCols = 7; psfRes = [64,64]; psfRowIdx = round(linspace(1,numSampleRows,numPsfRows)); psfColIdx = round(linspace(1,numSampleCols,numPsfCols)); psfGridX = sampleGridX(psfRowIdx,psfColIdx); psfGridY = sampleGridY(psfRowIdx,psfColIdx); [sc,sr] = meshgrid(psfColIdx,psfRowIdx); psfFP = sampleFP(sub2ind([numSampleRows,numSampleCols],sr(:),sc(:))); numPsfs = numPsfRows * numPsfCols; psfStack = zeros(psfRes(2),psfRes(1),numChannels,numPsfs,"single"); for wIdx = 1:numChannels result = psf(opsys, ... FieldPoints=psfFP, ... Method="Geometric", ... Wavelengths=opsys.Wavelengths(wIdx), ... OutputResolution=psfRes, ... PixelSize=10); % half of 20 um sensor pixel hasImg = ~cellfun(@isempty,{result(:).Image}); % Flip to match image row-down convention for correct convolution imgs = flipud(reshape(cat(3,result(hasImg).Image), ... [psfRes(2),psfRes(1),1,nnz(hasImg)])); ch = numChannels - wIdx + 1; psfStack(:,:,ch,hasImg) = imgs; end
Downsample the PSFs from half-pixel to sensor pixel scale.
psfStack = imresize(psfStack,1/2,"box");Visualize PSF Variation
Place each colored PSF at its physical sensor position. The PSFs grow larger and more asymmetric toward the field edges, showing how aberration increases with field angle.
psfSize = [size(psfStack,1),size(psfStack,2)]; sensorRef = imref2d([outputRows,outputCols], ... [-sensorW/2,sensorW/2],[-sensorH/2,sensorH/2]); halfPsfW = psfSize(2) * pixelSizeMM / 2; halfPsfH = psfSize(1) * pixelSizeMM / 2; splatImage = zeros(outputRows,outputCols,numChannels,"single"); for psfIdx = 1:numPsfs cx = psfGridX(psfIdx); cy = psfGridY(psfIdx); psfRef = imref2d(psfSize,[cx-halfPsfW,cx+halfPsfW],[cy-halfPsfH,cy+halfPsfH]); for ch = 1:numChannels splatImage(:,:,ch) = splatImage(:,:,ch) + ... imwarp(psfStack(:,:,ch,psfIdx),psfRef,affine2d(eye(3)),OutputView=sensorRef); end end imagesc(rescale(splatImage)) axis image title("Spatially-Varying PSFs Across the Sensor")

Apply Spatially Varying Blur
Build bilinear blending windows for the PSF grid. Each window tapers linearly from 1 at its grid point to 0 at the neighbors. The set of windows sums to one at every pixel, ensuring smooth transitions between PSF regions without gaining or losing image energy. Convolve the windowed scene with the corresponding PSF kernel to produce the final rendered image.
sensorX = sensorGridX(1,:); sensorY = sensorGridY(:,1); spacingX = psfGridX(1,2) - psfGridX(1,1); spacingY = psfGridY(2,1) - psfGridY(1,1); renderedImage = zeros(outputRows,outputCols,numChannels,"single"); for psfIdx = 1:numPsfs [r,c] = ind2sub([numPsfRows,numPsfCols],psfIdx); cx = psfGridX(r,c); cy = psfGridY(r,c); % Tent window = outer product of 1-D triangles wx = max(0,1 - abs(sensorX - cx) / spacingX); wy = max(0,1 - abs(sensorY - cy) / spacingY); % Border extension: fold the phantom neighbor's tent into the edge window if c == 1, wx = wx + max(0,1 - abs(sensorX - cx + spacingX) / spacingX); end if c == numPsfCols, wx = wx + max(0,1 - abs(sensorX - cx - spacingX) / spacingX); end if r == 1, wy = wy + max(0,1 - abs(sensorY - cy + spacingY) / spacingY); end if r == numPsfRows, wy = wy + max(0,1 - abs(sensorY - cy - spacingY) / spacingY); end window = wy * wx; for ch = 1:numChannels renderedImage(:,:,ch) = renderedImage(:,:,ch) + ... conv2(warpedScene(:,:,ch) .* window,psfStack(:,:,ch,psfIdx),"same"); end end renderedImage = renderedImage / max(renderedImage(:));
View Rendered Image
The rendered image shows barrel distortion bending straight lines, lateral chromatic aberration as color fringing toward the field edges, and spatially varying blur that increases toward the corners.
imageshow(renderedImage)

Apply to Other Scenes
To apply this workflow to other scenes, use the renderImageUsingPSF helper function. This function wraps the full pipeline for any optical system and scene image.
renderedBoard = renderImageUsingPSF(opsys,imread("board.tif"), ... Width=210,Height=280,Distance=objectDistance); imageshow(renderedBoard);

Local Functions
fillNaN
Use this function to fill NaN border regions by using pchip interpolation followed by nearest extrapolation.
function out = fillNaN(A) out = fillmissing(fillmissing(A,"pchip",2),"pchip",1); out = fillmissing(fillmissing(out,"nearest",2),"nearest",1); end
References
[1] Ho, Chun Jui, Yash Belhe, Samuel Rotenberg, Ravi Ramamoorthi, Tzu-Mao Li, and Nick Antipa. "A Differentiable Wave Optics Model for End-to-End Computational Imaging System Optimization." ICCV (2025).
See Also
Objects
Functions
traceRays|opd|fieldPoint