全自动电商图片打标系统(Docker + 云端API)完整实战教程

3 阅读4分钟

本地丢图 · 自动压缩 · 云端识别 · 自动归档 · 全流程可直接复刻

环境:WSL2 Ubuntu + Docker + Python3.12

适用场景:电商图片打标、批量识别、OCR、内容审核等自动化流水线

前言

在电商图片处理、批量标注、自动化识别场景中,我们经常被这些痛点困扰:

  • 本地环境依赖复杂,Python版本、第三方库冲突,换一台机器就无法运行;

  • 图片体积过大,直接调用云端API易超时、失败,占用带宽;

  • 批量处理后,待处理、已处理图片和识别结果混在一起,难以管理归档;

  • 用Docker运行脚本,容器内文件是临时的,无法和本地互通,结果拿不出来、图片传不进去。

本文提供一套完整可直接发布的工程化解决方案,从Docker安装、项目编写、镜像构建,到目录挂载、跨设备文件问题修复、一键启动,全部闭环,复制粘贴就能落地使用,无需额外调试。

一、环境安装(Ubuntu / WSL2 通用,一键部署)

全程命令行操作,无需手动配置,新手也能快速上手。

1.1 安装Docker

执行以下命令,自动下载并安装Docker:

curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh

1.2 免sudo使用Docker(可选,提升体验)

默认情况下,运行Docker需要sudo权限,配置后可直接使用docker命令:

sudo usermod -aG docker $USER

注意:配置完成后,必须退出当前终端,重新登录才能生效。

1.3 验证安装成功

执行以下命令,若出现Docker欢迎信息,说明安装成功:

docker --version
docker run hello-world

二、项目结构(标准工程化,清晰易维护)

统一项目目录结构,便于后续扩展和维护,目录说明如下:

coze-workflow/
├── input/          # 待处理图片(本地丢图到这个文件夹)
├── processed/      # 已处理图片归档(自动移动,避免重复处理)
├── output/         # 云端API识别结果(JSON格式,自动回写到本地)
├── your_script.py  # 主程序(图片压缩、API调用、任务调度)
├── requirements.txt# Python依赖清单(明确版本,避免冲突)
└── Dockerfile      # Docker镜像构建文件(一键构建,环境统一)

一键创建项目目录

无需手动创建文件夹,执行以下命令,自动生成完整目录:

cd ~
mkdir -p coze-workflow/{input,processed,output}
cd coze-workflow

执行完成后,当前目录就切换到了项目根目录(coze-workflow),后续所有操作都在此目录下进行。

三、编写项目文件(直接复制,无需修改)

以下所有文件内容,直接复制到对应文件中,无需额外调整(仅需后续替换API Token即可)。

3.1 requirements.txt(Python依赖清单)

明确依赖版本,避免因版本冲突导致程序报错:

pillow>=10.0.0
requests>=2.31.0

说明:pillow用于图片压缩、格式转换;requests用于调用云端API。

3.2 your_script.py(主程序,完整可运行)

包含图片压缩、Base64转换、API调用、任务调度、文件归档、日志记录等全部功能,已修复Docker跨设备文件移动问题:

import os
import time
import base64
import json
import threading
import shutil
from PIL import Image
from queue import Queue
from io import BytesIO
import requests
from datetime import datetime

# ===================== 核心配置区(仅需修改这里) =====================
INPUT_FOLDER = "./input"          # 待处理图片目录
PROCESSED_FOLDER = "./processed"  # 已处理图片归档目录
OUTPUT_FOLDER = "./output"        # 识别结果JSON目录
COMPRESS_QUALITY = 80             # 图片压缩质量(0-100,越高越清晰)
MAX_WORKERS = 1                   # 线程数(单线程足够,避免API限流)
MONITOR_INTERVAL = 2              # 文件夹监测间隔(秒)
API_TIMEOUT = 60                  # API调用超时时间(秒)
API_RETRY_TIMES = 2               # API调用失败重试次数
API_RETRY_DELAY = 2               # 重试间隔(秒)
MAX_IMAGE_SIZE = 1000             # 图片最大边长(像素),超过自动缩放

# 云端API配置(从配置文件读取,不再明文暴露)
def load_config():
    try:
        with open("config.json", "r", encoding="utf-8") as f:
            return json.load(f)
    except:
        print("❌ 请先创建 config.json 文件,并填入你的 API 信息!")
        exit(1)

config = load_config()
API_URL = config.get("api_url")
API_TOKEN = config.get("api_token")

API_HEADERS = {
    "Content-Type": "application/json",
    "Authorization": f"Bearer {API_TOKEN}"
}
# ======================================================================

# 自动创建目录(防止目录不存在导致报错)
for folder in [INPUT_FOLDER, PROCESSED_FOLDER, OUTPUT_FOLDER]:
    os.makedirs(folder, exist_ok=True)

# 任务队列、已处理/处理中文件集合(避免重复处理)
task_queue = Queue()
processed_files = set()
processing_files = set()
file_mtime = {}  # 记录文件修改时间,避免重复读取


def log_to_file(content):
    """日志记录到文件,方便排查问题"""
    log_path = "./process_log.txt"
    timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    with open(log_path, "a", encoding="utf-8") as f:
        f.write(f"[{timestamp}] {content}\n")


def compress_image(image_path, quality=COMPRESS_QUALITY, max_size=MAX_IMAGE_SIZE):
    """图片压缩+缩放,减少API调用带宽和超时概率"""
    try:
        log_to_file(f"开始压缩图片: {image_path}")
        with Image.open(image_path) as img:
            width, height = img.size
            # 图片缩放(超过最大边长自动缩小)
            if width > max_size or height > max_size:
                ratio = min(max_size / width, max_size / height)
                img = img.resize((int(width * ratio), int(height * ratio)), Image.Resampling.LANCZOS)
            # PNG图片转RGB(避免透明通道导致Base64过大)
            if img.format == "PNG" and img.mode == "RGBA":
                img = img.convert("RGB")
            # 压缩并保存到内存
            buf = BytesIO()
            img.save(buf, format=img.format or "JPEG", quality=quality)
            buf.seek(0)
            log_to_file(f"图片压缩成功,体积:{len(buf.getvalue()) / 1024:.2f}KB")
            return buf
    except Exception as e:
        err_msg = f"图片压缩失败 {image_path}: {str(e)}"
        print(f"❌ {err_msg}")
        log_to_file(err_msg)
        return None


def image_to_base64(buf):
    """将压缩后的图片转为Base64字符串(API调用要求)"""
    try:
        b64_str = base64.b64encode(buf.getvalue()).decode("utf-8")
        log_to_file(f"Base64转换成功,长度:{len(b64_str)} 字符")
        return b64_str
    except Exception as e:
        err_msg = f"Base64转换失败: {str(e)}"
        print(f"❌ {err_msg}")
        log_to_file(err_msg)
        return None


def call_coze_api(b64_str):
    """调用云端API进行图片打标,包含重试机制"""
    payload = {"image_data": b64_str}
    log_to_file(f"开始调用API: {API_URL}")

    for i in range(API_RETRY_TIMES + 1):
        try:
            response = requests.post(
                API_URL,
                headers=API_HEADERS,
                json=payload,
                timeout=API_TIMEOUT,
                verify=False
            )
            log_to_file(f"API响应状态码: {response.status_code}")
            response.raise_for_status()
            result = response.json()
            log_to_file(f"API调用成功,响应预览: {json.dumps(result)[:200]}...")
            return result
        except requests.exceptions.Timeout:
            if i < API_RETRY_TIMES:
                warn_msg = f"API调用超时,{API_RETRY_DELAY}秒后重试(剩余{API_RETRY_TIMES - i}次)"
                print(f"⚠️ {warn_msg}")
                log_to_file(warn_msg)
                time.sleep(API_RETRY_DELAY)
                continue
            err_msg = f"API调用超时({API_TIMEOUT}秒,已重试{API_RETRY_TIMES}次)"
        except requests.exceptions.SSLError:
            err_msg = "API调用失败:SSL证书验证错误"
        except requests.exceptions.ConnectionError:
            err_msg = "API调用失败:无法连接到服务器(地址错误/网络不通)"
        except requests.exceptions.RequestException as e:
            err_msg = f"API调用失败: {str(e)}"
        
        print(f"❌ {err_msg}")
        log_to_file(err_msg)
        return None


def save_json_result(filename, data):
    """将API识别结果保存为JSON文件,回写到本地output目录"""
    try:
        name, _ = os.path.splitext(filename)
        timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
        json_file_name = f"{name}_{timestamp}.json"
        json_save_path = os.path.join(OUTPUT_FOLDER, json_file_name)

        with open(json_save_path, "w", encoding="utf-8") as f:
            json.dump(data, f, ensure_ascii=False, indent=4)
        log_to_file(f"JSON结果保存成功: {json_save_path}")
        return json_save_path
    except Exception as e:
        err_msg = f"JSON保存失败: {str(e)}"
        print(f"❌ {err_msg}")
        log_to_file(err_msg)
        return None


def move_to_processed(image_path):
    """跨设备文件移动(复制+删除)"""
    try:
        file_name = os.path.basename(image_path)
        processed_path = os.path.join(PROCESSED_FOLDER, file_name)
        
        if os.path.exists(processed_path):
            name, ext = os.path.splitext(file_name)
            timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
            processed_path = os.path.join(PROCESSED_FOLDER, f"{name}_{timestamp}{ext}")
        
        shutil.copy2(image_path, processed_path)
        os.remove(image_path)
        
        log_to_file(f"图片归档成功: {processed_path}")
        return processed_path
    except Exception as e:
        err_msg = f"图片归档失败 {image_path}: {str(e)}"
        print(f"❌ {err_msg}")
        log_to_file(err_msg)
        return None


def worker():
    """工作线程:从队列中获取任务,执行图片处理全流程"""
    while True:
        image_path = task_queue.get()
        if image_path is None:
            break

        file_name = os.path.basename(image_path)
        log_to_file(f"开始处理图片: {file_name}")
        print(f"\n📌 开始处理: {file_name}")

        compressed_img = compress_image(image_path)
        if not compressed_img:
            processing_files.discard(file_name)
            task_queue.task_done()
            continue

        base64_str = image_to_base64(compressed_img)
        if not base64_str:
            processing_files.discard(file_name)
            task_queue.task_done()
            continue

        api_result = call_coze_api(base64_str)
        if not api_result:
            processing_files.discard(file_name)
            task_queue.task_done()
            continue

        json_save_path = save_json_result(file_name, api_result)
        if not json_save_path:
            processing_files.discard(file_name)
            task_queue.task_done()
            continue

        processed_path = move_to_processed(image_path)
        if processed_path:
            success_msg = f"处理完成: 原图片归档={processed_path} | JSON保存={json_save_path}"
            print(f"✅ {success_msg}")
            log_to_file(success_msg)
            processed_files.add(file_name)
        else:
            warn_msg = "JSON保存成功,但图片归档失败"
            print(f"⚠️ {warn_msg}")
            log_to_file(warn_msg)

        processing_files.discard(file_name)
        task_queue.task_done()


def monitor_folder():
    """监测文件夹:实时检测input目录,发现新图片自动加入任务队列"""
    print(f"\n🔍 开始实时监测文件夹: {os.path.abspath(INPUT_FOLDER)}")
    print(f"📁 已处理图片归档到: {os.path.abspath(PROCESSED_FOLDER)}")
    print(f"📄 API结果保存到: {os.path.abspath(OUTPUT_FOLDER)}")
    print("💡 按 Ctrl+C 停止脚本\n")
    log_to_file("脚本启动,开始监测文件夹")

    while True:
        try:
            for file_name in os.listdir(INPUT_FOLDER):
                image_path = os.path.join(INPUT_FOLDER, file_name)
                if not os.path.isfile(image_path):
                    continue

                if (file_name.lower().endswith((".png", ".jpg", ".jpeg", ".webp", ".bmp")) and
                        file_name not in processed_files and
                        file_name not in processing_files):

                    current_mtime = os.path.getmtime(image_path)
                    if file_name not in file_mtime or current_mtime != file_mtime[file_name]:
                        file_mtime[file_name] = current_mtime
                        print(f"🆕 发现新图片: {file_name}")
                        log_to_file(f"发现新图片: {file_name}")
                        processing_files.add(file_name)
                        task_queue.put(image_path)

            time.sleep(MONITOR_INTERVAL)
        except Exception as e:
            err_msg = f"文件夹监测异常: {str(e)}"
            print(f"❌ {err_msg}")
            log_to_file(err_msg)
            time.sleep(MONITOR_INTERVAL)


def main():
    """主函数:启动工作线程和文件夹监测线程"""
    threads = []
    for i in range(MAX_WORKERS):
        t = threading.Thread(target=worker, daemon=True)
        t.start()
        threads.append(t)
        print(f"🚀 工作线程 {i + 1} 已启动")

    monitor_thread = threading.Thread(target=monitor_folder, daemon=True)
    monitor_thread.start()

    try:
        while True:
            time.sleep(1)
    except KeyboardInterrupt:
        print("\n🛑 收到停止信号,正在退出...")
        log_to_file("脚本收到停止信号,开始退出")
        for _ in range(MAX_WORKERS):
            task_queue.put(None)
        for t in threads:
            t.join()
        print("✅ 所有线程已停止,脚本退出")
        log_to_file("脚本已退出")


if __name__ == "__main__":
    try:
        import PIL
        import requests
    except ImportError as e:
        print(f"❌ 缺少依赖包,请先执行安装命令:")
        print(f"   pip install pillow requests")
        exit(1)
    main()

3.3 Dockerfile(镜像构建文件)

基于轻量级Alpine镜像构建,体积小、启动快,解决pillow安装依赖问题:

FROM python:3.12-alpine

# 设置工作目录(容器内的项目根目录)
WORKDIR /app

# 安装编译依赖(解决pillow安装时的编译错误)
RUN apk add --no-cache gcc musl-dev libffi-dev python3-dev

# 复制依赖清单并安装(国内源加速,避免超时)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple

# 复制主程序到容器内
COPY your_script.py .

# 启动脚本(容器启动时自动执行)
CMD ["python3", "your_script.py"]

说明:Alpine镜像体积小(仅几十MB),比Ubuntu镜像更轻量;国内PyPI源(清华源)加速依赖安装,避免因网络问题超时。

3.4 config.json(API密钥配置,必加)

在项目根目录创建 config.json 文件,填入你的云端API信息:

{
  "api_url": "你的云端API地址",
  "api_token": "你的API Token(仅填Bearer后面的字符串)"
}

同时修改Docker启动命令,把config.json也挂载到容器内(否则容器内找不到这个文件)

docker run --rm -it \
  -v "$(pwd)/input:/app/input" \
  -v "$(pwd)/processed:/app/processed" \
  -v "$(pwd)/output:/app/output" \
  -v "$(pwd)/config.json:/app/config.json"  # 新增这一行
  ec-labeler:v1.0

四、构建Docker镜像(一键构建,环境统一)

在项目根目录(coze-workflow)下,执行以下命令构建镜像,镜像名称为ec-labeler:v1.0(可自定义):

# 关闭BuildKit(避免缓存和层问题,新手更友好)
export DOCKER_BUILDKIT=0

# 构建镜像
docker build -t ec-labeler:v1.0 .

构建过程中,会自动下载基础镜像、安装依赖、复制项目文件,等待几分钟(首次构建较慢,后续构建会复用缓存)。

构建成功后,执行以下命令查看镜像:

docker images

若出现ec-labeler:v1.0的镜像信息,说明构建成功。

五、核心操作:挂载本地文件夹,实现双向同步

这是最关键的一步!通过Docker的-v参数,将本地文件夹挂载到容器内,实现「本地丢图、Docker处理、结果回写」的无感体验。

5.1 一键启动命令(复制直接运行)

cd ~/coze-workflow

docker run --rm -it \
  -v "$(pwd)/input:/app/input" \
  -v "$(pwd)/processed:/app/processed" \
  -v "$(pwd)/output:/app/output" \
  ec-labeler:v1.0

命令解读(关键必看)

  • --rm:容器停止后,自动删除容器,避免残留垃圾;

  • -it:交互式运行,实时查看脚本输出(便于调试和观察处理进度);

  • -v "$(pwd)/input:/app/input":本地input文件夹 ↔ 容器内/app/input文件夹,本地丢图,Docker直接读取;

  • -v "$(pwd)/processed:/app/processed":容器内归档的图片,自动同步到本地processed文件夹;

  • -v "$(pwd)/output:/app/output":容器内生成的JSON结果,自动回写到本地output文件夹;

  • ec-labeler:v1.0:使用我们构建的镜像运行。

5.2 后台运行(可选,不占用终端)

若不想占用终端,可将-it替换为-d,让脚本在后台运行:

cd ~/coze-workflow

docker run -d --name ec-labeler -v "$(pwd)/input:/app/input" -v "$(pwd)/processed:/app/processed" -v "$(pwd)/output:/app/output" ec-labeler:v1.0

相关操作命令:

  • 查看后台运行日志:docker logs ec-labeler

  • 停止后台脚本:docker stop ec-labeler

六、关键问题与优化

实战中遇到的问题及解决方案,也是这套方案的核心价值,新手可直接规避踩坑。

6.1 问题1:Docker跨设备文件移动失败(Cross-device link)

❌ 报错信息:Errno 18: Cross-device link

✅ 原因:Docker挂载的本地目录和容器内目录属于不同设备,Python的os.rename()方法无法跨设备移动文件。

✅ 解决方案:用shutil.copy2()复制文件到归档目录,再用os.remove()删除原文件,替代os.rename()。

核心代码(已集成到脚本中):

shutil.copy2(源文件路径, 目标路径)
os.remove(源文件路径)

6.2 问题2:容器内文件是临时的,停止容器后文件丢失

通过Docker目录挂载(-v参数),将容器内的文件同步到本地,数据永久保存,即使容器停止,本地文件也不会丢失。

6.3 优化1:国内PyPI源加速,避免依赖安装超时

在Dockerfile中添加清华源,安装依赖速度提升10倍:

pip install --no-cache-dir -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple

6.4 优化2:Alpine镜像补齐编译依赖,解决pillow安装失败

Alpine镜像默认缺少编译依赖,直接安装pillow会报错,添加以下命令补齐依赖:

RUN apk add --no-cache gcc musl-dev libffi-dev python3-dev

6.5 优化3:图片压缩+缩放,提升API调用成功率

将图片压缩到合适体积、缩放到固定最大边长,避免因图片过大导致API超时、失败,同时减少带宽占用。

七、使用流程

  1. 把需要打标的图片,丢进本地~/coze-workflow/input文件夹(Windows路径:\\wsl$\Ubuntu\home\zc\coze-workflow\input);

  2. 执行第五步的一键启动命令,启动Docker脚本;

  3. 脚本自动执行:监测图片 → 压缩 → Base64转换 → 云端API识别 → JSON结果保存 → 图片归档;

  4. 查看结果:

    • 打标结果:~/coze-workflow/output 文件夹(JSON格式);

    • 已处理图片:~/coze-workflow/processed 文件夹;

    • 运行日志:~/coze-workflow/process_log.txt(排查问题用)。

  5. 停止脚本:按Ctrl+C,脚本优雅退出,不丢失文件。

八、总结

本文完整实现了一套「工程化、可复用、可部署」的全自动图片打标系统,核心优势如下:

  • 环境统一:Docker封装所有依赖,一次构建,到处运行,换机器也无需重新配置;

  • 无感使用:本地丢图即可自动处理,结果实时回写,无需手动操作Docker;

  • 稳定可靠:包含图片压缩、API重试、日志记录、重名处理等优化,生产级可用;

  • 可扩展性强:支持多线程、云端部署,可适配电商打标、OCR识别、内容审核等多种场景。

这套方案不仅解决了图片批量打标的痛点,更提供了Docker环境下「本地文件与容器互通」的通用思路,可直接复用到底其他自动化项目中。

所有代码可直接复制复刻,新手也能快速上手,赶紧试试吧!