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

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

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依賴:

執行 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

相關文章
相關標籤/搜索