Project 0 · Reun-Shaun Shieh · due September 1, 2026
Three exercises in what a camera actually does to space. Two of them are pure photography: move the camera, keep the subject the same size on the sensor, and watch the geometry change. The third is the Vertigo shot — and rather than physically dollying, I photographed a matched pair of frames and rebuilt the entire sweep from scratch: my own feature detector, my own RANSAC homography, my own perspective warp. The whole thing is then ported to JavaScript below — not just the renderer, the entire pipeline — so you can drag the dolly yourself, and run the effect on a pair of photographs of your own without leaving the page.
The same face, the same phone, the same lighting, and roughly the same head size in the frame. The only thing that changed is how far the camera was from my nose.
A — arm's length, ≈25–30 cm (left). The nose and cheeks balloon, the ears vanish behind the face, the forehead stretches. Nothing is wrong with the lens.
B — about 2 m back, zoomed in (right). Ears reappear, the nose sits back into the face, proportions read as they do in a mirror.
| Shot | A — close, no zoom | B — far, zoomed in |
|---|---|---|
| Camera | iPhone 16 Pro | iPhone 16 Pro |
| Subject distance | ≈ 0.25–0.30 m | ≈ 2 m |
| Lens / framing | main camera, 1×, then cropped so the head matches B | telephoto, ≈ 2× |
| Head height in frame | matched | matched |
Distances are paced estimates, not measured. Framing was matched by cropping A, which is why its pixel dimensions differ.
A pinhole camera puts a point of height h at depth d onto the sensor
at height f·h/d. The important part is the division by d: the image
of a three-dimensional object is not a uniform scaling of it, it is a scaling that gets
weaker the further away a feature is. So the ratio of depths across the face
is what decides its rendered shape.
That single number explains everything visible in shot A. The nose, lips and the front of the cheeks are the closest points, so they are magnified most; the ears are the furthest, so they are magnified least and disappear behind the cheekbones entirely. The face reads as if it were pushed outward from the middle, because geometrically it was.
In shot B the whole head sits inside a 5 % depth window, so every feature is scaled by almost the same factor. The projection is close to orthographic and the proportions survive.
The lens did not do this — the distance did. Zooming in does not change the shape of anything; it only changes which part of the same ray bundle lands on the sensor. What the zoom bought me in shot B is permission to stand two metres away while still filling the frame with a head. This is the whole reason portrait photographers reach for 85–135 mm lenses: not because long lenses are flattering, but because they let you back up until the face is depth-flat, and then crop back in.
The same corner of the same building, framed to about the same size, from two very different standing positions. What changes is not the size of the building — it is the shape of the space around it.
The building has two faces meeting at a corner. Call the near corner depth d and
the far end of the receding wall d + Δ, where Δ is fixed by the building itself
(roughly its length, maybe 15 m) and has nothing to do with where I stand. Everything
follows from the ratio again:
In A the far end of the wall is only 33 % smaller than the near corner, so the wall hardly tapers, the roofline is nearly horizontal, and the two façades keep similar widths. Layers of the scene that are genuinely far apart — the foreground trees, the wall, the sky — arrive at nearly the same magnification and therefore look glued together. That is perspective compression: it is not the telephoto lens flattening anything, it is the long shooting distance making all depth ratios approach 1.
In B I walked in until the near corner was a few metres away. Now the far end of the wall is 3.5× smaller than the near corner, so the wall visibly rushes to its vanishing point, the cornice dives down to the right, and the vertical edges splay because the camera is tilted up and no longer far enough away for the projection to be locally orthographic. The near corner dominates the frame the way the nose dominated shot A in Part 1.
Parts 1 and 2 are the same experiment at two scales, and both land on the same rule: perspective is a property of where the camera is, not of the lens on it. Focal length only decides how much of that fixed projection you keep.
Hitchcock's Vertigo shot: pull the camera back while zooming in, exactly fast enough that the subject stays the same size. The subject is nailed to the frame while the world behind it changes shape — a live demonstration of everything Parts 1 and 2 argued.
Doing this by hand needs the subject to stay registered to within a few pixels across every frame while both the tripod and the focal length move. Instead I shot a matched pair on a tripod-still phone — one frame with the subject on the table, one clean plate of the same scene with the subject removed — and then synthesised the entire sweep in code. Every intermediate frame you see below was computed, not photographed.
I want to be straightforward about the trade-off: this is a reconstruction of a dolly zoom rather than a physically executed one, and its depth model is a smooth radial approximation rather than a measured depth map. What it buys is a continuous, controllable sweep instead of eight discrete stills — which is what makes the slider below possible.
The lab above is interactive on the live page; in the PDF it is captured at its starting position. See the filmstrip for the endpoints of the sweep.
The lab above starts from tensors my Python program precomputed. This panel skips the Python entirely: drop in your own pair and the browser runs the whole pipeline — Difference-of-Gaussian detection, 81-dimensional patch descriptors, ratio-test matching, normalised DLT inside RANSAC, the perspective warp and the matte — and then hands the result to the same renderer. Nothing is uploaded anywhere; it all happens on your machine.
working…
A subject of height h at distance d covers f·h/d on the
sensor. Holding the subject the same size therefore means holding f/d constant:
if the camera retreats by a factor s, the focal length must grow by the same
s. The background does not cooperate, because its extra depth Δ does not scale:
With a single photograph there is no Δ per pixel, so I approximate the scene's depth structure
with a smooth radial profile: pixels near the locked centre are treated as near the subject's
depth and are scaled least, pixels out towards the frame edge are treated as further and are
scaled most. The background scale field is
1 + 0.65·(s−1)·(0.25 + 0.75·exp(−(r/0.68R)²)), and the subject gets a small
counter-contraction 1 + 0.06t − 0.35t·(r/r_obj)^1.35 so its silhouette stays put
while its own internal perspective changes very slightly. Both fields are radial, which is the
fact the JavaScript port exploits.
Five Gaussian blurs of the grayscale image, σ = 1.6 with k = 21/3, differenced into a
four-level DoG stack. Non-maximum suppression over 3×3×3 neighbourhoods with a |D| threshold of 0.015
gives 842 keypoints. keypoints.py: build_dog_space, find_local_extrema
A 9×9 window of the DoG level around each keypoint, flattened to 81 numbers, mean-subtracted and
divided by its standard deviation — cheap invariance to brightness and contrast.
keypoints.py: extract_descriptors
Euclidean distances between every pair of descriptors; a match is kept only if the best is closer than
0.8× the second best, so ambiguous wood-grain texture is rejected. 373 candidate matches survive.
extractor.py: match_features
3000 iterations, four random correspondences each, Hartley-normalised DLT solved by SVD, 3.5 px inlier
threshold, then a re-fit on all inliers followed by three refinement rounds. 239 inliers.
extractor.py: solve_homography, estimate_homography_ransac
The estimated homography is inverted, normalised, and handed to a perspective resample so the two
exposures share one coordinate system. main.py: warp_to_reference
Chroma seeds (a yellow test in RGB plus a saturation floor, intersected with the difference against the
aligned plate), connected components to drop specks, then geodesic region growth constrained to a
dilated band around the seed, hole filling, and a feathered alpha. main.py: foreground_mask
Inverse-mapped radial resample of the plate and of the premultiplied subject with their two different
scale fields, blended by the warped alpha. main.py: radial_warp, make_frames
A direct transliteration of radial_warp would evaluate a square root, a division
and two profile functions per pixel per frame, which is far too slow to drag. The saving grace
is that every scale field in the renderer is a function of the radius and nothing else.
The inverse warp needs r_src = r / s(r), so the whole per-pixel geometry collapses
into a one-dimensional lookup table rebuilt once per frame — a few thousand entries instead of
seven hundred thousand transcendental evaluations.
Two further things earn their keep. Compositing in premultiplied alpha means the warped
foreground and the warped alpha go straight into bg·(1−α) + fg with no
divide-then-multiply round trip. And because the subject only ever contracts towards the locked
centre, the foreground fetch is skipped entirely outside the matte's bounding box, which is
most of the frame. The result is a full inverse warp of both layers, per pixel, at interactive
rates — the render time is printed in the panel.
The renderer alone still needed my Python to have run first, because it starts from three
precomputed tensors. So pipeline.js ports everything upstream of it as well —
all seven stages above — and the panel below the lab runs them on a pair of photographs you
supply. Nothing is uploaded: the files never leave the browser.
Reproducing NumPy and SciPy honestly is most of the work. scipy.ndimage's
Gaussian is matched exactly, down to its truncate=4.0 kernel radius and its
reflecting boundary. Its binary morphology defaults to a 4-connected cross rather than a
3×3 square, so this does too, and binary_propagation becomes a breadth-first
flood inside the permitted region. The one place I deliberately diverge is
binary_dilation(seed, iterations=14): a cross dilated fourteen times reaches
exactly the pixels within city-block distance fourteen, so a two-pass chamfer distance
transform gives the identical result for a fraction of the work.
The linear algebra needed replacing outright, since there is no numpy.linalg in
a browser. The normalised DLT wants the null vector of a 2N×9 design matrix; rather than pull
in a library, the solver runs a one-sided Jacobi SVD — rotate pairs of
columns until they are mutually orthogonal, and the shortest resulting column is the smallest
singular value with the accumulated rotations as its singular vector. It is about thirty
lines, it is numerically well behaved on Hartley-normalised coordinates, and it is fast enough
to run inside all three thousand RANSAC iterations.
One more shortcut is worth naming because it changes the arithmetic. The descriptors are
mean-subtracted and divided by their standard deviation, so every one of them satisfies
‖v‖² = 81 exactly. That makes ‖a−b‖² = 162 − 2a·b, so the entire
matching stage reduces to a dot product per pair — the Euclidean distances the ratio test
needs are recovered exactly from it, at roughly half the cost.
Run against the two photographs at the top of this section, the browser finds 878 and 790 keypoints, keeps 394 matches and accepts 253 of them as inliers; the Python finds 842 and keeps 373 with 239 inliers. The small gap is resampling, not arithmetic — Pillow's Lanczos reduction and the canvas's own high-quality downscale disagree by a fraction of a level near the detector threshold, which tips a few dozen borderline extrema in or out. The inlier rate is the same to within a percentage point, and so is the recovered homography.
keypoints.py DoG scale space, extrema detection, normalised patch descriptors
extractor.py ratio matching, normalised DLT homography, RANSAC and refinement
main.py alignment, matting, the radial dolly-zoom renderer, GIF outputexport_web.py builds every figure and asset on this pagedocs/pipeline.js the JavaScript port of the detection, matching, alignment and matting
stagesdocs/dolly-lab.js the JavaScript port of the renderer, and the interface for both
panelsA plain account of who wrote what, so the line between my work and the assistant's is legible.
keypoints.py (DoG scale space, 3×3×3
extrema, normalised patch descriptors), extractor.py (ratio-test matching, normalised DLT,
RANSAC with inlier refinement) and main.py (alignment, the segmentation heuristic, the radial
field-of-view warp, premultiplied compositing, GIF output).warp_to_reference was passing a 3×3
homography to PIL's Image.Transform.AFFINE, which silently reads only the first two rows and
discards the projective terms. It now uses PERSPECTIVE with a properly normalised inverse.
make_frames and
radial_warp into docs/dolly-lab.js, including the radius-only lookup-table
reformulation, the bounding-box skip and the box-blur approximation of the Gaussian feather.
keypoints.py,
extractor.py and the alignment and matting halves of main.py into
docs/pipeline.js — including the stand-ins for the NumPy and SciPy routines that have no
browser equivalent: the one-sided Jacobi SVD behind the DLT, the 4-connected morphology, the chamfer
distance transform standing in for the fourteen-iteration dilation, and the dot-product form of the
descriptor distance.
export_web.py, which renders the keypoint, match, matte and
filmstrip figures and packages the web assets.I checked every change against my own code before keeping it, and I can explain any line in the repository. Nothing in the computer-vision pipeline was generated for me.