Files
ubuntu-post-install/paintplus/backend/app/services/patch_library.py
T
Claude 084922afaa paintplus: vendor the app source and rename from EditmaskwithAI
Bring the full EditmaskwithAI application into the repo under paintplus/
(429 files) so the service is self-contained — the installer copies the
vendored source to ~/docker/paintplus/src instead of cloning at runtime.

Rename to PaintPlus (service + branding; app logic untouched):
- services/editmaskwithai.sh -> services/paintplus.sh (register_service
  paintplus, install_paintplus, ~/docker/paintplus, Caddy paintplus:8000,
  Authelia option preserved)
- container names -> paintplus across docker-compose*.yml; dev network
  -> paintplus-network
- browser <title> -> "PaintPlus - AI Image Editor"; README heading ->
  PaintPlus with upstream provenance note
- README utilities table: editmaskwithai -> paintplus

Backend/frontend code (help strings referencing the old container name,
the ai_photo_edit.db filename) is intentionally left as-is to avoid
touching application logic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nb2vJ8W7bHKx1JXVvpCraH
2026-06-26 05:48:43 +00:00

207 lines
6.0 KiB
Python

import os
import shutil
from pathlib import Path
from typing import List, Optional
from PIL import Image
from datetime import datetime
from app.models.patch import Patch
from app.config import settings
class PatchLibraryService:
"""Service for managing the patch library"""
def __init__(self, data_dir: str = None):
self.data_dir = data_dir or settings.data_dir
self.patch_library_dir = Path(self.data_dir) / "patch_library"
self.patch_library_dir.mkdir(parents=True, exist_ok=True)
def get_patch_path(self, patch_id: int) -> Path:
"""Get path to patch file"""
return self.patch_library_dir / f"{patch_id}.png"
def get_thumbnail_path(self, patch_id: int) -> Path:
"""Get path to patch thumbnail"""
return self.patch_library_dir / f"{patch_id}_thumb.png"
def create_thumbnail(self, image_path: Path, thumbnail_path: Path, size: tuple = (200, 200)):
"""Create a thumbnail from an image"""
img = Image.open(image_path)
img.thumbnail(size, Image.Resampling.LANCZOS)
img.save(thumbnail_path, 'PNG')
def save_patch_from_file(
self,
patch_id: int,
image_path: str,
create_thumb: bool = True
) -> str:
"""
Save a patch from an existing file
Args:
patch_id: Patch ID
image_path: Source image path
create_thumb: Whether to create thumbnail
Returns:
Relative path to saved patch
"""
patch_path = self.get_patch_path(patch_id)
shutil.copy(image_path, patch_path)
if create_thumb:
thumbnail_path = self.get_thumbnail_path(patch_id)
self.create_thumbnail(patch_path, thumbnail_path)
return str(patch_path.relative_to(self.data_dir))
def save_patch_from_bytes(
self,
patch_id: int,
image_bytes: bytes,
create_thumb: bool = True
) -> str:
"""
Save a patch from bytes
Args:
patch_id: Patch ID
image_bytes: Image data as bytes
create_thumb: Whether to create thumbnail
Returns:
Relative path to saved patch
"""
patch_path = self.get_patch_path(patch_id)
# Save image
with open(patch_path, 'wb') as f:
f.write(image_bytes)
if create_thumb:
thumbnail_path = self.get_thumbnail_path(patch_id)
self.create_thumbnail(patch_path, thumbnail_path)
return str(patch_path.relative_to(self.data_dir))
def save_ai_generated_patch(
self,
patch_id: int,
edit_dir: Path
) -> str:
"""
Save an AI-generated patch from an edit
Args:
patch_id: Patch ID
edit_dir: Path to edit history directory
Returns:
Relative path to saved patch
"""
# Use the AI-generated output (patch_out.png)
source_path = edit_dir / "patch_out.png"
return self.save_patch_from_file(patch_id, str(source_path))
def save_manual_patch(
self,
patch_id: int,
project_id: int,
bbox: dict
) -> str:
"""
Save a manually selected patch from current project image
Args:
patch_id: Patch ID
project_id: Project ID
bbox: Bounding box {x, y, width, height}
Returns:
Relative path to saved patch
"""
from app.services.edit_service import EditService
from app.utils.image_processing import crop_patch
edit_service = EditService(self.data_dir)
current_image_path = edit_service.get_current_image_path(project_id)
# Load and crop current image
img = Image.open(current_image_path)
patch = crop_patch(img, bbox)
# Save patch
patch_path = self.get_patch_path(patch_id)
patch.save(patch_path, 'PNG')
# Create thumbnail
thumbnail_path = self.get_thumbnail_path(patch_id)
self.create_thumbnail(patch_path, thumbnail_path)
return str(patch_path.relative_to(self.data_dir))
def apply_patch_to_image(
self,
patch_id: int,
target_image: Image.Image,
bbox: dict,
feather_px: int = 5
) -> Image.Image:
"""
Apply a saved patch to a target image
Args:
patch_id: Patch ID to apply
target_image: Target image to apply patch to
bbox: Where to place the patch {x, y, width, height}
feather_px: Feather radius for blending
Returns:
Image with patch applied
"""
from app.utils.image_processing import insert_patch, create_feathered_mask
from PIL import ImageOps
# Load patch
patch_path = self.get_patch_path(patch_id)
patch = Image.open(patch_path).convert('RGBA')
# Resize patch to match bbox if needed
if patch.size != (bbox['width'], bbox['height']):
patch = patch.resize((bbox['width'], bbox['height']), Image.Resampling.LANCZOS)
# Create a soft-edged mask for the patch
mask = Image.new('L', patch.size, 255)
if feather_px > 0:
mask = create_feathered_mask(mask, feather_px)
# Apply mask to patch
patch.putalpha(mask)
# Insert patch into target image
result = insert_patch(target_image, patch, bbox)
return result
def delete_patch(self, patch_id: int):
"""Delete a patch and its thumbnail"""
patch_path = self.get_patch_path(patch_id)
thumbnail_path = self.get_thumbnail_path(patch_id)
if patch_path.exists():
patch_path.unlink()
if thumbnail_path.exists():
thumbnail_path.unlink()
def get_patch_size(self, patch_id: int) -> tuple:
"""Get patch dimensions"""
patch_path = self.get_patch_path(patch_id)
if not patch_path.exists():
return (0, 0)
img = Image.open(patch_path)
return img.size