File size: 13,220 Bytes
eae424a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 | //! 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
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stages_reach_the_image_resolution() {
let c = Config::vits16();
assert_eq!(c.grid() * 2usize.pow(STAGES.len() as u32), c.image_size);
}
#[test]
fn every_stage_divides_into_groups() {
for &c in &STAGES {
assert_eq!(c % GROUP_SIZE, 0, "{c} channels do not group evenly");
}
}
#[test]
fn parameter_count_is_modest() {
let n = parameter_count(&Config::vits16());
assert!(
(1_000_000..4_000_000).contains(&n),
"unexpected decoder size: {n}"
);
}
#[test]
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);
}
#[test]
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.
#[test]
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"
);
}
}
#[test]
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);
}
}
|