CS 180  ·  Fall 2026  ·  Project 3

Fun with
Flow Models

Part A: denoising, sampling and editing images with a pretrained flow model. Part B, flow matching from scratch, is here.

01

Noisy training inputs

The forward process.

Part A uses PixNerd-XXL, a pretrained text-to-image flow model. Time 0 is pure noise and time 1 is a clean image. Given a noisy image, the time and a text prompt, the model predicts a velocity: the direction the image should move to get cleaner. I ran everything on a Kaggle T4 GPU.

To make a noisy image I mix the clean image with Gaussian noise:

xt = (1 − t) ε + t x1,   ε ~ N(0, I)

I sampled one noise tensor and reused it for all three times. My image is a burger, center-cropped and resized to 512 × 512.

def interpolate(image, noise, t): return (1 - t) * noise + t * image
originalMy burger photo, original
t = 0.25Burger photo noised to t = 0.25
t = 0.50Burger photo noised to t = 0.50
t = 0.75Burger photo noised to t = 0.75

At t = 0.25 you can only make out the light stripes on the bun. By t = 0.75 most of the detail is there.

02

Classical denoising

Gaussian blur as a baseline.

Dividing by t fixes the brightness of the image part, xt / t = x1 + ((1 − t) / t) ε, but the noise is still there. Then I blur. I tried σ = 1, 2, 4, 8 and 16 and kept the one closest to the original (lowest MSE).

t = 0.25  ·  best σ = 8

noisyNoisy burger at t = 0.25
blurredBurger at t = 0.25 after Gaussian blur, sigma 8

t = 0.50  ·  best σ = 4

noisyNoisy burger at t = 0.50
blurredBurger at t = 0.50 after Gaussian blur, sigma 4

t = 0.75  ·  best σ = 2

noisyNoisy burger at t = 0.75
blurredBurger at t = 0.75 after Gaussian blur, sigma 2

The best σ went down as t went up. Blur removes the noise but takes the detail with it. At t = 0.25 it's just colored blobs and the two bun stripes.

03

One-step denoising

One velocity prediction, then jump to t = 1.

The model predicts velocity, not noise. The path from noise to image is a straight line, so one step of size 1 − t lands at the end:

x̂1 = xt + (1 − t) vθ(xt, t, c)

c is the embedding of "a high quality photo".

def one_step_denoise(x, t, condition, predict_velocity): return x + (1 - t) * predict_velocity(x, t, condition)

t = 0.25

originalBurger, original
noisyNoisy burger at t = 0.25
one stepOne-step estimate from t = 0.25

t = 0.50

originalBurger, original
noisyNoisy burger at t = 0.50
one stepOne-step estimate from t = 0.50

t = 0.75

originalBurger, original
noisyNoisy burger at t = 0.75
one stepOne-step estimate from t = 0.75

This is much better than blur. At t = 0.25 it's clearly a burger but smooth, and the onions and mushrooms are smudged. At t = 0.75 it's almost the original.

The model can't know the exact detail the noise covered up, so one step gives something like the average of all the clean images that could have produced this noisy input. With a lot of noise that average is blurry.

04

Visualizing velocity

What the model adds, next to what it should add.

The update that gets back to my image is x1 − xt, which is (1 − t) vgt with vgt = x1 − ε. The model's update is (1 − t) v̂. I show both on the same color scale with 0 at middle gray.

The error map is the RMS over RGB at each pixel, and brighter means more error. All three maps use the same color scale. At first the scale went from 0 to the largest error, 1.62, but only a few pixels were that bad, so the maps came out almost black. Now the top of the scale is the 99th percentile over all three maps, 0.42.

@torch.inference_mode() def velocity_diagnostics(x_t, t, clean_image, epsilon, condition, predict_velocity): v_hat = predict_velocity(x_t, t, condition) predicted_update = (1 - t) * v_hat ground_truth_update = clean_image - x_t error_map = torch.sqrt(((predicted_update - ground_truth_update) ** 2).mean(dim=1)) return predicted_update, ground_truth_update, error_map
originalBurger, original

t = 0.25

noisyNoisy burger at t = 0.25
predictedPredicted update at t = 0.25
ground truthGround truth update at t = 0.25
errorRMS error map at t = 0.25

t = 0.50

noisyNoisy burger at t = 0.50
predictedPredicted update at t = 0.50
ground truthGround truth update at t = 0.50
errorRMS error map at t = 0.50

t = 0.75

noisyNoisy burger at t = 0.75
predictedPredicted update at t = 0.75
ground truthGround truth update at t = 0.75
errorRMS error map at t = 0.75

The two updates look almost the same. Their difference is x̂1 − x1, the one-step error, so the map shows where one step missed. The error is on edges and texture: the bun outline, the onions, the lettuce and the wood grain. At t = 0.25 even the dark background has some, since the model has to guess what was under the noise. The median error goes from 0.096 at t = 0.25 to 0.057 at 0.5 and 0.035 at 0.75.

05

Euler sampling

Lots of small steps instead of one big one.

xt+h = xt + h vθ(xt, t, c)

Starting from the t = 0.5 input, I take 25 steps of h = 0.02 and predict a new velocity at each one. If the steps don't divide 1 − t evenly, the last one is shorter so it ends at exactly t = 1. I tested this with a constant velocity of 1, where the result should be the start plus (1 − tstart).

@torch.inference_mode() def euler_sample(x_start, t_start, step_size, velocity): x = x_start t = t_start num_steps = math.ceil(round((1 - t_start) / step_size, 6)) states = [(t, x)] for i in range(1, num_steps + 1): t_next = 1.0 if i == num_steps else t_start + i * step_size x = x + (t_next - t) * velocity(x, t) t = t_next if i % 5 == 0 or i == num_steps: states.append((t, x)) return x, states
t = 0.50Euler trajectory at t = 0.50
t = 0.60Euler trajectory at t = 0.60
t = 0.70Euler trajectory at t = 0.70
t = 0.80Euler trajectory at t = 0.80
t = 0.90Euler trajectory at t = 0.90
t = 1.00Euler trajectory at t = 1.00

All three methods on the same t = 0.5 input:

originalBurger, original
blurGaussian blur result from t = 0.5
one stepOne-step result from t = 0.5
EulerEuler result from t = 0.5
Method (from t = 0.5)MSE to the original
Gaussian blur, σ = 40.0248
One step0.0115
Euler, 25 steps0.0142

Euler looks the sharpest. The patty and the wood grain have real texture, and the one-step result is smoother. But Euler has a higher MSE than one step. The one-step result is close to the average guess, which is what MSE rewards. Euler makes up sharp detail that looks right but doesn't match the original pixel for pixel.

06

Generating images from noise

Euler sampling from t = 0.

Same sampler, but starting from pure noise at t = 0 (50 steps) with "a high quality photo". Seeds 180 to 184.

seed 180Sample from noise, seed 180, no guidance
seed 181Sample from noise, seed 181, no guidance
seed 182Sample from noise, seed 182, no guidance
seed 183Sample from noise, seed 183, no guidance
seed 184Sample from noise, seed 184, no guidance

All five are portraits of people, so this prompt seems to lean toward people. Without guidance the quality is mixed. Seed 180 has no face, seed 183 looks melted, and seeds 182 and 184 are okay.

07

Classifier-free guidance

Pushing harder toward the prompt.

vcfg = vc + w (vc − vu)

vc uses the prompt and vu uses the empty prompt "", both at the same xt and t. I used w = 7 and h = 0.02. I checked that w = 0 gives the plain conditional prediction.

def cfg_velocity(x, t, condition, w, predict_velocity, null_condition=''): vc = predict_velocity(x, t, condition) nc = predict_velocity(x, t, null_condition) return vc + w * (vc - nc)
seed 180CFG sample, seed 180, w = 7
seed 181CFG sample, seed 181, w = 7
seed 182CFG sample, seed 182, w = 7
seed 183CFG sample, seed 183, w = 7
seed 184CFG sample, seed 184, w = 7

These use the same five noises as section 06. They're still all portraits, but now every face is clean and in focus. They're also more alike: front-facing headshots on plain backgrounds with studio lighting. So CFG gives better images and less variety.

The layout still comes from the noise. Seed 182 has a hand by the face both times.

08

Image-to-image editing

Add some noise, then let the model clean it up.

edit() noises the image to a starting time with the same noise (seed 180), then runs Euler with CFG from there to t = 1. A small starting time means more noise, so the model has to make up more and the edit is bigger. Prompt "a high quality photo", w = 7, h = 0.02.

def edit(image, noise, t_start, condition, predict_velocity, w=7, step_size=0.02): x_t = interpolate(image, noise, t_start) x, states = euler_sample(x_t, t_start, step_size, lambda x, t: cfg_velocity(x, t, condition, w, predict_velocity)) return x

Campanile

originalThe Campanile, original
t = 0.02The Campanile edited from starting time 0.02
t = 0.04The Campanile edited from starting time 0.04
t = 0.06The Campanile edited from starting time 0.06
t = 0.10The Campanile edited from starting time 0.10
t = 0.14The Campanile edited from starting time 0.14
t = 0.25The Campanile edited from starting time 0.25
t = 0.50The Campanile edited from starting time 0.50

At 0.02 only a white post against blue sky is left. At 0.06 it's a plain tower with trees, at 0.10 the top has a window, and at 0.25 the belfry arches are back. At 0.5 the clock is back too.

My burger

originalMy burger photo, original
t = 0.02My burger photo edited from starting time 0.02
t = 0.04My burger photo edited from starting time 0.04
t = 0.06My burger photo edited from starting time 0.06
t = 0.10My burger photo edited from starting time 0.10
t = 0.14My burger photo edited from starting time 0.14
t = 0.25My burger photo edited from starting time 0.25
t = 0.50My burger photo edited from starting time 0.50

The low starting times keep the warm colors but not the burger: a woman at a desk, a hand holding a red box, a red cup. At 0.14 there's a volcano sitting on a bun. At 0.25 it's a burger again.

Me

originalPhoto of me on campus, original
t = 0.02Photo of me on campus edited from starting time 0.02
t = 0.04Photo of me on campus edited from starting time 0.04
t = 0.06Photo of me on campus edited from starting time 0.06
t = 0.10Photo of me on campus edited from starting time 0.10
t = 0.14Photo of me on campus edited from starting time 0.14
t = 0.25Photo of me on campus edited from starting time 0.25
t = 0.50Photo of me on campus edited from starting time 0.50

At 0.02 it's a white pen and a picture of a woman. From 0.04 to 0.10 it's a different guy in a blue cap, wearing a blue shirt over a white t-shirt like mine. At 0.14 the Campanile and the buildings come back, and by 0.25 it's me.

Hand-drawn and web images

This works well on images that aren't photos, since the model pulls them toward real photos. I used four images from the web and two drawings I made in a browser sketch pad.

Koi painting (web)

originalJapanese ink painting of a koi, original
t = 0.02Japanese ink painting of a koi edited from starting time 0.02
t = 0.04Japanese ink painting of a koi edited from starting time 0.04
t = 0.06Japanese ink painting of a koi edited from starting time 0.06
t = 0.10Japanese ink painting of a koi edited from starting time 0.10
t = 0.14Japanese ink painting of a koi edited from starting time 0.14
t = 0.25Japanese ink painting of a koi edited from starting time 0.25
t = 0.50Japanese ink painting of a koi edited from starting time 0.50

The model doesn't see the faint fish until t = 0.5. Before that it turns the beige background into other things: a portrait at 0.02, a framed photo at 0.04, a sign at 0.06 and a washing machine door at 0.10 and 0.14. At 0.25 the round shape becomes a cartoon face.

Cat (web)

originalWhite cat with one yellow and one blue eye, original
t = 0.02White cat with one yellow and one blue eye edited from starting time 0.02
t = 0.04White cat with one yellow and one blue eye edited from starting time 0.04
t = 0.06White cat with one yellow and one blue eye edited from starting time 0.06
t = 0.10White cat with one yellow and one blue eye edited from starting time 0.10
t = 0.14White cat with one yellow and one blue eye edited from starting time 0.14
t = 0.25White cat with one yellow and one blue eye edited from starting time 0.25
t = 0.50White cat with one yellow and one blue eye edited from starting time 0.50

At 0.02 it's hands holding a white tablet, and at 0.04 it's a man holding a paper face over his own. At 0.06 it's a little girl with white hair and cat ears. At 0.10 and 0.14 it's a white cat again, but both eyes are gray. The yellow and blue eyes only come back at 0.25.

Golden Gate Bridge (web)

originalThe Golden Gate Bridge, original
t = 0.02The Golden Gate Bridge edited from starting time 0.02
t = 0.04The Golden Gate Bridge edited from starting time 0.04
t = 0.06The Golden Gate Bridge edited from starting time 0.06
t = 0.10The Golden Gate Bridge edited from starting time 0.10
t = 0.14The Golden Gate Bridge edited from starting time 0.14
t = 0.25The Golden Gate Bridge edited from starting time 0.25
t = 0.50The Golden Gate Bridge edited from starting time 0.50

At 0.02 it's someone at a desk. At 0.04 the tower turns into a woman with very long legs standing on a road, and at 0.06 it's a tall wooden clothespin by a road. From 0.10 on it's the bridge, but the fort in the corner is an armchair until 0.25.

VW Beetle (web)

originalA red VW Beetle convertible, original
t = 0.02A red VW Beetle convertible edited from starting time 0.02
t = 0.04A red VW Beetle convertible edited from starting time 0.04
t = 0.06A red VW Beetle convertible edited from starting time 0.06
t = 0.10A red VW Beetle convertible edited from starting time 0.10
t = 0.14A red VW Beetle convertible edited from starting time 0.14
t = 0.25A red VW Beetle convertible edited from starting time 0.25
t = 0.50A red VW Beetle convertible edited from starting time 0.50

At 0.02 and 0.04 it's portraits again: a smiling woman, then a man behind a red card. At 0.06 it's a modern red sports car. At 0.10 and 0.14 it's an old red convertible with too many headlights. From 0.25 on it's the Beetle, but the license plate is scrambled.

Almost every image turns into a portrait or a product photo at 0.02 and 0.04. With that much noise only the rough colors are left, so the model falls back on the kinds of photos it makes most. That matches section 06, where pure noise always gave portraits.

My frog drawing

originalMy frog drawing, original
t = 0.02My frog drawing edited from starting time 0.02
t = 0.04My frog drawing edited from starting time 0.04
t = 0.06My frog drawing edited from starting time 0.06
t = 0.10My frog drawing edited from starting time 0.10
t = 0.14My frog drawing edited from starting time 0.14
t = 0.25My frog drawing edited from starting time 0.25
t = 0.50My frog drawing edited from starting time 0.50

At the low starting times the green lines are ignored and it makes white product photos: a metal box, a carton, some round parts. At 0.10 it's a different, shaded frog drawing, and from 0.25 on it's my frog.

My bunny drawing

originalMy bunny drawing, original
t = 0.02My bunny drawing edited from starting time 0.02
t = 0.04My bunny drawing edited from starting time 0.04
t = 0.06My bunny drawing edited from starting time 0.06
t = 0.10My bunny drawing edited from starting time 0.10
t = 0.14My bunny drawing edited from starting time 0.14
t = 0.25My bunny drawing edited from starting time 0.25
t = 0.50My bunny drawing edited from starting time 0.50

It stays black and white, but from 0.02 to 0.06 it turns into gadgets: a laptop, then a machine with knobs, then a bunny-shaped gadget with knobs for eyes. At 0.10 and 0.14 it's my bunny with shiny black ears. By 0.25 it's my drawing.

Both drawings turned into product photos on white backgrounds at first. I think that's the kind of real photo closest to a white page with a few lines on it.

09

Text-guided editing

Same edit, but the prompt describes the change.

Same procedure and starting times as section 08, with my own prompt for each image.

Campanile → "a photograph of a pencil"

originalThe Campanile, original
t = 0.02The Campanile edited from starting time 0.02 with the prompt
t = 0.04The Campanile edited from starting time 0.04 with the prompt
t = 0.06The Campanile edited from starting time 0.06 with the prompt
t = 0.10The Campanile edited from starting time 0.10 with the prompt
t = 0.14The Campanile edited from starting time 0.14 with the prompt
t = 0.25The Campanile edited from starting time 0.25 with the prompt
t = 0.50The Campanile edited from starting time 0.50 with the prompt

The tower is already shaped like a pencil, so the pencil takes its place. From 0.02 to 0.06 it's a pencil standing in front of sky and trees. At 0.10 the pencil tip sits on top of the tower, and at 0.14 the tower top is back but the body still looks like wood. At 0.25 it's the Campanile.

Burger → "a photograph of a fish"

originalMy burger photo, original
t = 0.02My burger photo edited from starting time 0.02 with the prompt
t = 0.04My burger photo edited from starting time 0.04 with the prompt
t = 0.06My burger photo edited from starting time 0.06 with the prompt
t = 0.10My burger photo edited from starting time 0.10 with the prompt
t = 0.14My burger photo edited from starting time 0.14 with the prompt
t = 0.25My burger photo edited from starting time 0.25 with the prompt
t = 0.50My burger photo edited from starting time 0.50 with the prompt

From 0.02 to 0.06 it's just a fish. At 0.10 and 0.14 the fish has the bun's white stripes and colors, and at 0.25 it's a fish shaped like a burger with the lettuce for lips. At 0.5 it's a burger.

Me → "a photograph of a dragon"

originalPhoto of me, original
t = 0.02Photo of me edited from starting time 0.02 with the prompt
t = 0.04Photo of me edited from starting time 0.04 with the prompt
t = 0.06Photo of me edited from starting time 0.06 with the prompt
t = 0.10Photo of me edited from starting time 0.10 with the prompt
t = 0.14Photo of me edited from starting time 0.14 with the prompt
t = 0.25Photo of me edited from starting time 0.25 with the prompt
t = 0.50Photo of me edited from starting time 0.50 with the prompt

The low starting times give a dragon. From 0.04 on it's blue like my shirt, and the campus background shows up at 0.10. At 0.14 the dragon is wearing my shirt. At 0.25 it's me again, but with dragon scales on my legs.

More web images

For variety I tried two prompts on each of the three web photos from section 08 and kept the one I liked better.

Cat → "a photograph of an owl"

originalWhite cat, original
t = 0.02White cat edited from starting time 0.02 with the prompt
t = 0.04White cat edited from starting time 0.04 with the prompt
t = 0.06White cat edited from starting time 0.06 with the prompt
t = 0.10White cat edited from starting time 0.10 with the prompt
t = 0.14White cat edited from starting time 0.14 with the prompt
t = 0.25White cat edited from starting time 0.25 with the prompt
t = 0.50White cat edited from starting time 0.50 with the prompt

The owl gets the cat's pointy ears as ear tufts. At 0.25 it's half and half: cat ears and fur, owl feathers and a beak, and the cat's yellow and blue eyes. At 0.5 it's the cat, but its nose looks like a small beak. I also tried a polar bear, but the owl blended better.

Golden Gate Bridge → "a photograph of a roller coaster"

originalThe Golden Gate Bridge, original
t = 0.02The Golden Gate Bridge edited from starting time 0.02 with the prompt
t = 0.04The Golden Gate Bridge edited from starting time 0.04 with the prompt
t = 0.06The Golden Gate Bridge edited from starting time 0.06 with the prompt
t = 0.10The Golden Gate Bridge edited from starting time 0.10 with the prompt
t = 0.14The Golden Gate Bridge edited from starting time 0.14 with the prompt
t = 0.25The Golden Gate Bridge edited from starting time 0.25 with the prompt
t = 0.50The Golden Gate Bridge edited from starting time 0.50 with the prompt

At 0.02 it's a loop full of riders. From 0.04 to 0.14 the road deck is a coaster train full of people, and at 0.10 and 0.14 the tower top is a coaster arch. At 0.25 it's the bridge, but the cables are coaster track. I also tried "a photograph of a harp", since the cables look like strings, and at 0.25 that one gave a wooden deck with strings.

VW Beetle → "a photograph of a strawberry"

originalA red VW Beetle, original
t = 0.02A red VW Beetle edited from starting time 0.02 with the prompt
t = 0.04A red VW Beetle edited from starting time 0.04 with the prompt
t = 0.06A red VW Beetle edited from starting time 0.06 with the prompt
t = 0.10A red VW Beetle edited from starting time 0.10 with the prompt
t = 0.14A red VW Beetle edited from starting time 0.14 with the prompt
t = 0.25A red VW Beetle edited from starting time 0.25 with the prompt
t = 0.50A red VW Beetle edited from starting time 0.50 with the prompt

From 0.02 to 0.10 it's a strawberry. At 0.14 it's a strawberry with a car wheel, and at 0.25 it's the Beetle with strawberry seeds all over the paint. The obvious prompt was a ladybug, and that worked too, but the strawberry blended better.

10

Visual anagrams

One image, two prompts, upside down.

At each step I get the CFG velocity for the first prompt on xt, and for the second prompt on xt rotated 180°. I rotate the second one back and average them:

v = ( vcfg(xt, t, p1) + flip(vcfg(flip(xt), t, p2)) ) / 2

def visual_anagrams(noise, condition_a, condition_b, predict_velocity, w=7, step_size=0.02): def velocity(x, t): v1 = cfg_velocity(x, t, condition_a, w, predict_velocity) v2 = rotate180(cfg_velocity(rotate180(x), t, condition_b, w, predict_velocity)) return (v1 + v2) / 2 x, _ = euler_sample(noise, 0, step_size, velocity) return x
uprightVisual anagram, upright: an oil painting of a dog
"an oil painting of a dog"
rotated 180°The same image rotated: a bowl of fruit
"an oil painting of a bowl of fruit"
uprightVisual anagram, upright: an old woman
"an oil painting of an old woman"
rotated 180°The same image rotated: a vase of flowers
"an oil painting of a vase of flowers"

Dog and fruit (seed 180). Upright it's a dog with fruit in front of it. Upside down the dog's dark head becomes the bowl.

Old woman and vase (seed 191). Upright it's an old woman in a shawl with flowers below her. Upside down her white hair and shawl are the vase and the flowers become the bouquet. Her face ends up as shading at the bottom of the vase.

Attempts that didn't work

uprightFailed anagram attempt, old woman seed 181, upright
rotatedFailed anagram attempt, old woman seed 181, rotated
uprightFailed anagram attempt, sailboat and cat seed 191, upright
rotatedFailed anagram attempt, sailboat and cat seed 191, rotated

Old woman and vase with seed 181: the flowers took over both ways. Sailboat and cat (watercolor, seed 191): it's a cat both ways and there's no sailboat. Seed 181 for the sailboat was the same.

11

Hybrid images

Low frequencies from one prompt, high frequencies from another.

v = lowpass(v1) + v2 − lowpass(v2)

Both are CFG velocities at the same xt and t. lowpass is a Gaussian blur with σ = 16 and kernel size 265. The first prompt is what you see from far away and the second is what you see up close.

def make_hybrids(noise, condition_a, condition_b, predict_velocity, w=7, step_size=0.02): def velocity(x, t): v1 = cfg_velocity(x, t, condition_a, w, predict_velocity) v2 = cfg_velocity(x, t, condition_b, w, predict_velocity) return lowpass(v1) + (v2 - lowpass(v2)) x, _ = euler_sample(noise, 0, step_size, velocity) return x

Koi and waves (seed 180)

Far: "an ink painting of a koi fish". Close: "an ink painting of ocean waves".

Koi and waves hybrid at full, half, quarter and eighth size
At 1, 1/2, 1/4 and 1/8 size.

Up close it's orange waves with white foam. From far away the orange shape is a koi.

Panda and bamboo (seed 181)

Far: "a lithograph of a panda's face". Close: "a lithograph of a bamboo forest".

Panda and bamboo hybrid at full, half, quarter and eighth size
At 1, 1/2, 1/4 and 1/8 size.

Up close it's a forest of palms and bamboo. From far away the trees in the top corners are the ears, the two in the middle are the eye patches and the small one under them is the nose.

Attempts that didn't work

With "a lithograph of a panda" (seeds 191 and 201) there was no panda from far away, just a clump of trees. Asking for the panda's face worked better. I think that's because a face fills the whole frame.

seed 191Failed hybrid attempt, panda seed 191, at four sizes
seed 201Failed hybrid attempt, panda seed 201, at four sizes