278 lines
12 KiB
Python
278 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""Независимый решатель задачи 4 «Он немного изменился» (CREF + CTM4).
|
|
|
|
Вход: участнический PNG. Метаданные намеренно НЕ раскрывают параметры
|
|
кодирования — решатель восстанавливает всё из данных. Логика:
|
|
1. ручной разбор PNG-чанков (struct), проверка CRC каждого чанка;
|
|
2. поиск приватного чанка caTs, разбор конверта CREF
|
|
(magic, version=1, length, sha256), извлечение чистого оригинала;
|
|
3. размер плитки — из выравнивания diff эталона и результата: для
|
|
кандидатов 4/8/16 каждая изменённая плитка обязана совпасть ровно с
|
|
одним из 4 циклических сдвигов эталонной плитки на 1 пиксель;
|
|
размер, прошедший проверку для ВСЕХ изменённых плиток, — рабочий;
|
|
4. сетка плиток от (0,0), обход по строкам; пригодность считается
|
|
ТОЛЬКО по оригиналу (4 сдвига попарно различны, ни один не равен
|
|
исходнику, max |дельта| по каналам <= 4);
|
|
5. для каждой пригодной плитки изменённое изображение сравнивается с 4
|
|
кандидатами (up/right/down/left): ровно одно совпадение, иначе отказ;
|
|
6. карта направлений — из калибровки: первые 4 пригодные плитки дают
|
|
up,right,down,left, то есть 00=up 01=right 10=down 11=left;
|
|
7. далее пары бит (старшая пара первой) -> конверт CTM4, узнаваемый
|
|
по magic в самих байтах (version=1, payload_length u32 LE, payload,
|
|
crc32 u32 LE);
|
|
8. печать payload (флаг + шутка).
|
|
|
|
Зависимости: stdlib + Pillow + numpy. Генератор не импортируется,
|
|
файлы организатора не читаются.
|
|
"""
|
|
import binascii
|
|
import hashlib
|
|
import struct
|
|
import sys
|
|
|
|
import numpy as np
|
|
from PIL import Image
|
|
|
|
TILE_CANDIDATES = (4, 8, 16)
|
|
MAXDIFF = 4
|
|
MAX_PAYLOAD = 1 << 20 # 1 МиБ
|
|
PNG_SIG = b"\x89PNG\r\n\x1a\n"
|
|
# up=00, right=01, down=10, left=11
|
|
DIRECTIONS = ("up", "right", "down", "left")
|
|
|
|
|
|
class SolveError(Exception):
|
|
pass
|
|
|
|
|
|
def parse_chunks(raw):
|
|
"""Ручной разбор PNG-чанков с проверкой CRC. Без Pillow."""
|
|
if raw[:8] != PNG_SIG:
|
|
raise SolveError("неверная сигнатура PNG")
|
|
chunks = []
|
|
off = 8
|
|
while True:
|
|
if off + 12 > len(raw):
|
|
raise SolveError("усечённый PNG (нет IEND)")
|
|
(length,) = struct.unpack(">I", raw[off:off + 4])
|
|
if length > len(raw) - off - 12:
|
|
raise SolveError("длина чанка выходит за границы файла")
|
|
ctype = raw[off + 4:off + 8]
|
|
data = raw[off + 8:off + 8 + length]
|
|
(crc_stored,) = struct.unpack(">I", raw[off + 8 + length:off + 12 + length])
|
|
crc_real = binascii.crc32(ctype + data) & 0xFFFFFFFF
|
|
if crc_real != crc_stored:
|
|
raise SolveError(f"неверная CRC чанка {ctype!r}")
|
|
chunks.append((ctype, data))
|
|
off += 12 + length
|
|
if ctype == b"IEND":
|
|
break
|
|
if off != len(raw):
|
|
raise SolveError("лишние байты после IEND")
|
|
return chunks
|
|
|
|
|
|
def extract_original(chunks):
|
|
"""Находит caTs, разбирает CREF, проверяет length и sha256."""
|
|
cats = [d for t, d in chunks if t == b"caTs"]
|
|
if len(cats) != 1:
|
|
raise SolveError(f"ожидался ровно один чанк caTs, найдено {len(cats)}")
|
|
env = cats[0]
|
|
if len(env) < 4 + 1 + 4 + 32:
|
|
raise SolveError("CREF: короткий конверт")
|
|
if env[:4] != b"CREF":
|
|
raise SolveError("CREF: неверный magic")
|
|
if env[4] != 1:
|
|
raise SolveError(f"CREF: version {env[4]} != 1")
|
|
(length,) = struct.unpack("<I", env[5:9])
|
|
if length != len(env) - (4 + 1 + 4 + 32):
|
|
raise SolveError("CREF: length не сходится с размером конверта")
|
|
original = env[41:]
|
|
if hashlib.sha256(original).digest() != env[9:41]:
|
|
raise SolveError("CREF: sha256 встроенного оригинала не сошёлся")
|
|
return original
|
|
|
|
|
|
def load_rgb(png_bytes, what):
|
|
import io
|
|
try:
|
|
img = Image.open(io.BytesIO(png_bytes)).convert("RGB")
|
|
except Exception as e:
|
|
raise SolveError(f"не удалось декодировать {what}: {e}")
|
|
return np.asarray(img, dtype=np.uint8)
|
|
|
|
|
|
def shift_tile(t, d):
|
|
"""Циклический сдвиг квадратной плитки ровно на 1 пиксель."""
|
|
if d == 0: # up: строка r получает старую строку r+1
|
|
return np.vstack([t[1:], t[:1]])
|
|
if d == 1: # right
|
|
return np.hstack([t[:, -1:], t[:, :-1]])
|
|
if d == 2: # down
|
|
return np.vstack([t[-1:], t[:-1]])
|
|
return np.hstack([t[:, 1:], t[:, :1]]) # left
|
|
|
|
|
|
def tile_eligible(t):
|
|
"""Пригодность ТОЛЬКО по оригиналу: 4 сдвига попарно различны,
|
|
ни один не равен исходнику, max |дельта| по каналам <= MAXDIFF."""
|
|
cands = [shift_tile(t, d) for d in range(4)]
|
|
for i in range(4):
|
|
if np.array_equal(cands[i], t):
|
|
return False
|
|
for j in range(i):
|
|
if np.array_equal(cands[i], cands[j]):
|
|
return False
|
|
ti = t.astype(np.int16)
|
|
for c in cands:
|
|
if int(np.abs(c.astype(np.int16) - ti).max()) > MAXDIFF:
|
|
return False
|
|
return True
|
|
|
|
|
|
def eligible_tiles(orig, tile):
|
|
h, w = orig.shape[:2]
|
|
out = []
|
|
for gy in range(h // tile):
|
|
for gx in range(w // tile):
|
|
t = orig[gy * tile:(gy + 1) * tile, gx * tile:(gx + 1) * tile]
|
|
if tile_eligible(t):
|
|
out.append((gx, gy))
|
|
return out
|
|
|
|
|
|
def changed_tiles(orig, mod, tile):
|
|
"""Индексы (gx, gy) плиток, содержащих хотя бы один изменённый пиксель."""
|
|
h, w = orig.shape[:2]
|
|
th, tw = h // tile, w // tile
|
|
diff = np.any(orig[:th * tile, :tw * tile] != mod[:th * tile, :tw * tile],
|
|
axis=2)
|
|
hit = diff.reshape(th, tile, tw, tile).any(axis=(1, 3))
|
|
return [(gx, gy) for gy in range(th) for gx in range(tw) if hit[gy, gx]]
|
|
|
|
|
|
def check_size_candidate(orig, mod, tile):
|
|
"""Быстрый отсев кандидата по выравниванию diff: каждая изменённая
|
|
плитка обязана быть ровно одним из 4 циклических сдвигов эталонной."""
|
|
for gx, gy in changed_tiles(orig, mod, tile):
|
|
t = orig[gy * tile:(gy + 1) * tile, gx * tile:(gx + 1) * tile]
|
|
m = mod[gy * tile:(gy + 1) * tile, gx * tile:(gx + 1) * tile]
|
|
hits = [d for d in range(4) if np.array_equal(shift_tile(t, d), m)]
|
|
if len(hits) != 1:
|
|
raise SolveError(
|
|
f"размер {tile}x{tile} отклонён: плитка (gx={gx} gy={gy}): "
|
|
f"совпадений с кандидатами {len(hits)}, ожидалось ровно 1")
|
|
|
|
|
|
def decode_with_tile(orig, mod, tile):
|
|
"""Полное декодирование при заданном размере плитки: пригодность,
|
|
калибровка, пары бит, конверт CTM4. Возвращает (payload, n_elig, n_used)."""
|
|
elig = eligible_tiles(orig, tile)
|
|
|
|
def read_dir(idx):
|
|
gx, gy = elig[idx]
|
|
t = orig[gy * tile:(gy + 1) * tile, gx * tile:(gx + 1) * tile]
|
|
m = mod[gy * tile:(gy + 1) * tile, gx * tile:(gx + 1) * tile]
|
|
hits = [d for d in range(4) if np.array_equal(shift_tile(t, d), m)]
|
|
if len(hits) != 1:
|
|
raise SolveError(
|
|
f"плитка #{idx} (gx={gx} gy={gy}): совпадений с кандидатами "
|
|
f"{len(hits)}, ожидалось ровно 1")
|
|
return hits[0]
|
|
|
|
# Калибровка: первые 4 пригодные плитки — up, right, down, left.
|
|
for i in range(4):
|
|
if len(elig) <= i:
|
|
raise SolveError("слишком мало пригодных плиток для калибровки")
|
|
d = read_dir(i)
|
|
if d != i:
|
|
raise SolveError(
|
|
f"калибровка не сошлась: плитка #{i} = {DIRECTIONS[d]}, "
|
|
f"ожидалось {DIRECTIONS[i]}")
|
|
|
|
# Пары бит: в байте сначала старшая пара. 4 плитки = 1 байт.
|
|
pairs = []
|
|
total = None # полная длина конверта в байтах, станет известна после заголовка
|
|
idx = 4
|
|
header_len = 4 + 1 + 4
|
|
while True:
|
|
have = len(pairs) // 4
|
|
if total is not None and have >= total:
|
|
break
|
|
if total is None and have >= header_len:
|
|
hdr = bytes()
|
|
for k in range(header_len):
|
|
hdr += bytes([pairs[4 * k] << 6 | pairs[4 * k + 1] << 4
|
|
| pairs[4 * k + 2] << 2 | pairs[4 * k + 3]])
|
|
if hdr[:4] != b"CTM4":
|
|
raise SolveError("CTM4: неверный magic")
|
|
if hdr[4] != 1:
|
|
raise SolveError(f"CTM4: version {hdr[4]} != 1")
|
|
(plen,) = struct.unpack("<I", hdr[5:9])
|
|
if plen > MAX_PAYLOAD:
|
|
raise SolveError(f"CTM4: payload_length {plen} превышает лимит")
|
|
total = header_len + plen + 4
|
|
continue
|
|
if idx >= len(elig):
|
|
raise SolveError("конверт CTM4 не поместился в пригодные плитки")
|
|
pairs.append(read_dir(idx))
|
|
idx += 1
|
|
|
|
data = bytearray()
|
|
for k in range(total):
|
|
data.append(pairs[4 * k] << 6 | pairs[4 * k + 1] << 4
|
|
| pairs[4 * k + 2] << 2 | pairs[4 * k + 3])
|
|
(plen,) = struct.unpack("<I", bytes(data[5:9]))
|
|
payload = bytes(data[header_len:header_len + plen])
|
|
(crc_stored,) = struct.unpack("<I", bytes(data[header_len + plen:total]))
|
|
if binascii.crc32(payload) & 0xFFFFFFFF != crc_stored:
|
|
raise SolveError("CTM4: crc32 payload не сошёлся")
|
|
return payload, len(elig), idx
|
|
|
|
|
|
def solve(path, quiet=False):
|
|
with open(path, "rb") as f:
|
|
raw = f.read()
|
|
chunks = parse_chunks(raw)
|
|
orig_png = extract_original(chunks)
|
|
orig = load_rgb(orig_png, "встроенный оригинал")
|
|
mod = load_rgb(raw, "участнический PNG")
|
|
if orig.shape != mod.shape:
|
|
raise SolveError(f"размеры не совпадают: {mod.shape} vs {orig.shape}")
|
|
|
|
# Размер плитки восстанавливаем из данных: кандидат проходит быстрый
|
|
# отсев по выравниванию diff, затем полное декодирование с калибровкой.
|
|
errors = []
|
|
for tile in TILE_CANDIDATES:
|
|
try:
|
|
check_size_candidate(orig, mod, tile)
|
|
payload, n_elig, n_used = decode_with_tile(orig, mod, tile)
|
|
except SolveError as e:
|
|
errors.append(f"[{tile}x{tile}] {e}")
|
|
continue
|
|
if not quiet:
|
|
print(f"размер плитки из данных: {tile}x{tile}", file=sys.stderr)
|
|
print(f"пригодных плиток: {n_elig}", file=sys.stderr)
|
|
return payload, n_elig, n_used
|
|
raise SolveError(
|
|
"ни один размер плитки не подошёл: " + " | ".join(errors))
|
|
|
|
|
|
def main(argv):
|
|
if len(argv) != 2:
|
|
print(f"usage: {argv[0]} <participant.png>", file=sys.stderr)
|
|
return 2
|
|
try:
|
|
payload, n_elig, n_used = solve(argv[1])
|
|
except (SolveError, OSError) as e:
|
|
print(f"ошибка: {e}", file=sys.stderr)
|
|
return 1
|
|
text = payload.decode("utf-8", errors="strict")
|
|
print(f"пригодных плиток: {n_elig}, использовано: {n_used}", file=sys.stderr)
|
|
print(text)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main(sys.argv))
|