refactor-cli 0.1.2__py3-none-any.whl → 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- refactor/cli.py +258 -23
- refactor/config.py +77 -0
- refactor/context_store.py +48 -0
- refactor/gemini.py +103 -0
- refactor/hardware.py +86 -0
- refactor/image_scanner.py +39 -0
- refactor/imagga.py +186 -0
- refactor/organizer.py +111 -0
- refactor/setup.py +175 -0
- refactor/shim.py +73 -0
- refactor_cli-0.2.0.dist-info/METADATA +107 -0
- refactor_cli-0.2.0.dist-info/RECORD +22 -0
- {refactor_cli-0.1.2.dist-info → refactor_cli-0.2.0.dist-info}/WHEEL +1 -1
- refactor_cli-0.1.2.dist-info/METADATA +0 -11
- refactor_cli-0.1.2.dist-info/RECORD +0 -13
- {refactor_cli-0.1.2.dist-info → refactor_cli-0.2.0.dist-info}/entry_points.txt +0 -0
- {refactor_cli-0.1.2.dist-info → refactor_cli-0.2.0.dist-info}/licenses/LICENSE +0 -0
- {refactor_cli-0.1.2.dist-info → refactor_cli-0.2.0.dist-info}/top_level.txt +0 -0
refactor/cli.py
CHANGED
|
@@ -1,51 +1,59 @@
|
|
|
1
1
|
import typer
|
|
2
|
-
from refactor.tree import print_tree
|
|
3
2
|
from importlib.metadata import version as get_version
|
|
4
|
-
|
|
5
|
-
from refactor.
|
|
6
|
-
from refactor.metadata import get_metadata
|
|
3
|
+
|
|
4
|
+
from refactor.context_store import DEFAULT_CACHE_FILE, save_context
|
|
7
5
|
from refactor.ignore import load_ignore_patterns
|
|
6
|
+
from refactor.image_scanner import scan_images
|
|
7
|
+
from refactor.metadata import get_metadata
|
|
8
|
+
from refactor.organizer import (
|
|
9
|
+
execute_moves,
|
|
10
|
+
managed_category_dirs,
|
|
11
|
+
plan_moves,
|
|
12
|
+
save_managed_category_dirs,
|
|
13
|
+
)
|
|
14
|
+
from refactor.scanner import scan_directory
|
|
15
|
+
from refactor.setup import command_setup, ensure_backend_configured, maybe_run_first_time_setup
|
|
16
|
+
from refactor.shim import build_image_classifier, choose_category_folders
|
|
17
|
+
from refactor.tree import print_tree
|
|
18
|
+
from refactor.validation import validate_path
|
|
8
19
|
|
|
9
20
|
app = typer.Typer(no_args_is_help=True)
|
|
10
21
|
|
|
22
|
+
|
|
23
|
+
@app.callback()
|
|
24
|
+
def bootstrap() -> None:
|
|
25
|
+
maybe_run_first_time_setup()
|
|
26
|
+
|
|
27
|
+
|
|
11
28
|
@app.command()
|
|
12
|
-
def version():
|
|
29
|
+
def version() -> None:
|
|
13
30
|
"""Print version information"""
|
|
14
31
|
print(get_version("refactor-cli"))
|
|
15
32
|
|
|
16
|
-
# @app.command()
|
|
17
|
-
# def tree(path: str = "."):
|
|
18
|
-
# """Print folder structure"""
|
|
19
|
-
# print_tree(path)
|
|
20
33
|
|
|
21
|
-
|
|
22
|
-
|
|
34
|
+
@app.command()
|
|
35
|
+
def setup() -> None:
|
|
36
|
+
"""Configure hosted or local multimodal backend."""
|
|
37
|
+
command_setup()
|
|
38
|
+
|
|
23
39
|
|
|
24
|
-
# validates folder
|
|
25
40
|
@app.command()
|
|
26
|
-
def tree(path: str = "."):
|
|
41
|
+
def tree(path: str = ".") -> None:
|
|
27
42
|
"""Display folder structure of given path"""
|
|
28
43
|
try:
|
|
29
44
|
validate_path(path)
|
|
30
45
|
print_tree(path)
|
|
31
|
-
|
|
32
46
|
except ValueError as e:
|
|
33
47
|
typer.echo(f"Error: {e}")
|
|
34
|
-
raise typer.Exit(code=1)
|
|
48
|
+
raise typer.Exit(code=1)
|
|
35
49
|
|
|
36
|
-
# scanner
|
|
37
|
-
# files = scan_directory(".")
|
|
38
|
-
# print(len(files))
|
|
39
50
|
|
|
40
51
|
@app.command()
|
|
41
|
-
def scan(path: str = "."):
|
|
52
|
+
def scan(path: str = ".") -> None:
|
|
42
53
|
"""Scan directory and display metadata"""
|
|
43
|
-
|
|
44
54
|
try:
|
|
45
55
|
validate_path(path)
|
|
46
|
-
|
|
47
56
|
patterns = load_ignore_patterns()
|
|
48
|
-
|
|
49
57
|
items = scan_directory(path, patterns)
|
|
50
58
|
|
|
51
59
|
for item in items:
|
|
@@ -53,4 +61,231 @@ def scan(path: str = "."):
|
|
|
53
61
|
|
|
54
62
|
except ValueError as e:
|
|
55
63
|
typer.echo(f"Error: {e}")
|
|
56
|
-
raise typer.Exit(code=1)
|
|
64
|
+
raise typer.Exit(code=1)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@app.command(name="get-image-context")
|
|
68
|
+
def get_image_context(
|
|
69
|
+
path: str = typer.Argument(".", help="Directory to scan for images"),
|
|
70
|
+
output: str = typer.Option(
|
|
71
|
+
".refactor_image_context.json",
|
|
72
|
+
"--output",
|
|
73
|
+
"-o",
|
|
74
|
+
help="Path to write the JSON context cache",
|
|
75
|
+
),
|
|
76
|
+
min_confidence: float = typer.Option(
|
|
77
|
+
40.0,
|
|
78
|
+
"--min-confidence",
|
|
79
|
+
help="Minimum tag confidence to include (hosted backend only)",
|
|
80
|
+
),
|
|
81
|
+
verbose: bool = typer.Option(
|
|
82
|
+
False,
|
|
83
|
+
"--verbose",
|
|
84
|
+
"-v",
|
|
85
|
+
help="Print per-image tag results to stdout",
|
|
86
|
+
),
|
|
87
|
+
category_root: str = typer.Option(
|
|
88
|
+
".",
|
|
89
|
+
"--category-root",
|
|
90
|
+
help="Directory whose existing folders Gemini may reuse",
|
|
91
|
+
),
|
|
92
|
+
write_context: bool = typer.Option(
|
|
93
|
+
True,
|
|
94
|
+
"--write-context/--no-write-context",
|
|
95
|
+
hidden=True,
|
|
96
|
+
),
|
|
97
|
+
) -> list:
|
|
98
|
+
"""
|
|
99
|
+
Scan directory images using configured backend shim and save
|
|
100
|
+
tags/captions to JSON context cache.
|
|
101
|
+
"""
|
|
102
|
+
from pathlib import Path
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
validate_path(path)
|
|
106
|
+
if not Path(category_root).exists() or not Path(category_root).is_dir():
|
|
107
|
+
raise ValueError(f"Category root is not a directory: {category_root}")
|
|
108
|
+
config = ensure_backend_configured()
|
|
109
|
+
except (ValueError, EnvironmentError) as e:
|
|
110
|
+
typer.echo(f"Error: {e}", err=True)
|
|
111
|
+
raise typer.Exit(code=1)
|
|
112
|
+
|
|
113
|
+
patterns = load_ignore_patterns()
|
|
114
|
+
source_root = Path(path).resolve()
|
|
115
|
+
destination_root = Path(category_root).resolve()
|
|
116
|
+
excluded_dirs = (
|
|
117
|
+
managed_category_dirs(destination_root)
|
|
118
|
+
if source_root == destination_root else set()
|
|
119
|
+
)
|
|
120
|
+
images = scan_images(path, patterns, excluded_dirs)
|
|
121
|
+
|
|
122
|
+
if not images:
|
|
123
|
+
typer.echo("No image files found in the specified directory.")
|
|
124
|
+
raise typer.Exit(code=0)
|
|
125
|
+
|
|
126
|
+
typer.echo(f"Found {len(images)} image(s) in '{path}'")
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
classifier = build_image_classifier(config, min_confidence=min_confidence)
|
|
130
|
+
except EnvironmentError as e:
|
|
131
|
+
typer.echo(f"Error: {e}", err=True)
|
|
132
|
+
raise typer.Exit(code=1)
|
|
133
|
+
|
|
134
|
+
results = []
|
|
135
|
+
failed = 0
|
|
136
|
+
|
|
137
|
+
with typer.progressbar(images, label="Analysing images") as progress:
|
|
138
|
+
for img_path in progress:
|
|
139
|
+
try:
|
|
140
|
+
ctx = classifier.classify_image(img_path)
|
|
141
|
+
results.append(ctx)
|
|
142
|
+
|
|
143
|
+
except Exception as exc:
|
|
144
|
+
typer.echo(f"\n [!] Skipping {img_path.name}: {exc}", err=True)
|
|
145
|
+
failed += 1
|
|
146
|
+
|
|
147
|
+
if results:
|
|
148
|
+
try:
|
|
149
|
+
results = choose_category_folders(config, results, Path(category_root))
|
|
150
|
+
except Exception as exc:
|
|
151
|
+
# Folder selection is an essential part of the hosted workflow; do
|
|
152
|
+
# not silently fall back to an Imagga top-tag directory.
|
|
153
|
+
typer.echo(f"Error: Gemini folder selection failed: {exc}", err=True)
|
|
154
|
+
raise typer.Exit(code=1)
|
|
155
|
+
|
|
156
|
+
if verbose:
|
|
157
|
+
for ctx in results:
|
|
158
|
+
typer.echo(f"\n {Path(ctx.file_path).name}")
|
|
159
|
+
typer.echo(f" Folder : {ctx.primary_folder}")
|
|
160
|
+
typer.echo(f" Tags : {', '.join(ctx.tags[:5])}")
|
|
161
|
+
typer.echo(f" Caption : {ctx.caption}")
|
|
162
|
+
|
|
163
|
+
output_path = Path(output)
|
|
164
|
+
if write_context:
|
|
165
|
+
save_context(results, path, output_path)
|
|
166
|
+
|
|
167
|
+
typer.echo(f"\n{'─' * 52}")
|
|
168
|
+
typer.echo(f" Processed : {len(results)} image(s)")
|
|
169
|
+
if failed:
|
|
170
|
+
typer.echo(f" Failed : {failed} image(s)")
|
|
171
|
+
if write_context:
|
|
172
|
+
typer.echo(f" Cache : {output_path.resolve()}")
|
|
173
|
+
typer.echo(f"{'─' * 52}")
|
|
174
|
+
|
|
175
|
+
if results:
|
|
176
|
+
typer.echo(f"\n{'Filename':<30} {'Folder':<20} {'Top tags'}")
|
|
177
|
+
typer.echo("─" * 80)
|
|
178
|
+
for ctx in results:
|
|
179
|
+
fname = Path(ctx.file_path).name
|
|
180
|
+
top_tags = ", ".join(ctx.tags[:3])
|
|
181
|
+
typer.echo(f" {fname:<28} {ctx.primary_folder:<20} {top_tags}")
|
|
182
|
+
|
|
183
|
+
return results
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@app.command(name="refactor-images")
|
|
187
|
+
def refactor_images(
|
|
188
|
+
path: str = typer.Argument(".", help="Directory containing images to organize"),
|
|
189
|
+
context: str = typer.Option(
|
|
190
|
+
".refactor_image_context.json",
|
|
191
|
+
"--context",
|
|
192
|
+
"-c",
|
|
193
|
+
help="Path to context cache (defaults to the source directory)",
|
|
194
|
+
),
|
|
195
|
+
output_dir: str = typer.Option(
|
|
196
|
+
".",
|
|
197
|
+
"--output-dir",
|
|
198
|
+
"-d",
|
|
199
|
+
help="Root directory where tag-named folders will be created",
|
|
200
|
+
),
|
|
201
|
+
dry_run: bool = typer.Option(
|
|
202
|
+
False,
|
|
203
|
+
"--dry-run",
|
|
204
|
+
help="Print the planned moves without executing them",
|
|
205
|
+
),
|
|
206
|
+
move: bool = typer.Option(
|
|
207
|
+
True,
|
|
208
|
+
"--move/--copy",
|
|
209
|
+
help="Move files into categories. Use --copy to preserve source files.",
|
|
210
|
+
),
|
|
211
|
+
min_confidence: float = typer.Option(
|
|
212
|
+
40.0,
|
|
213
|
+
"--min-confidence",
|
|
214
|
+
help="Minimum Imagga tag confidence to retain",
|
|
215
|
+
),
|
|
216
|
+
) -> None:
|
|
217
|
+
"""
|
|
218
|
+
Generate fresh Imagga and Gemini context, then organise images into
|
|
219
|
+
Gemini-selected subdirectories. The context cache is refreshed on every run.
|
|
220
|
+
|
|
221
|
+
By default, images are MOVED. Use --copy to preserve source files.
|
|
222
|
+
"""
|
|
223
|
+
from pathlib import Path
|
|
224
|
+
try:
|
|
225
|
+
validate_path(path)
|
|
226
|
+
out = Path(output_dir)
|
|
227
|
+
if not out.exists() or not out.is_dir():
|
|
228
|
+
raise ValueError(f"Output directory is not a directory: {output_dir}")
|
|
229
|
+
except ValueError as e:
|
|
230
|
+
typer.echo(f"Error: {e}", err=True)
|
|
231
|
+
raise typer.Exit(code=1)
|
|
232
|
+
|
|
233
|
+
context_path = Path(context)
|
|
234
|
+
if context == DEFAULT_CACHE_FILE:
|
|
235
|
+
context_path = Path(path) / DEFAULT_CACHE_FILE
|
|
236
|
+
|
|
237
|
+
excluded = managed_category_dirs(out) if Path(path).resolve() == out.resolve() else set()
|
|
238
|
+
if excluded:
|
|
239
|
+
typer.echo(f"Skipping {len(excluded)} folder(s) created by an earlier Refactor run.")
|
|
240
|
+
|
|
241
|
+
typer.echo("Generating image context with Imagga and Gemini...")
|
|
242
|
+
contexts = get_image_context(
|
|
243
|
+
path=path,
|
|
244
|
+
output=str(context_path),
|
|
245
|
+
min_confidence=min_confidence,
|
|
246
|
+
verbose=False,
|
|
247
|
+
category_root=output_dir,
|
|
248
|
+
write_context=not dry_run,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
if not contexts:
|
|
252
|
+
typer.echo("Context cache is empty. Nothing to organize.")
|
|
253
|
+
raise typer.Exit(code=0)
|
|
254
|
+
|
|
255
|
+
moves = plan_moves(contexts, out)
|
|
256
|
+
|
|
257
|
+
action_word = "Move" if move else "Copy"
|
|
258
|
+
|
|
259
|
+
if dry_run:
|
|
260
|
+
verb_plural = "moves" if move else "copies"
|
|
261
|
+
verb_past = "moved" if move else "copied"
|
|
262
|
+
typer.echo(f"\n[Dry Run] Planned {verb_plural} ({len(moves)} files):\n")
|
|
263
|
+
typer.echo(f" {'Source':<40} {'Destination'}")
|
|
264
|
+
typer.echo(" " + "─" * 76)
|
|
265
|
+
for src, dst in moves:
|
|
266
|
+
typer.echo(f" {src.name:<40} {dst}")
|
|
267
|
+
typer.echo(f"\n No files were {verb_past}.")
|
|
268
|
+
raise typer.Exit(code=0)
|
|
269
|
+
|
|
270
|
+
folders = {dst.parent.name for _, dst in moves}
|
|
271
|
+
typer.echo(f"\nWill {action_word.lower()} {len(moves)} image(s) into {len(folders)} folder(s):")
|
|
272
|
+
for folder in sorted(folders):
|
|
273
|
+
count = sum(1 for _, dst in moves if dst.parent.name == folder)
|
|
274
|
+
typer.echo(f" {out / folder} ({count} file(s))")
|
|
275
|
+
|
|
276
|
+
summary = execute_moves(moves, copy=not move)
|
|
277
|
+
if not summary["failed"]:
|
|
278
|
+
save_managed_category_dirs(out, folders)
|
|
279
|
+
|
|
280
|
+
typer.echo(f"\n{'─' * 52}")
|
|
281
|
+
count = summary["moved"] if move else summary["copied"]
|
|
282
|
+
verb_past = "moved" if move else "copied"
|
|
283
|
+
typer.echo(f" {verb_past.capitalize():<8}: {count} file(s)")
|
|
284
|
+
if summary["failed"]:
|
|
285
|
+
typer.echo(f" Failed : {summary['failed']} file(s)")
|
|
286
|
+
typer.echo(f" Output : {out.resolve()}")
|
|
287
|
+
typer.echo(f"{'─' * 52}")
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
if __name__ == "__main__":
|
|
291
|
+
app()
|
refactor/config.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
CONFIG_DIR = Path.home() / ".refactor"
|
|
8
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
9
|
+
ENV_FILE = CONFIG_DIR / ".env"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _ensure_config_dir() -> None:
|
|
13
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def default_config() -> dict[str, Any]:
|
|
17
|
+
return {
|
|
18
|
+
"version": 1,
|
|
19
|
+
"backend": "unconfigured",
|
|
20
|
+
"setup_skipped": False,
|
|
21
|
+
"hosted": {
|
|
22
|
+
"provider": "imagga_gemini",
|
|
23
|
+
"imagga_api_key_env": "IMAGGA_API_KEY",
|
|
24
|
+
"imagga_api_secret_env": "IMAGGA_API_SECRET",
|
|
25
|
+
"gemini_api_key_env": "GEMINI_API_KEY",
|
|
26
|
+
},
|
|
27
|
+
"local": {
|
|
28
|
+
"runtime": "ollama",
|
|
29
|
+
"recommended_model": "",
|
|
30
|
+
"status": "stub",
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def config_exists() -> bool:
|
|
36
|
+
return CONFIG_FILE.exists()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def load_config() -> dict[str, Any]:
|
|
40
|
+
if not CONFIG_FILE.exists():
|
|
41
|
+
return default_config()
|
|
42
|
+
|
|
43
|
+
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
|
|
44
|
+
payload = json.load(f)
|
|
45
|
+
|
|
46
|
+
merged = default_config()
|
|
47
|
+
merged.update(payload)
|
|
48
|
+
return merged
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def save_config(config: dict[str, Any]) -> None:
|
|
52
|
+
_ensure_config_dir()
|
|
53
|
+
|
|
54
|
+
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
|
|
55
|
+
json.dump(config, f, indent=2, ensure_ascii=False)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def save_env_vars(values: dict[str, str]) -> None:
|
|
59
|
+
_ensure_config_dir()
|
|
60
|
+
|
|
61
|
+
existing: dict[str, str] = {}
|
|
62
|
+
if ENV_FILE.exists():
|
|
63
|
+
for line in ENV_FILE.read_text(encoding="utf-8").splitlines():
|
|
64
|
+
if "=" not in line or line.strip().startswith("#"):
|
|
65
|
+
continue
|
|
66
|
+
key, value = line.split("=", 1)
|
|
67
|
+
existing[key.strip()] = value.strip()
|
|
68
|
+
|
|
69
|
+
existing.update(values)
|
|
70
|
+
|
|
71
|
+
lines = [f"{k}={v}" for k, v in existing.items()]
|
|
72
|
+
ENV_FILE.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
ENV_FILE.chmod(0o600)
|
|
76
|
+
except OSError:
|
|
77
|
+
pass
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""
|
|
2
|
+
refactor/context_store.py
|
|
3
|
+
~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
4
|
+
Manages reading and writing the JSON context cache produced by get-image-context.
|
|
5
|
+
|
|
6
|
+
Default cache filename: .refactor_image_context.json
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
import json
|
|
11
|
+
from dataclasses import asdict
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from refactor.imagga import ImageContext
|
|
16
|
+
|
|
17
|
+
DEFAULT_CACHE_FILE = ".refactor_image_context.json"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def save_context(
|
|
21
|
+
results: list[ImageContext],
|
|
22
|
+
source_dir: str,
|
|
23
|
+
output_path: Path,
|
|
24
|
+
) -> None:
|
|
25
|
+
"""Serialise a list of ImageContext objects to a JSON cache file."""
|
|
26
|
+
payload = {
|
|
27
|
+
"generated_at": datetime.now(timezone.utc).isoformat(),
|
|
28
|
+
"source_dir": str(Path(source_dir).resolve()),
|
|
29
|
+
"images": [asdict(ctx) for ctx in results],
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
with open(output_path, "w", encoding="utf-8") as f:
|
|
34
|
+
json.dump(payload, f, indent=2, ensure_ascii=False)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def load_context(input_path: Path) -> list[ImageContext]:
|
|
38
|
+
"""Load ImageContext objects from a JSON cache file."""
|
|
39
|
+
if not input_path.exists():
|
|
40
|
+
raise FileNotFoundError(
|
|
41
|
+
f"Context cache not found: {input_path}\n"
|
|
42
|
+
"Run 'refactor get-image-context <path>' first."
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
with open(input_path, "r", encoding="utf-8") as f:
|
|
46
|
+
payload = json.load(f)
|
|
47
|
+
|
|
48
|
+
return [ImageContext(**img) for img in payload.get("images", [])]
|
refactor/gemini.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Gemini-backed folder selection for Imagga image context."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import requests
|
|
11
|
+
from dotenv import load_dotenv
|
|
12
|
+
|
|
13
|
+
from refactor.config import ENV_FILE
|
|
14
|
+
from refactor.imagga import ImageContext
|
|
15
|
+
|
|
16
|
+
load_dotenv()
|
|
17
|
+
if ENV_FILE.exists():
|
|
18
|
+
load_dotenv(ENV_FILE)
|
|
19
|
+
|
|
20
|
+
# Current Gemini API default. Users may set GEMINI_MODEL to override it.
|
|
21
|
+
DEFAULT_MODEL = "gemini-3.8-flash"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _safe_folder_name(value: str) -> str:
|
|
25
|
+
"""Return one relative directory name, never a path supplied by the model."""
|
|
26
|
+
value = value.strip().lower()
|
|
27
|
+
value = re.sub(r"[^\w\s-]", "", value)
|
|
28
|
+
value = re.sub(r"\s+", "_", value).strip("_-")
|
|
29
|
+
return value or "uncategorized"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class GeminiFolderClassifier:
|
|
33
|
+
"""Ask Gemini to select consistent folders from Imagga tags and captions."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, api_key: str | None = None, model: str | None = None) -> None:
|
|
36
|
+
self.api_key = api_key or os.environ.get("GEMINI_API_KEY", "")
|
|
37
|
+
self.model = model or os.environ.get("GEMINI_MODEL", DEFAULT_MODEL)
|
|
38
|
+
if not self.api_key:
|
|
39
|
+
raise EnvironmentError(
|
|
40
|
+
"Gemini credentials not found. Set GEMINI_API_KEY in ~/.refactor/.env."
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
def choose_folders(
|
|
44
|
+
self, contexts: list[ImageContext], category_root: Path
|
|
45
|
+
) -> list[ImageContext]:
|
|
46
|
+
if not contexts:
|
|
47
|
+
return contexts
|
|
48
|
+
|
|
49
|
+
existing = sorted(
|
|
50
|
+
entry.name for entry in category_root.iterdir() if entry.is_dir()
|
|
51
|
+
) if category_root.exists() else []
|
|
52
|
+
images = [
|
|
53
|
+
{"id": index, "filename": Path(ctx.file_path).name,
|
|
54
|
+
"tags": ctx.tags, "caption": ctx.caption}
|
|
55
|
+
for index, ctx in enumerate(contexts)
|
|
56
|
+
]
|
|
57
|
+
prompt = (
|
|
58
|
+
"Choose exactly one concise, lowercase category folder for every image. "
|
|
59
|
+
"Prefer an existing category when it accurately fits; otherwise create a "
|
|
60
|
+
"new, broad and reusable category. Folder names must be a single relative "
|
|
61
|
+
"directory name (letters, numbers, spaces, underscores, or hyphens only). "
|
|
62
|
+
"Do not use filenames, paths, or explanations. Return JSON only in this "
|
|
63
|
+
"shape: {\"folders\":[{\"id\":0,\"folder\":\"example\"}]}.\n\n"
|
|
64
|
+
f"Existing categories: {json.dumps(existing)}\n"
|
|
65
|
+
f"Images and Imagga semantic context: {json.dumps(images, ensure_ascii=False)}"
|
|
66
|
+
)
|
|
67
|
+
payload = {
|
|
68
|
+
"contents": [{"parts": [{"text": prompt}]}],
|
|
69
|
+
"generationConfig": {
|
|
70
|
+
"responseMimeType": "application/json",
|
|
71
|
+
"temperature": 0,
|
|
72
|
+
},
|
|
73
|
+
}
|
|
74
|
+
url = (
|
|
75
|
+
"https://generativelanguage.googleapis.com/v1beta/models/"
|
|
76
|
+
f"{self.model}:generateContent"
|
|
77
|
+
)
|
|
78
|
+
response = requests.post(
|
|
79
|
+
url, headers={"x-goog-api-key": self.api_key}, json=payload, timeout=60
|
|
80
|
+
)
|
|
81
|
+
if not response.ok:
|
|
82
|
+
raise requests.HTTPError(
|
|
83
|
+
f"Gemini folder selection returned {response.status_code}: {response.text}",
|
|
84
|
+
response=response,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
text = response.json()["candidates"][0]["content"]["parts"][0]["text"]
|
|
89
|
+
choices = json.loads(text)["folders"]
|
|
90
|
+
except (KeyError, IndexError, TypeError, json.JSONDecodeError) as exc:
|
|
91
|
+
raise ValueError("Gemini returned an invalid folder-selection response") from exc
|
|
92
|
+
|
|
93
|
+
folders = {
|
|
94
|
+
choice.get("id"): _safe_folder_name(str(choice.get("folder", "")))
|
|
95
|
+
for choice in choices if isinstance(choice, dict) and isinstance(choice.get("id"), int)
|
|
96
|
+
}
|
|
97
|
+
missing = [str(index) for index in range(len(contexts)) if index not in folders]
|
|
98
|
+
if missing:
|
|
99
|
+
raise ValueError("Gemini did not choose a folder for image id(s): " + ", ".join(missing))
|
|
100
|
+
|
|
101
|
+
for index, context in enumerate(contexts):
|
|
102
|
+
context.primary_folder = folders[index]
|
|
103
|
+
return contexts
|
refactor/hardware.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class HardwareSpec:
|
|
11
|
+
cpu_cores: int
|
|
12
|
+
memory_gb: float
|
|
13
|
+
has_nvidia_gpu: bool
|
|
14
|
+
gpu_vram_gb: float | None = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _memory_gb() -> float:
|
|
18
|
+
pagesize = os.sysconf("SC_PAGE_SIZE")
|
|
19
|
+
pages = os.sysconf("SC_PHYS_PAGES")
|
|
20
|
+
return round((pagesize * pages) / (1024**3), 2)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _nvidia_vram_gb() -> float | None:
|
|
24
|
+
if not shutil.which("nvidia-smi"):
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
result = subprocess.run(
|
|
29
|
+
[
|
|
30
|
+
"nvidia-smi",
|
|
31
|
+
"--query-gpu=memory.total",
|
|
32
|
+
"--format=csv,noheader,nounits",
|
|
33
|
+
],
|
|
34
|
+
capture_output=True,
|
|
35
|
+
text=True,
|
|
36
|
+
check=True,
|
|
37
|
+
)
|
|
38
|
+
except Exception:
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
values = []
|
|
42
|
+
for line in result.stdout.splitlines():
|
|
43
|
+
line = line.strip()
|
|
44
|
+
if not line:
|
|
45
|
+
continue
|
|
46
|
+
try:
|
|
47
|
+
values.append(float(line) / 1024)
|
|
48
|
+
except ValueError:
|
|
49
|
+
continue
|
|
50
|
+
|
|
51
|
+
return round(max(values), 2) if values else None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def detect_hardware() -> HardwareSpec:
|
|
55
|
+
vram = _nvidia_vram_gb()
|
|
56
|
+
return HardwareSpec(
|
|
57
|
+
cpu_cores=os.cpu_count() or 1,
|
|
58
|
+
memory_gb=_memory_gb(),
|
|
59
|
+
has_nvidia_gpu=vram is not None,
|
|
60
|
+
gpu_vram_gb=vram,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def recommend_local_model(spec: HardwareSpec) -> tuple[str, str]:
|
|
65
|
+
if spec.has_nvidia_gpu and (spec.gpu_vram_gb or 0) >= 8:
|
|
66
|
+
return (
|
|
67
|
+
"llava:7b",
|
|
68
|
+
"Good fit for GPU-backed image understanding with balanced quality/speed.",
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
if spec.memory_gb >= 16:
|
|
72
|
+
return (
|
|
73
|
+
"llava:7b-q4",
|
|
74
|
+
"Reasonable CPU-only option with quantization for 16GB+ RAM systems.",
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
if spec.memory_gb >= 8:
|
|
78
|
+
return (
|
|
79
|
+
"moondream",
|
|
80
|
+
"Lightweight option for lower-memory systems focused on image captioning.",
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
"nanollava",
|
|
85
|
+
"Very small fallback model for constrained hardware; expect limited accuracy.",
|
|
86
|
+
)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""
|
|
2
|
+
refactor/image_scanner.py
|
|
3
|
+
~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
4
|
+
Scans a directory for image files only.
|
|
5
|
+
Extends the base scanner with image-extension filtering.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from refactor.ignore import should_ignore
|
|
11
|
+
|
|
12
|
+
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".tif", ".webp"}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def scan_images(
|
|
16
|
+
path: str, ignore_patterns: list[str], excluded_dirs: set[Path] | None = None
|
|
17
|
+
) -> list[Path]:
|
|
18
|
+
"""
|
|
19
|
+
Recursively scan *path* and return all image files.
|
|
20
|
+
|
|
21
|
+
Files matching any pattern in *ignore_patterns* are skipped.
|
|
22
|
+
"""
|
|
23
|
+
images: list[Path] = []
|
|
24
|
+
excluded_dirs = {directory.resolve() for directory in (excluded_dirs or set())}
|
|
25
|
+
|
|
26
|
+
for item in Path(path).rglob("*"):
|
|
27
|
+
if item.is_dir():
|
|
28
|
+
continue
|
|
29
|
+
|
|
30
|
+
if any(directory in item.resolve().parents for directory in excluded_dirs):
|
|
31
|
+
continue
|
|
32
|
+
|
|
33
|
+
if should_ignore(item.name, ignore_patterns):
|
|
34
|
+
continue
|
|
35
|
+
|
|
36
|
+
if item.suffix.lower() in IMAGE_EXTENSIONS:
|
|
37
|
+
images.append(item)
|
|
38
|
+
|
|
39
|
+
return images
|