Image > Trap (CMYK prepress trapping)
Clean-room trap: per-ink grayscale dilation (separable max filter, par_rows/wasm-safe) applied only under already-inked pixels, so internal colour edges get a trap band while object-vs-paper edges stay clean. Engine image.trap (CMYK-only, active pixel layer, one history step). algo 3 tests + engine 2 tests. Parity 607 -> 608; FLOOR 603. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
61aa2f9748
commit
1c474b8c7e
|
|
@ -44,6 +44,7 @@ pub mod matting;
|
|||
pub mod quantize;
|
||||
pub mod stack;
|
||||
pub mod tone;
|
||||
pub mod trap;
|
||||
pub mod transform;
|
||||
pub mod warp;
|
||||
pub mod liquify;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,125 @@
|
|||
//! Image › Trap (prepress). Trapping spreads each ink slightly into neighbouring inked areas so
|
||||
//! that, if the press misregisters the plates, the colours still overlap at an edge instead of
|
||||
//! leaving a white gap. We spread every ink by `width` px (grayscale dilation) but only *under*
|
||||
//! already-inked pixels, so internal colour edges get a trap band while the object-vs-paper edge is
|
||||
//! left alone. Clean-room approximation of Photoshop's Trap, which works on a flattened CMYK image.
|
||||
//!
|
||||
//! Operates on an interleaved buffer of `ch` channels per pixel (CMYK = inks 0..4, plus alpha).
|
||||
|
||||
use crate::photo_util::par_rows;
|
||||
|
||||
/// Separable grayscale dilation (max filter) of one interleaved channel, radius `r` px.
|
||||
fn dilate_channel(src: &[f32], w: usize, h: usize, ch: usize, c: usize, r: usize) -> Vec<f32> {
|
||||
let get = |buf: &[f32], x: usize, y: usize| buf[(y * w + x) * ch + c];
|
||||
// Horizontal pass.
|
||||
let mut tmp = vec![0.0f32; w * h];
|
||||
par_rows(&mut tmp, w, 1, |y, row| {
|
||||
for (x, o) in row.iter_mut().enumerate() {
|
||||
let lo = x.saturating_sub(r);
|
||||
let hi = (x + r).min(w - 1);
|
||||
let mut m = 0.0f32;
|
||||
for xx in lo..=hi {
|
||||
m = m.max(get(src, xx, y));
|
||||
}
|
||||
*o = m;
|
||||
}
|
||||
});
|
||||
// Vertical pass over tmp.
|
||||
let mut out = vec![0.0f32; w * h];
|
||||
par_rows(&mut out, w, 1, |y, row| {
|
||||
let lo = y.saturating_sub(r);
|
||||
let hi = (y + r).min(h - 1);
|
||||
for (x, o) in row.iter_mut().enumerate() {
|
||||
let mut m = 0.0f32;
|
||||
for yy in lo..=hi {
|
||||
m = m.max(tmp[yy * w + x]);
|
||||
}
|
||||
*o = m;
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// Trap an interleaved CMYK(+alpha) buffer in place. `width` is the trap in px (>=1). Inks 0..4 are
|
||||
/// spread; any alpha channel (`ch > 4`) is untouched. A pixel keeps its original ink where it has
|
||||
/// no ink at all (paper), so only internal colour edges gain a trap band.
|
||||
pub fn trap(px: &mut [f32], w: usize, h: usize, ch: usize, width: usize) {
|
||||
if width == 0 || w == 0 || h == 0 || ch < 4 {
|
||||
return;
|
||||
}
|
||||
let inks = 4.min(ch);
|
||||
let dil: Vec<Vec<f32>> = (0..inks).map(|c| dilate_channel(px, w, h, ch, c, width)).collect();
|
||||
par_rows(px, w, ch, |y, row| {
|
||||
for (x, pixel) in row.chunks_exact_mut(ch).enumerate() {
|
||||
let i = y * w + x;
|
||||
// Printed where any ink is present.
|
||||
let inked = (0..inks).any(|c| pixel[c] > 1e-4);
|
||||
if !inked {
|
||||
continue;
|
||||
}
|
||||
for c in 0..inks {
|
||||
pixel[c] = pixel[c].max(dil[c][i]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Two solid ink blocks meeting at x=4: cyan (C) on the left, magenta (M) on the right.
|
||||
fn two_blocks() -> (Vec<f32>, usize, usize) {
|
||||
let (w, h, ch) = (8usize, 4usize, 4usize);
|
||||
let mut px = vec![0.0f32; w * h * ch];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let p = &mut px[(y * w + x) * ch..][..ch];
|
||||
if x < 4 {
|
||||
p[0] = 1.0; // cyan
|
||||
} else {
|
||||
p[1] = 1.0; // magenta
|
||||
}
|
||||
}
|
||||
}
|
||||
(px, w, h)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn traps_the_internal_colour_edge() {
|
||||
let (mut px, w, h) = two_blocks();
|
||||
let ch = 4;
|
||||
trap(&mut px, w, h, ch, 1);
|
||||
// At the seam, cyan and magenta now overlap (both inks > 0) in a 1px band on each side.
|
||||
let at = |x: usize, y: usize, c: usize| px[(y * w + x) * ch + c];
|
||||
assert!(at(3, 0, 1) > 0.5, "magenta spread left into the cyan edge");
|
||||
assert!(at(4, 0, 0) > 0.5, "cyan spread right into the magenta edge");
|
||||
// Deep inside each block is unchanged (single ink).
|
||||
assert_eq!(at(0, 0, 1), 0.0, "far cyan has no magenta");
|
||||
assert_eq!(at(7, 0, 0), 0.0, "far magenta has no cyan");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaves_paper_untouched() {
|
||||
let (w, h, ch) = (6usize, 3usize, 4usize);
|
||||
let mut px = vec![0.0f32; w * h * ch];
|
||||
// One cyan pixel in the middle, rest paper.
|
||||
px[(1 * w + 3) * ch] = 1.0;
|
||||
let before = px.clone();
|
||||
trap(&mut px, w, h, ch, 1);
|
||||
// Paper pixels (no ink) stay paper — no ink bleeds into white.
|
||||
for i in 0..w * h {
|
||||
if before[i * ch] == 0.0 {
|
||||
assert_eq!(px[i * ch], 0.0, "paper pixel {i} gained cyan");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn width_zero_is_identity() {
|
||||
let (mut px, w, h) = two_blocks();
|
||||
let orig = px.clone();
|
||||
trap(&mut px, w, h, 4, 0);
|
||||
assert_eq!(px, orig);
|
||||
}
|
||||
}
|
||||
|
|
@ -838,6 +838,7 @@ fn build() -> Vec<CommandSpec> {
|
|||
v.extend(crate::pick_cmds::specs());
|
||||
v.extend(crate::frame_cmds::specs());
|
||||
v.extend(crate::migrate_cmds::specs());
|
||||
v.extend(crate::trap_cmds::specs());
|
||||
v.extend(crate::variables_cmds::specs());
|
||||
v
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ pub mod comps_cmds;
|
|||
pub mod automate_cmds;
|
||||
mod frame_cmds;
|
||||
mod migrate_cmds;
|
||||
mod trap_cmds;
|
||||
mod variables_cmds;
|
||||
pub mod pick_cmds;
|
||||
pub mod print_cmds;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,108 @@
|
|||
//! Image › Trap. Trapping is a CMYK prepress step (see `photocraft_algo::trap`). Photoshop requires
|
||||
//! a flattened CMYK image; we trap the active pixel layer's inks over the canvas. One history step.
|
||||
|
||||
use photocraft_color::ColorMode;
|
||||
use photocraft_doc::LayerContent;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::commands::CommandSpec;
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
fn enabled(s: &Session) -> std::result::Result<(), String> {
|
||||
match s.active() {
|
||||
Some(d) if d.doc.mode == ColorMode::Cmyk => Ok(()),
|
||||
Some(_) => Err("Trap needs a CMYK document (Image › Mode › CMYK Color)".into()),
|
||||
None => Err("no document".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn trap(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "image.trap";
|
||||
let width = p.get("width").and_then(Value::as_f64).unwrap_or(1.0).round().max(0.0) as usize;
|
||||
if s.active().ok_or(EngineError::NoDocument)?.doc.mode != ColorMode::Cmyk {
|
||||
return Err(EngineError::BadParams { cmd: cmd.into(), msg: "Trap needs a CMYK document".into() });
|
||||
}
|
||||
s.edit("Trap", |doc, active| {
|
||||
let id = active.ok_or(EngineError::NoDocument)?;
|
||||
let bounds = doc.bounds();
|
||||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||||
let LayerContent::Raster(surf) = &mut l.content else {
|
||||
return Err(EngineError::BadParams { cmd: cmd.into(), msg: format!("Trap needs a pixel layer (active layer is a {})", l.content.kind_name()) });
|
||||
};
|
||||
if bounds.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let ch = surf.channels();
|
||||
let (w, h) = (bounds.width() as usize, bounds.height() as usize);
|
||||
let mut data = surf.read_region(bounds);
|
||||
photocraft_algo::trap::trap(&mut data, w, h, ch, width);
|
||||
surf.write_region(bounds, &data);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"trapped": true, "width": width}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![CommandSpec {
|
||||
id: "image.trap",
|
||||
label: "Trap…",
|
||||
menu: &["Image"],
|
||||
shortcut: None,
|
||||
params: r#"{width:px>=1=1} → {trapped,width}: spread inks at colour edges (CMYK only)"#,
|
||||
enabled,
|
||||
journal: true,
|
||||
run: |s, p| trap(s, &p),
|
||||
}]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use photocraft_geom::Rect;
|
||||
|
||||
#[test]
|
||||
fn traps_a_cmyk_document() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 8, "height": 4, "mode": "cmyk"})).unwrap();
|
||||
// Paint cyan | magenta halves on the Background.
|
||||
s.edit("paint", |doc, active| {
|
||||
let id = active.unwrap();
|
||||
let fmt = doc.pixel_format();
|
||||
let l = doc.layer_mut(id).unwrap();
|
||||
if let LayerContent::Raster(surf) = &mut l.content {
|
||||
let ch = fmt.channels();
|
||||
let mut data = vec![0.0f32; 8 * 4 * ch];
|
||||
for y in 0..4 {
|
||||
for x in 0..8 {
|
||||
let px = &mut data[(y * 8 + x) * ch..][..ch];
|
||||
if x < 4 { px[0] = 1.0; } else { px[1] = 1.0; }
|
||||
if fmt.alpha { px[ch - 1] = 1.0; }
|
||||
}
|
||||
}
|
||||
surf.write_region(Rect::new(0, 0, 8, 4), &data);
|
||||
}
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
|
||||
let r = s.execute("image.trap", json!({"width": 1})).unwrap();
|
||||
assert_eq!(r["trapped"], true);
|
||||
// The seam now has overlapping inks.
|
||||
let st = s.active().unwrap();
|
||||
let id = st.active_layer.unwrap();
|
||||
if let LayerContent::Raster(surf) = &st.doc.layer(id).unwrap().content {
|
||||
let ch = surf.channels();
|
||||
let d = surf.read_region(Rect::new(0, 0, 8, 4));
|
||||
assert!(d[(3) * ch + 1] > 0.5, "magenta trapped into the cyan edge");
|
||||
assert!(d[(4) * ch + 0] > 0.5, "cyan trapped into the magenta edge");
|
||||
}
|
||||
// One history step.
|
||||
assert_eq!(st.history.entries().last().map(|e| e.as_str()), Some("Trap"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_cmyk() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 8, "height": 4, "mode": "rgb"})).unwrap();
|
||||
assert!(s.execute("image.trap", json!({"width": 1})).is_err());
|
||||
}
|
||||
}
|
||||
|
|
@ -7,7 +7,7 @@ use crate::menu_catalog::CATALOG;
|
|||
use crate::menus::is_live;
|
||||
|
||||
/// Minimum number of live catalog items. Raise it when parity grows; never lower it.
|
||||
pub const FLOOR: usize = 602;
|
||||
pub const FLOOR: usize = 603;
|
||||
|
||||
/// One top-level menu's coverage.
|
||||
#[derive(Clone, Debug, serde::Serialize)]
|
||||
|
|
|
|||
|
|
@ -2,13 +2,13 @@
|
|||
|
||||
Generated by `cargo xtask parity` from `crates/ui-egui/src/menu_catalog.rs` (Photoshop's menu tree) and the live command registry. Do not edit by hand.
|
||||
|
||||
**607 of 625 menu items live (97.1%).**
|
||||
**608 of 625 menu items live (97.3%).**
|
||||
|
||||
| Menu | Live | Total | % |
|
||||
|---|---:|---:|---:|
|
||||
| File | 47 | 50 | 94% |
|
||||
| Edit | 75 | 75 | 100% |
|
||||
| Image | 60 | 61 | 98% |
|
||||
| Image | 61 | 61 | 100% |
|
||||
| Layer | 147 | 160 | 92% |
|
||||
| Type | 44 | 44 | 100% |
|
||||
| Select | 25 | 25 | 100% |
|
||||
|
|
@ -24,10 +24,6 @@ Generated by `cargo xtask parity` from `crates/ui-egui/src/menu_catalog.rs` (Pho
|
|||
- Import › Video Frames to Layers… (`file.import.videoFramesToLayers`)
|
||||
- Import › WIA Support… (`file.import.wiaSupport`)
|
||||
|
||||
### Image (1)
|
||||
|
||||
- Trap… (`image.trap`)
|
||||
|
||||
### Layer (13)
|
||||
|
||||
- Video Layers › New Blank Video Layer (`layer.videoLayers.newBlankVideoLayer`)
|
||||
|
|
|
|||
Loading…
Reference in New Issue