Files
2026-09-17 00:50:07 +03:00

174 lines
6.9 KiB
Python

#!/usr/bin/env python
"""Эталонный решатель задачи 1 «В глазах всё написано» (формат CTP1).
Независимая реализация: только stdlib + Pillow + numpy. Не импортирует код
генератора и не читает файлы организатора.
Схема (восстанавливается из публичных улик в метаданных PNG):
* сетка блоков 2x2 от левого верхнего угла, обход по строкам;
* блок пригоден, если 4 цвета RGB попарно различны и размах каждого
канала <= 3 (пригодность инвариантна к перестановке пикселей);
* канонический порядок цветов: сортировка по ключу (77*R+150*G+29*B, R, G, B);
* 24 перестановки (0,1,2,3) нумеруются лексикографически от 0,
номера 0..15 кодируют полубайт, старший полубайт байта первым;
* первые 16 пригодных блоков — калибровка (номера 0..15 по порядку);
* далее конверт: magic "CTP1", version=1, payload_length u32 LE,
payload, crc32 u32 LE от payload.
Использование: python solve_task1.py <participant.png>
Печатает флаг (первая строка payload) в stdout. При ошибке — сообщение
в stderr и ненулевой код возврата.
"""
import struct
import sys
import zlib
import numpy as np
from PIL import Image
RANGE_MAX = 3
CALIBRATION_BLOCKS = 16
MAGIC = b"CTP1"
VERSION = 1
MAX_PAYLOAD = 1 << 20 # 1 МиБ
# Все 24 перестановки (0,1,2,3) в лексикографическом порядке.
LEX_PERMS = sorted(
(a, b, c, d)
for a in range(4)
for b in range(4)
for c in range(4)
for d in range(4)
if len({a, b, c, d}) == 4
)
PERM_INDEX = {p: i for i, p in enumerate(LEX_PERMS)}
class SolveError(Exception):
"""Ожидаемая ошибка решения: повреждённые или чужие данные."""
def suitable(px):
"""Пригодность блока 2x2. px — массив (4, 3) RGB в порядке TL,TR,BL,BR."""
for i in range(4):
for j in range(i + 1, 4):
if px[i][0] == px[j][0] and px[i][1] == px[j][1] and px[i][2] == px[j][2]:
return False
lo = px.min(axis=0)
hi = px.max(axis=0)
return bool(((hi - lo) <= RANGE_MAX).all())
def block_nibble(px):
"""Номер лексикографической перестановки блока (0..23).
perm[k] = позиция k-го канонического цвета в порядке чтения блока.
"""
order = sorted(
range(4), key=lambda i: (77 * px[i][0] + 150 * px[i][1] + 29 * px[i][2],
px[i][0], px[i][1], px[i][2])
)
perm = [0] * 4
for k, i in enumerate(order): # i — позиция в порядке чтения цвета C[k]
perm[k] = i
return PERM_INDEX[tuple(perm)]
def solve(path):
img = Image.open(path).convert("RGB")
w, h = img.size
arr = np.asarray(img, dtype=np.int32)
# Пригодные блоки 2x2 в порядке обхода по строкам; неполные краевые
# блоки игнорируются.
blocks = [] # (bx, by) левых верхних углов
nibbles = []
for by in range(0, h - 1, 2):
for bx in range(0, w - 1, 2):
px = arr[by:by + 2, bx:bx + 2].reshape(4, 3)
if suitable(px):
blocks.append((bx, by))
nibbles.append(block_nibble(px))
if len(blocks) < CALIBRATION_BLOCKS:
raise SolveError(
f"слишком мало пригодных блоков: {len(blocks)}, нужно минимум 16"
)
# Калибровка: первые 16 пригодных блоков обязаны давать 0..15 по порядку.
for i in range(CALIBRATION_BLOCKS):
if nibbles[i] != i:
raise SolveError(
f"калибровка нарушена: блок {i} даёт номер {nibbles[i]}, ожидался {i}"
)
def read_byte(idx):
"""Байт idx конверта: старший полубайт первым."""
vals = []
for half in range(2):
pos = CALIBRATION_BLOCKS + 2 * idx + half
if pos >= len(nibbles):
raise SolveError("конверт усечён: не хватает пригодных блоков")
n = nibbles[pos]
if n > 15:
raise SolveError(
f"недопустимый номер перестановки {n} (допустимы 0..15) "
f"в блоке {pos}"
)
vals.append(n)
return (vals[0] << 4) | vals[1]
header = bytes(read_byte(i) for i in range(9))
if header[:4] != MAGIC:
raise SolveError(f"неверный magic: {header[:4]!r}, ожидался {MAGIC!r}")
if header[4] != VERSION:
raise SolveError(f"неподдерживаемая версия конверта: {header[4]}")
(plen,) = struct.unpack("<I", header[5:9])
if plen > MAX_PAYLOAD:
raise SolveError(
f"payload_length {plen} превышает лимит {MAX_PAYLOAD} байт"
)
total = 13 + plen
if CALIBRATION_BLOCKS + 2 * total > len(blocks):
raise SolveError(
f"конверт усечён: нужно {total} байт, "
f"блоков хватает на {(len(blocks) - CALIBRATION_BLOCKS) // 2}"
)
rest = bytes(read_byte(i) for i in range(9, total))
payload = rest[:plen]
(crc_want,) = struct.unpack("<I", rest[plen:])
crc_got = zlib.crc32(payload) & 0xFFFFFFFF
if crc_got != crc_want:
raise SolveError(
f"несовпадение crc32: вычислено {crc_got:08x}, записано {crc_want:08x}"
)
return payload
def main(argv):
if len(argv) != 2:
print(f"usage: {argv[0]} <participant.png>", file=sys.stderr)
return 2
try:
payload = solve(argv[1])
except SolveError as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
except Exception as exc: # битый файл, не-PNG и т.п.
print(f"error: не удалось обработать файл: {exc}", file=sys.stderr)
return 1
try:
text = payload.decode("utf-8")
except UnicodeDecodeError as exc:
print(f"error: payload не является UTF-8: {exc}", file=sys.stderr)
return 1
flag = text.split("\n", 1)[0].strip()
print(flag)
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))