图片处理是 Web 开发中的常见需求。无论是用户头像裁剪、图片水印保护,还是格式转换优化,掌握图片处理技术都是开发者的必备技能。本文将深入讲解图片处理的核心原理和实现方法。

图片基础知识

数字图像的本质

数字图像本质上是一个像素矩阵,每个像素包含颜色信息:

图像 = 像素矩阵[宽度 × 高度]
像素 = (R, G, B, A)  // 红、绿、蓝、透明度

常见图片格式对比

格式 压缩类型 透明度 动画 适用场景
JPEG 有损 照片、复杂图像
PNG 无损 图标、需要透明的图像
GIF 无损 简单动画、图标
WebP 有损/无损 现代 Web 应用
SVG 矢量 图标、Logo、插图

图片质量与文件大小

图片质量和文件大小的关系:

文件大小 ≈ 宽度 × 高度 × 色深 × (1 - 压缩率)

图片裁剪技术

裁剪原理

图片裁剪的本质是从原图像素矩阵中提取一个子矩阵:

原图: Image[W × H]
裁剪区域: (x, y, width, height)
结果: SubImage[width × height]

Canvas 实现裁剪

class ImageCropper {
  constructor(image) {
    this.image = image;
    this.canvas = document.createElement('canvas');
    this.ctx = this.canvas.getContext('2d');
  }

  crop(x, y, width, height) {
    this.canvas.width = width;
    this.canvas.height = height;
    
    this.ctx.drawImage(
      this.image,
      x, y, width, height,  // 源图裁剪区域
      0, 0, width, height   // 目标画布位置
    );
    
    return this.canvas.toDataURL('image/png');
  }

  cropWithAspectRatio(aspectRatio) {
    const imgRatio = this.image.width / this.image.height;
    let cropWidth, cropHeight, x, y;

    if (imgRatio > aspectRatio) {
      cropHeight = this.image.height;
      cropWidth = cropHeight * aspectRatio;
      x = (this.image.width - cropWidth) / 2;
      y = 0;
    } else {
      cropWidth = this.image.width;
      cropHeight = cropWidth / aspectRatio;
      x = 0;
      y = (this.image.height - cropHeight) / 2;
    }

    return this.crop(x, y, cropWidth, cropHeight);
  }

  cropCircle(centerX, centerY, radius) {
    const diameter = radius * 2;
    this.canvas.width = diameter;
    this.canvas.height = diameter;

    this.ctx.beginPath();
    this.ctx.arc(radius, radius, radius, 0, Math.PI * 2);
    this.ctx.closePath();
    this.ctx.clip();

    this.ctx.drawImage(
      this.image,
      centerX - radius, centerY - radius, diameter, diameter,
      0, 0, diameter, diameter
    );

    return this.canvas.toDataURL('image/png');
  }
}

// 使用示例
const img = new Image();
img.onload = () => {
  const cropper = new ImageCropper(img);
  
  // 矩形裁剪
  const cropped = cropper.crop(100, 100, 300, 200);
  
  // 按比例裁剪 (16:9)
  const ratio16x9 = cropper.cropWithAspectRatio(16/9);
  
  // 圆形裁剪
  const circle = cropper.cropCircle(200, 200, 100);
};
img.src = 'image.jpg';

Python 实现裁剪

from PIL import Image
import numpy as np

class ImageCropper:
    def __init__(self, image_path):
        self.image = Image.open(image_path)
    
    def crop(self, x, y, width, height):
        """矩形裁剪"""
        box = (x, y, x + width, y + height)
        return self.image.crop(box)
    
    def crop_center(self, width, height):
        """中心裁剪"""
        img_width, img_height = self.image.size
        x = (img_width - width) // 2
        y = (img_height - height) // 2
        return self.crop(x, y, width, height)
    
    def crop_with_aspect_ratio(self, aspect_ratio):
        """按比例裁剪"""
        img_width, img_height = self.image.size
        img_ratio = img_width / img_height
        
        if img_ratio > aspect_ratio:
            new_width = int(img_height * aspect_ratio)
            new_height = img_height
            x = (img_width - new_width) // 2
            y = 0
        else:
            new_width = img_width
            new_height = int(img_width / aspect_ratio)
            x = 0
            y = (img_height - new_height) // 2
        
        return self.crop(x, y, new_width, new_height)
    
    def crop_circle(self):
        """圆形裁剪"""
        size = min(self.image.size)
        mask = Image.new('L', (size, size), 0)
        
        from PIL import ImageDraw
        draw = ImageDraw.Draw(mask)
        draw.ellipse((0, 0, size, size), fill=255)
        
        # 先裁剪为正方形
        square = self.crop_center(size, size)
        
        # 应用圆形蒙版
        output = Image.new('RGBA', (size, size), (0, 0, 0, 0))
        output.paste(square, mask=mask)
        
        return output

# 使用示例
cropper = ImageCropper('photo.jpg')
cropped = cropper.crop_with_aspect_ratio(16/9)
cropped.save('cropped_16x9.jpg')

图片水印技术

水印类型

  1. 文字水印:添加版权文字
  2. 图片水印:叠加 Logo 图片
  3. 盲水印:隐藏在图像数据中,肉眼不可见

文字水印实现

class WatermarkGenerator {
  constructor(image) {
    this.image = image;
    this.canvas = document.createElement('canvas');
    this.ctx = this.canvas.getContext('2d');
  }

  addTextWatermark(text, options = {}) {
    const {
      fontSize = 24,
      fontFamily = 'Arial',
      color = 'rgba(255, 255, 255, 0.5)',
      position = 'bottom-right',
      padding = 20,
      rotation = 0
    } = options;

    this.canvas.width = this.image.width;
    this.canvas.height = this.image.height;
    
    // 绘制原图
    this.ctx.drawImage(this.image, 0, 0);
    
    // 设置水印样式
    this.ctx.font = `${fontSize}px ${fontFamily}`;
    this.ctx.fillStyle = color;
    this.ctx.textBaseline = 'middle';
    
    // 计算位置
    const textMetrics = this.ctx.measureText(text);
    const textWidth = textMetrics.width;
    const textHeight = fontSize;
    
    let x, y;
    switch (position) {
      case 'top-left':
        x = padding;
        y = padding + textHeight / 2;
        break;
      case 'top-right':
        x = this.canvas.width - textWidth - padding;
        y = padding + textHeight / 2;
        break;
      case 'bottom-left':
        x = padding;
        y = this.canvas.height - padding - textHeight / 2;
        break;
      case 'bottom-right':
        x = this.canvas.width - textWidth - padding;
        y = this.canvas.height - padding - textHeight / 2;
        break;
      case 'center':
        x = (this.canvas.width - textWidth) / 2;
        y = this.canvas.height / 2;
        break;
    }
    
    // 应用旋转
    if (rotation !== 0) {
      this.ctx.save();
      this.ctx.translate(x + textWidth / 2, y);
      this.ctx.rotate(rotation * Math.PI / 180);
      this.ctx.fillText(text, -textWidth / 2, 0);
      this.ctx.restore();
    } else {
      this.ctx.fillText(text, x, y);
    }
    
    return this.canvas.toDataURL('image/png');
  }

  addTiledWatermark(text, options = {}) {
    const {
      fontSize = 16,
      color = 'rgba(128, 128, 128, 0.3)',
      spacing = 100,
      rotation = -30
    } = options;

    this.canvas.width = this.image.width;
    this.canvas.height = this.image.height;
    
    this.ctx.drawImage(this.image, 0, 0);
    
    this.ctx.font = `${fontSize}px Arial`;
    this.ctx.fillStyle = color;
    
    // 平铺水印
    const textWidth = this.ctx.measureText(text).width;
    const stepX = textWidth + spacing;
    const stepY = fontSize + spacing;
    
    this.ctx.save();
    this.ctx.rotate(rotation * Math.PI / 180);
    
    // 扩展绘制范围以覆盖旋转后的区域
    const diagonal = Math.sqrt(
      this.canvas.width ** 2 + this.canvas.height ** 2
    );
    
    for (let y = -diagonal; y < diagonal * 2; y += stepY) {
      for (let x = -diagonal; x < diagonal * 2; x += stepX) {
        this.ctx.fillText(text, x, y);
      }
    }
    
    this.ctx.restore();
    return this.canvas.toDataURL('image/png');
  }
}

// 使用示例
const img = new Image();
img.onload = () => {
  const watermark = new WatermarkGenerator(img);
  
  // 单个水印
  const single = watermark.addTextWatermark('© 2024 MyCompany', {
    position: 'bottom-right',
    color: 'rgba(255, 255, 255, 0.7)'
  });
  
  // 平铺水印
  const tiled = watermark.addTiledWatermark('CONFIDENTIAL', {
    rotation: -45,
    spacing: 150
  });
};
img.src = 'photo.jpg';

图片水印实现

addImageWatermark(watermarkImage, options = {}) {
  const {
    position = 'bottom-right',
    padding = 20,
    opacity = 0.7,
    scale = 0.2  // 水印相对于原图的比例
  } = options;

  this.canvas.width = this.image.width;
  this.canvas.height = this.image.height;
  
  this.ctx.drawImage(this.image, 0, 0);
  
  // 计算水印尺寸
  const wmWidth = this.image.width * scale;
  const wmHeight = (watermarkImage.height / watermarkImage.width) * wmWidth;
  
  // 计算位置
  let x, y;
  switch (position) {
    case 'bottom-right':
      x = this.canvas.width - wmWidth - padding;
      y = this.canvas.height - wmHeight - padding;
      break;
    // ... 其他位置
  }
  
  // 设置透明度
  this.ctx.globalAlpha = opacity;
  this.ctx.drawImage(watermarkImage, x, y, wmWidth, wmHeight);
  this.ctx.globalAlpha = 1;
  
  return this.canvas.toDataURL('image/png');
}

Python 水印实现

from PIL import Image, ImageDraw, ImageFont
import math

class WatermarkGenerator:
    def __init__(self, image_path):
        self.image = Image.open(image_path).convert('RGBA')
    
    def add_text_watermark(self, text, position='bottom-right', 
                           font_size=36, color=(255, 255, 255, 128)):
        """添加文字水印"""
        # 创建透明图层
        txt_layer = Image.new('RGBA', self.image.size, (255, 255, 255, 0))
        draw = ImageDraw.Draw(txt_layer)
        
        try:
            font = ImageFont.truetype('arial.ttf', font_size)
        except:
            font = ImageFont.load_default()
        
        # 获取文字尺寸
        bbox = draw.textbbox((0, 0), text, font=font)
        text_width = bbox[2] - bbox[0]
        text_height = bbox[3] - bbox[1]
        
        # 计算位置
        padding = 20
        positions = {
            'top-left': (padding, padding),
            'top-right': (self.image.width - text_width - padding, padding),
            'bottom-left': (padding, self.image.height - text_height - padding),
            'bottom-right': (self.image.width - text_width - padding, 
                           self.image.height - text_height - padding),
            'center': ((self.image.width - text_width) // 2,
                      (self.image.height - text_height) // 2)
        }
        
        x, y = positions.get(position, positions['bottom-right'])
        draw.text((x, y), text, font=font, fill=color)
        
        # 合并图层
        return Image.alpha_composite(self.image, txt_layer)
    
    def add_tiled_watermark(self, text, spacing=100, rotation=-30,
                            font_size=24, color=(128, 128, 128, 80)):
        """添加平铺水印"""
        # 创建单个水印
        try:
            font = ImageFont.truetype('arial.ttf', font_size)
        except:
            font = ImageFont.load_default()
        
        # 创建足够大的水印图层
        diagonal = int(math.sqrt(self.image.width**2 + self.image.height**2))
        txt_layer = Image.new('RGBA', (diagonal * 2, diagonal * 2), (0, 0, 0, 0))
        draw = ImageDraw.Draw(txt_layer)
        
        # 绘制平铺文字
        bbox = draw.textbbox((0, 0), text, font=font)
        text_width = bbox[2] - bbox[0]
        step_x = text_width + spacing
        step_y = font_size + spacing
        
        for y in range(0, diagonal * 2, step_y):
            for x in range(0, diagonal * 2, step_x):
                draw.text((x, y), text, font=font, fill=color)
        
        # 旋转水印图层
        txt_layer = txt_layer.rotate(rotation, expand=False)
        
        # 裁剪到原图大小
        center_x = txt_layer.width // 2
        center_y = txt_layer.height // 2
        crop_box = (
            center_x - self.image.width // 2,
            center_y - self.image.height // 2,
            center_x + self.image.width // 2,
            center_y + self.image.height // 2
        )
        txt_layer = txt_layer.crop(crop_box)
        
        return Image.alpha_composite(self.image, txt_layer)

# 使用示例
wm = WatermarkGenerator('photo.jpg')
result = wm.add_text_watermark('© 2024 MyCompany')
result.save('watermarked.png')

图片压缩与优化

压缩原理

图片压缩主要通过以下方式实现:

  1. 有损压缩:丢弃人眼不敏感的信息
  2. 无损压缩:利用数据冗余进行压缩
  3. 尺寸缩放:减少像素数量

JavaScript 压缩实现

class ImageCompressor {
  compress(image, options = {}) {
    const {
      maxWidth = 1920,
      maxHeight = 1080,
      quality = 0.8,
      format = 'image/jpeg'
    } = options;

    const canvas = document.createElement('canvas');
    const ctx = canvas.getContext('2d');
    
    // 计算新尺寸
    let { width, height } = this.calculateSize(
      image.width, image.height, maxWidth, maxHeight
    );
    
    canvas.width = width;
    canvas.height = height;
    
    // 使用双线性插值
    ctx.imageSmoothingEnabled = true;
    ctx.imageSmoothingQuality = 'high';
    
    ctx.drawImage(image, 0, 0, width, height);
    
    return new Promise((resolve) => {
      canvas.toBlob(resolve, format, quality);
    });
  }

  calculateSize(width, height, maxWidth, maxHeight) {
    if (width <= maxWidth && height <= maxHeight) {
      return { width, height };
    }
    
    const ratio = Math.min(maxWidth / width, maxHeight / height);
    return {
      width: Math.round(width * ratio),
      height: Math.round(height * ratio)
    };
  }

  async compressToTargetSize(image, targetSizeKB) {
    let quality = 0.9;
    let result;
    
    while (quality > 0.1) {
      result = await this.compress(image, { quality });
      const sizeKB = result.size / 1024;
      
      if (sizeKB <= targetSizeKB) {
        return result;
      }
      
      quality -= 0.1;
    }
    
    return result;
  }
}

// 使用示例
const compressor = new ImageCompressor();

// 文件选择处理
document.getElementById('fileInput').addEventListener('change', async (e) => {
  const file = e.target.files[0];
  const image = await loadImage(file);
  
  const compressed = await compressor.compress(image, {
    maxWidth: 1200,
    quality: 0.7
  });
  
  console.log(`原始大小: ${file.size / 1024}KB`);
  console.log(`压缩后: ${compressed.size / 1024}KB`);
});

function loadImage(file) {
  return new Promise((resolve) => {
    const img = new Image();
    img.onload = () => resolve(img);
    img.src = URL.createObjectURL(file);
  });
}

格式转换

class ImageConverter {
  async convert(image, targetFormat) {
    const canvas = document.createElement('canvas');
    const ctx = canvas.getContext('2d');
    
    canvas.width = image.width;
    canvas.height = image.height;
    ctx.drawImage(image, 0, 0);
    
    const mimeTypes = {
      'jpg': 'image/jpeg',
      'jpeg': 'image/jpeg',
      'png': 'image/png',
      'webp': 'image/webp',
      'gif': 'image/gif'
    };
    
    const mimeType = mimeTypes[targetFormat.toLowerCase()];
    
    return new Promise((resolve) => {
      canvas.toBlob(resolve, mimeType, 0.9);
    });
  }

  async toWebP(image, quality = 0.8) {
    return this.convert(image, 'webp');
  }

  async toPNG(image) {
    return this.convert(image, 'png');
  }

  async toJPEG(image, quality = 0.9) {
    const canvas = document.createElement('canvas');
    const ctx = canvas.getContext('2d');
    
    canvas.width = image.width;
    canvas.height = image.height;
    
    // JPEG 不支持透明,填充白色背景
    ctx.fillStyle = '#FFFFFF';
    ctx.fillRect(0, 0, canvas.width, canvas.height);
    ctx.drawImage(image, 0, 0);
    
    return new Promise((resolve) => {
      canvas.toBlob(resolve, 'image/jpeg', quality);
    });
  }
}

SVG 优化

SVG 优化原理

SVG 是基于 XML 的矢量图格式,优化主要包括:

  1. 移除不必要的元数据
  2. 简化路径数据
  3. 合并相同样式
  4. 压缩数值精度

SVG 优化实现

class SVGOptimizer {
  optimize(svgString, options = {}) {
    const {
      removeComments = true,
      removeMetadata = true,
      removeEmptyAttrs = true,
      cleanupNumericValues = true,
      precision = 2
    } = options;

    let result = svgString;

    if (removeComments) {
      result = result.replace(/<!--[\s\S]*?-->/g, '');
    }

    if (removeMetadata) {
      result = result.replace(/<metadata[\s\S]*?<\/metadata>/gi, '');
      result = result.replace(/<\?xml[\s\S]*?\?>/gi, '');
      result = result.replace(/<!DOCTYPE[\s\S]*?>/gi, '');
    }

    if (removeEmptyAttrs) {
      result = result.replace(/\s+[a-z-]+=""/gi, '');
    }

    if (cleanupNumericValues) {
      result = this.cleanupNumbers(result, precision);
    }

    // 移除多余空白
    result = result.replace(/\s+/g, ' ');
    result = result.replace(/>\s+</g, '><');

    return result.trim();
  }

  cleanupNumbers(svg, precision) {
    return svg.replace(
      /(\d+\.\d{3,})/g,
      (match) => parseFloat(match).toFixed(precision)
    );
  }

  simplifyPaths(svg) {
    // 简化路径命令
    return svg.replace(
      /d="([^"]+)"/g,
      (match, path) => {
        const simplified = path
          .replace(/\s+/g, ' ')
          .replace(/([MmLlHhVvCcSsQqTtAaZz])\s*/g, '$1')
          .replace(/\s+([MmLlHhVvCcSsQqTtAaZz])/g, '$1');
        return `d="${simplified}"`;
      }
    );
  }
}

// 使用示例
const optimizer = new SVGOptimizer();
const optimized = optimizer.optimize(svgContent, {
  precision: 1,
  removeMetadata: true
});

实际应用场景

1. 用户头像处理

class AvatarProcessor {
  async processAvatar(file, size = 200) {
    const image = await this.loadImage(file);
    
    // 1. 裁剪为正方形
    const cropper = new ImageCropper(image);
    const squareSize = Math.min(image.width, image.height);
    const croppedData = cropper.cropCircle(
      image.width / 2,
      image.height / 2,
      squareSize / 2
    );
    
    // 2. 缩放到目标尺寸
    const croppedImage = await this.loadImage(croppedData);
    const compressor = new ImageCompressor();
    
    return compressor.compress(croppedImage, {
      maxWidth: size,
      maxHeight: size,
      quality: 0.9
    });
  }

  loadImage(src) {
    return new Promise((resolve) => {
      const img = new Image();
      img.onload = () => resolve(img);
      img.src = typeof src === 'string' ? src : URL.createObjectURL(src);
    });
  }
}

2. 批量水印处理

import os
from concurrent.futures import ThreadPoolExecutor

def batch_watermark(input_dir, output_dir, watermark_text):
    """批量添加水印"""
    os.makedirs(output_dir, exist_ok=True)
    
    image_files = [
        f for f in os.listdir(input_dir)
        if f.lower().endswith(('.jpg', '.jpeg', '.png'))
    ]
    
    def process_image(filename):
        input_path = os.path.join(input_dir, filename)
        output_path = os.path.join(output_dir, filename)
        
        wm = WatermarkGenerator(input_path)
        result = wm.add_text_watermark(watermark_text)
        result.convert('RGB').save(output_path, 'JPEG', quality=90)
        
        return filename
    
    with ThreadPoolExecutor(max_workers=4) as executor:
        results = list(executor.map(process_image, image_files))
    
    return results

# 使用
processed = batch_watermark('./images', './output', '© 2024 MyCompany')
print(f'处理完成: {len(processed)} 张图片')

3. 响应式图片生成

async function generateResponsiveImages(image, sizes = [320, 640, 1024, 1920]) {
  const compressor = new ImageCompressor();
  const results = {};
  
  for (const width of sizes) {
    const blob = await compressor.compress(image, {
      maxWidth: width,
      quality: 0.8,
      format: 'image/webp'
    });
    
    results[width] = blob;
  }
  
  return results;
}

// 生成 srcset
function generateSrcSet(images) {
  return Object.entries(images)
    .map(([width, blob]) => `${URL.createObjectURL(blob)} ${width}w`)
    .join(', ');
}

性能优化建议

1. 使用 Web Workers

// worker.js
self.onmessage = async (e) => {
  const { imageData, operation, options } = e.data;
  
  const canvas = new OffscreenCanvas(imageData.width, imageData.height);
  const ctx = canvas.getContext('2d');
  ctx.putImageData(imageData, 0, 0);
  
  // 执行图片处理操作
  // ...
  
  const blob = await canvas.convertToBlob(options);
  self.postMessage({ blob });
};

// 主线程
const worker = new Worker('worker.js');
worker.postMessage({ imageData, operation: 'compress', options });

2. 渐进式加载

function loadImageProgressive(url, onProgress) {
  return new Promise((resolve, reject) => {
    const img = new Image();
    
    // 先加载缩略图
    const thumbUrl = url.replace('.jpg', '_thumb.jpg');
    const thumb = new Image();
    thumb.onload = () => onProgress(thumb, 0.3);
    thumb.src = thumbUrl;
    
    // 加载完整图片
    img.onload = () => resolve(img);
    img.onerror = reject;
    img.src = url;
  });
}

总结

图片处理是 Web 开发中的重要技能,核心要点包括:

  1. 裁剪:通过 Canvas 提取图像子区域
  2. 水印:在图像上叠加文字或图片保护版权
  3. 压缩:平衡质量和文件大小
  4. 格式转换:选择合适的格式优化传输

如需快速处理图片,可以使用我们的在线工具:

相关资源