Apply orientation transformation based on EXIF data (#1912)
16
Cargo.lock
generated
|
@ -1203,6 +1203,7 @@ version = "0.1.0"
|
|||
dependencies = [
|
||||
"config",
|
||||
"errors",
|
||||
"kamadak-exif",
|
||||
"libs",
|
||||
"serde",
|
||||
"tempfile",
|
||||
|
@ -1340,6 +1341,15 @@ dependencies = [
|
|||
"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]]
|
||||
name = "kernel32-sys"
|
||||
version = "0.2.2"
|
||||
|
@ -1843,6 +1853,12 @@ dependencies = [
|
|||
"similar",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mutate_once"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "16cf681a23b4d0a43fc35024c176437f9dcd818db34e0f42ab456a0ee5ad497b"
|
||||
|
||||
[[package]]
|
||||
name = "nanorand"
|
||||
version = "0.7.0"
|
||||
|
|
|
@ -5,6 +5,7 @@ edition = "2021"
|
|||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
kamadak-exif = "0.5.4"
|
||||
|
||||
errors = { path = "../errors" }
|
||||
utils = { path = "../utils" }
|
||||
|
|
|
@ -10,6 +10,7 @@ use image::error::ImageResult;
|
|||
use image::io::Reader as ImgReader;
|
||||
use image::{imageops::FilterType, EncodableLayout};
|
||||
use image::{ImageFormat, ImageOutputFormat};
|
||||
use libs::image::DynamicImage;
|
||||
use libs::{image, once_cell, rayon, regex, svg_metadata, webp};
|
||||
use once_cell::sync::Lazy;
|
||||
use rayon::prelude::*;
|
||||
|
@ -319,6 +320,8 @@ impl ImageOp {
|
|||
None => img,
|
||||
};
|
||||
|
||||
let img = fix_orientation(&img, &self.input_path).unwrap_or(img);
|
||||
|
||||
let mut f = File::create(target_path)?;
|
||||
|
||||
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)]
|
||||
pub struct EnqueueResponse {
|
||||
/// The final URL for that asset
|
||||
|
|
|
@ -2,7 +2,8 @@ use std::env;
|
|||
use std::path::{PathBuf, MAIN_SEPARATOR as SLASH};
|
||||
|
||||
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;
|
||||
|
||||
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
|
||||
|
|
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 |