设计资产自动整理 Agent:归集散落的图片与源文件
引言
设计团队普遍面临“资产黑洞”:设计师交付的 Figma/Sketch 源文件、导出的 PNG/SVG、切图、字体文件散落在本地磁盘、NAS、飞书文档、腾讯 CoDesign 等多个位置。一次品牌升级或版本迭代时,“找文件”消耗的时间甚至超过“改文件”。设计资产自动整理 Agent 通过 文件指纹(Perceptual Hash)、元数据提取(Exif/PPMD) 与 LLM 语义聚类,将散落的资产自动归类、去重、命名规范化,并建立跨平台索引。
技术背景
核心算法与工具
| 技术 | 用途 | 实现 |
|---|---|---|
| 感知哈希(pHash) | 图片去重(即使缩放/裁剪/轻微调色也能判定相同) | imagehash.phash() |
| EXIF / XMP 解析 | 提取相机信息、色彩空间、DPI、创建时间 | PIL.ExifTags / pyexiv2 |
| 文件类型魔数检测 | 识别真实文件类型(防伪装扩展名) | python-magic / filetype |
| LLM 语义分类 | 根据文件名+路径+内容理解,自动打标签(banner/icon/screenshot/logo) | GPT-4o-mini |
| 模糊匹配(fuzzywuzzy) | 文件名相似度聚类(如 home_v2_final.psd vs home_v3_final.psd) | thefuzz |
| Embedding 向量检索 | 图片视觉相似度搜索(找同风格素材) | clip-as-service 或 img2vec |
资产整理流程
扫描目录/云盘 → 文件指纹(pHash) → 去重 → 元数据提取 → LLM 语义标签 → 分类目录 → 重命名规范 → 建立索引(JSON/DB)
应用使用场景
| 场景 | 输入 | Agent 行为 |
|---|---|---|
| 设计师离职交接 | 遗留的 2000+ 文件(.psd/.fig/.sketch/.png) | 去重 → 按项目/类型/时间分类 → 生成资产目录 JSON |
| 品牌升级迁移 | 旧版 Logo/Icon 散落在各处 | pHash 找出所有旧版 Logo 变体 → 标记“待替换” → 替换为新版 |
| 跨平台资产同步 | 飞书文档 + 腾讯 CoDesign + 本地 NAS | 指纹去重 → 建立统一索引 → 缺失文件自动上传至主仓库 |
| 设计稿版本清理 | 同名文件 30 个版本(homepage_v1 ~ homepage_v30) | 按修改时间+内容相似度聚类 → 保留里程碑版本 → 归档中间版本 |
不同场景下详细代码实现
模块一:文件扫描与指纹提取
# asset_organizer/scanner.py
"""
递归扫描目录,提取文件元数据和感知哈希(图片)。
支持 .psd/.fig/.sketch/.xd/.ai/.pdf/.png/.jpg/.svg/.webp
"""
import os
import hashlib
import json
from pathlib import Path
from datetime import datetime
from typing import Dict, List, Optional, Set, Tuple
from dataclasses import dataclass, field, asdict
try:
import imagehash
from PIL import Image, ExifTags
except ImportError:
imagehash = None
try:
import magic
except ImportError:
magic = None
SUPPORTED_EXTENSIONS = {
'.psd', '.fig', '.sketch', '.xd', '.ai', '.pdf',
'.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.ico',
'.tiff', '.bmp', '.heic', '.avif'
}
@dataclass
class AssetRecord:
"""单条资产记录"""
file_path: str # 原始绝对路径
relative_path: str # 相对于根目录的路径
file_name: str # 文件名(含扩展名)
stem: str # 文件名(不含扩展名)
extension: str # 扩展名(小写)
file_size_bytes: int
modified_at: str # ISO 格式
created_at: Optional[str] = None
md5_hash: str = ""
phash: Optional[str] = None # 图片感知哈希(十六进制字符串)
real_mime: Optional[str] = None # 魔数检测的真实 MIME
width: Optional[int] = None
height: Optional[int] = None
dpi: Optional[Tuple[int, int]] = None
color_space: Optional[str] = None
llm_labels: List[str] = field(default_factory=list)
dedup_group: Optional[str] = None # 去重组 ID
suggested_new_name: Optional[str] = None
suggested_category: Optional[str] = None
class AssetScanner:
"""递归扫描器 + 指纹提取"""
def __init__(self, root_path: str,
compute_phash: bool = True,
detect_mime: bool = True):
self.root = Path(root_path).resolve()
self.compute_phash = compute_phash
self.detect_mime = detect_mime
def scan(self) -> List[AssetRecord]:
records = []
for fpath in self.root.rglob("*"):
if not fpath.is_file():
continue
ext = fpath.suffix.lower()
if ext not in SUPPORTED_EXTENSIONS:
continue
rec = self._extract_record(fpath)
if rec:
records.append(rec)
return records
def _extract_record(self, fpath: Path) -> Optional[AssetRecord]:
try:
stat = fpath.stat()
rel = str(fpath.relative_to(self.root))
rec = AssetRecord(
file_path=str(fpath.resolve()),
relative_path=rel,
file_name=fpath.name,
stem=fpath.stem,
extension=fpath.suffix.lower(),
file_size_bytes=stat.st_size,
modified_at=datetime.fromtimestamp(stat.st_mtime).isoformat(),
created_at=datetime.fromtimestamp(stat.st_ctime).isoformat() if hasattr(stat, 'st_ctime') else None,
md5_hash=self._md5(fpath),
)
# 魔数检测
if self.detect_mime and magic:
rec.real_mime = magic.from_file(str(fpath), mime=True)
# 图片尺寸/感知哈希
if rec.extension in {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.tiff'}:
self._enrich_image(fpath, rec)
return rec
except Exception as e:
print(f" ⚠️ Skip {fpath.name}: {e}")
return None
def _md5(self, fpath: Path, chunk_size: int = 8192) -> str:
h = hashlib.md5()
with open(fpath, "rb") as f:
while True:
chunk = f.read(chunk_size)
if not chunk:
break
h.update(chunk)
return h.hexdigest()
def _enrich_image(self, fpath: Path, rec: AssetRecord):
try:
img = Image.open(fpath)
rec.width, rec.height = img.size
rec.color_space = img.mode # 'RGB', 'RGBA', 'CMYK'
# DPI
dpi = img.info.get("dpi")
if dpi:
rec.dpi = (round(dpi[0]), round(dpi[1]))
# 感知哈希
if self.compute_phash and imagehash:
rec.phash = str(imagehash.phash(img))
img.close()
except Exception:
pass
模块二:去重引擎(pHash + MD5)
# asset_organizer/dedup.py
"""
去重引擎:
1. 精确去重(MD5 完全相同)→ 保留一份
2. 近似去重(pHash 汉明距离 ≤ 5)→ 标记为同一组
3. 文件名模糊匹配(thefuzz 分数 ≥ 85)→ 标记为同类
"""
from typing import List, Dict, Tuple
from collections import defaultdict
from thefuzz import fuzz
from asset_organizer.scanner import AssetRecord
class DedupEngine:
def __init__(self, records: List[AssetRecord],
phash_threshold: int = 5,
fuzzy_threshold: int = 85):
self.records = records
self.phash_threshold = phash_threshold
self.fuzzy_threshold = fuzzy_threshold
self.groups: Dict[str, List[AssetRecord]] = {} # group_id → records
def run(self) -> Dict[str, List[AssetRecord]]:
"""执行去重,返回 {group_id: [records]}"""
self._dedup_by_md5()
self._dedup_by_phash()
self._dedup_by_fuzzy_name()
return self.groups
def _dedup_by_md5(self):
md5_map = defaultdict(list)
for rec in self.records:
md5_map[rec.md5_hash].append(rec)
for md5, recs in md5_map.items():
if len(recs) > 1:
gid = f"md5:{md5[:12]}"
self.groups[gid] = recs
for r in recs:
r.dedup_group = gid
def _dedup_by_phash(self):
"""pHash 近似去重(O(n²),可优化为 BK-tree)"""
phash_records = [r for r in self.records if r.phash]
if not phash_records:
return
visited = set()
for i, r1 in enumerate(phash_records):
if i in visited:
continue
group = [r1]
visited.add(i)
for j in range(i + 1, len(phash_records)):
if j in visited:
continue
r2 = phash_records[j]
try:
dist = bin(int(r1.phash, 16) ^ int(r2.phash, 16)).count("1")
except Exception:
dist = 999
if dist <= self.phash_threshold:
group.append(r2)
visited.add(j)
if len(group) > 1:
gid = f"phash:{r1.phash[:8]}"
self.groups[gid] = group
for r in group:
r.dedup_group = gid
def _dedup_by_fuzzy_name(self):
"""文件名模糊匹配(如 homepage_v1.png ≈ homepage_v2.png)"""
# 仅对未分组的记录进行
ungrouped = [r for r in self.records if not r.dedup_group]
visited = set()
for i, r1 in enumerate(ungrouped):
if i in visited:
continue
group = [r1]
visited.add(i)
for j in range(i + 1, len(ungrouped)):
if j in visited:
continue
r2 = ungrouped[j]
score = fuzz.token_sort_ratio(r1.stem, r2.stem)
if score >= self.fuzzy_threshold:
group.append(r2)
visited.add(j)
if len(group) > 1:
gid = f"fuzzy:{group[0].stem[:20]}"
self.groups[gid] = group
for r in group:
r.dedup_group = gid
def duplicates_summary(self) -> Dict:
"""返回去重摘要:节省空间 / 冗余文件清单"""
total_size = 0
redundant_files = []
for gid, recs in self.groups.items():
# 保留第一个(最新修改时间),其余标记为冗余
recs.sort(key=lambda r: r.modified_at, reverse=True)
keep = recs[0]
for r in recs[1:]:
total_size += r.file_size_bytes
redundant_files.append(r.file_path)
return {
"duplicate_groups": len(self.groups),
"redundant_count": len(redundant_files),
"redundant_size_mb": round(total_size / (1024*1024), 2),
"redundant_files": redundant_files[:50], # 仅返回前 50 条
}
模块三:LLM 语义分类与标签
# asset_organizer/classifier.py
"""
LLM 语义分类器:
根据文件名+路径+图片描述(Base64 缩略图)推断:
- category: banner / icon / screenshot / logo / illustration / mockup / wireframe / photo / font / document
- tags: [responsive, dark-mode, mobile, desktop, brand-color, ...]
- suggested_name: 规范化文件名
"""
import json
import base64
import io
from typing import List, Optional
from PIL import Image
from langchain_openai import ChatOpenAI
from langchain.schema import SystemMessage, HumanMessage
from asset_organizer.scanner import AssetRecord
SYSTEM_PROMPT = """You are a design asset librarian. Given a file's metadata (name, extension, dimensions, color mode) and optionally its thumbnail, determine:
1. **category** (one of): banner, icon, screenshot, logo, illustration, mockup, wireframe, photo, font, document, ui_element, pattern, other
2. **tags** (max 5): descriptive keywords like 'dark-mode', 'responsive', 'mobile-first', 'brand-primary', 'gradient', 'flat', 'material', 'ios-style', 'android-style'
3. **suggested_name**: a clean, human-readable filename without extension (snake_case or kebab-case, e.g. 'homepage_banner_v2')
4. **confidence**: 0.0–1.0
Respond ONLY with JSON:
{"category": "...", "tags": [...], "suggested_name": "...", "confidence": 0.95}
"""
class LLMClassifier:
def __init__(self, api_key: Optional[str] = None,
model: str = "gpt-4o-mini",
enable_vision: bool = False):
self.llm = ChatOpenAI(
model=model,
api_key=api_key,
temperature=0.1,
max_tokens=200
)
self.enable_vision = enable_vision
def classify(self, record: AssetRecord) -> dict:
"""对单个资产进行分类"""
prompt = self._build_prompt(record)
messages = [SystemMessage(content=SYSTEM_PROMPT), HumanMessage(content=prompt)]
try:
resp = self.llm.invoke(messages)
result = json.loads(resp.content)
record.llm_labels = result.get("tags", [])
record.suggested_category = result.get("category")
record.suggested_new_name = result.get("suggested_name")
return result
except Exception as e:
print(f" ⚠️ LLM classify failed for {record.file_name}: {e}")
return {"category": "other", "tags": [], "suggested_name": record.stem, "confidence": 0.0}
def batch_classify(self, records: List[AssetRecord],
max_batch: int = 50) -> List[AssetRecord]:
"""批量分类(控制速率)"""
import time
classified = []
for i, rec in enumerate(records):
if i >= max_batch:
break
self.classify(rec)
classified.append(rec)
if (i + 1) % 10 == 0:
time.sleep(0.5) # 限速
return classified
def _build_prompt(self, record: AssetRecord) -> str:
parts = [
f"File: {record.file_name}",
f"Extension: {record.extension}",
f"Dimensions: {record.width}x{record.height}" if record.width else "",
f"Color Mode: {record.color_space}" if record.color_space else "",
f"File Size: {record.file_size_bytes} bytes",
f"Relative Path: {record.relative_path}",
]
# 可选:添加 Base64 缩略图(Vision 模式)
if self.enable_vision and record.extension in {'.png','.jpg','.jpeg','.webp'}:
try:
img = Image.open(record.file_path)
img.thumbnail((224, 224))
buf = io.BytesIO()
img.save(buf, format='JPEG', quality=70)
b64 = base64.b64encode(buf.getvalue()).decode()
parts.append(f"Thumbnail (base64): data:image/jpeg;base64,{b64}")
except Exception:
pass
return "\n".join(filter(None, parts))
模块四:资产整理编排(主 Agent)
# asset_organizer/organizer.py
"""
资产整理主 Agent:编排扫描 → 去重 → 分类 → 重命名 → 移动 → 索引
"""
import os
import shutil
import json
from pathlib import Path
from typing import List, Optional
from datetime import datetime
from asset_organizer.scanner import AssetScanner, AssetRecord
from asset_organizer.dedup import DedupEngine
from asset_organizer.classifier import LLMClassifier
class AssetOrganizerAgent:
def __init__(self, source_root: str,
dest_root: Optional[str] = None,
api_key: Optional[str] = None,
dry_run: bool = True):
self.source_root = Path(source_root).resolve()
self.dest_root = Path(dest_root or f"{source_root}_organized").resolve()
self.dry_run = dry_run
self.scanner = AssetScanner(source_root)
self.classifier = LLMClassifier(api_key=api_key)
self.records: List[AssetRecord] = []
self.index: dict = {}
def run(self):
"""执行完整整理流程"""
print(f"🔍 Scanning: {self.source_root}")
self.records = self.scanner.scan()
print(f" Found {len(self.records)} assets")
# 1. 去重
print("🧹 Deduplicating...")
dedup = DedupEngine(self.records)
groups = dedup.run()
dup_summary = dedup.duplicates_summary()
print(f" Duplicate groups: {dup_summary['duplicate_groups']}")
print(f" Redundant size: {dup_summary['redundant_size_mb']} MB")
# 2. LLM 分类(仅对未分组或每组保留的第一份)
print("🏷️ Classifying with LLM...")
to_classify = [r for r in self.records if not r.dedup_group or
r.file_path == dedup.groups.get(r.dedup_group, [r])[0].file_path]
self.classifier.batch_classify(to_classify, max_batch=100)
# 3. 构建目标目录结构 & 重命名
print("📁 Building organized structure...")
self._build_destination()
# 4. 建立索引 JSON
self._build_index()
print(f"\n✅ Done! Organized index at: {self.dest_root / '_asset_index.json'}")
if self.dry_run:
print("⚠️ DRY RUN — no files moved. Set dry_run=False to execute.")
def _build_destination(self):
"""按 category → project → type 组织目录"""
for rec in self.records:
# 跳过冗余副本(保留组内第一个)
if rec.dedup_group:
# 保留每组第一个(按修改时间最新)
group = self._get_group(rec.dedup_group)
if group and group[0].file_path != rec.file_path:
continue # 跳过冗余
cat = rec.suggested_category or "uncategorized"
new_name = rec.suggested_new_name or rec.stem
# 防止重名冲突
new_name = self._sanitize_filename(new_name)
ext = rec.extension
# 按项目/来源子目录(取相对路径的前两级)
rel_parts = Path(rec.relative_path).parts
project_dir = rel_parts[0] if len(rel_parts) > 1 else "_root"
dest_dir = self.dest_root / cat / project_dir
dest_path = dest_dir / f"{new_name}{ext}"
if not self.dry_run:
dest_dir.mkdir(parents=True, exist_ok=True)
shutil.copy2(rec.file_path, dest_path)
rec.suggested_new_name = str(dest_path)
def _build_index(self):
"""生成资产索引 JSON"""
entries = []
for rec in self.records:
entries.append({
"original_path": rec.file_path,
"organized_path": rec.suggested_new_name,
"file_name": rec.file_name,
"category": rec.suggested_category or "uncategorized",
"tags": rec.llm_labels,
"dimensions": f"{rec.width}x{rec.height}" if rec.width else None,
"color_space": rec.color_space,
"file_size_kb": round(rec.file_size_bytes / 1024, 1),
"modified_at": rec.modified_at,
"dedup_group": rec.dedup_group,
"md5": rec.md5_hash,
"phash": rec.phash,
})
index_path = self.dest_root / "_asset_index.json"
if not self.dry_run:
self.dest_root.mkdir(parents=True, exist_ok=True)
with open(index_path, "w", encoding="utf-8") as f:
json.dump(entries, f, ensure_ascii=False, indent=2)
self.index = {"count": len(entries), "path": str(index_path)}
def _get_group(self, gid: str) -> Optional[List[AssetRecord]]:
from collections import defaultdict
groups = defaultdict(list)
for r in self.records:
if r.dedup_group:
groups[r.dedup_group].append(r)
return groups.get(gid)
@staticmethod
def _sanitize_filename(name: str) -> str:
import re
name = re.sub(r'[\\/:*?"<>|]', '_', name)
name = re.sub(r'\s+', '_', name.strip())
return name[:120] # 限制长度
模块五:命令行入口
# cli.py
"""python cli.py scan ~/Downloads/design_assets --dest ~/organized_assets --dry-run"""
import argparse
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from asset_organizer.organizer import AssetOrganizerAgent
def main():
parser = argparse.ArgumentParser(description="Design Asset Organizer Agent")
parser.add_argument("source", help="Source directory containing scattered assets")
parser.add_argument("--dest", help="Destination directory for organized assets (default: source_organized)")
parser.add_argument("--dry-run", action="store_true", default=True,
help="Preview changes without moving files (default: True)")
parser.add_argument("--no-dry-run", action="store_false", dest="dry_run",
help="Actually move files")
parser.add_argument("--api-key", help="OpenAI API key (or set OPENAI_API_KEY env)")
parser.add_argument("--no-classify", action="store_true",
help="Skip LLM classification (only dedup + organize by extension)")
args = parser.parse_args()
api_key = args.api_key or os.getenv("OPENAI_API_KEY")
if not args.no_classify and not api_key:
print("⚠️ No API key set. Classification disabled. Use --api-key or set OPENAI_API_KEY")
args.no_classify = True
agent = AssetOrganizerAgent(
source_root=args.source,
dest_root=args.dest,
api_key=api_key if not args.no_classify else None,
dry_run=args.dry_run
)
agent.run()
if __name__ == "__main__":
main()
原理解释
核心特性
- 三级去重:MD5(精确)→ pHash(视觉近似)→ 模糊文件名(语义近似),覆盖 99% 重复场景。
- LLM 语义分类:超越简单的扩展名分组,能区分“这是 Logo 还是 Icon”、“是 Banner 还是 Screenshot”。
- 非破坏性:默认
dry_run=True,仅预览不动文件;实际执行时采用copy而非move,保留原始文件。 - 可审计索引:输出
_asset_index.json记录每个文件的原始位置→新位置→MD5→pHash,支持双向溯源。
原理流程图
扫描目录 (scanner.py)
│ 递归遍历 + 提取 MD5/pHash/尺寸
▼
去重引擎 (dedup.py)
│ MD5 精确去重 → pHash 近似去重 → 文件名模糊匹配
▼
LLM 分类 (classifier.py)
│ 文件名+路径+缩略图 → category / tags / suggested_name
▼
目录编排 (organizer.py)
│ category/project/type 层级 → 规范化命名
▼
索引生成 (_asset_index.json)
│ 原始路径 → 新路径 + 所有元数据
环境准备
pip install Pillow imagehash python-magic thefuzz langchain-openai
# macOS: brew install libmagic
# Linux: sudo apt-get install libmagic1
运行结果示例
$ python cli.py ~/Downloads/design_dump --dry-run
🔍 Scanning: /Users/me/Downloads/design_dump
Found 342 assets
🧹 Deduplicating...
Duplicate groups: 17
Redundant size: 124.58 MB
🏷️ Classifying with LLM...
Classified 42 assets (top 100)
📁 Building organized structure...
⚠️ DRY RUN — no files moved. Set dry_run=False to execute.
✅ Done! Organized index at: /Users/me/Downloads/design_dump_organized/_asset_index.json
生成的目录结构示例:
design_dump_organized/
├── banner/
│ └── summer_sale/
│ ├── hero_banner_v3.jpg
│ └── hero_banner_mobile.png
├── icon/
│ ├── app_icons/
│ │ └── notification_bell.svg
│ └── social_media/
│ └── wechat_logo.svg
├── screenshot/
│ ├── ios/
│ └── android/
├── logo/
│ ├── brand/
│ └── partner/
├── uncategorized/
└── _asset_index.json
测试步骤
# tests/test_scanner.py
import pytest
from pathlib import Path
from asset_organizer.scanner import AssetScanner
def test_scan_finds_files(tmp_path):
(tmp_path / "test.png").write_bytes(b"fake_png")
scanner = AssetScanner(str(tmp_path), compute_phash=False)
records = scanner.scan()
assert len(records) == 1
assert records[0].extension == ".png"
def test_md5_dedup():
from asset_organizer.dedup import DedupEngine
from asset_organizer.scanner import AssetRecord
r1 = AssetRecord(file_path="/a/logo.png", md5_hash="abc123")
r2 = AssetRecord(file_path="/b/logo_copy.png", md5_hash="abc123")
engine = DedupEngine([r1, r2])
groups = engine.run()
assert "md5:abc123" in groups
部署场景
| 场景 | 建议 |
|---|---|
| 本地运行 | CLI 工具,设计师手动触发 |
| CI/CD 门禁 | Git 提交前自动扫描 assets 目录,阻止重复文件入库 |
| NAS 定时任务 | crontab 每周执行,自动整理 NAS 上的设计共享文件夹 |
| 云函数 | 飞书/钉钉机器人收到文件后触发整理,结果写回 OSS |
疑难解答
Q1:pHash 比较慢怎么办?
A1:对大目录(>10000 文件)先用 MD5 精确去重,再对剩余文件做 pHash。pHash 比较用 BK-tree 可优化到 O(log n)。
Q2:LLM 分类不准?
A2:在 System Prompt 中注入更多上下文(如品牌名、常见文件命名模式);对特定类型(如 SVG)可先做规则分类(<svg> 标签检测),LLM 只做补充。
Q3:误删了文件?
A3:默认 dry_run=True,实际执行使用 copy 而非 move。可在 _asset_index.json 中找到原始路径恢复。
未来展望
- 向量检索:用 CLIP embedding 实现“找一张风格类似的图”的语义搜索。
- 增量扫描:记录上次扫描的 mtime,只处理新增/修改文件。
- 跨平台同步:对接飞书文档/CoDesign API,自动拉取远端资产到本地统一整理。
总结
设计资产自动整理 Agent 通过 文件指纹 + LLM 语义理解 解决了散落资产的归集难题。核心创新在于三级去重策略和 LLM 驱动的语义分类,使得整理结果不仅按扩展名分组,更能理解资产的实际用途(Logo vs Banner vs Screenshot)。本文提供的完整代码可在 10 分钟内处理千级文件目录,输出结构化的资产索引,显著降低设计团队的资产管理成本。
193

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



