Add Auto-Enhance, Color Palette, History Panel, Align, Text Presets

Auto-Enhance (Image menu):
  POST /api/enhance — gray-world white balance, CLAHE contrast on L channel,
  saturation boost ×1.15 in HSV, unsharp mask; all blended by strength slider
  Frontend: strength selector (25/50/75/100%), keep-original option

Extract Color Palette (Image menu):
  POST /api/extract-colors — k-means on 150×150 thumbnail, returns N dominant
  colors sorted by cluster size. Frontend: floating swatch panel, click=copy
  hex, shift+click=set as active color, toggle on/off.

History Panel (Edit menu, Ctrl+H):
  Pure frontend — reads app.State.action_history and action_history_index,
  renders clickable list of past actions (newest first), click any step to
  undo/redo to that point. Auto-refreshes every 800ms while open.

Align to Canvas (Layer menu):
  Floating toolbar with 7 alignment buttons: center H, center V, center both,
  align left/right/top/bottom edges. Uses Update_layer_action for undo support.

Add Text (Generate menu):
  6 styled presets (Heading, Subheading, Body, Caption, Quote, Bold Label)
  shown as live-rendered previews in the dialog. Click a preset to insert a
  text layer with the correct font/size/weight/color pre-applied.

https://claude.ai/code/session_01B58MaJCU1R6KwBDJCp8AfN
This commit is contained in:
Claude
2026-06-11 01:41:20 +00:00
parent 3746c02d44
commit d2273017fd
7 changed files with 827 additions and 0 deletions
+151
View File
@@ -408,3 +408,154 @@ async def segment_install():
from app.services.sam_service import ensure_sam_installed, get_install_status
asyncio.create_task(ensure_sam_installed())
return get_install_status()
# ─── Enhance ─────────────────────────────────────────────────────────────────
import io as _io
import numpy as _np
import cv2 as _cv2
from PIL import Image as _Image
class EnhanceRequest(BaseModel):
image: str # base64
strength: float = 1.0
def _enhance_image(image_bytes: bytes, strength: float) -> bytes:
"""
Apply a chain of non-AI image enhancements, each blended with `strength` (01).
Steps:
1. Auto white balance (gray-world)
2. CLAHE on L channel of LAB colorspace
3. Auto saturation boost in HSV (×1.15, clamped)
4. Mild unsharp mask (gaussian sigma=1.0, delta weight=0.3)
"""
strength = max(0.0, min(1.0, float(strength)))
# Decode to RGB numpy array
pil = _Image.open(_io.BytesIO(image_bytes)).convert("RGB")
orig = _np.array(pil, dtype=_np.float32) # H×W×3, float [0,255]
img = orig.copy()
# ── Step 1: Auto white balance (gray-world) ──────────────────────────────
mean_r = img[:, :, 0].mean()
mean_g = img[:, :, 1].mean()
mean_b = img[:, :, 2].mean()
overall_mean = (mean_r + mean_g + mean_b) / 3.0
def _scale(channel, channel_mean):
if channel_mean == 0:
return channel
return channel * (overall_mean / channel_mean)
wb = img.copy()
wb[:, :, 0] = _np.clip(_scale(img[:, :, 0], mean_r), 0, 255)
wb[:, :, 1] = _np.clip(_scale(img[:, :, 1], mean_g), 0, 255)
wb[:, :, 2] = _np.clip(_scale(img[:, :, 2], mean_b), 0, 255)
img = (orig + strength * (wb - orig)).clip(0, 255)
# ── Step 2: CLAHE on L channel (LAB) ────────────────────────────────────
img_u8 = img.astype(_np.uint8)
lab = _cv2.cvtColor(img_u8, _cv2.COLOR_RGB2LAB)
clahe = _cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
l_orig = lab[:, :, 0].copy()
lab[:, :, 0] = clahe.apply(l_orig)
# Blend L channel back using strength
lab_blended = lab.copy()
lab_blended[:, :, 0] = (l_orig + strength * (lab[:, :, 0].astype(_np.float32) - l_orig.astype(_np.float32))).clip(0, 255).astype(_np.uint8)
img = _cv2.cvtColor(lab_blended, _cv2.COLOR_LAB2RGB).astype(_np.float32)
# ── Step 3: Auto saturation boost (HSV, ×1.15) ──────────────────────────
img_u8 = img.astype(_np.uint8)
hsv = _cv2.cvtColor(img_u8, _cv2.COLOR_RGB2HSV).astype(_np.float32)
s_orig = hsv[:, :, 1].copy()
s_boosted = _np.clip(s_orig * 1.15, 0, 255)
hsv[:, :, 1] = s_orig + strength * (s_boosted - s_orig)
hsv = hsv.clip(0, 255).astype(_np.uint8)
img = _cv2.cvtColor(hsv, _cv2.COLOR_HSV2RGB).astype(_np.float32)
# ── Step 4: Mild unsharp mask (sigma=1.0, delta weight=0.3) ─────────────
img_u8 = img.astype(_np.uint8)
blurred = _cv2.GaussianBlur(img_u8, (0, 0), sigmaX=1.0)
sharpness_delta = img_u8.astype(_np.float32) - blurred.astype(_np.float32)
sharpened = img_u8.astype(_np.float32) + 0.3 * sharpness_delta * strength
img = sharpened.clip(0, 255)
# Encode result as PNG
result_pil = _Image.fromarray(img.astype(_np.uint8), mode="RGB")
buf = _io.BytesIO()
result_pil.save(buf, format="PNG")
return buf.getvalue()
@router.post("/enhance")
async def enhance(req: EnhanceRequest):
"""
Non-AI image enhancement: auto white balance, CLAHE, saturation boost,
and unsharp mask. Each step is blended proportionally to `strength` (01).
"""
try:
image_bytes = _decode(req.image)
result = await asyncio.get_event_loop().run_in_executor(
None, _enhance_image, image_bytes, req.strength
)
return {"result": _encode(result)}
except Exception as e:
import traceback; traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e))
# ─── Extract colors ───────────────────────────────────────────────────────────
from sklearn.cluster import KMeans as _KMeans
class ExtractColorsRequest(BaseModel):
image: str # base64
count: int = 6
def _extract_colors(image_bytes: bytes, count: int) -> list[str]:
"""
Resize image to 150×150, k-means cluster pixels into `count` groups,
sort by cluster size (largest first), return as hex strings.
"""
count = max(1, min(count, 32))
pil = _Image.open(_io.BytesIO(image_bytes)).convert("RGB").resize((150, 150))
pixels = _np.array(pil, dtype=_np.float32).reshape(-1, 3) # (N, 3)
km = _KMeans(n_clusters=count, n_init=10, random_state=42)
labels = km.fit_predict(pixels)
centers = km.cluster_centers_ # (count, 3)
# Count pixels per cluster and sort by frequency descending
counts = _np.bincount(labels, minlength=count)
order = _np.argsort(-counts) # descending
hex_colors = []
for idx in order:
r, g, b = centers[idx].astype(int).clip(0, 255)
hex_colors.append(f"#{r:02x}{g:02x}{b:02x}")
return hex_colors
@router.post("/extract-colors")
async def extract_colors(req: ExtractColorsRequest):
"""
Extract dominant colors from an image using k-means clustering.
Returns hex color strings sorted by frequency (most dominant first).
"""
try:
image_bytes = _decode(req.image)
colors = await asyncio.get_event_loop().run_in_executor(
None, _extract_colors, image_bytes, req.count
)
return {"colors": colors}
except Exception as e:
import traceback; traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e))