Init. Commit
This commit is contained in:
@@ -0,0 +1,277 @@
|
||||
#!/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))
|
||||
Reference in New Issue
Block a user