Apply orientation transformation based on EXIF data (#1912)
16
Cargo.lock
generated
|
@ -1203,6 +1203,7 @@ version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"config",
|
"config",
|
||||||
"errors",
|
"errors",
|
||||||
|
"kamadak-exif",
|
||||||
"libs",
|
"libs",
|
||||||
"serde",
|
"serde",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
|
@ -1340,6 +1341,15 @@ dependencies = [
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "kamadak-exif"
|
||||||
|
version = "0.5.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "70494964492bf8e491eb3951c5d70c9627eb7100ede6cc56d748b9a3f302cfb6"
|
||||||
|
dependencies = [
|
||||||
|
"mutate_once",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kernel32-sys"
|
name = "kernel32-sys"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
|
@ -1843,6 +1853,12 @@ dependencies = [
|
||||||
"similar",
|
"similar",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mutate_once"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "16cf681a23b4d0a43fc35024c176437f9dcd818db34e0f42ab456a0ee5ad497b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nanorand"
|
name = "nanorand"
|
||||||
version = "0.7.0"
|
version = "0.7.0"
|
||||||
|
|
|
@ -5,6 +5,7 @@ edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
kamadak-exif = "0.5.4"
|
||||||
|
|
||||||
errors = { path = "../errors" }
|
errors = { path = "../errors" }
|
||||||
utils = { path = "../utils" }
|
utils = { path = "../utils" }
|
||||||
|
|
|
@ -10,6 +10,7 @@ use image::error::ImageResult;
|
||||||
use image::io::Reader as ImgReader;
|
use image::io::Reader as ImgReader;
|
||||||
use image::{imageops::FilterType, EncodableLayout};
|
use image::{imageops::FilterType, EncodableLayout};
|
||||||
use image::{ImageFormat, ImageOutputFormat};
|
use image::{ImageFormat, ImageOutputFormat};
|
||||||
|
use libs::image::DynamicImage;
|
||||||
use libs::{image, once_cell, rayon, regex, svg_metadata, webp};
|
use libs::{image, once_cell, rayon, regex, svg_metadata, webp};
|
||||||
use once_cell::sync::Lazy;
|
use once_cell::sync::Lazy;
|
||||||
use rayon::prelude::*;
|
use rayon::prelude::*;
|
||||||
|
@ -319,6 +320,8 @@ impl ImageOp {
|
||||||
None => img,
|
None => img,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let img = fix_orientation(&img, &self.input_path).unwrap_or(img);
|
||||||
|
|
||||||
let mut f = File::create(target_path)?;
|
let mut f = File::create(target_path)?;
|
||||||
|
|
||||||
match self.format {
|
match self.format {
|
||||||
|
@ -343,6 +346,30 @@ impl ImageOp {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Apply image rotation based on EXIF data
|
||||||
|
/// Returns `None` if no transformation is needed
|
||||||
|
pub fn fix_orientation(img: &DynamicImage, path: &Path) -> Option<DynamicImage> {
|
||||||
|
let file = std::fs::File::open(path).ok()?;
|
||||||
|
let mut buf_reader = std::io::BufReader::new(&file);
|
||||||
|
let exif_reader = exif::Reader::new();
|
||||||
|
let exif = exif_reader.read_from_container(&mut buf_reader).ok()?;
|
||||||
|
let orientation = exif.get_field(exif::Tag::Orientation, exif::In::PRIMARY)?.value.get_uint(0)?;
|
||||||
|
match orientation {
|
||||||
|
// Values are taken from the page 30 of
|
||||||
|
// https://www.cipa.jp/std/documents/e/DC-008-2012_E.pdf
|
||||||
|
// For more details check http://sylvana.net/jpegcrop/exif_orientation.html
|
||||||
|
1 => None,
|
||||||
|
2 => Some(img.fliph()),
|
||||||
|
3 => Some(img.rotate180()),
|
||||||
|
4 => Some(img.flipv()),
|
||||||
|
5 => Some(img.fliph().rotate270()),
|
||||||
|
6 => Some(img.rotate90()),
|
||||||
|
7 => Some(img.fliph().rotate90()),
|
||||||
|
8 => Some(img.rotate270()),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct EnqueueResponse {
|
pub struct EnqueueResponse {
|
||||||
/// The final URL for that asset
|
/// The final URL for that asset
|
||||||
|
|
|
@ -2,7 +2,8 @@ use std::env;
|
||||||
use std::path::{PathBuf, MAIN_SEPARATOR as SLASH};
|
use std::path::{PathBuf, MAIN_SEPARATOR as SLASH};
|
||||||
|
|
||||||
use config::Config;
|
use config::Config;
|
||||||
use imageproc::{assert_processed_path_matches, ImageMetaResponse, Processor};
|
use imageproc::{assert_processed_path_matches, fix_orientation, ImageMetaResponse, Processor};
|
||||||
|
use libs::image::{self, DynamicImage, GenericImageView, Pixel};
|
||||||
use libs::once_cell::sync::Lazy;
|
use libs::once_cell::sync::Lazy;
|
||||||
|
|
||||||
static CONFIG: &str = r#"
|
static CONFIG: &str = r#"
|
||||||
|
@ -153,4 +154,75 @@ fn read_image_metadata_webp() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fix_orientation_test() {
|
||||||
|
fn load_img_and_fix_orientation(img_name: &str) -> DynamicImage {
|
||||||
|
let path = TEST_IMGS.join(img_name);
|
||||||
|
let img = image::open(&path).unwrap();
|
||||||
|
fix_orientation(&img, &path).unwrap_or(img)
|
||||||
|
}
|
||||||
|
|
||||||
|
let img = image::open(TEST_IMGS.join("exif_1.jpg")).unwrap();
|
||||||
|
assert!(check_img(img));
|
||||||
|
assert!(check_img(load_img_and_fix_orientation("exif_0.jpg")));
|
||||||
|
assert!(check_img(load_img_and_fix_orientation("exif_1.jpg")));
|
||||||
|
assert!(check_img(load_img_and_fix_orientation("exif_2.jpg")));
|
||||||
|
assert!(check_img(load_img_and_fix_orientation("exif_3.jpg")));
|
||||||
|
assert!(check_img(load_img_and_fix_orientation("exif_4.jpg")));
|
||||||
|
assert!(check_img(load_img_and_fix_orientation("exif_5.jpg")));
|
||||||
|
assert!(check_img(load_img_and_fix_orientation("exif_6.jpg")));
|
||||||
|
assert!(check_img(load_img_and_fix_orientation("exif_7.jpg")));
|
||||||
|
assert!(check_img(load_img_and_fix_orientation("exif_8.jpg")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resize_image_applies_exif_rotation() {
|
||||||
|
// No exif metadata
|
||||||
|
assert!(resize_and_check("exif_0.jpg"));
|
||||||
|
// 1: Horizontal (normal)
|
||||||
|
assert!(resize_and_check("exif_1.jpg"));
|
||||||
|
// 2: Mirror horizontal
|
||||||
|
assert!(resize_and_check("exif_2.jpg"));
|
||||||
|
// 3: Rotate 180
|
||||||
|
assert!(resize_and_check("exif_3.jpg"));
|
||||||
|
// 4: Mirror vertical
|
||||||
|
assert!(resize_and_check("exif_4.jpg"));
|
||||||
|
// 5: Mirror horizontal and rotate 270 CW
|
||||||
|
assert!(resize_and_check("exif_5.jpg"));
|
||||||
|
// 6: Rotate 90 CW
|
||||||
|
assert!(resize_and_check("exif_6.jpg"));
|
||||||
|
// 7: Mirror horizontal and rotate 90 CW
|
||||||
|
assert!(resize_and_check("exif_7.jpg"));
|
||||||
|
// 8: Rotate 270 CW
|
||||||
|
assert!(resize_and_check("exif_8.jpg"));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resize_and_check(source_img: &str) -> bool {
|
||||||
|
let source_path = TEST_IMGS.join(source_img);
|
||||||
|
let tmpdir = tempfile::tempdir().unwrap().into_path();
|
||||||
|
let config = Config::parse(CONFIG).unwrap();
|
||||||
|
let mut proc = Processor::new(tmpdir.clone(), &config);
|
||||||
|
|
||||||
|
let resp = proc
|
||||||
|
.enqueue(source_img.into(), source_path, "scale", Some(16), Some(16), "jpg", None)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
proc.do_process().unwrap();
|
||||||
|
let processed_path = PathBuf::from(&resp.static_path);
|
||||||
|
let img = image::open(&tmpdir.join(processed_path)).unwrap();
|
||||||
|
check_img(img)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checks that an image has the correct orientation
|
||||||
|
fn check_img(img: DynamicImage) -> bool {
|
||||||
|
// top left is red
|
||||||
|
img.get_pixel(0, 0)[0] > 250 // because of the jpeg compression some colors are a bit less than 255
|
||||||
|
// top right is green
|
||||||
|
&& img.get_pixel(15, 0)[1] > 250
|
||||||
|
// bottom left is blue
|
||||||
|
&& img.get_pixel(0, 15)[2] > 250
|
||||||
|
// bottom right is white
|
||||||
|
&& img.get_pixel(15, 15).channels() == [255, 255, 255, 255]
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: Test that hash remains the same if physical path is changed
|
// TODO: Test that hash remains the same if physical path is changed
|
||||||
|
|
BIN
components/imageproc/tests/test_imgs/exif_0.jpg
Normal file
After Width: | Height: | Size: 661 B |
BIN
components/imageproc/tests/test_imgs/exif_1.jpg
Normal file
After Width: | Height: | Size: 761 B |
BIN
components/imageproc/tests/test_imgs/exif_2.jpg
Normal file
After Width: | Height: | Size: 762 B |
BIN
components/imageproc/tests/test_imgs/exif_3.jpg
Normal file
After Width: | Height: | Size: 755 B |
BIN
components/imageproc/tests/test_imgs/exif_4.jpg
Normal file
After Width: | Height: | Size: 758 B |
BIN
components/imageproc/tests/test_imgs/exif_5.jpg
Normal file
After Width: | Height: | Size: 761 B |
BIN
components/imageproc/tests/test_imgs/exif_6.jpg
Normal file
After Width: | Height: | Size: 763 B |
BIN
components/imageproc/tests/test_imgs/exif_7.jpg
Normal file
After Width: | Height: | Size: 757 B |
BIN
components/imageproc/tests/test_imgs/exif_8.jpg
Normal file
After Width: | Height: | Size: 759 B |