File size: 7,682 Bytes
7025ca1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
"""Batch CLI: `augmenator-batch --input ./photos --count 5`."""

from __future__ import annotations

import argparse
import json
import random
import re
import sys
from datetime import datetime, timezone
from pathlib import Path

from PIL import Image

from augmenator import run_pipeline
from augmenator.ai_tools import AI_TOOL_KEYWORD_IDS
from augmenator.keyword_catalog import AUGMENT_KEYWORDS
from augmenator.planner import warmup as warmup_planner

IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png"}

COMPOUND_PROMPTS = (
    "vintage warm look",
    "blur everything softly",
    "replace background with neon city at night and rotate",
    "cover and add cutout",
    "flip horizontally",
)


def collect_input_images(folder: Path) -> list[Path]:
    if not folder.is_dir():
        return []
    images = [
        path
        for path in folder.iterdir()
        if path.is_file() and path.suffix.lower() in IMAGE_SUFFIXES
    ]
    return sorted(images, key=lambda p: p.name.lower())


def build_prompt_pool(*, use_ai_tools: bool) -> list[str]:
    prompts: list[str] = []
    for keyword in AUGMENT_KEYWORDS:
        if not use_ai_tools and keyword.id in AI_TOOL_KEYWORD_IDS:
            continue
        if not use_ai_tools and keyword.spatial_extra and keyword.spatial_extra.get("avoid_text"):
            continue
        prompts.extend(keyword.phrases)
    prompts.extend(COMPOUND_PROMPTS)
    if not use_ai_tools:
        prompts = [
            p
            for p in prompts
            if "replace background" not in p.lower()
            and "avoid text" not in p.lower()
            and "not text" not in p.lower()
            and "except text" not in p.lower()
        ]
    return sorted(set(prompts))


def slugify(text: str, max_len: int = 48) -> str:
    slug = re.sub(r"[^a-z0-9]+", "_", text.lower()).strip("_")
    return slug[:max_len] or "augmentation"


def augment_image(
    source_path: Path,
    source_image: Image.Image,
    output_dir: Path,
    *,
    count: int,
    prompt_pool: list[str],
    use_ai_tools: bool,
    strength: float,
) -> tuple[list[dict], int]:
    """Create up to `count` augmentations for one source image. Returns manifest rows and created count."""
    items: list[dict] = []
    created = 0
    attempts = 0
    max_attempts = count * 8
    source_stem = slugify(source_path.stem, max_len=32)

    while created < count and attempts < max_attempts:
        attempts += 1
        instruction = random.choice(prompt_pool)
        result = run_pipeline(
            source_image,
            instruction,
            strength=strength,
            use_ai_tools=use_ai_tools,
        )
        if not result["supported"]:
            continue

        created += 1
        filename = f"{source_stem}_{created:03d}_{slugify(instruction)}.png"
        out_path = output_dir / filename
        result["image"].save(out_path)

        items.append(
            {
                "file": filename,
                "source": source_path.name,
                "instruction": instruction,
                "applied_tags": result["applied_tags"],
                "applied_spatial": result["applied_spatial"],
                "use_ai_tools": use_ai_tools,
            }
        )
        tags = ", ".join(result["applied_tags"]) or "(none)"
        print(f"  [{created}/{count}] {filename}  <-  {instruction!r}  [{tags}]")

    return items, created


def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Generate augmented images for each JPG/PNG in an input folder.",
    )
    parser.add_argument(
        "--input",
        "-i",
        required=True,
        type=Path,
        help="Input folder containing .jpg / .jpeg / .png images",
    )
    parser.add_argument(
        "--output",
        "-o",
        type=Path,
        default=Path("generated_augmentations"),
        help="Output directory for augmented images (default: generated_augmentations)",
    )
    parser.add_argument(
        "--count",
        "-n",
        type=int,
        default=5,
        metavar="N",
        help="Augmentations to create per input image (default: 5)",
    )
    parser.add_argument(
        "--ignore-ai-tools",
        dest="use_ai_tools",
        action="store_false",
        default=True,
        help=(
            "Skip AI-powered ops: OCR text avoidance, background replacement (rembg/Openverse), "
            "and neural style transfer"
        ),
    )
    parser.add_argument(
        "--strength",
        type=float,
        default=1.0,
        help="Augmentation strength (default: 1.0)",
    )
    parser.add_argument(
        "--seed",
        type=int,
        default=None,
        help="Random seed for reproducible prompt selection",
    )
    return parser.parse_args(argv)


def main(argv: list[str] | None = None) -> int:
    args = parse_args(argv)
    if args.count < 1:
        print("Error: --count must be at least 1", file=sys.stderr)
        return 1

    input_images = collect_input_images(args.input)
    if not input_images:
        print(
            f"Error: no .jpg / .jpeg / .png images found in {args.input}",
            file=sys.stderr,
        )
        return 1

    if args.seed is not None:
        random.seed(args.seed)

    prompt_pool = build_prompt_pool(use_ai_tools=args.use_ai_tools)
    if not prompt_pool:
        print("Error: no prompts available for the selected mode", file=sys.stderr)
        return 1

    print("Loading embedding planner...")
    warmup_planner()

    run_id = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
    run_dir = args.output / run_id
    run_dir.mkdir(parents=True, exist_ok=True)

    manifest_sources: list[dict] = []
    total_created = 0
    total_requested = len(input_images) * args.count
    partial = False

    print(f"Found {len(input_images)} input image(s). Creating {args.count} augmentation(s) each.\n")

    for source_path in input_images:
        print(f"{source_path.name}:")
        source_image = Image.open(source_path)
        items, created = augment_image(
            source_path,
            source_image,
            run_dir,
            count=args.count,
            prompt_pool=prompt_pool,
            use_ai_tools=args.use_ai_tools,
            strength=args.strength,
        )
        total_created += created
        if created < args.count:
            partial = True
            print(
                f"  Warning: only {created}/{args.count} augmentations for {source_path.name}",
                file=sys.stderr,
            )
        manifest_sources.append(
            {
                "source": source_path.name,
                "count_requested": args.count,
                "count_created": created,
                "items": items,
            }
        )
        print()

    manifest_path = run_dir / "manifest.json"
    manifest_path.write_text(
        json.dumps(
            {
                "input_folder": str(args.input.resolve()),
                "output_folder": str(run_dir.resolve()),
                "images_found": len(input_images),
                "count_per_image": args.count,
                "total_requested": total_requested,
                "total_created": total_created,
                "use_ai_tools": args.use_ai_tools,
                "strength": args.strength,
                "sources": manifest_sources,
            },
            indent=2,
        ),
        encoding="utf-8",
    )

    print(f"Done. Saved {total_created} image(s) to {run_dir.resolve()}")
    print(f"Manifest: {manifest_path}")
    return 2 if partial else 0


if __name__ == "__main__":
    raise SystemExit(main())