Made it a pip project.
This commit is contained in:
commit
1a8cae62ca
|
@ -0,0 +1,2 @@
|
||||||
|
.DS_Store
|
||||||
|
.venv/
|
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2025
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
|
@ -0,0 +1,3 @@
|
||||||
|
include README.md
|
||||||
|
include LICENSE
|
||||||
|
include src/pynamer/config.yaml
|
|
@ -0,0 +1,123 @@
|
||||||
|
# PyNamer
|
||||||
|
|
||||||
|
PyNamer is a command-line tool that uses AI vision models to generate descriptive filenames for images. It analyzes the content of images and renames them with meaningful, descriptive filenames in snake_case format.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Uses LiteLLM to integrate with various vision-capable LLMs (default: GPT-4 Vision)
|
||||||
|
- Configurable via YAML config file
|
||||||
|
- Supports multiple image formats (jpg, jpeg, png, gif, webp)
|
||||||
|
- Dry-run mode to preview changes without renaming files
|
||||||
|
- Handles filename collisions automatically
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### Option 1: Install from PyPI (recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install pynamer
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 2: Install from source
|
||||||
|
|
||||||
|
1. Clone this repository
|
||||||
|
2. Install the package in development mode:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -e .
|
||||||
|
```
|
||||||
|
|
||||||
|
### Set up your API key
|
||||||
|
|
||||||
|
You need to set up your API key for the vision model:
|
||||||
|
|
||||||
|
- Set the appropriate environment variable (e.g., `OPENAI_API_KEY`), or
|
||||||
|
- Create a custom config file with your API key
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
PyNamer uses the following configuration file locations (in order of precedence):
|
||||||
|
|
||||||
|
1. Custom config file specified with `-c` or `--config` option
|
||||||
|
2. User config file at `~/.config/pynamer.yaml` (created automatically on first run)
|
||||||
|
3. Default config file included with the package
|
||||||
|
|
||||||
|
You can customize the following settings:
|
||||||
|
|
||||||
|
- LLM provider and model
|
||||||
|
- API key and endpoint
|
||||||
|
- Supported image formats
|
||||||
|
- Prompt templates for filename generation
|
||||||
|
|
||||||
|
Example configuration file:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
llm:
|
||||||
|
provider: "openai"
|
||||||
|
model: "gpt-4-vision-preview"
|
||||||
|
api_key: "your-api-key-here"
|
||||||
|
max_tokens: 100
|
||||||
|
temperature: 0.7
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
After installation, you can use PyNamer directly from the command line:
|
||||||
|
|
||||||
|
Basic usage:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pynamer path/to/image.jpg
|
||||||
|
```
|
||||||
|
|
||||||
|
Process multiple images:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pynamer image1.jpg image2.png image3.jpg
|
||||||
|
```
|
||||||
|
|
||||||
|
Use a different config file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pynamer -c custom_config.yaml image.jpg
|
||||||
|
```
|
||||||
|
|
||||||
|
Preview changes without renaming (dry run):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pynamer -d image.jpg
|
||||||
|
```
|
||||||
|
|
||||||
|
Enable verbose logging:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pynamer -v image.jpg
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
Input: `IMG_20230615_123456.jpg` (a photo of a cat sleeping on a window sill)
|
||||||
|
|
||||||
|
Output: `orange_cat_sleeping_on_sunny_windowsill.jpg`
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Building the package
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install build
|
||||||
|
python -m build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Installing in development mode
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -e .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Python 3.7+
|
||||||
|
- LiteLLM
|
||||||
|
- PyYAML
|
||||||
|
- Access to a vision-capable LLM API (OpenAI, Anthropic, etc.)
|
|
@ -0,0 +1,24 @@
|
||||||
|
# PyNamer Configuration
|
||||||
|
|
||||||
|
# LLM API Configuration
|
||||||
|
llm:
|
||||||
|
provider: "openai" # Provider name (openai, anthropic, etc.)
|
||||||
|
model: "gpt-4o-mini" # Model name
|
||||||
|
api_key: "" # Your API key (leave empty to use environment variable)
|
||||||
|
endpoint: "" # Custom endpoint URL (if using a proxy or alternative service)
|
||||||
|
max_tokens: 100 # Maximum tokens for response
|
||||||
|
temperature: 0.7 # Temperature for generation
|
||||||
|
|
||||||
|
# Image Processing
|
||||||
|
image:
|
||||||
|
supported_formats:
|
||||||
|
- ".jpg"
|
||||||
|
- ".jpeg"
|
||||||
|
- ".png"
|
||||||
|
- ".gif"
|
||||||
|
- ".webp"
|
||||||
|
|
||||||
|
# Prompt Configuration
|
||||||
|
prompt:
|
||||||
|
system_message: "You are a helpful assistant that generates concise, descriptive filenames for images. Focus on the main subject, key attributes, and context. Use snake_case format without special characters."
|
||||||
|
user_message: "Generate a descriptive filename for this image. The filename should be concise, specific, and use snake_case format. Do not include a file extension."
|
|
@ -0,0 +1,274 @@
|
||||||
|
#!/Volumes/SAM2/CODE/pynamer/.venv/bin/python
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
import yaml
|
||||||
|
from typing import Dict, List, Optional, Union
|
||||||
|
import litellm
|
||||||
|
from litellm import completion
|
||||||
|
import logging
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||||
|
)
|
||||||
|
logger = logging.getLogger('pynamer')
|
||||||
|
|
||||||
|
class PyNamer:
|
||||||
|
"""A tool to generate descriptive filenames for images using LLMs."""
|
||||||
|
|
||||||
|
def __init__(self, config_path: str = 'config.yaml'):
|
||||||
|
"""Initialize the PyNamer with configuration.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config_path: Path to the YAML configuration file
|
||||||
|
"""
|
||||||
|
self.config = self._load_config(config_path)
|
||||||
|
self._setup_llm()
|
||||||
|
|
||||||
|
def _load_config(self, config_path: str) -> Dict:
|
||||||
|
"""Load configuration from YAML file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config_path: Path to the configuration file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict containing configuration
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with open(config_path, 'r') as f:
|
||||||
|
config = yaml.safe_load(f)
|
||||||
|
logger.info(f"Loaded configuration from {config_path}")
|
||||||
|
return config
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to load configuration: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
def _setup_llm(self) -> None:
|
||||||
|
"""Set up the LLM client based on configuration."""
|
||||||
|
llm_config = self.config.get('llm', {})
|
||||||
|
|
||||||
|
# Set API key if provided in config
|
||||||
|
api_key = llm_config.get('api_key')
|
||||||
|
if api_key:
|
||||||
|
os.environ["OPENAI_API_KEY"] = api_key
|
||||||
|
|
||||||
|
# Set custom endpoint if provided
|
||||||
|
endpoint = llm_config.get('endpoint')
|
||||||
|
if endpoint:
|
||||||
|
os.environ["OPENAI_API_BASE"] = endpoint
|
||||||
|
|
||||||
|
self.model = llm_config.get('model', 'gpt-4-vision-preview')
|
||||||
|
self.max_tokens = llm_config.get('max_tokens', 100)
|
||||||
|
self.temperature = llm_config.get('temperature', 0.7)
|
||||||
|
|
||||||
|
logger.info(f"LLM setup complete. Using model: {self.model}")
|
||||||
|
|
||||||
|
def _encode_image(self, image_path: str) -> str:
|
||||||
|
"""Encode image to base64 for API submission.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
image_path: Path to the image file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Base64 encoded image string
|
||||||
|
"""
|
||||||
|
with open(image_path, "rb") as image_file:
|
||||||
|
return base64.b64encode(image_file.read()).decode('utf-8')
|
||||||
|
|
||||||
|
def _is_supported_format(self, file_path: str) -> bool:
|
||||||
|
"""Check if the file format is supported.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Path to the file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if supported, False otherwise
|
||||||
|
"""
|
||||||
|
supported_formats = self.config.get('image', {}).get('supported_formats', [])
|
||||||
|
file_ext = os.path.splitext(file_path)[1].lower()
|
||||||
|
return file_ext in supported_formats
|
||||||
|
|
||||||
|
def generate_filename(self, image_path: str) -> str:
|
||||||
|
"""Generate a descriptive filename for the image using LLM.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
image_path: Path to the image file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Generated filename (without extension)
|
||||||
|
"""
|
||||||
|
if not os.path.exists(image_path):
|
||||||
|
logger.error(f"Image not found: {image_path}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not self._is_supported_format(image_path):
|
||||||
|
logger.error(f"Unsupported file format: {image_path}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Encode image
|
||||||
|
base64_image = self._encode_image(image_path)
|
||||||
|
|
||||||
|
# Prepare messages for LLM
|
||||||
|
system_message = self.config.get('prompt', {}).get('system_message', '')
|
||||||
|
user_message = self.config.get('prompt', {}).get('user_message', '')
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
{"role": "system", "content": system_message},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": user_message},
|
||||||
|
{
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {"url": f"data:image/jpeg;base64,{base64_image}"}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
# Call LLM
|
||||||
|
response = completion(
|
||||||
|
model=self.model,
|
||||||
|
messages=messages,
|
||||||
|
max_tokens=self.max_tokens,
|
||||||
|
temperature=self.temperature
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extract filename from response
|
||||||
|
filename = response.choices[0].message.content.strip()
|
||||||
|
logger.info(f"Generated filename: {filename}")
|
||||||
|
return filename
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error generating filename: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def rename_image(self, image_path: str, dry_run: bool = False) -> Optional[str]:
|
||||||
|
"""Rename the image with a generated descriptive filename.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
image_path: Path to the image file
|
||||||
|
dry_run: If True, don't actually rename the file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
New path if successful, None otherwise
|
||||||
|
"""
|
||||||
|
# Generate filename
|
||||||
|
new_filename = self.generate_filename(image_path)
|
||||||
|
if not new_filename:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Clean up the filename (ensure snake_case, no special chars)
|
||||||
|
new_filename = new_filename.lower()
|
||||||
|
new_filename = ''.join(c if c.isalnum() else '_' for c in new_filename)
|
||||||
|
new_filename = new_filename.replace('__', '_').strip('_')
|
||||||
|
|
||||||
|
# Get original path components
|
||||||
|
path = Path(image_path)
|
||||||
|
directory = path.parent
|
||||||
|
extension = path.suffix
|
||||||
|
|
||||||
|
# Create new path
|
||||||
|
new_path = directory / f"{new_filename}{extension}"
|
||||||
|
|
||||||
|
# Rename file
|
||||||
|
if not dry_run:
|
||||||
|
try:
|
||||||
|
# Handle case where the new filename already exists
|
||||||
|
counter = 1
|
||||||
|
while new_path.exists():
|
||||||
|
new_path = directory / f"{new_filename}_{counter}{extension}"
|
||||||
|
counter += 1
|
||||||
|
|
||||||
|
path.rename(new_path)
|
||||||
|
logger.info(f"Renamed: {image_path} -> {new_path}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error renaming file: {e}")
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
logger.info(f"[DRY RUN] Would rename: {image_path} -> {new_path}")
|
||||||
|
|
||||||
|
return str(new_path)
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main entry point for the script."""
|
||||||
|
parser = argparse.ArgumentParser(description='Generate descriptive filenames for images using LLMs')
|
||||||
|
parser.add_argument('images', nargs='+', help='Paths to image files')
|
||||||
|
parser.add_argument('-c', '--config', default='config.yaml', help='Path to configuration file')
|
||||||
|
parser.add_argument('-d', '--dry-run', action='store_true', help='Preview changes without renaming files')
|
||||||
|
parser.add_argument('-v', '--verbose', action='store_true', help='Enable verbose output')
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Set logging level
|
||||||
|
if args.verbose:
|
||||||
|
logger.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
|
# Initialize PyNamer
|
||||||
|
namer = PyNamer(config_path=args.config)
|
||||||
|
|
||||||
|
# Process each image - handle image paths that might contain spaces
|
||||||
|
image_paths = []
|
||||||
|
|
||||||
|
# First, try to handle the case where the entire argument list is a single path with spaces
|
||||||
|
if len(args.images) > 1:
|
||||||
|
combined_path = ' '.join(args.images)
|
||||||
|
if os.path.exists(combined_path):
|
||||||
|
image_paths = [combined_path]
|
||||||
|
logger.info(f"Found image by combining all arguments: {combined_path}")
|
||||||
|
|
||||||
|
# If that didn't work, try to handle each argument individually
|
||||||
|
if not image_paths:
|
||||||
|
# Use a set to avoid duplicate processing
|
||||||
|
processed_paths = set()
|
||||||
|
|
||||||
|
for path in args.images:
|
||||||
|
# Check if the path exists as is
|
||||||
|
if os.path.exists(path) and path not in processed_paths:
|
||||||
|
image_paths.append(path)
|
||||||
|
processed_paths.add(path)
|
||||||
|
else:
|
||||||
|
# Try to find files that match the pattern
|
||||||
|
import glob
|
||||||
|
matching_files = glob.glob(f"*{path}*")
|
||||||
|
for file in matching_files:
|
||||||
|
if file not in processed_paths:
|
||||||
|
image_paths.append(file)
|
||||||
|
processed_paths.add(file)
|
||||||
|
|
||||||
|
if not matching_files:
|
||||||
|
logger.debug(f"Could not find any file matching '{path}'")
|
||||||
|
|
||||||
|
# Process each valid image path
|
||||||
|
if not image_paths:
|
||||||
|
print("Error: No valid image files found to process.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Remove duplicates while preserving order
|
||||||
|
unique_image_paths = []
|
||||||
|
seen = set()
|
||||||
|
for path in image_paths:
|
||||||
|
if path not in seen:
|
||||||
|
unique_image_paths.append(path)
|
||||||
|
seen.add(path)
|
||||||
|
|
||||||
|
# Process each image
|
||||||
|
for image_path in unique_image_paths:
|
||||||
|
if not os.path.exists(image_path):
|
||||||
|
logger.warning(f"Skipping non-existent file: {image_path}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
new_path = namer.rename_image(image_path, dry_run=args.dry_run)
|
||||||
|
if new_path:
|
||||||
|
print(f"{'[DRY RUN] ' if args.dry_run else ''}Renamed: {image_path} -> {new_path}")
|
||||||
|
else:
|
||||||
|
print(f"Failed to process: {image_path}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
|
@ -0,0 +1,11 @@
|
||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=42", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[tool.black]
|
||||||
|
line-length = 88
|
||||||
|
target-version = ['py37', 'py38', 'py39', 'py310']
|
||||||
|
|
||||||
|
[tool.isort]
|
||||||
|
profile = "black"
|
||||||
|
line_length = 88
|
|
@ -0,0 +1,2 @@
|
||||||
|
litellm>=1.10.0
|
||||||
|
pyyaml>=6.0
|
|
@ -0,0 +1,50 @@
|
||||||
|
from setuptools import setup, find_packages
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Read the contents of README.md
|
||||||
|
with open("README.md", encoding="utf-8") as f:
|
||||||
|
long_description = f.read()
|
||||||
|
|
||||||
|
# Read version from __init__.py
|
||||||
|
with open(os.path.join("src", "pynamer", "__init__.py"), encoding="utf-8") as f:
|
||||||
|
for line in f:
|
||||||
|
if line.startswith("__version__"):
|
||||||
|
version = line.split("=")[1].strip().strip('"').strip("'")
|
||||||
|
break
|
||||||
|
|
||||||
|
setup(
|
||||||
|
name="pynamer",
|
||||||
|
version=version,
|
||||||
|
description="Generate descriptive filenames for images using LLMs",
|
||||||
|
long_description=long_description,
|
||||||
|
long_description_content_type="text/markdown",
|
||||||
|
author="Your Name",
|
||||||
|
author_email="your.email@example.com",
|
||||||
|
url="https://github.com/yourusername/pynamer",
|
||||||
|
package_dir={"": "src"},
|
||||||
|
packages=find_packages(where="src"),
|
||||||
|
include_package_data=True,
|
||||||
|
package_data={
|
||||||
|
"pynamer": ["config.yaml"],
|
||||||
|
},
|
||||||
|
install_requires=[
|
||||||
|
"litellm>=1.10.0",
|
||||||
|
"pyyaml>=6.0",
|
||||||
|
],
|
||||||
|
python_requires=">=3.7",
|
||||||
|
entry_points={
|
||||||
|
"console_scripts": [
|
||||||
|
"pynamer=pynamer.cli:main",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
classifiers=[
|
||||||
|
"Development Status :: 3 - Alpha",
|
||||||
|
"Intended Audience :: Developers",
|
||||||
|
"License :: OSI Approved :: MIT License",
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
"Programming Language :: Python :: 3.7",
|
||||||
|
"Programming Language :: Python :: 3.8",
|
||||||
|
"Programming Language :: Python :: 3.9",
|
||||||
|
"Programming Language :: Python :: 3.10",
|
||||||
|
],
|
||||||
|
)
|
|
@ -0,0 +1,138 @@
|
||||||
|
Metadata-Version: 2.1
|
||||||
|
Name: pynamer
|
||||||
|
Version: 0.1.0
|
||||||
|
Summary: Generate descriptive filenames for images using LLMs
|
||||||
|
Home-page: https://github.com/yourusername/pynamer
|
||||||
|
Author: Your Name
|
||||||
|
Author-email: your.email@example.com
|
||||||
|
Classifier: Development Status :: 3 - Alpha
|
||||||
|
Classifier: Intended Audience :: Developers
|
||||||
|
Classifier: License :: OSI Approved :: MIT License
|
||||||
|
Classifier: Programming Language :: Python :: 3
|
||||||
|
Classifier: Programming Language :: Python :: 3.7
|
||||||
|
Classifier: Programming Language :: Python :: 3.8
|
||||||
|
Classifier: Programming Language :: Python :: 3.9
|
||||||
|
Classifier: Programming Language :: Python :: 3.10
|
||||||
|
Requires-Python: >=3.7
|
||||||
|
Description-Content-Type: text/markdown
|
||||||
|
License-File: LICENSE
|
||||||
|
Requires-Dist: litellm>=1.10.0
|
||||||
|
Requires-Dist: pyyaml>=6.0
|
||||||
|
|
||||||
|
# PyNamer
|
||||||
|
|
||||||
|
PyNamer is a command-line tool that uses AI vision models to generate descriptive filenames for images. It analyzes the content of images and renames them with meaningful, descriptive filenames in snake_case format.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Uses LiteLLM to integrate with various vision-capable LLMs (default: GPT-4 Vision)
|
||||||
|
- Configurable via YAML config file
|
||||||
|
- Supports multiple image formats (jpg, jpeg, png, gif, webp)
|
||||||
|
- Dry-run mode to preview changes without renaming files
|
||||||
|
- Handles filename collisions automatically
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### Option 1: Install from PyPI (recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install pynamer
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 2: Install from source
|
||||||
|
|
||||||
|
1. Clone this repository
|
||||||
|
2. Install the package in development mode:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -e .
|
||||||
|
```
|
||||||
|
|
||||||
|
### Set up your API key
|
||||||
|
|
||||||
|
You need to set up your API key for the vision model:
|
||||||
|
|
||||||
|
- Set the appropriate environment variable (e.g., `OPENAI_API_KEY`), or
|
||||||
|
- Create a custom config file with your API key
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
PyNamer comes with a default configuration, but you can create a custom config file to customize:
|
||||||
|
|
||||||
|
- LLM provider and model
|
||||||
|
- API key and endpoint
|
||||||
|
- Supported image formats
|
||||||
|
- Prompt templates for filename generation
|
||||||
|
|
||||||
|
Example custom config file (config.yaml):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
llm:
|
||||||
|
provider: "openai"
|
||||||
|
model: "gpt-4-vision-preview"
|
||||||
|
api_key: "your-api-key-here"
|
||||||
|
max_tokens: 100
|
||||||
|
temperature: 0.7
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
After installation, you can use PyNamer directly from the command line:
|
||||||
|
|
||||||
|
Basic usage:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pynamer path/to/image.jpg
|
||||||
|
```
|
||||||
|
|
||||||
|
Process multiple images:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pynamer image1.jpg image2.png image3.jpg
|
||||||
|
```
|
||||||
|
|
||||||
|
Use a different config file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pynamer -c custom_config.yaml image.jpg
|
||||||
|
```
|
||||||
|
|
||||||
|
Preview changes without renaming (dry run):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pynamer -d image.jpg
|
||||||
|
```
|
||||||
|
|
||||||
|
Enable verbose logging:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pynamer -v image.jpg
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
Input: `IMG_20230615_123456.jpg` (a photo of a cat sleeping on a window sill)
|
||||||
|
|
||||||
|
Output: `orange_cat_sleeping_on_sunny_windowsill.jpg`
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Building the package
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install build
|
||||||
|
python -m build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Installing in development mode
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -e .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Python 3.7+
|
||||||
|
- LiteLLM
|
||||||
|
- PyYAML
|
||||||
|
- Access to a vision-capable LLM API (OpenAI, Anthropic, etc.)
|
|
@ -0,0 +1,15 @@
|
||||||
|
LICENSE
|
||||||
|
MANIFEST.in
|
||||||
|
README.md
|
||||||
|
pyproject.toml
|
||||||
|
setup.py
|
||||||
|
src/pynamer/__init__.py
|
||||||
|
src/pynamer/cli.py
|
||||||
|
src/pynamer/config.yaml
|
||||||
|
src/pynamer/core.py
|
||||||
|
src/pynamer.egg-info/PKG-INFO
|
||||||
|
src/pynamer.egg-info/SOURCES.txt
|
||||||
|
src/pynamer.egg-info/dependency_links.txt
|
||||||
|
src/pynamer.egg-info/entry_points.txt
|
||||||
|
src/pynamer.egg-info/requires.txt
|
||||||
|
src/pynamer.egg-info/top_level.txt
|
|
@ -0,0 +1 @@
|
||||||
|
|
|
@ -0,0 +1,2 @@
|
||||||
|
[console_scripts]
|
||||||
|
pynamer = pynamer.cli:main
|
|
@ -0,0 +1,2 @@
|
||||||
|
litellm>=1.10.0
|
||||||
|
pyyaml>=6.0
|
|
@ -0,0 +1 @@
|
||||||
|
pynamer
|
|
@ -0,0 +1,3 @@
|
||||||
|
"""PyNamer - Generate descriptive filenames for images using LLMs."""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
@ -0,0 +1,103 @@
|
||||||
|
"""Command-line interface for PyNamer."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import glob
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from .core import PyNamer, logger
|
||||||
|
from .setup_utils import ensure_user_config_exists
|
||||||
|
|
||||||
|
def find_image_files(args_images: List[str]) -> List[str]:
|
||||||
|
"""Find image files from command line arguments.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
args_images: List of image paths from command line
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of valid image paths
|
||||||
|
"""
|
||||||
|
image_paths = []
|
||||||
|
|
||||||
|
# First, try to handle the case where the entire argument list is a single path with spaces
|
||||||
|
if len(args_images) > 1:
|
||||||
|
combined_path = ' '.join(args_images)
|
||||||
|
if os.path.exists(combined_path):
|
||||||
|
image_paths = [combined_path]
|
||||||
|
logger.info(f"Found image by combining all arguments: {combined_path}")
|
||||||
|
|
||||||
|
# If that didn't work, try to handle each argument individually
|
||||||
|
if not image_paths:
|
||||||
|
# Use a set to avoid duplicate processing
|
||||||
|
processed_paths = set()
|
||||||
|
|
||||||
|
for path in args_images:
|
||||||
|
# Check if the path exists as is
|
||||||
|
if os.path.exists(path) and path not in processed_paths:
|
||||||
|
image_paths.append(path)
|
||||||
|
processed_paths.add(path)
|
||||||
|
else:
|
||||||
|
# Try to find files that match the pattern
|
||||||
|
matching_files = glob.glob(f"*{path}*")
|
||||||
|
for file in matching_files:
|
||||||
|
if file not in processed_paths:
|
||||||
|
image_paths.append(file)
|
||||||
|
processed_paths.add(file)
|
||||||
|
|
||||||
|
if not matching_files:
|
||||||
|
logger.debug(f"Could not find any file matching '{path}'")
|
||||||
|
|
||||||
|
# Remove duplicates while preserving order
|
||||||
|
unique_image_paths = []
|
||||||
|
seen = set()
|
||||||
|
for path in image_paths:
|
||||||
|
if path not in seen:
|
||||||
|
unique_image_paths.append(path)
|
||||||
|
seen.add(path)
|
||||||
|
|
||||||
|
return unique_image_paths
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main entry point for the script."""
|
||||||
|
# Ensure the user config exists
|
||||||
|
ensure_user_config_exists()
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description='Generate descriptive filenames for images using LLMs')
|
||||||
|
parser.add_argument('images', nargs='+', help='Paths to image files')
|
||||||
|
parser.add_argument('-c', '--config', help='Path to configuration file')
|
||||||
|
parser.add_argument('-d', '--dry-run', action='store_true', help='Preview changes without renaming files')
|
||||||
|
parser.add_argument('-v', '--verbose', action='store_true', help='Enable verbose output')
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Set logging level
|
||||||
|
if args.verbose:
|
||||||
|
logger.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
|
# Initialize PyNamer
|
||||||
|
namer = PyNamer(config_path=args.config)
|
||||||
|
|
||||||
|
# Find image files
|
||||||
|
image_paths = find_image_files(args.images)
|
||||||
|
|
||||||
|
# Process each valid image path
|
||||||
|
if not image_paths:
|
||||||
|
print("Error: No valid image files found to process.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Process each image
|
||||||
|
for image_path in image_paths:
|
||||||
|
if not os.path.exists(image_path):
|
||||||
|
logger.warning(f"Skipping non-existent file: {image_path}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
new_path = namer.rename_image(image_path, dry_run=args.dry_run)
|
||||||
|
if new_path:
|
||||||
|
print(f"{'[DRY RUN] ' if args.dry_run else ''}Renamed: {image_path} -> {new_path}")
|
||||||
|
else:
|
||||||
|
print(f"Failed to process: {image_path}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
|
@ -0,0 +1,24 @@
|
||||||
|
# PyNamer Configuration
|
||||||
|
|
||||||
|
# LLM API Configuration
|
||||||
|
llm:
|
||||||
|
provider: "openai" # Provider name (openai, anthropic, etc.)
|
||||||
|
model: "gpt-4o-mini" # Model name
|
||||||
|
api_key: "" # Your API key (leave empty to use environment variable)
|
||||||
|
endpoint: "" # Custom endpoint URL (if using a proxy or alternative service)
|
||||||
|
max_tokens: 100 # Maximum tokens for response
|
||||||
|
temperature: 0.7 # Temperature for generation
|
||||||
|
|
||||||
|
# Image Processing
|
||||||
|
image:
|
||||||
|
supported_formats:
|
||||||
|
- ".jpg"
|
||||||
|
- ".jpeg"
|
||||||
|
- ".png"
|
||||||
|
- ".gif"
|
||||||
|
- ".webp"
|
||||||
|
|
||||||
|
# Prompt Configuration
|
||||||
|
prompt:
|
||||||
|
system_message: "You are a helpful assistant that generates concise, descriptive filenames for images. Focus on the main subject, key attributes, and context. Use snake_case format without special characters."
|
||||||
|
user_message: "Generate a descriptive filename for this image. The filename should be concise, specific, and use snake_case format. Do not include a file extension."
|
|
@ -0,0 +1,207 @@
|
||||||
|
"""Core functionality for PyNamer."""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
import yaml
|
||||||
|
from typing import Dict, List, Optional, Union
|
||||||
|
import litellm
|
||||||
|
from litellm import completion
|
||||||
|
import logging
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||||
|
)
|
||||||
|
logger = logging.getLogger('pynamer')
|
||||||
|
|
||||||
|
class PyNamer:
|
||||||
|
"""A tool to generate descriptive filenames for images using LLMs."""
|
||||||
|
|
||||||
|
def __init__(self, config_path: str = None):
|
||||||
|
"""Initialize the PyNamer with configuration.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config_path: Path to the YAML configuration file
|
||||||
|
"""
|
||||||
|
if config_path is None:
|
||||||
|
# Look for config in user's home directory first
|
||||||
|
user_config_path = os.path.expanduser("~/.config/pynamer.yaml")
|
||||||
|
if os.path.exists(user_config_path):
|
||||||
|
config_path = user_config_path
|
||||||
|
logger.info(f"Using user config from {user_config_path}")
|
||||||
|
else:
|
||||||
|
# Fall back to default config in package
|
||||||
|
config_path = os.path.join(os.path.dirname(__file__), 'config.yaml')
|
||||||
|
logger.info(f"Using default config from {config_path}")
|
||||||
|
|
||||||
|
self.config = self._load_config(config_path)
|
||||||
|
self._setup_llm()
|
||||||
|
|
||||||
|
def _load_config(self, config_path: str) -> Dict:
|
||||||
|
"""Load configuration from YAML file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config_path: Path to the configuration file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict containing configuration
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with open(config_path, 'r') as f:
|
||||||
|
config = yaml.safe_load(f)
|
||||||
|
logger.info(f"Loaded configuration from {config_path}")
|
||||||
|
return config
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to load configuration: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
def _setup_llm(self) -> None:
|
||||||
|
"""Set up the LLM client based on configuration."""
|
||||||
|
llm_config = self.config.get('llm', {})
|
||||||
|
|
||||||
|
# Set API key if provided in config
|
||||||
|
api_key = llm_config.get('api_key')
|
||||||
|
if api_key:
|
||||||
|
os.environ["OPENAI_API_KEY"] = api_key
|
||||||
|
|
||||||
|
# Set custom endpoint if provided
|
||||||
|
endpoint = llm_config.get('endpoint')
|
||||||
|
if endpoint:
|
||||||
|
os.environ["OPENAI_API_BASE"] = endpoint
|
||||||
|
|
||||||
|
self.model = llm_config.get('model', 'gpt-4-vision-preview')
|
||||||
|
self.max_tokens = llm_config.get('max_tokens', 100)
|
||||||
|
self.temperature = llm_config.get('temperature', 0.7)
|
||||||
|
|
||||||
|
logger.info(f"LLM setup complete. Using model: {self.model}")
|
||||||
|
|
||||||
|
def _encode_image(self, image_path: str) -> str:
|
||||||
|
"""Encode image to base64 for API submission.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
image_path: Path to the image file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Base64 encoded image string
|
||||||
|
"""
|
||||||
|
with open(image_path, "rb") as image_file:
|
||||||
|
return base64.b64encode(image_file.read()).decode('utf-8')
|
||||||
|
|
||||||
|
def _is_supported_format(self, file_path: str) -> bool:
|
||||||
|
"""Check if the file format is supported.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Path to the file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if supported, False otherwise
|
||||||
|
"""
|
||||||
|
supported_formats = self.config.get('image', {}).get('supported_formats', [])
|
||||||
|
file_ext = os.path.splitext(file_path)[1].lower()
|
||||||
|
return file_ext in supported_formats
|
||||||
|
|
||||||
|
def generate_filename(self, image_path: str) -> str:
|
||||||
|
"""Generate a descriptive filename for the image using LLM.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
image_path: Path to the image file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Generated filename (without extension)
|
||||||
|
"""
|
||||||
|
if not os.path.exists(image_path):
|
||||||
|
logger.error(f"Image not found: {image_path}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not self._is_supported_format(image_path):
|
||||||
|
logger.error(f"Unsupported file format: {image_path}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Encode image
|
||||||
|
base64_image = self._encode_image(image_path)
|
||||||
|
|
||||||
|
# Prepare messages for LLM
|
||||||
|
system_message = self.config.get('prompt', {}).get('system_message', '')
|
||||||
|
user_message = self.config.get('prompt', {}).get('user_message', '')
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
{"role": "system", "content": system_message},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": user_message},
|
||||||
|
{
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {"url": f"data:image/jpeg;base64,{base64_image}"}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
# Call LLM
|
||||||
|
response = completion(
|
||||||
|
model=self.model,
|
||||||
|
messages=messages,
|
||||||
|
max_tokens=self.max_tokens,
|
||||||
|
temperature=self.temperature
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extract filename from response
|
||||||
|
filename = response.choices[0].message.content.strip()
|
||||||
|
logger.info(f"Generated filename: {filename}")
|
||||||
|
return filename
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error generating filename: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def rename_image(self, image_path: str, dry_run: bool = False) -> Optional[str]:
|
||||||
|
"""Rename the image with a generated descriptive filename.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
image_path: Path to the image file
|
||||||
|
dry_run: If True, don't actually rename the file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
New path if successful, None otherwise
|
||||||
|
"""
|
||||||
|
# Generate filename
|
||||||
|
new_filename = self.generate_filename(image_path)
|
||||||
|
if not new_filename:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Clean up the filename (ensure snake_case, no special chars)
|
||||||
|
new_filename = new_filename.lower()
|
||||||
|
new_filename = ''.join(c if c.isalnum() else '_' for c in new_filename)
|
||||||
|
new_filename = new_filename.replace('__', '_').strip('_')
|
||||||
|
|
||||||
|
# Get original path components
|
||||||
|
path = Path(image_path)
|
||||||
|
directory = path.parent
|
||||||
|
extension = path.suffix
|
||||||
|
|
||||||
|
# Create new path
|
||||||
|
new_path = directory / f"{new_filename}{extension}"
|
||||||
|
|
||||||
|
# Rename file
|
||||||
|
if not dry_run:
|
||||||
|
try:
|
||||||
|
# Handle case where the new filename already exists
|
||||||
|
counter = 1
|
||||||
|
while new_path.exists():
|
||||||
|
new_path = directory / f"{new_filename}_{counter}{extension}"
|
||||||
|
counter += 1
|
||||||
|
|
||||||
|
path.rename(new_path)
|
||||||
|
logger.info(f"Renamed: {image_path} -> {new_path}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error renaming file: {e}")
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
logger.info(f"[DRY RUN] Would rename: {image_path} -> {new_path}")
|
||||||
|
|
||||||
|
return str(new_path)
|
|
@ -0,0 +1,59 @@
|
||||||
|
"""Utilities for setting up PyNamer."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import logging
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
logger = logging.getLogger('pynamer')
|
||||||
|
|
||||||
|
def ensure_user_config_exists():
|
||||||
|
"""
|
||||||
|
Ensure that the user config file exists in ~/.config/pynamer.yaml.
|
||||||
|
If it doesn't exist, copy the default config there.
|
||||||
|
"""
|
||||||
|
# Get the default config path from the package
|
||||||
|
default_config_path = os.path.join(os.path.dirname(__file__), 'config.yaml')
|
||||||
|
|
||||||
|
# Define the user config path
|
||||||
|
user_config_dir = os.path.expanduser("~/.config")
|
||||||
|
user_config_path = os.path.join(user_config_dir, "pynamer.yaml")
|
||||||
|
|
||||||
|
# Check if the user config already exists
|
||||||
|
if os.path.exists(user_config_path):
|
||||||
|
logger.info(f"User config already exists at {user_config_path}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create the ~/.config directory if it doesn't exist
|
||||||
|
if not os.path.exists(user_config_dir):
|
||||||
|
try:
|
||||||
|
os.makedirs(user_config_dir, exist_ok=True)
|
||||||
|
logger.info(f"Created directory: {user_config_dir}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to create directory {user_config_dir}: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Copy the default config to the user config path
|
||||||
|
try:
|
||||||
|
shutil.copy2(default_config_path, user_config_path)
|
||||||
|
logger.info(f"Copied default config to {user_config_path}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to copy default config to {user_config_path}: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Optionally, you can modify the user config here
|
||||||
|
# For example, to add a comment about it being a user-specific config
|
||||||
|
try:
|
||||||
|
with open(user_config_path, 'r') as f:
|
||||||
|
config = yaml.safe_load(f)
|
||||||
|
|
||||||
|
# Add a comment at the top of the file
|
||||||
|
with open(user_config_path, 'w') as f:
|
||||||
|
f.write("# PyNamer User Configuration\n")
|
||||||
|
f.write("# This file was automatically created during installation\n")
|
||||||
|
f.write("# You can modify this file to customize PyNamer's behavior\n\n")
|
||||||
|
yaml.dump(config, f, default_flow_style=False)
|
||||||
|
|
||||||
|
logger.info(f"Updated user config with comments")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to update user config with comments: {e}")
|
Loading…
Reference in New Issue