python的机器视觉技术与应用工业场景模拟第十五篇:读取图片后,将图片另存为新的图片文件,练习图像保存操作。
《Python 机器视觉技术与应用》第十五篇(修订版):读取图片后将图片另存为新的图片文件
声明:本篇全部为课程离线仿真代码,不能直接用于真实视觉检测系统,规避工程误导风险。
环境版本:本篇验证环境为
"opencv-python>=4.8.0",所有数值结果基于该版本复现。
1. 实际工业应用场景
周二上午,汽车零部件厂的视觉检测工位。实习生小王把昨天拍的500张密封圈照片从相机SD卡拷到工控机,按日期分好文件夹,准备交给算法组做缺陷训练。他随手写了行
"cv2.imwrite('result.jpg', img)",运行完一看——文件是生成了,但文件名全是
"result.jpg",后一张把前一张覆盖得干干净净。更糟的是,路径里带中文"检测记录/2026-11-04/合格件",程序直接报
"cv2.error",一张都没存下来。
老师傅老周路过,瞥了一眼屏幕:"你这代码在实验室跑Demo没问题,丢到产线上就是事故——检测结果存不下来,整批次的追溯记录全丢,质量部门能追着你问三个月。"
小王挠挠头:"不就是存个图吗?"
"图像保存是视觉软件的最后一道关口。"老周敲了敲屏幕,"路径里有中文,
"imwrite"直接失败;文件名不加时间戳,批次数据互相覆盖;存成有损JPEG,缺陷边缘的细微裂纹被压没了,质检报告等于废纸。工业软件里,存图这一步没有兜底,前面所有算法都白跑。"
小陈调出参数表:200万像素全局快门CMOS,分辨率1600×1200,像元尺寸4.5 μm,镜头焦距25 mm,光圈F4,工作距离300 mm,视野范围约216 mm×162 mm。环形白色LED光源,照度约15000 lux,安装在镜头同轴位置。被测密封圈外径80 mm,内径60 mm,表面划痕宽度约0.05~0.1 mm,对应图像上约0.4~0.7像素。要求检出最小0.1 mm宽的真实缺陷。
"先离线仿真读取一张图片,再另存为新的图片文件,把中文路径兜底、无损格式选择、时间戳文件名、文件完整性校验的逻辑跑通。"
⚠️ 声明:本代码仅用于课程离线仿真,运行在普通PC上。不包含任何真实工业相机的SDK、GigE或USB3 Vision驱动。不可直接用于产线视觉系统。
效率说明:开篇场景为1600×1200的图像,但本篇为演示效率,仿真使用400×300的程序生成图像代替真实照片。相机内参、光源参数、零件参数在本篇仅用于打印说明,不参与成像计算——仿真图像由程序内置生成器直接合成。这些参数为后续篇章的标定与测量做铺垫,请勿误认为仿真结果与焦距、光圈等物理量存在数值关联。本篇以仿真图像替代实拍图,同时演示读取后另存为的完整流程。
2. 痛点分析
课程模块 本篇对应的工程痛点
第1章 数字图像基础 → 图像文件读写
"cv2.imwrite"在Windows上使用ANSI窄字符
"fopen",中文路径会失败,需要
"imencode"+
"tofile"兜底
第1章 数字图像基础 → 图像压缩与格式 有损JPEG会破坏缺陷边缘细节,工业检测应优先使用无损PNG
第1章 数字图像基础 → 数据完整性校验 保存后必须重新读取校验,确保逐字节一致,否则存图等于没存
第1章 数字图像基础 → 文件命名规范 批量保存时文件名不加时间戳会互相覆盖,需要时间戳命名策略
本篇代码解决 L1 离线仿真校核层。输入:本地图片文件(程序自动生成并保存)。处理:读取、中文路径写入验证、另存为新文件、格式对比、完整性校验。输出:4张图表 + 单元测试。
不覆盖:工业相机SDK实时取流、GigE Vision协议、USB3 Vision协议、硬件触发采集。
3. 核心逻辑讲解
物理直觉
把图像存成文件,本质上是把内存里的三维数字矩阵"翻译"成一串字节,再写进磁盘。这个过程有三个容易翻车的地方。
一是路径编码。Windows的NTFS文件系统本身以UTF-16存储文件名,没有编码问题;问题出在OpenCV的C++层调用了ANSI窄字符
"fopen"。在中文Windows上,ANSI代码页是GBK(936),遇到UTF-8编码的中文路径就会失败。Linux和macOS下OpenCV通常使用UTF-8,中文路径一般正常——这是Windows平台特有的坑。解决办法是用
"cv2.imencode"把图像矩阵编码成字节流,再用NumPy的
"tofile"写入,完全绕开路径字符串,让文件系统只管"写字节"。
二是压缩损失。JPEG是有损压缩,把图像切成8×8的小块做离散余弦变换后丢弃高频分量——恰好是缺陷边缘那种"突然变化"的信息。PNG使用DEFLATE无损压缩,解压后和原图逐字节一致。工业检测中缺陷可能只有不到1像素宽,JPEG的压缩伪影足以把缺陷淹没。
三是文件名覆盖。批量保存时如果文件名固定,后一张必然覆盖前一张。用时间戳(年月日时分秒微秒)生成唯一文件名,是工业软件的最低要求。
业务逻辑 → 代码映射
输入:本地图片文件路径(支持中文)
↓
【步骤1:程序自动生成仿真图片(含高频条纹纹理)】
numpy向量化生成400×300的BGR测试图(色块+渐变+条纹)
↓
【步骤2:cv2.imdecode读取图片(兜底中文路径)】
校验返回值非空
↓
【步骤3:中文路径写入验证 + imwrite失败对照】
imencode+tofile写入中文路径 → 成功
cv2.imwrite写入中文路径 → 返回False(演示痛点)
↓
【步骤4:另存为新文件(按扩展名自动选择编码格式)】
PNG无损 / JPEG有损(质量可选)
↓
【步骤5:完整性校验 + 4张图表 + 单元测试】
关键公式文字翻译
图像编码:
"cv2.imencode(ext, img)"根据扩展名选择编码器,返回布尔标志和字节数组。PNG使用DEFLATE算法,对相似像素做游程编码后再压缩,不丢弃任何信息。
字节级校验:把保存后的文件重新读入内存,逐像素计算绝对差。
"max_diff == 0"意味着两个矩阵在所有位置上逐字节一致——这是无损保存的充要条件。
JPEG质量参数:
"cv2.IMWRITE_JPEG_QUALITY"取值范围0~100,数值越高压缩越轻、文件越大。即使设为100,JPEG仍然是有损的,因为量化表的最小步长不为零。工业检测中应使用PNG彻底规避此问题。
4. OOP代码实现
技术栈
限定:numpy, opencv-python (cv2>=4.8.0), matplotlib。不引入重型框架。
项目结构树
image_save/
├── run_sim.py # 主入口
├── requirements.txt # 依赖版本声明
├── vision/ # 视觉模块
│ ├── __init__.py
│ ├── models.py # dataclass数据模型
│ ├── common.py # 公共工具(图像校验、image_info、时间戳命名)
│ ├── image_generator.py # 仿真图片生成(含高频条纹)
│ ├── image_io.py # 读取与保存(含中文路径兜底、按扩展名编码)
│ ├── visualize.py # 4张图表
│ └── metrics.py # 保存质量指标
├── tests/
│ ├── __init__.py
│ └── test_integration.py # 3类10个测试
├── data/ # 原始仿真图片
│ └── test_image.png
├── results/ # 4张图表输出目录
└── output/ # 另存为新文件的输出目录
核心源码
"vision/models.py"
"""
Data models for image save simulation.
# OFFLINE SIMULATION ONLY — not for production vision systems.
"""
from dataclasses import dataclass
from typing import Tuple
@dataclass
class CameraIntrinsics:
"""工业相机内参(本篇仅用于打印说明,不参与成像计算)。"""
resolution: Tuple[int, int] # (width, height) 像素
pixel_size_um: float = 4.5 # 像元尺寸 μm
focal_length_mm: float = 25.0 # 焦距 mm
def __post_init__(self):
w, h = self.resolution
if w <= 0 or h <= 0:
raise ValueError(f"Resolution must be positive, got {self.resolution}")
if self.pixel_size_um <= 0:
raise ValueError("pixel_size_um must be positive")
if self.focal_length_mm <= 0:
raise ValueError("focal_length_mm must be positive")
@dataclass
class LightingParameters:
"""光源参数(本篇仅用于打印说明,不参与成像计算)。"""
illuminance_lux: float = 15000.0 # 环形光源照度 lux
position: str = "coaxial" # 同轴安装
def __post_init__(self):
if self.illuminance_lux <= 0:
raise ValueError("illuminance_lux must be positive")
@dataclass
class PartParameters:
"""被测零件参数(本篇仅用于打印说明,不参与成像计算)。"""
outer_diameter_mm: float = 80.0 # 密封圈外径 mm
inner_diameter_mm: float = 60.0 # 密封圈内径 mm
min_defect_mm: float = 0.1 # 最小检出缺陷 mm
def __post_init__(self):
if self.outer_diameter_mm <= self.inner_diameter_mm:
raise ValueError("outer_diameter must exceed inner_diameter")
if self.min_defect_mm <= 0:
raise ValueError("min_defect_mm must be positive")
@dataclass
class SaveParameters:
"""图像保存参数。"""
output_dir: str = "output" # 输出目录
png_filename: str = "saved_copy.png" # PNG文件名
jpeg_quality: int = 90 # JPEG质量参数(0~100)
def __post_init__(self):
if self.jpeg_quality < 0 or self.jpeg_quality > 100:
raise ValueError("jpeg_quality must be in range [0, 100]")
"vision/common.py"
"""
Common utilities shared across the series.
统一图像校验、信息获取、时间戳文件名等公共逻辑。
# OFFLINE SIMULATION ONLY — not for production vision systems.
"""
import os
import time
import numpy as np
def validate_color_image(image: np.ndarray, name: str = "image"):
"""
统一彩色图像校验:类型、维度、通道数。
uint8类型隐含保证值域在0~255,无需额外检查。
"""
if not isinstance(image, np.ndarray):
raise TypeError(f"{name} must be numpy array")
if image.ndim != 3 or image.shape[2] != 3:
raise ValueError(f"{name} must be 3-channel color (H,W,3), got shape {image.shape}")
if image.dtype != np.uint8:
raise ValueError(f"{name} must be uint8, got {image.dtype}")
def ensure_dir(path: str):
"""
安全创建目录。
对纯文件名(dirname为空)不做任何操作,避免os.makedirs('')崩溃。
"""
d = os.path.dirname(path)
if d:
os.makedirs(d, exist_ok=True)
def get_image_info(img: np.ndarray) -> dict:
"""
返回图像基本信息(尺寸、通道、数据类型、通道均值)。
下沉到common.py供各模块复用,避免重复代码。
"""
validate_color_image(img, "img")
return {
'height': img.shape[0],
'width': img.shape[1],
'channels': img.shape[2],
'dtype': str(img.dtype),
'mean_bgr': [
float(np.mean(img[:, :, 0])),
float(np.mean(img[:, :, 1])),
float(np.mean(img[:, :, 2])),
],
}
def generate_timestamp_filename(prefix: str, ext: str) -> str:
"""
生成带微秒时间戳的文件名,避免批量保存时互相覆盖。
格式:prefix_YYYYMMDD_HHMMSS_mmmmmm.ext
"""
t = time.localtime()
ms = int(time.time() * 1e6) % 1_000_000
return f"{prefix}_{time.strftime('%Y%m%d_%H%M%S', t)}_{ms:06d}{ext}"
"vision/image_generator.py"
"""
Simulated test image generator.
生成仿真彩色测试图片(含色块、渐变、高频条纹纹理)。
使用numpy向量化生成,避免逐列Python循环。
# OFFLINE SIMULATION ONLY — not for production vision systems.
"""
import os
import cv2
import numpy as np
from vision.common import ensure_dir
class TestImageGenerator:
"""
生成仿真彩色测试图片。
包含色块、渐变背景、高频条纹纹理,用于验证图像读取和保存流程。
"""
def __init__(self, height: int = 300, width: int = 400):
self.height = height
self.width = width
def generate(self) -> np.ndarray:
"""
生成测试图片(BGR格式)。
蓝色渐变背景 + 红/绿/蓝/白色块 + 高频条纹纹理。
全程向量化操作。
"""
img = np.zeros((self.height, self.width, 3), dtype=np.uint8)
# 蓝色渐变背景(B通道从50到150)
# np.round确保浮点转整数时四舍五入而非截断
b_gradient = np.round(
np.linspace(50, 150, self.width)
).astype(np.uint8)[None, :]
img[:, :, 0] = b_gradient
img[:, :, 1] = 30
img[:, :, 2] = 30
# 红色色块 (B=0, G=0, R=255)
img[50:150, 50:150] = [0, 0, 255]
# 绿色色块 (B=0, G=255, R=0)
img[50:150, 200:300] = [0, 255, 0]
# 蓝色彩块 (B=255, G=0, R=0)
img[200:300, 50:150] = [255, 0, 0]
# 白色区域 (B=255, G=255, R=255)
img[200:300, 200:300] = [255, 255, 255]
# 高频条纹纹理(用于验证JPEG有损压缩效果)
# 水平条纹:每2像素交替黑白,产生高频变化
stripe_h = np.zeros((20, self.width, 3), dtype=np.uint8)
stripe_h[:, ::2] = [255, 255, 255] # 白
stripe_h[:, 1::2] = [0, 0, 0] # 黑
img[270:290, :] = stripe_h
# 垂直条纹:每2像素交替黑白
stripe_v = np.zeros((self.height, 20, 3), dtype=np.uint8)
stripe_v[::2, :] = [255, 255, 255]
stripe_v[1::2, :] = [0, 0, 0]
img[:, 380:400] = stripe_v
return img
def save(self, filepath: str) -> str:
"""
生成并保存测试图片到指定路径。
使用imencode+tofile兜底中文路径。
"""
ensure_dir(filepath)
img = self.generate()
_, buf = cv2.imencode('.png', img)
buf.tofile(filepath)
return filepath
"vision/image_io.py"
"""
Image reading and saving with OpenCV.
封装图像读取、另存为新文件、中文路径兜底、按扩展名选择编码格式。
# OFFLINE SIMULATION ONLY — not for production vision systems.
"""
import os
import cv2
import numpy as np
from vision.models import SaveParameters
from vision.common import validate_color_image, ensure_dir
class ImageIO:
"""
图像读取与保存。
核心方法:read_image(fromfile+imdecode兜底中文路径)、
save_image(按扩展名自动选择编码格式)、
save_jpeg(指定质量参数保存)。
"""
def __init__(self, params: SaveParameters = None):
self.params = params if params is not None else SaveParameters()
def read_image(self, filepath: str) -> np.ndarray:
"""
读取本地图片文件。
使用numpy.fromfile + cv2.imdecode兜底中文路径。
"""
if not isinstance(filepath, str):
raise TypeError("filepath must be string")
if not os.path.isfile(filepath):
raise FileNotFoundError(f"File not found: {filepath}")
buf = np.fromfile(filepath, dtype=np.uint8)
if buf.size == 0:
raise ValueError(f"Empty file: {filepath}")
img = cv2.imdecode(buf, cv2.IMREAD_COLOR)
if img is None:
raise ValueError(f"Failed to decode image: {filepath}")
validate_color_image(img, "read_image result")
return img
def save_image(self, img: np.ndarray, filepath: str) -> str:
"""
将图像另存为新文件,按扩展名自动选择编码格式。
支持 .png / .jpg / .jpeg / .bmp。
使用imencode+tofile兜底中文路径。
"""
validate_color_image(img, "img")
ensure_dir(filepath)
# 从文件路径提取扩展名,默认使用.png
ext = os.path.splitext(filepath)[1].lower()
if not ext:
ext = '.png'
ok, buf = cv2.imencode(ext, img)
if not ok:
raise ValueError(f"imencode failed for extension: {ext}")
buf.tofile(filepath)
return filepath
def save_jpeg(self, img: np.ndarray, filepath: str,
quality: int = None) -> str:
"""
将图像保存为JPEG格式,可指定质量参数。
"""
validate_color_image(img, "img")
ensure_dir(filepath)
q = quality if quality is not None else self.params.jpeg_quality
# 确保扩展名为.jpg
base, _ = os.path.splitext(filepath)
jpg_path = base + '.jpg'
_, buf = cv2.imencode('.jpg', img, [cv2.IMWRITE_JPEG_QUALITY, q])
buf.tofile(jpg_path)
return jpg_path
def image_info(self, img: np.ndarray) -> dict:
"""返回图像基本信息(委托给common.get_image_info)。"""
from vision.common import get_image_info
return get_image_info(img)
"vision/metrics.py"
"""
Quality metrics for image save verification.
验证图像另存为操作的完整性和正确性。
# OFFLINE SIMULATION ONLY — not for production vision systems.
"""
import os
import cv2
import numpy as np
from vision.common import validate_color_image
class ImageSaveMetrics:
"""
图像保存质量指标。
"""
def __init__(self, original: np.ndarray):
validate_color_image(original, "original")
self.original = original
def png_roundtrip_difference(self, filepath: str) -> dict:
"""
PNG无损回读对比。
保存为PNG后重新读取,计算与原始图像的逐像素差异。
"""
_, buf = cv2.imencode('.png', self.original)
buf.tofile(filepath)
read_buf = np.fromfile(filepath, dtype=np.uint8)
readback = cv2.imdecode(read_buf, cv2.IMREAD_COLOR)
if readback is None:
raise ValueError(f"Failed to decode PNG roundtrip file: {filepath}")
diff = np.abs(readback.astype(np.float64) -
self.original.astype(np.float64))
return {
'max_diff': float(np.max(diff)),
'mean_diff': float(np.mean(diff)),
'std_diff': float(np.std(diff)),
}
def jpeg_roundtrip_difference(self, filepath: str, quality: int) -> dict:
"""
JPEG有损压缩回读对比。
"""
_, buf = cv2.imencode('.jpg', self.original,
[cv2.IMWRITE_JPEG_QUALITY, quality])
buf.tofile(filepath)
read_buf = np.fromfile(filepath, dtype=np.uint8)
readback = cv2.imdecode(read_buf, cv2.IMREAD_COLOR)
if readback is None:
raise ValueError(f"Failed to decode JPEG roundtrip file: {filepath}")
diff = np.abs(readback.astype(np.float64) -
self.original.astype(np.float64))
return {
'max_diff': float(np.max(diff)),
'mean_diff': float(np.mean(diff)),
'std_diff': float(np.std(diff)),
}
def file_size_bytes(self, filepath: str) -> int:
"""返回文件大小(字节)。"""
if not os.path.isfile(filepath):
raise FileNotFoundError(f"File not found: {filepath}")
return os.path.getsize(filepath)
"vision/visualize.py"
"""
4 visualization charts for image save verification.
# OFFLINE SIMULATION ONLY — not for production vision systems.
"""
import os
import cv2
import numpy as np
import matplotlib.pyplot as plt
from vision.common import validate_color_image
plt.rcParams['font.sans-serif'] = ['SimHei', 'Noto Sans CJK SC', 'DejaVu Sans']
plt.rcParams['axes.unicode_minus'] = False
class ImageSaveVisualizer:
"""生成4张对比验证图表。"""
def __init__(self, results_dir="results"):
self.rd = results_dir
os.makedirs(results_dir, exist_ok=True)
def plot_original_vs_saved(self, original: np.ndarray,
saved: np.ndarray):
"""
图1:原始图 vs 另存后读取的图并排。
验证保存-读取流程的完整性。
"""
for name, img in [("original", original), ("saved", saved)]:
validate_color_image(img, name)
fig, axes = plt.subplots(1, 2, figsize=(12, 5))
orig_rgb = cv2.cvtColor(original, cv2.COLOR_BGR2RGB)
saved_rgb = cv2.cvtColor(saved, cv2.COLOR_BGR2RGB)
axes[0].imshow(orig_rgb)
axes[0].set_title(f'原始生成图 (BGR)\n{original.shape[1]}×{original.shape[0]} 像素', fontsize=11)
axes[0].set_xlabel('宽度 (像素)')
axes[0].set_ylabel('高度 (像素)')
axes[0].grid(False)
axes[1].imshow(saved_rgb)
axes[1].set_title(f'另存后读取的图 (PNG)\n{saved.shape[1]}×{saved.shape[0]} 像素', fontsize=11)
axes[1].set_xlabel('宽度 (像素)')
axes[1].set_ylabel('高度 (像素)')
axes[1].grid(False)
plt.tight_layout()
path = os.path.join(self.rd, "01_original_vs_saved.png")
plt.savefig(path, dpi=150)
plt.close()
return path
def plot_channel_comparison(self, original: np.ndarray,
saved: np.ndarray):
"""
图2:B、G、R三通道均值对比柱状图。
验证保存后通道顺序是否保持一致。
"""
for name, img in [("original", original), ("saved", saved)]:
validate_color_image(img, name)
fig, ax = plt.subplots(figsize=(10, 6))
orig_means = [np.mean(original[:, :, c]) for c in range(3)]
saved_means = [np.mean(saved[:, :, c]) for c in range(3)]
x = np.arange(3)
width = 0.35
ax.bar(x - width/2, orig_means, width, label='原始图', color='blue', alpha=0.7)
ax.bar(x + width/2, saved_means, width, label='另存后读取', color='orange', alpha=0.7)
ax.set_title('B、G、R通道均值对比(验证保存后通道一致性)')
ax.set_xlabel('通道')
ax.set_ylabel('平均灰度值')
ax.set_xticks(x)
ax.set_xticklabels(['B通道', 'G通道', 'R通道'])
ax.legend()
ax.grid(True, alpha=0.3, axis='y')
plt.tight_layout()
path = os.path.join(self.rd, "02_channel_comparison.png")
plt.savefig(path, dpi=150)
plt.close()
return path
def plot_file_size_comparison(self, sizes: dict):
"""
图3:不同格式文件大小对比柱状图。
"""
fig, ax = plt.subplots(figsize=(10, 6))
labels = list(sizes.keys())
values = [sizes[k] for k in labels]
bars = ax.bar(labels, values, color=['blue', 'orange', 'green'], alpha=0.7)
ax.set_title('不同格式文件大小对比')
ax.set_ylabel('文件大小 (字节)')
ax.grid(True, alpha=0.3, axis='y')
for bar, val in zip(bars, values):
ax.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 50,
f'{val} B', ha='center', va='bottom', fontsize=10)
plt.tight_layout()
path = os.path.join(self.rd, "03_file_size_comparison.png")
plt.savefig(path, dpi=150)
plt.close()
return path
def plot_jpeg_difference_histogram(self, original: np.ndarray,
jpeg_path_90: str,
jpeg_path_50: str):
"""
图4:JPEG有损压缩像素差异分布直方图(质量90 vs 质量50)。
高频条纹区域会放大JPEG的量化损失,使直方图有明显分布。
"""
validate_color_image(original, "original")
buf_90 = np.fromfile(jpeg_path_90, dtype=np.uint8)
img_90 = cv2.imdecode(buf_90, cv2.IMREAD_COLOR)
diff_90 = np.abs(img_90.astype(np.float64) -
original.astype(np.float64)).flatten()
buf_50 = np.fromfile(jpeg_path_50, dtype=np.uint8)
img_50 = cv2.imdecode(buf_50, cv2.IMREAD_COLOR)
diff_50 = np.abs(img_50.astype(np.float64) -
original.astype(np.float64)).flatten()
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
axes[0].hist(diff_90, bins=50, color='blue', alpha=0.7, edgecolor='black')
axes[0].set_title(f'JPEG质量90 像素差异分布\n均值: {np.mean(diff_90):.2f}')
axes[0].set_xlabel('绝对差异值')
axes[0].set_ylabel('像素数量')
axes[0].grid(True, alpha=0.3, axis='y')
axes[1].hist(diff_50, bins=50, color='orange', alpha=0.7, edgecolor='black')
axes[1].set_title(f'JPEG质量50 像素差异分布\n均值: {np.mean(diff_50):.2f}')
axes[1].set_xlabel('绝对差异值')
axes[1].set_ylabel('像素数量')
axes[1].grid(True, alpha=0.3, axis='y')
plt.tight_layout()
path = os.path.join(self.rd, "04_jpeg_difference.png")
plt.s
利用AI解决实际问题,如果你觉得这个工具好用,欢迎关注长安牧笛!
更多推荐
所有评论(0)