Files
cadSet/tools/rank_sldprt_screenshots.py
T
2026-07-27 16:00:36 +08:00

419 lines
15 KiB
Python

#!/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())