DageBjorne commited on
Commit
7025ca1
·
1 Parent(s): fd8b97e

Package project as pip-installable augmenator.

Browse files

Rename pipeline to augmenator, add pyproject.toml and PIL-first public API (augment/augment_batch), and keep Gradio as an optional UI extra.

LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dag Bjornberg
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md CHANGED
@@ -11,9 +11,60 @@ startup_duration_timeout: 1h
11
  pinned: false
12
  ---
13
 
14
- # Text-Driven Image Augmentation
15
 
16
- Upload an image and type an instruction. Transforms are selected by **semantic similarity** between your prompt and a catalog of augmentation keywords (1–5 matches above a cosine-similarity threshold).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
 
18
  ## How planning works
19
 
@@ -59,19 +110,14 @@ Object insertion (not background swap):
59
 
60
  - `put a table behind it`
61
 
62
- ## Local setup (Windows)
63
-
64
- Requires Python 3.10+.
65
 
66
  ```powershell
67
- cd "C:\Users\Dag Bjornberg\AI\cursor_pilot"
68
- python -m venv .venv
69
- .\.venv\Scripts\Activate.ps1
70
- pip install -r requirements.txt
71
  python app.py
72
  ```
73
 
74
- First run downloads the embedding model, RapidOCR, and rembg u2netp weights.
75
 
76
  ## Models
77
 
 
11
  pinned: false
12
  ---
13
 
14
+ # augmenator
15
 
16
+ Text-driven image augmentation. Transforms are selected by **semantic similarity** between your prompt and a catalog of augmentation keywords (1–5 matches above a cosine-similarity threshold).
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ pip install augmenator
22
+ # optional Gradio demo dependency
23
+ pip install "augmenator[ui]"
24
+ ```
25
+
26
+ Editable / local development:
27
+
28
+ ```powershell
29
+ cd "C:\Users\Dag Bjornberg\AI\augmenator"
30
+ python -m venv .venv
31
+ .\.venv\Scripts\Activate.ps1
32
+ pip install -e ".[ui]"
33
+ ```
34
+
35
+ First run downloads the embedding model, RapidOCR, rembg, and style weights as needed.
36
+
37
+ ## Python API
38
+
39
+ PIL in, PIL out — easy to loop over paired lists:
40
+
41
+ ```python
42
+ from PIL import Image
43
+ from augmenator import augment, augment_batch, warmup
44
+
45
+ warmup() # optional; loads the embedding planner once
46
+
47
+ image = Image.open("photo.jpg")
48
+ out = augment("make it brighter", image)
49
+
50
+ # Batch: zip instructions with images
51
+ outs = augment_batch(
52
+ ["blur softly", "cartoon style"],
53
+ [image, image],
54
+ )
55
+
56
+ # Or loop yourself
57
+ results = [augment(text, img) for text, img in zip(texts, images)]
58
+ ```
59
+
60
+ For planner metadata (tags, spatial ops, scores), use `augment_detailed(...)`.
61
+
62
+ Batch folder CLI:
63
+
64
+ ```bash
65
+ augmenator-batch --input ./photos --count 5
66
+ augmenator-batch --input ./photos --count 3 --ignore-ai-tools
67
+ ```
68
 
69
  ## How planning works
70
 
 
110
 
111
  - `put a table behind it`
112
 
113
+ ## Gradio demo
 
 
114
 
115
  ```powershell
116
+ pip install -e ".[ui]"
 
 
 
117
  python app.py
118
  ```
119
 
120
+ Live demo: [Hugging Face Space](https://huggingface.co/spaces/dagbjorn/text-driven-image-augmentation)
121
 
122
  ## Models
123
 
app.py CHANGED
@@ -1,4 +1,4 @@
1
- import os
2
 
3
  # HF Spaces often set HTTP_PROXY; without NO_PROXY, Gradio's localhost health check fails.
4
  os.environ.setdefault("NO_PROXY", "localhost,127.0.0.1,::1")
@@ -7,10 +7,10 @@ os.environ.setdefault("no_proxy", "localhost,127.0.0.1,::1")
7
  import gradio as gr
8
  from PIL import Image
9
 
10
- from pipeline import run_pipeline
11
- from pipeline.background_replace import warmup as warmup_rembg
12
- from pipeline.planner import warmup
13
- from pipeline.text_regions import warmup as warmup_ocr
14
 
15
  _warmed_up = False
16
 
@@ -145,9 +145,9 @@ with gr.Blocks(title="Text-Driven Image Augmentation") as demo:
145
  gr.Markdown(
146
  "# Text-Driven Image Augmentation\n"
147
  "Upload an image and describe what you want. Transforms are chosen by **semantic similarity** "
148
- "to augmentation keywords (1–5 matches above a similarity threshold). "
149
  "Background replacement uses CC-licensed photos from Openverse "
150
- "(web only describe any scene, e.g. `replace background with sunset over Paris`)."
151
  )
152
 
153
  with gr.Row():
@@ -192,3 +192,4 @@ if __name__ == "__main__":
192
  server_port=int(os.environ.get("PORT", 7860)),
193
  share=False,
194
  )
 
 
1
+ import os
2
 
3
  # HF Spaces often set HTTP_PROXY; without NO_PROXY, Gradio's localhost health check fails.
4
  os.environ.setdefault("NO_PROXY", "localhost,127.0.0.1,::1")
 
7
  import gradio as gr
8
  from PIL import Image
9
 
10
+ from augmenator import run_pipeline
11
+ from augmenator.background_replace import warmup as warmup_rembg
12
+ from augmenator.planner import warmup
13
+ from augmenator.text_regions import warmup as warmup_ocr
14
 
15
  _warmed_up = False
16
 
 
145
  gr.Markdown(
146
  "# Text-Driven Image Augmentation\n"
147
  "Upload an image and describe what you want. Transforms are chosen by **semantic similarity** "
148
+ "to augmentation keywords (1–5 matches above a similarity threshold). "
149
  "Background replacement uses CC-licensed photos from Openverse "
150
+ "(web only — describe any scene, e.g. `replace background with sunset over Paris`)."
151
  )
152
 
153
  with gr.Row():
 
192
  server_port=int(os.environ.get("PORT", 7860)),
193
  share=False,
194
  )
195
+
{pipeline → augmenator}/__init__.py RENAMED
@@ -1,8 +1,25 @@
 
 
 
 
 
 
1
  from PIL import Image
2
 
3
- from pipeline.ai_tools import strip_ai_tools_from_plan
4
- from pipeline.augment import apply_augmentations
5
- from pipeline.planner import plan_from_instruction
 
 
 
 
 
 
 
 
 
 
 
6
 
7
 
8
  def run_pipeline(
@@ -61,3 +78,60 @@ def run_pipeline(
61
  "image": augmented,
62
  "use_ai_tools": use_ai_tools,
63
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """augmenator — text-driven image augmentation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+
7
  from PIL import Image
8
 
9
+ from augmenator.ai_tools import strip_ai_tools_from_plan
10
+ from augmenator.augment import apply_augmentations
11
+ from augmenator.planner import plan_from_instruction
12
+ from augmenator.planner import warmup as warmup
13
+
14
+ __all__ = [
15
+ "augment",
16
+ "augment_batch",
17
+ "augment_detailed",
18
+ "run_pipeline",
19
+ "warmup",
20
+ ]
21
+
22
+ __version__ = "0.1.0"
23
 
24
 
25
  def run_pipeline(
 
78
  "image": augmented,
79
  "use_ai_tools": use_ai_tools,
80
  }
81
+
82
+
83
+ def augment(
84
+ instruction: str,
85
+ image: Image.Image,
86
+ *,
87
+ strength: float = 1.0,
88
+ use_ai_tools: bool = True,
89
+ ) -> Image.Image:
90
+ """Augment a single image from a text instruction. Returns a PIL image.
91
+
92
+ If the instruction is unsupported or stripped empty, returns ``image`` unchanged.
93
+ """
94
+ return run_pipeline(
95
+ image,
96
+ instruction,
97
+ strength=strength,
98
+ use_ai_tools=use_ai_tools,
99
+ )["image"]
100
+
101
+
102
+ def augment_batch(
103
+ instructions: Sequence[str],
104
+ images: Sequence[Image.Image],
105
+ *,
106
+ strength: float = 1.0,
107
+ use_ai_tools: bool = True,
108
+ ) -> list[Image.Image]:
109
+ """Augment paired lists of instructions and images.
110
+
111
+ ``instructions`` and ``images`` must be the same length.
112
+ """
113
+ if len(instructions) != len(images):
114
+ raise ValueError(
115
+ f"instructions and images must be the same length "
116
+ f"(got {len(instructions)} and {len(images)})"
117
+ )
118
+ return [
119
+ augment(instruction, image, strength=strength, use_ai_tools=use_ai_tools)
120
+ for instruction, image in zip(instructions, images)
121
+ ]
122
+
123
+
124
+ def augment_detailed(
125
+ instruction: str,
126
+ image: Image.Image,
127
+ *,
128
+ strength: float = 1.0,
129
+ use_ai_tools: bool = True,
130
+ ) -> dict:
131
+ """Like ``augment``, but returns the full result dict (tags, spatial, scores)."""
132
+ return run_pipeline(
133
+ image,
134
+ instruction,
135
+ strength=strength,
136
+ use_ai_tools=use_ai_tools,
137
+ )
{pipeline → augmenator}/ai_tools.py RENAMED
@@ -1,8 +1,8 @@
1
- """AI-powered pipeline features: OCR text avoidance, background replacement, neural style transfer."""
2
 
3
  from copy import deepcopy
4
 
5
- from pipeline.style_transfer import STYLE_TAGS
6
 
7
  AI_TOOL_KEYWORD_IDS = frozenset(
8
  {
@@ -46,3 +46,4 @@ def strip_ai_tools_from_plan(plan: dict) -> dict:
46
  if keyword_id not in AI_TOOL_KEYWORD_IDS
47
  ]
48
  return updated
 
 
1
+ """AI-powered pipeline features: OCR text avoidance, background replacement, neural style transfer."""
2
 
3
  from copy import deepcopy
4
 
5
+ from augmenator.style_transfer import STYLE_TAGS
6
 
7
  AI_TOOL_KEYWORD_IDS = frozenset(
8
  {
 
46
  if keyword_id not in AI_TOOL_KEYWORD_IDS
47
  ]
48
  return updated
49
+
{pipeline → augmenator}/augment.py RENAMED
@@ -1,11 +1,11 @@
1
- import random
2
 
3
  import cv2
4
  import numpy as np
5
  from PIL import Image, ImageEnhance, ImageFilter, ImageOps
6
 
7
- from pipeline.spatial import apply_spatial_ops
8
- from pipeline.style_transfer import STYLE_MODELS, STYLE_TAGS, apply_style
9
 
10
  ALLOWED_TAGS = {
11
  "brighten",
@@ -147,15 +147,15 @@ def _apply_tag(image: Image.Image, tag: str, strength: float) -> Image.Image:
147
  angle = random.uniform(0, 360)
148
  fill = (128, 128, 128) if image.mode == "RGB" else (128, 128, 128, 255)
149
  rotated = image.rotate(angle, expand=True, fillcolor=fill)
150
- return rotated, f"rotate({angle:.1f}°)"
151
  if tag == "rotate_90_random":
152
  degrees = random.choice([90, 270])
153
  direction = "CCW" if degrees == 90 else "CW"
154
  return _rotate_cardinal(image, degrees), f"rotate_90({direction})"
155
  if tag == "rotate_left":
156
- return _rotate_cardinal(image, 90), "rotate_left(90° CCW)"
157
  if tag == "rotate_right":
158
- return _rotate_cardinal(image, 270), "rotate_right(90° CW)"
159
  if tag == "rotate_180":
160
  return _rotate_cardinal(image, 180), "rotate_180"
161
  if tag == "flip":
@@ -266,3 +266,4 @@ def apply_augmentations(
266
  result = result.convert("RGB")
267
 
268
  return result, applied, applied_spatial
 
 
1
+ import random
2
 
3
  import cv2
4
  import numpy as np
5
  from PIL import Image, ImageEnhance, ImageFilter, ImageOps
6
 
7
+ from augmenator.spatial import apply_spatial_ops
8
+ from augmenator.style_transfer import STYLE_MODELS, STYLE_TAGS, apply_style
9
 
10
  ALLOWED_TAGS = {
11
  "brighten",
 
147
  angle = random.uniform(0, 360)
148
  fill = (128, 128, 128) if image.mode == "RGB" else (128, 128, 128, 255)
149
  rotated = image.rotate(angle, expand=True, fillcolor=fill)
150
+ return rotated, f"rotate({angle:.1f}°)"
151
  if tag == "rotate_90_random":
152
  degrees = random.choice([90, 270])
153
  direction = "CCW" if degrees == 90 else "CW"
154
  return _rotate_cardinal(image, degrees), f"rotate_90({direction})"
155
  if tag == "rotate_left":
156
+ return _rotate_cardinal(image, 90), "rotate_left(90° CCW)"
157
  if tag == "rotate_right":
158
+ return _rotate_cardinal(image, 270), "rotate_right(90° CW)"
159
  if tag == "rotate_180":
160
  return _rotate_cardinal(image, 180), "rotate_180"
161
  if tag == "flip":
 
266
  result = result.convert("RGB")
267
 
268
  return result, applied, applied_spatial
269
+
{pipeline → augmenator}/background_replace.py RENAMED
@@ -1,10 +1,10 @@
1
- import re
2
  from typing import Literal
3
 
4
  from PIL import Image
5
  from rembg import new_session, remove
6
 
7
- from pipeline.background_web import fetch_public_background
8
 
9
  BACKGROUND_TRIGGERS = (
10
  "replace background",
@@ -105,3 +105,4 @@ def replace_background(
105
  "requested_query": query,
106
  **pick_meta,
107
  }
 
 
1
+ import re
2
  from typing import Literal
3
 
4
  from PIL import Image
5
  from rembg import new_session, remove
6
 
7
+ from augmenator.background_web import fetch_public_background
8
 
9
  BACKGROUND_TRIGGERS = (
10
  "replace background",
 
105
  "requested_query": query,
106
  **pick_meta,
107
  }
108
+
{pipeline → augmenator}/background_web.py RENAMED
File without changes
augmenator/cli.py ADDED
@@ -0,0 +1,259 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Batch CLI: `augmenator-batch --input ./photos --count 5`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import random
8
+ import re
9
+ import sys
10
+ from datetime import datetime, timezone
11
+ from pathlib import Path
12
+
13
+ from PIL import Image
14
+
15
+ from augmenator import run_pipeline
16
+ from augmenator.ai_tools import AI_TOOL_KEYWORD_IDS
17
+ from augmenator.keyword_catalog import AUGMENT_KEYWORDS
18
+ from augmenator.planner import warmup as warmup_planner
19
+
20
+ IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png"}
21
+
22
+ COMPOUND_PROMPTS = (
23
+ "vintage warm look",
24
+ "blur everything softly",
25
+ "replace background with neon city at night and rotate",
26
+ "cover and add cutout",
27
+ "flip horizontally",
28
+ )
29
+
30
+
31
+ def collect_input_images(folder: Path) -> list[Path]:
32
+ if not folder.is_dir():
33
+ return []
34
+ images = [
35
+ path
36
+ for path in folder.iterdir()
37
+ if path.is_file() and path.suffix.lower() in IMAGE_SUFFIXES
38
+ ]
39
+ return sorted(images, key=lambda p: p.name.lower())
40
+
41
+
42
+ def build_prompt_pool(*, use_ai_tools: bool) -> list[str]:
43
+ prompts: list[str] = []
44
+ for keyword in AUGMENT_KEYWORDS:
45
+ if not use_ai_tools and keyword.id in AI_TOOL_KEYWORD_IDS:
46
+ continue
47
+ if not use_ai_tools and keyword.spatial_extra and keyword.spatial_extra.get("avoid_text"):
48
+ continue
49
+ prompts.extend(keyword.phrases)
50
+ prompts.extend(COMPOUND_PROMPTS)
51
+ if not use_ai_tools:
52
+ prompts = [
53
+ p
54
+ for p in prompts
55
+ if "replace background" not in p.lower()
56
+ and "avoid text" not in p.lower()
57
+ and "not text" not in p.lower()
58
+ and "except text" not in p.lower()
59
+ ]
60
+ return sorted(set(prompts))
61
+
62
+
63
+ def slugify(text: str, max_len: int = 48) -> str:
64
+ slug = re.sub(r"[^a-z0-9]+", "_", text.lower()).strip("_")
65
+ return slug[:max_len] or "augmentation"
66
+
67
+
68
+ def augment_image(
69
+ source_path: Path,
70
+ source_image: Image.Image,
71
+ output_dir: Path,
72
+ *,
73
+ count: int,
74
+ prompt_pool: list[str],
75
+ use_ai_tools: bool,
76
+ strength: float,
77
+ ) -> tuple[list[dict], int]:
78
+ """Create up to `count` augmentations for one source image. Returns manifest rows and created count."""
79
+ items: list[dict] = []
80
+ created = 0
81
+ attempts = 0
82
+ max_attempts = count * 8
83
+ source_stem = slugify(source_path.stem, max_len=32)
84
+
85
+ while created < count and attempts < max_attempts:
86
+ attempts += 1
87
+ instruction = random.choice(prompt_pool)
88
+ result = run_pipeline(
89
+ source_image,
90
+ instruction,
91
+ strength=strength,
92
+ use_ai_tools=use_ai_tools,
93
+ )
94
+ if not result["supported"]:
95
+ continue
96
+
97
+ created += 1
98
+ filename = f"{source_stem}_{created:03d}_{slugify(instruction)}.png"
99
+ out_path = output_dir / filename
100
+ result["image"].save(out_path)
101
+
102
+ items.append(
103
+ {
104
+ "file": filename,
105
+ "source": source_path.name,
106
+ "instruction": instruction,
107
+ "applied_tags": result["applied_tags"],
108
+ "applied_spatial": result["applied_spatial"],
109
+ "use_ai_tools": use_ai_tools,
110
+ }
111
+ )
112
+ tags = ", ".join(result["applied_tags"]) or "(none)"
113
+ print(f" [{created}/{count}] {filename} <- {instruction!r} [{tags}]")
114
+
115
+ return items, created
116
+
117
+
118
+ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
119
+ parser = argparse.ArgumentParser(
120
+ description="Generate augmented images for each JPG/PNG in an input folder.",
121
+ )
122
+ parser.add_argument(
123
+ "--input",
124
+ "-i",
125
+ required=True,
126
+ type=Path,
127
+ help="Input folder containing .jpg / .jpeg / .png images",
128
+ )
129
+ parser.add_argument(
130
+ "--output",
131
+ "-o",
132
+ type=Path,
133
+ default=Path("generated_augmentations"),
134
+ help="Output directory for augmented images (default: generated_augmentations)",
135
+ )
136
+ parser.add_argument(
137
+ "--count",
138
+ "-n",
139
+ type=int,
140
+ default=5,
141
+ metavar="N",
142
+ help="Augmentations to create per input image (default: 5)",
143
+ )
144
+ parser.add_argument(
145
+ "--ignore-ai-tools",
146
+ dest="use_ai_tools",
147
+ action="store_false",
148
+ default=True,
149
+ help=(
150
+ "Skip AI-powered ops: OCR text avoidance, background replacement (rembg/Openverse), "
151
+ "and neural style transfer"
152
+ ),
153
+ )
154
+ parser.add_argument(
155
+ "--strength",
156
+ type=float,
157
+ default=1.0,
158
+ help="Augmentation strength (default: 1.0)",
159
+ )
160
+ parser.add_argument(
161
+ "--seed",
162
+ type=int,
163
+ default=None,
164
+ help="Random seed for reproducible prompt selection",
165
+ )
166
+ return parser.parse_args(argv)
167
+
168
+
169
+ def main(argv: list[str] | None = None) -> int:
170
+ args = parse_args(argv)
171
+ if args.count < 1:
172
+ print("Error: --count must be at least 1", file=sys.stderr)
173
+ return 1
174
+
175
+ input_images = collect_input_images(args.input)
176
+ if not input_images:
177
+ print(
178
+ f"Error: no .jpg / .jpeg / .png images found in {args.input}",
179
+ file=sys.stderr,
180
+ )
181
+ return 1
182
+
183
+ if args.seed is not None:
184
+ random.seed(args.seed)
185
+
186
+ prompt_pool = build_prompt_pool(use_ai_tools=args.use_ai_tools)
187
+ if not prompt_pool:
188
+ print("Error: no prompts available for the selected mode", file=sys.stderr)
189
+ return 1
190
+
191
+ print("Loading embedding planner...")
192
+ warmup_planner()
193
+
194
+ run_id = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
195
+ run_dir = args.output / run_id
196
+ run_dir.mkdir(parents=True, exist_ok=True)
197
+
198
+ manifest_sources: list[dict] = []
199
+ total_created = 0
200
+ total_requested = len(input_images) * args.count
201
+ partial = False
202
+
203
+ print(f"Found {len(input_images)} input image(s). Creating {args.count} augmentation(s) each.\n")
204
+
205
+ for source_path in input_images:
206
+ print(f"{source_path.name}:")
207
+ source_image = Image.open(source_path)
208
+ items, created = augment_image(
209
+ source_path,
210
+ source_image,
211
+ run_dir,
212
+ count=args.count,
213
+ prompt_pool=prompt_pool,
214
+ use_ai_tools=args.use_ai_tools,
215
+ strength=args.strength,
216
+ )
217
+ total_created += created
218
+ if created < args.count:
219
+ partial = True
220
+ print(
221
+ f" Warning: only {created}/{args.count} augmentations for {source_path.name}",
222
+ file=sys.stderr,
223
+ )
224
+ manifest_sources.append(
225
+ {
226
+ "source": source_path.name,
227
+ "count_requested": args.count,
228
+ "count_created": created,
229
+ "items": items,
230
+ }
231
+ )
232
+ print()
233
+
234
+ manifest_path = run_dir / "manifest.json"
235
+ manifest_path.write_text(
236
+ json.dumps(
237
+ {
238
+ "input_folder": str(args.input.resolve()),
239
+ "output_folder": str(run_dir.resolve()),
240
+ "images_found": len(input_images),
241
+ "count_per_image": args.count,
242
+ "total_requested": total_requested,
243
+ "total_created": total_created,
244
+ "use_ai_tools": args.use_ai_tools,
245
+ "strength": args.strength,
246
+ "sources": manifest_sources,
247
+ },
248
+ indent=2,
249
+ ),
250
+ encoding="utf-8",
251
+ )
252
+
253
+ print(f"Done. Saved {total_created} image(s) to {run_dir.resolve()}")
254
+ print(f"Manifest: {manifest_path}")
255
+ return 2 if partial else 0
256
+
257
+
258
+ if __name__ == "__main__":
259
+ raise SystemExit(main())
{pipeline → augmenator}/embedding_planner.py RENAMED
@@ -1,7 +1,7 @@
1
- import numpy as np
2
  from sentence_transformers import SentenceTransformer
3
 
4
- from pipeline.keyword_catalog import AUGMENT_KEYWORDS, AugmentKeyword
5
 
6
  MODEL_ID = "sentence-transformers/all-MiniLM-L6-v2"
7
  SIMILARITY_THRESHOLD = 0.38
@@ -128,3 +128,4 @@ def select_keywords(instruction: str) -> list[tuple[AugmentKeyword, float]]:
128
  ]
129
  deduped = _apply_mutual_exclusion(above_threshold)
130
  return deduped[:MAX_SELECTIONS]
 
 
1
+ import numpy as np
2
  from sentence_transformers import SentenceTransformer
3
 
4
+ from augmenator.keyword_catalog import AUGMENT_KEYWORDS, AugmentKeyword
5
 
6
  MODEL_ID = "sentence-transformers/all-MiniLM-L6-v2"
7
  SIMILARITY_THRESHOLD = 0.38
 
128
  ]
129
  deduped = _apply_mutual_exclusion(above_threshold)
130
  return deduped[:MAX_SELECTIONS]
131
+
{pipeline → augmenator}/keyword_catalog.py RENAMED
File without changes
{pipeline → augmenator}/planner.py RENAMED
@@ -1,10 +1,10 @@
1
- import re
2
 
3
- from pipeline.background_replace import parse_background_query
4
- from pipeline.embedding_planner import select_keywords, warmup as warmup_embeddings
5
- from pipeline.keyword_catalog import AugmentKeyword
6
- from pipeline.spatial import infer_cover_count
7
- from pipeline.spatial_triggers import allows_spatial_keyword
8
 
9
  SPATIAL_OPS = {
10
  "cover_avoid_text",
@@ -31,7 +31,7 @@ GENERATIVE_PATTERNS = (
31
  UNSUPPORTED_REASON = (
32
  "This instruction needs generative editing (adding or replacing objects/scenes). "
33
  "v1 supports color transforms, classical edits, procedural cutouts, text-aware covering, "
34
- "and background replacement not object insertion."
35
  )
36
 
37
 
@@ -351,3 +351,4 @@ def plan_from_instruction(instruction: str) -> dict:
351
  "Try describing an effect more clearly, or use one of the example prompts below."
352
  ),
353
  }
 
 
1
+ import re
2
 
3
+ from augmenator.background_replace import parse_background_query
4
+ from augmenator.embedding_planner import select_keywords, warmup as warmup_embeddings
5
+ from augmenator.keyword_catalog import AugmentKeyword
6
+ from augmenator.spatial import infer_cover_count
7
+ from augmenator.spatial_triggers import allows_spatial_keyword
8
 
9
  SPATIAL_OPS = {
10
  "cover_avoid_text",
 
31
  UNSUPPORTED_REASON = (
32
  "This instruction needs generative editing (adding or replacing objects/scenes). "
33
  "v1 supports color transforms, classical edits, procedural cutouts, text-aware covering, "
34
+ "and background replacement — not object insertion."
35
  )
36
 
37
 
 
351
  "Try describing an effect more clearly, or use one of the example prompts below."
352
  ),
353
  }
354
+
{pipeline → augmenator}/spatial.py RENAMED
@@ -1,12 +1,12 @@
1
- import random
2
  import re
3
 
4
  import cv2
5
  import numpy as np
6
  from PIL import Image, ImageDraw
7
 
8
- from pipeline.background_replace import replace_background as do_replace_background
9
- from pipeline.text_regions import detect_text_boxes, find_safe_rect
10
 
11
  SHAPES = ("rounded_rect", "ellipse", "rect")
12
  MAX_CUTOUT_COUNT = 4
@@ -356,3 +356,4 @@ def apply_spatial_ops(
356
  result, meta = apply_spatial_op(result, op, strength, instruction)
357
  applied.append(meta)
358
  return result, applied
 
 
1
+ import random
2
  import re
3
 
4
  import cv2
5
  import numpy as np
6
  from PIL import Image, ImageDraw
7
 
8
+ from augmenator.background_replace import replace_background as do_replace_background
9
+ from augmenator.text_regions import detect_text_boxes, find_safe_rect
10
 
11
  SHAPES = ("rounded_rect", "ellipse", "rect")
12
  MAX_CUTOUT_COUNT = 4
 
356
  result, meta = apply_spatial_op(result, op, strength, instruction)
357
  applied.append(meta)
358
  return result, applied
359
+
{pipeline → augmenator}/spatial_triggers.py RENAMED
File without changes
{pipeline → augmenator}/style_net.py RENAMED
File without changes
{pipeline → augmenator}/style_transfer.py RENAMED
@@ -1,4 +1,4 @@
1
- """Neural style transfer: ONNX Model Zoo + PyTorch HF weights (CPU-friendly)."""
2
 
3
  from __future__ import annotations
4
 
@@ -14,8 +14,8 @@ from huggingface_hub import hf_hub_download
14
  from PIL import Image
15
  from torchvision import transforms
16
 
17
- from pipeline.style_net import StyleNet
18
- from pipeline.transform_net import TransformNet
19
 
20
  StyleBackend = Literal["onnx", "transformnet", "stylenet", "classical"]
21
 
@@ -239,3 +239,4 @@ def apply_style(image: Image.Image, tag: str, strength: float = 1.0) -> Image.Im
239
  styled = styled.resize((width, height), Image.Resampling.LANCZOS)
240
 
241
  return _blend_strength(original, styled, strength)
 
 
1
+ """Neural style transfer: ONNX Model Zoo + PyTorch HF weights (CPU-friendly)."""
2
 
3
  from __future__ import annotations
4
 
 
14
  from PIL import Image
15
  from torchvision import transforms
16
 
17
+ from augmenator.style_net import StyleNet
18
+ from augmenator.transform_net import TransformNet
19
 
20
  StyleBackend = Literal["onnx", "transformnet", "stylenet", "classical"]
21
 
 
239
  styled = styled.resize((width, height), Image.Resampling.LANCZOS)
240
 
241
  return _blend_strength(original, styled, strength)
242
+
{pipeline → augmenator}/text_regions.py RENAMED
File without changes
{pipeline → augmenator}/transform_net.py RENAMED
File without changes
pyproject.toml ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "augmenator"
7
+ version = "0.1.0"
8
+ description = "Text-driven image augmentation via embedding-matched keywords"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.10"
13
+ authors = [{ name = "Dag Bjornberg" }]
14
+ keywords = ["image", "augmentation", "style-transfer", "computer-vision"]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Intended Audience :: Developers",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Topic :: Scientific/Engineering :: Image Processing",
23
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
24
+ ]
25
+ dependencies = [
26
+ "torch>=2.1.0",
27
+ "torchvision>=0.16.0",
28
+ "transformers>=4.40.0",
29
+ "sentence-transformers>=2.7.0",
30
+ "Pillow>=10.0.0",
31
+ "numpy>=1.24.0",
32
+ "rapidocr-onnxruntime>=1.3.0",
33
+ "opencv-python-headless>=4.8.0",
34
+ "rembg[cpu]>=2.0.50",
35
+ "onnxruntime>=1.16.0",
36
+ "requests>=2.28.0",
37
+ "huggingface_hub>=0.20.0",
38
+ ]
39
+
40
+ [project.optional-dependencies]
41
+ ui = ["gradio>=5.23.1"]
42
+ dev = ["build", "twine"]
43
+
44
+ [project.urls]
45
+ Homepage = "https://huggingface.co/spaces/dagbjorn/text-driven-image-augmentation"
46
+ Repository = "https://huggingface.co/spaces/dagbjorn/text-driven-image-augmentation"
47
+
48
+ [project.scripts]
49
+ augmenator-batch = "augmenator.cli:main"
50
+
51
+ [tool.setuptools.packages.find]
52
+ include = ["augmenator*"]
53
+ exclude = ["scripts*", "backgrounds*", "generated_augmentations*"]
requirements.txt CHANGED
@@ -1,12 +1,2 @@
1
- torch>=2.1.0
2
- torchvision>=0.16.0
3
- transformers>=4.40.0
4
- sentence-transformers>=2.7.0
5
- gradio>=5.23.1
6
- Pillow>=10.0.0
7
- numpy>=1.24.0
8
- rapidocr-onnxruntime>=1.3.0
9
- opencv-python-headless>=4.8.0
10
- rembg[cpu]>=2.0.50
11
- onnxruntime>=1.16.0
12
- requests>=2.28.0
 
1
+ # Core package (also installed via pyproject.toml). Gradio is Space / UI-only.
2
+ -e .[ui]
 
 
 
 
 
 
 
 
 
 
scripts/generate_augmentations.py CHANGED
@@ -1,267 +1,14 @@
1
- """
2
  Batch-generate augmented images from every image in an input folder.
3
 
4
  Example:
5
  python scripts/generate_augmentations.py --input ./photos --count 5
6
- python scripts/generate_augmentations.py --input ./photos --count 3 --ignore-ai-tools
7
  """
8
 
9
  from __future__ import annotations
10
 
11
- import argparse
12
- import json
13
- import random
14
- import re
15
- import sys
16
- from datetime import datetime, timezone
17
- from pathlib import Path
18
-
19
- sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
20
-
21
- from PIL import Image
22
-
23
- from pipeline import run_pipeline
24
- from pipeline.ai_tools import AI_TOOL_KEYWORD_IDS
25
- from pipeline.keyword_catalog import AUGMENT_KEYWORDS
26
- from pipeline.planner import warmup as warmup_planner
27
-
28
- IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png"}
29
-
30
- COMPOUND_PROMPTS = (
31
- "vintage warm look",
32
- "blur everything softly",
33
- "replace background with neon city at night and rotate",
34
- "cover and add cutout",
35
- "flip horizontally",
36
- )
37
-
38
-
39
- def collect_input_images(folder: Path) -> list[Path]:
40
- if not folder.is_dir():
41
- return []
42
- images = [
43
- path
44
- for path in folder.iterdir()
45
- if path.is_file() and path.suffix.lower() in IMAGE_SUFFIXES
46
- ]
47
- return sorted(images, key=lambda p: p.name.lower())
48
-
49
-
50
- def build_prompt_pool(*, use_ai_tools: bool) -> list[str]:
51
- prompts: list[str] = []
52
- for keyword in AUGMENT_KEYWORDS:
53
- if not use_ai_tools and keyword.id in AI_TOOL_KEYWORD_IDS:
54
- continue
55
- if not use_ai_tools and keyword.spatial_extra and keyword.spatial_extra.get("avoid_text"):
56
- continue
57
- prompts.extend(keyword.phrases)
58
- prompts.extend(COMPOUND_PROMPTS)
59
- if not use_ai_tools:
60
- prompts = [
61
- p
62
- for p in prompts
63
- if "replace background" not in p.lower()
64
- and "avoid text" not in p.lower()
65
- and "not text" not in p.lower()
66
- and "except text" not in p.lower()
67
- ]
68
- return sorted(set(prompts))
69
-
70
-
71
- def slugify(text: str, max_len: int = 48) -> str:
72
- slug = re.sub(r"[^a-z0-9]+", "_", text.lower()).strip("_")
73
- return slug[:max_len] or "augmentation"
74
-
75
-
76
- def augment_image(
77
- source_path: Path,
78
- source_image: Image.Image,
79
- output_dir: Path,
80
- *,
81
- count: int,
82
- prompt_pool: list[str],
83
- use_ai_tools: bool,
84
- strength: float,
85
- ) -> tuple[list[dict], int]:
86
- """Create up to `count` augmentations for one source image. Returns manifest rows and created count."""
87
- items: list[dict] = []
88
- created = 0
89
- attempts = 0
90
- max_attempts = count * 8
91
- source_stem = slugify(source_path.stem, max_len=32)
92
-
93
- while created < count and attempts < max_attempts:
94
- attempts += 1
95
- instruction = random.choice(prompt_pool)
96
- result = run_pipeline(
97
- source_image,
98
- instruction,
99
- strength=strength,
100
- use_ai_tools=use_ai_tools,
101
- )
102
- if not result["supported"]:
103
- continue
104
-
105
- created += 1
106
- filename = f"{source_stem}_{created:03d}_{slugify(instruction)}.png"
107
- out_path = output_dir / filename
108
- result["image"].save(out_path)
109
-
110
- items.append(
111
- {
112
- "file": filename,
113
- "source": source_path.name,
114
- "instruction": instruction,
115
- "applied_tags": result["applied_tags"],
116
- "applied_spatial": result["applied_spatial"],
117
- "use_ai_tools": use_ai_tools,
118
- }
119
- )
120
- tags = ", ".join(result["applied_tags"]) or "(none)"
121
- print(f" [{created}/{count}] {filename} <- {instruction!r} [{tags}]")
122
-
123
- return items, created
124
-
125
-
126
- def parse_args() -> argparse.Namespace:
127
- parser = argparse.ArgumentParser(
128
- description="Generate augmented images for each JPG/PNG in an input folder.",
129
- )
130
- parser.add_argument(
131
- "--input",
132
- "-i",
133
- required=True,
134
- type=Path,
135
- help="Input folder containing .jpg / .jpeg / .png images",
136
- )
137
- parser.add_argument(
138
- "--output",
139
- "-o",
140
- type=Path,
141
- default=Path("generated_augmentations"),
142
- help="Output directory for augmented images (default: generated_augmentations)",
143
- )
144
- parser.add_argument(
145
- "--count",
146
- "-n",
147
- type=int,
148
- default=5,
149
- metavar="N",
150
- help="Augmentations to create per input image (default: 5)",
151
- )
152
- parser.add_argument(
153
- "--ignore-ai-tools",
154
- dest="use_ai_tools",
155
- action="store_false",
156
- default=True,
157
- help=(
158
- "Skip AI-powered ops: OCR text avoidance, background replacement (rembg/Openverse), "
159
- "and neural style transfer"
160
- ),
161
- )
162
- parser.add_argument(
163
- "--strength",
164
- type=float,
165
- default=1.0,
166
- help="Augmentation strength passed to the pipeline (default: 1.0)",
167
- )
168
- parser.add_argument(
169
- "--seed",
170
- type=int,
171
- default=None,
172
- help="Random seed for reproducible prompt selection",
173
- )
174
- return parser.parse_args()
175
-
176
-
177
- def main() -> int:
178
- args = parse_args()
179
- if args.count < 1:
180
- print("Error: --count must be at least 1", file=sys.stderr)
181
- return 1
182
-
183
- input_images = collect_input_images(args.input)
184
- if not input_images:
185
- print(
186
- f"Error: no .jpg / .jpeg / .png images found in {args.input}",
187
- file=sys.stderr,
188
- )
189
- return 1
190
-
191
- if args.seed is not None:
192
- random.seed(args.seed)
193
-
194
- prompt_pool = build_prompt_pool(use_ai_tools=args.use_ai_tools)
195
- if not prompt_pool:
196
- print("Error: no prompts available for the selected mode", file=sys.stderr)
197
- return 1
198
-
199
- print("Loading embedding planner...")
200
- warmup_planner()
201
-
202
- run_id = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
203
- run_dir = args.output / run_id
204
- run_dir.mkdir(parents=True, exist_ok=True)
205
-
206
- manifest_sources: list[dict] = []
207
- total_created = 0
208
- total_requested = len(input_images) * args.count
209
- partial = False
210
-
211
- print(f"Found {len(input_images)} input image(s). Creating {args.count} augmentation(s) each.\n")
212
-
213
- for source_path in input_images:
214
- print(f"{source_path.name}:")
215
- source_image = Image.open(source_path)
216
- items, created = augment_image(
217
- source_path,
218
- source_image,
219
- run_dir,
220
- count=args.count,
221
- prompt_pool=prompt_pool,
222
- use_ai_tools=args.use_ai_tools,
223
- strength=args.strength,
224
- )
225
- total_created += created
226
- if created < args.count:
227
- partial = True
228
- print(
229
- f" Warning: only {created}/{args.count} augmentations for {source_path.name}",
230
- file=sys.stderr,
231
- )
232
- manifest_sources.append(
233
- {
234
- "source": source_path.name,
235
- "count_requested": args.count,
236
- "count_created": created,
237
- "items": items,
238
- }
239
- )
240
- print()
241
-
242
- manifest_path = run_dir / "manifest.json"
243
- manifest_path.write_text(
244
- json.dumps(
245
- {
246
- "input_folder": str(args.input.resolve()),
247
- "output_folder": str(run_dir.resolve()),
248
- "images_found": len(input_images),
249
- "count_per_image": args.count,
250
- "total_requested": total_requested,
251
- "total_created": total_created,
252
- "use_ai_tools": args.use_ai_tools,
253
- "strength": args.strength,
254
- "sources": manifest_sources,
255
- },
256
- indent=2,
257
- ),
258
- encoding="utf-8",
259
- )
260
-
261
- print(f"Done. Saved {total_created} image(s) to {run_dir.resolve()}")
262
- print(f"Manifest: {manifest_path}")
263
- return 2 if partial else 0
264
-
265
 
266
  if __name__ == "__main__":
267
  raise SystemExit(main())
 
1
+ """
2
  Batch-generate augmented images from every image in an input folder.
3
 
4
  Example:
5
  python scripts/generate_augmentations.py --input ./photos --count 5
6
+ augmenator-batch --input ./photos --count 3 --ignore-ai-tools
7
  """
8
 
9
  from __future__ import annotations
10
 
11
+ from augmenator.cli import main
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
 
13
  if __name__ == "__main__":
14
  raise SystemExit(main())
scripts/validate_planner.py CHANGED
@@ -1,13 +1,10 @@
1
- import sys
2
- from pathlib import Path
3
-
4
- sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
5
 
6
  from PIL import Image
7
 
8
- from pipeline.embedding_planner import SIMILARITY_THRESHOLD
9
- from pipeline.planner import plan_from_instruction, warmup
10
- from pipeline.spatial import apply_spatial_ops
11
 
12
  warmup()
13
 
@@ -127,3 +124,4 @@ if failed:
127
  raise SystemExit(f"{failed} test(s) failed")
128
 
129
  print("All validation tests passed")
 
 
1
+ import sys
 
 
 
2
 
3
  from PIL import Image
4
 
5
+ from augmenator.embedding_planner import SIMILARITY_THRESHOLD
6
+ from augmenator.planner import plan_from_instruction, warmup
7
+ from augmenator.spatial import apply_spatial_ops
8
 
9
  warmup()
10
 
 
124
  raise SystemExit(f"{failed} test(s) failed")
125
 
126
  print("All validation tests passed")
127
+