Rust CLI tool that crops the vertical center strip of a Laowa 8-15mm fisheye photo and remaps it to rectilinear projection — producing a natural-looking wide panoramic strip.
Designed for the Laowa 8-15mm f/2.8 fisheye on any sensor. Optimised for the Fuji GFX 100S II (100MP medium format) and Nikon Z8. Focal length and camera body are read from EXIF automatically.
The fisheye packs edge content too close to the center (barrel distortion). The tool remaps each output pixel through the inverse equidistant→rectilinear transform, so edges stretch outward to their correct proportions.
The output is wider than the input — this is correct, not a bug. Rectilinear projection allocates more pixels per degree at the center than at the edges, so preserving center scale requires expanding the output width. Height scales proportionally so pixels remain square.
| Focal length | Output width expansion |
|---|---|
| 15mm | +13% |
| 12mm | +26% |
| 10mm | +46% |
| 8mm | +183% |
defish is structured as a small toolkit with subcommands:
| Command | Description |
|---|---|
flatten |
Fisheye → cylindrical/rectilinear strip (the default) |
refish |
Rectilinear → circular fisheye (the inverse of flatten) |
tunnel |
Radial warp: push the center back ("tunnel") or bulge it forward |
animate |
Render N frames morphing fisheye → flat, to video or an image sequence |
flatten is the default subcommand, so defish photo.jpg ... is shorthand
for defish flatten photo.jpg ... — existing commands keep working unchanged.
All subcommands share one remap engine and lens model (src/lib.rs).
defish refish photo.jpg # physical fisheye; a bare defish inverts it
defish refish photo.jpg --fov 180 # wider / more dramatic
defish refish photo.jpg --barrel -k 2.5 # creative barrel (not invertible)Two modes:
-
Physical (default). The exact analytic inverse of
flatten(same separable az/el model). It stamps the geometry it used into the output's EXIF, so a baredefishreads it back and recovers the original — no matching flags needed:defish refish photo.jpg # → photo_refish.jpg (geometry stamped) defish photo_refish.jpg # → photo_refish_defish.jpg ≈ photo.jpg (modulo clipping)
Set the lens angle with
--fov/-f(default ≈ a 130° fisheye). Warp strength is the field of view, so a narrow angle bends only a little. -
Barrel (
--barrel). A fill-the-frame fisheye whose curvature is set by-k/--strength, independent of field of view — turns any image into an obvious fisheye that fills the disc. It's a creative effect, not invertible byflatten.
Output is a square; --size sets the diameter (default: the shorter source side).
defish tunnel photo.jpg # default tunnel (strength 1.0)
defish tunnel photo.jpg -k 2.5 # stronger
defish tunnel photo.jpg -k -0.5 # bulge the center forward insteadA pure radial warp about the center, on any image (fisheye or not). Positive
-k/--strength compresses the center and magnifies the edges (the center looks
further away); negative bulges. The frame is filled edge-to-edge — no black
borders.
defish animate photo.jpg --steps 60 # → photo_anim.mp4 (H.264)
defish animate photo.jpg --steps 60 -o unroll.mov # ProRes
defish animate photo.jpg --steps 60 --pix-fmt yuv444p # no chroma subsampling
defish animate photo.jpg --steps 60 --frames # numbered PNG sequence
defish animate photo.jpg --steps 60 --show-crop # start on the full frameRenders --steps N frames that morph from the original fisheye (frame 0) to the
fully-flattened panorama (last frame), anchored at the center so the edges
"unroll". Takes every flatten option (-f, -p, --projection, -s, …).
- Output is chosen by the
-oextension:.mp4/.mkv→H.264,.mov→ProRes,.webm→VP9.--frameswrites a numbered image sequence instead. - ffmpeg is required for video (frames are streamed to it over a pipe — no
temp files). Install it (
brew install ffmpeg). If it's missing,animatefalls back to writing a numbered sequence and prints the ffmpeg command to assemble it. - No GIF — its 256-color palette wrecks photographic gradients. The codecs
above give full color (and 10-bit via ProRes/
--pix-fmt). --steps N,--fps(default 30; the animation speed — morph frames shown per second),--crf(default 16; lower = better/larger),--max-width(default 1920; caps frame size so videos stay sane; 0 disables).- The video is always encoded at a constant 30fps with
+faststartfor wide player compatibility — at low--fpseach morph frame is simply held for several video frames (perceived speed and duration are unchanged). This avoids finicky players (QuickTime/Preview) refusing to animate very-low-fps clips. --show-cropstarts on the whole input frame so the animation tells the full story (full photo → flat).--crop-styleorders the crop vs the unroll:simul(default — both at once, one fluid motion),phased(crop first, then unroll), orwarp-first(unroll the full frame first, then crop/zoom to the strip).--crop-frac(default 0.4) sets the first phase's share for the latter two. Without--show-crop, the animation starts already cropped to the strip.
defish photo.jpg # auto everything (= defish flatten photo.jpg)
defish flatten photo.jpg # explicit form, identical result
defish photo.jpg -p 0.5 # 50% strip height
defish photo.jpg -p 0.5 -s 0.5 # 50% strip, half-resolution output
defish photo.jpg -q 100 # max-quality JPEG (larger file)
defish photo.jpg -f 8 -c 0.79 # GFX 100S II at 8mm (override EXIF)
defish photo.jpg --projection rect --hfov 120 # straight lines over a 120° view
defish photo.jpg -v # print geometry details| Flag | Default | Description |
|---|---|---|
-p |
0.50 |
Vertical coverage as a fraction (0.30) or percent (30) — both mean 30% |
-f |
EXIF / auto | Focal length in mm (see auto-detection below) |
-c |
EXIF model | Crop factor vs full-frame (GFX=0.79, Z8=1.0) |
--projection |
auto | cyl (wide; verticals straight, horizontals bow) or rect (all straight lines, but edges stretch) |
--hfov |
full | Limit horizontal field of view (degrees) so --projection rect stays a sane size on wide fisheyes |
--calibrate |
— | Print recommended --calib-scale for a shot of known focal length, then exit |
--calib-scale |
1.0 |
Correction factor for the circle→focal-length model |
-s |
1.0 |
Output scale multiplier |
-q |
95 |
JPEG output quality, 1–100 (ignored for non-JPEG) |
-o |
auto | Output path (default: {stem}_defish.{ext}) |
-v |
off | Verbose geometry output |
The Laowa 8-15mm is a fully manual lens with no electronic contacts on most
mounts, so it writes no focal length to EXIF. When neither -f nor EXIF
supplies one, the tool recovers it from the image circle:
r_circle_px = (focal_mm / pixel_pitch_mm) · θ_max(focal)
The pixel pitch comes from the body (EXIF FocalPlaneXResolution, or a
per-model sensor-width lookup), and the circle radius is measured directly. The
relation is inverted to solve for the focal length. The radius is found from:
- Full circle — black border on all four sides (rays from the center to a consistent radius).
- Top/bottom cropped, sides fall off to black — the image circle is taller than the sensor but narrower than it. The center row still spans the full diameter, so the radius is measured from the horizontal extent (the black→image boundary on the left and right, required to be symmetric).
If neither holds (a full-frame fisheye whose circle overflows the frame on all
sides), the signal is gone — pass -f.
Because a large sensor can make r_circle(focal) non-monotonic, the detector
reports when a radius matches more than one focal length and asks you to confirm
with -f.
Aperture is not auto-detected — a single frame carries no clean, invertible aperture signal, and aperture has no effect on the defish geometry anyway.
The bare equidistant model can be a few percent off. Shoot one frame at a known focal length and let the tool fit a correction:
$ defish ref_8mm.JPG --calibrate 8
Calibration @ 8.0mm:
measured circle r = 3360px
model circle r = 3343px (pitch 3.76µm, crop 0.790×)
→ re-run with --calib-scale 1.0051
$ defish photo.JPG --calib-scale 1.0051 # now auto-detect is calibratedEvery JPEG output gets the effective processing settings written into its EXIF so they travel with the file:
- ImageDescription (
0x010E) — a human-readable summary (projection, focal length, crop factor, FF-equiv, lens type, strip %, scale, angular coverage, output size, source file). This is the field Google Photos shows as the photo's description in its info panel. - Software (
0x0131) —defish (fisheye-flatten) vX.Y.Z, the standard "processed by" field.
Both are common, human-visible fields (not maker notes), and the text is ASCII so it renders cleanly everywhere. Non-JPEG outputs (PNG/TIFF) skip this step.
$ defish DSCF1857.JPG
$ exiftool -ImageDescription DSCF1857_defish.JPG
Image Description : defish (fisheye-flatten v0.1.0): projection=rectilinear, focal=8.0mm, ...
git clone https://github.com/asah/fisheye-flatten.git
cd fisheye-flatten
cargo build --release
# binary: target/release/defishRequires Rust ≥ 1.75 stable. Uses Rayon for parallel processing.
cargo testtestdata/checkerboard.jpg is a rectilinear checkerboard fixture (center cross +
colored quadrant dots) for eyeballing defish / refish / tunnel / animate.
The integration test in tests/roundtrip.rs runs refish then flatten on it
and asserts the recovered central region matches the source in brightness,
contrast, and the on-axis center cross — i.e. the round-trip returns a similar
image, minus the cropped/out-of-FoV parts.