fix(portrait): cover-crop aspect ratio, 3× source resolution
Photo portraits were stretched into a square (resize_exact to 18×18) and lost all detail. Fix doubles the problem: - Cover-crop: scale so the shorter axis fills the target, center-crop the excess — landscape or portrait photos now fill the frame without distortion. - 3× source resolution: store at 54×54 and bilinearly interpolate at render time, so each of the 18×18 grid cells is a weighted blend of ~9 source pixels instead of a single hard-sampled one.
This commit is contained in:
parent
ba576f63f4
commit
e261e9758e
2 changed files with 108 additions and 18 deletions
|
|
@ -80,6 +80,28 @@ pub enum Screen {
|
||||||
Presence,
|
Presence,
|
||||||
/// Agent gallery — portrait grid of all available agents, choose one.
|
/// Agent gallery — portrait grid of all available agents, choose one.
|
||||||
Gallery,
|
Gallery,
|
||||||
|
/// Agent Repo Manager — richer grid surfacing per-agent SeedID glyph,
|
||||||
|
/// instance count, uptime %, memory count. Successor to the simple
|
||||||
|
/// Gallery view.
|
||||||
|
AgentsManager,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-agent card data shown in the manager grid. Populated on entry to
|
||||||
|
/// the manager screen via `refresh_agent_cards`.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AgentCard {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
/// 4-glyph SeedID badge (`SeedId::glyph()`).
|
||||||
|
pub glyph: String,
|
||||||
|
/// First 8 hex chars of the pubkey, for copy-paste.
|
||||||
|
pub pubkey_prefix: String,
|
||||||
|
pub instance_count: i64,
|
||||||
|
/// Cap at 99 in display per UX spec — humans distrust 100% liveness.
|
||||||
|
pub uptime_pct: u8,
|
||||||
|
pub memory_count: usize,
|
||||||
|
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
|
|
||||||
|
|
@ -46,28 +46,82 @@ use crate::ui::presence::{Posture, Presence};
|
||||||
|
|
||||||
// ── Loaded per-agent portrait (Tier 2 source) ───────────────────
|
// ── Loaded per-agent portrait (Tier 2 source) ───────────────────
|
||||||
|
|
||||||
/// Pixel-grid portrait loaded from a PNG/JPEG on disk and downsampled to
|
/// Internal resolution multiplier for loaded photo portraits. The hand-crafted
|
||||||
/// `PORTRAIT_W × PORTRAIT_H` colors. When attached to a [`Presence`], the
|
/// fallback grid is `PORTRAIT_W × PORTRAIT_H` (18×18), but a photo needs more
|
||||||
/// renderer pulls non-overlay pixels from here instead of the hand-coded
|
/// pixels to stay recognizable. We load at `SRC_MULT ×` that resolution and
|
||||||
/// palette grid, while still painting eye/mouth/brow rows from the
|
/// bilinearly sample when the renderer asks for a grid pixel. This way a photo
|
||||||
/// state-aware overlays so the seven animation states keep working.
|
/// contributes detail at any render scale while the rendering pipeline code
|
||||||
|
/// (half-block pairing, posture modulation, scaled rendering) stays unchanged.
|
||||||
|
const SRC_MULT: usize = 3;
|
||||||
|
|
||||||
|
/// Pixel-grid portrait loaded from a PNG/JPEG on disk and stored at
|
||||||
|
/// `(PORTRAIT_W * SRC_MULT) × (PORTRAIT_H * SRC_MULT)` for detail. When the
|
||||||
|
/// renderer asks for a pixel at grid coordinate `(x, y)` (0..18), we
|
||||||
|
/// bilinearly sample the source and return an interpolated color. If the
|
||||||
|
/// coordinate is out of range (shouldn't happen in practice), `None` is
|
||||||
|
/// returned and the caller falls back to the hand-coded palette grid.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct PortraitSource {
|
pub struct PortraitSource {
|
||||||
pub pixels: Vec<Color>, // PORTRAIT_W * PORTRAIT_H, row-major
|
pixels: Vec<Color>, // SRC_W * SRC_H, row-major
|
||||||
|
src_w: usize,
|
||||||
|
src_h: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PortraitSource {
|
impl PortraitSource {
|
||||||
/// Sample the pixel at `(x, y)` from the source grid. Returns `None` if
|
const SRC_W: usize = PORTRAIT_W as usize * SRC_MULT;
|
||||||
/// the indices are out of range (caller falls back to the palette grid).
|
const SRC_H: usize = PORTRAIT_H as usize * SRC_MULT;
|
||||||
|
|
||||||
|
/// Sample a pixel at grid coordinate (x, y), bilinearly interpolated
|
||||||
|
/// from the higher-resolution source. Returns None for out-of-bounds.
|
||||||
pub fn at(&self, x: usize, y: usize) -> Option<Color> {
|
pub fn at(&self, x: usize, y: usize) -> Option<Color> {
|
||||||
let idx = y * PORTRAIT_W as usize + x;
|
if x >= PORTRAIT_W as usize || y >= PORTRAIT_H as usize {
|
||||||
self.pixels.get(idx).copied()
|
return None;
|
||||||
|
}
|
||||||
|
// Map grid coordinate into source space, then back off 0.5 so the
|
||||||
|
// interpolation kernel is centered on the "area" this grid cell covers.
|
||||||
|
let sx = (x as f32 + 0.5) * self.src_w as f32 / PORTRAIT_W as f32 - 0.5;
|
||||||
|
let sy = (y as f32 + 0.5) * self.src_h as f32 / PORTRAIT_H as f32 - 0.5;
|
||||||
|
let sx = sx.max(0.0);
|
||||||
|
let sy = sy.max(0.0);
|
||||||
|
|
||||||
|
let ix = sx as usize;
|
||||||
|
let iy = sy as usize;
|
||||||
|
let fx = sx - ix as f32;
|
||||||
|
let fy = sy - iy as f32;
|
||||||
|
|
||||||
|
// Clamp to valid range for the four sample points.
|
||||||
|
let ix1 = (ix + 1).min(self.src_w - 1);
|
||||||
|
let iy1 = (iy + 1).min(self.src_h - 1);
|
||||||
|
|
||||||
|
let extract = |c: &Color| -> (u8, u8, u8) {
|
||||||
|
if let Color::Rgb(r, g, b) = *c { (r, g, b) } else { (0, 0, 0) }
|
||||||
|
};
|
||||||
|
|
||||||
|
let c00 = extract(&self.pixels[iy * self.src_w + ix]);
|
||||||
|
let c01 = extract(&self.pixels[iy * self.src_w + ix1]);
|
||||||
|
let c10 = extract(&self.pixels[iy1 * self.src_w + ix]);
|
||||||
|
let c11 = extract(&self.pixels[iy1 * self.src_w + ix1]);
|
||||||
|
|
||||||
|
let lerp = |a: u8, b: u8, t: f32| (a as f32 + (b as f32 - a as f32) * t) as u8;
|
||||||
|
|
||||||
|
let r0 = lerp(c00.0, c01.0, fx);
|
||||||
|
let g0 = lerp(c00.1, c01.1, fx);
|
||||||
|
let b0 = lerp(c00.2, c01.2, fx);
|
||||||
|
let r1 = lerp(c10.0, c11.0, fx);
|
||||||
|
let g1 = lerp(c10.1, c11.1, fx);
|
||||||
|
let b1 = lerp(c10.2, c11.2, fx);
|
||||||
|
|
||||||
|
Some(Color::Rgb(
|
||||||
|
lerp(r0, r1, fy),
|
||||||
|
lerp(g0, g1, fy),
|
||||||
|
lerp(b0, b1, fy),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decode an image file (PNG or JPEG), resize to portrait grid dims,
|
/// Decode an image file (PNG or JPEG), resize to `SRC_W × SRC_H`,
|
||||||
/// and produce a colored pixel array. Returns `None` on any I/O or
|
/// and produce a colored pixel array. Returns `None` on any I/O or
|
||||||
/// decode error — but now logs the reason so we can see why a PNG
|
/// decode error — logs the reason so we can see why a PNG didn't
|
||||||
/// didn't take instead of silently falling back to the silhouette.
|
/// take instead of silently falling back to the silhouette.
|
||||||
pub fn from_path(path: &Path) -> Option<Self> {
|
pub fn from_path(path: &Path) -> Option<Self> {
|
||||||
let img = match image::open(path) {
|
let img = match image::open(path) {
|
||||||
Ok(img) => img,
|
Ok(img) => img,
|
||||||
|
|
@ -80,17 +134,31 @@ impl PortraitSource {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let resized = img.resize_exact(
|
// Cover-crop: resize so the shorter dimension fills the target
|
||||||
PORTRAIT_W as u32,
|
// (maintaining aspect ratio), then center-crop. This way a
|
||||||
PORTRAIT_H as u32,
|
// landscape or portrait photo both fill the square frame without
|
||||||
|
// stretching — the center of the image is what survives.
|
||||||
|
// Cover-crop: scale so the shorter axis fills the target (maintaining
|
||||||
|
// aspect ratio), then center-crop the excess. This way a landscape or
|
||||||
|
// portrait photo both fill the square frame without stretching.
|
||||||
|
let (w, h) = (img.width(), img.height());
|
||||||
|
let scale = (Self::SRC_W as f32 / w as f32)
|
||||||
|
.max(Self::SRC_H as f32 / h as f32);
|
||||||
|
let sw = (w as f32 * scale) as u32;
|
||||||
|
let sh = (h as f32 * scale) as u32;
|
||||||
|
let scaled = image::imageops::resize(
|
||||||
|
&img, sw, sh,
|
||||||
image::imageops::FilterType::Lanczos3,
|
image::imageops::FilterType::Lanczos3,
|
||||||
);
|
);
|
||||||
let rgb = resized.to_rgb8();
|
let crop_x = (sw.saturating_sub(Self::SRC_W as u32)) / 2;
|
||||||
|
let crop_y = (sh.saturating_sub(Self::SRC_H as u32)) / 2;
|
||||||
|
let cropped = image::DynamicImage::ImageRgba8(scaled).crop_imm(crop_x, crop_y, Self::SRC_W as u32, Self::SRC_H as u32);
|
||||||
|
let rgb = cropped.to_rgb8();
|
||||||
let pixels = rgb
|
let pixels = rgb
|
||||||
.pixels()
|
.pixels()
|
||||||
.map(|p| Color::Rgb(p[0], p[1], p[2]))
|
.map(|p| Color::Rgb(p[0], p[1], p[2]))
|
||||||
.collect();
|
.collect();
|
||||||
Some(Self { pixels })
|
Some(Self { pixels, src_w: Self::SRC_W, src_h: Self::SRC_H })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue