Skip to main content

图片处理

系统的并发控制通过加锁来实现,锁分乐观锁和悲观锁两种。乐观锁是事后补救措施,通过管理数据版本来实现;悲观锁是事前预防措施,通过数据库的锁机制来实现.

格式选择#

网络图像常见的文件格式有 JPG/JPEG、PNG、GIF 3种,选择的原则如下:

  • 尺寸小、构图简单、色彩表现要求不高时选择 PNG 或 GIF(动画时)
  • 图形细节及色彩表现要求较高,或需要半透明效果,选择 PNG
  • 图形细节及色彩表现要求不高,但对文件大小有较严格要求,选择 JPEG

文件格式对比#

格式文件后缀压缩损耗色彩空间透明支持适用场景
JPEG.jpg.jpeg有损耗
压缩越高,文件越小,质量越差
RGB不支持照片等构图复杂的图像
PNG.png无损耗
压缩越高,文件越小,质量不变
客户端解码消耗更多系统资源
RGB、索引色支持 256 级透明图标、绘图、文本图像
GIF.gif无损耗索引色完全透明或不透明简单的图标、绘图、文本图像及动画

RGB 色彩空间最多支持 16777216 色;索引色最多支持 256 色,且调色板可根据需要设置。支持半透明的图像,每个像素会包含透明度信息(即 Alpha 通道值)。

影响图像文件大小的主要因素#

  • 图像尺寸(像素数)
  • 图形的复杂度
  • 包含色彩的数量
  • 压缩算法

渐进加载#

如果文件尺寸较大则应设置为渐进加载(Progressive JPEGInterlaced PNG),可使客户端在加载图像的过程中呈现为图像逐渐由模糊变清晰(逐渐强化细节),而不是从上到下逐渐显示。优点是能够很快呈现图像的内容,缺点是会增加图像文件的大小。

视频中通过 Chrome 浏览器的网络状况(Network conditions)模拟 256Kb/s 速率减慢图像的加载速度以使差异明显呈现。

图像质量示例

构图简单的图像随着压缩级别的提高,其质量的下降相较构图复杂的图像将更明显。

ImageMagick#

[ImageMagick 是一款开源的图像处理软件包,能够运行于多个平台之上,支持超过 200 种图像文件格式。

本文的代码示例基于 ImageMagick 的命令行工具,如 convertidentify 等。

将 source.png 转换为 output.jpg,并将质量设置为 75%

> convert source.png -quality 75 output.jpg

从 source.png 的 (25, 0) 位置开始裁切一个 100×100 的矩形,并将其另存为 output.jpg

> convert source.png -crop 100x100+25+0 output.jpg

将 source.png 转换为 output.jpg,并将其大小更新为原尺寸的 50%

> convert source.png -resize 50% output.jpg

将 source.png 转换为 output.jpg,并将其大小更新为 50×50,且不保持原始比例

> convert source.png -resize 50x50! output.jpg

拼合两张照片

> convert photo1.jpg \
\( \
photo2.jpg \
-resize 150x100 \
-crop 100x100+25+0 \
\) \
-geometry 100x100+190+90 \
-composite \
photo3.jpg

photo1.jpg 和 photo2.jpg 的大小均为 300×200,将 photo2.jpg 中心部分裁切并缩放为 100×100 大小置于 photo1.jpg 的右下角。

ImageMagick Composite Sample

在 source.png(其大小为 150×100)转换为 output.jpg 并在右下角添加文本

> convert \
source.png \
\( \
-size 150x100 \ # 添加一个大小与 source.png 相同的图层
canvas:none \
-fill "#FFFFFF" \
-pointsize 24 \
-font arial.ttf \
-draw "gravity SouthEast text 0,0 'Watermark'" \ # SouthEast 代表右下角
\) \
-composite \ # 合并图层
output.jpg

以下命令将生成一个新的 GIF 图像文件,png:- 表示输入或输出 PNG 格式的流(而不是文件)

> convert -size 240x80 gradient:#FFDFBF-#BFFFBF png:- \ # 创建一个背景为渐变色的图像
| convert png:- \
\( \
-size 60x80 \
canvas:none \
-pointsize 60 \
-font arial.ttf \
-fill "#9F7F00" \
-draw "gravity Center scale 0.75,0.75 rotate 15 text 0,0 'A'" \
\) \
-geometry 60x80+0+0 \
-composite \
png:- \
| convert png:- \
\( \
-size 60x80 \
canvas:none \
-pointsize 60 \
-font arial.ttf \
-fill "#7F7FFF" \
-draw "gravity Center scale 1.15,1 rotate -15 text 0,0 'B'" \
\) \
-geometry 60x80+60+0 \
-composite \
png:- \
| convert png:- \
\( \
-size 60x80 \
canvas:none \
-pointsize 60 \
-font arial.ttf \
-fill "#00BF5F" \
-draw "gravity Center skewX 15 text 0,0 'C'" \
\) \
-geometry 60x80+120+0 \
-composite \
png:- \
| convert png:- \
\( \
-size 60x80 \
canvas:none \
-pointsize 60 \
-font arial.ttf \
-fill "#BF5F7F" \
-draw "gravity Center scale 1,1.25 skewY 15 text 0,0 'D'" \
\) \
-geometry 60x80+180+0 \
-composite \
output.gif

captcha sample

取得 photo.png 的格式、尺寸信息,-format 参数详细说明参见 ImageMagick 文档说明

> identify -format "%m %wx%h" photo.png
JPEG 4592x3056

注意:该示例中 photo.png 的扩展名被错误地设置为 png,通过 identify 命令可以取得实际的图像格式。

取得 photo.png 的 EXIF 信息

> identify -format "%[EXIF:*]" photo.png
...
exif:ExposureTime=1/13
...
exif:Make=SONY
...
exif:Model=NEX-5C
exif:Orientation=1
...
exif:Software=NEX-5C Ver.04
...

EXIF 信息包含了照片的基本元数据,以上示例列出了曝光时间(1/13 秒)、设备制造商(SONY)、设备型号(NEX-5C)、方向(1:正常)和编辑软件(NEX-5C Ver.04)等信息。

裁剪压缩#

图片的压缩策略是要在清晰度和文件大小上寻求一个平衡点,即要保证图片有足够的清晰度,又要保证网络加载的量尽量小。

客户端#

客户端在将文件上传到服务器前可以预先对文件进行压缩以提高上传速度,减少网络带宽占用。在对文件处理的过程中务必保留 EXIF 的方向信息,或者在上传前将图像调整为正确的方向。关于客户端裁剪压缩处理参照本站内相应的客户端文档。

EXIF信息,是可交换图像文件的缩写,是专门为数码相机的照片设定的,可以记录数码照片的属性信息和拍摄数据。EXIF可以附加于JPEG、TIFF、RIFF等文件之中,为其增加有关数码相机拍摄信息的内容和索引图或图像处理软件的版本信息。

如下图所示,对于带有方向信息的图片,如果在压缩过程中丢失了方向信息,图片在呈现端会出现扭转。 general_02_image_01

服务端#

服务器端通过对客户端上传的图像文件进行裁剪、压缩,以使文件大小及尺寸适合网络传输及显示。

原则:

  • 针对每一张原图生成两张图片:压缩图和缩略图;压缩图用于放大显示,缩略图用于列表显示。
  • 朋友圈、评价等晒图多来源于手机拍摄,文件较大、数量多、比例不一。采用等比缩放方式进行压缩,用 jpg 格式存储。
  • 封面广告采用固定宽度,高度根据宽度等比缩放。
  • 尺寸与文件大小的比例,在可接受范围内时不再进行压缩,避免如多次转载等操作对图像质量造成的损失。

    JPG 使用的是有损压缩,反复编辑会使其质量持续降低

普通图#
应用场景原始大小上限存储格式宽(px)高(px)缩放压缩质量压缩后大小
头像、徽标等500Kjpg/png540540固定宽高75%30K~70K
封面、广告等2Mjpg/png1080-固定宽75%60K~150K
照片(朋友圈、评价等晒图)2Mjpg10801440保持比例75%150K~350K
缩略图(thumbnail)#
应用场景原始大小上限存储格式宽(px)高(px)缩放压缩质量压缩后大小
头像、徽标等500Kjpg/png120120固定宽高100%7K~20K
封面、广告等2Mjpg/png240-固定宽100%7K~20K
照片(朋友圈、评价等晒图)2Mjpg180240保持比例100%7K~20K

代码示例#

需求:将客户端上传的任意尺寸的图像缩放并裁切成规定的大小,即最大宽度 1080px,最大高度 1440px,且保持原始比例,并在文件大于 200KB 时对图像质量进行压缩。

处理流程#

Image Compression Workflow

Node.js 实现#
'use strict';
const spawn = require('child_process').spawn;
const MAX_WIDTH = 1080;
const MAX_HEIGHT = 1440;
const MAX_FILE_SIZE = 200;
/**
* 执行命令。
* @param {string} command 命令
* @param {[string|number]} args 命令参数
* @returns {Promise.<string>}
*/
const executeCommand = (command, ...args) => {
return new Promise((resolve, reject) => {
let childProcess = spawn(command, args);
let result = new Buffer(0);
childProcess.stdout.on('data', buffer => {
result = Buffer.concat([ result, buffer ]);
});
childProcess.on('close', code => {
code === 0 ? resolve(result.toString()) : reject(code);
});
});
};
/**
* 裁切并压缩图像,保持原始比例。
* @param {string} filePath 图像文件路径
* @returns {Promise.<void>}
*/
module.exports = async (filePath) => {
let [ width, height ] = (await executeCommand(
'identify',
'-format', '%wx%h',
filePath
)).split('x');
width = parseInt(width);
height = parseInt(height);
// 当图像的宽或高超出限定范围时缩放图像
if (width > MAX_WIDTH || height > MAX_HEIGHT) {
// ImageMagick 默认执行等比缩放,-resize 参数提供的是最大宽高
await executeCommand('convert', filePath, '-resize', `${MAX_WIDTH}x${MAX_HEIGHT}`, filePath);
}
let fileSize = parseInt((await executeCommand('identify', '-format', '%b', filePath)));
// 当图像文件的大小超出限定范围时对图像的质量进行压缩
if (fileSize > MAX_FILE_SIZE) {
await executeCommand('convert', filePath, '-quality', 75, filePath);
}
};

添加水印#

给图片添加水印是为了保护图片版权,防止被盗图。

添加对象#

  • 添加对象为资产性质的图片,这类图片数量多。如电商平台的商品展示图、买家show、美食平台的菜品展示图等。
  • 用户头像不属于平台资产,不添加水印。
  • 广告封面数量小且添加水印影响美观,不添加水印。

水印设计#

水印设计遵循以下基本原则:

  • 水印的颜色采用系统的主色调
  • 透明度
  • 比例

代码示例(Node.js 实现)#

  • 在给定图像的右下角添加一个图标作为水印;
  • 水印最高占图像高度的 20%,最宽不超过图像的宽度。
'use strict';
const spawn = require('child_process').spawn;
/**
* 执行命令。
* @param {string} command 命令
* @param {[string|number]} args 命令参数
* @returns {Promise.<string>}
*/
const executeCommand = (command, ...args) => {
return new Promise((resolve, reject) => {
let childProcess = spawn(command, args);
let result = new Buffer(0);
childProcess.stdout.on('data', buffer => {
result = Buffer.concat([ result, buffer ]);
});
childProcess.on('close', code => {
code === 0 ? resolve(result.toString()) : reject(code);
});
});
};
/**
* 为图像添加水印。
* @param {string} filePath 图像文件路径
* @returns {Promise.<void>}
*/
module.exports = async (filePath) => {
let [ imageWidth, imageHeight ] = (await executeCommand(
'identify',
'-format', '%wx%h',
filePath
)).split('x');
imageWidth = parseInt(imageWidth);
imageHeight = parseInt(imageHeight);
let maxHeight = Math.ceil(imageHeight / 5);
await executeCommand(
'convert',
filePath,
'(',
'./watermark.jpg',
'-resize', `${imageWidth}x${maxHeight}`,
'-gravity', 'SouthEast',
')',
'-composite',
filePath
);
};