| """Task 1のcross-codec adapter、条件registry、protected payloadを提供する。""" |
|
|
| from __future__ import annotations |
|
|
| import hashlib |
| import json |
| import logging |
| import re |
| import subprocess |
| import tempfile |
| from collections.abc import Mapping, Sequence |
| from dataclasses import asdict, dataclass, field |
| from io import BytesIO |
| from types import MappingProxyType |
| from typing import Final |
|
|
| import numpy as np |
| from PIL import Image, features |
| from PIL import __version__ as PILLOW_VERSION |
|
|
| from pixelmodel_robustness.jpeg_repair import RepairResult, repair_nonfinite |
|
|
| LOGGER = logging.getLogger(__name__) |
| MAGICK_PATH: Final[str] = "/etc/profiles/per-user/scratchbrulee/bin/magick" |
| LOSSY_CODEC_NAMES: Final[tuple[str, ...]] = ("jpeg_q100", "jpeg_q80", "webp_q80", "avif_q70", "jxl_d1") |
|
|
| CONDITION_IDS: Final[tuple[str, ...]] = ( |
| "png_baseline", "webp_lossless", "jpeg_q100_raw", "jpeg_q100_repair_zero", |
| "jpeg_q100_protected_high", "jpeg_q80_raw", "jpeg_q80_repair_zero", |
| "jpeg_q80_protected_high", "webp_q80_raw", "webp_q80_repair_zero", |
| "webp_q80_protected_high", "avif_q70_raw", "avif_q70_repair_zero", |
| "avif_q70_protected_high", "jxl_d1_raw", "jxl_d1_repair_zero", |
| "jxl_d1_protected_high", |
| ) |
|
|
|
|
| @dataclass(frozen=True) |
| class CodecSpec: |
| """一つのcodecの固定encode/decode契約。""" |
|
|
| name: str |
| format: str |
| quality: int | None |
| options: Mapping[str, object] |
| metadata: Mapping[str, object] |
| grayscale: bool = True |
|
|
|
|
| @dataclass(frozen=True) |
| class ConditionSpec: |
| """ordered registryのsemantic entry。""" |
|
|
| condition_id: str |
| codec: str |
| mode: str |
| repair: str | None |
| protected: bool |
|
|
|
|
| @dataclass(frozen=True) |
| class CodecPreflight: |
| """codec availabilityと実行identity。""" |
|
|
| name: str |
| available: bool |
| version: str | None |
| reason: str | None |
| argv: tuple[str, ...] |
| metadata: Mapping[str, object] |
| diagnostics: Mapping[str, object] = field(default_factory=lambda: MappingProxyType({})) |
|
|
|
|
| @dataclass(frozen=True) |
| class ProtectedArtifact: |
| """high PNGとlossy low planeのin-memory payload。""" |
|
|
| high_png_bytes: bytes |
| low_codec_bytes: bytes |
| low_codec: str |
|
|
| @property |
| def payload(self) -> bytes: |
| """high bytesとlow bytesを境界なしで結合したpayloadを返す。""" |
| return self.high_png_bytes + self.low_codec_bytes |
|
|
|
|
| class JxlCodecError(RuntimeError): |
| """JXL subprocessの失敗をdiagnostics付きで表す。""" |
|
|
| def __init__(self, message: str, *, argv: Sequence[str], stdout: str, stderr: str, returncode: int) -> None: |
| super().__init__(f"{message}: returncode={returncode}") |
| self.argv = tuple(argv) |
| self.stdout = stdout |
| self.stderr = stderr |
| self.returncode = returncode |
|
|
|
|
| def _mapping(values: Mapping[str, object]) -> Mapping[str, object]: |
| return MappingProxyType(dict(values)) |
|
|
|
|
| CODEC_SPECS: Final[Mapping[str, CodecSpec]] = MappingProxyType({ |
| "png": CodecSpec("png", "PNG", None, _mapping({}), _mapping({})), |
| "webp_lossless": CodecSpec( |
| "webp_lossless", "WEBP", None, |
| _mapping({"lossless": True, "exact": True, "method": 6}), _mapping({}), |
| ), |
| "jpeg_q100": CodecSpec( |
| "jpeg_q100", "JPEG", 100, |
| _mapping({"quality": 100, "subsampling": 0, "optimize": False}), _mapping({}), |
| ), |
| "jpeg_q80": CodecSpec( |
| "jpeg_q80", "JPEG", 80, |
| _mapping({"quality": 80, "subsampling": 0, "optimize": False}), _mapping({}), |
| ), |
| "webp_q80": CodecSpec( |
| "webp_q80", "WEBP", 80, |
| _mapping({"quality": 80, "lossless": False, "method": 6, "exact": True}), _mapping({}), |
| ), |
| "avif_q70": CodecSpec( |
| "avif_q70", "AVIF", 70, |
| _mapping({"quality": 70, "subsampling": "4:4:4", "speed": 6, "range": "full", "codec": "aom", "max_threads": 1, "autotiling": False}), |
| _mapping({}), |
| ), |
| "jxl_d1": CodecSpec( |
| "jxl_d1", "JXL", 90, |
| _mapping({"quality": 90}), |
| _mapping({"quality": 90, "libjxl_distance": 1.0, "quality_mapping": "JxlEncoderDistanceFromQuality(90)"}), |
| ), |
| }) |
|
|
| EXPECTED_FORMAT_BY_CODEC: Final[Mapping[str, str]] = MappingProxyType({ |
| "png": "PNG", "webp_lossless": "WEBP", "jpeg_q100": "JPEG", "jpeg_q80": "JPEG", |
| "webp_q80": "WEBP", "avif_q70": "AVIF", "jxl_d1": "JXL", |
| }) |
|
|
|
|
| def _build_registry() -> Mapping[str, ConditionSpec]: |
| entries: dict[str, ConditionSpec] = {} |
| for condition_id in CONDITION_IDS: |
| if condition_id == "png_baseline": |
| codec, mode, repair, protected = "png", "raw", None, False |
| elif condition_id == "webp_lossless": |
| codec, mode, repair, protected = "webp_lossless", "raw", None, False |
| else: |
| codec = _condition_codec(condition_id) |
| protected = condition_id.endswith("_protected_high") |
| repair = "zero" if condition_id.endswith("_repair_zero") else None |
| mode = "protected_high" if protected else "raw" if repair is None else "repair_zero" |
| entries[condition_id] = ConditionSpec(condition_id, codec, mode, repair, protected) |
| return MappingProxyType(entries) |
|
|
|
|
| def _condition_codec(condition_id: str) -> str: |
| for codec in ("jpeg_q100", "jpeg_q80", "webp_q80", "avif_q70", "jxl_d1"): |
| if condition_id.startswith(codec): |
| return codec |
| raise ValueError(f"unknown condition: {condition_id}") |
|
|
|
|
| CONDITION_REGISTRY: Final[Mapping[str, ConditionSpec]] = _build_registry() |
|
|
|
|
| def semantic_hash(registry: Mapping[str, ConditionSpec] | Sequence[ConditionSpec]) -> str: |
| """registryの順序と全semantic fieldをcanonical JSONでhashする。""" |
| values = registry.values() if isinstance(registry, Mapping) else registry |
| entries = [CONDITION_REGISTRY[item] if isinstance(item, str) else item for item in values] |
| encoded = json.dumps([asdict(item) for item in entries], sort_keys=True, separators=(",", ":")).encode() |
| return hashlib.sha256(encoded).hexdigest() |
|
|
|
|
| def _pillow_available(format_name: str) -> tuple[bool, str | None]: |
| Image.init() |
| if format_name == "AVIF": |
| available = bool(features.check("avif")) |
| else: |
| available = format_name in Image.SAVE |
| return available, None if available else f"Pillow codec unavailable: {format_name}" |
|
|
|
|
| def _jxl_run(args: Sequence[str]) -> subprocess.CompletedProcess[str]: |
| argv = [MAGICK_PATH, *args] |
| try: |
| result = subprocess.run(argv, capture_output=True, text=True, shell=False, check=False) |
| except OSError as error: |
| raise JxlCodecError(str(error), argv=argv, stdout="", stderr=str(error), returncode=-1) from error |
| if result.returncode != 0: |
| raise JxlCodecError("ImageMagick JXL command failed", argv=argv, stdout=result.stdout, stderr=result.stderr, returncode=result.returncode) |
| return result |
|
|
|
|
| def _jxl_version() -> CodecPreflight: |
| argv = (MAGICK_PATH, "-version") |
| try: |
| result = _jxl_run(["-version"]) |
| except JxlCodecError as error: |
| return CodecPreflight( |
| "jxl_d1", False, None, str(error), tuple(error.argv), _mapping({"quality": 90, "libjxl_distance": 1.0}), |
| _mapping({"argv": error.argv, "stdout": error.stdout, "stderr": error.stderr, "returncode": error.returncode}), |
| ) |
| if "jxl" not in result.stdout.lower(): |
| diagnostics = _mapping({"argv": argv, "stdout": result.stdout, "stderr": result.stderr, "returncode": result.returncode}) |
| return CodecPreflight("jxl_d1", False, result.stdout, "ImageMagick version has no JXL delegate", argv, _mapping({}), diagnostics) |
| try: |
| formats = _jxl_run(["-list", "format"]) |
| delegate_version = _parse_libjxl_delegate_version(formats.stdout) |
| except JxlCodecError as error: |
| return CodecPreflight( |
| "jxl_d1", False, result.stdout, str(error), argv, _mapping({}), |
| _mapping({"argv": error.argv, "stdout": error.stdout, "stderr": error.stderr, "returncode": error.returncode}), |
| ) |
| if delegate_version is None: |
| diagnostics = _mapping({"argv": (MAGICK_PATH, "-list", "format"), "stdout": formats.stdout, "stderr": formats.stderr, "returncode": formats.returncode}) |
| return CodecPreflight("jxl_d1", False, result.stdout, "libjxl delegate version could not be parsed", argv, _mapping({}), diagnostics) |
| probe_image = Image.fromarray(np.arange(8 * 8 * 3, dtype=np.uint8).reshape(8, 8, 3), "RGB") |
| try: |
| q90 = _jxl_encode(probe_image, quality=90) |
| q50 = _jxl_encode(probe_image, quality=50) |
| decoded_q90 = _jxl_decode(q90) |
| decoded_q50 = _jxl_decode(q50) |
| if not q90 or not q50 or q90 == q50 or decoded_q90.size != probe_image.size or decoded_q50.size != probe_image.size: |
| raise ValueError("JXL probe payloads are empty, identical, or have wrong dimensions") |
| except JxlCodecError as error: |
| stages = _mapping({ |
| "version": _mapping({"argv": argv, "stdout": result.stdout, "stderr": result.stderr, "returncode": result.returncode}), |
| "format": _mapping({"argv": (MAGICK_PATH, "-list", "format"), "stdout": formats.stdout, "stderr": formats.stderr, "returncode": formats.returncode}), |
| "probe": _mapping({"argv": error.argv, "stdout": error.stdout, "stderr": error.stderr, "returncode": error.returncode}), |
| }) |
| return CodecPreflight("jxl_d1", False, result.stdout, str(error), tuple(error.argv), _mapping({}), stages) |
| except (OSError, ValueError) as error: |
| return CodecPreflight( |
| "jxl_d1", False, result.stdout, str(error), argv, |
| _mapping({"quality": 90, "libjxl_distance": 1.0, "stderr": result.stderr}), |
| ) |
| metadata = { |
| "quality": 90, |
| "libjxl_distance": 1.0, |
| "quality_mapping": "JxlEncoderDistanceFromQuality(quality)", |
| "imagemagick_full_version": result.stdout, |
| "libjxl_delegate_version": delegate_version, |
| "stderr": result.stderr, |
| "probe_q90_bytes": len(q90), |
| "probe_q50_bytes": len(q50), |
| "probe_q90_sha256": hashlib.sha256(q90).hexdigest(), |
| "probe_q50_sha256": hashlib.sha256(q50).hexdigest(), |
| "probe_dimensions": (probe_image.height, probe_image.width), |
| "probe_q50_dimensions": (decoded_q50.height, decoded_q50.width), |
| "argv_template": (MAGICK_PATH, "-quality", "90", "<source>", "<target>"), |
| } |
| diagnostics = _mapping({"argv": argv, "stdout": result.stdout, "stderr": result.stderr, "returncode": result.returncode}) |
| return CodecPreflight("jxl_d1", True, result.stdout, None, argv, _mapping(metadata), diagnostics) |
|
|
|
|
| def _parse_libjxl_delegate_version(output: str) -> str | None: |
| match = re.search(r"libjxl\s+([0-9]+\.[0-9]+\.[0-9]+)", output, flags=re.IGNORECASE) |
| return match.group(1) if match else None |
|
|
|
|
| def preflight_codecs() -> tuple[CodecPreflight, ...]: |
| """全codecのavailabilityをfail-closedで返す。""" |
| results: list[CodecPreflight] = [] |
| for name, spec in CODEC_SPECS.items(): |
| if name == "jxl_d1": |
| results.append(_jxl_version()) |
| continue |
| available, reason = _pillow_available(spec.format) |
| results.append(CodecPreflight(name, available, PILLOW_VERSION if available else None, reason, ("Pillow", spec.format), spec.metadata)) |
| return tuple(results) |
|
|
|
|
| def _verified_status(spec: CodecSpec, verified_preflight: Mapping[str, CodecPreflight] | str | None) -> CodecPreflight | None: |
| """verified preflight tokenを検証し、standalone時はNoneを返す。""" |
| if verified_preflight is None: |
| return None |
| if isinstance(verified_preflight, str): |
| if verified_preflight != spec.name: |
| raise ValueError(f"verified preflight token mismatch: {verified_preflight} != {spec.name}") |
| return CodecPreflight(spec.name, True, "verified-token", None, ("verified", spec.name), spec.metadata) |
| status = verified_preflight.get(spec.name) |
| if status is None or status.name != spec.name or not status.available: |
| raise RuntimeError(status.reason if status is not None and status.reason else f"codec unavailable: {spec.name}") |
| return status |
|
|
|
|
| def _require_available(spec: CodecSpec, verified_preflight: Mapping[str, CodecPreflight] | str | None = None) -> None: |
| status = _verified_status(spec, verified_preflight) |
| if status is None and spec.name == "jxl_d1": |
| status = _jxl_version() |
| elif status is None: |
| available, reason = _pillow_available(spec.format) |
| status = CodecPreflight(spec.name, available, PILLOW_VERSION if available else None, reason, ("Pillow", spec.format), spec.metadata) |
| if not status.available: |
| raise RuntimeError(status.reason or f"codec unavailable: {spec.name}") |
|
|
|
|
| def encode_rgb(image: Image.Image, spec: CodecSpec, *, verified_preflight: Mapping[str, CodecPreflight] | str | None = None) -> bytes: |
| """RGB imageを固定optionでmemory encodeする。""" |
| _require_available(spec, verified_preflight) |
| rgb = image.convert("RGB") |
| if spec.name == "jxl_d1": |
| return _jxl_encode(rgb) |
| return _encode_pillow(rgb, spec) |
|
|
|
|
| def _encode_pillow(image: Image.Image, spec: CodecSpec) -> bytes: |
| """Pillowへ入力modeを変更せず固定optionで渡す。""" |
| stream = BytesIO() |
| image.save(stream, format=spec.format, **dict(spec.options)) |
| return stream.getvalue() |
|
|
|
|
| def decode_rgb(payload: bytes, spec: CodecSpec, *, verified_preflight: Mapping[str, CodecPreflight] | str | None = None) -> Image.Image: |
| """bytesをRGB imageへdecodeし、file handleを閉じる。""" |
| _verified_status(spec, verified_preflight) |
| return _decode_l_image(payload, spec).convert("RGB") |
|
|
|
|
| def _decode_image(payload: bytes, spec: CodecSpec) -> Image.Image: |
| """payloadをdecoderの実modeのままmemory imageへdecodeする。""" |
| if spec.name == "jxl_d1": |
| return _jxl_decode(payload) |
| with Image.open(BytesIO(payload)) as image: |
| return image.copy() |
|
|
|
|
| def encode_l_plane(plane: np.ndarray, spec: CodecSpec, *, verified_preflight: Mapping[str, CodecPreflight] | str | None = None) -> bytes: |
| """grayscale planeを固定codec optionでmemory encodeする。""" |
| array = np.asarray(plane, dtype=np.uint8) |
| if array.ndim != 2: |
| raise ValueError("L plane must be two-dimensional") |
| _require_available(spec, verified_preflight) |
| image = Image.fromarray(array, "L") |
| if spec.name == "jxl_d1": |
| return _jxl_encode(image) |
| return _encode_pillow(image, spec) |
|
|
|
|
| def decode_l_plane(payload: bytes, spec: CodecSpec, *, verified_preflight: Mapping[str, CodecPreflight] | str | None = None) -> np.ndarray: |
| """bytesをgrayscale uint8 planeへdecodeする。""" |
| _verified_status(spec, verified_preflight) |
| image = _decode_l_image(payload, spec) |
| return np.asarray(image.convert("L"), dtype=np.uint8).copy() |
|
|
|
|
| def _decode_l_image(payload: bytes, spec: CodecSpec) -> Image.Image: |
| """L plane payloadをcodec format gate付きでdecodeする。""" |
| expected_format = EXPECTED_FORMAT_BY_CODEC.get(spec.name) |
| if expected_format is None: |
| raise ValueError(f"unknown codec name: {spec.name}") |
| if spec.format != expected_format: |
| raise ValueError(f"codec format is not canonical for {spec.name}: {spec.format}") |
| if spec.name == "jxl_d1": |
| return _jxl_decode(payload) |
| with Image.open(BytesIO(payload)) as image: |
| if image.format != expected_format: |
| raise ValueError(f"payload format {image.format} does not match codec format {expected_format}") |
| return image.copy() |
|
|
|
|
| def _jxl_encode(image: Image.Image, *, quality: int = 90) -> bytes: |
| with tempfile.TemporaryDirectory(prefix="pixelmodel-jxl-") as directory: |
| source, target = f"{directory}/source.png", f"{directory}/payload.jxl" |
| image.save(source, format="PNG") |
| _jxl_run(["-quality", str(quality), source, target]) |
| with open(target, "rb") as stream: |
| return stream.read() |
|
|
|
|
| def _jxl_decode(payload: bytes) -> Image.Image: |
| with tempfile.TemporaryDirectory(prefix="pixelmodel-jxl-") as directory: |
| source, target = f"{directory}/payload.jxl", f"{directory}/decoded.png" |
| with open(source, "wb") as stream: |
| stream.write(payload) |
| identify_argv = [MAGICK_PATH, "identify", "-format", "%m", source] |
| identified = _jxl_run(["identify", "-format", "%m", source]) |
| if identified.stdout.strip() != "JXL": |
| raise JxlCodecError( |
| "JXL identify format gate failed", argv=identify_argv, |
| stdout=identified.stdout, stderr=identified.stderr, returncode=identified.returncode, |
| ) |
| _jxl_run([source, target]) |
| with Image.open(target) as image: |
| return image.copy() |
|
|
|
|
| def encode_protected_high(values: np.ndarray, shape: tuple[int, int], low_spec: CodecSpec, *, verified_preflight: Mapping[str, CodecPreflight] | str | None = None) -> ProtectedArtifact: |
| """high byteをPNG、low byteを指定lossy codecで保存する。""" |
| if low_spec.name not in LOSSY_CODEC_NAMES: |
| raise ValueError(f"protected low codec must be lossy: {low_spec.name}") |
| canonical = CODEC_SPECS[low_spec.name] |
| if ( |
| low_spec.format != canonical.format |
| or low_spec.quality != canonical.quality |
| or low_spec.options != canonical.options |
| or low_spec.metadata != canonical.metadata |
| or low_spec.grayscale != canonical.grayscale |
| ): |
| raise ValueError(f"protected low codec spec is not semantically canonical: {low_spec.name}") |
| flat = np.asarray(values, dtype=np.float16).reshape(-1) |
| capacity = int(np.prod(shape)) |
| if flat.size > capacity: |
| raise ValueError("values exceed shape capacity") |
| bits = np.zeros(capacity, dtype=np.uint16) |
| bits[: flat.size] = flat.view(np.uint16) |
| high = (bits >> 8).astype(np.uint8).reshape(shape) |
| low = bits.astype(np.uint8).reshape(shape) |
| high_stream = BytesIO() |
| Image.fromarray(high, "L").save(high_stream, format="PNG") |
| return ProtectedArtifact(high_stream.getvalue(), encode_l_plane(low, low_spec, verified_preflight=verified_preflight), low_spec.name) |
|
|
|
|
| def decode_protected_high(artifact: ProtectedArtifact, shape: tuple[int, int], source_values: np.ndarray, *, verified_preflight: Mapping[str, CodecPreflight] | str | None = None) -> tuple[np.ndarray, dict[str, object]]: |
| """protected payloadを復元し、high exactnessとbytes/hashを検証する。""" |
| with Image.open(BytesIO(artifact.high_png_bytes)) as high_image: |
| if high_image.format != "PNG": |
| raise ValueError(f"protected high plane format must be PNG, got {high_image.format}") |
| if high_image.mode != "L": |
| raise ValueError(f"protected high plane mode must be L, got {high_image.mode}") |
| high = np.asarray(high_image.convert("L"), dtype=np.uint8).copy() |
| high_mode, high_dimensions = high_image.mode, [high_image.height, high_image.width] |
| if artifact.low_codec not in LOSSY_CODEC_NAMES: |
| raise ValueError(f"protected low codec must be lossy: {artifact.low_codec}") |
| _verified_status(CODEC_SPECS[artifact.low_codec], verified_preflight) |
| low_image = _decode_l_image(artifact.low_codec_bytes, CODEC_SPECS[artifact.low_codec]) |
| low_mode = low_image.mode |
| low = np.asarray(low_image.convert("L"), dtype=np.uint8).copy() |
| if high.shape != shape or low.shape != shape: |
| raise ValueError("protected plane dimensions mismatch") |
| source_flat = np.asarray(source_values, dtype=np.float16).reshape(-1) |
| if source_flat.size > int(np.prod(shape)): |
| raise ValueError("source values exceed shape capacity") |
| expected_bits = np.zeros(int(np.prod(shape)), dtype=np.uint16) |
| expected_bits[: source_flat.size] = source_flat.view(np.uint16) |
| source_high = (expected_bits >> 8).astype(np.uint8).reshape(shape) |
| if not np.array_equal(high, source_high): |
| raise ValueError("decoded protected high plane is not bit-exact") |
| bits = ((high.reshape(-1).astype(np.uint16) << 8) | low.reshape(-1).astype(np.uint16)).view(np.float16) |
| metadata: dict[str, object] = { |
| "dimensions": list(shape), "high_mode": high_mode, "low_mode": low_mode, "high_dimensions": high_dimensions, |
| "low_dimensions": [low_image.height, low_image.width], |
| "decoded_high_exact": True, "decoded_high_sha256": hashlib.sha256(high.tobytes()).hexdigest(), |
| "combined_payload_bytes": len(artifact.payload), "combined_payload_sha256": hashlib.sha256(artifact.payload).hexdigest(), |
| "low_codec": artifact.low_codec, "source_value_count": int(source_flat.size), |
| "low_changed_count": int(np.count_nonzero(low != expected_bits.astype(np.uint8).reshape(shape))), |
| "high_encoded_bytes": len(artifact.high_png_bytes), "high_encoded_sha256": hashlib.sha256(artifact.high_png_bytes).hexdigest(), |
| "low_encoded_bytes": len(artifact.low_codec_bytes), "low_encoded_sha256": hashlib.sha256(artifact.low_codec_bytes).hexdigest(), |
| "decoded_low_sha256": hashlib.sha256(low.tobytes()).hexdigest(), |
| "pillow_version": PILLOW_VERSION, |
| } |
| return bits, metadata |
|
|
|
|
| def repair_nonfinite_zero(values: np.ndarray, layers: Mapping[str, tuple[int, int]]) -> RepairResult: |
| """NaN/+Inf/-Infだけをzeroへ置換し、finite fp16 bit patternを保持する。""" |
| return repair_nonfinite(values, "zero", layers) |
|
|