Compare commits
2 Commits
Luke
...
visualNKod
| Author | SHA1 | Date | |
|---|---|---|---|
| 4fa7a621d7 | |||
| d0329090ac |
2
.gitignore
vendored
@@ -2,3 +2,5 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
output
|
output
|
||||||
__pycache__
|
__pycache__
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
|||||||
0
__init__.py
Normal file
3810
example/obs_json/observation_1.json
Normal file
BIN
example/obs_png/run_001/observation_001.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
example/obs_png/run_001/observation_002.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
example/obs_png/run_001/observation_003.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
example/obs_png/run_001/observation_004.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
example/obs_png/run_001/observation_005.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
example/obs_png/run_001/observation_006.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
example/obs_png/run_001/observation_007.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
example/obs_png/run_001/observation_008.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
example/obs_png/run_001/observation_009.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
example/obs_png/run_001/observation_010.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
example/obs_png/run_001/observation_011.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
example/obs_png/run_001/observation_012.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
example/obs_png/run_001/observation_013.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
example/obs_png/run_001/observation_014.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
example/obs_png/run_001/observation_015.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
example/obs_png/run_001/observation_016.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
example/obs_png/run_001/observation_017.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
example/obs_png/run_001/observation_018.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
example/obs_png/run_001/observation_019.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
example/obs_png/run_001/observation_020.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
example/obs_png/run_001/observation_021.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
example/obs_png/run_001/observation_022.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
example/obs_png/run_001/observation_023.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
example/obs_png/run_001/observation_024.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
example/obs_png/run_001/observation_025.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
example/obs_png/run_001/observation_026.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
example/obs_png/run_001/observation_027.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
example/obs_png/run_001/observation_028.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
example/obs_png/run_001/observation_029.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
example/obs_png/run_001/observation_030.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
example/obs_png/run_001/observation_031.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
example/obs_png/run_001/observation_032.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
example/obs_png/run_001/observation_033.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
example/obs_png/run_001/observation_034.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
example/obs_png/run_001/observation_035.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
example/obs_png/run_001/observation_036.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
example/obs_png/run_001/observation_037.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
example/obs_png/run_001/observation_038.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
example/obs_png/run_001/observation_039.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
example/obs_png/run_001/observation_040.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
example/obs_png/run_001/observation_041.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
example/obs_png/run_001/observation_042.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
example/obs_png/run_001/observation_043.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
example/obs_png/run_001/observation_044.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
example/obs_png/run_001/observation_045.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
example/obs_png/run_001/observation_046.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
example/obs_png/run_001/observation_047.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
example/obs_png/run_001/observation_048.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
example/obs_png/run_001/observation_049.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
example/obs_png/run_001/observation_050.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
@@ -1,82 +1,17 @@
|
|||||||
from src.evilkode import Observation, Evilkode
|
from src.evilkode import Evilkode
|
||||||
from src.keypad import Keypad
|
|
||||||
import random
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from statistics import mean, variance
|
from statistics import mean, variance
|
||||||
from enum import Enum
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from src.utils import ShuffleTypes, observations, passcode_generator
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Benchmark:
|
class Benchmark:
|
||||||
mean: int
|
mean: int
|
||||||
variance: int
|
variance: int
|
||||||
runs: list[int]
|
runs: list[int]
|
||||||
|
|
||||||
class ShuffleTypes(Enum):
|
|
||||||
FULL_SHUFFLE = "FULL_SHUFFLE"
|
|
||||||
SPLIT_SHUFFLE = "SPLIT_SHUFFLE"
|
|
||||||
|
|
||||||
def observations(number_of_keys, properties_per_key, passcode_len, complexity: int, disparity: int, shuffle_type: ShuffleTypes):
|
|
||||||
k = number_of_keys
|
|
||||||
p = properties_per_key
|
|
||||||
n = passcode_len
|
|
||||||
passcode = passcode_generator(k, p, n, complexity, disparity)
|
|
||||||
keypad = Keypad.new_keypad(k, p)
|
|
||||||
|
|
||||||
def obs_gen():
|
|
||||||
for _ in range(100): # finite number of yields
|
|
||||||
yield Observation(
|
|
||||||
keypad=keypad.keypad.copy(),
|
|
||||||
key_selection=keypad.key_entry(target_passcode=passcode)
|
|
||||||
)
|
|
||||||
match shuffle_type:
|
|
||||||
case ShuffleTypes.FULL_SHUFFLE:
|
|
||||||
keypad.full_shuffle()
|
|
||||||
case ShuffleTypes.SPLIT_SHUFFLE:
|
|
||||||
keypad.split_shuffle()
|
|
||||||
case _:
|
|
||||||
raise Exception(f"no shuffle type {shuffle_type}")
|
|
||||||
|
|
||||||
return obs_gen()
|
|
||||||
|
|
||||||
def passcode_generator(k: int, p: int, n: int, c: int, d: int) -> list[int]:
|
|
||||||
assert n >= c
|
|
||||||
assert p*k >= c
|
|
||||||
|
|
||||||
assert n >= d
|
|
||||||
assert p >= d
|
|
||||||
passcode_prop = []
|
|
||||||
passcode_set = []
|
|
||||||
valid_choices = {i for i in range(k*p)}
|
|
||||||
repeat_set = n-d
|
|
||||||
repeat_prop = n-c
|
|
||||||
prop_added = set()
|
|
||||||
set_added = set()
|
|
||||||
|
|
||||||
for _ in range(n):
|
|
||||||
prop = random.choice(list(valid_choices))
|
|
||||||
prop_set = prop//p
|
|
||||||
passcode_prop.append(prop)
|
|
||||||
passcode_set.append(prop_set)
|
|
||||||
|
|
||||||
if prop in prop_added:
|
|
||||||
repeat_prop -= 1
|
|
||||||
if prop_set in set_added:
|
|
||||||
repeat_set -= 1
|
|
||||||
|
|
||||||
prop_added.add(prop)
|
|
||||||
set_added.add(prop_set)
|
|
||||||
|
|
||||||
if repeat_prop <= 0:
|
|
||||||
valid_choices -= prop_added
|
|
||||||
|
|
||||||
if repeat_set <= 0:
|
|
||||||
for el in valid_choices.copy():
|
|
||||||
if el // p in set_added:
|
|
||||||
valid_choices.remove(el)
|
|
||||||
|
|
||||||
return passcode_prop
|
|
||||||
|
|
||||||
|
|
||||||
def shuffle_benchmark(
|
def shuffle_benchmark(
|
||||||
number_of_keys: int,
|
number_of_keys: int,
|
||||||
@@ -94,7 +29,6 @@ def shuffle_benchmark(
|
|||||||
full_path = Path(file_path) / file_name
|
full_path = Path(file_path) / file_name
|
||||||
if not overwrite and full_path.exists():
|
if not overwrite and full_path.exists():
|
||||||
print(f"file exists {file_path}")
|
print(f"file exists {file_path}")
|
||||||
|
|
||||||
with open(full_path, "r") as fp:
|
with open(full_path, "r") as fp:
|
||||||
runs = fp.readline()
|
runs = fp.readline()
|
||||||
runs = runs.split(',')
|
runs = runs.split(',')
|
||||||
@@ -106,13 +40,14 @@ def shuffle_benchmark(
|
|||||||
)
|
)
|
||||||
runs = []
|
runs = []
|
||||||
for _ in range(run_count):
|
for _ in range(run_count):
|
||||||
|
passcode = passcode_generator(number_of_keys, properties_per_key, passcode_len, complexity, disparity)
|
||||||
evilkode = Evilkode(
|
evilkode = Evilkode(
|
||||||
observations=observations(
|
observations=observations(
|
||||||
|
target_passcode=passcode,
|
||||||
number_of_keys=number_of_keys,
|
number_of_keys=number_of_keys,
|
||||||
properties_per_key=properties_per_key,
|
properties_per_key=properties_per_key,
|
||||||
passcode_len=passcode_len,
|
min_complexity=complexity,
|
||||||
complexity=complexity,
|
min_disparity=disparity,
|
||||||
disparity=disparity,
|
|
||||||
shuffle_type=shuffle_type,
|
shuffle_type=shuffle_type,
|
||||||
),
|
),
|
||||||
number_of_keys=number_of_keys,
|
number_of_keys=number_of_keys,
|
||||||
@@ -145,13 +80,14 @@ def full_shuffle_benchmark(
|
|||||||
) -> Benchmark:
|
) -> Benchmark:
|
||||||
runs = []
|
runs = []
|
||||||
for _ in range(run_count):
|
for _ in range(run_count):
|
||||||
|
passcode = passcode_generator(number_of_keys, properties_per_key, passcode_len, complexity, disparity)
|
||||||
evilkode = Evilkode(
|
evilkode = Evilkode(
|
||||||
observations=observations(
|
observations=observations(
|
||||||
|
target_passcode=passcode,
|
||||||
number_of_keys=number_of_keys,
|
number_of_keys=number_of_keys,
|
||||||
properties_per_key=properties_per_key,
|
properties_per_key=properties_per_key,
|
||||||
passcode_len=passcode_len,
|
min_complexity=complexity,
|
||||||
complexity=complexity,
|
min_disparity=disparity,
|
||||||
disparity=disparity,
|
|
||||||
shuffle_type=ShuffleTypes.FULL_SHUFFLE,
|
shuffle_type=ShuffleTypes.FULL_SHUFFLE,
|
||||||
),
|
),
|
||||||
number_of_keys=number_of_keys,
|
number_of_keys=number_of_keys,
|
||||||
|
|||||||
@@ -44,5 +44,4 @@ class Evilkode:
|
|||||||
return EvilOutput(possible_nkodes=[list(el) for el in self.possible_nkode], iterations=idx+1)
|
return EvilOutput(possible_nkodes=[list(el) for el in self.possible_nkode], iterations=idx+1)
|
||||||
for jdx, props in enumerate(obs.property_list):
|
for jdx, props in enumerate(obs.property_list):
|
||||||
self.possible_nkode[jdx] = props.intersection(self.possible_nkode[jdx])
|
self.possible_nkode[jdx] = props.intersection(self.possible_nkode[jdx])
|
||||||
|
|
||||||
raise Exception("error in Evilkode, observations stopped yielding")
|
raise Exception("error in Evilkode, observations stopped yielding")
|
||||||
|
|||||||
72
src/utils.py
@@ -1,7 +1,79 @@
|
|||||||
|
import random
|
||||||
|
from enum import Enum
|
||||||
from math import factorial, comb
|
from math import factorial, comb
|
||||||
|
|
||||||
|
from src.evilkode import Observation
|
||||||
|
from src.keypad import Keypad
|
||||||
|
|
||||||
|
|
||||||
def total_valid_nkode_states(k: int, p: int) -> int:
|
def total_valid_nkode_states(k: int, p: int) -> int:
|
||||||
return factorial(k) ** (p-1)
|
return factorial(k) ** (p-1)
|
||||||
|
|
||||||
def total_shuffle_states(k: int, p: int) -> int:
|
def total_shuffle_states(k: int, p: int) -> int:
|
||||||
return comb((p-1), (p-1) // 2) * factorial(k)
|
return comb((p-1), (p-1) // 2) * factorial(k)
|
||||||
|
|
||||||
|
|
||||||
|
class ShuffleTypes(Enum):
|
||||||
|
FULL_SHUFFLE = "FULL_SHUFFLE"
|
||||||
|
SPLIT_SHUFFLE = "SPLIT_SHUFFLE"
|
||||||
|
|
||||||
|
|
||||||
|
def observations(target_passcode: list[int], number_of_keys:int, properties_per_key: int, min_complexity: int, min_disparity: int, shuffle_type: ShuffleTypes, number_of_observations: int = 100):
|
||||||
|
k = number_of_keys
|
||||||
|
p = properties_per_key
|
||||||
|
keypad = Keypad.new_keypad(k, p)
|
||||||
|
|
||||||
|
def obs_gen():
|
||||||
|
for _ in range(number_of_observations):
|
||||||
|
yield Observation(
|
||||||
|
keypad=keypad.keypad.copy(),
|
||||||
|
key_selection=keypad.key_entry(target_passcode=target_passcode)
|
||||||
|
)
|
||||||
|
match shuffle_type:
|
||||||
|
case ShuffleTypes.FULL_SHUFFLE:
|
||||||
|
keypad.full_shuffle()
|
||||||
|
case ShuffleTypes.SPLIT_SHUFFLE:
|
||||||
|
keypad.split_shuffle()
|
||||||
|
case _:
|
||||||
|
raise Exception(f"no shuffle type {shuffle_type}")
|
||||||
|
|
||||||
|
return obs_gen()
|
||||||
|
|
||||||
|
|
||||||
|
def passcode_generator(k: int, p: int, n: int, c: int, d: int) -> list[int]:
|
||||||
|
assert n >= c
|
||||||
|
assert p*k >= c
|
||||||
|
|
||||||
|
assert n >= d
|
||||||
|
assert p >= d
|
||||||
|
passcode_prop = []
|
||||||
|
passcode_set = []
|
||||||
|
valid_choices = {i for i in range(k*p)}
|
||||||
|
repeat_set = n-d
|
||||||
|
repeat_prop = n-c
|
||||||
|
prop_added = set()
|
||||||
|
set_added = set()
|
||||||
|
|
||||||
|
for _ in range(n):
|
||||||
|
prop = random.choice(list(valid_choices))
|
||||||
|
prop_set = prop//p
|
||||||
|
passcode_prop.append(prop)
|
||||||
|
passcode_set.append(prop_set)
|
||||||
|
|
||||||
|
if prop in prop_added:
|
||||||
|
repeat_prop -= 1
|
||||||
|
if prop_set in set_added:
|
||||||
|
repeat_set -= 1
|
||||||
|
|
||||||
|
prop_added.add(prop)
|
||||||
|
set_added.add(prop_set)
|
||||||
|
|
||||||
|
if repeat_prop <= 0:
|
||||||
|
valid_choices -= prop_added
|
||||||
|
|
||||||
|
if repeat_set <= 0:
|
||||||
|
for el in valid_choices.copy():
|
||||||
|
if el // p in set_added:
|
||||||
|
valid_choices.remove(el)
|
||||||
|
|
||||||
|
return passcode_prop
|
||||||
|
|||||||
229
src/visualnkode.py
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
import json
|
||||||
|
from dataclasses import dataclass, asdict
|
||||||
|
from evilkode import Observation
|
||||||
|
from utils import observations, passcode_generator, ShuffleTypes
|
||||||
|
from pathlib import Path
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
# Project root = parent of *this* file's directory
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
OUTPUT_DIR = PROJECT_ROOT / "example" / "obs_json"
|
||||||
|
PNG_DIR = PROJECT_ROOT / "example" / "obs_png"
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ObservationSequence:
|
||||||
|
target_passcode: list[int]
|
||||||
|
observations: list[Observation]
|
||||||
|
|
||||||
|
def new_observation_sequence(
|
||||||
|
number_of_keys: int,
|
||||||
|
properties_per_key: int,
|
||||||
|
passcode_len: int,
|
||||||
|
complexity: int,
|
||||||
|
disparity: int,
|
||||||
|
numb_runs: int,
|
||||||
|
) -> ObservationSequence:
|
||||||
|
passcode = passcode_generator(number_of_keys, properties_per_key, passcode_len, complexity, disparity)
|
||||||
|
obs_seq = ObservationSequence(target_passcode=passcode, observations=[])
|
||||||
|
obs_gen = observations(
|
||||||
|
target_passcode=passcode,
|
||||||
|
number_of_keys=number_of_keys,
|
||||||
|
properties_per_key=properties_per_key,
|
||||||
|
min_complexity=complexity,
|
||||||
|
min_disparity=disparity,
|
||||||
|
shuffle_type=ShuffleTypes.FULL_SHUFFLE,
|
||||||
|
number_of_observations=numb_runs,
|
||||||
|
)
|
||||||
|
for obs in obs_gen:
|
||||||
|
obs.keypad = obs.keypad.tolist()
|
||||||
|
obs_seq.observations.append(obs)
|
||||||
|
|
||||||
|
return obs_seq
|
||||||
|
|
||||||
|
def _next_json_filename(base_dir: Path) -> Path:
|
||||||
|
"""Find the next available observation_X.json file in base_dir."""
|
||||||
|
counter = 1
|
||||||
|
while True:
|
||||||
|
candidate = base_dir / f"observation_{counter}.json"
|
||||||
|
if not candidate.exists():
|
||||||
|
return candidate
|
||||||
|
counter += 1
|
||||||
|
|
||||||
|
def save_observation_sequence_to_json(seq: ObservationSequence, filename: Path | None = None) -> None:
|
||||||
|
"""
|
||||||
|
Save ObservationSequence to JSON.
|
||||||
|
- If filename is None, put it under PROJECT_ROOT/output/obs_json/ as observation_{n}.json
|
||||||
|
- Creates directory if needed
|
||||||
|
"""
|
||||||
|
if filename is None:
|
||||||
|
base_dir = OUTPUT_DIR
|
||||||
|
base_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
filename = _next_json_filename(base_dir)
|
||||||
|
else:
|
||||||
|
filename.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
with filename.open("w", encoding="utf-8") as f:
|
||||||
|
json.dump(asdict(seq), f, indent=4)
|
||||||
|
|
||||||
|
# ---------- Helpers ----------
|
||||||
|
def _load_font(preferred: str, size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
||||||
|
"""Try a preferred TTF, fall back to common monospace, then PIL default."""
|
||||||
|
candidates = [
|
||||||
|
preferred,
|
||||||
|
"DejaVuSansMono.ttf", # common on Linux
|
||||||
|
"Consolas.ttf", # Windows
|
||||||
|
"Menlo.ttc", "Menlo.ttf", # macOS
|
||||||
|
"Courier New.ttf",
|
||||||
|
]
|
||||||
|
for c in candidates:
|
||||||
|
try:
|
||||||
|
return ImageFont.truetype(c, size)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return ImageFont.load_default()
|
||||||
|
|
||||||
|
def _text_size(draw: ImageDraw.ImageDraw, text: str, font: ImageFont.ImageFont) -> tuple[int, int]:
|
||||||
|
"""Get (w, h) using font bbox for accurate layout."""
|
||||||
|
left, top, right, bottom = draw.textbbox((0, 0), text, font=font)
|
||||||
|
return right - left, bottom - top
|
||||||
|
|
||||||
|
def _join_nums(nums: Iterable[int]) -> str:
|
||||||
|
return " ".join(str(n) for n in nums)
|
||||||
|
|
||||||
|
def _next_available_path(path: Path) -> Path:
|
||||||
|
"""If path exists, append _1, _2, ..."""
|
||||||
|
if not path.exists():
|
||||||
|
return path
|
||||||
|
base, suffix = path.stem, path.suffix or ".png"
|
||||||
|
i = 1
|
||||||
|
while True:
|
||||||
|
candidate = path.with_name(f"{base}_{i}{suffix}")
|
||||||
|
if not candidate.exists():
|
||||||
|
return candidate
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
# ---------- Core rendering ----------
|
||||||
|
def render_observation_to_png(
|
||||||
|
target_passcode: list[int],
|
||||||
|
obs: Observation,
|
||||||
|
out_path: Path,
|
||||||
|
*,
|
||||||
|
header_font_name: str = "DejaVuSans.ttf",
|
||||||
|
body_font_name: str = "DejaVuSans.ttf",
|
||||||
|
header_size: int = 28,
|
||||||
|
body_size: int = 24,
|
||||||
|
margin: int = 32,
|
||||||
|
row_padding_xy: tuple[int, int] = (16, 12), # (x, y) padding inside row box
|
||||||
|
row_spacing: int = 14,
|
||||||
|
header_spacing: int = 10,
|
||||||
|
section_spacing: int = 18,
|
||||||
|
bg_color: str = "white",
|
||||||
|
fg_color: str = "black",
|
||||||
|
row_fill: str = "#f7f7f7",
|
||||||
|
row_outline: str = "#222222",
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Render a single observation:
|
||||||
|
- Top lines:
|
||||||
|
Target Passcode: {target}
|
||||||
|
Selected Keys: {selected keys}
|
||||||
|
- Then a stack of row boxes representing the keypad rows.
|
||||||
|
"""
|
||||||
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
out_path = _next_available_path(out_path)
|
||||||
|
|
||||||
|
# Fonts
|
||||||
|
header_font = _load_font(header_font_name, header_size)
|
||||||
|
body_font = _load_font(body_font_name, body_size)
|
||||||
|
|
||||||
|
# Prepare strings
|
||||||
|
header1 = f"Target Passcode: {_join_nums(target_passcode)}"
|
||||||
|
header2 = f"Selected Keys: {_join_nums(obs.key_selection)}"
|
||||||
|
row_texts = [_join_nums(row) for row in obs.keypad]
|
||||||
|
|
||||||
|
# Measure to compute canvas size
|
||||||
|
# Provisional image for measurement
|
||||||
|
temp_img = Image.new("RGB", (1, 1), bg_color)
|
||||||
|
d = ImageDraw.Draw(temp_img)
|
||||||
|
|
||||||
|
h1_w, h1_h = _text_size(d, header1, header_font)
|
||||||
|
h2_w, h2_h = _text_size(d, header2, header_font)
|
||||||
|
|
||||||
|
row_text_sizes = [_text_size(d, t, body_font) for t in row_texts]
|
||||||
|
row_box_widths = [tw + 2 * row_padding_xy[0] for (tw, th) in row_text_sizes]
|
||||||
|
row_box_heights = [th + 2 * row_padding_xy[1] for (tw, th) in row_text_sizes]
|
||||||
|
|
||||||
|
content_width = max([h1_w, h2_w] + (row_box_widths or [0]))
|
||||||
|
total_rows_height = sum(row_box_heights) + row_spacing * max(0, len(row_box_heights) - 1)
|
||||||
|
|
||||||
|
width = content_width + 2 * margin
|
||||||
|
height = (
|
||||||
|
margin
|
||||||
|
+ h1_h
|
||||||
|
+ header_spacing
|
||||||
|
+ h2_h
|
||||||
|
+ section_spacing
|
||||||
|
+ total_rows_height
|
||||||
|
+ margin
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create final image
|
||||||
|
img = Image.new("RGB", (max(width, 300), max(height, 200)), bg_color)
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
# Draw headers
|
||||||
|
x = margin
|
||||||
|
y = margin
|
||||||
|
draw.text((x, y), header1, font=header_font, fill=fg_color)
|
||||||
|
y += h1_h + header_spacing
|
||||||
|
draw.text((x, y), header2, font=header_font, fill=fg_color)
|
||||||
|
y += h2_h + section_spacing
|
||||||
|
|
||||||
|
# Draw row boxes
|
||||||
|
for (text, (tw, th), box_h) in zip(row_texts, row_text_sizes, row_box_heights):
|
||||||
|
box_left = x
|
||||||
|
box_top = y
|
||||||
|
box_right = x + max(row_box_widths) # make all boxes same width for neatness
|
||||||
|
box_bottom = y + box_h
|
||||||
|
|
||||||
|
# rectangle
|
||||||
|
draw.rectangle([box_left, box_top, box_right, box_bottom], fill=row_fill, outline=row_outline, width=2)
|
||||||
|
|
||||||
|
# text centered vertically, left-padded
|
||||||
|
text_x = box_left + row_padding_xy[0]
|
||||||
|
text_y = box_top + (box_h - th) // 2
|
||||||
|
draw.text((text_x, text_y), text, font=body_font, fill=fg_color)
|
||||||
|
|
||||||
|
y = box_bottom + row_spacing
|
||||||
|
|
||||||
|
img.save(out_path, format="PNG")
|
||||||
|
|
||||||
|
def _next_run_dir(base_dir: Path) -> Path:
|
||||||
|
"""Find the next available run directory under base_dir (run_001, run_002, ...)."""
|
||||||
|
counter = 1
|
||||||
|
while True:
|
||||||
|
run_dir = base_dir / f"run_{counter:03d}"
|
||||||
|
if not run_dir.exists():
|
||||||
|
run_dir.mkdir(parents=True)
|
||||||
|
return run_dir
|
||||||
|
counter += 1
|
||||||
|
|
||||||
|
def render_sequence_to_pngs(seq: ObservationSequence, out_dir: Path | None = None) -> None:
|
||||||
|
"""
|
||||||
|
Render each observation to its own PNG inside a fresh run directory.
|
||||||
|
Default: PROJECT_ROOT/output/obs_png/run_XXX/observation_001.png
|
||||||
|
"""
|
||||||
|
base_dir = PNG_DIR if out_dir is None else out_dir
|
||||||
|
base_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Create a fresh run dir
|
||||||
|
run_dir = _next_run_dir(base_dir)
|
||||||
|
|
||||||
|
for i, obs in enumerate(seq.observations, start=1):
|
||||||
|
filename = run_dir / f"observation_{i:03d}.png"
|
||||||
|
render_observation_to_png(seq.target_passcode, obs, filename)
|
||||||
|
if __name__ == "__main__":
|
||||||
|
obs_seq = new_observation_sequence(6, 9,4,0,0,numb_runs=50)
|
||||||
|
save_observation_sequence_to_json(obs_seq)
|
||||||
|
render_sequence_to_pngs(obs_seq)
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
from src.benchmark import passcode_generator
|
from src.utils import passcode_generator
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
|
|||||||