Skip to content

Photos alias when shrunk and turn blocky under camera zoom: image and video sample with nearest neighbour and no mipmaps #465

Description

@ThomasTartrau

image and video draw their bitmap with canvas.draw_image_rect(img, None, dst, &Paint::default()), which samples with Skia's default SamplingOptions: nearest neighbour, no mipmaps. A photo drawn smaller than its source picks one source pixel out of N and drops the rest, so it aliases (jagged hair, stair-stepped glasses, moiré on fabric). A photo drawn larger, for instance under a camera zoom, is enlarged pixel by pixel into visible blocks.

crates/rustmotion-components/src/image.rs:

let dst = Rect::from_xywh(offset_x, offset_y, draw_w, draw_h);
let paint = Paint::default();
// ...
canvas.draw_image_rect(img, None, dst, &paint);

video.rs (draw_fitted) has the same call. The camera is applied as a canvas matrix (apply_camera_transform scales the canvas), so the scale the image is finally drawn at is the layout scale times the camera zoom, and both go through that nearest-neighbour sampling.

Reproduction

A 640x640 checkerboard of 1px black/white cells (checker.png), drawn at 64px and 120px on a #808080 background. Correct downsampling averages it to the background grey.

{
  "version": "1.0",
  "video": { "width": 320, "height": 160, "fps": 30, "background": "#808080" },
  "scenes": [{
    "duration": 1.0,
    "layout": { "direction": "row", "align_items": "center", "justify_content": "center", "gap": 40 },
    "children": [
      { "type": "image", "src": "checker.png", "fit": "cover", "style": { "width": 64, "height": 64 } },
      { "type": "image", "src": "checker.png", "fit": "cover", "style": { "width": 120, "height": 120 } }
    ]
  }]
}

Luma inside each image (4px inset), render --frame 0:

drawn size mean min max near-black or near-white pixels
64px (current) 255 255 255 100%
120px (current) 124 0 255 100%
64px (mipmapped) 127 127 127 0%
120px (mipmapped) 127 127 127 0%

At 64px the scale is exactly 1/10, so every sampled pixel lands on the same parity and the image renders solid white. At 120px it is black/white noise.

Seen in production on a video using portraits: 640px faces drawn as 52px avatars in a table had visibly jagged hair and glasses, and a wall of 400px portraits under a zoom: 3.4 camera move showed pixel blocks.

Proposed fix

Choose the sampling from the on-screen size, camera included:

  • drawn smaller than the source: SamplingOptions::new(FilterMode::Linear, MipmapMode::Linear);
  • drawn at or above source size: SamplingOptions::from(CubicResampler::mitchell()) (cubic does not use mipmaps, so it is only right when enlarging).

The on-screen width is dst.width() times the scale of canvas.local_to_device_as_3x3(), which already includes the camera zoom. A shared helper in rustmotion-core::engine::renderer (draw_photo(canvas, image, dst, paint)) can replace the draw_image_rect call in image.rs and video.rs. A local patch along those lines gives the "mipmapped" rows above. icon.rs already does something similar with CubicResampler::mitchell().

The other places that draw a bitmap with Paint::default() are not covered by that patch and likely have the same problem: avatar.rs, avatar_group.rs, badge.rs, list.rs, stat.rs, mockup.rs, gif.rs, svg.rs (raster fallbacks), lottie.rs, engine/transition.rs and the sheet command.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions