Java 藉助ImageMagic實現圖片編輯服務

title

java原生對於圖片的編輯處理並無特別友好,並且問題也有很多,那麼做爲一個java後端,若是要提供圖片的編輯服務能夠怎麼辦?也得想辦法去支持業務需求,本片博文基於此進行展開php

I. 調研

首先最容易想到的就是目前是否是已經有了相關的開源庫,直接用不就很high了嘛,git上搜一下java

1. thumbnailator

差很少四年都沒有更新了,基於awt進行圖片的編輯處理,目前提供了基本的圖片編輯接口,開始用了一段時間,有幾個繞不夠去的坑,因此最後放棄了linux

使用姿式:c++

<dependency>
  <groupId>net.coobird</groupId>
  <artifactId>thumbnailator</artifactId>
  <version>0.4.8</version>
</dependency>
複製代碼

一個使用case:git

BufferedImage originalImage = ImageIO.read(new File("original.jpg"));

BufferedImage thumbnail = Thumbnails.of(originalImage)
        .size(200, 200)
        .rotate(90)
        .asBufferedImage();
複製代碼

問題說明:github

  • jpg圖片編輯後,輸出圖片變紅的問題(詳情參考:兼容ImageIO讀取jpeg圖片變紅
  • 圖片精度丟失(對於精度要求較高的場景下,直接使用Jdk的BufferedImage會丟失精度)

上面兩個問題中,第二個精度丟失在某些對圖片質量有要求的場景下比較嚴重,若是業務場景沒那麼將就的話,用這個庫仍是能夠減小不少事情的,下面基於ImageMagic的接口設計,很大程度上參考了該工程的使用規範,由於使用起來(+閱讀)確實特別順暢web

2. simpleimage

阿里的開源庫,文檔極其欠缺,並且良久沒有人維護,沒有實際使用過,感受屬於玩票的性質(我的猜想是KPI爲導向下的產物)後端

若是想造輪子的話,參考它的源碼,某些圖片的處理方案仍是不錯的網絡

3. imagemagic + im4java

ImageMagic/GraphicMagic 是c++的圖象處理軟件,不少服務基於此來搭建圖片處理服務的hexo

  • 優勢:穩定、性能高、支持接口多、開箱即用、靠譜
  • 缺點:得提早配置環境,基本上改造不動,內部有問題也沒轍

這個方法也是下面的主要講述重點,放棄Thumbnailator選擇imagemagic的緣由以下:

  • 支持更多的服務功能(比Thumbnailator多不少的接口)
  • 沒有精度丟失問題
  • 沒有圖片失真問題(顏色變化,alpha值變化問題)

II. 環境準備

首先得安裝ImageMagic環境,有很多的第三方依賴,下面提供linux和mac的安裝過程

1. linux安裝過程

# 依賴安裝
yum install libjpeg-devel
yum install libpng-devel
yum install libwebp-devel


## 也可使用源碼方式安裝
安裝jpeg 包 `wget ftp://223.202.54.10/pub/web/php/libjpeg-6b.tar.gz`
安裝webp 包 `wget http://www.imagemagick.org/download/delegates/libwebp-0.5.1.tar.gz`
安裝png 包 `wget http://www.imagemagick.org/download/delegates/libpng-1.6.24.tar.gz`


## 下載並安裝ImageMagic
wget http://www.imagemagick.org/download/ImageMagick.tar.gz

tar -zxvf ImageMagick.tar.gz
cd ImageMagick-7.0.7-28
./configure; sudo make; sudo make install
複製代碼

安裝完畢以後,進行測試

$ convert --version

Version: ImageMagick 7.0.7-28 Q16 x86_64 2018-04-17 http://www.imagemagick.org
Copyright: © 1999-2018 ImageMagick Studio LLC
License: http://www.imagemagick.org/script/license.php
Features: Cipher DPC HDRI OpenMP
Delegates (built-in): fontconfig freetype jng jpeg lzma png webp x xml zlib
複製代碼

2. mac安裝過程

依賴安裝

sudo brew install jpeg
sudo brew install libpng
sudo brew install libwebp
sudo brew install GraphicsMagick
sudo brew install ImageMagick
複製代碼

源碼安裝方式與上面一致

3. 問題及修復

若是安裝完畢以後,可能會出現下面的問題

提示找不到png依賴:

  • 安裝:一直找不到 png的依賴,查閱須要安裝 http://pkgconfig.freedesktop.org/releases/pkg-config-0.28.tar.gz

執行 convert 提示linux shared libraries 不包含某個庫

  • 臨時方案:export LD_LIBRARY_PATH=/usr/local/lib:$LD_LIBRARY_PATH

  • 永久方案:

    vi /etc/ld.so.conf
    在這個文件里加入:/usr/local/lib 來指明共享庫的搜索位置
    而後再執行/sbin/ldconf
    複製代碼

4. 常見Convert命令

imagemagic的場景使用命令以下

裁圖

  • convert test.jpg -crop 640x960+0+0 output.jpg

旋轉

  • convert test.jpg -rotate 90 output.jpg

縮放

  • convert test.jpg -resize 200x200 output.jpg

強制寬高縮放

  • convert test.jpg -resize 200x200! output.jpg

縮略圖

  • convert -thumbnail 200x300 test.jpg thumb.jpg

上下翻轉:

  • convert -flip foo.png bar.png

左右翻轉:

  • convert -flop foo.png bar.png

水印:

  • composite -gravity northwest -dissolve 100 -geometry +0+0 water.png temp.jpg out.jpg

添加邊框 :

  • convert -border 6x6 -bordercolor "#ffffff" test.jpg bord.jpg

去除邊框 :

  • convert -thumbnail 200x300 test.jpg thumb.jpg

III. 接口設計與實現

java調用ImageMagic的方式有兩種,一個是基於命令行的,一種是基於JNI的,咱們選則im4java來操做imagemagic的接口(基於命令行的操做)

目標:

對外的使用姿式儘量如 Thumbnailtor,採用builder模式來設置參數,支持多種輸入輸出

1. im4java使用姿式

幾個簡單的case,演示下如何使用im4java實現圖片的操做

IMOperation op = new IMOperation();

// 裁剪
op.crop(operate.getWidth(), operate.getHeight(), operate.getX(), operate.getY());


// 旋轉
op.rotate(rotate);


// 壓縮
op.resize(operate.getWidth(), operate.getHeight());
op.quality(operate.getQuality().doubleValue()); // 精度


// 翻轉
op.flip();

// 鏡像
op.flop();

// 水印
op.geometry(operate.getWidth(), operate.getHeight(), operate.getX(), operate.getY()).composite();

// 邊框
op.border(operate.getWidth(), operate.getHeight()).bordercolor(operate.getColor());


// 原始命令方式添加
op.addRawArgs("-resize", "!100x200");


// 添加原始圖片地址
op.addImage(sourceFilename);
// 目標圖片地址
op.addImage(outputFilename);


/** 傳true到構造函數中,則表示使用GraphicMagic, 裁圖時,圖片大小會變 */
ConvertCmd convert = new ConvertCmd();
convert.run(op);
複製代碼

2. 使用姿式

在具體的設計接口以前,不妨先看一下最終的使用姿式,而後逆向的再看是如何設計的

private static final String localFile = "blogInfoV2.png";


/** * 複合操做 */
@Test
public void testOperate() {
    BufferedImage img;
    try {
        img = ImgWrapper.of(localFile)
                .board(10, 10, "red")
                .flip()
                .rotate(180)
                .crop(0, 0, 1200, 500)
                .asImg();
        System.out.println("--- " + img);
    } catch (Exception e) {
        e.printStackTrace();
    }
}
複製代碼

上面這個方法,演示了圖片的多個操做,首先是加個紅色邊框,而後翻轉,而後旋轉180°,再裁剪輸出圖片

因此這個封裝,確定是使用了Builder模式了,接下來看下配置參數

3. 接口設計

首先肯定目前支持的幾個方法:OperateType

其次就是相關的配置參數: Operate<T>

@Data
public static class Operate<T> {
    /** * 操做類型 */
    private OperateType operateType;

    /** * 裁剪寬; 縮放寬 */
    private Integer width;
    /** * 高 */
    private Integer height;
    /** * 裁剪時,起始 x */
    private Integer x;
    /** * 裁剪時,起始y */
    private Integer y;
    /** * 旋轉角度 */
    private Double rotate;

    /** * 按照總體的縮放參數, 1 表示不變, 和裁剪一塊兒使用 */
    private Double radio;

    /** * 圖片精度, 1 - 100 */
    private Integer quality;

    /** * 顏色 (添加邊框中的顏色; 去除圖片中某顏色) */
    private String color;

    /** * 水印圖片, 能夠爲圖片名, uri, 或者inputstream */
    private T water;

    /** * 水印圖片的類型 */
    private String waterImgType;

    /** * 強制按照給定的參數進行壓縮 */
    private boolean forceScale;


    public boolean valid() {
        switch (operateType) {
            case CROP:
                return width != null && height != null && x != null && y != null;
            case SCALE:
                return width != null || height != null || radio != null;
            case ROTATE:
                return rotate != null;
            case WATER:
                // 暫時不支持水印操做
                return water != null;
            case BOARD:
                if (width == null) {
                    width = 3;
                }
                if (height == null) {
                    height = 3;
                }
                if (color == null) {
                    color = "#ffffff";
                }
            case FLIP:
            case FLOP:
                return true;
            default:
                return false;
        }
    }

    /** * 獲取水印圖片的路徑 * * @return */
    public String getWaterFilename() throws ImgOperateException {
        try {
            return FileWriteUtil.saveFile(water, waterImgType).getAbsFile();
        } catch (Exception e) {
            e.printStackTrace();
            return null;
        }
    }
}


public enum OperateType {
    /** * 裁剪 */
    CROP,
    /** * 縮放 */
    SCALE,
    /** * 旋轉 */
    ROTATE,
    /** * 水印 */
    WATER,

    /** * 上下翻轉 */
    FLIP,

    /** * 水平翻轉 */
    FLOP,
    /** * 添加邊框 */
    BOARD;
}
複製代碼

4. Builder實現

簡化使用成本,所以針對圖片裁剪、旋轉等接口,封裝了更友好的接口方式

public static class Builder<T> {
    private T sourceFile;

    /** * 圖片類型 JPEG, PNG, GIF ... * <p> * 默認爲jpg圖片 */
    private String outputFormat = "jpg";

    private List<Operate> operates = new ArrayList<>();

    public Builder(T sourceFile) {
        this.sourceFile = sourceFile;
    }


    private static Builder<String> ofString(String str) {
        return new Builder<String>(ImgWrapper.class.getClassLoader().getResource(str).getFile());
    }


    private static Builder<URI> ofUrl(URI url) {
        return new Builder<URI>(url);
    }

    private static Builder<InputStream> ofStream(InputStream stream) {
        return new Builder<InputStream>(stream);
    }


    /** * 設置輸出的文件格式 * * @param format * @return */
    public Builder<T> setOutputFormat(String format) {
        this.outputFormat = format;
        return this;
    }


    private void updateOutputFormat(String originType) {
        if (this.outputFormat != null || originType == null) {
            return;
        }

        int index = originType.lastIndexOf(".");
        if (index <= 0) {
            return;
        }
        this.outputFormat = originType.substring(index + 1);
    }

    /** * 縮放 * * @param width * @param height * @return */
    public Builder<T> scale(Integer width, Integer height, Integer quality) {
        return scale(width, height, quality, false);
    }


    public Builder<T> scale(Integer width, Integer height, Integer quality, boolean forceScale) {
        Operate operate = new Operate();
        operate.setOperateType(OperateType.SCALE);
        operate.setWidth(width);
        operate.setHeight(height);
        operate.setQuality(quality);
        operate.setForceScale(forceScale);
        operates.add(operate);
        return this;
    }

    /** * 按照比例進行縮放 * * @param radio 1.0 表示不縮放, 0.5 縮放爲一半 * @return */
    public Builder<T> scale(Double radio, Integer quality) {
        Operate operate = new Operate();
        operate.setOperateType(OperateType.SCALE);
        operate.setRadio(radio);
        operate.setQuality(quality);
        operates.add(operate);
        return this;
    }


    /** * 裁剪 * * @param x * @param y * @param width * @param height * @return */
    public Builder<T> crop(int x, int y, int width, int height) {
        Operate operate = new Operate();
        operate.setOperateType(OperateType.CROP);
        operate.setWidth(width);
        operate.setHeight(height);
        operate.setX(x);
        operate.setY(y);
        operates.add(operate);
        return this;
    }


    /** * 旋轉 * * @param rotate * @return */
    public Builder<T> rotate(double rotate) {
        Operate operate = new Operate();
        operate.setOperateType(OperateType.ROTATE);
        operate.setRotate(rotate);
        operates.add(operate);
        return this;
    }

    /** * 上下翻轉 * * @return */
    public Builder<T> flip() {
        Operate operate = new Operate();
        operate.setOperateType(OperateType.FLIP);
        operates.add(operate);
        return this;
    }

    /** * 左右翻轉,即鏡像 * * @return */
    public Builder<T> flop() {
        Operate operate = new Operate();
        operate.setOperateType(OperateType.FLOP);
        operates.add(operate);
        return this;
    }

    /** * 添加邊框 * * @param width 邊框的寬 * @param height 邊框的高 * @param color 邊框的填充色 * @return */
    public Builder<T> board(Integer width, Integer height, String color) {
        Operate args = new Operate();
        args.setOperateType(OperateType.BOARD);
        args.setWidth(width);
        args.setHeight(height);
        args.setColor(color);
        operates.add(args);
        return this;
    }

    /** * 添加水印 * * @param water 水印的源圖片 (默認爲png格式) * @param x 添加到目標圖片的x座標 * @param y 添加到目標圖片的y座標 * @param <U> * @return */
    public <U> Builder<T> water(U water, int x, int y) {
        return water(water, "png", x, y);
    }

    /** * 添加水印 * * @param water * @param imgType 水印圖片的類型; 當傳入的爲inputStream時, 此參數纔有意義 * @param x * @param y * @param <U> * @return */
    public <U> Builder<T> water(U water, String imgType, int x, int y) {
        Operate<U> operate = new Operate<>();
        operate.setOperateType(OperateType.WATER);
        operate.setX(x);
        operate.setY(y);
        operate.setWater(water);
        operate.setWaterImgType(imgType);
        operates.add(operate);
        return this;
    }


    /** * 執行圖片處理, 並保存文件爲: 源文件_out.jpg (類型由輸出的圖片類型決定) * * @return 保存的文件名 * @throws Exception */
    public String toFile() throws Exception {
        return toFile(null);
    }


    /** * 執行圖片處理,並將結果保存爲指定文件名的file * * @param outputFilename 若爲null, 則輸出文件爲 源文件_out.jpg 這種格式 * @return * @throws Exception */
    public String toFile(String outputFilename) throws Exception {
        if (CollectionUtils.isEmpty(operates)) {
            throw new ImgOperateException("operates null!");
        }

        /** * 獲取原始的圖片信息, 並構建輸出文件名 * 1. 遠程圖片,則保存到臨時目錄下 * 2. stream, 保存到臨時目錄下 * 3. 本地文件 * * 輸出文件都放在臨時文件夾內,和原文件同名,加一個_out進行區分 **/
        FileWriteUtil.FileInfo sourceFile = createFile();
        if (outputFilename == null) {
            outputFilename = FileWriteUtil.getTmpPath() + "/"
                    + sourceFile.getFilename() + "_"
                    + System.currentTimeMillis() + "_out." + outputFormat;
        }

        /** 執行圖片的操做 */
        if (ImgBaseOperate.operate(operates, sourceFile.getAbsFile(), outputFilename)) {
            return outputFilename;
        } else {
            return null;
        }
    }

    /** * 執行圖片操做,並輸出字節流 * * @return * @throws Exception */
    public InputStream asStream() throws Exception {
        if (CollectionUtils.isEmpty(operates)) {
            throw new ImgOperateException("operate null!");
        }

        String outputFilename = this.toFile();
        if (StringUtils.isBlank(outputFilename)) {
            return null;
        }

        return new FileInputStream(new File(outputFilename));
    }


    public byte[] asBytes() throws Exception {
        if (CollectionUtils.isEmpty(operates)) {
            throw new ImgOperateException("operate null!");
        }

        String outputFilename = this.toFile();
        if (StringUtils.isBlank(outputFilename)) {
            return null;
        }


        return BytesTool.file2bytes(outputFilename);
    }


    public BufferedImage asImg() throws Exception {
        if (CollectionUtils.isEmpty(operates)) {
            throw new ImgOperateException("operate null!");
        }

        String outputFilename = this.toFile();
        if (StringUtils.isBlank(outputFilename)) {
            return null;
        }

        return ImageIO.read(new File(outputFilename));
    }


    private FileWriteUtil.FileInfo createFile() throws Exception {
        if (this.sourceFile instanceof String) {
            /** 生成的文件在源文件目錄下 */
            updateOutputFormat((String) this.sourceFile);
        } else if (this.sourceFile instanceof URI) {
            /** 源文件和生成的文件都保存在臨時目錄下 */
            String urlPath = ((URI) this.sourceFile).getPath();
            updateOutputFormat(urlPath);
        }

        return FileWriteUtil.saveFile(this.sourceFile, outputFormat);
    }
}
複製代碼

參數的設置相關的比較清晰,惟一須要注意的是輸出asFile(),這個裏面實現了一些有意思的東西

  • 保存原圖片(將網絡/二進制的原圖,保存到本地)
  • 生成臨時輸出文件
  • 命令執行

上面前兩個,主要是藉助輔助工具 FileWriteUtil實現,與主題的關聯不大,可是內部東西仍是頗有意思的,推薦查看:

命令執行的封裝以下(就是解析Operate參數,翻譯成對應的IMOperation)

/** * 執行圖片的複合操做 * * @param operates * @param sourceFilename 原始圖片名 * @param outputFilename 生成圖片名 * @return * @throws ImgOperateException */
public static boolean operate(List<ImgWrapper.Builder.Operate> operates, String sourceFilename, String outputFilename) throws ImgOperateException {
    try {
        IMOperation op = new IMOperation();
        boolean operateTag = false;
        String waterFilename = null;
        for (ImgWrapper.Builder.Operate operate : operates) {
            if (!operate.valid()) {
                continue;
            }

            if (operate.getOperateType() == ImgWrapper.Builder.OperateType.CROP) {
                op.crop(operate.getWidth(), operate.getHeight(), operate.getX(), operate.getY());
// if (operate.getRadio() != null && Math.abs(operate.getRadio() - 1.0) > 0.005) {
// // 須要對圖片進行縮放
// op.resize((int) Math.ceil(operate.getWidth() * operate.getRadio()));
// }
                operateTag = true;
            } else if (operate.getOperateType() == ImgWrapper.Builder.OperateType.ROTATE) {
                // fixme 180度旋轉後裁圖,會出現bug, 先這麼兼容
                double rotate = operate.getRotate();
                if (Math.abs((rotate % 360) - 180) <= 0.005) {
                    rotate += 0.01;
                }
                op.rotate(rotate);
                operateTag = true;
            } else if (operate.getOperateType() == ImgWrapper.Builder.OperateType.SCALE) {
                if (operate.getRadio() == null) {
                    if (operate.isForceScale()) { // 強制根據給定的參數進行壓縮時
                        StringBuilder builder = new StringBuilder();
                        builder.append("!").append(operate.getWidth() == null ? "" : operate.getWidth()).append("x");
                        builder.append(operate.getHeight() == null ? "" : operate.getHeight());
                        op.addRawArgs("-resize", builder.toString());
                    } else {
                        op.resize(operate.getWidth(), operate.getHeight());
                    }
                } else if(Math.abs(operate.getRadio() - 1) > 0.005) {
                    // 對圖片進行比例縮放
                    op.addRawArgs("-resize", "%" + (operate.getRadio() * 100));
                }

                if (operate.getQuality() != null && operate.getQuality() > 0) {
                    op.quality(operate.getQuality().doubleValue());
                }
                operateTag = true;
            } else if (operate.getOperateType() == ImgWrapper.Builder.OperateType.FLIP) {
                op.flip();
                operateTag = true;
            } else if (operate.getOperateType() == ImgWrapper.Builder.OperateType.FLOP) {
                op.flop();
                operateTag = true;
            } else if (operate.getOperateType() == ImgWrapper.Builder.OperateType.WATER && waterFilename == null) {
                // 當前只支持添加一次水印
                op.geometry(operate.getWidth(), operate.getHeight(), operate.getX(), operate.getY())
                        .composite();
                waterFilename = operate.getWaterFilename();
                operateTag = true;
            } else if (operate.getOperateType() == ImgWrapper.Builder.OperateType.BOARD) {
                op.border(operate.getWidth(), operate.getHeight()).bordercolor(operate.getColor());
                operateTag = true;
            }
        }

        if (!operateTag) {
            throw new ImgOperateException("operate illegal! operates: " + operates);
        }
        op.addImage(sourceFilename);
        if (waterFilename != null) {
            op.addImage(waterFilename);
        }
        op.addImage(outputFilename);
        /** 傳true到構造函數中,則表示使用GraphicMagic, 裁圖時,圖片大小會變 */
        ConvertCmd convert = new ConvertCmd();
        convert.run(op);
    } catch (IOException e) {
        log.error("file read error!, e: {}", e);
        return false;
    } catch (InterruptedException e) {
        log.error("interrupt exception! e: {}", e);
        return false;
    } catch (IM4JavaException e) {
        log.error("im4java exception! e: {}", e);
        return false;
    }
    return true;
}
複製代碼

5. 接口封裝

包裝一個對外使用的方式

public class ImgWrapper {
    /** * 根據本地圖片進行處理 * * @param file * @return */
    public static Builder<String> of(String file) {
        checkForNull(file, "Cannot specify null for input file.");
        if (file.startsWith("http")) {
            throw new IllegalArgumentException("file should not be URI resources! file: " + file);
        }
        return Builder.ofString(file);
    }

    public static Builder<URI> of(URI uri) {
        checkForNull(uri, "Cannot specify null for input uri.");
        return Builder.ofUrl(uri);
    }

    public static Builder<InputStream> of(InputStream inputStream) {
        checkForNull(inputStream, "Cannot specify null for InputStream.");
        return Builder.ofStream(inputStream);
    }


    private static void checkForNull(Object o, String message) {
        if (o == null) {
            throw new NullPointerException(message);
        }
    }
}
複製代碼

IV. 測試

上面基本上完成了整個接口的設計與實現,接下來就是接口測試了

給出幾個使用姿式演示,更多能夠查看:ImgWrapperTest

private static final String url = "http://a.hiphotos.baidu.com/image/pic/item/14ce36d3d539b6006a6cc5d0e550352ac65cb733.jpg";
private static final String localFile = "blogInfoV2.png";

@Test
public void testCutImg() {

    try {
        // 保存到本地
        ImgWrapper.of(URI.create(url))
                .crop(10, 20, 500, 500)
                .toFile();
    } catch (Exception e) {
        e.printStackTrace();
    }
}


@Test
public void testRotateImg() {
    try {
        InputStream stream = FileReadUtil.getStreamByFileName(localFile);
        BufferedImage img = ImgWrapper.of(stream).rotate(90).asImg();
        System.out.println("----" + img);
    } catch (Exception e) {
        e.printStackTrace();
    }
}


@Test
public void testWater() {
    BufferedImage img;
    try {
        img = ImgWrapper.of(URI.create(url))
                .board(10, 10, "red")
                .water(localFile, 100, 100)
                .asImg();
        System.out.println("--- " + img);
    } catch (Exception e) {
        e.printStackTrace();
    }
}
複製代碼

V. 其餘

項目:

GitHub:

Gitee:

我的博客: 一灰灰Blog

基於hexo + github pages搭建的我的博客,記錄全部學習和工做中的博文,歡迎你們前去逛逛

聲明

盡信書則不如,已上內容,純屬一家之言,因本人能力通常,見識有限,如發現bug或者有更好的建議,隨時歡迎批評指正

掃描關注

QrCode
相關文章
相關標籤/搜索