Download environment/training-source/source-snapshot/src/decoder.rs from mad-bot/dinovision: direct link, hf CLI and curl.
- Browser
- Download file 13.2 kB
-
https://huggingface.co/mad-bot/dinovision/resolve/main/environment/training-source/source-snapshot/src/decoder.rs
- Command line
-
hf download hf://mad-bot/dinovision/environment/training-source/source-snapshot/src/decoder.rs
-
curl -L -o decoder.rs https://huggingface.co/mad-bot/dinovision/resolve/main/environment/training-source/source-snapshot/src/decoder.rs
13.2 kB
| //! Reconstructing RGB from DINOv3 patch features. | |
| //! | |
| //! This is the roundtrip the project is named for: encode an image to | |
| //! features, decode those features back to pixels, and look at what | |
| //! survived. | |
| //! | |
| //! # What to expect | |
| //! | |
| //! At 224Β² the encoder produces a 14Γ14 grid of 384-dimensional features β | |
| //! about 75k numbers standing in for a 150k-value image. That alone would | |
| //! permit a decent reconstruction, but DINOv3 features are trained for | |
| //! *semantic invariance*: two views of the same object should land in the | |
| //! same place regardless of colour, lighting, or fine texture, which means | |
| //! precisely that information is discarded on purpose. So the reconstruction | |
| //! recovers layout and dominant colour well and fine detail poorly. That is | |
| //! the interesting result, not a defect in the decoder. | |
| //! | |
| //! # Shape | |
| //! | |
| //! A plain convolutional upsampler, four stages of Γ2 from 14 to 224: | |
| //! | |
| //! ```text | |
| //! [384, 14, 14] β 256@28 β 128@56 β 64@112 β 32@224 β 3@224 | |
| //! ``` | |
| //! | |
| //! 2.01M parameters and about 1.14 GMAC per image. The early two stages use | |
| //! a second convolution to blend patch seams; the later stages do not. | |
| //! | |
| //! No skip connections and nothing fancy: the point is to measure what the | |
| //! features carry, and a decoder with its own access to the input would | |
| //! confuse that question entirely. | |
| use meganeura::{Graph, NodeId}; | |
| use crate::dinov3::Config; | |
| /// Channel widths after each upsampling stage, from the feature grid up. | |
| pub const STAGES: [usize; 4] = [256, 128, 64, 32]; | |
| /// How many of the early stages get a second convolution. Seams originate | |
| /// at the patch grid, so blending earns its cost at low resolution and | |
| /// mostly smooths detail away at high resolution. | |
| pub const BLEND_STAGES: usize = 2; | |
| /// Channels per group in the group norms. | |
| const GROUP_SIZE: usize = 16; | |
| const EPS: f32 = 1e-5; | |
| /// Total parameter count, for reporting and for sizing a weights file. | |
| pub fn parameter_count(config: &Config) -> usize { | |
| let mut total = 0; | |
| let mut in_c = config.hidden_size; | |
| for (i, &out_c) in STAGES.iter().enumerate() { | |
| total += out_c * in_c * 9 + out_c + out_c * 2; | |
| if i < BLEND_STAGES { | |
| total += out_c * out_c * 9 + out_c + out_c * 2; | |
| } | |
| in_c = out_c; | |
| } | |
| total += 3 * in_c * 9 + 3; // final projection to RGB | |
| total | |
| } | |
| /// Multiply-accumulates in the decoder's convolution kernels at batch one. | |
| /// Bias, normalization, activations, and upsampling are intentionally not | |
| /// folded into this number; timing still includes every operation. | |
| pub fn forward_macs(config: &Config) -> u64 { | |
| let mut total = 0u64; | |
| let mut in_c = config.hidden_size; | |
| let mut hw = config.grid(); | |
| for (i, &out_c) in STAGES.iter().enumerate() { | |
| total += (out_c * in_c * 9 * hw * hw) as u64; | |
| if i < BLEND_STAGES { | |
| total += (out_c * out_c * 9 * hw * hw) as u64; | |
| } | |
| hw *= 2; | |
| in_c = out_c; | |
| } | |
| total + (3 * in_c * 9 * hw * hw) as u64 | |
| } | |
| /// One `conv3x3 β bias β group-norm β SiLU` block at a fixed resolution. | |
| fn block( | |
| g: &mut Graph, | |
| x: NodeId, | |
| name: &str, | |
| batch: usize, | |
| in_c: usize, | |
| out_c: usize, | |
| hw: usize, | |
| ) -> NodeId { | |
| let kernel = g.parameter(&format!("{name}.weight"), &[out_c, in_c, 3, 3]); | |
| let x = g.conv2d( | |
| x, | |
| kernel, | |
| batch as u32, | |
| in_c as u32, | |
| hw as u32, | |
| hw as u32, | |
| out_c as u32, | |
| 3, | |
| 3, | |
| 1, | |
| 1, | |
| ); | |
| let bias = g.parameter(&format!("{name}.bias"), &[out_c]); | |
| let x = g.add_per_channel(x, bias, out_c as u32, (hw * hw) as u32); | |
| let gn_w = g.parameter(&format!("{name}.norm.weight"), &[out_c]); | |
| let gn_b = g.parameter(&format!("{name}.norm.bias"), &[out_c]); | |
| let x = g.group_norm( | |
| x, | |
| gn_w, | |
| gn_b, | |
| batch as u32, | |
| out_c as u32, | |
| (hw * hw) as u32, | |
| (out_c / GROUP_SIZE) as u32, | |
| EPS, | |
| ); | |
| g.silu(x) | |
| } | |
| /// Build the decoder over an existing feature node. | |
| /// | |
| /// `features` must be `[batch, hidden, grid, grid]` flattened NCHW β patch | |
| /// tokens only, transposed out of the encoder's token-major layout. See | |
| /// [`patch_features_to_nchw`]. | |
| /// | |
| /// Returns `[batch, 3, image_size, image_size]` in `[0, 1]`. | |
| pub fn build_decoder(g: &mut Graph, config: &Config, features: NodeId, batch: usize) -> NodeId { | |
| let mut x = features; | |
| let mut in_c = config.hidden_size; | |
| let mut hw = config.grid(); | |
| for (i, &out_c) in STAGES.iter().enumerate() { | |
| x = block(g, x, &format!("dec.{i}"), batch, in_c, out_c, hw); | |
| // A second convolution, but only where it pays for itself. | |
| // | |
| // The seams come from the input being piecewise constant β one | |
| // feature vector per 16Γ16 patch β so hiding them needs a receptive | |
| // field wide enough to mix across cells, and one 3Γ3 per scale | |
| // reaches only a single neighbour. Doing that at *every* scale | |
| // removed the tiling but doubled the decoder, and the decoder is | |
| // ~40% of the frame on device. | |
| // | |
| // Each stage halves the channels and doubles the resolution, so all | |
| // four second convolutions cost the same. The early ones are where | |
| // patch boundaries actually live; by 56Β² and beyond the grid has | |
| // already been blended and the extra pass mostly smooths detail | |
| // away. Keeping the first two buys the seam repair for half the | |
| // price. | |
| if i < BLEND_STAGES { | |
| x = block(g, x, &format!("dec.{i}b"), batch, out_c, out_c, hw); | |
| } | |
| x = g.upsample_2x(x, batch as u32, out_c as u32, hw as u32, hw as u32); | |
| hw *= 2; | |
| in_c = out_c; | |
| } | |
| assert_eq!( | |
| hw, config.image_size, | |
| "stage count does not reach the image resolution" | |
| ); | |
| let kernel = g.parameter("dec.out.weight", &[3, in_c, 3, 3]); | |
| let x = g.conv2d( | |
| x, | |
| kernel, | |
| batch as u32, | |
| in_c as u32, | |
| hw as u32, | |
| hw as u32, | |
| 3, | |
| 3, | |
| 3, | |
| 1, | |
| 1, | |
| ); | |
| let bias = g.parameter("dec.out.bias", &[3]); | |
| let x = g.add_per_channel(x, bias, 3, (hw * hw) as u32); | |
| // Sigmoid rather than a clamp: pixels live in [0, 1] and a hard clamp | |
| // has zero gradient outside the range, which strands any channel that | |
| // starts saturated. | |
| g.sigmoid(x) | |
| } | |
| /// Attach the decoder directly to a live encoder output. | |
| /// | |
| /// Does in the graph what [`patch_features_to_nchw`] does on the CPU: drop | |
| /// the prefix tokens and transpose from the encoder's token-major layout to | |
| /// the channel-major one convolution needs. Batch 1 only β that is what | |
| /// inference runs. | |
| pub fn attach_to_encoder(g: &mut Graph, config: &Config, encoder_out: NodeId) -> NodeId { | |
| let hidden = config.hidden_size; | |
| let patches = config.num_patches(); | |
| let prefix = config.num_prefix_tokens(); | |
| // Slice off CLS and the register tokens. They carry global rather than | |
| // spatial information and have no place on a feature map. | |
| let patch_tokens = g.split_b( | |
| encoder_out, | |
| 1, | |
| (prefix * hidden) as u32, | |
| (patches * hidden) as u32, | |
| 1, | |
| ); | |
| let patch_tokens = g.reshape(patch_tokens, &[patches, hidden]); | |
| let planes = g.transpose(patch_tokens); | |
| let planes = g.reshape(planes, &[hidden * patches]); | |
| build_decoder(g, config, planes, 1) | |
| } | |
| /// Load parameters written by `examples/train_decoder.rs`. | |
| /// | |
| /// The file is raw f32 in graph declaration order, so it is only valid for | |
| /// the graph shape that produced it; a length mismatch means the decoder | |
| /// architecture changed and the weights are stale. | |
| pub fn load_parameters( | |
| session: &mut meganeura::Session, | |
| graph: &Graph, | |
| path: &std::path::Path, | |
| ) -> Result<(), Box<dyn std::error::Error>> { | |
| let bytes = std::fs::read(path)?; | |
| let values: Vec<f32> = bytes | |
| .chunks_exact(4) | |
| .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])) | |
| .collect(); | |
| let mut offset = 0; | |
| let mut loaded = 0; | |
| for node in graph.nodes() { | |
| let meganeura::graph::Op::Parameter { name } = &node.op else { | |
| continue; | |
| }; | |
| // Only decoder parameters live in this file; the encoder's come | |
| // from the checkpoint. | |
| if !name.starts_with("dec.") { | |
| continue; | |
| } | |
| let n = node.ty.num_elements(); | |
| if offset + n > values.len() { | |
| return Err(format!( | |
| "{} is too short: needed {} values by parameter '{name}', have {}", | |
| path.display(), | |
| offset + n, | |
| values.len() | |
| ) | |
| .into()); | |
| } | |
| session.set_parameter(name, &values[offset..offset + n]); | |
| offset += n; | |
| loaded += 1; | |
| } | |
| if offset != values.len() { | |
| return Err(format!( | |
| "{} has {} values but the graph consumed {offset}; the decoder \ | |
| architecture and the weights disagree", | |
| path.display(), | |
| values.len() | |
| ) | |
| .into()); | |
| } | |
| log::info!("loaded {loaded} decoder parameters from {}", path.display()); | |
| Ok(()) | |
| } | |
| /// Rearrange the encoder's `[tokens, hidden]` output into the `[hidden, | |
| /// grid, grid]` NCHW block the decoder consumes, dropping the CLS and | |
| /// register tokens. | |
| /// | |
| /// The encoder is token-major (each row one token); convolution wants | |
| /// channel-major. Done on the CPU here because it happens once per image | |
| /// during dataset preparation; the live path folds it into the graph. | |
| pub fn patch_features_to_nchw(features: &[f32], config: &Config) -> Vec<f32> { | |
| let hidden = config.hidden_size; | |
| let patches = config.num_patches(); | |
| let skip = config.num_prefix_tokens(); | |
| assert_eq!(features.len(), config.num_tokens() * hidden); | |
| let mut out = vec![0.0f32; hidden * patches]; | |
| for p in 0..patches { | |
| let src = (skip + p) * hidden; | |
| for c in 0..hidden { | |
| out[c * patches + p] = features[src + c]; | |
| } | |
| } | |
| out | |
| } | |
| /// Peak signal-to-noise ratio in dB between two `[0, 1]` images. | |
| /// | |
| /// The number to quote for reconstruction quality. Roughly: below 15 dB is | |
| /// unrecognizable, 20 dB is a recognizable blur, 30 dB is visually close. | |
| pub fn psnr(a: &[f32], b: &[f32]) -> f32 { | |
| assert_eq!(a.len(), b.len()); | |
| let mse: f64 = a | |
| .iter() | |
| .zip(b) | |
| .map(|(x, y)| { | |
| let d = (x - y) as f64; | |
| d * d | |
| }) | |
| .sum::<f64>() | |
| / a.len() as f64; | |
| if mse <= f64::EPSILON { | |
| return f32::INFINITY; | |
| } | |
| (10.0 * (1.0 / mse).log10()) as f32 | |
| } | |
| mod tests { | |
| use super::*; | |
| fn stages_reach_the_image_resolution() { | |
| let c = Config::vits16(); | |
| assert_eq!(c.grid() * 2usize.pow(STAGES.len() as u32), c.image_size); | |
| } | |
| fn every_stage_divides_into_groups() { | |
| for &c in &STAGES { | |
| assert_eq!(c % GROUP_SIZE, 0, "{c} channels do not group evenly"); | |
| } | |
| } | |
| fn parameter_count_is_modest() { | |
| let n = parameter_count(&Config::vits16()); | |
| assert!( | |
| (1_000_000..4_000_000).contains(&n), | |
| "unexpected decoder size: {n}" | |
| ); | |
| } | |
| fn forward_macs_match_the_deployed_decoder() { | |
| let c = Config::vits16().at_resolution(224).with_layers(3); | |
| assert_eq!(forward_macs(&c), 1_141_604_352); | |
| } | |
| fn graph_builds_with_the_right_output_shape() { | |
| let c = Config::vits16(); | |
| let mut g = Graph::new(); | |
| let feat = g.input("feat", &[c.hidden_size * c.num_patches()]); | |
| let out = build_decoder(&mut g, &c, feat, 1); | |
| assert_eq!( | |
| g.node(out).ty.num_elements(), | |
| 3 * c.image_size * c.image_size | |
| ); | |
| } | |
| /// The NCHW rearrangement must move each token's channel `c` to plane | |
| /// `c` at the token's grid position, and must skip the prefix tokens. | |
| fn nchw_rearrangement_is_a_transpose_past_the_prefix() { | |
| let c = Config::vits16(); | |
| let h = c.hidden_size; | |
| // Encode each value as token*1000 + channel so the mapping is | |
| // checkable by arithmetic. | |
| let features: Vec<f32> = (0..c.num_tokens() * h) | |
| .map(|i| ((i / h) * 1000 + (i % h)) as f32) | |
| .collect(); | |
| let nchw = patch_features_to_nchw(&features, &c); | |
| assert_eq!(nchw.len(), h * c.num_patches()); | |
| for &(p, ch) in &[(0usize, 0usize), (37, 5), (195, 383)] { | |
| let token = c.num_prefix_tokens() + p; | |
| assert_eq!( | |
| nchw[ch * c.num_patches() + p], | |
| (token * 1000 + ch) as f32, | |
| "patch {p} channel {ch} came from the wrong token" | |
| ); | |
| } | |
| } | |
| fn psnr_behaves() { | |
| let a = vec![0.5f32; 100]; | |
| assert!(psnr(&a, &a).is_infinite(), "identical images are lossless"); | |
| // A uniform 0.1 error is MSE 0.01, i.e. 20 dB. | |
| let b: Vec<f32> = a.iter().map(|v| v + 0.1).collect(); | |
| assert!((psnr(&a, &b) - 20.0).abs() < 0.1); | |
| } | |
| } | |