Initial Commit

This commit is contained in:
2026-01-11 10:27:06 -05:00
commit 04cffb964e
9 changed files with 1576 additions and 0 deletions

548
app.py Normal file
View File

@@ -0,0 +1,548 @@
from flask import Flask, request, jsonify, send_file, render_template
from PIL import Image, ImageDraw, ImageFont
import io
import os
import re
import requests
from werkzeug.utils import secure_filename
try:
import pytesseract
TESSERACT_AVAILABLE = True
except ImportError:
TESSERACT_AVAILABLE = False
app = Flask(__name__)
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16MB max file size
app.config['UPLOAD_FOLDER'] = 'uploads'
# Ensure upload folder exists
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp'}
def allowed_file(filename):
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
def trim_transparent_borders(img):
"""
Trim transparent/whitespace borders from image
Returns the cropped image
"""
if img.mode not in ('RGBA', 'LA'):
# No transparency to trim
return img
# Get the alpha channel
if img.mode == 'RGBA':
alpha = img.split()[3]
else:
alpha = img.split()[1]
# Get the bounding box of non-transparent pixels
bbox = alpha.getbbox()
if bbox:
return img.crop(bbox)
else:
# Image is completely transparent, return as is
return img
def detect_font_from_image(img):
"""
Attempt to detect font characteristics from the logo using OCR
Returns a font path that best matches the detected style
"""
if not TESSERACT_AVAILABLE:
return None
try:
# Get detailed OCR data including font info
data = pytesseract.image_to_data(img, output_type=pytesseract.Output.DICT)
# Look for font characteristics in detected text
# Common bold/heavy fonts used in TV logos
bold_fonts = [
'/System/Library/Fonts/Supplemental/Arial Black.ttf',
'/System/Library/Fonts/Supplemental/Impact.ttf',
'/System/Library/Fonts/Supplemental/Arial Bold.ttf',
'/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',
'C:\\Windows\\Fonts\\ariblk.ttf',
'C:\\Windows\\Fonts\\impact.ttf',
'C:\\Windows\\Fonts\\arialbd.ttf',
]
# Default to clean sans-serif fonts
regular_fonts = [
'/System/Library/Fonts/Helvetica.ttc',
'/System/Library/Fonts/Supplemental/Arial.ttf',
'/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',
'C:\\Windows\\Fonts\\arial.ttf',
]
# Try to detect if logo uses bold/heavy text by checking confidence scores
# Higher confidence often correlates with bolder, clearer text
confidences = [conf for conf in data['conf'] if conf != -1]
avg_confidence = sum(confidences) / len(confidences) if confidences else 0
# If high confidence detected text, likely uses bold fonts
font_list = bold_fonts if avg_confidence > 60 else regular_fonts
# Return first available font
for font_path in font_list:
if os.path.exists(font_path):
return font_path
except Exception as e:
# If OCR fails, fall back to None
pass
return None
def get_font(font_size, detected_font_path=None):
"""
Get the best available font for text rendering
"""
try:
# If we detected a font from the logo, use it
if detected_font_path and os.path.exists(detected_font_path):
return ImageFont.truetype(detected_font_path, font_size)
# Otherwise try common fonts (prioritize bold/heavy fonts)
font_paths = [
# macOS paths
'/System/Library/Fonts/Supplemental/Arial Black.ttf',
'/System/Library/Fonts/Supplemental/Impact.ttf',
'/System/Library/Fonts/Supplemental/Arial Bold.ttf',
'/System/Library/Fonts/Helvetica.ttc',
# Linux paths (DejaVu)
'/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',
'/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',
# Linux paths (Liberation - free alternative to Arial)
'/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf',
'/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf',
# Windows paths
'C:\\Windows\\Fonts\\ariblk.ttf',
'C:\\Windows\\Fonts\\impact.ttf',
'C:\\Windows\\Fonts\\arialbd.ttf',
]
for font_path in font_paths:
if os.path.exists(font_path):
return ImageFont.truetype(font_path, font_size)
return ImageFont.load_default()
except:
return ImageFont.load_default()
def add_text_to_image(image_path, text, position='below', font_size=None,
text_color='white', bg_color=None, padding=None):
"""
Add text to an image by expanding the canvas
Args:
image_path: Path to the source image
text: Text to add
position: Where to add text ('above', 'below', 'left', 'right')
font_size: Size of the text font (auto if None)
text_color: Color of the text
bg_color: Background color for the expanded area (transparent if None and image has alpha)
padding: Padding around the text (auto if None)
Returns:
PIL Image object with text added
"""
# Load the original image
img = Image.open(image_path)
# Preserve transparency
has_transparency = img.mode in ('RGBA', 'LA') or (img.mode == 'P' and 'transparency' in img.info)
# Convert to RGBA if it has transparency, otherwise RGB
if has_transparency:
img = img.convert('RGBA')
# Trim transparent borders first
img = trim_transparent_borders(img)
else:
img = img.convert('RGB')
orig_width, orig_height = img.size
# Auto-calculate font size if not provided (based on image dimensions)
if font_size is None:
if position in ['above', 'below']:
font_size = int(orig_width * 0.12) # 12% of image width
else: # left or right
font_size = int(orig_height * 0.20) # 20% of image height (larger for vertical text)
font_size = max(30, min(font_size, 250)) # Clamp between 30 and 250
# Auto-calculate padding if not provided
if padding is None:
padding = int(font_size * 0.25) # 25% of font size
# Auto-determine background color
if bg_color is None:
if has_transparency:
bg_color = (0, 0, 0, 0) # Transparent
else:
bg_color = '#1a1a1a' # Dark gray for non-transparent images
# Try to detect font from the logo
detected_font_path = detect_font_from_image(img)
# Get the appropriate font
font = get_font(font_size, detected_font_path)
# Create a temporary image to measure text size
temp_img = Image.new('RGB', (1, 1))
temp_draw = ImageDraw.Draw(temp_img)
# Get text bounding box
bbox = temp_draw.textbbox((0, 0), text, font=font)
text_width = bbox[2] - bbox[0]
text_height = bbox[3] - bbox[1]
bbox_offset_x = -bbox[0] # Offset to align text properly
bbox_offset_y = -bbox[1] # Offset to align text properly
# Calculate new image dimensions with directional padding
# More padding between logo and text, minimal on outer edges (mimic existing padding)
inner_padding = padding # Padding between logo and text
outer_padding = padding // 4 # Minimal padding on outer edge
side_padding = padding // 4 # Minimal padding on perpendicular sides
if position in ['above', 'below']:
text_area_height = text_height + outer_padding + inner_padding
new_width = max(orig_width, text_width + side_padding * 2)
new_height = orig_height + text_area_height
else: # left or right
text_area_width = text_width + outer_padding + inner_padding
new_width = orig_width + text_area_width
new_height = max(orig_height, text_height + side_padding * 2)
# Create new image with expanded canvas (preserve transparency)
if has_transparency:
new_img = Image.new('RGBA', (new_width, new_height), bg_color)
else:
new_img = Image.new('RGB', (new_width, new_height), bg_color)
# Calculate positions with directional padding
if position == 'below':
# Paste original image at top
paste_x = (new_width - orig_width) // 2
new_img.paste(img, (paste_x, 0))
# Add text below with padding between logo and text, minimal at bottom
text_x = (new_width - text_width) // 2 + bbox_offset_x
text_y = orig_height + inner_padding + bbox_offset_y
elif position == 'above':
# Paste original image at bottom
paste_x = (new_width - orig_width) // 2
new_img.paste(img, (paste_x, text_area_height))
# Add text above with minimal padding at top, more near logo
text_x = (new_width - text_width) // 2 + bbox_offset_x
text_y = outer_padding + bbox_offset_y
elif position == 'right':
# Paste original image on left
paste_y = (new_height - orig_height) // 2
new_img.paste(img, (0, paste_y))
# Add text on right with padding between logo and text, minimal on right edge
text_x = orig_width + inner_padding + bbox_offset_x
text_y = (new_height - text_height) // 2 + bbox_offset_y
else: # left
# Paste original image on right
paste_y = (new_height - orig_height) // 2
new_img.paste(img, (text_area_width, paste_y))
# Add text on left with minimal padding on left edge, more near logo
text_x = outer_padding + bbox_offset_x
text_y = (new_height - text_height) // 2 + bbox_offset_y
# Draw text on new image
draw = ImageDraw.Draw(new_img)
draw.text((text_x, text_y), text, fill=text_color, font=font)
return new_img
@app.route('/')
def index():
"""Serve the web interface"""
return render_template('index.html')
@app.route('/api/process', methods=['POST'])
def process_image():
"""
API endpoint to process an image
Expected form data:
- image: Image file
- text: Text to add
- position: Where to add text (above/below/left/right)
- font_size: Font size (optional, default 60)
- text_color: Text color (optional, default white)
- bg_color: Background color (optional, default #1a1a1a)
- padding: Padding around text (optional, default 20)
"""
# Check if image file is present
if 'image' not in request.files:
return jsonify({'error': 'No image file provided'}), 400
file = request.files['image']
if file.filename == '':
return jsonify({'error': 'No file selected'}), 400
if not allowed_file(file.filename):
return jsonify({'error': 'Invalid file type. Allowed: PNG, JPG, JPEG, GIF, WEBP'}), 400
# Get parameters
text = request.form.get('text', '')
if not text:
return jsonify({'error': 'No text provided'}), 400
position = request.form.get('position', 'below').lower()
if position not in ['above', 'below', 'left', 'right']:
return jsonify({'error': 'Invalid position. Use: above, below, left, or right'}), 400
# Handle font size - allow auto/empty for automatic sizing
font_size_input = request.form.get('font_size', 'auto')
if font_size_input == '' or font_size_input == 'auto':
font_size = None
else:
try:
font_size = int(font_size_input)
except ValueError:
return jsonify({'error': 'Font size must be a number or "auto"'}), 400
# Handle padding - allow auto/empty for automatic padding
padding_input = request.form.get('padding', 'auto')
if padding_input == '' or padding_input == 'auto':
padding = None
else:
try:
padding = int(padding_input)
except ValueError:
return jsonify({'error': 'Padding must be a number or "auto"'}), 400
text_color = request.form.get('text_color', 'white')
bg_color = request.form.get('bg_color', 'transparent')
# Allow transparent background
if bg_color == '' or bg_color == 'transparent' or bg_color == 'auto':
bg_color = None
# Save uploaded file
filename = secure_filename(file.filename)
filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
file.save(filepath)
try:
# Process the image
result_img = add_text_to_image(
filepath,
text,
position,
font_size,
text_color,
bg_color,
padding
)
# Save to bytes buffer
img_io = io.BytesIO()
# Determine output format (preserve transparency for PNG)
output_format = 'PNG'
if filename.lower().endswith(('.jpg', '.jpeg')):
output_format = 'JPEG'
# Convert RGBA to RGB for JPEG
if result_img.mode == 'RGBA':
rgb_img = Image.new('RGB', result_img.size, (255, 255, 255))
rgb_img.paste(result_img, mask=result_img.split()[3])
result_img = rgb_img
elif filename.lower().endswith('.gif'):
output_format = 'GIF'
elif filename.lower().endswith('.webp'):
output_format = 'WEBP'
# Save with appropriate settings
if output_format == 'PNG':
result_img.save(img_io, output_format, optimize=True)
elif output_format == 'JPEG':
result_img.save(img_io, output_format, quality=95)
else:
result_img.save(img_io, output_format, quality=95)
img_io.seek(0)
# Clean up uploaded file
os.remove(filepath)
# Determine mimetype
mimetype_map = {
'PNG': 'image/png',
'JPEG': 'image/jpeg',
'GIF': 'image/gif',
'WEBP': 'image/webp'
}
return send_file(
img_io,
mimetype=mimetype_map.get(output_format, 'image/png'),
as_attachment=True,
download_name=f'processed_{filename}'
)
except Exception as e:
# Clean up on error
if os.path.exists(filepath):
os.remove(filepath)
return jsonify({'error': f'Error processing image: {str(e)}'}), 500
@app.route('/api/health', methods=['GET'])
def health():
"""Health check endpoint"""
return jsonify({'status': 'ok'})
@app.route('/api/image', methods=['GET'])
def process_image_url():
"""
API endpoint to process an image from URL and return the image directly
Query parameters:
- url: Image URL (required)
- text: Text to add (required)
- position: Where to add text (optional, default: below)
- font_size: Font size or 'auto' (optional, default: auto)
- text_color: Text color (optional, default: white)
- bg_color: Background color or 'transparent' (optional, default: transparent)
- padding: Padding or 'auto' (optional, default: auto)
Example:
/api/image?url=https://example.com/logo.png&text=Breaking%20News&position=below
"""
# Get required parameters
image_url = request.args.get('url')
text = request.args.get('text')
if not image_url:
return jsonify({'error': 'Missing required parameter: url'}), 400
if not text:
return jsonify({'error': 'Missing required parameter: text'}), 400
# Get optional parameters with defaults
position = request.args.get('position', 'below').lower()
if position not in ['above', 'below', 'left', 'right']:
return jsonify({'error': 'Invalid position. Use: above, below, left, or right'}), 400
# Handle font size
font_size_input = request.args.get('font_size', 'auto')
if font_size_input == '' or font_size_input == 'auto':
font_size = None
else:
try:
font_size = int(font_size_input)
except ValueError:
return jsonify({'error': 'Font size must be a number or "auto"'}), 400
# Handle padding
padding_input = request.args.get('padding', 'auto')
if padding_input == '' or padding_input == 'auto':
padding = None
else:
try:
padding = int(padding_input)
except ValueError:
return jsonify({'error': 'Padding must be a number or "auto"'}), 400
text_color = request.args.get('text_color', 'white')
bg_color = request.args.get('bg_color', 'transparent')
# Allow transparent background
if bg_color == '' or bg_color == 'transparent' or bg_color == 'auto':
bg_color = None
try:
# Download the image
response = requests.get(image_url, timeout=10, headers={'User-Agent': 'LogoTextAdder/1.0'})
response.raise_for_status()
# Save to temporary file
img_bytes = io.BytesIO(response.content)
# Determine format from content-type or URL
content_type = response.headers.get('content-type', '')
if 'png' in content_type or image_url.lower().endswith('.png'):
output_format = 'PNG'
mimetype = 'image/png'
elif 'jpeg' in content_type or 'jpg' in content_type or image_url.lower().endswith(('.jpg', '.jpeg')):
output_format = 'JPEG'
mimetype = 'image/jpeg'
elif 'gif' in content_type or image_url.lower().endswith('.gif'):
output_format = 'GIF'
mimetype = 'image/gif'
elif 'webp' in content_type or image_url.lower().endswith('.webp'):
output_format = 'WEBP'
mimetype = 'image/webp'
else:
# Default to PNG
output_format = 'PNG'
mimetype = 'image/png'
# Create temp file to process
temp_filename = f"temp_{os.urandom(8).hex()}.{output_format.lower()}"
temp_filepath = os.path.join(app.config['UPLOAD_FOLDER'], temp_filename)
with open(temp_filepath, 'wb') as f:
f.write(response.content)
# Process the image
result_img = add_text_to_image(
temp_filepath,
text,
position,
font_size,
text_color,
bg_color,
padding
)
# Save to bytes buffer
img_io = io.BytesIO()
# Convert RGBA to RGB for JPEG
if output_format == 'JPEG' and result_img.mode == 'RGBA':
rgb_img = Image.new('RGB', result_img.size, (255, 255, 255))
rgb_img.paste(result_img, mask=result_img.split()[3])
result_img = rgb_img
# Save with appropriate settings
if output_format == 'PNG':
result_img.save(img_io, output_format, optimize=True)
elif output_format == 'JPEG':
result_img.save(img_io, output_format, quality=95)
else:
result_img.save(img_io, output_format, quality=95)
img_io.seek(0)
# Clean up temp file
os.remove(temp_filepath)
# Return image directly
return send_file(
img_io,
mimetype=mimetype,
as_attachment=False,
download_name=f'logo_{text[:20].replace(" ", "_")}.{output_format.lower()}'
)
except requests.RequestException as e:
return jsonify({'error': f'Failed to download image: {str(e)}'}), 400
except Exception as e:
# Clean up on error
if 'temp_filepath' in locals() and os.path.exists(temp_filepath):
os.remove(temp_filepath)
return jsonify({'error': f'Error processing image: {str(e)}'}), 500
if __name__ == '__main__':
app.run(debug=True, host='0.0.0.0', port=5001)