Feature Complete Shipping V1.0
This commit is contained in:
+209
-21
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user