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

一灰灰發表於2018-04-17

title

java原生對於圖片的編輯處理並沒有特別友好,而且問題也有不少,那麼作為一個java後端,如果要提供圖片的編輯服務可以怎麼辦?也得想辦法去支援業務需求,本片博文基於此進行展開

I. 調研

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

1. thumbnailator

差不多四年都沒有更新了,基於awt進行圖片的編輯處理,目前提供了基本的圖片編輯介面,開始用了一段時間,有幾個繞不夠去的坑,所以最後放棄了

使用姿勢:

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

一個使用case:

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

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

問題說明:

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

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

2. simpleimage

阿里的開源庫,文件極其欠缺,而且良久沒有人維護,沒有實際使用過,感覺屬於玩票的性質(個人猜測是KPI為導向下的產物)

如果想造輪子的話,參考它的原始碼,某些圖片的處理方案還是不錯的

3. imagemagic + im4java

ImageMagic/GraphicMagic 是c++的圖象處理軟體,很多服務基於此來搭建圖片處理服務的

  • 優點:穩定、效能高、支援介面多、開箱即用、靠譜
  • 缺點:得提前配置環境,基本上改造不動,內部有問題也沒轍

這個方法也是下面的主要講述重點,放棄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

相關文章