initial commit
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
# Fake Media Generator
|
||||
|
||||
Creates a large fake image collection for testing without downloading real files.
|
||||
|
||||
## What it does
|
||||
|
||||
- Generates a small set of unique placeholder PNGs in several dimensions.
|
||||
- Reuses those generated variants to create many files.
|
||||
- Spreads the files across as many subfolders as you want.
|
||||
- Uses only Python's standard library.
|
||||
|
||||
## TUI usage
|
||||
|
||||
```bash
|
||||
python3 tools/fake-media/generate_fake_media.py
|
||||
```
|
||||
|
||||
The script opens a terminal UI where you can edit:
|
||||
|
||||
- output directory
|
||||
- image sizes
|
||||
- variants per size
|
||||
- folder count
|
||||
- images per folder
|
||||
- folder and file prefixes
|
||||
- seed
|
||||
- clear-output toggle
|
||||
|
||||
Controls:
|
||||
|
||||
- `Up` and `Down`: move between fields
|
||||
- `Enter`: edit the selected field
|
||||
- `Space`: toggle `Clear Output`
|
||||
- `G`: generate files
|
||||
- `Q`: quit
|
||||
|
||||
## Optional non-interactive mode
|
||||
|
||||
```bash
|
||||
python3 tools/fake-media/generate_fake_media.py \
|
||||
/tmp/fake-media \
|
||||
--no-tui \
|
||||
--sizes 320x240,640x480,1280x720,2048x1536 \
|
||||
--variants-per-size 4 \
|
||||
--folder-count 20 \
|
||||
--images-per-folder 500 \
|
||||
--folder-prefix album \
|
||||
--file-prefix asset \
|
||||
--clear-output
|
||||
```
|
||||
|
||||
That example creates:
|
||||
|
||||
- 16 unique placeholder images.
|
||||
- 20 subfolders named `album-001` through `album-020`.
|
||||
- 10,000 total PNG files.
|
||||
|
||||
## Useful flags
|
||||
|
||||
- `--sizes`: comma-separated dimensions.
|
||||
- `--variants-per-size`: unique image patterns per size.
|
||||
- `--folder-count`: number of subfolders.
|
||||
- `--images-per-folder`: files per subfolder.
|
||||
- `--clear-output`: removes the target directory first.
|
||||
- `--seed`: makes output repeatable.
|
||||
- `--no-tui`: bypasses the terminal UI.
|
||||
@@ -0,0 +1,473 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import curses
|
||||
import random
|
||||
import shutil
|
||||
import struct
|
||||
import zlib
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
|
||||
DEFAULT_SIZES = "320x240,640x480,1024x768,1600x900"
|
||||
|
||||
|
||||
@dataclass
|
||||
class GeneratorConfig:
|
||||
output: Path
|
||||
sizes: list[tuple[int, int]]
|
||||
variants_per_size: int
|
||||
folder_count: int
|
||||
images_per_folder: int
|
||||
folder_prefix: str
|
||||
file_prefix: str
|
||||
seed: int
|
||||
clear_output: bool
|
||||
|
||||
|
||||
def parse_sizes(value: str) -> list[tuple[int, int]]:
|
||||
sizes: list[tuple[int, int]] = []
|
||||
for item in value.split(","):
|
||||
item = item.strip().lower()
|
||||
if not item:
|
||||
continue
|
||||
if "x" not in item:
|
||||
raise ValueError(f"Invalid size '{item}'. Use WIDTHxHEIGHT,WIDTHxHEIGHT")
|
||||
width_text, height_text = item.split("x", 1)
|
||||
try:
|
||||
width = int(width_text)
|
||||
height = int(height_text)
|
||||
except ValueError as exc:
|
||||
raise ValueError(
|
||||
f"Invalid size '{item}'. Width and height must be integers."
|
||||
) from exc
|
||||
if width <= 0 or height <= 0:
|
||||
raise ValueError(
|
||||
f"Invalid size '{item}'. Width and height must be positive."
|
||||
)
|
||||
sizes.append((width, height))
|
||||
if not sizes:
|
||||
raise ValueError("At least one image size is required.")
|
||||
return sizes
|
||||
|
||||
|
||||
def build_png(width: int, height: int, variant_seed: int) -> bytes:
|
||||
rng = random.Random(variant_seed)
|
||||
base_r = rng.randint(20, 230)
|
||||
base_g = rng.randint(20, 230)
|
||||
base_b = rng.randint(20, 230)
|
||||
accent_r = (base_r + rng.randint(40, 120)) % 256
|
||||
accent_g = (base_g + rng.randint(40, 120)) % 256
|
||||
accent_b = (base_b + rng.randint(40, 120)) % 256
|
||||
block = max(12, min(width, height) // max(6, rng.randint(6, 12)))
|
||||
raw = bytearray()
|
||||
|
||||
for y in range(height):
|
||||
raw.append(0)
|
||||
for x in range(width):
|
||||
mix_x = x / max(1, width - 1)
|
||||
mix_y = y / max(1, height - 1)
|
||||
checker = ((x // block) + (y // block) + variant_seed) % 2
|
||||
stripe = ((x // max(4, block // 2)) + variant_seed) % 3
|
||||
noise = rng.randint(-12, 12)
|
||||
|
||||
r = int(base_r * (1 - mix_x) + accent_r * mix_x)
|
||||
g = int(base_g * (1 - mix_y) + accent_g * mix_y)
|
||||
b = int((base_b + accent_b) / 2)
|
||||
|
||||
if checker:
|
||||
r = min(255, r + 18)
|
||||
g = max(0, g - 10)
|
||||
if stripe == 0:
|
||||
b = min(255, b + 28)
|
||||
elif stripe == 1:
|
||||
r = max(0, r - 18)
|
||||
|
||||
raw.extend(
|
||||
(
|
||||
max(0, min(255, r + noise)),
|
||||
max(0, min(255, g - noise)),
|
||||
max(0, min(255, b + noise // 2)),
|
||||
)
|
||||
)
|
||||
|
||||
def chunk(chunk_type: bytes, data: bytes) -> bytes:
|
||||
return (
|
||||
struct.pack(">I", len(data))
|
||||
+ chunk_type
|
||||
+ data
|
||||
+ struct.pack(">I", zlib.crc32(chunk_type + data) & 0xFFFFFFFF)
|
||||
)
|
||||
|
||||
header = b"\x89PNG\r\n\x1a\n"
|
||||
ihdr = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)
|
||||
idat = zlib.compress(bytes(raw), level=6)
|
||||
return header + chunk(b"IHDR", ihdr) + chunk(b"IDAT", idat) + chunk(b"IEND", b"")
|
||||
|
||||
|
||||
def build_variants(
|
||||
sizes: list[tuple[int, int]], variants_per_size: int, seed: int
|
||||
) -> list[tuple[str, bytes]]:
|
||||
variants: list[tuple[str, bytes]] = []
|
||||
for size_index, (width, height) in enumerate(sizes):
|
||||
for variant_index in range(variants_per_size):
|
||||
variant_seed = seed + (size_index * 10_000) + variant_index
|
||||
name = f"{width}x{height}-v{variant_index + 1}.png"
|
||||
variants.append((name, build_png(width, height, variant_seed)))
|
||||
return variants
|
||||
|
||||
|
||||
def write_collection(
|
||||
output_dir: Path,
|
||||
folder_count: int,
|
||||
images_per_folder: int,
|
||||
folder_prefix: str,
|
||||
file_prefix: str,
|
||||
variants: list[tuple[str, bytes]],
|
||||
progress: Callable[[int, int, int], None] | None = None,
|
||||
) -> int:
|
||||
total_files = 0
|
||||
for folder_number in range(1, folder_count + 1):
|
||||
folder = output_dir / f"{folder_prefix}-{folder_number:03d}"
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for image_number in range(1, images_per_folder + 1):
|
||||
variant_name, variant_bytes = variants[(image_number - 1) % len(variants)]
|
||||
destination = folder / f"{file_prefix}-{image_number:05d}-{variant_name}"
|
||||
destination.write_bytes(variant_bytes)
|
||||
total_files += 1
|
||||
|
||||
if progress:
|
||||
progress(folder_number, folder_count, total_files)
|
||||
|
||||
return total_files
|
||||
|
||||
|
||||
def validate_config(config: GeneratorConfig) -> None:
|
||||
if config.variants_per_size <= 0:
|
||||
raise ValueError("Variants per size must be greater than 0.")
|
||||
if config.folder_count <= 0:
|
||||
raise ValueError("Folder count must be greater than 0.")
|
||||
if config.images_per_folder <= 0:
|
||||
raise ValueError("Images per folder must be greater than 0.")
|
||||
if not config.folder_prefix.strip():
|
||||
raise ValueError("Folder prefix cannot be empty.")
|
||||
if not config.file_prefix.strip():
|
||||
raise ValueError("File prefix cannot be empty.")
|
||||
|
||||
|
||||
def generate_collection(
|
||||
config: GeneratorConfig,
|
||||
progress: Callable[[int, int, int], None] | None = None,
|
||||
) -> dict[str, str | int]:
|
||||
validate_config(config)
|
||||
|
||||
output_dir = config.output.expanduser().resolve()
|
||||
if config.clear_output and output_dir.exists():
|
||||
shutil.rmtree(output_dir)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
variants = build_variants(config.sizes, config.variants_per_size, config.seed)
|
||||
total_files = write_collection(
|
||||
output_dir=output_dir,
|
||||
folder_count=config.folder_count,
|
||||
images_per_folder=config.images_per_folder,
|
||||
folder_prefix=config.folder_prefix,
|
||||
file_prefix=config.file_prefix,
|
||||
variants=variants,
|
||||
progress=progress,
|
||||
)
|
||||
|
||||
return {
|
||||
"output_dir": str(output_dir),
|
||||
"unique_variants": len(variants),
|
||||
"folders_created": config.folder_count,
|
||||
"files_created": total_files,
|
||||
"sizes_used": ", ".join(f"{w}x{h}" for w, h in config.sizes),
|
||||
}
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate fake placeholder image collections for testing."
|
||||
)
|
||||
parser.add_argument("output", nargs="?", type=Path)
|
||||
parser.add_argument("--sizes", default=DEFAULT_SIZES)
|
||||
parser.add_argument("--variants-per-size", type=int, default=3)
|
||||
parser.add_argument("--folder-count", type=int, default=5)
|
||||
parser.add_argument("--images-per-folder", type=int, default=100)
|
||||
parser.add_argument("--folder-prefix", default="batch")
|
||||
parser.add_argument("--file-prefix", default="image")
|
||||
parser.add_argument("--seed", type=int, default=42)
|
||||
parser.add_argument("--clear-output", action="store_true")
|
||||
parser.add_argument(
|
||||
"--no-tui",
|
||||
action="store_true",
|
||||
help="Run in non-interactive mode using command-line options.",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def config_from_args(args: argparse.Namespace) -> GeneratorConfig:
|
||||
if args.output is None:
|
||||
raise ValueError("Output directory is required in --no-tui mode.")
|
||||
return GeneratorConfig(
|
||||
output=args.output,
|
||||
sizes=parse_sizes(args.sizes),
|
||||
variants_per_size=args.variants_per_size,
|
||||
folder_count=args.folder_count,
|
||||
images_per_folder=args.images_per_folder,
|
||||
folder_prefix=args.folder_prefix,
|
||||
file_prefix=args.file_prefix,
|
||||
seed=args.seed,
|
||||
clear_output=args.clear_output,
|
||||
)
|
||||
|
||||
|
||||
def cli_mode(args: argparse.Namespace) -> int:
|
||||
try:
|
||||
config = config_from_args(args)
|
||||
summary = generate_collection(config)
|
||||
except ValueError as exc:
|
||||
raise SystemExit(str(exc)) from exc
|
||||
|
||||
print(f"Output directory: {summary['output_dir']}")
|
||||
print(f"Unique variants: {summary['unique_variants']}")
|
||||
print(f"Folders created: {summary['folders_created']}")
|
||||
print(f"Files created: {summary['files_created']}")
|
||||
print(f"Sizes used: {summary['sizes_used']}")
|
||||
return 0
|
||||
|
||||
|
||||
def truncate(text: str, width: int) -> str:
|
||||
if width <= 0:
|
||||
return ""
|
||||
if len(text) <= width:
|
||||
return text
|
||||
if width <= 3:
|
||||
return text[:width]
|
||||
return text[: width - 3] + "..."
|
||||
|
||||
|
||||
def prompt_text(
|
||||
stdscr: curses.window, label: str, current_value: str, message: str
|
||||
) -> str | None:
|
||||
curses.curs_set(1)
|
||||
height, width = stdscr.getmaxyx()
|
||||
prompt = f"{label}: "
|
||||
stdscr.move(height - 2, 0)
|
||||
stdscr.clrtoeol()
|
||||
stdscr.addstr(height - 2, 0, truncate(prompt + current_value, width - 1))
|
||||
stdscr.move(height - 1, 0)
|
||||
stdscr.clrtoeol()
|
||||
stdscr.addstr(height - 1, 0, truncate(message, width - 1))
|
||||
buffer = list(current_value)
|
||||
position = len(buffer)
|
||||
|
||||
while True:
|
||||
stdscr.move(height - 2, 0)
|
||||
stdscr.clrtoeol()
|
||||
display = prompt + "".join(buffer)
|
||||
stdscr.addstr(height - 2, 0, truncate(display, width - 1))
|
||||
cursor_x = min(len(prompt) + position, max(0, width - 2))
|
||||
stdscr.move(height - 2, cursor_x)
|
||||
key = stdscr.getch()
|
||||
|
||||
if key in (10, 13):
|
||||
curses.curs_set(0)
|
||||
return "".join(buffer).strip()
|
||||
if key == 27:
|
||||
curses.curs_set(0)
|
||||
return None
|
||||
if key in (curses.KEY_BACKSPACE, 127, 8):
|
||||
if position > 0:
|
||||
position -= 1
|
||||
buffer.pop(position)
|
||||
continue
|
||||
if key == curses.KEY_LEFT and position > 0:
|
||||
position -= 1
|
||||
continue
|
||||
if key == curses.KEY_RIGHT and position < len(buffer):
|
||||
position += 1
|
||||
continue
|
||||
if 32 <= key <= 126:
|
||||
buffer.insert(position, chr(key))
|
||||
position += 1
|
||||
|
||||
|
||||
def draw_screen(
|
||||
stdscr: curses.window,
|
||||
fields: list[tuple[str, str]],
|
||||
selected: int,
|
||||
message: str,
|
||||
status_lines: list[str],
|
||||
) -> None:
|
||||
stdscr.erase()
|
||||
height, width = stdscr.getmaxyx()
|
||||
title = "Fake Media Generator"
|
||||
subtitle = "Arrows to move, Enter to edit, Space to toggle clear-output, G to generate, Q to quit"
|
||||
stdscr.addstr(0, 0, truncate(title, width - 1), curses.A_BOLD)
|
||||
stdscr.addstr(1, 0, truncate(subtitle, width - 1))
|
||||
|
||||
for index, (label, value) in enumerate(fields):
|
||||
y = 3 + index
|
||||
if y >= height - 5:
|
||||
break
|
||||
prefix = ">" if index == selected else " "
|
||||
line = f"{prefix} {label:<18} {value}"
|
||||
attr = curses.A_REVERSE if index == selected else curses.A_NORMAL
|
||||
stdscr.addstr(y, 0, truncate(line, width - 1), attr)
|
||||
|
||||
status_start = min(height - 4, 4 + len(fields))
|
||||
for offset, line in enumerate(status_lines[-3:]):
|
||||
y = status_start + offset
|
||||
if y < height - 1:
|
||||
stdscr.addstr(y, 0, truncate(line, width - 1))
|
||||
|
||||
stdscr.addstr(height - 1, 0, truncate(message, width - 1), curses.A_DIM)
|
||||
stdscr.refresh()
|
||||
|
||||
|
||||
def tui_mode(args: argparse.Namespace) -> int:
|
||||
initial_output = (
|
||||
str(args.output) if args.output else str(Path.cwd() / "fake-media-output")
|
||||
)
|
||||
state: dict[str, str | bool] = {
|
||||
"Output Directory": initial_output,
|
||||
"Sizes": args.sizes,
|
||||
"Variants Per Size": str(args.variants_per_size),
|
||||
"Folder Count": str(args.folder_count),
|
||||
"Images Per Folder": str(args.images_per_folder),
|
||||
"Folder Prefix": args.folder_prefix,
|
||||
"File Prefix": args.file_prefix,
|
||||
"Seed": str(args.seed),
|
||||
"Clear Output": args.clear_output,
|
||||
}
|
||||
|
||||
def run(stdscr: curses.window) -> int:
|
||||
curses.curs_set(0)
|
||||
stdscr.keypad(True)
|
||||
|
||||
field_order = [
|
||||
"Output Directory",
|
||||
"Sizes",
|
||||
"Variants Per Size",
|
||||
"Folder Count",
|
||||
"Images Per Folder",
|
||||
"Folder Prefix",
|
||||
"File Prefix",
|
||||
"Seed",
|
||||
"Clear Output",
|
||||
]
|
||||
selected = 0
|
||||
message = "Fill in the fields, then press G to generate."
|
||||
status_lines: list[str] = []
|
||||
|
||||
while True:
|
||||
fields = []
|
||||
for key in field_order:
|
||||
value = state[key]
|
||||
if isinstance(value, bool):
|
||||
display = "yes" if value else "no"
|
||||
else:
|
||||
display = value
|
||||
fields.append((key, str(display)))
|
||||
|
||||
draw_screen(stdscr, fields, selected, message, status_lines)
|
||||
key = stdscr.getch()
|
||||
|
||||
if key in (ord("q"), ord("Q")):
|
||||
return 0
|
||||
if key in (curses.KEY_UP, ord("k")):
|
||||
selected = (selected - 1) % len(field_order)
|
||||
continue
|
||||
if key in (curses.KEY_DOWN, ord("j")):
|
||||
selected = (selected + 1) % len(field_order)
|
||||
continue
|
||||
|
||||
current_field = field_order[selected]
|
||||
|
||||
if key == ord(" ") and current_field == "Clear Output":
|
||||
state[current_field] = not bool(state[current_field])
|
||||
message = (
|
||||
f"{current_field} set to {'yes' if state[current_field] else 'no'}."
|
||||
)
|
||||
continue
|
||||
|
||||
if key in (10, 13):
|
||||
if current_field == "Clear Output":
|
||||
state[current_field] = not bool(state[current_field])
|
||||
message = f"{current_field} set to {'yes' if state[current_field] else 'no'}."
|
||||
continue
|
||||
|
||||
current_value = str(state[current_field])
|
||||
updated = prompt_text(
|
||||
stdscr,
|
||||
current_field,
|
||||
current_value,
|
||||
"Enter saves, Esc cancels",
|
||||
)
|
||||
if updated is not None and updated:
|
||||
state[current_field] = updated
|
||||
message = f"Updated {current_field}."
|
||||
elif updated == "":
|
||||
message = f"{current_field} cannot be empty."
|
||||
else:
|
||||
message = f"Canceled edit for {current_field}."
|
||||
continue
|
||||
|
||||
if key in (ord("g"), ord("G")):
|
||||
try:
|
||||
config = GeneratorConfig(
|
||||
output=Path(str(state["Output Directory"])),
|
||||
sizes=parse_sizes(str(state["Sizes"])),
|
||||
variants_per_size=int(str(state["Variants Per Size"])),
|
||||
folder_count=int(str(state["Folder Count"])),
|
||||
images_per_folder=int(str(state["Images Per Folder"])),
|
||||
folder_prefix=str(state["Folder Prefix"]),
|
||||
file_prefix=str(state["File Prefix"]),
|
||||
seed=int(str(state["Seed"])),
|
||||
clear_output=bool(state["Clear Output"]),
|
||||
)
|
||||
status_lines = ["Generating files..."]
|
||||
|
||||
def progress(
|
||||
folder_number: int, folder_total: int, total_files: int
|
||||
) -> None:
|
||||
status_lines[:] = [
|
||||
f"Output: {config.output.expanduser().resolve()}",
|
||||
f"Progress: folder {folder_number}/{folder_total}",
|
||||
f"Files written: {total_files}",
|
||||
]
|
||||
draw_screen(stdscr, fields, selected, message, status_lines)
|
||||
|
||||
summary = generate_collection(config, progress=progress)
|
||||
status_lines = [
|
||||
f"Output: {summary['output_dir']}",
|
||||
f"Unique variants: {summary['unique_variants']}",
|
||||
f"Files created: {summary['files_created']}",
|
||||
]
|
||||
message = "Generation complete. Press G to run again or Q to quit."
|
||||
except ValueError as exc:
|
||||
message = str(exc)
|
||||
continue
|
||||
|
||||
return 0
|
||||
|
||||
return curses.wrapper(run)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args()
|
||||
if args.no_tui:
|
||||
return cli_mode(args)
|
||||
return tui_mode(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user