1use otf_pixels_core::{
16 ChannelLayout, ImageDescriptor, Op, PixelFormat, PixelsError, Region, Result, SampleKind, Tile,
17 TileMut,
18};
19
20#[derive(Debug, Clone, Copy, PartialEq)]
28#[non_exhaustive]
29pub struct Modulate {
30 pub brightness: f32,
32 pub saturation: f32,
34 pub hue: f32,
36 pub lightness: f32,
42}
43
44impl Default for Modulate {
45 fn default() -> Self {
46 Self {
47 brightness: 1.0,
48 saturation: 1.0,
49 hue: 0.0,
50 lightness: 0.0,
51 }
52 }
53}
54
55impl Modulate {
56 #[must_use]
58 pub fn identity() -> Self {
59 Self::default()
60 }
61
62 pub fn with_brightness(mut self, factor: f32) -> Result<Self> {
69 self.brightness = check_factor("brightness", factor)?;
70 Ok(self)
71 }
72
73 pub fn with_saturation(mut self, factor: f32) -> Result<Self> {
79 self.saturation = check_factor("saturation", factor)?;
80 Ok(self)
81 }
82
83 pub fn with_hue(mut self, degrees: f32) -> Result<Self> {
89 if !degrees.is_finite() {
90 return Err(PixelsError::invalid_argument(
91 "hue",
92 format!("rotation must be finite, got {degrees}"),
93 ));
94 }
95 self.hue = degrees;
96 Ok(self)
97 }
98
99 pub fn with_lightness(mut self, amount: f32) -> Result<Self> {
105 if !amount.is_finite() {
106 return Err(PixelsError::invalid_argument(
107 "lightness",
108 format!("must be finite, got {amount}"),
109 ));
110 }
111 self.lightness = amount;
112 Ok(self)
113 }
114
115 #[must_use]
117 pub fn is_identity(&self) -> bool {
118 self.brightness == 1.0
119 && self.saturation == 1.0
120 && self.hue % 360.0 == 0.0
121 && self.lightness == 0.0
122 }
123}
124
125fn check_factor(name: &'static str, factor: f32) -> Result<f32> {
127 if !factor.is_finite() || factor < 0.0 {
128 return Err(PixelsError::invalid_argument(
129 name,
130 format!("must be finite and non-negative, got {factor}"),
131 ));
132 }
133 Ok(factor)
134}
135
136fn rgb_to_hsv(r: f32, g: f32, b: f32) -> (f32, f32, f32) {
138 let max = r.max(g).max(b);
139 let min = r.min(g).min(b);
140 let delta = max - min;
141
142 let hue = if delta <= f32::EPSILON {
143 0.0
144 } else if max == r {
145 60.0 * (((g - b) / delta) % 6.0)
146 } else if max == g {
147 60.0 * ((b - r) / delta + 2.0)
148 } else {
149 60.0 * ((r - g) / delta + 4.0)
150 };
151 let saturation = if max <= f32::EPSILON {
152 0.0
153 } else {
154 delta / max
155 };
156 (hue, saturation, max)
157}
158
159fn hsv_to_rgb(hue: f32, saturation: f32, value: f32) -> (f32, f32, f32) {
161 let hue = hue.rem_euclid(360.0);
162 let c = value * saturation;
163 let x = c * (1.0 - (((hue / 60.0) % 2.0) - 1.0).abs());
164 let m = value - c;
165 let (r, g, b) = match (hue / 60.0) as u32 {
166 0 => (c, x, 0.0),
167 1 => (x, c, 0.0),
168 2 => (0.0, c, x),
169 3 => (0.0, x, c),
170 4 => (x, 0.0, c),
171 _ => (c, 0.0, x),
172 };
173 (r + m, g + m, b + m)
174}
175
176fn modulate_rgb(m: &Modulate, r: f32, g: f32, b: f32) -> (f32, f32, f32) {
178 let (hue, saturation, value) = rgb_to_hsv(r, g, b);
179 let (r, g, b) = hsv_to_rgb(
180 hue + m.hue,
181 (saturation * m.saturation).clamp(0.0, 1.0),
182 (value * m.brightness).clamp(0.0, 1.0),
183 );
184 let lift = m.lightness / 100.0;
187 (r + lift, g + lift, b + lift)
188}
189
190fn modulate_grey(m: &Modulate, value: f32) -> f32 {
192 (value * m.brightness).clamp(0.0, 1.0) + m.lightness / 100.0
193}
194
195impl Op for Modulate {
196 fn rescaled(&self) -> Option<std::sync::Arc<dyn Op>> {
200 Some(std::sync::Arc::new(*self))
201 }
202 fn name(&self) -> &'static str {
203 "modulate"
204 }
205
206 fn output_descriptor(&self, inputs: &[ImageDescriptor]) -> Result<ImageDescriptor> {
207 let input = sole(self.name(), inputs)?;
208 Ok(*input)
209 }
210
211 fn input_regions(&self, output: Region, _inputs: &[ImageDescriptor]) -> Result<Vec<Region>> {
212 Ok(vec![output])
213 }
214
215 fn compute(&self, inputs: &[Tile<'_>], output: &mut TileMut<'_>) -> Result<()> {
216 let input = sole_tile(self.name(), inputs)?;
217 let format = output.pixel();
218 let region = output.region();
219 let channels = format.channels();
220 let colour = format.layout();
221
222 for y in region.y..region.y + region.height {
223 let Some(source) = input.row(y) else { continue };
224 let Some(target) = output.row_mut(y) else {
225 continue;
226 };
227 match format.sample_kind() {
228 SampleKind::U8 => modulate_row_u8(self, source, target, channels, colour),
229 SampleKind::U16 => modulate_row_u16(self, source, target, channels, colour),
230 SampleKind::F32 => modulate_row_f32(self, source, target, channels, colour),
231 }
232 }
233 Ok(())
234 }
235}
236
237const fn has_alpha(layout: ChannelLayout) -> bool {
239 matches!(layout, ChannelLayout::GrayAlpha | ChannelLayout::Rgba)
240}
241
242const fn is_gray(layout: ChannelLayout) -> bool {
244 matches!(layout, ChannelLayout::Gray | ChannelLayout::GrayAlpha)
245}
246
247fn modulate_row_u8(
248 m: &Modulate,
249 source: &[u8],
250 target: &mut [u8],
251 channels: usize,
252 layout: ChannelLayout,
253) {
254 let colour_channels = if has_alpha(layout) {
255 channels - 1
256 } else {
257 channels
258 };
259 for (from, to) in source
260 .chunks_exact(channels)
261 .zip(target.chunks_exact_mut(channels))
262 {
263 if is_gray(layout) {
264 let value = f32::from(from.first().copied().unwrap_or(0)) / 255.0;
267 let scaled = modulate_grey(m, value).clamp(0.0, 1.0);
268 if let Some(slot) = to.first_mut() {
269 *slot = (scaled * 255.0 + 0.5) as u8;
270 }
271 } else {
272 let r = f32::from(from.first().copied().unwrap_or(0)) / 255.0;
273 let g = f32::from(from.get(1).copied().unwrap_or(0)) / 255.0;
274 let b = f32::from(from.get(2).copied().unwrap_or(0)) / 255.0;
275 let (r, g, b) = modulate_rgb(m, r, g, b);
276 for (index, value) in [r, g, b].into_iter().enumerate().take(colour_channels) {
277 if let Some(slot) = to.get_mut(index) {
278 *slot = (value.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
279 }
280 }
281 }
282 if has_alpha(layout) {
284 if let (Some(alpha), Some(slot)) = (from.get(channels - 1), to.get_mut(channels - 1)) {
285 *slot = *alpha;
286 }
287 }
288 }
289}
290
291fn modulate_row_u16(
292 m: &Modulate,
293 source: &[u8],
294 target: &mut [u8],
295 channels: usize,
296 layout: ChannelLayout,
297) {
298 let pixel_bytes = channels * 2;
299 for (from, to) in source
300 .chunks_exact(pixel_bytes)
301 .zip(target.chunks_exact_mut(pixel_bytes))
302 {
303 let read = |index: usize| -> f32 {
304 let at = index * 2;
305 let value = u16::from_ne_bytes([
306 from.get(at).copied().unwrap_or(0),
307 from.get(at + 1).copied().unwrap_or(0),
308 ]);
309 f32::from(value) / 65535.0
310 };
311 let mut write = |index: usize, value: f32| {
312 let scaled = (value.clamp(0.0, 1.0) * 65535.0 + 0.5) as u16;
313 let at = index * 2;
314 for (offset, byte) in scaled.to_ne_bytes().iter().enumerate() {
315 if let Some(slot) = to.get_mut(at + offset) {
316 *slot = *byte;
317 }
318 }
319 };
320
321 if is_gray(layout) {
322 write(0, modulate_grey(m, read(0)));
323 } else {
324 let (r, g, b) = modulate_rgb(m, read(0), read(1), read(2));
325 write(0, r);
326 write(1, g);
327 write(2, b);
328 }
329 if has_alpha(layout) {
330 let alpha = channels - 1;
331 let at = alpha * 2;
332 for offset in 0..2 {
333 if let (Some(byte), Some(slot)) = (from.get(at + offset), to.get_mut(at + offset)) {
334 *slot = *byte;
335 }
336 }
337 }
338 }
339}
340
341fn modulate_row_f32(
342 m: &Modulate,
343 source: &[u8],
344 target: &mut [u8],
345 channels: usize,
346 layout: ChannelLayout,
347) {
348 let pixel_bytes = channels * 4;
349 for (from, to) in source
350 .chunks_exact(pixel_bytes)
351 .zip(target.chunks_exact_mut(pixel_bytes))
352 {
353 let read = |index: usize| -> f32 {
354 let at = index * 4;
355 let mut bytes = [0_u8; 4];
356 for (slot, offset) in bytes.iter_mut().zip(0..4) {
357 *slot = from.get(at + offset).copied().unwrap_or(0);
358 }
359 f32::from_ne_bytes(bytes)
360 };
361 let mut write = |index: usize, value: f32| {
362 let at = index * 4;
363 for (offset, byte) in value.to_ne_bytes().iter().enumerate() {
364 if let Some(slot) = to.get_mut(at + offset) {
365 *slot = *byte;
366 }
367 }
368 };
369
370 if is_gray(layout) {
371 write(0, modulate_grey(m, read(0)));
372 } else {
373 let (r, g, b) = modulate_rgb(m, read(0), read(1), read(2));
374 write(0, r);
375 write(1, g);
376 write(2, b);
377 }
378 if has_alpha(layout) {
379 let at = (channels - 1) * 4;
380 for offset in 0..4 {
381 if let (Some(byte), Some(slot)) = (from.get(at + offset), to.get_mut(at + offset)) {
382 *slot = *byte;
383 }
384 }
385 }
386 }
387}
388
389#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
391pub struct ExtractChannel {
392 index: usize,
393}
394
395impl ExtractChannel {
396 #[must_use]
401 pub const fn new(index: usize) -> Self {
402 Self { index }
403 }
404
405 #[must_use]
407 pub const fn index(&self) -> usize {
408 self.index
409 }
410}
411
412impl Op for ExtractChannel {
413 fn rescaled(&self) -> Option<std::sync::Arc<dyn Op>> {
417 Some(std::sync::Arc::new(*self))
418 }
419 fn name(&self) -> &'static str {
420 "extract_channel"
421 }
422
423 fn output_descriptor(&self, inputs: &[ImageDescriptor]) -> Result<ImageDescriptor> {
424 let input = sole(self.name(), inputs)?;
425 if self.index >= input.pixel.channels() {
426 return Err(PixelsError::invalid_argument(
427 "index",
428 format!(
429 "channel {} does not exist in {} ({} channels)",
430 self.index,
431 input.pixel,
432 input.pixel.channels()
433 ),
434 ));
435 }
436 let gray = match input.pixel.sample_kind() {
437 SampleKind::U8 => PixelFormat::Gray8,
438 SampleKind::U16 => PixelFormat::Gray16,
439 SampleKind::F32 => {
442 return Err(PixelsError::unsupported(format!(
443 "cannot extract a channel from {}: v1 has no float greyscale format",
444 input.pixel
445 )));
446 }
447 };
448 let mut out = *input;
449 out.pixel = gray;
450 Ok(out)
451 }
452
453 fn input_regions(&self, output: Region, _inputs: &[ImageDescriptor]) -> Result<Vec<Region>> {
454 Ok(vec![output])
455 }
456
457 fn compute(&self, inputs: &[Tile<'_>], output: &mut TileMut<'_>) -> Result<()> {
458 let input = sole_tile(self.name(), inputs)?;
459 let region = output.region();
460 let in_channels = input.pixel().channels();
461 let sample = input.pixel().sample_kind().size();
462
463 for y in region.y..region.y + region.height {
464 let Some(source) = input.row(y) else { continue };
465 let Some(target) = output.row_mut(y) else {
466 continue;
467 };
468 for (pixel, slot) in source
469 .chunks_exact(in_channels * sample)
470 .zip(target.chunks_exact_mut(sample))
471 {
472 let at = self.index * sample;
473 let Some(from) = pixel.get(at..at + sample) else {
474 continue;
475 };
476 slot.copy_from_slice(from);
477 }
478 }
479 Ok(())
480 }
481}
482
483#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
485pub struct Flatten {
486 background: [u8; 3],
487}
488
489impl Flatten {
490 #[must_use]
492 pub const fn onto(red: u8, green: u8, blue: u8) -> Self {
493 Self {
494 background: [red, green, blue],
495 }
496 }
497
498 #[must_use]
500 pub const fn black() -> Self {
501 Self::onto(0, 0, 0)
502 }
503
504 #[must_use]
506 pub const fn background(&self) -> [u8; 3] {
507 self.background
508 }
509}
510
511impl Op for Flatten {
512 fn rescaled(&self) -> Option<std::sync::Arc<dyn Op>> {
516 Some(std::sync::Arc::new(*self))
517 }
518 fn name(&self) -> &'static str {
519 "flatten"
520 }
521
522 fn output_descriptor(&self, inputs: &[ImageDescriptor]) -> Result<ImageDescriptor> {
523 let input = sole(self.name(), inputs)?;
524 let opaque = match input.pixel {
525 PixelFormat::GrayA8 => PixelFormat::Gray8,
526 PixelFormat::Rgba8 => PixelFormat::Rgb8,
527 PixelFormat::Rgba16 => PixelFormat::Rgb16,
528 PixelFormat::RgbaF32 => PixelFormat::RgbF32,
529 other => other,
532 };
533 let mut out = *input;
534 out.pixel = opaque;
535 Ok(out)
536 }
537
538 fn input_regions(&self, output: Region, _inputs: &[ImageDescriptor]) -> Result<Vec<Region>> {
539 Ok(vec![output])
540 }
541
542 fn compute(&self, inputs: &[Tile<'_>], output: &mut TileMut<'_>) -> Result<()> {
543 let input = sole_tile(self.name(), inputs)?;
544 let region = output.region();
545 let in_format = input.pixel();
546 let out_format = output.pixel();
547
548 if in_format == out_format {
549 for y in region.y..region.y + region.height {
551 let (Some(source), Some(target)) = (input.row(y), output.row_mut(y)) else {
552 continue;
553 };
554 let len = target.len().min(source.len());
555 if let (Some(from), Some(to)) = (source.get(..len), target.get_mut(..len)) {
556 to.copy_from_slice(from);
557 }
558 }
559 return Ok(());
560 }
561
562 if in_format.sample_kind() != SampleKind::U8 {
563 return Err(PixelsError::unsupported(format!(
564 "flatten is implemented for 8-bit input; got {in_format}"
565 )));
566 }
567
568 let in_channels = in_format.channels();
569 let out_channels = out_format.channels();
570 for y in region.y..region.y + region.height {
571 let (Some(source), Some(target)) = (input.row(y), output.row_mut(y)) else {
572 continue;
573 };
574 for (pixel, slot) in source
575 .chunks_exact(in_channels)
576 .zip(target.chunks_exact_mut(out_channels))
577 {
578 let alpha = u32::from(pixel.get(in_channels - 1).copied().unwrap_or(255));
579 for channel in 0..out_channels {
580 let foreground = u32::from(pixel.get(channel).copied().unwrap_or(0));
581 let background = u32::from(self.background.get(channel).copied().unwrap_or(0));
582 let blended = (foreground * alpha + background * (255 - alpha) + 127) / 255;
585 if let Some(out) = slot.get_mut(channel) {
586 *out = blended as u8;
587 }
588 }
589 }
590 }
591 Ok(())
592 }
593}
594
595fn sole<'a>(op: &str, inputs: &'a [ImageDescriptor]) -> Result<&'a ImageDescriptor> {
597 inputs
598 .first()
599 .ok_or_else(|| PixelsError::graph(format!("`{op}` takes one input, got none")))
600}
601
602fn sole_tile<'a, 'b>(op: &str, inputs: &'a [Tile<'b>]) -> Result<&'a Tile<'b>> {
604 inputs
605 .first()
606 .ok_or_else(|| PixelsError::graph(format!("`{op}` takes one input tile, got none")))
607}
608
609#[cfg(test)]
610#[allow(
611 clippy::unwrap_used,
612 clippy::expect_used,
613 clippy::indexing_slicing,
614 clippy::panic,
615 reason = "tests operate on known-good values and assert shapes directly"
616)]
617mod tests {
618 use super::*;
619 use otf_pixels_core::TileBuf;
620
621 fn apply(
623 op: &dyn Op,
624 input: &ImageDescriptor,
625 bytes: &[u8],
626 ) -> Result<(ImageDescriptor, Vec<u8>)> {
627 let out_desc = op.output_descriptor(std::slice::from_ref(input))?;
628 let source = TileBuf::from_vec(input.region(), input.pixel, bytes.to_vec())?;
629 let mut target = TileBuf::for_image(&out_desc)?;
630 op.compute(&[source.as_tile()?], &mut target.as_tile_mut()?)?;
631 Ok((out_desc, target.into_bytes()))
632 }
633
634 fn image(
635 width: u32,
636 height: u32,
637 format: PixelFormat,
638 fill: &[u8],
639 ) -> (ImageDescriptor, Vec<u8>) {
640 let descriptor = ImageDescriptor::new(width, height, format).unwrap();
641 let len = descriptor.byte_len().unwrap();
642 let bytes = (0..len).map(|i| fill[i % fill.len()]).collect();
643 (descriptor, bytes)
644 }
645
646 #[test]
651 fn the_identity_modulation_changes_nothing() {
652 for format in [
655 PixelFormat::Gray8,
656 PixelFormat::Rgb8,
657 PixelFormat::Rgba8,
658 PixelFormat::Rgb16,
659 PixelFormat::Rgba16,
660 ] {
661 let (desc, bytes) = image(8, 8, format, &[13, 200, 77, 255, 4, 91]);
662 let (_, out) = apply(&Modulate::identity(), &desc, &bytes).unwrap();
663 assert_eq!(out, bytes, "{format} changed under the identity");
664 }
665 }
666
667 #[test]
668 fn hsv_round_trips_for_every_colour() {
669 for r in 0..8 {
672 for g in 0..8 {
673 for b in 0..8 {
674 let (rf, gf, bf) = (r as f32 / 7.0, g as f32 / 7.0, b as f32 / 7.0);
675 let (h, s, v) = rgb_to_hsv(rf, gf, bf);
676 let (r2, g2, b2) = hsv_to_rgb(h, s, v);
677 assert!(
678 (rf - r2).abs() < 1e-4 && (gf - g2).abs() < 1e-4 && (bf - b2).abs() < 1e-4,
679 "({rf},{gf},{bf}) -> ({h},{s},{v}) -> ({r2},{g2},{b2})"
680 );
681 }
682 }
683 }
684 }
685
686 #[test]
687 fn zero_saturation_produces_grey() {
688 let (desc, bytes) = image(4, 4, PixelFormat::Rgb8, &[200, 50, 30]);
689 let m = Modulate::identity().with_saturation(0.0).unwrap();
690 let (_, out) = apply(&m, &desc, &bytes).unwrap();
691 for pixel in out.chunks_exact(3) {
692 assert_eq!(pixel[0], pixel[1], "not grey: {pixel:?}");
693 assert_eq!(pixel[1], pixel[2], "not grey: {pixel:?}");
694 }
695 }
696
697 #[test]
698 fn brightness_scales_and_saturates_rather_than_wrapping() {
699 let (desc, bytes) = image(4, 4, PixelFormat::Rgb8, &[200, 200, 200]);
701 let m = Modulate::identity().with_brightness(2.0).unwrap();
702 let (_, out) = apply(&m, &desc, &bytes).unwrap();
703 assert!(
704 out.iter().all(|&v| v == 255),
705 "expected white, got {:?}",
706 &out[..3]
707 );
708
709 let m = Modulate::identity().with_brightness(0.5).unwrap();
710 let (_, dim) = apply(&m, &desc, &bytes).unwrap();
711 assert!(
712 dim.iter().all(|&v| v == 100),
713 "expected 100, got {:?}",
714 &dim[..3]
715 );
716 }
717
718 #[test]
719 fn a_full_hue_rotation_is_the_identity() {
720 let (desc, bytes) = image(4, 4, PixelFormat::Rgb8, &[200, 50, 30]);
721 let m = Modulate::identity().with_hue(360.0).unwrap();
722 let (_, out) = apply(&m, &desc, &bytes).unwrap();
723 for (a, b) in out.iter().zip(&bytes) {
724 assert!(a.abs_diff(*b) <= 1, "360 degrees changed {b} to {a}");
725 }
726 }
727
728 #[test]
729 fn alpha_passes_through_untouched() {
730 let (desc, bytes) = image(4, 4, PixelFormat::Rgba8, &[200, 50, 30, 137]);
733 let m = Modulate::identity()
734 .with_brightness(0.25)
735 .unwrap()
736 .with_saturation(2.0)
737 .unwrap();
738 let (_, out) = apply(&m, &desc, &bytes).unwrap();
739 for pixel in out.chunks_exact(4) {
740 assert_eq!(pixel[3], 137, "alpha was modulated");
741 }
742 }
743
744 #[test]
745 fn greyscale_takes_brightness_but_not_hue() {
746 let (desc, bytes) = image(4, 4, PixelFormat::Gray8, &[100]);
747 let m = Modulate::identity()
748 .with_brightness(0.5)
749 .unwrap()
750 .with_hue(180.0)
751 .unwrap();
752 let (_, out) = apply(&m, &desc, &bytes).unwrap();
753 assert!(out.iter().all(|&v| v == 50), "got {:?}", &out[..4]);
754 }
755
756 #[test]
757 fn lightness_lifts_every_channel_by_the_same_amount() {
758 let m = Modulate::identity().with_lightness(20.0).unwrap();
760 let (desc, bytes) = image(2, 2, PixelFormat::Rgba8, &[100, 50, 200, 77]);
761 let (_, out) = apply(&m, &desc, &bytes).unwrap();
762 assert_eq!(&out[..4], &[151, 101, 251, 77]);
763 let (desc, bytes) = image(2, 2, PixelFormat::Gray8, &[100]);
764 let (_, out) = apply(&m, &desc, &bytes).unwrap();
765 assert!(out.iter().all(|&v| v == 151), "got {:?}", &out[..4]);
766 let m = Modulate::identity().with_lightness(-100.0).unwrap();
768 let (desc, bytes) = image(2, 2, PixelFormat::Rgb8, &[100, 50, 200]);
769 let (_, out) = apply(&m, &desc, &bytes).unwrap();
770 assert!(out.iter().all(|&v| v == 0));
771 assert!(Modulate::identity().with_lightness(f32::NAN).is_err());
772 assert!(
773 !Modulate::identity()
774 .with_lightness(1.0)
775 .unwrap()
776 .is_identity()
777 );
778 }
779
780 #[test]
781 fn a_non_finite_factor_is_an_error_not_a_poisoned_image() {
782 assert!(Modulate::identity().with_brightness(f32::NAN).is_err());
783 assert!(Modulate::identity().with_brightness(f32::INFINITY).is_err());
784 assert!(Modulate::identity().with_brightness(-1.0).is_err());
785 assert!(Modulate::identity().with_saturation(f32::NAN).is_err());
786 assert!(Modulate::identity().with_hue(f32::NAN).is_err());
787 }
788
789 #[test]
794 fn extracting_a_channel_gives_that_channel() {
795 let (desc, bytes) = image(4, 4, PixelFormat::Rgba8, &[10, 20, 30, 40]);
796 for index in 0..4 {
797 let (out_desc, out) = apply(&ExtractChannel::new(index), &desc, &bytes).unwrap();
798 assert_eq!(out_desc.pixel, PixelFormat::Gray8);
799 let expected = (index as u8 + 1) * 10;
800 assert!(
801 out.iter().all(|&v| v == expected),
802 "channel {index} gave {:?}",
803 &out[..4]
804 );
805 }
806 }
807
808 #[test]
809 fn extracting_sixteen_bit_channels_preserves_precision() {
810 let descriptor = ImageDescriptor::new(4, 4, PixelFormat::Rgb16).unwrap();
811 let mut bytes = Vec::new();
812 for _ in 0..16 {
813 for value in [1000_u16, 30000, 65535] {
814 bytes.extend_from_slice(&value.to_ne_bytes());
815 }
816 }
817 let (out_desc, out) = apply(&ExtractChannel::new(1), &descriptor, &bytes).unwrap();
818 assert_eq!(out_desc.pixel, PixelFormat::Gray16);
819 for pair in out.chunks_exact(2) {
820 assert_eq!(u16::from_ne_bytes([pair[0], pair[1]]), 30000);
821 }
822 }
823
824 #[test]
825 fn extracting_a_channel_that_does_not_exist_is_an_error() {
826 let descriptor = ImageDescriptor::new(4, 4, PixelFormat::Rgb8).unwrap();
827 let error = ExtractChannel::new(3)
828 .output_descriptor(std::slice::from_ref(&descriptor))
829 .unwrap_err();
830 assert!(error.to_string().contains("does not exist"), "{error}");
831 }
832
833 #[test]
834 fn extracting_from_a_float_image_is_unsupported_not_wrong() {
835 let descriptor = ImageDescriptor::new(4, 4, PixelFormat::RgbF32).unwrap();
838 assert!(
839 ExtractChannel::new(0)
840 .output_descriptor(std::slice::from_ref(&descriptor))
841 .is_err()
842 );
843 }
844
845 #[test]
850 fn flattening_an_opaque_pixel_keeps_its_colour() {
851 let (desc, bytes) = image(4, 4, PixelFormat::Rgba8, &[200, 100, 50, 255]);
852 let (out_desc, out) = apply(&Flatten::black(), &desc, &bytes).unwrap();
853 assert_eq!(out_desc.pixel, PixelFormat::Rgb8);
854 for pixel in out.chunks_exact(3) {
855 assert_eq!(pixel, [200, 100, 50], "opaque pixel changed");
856 }
857 }
858
859 #[test]
860 fn flattening_a_transparent_pixel_gives_the_background() {
861 let (desc, bytes) = image(4, 4, PixelFormat::Rgba8, &[200, 100, 50, 0]);
862 let (_, out) = apply(&Flatten::onto(9, 8, 7), &desc, &bytes).unwrap();
863 for pixel in out.chunks_exact(3) {
864 assert_eq!(
865 pixel,
866 [9, 8, 7],
867 "transparent pixel did not take the background"
868 );
869 }
870 }
871
872 #[test]
873 fn flattening_at_half_alpha_is_the_midpoint() {
874 let (desc, bytes) = image(4, 4, PixelFormat::Rgba8, &[255, 255, 255, 128]);
876 let (_, out) = apply(&Flatten::black(), &desc, &bytes).unwrap();
877 for pixel in out.chunks_exact(3) {
878 assert_eq!(pixel, [128, 128, 128], "blend is not the midpoint");
879 }
880 }
881
882 #[test]
883 fn flattening_an_image_without_alpha_is_a_pass_through() {
884 let (desc, bytes) = image(4, 4, PixelFormat::Rgb8, &[1, 2, 3]);
886 let (out_desc, out) = apply(&Flatten::onto(200, 200, 200), &desc, &bytes).unwrap();
887 assert_eq!(out_desc.pixel, PixelFormat::Rgb8);
888 assert_eq!(out, bytes);
889 }
890
891 #[test]
892 fn flattening_greyscale_alpha_gives_greyscale() {
893 let (desc, bytes) = image(4, 4, PixelFormat::GrayA8, &[255, 0]);
894 let (out_desc, out) = apply(&Flatten::onto(30, 0, 0), &desc, &bytes).unwrap();
895 assert_eq!(out_desc.pixel, PixelFormat::Gray8);
896 assert!(out.iter().all(|&v| v == 30), "got {:?}", &out[..4]);
897 }
898}