Vision & Multimodal AI

The vision space, laid out end to end across seven subdomains — from pixel arithmetic through recognition, generation, models that reason across modalities, and output that has to drive something interactive at frame rate. Each subdomain runs from foundations to advanced, so you can enter wherever you already are.

Click a subdomain to open it, then any concept inside. PyTorch code for everything is collected at the end. Tiers are cumulative: advanced material generally assumes the foundations above it.

1 · Image Processing 2 · Computer Vision 3 · Diffusion Models 4 · Vision-Language Models 5 · Multimodal Language Modelling 6 · Generative Vision — Autoencoders & GANs 7 · Animation, Audio & Real-Time 3D Code snippets

1 · Image Processing

Libraries & packagesopencv-python (cv2) numpy scikit-image Pillow scipy.ndimage PyWavelets

Operating on images as signals rather than as scenes. Everything downstream inherits these representations, and a surprising share of "model" problems turn out to be preprocessing problems.

Foundations

Digital image representation

An image is a grid of pixels, each holding one value per channel at some bit depth — typically 8-bit RGB, giving 256 levels per channel. Height × width × channels is the tensor every later stage consumes.

Bit depth matters more than people expect: 8-bit is fine for display but clips highlights and quantises gradients, which is why medical and scientific imaging works in 12- or 16-bit.

Uses: numpy · cv2.imread · PIL.Image.open

Colour spaces

RGB is how displays emit light, not how anything perceives it. HSV separates hue from saturation and brightness, making colour thresholding tractable; LAB is designed so that equal numeric distances correspond to roughly equal perceptual differences.

Choosing the right space often converts a hard problem into a trivial one — segmenting a coloured object is painful in RGB and nearly free in HSV, because illumination changes move all three RGB channels at once.

Uses: cv2.cvtColor · skimage.color

Histograms & equalization

A histogram counts pixel intensities. Its shape tells you immediately whether an image is under- or over-exposed, low contrast, or clipped.

Equalization redistributes intensities to spread the histogram, and CLAHE does it in local tiles so one bright region does not wash out the rest. It is a standard preprocessing step wherever illumination varies across a dataset.

Uses: cv2.calcHist · cv2.equalizeHist · cv2.createCLAHE

Point operations

Operations applied to each pixel independently of its neighbours — brightness offsets, contrast scaling, gamma correction, inversion, and lookup-table mappings.

Gamma is the one that matters most and is most often ignored: pixel values are usually stored non-linearly, so averaging or blending raw values is arithmetically wrong unless you linearise first.

Uses: numpy · cv2.LUT · cv2.convertScaleAbs

Convolution & kernels

Convolution slides a small matrix over the image, replacing each pixel with a weighted sum of its neighbourhood. The kernel's values determine the operation — blur, sharpen, or edge response.

This is the single most important operation in the field. The only difference between classical image processing and a CNN is whether the kernel weights are designed or learned.

Uses: cv2.filter2D · scipy.ndimage.convolve

Smoothing & noise reduction

Gaussian blur averages with distance-weighted neighbours; median filtering replaces each pixel with the neighbourhood median and is far better against salt-and-pepper noise; bilateral filtering weights by both spatial and intensity distance.

Bilateral is the useful one when you want denoising without destroying edges — it refuses to average across a large intensity jump, which is exactly what Gaussian blur does wrong.

Uses: cv2.GaussianBlur · cv2.medianBlur · cv2.bilateralFilter

Edge detection

Edges are intensity discontinuities, found by approximating spatial derivatives. Sobel and Prewitt give gradient magnitude and direction; Laplacian responds to second derivatives.

Canny remains the standard because it is a pipeline rather than a filter: smooth, compute gradients, suppress non-maxima to thin edges, then hysteresis-threshold so weak edges survive only when connected to strong ones.

Uses: cv2.Canny · cv2.Sobel · cv2.Laplacian

Core

Thresholding & binarisation

Reducing an image to foreground and background. A global threshold works only under even illumination; Otsu's method picks it automatically by maximising between-class variance.

Adaptive thresholding computes a local threshold per region and is what makes OCR work on photographed documents, where one corner is always darker than the other.

Uses: cv2.threshold · cv2.adaptiveThreshold · skimage.filters.threshold_otsu

Morphological operations

Shape operations on binary images using a structuring element. Erosion shrinks foreground, dilation grows it; opening (erode then dilate) removes small specks, and closing (dilate then erode) fills small holes.

These are the cheap, reliable cleanup tools after thresholding or segmentation — removing isolated noise pixels and closing gaps in contours that would otherwise break connected-component analysis.

Uses: cv2.erode · cv2.dilate · cv2.morphologyEx

Image pyramids & multi-scale

A pyramid is the same image at successively lower resolutions. Gaussian pyramids downsample after blurring; Laplacian pyramids store the difference between levels, capturing detail per scale.

Scale is the recurring problem in vision — an object's size in pixels is arbitrary — and pyramids are the classical answer, echoed later by feature pyramid networks in detection.

Uses: cv2.pyrDown · cv2.pyrUp · skimage.transform.pyramid_gaussian

Frequency domain & Fourier

The Fourier transform re-expresses an image as a sum of spatial frequencies. Low frequencies carry broad structure, high frequencies carry edges and texture.

It makes some operations trivial — periodic noise becomes an isolated spike you can remove, and convolution becomes multiplication — and it explains aliasing, which is why downsampling without blurring first produces moiré artefacts.

Uses: numpy.fft · cv2.dft · scipy.fft

Geometric transforms & interpolation

Translation, rotation, scaling, affine, and perspective (homography) transforms map pixels to new positions. Because the mapping lands between pixels, the value must be interpolated — nearest neighbour, bilinear, or bicubic.

A homography maps any plane to any other plane, which is what lets you rectify a photographed document to a flat rectangle. Interpolation choice is a real quality decision: nearest neighbour preserves hard labels in masks, bilinear is right for photographs.

Uses: cv2.warpAffine · cv2.warpPerspective · cv2.findHomography

Compression & artefacts

JPEG divides the image into blocks, applies a discrete cosine transform, and quantises high-frequency coefficients — which is where the information is thrown away. PNG is lossless; WebP and AVIF improve on both.

Worth understanding because compression artefacts become training signal. A model trained only on high-quality JPEGs frequently degrades on heavily compressed real-world uploads, and blocking artefacts can be mistaken for texture.

Uses: cv2.imencode · PIL.Image.save · imageio

Advanced

Feature detectors & descriptors

Corners and blobs are locations identifiable across viewpoints. Harris detects corners; SIFT and ORB add scale and rotation invariance plus a descriptor vector for matching between images.

These carried vision for two decades and remain in use where learned features are unnecessary or unaffordable — SLAM, panorama stitching, and registration all still run on them.

Uses: cv2.SIFT_create · cv2.ORB_create · cv2.BFMatcher

Registration & stitching

Aligning multiple images of the same scene: detect features, match them, estimate a transform robustly with RANSAC to reject bad matches, then warp and blend.

RANSAC is the key idea and generalises well beyond vision — fit a model on random minimal subsets, keep the hypothesis with the most inliers, and let outliers be ignored rather than averaged in.

Uses: cv2.findHomography (RANSAC) · cv2.Stitcher

Advanced denoising & restoration

Non-local means averages similar patches from anywhere in the image rather than only nearby pixels; wavelet methods threshold coefficients in a multi-scale basis; deconvolution attempts to invert a known blur.

The insight behind non-local means — that natural images are self-similar, so the best evidence about a patch is other patches like it — later reappears in self-attention.

Uses: cv2.fastNlMeansDenoising · skimage.restoration · PyWavelets

Colour constancy & white balance

Recovering how a scene would look under neutral illumination. Grey-world assumes the average scene is achromatic; more careful methods estimate the illuminant explicitly.

It matters for datasets because illumination is a confound: a classifier can learn indoor tungsten lighting instead of the object, and then fail outdoors.

Uses: cv2.xphoto.createSimpleWB · colour-science

2 · Computer Vision

Libraries & packagestorch torchvision timm ultralytics transformers albumentations pycocotools opencv-python

Recovering meaning and geometry from images — what is present, where it is, and how the scene is arranged in three dimensions.

Foundations

The core task taxonomy

Classification assigns a label to the whole image. Detection adds localisation with bounding boxes. Semantic segmentation labels every pixel by class; instance segmentation separates individual objects; panoptic segmentation does both at once.

Choosing the weakest task that solves your problem is a real cost decision — annotation effort rises steeply from image labels to boxes to pixel masks.

Uses: torchvision.datasets · pycocotools

CNN building blocks

Convolutional layers apply learned filters, stride controls downsampling, padding preserves spatial size, and pooling aggregates locally. Stacking them grows the receptive field — how much of the input one output unit can see.

Receptive field is the concept to hold onto: if it is smaller than the object, the network structurally cannot see the whole thing, no matter how much you train it.

Uses: torch.nn.Conv2d · torch.nn.MaxPool2d

Backbones & transfer learning

A backbone is a feature extractor — ResNet, EfficientNet, ViT — pretrained on a large dataset and reused. You attach a task-specific head and fine-tune.

This is the default starting point for almost all applied vision. Early layers learn edges and textures that transfer across essentially every visual domain, so training from scratch is rarely justified below very large data scales.

Uses: timm.create_model · torchvision.models

Data augmentation

Synthetic variation applied during training — flips, crops, rotations, colour jitter, and stronger schemes like Mixup, CutMix, and RandAugment.

It encodes the invariances you want. The discipline is that augmentation must preserve the label: horizontal flips are fine for cats and wrong for text or for distinguishing left from right hands.

Uses: albumentations · torchvision.transforms.v2

IoU, NMS and detection basics

Intersection over Union measures box overlap. Detectors emit many overlapping candidates, so non-maximum suppression keeps the highest-confidence box and discards others above an IoU threshold.

NMS is a frequent source of subtle bugs: too aggressive and genuinely overlapping objects get deleted, too permissive and duplicates survive into the output.

Uses: torchvision.ops.nms · torchvision.ops.box_iou

Evaluation metrics

mAP averages precision over recall levels and IoU thresholds for detection; Dice and IoU score segmentation masks; classification uses precision, recall, and F1.

Metric choice encodes what you care about. A pedestrian detector should be scored on recall at high precision, because a missed pedestrian and a false box are not equally costly — and mAP hides that entirely.

Uses: torchmetrics · pycocotools.cocoeval

Core

Detection architectures

Two-stage detectors (R-CNN → Faster R-CNN) propose regions then classify them, favouring accuracy. One-stage detectors (YOLO, SSD, RetinaNet) predict directly over a grid in a single pass, favouring speed. Anchor-free methods (FCOS, CenterNet) drop preset box priors entirely.

Focal loss was the pivotal idea for one-stage models: background overwhelms foreground by orders of magnitude, and down-weighting easy negatives is what closed the accuracy gap.

Uses: ultralytics.YOLO · torchvision.models.detection

Segmentation architectures

FCN made segmentation fully convolutional. U-Net added skip connections between encoder and decoder, restoring the spatial detail that downsampling destroys — which is why it dominates medical imaging. Mask R-CNN adds a mask head to detection; DeepLab uses atrous convolution to widen receptive field without losing resolution.

The recurring tension is resolution against context: you must downsample to see broadly, and you must retain detail to place boundaries precisely.

Uses: segmentation_models_pytorch · torchvision maskrcnn

Vision transformers

ViT splits an image into patches, embeds them as tokens, and applies a standard transformer. Without convolution's built-in locality bias, it needs more data or stronger augmentation — but scales better when that data exists.

DETR reframed detection as set prediction with bipartite matching, removing anchors and NMS from the pipeline entirely and making detection end-to-end for the first time.

Uses: timm · transformers.ViTModel · DetrForObjectDetection

Object tracking

Maintaining identity across frames. SORT combines a Kalman filter for motion with Hungarian matching on IoU; DeepSORT adds an appearance embedding so identities survive occlusion.

The hard part is not detection but association — deciding whether the box in this frame is the same object as the box in the last one, through occlusion, crossing paths, and missed detections.

Uses: ultralytics track · filterpy (Kalman) · scipy.optimize.linear_sum_assignment

Pose estimation & keypoints

Locating structured landmarks — human joints, facial points, object keypoints. Top-down approaches detect a person then find joints within the crop; bottom-up detect all joints then group them.

Heatmap regression rather than direct coordinate regression is the standard trick: predicting a spatial probability map per keypoint is far easier to learn than predicting numbers.

Uses: ultralytics pose · mediapipe · torchvision keypointrcnn

Camera models & calibration

The pinhole model relates 3D points to image pixels through intrinsics (focal length, principal point) and extrinsics (rotation, translation). Real lenses add distortion that must be estimated and corrected.

Nothing metric is possible without this. Any claim about real-world size, distance, or position depends on knowing the camera parameters — uncalibrated images support relative reasoning only.

Uses: cv2.calibrateCamera · cv2.undistort

Advanced

Stereo & depth estimation

Two cameras a known distance apart produce disparity between corresponding points, which converts directly to depth. Monocular depth estimation instead learns the mapping from a single image using learned priors.

Monocular depth is fundamentally ambiguous — a small near object and a large far one project identically — so learned models produce plausible relative depth rather than metric truth.

Uses: cv2.StereoSGBM_create · transformers DPT

Multi-view geometry & SfM

Epipolar geometry constrains where a point in one view can appear in another, encoded in the fundamental and essential matrices. Structure from Motion recovers both 3D structure and camera poses from an unordered image collection; SLAM does it online.

Bundle adjustment — jointly refining all points and all poses to minimise reprojection error — is the optimisation at the centre of both.

Uses: COLMAP · pycolmap · cv2.findEssentialMat

Neural rendering: NeRF & Gaussian splatting

NeRF represents a scene as a neural field mapping position and viewing direction to colour and density, rendered by volumetric ray marching. 3D Gaussian splatting instead represents the scene as explicit anisotropic Gaussians and rasterises them.

Splatting largely displaced NeRF for practical use by making rendering real-time while keeping quality, trading an implicit representation for an explicit, editable one.

Uses: nerfstudio · gsplat

Self-supervised visual learning

Learning representations without labels. Contrastive methods (SimCLR, MoCo) pull augmented views of the same image together and push different images apart; masked autoencoders reconstruct hidden patches; DINO uses self-distillation.

The payoff is that labels become needed only for fine-tuning, and DINO features show emergent segmentation without ever being trained on masks — evidence the representation captures real structure.

Uses: lightly · transformers Dinov2Model

Open-vocabulary vision & SAM

Traditional detectors recognise a fixed label set. Open-vocabulary models align visual features with text embeddings so any phrase becomes a query. Segment Anything is promptable — a point, box, or mask yields a segmentation without class labels at all.

This separates segmentation from recognition: SAM finds the boundary, a language model decides what the thing is, and the two compose without retraining either.

Uses: segment_anything · transformers OwlViT

Video understanding

Adding time. Approaches range from frame-wise CNNs with temporal pooling, through 3D convolutions and two-stream networks using optical flow, to video transformers with spatiotemporal attention.

The binding constraint is cost: attention over every frame patch is quadratic in sequence length, so nearly every practical system is a compromise on sampling rate and resolution.

Uses: decord · torchvision.io.read_video · transformers VideoMAE

3 · Diffusion Models

Libraries & packagesdiffusers torch transformers accelerate peft controlnet_aux safetensors

Generative vision. How iterative denoising became the dominant approach to image synthesis, and the machinery that made it controllable and fast enough to use.

Foundations

The generative landscape

VAEs encode to a probabilistic latent and decode, giving stable training but blurry samples. GANs pit generator against discriminator, giving sharp samples but unstable training and mode collapse. Normalising flows are exactly invertible but architecturally constrained.

Diffusion won because it trades a single hard problem for many easy ones: instead of learning to generate an image in one step, learn to remove a little noise — a stable regression objective, repeated.

Uses: diffusers · torch

The forward (noising) process

Gaussian noise is added over many timesteps until the image becomes pure noise. This process is fixed, requires no learning, and has a closed form — you can jump to any timestep directly rather than simulating step by step.

That closed form is what makes training practical: sample a random timestep, noise the image once, and train on it.

Uses: diffusers.DDPMScheduler.add_noise

The reverse (denoising) process

Generation runs the process backwards: start from noise and repeatedly remove a little, guided by a network trained to predict the noise present at each step.

The training objective is remarkably plain — predict the noise you added, measured by mean squared error. Nearly all of the apparent complexity in diffusion sits in sampling, not in training.

Uses: diffusers.UNet2DConditionModel

Noise schedules

The schedule sets how much noise is added at each timestep — linear, cosine, or learned. It determines how the difficulty of the denoising task is distributed across the trajectory.

Cosine schedules improved results by not destroying the image too quickly at the start, leaving more of the trajectory spent on the steps that actually carry structure.

Uses: DDPMScheduler(beta_schedule=...)

The denoiser network

Usually a U-Net — encoder, decoder, skip connections — conditioned on the timestep so one network handles every noise level. Attention layers at lower resolutions supply global coherence. Newer systems increasingly use transformers (DiT) instead.

Timestep conditioning is what allows a single set of weights to serve as a thousand different denoisers.

Uses: UNet2DConditionModel · DiTTransformer2DModel

Core

DDPM and DDIM sampling

DDPM sampling is stochastic and originally needed a thousand steps. DDIM reformulates it as a deterministic process that can skip steps, producing comparable images in twenty to fifty.

Determinism also gives reproducibility and meaningful latent interpolation — the same seed yields the same image, and paths between seeds become traversable.

Uses: DDIMScheduler · DDPMScheduler

Classifier-free guidance

Train the model both with and without conditioning, then at sampling time extrapolate away from the unconditional prediction toward the conditional one. A guidance scale controls how far.

This single trick is most of why text-to-image works as well as it does. The scale is a direct prompt-adherence against diversity dial: too low and the prompt is ignored, too high and images become oversaturated and stereotyped.

Uses: guidance_scale in any diffusers pipeline

Latent diffusion

Rather than diffusing in pixel space, compress the image with an autoencoder and diffuse in that much smaller latent space, decoding at the end. This is what Stable Diffusion is.

The saving is enormous — a 512×512 image becomes a 64×64 latent, roughly sixty-four times fewer positions — and it is the reason high-resolution generation became feasible on consumer hardware.

Uses: AutoencoderKL · StableDiffusionPipeline

Text conditioning & cross-attention

A text encoder (originally CLIP, later T5-family) embeds the prompt, and cross-attention layers inside the denoiser let each spatial position attend to text tokens.

Text encoder quality is a major determinant of prompt fidelity — several generational jumps in prompt following came from changing the text encoder rather than the diffusion model.

Uses: CLIPTextModel · T5EncoderModel

Samplers & solvers

Sampling is numerical integration of a differential equation, so better solvers need fewer evaluations. Euler, Heun, and DPM-Solver++ trade steps against fidelity.

This is why step count is a user-facing setting: it is literally the number of function evaluations, and the useful range depends on which solver is running.

Uses: DPMSolverMultistepScheduler · EulerDiscreteScheduler

Score-based & SDE formulation

The continuous-time view: diffusion is a stochastic differential equation, and the model learns the score — the gradient of log-density — which points toward higher-probability regions.

This unifies the discrete-step and continuous formulations, and it is the framework in which most modern sampler and distillation work is derived.

Uses: ScoreSdeVeScheduler

Advanced

ControlNet & structural conditioning

ControlNet adds a trainable copy of the encoder that accepts a structural input — edges, depth, pose, segmentation — and injects it into the frozen base model through zero-initialised connections.

Zero initialisation is the trick: the added branch starts as an identity operation, so the base model is not damaged at the beginning of training. It converts an uncontrollable generator into a controllable one without retraining it.

Uses: ControlNetModel · controlnet_aux

Personalisation: LoRA, DreamBooth, textual inversion

Teaching a model a new subject or style from a handful of images. Textual inversion learns a new embedding only; DreamBooth fine-tunes the model against a rare token with a preservation loss; LoRA trains small low-rank adapters.

LoRA dominates in practice because adapters are a few megabytes, compose with each other, and swap at inference — one base model can serve hundreds of styles.

Uses: peft · pipe.load_lora_weights

Inpainting, outpainting and img2img

All three start the reverse process from something other than pure noise. img2img noises an existing image partway and denoises back; inpainting restricts changes to a mask; outpainting extends beyond the original border.

The denoising strength setting is just where in the trajectory you begin — high strength discards more of the original, low strength preserves composition and alters only surface detail.

Uses: StableDiffusionImg2ImgPipeline · ...InpaintPipeline

Distillation & few-step generation

Consistency models, latent consistency models, and adversarial distillation compress the sampling trajectory so a student produces in one to four steps what the teacher needed fifty for.

This is what made real-time and interactive generation possible. The cost is usually some diversity and fine detail — distilled models are noticeably more deterministic.

Uses: LCMScheduler · AutoPipelineForText2Image

Flow matching & rectified flow

Instead of a noising process, learn a velocity field that transports noise to data along near-straight paths. Training is a simple regression onto that velocity.

Straighter trajectories are cheaper to integrate, so fewer steps are needed by construction. Several recent large image and video generators are built on this rather than classical diffusion.

Uses: FlowMatchEulerDiscreteScheduler

Video diffusion

Extending generation across time, using temporal attention or 3D convolutions, often initialised from an image model and fine-tuned on video.

Temporal consistency is the hard part: independently generating frames yields flicker, and the compute cost scales with frame count, which is why most systems generate short clips at limited resolution.

Uses: StableVideoDiffusionPipeline · export_to_video

Evaluating generative models

FID compares feature distributions of real and generated sets; CLIP score measures prompt alignment; human preference remains the reference standard.

All the automatic metrics are weak. FID is sensitive to sample count and preprocessing and rewards distribution matching rather than quality, and a model can score well while producing images nobody likes — which is why leaderboards in this area are dominated by human-preference arenas.

Uses: torchmetrics FID · clip_score

4 · Vision-Language Models

Libraries & packagestransformers open_clip_torch torch Pillow accelerate pytesseract lmms-eval

Models that connect pixels to language — the alignment idea that made zero-shot vision possible, and the architectures that gave language models eyes.

Foundations

The alignment idea

Train an image encoder and a text encoder so that matching image–text pairs land close together in a shared embedding space, and mismatched pairs land far apart.

Once that space exists, classification becomes retrieval: embed the image, embed candidate label phrases, and take the nearest. This is the mechanism behind zero-shot vision.

Uses: open_clip · transformers CLIPModel

CLIP and contrastive pretraining

CLIP trains on hundreds of millions of image–caption pairs with a contrastive objective over a large batch: each image should match its own caption against all the others in the batch as negatives.

Batch size is a first-class hyperparameter here, because it determines how many negatives each example sees. CLIP embeddings became infrastructure — used for retrieval, filtering, guidance, and as the vision encoder inside later VLMs.

Uses: open_clip_torch · CLIPProcessor

The encoder–projector–LLM pattern

The dominant modern architecture: a pretrained vision encoder produces patch features, a projection layer maps them into the language model's embedding space, and the LLM consumes them as if they were tokens.

Its appeal is economy. Both large components are pretrained and often frozen; only the small projector must be trained, so a capable VLM can be built without training either half from scratch.

Uses: transformers AutoModelForVision2Seq

Captioning and VQA

The two canonical tasks — describe this image, and answer a question about it. Both are now generative rather than classification tasks.

Their evaluation is genuinely unresolved. n-gram metrics like CIDEr reward matching reference phrasing, so a correct description written differently scores badly, which is why benchmarks increasingly use multiple-choice or model judges.

Uses: BlipForConditionalGeneration · AutoProcessor

Core

BLIP-2 and the Q-Former

BLIP-2 inserts a lightweight querying transformer between a frozen vision encoder and a frozen LLM. A small set of learned query tokens attends to image features and outputs a fixed-length summary for the language model.

It solves the bridging problem economically: a fixed number of query outputs regardless of image resolution, so the language model's context cost stays bounded.

Uses: Blip2ForConditionalGeneration

LLaVA and visual instruction tuning

LLaVA showed that a simple linear or MLP projection plus instruction-tuning data is enough for strong conversational visual ability, with training data generated by prompting a text-only model with image annotations.

The lesson was that data mattered more than architecture — a minimal connector with good instruction data outperformed more elaborate designs, and this recipe became the default.

Uses: LlavaForConditionalGeneration

Cross-attention architectures

Flamingo interleaves gated cross-attention layers inside a frozen language model, letting text tokens attend to image features at multiple depths rather than only at the input.

It handles interleaved image–text sequences naturally and supports few-shot prompting with images, at the cost of modifying the language model's internals rather than only its input.

Uses: IdeficsForVisionText2Text

Grounding & referring expressions

Connecting language to specific image regions — "the mug behind the laptop" must resolve to coordinates, not just a description. Models output boxes or masks alongside text.

This is what separates a model that describes a scene from one that can act in it, and it is the capability robotics and UI-automation work depends on.

Uses: OwlViTForObjectDetection · GroundingDINO

OCR & document understanding

Reading text in images — signage, receipts, forms, charts, screenshots — and reasoning over layout as well as content.

It is disproportionately resolution-sensitive: small text is destroyed by the downsampling most vision encoders apply, which is why document-capable models invest heavily in high-resolution handling.

Uses: pytesseract · transformers TrOCR · docTR

Advanced

Hallucination in VLMs

Describing objects, attributes, or relationships that are not present. Object hallucination is measurable with benchmarks like POPE, which simply ask whether a given object appears.

The causes are structural: language priors overwhelm weak visual evidence, so the model completes a plausible scene rather than reporting the observed one. Mitigations include stronger visual grounding, contrastive decoding, and training on negatives.

Uses: POPE benchmark · lmms-eval

High-resolution and token cost

Vision encoders were trained at modest fixed resolutions, but real tasks need detail. Solutions include tiling the image into crops, dynamic aspect-ratio handling, and multi-scale encoding.

Every approach trades against context: more visual tokens means better detail and fewer tokens left for reasoning, and a single high-resolution image can consume thousands of tokens.

Uses: AutoProcessor(size=...) · dynamic tiling

Visual token compression

Reducing how many tokens an image occupies through pruning uninformative patches, merging similar ones, or learned resamplers.

An interesting extreme is optical compression, where text is rendered as an image because visual tokens can carry it more densely than text tokens — which inverts the usual assumption about which modality is expensive.

Uses: transformers resamplers · Perceiver

Video VLMs

Extending to video means choosing frames, since encoding every frame is infeasible. Strategies include uniform sampling, keyframe selection, and temporal pooling of features.

Sampling is where most video VLMs actually fail: an event lasting half a second is simply absent if it falls between sampled frames, and no amount of reasoning recovers it.

Uses: decord · VideoLlavaForConditionalGeneration

Spatial and 3D-aware reasoning

Answering questions about relative position, distance, size, and occlusion. Standard VLMs are notably weak here, since 2-D patch features encode little explicit geometry.

Recent work augments VLMs with reconstructed 3D structure from monocular video, moving from models that describe a scene toward models that hold a geometric representation of it.

Evaluation & contamination

Benchmarks such as MMMU, MMBench, and MathVista probe reasoning rather than recognition, usually as multiple choice for automatic scoring.

Two caveats. Multiple choice is gameable — some models score above chance from text alone, without the image. And contamination is pervasive, since benchmark images and questions are on the public web and end up in pretraining data.

Uses: lmms-eval · MMMU

5 · Multimodal Language Modelling

Libraries & packagestransformers torch torchaudio decord datasets accelerate lerobot

Beyond vision alone — architectures that take in and produce several modalities, and the problems that only appear once you stop treating text as the centre.

Foundations

What multimodal actually means

More than one modality — text, image, audio, video, and increasingly action — handled by one model. Some accept several inputs but emit only text; genuinely multimodal systems both understand and generate across modalities.

The distinction matters when reading claims: many "multimodal" models are multimodal in and text-only out.

Tokenising other modalities

Transformers consume sequences of tokens, so every modality must become one. Images become patches, audio becomes spectrogram frames or learned acoustic codes, video becomes spatiotemporal patches.

Discrete tokenisers such as VQ-VAE make generation as straightforward as next-token prediction, which is why any-to-any models tend to adopt them.

Uses: torchaudio · transformers AutoFeatureExtractor

Fusion strategies

Early fusion combines modalities at the input so all layers see everything. Late fusion encodes separately and merges near the output. Intermediate fusion interleaves cross-attention at several depths.

Early fusion allows the richest interaction and costs the most; late fusion is cheap and modular but can only combine already-formed representations.

Uses: torch.nn.MultiheadAttention

Core

Native multimodal pretraining

Training on mixed-modality data from the start, rather than attaching a vision encoder to a finished language model.

It produces better cross-modal integration because representations co-develop, at the cost of far more expensive training and the inability to reuse an existing language model.

Uses: transformers · accelerate

Interleaved data

Training on documents where images and text alternate naturally — web pages, papers, tutorials — rather than isolated caption pairs.

This is what teaches a model that an image can refer to text before it and be referred to by text after it. It is also the source of in-context multimodal few-shot ability.

Uses: datasets · webdataset

Shared versus specialised encoders

Either train modality-specific encoders that project into a common space, or push a single architecture to handle raw inputs from several modalities directly.

Specialised encoders exploit known structure and reuse pretrained weights; shared architectures are simpler and scale better, and the field has been drifting toward them as data and compute grow.

Cross-modal attention

The mechanism that lets one modality condition another — text tokens attending to image patches, audio attending to text.

Where and how often it appears is the main architectural decision in multimodal design, and it determines both integration quality and compute cost.

Uses: torch.nn.MultiheadAttention

Advanced

Any-to-any generation

One model that both understands and generates across modalities — text to image, image to audio, video to text — usually by unifying everything into discrete tokens and predicting them autoregressively.

It removes the pipeline of separate specialist models and allows genuine cross-modal composition, though per-modality quality has generally trailed dedicated systems.

Uses: transformers · VQ tokenizers

Modality imbalance and collapse

When one modality is easier or more abundant, the model leans on it and underuses the others — a VQA model that answers from the question alone, ignoring the image.

It inflates benchmark scores while the model is not actually looking. The diagnostic is simple and underused: withhold a modality and see whether performance drops. If it barely moves, it was not being used.

Uses: ablation harness · lmms-eval

Long-context multimodal

Reasoning over hours of video, long documents with figures, or extended interaction histories, where token counts run to hundreds of thousands.

Compression is unavoidable at this scale, and the open question is what to discard. Uniform sampling is simple and loses precisely the brief events that often matter most.

Uses: decord · frame samplers

Action as a modality: VLA models

Vision-language-action models add robot actions as an output modality, mapping observations and instructions directly to control commands.

Action differs from other modalities in a way that matters: it is closed-loop and consequential. Errors change the world and compound, so there is no equivalent of regenerating a bad caption.

Uses: lerobot · transformers

World models

Learned models that predict how a scene evolves, allowing an agent to simulate outcomes before acting. Increasingly used inside training loops rather than only for evaluation.

Evaluating them is unresolved: visual fidelity is easy to measure and largely irrelevant, while what matters is whether the predicted dynamics support good decisions.

Cross-modal safety and alignment

Safety work developed for text does not transfer cleanly. Instructions can be embedded in an image, harmful content can be split across modalities, and refusal behaviour trained on text often fails when the same request arrives visually.

The general lesson is that every added modality is an added attack surface, and guardrails have to be evaluated per modality and per combination rather than assumed to generalise.

6 · Generative Vision — Autoencoders & GANs

Libraries & packagestorch.nn torch.distributions torchmetrics.image diffusers.AutoencoderKL kornia timm lpips

The generative half of vision that predates diffusion and still underpins it. Autoencoders give you a latent space; GANs give you a way to make samples from one look real. Latent diffusion is both of these stacked, so this subdomain is a prerequisite for § 3 rather than an alternative to it.

Foundations

The autoencoder bottleneck

An encoder compresses input to a low-dimensional code, a decoder reconstructs the input from it, and the loss is reconstruction error against the input itself. No labels are involved, which makes it the simplest form of representation learning.

The bottleneck is the entire mechanism. Given enough latent capacity the network learns the identity function and generalises nothing — so the constraint, not the architecture, is what forces useful structure to be discovered.

Uses: torch.nn.Sequential · torch.nn.MSELoss

Denoising, sparse and contractive variants

Denoising autoencoders corrupt the input and ask for the clean version, which prevents identity-copying and produces features robust to exactly the corruption used. Sparse autoencoders penalise mean activation so only a few units fire per input, yielding parts-based features — and are now central to interpretability work on language models.

Contractive autoencoders penalise the Jacobian of the encoding with respect to the input, so the code changes little under small input perturbations. All three are the same idea: the useful autoencoder is the constrained one.

Uses: torch.autograd.functional.jacobian · torch.nn.L1Loss

Reconstruction losses

Pixel MSE is the default and the reason autoencoder outputs blur — under uncertainty the loss-minimising output is the average of plausible reconstructions. L1 blurs slightly less. Perceptual loss compares activations of a pretrained network rather than pixels, so outputs match in feature space and look sharper.

LPIPS is the standard learned perceptual metric. Combining pixel loss with perceptual loss, and sometimes an adversarial term, is what production autoencoders actually use — including the VAE inside Stable Diffusion.

Uses: lpips · torchmetrics.image.LearnedPerceptualImagePatchSimilarity

Anomaly detection by reconstruction

Train only on normal data; anything the autoencoder reconstructs badly is anomalous. Attractive because it needs no labelled defects, which is usually the situation in inspection and monitoring.

Its weakness is that a sufficiently powerful autoencoder reconstructs anomalies too. Practical systems constrain capacity, use memory banks of normal features, or compare features from a pretrained network rather than pixels.

Uses: anomalib · torch

Core

Variational autoencoders and the ELBO

A VAE encodes to a distribution rather than a point: the encoder outputs a mean and log-variance, a sample is drawn, and the decoder reconstructs from that sample. This is what makes the latent space continuous and sampleable — a plain autoencoder's latent space has holes, so decoding a random point produces nothing meaningful.

Training maximises the evidence lower bound, which is reconstruction likelihood minus the KL divergence between the encoder's posterior and a standard normal prior. The KL term is the regulariser that pulls the latent space into a usable shape.

Uses: torch.distributions.Normal · torch.distributions.kl_divergence

The reparameterisation trick

Sampling is not differentiable, so gradients cannot flow through a random draw. Writing the sample as z = mu + sigma * eps with eps drawn from a fixed standard normal moves the randomness outside the computation graph, leaving mu and sigma differentiable.

It is a small algebraic move with an outsized consequence — it is what made variational inference trainable by backpropagation, and it is the single most commonly asked VAE question.

Uses: torch.randn_like

Posterior collapse and beta-VAE

If the KL term dominates, the encoder outputs the prior regardless of input and the decoder learns to ignore the latent entirely, producing the same average image every time. This is posterior collapse, and it is the characteristic VAE failure.

Mitigations: KL annealing that ramps the weight up during training, free bits that exempt a minimum KL per dimension from the penalty, and weakening the decoder so it cannot succeed alone. Beta-VAE turns the weight into a deliberate knob — higher beta trades reconstruction quality for more disentangled factors.

Uses: torch · custom loss weighting schedules

The GAN objective

A generator maps noise to images; a discriminator classifies real against generated. The discriminator's gradient tells the generator how to become more convincing. At the theoretical optimum the generator matches the data distribution and the discriminator is reduced to guessing.

The original minimax loss saturates when the discriminator wins early, giving the generator no gradient — so in practice the non-saturating form is used, maximising log D(G(z)) rather than minimising log(1 - D(G(z))).

Uses: torch.nn.BCEWithLogitsLoss

WGAN, gradient penalty and hinge loss

When real and generated distributions barely overlap, the Jensen-Shannon objective gives vanishing gradients. WGAN approximates Earth Mover's distance instead, which stays informative — but requires the critic to be Lipschitz-constrained.

Weight clipping enforced that crudely and damaged capacity; WGAN-GP replaced it with a penalty on the gradient norm at interpolated points. Hinge loss is the common modern default, used in BigGAN and StyleGAN-adjacent work, and is simpler than either.

Uses: torch.autograd.grad · torch.nn.functional.relu

DCGAN and architectural conventions

DCGAN established the recipe that made GAN training reproducible: strided convolutions instead of pooling, transposed convolutions for upsampling, batch normalisation in both networks, ReLU in the generator with tanh output, LeakyReLU in the discriminator, and no fully-connected layers.

Much of it has since been revised — transposed convolution is now usually replaced by upsample-then-convolve to avoid checkerboard artefacts — but it remains the baseline any GAN discussion starts from.

Uses: torch.nn.ConvTranspose2d · torch.nn.BatchNorm2d · torch.nn.LeakyReLU

Mode collapse and training instability

Mode collapse is the generator discovering a handful of outputs that reliably fool the discriminator and abandoning the rest of the distribution. It looks like impressive individual samples with almost no diversity across a batch — which is why you always inspect a grid, never a single image.

Causes trace to the generator optimising against a discriminator that has not seen enough variety. Mitigations: minibatch discrimination so the discriminator sees batch statistics, WGAN-GP's better-behaved objective, two-timescale update rules with different learning rates, and spectral normalisation on the discriminator.

Uses: torch.nn.utils.spectral_norm

Conditional generation — cGAN, pix2pix, CycleGAN

A conditional GAN feeds a label or embedding to both networks so generation can be steered. pix2pix does paired image-to-image translation with a U-Net generator, an L1 term for structural fidelity, and a PatchGAN discriminator that judges overlapping local patches rather than the whole image — cheaper and sharper on texture.

CycleGAN removes the need for paired data with a cycle-consistency loss: translating to the other domain and back should return the original. It is the standard answer for style transfer when no aligned dataset exists, and its known failure is hiding information imperceptibly in the output to satisfy the cycle.

Uses: torch · kornia · pix2pix / CycleGAN reference implementations

Evaluating generative models

There is no held-out likelihood to report, so evaluation is indirect. Inception Score rewards confident and diverse classifier predictions but never looks at the real data, so it cannot detect a model that generates convincing images from the wrong distribution.

Fréchet Inception Distance fits Gaussians to Inception features of real and generated sets and measures the distance between them — lower is better, and it is the standard. Its caveats matter: it is biased by sample count, depends on the feature extractor, and is blind to memorisation of training images. Precision and recall for generative models separate fidelity from coverage, which FID conflates into one number.

Uses: torchmetrics.image.fid.FrechetInceptionDistance · torchmetrics.image.inception · clean-fid

Advanced

StyleGAN and disentangled latent spaces

StyleGAN's key move is a mapping network from the sampled z to an intermediate w space, which need not be Gaussian and so can un-warp the entangled factors that z forces together. w then modulates each layer through AdaIN, giving scale-specific control — coarse layers govern pose and shape, fine layers govern colour and texture.

StyleGAN2 traced the characteristic droplet artefacts to AdaIN's normalisation and replaced it with weight demodulation. The extended W+ space, with a separate vector per layer, is what GAN inversion and editing methods actually operate in.

Uses: stylegan2-ada-pytorch · torch

GAN inversion and latent editing

Editing a real photograph with a GAN requires first finding the latent code that reproduces it — inversion, done by optimisation, by a trained encoder, or by a hybrid of the two. There is a persistent tension between reconstruction accuracy and editability: codes that reconstruct perfectly often sit outside the well-behaved region of the latent space and edit badly.

Once inverted, editing means moving along a direction in latent space. Those directions are found supervised with attribute classifiers, or unsupervised through PCA of sampled codes (GANSpace) and closed-form factorisation (SeFa).

Uses: stylegan2-ada-pytorch · lpips · encoder4editing

Training on limited data

With a few thousand images the discriminator memorises the training set, stops providing useful gradient, and the generator degenerates. Naive augmentation makes it worse — the generator learns to reproduce the augmentation artefacts.

Adaptive discriminator augmentation applies augmentations to both real and fake inputs with a probability tuned automatically from the discriminator's overfitting signal, and the augmentations remain invisible in the generator's output. It made high-quality GAN training on small datasets practical.

Uses: stylegan2-ada-pytorch · kornia.augmentation

VQ-VAE and discrete latents

Instead of a continuous code, the encoder output is snapped to the nearest entry in a learned codebook. The gradient is passed through the non-differentiable lookup with a straight-through estimator, and a commitment loss keeps encoder outputs near their chosen codes.

Discretisation sidesteps posterior collapse and, more importantly, turns an image into a sequence of tokens — which is what lets an autoregressive transformer model images the way it models text. This is the mechanism behind DALL·E 1 and behind image tokenisation in multimodal LLMs.

Uses: vector-quantize-pytorch · torch

Where GANs still win over diffusion

Diffusion surpassed GANs on sample quality, diversity, and controllability for text-to-image, and largely displaced them there. But a GAN generates in one forward pass while diffusion needs tens — so on latency-bound and on-device work the trade-off is not settled.

Super-resolution, face restoration, real-time style transfer, and interactive editing still commonly use GANs or GAN-distilled diffusion models. The honest position is that diffusion is the default for quality and GANs for speed, with distillation steadily narrowing the gap.

Uses: diffusers · basicsr · gfpgan

7 · Animation, Audio & Real-Time 3D

Libraries & packagestorchaudio librosa mediapipe smplx open3d pytorch3d trimesh nerfstudio transformers

Vision output that has to drive something interactive — a character, a voice, a scene rendered at frame rate. The modelling here is mostly familiar; what is unfamiliar is that the deadline is 16 milliseconds and a human perceptual system is the judge.

Foundations

Rigs, skinning and blendshapes

A rig is a hierarchy of joints. Skinning binds each mesh vertex to one or more joints with weights, so moving a joint deforms the surface — linear blend skinning is the standard, with its familiar collapsing artefact at strongly bent joints.

Facial animation normally uses blendshapes instead: a set of sculpted target expressions that are linearly combined by coefficients. This is the interface most ML-driven animation targets, because predicting fifty-odd coefficients per frame is a tractable regression problem.

Uses: trimesh · smplx · ARKit blendshape coefficients

Audio as a spectrogram

The short-time Fourier transform slices a waveform into overlapping windows and takes the frequency content of each, producing a time-frequency image. Mapping frequency onto the mel scale — which is roughly logarithmic, matching human pitch perception — gives the mel spectrogram that nearly all audio models consume.

The consequence worth internalising: once audio is a mel spectrogram it is a 2D array, so convolutional and transformer architectures from vision apply unchanged. Window size sets the trade-off between time and frequency resolution, and that choice matters more than most architecture decisions.

Uses: torchaudio.transforms.MelSpectrogram · librosa.feature.melspectrogram

2D pose estimation

Locating body or hand joints in the image plane. Top-down pipelines detect a person then find joints inside the crop, which is accurate but scales with the number of people; bottom-up pipelines find all joints then group them into individuals, which is constant-time but harder.

Heatmap regression — predicting a spatial probability map per joint rather than coordinates directly — is the standard trick, because a spatial map is a far easier target to learn than raw numbers.

Uses: mediapipe · mmpose · ultralytics (pose models)

3D representations

Point clouds are unordered sets, so networks over them must be permutation-invariant — PointNet's shared MLP plus max-pooling was the founding solution. Voxels are regular and convolvable but cost memory cubically. Meshes render efficiently but are awkward to predict. Implicit representations — signed distance fields, occupancy networks — are continuous and resolution-free but need querying.

The representation choice constrains everything downstream, and picking one for a stated use case is a standard interview question rather than a detail.

Uses: open3d · pytorch3d · trimesh

Core

Motion capture from video

Lifting 2D pose to 3D, or regressing the parameters of a body model such as SMPL directly. Parametric models help because they constrain output to anatomically plausible bodies rather than arbitrary joint positions.

The persistent problems are temporal jitter, foot sliding, and depth ambiguity from a single view. Jitter is not fixed by a better per-frame model — it is fixed by predicting over a temporal window or filtering the sequence, because per-frame independence is the cause.

Uses: smplx · mmpose · pytorch3d

Temporal smoothing and filtering

Any per-frame prediction driving animation needs smoothing or it reads as noise. A Kalman filter models position and velocity and is the classical answer. The One Euro filter is the practical favourite for interactive work because it adapts its cutoff to speed — heavy smoothing when still, light smoothing when moving fast.

The trade-off is always smoothing against latency: every filter that removes jitter also delays response, and in an interactive system users feel the delay.

Uses: filterpy · One Euro filter implementations

Speech representation models

wav2vec 2.0 learns from raw audio by masking latent speech units and solving a contrastive task over quantised targets; HuBERT uses offline clustering to provide masked-prediction targets. Both give representations that fine-tune to strong recognition with a fraction of the labelled data.

Whisper took the opposite route — large-scale weakly-supervised training on diverse audio — and is the practical default for transcription because it degrades gracefully on accents, noise, and code-switching.

Uses: transformers (Wav2Vec2, HuBERT, Whisper) · torchaudio.pipelines

Audio-driven facial animation

Mapping speech to mouth motion, either through explicit phoneme-to-viseme rules or by regressing blendshape coefficients from learned audio features. Learned approaches capture coarticulation — the way a sound's mouth shape is influenced by its neighbours — which rule-based mappings miss.

Wav2Lip and SadTalker are the reference points for video-space generation. The evaluation problem is that lip sync quality is perceptual: humans detect misalignment of a few tens of milliseconds, so any metric that ignores timing is measuring the wrong thing.

Uses: transformers · torchaudio · Wav2Lip / SadTalker reference implementations

Depth and scene reconstruction

Depth arrives from stereo matching on a calibrated pair, from structure-from-motion across many views, from monocular estimators such as MiDaS and Depth Anything, or from active sensors — LiDAR and time-of-flight cameras.

The distinction that matters: monocular depth is relative unless anchored by known scale or sensor data. Treating a monocular depth map as metric is a common and consequential mistake in AR work.

Uses: transformers (Depth Anything) · open3d · cv2.StereoSGBM

Advanced

NeRF and Gaussian splatting

NeRF represents a scene as a function from 3D position and viewing direction to colour and density, rendered by marching rays and integrating. It produces striking view synthesis but was originally slow to both train and render, and it bakes lighting into the representation.

3D Gaussian Splatting instead represents the scene as millions of anisotropic Gaussians rasterised directly to the screen, reaching real-time frame rates at comparable quality. For interactive and AR applications it has largely displaced NeRF, which is a rendering-pipeline argument rather than a modelling one.

Uses: nerfstudio · gsplat · pytorch3d

The AR pipeline end to end

Pose tracking through visual-inertial odometry, fusing camera features with IMU because each covers the other's weakness — the IMU drifts but is fast, vision is stable but slow and fails on blur. Then plane detection and scene meshing, anchoring virtual content into that reconstruction, occlusion so virtual objects can pass behind real ones, and lighting estimation so they are shaded consistently.

Occlusion is the piece that most often looks wrong, and it needs either a depth sensor or a per-frame segmentation mask for people — which is a real-time vision model competing for the same compute as everything else.

Uses: ARKit · RealityKit · open3d

The real-time budget

Sixty frames per second leaves sixteen milliseconds for capture, preprocessing, inference, postprocessing, and rendering combined — and the renderer wants the GPU too. On a handheld device this sits under a thermal ceiling, so sustained performance is lower than a benchmark run suggests.

The practical consequences: measure p95 rather than mean, measure after thermal soak rather than cold, budget end to end rather than model-only, and treat model choice as subordinate to the budget. Most accuracy arguments in interactive vision are settled by latency before they are settled by metrics.

Uses: coremltools · onnxruntime · Instruments / Metal System Trace

Generative animation and video

Video diffusion extends image diffusion with temporal layers or full spatiotemporal attention, and the hard constraint is consistency — a model that generates each frame well but independently produces flicker that is immediately obvious.

Approaches include conditioning on previous frames, latent interpolation along a trajectory, and explicit motion conditioning from pose or depth sequences. Evaluation remains unresolved: per-frame quality metrics reward exactly the models that flicker.

Uses: diffusers (video pipelines) · transformers

Code Snippets

Working PyTorch for the concepts above, grouped by subdomain. Written in torch rather than library one-liners wherever the mechanics are the point — the closed-form forward diffusion, the contrastive loss, morphology as pooling — because those are the parts worth being able to write from memory.

1 · Image Processing — in PyTorch

Load an image as a tensor

import torch, torchvision
from torchvision.io import read_image
import torchvision.transforms.v2.functional as TF

img = read_image("in.jpg")                 # uint8, [C,H,W], 0-255
x   = img.float().div(255).unsqueeze(0)    # float32, [1,C,H,W], 0-1
x   = x.to("cuda")                         # everything below runs batched on GPU
print(x.shape, x.dtype, x.min().item(), x.max().item())

Colour conversion (matrix form)

# RGB -> grayscale using luminance weights, as a 1x1 convolution
w = torch.tensor([0.299, 0.587, 0.114], device=x.device).view(1, 3, 1, 1)
gray = (x * w).sum(1, keepdim=True)        # [1,1,H,W]

Convolution with your own kernel

import torch.nn.functional as F
sharpen = torch.tensor([[0., -1., 0.],
                        [-1., 5., -1.],
                        [0., -1., 0.]], device=x.device)
k = sharpen.view(1, 1, 3, 3).repeat(x.shape[1], 1, 1, 1)
out = F.conv2d(x, k, padding=1, groups=x.shape[1])   # per-channel (depthwise)

Separable Gaussian blur

def gaussian_kernel1d(sigma, ks):
    t = torch.arange(ks) - ks // 2
    g = torch.exp(-(t ** 2) / (2 * sigma ** 2))
    return (g / g.sum()).float()

g = gaussian_kernel1d(1.5, 9).to(x.device)
C = x.shape[1]
x = F.conv2d(x, g.view(1, 1, 1, -1).repeat(C, 1, 1, 1), padding=(0, 4), groups=C)
x = F.conv2d(x, g.view(1, 1, -1, 1).repeat(C, 1, 1, 1), padding=(4, 0), groups=C)
# two 1-D passes instead of one 2-D: O(k) per pixel rather than O(k^2)

Sobel edges

sx = torch.tensor([[-1., 0., 1.], [-2., 0., 2.], [-1., 0., 1.]], device=x.device)
sy = sx.T.contiguous()
gx = F.conv2d(gray, sx.view(1, 1, 3, 3), padding=1)
gy = F.conv2d(gray, sy.view(1, 1, 3, 3), padding=1)
mag = torch.sqrt(gx ** 2 + gy ** 2)
ang = torch.atan2(gy, gx)

Histogram and equalization

hist = torch.histc(gray * 255, bins=256, min=0, max=255)
cdf  = hist.cumsum(0)
cdf  = (cdf - cdf.min()) / (cdf.max() - cdf.min())      # normalise to 0-1
eq   = cdf[(gray * 255).long().clamp(0, 255)]           # LUT via indexing

Morphology as pooling

# dilation is a max filter; erosion is a min filter (= -max of the negative)
def dilate(m, k=3): return F.max_pool2d(m, k, stride=1, padding=k // 2)
def erode(m, k=3):  return -F.max_pool2d(-m, k, stride=1, padding=k // 2)

mask   = (gray > 0.5).float()
opened = dilate(erode(mask))     # remove specks
closed = erode(dilate(opened))   # fill holes

Image pyramid

pyr, level = [x], x
for _ in range(3):
    level = F.avg_pool2d(level, 2)                       # blur + subsample
    pyr.append(level)
up = F.interpolate(pyr[-1], scale_factor=2, mode="bilinear", align_corners=False)

FFT and frequency filtering

f = torch.fft.fftshift(torch.fft.fft2(gray))
H, W = gray.shape[-2:]
yy, xx = torch.meshgrid(torch.arange(H), torch.arange(W), indexing="ij")
r = ((yy - H // 2) ** 2 + (xx - W // 2) ** 2).sqrt().to(f.device)
low = torch.fft.ifft2(torch.fft.ifftshift(f * (r < 30))).abs()   # low-pass

Geometric warp with grid_sample

theta = torch.tensor([[[1., 0., 0.2],          # affine: translate + scale
                        [0., 1., 0.0]]], device=x.device)
grid = F.affine_grid(theta, x.shape, align_corners=False)
warped = F.grid_sample(x, grid, mode="bilinear", align_corners=False)
# grid_sample is differentiable, so warps can sit inside a training graph

Batched preprocessing on GPU

from torchvision.transforms import v2
tf = v2.Compose([v2.ToDtype(torch.float32, scale=True),
                 v2.Resize((256, 256), antialias=True),
                 v2.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])])
batch = tf(torch.stack([read_image(p) for p in paths]).cuda())   # whole batch at once
2 · Computer Vision — in PyTorch

Dataset and DataLoader

from torch.utils.data import Dataset, DataLoader
class ImageSet(Dataset):
    def __init__(self, paths, labels, tf): self.p, self.y, self.tf = paths, labels, tf
    def __len__(self): return len(self.p)
    def __getitem__(self, i): return self.tf(read_image(self.p[i])), self.y[i]

dl = DataLoader(ImageSet(paths, labels, tf), batch_size=32, shuffle=True,
                num_workers=8, pin_memory=True, persistent_workers=True)

Transfer learning: freeze and replace the head

import torchvision.models as M
net = M.resnet50(weights=M.ResNet50_Weights.IMAGENET1K_V2)
for p in net.parameters():
    p.requires_grad = False
net.fc = torch.nn.Linear(net.fc.in_features, num_classes)   # new head trains
net = net.cuda()

Training loop with mixed precision

opt    = torch.optim.AdamW([p for p in net.parameters() if p.requires_grad], lr=3e-4)
scaler = torch.amp.GradScaler("cuda")
sched  = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=epochs)

for ep in range(epochs):
    net.train()
    for xb, yb in dl:
        xb, yb = xb.cuda(non_blocking=True), yb.cuda(non_blocking=True)
        opt.zero_grad(set_to_none=True)
        with torch.autocast("cuda", dtype=torch.bfloat16):
            loss = F.cross_entropy(net(xb), yb)
        scaler.scale(loss).backward()
        scaler.step(opt); scaler.update()
    sched.step()

IoU from scratch

def box_iou(a, b):                      # a:[N,4], b:[M,4] as x1,y1,x2,y2
    area_a = (a[:, 2] - a[:, 0]) * (a[:, 3] - a[:, 1])
    area_b = (b[:, 2] - b[:, 0]) * (b[:, 3] - b[:, 1])
    lt = torch.max(a[:, None, :2], b[None, :, :2])
    rb = torch.min(a[:, None, 2:], b[None, :, 2:])
    inter = (rb - lt).clamp(min=0).prod(dim=2)
    return inter / (area_a[:, None] + area_b[None, :] - inter)

NMS and detection post-processing

from torchvision.ops import nms, batched_nms
keep = nms(boxes, scores, iou_threshold=0.5)              # single class
keep = batched_nms(boxes, scores, class_ids, 0.5)         # per class, no cross-suppression

Dice loss for segmentation

def dice_loss(logits, target, eps=1.0):
    prob = torch.sigmoid(logits)
    num  = 2 * (prob * target).sum(dim=(2, 3)) + eps
    den  = prob.sum(dim=(2, 3)) + target.sum(dim=(2, 3)) + eps
    return 1 - (num / den).mean()

loss = 0.5 * F.binary_cross_entropy_with_logits(logits, target) + 0.5 * dice_loss(logits, target)

ViT patch embedding

class PatchEmbed(torch.nn.Module):
    def __init__(self, dim=768, patch=16, in_ch=3):
        super().__init__()
        self.proj = torch.nn.Conv2d(in_ch, dim, kernel_size=patch, stride=patch)
    def forward(self, x):                      # [B,3,224,224]
        return self.proj(x).flatten(2).transpose(1, 2)   # [B,196,768] tokens
# a strided convolution IS the patch split plus linear projection

Inference correctly

net.eval()
with torch.inference_mode():                   # no autograd, no graph
    logits = net(batch.cuda())
    probs  = logits.softmax(dim=1)
# forgetting .eval() leaves BatchNorm/Dropout in training mode — a classic silent bug
3 · Diffusion Models — in PyTorch

The noise schedule

T = 1000
betas = torch.linspace(1e-4, 0.02, T)                 # linear schedule
alphas = 1.0 - betas
abar   = torch.cumprod(alphas, dim=0)                 # alpha-bar_t

Forward process in closed form

def q_sample(x0, t, noise):
    a = abar[t].view(-1, 1, 1, 1)
    return a.sqrt() * x0 + (1 - a).sqrt() * noise
# no loop over timesteps — jump straight to any t

The training step (this is the whole objective)

t     = torch.randint(0, T, (x0.size(0),), device=x0.device)
noise = torch.randn_like(x0)
xt    = q_sample(x0, t, noise)
pred  = unet(xt, t, cond)                 # predict the noise that was added
loss  = F.mse_loss(pred, noise)
loss.backward()

Sinusoidal timestep embedding

def timestep_embedding(t, dim):
    half = dim // 2
    freqs = torch.exp(-torch.arange(half, device=t.device) * (torch.log(torch.tensor(10000.0)) / half))
    args = t[:, None].float() * freqs[None]
    return torch.cat([args.cos(), args.sin()], dim=-1)
# one network serves every noise level because t is fed in as a conditioning vector

DDPM sampling loop

@torch.no_grad()
def ddpm_sample(shape):
    x = torch.randn(shape, device=dev)
    for i in reversed(range(T)):
        t = torch.full((shape[0],), i, device=dev, dtype=torch.long)
        eps = unet(x, t, cond)
        a, ab = alphas[i], abar[i]
        mean = (x - (1 - a) / (1 - ab).sqrt() * eps) / a.sqrt()
        x = mean + (betas[i].sqrt() * torch.randn_like(x) if i > 0 else 0)
    return x

DDIM: deterministic, skippable steps

@torch.no_grad()
def ddim_sample(shape, steps=50):
    ts = torch.linspace(T - 1, 0, steps).long()
    x = torch.randn(shape, device=dev)
    for i, j in zip(ts[:-1], ts[1:]):
        eps = unet(x, i.repeat(shape[0]).to(dev), cond)
        x0  = (x - (1 - abar[i]).sqrt() * eps) / abar[i].sqrt()
        x   = abar[j].sqrt() * x0 + (1 - abar[j]).sqrt() * eps    # no noise added
    return x

Classifier-free guidance

eps_uncond = unet(x, t, null_cond)
eps_cond   = unet(x, t, cond)
eps = eps_uncond + guidance_scale * (eps_cond - eps_uncond)
# guidance_scale = 1 is plain conditional; higher pushes harder toward the prompt

Training with CFG dropout

mask = (torch.rand(cond.size(0), device=cond.device) < 0.1)   # drop 10%
cond = torch.where(mask[:, None], null_cond, cond)
# the same network learns conditional and unconditional prediction
4 · Vision-Language Models — in PyTorch

CLIP contrastive loss (symmetric InfoNCE)

def clip_loss(img_emb, txt_emb, logit_scale):
    img = F.normalize(img_emb, dim=-1)
    txt = F.normalize(txt_emb, dim=-1)
    logits = logit_scale.exp() * img @ txt.T          # [B,B]
    labels = torch.arange(len(logits), device=logits.device)
    return 0.5 * (F.cross_entropy(logits, labels) +   # image -> text
                  F.cross_entropy(logits.T, labels))  # text -> image
# every other item in the batch is a negative, so batch size is a real hyperparameter

Zero-shot classification

with torch.inference_mode():
    i = F.normalize(image_encoder(pixels), dim=-1)
    t = F.normalize(text_encoder(tokenised_labels), dim=-1)
    probs = (100.0 * i @ t.T).softmax(dim=-1)
# classification reduced to nearest-neighbour retrieval in a shared space

The projector that gives an LLM eyes

class Projector(torch.nn.Module):
    def __init__(self, vision_dim=1024, llm_dim=4096):
        super().__init__()
        self.net = torch.nn.Sequential(
            torch.nn.Linear(vision_dim, llm_dim),
            torch.nn.GELU(),
            torch.nn.Linear(llm_dim, llm_dim))
    def forward(self, patches):            # [B, N_patches, vision_dim]
        return self.net(patches)           # [B, N_patches, llm_dim] -> used as tokens

Train only the connector

for p in vision_encoder.parameters(): p.requires_grad = False
for p in language_model.parameters():  p.requires_grad = False
opt = torch.optim.AdamW(projector.parameters(), lr=1e-3)

vis   = projector(vision_encoder(pixels))                     # visual tokens
embeds = torch.cat([vis, language_model.get_input_embeddings()(text_ids)], dim=1)
loss   = language_model(inputs_embeds=embeds, labels=labels).loss

Masking image tokens out of the loss

labels = text_ids.clone()
labels[:, :vis.size(1)] = -100      # ignore_index: never predict visual positions
# forgetting this trains the model to 'generate' image tokens and quietly degrades it
5 · Multimodal Language Modelling — in PyTorch

A cross-attention block

class CrossAttn(torch.nn.Module):
    def __init__(self, dim, heads=8):
        super().__init__()
        self.attn = torch.nn.MultiheadAttention(dim, heads, batch_first=True)
        self.norm = torch.nn.LayerNorm(dim)
        self.gate = torch.nn.Parameter(torch.zeros(1))     # start as identity
    def forward(self, text, other):
        out, _ = self.attn(self.norm(text), other, other)
        return text + self.gate.tanh() * out               # gated residual

Early vs late fusion

# early: concatenate tokens, every layer sees both
seq = torch.cat([img_tokens, txt_tokens], dim=1)
out = transformer(seq)

# late: encode separately, combine at the end
out = head(torch.cat([img_encoder(img).mean(1), txt_encoder(txt).mean(1)], dim=-1))

Modality type embeddings

mod_emb = torch.nn.Embedding(3, dim)          # 0=text 1=image 2=audio
types = torch.cat([torch.zeros(Lt), torch.ones(Li), torch.full((La,), 2)]).long()
seq = seq + mod_emb(types.to(seq.device))[None]
# without this the model cannot tell which tokens came from which modality

Modality dropout during training

if self.training and torch.rand(1).item() < 0.15:
    img_tokens = torch.zeros_like(img_tokens)   # force reliance on text sometimes
# improves robustness and reduces collapse onto one modality

The collapse test

@torch.inference_mode()
def ablate(model, loader):
    full  = evaluate(model, loader)
    blind = evaluate(model, loader, zero_out="image")
    return full, blind, full - blind
# a small gap means the image was never really being used

Padding and attention masks across modalities

attn_mask = torch.cat([
    torch.ones(B, Li, dtype=torch.bool, device=dev),   # images: always present
    text_attention_mask.bool(),                        # text: variable length
], dim=1)
out = model(inputs_embeds=seq, attention_mask=attn_mask)
6 · Generative Vision — in PyTorch

A convolutional autoencoder with an explicit bottleneck

import torch, torch.nn as nn

class AutoEncoder(nn.Module):
    def __init__(self, latent=128):
        super().__init__()
        self.enc = nn.Sequential(
            nn.Conv2d(3, 32, 4, 2, 1), nn.ReLU(),    # 64 -> 32
            nn.Conv2d(32, 64, 4, 2, 1), nn.ReLU(),   # 32 -> 16
            nn.Conv2d(64, 128, 4, 2, 1), nn.ReLU(),  # 16 -> 8
            nn.Flatten(), nn.Linear(128 * 8 * 8, latent),
        )
        self.dec = nn.Sequential(
            nn.Linear(latent, 128 * 8 * 8), nn.Unflatten(1, (128, 8, 8)),
            nn.Upsample(scale_factor=2), nn.Conv2d(128, 64, 3, 1, 1), nn.ReLU(),
            nn.Upsample(scale_factor=2), nn.Conv2d(64, 32, 3, 1, 1), nn.ReLU(),
            nn.Upsample(scale_factor=2), nn.Conv2d(32, 3, 3, 1, 1), nn.Sigmoid(),
        )

    def forward(self, x):
        return self.dec(self.enc(x))
# upsample-then-convolve rather than ConvTranspose2d: no checkerboard artefacts

Denoising objective — corrupt the input, reconstruct the clean target

clean = batch
noisy = clean + 0.2 * torch.randn_like(clean)
loss  = nn.functional.mse_loss(model(noisy.clamp(0, 1)), clean)

VAE: encode to a distribution, reparameterise, decode

class VAE(nn.Module):
    def __init__(self, latent=128):
        super().__init__()
        self.backbone = nn.Sequential(nn.Flatten(), nn.Linear(3 * 64 * 64, 512), nn.ReLU())
        self.to_mu     = nn.Linear(512, latent)
        self.to_logvar = nn.Linear(512, latent)
        self.dec = nn.Sequential(
            nn.Linear(latent, 512), nn.ReLU(),
            nn.Linear(512, 3 * 64 * 64), nn.Sigmoid(), nn.Unflatten(1, (3, 64, 64)),
        )

    def encode(self, x):
        h = self.backbone(x)
        return self.to_mu(h), self.to_logvar(h)

    def reparameterise(self, mu, logvar):
        std = torch.exp(0.5 * logvar)
        eps = torch.randn_like(std)          # randomness lives outside the graph
        return mu + std * eps                # differentiable in mu and std

    def forward(self, x):
        mu, logvar = self.encode(x)
        z = self.reparameterise(mu, logvar)
        return self.dec(z), mu, logvar

The ELBO, with beta as an explicit knob

def elbo_loss(recon, x, mu, logvar, beta=1.0):
    rec = nn.functional.mse_loss(recon, x, reduction="sum") / x.size(0)
    # closed form KL between N(mu, sigma^2) and N(0, 1)
    kld = -0.5 * torch.sum(1 + logvar - mu.pow(2) - logvar.exp()) / x.size(0)
    return rec + beta * kld, rec, kld
# watch kld -> 0 during training: that is posterior collapse, not convergence

KL annealing to avoid collapse

def kl_weight(step, warmup=10_000, target=1.0):
    return target * min(1.0, step / warmup)

One GAN training step, non-saturating loss

bce = nn.BCEWithLogitsLoss()

def gan_step(G, D, real, opt_g, opt_d, z_dim=128):
    B, dev = real.size(0), real.device

    # --- discriminator ---
    z    = torch.randn(B, z_dim, device=dev)
    fake = G(z)
    d_real = D(real)
    d_fake = D(fake.detach())                # detach: no generator gradients here
    loss_d = bce(d_real, torch.ones_like(d_real)) + \
             bce(d_fake, torch.zeros_like(d_fake))
    opt_d.zero_grad(); loss_d.backward(); opt_d.step()

    # --- generator: non-saturating form ---
    d_fake = D(fake)
    loss_g = bce(d_fake, torch.ones_like(d_fake))   # maximise log D(G(z))
    opt_g.zero_grad(); loss_g.backward(); opt_g.step()
    return loss_d.item(), loss_g.item()

WGAN-GP: the gradient penalty term

def gradient_penalty(D, real, fake, lam=10.0):
    B = real.size(0)
    eps = torch.rand(B, 1, 1, 1, device=real.device)
    mixed = (eps * real + (1 - eps) * fake).requires_grad_(True)
    scores = D(mixed)
    grads = torch.autograd.grad(
        outputs=scores, inputs=mixed,
        grad_outputs=torch.ones_like(scores),
        create_graph=True, retain_graph=True,
    )[0]
    norm = grads.flatten(1).norm(2, dim=1)
    return lam * ((norm - 1) ** 2).mean()      # push the critic towards 1-Lipschitz

Spectral normalisation on the discriminator

from torch.nn.utils import spectral_norm

D = nn.Sequential(
    spectral_norm(nn.Conv2d(3, 64, 4, 2, 1)), nn.LeakyReLU(0.2),
    spectral_norm(nn.Conv2d(64, 128, 4, 2, 1)), nn.LeakyReLU(0.2),
    nn.Flatten(), spectral_norm(nn.Linear(128 * 16 * 16, 1)),
)

Detecting mode collapse from a batch

@torch.no_grad()
def sample_diversity(G, n=256, z_dim=128, dev="cuda"):
    imgs = G(torch.randn(n, z_dim, device=dev)).flatten(1)
    imgs = nn.functional.normalize(imgs, dim=1)
    sim  = imgs @ imgs.T                       # pairwise cosine similarity
    off  = sim[~torch.eye(n, dtype=torch.bool, device=dev)]
    return off.mean().item()                   # climbing towards 1.0 = collapsing

FID with torchmetrics

from torchmetrics.image.fid import FrechetInceptionDistance

fid = FrechetInceptionDistance(feature=2048).to(dev)
for real in real_loader:
    fid.update((real * 255).to(torch.uint8), real=True)
for _ in range(n_batches):
    fake = G(torch.randn(B, z_dim, device=dev))
    fid.update((fake * 255).to(torch.uint8), real=False)
print(fid.compute())    # sensitive to sample count - keep it fixed across runs

Vector quantisation with a straight-through estimator

class VectorQuantiser(nn.Module):
    def __init__(self, n_codes=512, dim=64, commit=0.25):
        super().__init__()
        self.codebook = nn.Embedding(n_codes, dim)
        self.codebook.weight.data.uniform_(-1 / n_codes, 1 / n_codes)
        self.commit = commit

    def forward(self, z):                       # z: [B, dim, H, W]
        z_ = z.permute(0, 2, 3, 1).reshape(-1, z.size(1))
        d  = torch.cdist(z_, self.codebook.weight)
        idx = d.argmin(dim=1)
        q  = self.codebook(idx).view(z.size(0), z.size(2), z.size(3), -1)
        q  = q.permute(0, 3, 1, 2)

        loss = nn.functional.mse_loss(q, z.detach()) + \
               self.commit * nn.functional.mse_loss(z, q.detach())
        q = z + (q - z).detach()                # straight-through: copy the gradient
        return q, loss, idx
7 · Animation, Audio & Real-Time 3D — in PyTorch

Waveform to mel spectrogram

import torchaudio

wav, sr = torchaudio.load("speech.wav")            # [C, T]
wav = torchaudio.functional.resample(wav, sr, 16_000).mean(0, keepdim=True)

mel = torchaudio.transforms.MelSpectrogram(
    sample_rate=16_000,
    n_fft=400,          # 25 ms window at 16 kHz
    hop_length=160,     # 10 ms hop -> 100 frames per second
    n_mels=80,
)(wav)
mel_db = torchaudio.transforms.AmplitudeToDB()(mel)   # [1, 80, frames]
# from here it is a 2D array: any vision backbone applies unchanged

A conv encoder over audio, reused straight from vision

audio_encoder = nn.Sequential(
    nn.Conv2d(1, 32, 3, padding=1), nn.BatchNorm2d(32), nn.ReLU(),
    nn.MaxPool2d(2),
    nn.Conv2d(32, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(),
    nn.AdaptiveAvgPool2d((1, None)),      # collapse mel bins, keep time
    nn.Flatten(2),                        # [B, 64, frames]
)

Regressing blendshape coefficients from audio

class AudioToBlendshapes(nn.Module):
    """Predicts 52 ARKit-style coefficients per frame from audio features."""
    def __init__(self, feat_dim=64, n_shapes=52, ctx=8):
        super().__init__()
        self.temporal = nn.Conv1d(feat_dim, 128, kernel_size=2 * ctx + 1,
                                  padding=ctx)      # sees neighbours: coarticulation
        self.head = nn.Sequential(nn.ReLU(), nn.Conv1d(128, n_shapes, 1), nn.Sigmoid())

    def forward(self, feats):                        # [B, feat_dim, frames]
        return self.head(self.temporal(feats))       # [B, 52, frames] in [0, 1]

Penalising jitter explicitly in the loss

def animation_loss(pred, target, w_vel=0.5):
    pos = nn.functional.l1_loss(pred, target)
    vel = nn.functional.l1_loss(pred.diff(dim=-1), target.diff(dim=-1))
    return pos + w_vel * vel
# the velocity term is what stops per-frame prediction reading as noise

One Euro filter — smoothing that adapts to speed

class OneEuroFilter:
    def __init__(self, freq=60.0, min_cutoff=1.0, beta=0.007):
        self.freq, self.min_cutoff, self.beta = freq, min_cutoff, beta
        self.x_prev = None
        self.dx_prev = 0.0

    def _alpha(self, cutoff):
        tau = 1.0 / (2 * torch.pi * cutoff)
        return 1.0 / (1.0 + tau * self.freq)

    def __call__(self, x):
        if self.x_prev is None:
            self.x_prev = x
            return x
        dx = (x - self.x_prev) * self.freq
        a_d = self._alpha(1.0)
        self.dx_prev = a_d * dx + (1 - a_d) * self.dx_prev
        cutoff = self.min_cutoff + self.beta * self.dx_prev.abs()
        a = self._alpha(cutoff)                  # moving fast -> less smoothing
        self.x_prev = a * x + (1 - a) * self.x_prev
        return self.x_prev

Speech features from a pretrained model

from transformers import Wav2Vec2Model, Wav2Vec2FeatureExtractor

fe    = Wav2Vec2FeatureExtractor.from_pretrained("facebook/wav2vec2-base-960h")
model = Wav2Vec2Model.from_pretrained("facebook/wav2vec2-base-960h").eval()

inputs = fe(wav.squeeze().numpy(), sampling_rate=16_000, return_tensors="pt")
with torch.no_grad():
    feats = model(**inputs).last_hidden_state      # [1, frames, 768] at ~50 Hz

Resampling two modalities onto a common timebase

def align_to(x, n_frames):
    """x: [B, C, T] at some rate -> [B, C, n_frames] at the video rate."""
    return nn.functional.interpolate(x, size=n_frames, mode="linear",
                                     align_corners=False)

audio_at_video_rate = align_to(feats.transpose(1, 2), n_frames=video.size(1))

Monocular depth, and why it is relative

from transformers import pipeline

depth = pipeline("depth-estimation", model="depth-anything/Depth-Anything-V2-Small-hf")
rel = torch.tensor(depth(image)["predicted_depth"])       # relative, unitless

def to_metric(rel, known_depth, mask):
    """Anchor with any real measurement - LiDAR sample, known object size."""
    scale = known_depth.mean() / rel[mask].mean()
    return rel * scale

Timing a model honestly on device

import time

@torch.no_grad()
def benchmark(model, x, warmup=20, runs=200):
    for _ in range(warmup):                       # never time a cold model
        model(x)
    if x.is_cuda:
        torch.cuda.synchronize()
    times = []
    for _ in range(runs):
        t0 = time.perf_counter()
        model(x)
        if x.is_cuda:
            torch.cuda.synchronize()
        times.append(time.perf_counter() - t0)
    times = torch.tensor(times)
    return {"mean_ms": times.mean().item() * 1e3,
            "p95_ms":  times.quantile(0.95).item() * 1e3}
# report p95: users feel the tail, not the mean

Export for on-device inference

import coremltools as ct

model.eval()
traced = torch.jit.trace(model, torch.randn(1, 3, 224, 224))
mlmodel = ct.convert(
    traced,
    inputs=[ct.ImageType(name="image", shape=(1, 3, 224, 224), scale=1 / 255.0)],
    compute_units=ct.ComputeUnit.ALL,      # CPU + GPU + Neural Engine
    minimum_deployment_target=ct.target.iOS17,
)
mlmodel.save("Model.mlpackage")
# check the conversion report: unsupported ops fall back to CPU silently

Related: the Stacks section covers agentic, API, data science, ML, and AWS concepts in the same format.