Measure the distance to the edge, and get four controls for one transform
Feathering, growing, shrinking, closing and opening are the same number read differently. With the signed distance from the boundary in hand, dilation is the set where d >= -r, erosion where d >= +r, and a feather of any shape is a function of d. So the field is computed once and the controls are arithmetic on it. The **field** is what reaches the GPU, not a finished alpha, and that is the point: growing a mask or changing its falloff then costs a uniform upload and no recomputation, which is what makes them live controls rather than ones that stall on every drag. Only closing and opening rebuild, because after the first threshold the shape has changed and the old distances describe the old one. Exact Euclidean, via Felzenszwalb's separable transform — not a chamfer approximation, which leaves a mask visibly octagonal once grown more than a few pixels. A test asserts the diagonal is √2 rather than 1 or 2. It runs on the CPU, which ARCH §5.4 forbids for masks. The rule is about brush lag — a stroke rasterised per frame — and this is a different operation: once per mask edit, on input the model already produced here, producing a field the GPU then samples for free. What it buys is exact determinism, which matters because masks reach the sidecar as indices and a field that varied by vendor would mean a mask meaning one thing on the desktop and another on the phone. The half-pixel in `signed_distance` is not a detail, and a test caught it. Measuring to the nearest opposite pixel *centre* puts the smallest magnitude at 1 either side, so the boundary is nowhere and **eroding by less than a pixel removes nothing**. A control whose first notch does nothing is a broken control. Half a pixel off each side puts the boundary where it physically is, and eroding by 1 takes exactly the outermost ring. Every falloff curve is 0.5 at the boundary by construction, asserted for all five: changing the curve should change how the transition looks and never where it sits.
This commit is contained in:
+96
-40
@@ -43,7 +43,9 @@ struct MaskParams {
|
||||
mode: u32,
|
||||
region_count: u32,
|
||||
feather: f32,
|
||||
_pad0: f32,
|
||||
/// Which falloff curve a subject layer uses. Kept in step with the
|
||||
/// `switch` in `mask.wgsl` by `falloff_code`.
|
||||
falloff: u32,
|
||||
|
||||
centre: [f32; 2],
|
||||
axis: [f32; 2],
|
||||
@@ -110,10 +112,17 @@ impl LabelField {
|
||||
}
|
||||
}
|
||||
|
||||
/// The recognised objects' coverage, resident on the GPU.
|
||||
/// Signed distance fields for the subject layers, resident on the GPU.
|
||||
///
|
||||
/// Uploaded once per segmentation, indexed exactly as the detection list is,
|
||||
/// so a layer storing "instance 3" finds instance 3 here.
|
||||
/// **One per active layer, in that order** — not one per detected object. Two
|
||||
/// layers can mask the same subject with different morphology, and closing or
|
||||
/// opening rebuilds the field rather than offsetting it, so the field belongs
|
||||
/// to the layer that shaped it.
|
||||
///
|
||||
/// `R32Float`, because the values are signed distances in pixels and the
|
||||
/// controls read them at sub-pixel precision. That is four bytes a pixel:
|
||||
/// ~7 MB per layer at a 1600 px proxy, which is the price of making grow,
|
||||
/// shrink and feather cost nothing per frame.
|
||||
pub struct SubjectMasks {
|
||||
views: Vec<wgpu::TextureView>,
|
||||
width: u32,
|
||||
@@ -121,33 +130,28 @@ pub struct SubjectMasks {
|
||||
}
|
||||
|
||||
impl SubjectMasks {
|
||||
/// Upload one `R8Unorm` texture per instance.
|
||||
///
|
||||
/// A byte per pixel, which is what the model's coverage was quantised to
|
||||
/// on the way out of inference: 256 levels is finer than any edge a person
|
||||
/// can see, and four bytes would make a handful of objects most of a
|
||||
/// hundred megabytes for one photograph.
|
||||
/// Upload one distance field per active subject layer.
|
||||
pub fn upload(
|
||||
ctx: &GpuContext,
|
||||
masks: &[&[u8]],
|
||||
fields: &[&[f32]],
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> Result<Self, GpuError> {
|
||||
let expected = (width * height) as usize;
|
||||
let mut views = Vec::with_capacity(masks.len());
|
||||
let mut views = Vec::with_capacity(fields.len());
|
||||
|
||||
for (i, mask) in masks.iter().enumerate() {
|
||||
if mask.len() != expected {
|
||||
for (i, field) in fields.iter().enumerate() {
|
||||
if field.len() != expected {
|
||||
return Err(GpuError::InvalidMask(format!(
|
||||
"subject {i} mask is {} bytes, expected {width}x{height}",
|
||||
mask.len()
|
||||
"subject field {i} is {} values, expected {width}x{height}",
|
||||
field.len()
|
||||
)));
|
||||
}
|
||||
|
||||
let texture = ctx.device.create_texture_with_data(
|
||||
&ctx.queue,
|
||||
&wgpu::TextureDescriptor {
|
||||
label: Some("subject-mask"),
|
||||
label: Some("subject-distance"),
|
||||
size: wgpu::Extent3d {
|
||||
width,
|
||||
height,
|
||||
@@ -156,12 +160,12 @@ impl SubjectMasks {
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::R8Unorm,
|
||||
format: wgpu::TextureFormat::R32Float,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
},
|
||||
wgpu::util::TextureDataOrder::LayerMajor,
|
||||
mask,
|
||||
bytemuck::cast_slice(field),
|
||||
);
|
||||
views.push(texture.create_view(&wgpu::TextureViewDescriptor::default()));
|
||||
}
|
||||
@@ -190,7 +194,7 @@ impl SubjectMasks {
|
||||
}
|
||||
}
|
||||
|
||||
/// The rasterised masks for one edit.
|
||||
/// The rasterised masks for one edit./// The rasterised masks for one edit.
|
||||
pub struct MaskArray {
|
||||
texture: wgpu::Texture,
|
||||
view: wgpu::TextureView,
|
||||
@@ -266,7 +270,10 @@ impl MaskPass {
|
||||
binding: 3,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||
// `filterable: false`: R32Float cannot be filtered
|
||||
// without an optional feature, and the shader loads
|
||||
// texels and interpolates them itself anyway.
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: false },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
@@ -312,7 +319,9 @@ impl MaskPass {
|
||||
}
|
||||
|
||||
let placeholder = LabelField::upload(ctx, &[0], 1, 1, 0)?;
|
||||
let empty_subject = SubjectMasks::upload(ctx, &[&[0u8][..]], 1, 1)?;
|
||||
// Everywhere outside, so a layer that somehow reaches this masks
|
||||
// nothing rather than everything.
|
||||
let empty_subject = SubjectMasks::upload(ctx, &[&[-1.0f32][..]], 1, 1)?;
|
||||
|
||||
Ok(Self {
|
||||
ctx: ctx.clone(),
|
||||
@@ -367,20 +376,20 @@ impl MaskPass {
|
||||
// same reason a region layer without a segmentation is: an absent
|
||||
// mask that defaults to "everything" would apply the adjustment to
|
||||
// the whole photograph, which is a much louder failure than none.
|
||||
// Indexed by *slot*, not by the instance the layer names: the
|
||||
// fields are built per layer, in this same order, because two
|
||||
// layers over one subject can carry different morphology.
|
||||
let subject = match &layer.source {
|
||||
MaskSource::Subject { index, .. } => {
|
||||
match subjects.filter(|s| (*index as usize) < s.len()) {
|
||||
Some(s) => (s, *index as usize),
|
||||
None => {
|
||||
log::warn!(
|
||||
"mask layer {} names subject {index}, which this segmentation \
|
||||
does not have; skipping",
|
||||
layer.id
|
||||
);
|
||||
continue;
|
||||
}
|
||||
MaskSource::Subject { .. } => match subjects.filter(|s| slot < s.len()) {
|
||||
Some(s) => (s, slot),
|
||||
None => {
|
||||
log::warn!(
|
||||
"mask layer {} has no distance field; skipping",
|
||||
layer.id
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
},
|
||||
_ => (&self.empty_subject, 0),
|
||||
};
|
||||
|
||||
@@ -418,7 +427,7 @@ impl MaskPass {
|
||||
mode: MODE_REGIONS,
|
||||
region_count: field.region_count,
|
||||
feather: 0.0,
|
||||
_pad0: 0.0,
|
||||
falloff: 0,
|
||||
centre: [0.5, 0.5],
|
||||
axis: [1.0, 0.0],
|
||||
softness: 0.0,
|
||||
@@ -431,11 +440,24 @@ impl MaskPass {
|
||||
// already a soft sigmoid, so zero means "use the edge the model
|
||||
// drew" rather than "hard edge" — the one place in this shader
|
||||
// where zero softness is not a step.
|
||||
MaskSource::Subject { .. } => MaskParams {
|
||||
mode: MODE_SUBJECT,
|
||||
softness: layer.feather.clamp(0.0, 0.5),
|
||||
..base
|
||||
},
|
||||
// Feather and morphology are in fractions of the frame's shorter
|
||||
// edge; the field is in proxy pixels. Converting here keeps the
|
||||
// stored edit resolution-independent while the shader works in the
|
||||
// units its texture is actually measured in.
|
||||
MaskSource::Subject { .. } => {
|
||||
let short = field_short_edge(width, height);
|
||||
MaskParams {
|
||||
mode: MODE_SUBJECT,
|
||||
// `softness` is the feather half-width in pixels.
|
||||
softness: (layer.feather * short).max(0.0),
|
||||
// `angle` carries the morphology offset — reused rather
|
||||
// than padded, since a subject layer has no ellipse to
|
||||
// rotate.
|
||||
angle: morph_offset(layer) * short,
|
||||
falloff: falloff_code(layer.falloff),
|
||||
..base
|
||||
}
|
||||
}
|
||||
MaskSource::Regions { .. } => MaskParams {
|
||||
// A pixel of softening at the proxy-to-output ratio, so the
|
||||
// edge is equally soft whatever size the render is.
|
||||
@@ -619,6 +641,40 @@ impl MaskPass {
|
||||
}
|
||||
}
|
||||
|
||||
/// The shorter edge of the space the mask is rasterised in.
|
||||
///
|
||||
/// Feather and morphology are stored as fractions of it, so the same edit is
|
||||
/// the same edge whether it renders to a viewport or to a 24 MP export.
|
||||
fn field_short_edge(width: u32, height: u32) -> f32 {
|
||||
width.min(height).max(1) as f32
|
||||
}
|
||||
|
||||
/// How far the boundary moves, in fractions of the shorter edge.
|
||||
///
|
||||
/// Zero for closing and opening: those are folded into the field itself when
|
||||
/// it is built, because their second half acts on a shape the original field
|
||||
/// does not describe.
|
||||
fn morph_offset(layer: &dr_pipeline::mask::MaskLayer) -> f32 {
|
||||
use dr_pipeline::mask::Morphology;
|
||||
match layer.morphology {
|
||||
Morphology::Dilate => layer.morph_radius,
|
||||
Morphology::Erode => -layer.morph_radius,
|
||||
Morphology::None | Morphology::Close | Morphology::Open => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Kept in step with the `switch` in `mask.wgsl`.
|
||||
fn falloff_code(falloff: dr_pipeline::mask::Falloff) -> u32 {
|
||||
use dr_pipeline::mask::Falloff;
|
||||
match falloff {
|
||||
Falloff::Hard => 0,
|
||||
Falloff::Linear => 1,
|
||||
Falloff::Smooth => 2,
|
||||
Falloff::Gaussian => 3,
|
||||
Falloff::Exponential => 4,
|
||||
}
|
||||
}
|
||||
|
||||
fn uniform_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
|
||||
Reference in New Issue
Block a user