Feature Complete Shipping V1.0

This commit is contained in:
2026-08-12 22:57:52 -04:00
parent 19e4ac0cce
commit 56b6a46811
30 changed files with 9934 additions and 9361 deletions
+209 -21
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import argparse
import colorsys
import json
import math
import os
@@ -17,7 +18,7 @@ from functools import lru_cache
from pathlib import Path
from typing import Iterable, Sequence, TextIO
from PIL import Image, UnidentifiedImageError
from PIL import Image, ImageChops, UnidentifiedImageError
DEFAULT_WORKSHOP_ROOT = Path(r"D:\SteamLibrary\steamapps\workshop\content\108600")
@@ -55,6 +56,14 @@ MAX_SKINS_PER_VEHICLE = 2_000
MAX_WARNINGS = 20_000
MAX_METADATA_CHARS = 2_048
MAX_MANIFEST_BYTES = 64 * 1024 * 1024
MAX_SCAN_DECODED_PIXELS = 3_000_000_000
MAX_VARIANT_MASK_PIXELS = 268_435_456
MAX_VARIANT_MASK_SKINS = 64
CHROMATIC_SATURATION_MIN = 0.10
CHROMATIC_RGB_RANGE_MIN = 8 / 255
MAX_CHROMATIC_HUE_DISTANCE = 0.16
VARIANT_RGB_RANGE_MIN = 16
MIN_VARIANT_MASK_COVERAGE = 0.20
class ScanFileError(ValueError):
@@ -607,11 +616,46 @@ def _nearest_paint(
red: int,
green: int,
blue: int,
palette_labs: tuple[tuple[PaintCan, tuple[float, float, float]], ...],
palette_colors: tuple[
tuple[PaintCan, tuple[float, float, float], tuple[float, float, float]], ...
],
preserve_chromatic_hue: bool,
) -> PaintCan:
pixel_lab = _srgb_to_lab((red / 255, green / 255, blue / 255))
pixel_rgb = red / 255, green / 255, blue / 255
pixel_hsv = colorsys.rgb_to_hsv(*pixel_rgb)
if (
preserve_chromatic_hue
and pixel_hsv[1] >= CHROMATIC_SATURATION_MIN
and max(pixel_rgb) - min(pixel_rgb) >= CHROMATIC_RGB_RANGE_MIN
):
chromatic = [
entry for entry in palette_colors if entry[2][1] >= CHROMATIC_SATURATION_MIN
]
if chromatic:
def chromatic_distance(
entry: tuple[
PaintCan,
tuple[float, float, float],
tuple[float, float, float],
],
) -> tuple[float, str]:
hue_distance = abs(pixel_hsv[0] - entry[2][0])
hue_distance = min(hue_distance, 1 - hue_distance)
return (
(hue_distance * 3) ** 2
+ (pixel_hsv[1] - entry[2][1]) ** 2
+ 0.02 * (pixel_hsv[2] - entry[2][2]) ** 2,
entry[0].item_id,
)
nearest_chromatic = min(chromatic, key=chromatic_distance)
hue_distance = abs(pixel_hsv[0] - nearest_chromatic[2][0])
if min(hue_distance, 1 - hue_distance) <= MAX_CHROMATIC_HUE_DISTANCE:
return nearest_chromatic[0]
pixel_lab = _srgb_to_lab(pixel_rgb)
return min(
palette_labs,
palette_colors,
key=lambda entry: (
sum((pixel_lab[index] - entry[1][index]) ** 2 for index in range(3)),
entry[0].item_id,
@@ -619,26 +663,116 @@ def _nearest_paint(
)[0]
def _read_rgba_png(texture_path: Path) -> Image.Image:
try:
with warnings.catch_warnings():
warnings.simplefilter("error", Image.DecompressionBombWarning)
image = Image.open(texture_path, formats=("PNG",))
with image:
if image.format != "PNG":
raise UnidentifiedImageError(f"Not a PNG image: {texture_path}")
width, height = image.size
if width <= 0 or height <= 0 or width * height > MAX_IMAGE_PIXELS:
raise ScanFileError(
f"Image exceeds the {MAX_IMAGE_PIXELS}-pixel safety limit: {texture_path}"
)
return image.convert("RGBA")
except ValueError as error:
raise ScanFileError(f"Could not decode PNG {texture_path}: {error}") from error
def _png_pixel_count(texture_path: Path) -> int:
try:
with warnings.catch_warnings():
warnings.simplefilter("error", Image.DecompressionBombWarning)
with Image.open(texture_path, formats=("PNG",)) as image:
if image.format != "PNG":
raise UnidentifiedImageError(f"Not a PNG image: {texture_path}")
width, height = image.size
except ValueError as error:
raise ScanFileError(f"Could not decode PNG {texture_path}: {error}") from error
pixels = width * height
if width <= 0 or height <= 0 or pixels > MAX_IMAGE_PIXELS:
raise ScanFileError(
f"Image exceeds the {MAX_IMAGE_PIXELS}-pixel safety limit: {texture_path}"
)
return pixels
def _add_decoded_pixels(current: int, additional: int) -> int:
total = current + additional
if additional < 0 or total > MAX_SCAN_DECODED_PIXELS:
raise ScanFileError(
f"Scan exceeds the {MAX_SCAN_DECODED_PIXELS}-decoded-pixel safety limit"
)
return total
def build_variant_mask(texture_paths: Sequence[Path]) -> bytes | None:
"""Select shell pixels whose RGB changes between sibling skin textures."""
unique_paths = tuple(dict.fromkeys(texture_paths))
if len(unique_paths) < 2 or len(unique_paths) > MAX_VARIANT_MASK_SKINS:
return None
pixel_counts = tuple(_png_pixel_count(path) for path in unique_paths)
if sum(pixel_counts) > MAX_VARIANT_MASK_PIXELS:
return None
first = _read_rgba_png(unique_paths[0])
size = first.size
darkest = first.convert("RGB")
lightest = darkest.copy()
shared_opacity = first.getchannel("A")
first.close()
for path in unique_paths[1:]:
image = _read_rgba_png(path)
if image.size != size:
image.close()
return None
rgb = image.convert("RGB")
darkest = ImageChops.darker(darkest, rgb)
lightest = ImageChops.lighter(lightest, rgb)
shared_opacity = ImageChops.darker(shared_opacity, image.getchannel("A"))
image.close()
shared_alpha = shared_opacity.tobytes()
if not any(shared_alpha):
return None
difference = ImageChops.difference(lightest, darkest)
channels = difference.split()
maximum_difference = ImageChops.lighter(ImageChops.lighter(channels[0], channels[1]), channels[2])
mask = maximum_difference.point(
lambda value: 255 if value >= VARIANT_RGB_RANGE_MIN else 0
)
visible_in_every_skin = shared_opacity.point(lambda value: 255 if value else 0)
mask = ImageChops.multiply(mask, visible_in_every_skin)
mask_bytes = mask.tobytes()
visible_pixels = visible_in_every_skin.tobytes().count(255)
masked_pixels = mask_bytes.count(255)
if not visible_pixels or masked_pixels / visible_pixels < MIN_VARIANT_MASK_COVERAGE:
return None
return mask_bytes
def analyze_colors(
texture_path: Path,
paint_cans: Sequence[PaintCan],
*,
pixel_mask: bytes | None = None,
preserve_chromatic_hue: bool = False,
) -> list[PaintPercentage]:
"""Map non-transparent PNG pixels to their nearest available B42 paint cans."""
if not paint_cans:
raise ValueError("The B42 paint palette is empty")
palette_labs = tuple((paint, _srgb_to_lab(paint.rgb)) for paint in paint_cans)
with warnings.catch_warnings():
warnings.simplefilter("error", Image.DecompressionBombWarning)
image = Image.open(texture_path, formats=("PNG",))
with image:
if image.format != "PNG":
raise UnidentifiedImageError(f"Not a PNG image: {texture_path}")
width, height = image.size
if width <= 0 or height <= 0 or width * height > MAX_IMAGE_PIXELS:
raise ScanFileError(
f"Image exceeds the {MAX_IMAGE_PIXELS}-pixel safety limit: {texture_path}"
)
rgba = image.convert("RGBA")
palette_colors = tuple(
(paint, _srgb_to_lab(paint.rgb), colorsys.rgb_to_hsv(*paint.rgb))
for paint in paint_cans
)
rgba = _read_rgba_png(texture_path)
with rgba:
width, height = rgba.size
if pixel_mask is not None:
if len(pixel_mask) != width * height:
raise ValueError("Variant mask dimensions do not match the texture")
mask = Image.frombytes("L", (width, height), pixel_mask)
rgba.putalpha(ImageChops.multiply(rgba.getchannel("A"), mask))
counts: Counter[PaintCan] = Counter()
rgba_counts = rgba.getcolors(maxcolors=MAX_UNIQUE_COLORS)
if rgba_counts is None:
@@ -648,7 +782,13 @@ def analyze_colors(
for pixel_count, (red, green, blue, opacity) in rgba_counts:
if not opacity:
continue
paint = _nearest_paint(red, green, blue, palette_labs)
paint = _nearest_paint(
red,
green,
blue,
palette_colors,
preserve_chromatic_hue,
)
counts[paint] += pixel_count * opacity
total = sum(counts.values())
if total == 0:
@@ -675,11 +815,13 @@ def scan_workshop(workshop_root: Path, paint_cans: Sequence[PaintCan]) -> ScanRe
warnings: list[str] = []
cars_found = 0
paint_cache: dict[Path, tuple[PaintPercentage, ...]] = {}
decoded_pixels = 0
for mod in mods:
scripts_root = mod.content_root / "media" / "scripts" / "vehicles"
vehicles = parse_vehicle_scripts(scripts_root)
cars_found += len(vehicles)
for vehicle in vehicles:
resolved_skins: list[tuple[SkinDefinition, Path, int]] = []
for skin in vehicle.skins:
reference = skin.texture_reference
texture_path = resolve_texture(mod, reference)
@@ -689,10 +831,56 @@ def scan_workshop(workshop_root: Path, paint_cans: Sequence[PaintCan]) -> ScanRe
warnings.append(f"Missing texture for {vehicle.vehicle_id}: {reference}")
continue
try:
paints = paint_cache.get(texture_path)
pixel_count = _png_pixel_count(texture_path)
decoded_pixels = _add_decoded_pixels(decoded_pixels, pixel_count)
except (
OSError,
ScanFileError,
UnidentifiedImageError,
Image.DecompressionBombError,
Image.DecompressionBombWarning,
) as error:
if len(warnings) >= MAX_WARNINGS:
raise ValueError(f"Scan exceeded the {MAX_WARNINGS}-warning safety limit")
warnings.append(f"Could not read {texture_path}: {error}")
continue
resolved_skins.append((skin, texture_path, pixel_count))
variant_mask = None
try:
mask_pixels = sum(pixel_count for _skin, _path, pixel_count in resolved_skins)
if (
len(resolved_skins) <= MAX_VARIANT_MASK_SKINS
and mask_pixels <= MAX_VARIANT_MASK_PIXELS
):
decoded_pixels = _add_decoded_pixels(decoded_pixels, mask_pixels)
variant_mask = build_variant_mask(
tuple(texture_path for _skin, texture_path, _pixels in resolved_skins)
)
except (
OSError,
ScanFileError,
UnidentifiedImageError,
Image.DecompressionBombError,
Image.DecompressionBombWarning,
):
pass
vehicle_paint_cache: dict[Path, tuple[PaintPercentage, ...]] = {}
for skin, texture_path, _pixel_count in resolved_skins:
reference = skin.texture_reference
try:
cache = vehicle_paint_cache if variant_mask is not None else paint_cache
paints = cache.get(texture_path)
if paints is None:
paints = tuple(analyze_colors(texture_path, paint_cans))
paint_cache[texture_path] = paints
paints = tuple(
analyze_colors(
texture_path,
paint_cans,
pixel_mask=variant_mask,
preserve_chromatic_hue=variant_mask is not None,
)
)
cache[texture_path] = paints
except (
OSError,
ScanFileError,