seekia/internal/imagery/imagery.go

545 lines
15 KiB
Go

// imagery provides functions to read, edit and export images
package imagery
import "seekia/internal/encoding"
import "seekia/internal/helpers"
import "seekia/internal/appValues"
import "seekia/internal/localFilesystem"
import "github.com/disintegration/gift"
import "github.com/srwiley/oksvg"
import "github.com/srwiley/rasterx"
import chaiWebp "github.com/chai2010/webp"
import goJpeg "image/jpeg"
import goWebp "golang.org/x/image/webp"
import goPng "image/png"
import goFilepath "path/filepath"
import "image"
import "image/color"
import "image/draw"
import "strings"
import "bytes"
import "errors"
// Image can be either jpeg, jpg, webp or png
//Outputs:
// -bool: File found
// -bool: Able to read image
// -image.Image: golang image
// -error
func ReadImageFile(filepath string) (bool, bool, image.Image, error){
exists, fileBytes, err := localFilesystem.GetFileContents(filepath)
if (err != nil) { return false, false, nil, err }
if (exists == false){
return false, false, nil, nil
}
filename := goFilepath.Base(filepath)
filenameLowercase := strings.ToLower(filename)
isJPEG := strings.HasSuffix(filenameLowercase, "jpeg")
isJPG := strings.HasSuffix(filenameLowercase, "jpg")
if (isJPEG == true || isJPG == true){
imageObject, err := ConvertJPEGImageFileBytesToGolangImage(fileBytes)
if (err == nil) {
return true, true, imageObject, nil
}
}
isPNG := strings.HasSuffix(filenameLowercase, "png")
if (isPNG == true){
imageObject, err := ConvertPNGImageFileBytesToGolangImage(fileBytes)
if (err == nil) {
return true, true, imageObject, nil
}
}
isWEBP := strings.HasSuffix(filenameLowercase, "webp")
if (isWEBP == true){
imageObject, err := ConvertWEBPImageFileBytesToGolangImage(fileBytes)
if (err == nil){
return true, true, imageObject, nil
}
}
// File extention is unknown, or it does not correspond to the image file's true format
// We will try every method we haven't already tried.
if (isJPEG == false && isJPG == false){
imageObject, err := ConvertJPEGImageFileBytesToGolangImage(fileBytes)
if (err == nil) {
return true, true, imageObject, nil
}
}
if (isPNG == false){
imageObject, err := ConvertPNGImageFileBytesToGolangImage(fileBytes)
if (err == nil) {
return true, true, imageObject, nil
}
}
if (isWEBP == false){
imageObject, err := ConvertWEBPImageFileBytesToGolangImage(fileBytes)
if (err == nil){
return true, true, imageObject, nil
}
}
return true, false, nil, nil
}
func ConvertJPEGImageFileBytesToGolangImage(input []byte)(image.Image, error){
bytesBuffer := bytes.NewBuffer(input)
imageObject, err := goJpeg.Decode(bytesBuffer)
if (err != nil) { return nil, err }
return imageObject, nil
}
func ConvertPNGImageFileBytesToGolangImage(input []byte)(image.Image, error){
bytesBuffer := bytes.NewBuffer(input)
imageObject, err := goPng.Decode(bytesBuffer)
if (err != nil) { return nil, err }
return imageObject, nil
}
func ConvertWEBPImageFileBytesToGolangImage(input []byte)(image.Image, error){
bytesBuffer := bytes.NewBuffer(input)
imageObject, err := goWebp.Decode(bytesBuffer)
if (err != nil) { return nil, err }
return imageObject, nil
}
// This function only works for square images
func ConvertSVGImageFileBytesToGolangImage(inputBytes []byte)(image.Image, error){
//TODO: Use a different svg package, because oksvg cannot render many complex openmoji icons
fileReader := bytes.NewReader(inputBytes)
svgIconObject, err := oksvg.ReadIconStream(fileReader, oksvg.StrictErrorMode)
if (err != nil) { return nil, err }
svgWidth := int(svgIconObject.ViewBox.W)
svgHeight := int(svgIconObject.ViewBox.H)
svgIconObject.SetTarget(0, 0, 400, 400)
imageObject := image.NewRGBA(image.Rect(0, 0, 400, 400))
scannerGV := rasterx.NewScannerGV(svgWidth, svgHeight, imageObject, imageObject.Bounds())
raster := rasterx.NewDasher(400, 400, scannerGV)
svgIconObject.Draw(raster, 1)
return imageObject, nil
}
// This function creates a 1 pixel image of a provided color
func GetColorSquare(colorCode string)(image.Image, error){
colorObject, err := GetColorObjectFromColorCode(colorCode)
if (err != nil){
return nil, errors.New("GetColorSquare called with invalid color code: " + colorCode)
}
imageRectangle := image.Rect(0, 0, 1, 1)
imageObject := image.NewRGBA(imageRectangle)
imageObject.Set(0, 0, colorObject)
return imageObject, nil
}
// Example color codes:
// -Black: "000000"
// -White: "ffffff"
// -Blue: "0000ff"
// -Red: "ff0000"
func GetColorObjectFromColorCode(colorCode string)(color.Color, error){
colorCodeBytes, err := encoding.DecodeHexStringToBytes(colorCode)
if (err != nil) {
return nil, errors.New("GetColorObjectFromColorCode called with invalid color code: " + colorCode)
}
if (len(colorCodeBytes) != 3){
return nil, errors.New("GetColorObjectFromColorCode called with invalid color code: " + colorCode)
}
colorRed := colorCodeBytes[0]
colorGreen := colorCodeBytes[1]
colorBlue := colorCodeBytes[2]
// Color Alpha (opacity)
// It is always ff, which represents 100% opacity
colorAlpha := uint8(0xff)
colorObject := color.RGBA{colorRed, colorGreen, colorBlue, colorAlpha}
return colorObject, nil
}
// This converts a profile/message webp base64 image string to a viewable, cropped image
// The cropping adds transparent bars so it will conform to a reasonable ratio
// The images are encoded into profiles/messages without the bars to save space
func ConvertWEBPBase64StringToCroppedDownsizedImageObject(base64Input string)(image.Image, error){
imageObject, err := ConvertWebpBase64StringToImageObject(base64Input)
if (err != nil) { return nil, err }
croppedImage, err := cropGolangImageToMaximumRatio(imageObject)
if (err != nil) { return nil, err }
maximumSideLength := appValues.GetStandardImageMaximumSideLength()
downsizedImage, err := DownsizeGolangImage(croppedImage, maximumSideLength)
if (err != nil){ return nil, err }
return downsizedImage, nil
}
func ConvertWebpBase64StringToImageObject(base64Input string)(image.Image, error){
imageBytes, err := encoding.DecodeBase64StringToBytes(base64Input)
if (err != nil) { return nil, err }
imageObject, err := ConvertWEBPImageFileBytesToGolangImage(imageBytes)
if (err != nil) { return nil, err }
return imageObject, nil
}
func GetImageWidthAndHeightPixels(inputImage image.Image)(int, int, error){
if (inputImage == nil) {
return 0, 0, errors.New("GetImageWidthAndHeightPixels called with nil image.")
}
minX := inputImage.Bounds().Min.X
maxX := inputImage.Bounds().Max.X
minY := inputImage.Bounds().Min.Y
maxY := inputImage.Bounds().Max.Y
width := maxX-minX
height := maxY-minY
if (width <= 0 || height <= 0) {
return 0, 0, errors.New("Failed to derive image width and height.")
}
return width, height, nil
}
// This will downscale an image to the maximum side length
// If the image is already small enough, it will do nothing
// It preserves aspect ratio
func DownsizeGolangImage(inputImage image.Image, maximumSideLength int)(image.Image, error){
inputImageWidth, inputImageHeight, err := GetImageWidthAndHeightPixels(inputImage)
if (err != nil) { return nil, err }
if (inputImageWidth <= maximumSideLength && inputImageHeight <= maximumSideLength) {
return inputImage, nil
}
resizedImage, err := ResizeGolangImage(inputImage, maximumSideLength)
if (err != nil) { return nil, err }
return resizedImage, nil
}
//This function can upscale or downscale an image. It preserves aspect ratio.
func ResizeGolangImage(inputImage image.Image, maximumSideLength int) (image.Image, error){
if (inputImage == nil) {
return nil, errors.New("ResizeGolangImage called with nil image.")
}
imageWidth, imageHeight, err := GetImageWidthAndHeightPixels(inputImage)
if (err != nil) { return nil, err }
if (imageWidth == 0 || imageHeight == 0){
return nil, errors.New("ResizeGolangImage called with input image of zero width and length.")
}
if (maximumSideLength <= 0){
return nil, errors.New("ResizeGolangImage called with invalid maximumSideLength")
}
//Outputs:
// -int: New width
// -int: New height
// -error
getResizedImageWidthAndHeight := func()(int, int, error){
if (imageWidth == imageHeight){
return maximumSideLength, maximumSideLength, nil
}
// oldWidth/oldHeight = newWidth/newHeight
// We set either newWidth or newHeight to maximumSideLength
// Then we solve for either newHeight or newWidth
if (imageWidth > imageHeight) {
newHeight := float64(maximumSideLength) * (float64(imageHeight)/float64(imageWidth))
newHeightInt, err := helpers.FloorFloat64ToInt(newHeight)
if (err != nil) { return 0, 0, err }
return maximumSideLength, newHeightInt, nil
}
newWidth := float64(maximumSideLength) * (float64(imageWidth)/float64(imageHeight))
newWidthInt, err := helpers.FloorFloat64ToInt(newWidth)
if (err != nil) { return 0, 0, err }
return newWidthInt, maximumSideLength, nil
}
newWidth, newHeight, err := getResizedImageWidthAndHeight()
if (err != nil) { return nil, err }
rectangle := image.Rect(0, 0, newWidth, newHeight)
resizedImage := image.NewRGBA(rectangle)
giftResizeFilterList := gift.New(gift.Resize(newWidth, newHeight, gift.LanczosResampling))
giftResizeFilterList.Draw(resizedImage, inputImage)
return resizedImage, nil
}
// This function crops image to maximum allowed aspect ratio.
// It adds transparent space to top/bottom or left/right of image.
// (Smaller Side)/(Larger side) must be > 0.7
func cropGolangImageToMaximumRatio(inputImage image.Image)(image.Image, error){
if (inputImage == nil) {
return nil, errors.New("cropGolangImageToMaximumRatio called with nil image.")
}
widthPixels, heightPixels, err := GetImageWidthAndHeightPixels(inputImage)
if (err != nil) { return nil, err }
shorterSide := min(widthPixels, heightPixels)
longerSide := max(widthPixels, heightPixels)
currentImageRatio := float64(shorterSide)/float64(longerSide)
if (currentImageRatio > 0.7) {
//Image does not exceed maximum allowed ratio, image does not need cropping.
return inputImage, nil
}
// We must increase the length of the smaller side so that the image has a ratio of .7
// smaller/larger = .7
// smaller = (.7)*(larger)
getNewWidthAndHeight := func()(int, int, error){
minimumRatio := float64(0.7)
if (widthPixels > heightPixels){
newHeightFloat := float64(widthPixels) * minimumRatio
newHeight, err := helpers.CeilFloat64ToInt(newHeightFloat)
if (err != nil) { return 0, 0, err }
return widthPixels, newHeight, nil
}
newWidthFloat := float64(heightPixels) * minimumRatio
newWidth, err := helpers.CeilFloat64ToInt(newWidthFloat)
if (err != nil) { return 0, 0, err }
return newWidth, heightPixels, nil
}
newWidthPixels, newHeightPixels, err := getNewWidthAndHeight()
if (err != nil) { return nil, err }
newLongerSideLength := max(newWidthPixels, newHeightPixels)
resizedImage, err := ResizeGolangImage(inputImage, newLongerSideLength)
if (err != nil) { return nil, err }
resizedImageWidth, resizedImageHeight, err := GetImageWidthAndHeightPixels(resizedImage)
if (err != nil) { return nil, err }
croppedImageRectangle := image.Rect(0, 0, newWidthPixels, newHeightPixels)
croppedImage := image.NewRGBA(croppedImageRectangle)
// We find the coordinates of the top left corner of the image we are placing within our new image
xAxisPlacementPoint := -((newWidthPixels - resizedImageWidth)/2)
yAxisPlacementPoint := -((newHeightPixels - resizedImageHeight)/2)
croppedImagePlacementPoint := image.Point{
X: xAxisPlacementPoint,
Y: yAxisPlacementPoint,
}
draw.Draw(croppedImage, croppedImageRectangle, resizedImage, croppedImagePlacementPoint, draw.Over)
return croppedImage, nil
}
// Performs downsizing and compression to maximum standard size
func ConvertImageObjectToStandardWebpBase64String(inputImage image.Image)(string, error){
if (inputImage == nil) {
return "", errors.New("ConvertImageObjectToStandardWebpBase64String called with nil image.")
}
maximumSideLength := appValues.GetStandardImageMaximumSideLength()
sideLength := maximumSideLength
for sideLength > 1 {
resizedImage, err := ResizeGolangImage(inputImage, sideLength)
if (err != nil) { return "", err }
imageMaximumBytes := appValues.GetStandardImageMaximumBytes()
imageQuality := float32(100)
for {
bytesBuffer := new(bytes.Buffer)
webpOptions := &chaiWebp.Options{
Lossless: false,
Quality: imageQuality,
}
err := chaiWebp.Encode(bytesBuffer, resizedImage, webpOptions)
if (err != nil) { return "", err }
if (&bytesBuffer == nil) {
return "", errors.New("Nil image buffer after chaiWebp.Encode()")
}
imageBytes := bytesBuffer.Bytes()
if (len(imageBytes) == 0){
return "", errors.New("Empty image buffer after chaiWebp.Encode()")
}
imageSize := len(imageBytes)
if (imageSize < imageMaximumBytes){
imageBase64String := encoding.EncodeBytesToBase64String(imageBytes)
return imageBase64String, nil
}
if (imageQuality > 10){
imageQuality -= 10
} else if (imageQuality > 4){
imageQuality -= 1
} else {
// imageQuality is <= 4
// We cannot compress the image while retaining good quality at these dimensions
// We will downsize the image by 100 pixels and try again
sideLength -= 100
break
}
}
}
return "", errors.New("Failed to create standard webp image.")
}
func PixelateGolangImage(inputImage image.Image, amount0to100 int)(image.Image, error){
if (inputImage == nil) {
return nil, errors.New("PixelateGolangImage called with nil image.")
}
if (amount0to100 < 0 || amount0to100 > 100) {
return nil, errors.New("PixelateGolangImage called with input not between 0 and 100.")
}
if (amount0to100 == 0) {
return inputImage, nil
}
widthPixels, heightPixels, err := GetImageWidthAndHeightPixels(inputImage)
if (err != nil) { return nil, err }
longerSideLength := max(widthPixels, heightPixels)
pixelationAmountInt, err := helpers.ScaleIntProportionally(true, amount0to100, 0, 100, 0, longerSideLength/5)
if (err != nil) { return nil, err }
rectangle := image.Rect(0, 0, widthPixels, heightPixels)
pixelatedImage := image.NewRGBA(rectangle)
giftPixelateFilterList := gift.New(gift.Pixelate(pixelationAmountInt))
giftPixelateFilterList.Draw(pixelatedImage, inputImage)
return pixelatedImage, nil
}
// This will verify an image conforms to Seekia's image size and ratio requirements
// All images in profiles and messages must conform to these requirements
//Outputs:
// -bool: Image is valid
// -error
func VerifyStandardImageBytes(inputBytes []byte)(bool, error){
imageMaximumBytes := appValues.GetStandardImageMaximumBytes()
if (len(inputBytes) > imageMaximumBytes){
return false, nil
}
imageObject, err := ConvertWEBPImageFileBytesToGolangImage(inputBytes)
if (err != nil) {
return false, nil
}
imageWidth, imageHeight, err := GetImageWidthAndHeightPixels(imageObject)
if (err != nil) {
return false, errors.New("ConvertWEBPImageFileBytesToGolangImage returning image with invalid width and height: " + err.Error())
}
maximumAllowedSideLength := appValues.GetStandardImageMaximumSideLength()
if (imageWidth > maximumAllowedSideLength || imageHeight > maximumAllowedSideLength){
return false, nil
}
return true, nil
}