photonamer 0.1.0__tar.gz
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.
- photonamer-0.1.0/PKG-INFO +12 -0
- photonamer-0.1.0/README.md +70 -0
- photonamer-0.1.0/photonamer/__init__.py +0 -0
- photonamer-0.1.0/photonamer/cli.py +103 -0
- photonamer-0.1.0/photonamer/parser.py +82 -0
- photonamer-0.1.0/photonamer/test.py +73 -0
- photonamer-0.1.0/photonamer/utils.py +40 -0
- photonamer-0.1.0/photonamer/vision.py +45 -0
- photonamer-0.1.0/photonamer.egg-info/PKG-INFO +12 -0
- photonamer-0.1.0/photonamer.egg-info/SOURCES.txt +14 -0
- photonamer-0.1.0/photonamer.egg-info/dependency_links.txt +1 -0
- photonamer-0.1.0/photonamer.egg-info/entry_points.txt +2 -0
- photonamer-0.1.0/photonamer.egg-info/requires.txt +8 -0
- photonamer-0.1.0/photonamer.egg-info/top_level.txt +1 -0
- photonamer-0.1.0/pyproject.toml +24 -0
- photonamer-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: photonamer
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Autonomous AI Photo Namer using MLX
|
|
5
|
+
Requires-Dist: typer>=0.24.1
|
|
6
|
+
Requires-Dist: rich>=14.3.3
|
|
7
|
+
Requires-Dist: mlx-vlm>=0.4.2
|
|
8
|
+
Requires-Dist: Pillow>=12.1.1
|
|
9
|
+
Requires-Dist: exifread>=3.5.1
|
|
10
|
+
Requires-Dist: transformers>=5.4.0
|
|
11
|
+
Requires-Dist: qwen-vl-utils>=0.0.14
|
|
12
|
+
Requires-Dist: torchvision>=0.26.0
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# Photonamer: Autonomous AI Image File Renamer for Apple Silicon Macs
|
|
2
|
+
|
|
3
|
+
PhotoNamer is a fast, privacy-first CLI tool that uses local a Vision-Language Model (specifically Qwen2.5-VL) to analyze your photos and automatically rename them based on their visual composition, lighting, and mood.
|
|
4
|
+
|
|
5
|
+
Built specifically for Apple Silicon using the MLX framework, it processes heavy RAW and JPEG files entirely offline—meaning your personal photos never leave your Mac.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
* **True Visual Understanding:** Powered by Qwen2.5-VL, it looks at the image and extracts the subject, mood, lighting, and photographic principles.
|
|
9
|
+
* **100% Local & Private:** No API keys, no cloud uploads, no subscriptions. Everything runs on your own hardware.
|
|
10
|
+
* **Apple Silicon Optimized:** Uses Apple's native MLX framework for unified memory processing, keeping RAM usage perfectly stable even when processing thousands of photos.
|
|
11
|
+
* **Fail-Safe Dry Runs:** By default, the app runs in "Preview Mode" so you can see exactly how files will be renamed before altering your file system.
|
|
12
|
+
* **Highly Customizable:** Interactive wizard lets you build your naming template on the fly and choose your preferred casing (PascalCase, snake_case, UPPERCASE, lowercase).
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
### Prerequisites
|
|
19
|
+
* A Mac with an Apple Silicon chip (M1/M2/M3/M4), at least 16GB of RAM is recommended.
|
|
20
|
+
* Python 3.10 or newer.
|
|
21
|
+
|
|
22
|
+
### For Photographers & End-Users (Recommended)
|
|
23
|
+
The safest and easiest way to install PhotoNamer globally is using `pipx`. This ensures the heavy AI dependencies don't conflict with your Mac's system files.
|
|
24
|
+
|
|
25
|
+
1. Install `pipx` via Homebrew (if you haven't already):
|
|
26
|
+
```bash
|
|
27
|
+
brew install pipx
|
|
28
|
+
pipx ensurepath
|
|
29
|
+
```
|
|
30
|
+
2. Install the app via `pipx`:
|
|
31
|
+
```bash
|
|
32
|
+
pipx install git+https://github.com/Kevo-03/Automatic-Photo-Namer.git
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### For Developers
|
|
36
|
+
|
|
37
|
+
If you want to modify the source code or contribute, install it in editable mode:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
git clone https://github.com/Kevo-03/Automatic-Photo-Namer.git
|
|
41
|
+
cd Automatic-Photo-Namer
|
|
42
|
+
python3 -m venv .venv
|
|
43
|
+
source .venv/bin/activate
|
|
44
|
+
pip install -e .
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Usage
|
|
48
|
+
|
|
49
|
+
Navigate to any folder containing your photos (.jpg, .jpeg, .png, .nef) and simply run the command:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
photonamer
|
|
53
|
+
```
|
|
54
|
+
The interactive wizard will guide you through the process:
|
|
55
|
+
|
|
56
|
+
1. **Fields:** Choose what information you want in the filename (Options: date, subject, mood, lighting, principle).
|
|
57
|
+
2. **Separator:** Choose how fields are connected (e.g., _ or -).
|
|
58
|
+
3. **Casing Style:** Format the text (pascal, snake, upper, lower).
|
|
59
|
+
4. **Execution:** Confirm if you want a safe dry-run (Preview) or a live execution.
|
|
60
|
+
|
|
61
|
+
### Dry Run Example
|
|
62
|
+

|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
### Architecture Under the Hood
|
|
66
|
+
|
|
67
|
+
- **Engine:** Apple mlx-vlm for hardware-accelerated inference.
|
|
68
|
+
- **Model:** Qwen/Qwen2.5-VL-3B-Instruct for optimal speed-to-accuracy ratio.
|
|
69
|
+
- **Memory Management:** Implements isolated sequential processing. The 5GB AI model loads into unified memory exactly once, and Python's garbage collector destroys individual image tensors post-inference, preventing thermal throttling or RAM overflow during massive batch jobs.
|
|
70
|
+
- **CLI Framework:** Built with Typer and Rich for a beautiful, type-safe terminal experience.
|
|
File without changes
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from rich import print
|
|
4
|
+
from rich.progress import track
|
|
5
|
+
|
|
6
|
+
from . import vision
|
|
7
|
+
from . import parser
|
|
8
|
+
from . import utils
|
|
9
|
+
|
|
10
|
+
app = typer.Typer(help="Autonomous AI Photo Namer")
|
|
11
|
+
|
|
12
|
+
@app.command()
|
|
13
|
+
def process_photos(
|
|
14
|
+
folder: Path = typer.Argument(
|
|
15
|
+
Path.cwd(),
|
|
16
|
+
help="The directory containing the photos."
|
|
17
|
+
),
|
|
18
|
+
fields: str = typer.Option(
|
|
19
|
+
"date, subject, mood, principle",
|
|
20
|
+
"--fields", "-f",
|
|
21
|
+
prompt="\nWhat fields do you want in the filename? (comma-separated)\n[Options: date, subject, mood, lighting, principle] (Default: date, subject, mood, principle)",
|
|
22
|
+
show_default=False,
|
|
23
|
+
help="Comma-separated list of tags to include."
|
|
24
|
+
),
|
|
25
|
+
separator: str = typer.Option(
|
|
26
|
+
"_",
|
|
27
|
+
"--sep", "-s",
|
|
28
|
+
prompt="\nWhat separator should connect the words? (e.g., '_' or '-') (Default: '_')",
|
|
29
|
+
show_default=False,
|
|
30
|
+
help="Character to separate the fields."
|
|
31
|
+
),
|
|
32
|
+
casing: str = typer.Option(
|
|
33
|
+
"pascal",
|
|
34
|
+
"--casing", "-c",
|
|
35
|
+
prompt="\nWhich casing style? (pascal, snake, kebab, upper, lower) (Default: pascal)",
|
|
36
|
+
show_default=False,
|
|
37
|
+
help="Text formatting style."
|
|
38
|
+
),
|
|
39
|
+
dry_run: bool = typer.Option(
|
|
40
|
+
True,
|
|
41
|
+
"--execute", "-e",
|
|
42
|
+
prompt="\nIs this a safe dry-run? (Default: Yes)",
|
|
43
|
+
show_default=False,
|
|
44
|
+
help="Set to False to actually rename the files."
|
|
45
|
+
)
|
|
46
|
+
):
|
|
47
|
+
"""
|
|
48
|
+
Scans a folder of images, uses local AI to extract visual tags,
|
|
49
|
+
and renames them according to a custom template.
|
|
50
|
+
"""
|
|
51
|
+
valid_extensions = {".jpg", ".jpeg", ".png", ".nef"}
|
|
52
|
+
images = [p for p in folder.iterdir() if p.is_file() and p.suffix.lower() in valid_extensions]
|
|
53
|
+
|
|
54
|
+
if not images:
|
|
55
|
+
print(f"[bold red]No valid images found in {folder}[/bold red]")
|
|
56
|
+
raise typer.Exit()
|
|
57
|
+
|
|
58
|
+
allowed_fields = {"date", "subject", "mood", "lighting", "principle"}
|
|
59
|
+
|
|
60
|
+
selected_fields = [f.strip().lower() for f in fields.split(",")]
|
|
61
|
+
|
|
62
|
+
for field in selected_fields:
|
|
63
|
+
if field not in allowed_fields:
|
|
64
|
+
print(f"\n[bold red]Error: '{field}' is not a valid option.[/bold red]")
|
|
65
|
+
print(f"[yellow]Please choose from: {', '.join(allowed_fields)}[/yellow]\n")
|
|
66
|
+
raise typer.Exit(code=1)
|
|
67
|
+
|
|
68
|
+
generated_template = separator.join([f"{{{f}}}" for f in selected_fields])
|
|
69
|
+
|
|
70
|
+
print(f"\n[bold blue]Found {len(images)} images.[/bold blue]")
|
|
71
|
+
print(f"[dim]Generated Naming Template: {generated_template}[/dim]\n")
|
|
72
|
+
|
|
73
|
+
if dry_run:
|
|
74
|
+
print("[bold yellow]DRY RUN MODE ACTIVATED. No files will actually be changed.[/bold yellow]\n")
|
|
75
|
+
|
|
76
|
+
analyzer = vision.ImageAnalyzer()
|
|
77
|
+
|
|
78
|
+
for img_path in track(images, description="Processing Photos..."):
|
|
79
|
+
|
|
80
|
+
original_ext = img_path.suffix
|
|
81
|
+
date_str = utils.get_photo_date(img_path)
|
|
82
|
+
|
|
83
|
+
raw_ai_text = analyzer.analyze_image(img_path)
|
|
84
|
+
|
|
85
|
+
tags = parser.parse_ai_output(raw_ai_text)
|
|
86
|
+
|
|
87
|
+
base_name = parser.format_filename(tags, date_str, generated_template, casing)
|
|
88
|
+
|
|
89
|
+
new_path = utils.get_safe_filepath(folder, base_name, original_ext)
|
|
90
|
+
|
|
91
|
+
if dry_run:
|
|
92
|
+
print(f"[dim]Preview:[/dim] {img_path.name} [bold green]➔[/bold green] {new_path.name}")
|
|
93
|
+
else:
|
|
94
|
+
try:
|
|
95
|
+
img_path.rename(new_path)
|
|
96
|
+
print(f"[dim]Renamed:[/dim] {img_path.name} [bold green]➔[/bold green] {new_path.name}")
|
|
97
|
+
except Exception as e:
|
|
98
|
+
print(f"[bold red]Failed to rename {img_path.name}: {e}[/bold red]")
|
|
99
|
+
|
|
100
|
+
print("\n[bold green]Processing complete![/bold green]\n")
|
|
101
|
+
|
|
102
|
+
if __name__ == "__main__":
|
|
103
|
+
app()
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
def clean_json_string(raw_text: str) -> str:
|
|
4
|
+
"""Strips markdown and conversational filler from LLM outputs."""
|
|
5
|
+
text = raw_text.strip()
|
|
6
|
+
|
|
7
|
+
if text.startswith("```json"):
|
|
8
|
+
text = text[7:]
|
|
9
|
+
elif text.startswith("```"):
|
|
10
|
+
text = text[3:]
|
|
11
|
+
|
|
12
|
+
if text.endswith("```"):
|
|
13
|
+
text = text[:-3]
|
|
14
|
+
|
|
15
|
+
start_idx = text.find('{')
|
|
16
|
+
end_idx = text.rfind('}')
|
|
17
|
+
|
|
18
|
+
if start_idx != -1 and end_idx != -1:
|
|
19
|
+
return text[start_idx:end_idx+1]
|
|
20
|
+
|
|
21
|
+
return text.strip()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def parse_ai_output(raw_text: str) -> dict:
|
|
25
|
+
"""Safely converts the AI text into a dictionary, with fail-safes."""
|
|
26
|
+
cleaned_text = clean_json_string(raw_text)
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
return json.loads(cleaned_text)
|
|
30
|
+
except json.JSONDecodeError:
|
|
31
|
+
print(f"\nWarning: AI hallucinated invalid JSON. Skipping advanced tags.\nRaw text: {cleaned_text}")
|
|
32
|
+
return {
|
|
33
|
+
"subject": "UnknownSubject",
|
|
34
|
+
"mood": "UnknownMood",
|
|
35
|
+
"lighting": "UnknownLighting",
|
|
36
|
+
"photography_principle": "None"
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def format_filename(tags: dict, date_str: str, template: str, casing: str = "pascal") -> str:
|
|
41
|
+
"""
|
|
42
|
+
Injects the parsed tags into the user's template using their preferred casing.
|
|
43
|
+
Example template: "{date}_{subject}_{mood}_{principle}"
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def apply_casing(val: str, style: str) -> str:
|
|
47
|
+
if not val or val.lower() == "none":
|
|
48
|
+
return ""
|
|
49
|
+
|
|
50
|
+
clean_val = "".join(c for c in val if c.isalnum() or c.isspace())
|
|
51
|
+
|
|
52
|
+
if style == "pascal":
|
|
53
|
+
return clean_val.title().replace(" ", "") # -> RuleOfThirds
|
|
54
|
+
elif style == "snake":
|
|
55
|
+
return clean_val.lower().replace(" ", "_") # -> rule_of_thirds
|
|
56
|
+
elif style == "kebab":
|
|
57
|
+
return clean_val.lower().replace(" ", "-") # -> rule-of-thirds
|
|
58
|
+
elif style == "upper":
|
|
59
|
+
return clean_val.upper().replace(" ", "") # -> RULEOFTHIRDS
|
|
60
|
+
elif style == "lower":
|
|
61
|
+
return clean_val.lower().replace(" ", "") # -> ruleofthirds
|
|
62
|
+
else:
|
|
63
|
+
return clean_val.replace(" ", "") # Default fallback
|
|
64
|
+
|
|
65
|
+
formatted_tags = {
|
|
66
|
+
"date": date_str,
|
|
67
|
+
"subject": apply_casing(tags.get("subject", "Unknown"), casing),
|
|
68
|
+
"mood": apply_casing(tags.get("mood", "Neutral"), casing),
|
|
69
|
+
"lighting": apply_casing(tags.get("lighting", "Unknown"), casing),
|
|
70
|
+
"principle": apply_casing(tags.get("photography_principle", "None"), casing)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
base_name = template.format(**formatted_tags)
|
|
75
|
+
|
|
76
|
+
base_name = base_name.replace("__", "_").replace("--", "-").strip("_-")
|
|
77
|
+
|
|
78
|
+
return base_name
|
|
79
|
+
|
|
80
|
+
except KeyError as e:
|
|
81
|
+
print(f"\nWarning: Invalid template key {e}. Using default format.")
|
|
82
|
+
return f"{formatted_tags['date']}_{formatted_tags['subject']}"
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import mlx.core as mx
|
|
2
|
+
from mlx_vlm import load, generate
|
|
3
|
+
from mlx_vlm.prompt_utils import apply_chat_template
|
|
4
|
+
from mlx_vlm.utils import load_config
|
|
5
|
+
from PIL import Image
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
def clean_json_string(raw_text):
|
|
10
|
+
text = raw_text.strip()
|
|
11
|
+
|
|
12
|
+
if text.startswith("```json"):
|
|
13
|
+
text = text[7:]
|
|
14
|
+
elif text.startswith("```"):
|
|
15
|
+
text = text[3:]
|
|
16
|
+
|
|
17
|
+
if text.endswith("```"):
|
|
18
|
+
text = text[:-3]
|
|
19
|
+
|
|
20
|
+
start_idx = text.find('{')
|
|
21
|
+
end_idx = text.rfind('}')
|
|
22
|
+
|
|
23
|
+
if start_idx != -1 and end_idx != -1:
|
|
24
|
+
return text[start_idx:end_idx+1]
|
|
25
|
+
|
|
26
|
+
return text.strip()
|
|
27
|
+
|
|
28
|
+
model_path = "mlx-community/Qwen2.5-VL-7B-Instruct-4bit"
|
|
29
|
+
model, processor = load(model_path)
|
|
30
|
+
config = load_config(model_path)
|
|
31
|
+
|
|
32
|
+
image_path = "/Users/kivanc/Desktop/Reflection/DSC_1008.JPG"
|
|
33
|
+
og_extension = Path(image_path).suffix
|
|
34
|
+
img = Image.open(image_path)
|
|
35
|
+
max_size = 512
|
|
36
|
+
img.thumbnail((max_size, max_size), Image.Resampling.LANCZOS)
|
|
37
|
+
image = [img]
|
|
38
|
+
prompt = """
|
|
39
|
+
Analyze this image and extract its core visual characteristics.
|
|
40
|
+
You must output your response ONLY as a valid, parsable JSON object.
|
|
41
|
+
Do not include any conversational text, explanations, or markdown formatting (like ```json).
|
|
42
|
+
|
|
43
|
+
Use the following exact JSON schema:
|
|
44
|
+
{
|
|
45
|
+
"subject": "A 1-3 word description of the main subject (e.g., Galata Tower, Neon Sign, Golden Retriever)",
|
|
46
|
+
"mood": "A 1-2 word description of the emotional vibe or atmosphere (e.g., Vibrant, Moody, Melancholic, Uplifting)",
|
|
47
|
+
"lighting": "A 1-2 word description of the lighting condition (e.g., Golden Hour, Neon, Harsh, Soft, Low Key)",
|
|
48
|
+
"photography_principle": "The dominant photographic technique or composition rule used (e.g., Rule of Thirds, Reflection, Motion Blur, Bokeh, Symmetry, Leading Lines)"
|
|
49
|
+
}
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
formatted_prompt = apply_chat_template(
|
|
53
|
+
processor, config, prompt, num_images=len(image)
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
output = generate(model, processor, formatted_prompt, image, verbose=False)
|
|
57
|
+
|
|
58
|
+
raw_text = output.text
|
|
59
|
+
|
|
60
|
+
cleaned_text = clean_json_string(raw_text)
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
tags = json.loads(cleaned_text)
|
|
64
|
+
|
|
65
|
+
subject = tags.get("subject", "Unknown").title().replace(" ", "")
|
|
66
|
+
mood = tags.get("mood", "Neutral").title().replace(" ", "")
|
|
67
|
+
principle = tags.get("photography_principle", "None").title().replace(" ", "")
|
|
68
|
+
|
|
69
|
+
new_filename = f"{subject}_{mood}_{principle}{og_extension}"
|
|
70
|
+
print(f"\nSuccessfully parsed! Proposed filename: {new_filename}")
|
|
71
|
+
|
|
72
|
+
except json.JSONDecodeError:
|
|
73
|
+
print(f"\nError: Failed to parse JSON even after cleaning.\nCleaned Text: {cleaned_text}")
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
import exifread
|
|
4
|
+
|
|
5
|
+
def get_photo_date(image_path: Path) -> str:
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
with open(image_path, 'rb') as f:
|
|
9
|
+
tags = exifread.process_file(f, details=False, stop_tag="EXIF DateTimeOriginal")
|
|
10
|
+
|
|
11
|
+
if "EXIF DateTimeOriginal" in tags:
|
|
12
|
+
date_str = str(tags["EXIF DateTimeOriginal"])
|
|
13
|
+
date_obj = datetime.strptime(date_str, "%Y:%m:%d %H:%M:%S")
|
|
14
|
+
|
|
15
|
+
return date_obj.strftime("%Y%m%d")
|
|
16
|
+
|
|
17
|
+
except Exception as e:
|
|
18
|
+
print(f"Warning: Could not read EXIF date for {image_path.name}")
|
|
19
|
+
|
|
20
|
+
return "UnknownDate"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_safe_filepath(target_directory: Path, proposed_base_name: str, original_extension: str) -> Path:
|
|
24
|
+
"""
|
|
25
|
+
Checks if a filename exists. If it does, appends _1, _2 safely.
|
|
26
|
+
Example: NeonSign_Vibrant.JPG -> NeonSign_Vibrant_1.JPG
|
|
27
|
+
"""
|
|
28
|
+
proposed_path = target_directory / f"{proposed_base_name}{original_extension}"
|
|
29
|
+
|
|
30
|
+
if not proposed_path.exists():
|
|
31
|
+
return proposed_path
|
|
32
|
+
|
|
33
|
+
counter = 1
|
|
34
|
+
while True:
|
|
35
|
+
new_path = target_directory / f"{proposed_base_name}_{counter}{original_extension}"
|
|
36
|
+
|
|
37
|
+
if not new_path.exists():
|
|
38
|
+
return new_path
|
|
39
|
+
|
|
40
|
+
counter += 1
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from PIL import Image
|
|
2
|
+
|
|
3
|
+
class ImageAnalyzer:
|
|
4
|
+
def __init__(self):
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
os.environ["HF_HUB_DISABLE_PROGRESS_BARS"] = "1"
|
|
8
|
+
os.environ["TRANSFORMERS_VERBOSITY"] = "error"
|
|
9
|
+
|
|
10
|
+
from mlx_vlm import load
|
|
11
|
+
from mlx_vlm.utils import load_config
|
|
12
|
+
|
|
13
|
+
self.model_path = "mlx-community/Qwen2.5-VL-7B-Instruct-4bit"
|
|
14
|
+
self.model, self.processor = load(self.model_path)
|
|
15
|
+
self.config = load_config(self.model_path)
|
|
16
|
+
self.prompt = """
|
|
17
|
+
Analyze this image and extract its core visual characteristics.
|
|
18
|
+
You must output your response ONLY as a valid, parsable JSON object.
|
|
19
|
+
Do not include any conversational text, explanations, or markdown formatting (like ```json).
|
|
20
|
+
|
|
21
|
+
Use the following exact JSON schema:
|
|
22
|
+
{
|
|
23
|
+
"subject": "A 1-3 word description of the main subject (e.g., Galata Tower, Neon Sign, Golden Retriever)",
|
|
24
|
+
"mood": "A 1-2 word description of the emotional vibe or atmosphere (e.g., Vibrant, Moody, Melancholic, Uplifting)",
|
|
25
|
+
"lighting": "A 1-2 word description of the lighting condition (e.g., Golden Hour, Neon, Harsh, Soft, Low Key)",
|
|
26
|
+
"photography_principle": "The dominant photographic technique or composition rule used (e.g., Rule of Thirds, Reflection, Motion Blur, Bokeh, Symmetry, Leading Lines)"
|
|
27
|
+
}
|
|
28
|
+
"""
|
|
29
|
+
def analyze_image(self, image_path):
|
|
30
|
+
|
|
31
|
+
from mlx_vlm import generate
|
|
32
|
+
from mlx_vlm.prompt_utils import apply_chat_template
|
|
33
|
+
|
|
34
|
+
img = Image.open(image_path)
|
|
35
|
+
max_size = 512
|
|
36
|
+
img.thumbnail((max_size, max_size), Image.Resampling.LANCZOS)
|
|
37
|
+
image = [img]
|
|
38
|
+
|
|
39
|
+
formatted_prompt = apply_chat_template(
|
|
40
|
+
self.processor, self.config, self.prompt, num_images=len(image)
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
output = generate(self.model, self.processor, formatted_prompt, image, verbose=False)
|
|
44
|
+
return output.text
|
|
45
|
+
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: photonamer
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Autonomous AI Photo Namer using MLX
|
|
5
|
+
Requires-Dist: typer>=0.24.1
|
|
6
|
+
Requires-Dist: rich>=14.3.3
|
|
7
|
+
Requires-Dist: mlx-vlm>=0.4.2
|
|
8
|
+
Requires-Dist: Pillow>=12.1.1
|
|
9
|
+
Requires-Dist: exifread>=3.5.1
|
|
10
|
+
Requires-Dist: transformers>=5.4.0
|
|
11
|
+
Requires-Dist: qwen-vl-utils>=0.0.14
|
|
12
|
+
Requires-Dist: torchvision>=0.26.0
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
photonamer/__init__.py
|
|
4
|
+
photonamer/cli.py
|
|
5
|
+
photonamer/parser.py
|
|
6
|
+
photonamer/test.py
|
|
7
|
+
photonamer/utils.py
|
|
8
|
+
photonamer/vision.py
|
|
9
|
+
photonamer.egg-info/PKG-INFO
|
|
10
|
+
photonamer.egg-info/SOURCES.txt
|
|
11
|
+
photonamer.egg-info/dependency_links.txt
|
|
12
|
+
photonamer.egg-info/entry_points.txt
|
|
13
|
+
photonamer.egg-info/requires.txt
|
|
14
|
+
photonamer.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
photonamer
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "photonamer"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Autonomous AI Photo Namer using MLX"
|
|
9
|
+
dependencies = [
|
|
10
|
+
"typer>=0.24.1",
|
|
11
|
+
"rich>=14.3.3",
|
|
12
|
+
"mlx-vlm>=0.4.2",
|
|
13
|
+
"Pillow>=12.1.1",
|
|
14
|
+
"exifread>=3.5.1",
|
|
15
|
+
"transformers>=5.4.0",
|
|
16
|
+
"qwen-vl-utils>=0.0.14",
|
|
17
|
+
"torchvision>=0.26.0"
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.scripts]
|
|
21
|
+
photonamer = "photonamer.cli:app"
|
|
22
|
+
|
|
23
|
+
[tool.setuptools]
|
|
24
|
+
packages = ["photonamer"]
|