使用 Node.js 实现图片的动态裁切

背景

目前常见的图床服务都会有图片动态裁切的功能,主要的应用场景用以为各种终端和业务形态输出合适尺寸的图片。

一张动辄以 MB 为计量单位的原始大图,通常不会只设置一下显示尺寸就直接输出到终端中,因为体积太大加载体验会很差,除了影响加载速度还会增加终端设备的 CPU 使用和内存占用。所以要想在各种终端下都能保证图片质量的同时又确保输出合适的尺寸,那么此时就需要根据图片 URL 来对原始图片进行裁切,图片裁切服务会动态生成并输出一张新的图片。

本文名词释义:

裁切:使用图片处理程序来改变图片的实体大小。

缩放:使用 CSS 样式来改变图片的大小,图片的实际大小不变。

URL 的设计

图片 URL 需要包含图片 id、尺寸、质量等信息。有两种类型的图片 URL,分别是原图 URL 和带动态裁切信息的 URL。

// 原图 URL
http://example.com/$imgId

// 带裁切信息的图片 URL
http://example.com/$cropType/$width_$height_$quality/$imgId

来分析一下上面 URL 中的变量:

  • $imgId 图片 id
  • $cropType 动态裁切的类型
  • $width 裁切的宽度
  • $height 裁切的高度
  • $quality 裁切的质量

那么一张图片 id 为 4b2d4edcc1f82452 的原图 URL 应该是:

http://example.com/4b2d4edcc1f82452.jpg

如果想要一张该图 800×600 的版本,裁切的 URL 是下面这样的:

http://example.com/es/800_600_/4b2d4edcc1f82452.jpg

其中裁切的质量可以考虑缺省,缺省时使用默认的质量进行裁切。

裁切算法

该来说说以上 URL 背后的算法了。在 Node.js 中可以使用著名的图片裁切库 GM,该库是基于 imagemagick 和 graphicsmagick 底层库的封装。

最常见的裁切算法是等比例裁切,等比裁切的算法需要至少给出裁切目标图片的宽度和高度的其中一个,如果图片限宽就给出宽度,限高就给出高度,如果两个参数都有,就需要确保裁切的目标宽高相对于原始的宽高是按比例计算的,否则裁切的结果就会出现拉伸。

var gm = require('gm');

// 裁切的最小尺寸
var minSize = 48;
var defaultQuality = 90;

/**
 * 等比例缩放 equal scaling
 * @param { String } 原文件路径
 * @param { String } 新文件路径
 * @param { String } 缩放规则
 * @return { promise }
 */
var es = function(src, dest, rules) {
    return new Promise(function(resolve, reject) {
        // 900_600_90 => 宽度900/高度600/品质90
        rules = rules.split('_');

        if (rules.length !== 3) {
            return reject(new Error('Resize rules invalid'));
        }

        // 解析裁切的目标宽高
        let resizeWidth = parseInt(rules[0]);
        let resizeHeight = parseInt(rules[1]);
        let quality = parseInt(rules[2]) || defaultQuality;
        const readStream = fs.createReadStream(src);
        const writeStream = fs.createWriteStream(dest);

        gm(readStream)
            .size({
                bufferStream: true
            }, function(err, size) {
                if (err) {
                    return reject(err);
                }

                const origWidth = size.width;
                const origHeight = size.height;
                let resizeResult;

                // 缩放的宽度和高度做最大最小值限制
                if (resizeWidth) {
                    if (resizeWidth > origWidth * 1.5) {
                        resizeWidth = Math.floor(origWidth * 1.5);
                    }
                    else if (resizeWidth < minSize) {
                        resizeWidth = minSize;
                    }
                }

                if (resizeHeight) {
                    if (resizeHeight > origHeight * 1.5) {
                        resizeHeight = Math.floor(origHeight * 1.5);
                    }
                    else if (resizeHeight < minSize) {
                        resizeHeight = minSize;
                    }
                }

                resizeResult = this.resize(resizeWidth, resizeHeight);

                resizeResult
                    .quality(quality)
                    .interlace('line') // 指定输出图片为 Progressive 渐进式压缩方式
                    .unsharp(2, 0.5, 0.5, 0)
                    .stream()
                    .on('end', resolve)
                    .pipe(writeStream);
            });
    });
};

说说几个重要的 API:

  • quality 设置图片的质量,GM 图片质量范围是 0-100,默认的质量是 75。
  • interlace 用于设置图片加载时的渲染方式,设置为 line 则可以输出一张 Progressive 渐进式压缩方式的图片。关于渐进式的图片编码的说明,详见:渐进式jpeg(progressive jpeg)图片及其相关
  • unsharp 用来设置图片的锐度,将一张大图缩放成一张小图时,会损失很多像素,需要适当的增加图片锐度来保证图片的质量。关于 unsharp 的使用,详见 Using ImageMagick to make sharp web-sized photographs

等比例裁切严格来说实际上还只是对图片进行缩放,并未动用图片裁切的 API。

还有一种比较常见的裁切方式,会先将图片等比例缩放后再从中心裁切,裁切出来的图片是一个正方形,这样能尽可能保证图片的内容。

/*
 * 等比例缩放后从中心裁切 equal scaling crop center(正方形裁切)
 * @param { String } 原文件路径
 * @param { String } 新文件路径
 * @param { String } 缩放规则
 * @return { promise }
 */
var escc = function(src, dest, rules) {
    return new Promise(function(resolve, reject) {
          // 600_90 => 宽度600/高度600/品质90
        rules = rules.split('_');

        if (rules.length !== 2) {
            return reject(new Error('Resize rules invalid'));
        }

        let cropSize = parseInt(rules[0]);
        let quality = parseInt(rules[1]) || defaultQuality;
        const readStream = fs.createReadStream(src);
        const writeStream = fs.createWriteStream(dest);

        if (!cropSize) {
            reject(new Error('Crop params invalid'));
            return;
        }

        gm(readStream)
            .size({
                bufferStream: true
            }, function(err, size) {
                if (err) {
                    reject(err);
                    return;
                }

                const origWidth = size.width;
                const origHeight = size.height;
                let cropX = 0;
                let cropY = 0;
                let resizeWidth;
                let resizeHeight;
                let resizeResult;

                // 裁切的宽度和高度做最大最小值限制
                if (cropSize > origWidth) {
                    cropSize = origWidth;
                }
                else if (cropSize > origHeight) {
                    cropSize = origHeight;
                }
                else if (cropSize < minSize) {
                    cropSize = minSize;
                }

                // 先计算出等比缩放的尺寸,然后再根据此尺寸计算出裁切位置
                if (origWidth > origHeight) {
                    resizeWidth = cropSize / origHeight * origWidth;
                    resizeHeight = cropSize;
                    cropX = Math.floor((resizeWidth - cropSize) / 2);
                    cropY = 0;
                }
                else {
                    resizeHeight = cropSize / origWidth * origHeight;
                    resizeWidth = cropSize;
                    cropX = 0;
                    cropY = Math.floor((resizeHeight - cropSize) / 2);
                }

                resizeResult = this.resize(resizeWidth, resizeHeight);

                resizeResult
                    .quality(quality)
                    .interlace('line') // 指定输出图片为 Progressive 渐进式压缩方式
                    .crop(cropSize, cropSize, cropX, cropY)
                    .unsharp(2, 0.5, 0.5, 0)
                    .stream()
                    .on('end', resolve)
                    .pipe(writeStream);
            });
    });
};

上面的 crop 就是对图片进行裁切。当然除了中心裁切,还能延伸出顶部裁切,底部裁切等,相对来说使用场景要少很多。

图片动态裁切的处理流程

流程图如下:

图片动态裁切的处理流程
 

以上是一个最简版的带图片裁切功能的图床服务的裁切处理流程图,只是介绍了最基本的流程,企业级的图床服务要比这复杂得多,比如图片存储通常会使用对象存储,还需要考虑分布式计算、缓存层、CDN等。

其他注意事项

安全限制

在服务的实际应用中会对服务的接口做一些安全限制,确保该接口不会被刷,裁切本身是比较消耗资源的操作。由于裁切操作比较耗资源,那么相同的尺寸应该保证只有一次裁切操作,这样只有第一次请求裁切图片才会真正有裁切操作,后续的访问就直接读取原来就裁切好的实体文件即可。

任务队列

考虑到比较耗资源的裁切任务还会有并发处理的场景,最好设计一个异步的任务队列来确保处理中的任务在可控的数量级之内。并发任务的最大处理量视资源消耗情况以及服务器的实际性能而定。

原载于:雨夜带刀’s Blog
本文链接:https://blog.yiguochen.com/crop-image.html
如需转载请以链接形式注明原载或原文地址。

“使用 Node.js 实现图片的动态裁切”目前一条评论