043-照片管理系统
项目概述
本章将构建一个完整的照片管理系统,集成ExifTool进行元数据处理,实现照片的智能分类、搜索、标签管理和批量处理功能。系统采用现代Web架构,提供直观的用户界面和强大的后端处理能力。
系统特性
- 智能分类: 基于EXIF数据自动分类照片
- 高级搜索: 支持多维度元数据搜索
- 标签管理: 自动和手动标签系统
- 批量处理: 大规模照片处理能力
- 版本控制: 照片编辑历史追踪
- 云存储集成: 支持多种云存储服务
- 权限管理: 多用户访问控制
- API接口: RESTful API支持
系统架构设计
整体架构
# 文件路径: photo_manager/architecture/system_design.py
from typing import Dict, List, Any, Optional
from dataclasses import dataclass
from enum import Enum
import asyncio
from abc import ABC, abstractmethod
class ComponentType(Enum):
"""组件类型"""
WEB_FRONTEND = "web_frontend"
API_GATEWAY = "api_gateway"
PHOTO_SERVICE = "photo_service"
METADATA_SERVICE = "metadata_service"
SEARCH_SERVICE = "search_service"
STORAGE_SERVICE = "storage_service"
AUTH_SERVICE = "auth_service"
NOTIFICATION_SERVICE = "notification_service"
TASK_QUEUE = "task_queue"
DATABASE = "database"
CACHE = "cache"
FILE_STORAGE = "file_storage"
@dataclass
class SystemComponent:
"""系统组件"""
name: str
component_type: ComponentType
version: str
dependencies: List[str]
config: Dict[str, Any]
health_check_url: Optional[str] = None
metrics_endpoint: Optional[str] = None
class SystemArchitecture:
"""系统架构管理器"""
def __init__(self):
self.components: Dict[str, SystemComponent] = {}
self.service_mesh = ServiceMesh()
self.config_manager = ConfigurationManager()
def register_component(self, component: SystemComponent):
"""注册系统组件"""
self.components[component.name] = component
def get_architecture_diagram(self) -> str:
"""获取架构图"""
return """
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Web Frontend │ │ Mobile App │ │ Desktop App │
└─────────┬───────┘ └─────────┬───────┘ └─────────┬───────┘
│ │ │
└──────────────────────┼──────────────────────┘
│
┌─────────────────────────────────┼─────────────────────────────────┐
│ API Gateway │ │
└─────────────────────────────────┼─────────────────────────────────┘
│
┌────────────┬────────────┬──────┼──────┬────────────┬────────────┐
│Photo │Metadata │Search│ │Storage │Auth │
│Service │Service │Service │Service │Service │
└────────────┴────────────┴──────┼──────┴────────────┴────────────┘
│
┌────────────┬────────────┬──────┼──────┬────────────┬────────────┐
│PostgreSQL │Redis │Elastic │MinIO │RabbitMQ │
│Database │Cache │Search │Storage │Queue │
└────────────┴────────────┴──────┼──────┴────────────┴────────────┘
"""
class ServiceMesh:
"""服务网格管理"""
def __init__(self):
self.services: Dict[str, Dict[str, Any]] = {}
self.load_balancer = LoadBalancer()
self.circuit_breaker = CircuitBreaker()
async def register_service(self, service_name: str,
endpoints: List[str],
health_check: str):
"""注册服务"""
self.services[service_name] = {
'endpoints': endpoints,
'health_check': health_check,
'status': 'healthy',
'last_check': None
}
async def discover_service(self, service_name: str) -> Optional[str]:
"""服务发现"""
if service_name in self.services:
return await self.load_balancer.get_endpoint(service_name)
return None
class LoadBalancer:
"""负载均衡器"""
def __init__(self):
self.strategy = 'round_robin'
self.counters: Dict[str, int] = {}
async def get_endpoint(self, service_name: str) -> str:
"""获取服务端点"""
# 实现轮询负载均衡
pass
class CircuitBreaker:
"""熔断器"""
def __init__(self):
self.failure_threshold = 5
self.recovery_timeout = 60
self.states: Dict[str, str] = {} # 'closed', 'open', 'half_open'
async def call_service(self, service_name: str, func, *args, **kwargs):
"""调用服务(带熔断保护)"""
state = self.states.get(service_name, 'closed')
if state == 'open':
raise Exception(f"Circuit breaker is open for {service_name}")
try:
result = await func(*args, **kwargs)
if state == 'half_open':
self.states[service_name] = 'closed'
return result
except Exception as e:
self._handle_failure(service_name)
raise e
def _handle_failure(self, service_name: str):
"""处理服务失败"""
# 实现失败计数和状态转换逻辑
pass
class ConfigurationManager:
"""配置管理器"""
def __init__(self):
self.configs: Dict[str, Any] = {
'database': {
'host': 'localhost',
'port': 5432,
'name': 'photo_manager',
'pool_size': 20
},
'redis': {
'host': 'localhost',
'port': 6379,
'db': 0
},
'storage': {
'type': 'minio',
'endpoint': 'localhost:9000',
'bucket': 'photos'
},
'exiftool': {
'executable_path': '/usr/local/bin/exiftool',
'timeout': 30,
'max_workers': 4
}
}
def get_config(self, key: str) -> Any:
"""获取配置"""
keys = key.split('.')
config = self.configs
for k in keys:
config = config.get(k, {})
return config
def update_config(self, key: str, value: Any):
"""更新配置"""
keys = key.split('.')
config = self.configs
for k in keys[:-1]:
config = config.setdefault(k, {})
config[keys[-1]] = value
# 使用示例
def example_architecture_setup():
"""架构设置示例"""
# 创建系统架构
architecture = SystemArchitecture()
# 注册组件
components = [
SystemComponent(
name="photo-service",
component_type=ComponentType.PHOTO_SERVICE,
version="1.0.0",
dependencies=["database", "storage", "cache"],
config={"port": 8001, "workers": 4}
),
SystemComponent(
name="metadata-service",
component_type=ComponentType.METADATA_SERVICE,
version="1.0.0",
dependencies=["database", "cache"],
config={"port": 8002, "workers": 2}
)
]
for component in components:
architecture.register_component(component)
print("System architecture initialized")
print(architecture.get_architecture_diagram())
if __name__ == "__main__":
example_architecture_setup()
数据模型设计
核心数据模型
# 文件路径: photo_manager/models/core_models.py
from sqlalchemy import Column, Integer, String, DateTime, Text, Boolean, ForeignKey, JSON, Float
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship
from sqlalchemy.dialects.postgresql import UUID
from datetime import datetime
from typing import Dict, List, Any, Optional
import uuid
Base = declarative_base()
class User(Base):
"""用户模型"""
__tablename__ = 'users'
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
username = Column(String(50), unique=True, nullable=False)
email = Column(String(100), unique=True, nullable=False)
password_hash = Column(String(255), nullable=False)
full_name = Column(String(100))
avatar_url = Column(String(500))
is_active = Column(Boolean, default=True)
is_admin = Column(Boolean, default=False)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# 关系
photos = relationship("Photo", back_populates="owner")
albums = relationship("Album", back_populates="owner")
tags = relationship("Tag", back_populates="creator")
class Photo(Base):
"""照片模型"""
__tablename__ = 'photos'
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
filename = Column(String(255), nullable=False)
original_filename = Column(String(255), nullable=False)
file_path = Column(String(1000), nullable=False)
file_size = Column(Integer)
mime_type = Column(String(100))
width = Column(Integer)
height = Column(Integer)
# EXIF数据
camera_make = Column(String(100))
camera_model = Column(String(100))
lens_model = Column(String(100))
focal_length = Column(Float)
aperture = Column(Float)
shutter_speed = Column(String(50))
iso = Column(Integer)
flash = Column(String(100))
# 时间信息
date_taken = Column(DateTime)
date_uploaded = Column(DateTime, default=datetime.utcnow)
date_modified = Column(DateTime)
# GPS信息
gps_latitude = Column(Float)
gps_longitude = Column(Float)
gps_altitude = Column(Float)
location_name = Column(String(200))
# 元数据
raw_exif = Column(JSON) # 完整的EXIF数据
keywords = Column(JSON) # 关键词列表
description = Column(Text)
rating = Column(Integer, default=0) # 1-5星评级
# 处理状态
processing_status = Column(String(50), default='pending') # pending, processing, completed, failed
thumbnail_path = Column(String(1000))
preview_path = Column(String(1000))
# 关系
owner_id = Column(UUID(as_uuid=True), ForeignKey('users.id'), nullable=False)
owner = relationship("User", back_populates="photos")
# 多对多关系
albums = relationship("Album", secondary="photo_albums", back_populates="photos")
tags = relationship("Tag", secondary="photo_tags", back_populates="photos")
class Album(Base):
"""相册模型"""
__tablename__ = 'albums'
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name = Column(String(100), nullable=False)
description = Column(Text)
cover_photo_id = Column(UUID(as_uuid=True), ForeignKey('photos.id'))
is_public = Column(Boolean, default=False)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# 关系
owner_id = Column(UUID(as_uuid=True), ForeignKey('users.id'), nullable=False)
owner = relationship("User", back_populates="albums")
photos = relationship("Photo", secondary="photo_albums", back_populates="albums")
cover_photo = relationship("Photo", foreign_keys=[cover_photo_id])
class Tag(Base):
"""标签模型"""
__tablename__ = 'tags'
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name = Column(String(50), nullable=False)
color = Column(String(7), default='#007bff') # 十六进制颜色
description = Column(Text)
is_system = Column(Boolean, default=False) # 系统自动生成的标签
usage_count = Column(Integer, default=0)
created_at = Column(DateTime, default=datetime.utcnow)
# 关系
creator_id = Column(UUID(as_uuid=True), ForeignKey('users.id'))
creator = relationship("User", back_populates="tags")
photos = relationship("Photo", secondary="photo_tags", back_populates="tags")
# 关联表
from sqlalchemy import Table
photo_albums = Table(
'photo_albums',
Base.metadata,
Column('photo_id', UUID(as_uuid=True), ForeignKey('photos.id'), primary_key=True),
Column('album_id', UUID(as_uuid=True), ForeignKey('albums.id'), primary_key=True),
Column('added_at', DateTime, default=datetime.utcnow)
)
photo_tags = Table(
'photo_tags',
Base.metadata,
Column('photo_id', UUID(as_uuid=True), ForeignKey('photos.id'), primary_key=True),
Column('tag_id', UUID(as_uuid=True), ForeignKey('tags.id'), primary_key=True),
Column('added_at', DateTime, default=datetime.utcnow)
)
class PhotoVersion(Base):
"""照片版本模型(用于编辑历史)"""
__tablename__ = 'photo_versions'
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
photo_id = Column(UUID(as_uuid=True), ForeignKey('photos.id'), nullable=False)
version_number = Column(Integer, nullable=False)
file_path = Column(String(1000), nullable=False)
edit_operations = Column(JSON) # 编辑操作记录
created_at = Column(DateTime, default=datetime.utcnow)
# 关系
photo = relationship("Photo")
class ProcessingJob(Base):
"""处理任务模型"""
__tablename__ = 'processing_jobs'
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
job_type = Column(String(50), nullable=False) # metadata_extraction, thumbnail_generation, etc.
status = Column(String(50), default='pending') # pending, running, completed, failed
progress = Column(Integer, default=0) # 0-100
error_message = Column(Text)
result_data = Column(JSON)
# 关联的照片或批量操作
photo_id = Column(UUID(as_uuid=True), ForeignKey('photos.id'))
batch_id = Column(String(100)) # 批量操作ID
created_at = Column(DateTime, default=datetime.utcnow)
started_at = Column(DateTime)
completed_at = Column(DateTime)
# 关系
photo = relationship("Photo")
class SystemSettings(Base):
"""系统设置模型"""
__tablename__ = 'system_settings'
id = Column(Integer, primary_key=True)
key = Column(String(100), unique=True, nullable=False)
value = Column(JSON)
description = Column(Text)
is_public = Column(Boolean, default=False) # 是否对普通用户可见
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# 数据访问层
class DatabaseManager:
"""数据库管理器"""
def __init__(self, database_url: str):
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
self.engine = create_engine(database_url, pool_size=20, max_overflow=30)
self.SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=self.engine)
def create_tables(self):
"""创建数据表"""
Base.metadata.create_all(bind=self.engine)
def get_session(self):
"""获取数据库会话"""
return self.SessionLocal()
def init_default_data(self):
"""初始化默认数据"""
session = self.get_session()
try:
# 创建默认系统设置
default_settings = [
SystemSettings(
key='max_upload_size',
value={'size': 50 * 1024 * 1024}, # 50MB
description='最大上传文件大小(字节)'
),
SystemSettings(
key='supported_formats',
value={'formats': ['jpg', 'jpeg', 'png', 'tiff', 'raw', 'cr2', 'nef']},
description='支持的图片格式'
),
SystemSettings(
key='thumbnail_sizes',
value={'sizes': [150, 300, 600]},
description='缩略图尺寸'
)
]
for setting in default_settings:
existing = session.query(SystemSettings).filter_by(key=setting.key).first()
if not existing:
session.add(setting)
# 创建默认标签
default_tags = [
Tag(name='风景', color='#28a745', is_system=True),
Tag(name='人像', color='#dc3545', is_system=True),
Tag(name='动物', color='#ffc107', is_system=True),
Tag(name='建筑', color='#6c757d', is_system=True),
Tag(name='美食', color='#fd7e14', is_system=True)
]
for tag in default_tags:
existing = session.query(Tag).filter_by(name=tag.name, is_system=True).first()
if not existing:
session.add(tag)
session.commit()
except Exception as e:
session.rollback()
raise e
finally:
session.close()
# 使用示例
def example_database_setup():
"""数据库设置示例"""
# 创建数据库管理器
db_manager = DatabaseManager('postgresql://user:password@localhost/photo_manager')
# 创建表
db_manager.create_tables()
# 初始化默认数据
db_manager.init_default_data()
print("Database setup completed")
if __name__ == "__main__":
example_database_setup()
照片处理服务
ExifTool集成服务
# 文件路径: photo_manager/services/photo_service.py
import asyncio
import aiofiles
import hashlib
from pathlib import Path
from typing import Dict, List, Any, Optional, Tuple
from dataclasses import dataclass
from datetime import datetime
import logging
from PIL import Image, ImageOps
import subprocess
import json
from concurrent.futures import ThreadPoolExecutor
import tempfile
import shutil
@dataclass
class PhotoMetadata:
"""照片元数据"""
filename: str
file_size: int
mime_type: str
width: int
height: int
camera_make: Optional[str] = None
camera_model: Optional[str] = None
lens_model: Optional[str] = None
focal_length: Optional[float] = None
aperture: Optional[float] = None
shutter_speed: Optional[str] = None
iso: Optional[int] = None
flash: Optional[str] = None
date_taken: Optional[datetime] = None
gps_latitude: Optional[float] = None
gps_longitude: Optional[float] = None
gps_altitude: Optional[float] = None
keywords: List[str] = None
description: Optional[str] = None
raw_exif: Dict[str, Any] = None
@dataclass
class ProcessingResult:
"""处理结果"""
success: bool
metadata: Optional[PhotoMetadata] = None
thumbnail_path: Optional[str] = None
preview_path: Optional[str] = None
error_message: Optional[str] = None
processing_time: float = 0.0
class ExifToolService:
"""ExifTool服务"""
def __init__(self, executable_path: str = 'exiftool', timeout: int = 30):
self.executable_path = executable_path
self.timeout = timeout
self.logger = logging.getLogger(f"{__name__}.ExifToolService")
# 验证ExifTool是否可用
self._verify_exiftool()
def _verify_exiftool(self):
"""验证ExifTool是否可用"""
try:
result = subprocess.run(
[self.executable_path, '-ver'],
capture_output=True,
text=True,
timeout=5
)
if result.returncode == 0:
version = result.stdout.strip()
self.logger.info(f"ExifTool version: {version}")
else:
raise Exception("ExifTool not found or not working")
except Exception as e:
self.logger.error(f"ExifTool verification failed: {e}")
raise e
async def extract_metadata(self, file_path: str) -> PhotoMetadata:
"""提取照片元数据"""
try:
# 使用线程池执行ExifTool命令
loop = asyncio.get_event_loop()
with ThreadPoolExecutor() as executor:
raw_metadata = await loop.run_in_executor(
executor, self._extract_raw_metadata, file_path
)
# 解析元数据
metadata = self._parse_metadata(file_path, raw_metadata)
return metadata
except Exception as e:
self.logger.error(f"Failed to extract metadata from {file_path}: {e}")
raise e
def _extract_raw_metadata(self, file_path: str) -> Dict[str, Any]:
"""提取原始元数据"""
cmd = [
self.executable_path,
'-json',
'-all',
'-coordFormat', '%.6f',
file_path
]
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=self.timeout
)
if result.returncode != 0:
raise Exception(f"ExifTool error: {result.stderr}")
try:
metadata_list = json.loads(result.stdout)
return metadata_list[0] if metadata_list else {}
except json.JSONDecodeError as e:
raise Exception(f"Failed to parse ExifTool output: {e}")
def _parse_metadata(self, file_path: str, raw_data: Dict[str, Any]) -> PhotoMetadata:
"""解析元数据"""
file_stat = Path(file_path).stat()
# 基本文件信息
metadata = PhotoMetadata(
filename=Path(file_path).name,
file_size=file_stat.st_size,
mime_type=raw_data.get('MIMEType', ''),
width=raw_data.get('ImageWidth', 0),
height=raw_data.get('ImageHeight', 0),
raw_exif=raw_data
)
# 相机信息
metadata.camera_make = raw_data.get('Make')
metadata.camera_model = raw_data.get('Model')
metadata.lens_model = raw_data.get('LensModel')
# 拍摄参数
if 'FocalLength' in raw_data:
focal_length_str = raw_data['FocalLength']
if isinstance(focal_length_str, str) and 'mm' in focal_length_str:
metadata.focal_length = float(focal_length_str.replace('mm', '').strip())
elif isinstance(focal_length_str, (int, float)):
metadata.focal_length = float(focal_length_str)
if 'FNumber' in raw_data:
metadata.aperture = float(raw_data['FNumber'])
metadata.shutter_speed = raw_data.get('ShutterSpeed')
if 'ISO' in raw_data:
metadata.iso = int(raw_data['ISO'])
metadata.flash = raw_data.get('Flash')
# 时间信息
date_fields = ['DateTimeOriginal', 'CreateDate', 'DateTime']
for field in date_fields:
if field in raw_data:
try:
date_str = raw_data[field]
# 处理不同的日期格式
if ':' in date_str and ' ' in date_str:
metadata.date_taken = datetime.strptime(date_str, '%Y:%m:%d %H:%M:%S')
break
except ValueError:
continue
# GPS信息
if 'GPSLatitude' in raw_data and 'GPSLongitude' in raw_data:
metadata.gps_latitude = float(raw_data['GPSLatitude'])
metadata.gps_longitude = float(raw_data['GPSLongitude'])
# 处理GPS参考方向
if raw_data.get('GPSLatitudeRef') == 'S':
metadata.gps_latitude = -metadata.gps_latitude
if raw_data.get('GPSLongitudeRef') == 'W':
metadata.gps_longitude = -metadata.gps_longitude
if 'GPSAltitude' in raw_data:
metadata.gps_altitude = float(raw_data['GPSAltitude'])
# 关键词和描述
keywords = raw_data.get('Keywords', [])
if isinstance(keywords, str):
metadata.keywords = [keywords]
elif isinstance(keywords, list):
metadata.keywords = keywords
else:
metadata.keywords = []
metadata.description = raw_data.get('Description') or raw_data.get('ImageDescription')
return metadata
async def update_metadata(self, file_path: str, metadata_updates: Dict[str, Any]) -> bool:
"""更新照片元数据"""
try:
# 构建ExifTool命令
cmd = [self.executable_path, '-overwrite_original']
for key, value in metadata_updates.items():
if value is not None:
cmd.extend([f'-{key}={value}'])
cmd.append(file_path)
# 执行命令
loop = asyncio.get_event_loop()
with ThreadPoolExecutor() as executor:
result = await loop.run_in_executor(
executor, self._run_exiftool_command, cmd
)
return result.returncode == 0
except Exception as e:
self.logger.error(f"Failed to update metadata for {file_path}: {e}")
return False
def _run_exiftool_command(self, cmd: List[str]):
"""运行ExifTool命令"""
return subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=self.timeout
)
async def batch_extract_metadata(self, file_paths: List[str]) -> Dict[str, PhotoMetadata]:
"""批量提取元数据"""
results = {}
# 并发处理
semaphore = asyncio.Semaphore(4) # 限制并发数
async def process_file(file_path: str):
async with semaphore:
try:
metadata = await self.extract_metadata(file_path)
results[file_path] = metadata
except Exception as e:
self.logger.error(f"Failed to process {file_path}: {e}")
tasks = [process_file(fp) for fp in file_paths]
await asyncio.gather(*tasks, return_exceptions=True)
return results
class ThumbnailGenerator:
"""缩略图生成器"""
def __init__(self, sizes: List[int] = None):
self.sizes = sizes or [150, 300, 600]
self.logger = logging.getLogger(f"{__name__}.ThumbnailGenerator")
async def generate_thumbnails(self, source_path: str,
output_dir: str) -> Dict[int, str]:
"""生成缩略图"""
try:
output_paths = {}
# 使用线程池处理图像
loop = asyncio.get_event_loop()
with ThreadPoolExecutor() as executor:
for size in self.sizes:
output_path = Path(output_dir) / f"thumb_{size}_{Path(source_path).name}"
await loop.run_in_executor(
executor, self._generate_single_thumbnail,
source_path, str(output_path), size
)
output_paths[size] = str(output_path)
return output_paths
except Exception as e:
self.logger.error(f"Failed to generate thumbnails for {source_path}: {e}")
raise e
def _generate_single_thumbnail(self, source_path: str, output_path: str, size: int):
"""生成单个缩略图"""
with Image.open(source_path) as img:
# 自动旋转(基于EXIF方向信息)
img = ImageOps.exif_transpose(img)
# 计算缩略图尺寸(保持宽高比)
img.thumbnail((size, size), Image.Resampling.LANCZOS)
# 保存缩略图
img.save(output_path, 'JPEG', quality=85, optimize=True)
class PhotoProcessor:
"""照片处理器"""
def __init__(self, storage_path: str, exiftool_path: str = 'exiftool'):
self.storage_path = Path(storage_path)
self.exiftool_service = ExifToolService(exiftool_path)
self.thumbnail_generator = ThumbnailGenerator()
self.logger = logging.getLogger(f"{__name__}.PhotoProcessor")
# 创建存储目录
self.storage_path.mkdir(parents=True, exist_ok=True)
(self.storage_path / 'originals').mkdir(exist_ok=True)
(self.storage_path / 'thumbnails').mkdir(exist_ok=True)
(self.storage_path / 'previews').mkdir(exist_ok=True)
async def process_photo(self, source_path: str, user_id: str) -> ProcessingResult:
"""处理单张照片"""
start_time = asyncio.get_event_loop().time()
try:
# 生成文件哈希作为唯一标识
file_hash = await self._calculate_file_hash(source_path)
file_ext = Path(source_path).suffix.lower()
# 目标路径
target_filename = f"{file_hash}{file_ext}"
target_path = self.storage_path / 'originals' / user_id / target_filename
target_path.parent.mkdir(parents=True, exist_ok=True)
# 复制文件到存储位置
shutil.copy2(source_path, target_path)
# 提取元数据
metadata = await self.exiftool_service.extract_metadata(str(target_path))
# 生成缩略图
thumbnail_dir = self.storage_path / 'thumbnails' / user_id
thumbnail_dir.mkdir(parents=True, exist_ok=True)
thumbnail_paths = await self.thumbnail_generator.generate_thumbnails(
str(target_path), str(thumbnail_dir)
)
# 生成预览图(中等尺寸)
preview_path = await self._generate_preview(str(target_path), user_id)
processing_time = asyncio.get_event_loop().time() - start_time
return ProcessingResult(
success=True,
metadata=metadata,
thumbnail_path=thumbnail_paths.get(300), # 使用300px作为主缩略图
preview_path=preview_path,
processing_time=processing_time
)
except Exception as e:
processing_time = asyncio.get_event_loop().time() - start_time
self.logger.error(f"Failed to process photo {source_path}: {e}")
return ProcessingResult(
success=False,
error_message=str(e),
processing_time=processing_time
)
async def _calculate_file_hash(self, file_path: str) -> str:
"""计算文件哈希"""
hash_sha256 = hashlib.sha256()
async with aiofiles.open(file_path, 'rb') as f:
while chunk := await f.read(8192):
hash_sha256.update(chunk)
return hash_sha256.hexdigest()
async def _generate_preview(self, source_path: str, user_id: str) -> str:
"""生成预览图"""
preview_dir = self.storage_path / 'previews' / user_id
preview_dir.mkdir(parents=True, exist_ok=True)
preview_filename = f"preview_{Path(source_path).name}"
preview_path = preview_dir / preview_filename
loop = asyncio.get_event_loop()
with ThreadPoolExecutor() as executor:
await loop.run_in_executor(
executor, self._create_preview_image, source_path, str(preview_path)
)
return str(preview_path)
def _create_preview_image(self, source_path: str, output_path: str):
"""创建预览图像"""
with Image.open(source_path) as img:
# 自动旋转
img = ImageOps.exif_transpose(img)
# 调整大小(最大边1200px)
max_size = 1200
if max(img.size) > max_size:
ratio = max_size / max(img.size)
new_size = tuple(int(dim * ratio) for dim in img.size)
img = img.resize(new_size, Image.Resampling.LANCZOS)
# 保存预览图
img.save(output_path, 'JPEG', quality=90, optimize=True)
async def batch_process_photos(self, file_paths: List[str],
user_id: str) -> List[ProcessingResult]:
"""批量处理照片"""
results = []
# 限制并发数量
semaphore = asyncio.Semaphore(2)
async def process_single(file_path: str):
async with semaphore:
result = await self.process_photo(file_path, user_id)
results.append(result)
return result
tasks = [process_single(fp) for fp in file_paths]
await asyncio.gather(*tasks)
return results
# 使用示例
async def example_photo_processing():
"""照片处理示例"""
# 创建照片处理器
processor = PhotoProcessor('/data/photos')
# 处理单张照片
result = await processor.process_photo('/tmp/test.jpg', 'user123')
if result.success:
print(f"Photo processed successfully:")
print(f" Metadata: {result.metadata.camera_make} {result.metadata.camera_model}")
print(f" Thumbnail: {result.thumbnail_path}")
print(f" Processing time: {result.processing_time:.2f}s")
else:
print(f"Processing failed: {result.error_message}")
# 批量处理
file_paths = ['/tmp/photo1.jpg', '/tmp/photo2.jpg', '/tmp/photo3.jpg']
batch_results = await processor.batch_process_photos(file_paths, 'user123')
successful = sum(1 for r in batch_results if r.success)
print(f"Batch processing completed: {successful}/{len(batch_results)} successful")
if __name__ == "__main__":
asyncio.run(example_photo_processing())
搜索与索引服务
Elasticsearch集成
# 文件路径: photo_manager/services/search_service.py
from elasticsearch import AsyncElasticsearch
from typing import Dict, List, Any, Optional, Tuple
from dataclasses import dataclass, asdict
from datetime import datetime
import json
import logging
from enum import Enum
class SearchType(Enum):
"""搜索类型"""
SIMPLE = "simple"
ADVANCED = "advanced"
SEMANTIC = "semantic"
SIMILAR = "similar"
@dataclass
class SearchFilter:
"""搜索过滤器"""
camera_make: Optional[str] = None
camera_model: Optional[str] = None
date_range: Optional[Tuple[datetime, datetime]] = None
location_radius: Optional[Tuple[float, float, float]] = None # lat, lon, radius_km
tags: Optional[List[str]] = None
rating_min: Optional[int] = None
file_size_range: Optional[Tuple[int, int]] = None
resolution_min: Optional[Tuple[int, int]] = None
has_gps: Optional[bool] = None
@dataclass
class SearchResult:
"""搜索结果"""
photo_id: str
filename: str
thumbnail_url: str
score: float
metadata: Dict[str, Any]
highlights: Dict[str, List[str]] = None
@dataclass
class SearchResponse:
"""搜索响应"""
results: List[SearchResult]
total_count: int
page: int
page_size: int
aggregations: Dict[str, Any] = None
suggestions: List[str] = None
search_time: float = 0.0
class PhotoSearchService:
"""照片搜索服务"""
def __init__(self, elasticsearch_url: str, index_name: str = 'photos'):
self.es = AsyncElasticsearch([elasticsearch_url])
self.index_name = index_name
self.logger = logging.getLogger(f"{__name__}.PhotoSearchService")
async def initialize_index(self):
"""初始化搜索索引"""
# 定义索引映射
mapping = {
"mappings": {
"properties": {
"photo_id": {"type": "keyword"},
"filename": {
"type": "text",
"analyzer": "standard",
"fields": {
"keyword": {"type": "keyword"}
}
},
"description": {
"type": "text",
"analyzer": "standard"
},
"keywords": {
"type": "keyword"
},
"tags": {
"type": "keyword"
},
"camera_make": {"type": "keyword"},
"camera_model": {"type": "keyword"},
"lens_model": {"type": "keyword"},
"focal_length": {"type": "float"},
"aperture": {"type": "float"},
"iso": {"type": "integer"},
"shutter_speed": {"type": "keyword"},
"date_taken": {"type": "date"},
"date_uploaded": {"type": "date"},
"location": {"type": "geo_point"},
"location_name": {
"type": "text",
"analyzer": "standard"
},
"file_size": {"type": "long"},
"width": {"type": "integer"},
"height": {"type": "integer"},
"rating": {"type": "integer"},
"owner_id": {"type": "keyword"},
"album_ids": {"type": "keyword"},
"processing_status": {"type": "keyword"},
"color_palette": {"type": "keyword"}, # 主要颜色
"dominant_colors": {
"type": "nested",
"properties": {
"color": {"type": "keyword"},
"percentage": {"type": "float"}
}
},
"face_count": {"type": "integer"},
"object_tags": {"type": "keyword"}, # AI识别的对象标签
"quality_score": {"type": "float"},
"embedding": { # 图像特征向量
"type": "dense_vector",
"dims": 512
}
}
},
"settings": {
"number_of_shards": 1,
"number_of_replicas": 0,
"analysis": {
"analyzer": {
"photo_analyzer": {
"type": "custom",
"tokenizer": "standard",
"filter": ["lowercase", "stop"]
}
}
}
}
}
# 创建索引
if not await self.es.indices.exists(index=self.index_name):
await self.es.indices.create(index=self.index_name, body=mapping)
self.logger.info(f"Created search index: {self.index_name}")
async def index_photo(self, photo_data: Dict[str, Any]):
"""索引单张照片"""
try:
# 准备索引文档
doc = self._prepare_document(photo_data)
# 索引文档
await self.es.index(
index=self.index_name,
id=photo_data['photo_id'],
body=doc
)
self.logger.debug(f"Indexed photo: {photo_data['photo_id']}")
except Exception as e:
self.logger.error(f"Failed to index photo {photo_data['photo_id']}: {e}")
raise e
def _prepare_document(self, photo_data: Dict[str, Any]) -> Dict[str, Any]:
"""准备索引文档"""
doc = {
"photo_id": photo_data['photo_id'],
"filename": photo_data['filename'],
"description": photo_data.get('description', ''),
"keywords": photo_data.get('keywords', []),
"tags": photo_data.get('tags', []),
"camera_make": photo_data.get('camera_make'),
"camera_model": photo_data.get('camera_model'),
"lens_model": photo_data.get('lens_model'),
"focal_length": photo_data.get('focal_length'),
"aperture": photo_data.get('aperture'),
"iso": photo_data.get('iso'),
"shutter_speed": photo_data.get('shutter_speed'),
"date_taken": photo_data.get('date_taken'),
"date_uploaded": photo_data.get('date_uploaded'),
"file_size": photo_data.get('file_size'),
"width": photo_data.get('width'),
"height": photo_data.get('height'),
"rating": photo_data.get('rating', 0),
"owner_id": photo_data['owner_id'],
"album_ids": photo_data.get('album_ids', []),
"processing_status": photo_data.get('processing_status', 'completed'),
"location_name": photo_data.get('location_name')
}
# 处理GPS坐标
if photo_data.get('gps_latitude') and photo_data.get('gps_longitude'):
doc['location'] = {
"lat": photo_data['gps_latitude'],
"lon": photo_data['gps_longitude']
}
# 添加AI分析结果
if 'ai_analysis' in photo_data:
ai_data = photo_data['ai_analysis']
doc.update({
"face_count": ai_data.get('face_count', 0),
"object_tags": ai_data.get('object_tags', []),
"quality_score": ai_data.get('quality_score'),
"dominant_colors": ai_data.get('dominant_colors', []),
"embedding": ai_data.get('embedding')
})
return doc
async def search_photos(self, query: str,
search_filter: SearchFilter = None,
page: int = 1,
page_size: int = 20,
search_type: SearchType = SearchType.SIMPLE) -> SearchResponse:
"""搜索照片"""
start_time = datetime.now()
try:
# 构建搜索查询
search_body = self._build_search_query(
query, search_filter, search_type
)
# 添加分页
search_body.update({
"from": (page - 1) * page_size,
"size": page_size,
"sort": [{"_score": {"order": "desc"}}]
})
# 添加高亮
search_body["highlight"] = {
"fields": {
"filename": {},
"description": {},
"keywords": {},
"location_name": {}
}
}
# 添加聚合
search_body["aggs"] = self._build_aggregations()
# 执行搜索
response = await self.es.search(
index=self.index_name,
body=search_body
)
# 解析结果
search_results = self._parse_search_response(response)
search_time = (datetime.now() - start_time).total_seconds()
search_results.search_time = search_time
return search_results
except Exception as e:
self.logger.error(f"Search failed: {e}")
raise e
def _build_search_query(self, query: str,
search_filter: SearchFilter,
search_type: SearchType) -> Dict[str, Any]:
"""构建搜索查询"""
if search_type == SearchType.SIMPLE:
return self._build_simple_query(query, search_filter)
elif search_type == SearchType.ADVANCED:
return self._build_advanced_query(query, search_filter)
elif search_type == SearchType.SEMANTIC:
return self._build_semantic_query(query, search_filter)
else:
return self._build_simple_query(query, search_filter)
def _build_simple_query(self, query: str,
search_filter: SearchFilter) -> Dict[str, Any]:
"""构建简单搜索查询"""
must_clauses = []
filter_clauses = []
# 文本搜索
if query:
must_clauses.append({
"multi_match": {
"query": query,
"fields": [
"filename^2",
"description^1.5",
"keywords^2",
"tags^1.5",
"location_name",
"camera_make",
"camera_model"
],
"type": "best_fields",
"fuzziness": "AUTO"
}
})
# 应用过滤器
if search_filter:
filter_clauses.extend(self._build_filter_clauses(search_filter))
search_body = {
"query": {
"bool": {
"must": must_clauses if must_clauses else [{"match_all": {}}],
"filter": filter_clauses
}
}
}
return search_body
def _build_advanced_query(self, query: str,
search_filter: SearchFilter) -> Dict[str, Any]:
"""构建高级搜索查询"""
# 解析高级查询语法
# 例如: camera:Canon AND lens:"24-70mm" AND date:[2023-01-01 TO 2023-12-31]
must_clauses = []
filter_clauses = []
# 这里可以实现更复杂的查询解析逻辑
# 暂时使用简单查询作为基础
return self._build_simple_query(query, search_filter)
def _build_semantic_query(self, query: str,
search_filter: SearchFilter) -> Dict[str, Any]:
"""构建语义搜索查询"""
# 使用向量搜索进行语义匹配
# 需要先将查询转换为向量
must_clauses = []
filter_clauses = []
# 文本搜索作为基础
if query:
must_clauses.append({
"multi_match": {
"query": query,
"fields": ["description", "keywords", "tags"],
"type": "cross_fields"
}
})
# 应用过滤器
if search_filter:
filter_clauses.extend(self._build_filter_clauses(search_filter))
search_body = {
"query": {
"bool": {
"must": must_clauses if must_clauses else [{"match_all": {}}],
"filter": filter_clauses
}
}
}
return search_body
def _build_filter_clauses(self, search_filter: SearchFilter) -> List[Dict[str, Any]]:
"""构建过滤条件"""
clauses = []
if search_filter.camera_make:
clauses.append({"term": {"camera_make": search_filter.camera_make}})
if search_filter.camera_model:
clauses.append({"term": {"camera_model": search_filter.camera_model}})
if search_filter.date_range:
start_date, end_date = search_filter.date_range
clauses.append({
"range": {
"date_taken": {
"gte": start_date.isoformat(),
"lte": end_date.isoformat()
}
}
})
if search_filter.location_radius:
lat, lon, radius_km = search_filter.location_radius
clauses.append({
"geo_distance": {
"distance": f"{radius_km}km",
"location": {"lat": lat, "lon": lon}
}
})
if search_filter.tags:
clauses.append({"terms": {"tags": search_filter.tags}})
if search_filter.rating_min:
clauses.append({
"range": {"rating": {"gte": search_filter.rating_min}}
})
if search_filter.file_size_range:
min_size, max_size = search_filter.file_size_range
clauses.append({
"range": {
"file_size": {"gte": min_size, "lte": max_size}
}
})
if search_filter.resolution_min:
min_width, min_height = search_filter.resolution_min
clauses.extend([
{"range": {"width": {"gte": min_width}}},
{"range": {"height": {"gte": min_height}}}
])
if search_filter.has_gps is not None:
if search_filter.has_gps:
clauses.append({"exists": {"field": "location"}})
else:
clauses.append({
"bool": {"must_not": {"exists": {"field": "location"}}}
})
return clauses
def _build_aggregations(self) -> Dict[str, Any]:
"""构建聚合查询"""
return {
"camera_makes": {
"terms": {"field": "camera_make", "size": 10}
},
"camera_models": {
"terms": {"field": "camera_model", "size": 10}
},
"tags": {
"terms": {"field": "tags", "size": 20}
},
"date_histogram": {
"date_histogram": {
"field": "date_taken",
"calendar_interval": "month"
}
},
"rating_stats": {
"stats": {"field": "rating"}
},
"file_size_ranges": {
"range": {
"field": "file_size",
"ranges": [
{"to": 1048576, "key": "< 1MB"},
{"from": 1048576, "to": 10485760, "key": "1-10MB"},
{"from": 10485760, "to": 52428800, "key": "10-50MB"},
{"from": 52428800, "key": "> 50MB"}
]
}
}
}
def _parse_search_response(self, response: Dict[str, Any]) -> SearchResponse:
"""解析搜索响应"""
hits = response['hits']
total_count = hits['total']['value']
results = []
for hit in hits['hits']:
source = hit['_source']
highlights = hit.get('highlight', {})
result = SearchResult(
photo_id=source['photo_id'],
filename=source['filename'],
thumbnail_url=f"/api/photos/{source['photo_id']}/thumbnail",
score=hit['_score'],
metadata=source,
highlights=highlights
)
results.append(result)
# 解析聚合结果
aggregations = {}
if 'aggregations' in response:
aggs = response['aggregations']
for agg_name, agg_data in aggs.items():
if 'buckets' in agg_data:
aggregations[agg_name] = [
{"key": bucket['key'], "count": bucket['doc_count']}
for bucket in agg_data['buckets']
]
elif 'value' in agg_data:
aggregations[agg_name] = agg_data['value']
return SearchResponse(
results=results,
total_count=total_count,
page=1, # 需要从请求参数中获取
page_size=len(results),
aggregations=aggregations
)
async def suggest_search_terms(self, partial_query: str) -> List[str]:
"""搜索建议"""
try:
suggest_body = {
"suggest": {
"photo_suggest": {
"prefix": partial_query,
"completion": {
"field": "suggest",
"size": 10
}
}
}
}
response = await self.es.search(
index=self.index_name,
body=suggest_body
)
suggestions = []
if 'suggest' in response:
for suggestion in response['suggest']['photo_suggest']:
for option in suggestion['options']:
suggestions.append(option['text'])
return suggestions
except Exception as e:
self.logger.error(f"Failed to get suggestions: {e}")
return []
async def find_similar_photos(self, photo_id: str,
limit: int = 10) -> List[SearchResult]:
"""查找相似照片"""
try:
# 获取目标照片的特征向量
photo_doc = await self.es.get(
index=self.index_name,
id=photo_id
)
if 'embedding' not in photo_doc['_source']:
return []
embedding = photo_doc['_source']['embedding']
# 使用向量相似度搜索
search_body = {
"query": {
"script_score": {
"query": {"match_all": {}},
"script": {
"source": "cosineSimilarity(params.query_vector, 'embedding') + 1.0",
"params": {"query_vector": embedding}
}
}
},
"size": limit + 1, # +1 因为会包含自己
"_source": ["photo_id", "filename", "camera_make", "camera_model"]
}
response = await self.es.search(
index=self.index_name,
body=search_body
)
results = []
for hit in response['hits']['hits']:
# 排除自己
if hit['_source']['photo_id'] != photo_id:
result = SearchResult(
photo_id=hit['_source']['photo_id'],
filename=hit['_source']['filename'],
thumbnail_url=f"/api/photos/{hit['_source']['photo_id']}/thumbnail",
score=hit['_score'],
metadata=hit['_source']
)
results.append(result)
return results[:limit]
except Exception as e:
self.logger.error(f"Failed to find similar photos: {e}")
return []
async def update_photo_index(self, photo_id: str, updates: Dict[str, Any]):
"""更新照片索引"""
try:
await self.es.update(
index=self.index_name,
id=photo_id,
body={"doc": updates}
)
except Exception as e:
self.logger.error(f"Failed to update photo index: {e}")
raise e
async def delete_photo_index(self, photo_id: str):
"""删除照片索引"""
try:
await self.es.delete(
index=self.index_name,
id=photo_id
)
except Exception as e:
self.logger.error(f"Failed to delete photo index: {e}")
raise e
async def bulk_index_photos(self, photos_data: List[Dict[str, Any]]):
"""批量索引照片"""
try:
actions = []
for photo_data in photos_data:
doc = self._prepare_document(photo_data)
action = {
"_index": self.index_name,
"_id": photo_data['photo_id'],
"_source": doc
}
actions.append(action)
from elasticsearch.helpers import async_bulk
await async_bulk(self.es, actions)
self.logger.info(f"Bulk indexed {len(photos_data)} photos")
except Exception as e:
self.logger.error(f"Bulk indexing failed: {e}")
raise e
# 使用示例
async def example_search_service():
"""搜索服务示例"""
# 创建搜索服务
search_service = PhotoSearchService('http://localhost:9200')
# 初始化索引
await search_service.initialize_index()
# 索引照片
photo_data = {
'photo_id': 'photo123',
'filename': 'sunset.jpg',
'description': 'Beautiful sunset over the ocean',
'keywords': ['sunset', 'ocean', 'landscape'],
'tags': ['nature', 'seascape'],
'camera_make': 'Canon',
'camera_model': 'EOS R5',
'owner_id': 'user123',
'gps_latitude': 37.7749,
'gps_longitude': -122.4194
}
await search_service.index_photo(photo_data)
# 搜索照片
search_filter = SearchFilter(
camera_make='Canon',
tags=['nature']
)
results = await search_service.search_photos(
query='sunset ocean',
search_filter=search_filter,
page=1,
page_size=10
)
print(f"Found {results.total_count} photos")
for result in results.results:
print(f" {result.filename} (score: {result.score:.2f})")
if __name__ == "__main__":
asyncio.run(example_search_service())
## 智能标签与分类系统
### 自动标签生成
```python
# 文件路径: src/services/tagging_service.py
from dataclasses import dataclass
from typing import List, Dict, Optional, Set
from enum import Enum
import cv2
import numpy as np
from sklearn.cluster import KMeans
from sklearn.feature_extraction.text import TfidfVectorizer
import face_recognition
from geopy.geocoders import Nominatim
import time
import os
from datetime import datetime
class TagType(Enum):
AUTOMATIC = "automatic"
MANUAL = "manual"
AI_GENERATED = "ai_generated"
LOCATION = "location"
PERSON = "person"
OBJECT = "object"
SCENE = "scene"
COLOR = "color"
TECHNICAL = "technical"
@dataclass
class Tag:
name: str
tag_type: TagType
confidence: float
source: str
metadata: Dict = None
@dataclass
class TaggingResult:
photo_id: str
tags: List[Tag]
processing_time: float
success: bool
error_message: Optional[str] = None
class AutoTaggingService:
"""自动标签生成服务"""
def __init__(self):
self.face_encodings_db = {} # 人脸编码数据库
self.location_cache = {} # 位置信息缓存
self.color_clusters = None # 颜色聚类模型
self.tfidf_vectorizer = TfidfVectorizer(max_features=1000)
async def generate_tags(self, photo: 'Photo', image_path: str) -> TaggingResult:
"""生成照片标签"""
start_time = time.time()
tags = []
try:
# 技术标签(基于EXIF数据)
technical_tags = await self._extract_technical_tags(photo)
tags.extend(technical_tags)
# 位置标签
if photo.latitude and photo.longitude:
location_tags = await self._extract_location_tags(
photo.latitude, photo.longitude
)
tags.extend(location_tags)
# 图像分析标签
if os.path.exists(image_path):
image_tags = await self._analyze_image(image_path)
tags.extend(image_tags)
# 时间标签
time_tags = self._extract_time_tags(photo.date_taken)
tags.extend(time_tags)
processing_time = time.time() - start_time
return TaggingResult(
photo_id=photo.id,
tags=tags,
processing_time=processing_time,
success=True
)
except Exception as e:
return TaggingResult(
photo_id=photo.id,
tags=[],
processing_time=time.time() - start_time,
success=False,
error_message=str(e)
)
async def _extract_technical_tags(self, photo: 'Photo') -> List[Tag]:
"""提取技术标签"""
tags = []
# 相机品牌和型号
if photo.camera_make:
tags.append(Tag(
name=f"camera:{photo.camera_make.lower()}",
tag_type=TagType.TECHNICAL,
confidence=1.0,
source="exif"
))
if photo.camera_model:
tags.append(Tag(
name=f"model:{photo.camera_model.lower().replace(' ', '_')}",
tag_type=TagType.TECHNICAL,
confidence=1.0,
source="exif"
))
# 拍摄模式
if photo.iso:
if photo.iso >= 1600:
tags.append(Tag(
name="high_iso",
tag_type=TagType.TECHNICAL,
confidence=0.9,
source="analysis"
))
elif photo.iso <= 200:
tags.append(Tag(
name="low_iso",
tag_type=TagType.TECHNICAL,
confidence=0.9,
source="analysis"
))
# 焦距分类
if photo.focal_length:
if photo.focal_length <= 35:
tags.append(Tag(
name="wide_angle",
tag_type=TagType.TECHNICAL,
confidence=0.8,
source="analysis"
))
elif photo.focal_length >= 85:
tags.append(Tag(
name="telephoto",
tag_type=TagType.TECHNICAL,
confidence=0.8,
source="analysis"
))
return tags
async def _extract_location_tags(self, latitude: float, longitude: float) -> List[Tag]:
"""提取位置标签"""
cache_key = f"{latitude:.4f},{longitude:.4f}"
if cache_key in self.location_cache:
location_info = self.location_cache[cache_key]
else:
try:
geolocator = Nominatim(user_agent="photo_manager")
location = geolocator.reverse(f"{latitude}, {longitude}")
location_info = location.raw['address'] if location else {}
self.location_cache[cache_key] = location_info
except Exception:
return []
tags = []
# 国家
if 'country' in location_info:
tags.append(Tag(
name=f"country:{location_info['country'].lower()}",
tag_type=TagType.LOCATION,
confidence=0.95,
source="geocoding"
))
# 城市
if 'city' in location_info:
tags.append(Tag(
name=f"city:{location_info['city'].lower()}",
tag_type=TagType.LOCATION,
confidence=0.9,
source="geocoding"
))
# 地区类型
if 'amenity' in location_info:
tags.append(Tag(
name=f"amenity:{location_info['amenity'].lower()}",
tag_type=TagType.LOCATION,
confidence=0.8,
source="geocoding"
))
return tags
async def _analyze_image(self, image_path: str) -> List[Tag]:
"""分析图像内容"""
tags = []
try:
# 读取图像
image = cv2.imread(image_path)
if image is None:
return tags
# 颜色分析
color_tags = self._analyze_colors(image)
tags.extend(color_tags)
# 人脸检测
face_tags = await self._detect_faces(image_path)
tags.extend(face_tags)
# 场景分析
scene_tags = self._analyze_scene(image)
tags.extend(scene_tags)
except Exception as e:
print(f"图像分析错误: {e}")
return tags
def _analyze_colors(self, image: np.ndarray) -> List[Tag]:
"""分析图像主要颜色"""
tags = []
# 将图像转换为RGB并重塑
image_rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
pixels = image_rgb.reshape(-1, 3)
# 使用K-means聚类找到主要颜色
kmeans = KMeans(n_clusters=5, random_state=42)
kmeans.fit(pixels)
# 分析主要颜色
colors = kmeans.cluster_centers_
for i, color in enumerate(colors):
color_name = self._get_color_name(color)
if color_name:
tags.append(Tag(
name=f"color:{color_name}",
tag_type=TagType.COLOR,
confidence=0.7,
source="color_analysis"
))
return tags
def _get_color_name(self, rgb: np.ndarray) -> Optional[str]:
"""根据RGB值获取颜色名称"""
r, g, b = rgb
# 简单的颜色分类
if r > 200 and g > 200 and b > 200:
return "white"
elif r < 50 and g < 50 and b < 50:
return "black"
elif r > g and r > b:
return "red"
elif g > r and g > b:
return "green"
elif b > r and b > g:
return "blue"
elif r > 150 and g > 150 and b < 100:
return "yellow"
elif r > 150 and g < 100 and b > 150:
return "purple"
elif r > 150 and g > 100 and b < 100:
return "orange"
return None
async def _detect_faces(self, image_path: str) -> List[Tag]:
"""检测人脸"""
tags = []
try:
# 加载图像
image = face_recognition.load_image_file(image_path)
face_locations = face_recognition.face_locations(image)
if face_locations:
tags.append(Tag(
name="has_people",
tag_type=TagType.PERSON,
confidence=0.9,
source="face_detection",
metadata={"face_count": len(face_locations)}
))
# 如果检测到多个人脸
if len(face_locations) > 1:
tags.append(Tag(
name="group_photo",
tag_type=TagType.SCENE,
confidence=0.8,
source="face_detection"
))
except Exception as e:
print(f"人脸检测错误: {e}")
return tags
def _analyze_scene(self, image: np.ndarray) -> List[Tag]:
"""分析场景类型"""
tags = []
# 简单的场景分析(基于图像特征)
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
# 边缘检测
edges = cv2.Canny(gray, 50, 150)
edge_ratio = np.sum(edges > 0) / edges.size
if edge_ratio > 0.1:
tags.append(Tag(
name="detailed_scene",
tag_type=TagType.SCENE,
confidence=0.6,
source="edge_analysis"
))
# 亮度分析
brightness = np.mean(gray)
if brightness > 200:
tags.append(Tag(
name="bright_scene",
tag_type=TagType.SCENE,
confidence=0.7,
source="brightness_analysis"
))
elif brightness < 50:
tags.append(Tag(
name="dark_scene",
tag_type=TagType.SCENE,
confidence=0.7,
source="brightness_analysis"
))
return tags
def _extract_time_tags(self, date_taken: datetime) -> List[Tag]:
"""提取时间相关标签"""
tags = []
if not date_taken:
return tags
# 季节
month = date_taken.month
if month in [12, 1, 2]:
season = "winter"
elif month in [3, 4, 5]:
season = "spring"
elif month in [6, 7, 8]:
season = "summer"
else:
season = "autumn"
tags.append(Tag(
name=f"season:{season}",
tag_type=TagType.AUTOMATIC,
confidence=1.0,
source="date_analysis"
))
# 时间段
hour = date_taken.hour
if 5 <= hour < 12:
time_period = "morning"
elif 12 <= hour < 17:
time_period = "afternoon"
elif 17 <= hour < 21:
time_period = "evening"
else:
time_period = "night"
tags.append(Tag(
name=f"time:{time_period}",
tag_type=TagType.AUTOMATIC,
confidence=0.9,
source="date_analysis"
))
# 年份
tags.append(Tag(
name=f"year:{date_taken.year}",
tag_type=TagType.AUTOMATIC,
confidence=1.0,
source="date_analysis"
))
return tags
class SmartClassificationService:
"""智能分类服务"""
def __init__(self):
self.classification_rules = {
'portrait': {
'required_tags': ['has_people'],
'preferred_tags': ['telephoto', 'low_iso'],
'weight': 0.8
},
'landscape': {
'required_tags': ['wide_angle'],
'excluded_tags': ['has_people'],
'weight': 0.7
},
'street': {
'required_tags': ['has_people'],
'preferred_tags': ['city'],
'weight': 0.6
},
'nature': {
'excluded_tags': ['city', 'has_people'],
'preferred_tags': ['country'],
'weight': 0.5
},
'night': {
'required_tags': ['time:night'],
'preferred_tags': ['high_iso'],
'weight': 0.9
}
}
def classify_photo(self, tags: List[Tag]) -> Dict[str, float]:
"""对照片进行智能分类"""
tag_names = {tag.name for tag in tags}
classifications = {}
for category, rules in self.classification_rules.items():
score = self._calculate_category_score(tag_names, rules)
if score > 0:
classifications[category] = score
return classifications
def _calculate_category_score(self, tag_names: Set[str], rules: Dict) -> float:
"""计算分类得分"""
score = 0.0
# 检查必需标签
required_tags = rules.get('required_tags', [])
if required_tags:
if not all(tag in tag_names for tag in required_tags):
return 0.0
score += 0.5
# 检查排除标签
excluded_tags = rules.get('excluded_tags', [])
if any(tag in tag_names for tag in excluded_tags):
return 0.0
# 检查偏好标签
preferred_tags = rules.get('preferred_tags', [])
if preferred_tags:
matched_preferred = sum(1 for tag in preferred_tags if tag in tag_names)
score += (matched_preferred / len(preferred_tags)) * 0.3
# 应用权重
weight = rules.get('weight', 1.0)
return score * weight
# 使用示例
async def demo_tagging_system():
"""标签系统演示"""
# 初始化服务
tagging_service = AutoTaggingService()
classification_service = SmartClassificationService()
# 模拟照片数据
from dataclasses import dataclass
@dataclass
class Photo:
id: str
filename: str
camera_make: str
camera_model: str
iso: int
focal_length: float
latitude: float
longitude: float
date_taken: datetime
photo = Photo(
id="photo_001",
filename="sunset_portrait.jpg",
camera_make="Canon",
camera_model="EOS R5",
iso=800,
focal_length=85,
latitude=37.7749,
longitude=-122.4194,
date_taken=datetime(2024, 6, 15, 19, 30)
)
# 生成标签
result = await tagging_service.generate_tags(photo, "/path/to/image.jpg")
if result.success:
print(f"为照片 {photo.filename} 生成了 {len(result.tags)} 个标签:")
for tag in result.tags:
print(f" - {tag.name} ({tag.tag_type.value}, 置信度: {tag.confidence:.2f})")
# 智能分类
classifications = classification_service.classify_photo(result.tags)
print(f"\n分类结果:")
for category, score in sorted(classifications.items(), key=lambda x: x[1], reverse=True):
print(f" - {category}: {score:.2f}")
print(f"\n处理时间: {result.processing_time:.2f}秒")
if __name__ == "__main__":
import asyncio
asyncio.run(demo_tagging_system())
用户界面与交互
Web界面实现
# 文件路径: src/web/app.py
from fastapi import FastAPI, Request, Form, File, UploadFile, Depends
from fastapi.templating import Jinja2Templates
from fastapi.staticfiles import StaticFiles
from fastapi.responses import HTMLResponse, JSONResponse
from typing import List, Optional
import json
import uuid
import os
app = FastAPI(title="照片管理系统")
app.mount("/static", StaticFiles(directory="static"), name="static")
templates = Jinja2Templates(directory="templates")
# 依赖注入
def get_photo_service():
return PhotoProcessor()
def get_search_service():
return PhotoSearchService()
def get_tagging_service():
return AutoTaggingService()
@app.get("/", response_class=HTMLResponse)
async def dashboard(request: Request):
"""主页面"""
return templates.TemplateResponse("dashboard.html", {
"request": request,
"title": "照片管理系统"
})
@app.get("/photos", response_class=HTMLResponse)
async def photo_gallery(request: Request, page: int = 1, per_page: int = 20):
"""照片画廊"""
# 获取照片列表
photos = await get_photos_paginated(page, per_page)
return templates.TemplateResponse("gallery.html", {
"request": request,
"photos": photos,
"page": page,
"per_page": per_page
})
@app.post("/upload")
async def upload_photos(
files: List[UploadFile] = File(...),
photo_service: PhotoProcessor = Depends(get_photo_service)
):
"""批量上传照片"""
results = []
for file in files:
try:
# 保存文件
file_path = await save_uploaded_file(file)
# 处理照片
result = await photo_service.process_photo(file_path)
results.append({
"filename": file.filename,
"success": result.success,
"photo_id": result.photo_id if result.success else None,
"error": result.error_message
})
except Exception as e:
results.append({
"filename": file.filename,
"success": False,
"error": str(e)
})
return JSONResponse({"results": results})
@app.get("/search")
async def search_photos(
q: str,
search_type: str = "simple",
page: int = 1,
per_page: int = 20,
search_service: PhotoSearchService = Depends(get_search_service)
):
"""搜索照片"""
try:
if search_type == "simple":
results = await search_service.simple_search(q, page, per_page)
elif search_type == "advanced":
# 解析高级搜索参数
filters = parse_advanced_search(q)
results = await search_service.advanced_search(filters, page, per_page)
else:
results = await search_service.semantic_search(q, page, per_page)
return JSONResponse({
"success": True,
"results": results.photos,
"total": results.total,
"page": page,
"per_page": per_page
})
except Exception as e:
return JSONResponse({
"success": False,
"error": str(e)
})
@app.get("/photos/{photo_id}")
async def photo_detail(photo_id: str, request: Request):
"""照片详情页"""
photo = await get_photo_by_id(photo_id)
if not photo:
return templates.TemplateResponse("404.html", {"request": request})
# 获取相关照片
similar_photos = await find_similar_photos(photo_id, limit=6)
return templates.TemplateResponse("photo_detail.html", {
"request": request,
"photo": photo,
"similar_photos": similar_photos
})
@app.post("/photos/{photo_id}/tags")
async def update_photo_tags(
photo_id: str,
tags: List[str] = Form(...),
tagging_service: AutoTaggingService = Depends(get_tagging_service)
):
"""更新照片标签"""
try:
# 更新标签
await update_photo_manual_tags(photo_id, tags)
return JSONResponse({
"success": True,
"message": "标签更新成功"
})
except Exception as e:
return JSONResponse({
"success": False,
"error": str(e)
})
@app.get("/api/stats")
async def get_statistics():
"""获取系统统计信息"""
stats = {
"total_photos": await count_total_photos(),
"total_size": await calculate_total_size(),
"photos_by_month": await get_photos_by_month(),
"top_tags": await get_top_tags(limit=10),
"camera_stats": await get_camera_statistics()
}
return JSONResponse(stats)
# 辅助函数
async def save_uploaded_file(file: UploadFile) -> str:
"""保存上传的文件"""
# 生成唯一文件名
file_extension = os.path.splitext(file.filename)[1]
unique_filename = f"{uuid.uuid4()}{file_extension}"
file_path = f"uploads/{unique_filename}"
# 确保目录存在
os.makedirs(os.path.dirname(file_path), exist_ok=True)
# 保存文件
with open(file_path, "wb") as buffer:
content = await file.read()
buffer.write(content)
return file_path
def parse_advanced_search(query: str) -> 'SearchFilter':
"""解析高级搜索查询"""
# 简单的查询解析器
filters = SearchFilter()
# 解析查询字符串
# 例如: "camera:canon date:2024 tag:portrait"
parts = query.split()
for part in parts:
if ":" in part:
key, value = part.split(":", 1)
if key == "camera":
filters.camera_make = value
elif key == "date":
filters.date_range = (value, value)
elif key == "tag":
if not filters.tags:
filters.tags = []
filters.tags.append(value)
else:
filters.query = part
return filters
# 模拟数据库操作函数
async def get_photos_paginated(page: int, per_page: int):
"""分页获取照片"""
# 这里应该是实际的数据库查询
return []
async def get_photo_by_id(photo_id: str):
"""根据ID获取照片"""
# 这里应该是实际的数据库查询
return None
async def find_similar_photos(photo_id: str, limit: int):
"""查找相似照片"""
# 这里应该是实际的相似度搜索
return []
async def update_photo_manual_tags(photo_id: str, tags: List[str]):
"""更新照片手动标签"""
# 这里应该是实际的数据库更新
pass
async def count_total_photos():
"""统计照片总数"""
return 0
async def calculate_total_size():
"""计算总存储大小"""
return "0 MB"
async def get_photos_by_month():
"""按月份统计照片"""
return {}
async def get_top_tags(limit: int):
"""获取热门标签"""
return []
async def get_camera_statistics():
"""获取相机统计"""
return {}
前端界面模板
<!-- 文件路径: templates/dashboard.html -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ title }}</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<link href="/static/css/custom.css" rel="stylesheet">
</head>
<body class="bg-gray-100">
<!-- 导航栏 -->
<nav class="bg-white shadow-lg">
<div class="max-w-7xl mx-auto px-4">
<div class="flex justify-between h-16">
<div class="flex items-center">
<h1 class="text-xl font-bold text-gray-800">照片管理系统</h1>
</div>
<div class="flex items-center space-x-4">
<a href="/photos" class="text-gray-600 hover:text-gray-900">照片库</a>
<a href="/albums" class="text-gray-600 hover:text-gray-900">相册</a>
<a href="/tags" class="text-gray-600 hover:text-gray-900">标签</a>
<a href="/settings" class="text-gray-600 hover:text-gray-900">设置</a>
</div>
</div>
</div>
</nav>
<!-- 主要内容 -->
<div class="max-w-7xl mx-auto py-6 px-4">
<!-- 统计卡片 -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<div class="bg-white rounded-lg shadow p-6">
<div class="flex items-center">
<div class="p-3 rounded-full bg-blue-500 bg-opacity-75">
<svg class="h-8 w-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-600">总照片数</p>
<p class="text-2xl font-semibold text-gray-900" id="total-photos">-</p>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow p-6">
<div class="flex items-center">
<div class="p-3 rounded-full bg-green-500 bg-opacity-75">
<svg class="h-8 w-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-600">存储空间</p>
<p class="text-2xl font-semibold text-gray-900" id="total-size">-</p>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow p-6">
<div class="flex items-center">
<div class="p-3 rounded-full bg-yellow-500 bg-opacity-75">
<svg class="h-8 w-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
</svg>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-600">标签数量</p>
<p class="text-2xl font-semibold text-gray-900" id="total-tags">-</p>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow p-6">
<div class="flex items-center">
<div class="p-3 rounded-full bg-purple-500 bg-opacity-75">
<svg class="h-8 w-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-600">相册数量</p>
<p class="text-2xl font-semibold text-gray-900" id="total-albums">-</p>
</div>
</div>
</div>
</div>
<!-- 快速操作 -->
<div class="bg-white rounded-lg shadow mb-8">
<div class="p-6">
<h2 class="text-lg font-medium text-gray-900 mb-4">快速操作</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<button onclick="openUploadModal()" class="flex items-center justify-center px-4 py-3 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700">
<svg class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
上传照片
</button>
<button onclick="createAlbum()" class="flex items-center justify-center px-4 py-3 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
<svg class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
创建相册
</button>
<button onclick="startBatchProcess()" class="flex items-center justify-center px-4 py-3 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
<svg class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
批量处理
</button>
</div>
</div>
</div>
<!-- 最近照片 -->
<div class="bg-white rounded-lg shadow">
<div class="p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-medium text-gray-900">最近照片</h2>
<a href="/photos" class="text-sm text-blue-600 hover:text-blue-500">查看全部</a>
</div>
<div id="recent-photos" class="grid grid-cols-2 md:grid-cols-6 gap-4">
<!-- 照片缩略图将通过JavaScript加载 -->
</div>
</div>
</div>
</div>
<!-- 上传模态框 -->
<div id="uploadModal" class="fixed inset-0 bg-gray-600 bg-opacity-50 hidden">
<div class="flex items-center justify-center min-h-screen">
<div class="bg-white rounded-lg shadow-xl max-w-md w-full mx-4">
<div class="p-6">
<h3 class="text-lg font-medium text-gray-900 mb-4">上传照片</h3>
<div id="dropZone" class="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center hover:border-gray-400 transition-colors">
<svg class="mx-auto h-12 w-12 text-gray-400" stroke="currentColor" fill="none" viewBox="0 0 48 48">
<path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<p class="mt-2 text-sm text-gray-600">拖拽照片到这里或点击选择文件</p>
<input type="file" id="fileInput" multiple accept="image/*" class="hidden">
</div>
<div class="mt-4 flex justify-end space-x-3">
<button onclick="closeUploadModal()" class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50">取消</button>
<button onclick="uploadFiles()" class="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700">上传</button>
</div>
</div>
</div>
</div>
</div>
<script src="/static/js/dashboard.js"></script>
</body>
</html>
总结
本章节构建了一个完整的照片管理系统,展示了ExifTool在实际项目中的应用。通过系统架构设计、数据模型设计、照片处理服务、搜索索引服务、智能标签系统和用户界面的实现,我们创建了一个功能丰富、性能优秀的照片管理平台。
核心知识点
-
系统架构设计
- 微服务架构模式
- 服务网格和负载均衡
- 熔断器和配置管理
-
数据模型设计
- 关系型数据库设计
- 数据模型优化
- 数据库连接管理
-
照片处理服务
- ExifTool集成
- 元数据提取和更新
- 缩略图生成
- 批量处理
-
搜索与索引
- Elasticsearch集成
- 全文搜索
- 语义搜索
- 相似照片查找
-
智能标签系统
- 自动标签生成
- 图像分析
- 智能分类
- 机器学习应用
-
用户界面设计
- Web界面开发
- 响应式设计
- 交互体验优化
实用技能
-
系统设计能力
- 架构模式选择
- 组件设计
- 接口定义
-
数据处理技术
- 元数据处理
- 图像处理
- 批量操作
-
搜索技术
- 搜索引擎集成
- 索引优化
- 查询优化
-
AI技术应用
- 计算机视觉
- 机器学习
- 自然语言处理
最佳实践
-
性能优化
- 异步处理
- 缓存策略
- 数据库优化
-
可扩展性
- 微服务架构
- 水平扩展
- 负载均衡
-
可维护性
- 模块化设计
- 接口标准化
- 错误处理
-
用户体验
- 响应式设计
- 交互优化
- 性能监控
扩展思考
高级特性开发
-
AI功能增强
- 深度学习图像分析
- 自然语言处理
- 推荐系统
-
高级搜索功能
- 图像相似度搜索
- 地理位置搜索
- 时间轴搜索
-
协作功能
- 多用户协作
- 权限管理
- 版本控制
企业级功能
-
安全性增强
- 数据加密
- 访问控制
- 审计日志
-
监控和运维
- 性能监控
- 日志分析
- 自动化运维
-
数据管理
- 数据备份
- 灾难恢复
- 数据迁移
集成和扩展
-
第三方集成
- 云存储服务
- CDN加速
- 社交媒体
-
API开发
- RESTful API
- GraphQL
- WebSocket
-
移动端支持
- 移动应用
- 响应式设计
- 离线功能
下一步学习
-
深入学习
- 分布式系统设计
- 大数据处理
- 机器学习应用
-
技术栈扩展
- 容器化部署
- 云原生架构
- DevOps实践
-
项目实践
- 开源项目贡献
- 企业级项目
- 技术分享
参考资源
官方文档
技术书籍
- 《设计数据密集型应用》
- 《微服务架构设计模式》
- 《高性能MySQL》
- 《计算机视觉:算法与应用》
开源项目

2663

被折叠的 条评论
为什么被折叠?



