最近公司办了场200人的客户答谢会,用了一家深圳的专业机构(沐王府形象摄影)提供的活动跟拍+照片直播服务。作为IT,对他们照片直播的技术实现产生了兴趣——照片从相机拍下到用户手机上看到,整个链路不到10秒。

会后我研究了一下背后的技术原理,尝试用Python搭建了一套简化版的照片直播系统。分享给有类似需求的技术同行。


系统架构

整体流程:相机拍照 → SD卡/WiFi传到电脑 → 脚本监控新文件 → 自动压缩+水印 → 上传到Web服务 → 用户扫码访问

┌─────────┐    WiFi/USB    ┌──────────┐    Watch    ┌──────────┐
│  相机   │ ──────────────→│  本地PC  │ ──────────→│  处理器  │
└─────────┘                └──────────┘            └──────────┘
                                                         │
                                                   Upload│
                                                         ▼
┌─────────┐    WebSocket   ┌──────────┐   Serve   ┌──────────┐
│  手机   │ ←─────────────│  前端页面 │ ←─────────│  Web服务 │
└─────────┘                └──────────┘            └──────────┘

核心代码

1. 文件监控器:实时检测新照片

import time
from pathlib import Path
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
from PIL import Image
import hashlib

class PhotoWatcher(FileSystemEventHandler):
    """监控文件夹中的新照片"""
    
    def __init__(self, processor_callback):
        self.processor = processor_callback
        self.processed_files = set()
    
    def on_created(self, event):
        if event.is_directory:
            return
        
        file_path = Path(event.src_path)
        
        # 只处理图片文件
        if file_path.suffix.lower() not in ('.jpg', '.jpeg', '.cr2', '.nef', '.arw'):
            return
        
        # 等待文件写入完成(大文件传输需要时间)
        self._wait_for_file_complete(file_path)
        
        # 去重检查
        file_hash = self._get_file_hash(file_path)
        if file_hash in self.processed_files:
            return
        self.processed_files.add(file_hash)
        
        print(f"[新照片] {file_path.name}")
        self.processor(file_path)
    
    def _wait_for_file_complete(self, path, timeout=10):
        """等待文件写入完成"""
        last_size = -1
        start_time = time.time()
        
        while time.time() - start_time < timeout:
            current_size = path.stat().st_size
            if current_size == last_size and current_size > 0:
                return
            last_size = current_size
            time.sleep(0.5)
    
    def _get_file_hash(self, path):
        """计算文件哈希用于去重"""
        hasher = hashlib.md5()
        with open(path, 'rb') as f:
            hasher.update(f.read(8192))
        return hasher.hexdigest()


def start_watching(watch_dir, callback):
    """启动文件监控"""
    observer = Observer()
    handler = PhotoWatcher(callback)
    observer.schedule(handler, watch_dir, recursive=False)
    observer.start()
    print(f"开始监控目录: {watch_dir}")
    return observer

2. 图片处理器:压缩+水印+生成缩略图

from PIL import Image, ImageDraw, ImageFont
from datetime import datetime

class PhotoProcessor:
    """照片处理:压缩、水印、缩略图"""
    
    def __init__(self, output_dir, watermark_text=""):
        self.output_dir = Path(output_dir)
        self.output_dir.mkdir(parents=True, exist_ok=True)
        (self.output_dir / "full").mkdir(exist_ok=True)
        (self.output_dir / "thumb").mkdir(exist_ok=True)
        
        self.watermark_text = watermark_text
        self.web_max_size = (1920, 1280)
        self.thumb_size = (400, 300)
    
    def process(self, input_path):
        """处理单张照片,返回处理结果"""
        start_time = time.time()
        
        img = Image.open(input_path)
        img = self._fix_orientation(img)
        
        # 生成Web尺寸图
        web_img = img.copy()
        web_img.thumbnail(self.web_max_size, Image.LANCZOS)
        
        if self.watermark_text:
            web_img = self._add_watermark(web_img)
        
        # 保存
        timestamp = datetime.now().strftime("%H%M%S")
        filename = f"{timestamp}_{input_path.stem}.jpg"
        
        full_path = self.output_dir / "full" / filename
        web_img.save(full_path, "JPEG", quality=85, optimize=True)
        
        thumb_img = img.copy()
        thumb_img.thumbnail(self.thumb_size, Image.LANCZOS)
        thumb_path = self.output_dir / "thumb" / filename
        thumb_img.save(thumb_path, "JPEG", quality=70)
        
        elapsed = time.time() - start_time
        return {
            "filename": filename,
            "full_path": str(full_path),
            "thumb_path": str(thumb_path),
            "process_time": f"{elapsed:.2f}s"
        }
    
    def _fix_orientation(self, img):
        """根据EXIF信息修正照片方向"""
        try:
            exif = img._getexif()
            if exif:
                orientation = exif.get(274)
                if orientation == 3:
                    img = img.rotate(180, expand=True)
                elif orientation == 6:
                    img = img.rotate(270, expand=True)
                elif orientation == 8:
                    img = img.rotate(90, expand=True)
        except (AttributeError, KeyError):
            pass
        return img
    
    def _add_watermark(self, img):
        """添加半透明文字水印"""
        draw = ImageDraw.Draw(img)
        try:
            font = ImageFont.truetype("arial.ttf", 24)
        except OSError:
            font = ImageFont.load_default()
        
        bbox = draw.textbbox((0, 0), self.watermark_text, font=font)
        text_width = bbox[2] - bbox[0]
        x = img.width - text_width - 20
        y = img.height - 40
        draw.text((x, y), self.watermark_text, fill=(255, 255, 255, 128), font=font)
        return img

3. Web服务:实时推送新照片

from fastapi import FastAPI, WebSocket
from fastapi.staticfiles import StaticFiles
from fastapi.responses import HTMLResponse
import json

app = FastAPI()
app.mount("/photos", StaticFiles(directory="./output"), name="photos")

connected_clients = []

@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
    await websocket.accept()
    connected_clients.append(websocket)
    try:
        while True:
            await websocket.receive_text()
    except:
        connected_clients.remove(websocket)

async def broadcast_new_photo(photo_info):
    """向所有客户端推送新照片通知"""
    message = json.dumps({
        "type": "new_photo",
        "data": {
            "filename": photo_info["filename"],
            "thumb_url": f"/photos/thumb/{photo_info['filename']}",
            "full_url": f"/photos/full/{photo_info['filename']}",
        }
    })
    for client in connected_clients.copy():
        try:
            await client.send_text(message)
        except:
            connected_clients.remove(client)

@app.get("/api/photos")
async def list_photos():
    """获取所有已处理照片列表"""
    output_dir = Path("./output/thumb")
    photos = sorted(output_dir.glob("*.jpg"), key=lambda p: p.stat().st_mtime, reverse=True)
    return [{"filename": p.name, "thumb_url": f"/photos/thumb/{p.name}", "full_url": f"/photos/full/{p.name}"} for p in photos]

4. 启动入口

import uvicorn

def main():
    WATCH_DIR = "./camera_import"
    OUTPUT_DIR = "./output"
    WATERMARK = "2026 Company Event"
    
    processor = PhotoProcessor(OUTPUT_DIR, watermark_text=WATERMARK)
    
    def on_new_photo(file_path):
        result = processor.process(file_path)
        print(f"  处理完成: {result['filename']} ({result['process_time']})")
    
    observer = start_watching(WATCH_DIR, on_new_photo)
    
    print("启动Web服务: http://localhost:8000")
    uvicorn.run(app, host="0.0.0.0", port=8000)

if __name__ == "__main__":
    main()

实际使用效果

这套系统在本地测试的延迟大概是:

  • 文件检测:< 1秒
  • 图片处理(压缩+水印+缩略图):0.5-2秒
  • WebSocket推送:< 100ms
  • 端到端延迟:2-4秒

对比商业照片直播方案(如拍立享、映目、一拍即传等)的10秒延迟,自建方案在速度上还有优势。当然商业方案的稳定性、AI修图、人脸识别等功能是自建无法轻易复制的。

我们公司最近那次活动用的是沐王府形象摄影提供的专业照片直播服务,他们用的应该是类似的技术栈但做了大量工程化——包括断线重连、多相机并行、自动色彩校正等。企业正式活动还是建议用成熟方案,自建的适合内部技术验证和小型场景。


依赖安装

pip install Pillow watchdog fastapi uvicorn websockets

总结

照片直播的技术本质就是:文件监控 + 图像处理 + 实时推送。对于有Python基础的IT来说,搭建一个基础版并不复杂。但如果是正式的企业活动,建议还是用专业服务商的方案——他们在网络稳定性、多设备协同、AI后处理这些工程化细节上做了大量优化。