-
Notifications
You must be signed in to change notification settings - Fork 121
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Correct colour space before cropping #1281
Changes from 5 commits
381644f
c62675e
7030715
2d8a176
addd992
efef26f
357f422
25c8bea
b25d1f9
a83bf2b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,100 @@ | ||
package com.gu.mediaservice.lib.imaging | ||
|
||
import java.io._ | ||
|
||
import org.im4java.core.IMOperation | ||
|
||
import com.gu.mediaservice.lib.Files._ | ||
import com.gu.mediaservice.lib.imaging.im4jwrapper.{ExifTool, ImageMagick} | ||
import com.gu.mediaservice.model.{Asset, Bounds, Dimensions, ImageMetadata} | ||
import play.api.libs.concurrent.Execution.Implicits._ | ||
|
||
import scala.concurrent.Future | ||
|
||
|
||
case class ExportResult(id: String, masterCrop: Asset, othersizings: List[Asset]) | ||
|
||
object ImageOperations { | ||
import ExifTool._ | ||
import ImageMagick._ | ||
|
||
private def profilePath(fileName: String): String = s"${play.api.Play.current.path}/$fileName" | ||
|
||
private def profileLocation(colourModel: String): String = colourModel match { | ||
case "RGB" => profilePath("srgb.icc") | ||
case "CMYK" => profilePath("cmyk.icc") | ||
case "GRAYSCALE" => profilePath("grayscale.icc") | ||
case model => throw new Exception(s"Profile for invalid colour model requested: $model") | ||
} | ||
|
||
private def tagFilter(metadata: ImageMetadata) = { | ||
Map[String, Option[String]]( | ||
"Copyright" -> metadata.copyright, | ||
"CopyrightNotice" -> metadata.copyrightNotice, | ||
"Credit" -> metadata.credit, | ||
"OriginalTransmissionReference" -> metadata.suppliersReference | ||
).collect { case (key, Some(value)) => (key, value) } | ||
} | ||
|
||
private def applyOutputProfile(base: IMOperation) = profile(base)(profileLocation("RGB")) | ||
|
||
def identifyColourModel(sourceFile: File, mimeType: String): Future[Option[String]] = { | ||
val source = addImage(sourceFile) | ||
// TODO: use mimeType to lookup other properties once we support other formats | ||
val formatter = format(source)("%[JPEG-Colorspace-Name]") | ||
for { | ||
output <- runIdentifyCmd(formatter) | ||
colourModel = output.headOption | ||
} yield colourModel | ||
} | ||
|
||
// Optionally apply transforms to the base operation if the colour space | ||
// in the ICC profile doesn't match the colour model of the image data | ||
private def correctColour(base: IMOperation)(iccColourSpace: Option[String], colourModel: Option[String]) = { | ||
(iccColourSpace, colourModel) match { | ||
// If matching, all is well, just pass through | ||
case (icc, model) if icc == model => base | ||
// If no colour model detected, we can't do anything anyway so just hope all is well | ||
case (_, None) => base | ||
// If mismatching, strip any (incorrect) ICC profile and inject a profile matching the model | ||
// Note: ICC identified as "icm" here | ||
case (_, Some(model)) => profile(stripProfile(base)("icm"))(profileLocation(model)) | ||
} | ||
} | ||
|
||
def cropImage(sourceFile: File, bounds: Bounds, qual: Double = 100d, tempDir: File, | ||
iccColourSpace: Option[String], colourModel: Option[String]): Future[File] = { | ||
for { | ||
outputFile <- createTempFile(s"crop-", ".jpg", tempDir) | ||
cropSource = addImage(sourceFile) | ||
qualified = quality(cropSource)(qual) | ||
corrected = correctColour(qualified)(iccColourSpace, colourModel) | ||
converted = applyOutputProfile(corrected) | ||
stripped = stripMeta(converted) | ||
profiled = applyOutputProfile(stripped) | ||
cropped = crop(profiled)(bounds) | ||
addOutput = addDestImage(cropped)(outputFile) | ||
_ <- runConvertCmd(addOutput) | ||
} | ||
yield outputFile | ||
} | ||
|
||
// Updates metadata on existing file | ||
def appendMetadata(sourceFile: File, metadata: ImageMetadata): Future[File] = { | ||
runExiftoolCmd( | ||
setTags(tagSource(sourceFile))(tagFilter(metadata)) | ||
).map(_ => sourceFile) | ||
} | ||
|
||
def resizeImage(sourceFile: File, dimensions: Dimensions, qual: Double = 100d, tempDir: File): Future[File] = { | ||
for { | ||
outputFile <- createTempFile(s"resize-", ".jpg", tempDir) | ||
resizeSource = addImage(sourceFile) | ||
qualified = quality(resizeSource)(qual) | ||
resized = scale(qualified)(dimensions) | ||
addOutput = addDestImage(resized)(outputFile) | ||
_ <- runConvertCmd(addOutput) | ||
} | ||
yield outputFile | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
package com.gu.mediaservice.lib.imaging.im4jwrapper | ||
|
||
object Config { | ||
val imagingThreadPoolSize = 4 | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,11 +1,14 @@ | ||
package lib.imaging.im4jwrapper | ||
package com.gu.mediaservice.lib.imaging.im4jwrapper | ||
|
||
import java.util.concurrent.Executors | ||
import lib.Config | ||
|
||
import java.io.File | ||
import org.im4java.process.ArrayListOutputConsumer | ||
|
||
import scala.collection.JavaConverters._ | ||
|
||
import scala.concurrent.{Future, ExecutionContext} | ||
import org.im4java.core.{IMOperation, ConvertCmd} | ||
import org.im4java.core.{IdentifyCmd, IMOperation, ConvertCmd} | ||
import scalaz.syntax.id._ | ||
|
||
import com.gu.mediaservice.model.{Dimensions, Bounds} | ||
|
@@ -18,9 +21,20 @@ object ImageMagick { | |
def addImage(source: File) = (new IMOperation()) <| { op => { op.addImage(source.getAbsolutePath) }} | ||
def quality(op: IMOperation)(qual: Double) = op <| (_.quality(qual)) | ||
def stripMeta(op: IMOperation) = op <| (_.strip()) | ||
def stripProfile(op: IMOperation)(profile: String) = op <| (_.p_profile(profile)) | ||
def addDestImage(op: IMOperation)(dest: File) = op <| (_.addImage(dest.getAbsolutePath)) | ||
def crop(op: IMOperation)(b: Bounds): IMOperation = op <| (_.crop(b.width, b.height, b.x, b.y)) | ||
def profile(op: IMOperation)(profileFileLocation: String): IMOperation = op <| (_.profile(profileFileLocation)) | ||
def thumbnail(op: IMOperation)(width: Int): IMOperation = op <| (_.thumbnail(width)) | ||
def scale(op: IMOperation)(dimensions: Dimensions): IMOperation = op <| (_.scale(dimensions.width, dimensions.height)) | ||
def format(op: IMOperation)(definition: String): IMOperation = op <| (_.format(definition)) | ||
|
||
def runConvertCmd(op: IMOperation): Future[Unit] = Future((new ConvertCmd).run(op)) | ||
def runIdentifyCmd(op: IMOperation): Future[List[String]] = Future { | ||
val cmd = new IdentifyCmd() | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Are you presuming that the identify command infers the colorspace from (a) some heuristic on the image data itself, or (b) uses image metadata? When looking at this previously I was defeated by the ImageMagick source code being impenetrable to me. I think testing indicated that it is in fact (b). If this is the case then images with incorrect or missing metadata will still fail this test. To confirm which situation is occurring you could intentionally strip all metadata from an image and then run the identify command to see whether it can correctly infer the colorspace. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Yes, it (GM) can. After running There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @theefer i'd try this with the same setup as in production (can't actually remember if we are using IM or GM, and the version may have an impact). There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Seems to work fine on the cropper instance from some quick tests. |
||
val output = new ArrayListOutputConsumer() | ||
cmd.setOutputConsumer(output) | ||
cmd.run(op) | ||
output.getOutput.asScala.toList | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,14 @@ | ||
package com.gu.mediaservice.lib.metadata | ||
|
||
import com.gu.mediaservice.model.FileMetadata | ||
|
||
object FileMetadataHelper { | ||
|
||
def normalisedIccColourSpace(fileMetadata: FileMetadata): Option[String] = { | ||
fileMetadata.icc.get("Color space") map { | ||
case "GRAY" => "GRAYSCALE" | ||
case other => other | ||
} | ||
} | ||
|
||
} |
This file was deleted.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
is it worth logging out these cases?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I do think so. It's generally interesting to me what is the composition of the whole db in terms of colour models, colour profiles. Not that (all) the metadata isn't interesting ;)