Watch
1
0
Fork
You've already forked souveraine
0

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:
Fimeg 2026-05-12 14:25:42 -04:00
commit e261e9758e
2 changed files with 108 additions and 18 deletions

View file

@ -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)]

View file

@ -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 })
} }
} }