Skip to content
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

Handle tiffs correctly #3050

Merged
merged 36 commits into from
Dec 4, 2020
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
ae0c67a
Detect file type inside tiff, do not assume jpg or png
aug24 Nov 30, 2020
e8a7bd4
First draft at testable image handling
aug24 Nov 30, 2020
601ab49
First draft at testable image handling
aug24 Nov 30, 2020
9969b7d
First draft at testable image handling
aug24 Nov 30, 2020
27e7c2f
First draft at testable image handling
aug24 Nov 30, 2020
ba28cec
First attempt at a mocked test
aug24 Nov 30, 2020
54b2d9b
Add basic README on TIFFs
aug24 Dec 1, 2020
4386489
Failing test
jonathonherbert Dec 1, 2020
c619069
Remove all information which does not change from test to specific test
aug24 Dec 1, 2020
d62b4f5
Add test resources - various png images with or without true colour
aug24 Dec 2, 2020
4e0077f
Expand and clean up tests
aug24 Dec 2, 2020
87db296
Fix actual bug - upload original, thumb, and optional optimised file
aug24 Dec 2, 2020
84141be
Massive refactor
aug24 Dec 2, 2020
adf3e1a
Minor refactor to move synchronous code out for for comp; check optim…
jonathonherbert Dec 2, 2020
cbc85f5
Comment out IM tests for CI
aug24 Dec 2, 2020
2b4e608
Enforce correct mime types for optimised files and thumbs
aug24 Dec 3, 2020
cebe0c4
Turn off image magick tests for CI
aug24 Dec 3, 2020
965a467
Remove hard coded type
aug24 Dec 3, 2020
8ca9d58
Correct typoed method name
aug24 Dec 3, 2020
59faf03
Rename tests to state what they are testing for
aug24 Dec 3, 2020
145f360
Correct typoed method name
aug24 Dec 3, 2020
2c86e23
Adjust image traits to prevent browser image from being labelled stor…
jonathonherbert Dec 3, 2020
637df1e
Remove association between S3 and generic store
aug24 Dec 3, 2020
bfa9dc7
Linting storable image types
aug24 Dec 3, 2020
c14e43b
Correct information in documentation
aug24 Dec 3, 2020
fc65b4d
Correct information in documentation
aug24 Dec 3, 2020
a737edf
Correct information in documentation
aug24 Dec 3, 2020
66fd580
Improve method signature and add scaladoc
aug24 Dec 3, 2020
5b5fbcf
Use new method signature
aug24 Dec 3, 2020
7f6419f
Turn off tests again
aug24 Dec 3, 2020
42ce964
Turn off tests again
aug24 Dec 3, 2020
08f0696
Seal traits
aug24 Dec 3, 2020
9d7b9f5
Tiny lint to use defined mimetype rather than infer it from the exten…
aug24 Dec 3, 2020
35ab3fb
Remove unnecessary return values
aug24 Dec 3, 2020
4021d7b
Improve file handling when cropping layered tiffs
aug24 Dec 3, 2020
a739c61
Rewrite test for better scalatest idiom when handling exceptions
aug24 Dec 4, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,43 @@ class ImageIngestOperations(imageBucket: String, thumbnailBucket: String, config

import ImageIngestOperations.{fileKeyFromId, optimisedPngKeyFromId}

def storeOriginal(id: String, file: File, mimeType: Option[MimeType], meta: Map[String, String] = Map.empty)
(implicit logMarker: LogMarker): Future[S3Object] =
storeImage(imageBucket, fileKeyFromId(id), file, mimeType, meta)

def storeThumbnail(id: String, file: File, mimeType: Option[MimeType])
(implicit logMarker: LogMarker): Future[S3Object] =
storeImage(thumbnailBucket, fileKeyFromId(id), file, mimeType)

def storeOptimisedPng(id: String, file: File)
def store(storableImage: StorableImage)
(implicit logMarker: LogMarker): Future[S3Object] = storableImage match {
aug24 marked this conversation as resolved.
Show resolved Hide resolved
case s:StorableOriginalImage => storeOriginalImage(s)
case s:StorableThumbImage => storeThumbnailImage(s)
case s:StorableOptimisedImage => storeOptimisedImage(s)
}

private def storeOriginalImage(storableImage: StorableImage)
aug24 marked this conversation as resolved.
Show resolved Hide resolved
(implicit logMarker: LogMarker): Future[S3Object] =
storeImage(imageBucket, fileKeyFromId(storableImage.id), storableImage.file, Some(storableImage.mimeType), storableImage.meta)

private def storeThumbnailImage(storableImage: StorableImage)
sihil marked this conversation as resolved.
Show resolved Hide resolved
(implicit logMarker: LogMarker): Future[S3Object] =
storeImage(thumbnailBucket, fileKeyFromId(storableImage.id), storableImage.file, Some(storableImage.mimeType))

private def storeOptimisedImage(storableImage: StorableImage)
sihil marked this conversation as resolved.
Show resolved Hide resolved
(implicit logMarker: LogMarker): Future[S3Object] =
storeImage(imageBucket, optimisedPngKeyFromId(id), file, Some(Png))
storeImage(imageBucket, optimisedPngKeyFromId(storableImage.id), storableImage.file, Some(storableImage.mimeType))

def deleteOriginal(id: String): Future[Unit] = if(isVersionedS3) deleteVersionedImage(imageBucket, fileKeyFromId(id)) else deleteImage(imageBucket, fileKeyFromId(id))
def deleteThumbnail(id: String): Future[Unit] = deleteImage(thumbnailBucket, fileKeyFromId(id))
def deletePng(id: String): Future[Unit] = deleteImage(imageBucket, optimisedPngKeyFromId(id))
}



trait StorableImage {
sihil marked this conversation as resolved.
Show resolved Hide resolved
val id: String
val file: File
val mimeType: MimeType
val meta: Map[String, String]
}
case class StorableThumbImage(id: String, file: File, mimeType: MimeType, meta: Map[String, String] = Map.empty) extends StorableImage
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We've refactored here to codify the three possible sorts of images in types. In this way, it's harder to make an error by mistaking one StorableImage for another.

case class StorableOriginalImage(id: String, file: File, mimeType: MimeType, meta: Map[String, String] = Map.empty) extends StorableImage
case class StorableOptimisedImage(id: String, file: File, mimeType: MimeType, meta: Map[String, String] = Map.empty) extends StorableImage
case class StorableBrowserViewableImage(id: String, file: File, mimeType: MimeType, meta: Map[String, String] = Map.empty, mustUpload: Boolean = false) extends StorableImage {
aug24 marked this conversation as resolved.
Show resolved Hide resolved
def asStorableOptimisedImage = StorableOptimisedImage(id, file, mimeType, meta)
def asStorableThumbImage = StorableThumbImage(id, file, mimeType, meta)

}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import java.io._

import org.im4java.core.IMOperation
import com.gu.mediaservice.lib.Files._
import com.gu.mediaservice.lib.StorableThumbImage
import com.gu.mediaservice.lib.imaging.ImageOperations.thumbMimeType
import com.gu.mediaservice.lib.imaging.im4jwrapper.ImageMagick.{addImage, format, runIdentifyCmd}
import com.gu.mediaservice.lib.imaging.im4jwrapper.{ExifTool, ImageMagick}
import com.gu.mediaservice.lib.logging.GridLogging
Expand Down Expand Up @@ -114,31 +116,37 @@ class ImageOperations(playPath: String) extends GridLogging {
val thumbUnsharpRadius = 0.5d
val thumbUnsharpSigma = 0.5d
val thumbUnsharpAmount = 0.8d
def createThumbnail(sourceFile: File, sourceMimeType: Option[MimeType], width: Int, qual: Double = 100d,
tempDir: File, iccColourSpace: Option[String], colourModel: Option[String]): Future[File] = {
def createThumbnail(sourceFile: File,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As you've been through this enough to understnad the flow to some degree would it be possible to add some basic scaladoc to these methods?

sourceMimeType: Option[MimeType],
width: Int,
qual: Double = 100d,
tempDir: File,
iccColourSpace: Option[String],
colourModel: Option[String]): Future[(File, MimeType)] = {
val cropSource = addImage(sourceFile)
val thumbnailed = thumbnail(cropSource)(width)
val corrected = correctColour(thumbnailed)(iccColourSpace, colourModel)
val converted = applyOutputProfile(corrected, optimised = true)
val stripped = stripMeta(converted)
val profiled = applyOutputProfile(stripped, optimised = true)
val unsharpened = unsharp(profiled)(thumbUnsharpRadius, thumbUnsharpSigma, thumbUnsharpAmount)
val qualified = quality(unsharpened)(qual)
val addOutput = {file:File => addDestImage(qualified)(file)}
for {
outputFile <- createTempFile(s"thumb-", ".jpg", tempDir)
cropSource = addImage(sourceFile)
thumbnailed = thumbnail(cropSource)(width)
corrected = correctColour(thumbnailed)(iccColourSpace, colourModel)
converted = applyOutputProfile(corrected, optimised = true)
stripped = stripMeta(converted)
profiled = applyOutputProfile(stripped, optimised = true)
unsharpened = unsharp(profiled)(thumbUnsharpRadius, thumbUnsharpSigma, thumbUnsharpAmount)
qualified = quality(unsharpened)(qual)
addOutput = addDestImage(qualified)(outputFile)
_ <- runConvertCmd(addOutput, useImageMagick = sourceMimeType.contains(Tiff))
} yield outputFile
outputFile <- createTempFile(s"thumb-", thumbMimeType.fileExtension, tempDir)
_ <- runConvertCmd(addOutput(outputFile), useImageMagick = sourceMimeType.contains(Tiff))
} yield (outputFile, thumbMimeType)
}

def transformImage(sourceFile: File, sourceMimeType: Option[MimeType], tempDir: File): Future[File] = {
def transformImage(sourceFile: File, sourceMimeType: Option[MimeType], tempDir: File): Future[(File, String)] = {
aug24 marked this conversation as resolved.
Show resolved Hide resolved
for {
outputFile <- createTempFile(s"transformed-", ".png", tempDir)
// png suffix is used by imagemagick to infer the required type
outputFile <- createTempFile(s"transformed-", Png.fileExtension, tempDir)
transformSource = addImage(sourceFile)
addOutput = addDestImage(transformSource)(outputFile)
_ <- runConvertCmd(addOutput, useImageMagick = sourceMimeType.contains(Tiff))
_ <- checkForOutputFileChange(outputFile)
} yield outputFile
extension <- checkForOutputFileChange(outputFile)
} yield (outputFile, extension)
}

// When a layered tiff is unpacked, the temp file (blah.something) is moved
Expand All @@ -147,7 +155,7 @@ class ImageOperations(playPath: String) extends GridLogging {
// As the file has been renamed, the file object still exists, but has the wrong name
// We will need to put it back where it is expected to be found, and clean up the other
// files.
private def checkForOutputFileChange(f: File): Future[Unit] = Future {
private def checkForOutputFileChange(f: File): Future[String] = Future {
val fileBits = f.getAbsolutePath.split("\\.").toList
val mainPart = fileBits.dropRight(1).mkString(".")
val extension = fileBits.last
Expand All @@ -160,6 +168,7 @@ class ImageOperations(playPath: String) extends GridLogging {
// Tidy up any other files (blah-1,2,3 etc will be created for each subsequent layer)
cleanUpLayerFiles(mainPart, extension, 1)
}
fileBits.last
aug24 marked this conversation as resolved.
Show resolved Hide resolved
}

@scala.annotation.tailrec
Expand All @@ -175,6 +184,7 @@ class ImageOperations(playPath: String) extends GridLogging {
}

object ImageOperations {
val thumbMimeType = Jpeg
def identifyColourModel(sourceFile: File, mimeType: MimeType)(implicit ec: ExecutionContext): Future[Option[String]] = {
// TODO: use mimeType to lookup other properties once we support other formats

Expand Down
18 changes: 18 additions & 0 deletions docs/06-objects-of-interest/06.01-tiffs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# TIFF files

Tiff files have special handling for importing.

Like all files, we upload the original, but create an optimised and thumbnail
png version.

For TIFF files, these pngs derive from the first image file found inside the
container format, which, in a file named `xxx.tif` will explode out as
`xxx.png` or `xxx.jpg`.
aug24 marked this conversation as resolved.
Show resolved Hide resolved

However, there is additional complexity with tiff files which contain layered
images. Specifically, the layer files will explode as `xxx-n.jpg` or
`xxx-n.png` where `n` is a numeric index.

Our code looks for this special case (see `ImageOperations.checkForOutputFileChange`)
and simply moves the layer file to the expected location.

2 changes: 1 addition & 1 deletion image-loader/app/ImageLoaderComponents.scala
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import com.gu.mediaservice.lib.play.GridComponents
import controllers.ImageLoaderController
import lib._
import lib.storage.ImageLoaderStore
import model.{Uploader, Projector}
import model.{Projector, Uploader}
import play.api.ApplicationLoader.Context
import router.Routes

Expand Down
19 changes: 12 additions & 7 deletions image-loader/app/lib/imaging/FileMetadataReader.scala
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@ import com.drew.metadata.jpeg.JpegDirectory
import com.drew.metadata.png.PngDirectory
import com.drew.metadata.xmp.XmpDirectory
import com.drew.metadata.{Directory, Metadata}
import com.gu.mediaservice.lib.StorableImage
import com.gu.mediaservice.lib.imaging.im4jwrapper.ImageMagick._
import com.gu.mediaservice.lib.metadata.ImageMetadataConverter
import com.gu.mediaservice.model._
import model.upload.UploadRequest
import org.joda.time.{DateTime, DateTimeZone}
import org.joda.time.format.ISODateTimeFormat
import play.api.libs.json.JsValue
Expand Down Expand Up @@ -54,16 +56,19 @@ object FileMetadataReader {
for {
metadata <- readMetadata(image)
}
yield getMetadataWithICPTCHeaders(metadata, imageId) // FIXME: JPEG, JFIF, Photoshop, GPS, File
yield getMetadataWithIPTCHeaders(metadata, imageId) // FIXME: JPEG, JFIF, Photoshop, GPS, File

def fromICPTCHeadersWithColorInfo(image: File, imageId:String, mimeType: MimeType): Future[FileMetadata] =
def fromIPTCHeadersWithColorInfo(image: StorableImage): Future[FileMetadata] =
fromIPTCHeadersWithColorInfo(image.file, image.id, image.mimeType)

def fromIPTCHeadersWithColorInfo(image: File, imageId:String, mimeType: MimeType): Future[FileMetadata] =
for {
metadata <- readMetadata(image)
colourModelInformation <- getColorModelInformation(image, metadata, mimeType)
}
yield getMetadataWithICPTCHeaders(metadata, imageId).copy(colourModelInformation = colourModelInformation)
yield getMetadataWithIPTCHeaders(metadata, imageId).copy(colourModelInformation = colourModelInformation)

private def getMetadataWithICPTCHeaders(metadata: Metadata, imageId:String): FileMetadata =
private def getMetadataWithIPTCHeaders(metadata: Metadata, imageId:String): FileMetadata =
FileMetadata(
iptc = exportDirectory(metadata, classOf[IptcDirectory]),
exif = exportDirectory(metadata, classOf[ExifIFD0Directory]),
Expand Down Expand Up @@ -217,12 +222,12 @@ object FileMetadataReader {
"paletteSize" -> Option(metaDir.getDescription(PngDirectory.TAG_PALETTE_SIZE)),
"iccProfileName" -> Option(metaDir.getDescription(PngDirectory.TAG_ICC_PROFILE_NAME))
).flattenOptions
case _ => val metaDir = metadata.getFirstDirectoryOfType(classOf[ExifIFD0Directory])
case _ => val metaDir = Option(metadata.getFirstDirectoryOfType(classOf[ExifIFD0Directory]))
Map(
"hasAlpha" -> hasAlpha,
"colorType" -> maybeImageType,
"photometricInterpretation" -> Option(metaDir.getDescription(ExifDirectoryBase.TAG_PHOTOMETRIC_INTERPRETATION)),
"bitsPerSample" -> Option(metaDir.getDescription(ExifDirectoryBase.TAG_BITS_PER_SAMPLE))
"photometricInterpretation" -> metaDir.map(_.getDescription(ExifDirectoryBase.TAG_PHOTOMETRIC_INTERPRETATION)),
"bitsPerSample" -> metaDir.map(_.getDescription(ExifDirectoryBase.TAG_BITS_PER_SAMPLE))
).flattenOptions
}

Expand Down
Loading