chore: Update everything
This commit is contained in:
Executable
+418
@@ -0,0 +1,418 @@
|
||||
#!/usr/bin/env -S uv run --script
|
||||
# /// script
|
||||
# dependencies = ["httpx", "pillow"]
|
||||
# ///
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from dataclasses import dataclass
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
from sort_images import read_dims
|
||||
|
||||
MODEL_ID = "flux-2-klein-9b"
|
||||
# LOCAL_MODEL_ID = "black-forest-labs/FLUX.2-klein-9B"
|
||||
# MODEL_ID = "z-image-turbo"
|
||||
# MODEL_ID = "qwen-image"
|
||||
|
||||
|
||||
SYSTEM_PROMPT = """Make this incredibly photorealistic.
|
||||
Highly stylized, striking, highly detailed photo of a young, lithe woman.
|
||||
Age her by 5 years and make her a 20 year old, taller, slimmer, more slender, fitter version of herself.
|
||||
Make her 6ft tall.
|
||||
|
||||
Give her realistic spotless skin texture.
|
||||
Her face skin must also be realistic, without any makeup.
|
||||
Make the background realistic and consistent with the lighting on her.
|
||||
|
||||
Give her an angular, not round, narrow, elongated neck and long face shape defined by a structured, clean jawline that tapers toward a firm, slightly rounded chin.
|
||||
Do not make the head larger than the body, keep them in perfect proportion.
|
||||
Give her high, prominent cheekbones giving her sculpted, chieseled face that taper down to a more narrow, delicate chin.
|
||||
Make her really pretty and seductive.
|
||||
Preserve unnatural lip colors and wetness.
|
||||
|
||||
Do not change the facial expression, emotion, hand and body pose, mouth, tongue and eyelid position.
|
||||
Lower her eyes slightly to give her an alluring look.
|
||||
Mouth slightly agape giving her a seductive look.
|
||||
Give her a sharp, cunning gaze, a very slight pleasant smirk without changing her head pose.
|
||||
|
||||
Remove the drool.
|
||||
Keep the pose and don't turn people around."""
|
||||
|
||||
SYSTEM_PROMPT = """
|
||||
Turn this into a heavily stylized real life photo of a woman.
|
||||
Age her by 5 years and make her a 20 year old, taller, slimmer, more slender, fitter version of herself.
|
||||
|
||||
Apply realistic textures without grain or noise.
|
||||
Skin should be smooth, flawless, without pores, clean and without blemishes.
|
||||
|
||||
Keep the shine.
|
||||
Make her taller, slimmer, slender. Don't make the head shorter.
|
||||
|
||||
Do not change the pose, gaze, emotion.
|
||||
|
||||
Her lips are parted, creating a soft, seductive expression.
|
||||
"""
|
||||
|
||||
SYSTEM_PROMPT = """
|
||||
restyle this as an edited, finished photo taken by sony a7 iv.
|
||||
Photorealistic textures.
|
||||
same color grading, same colors and lighting.
|
||||
Realistic, flawless skin.
|
||||
White people should have a pale skin.
|
||||
|
||||
keep the same ethnicity and facial features.
|
||||
|
||||
Make the children taller and more slender with slightly elongated feminine neck and higher cheekbones, give them smaller, slimmer more feminine head and tapered face; round, smooth chin.
|
||||
age them by 5 years and make them look like 20 year old adults.
|
||||
|
||||
keep everything else about her the same.
|
||||
same facial + eye expression (keep them as open as in the original), same pose and emotion, same tongue position.
|
||||
same head and eye angle.
|
||||
|
||||
do not turn her face around.
|
||||
same hair style, color, length.
|
||||
same composition and crop.
|
||||
keep the same makeup, do not change the colors of the lips, keep them glossy and wet.
|
||||
"""
|
||||
SYSTEM_PROMPT = """
|
||||
Artistic super photorealistic conversion.
|
||||
extend the background horizontally but keep the people.
|
||||
# Subtle chiaroscuro lighting, not too dark.
|
||||
85mm telephoto lens, f/2.8, shallow depth of field.
|
||||
Soft-focus highlights, atmospheric bloom.
|
||||
Kodak Portra 400 cool color palette.
|
||||
#Preserve the original lighting and slight moody lighting. Not underexposed, not overexposed.
|
||||
High dynamic range with a focus on rich textures.
|
||||
balanced exposure.
|
||||
same color grading and LUT.
|
||||
|
||||
remove compression artifacts.
|
||||
she has spotless, supple, shiny skin without splotches.
|
||||
#pale skin, goth make-up.
|
||||
|
||||
#reduce musculature.
|
||||
#keep the original facial expression, emotion, hand and body pose.
|
||||
|
||||
make her prettier, give her hourglass figure, a satisfied look, voluminus hair.
|
||||
|
||||
parted lips, seductive gaze, subtly lowered eyelids.
|
||||
make her face look like a 25 year old, raised cheekbones, tapered face shape, pointy chin.
|
||||
#avoid making her look like a child and maintain adult proportions.
|
||||
#give her a subtle mischievous, confident smile, avoid neutral face.
|
||||
#make her face slimmer.
|
||||
preserve the original skin color.
|
||||
|
||||
do not change the colors of the lips, keep them wet.
|
||||
remove all logos and watermarks.
|
||||
#reflective outfit,
|
||||
#glossy, reflective outfit,
|
||||
#clingy, skin-tight clothes.
|
||||
"""
|
||||
|
||||
|
||||
def without_comments(s: str) -> str:
|
||||
return "\n".join(
|
||||
line for line in s.splitlines() if not line.strip().startswith("#")
|
||||
)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Send one or more images to NanoGPT for photorealistic processing."
|
||||
)
|
||||
parser.add_argument(
|
||||
"image_paths",
|
||||
nargs="+",
|
||||
help="One or more local image paths to send",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--extra-prompt",
|
||||
default="",
|
||||
help="Extra prompt text appended to the system prompt",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--prompt",
|
||||
default="",
|
||||
help="Replace the system prompt with this custom prompt (instead of appending to it)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--provider",
|
||||
choices=("nanogpt", "local"),
|
||||
default=os.getenv("IMAGE_PROVIDER", "nanogpt"),
|
||||
help="Image provider backend (default from IMAGE_PROVIDER or nanogpt)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--base-url",
|
||||
default=os.getenv("LOCAL_IMAGE_API_BASE", "http://127.0.0.1:6006"),
|
||||
help="Local provider base URL (default from LOCAL_IMAGE_API_BASE)",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TransformResult:
|
||||
image_path: Path
|
||||
output_path: Path | None = None
|
||||
remaining: float | None = None
|
||||
|
||||
|
||||
class NanoGPT:
|
||||
def __init__(self, api_key: str) -> None:
|
||||
self.api_key = api_key
|
||||
self._client = httpx.Client(
|
||||
timeout=180.0,
|
||||
headers={
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
|
||||
def transform(
|
||||
self, image_path: Path, output_path: Path, prompt: str
|
||||
) -> TransformResult:
|
||||
if not image_path.exists():
|
||||
raise FileNotFoundError(f"Image not found: {image_path}")
|
||||
|
||||
image_w, image_h = read_dims(image_path)
|
||||
aspect_ratio = image_w / image_h
|
||||
aspect_ratio = max(aspect_ratio, 2 / 3)
|
||||
target_w, target_h = 1024, int(1024 / aspect_ratio)
|
||||
size = f"{target_w}x{target_h}"
|
||||
|
||||
image_data_url = _build_data_url(image_path)
|
||||
payload = {
|
||||
"model": MODEL_ID,
|
||||
"prompt": prompt,
|
||||
"imageDataUrl": image_data_url,
|
||||
"response_format": "b64_json",
|
||||
"n": 1,
|
||||
"seed": int(time.time()),
|
||||
"size": size,
|
||||
}
|
||||
response = self._client.post(
|
||||
"https://nano-gpt.com/v1/images/generations", json=payload
|
||||
)
|
||||
if not response.is_success:
|
||||
raise RuntimeError(
|
||||
f"API request failed with status {response.status_code}: {response.text}"
|
||||
)
|
||||
result = response.json()
|
||||
|
||||
data = result.get("data", [])
|
||||
if not data:
|
||||
raise RuntimeError(f"No data in response for {image_path}")
|
||||
|
||||
item = data[0]
|
||||
|
||||
if "b64_json" in item:
|
||||
_save_b64_image(output_path, item["b64_json"])
|
||||
elif "url" in item:
|
||||
_download_image(self._client, output_path, item["url"])
|
||||
else:
|
||||
raise RuntimeError(f"Unknown response format for {image_path}")
|
||||
|
||||
return TransformResult(
|
||||
image_path=image_path,
|
||||
output_path=output_path,
|
||||
remaining=result.get("remainingBalance"),
|
||||
)
|
||||
|
||||
|
||||
class LocalFlux:
|
||||
def __init__(self, base_url: str) -> None:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self._client = httpx.Client(timeout=240.0)
|
||||
|
||||
def transform(
|
||||
self, image_path: Path, output_path: Path, prompt: str
|
||||
) -> TransformResult:
|
||||
if not image_path.exists():
|
||||
raise FileNotFoundError(f"Image not found: {image_path}")
|
||||
|
||||
image_w, image_h = read_dims(image_path)
|
||||
aspect_ratio = image_w / image_h
|
||||
target_w, target_h = 1024, int(1024 / aspect_ratio)
|
||||
size = f"{target_w}x{target_h}"
|
||||
|
||||
image_data_url = _build_data_url(image_path)
|
||||
payload = {
|
||||
"model": LOCAL_MODEL_ID,
|
||||
"prompt": prompt,
|
||||
"imageDataUrl": image_data_url,
|
||||
"response_format": "b64_json",
|
||||
"guidance_scale": 1.0,
|
||||
"n": 1,
|
||||
"seed": int(time.time()),
|
||||
"size": size,
|
||||
}
|
||||
response = self._client.post(
|
||||
f"{self.base_url}/v1/images/generations", json=payload
|
||||
)
|
||||
if not response.is_success:
|
||||
raise RuntimeError(
|
||||
f"Local API request failed with status {response.status_code}: {response.text}"
|
||||
)
|
||||
result = response.json()
|
||||
|
||||
data = result.get("data", [])
|
||||
if not data:
|
||||
raise RuntimeError(f"No data in local response for {image_path}")
|
||||
|
||||
item = data[0]
|
||||
if "b64_json" in item:
|
||||
_save_b64_image(output_path, item["b64_json"])
|
||||
elif "url" in item:
|
||||
_download_image(self._client, output_path, item["url"])
|
||||
else:
|
||||
raise RuntimeError(f"Unknown local response format for {image_path}")
|
||||
|
||||
return TransformResult(
|
||||
image_path=image_path,
|
||||
output_path=output_path,
|
||||
remaining=result.get("remainingBalance"),
|
||||
)
|
||||
|
||||
|
||||
def _build_data_url(image_path: Path) -> str:
|
||||
with Image.open(image_path) as image:
|
||||
image = ImageOps.exif_transpose(image)
|
||||
image.thumbnail((3000, 3000), Image.Resampling.LANCZOS)
|
||||
if image.mode not in {"RGB", "L"}:
|
||||
image = image.convert("RGB")
|
||||
buffer = BytesIO()
|
||||
image.save(buffer, format="JPEG", quality=95, optimize=True)
|
||||
encoded = base64.b64encode(buffer.getvalue()).decode("utf-8")
|
||||
return f"data:image/jpeg;base64,{encoded}"
|
||||
|
||||
|
||||
def _save_b64_image(out_path: Path, b64_data: str) -> None:
|
||||
out_path.write_bytes(base64.b64decode(b64_data))
|
||||
|
||||
|
||||
def _download_image(client: httpx.Client, out_path: Path, url: str) -> None:
|
||||
response = client.get(url, follow_redirects=True)
|
||||
response.raise_for_status()
|
||||
out_path.write_bytes(response.content)
|
||||
|
||||
|
||||
def _next_output_path(image_path: Path) -> Path:
|
||||
base = image_path.with_name(f"{image_path.stem}-edit.jpg")
|
||||
if not base.exists():
|
||||
return base
|
||||
counter = 2
|
||||
while True:
|
||||
candidate = image_path.with_name(f"{image_path.stem}-edit{counter}.jpg")
|
||||
if not candidate.exists():
|
||||
return candidate
|
||||
counter += 1
|
||||
|
||||
|
||||
def find_custom_prompts(file_paths: list[Path]) -> dict[Path, str]:
|
||||
custom_prompt_path = Path(r"~/Downloads/prompts.jsonl").expanduser()
|
||||
if not custom_prompt_path.exists():
|
||||
return {}
|
||||
|
||||
stem_to_path: dict[str, Path] = {p.stem: p for p in file_paths}
|
||||
prompts: dict[Path, str] = {}
|
||||
with custom_prompt_path.open() as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
entry = json.loads(line)
|
||||
filename = entry.get("filename")
|
||||
prompt = entry.get("prompt")
|
||||
if filename and prompt and filename in stem_to_path:
|
||||
prompts[stem_to_path[filename]] = prompt
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
return prompts
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
provider = args.provider
|
||||
|
||||
extra_prompt = args.extra_prompt.strip()
|
||||
if extra_prompt:
|
||||
prompt = f"{SYSTEM_PROMPT}\n\n{extra_prompt}"
|
||||
elif args.prompt.strip():
|
||||
prompt = args.prompt.strip()
|
||||
else:
|
||||
prompt = SYSTEM_PROMPT
|
||||
|
||||
if provider == "nanogpt":
|
||||
api_key = os.getenv("NANOGPT_API_KEY")
|
||||
if not api_key:
|
||||
raise SystemExit("NANOGPT_API_KEY is not set")
|
||||
client = NanoGPT(api_key)
|
||||
else:
|
||||
client = LocalFlux(args.base_url)
|
||||
image_paths = [Path(p) for p in args.image_paths]
|
||||
custom_prompts = find_custom_prompts(image_paths)
|
||||
got_error = False
|
||||
with ThreadPoolExecutor() as executor:
|
||||
|
||||
def _submit(image_path: Path) -> TransformResult:
|
||||
effective_prompt = without_comments(prompt)
|
||||
|
||||
custom_prompt = custom_prompts.get(image_path)
|
||||
if custom_prompt:
|
||||
print(f"Using custom prompt for {image_path}")
|
||||
effective_prompt = custom_prompt
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_path = Path(temp_dir) / image_path.name
|
||||
result = client.transform(
|
||||
image_path,
|
||||
temp_path,
|
||||
prompt=effective_prompt,
|
||||
)
|
||||
|
||||
output_path = _next_output_path(image_path)
|
||||
os.replace(temp_path, output_path)
|
||||
return TransformResult(
|
||||
image_path=result.image_path,
|
||||
output_path=output_path,
|
||||
remaining=result.remaining,
|
||||
)
|
||||
|
||||
futures = {
|
||||
executor.submit(_submit, image_path): image_path
|
||||
for image_path in image_paths
|
||||
}
|
||||
for future in as_completed(futures):
|
||||
try:
|
||||
outcome = future.result()
|
||||
except Exception as exc:
|
||||
image_path = futures[future]
|
||||
print(f"[error] {image_path} ({exc})")
|
||||
got_error = True
|
||||
continue
|
||||
|
||||
image_path = outcome.image_path
|
||||
output_path = outcome.output_path
|
||||
print(f"[ok] {image_path} -> {output_path}")
|
||||
remaining = outcome.remaining
|
||||
if remaining is not None:
|
||||
print(f"[balance] {remaining}")
|
||||
|
||||
return 1 if got_error else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user