From 889517884473fc3f721ddc845c0295cb1e8d98f9 Mon Sep 17 00:00:00 2001 From: Jerry <99706807@qq.com> Date: Mon, 27 Jul 2026 17:31:21 +0800 Subject: [PATCH] docs: refresh project architecture and repository rules --- .gitattributes | 4 + .gitignore | 29 ++- README.md | 207 +++++++++++++-- cad-agent-studio/README.md | 5 +- tools/rank_sldprt_screenshots.py | 418 ------------------------------- 5 files changed, 211 insertions(+), 452 deletions(-) delete mode 100644 tools/rank_sldprt_screenshots.py diff --git a/.gitattributes b/.gitattributes index e5149c9..c958ed0 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,6 @@ +# Normalize source and configuration files across operating systems. +* text=auto eol=lf + +# STEP is an exchange artifact, not reviewable line-oriented source. *.step binary *.stp binary diff --git a/.gitignore b/.gitignore index 0d4ebdf..b913a43 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,9 @@ -# Operating-system files +# Operating-system and editor metadata .DS_Store +**/.idea/ +**/.vscode/ +*.swp +*.tmp # Python environments and caches **/.venv/ @@ -9,28 +13,23 @@ **/.mypy_cache/ *.py[cod] -# JavaScript dependencies and generated caches +# JavaScript dependencies and generated builds **/node_modules/ **/.next/ **/.cache/ -models/ +**/dist/ -# Undownloaded upstream Git LFS demo/catalog pointers +# Local frontend runtime data +cad-agent-studio/config/llm.config.yaml +cad-agent-studio/data/ + +# Generated and upstream-heavy CAD assets +/models/ text-to-cad/assets/ text-to-cad/benchmarks/*.gif text-to-cad/models/ -# Local editors and temporary files -**/.idea/ -**/.vscode/ -*.swp -*.tmp - -# Local frontend configuration and task artifacts -cad-agent-studio/config/llm.config.yaml -cad-agent-studio/data/ - -# Protected DesignIR runtime data +# DesignIR teacher data and runtime artifacts designir-pipeline/workspace/teacher/inbox/* !designir-pipeline/workspace/teacher/inbox/.gitkeep designir-pipeline/workspace/teacher/evidence/* diff --git a/README.md b/README.md index fd345d5..1cd7062 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,197 @@ # CadSet -CAD Router provides three STEP-centered workflows: +CadSet 是一个面向机械零件的 STEP-first 参数化 CAD 系统。它覆盖自然语言与图文生成、上传 STEP 的独立参数化重建、模型修改、几何验收,以及从批量 STEP 案例中蒸馏可复用建模经验。 -- Generate editable CAD from natural-language requests. -- Generate or modify editable CAD from text plus reference images. -- Reconstruct uploaded STEP files into editable DesignIR 2.0 and modify them. +系统的统一设计源是自有的 **DesignIR 2.0**。STEP 是主要交换与验收格式,但上传的源 STEP 只作为教师证据和验收真值,不作为重建时的几何依赖。 -For every request, CAD Router selects either text-to-cad or SimpleCADAPI as -the execution backend. The persistent contract is DesignIR 2.0 plus STEP. +## 产品范围 -## Project layout +当前主线只包含三类工作流: -- `cad-agent-studio/`: standalone web frontend. -- `designir-pipeline/`: role-separated reconstruction, acceptance, and - experience-promotion system. -- `text-to-cad/`: CAD Router, Build123d generation skill, Viewer, and shared - CAD runtime. -- `SimpleCADAPI/`: the second CAD execution backend. -- `llm.config.yaml`: private, Git-ignored provider configuration shared by the - standalone frontend. +1. 根据自然语言生成可编辑 CAD。 +2. 根据文字和参考图片生成或修改可编辑 CAD。 +3. 将上传的 STEP 重建为 DesignIR,独立生成新的 STEP,并继续参数化修改。 -The uploaded teacher STEP is evidence only. A reconstruction must compile from -DesignIR after the teacher file has been removed from the build environment. +CAD Router 会在两个执行后端之间选择: + +- **text-to-cad / Build123d**:通用机械零件、特征建模、几何验证和 STEP 输出。 +- **SimpleCADAPI**:可重放操作图、语义化机械结构和专用机械零件能力。 + +## 系统架构 + +```text + ┌────────────────────┐ + Text / Image / STEP ──> │ CAD Agent Studio │ + └─────────┬──────────┘ + │ + ┌─────────▼──────────┐ + │ CAD Router │ + └──────┬───────┬─────┘ + │ │ + ┌───────────▼─┐ ┌─▼──────────────┐ + │ Build123d │ │ SimpleCADAPI │ + └───────────┬─┘ └─┬──────────────┘ + └────┬────┘ + │ + DesignIR 2.0 + STEP + │ + ┌───────────▼───────────┐ + │ Viewer / Acceptance │ + └───────────────────────┘ +``` + +上传 STEP 的重建链路使用严格的教师隔离: + +```text +Teacher STEP + -> private geometry evidence + -> Reconstruction Agent + -> DesignIR 2.0 + -> isolated compiler without Teacher STEP access + -> rebuilt STEP + -> independent Acceptance Agent + -> deterministic experience promotion +``` + +## 仓库结构 + +| 路径 | 职责 | +| --- | --- | +| [`cad-agent-studio/`](cad-agent-studio/README.md) | 独立 Next.js 前端,负责对话、图片与 STEP 上传、任务管理和模型预览。 | +| [`designir-pipeline/`](designir-pipeline/README.md) | DesignIR Schema、双 Agent、隔离编译、验收和经验晋升。 | +| [`text-to-cad/`](text-to-cad/README.md) | CAD Router、Build123d CAD Skill、CAD Viewer 和共享 CAD 运行时。 | +| [`SimpleCADAPI/`](SimpleCADAPI/README.md) | 第二建模后端,提供可重放操作图和机械建模 API。 | +| [`llm.config.yaml`](llm.config.yaml) | 前端共享的模型供应商、模型和密钥配置。 | + +依赖目录、虚拟环境、构建缓存和批量运行数据不会进入 Git。锁文件、源码、Schema、Agent 定义和确定性测试会进入版本控制。 + +## DesignIR 2.0 + +DesignIR 保存设计意图,而不是复制 STEP 的 B-Rep 或三角网格。主要结构包括: + +- coordinate systems 与 datums +- editable parameters 与 expressions +- sketches 与 constraints +- ordered features、patterns 与 attachments +- construction stages +- edit interface +- validation and perturbation contracts + +Schema 位于: + +[`designir-pipeline/contracts/designir-2.0.schema.json`](designir-pipeline/contracts/designir-2.0.schema.json) + +当前两个后端共同支持的首批操作包括: + +- `extrude_circle` +- `extrude_rectangle` +- `add_cylinder` +- `through_hole` +- `polar_hole_pattern` + +不支持的几何必须显式进入 quarantine,并记录缺失能力;系统禁止使用源 STEP、嵌入 B-Rep、完整网格替身或源拓扑引用绕过重建。 + +## STEP 蒸馏与验收 + +将待处理的 STEP/STP 文件放入: + +```text +designir-pipeline/workspace/teacher/inbox/ +``` + +完整运行目录: + +| 阶段 | 路径 | +| --- | --- | +| 教师 STEP 输入 | `workspace/teacher/inbox/` | +| 私有几何证据 | `workspace/teacher/evidence/` | +| Reconstruction Agent 输入 | `workspace/reconstruction/inbox/` | +| 参数化重建结果 | `workspace/reconstruction/designir/` | +| 独立重建 STEP | `workspace/reconstruction/output/` | +| Acceptance Agent 输入 | `workspace/acceptance/inbox/` | +| 验收报告 | `workspace/acceptance/reports/` | +| 经验候选 | `workspace/promotion/candidates/` | +| 跨案例回放验证 | `workspace/promotion/replay_validated/` | +| 正式经验库 | `workspace/promotion/promoted/library.json` | +| 失败与能力缺口 | `workspace/quarantine/` | + +详细说明见 [`designir-pipeline/workspace/README.md`](designir-pipeline/workspace/README.md)。 + +批量提取教师证据: + +```bash +designir-pipeline/scripts/cad-experience extract-folder \ + --input designir-pipeline/workspace/teacher/inbox \ + --output designir-pipeline/workspace/teacher/evidence +``` + +蒸馏得到的经验必须同时通过几何一致性、特征语义、参数可编辑性和修改稳定性验收。两个 Agent 都无权直接发布经验,最终晋升由确定性策略执行。 + +## 本地开发 + +### 环境要求 + +- Node.js 22+ +- Python 3.12 +- OpenCascade/Build123d 所需的本地运行依赖 + +### 启动前端 + +```bash +cd cad-agent-studio +npm install +npm run dev -- -H 127.0.0.1 -p 53821 +``` + +打开 `http://127.0.0.1:53821`。 + +前端默认读取仓库根目录的 `llm.config.yaml`。也可以通过 `CAD_AGENT_STUDIO_CONFIG` 指向其他配置文件。 + +### DesignIR 命令 + +```bash +text-to-cad/.venv/bin/python \ + designir-pipeline/scripts/designir_pipeline.py validate \ + designir-pipeline/examples/raised_hub_flange.designir.json + +text-to-cad/.venv/bin/python \ + designir-pipeline/scripts/designir_pipeline.py isolated-rebuild \ + designir-pipeline/examples/raised_hub_flange.designir.json \ + --output /tmp/raised_hub_flange.step \ + --backend build123d +``` + +`simplecadapi` 后端还会在 STEP 旁生成可重放的 `*.simplecad.model.json`。 + +## 验证 + +前端: + +```bash +cd cad-agent-studio +npm test +npm run build +``` + +DesignIR、经验库与 CAD Router: + +```bash +text-to-cad/.venv/bin/python -m unittest \ + designir-pipeline/tests/test_designir_pipeline.py \ + designir-pipeline/tests/test_experience_library.py \ + text-to-cad/tests/python/skills/cad-router/test_route.py +``` + +经验库审计: + +```bash +text-to-cad/.venv/bin/python \ + designir-pipeline/skills/cad-experience-builder/scripts/cad_experience.py \ + audit designir-pipeline/workspace/promotion/promoted/library.json +``` + +## 当前边界 + +DesignIR 当前优先覆盖规则机械零件,例如法兰、板件、支架、轴套、带孔块和基础壳体。自由曲面、多轨扫掠、复杂铸造过渡和高阶连续曲面仍需要扩展特征词汇。 + +工作区已经具备批量证据提取、角色隔离、独立重建、验收和经验晋升能力;批处理目前由命令或 Agent 任务触发,尚未提供常驻目录监听服务。 diff --git a/cad-agent-studio/README.md b/cad-agent-studio/README.md index 21c0aa7..7b1e71e 100644 --- a/cad-agent-studio/README.md +++ b/cad-agent-studio/README.md @@ -8,8 +8,9 @@ CAD Agent Studio is a two-pane web app for agent-driven CAD work: ## Setup The app first reads the private shared configuration at `../llm.config.yaml`. -That file is Git-ignored and may use a direct `apiKey` or an environment name -in `apiKeyEnv`. It falls back to the app-local example: +The repository currently versions that configuration for the private deployment. +It may use a direct `apiKey` or an environment name in `apiKeyEnv`, and falls +back to the app-local example: ```bash cp config/llm.config.yaml.example ../llm.config.yaml diff --git a/tools/rank_sldprt_screenshots.py b/tools/rank_sldprt_screenshots.py deleted file mode 100644 index e5fbc83..0000000 --- a/tools/rank_sldprt_screenshots.py +++ /dev/null @@ -1,418 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import argparse -import csv -import json -import math -from dataclasses import asdict, dataclass -from pathlib import Path - -import numpy as np -from PIL import Image, ImageDraw -from skimage import color, feature, measure, morphology, transform - - -IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp"} - - -@dataclass -class ImageFeatures: - file: str - stem: str - width: int - height: int - foreground_fraction: float - component_count: int - largest_component_fraction: float - hole_count: int - weighted_compactness: float - edge_density: float - color_bins: int - circular_signal: float - rectilinear_signal: float - thin_profile_signal: float - symmetry_signal: float - gear_signal: float - standard_signal: float - complexity_penalty: float - restorability_score: float - recommended_backend: str - confidence: str - reasons: str - - -def clamp(value: float, lo: float = 0.0, hi: float = 1.0) -> float: - return max(lo, min(hi, value)) - - -def percentile_score(value: float, p10: float, p90: float, invert: bool = False) -> float: - if p90 <= p10: - score = 0.5 - else: - score = clamp((value - p10) / (p90 - p10)) - return 1.0 - score if invert else score - - -def list_images(image_dir: Path) -> list[Path]: - return sorted( - p for p in image_dir.iterdir() - if p.is_file() and p.suffix.lower() in IMAGE_EXTS - ) - - -def load_small_image(path: Path, max_width: int) -> tuple[np.ndarray, tuple[int, int]]: - image = Image.open(path).convert("RGB") - original_size = image.size - if image.width > max_width: - height = round(image.height * max_width / image.width) - image = image.resize((max_width, height), Image.Resampling.LANCZOS) - return np.asarray(image, dtype=np.uint8), original_size - - -def foreground_mask(rgb: np.ndarray) -> np.ndarray: - arr = rgb.astype(np.float32) / 255.0 - hsv = color.rgb2hsv(arr) - value = hsv[..., 2] - saturation = hsv[..., 1] - - # SOLIDWORKS screenshots have pale gradient backgrounds and many light-gray - # parts, so include medium-light gray entity pixels as well as colored parts. - mask = (value < 0.88) | ((saturation > 0.09) & (value < 0.99)) - mask = morphology.closing(mask, morphology.disk(2)) - mask = morphology.remove_small_objects(mask, max_size=max(32, mask.size // 9000)) - mask = morphology.remove_small_holes(mask, max_size=max(32, mask.size // 14000)) - return mask - - -def crop_component(mask: np.ndarray, label_image: np.ndarray, label_id: int) -> np.ndarray: - ys, xs = np.where(label_image == label_id) - if len(xs) == 0: - return np.zeros((1, 1), dtype=bool) - pad = 4 - y0 = max(int(ys.min()) - pad, 0) - y1 = min(int(ys.max()) + pad + 1, mask.shape[0]) - x0 = max(int(xs.min()) - pad, 0) - x1 = min(int(xs.max()) + pad + 1, mask.shape[1]) - return label_image[y0:y1, x0:x1] == label_id - - -def symmetry_for(component: np.ndarray) -> float: - if component.size <= 1: - return 0.0 - target = transform.resize( - component.astype(float), - (64, 64), - order=0, - preserve_range=True, - anti_aliasing=False, - ) > 0.5 - area = target.sum() - if area == 0: - return 0.0 - horiz = np.logical_and(target, np.fliplr(target)).sum() / np.logical_or(target, np.fliplr(target)).sum() - vert = np.logical_and(target, np.flipud(target)).sum() / np.logical_or(target, np.flipud(target)).sum() - return float(max(horiz, vert)) - - -def raw_features(path: Path, max_width: int) -> dict[str, float | int | str]: - rgb, (width, height) = load_small_image(path, max_width=max_width) - gray = color.rgb2gray(rgb) - mask = foreground_mask(rgb) - label_image = measure.label(mask) - props = [ - prop for prop in measure.regionprops(label_image) - if prop.area >= max(48, mask.size // 4500) - ] - props.sort(key=lambda item: item.area, reverse=True) - - foreground_fraction = float(mask.mean()) - component_count = len(props) - largest = props[0] if props else None - largest_component_fraction = float((largest.area / mask.size) if largest else 0.0) - total_area = sum(prop.area for prop in props) or 1 - - compactness_values = [] - holes = 0 - circular_signal = 0.0 - rectilinear_signal = 0.0 - thin_profile_signal = 0.0 - gear_signal = 0.0 - symmetry_signal = 0.0 - - for index, prop in enumerate(props[:16]): - area = max(float(prop.area), 1.0) - perimeter = max(float(prop.perimeter), 1.0) - compactness = perimeter * perimeter / (4.0 * math.pi * area) - compactness_values.append(compactness * (area / total_area)) - component_holes = max(0, 1 - int(prop.euler_number)) - holes += component_holes - - minr, minc, maxr, maxc = prop.bbox - box_h = maxr - minr - box_w = maxc - minc - box_area = max(float(box_h * box_w), 1.0) - aspect = max(box_w / max(box_h, 1), box_h / max(box_w, 1)) - extent = prop.area / box_area - solidity = float(getattr(prop, "solidity", 0.0)) - eccentricity = float(getattr(prop, "eccentricity", 1.0)) - - circular = clamp((1.0 - eccentricity) * 1.7) * clamp(solidity * 1.25) * clamp(2.2 - compactness / 2.1) - rect = clamp((extent - 0.35) / 0.45) * clamp(solidity * 1.15) - thin = clamp((aspect - 3.0) / 8.0) * clamp(solidity * 1.2) - - weight = area / total_area - circular_signal = max(circular_signal, circular) - rectilinear_signal = max(rectilinear_signal, rect) - thin_profile_signal = max(thin_profile_signal, thin) - - if index < 4: - comp = crop_component(mask, label_image, prop.label) - symmetry_signal = max(symmetry_signal, symmetry_for(comp) * clamp(weight * 4.0)) - - teeth_like = clamp((compactness - 2.2) / 7.5) - gear_signal = max( - gear_signal, - clamp((1.0 - eccentricity) * 2.0) * teeth_like * clamp((component_holes + 1) / 3.0), - ) - - weighted_compactness = float(sum(compactness_values)) - edges = feature.canny(gray, sigma=1.1) - edge_density = float(edges.mean()) - - object_pixels = rgb[mask] - if len(object_pixels) == 0: - color_bins = 0 - else: - quantized = (object_pixels // 32).astype(np.uint8) - color_bins = int(len(np.unique(quantized.reshape(-1, 3), axis=0))) - - return { - "file": str(path), - "stem": path.stem, - "width": width, - "height": height, - "foreground_fraction": foreground_fraction, - "component_count": component_count, - "largest_component_fraction": largest_component_fraction, - "hole_count": holes, - "weighted_compactness": weighted_compactness, - "edge_density": edge_density, - "color_bins": color_bins, - "circular_signal": float(circular_signal), - "rectilinear_signal": float(rectilinear_signal), - "thin_profile_signal": float(thin_profile_signal), - "symmetry_signal": float(symmetry_signal), - "gear_signal": float(gear_signal), - } - - -def score_rows(rows: list[dict[str, float | int | str]]) -> list[ImageFeatures]: - compactness = np.asarray([float(row["weighted_compactness"]) for row in rows]) - edge_density = np.asarray([float(row["edge_density"]) for row in rows]) - components = np.asarray([float(row["component_count"]) for row in rows]) - color_bins = np.asarray([float(row["color_bins"]) for row in rows]) - holes = np.asarray([float(row["hole_count"]) for row in rows]) - - p = { - "compactness": np.percentile(compactness, [10, 90]), - "edge": np.percentile(edge_density, [10, 90]), - "components": np.percentile(components, [10, 90]), - "colors": np.percentile(color_bins, [10, 90]), - "holes": np.percentile(holes, [50, 95]), - } - - scored: list[ImageFeatures] = [] - for row in rows: - comp_score = percentile_score(float(row["weighted_compactness"]), *p["compactness"], invert=True) - edge_score = percentile_score(float(row["edge_density"]), *p["edge"], invert=True) - component_score = percentile_score(float(row["component_count"]), *p["components"], invert=True) - color_score = percentile_score(float(row["color_bins"]), *p["colors"], invert=True) - many_hole_penalty = percentile_score(float(row["hole_count"]), *p["holes"]) - - circular = float(row["circular_signal"]) - rect = float(row["rectilinear_signal"]) - thin = float(row["thin_profile_signal"]) - symmetry = float(row["symmetry_signal"]) - gear = float(row["gear_signal"]) - holes_good = 1.0 if 1 <= int(row["hole_count"]) <= 12 else 0.35 if int(row["hole_count"]) == 0 else 0.0 - standard = max(circular, rect, thin, gear) - - complexity_penalty = ( - 0.34 * (1.0 - comp_score) - + 0.28 * (1.0 - edge_score) - + 0.25 * (1.0 - component_score) - + 0.13 * (1.0 - color_score) - ) - - special_family = ( - gear >= 0.80 - and circular >= 0.40 - and float(row["edge_density"]) >= 0.048 - ) - - score = ( - 25.0 - + 16.0 * comp_score - + 12.0 * edge_score - + 8.0 * component_score - + 4.0 * color_score - + 12.0 * standard - + 5.0 * symmetry - + 4.0 * holes_good - + (7.0 * gear if special_family else 0.0) - - 10.0 * many_hole_penalty - ) - score = max(0.0, min(100.0, score)) - - if special_family: - backend = "simplecadapi" - elif score >= 72.0: - backend = "build123d" - elif score >= 58.0: - backend = "build123d_or_manual_review" - else: - backend = "manual_review_before_modeling" - - if score >= 82: - confidence = "high" - elif score >= 68: - confidence = "medium" - else: - confidence = "low" - - reasons = [] - if special_family: - reasons.append("gear/ring/rack-like standard mechanical family") - if circular >= 0.45: - reasons.append("circular or coaxial feature signal") - if rect >= 0.55: - reasons.append("simple plate/block extrusion signal") - if thin >= 0.45: - reasons.append("shaft/rail/thin extrusion signal") - if symmetry >= 0.55: - reasons.append("strong silhouette symmetry") - if holes_good >= 1.0: - reasons.append("moderate visible hole count") - if complexity_penalty >= 0.55: - reasons.append("penalized for dense edges/components/colors") - if not reasons: - reasons.append("generic low-complexity B-Rep signal") - - scored.append( - ImageFeatures( - restorability_score=round(score, 3), - recommended_backend=backend, - confidence=confidence, - standard_signal=round(standard, 4), - complexity_penalty=round(complexity_penalty, 4), - reasons="; ".join(reasons), - **{ - key: row[key] for key in ( - "file", - "stem", - "width", - "height", - "foreground_fraction", - "component_count", - "largest_component_fraction", - "hole_count", - "weighted_compactness", - "edge_density", - "color_bins", - "circular_signal", - "rectilinear_signal", - "thin_profile_signal", - "symmetry_signal", - "gear_signal", - ) - }, - ) - ) - return sorted(scored, key=lambda item: item.restorability_score, reverse=True) - - -def write_csv(rows: list[ImageFeatures], path: Path) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - fields = list(asdict(rows[0]).keys()) if rows else [] - with path.open("w", newline="", encoding="utf-8") as handle: - writer = csv.DictWriter(handle, fieldnames=fields) - writer.writeheader() - for row in rows: - writer.writerow(asdict(row)) - - -def write_json(rows: list[ImageFeatures], path: Path) -> None: - path.write_text( - json.dumps([asdict(row) for row in rows], ensure_ascii=False, indent=2), - encoding="utf-8", - ) - - -def make_contact_sheet(rows: list[ImageFeatures], output: Path, limit: int, title: str) -> None: - selected = rows[:limit] - if not selected: - return - thumb_w, thumb_h = 220, 316 - label_h = 42 - cols = 8 - rows_count = math.ceil(len(selected) / cols) - sheet = Image.new("RGB", (cols * thumb_w, rows_count * (thumb_h + label_h) + 28), "white") - draw = ImageDraw.Draw(sheet) - draw.text((8, 8), title, fill=(0, 0, 0)) - y_offset = 28 - for index, item in enumerate(selected): - path = Path(item.file) - image = Image.open(path).convert("RGB") - image.thumbnail((thumb_w, thumb_h), Image.Resampling.LANCZOS) - col = index % cols - row = index // cols - x = col * thumb_w + (thumb_w - image.width) // 2 - y = y_offset + row * (thumb_h + label_h) - sheet.paste(image, (x, y)) - label = f"{index + 1:02d} {item.stem} {item.restorability_score:.1f}" - draw.text((col * thumb_w + 4, y + thumb_h + 3), label, fill=(0, 0, 0)) - draw.text((col * thumb_w + 4, y + thumb_h + 20), item.recommended_backend[:28], fill=(70, 70, 70)) - output.parent.mkdir(parents=True, exist_ok=True) - sheet.save(output, quality=90) - - -def main() -> int: - parser = argparse.ArgumentParser(description="Rank SOLIDWORKS screenshot restorability for cad-router backends.") - parser.add_argument("image_dir", type=Path) - parser.add_argument("--out-dir", type=Path, default=Path("models/sldprt_screen_rank")) - parser.add_argument("--max-width", type=int, default=420) - parser.add_argument("--contact-limit", type=int, default=80) - parser.add_argument("--skip-contact", action="store_true", help="Only write CSV/JSON rankings.") - args = parser.parse_args() - - paths = list_images(args.image_dir) - if not paths: - raise SystemExit(f"No images found in {args.image_dir}") - - raw = [] - for index, path in enumerate(paths, start=1): - raw.append(raw_features(path, max_width=args.max_width)) - if index % 100 == 0: - print(f"processed={index}/{len(paths)}", flush=True) - ranked = score_rows(raw) - - args.out_dir.mkdir(parents=True, exist_ok=True) - write_csv(ranked, args.out_dir / "ranking.csv") - write_json(ranked, args.out_dir / "ranking.json") - if not args.skip_contact: - make_contact_sheet(ranked, args.out_dir / "top_80_contact_sheet.jpg", args.contact_limit, "Top restorability candidates") - make_contact_sheet(list(reversed(ranked)), args.out_dir / "bottom_40_contact_sheet.jpg", 40, "Lowest restorability candidates") - - print(f"images={len(ranked)}") - print(f"csv={args.out_dir / 'ranking.csv'}") - print(f"json={args.out_dir / 'ranking.json'}") - print(f"top_sheet={args.out_dir / 'top_80_contact_sheet.jpg'}") - print("top10=") - for item in ranked[:10]: - print(f"{item.stem},{item.restorability_score:.1f},{item.recommended_backend},{item.reasons}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main())