Skip to content

Instantly share code, notes, and snippets.

@cookieukw
Created July 26, 2026 03:52
Show Gist options
  • Select an option

  • Save cookieukw/f91191577455b5074ff5369e7aa2a99a to your computer and use it in GitHub Desktop.

Select an option

Save cookieukw/f91191577455b5074ff5369e7aa2a99a to your computer and use it in GitHub Desktop.
A simple utility to overlay an image (watermark) onto another image.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import os
import tkinter as tk
from tkinter import ttk, filedialog, messagebox
from PIL import Image, ImageTk
PREVIEW_MAX_W = 1100
PREVIEW_MAX_H = 760
def clamp(value, low, high):
return max(low, min(high, value))
def hex_from_rgb(rgb):
return "#{:02x}{:02x}{:02x}".format(*rgb)
def apply_opacity(img, opacity):
opacity = max(0.0, min(1.0, float(opacity)))
if opacity >= 1.0:
return img
img = img.convert("RGBA")
alpha = img.getchannel("A")
alpha = alpha.point(lambda p: int(p * opacity))
img.putalpha(alpha)
return img
def remove_background_by_color(img, target_rgb, tolerance):
"""
Faz color-to-alpha simples: pixels próximos de target_rgb viram transparentes.
tolerance: distância euclidiana em RGB.
"""
img = img.convert("RGBA")
px = img.load()
tr, tg, tb = target_rgb
tol2 = int(tolerance) * int(tolerance)
for y in range(img.height):
for x in range(img.width):
r, g, b, a = px[x, y]
dr = r - tr
dg = g - tg
db = b - tb
if (dr * dr + dg * dg + db * db) <= tol2:
px[x, y] = (r, g, b, 0)
return img
class WatermarkApp:
def __init__(self, root):
self.root = root
self.root.title("Watermark GUI")
self.root.geometry("1380x900")
self.root.minsize(1180, 780)
self.base_path = None
self.logo_path = None
self.base_original = None
self.logo_original = None
self.preview_base = None
self.preview_ratio = 1.0
self.logo_x_preview = 20
self.logo_y_preview = 20
self.preview_logo_cache = None
self.dragging = False
self.drag_offset_x = 0
self.drag_offset_y = 0
self.pick_color_mode = False
self.bg_color = None
self._syncing_fields = False
self._build_style()
self._build_ui()
self.root.bind_all("<Left>", lambda e: self.nudge(-1, 0))
self.root.bind_all("<Right>", lambda e: self.nudge(1, 0))
self.root.bind_all("<Up>", lambda e: self.nudge(0, -1))
self.root.bind_all("<Down>", lambda e: self.nudge(0, 1))
self.root.bind_all("<Shift-Left>", lambda e: self.nudge(-10, 0))
self.root.bind_all("<Shift-Right>", lambda e: self.nudge(10, 0))
self.root.bind_all("<Shift-Up>", lambda e: self.nudge(0, -10))
self.root.bind_all("<Shift-Down>", lambda e: self.nudge(0, 10))
def _build_style(self):
try:
style = ttk.Style()
style.theme_use("clam")
except Exception:
pass
def _build_ui(self):
outer = ttk.Frame(self.root, padding=10)
outer.pack(fill="both", expand=True)
# Container esquerdo com largura fixa para abrigar o Canvas e a Scrollbar
left_container = ttk.Frame(outer, width=360)
left_container.pack(side="left", fill="y", padx=(0, 12))
left_container.pack_propagate(False)
# Configuração do Canvas e Scrollbar
self.left_canvas = tk.Canvas(left_container, highlightthickness=0)
left_scrollbar = ttk.Scrollbar(left_container, orient="vertical", command=self.left_canvas.yview)
left = ttk.Frame(self.left_canvas)
left.bind(
"<Configure>",
lambda e: self.left_canvas.configure(scrollregion=self.left_canvas.bbox("all"))
)
# Largura interna ajustada para acomodar a barra de rolagem (360 total - ~20 da barra)
self.left_canvas.create_window((0, 0), window=left, anchor="nw", width=335)
self.left_canvas.configure(yscrollcommand=left_scrollbar.set)
self.left_canvas.pack(side="left", fill="both", expand=True)
left_scrollbar.pack(side="right", fill="y")
# Bindings de rolagem do mouse (Suporte cross-platform, incluindo X11/Wayland)
def _on_mousewheel(event):
if event.delta:
self.left_canvas.yview_scroll(int(-1 * (event.delta / 120)), "units")
def _on_linux_scroll_up(event):
self.left_canvas.yview_scroll(-1, "units")
def _on_linux_scroll_down(event):
self.left_canvas.yview_scroll(1, "units")
self.left_canvas.bind_all("<MouseWheel>", _on_mousewheel)
self.left_canvas.bind_all("<Button-4>", _on_linux_scroll_up)
self.left_canvas.bind_all("<Button-5>", _on_linux_scroll_down)
right = ttk.Frame(outer)
right.pack(side="right", fill="both", expand=True)
# Arquivos
box_files = ttk.LabelFrame(left, text="Arquivos", padding=10)
box_files.pack(fill="x", pady=(0, 10))
ttk.Button(box_files, text="Abrir imagem", command=self.open_base).pack(fill="x")
ttk.Button(box_files, text="Abrir logo", command=self.open_logo).pack(fill="x", pady=(6, 0))
self.base_label = ttk.Label(box_files, text="Imagem: -", wraplength=285, justify="left")
self.base_label.pack(fill="x", pady=(8, 0))
self.logo_label = ttk.Label(box_files, text="Logo: -", wraplength=285, justify="left")
self.logo_label.pack(fill="x", pady=(4, 0))
# Ajustes
box_adjust = ttk.LabelFrame(left, text="Ajustes", padding=10)
box_adjust.pack(fill="x", pady=(0, 10))
ttk.Label(box_adjust, text="Escala da logo").pack(anchor="w")
self.scale_var = tk.DoubleVar(value=1.0)
self.scale_value = ttk.Label(box_adjust, text="1.00x")
self.scale_value.pack(anchor="e")
self.scale_slider = ttk.Scale(
box_adjust, from_=0.05, to=4.0, orient="horizontal",
variable=self.scale_var, command=self._on_scale_change
)
self.scale_slider.pack(fill="x")
ttk.Label(box_adjust, text="Opacidade").pack(anchor="w", pady=(10, 0))
self.opacity_var = tk.DoubleVar(value=1.0)
self.opacity_value = ttk.Label(box_adjust, text="100%")
self.opacity_value.pack(anchor="e")
self.opacity_slider = ttk.Scale(
box_adjust, from_=0.05, to=1.0, orient="horizontal",
variable=self.opacity_var, command=self._on_opacity_change
)
self.opacity_slider.pack(fill="x")
ttk.Label(box_adjust, text="Margem").pack(anchor="w", pady=(10, 0))
self.margin_var = tk.IntVar(value=20)
self.margin_value = ttk.Label(box_adjust, text="20 px")
self.margin_value.pack(anchor="e")
self.margin_slider = ttk.Scale(
box_adjust, from_=0, to=200, orient="horizontal",
variable=self.margin_var, command=self._on_margin_change
)
self.margin_slider.pack(fill="x")
# Posição
box_pos = ttk.LabelFrame(left, text="Posição", padding=10)
box_pos.pack(fill="x", pady=(0, 10))
grid = ttk.Frame(box_pos)
grid.pack(fill="x")
ttk.Button(grid, text="↖", width=4, command=lambda: self.snap("left", "top")).grid(row=0, column=0, padx=2, pady=2)
ttk.Button(grid, text="↑", width=4, command=lambda: self.snap("center", "top")).grid(row=0, column=1, padx=2, pady=2)
ttk.Button(grid, text="↗", width=4, command=lambda: self.snap("right", "top")).grid(row=0, column=2, padx=2, pady=2)
ttk.Button(grid, text="←", width=4, command=lambda: self.snap("left", "center")).grid(row=1, column=0, padx=2, pady=2)
ttk.Button(grid, text="●", width=4, command=lambda: self.snap("center", "center")).grid(row=1, column=1, padx=2, pady=2)
ttk.Button(grid, text="→", width=4, command=lambda: self.snap("right", "center")).grid(row=1, column=2, padx=2, pady=2)
ttk.Button(grid, text="↙", width=4, command=lambda: self.snap("left", "bottom")).grid(row=2, column=0, padx=2, pady=2)
ttk.Button(grid, text="↓", width=4, command=lambda: self.snap("center", "bottom")).grid(row=2, column=1, padx=2, pady=2)
ttk.Button(grid, text="↘", width=4, command=lambda: self.snap("right", "bottom")).grid(row=2, column=2, padx=2, pady=2)
form = ttk.Frame(box_pos)
form.pack(fill="x", pady=(10, 0))
ttk.Label(form, text="X").grid(row=0, column=0, sticky="w")
ttk.Label(form, text="Y").grid(row=0, column=2, sticky="w", padx=(10, 0))
self.x_var = tk.IntVar(value=20)
self.y_var = tk.IntVar(value=20)
self.x_spin = tk.Spinbox(form, from_=0, to=99999, textvariable=self.x_var, width=10, command=self._on_xy_changed)
self.y_spin = tk.Spinbox(form, from_=0, to=99999, textvariable=self.y_var, width=10, command=self._on_xy_changed)
self.x_spin.grid(row=1, column=0, sticky="we")
ttk.Button(form, text="←", width=3, command=lambda: self.nudge(-1, 0)).grid(row=1, column=1, padx=3)
self.y_spin.grid(row=1, column=2, sticky="we")
ttk.Button(form, text="↑", width=3, command=lambda: self.nudge(0, -1)).grid(row=1, column=3, padx=3)
ttk.Button(form, text="→", width=3, command=lambda: self.nudge(1, 0)).grid(row=2, column=1, padx=3, pady=(3, 0))
ttk.Button(form, text="↓", width=3, command=lambda: self.nudge(0, 1)).grid(row=2, column=3, padx=3, pady=(3, 0))
ttk.Button(box_pos, text="Centralizar logo", command=lambda: self.snap("center", "center")).pack(fill="x", pady=(10, 0))
ttk.Label(
box_pos,
text="Dica: arraste a logo na prévia ou use as setas do teclado.\nShift + setas move 10 px.",
wraplength=285,
justify="left"
).pack(fill="x", pady=(8, 0))
# Fundo
box_bg = ttk.LabelFrame(left, text="Remover fundo da logo", padding=10)
box_bg.pack(fill="x", pady=(0, 10))
self.remove_bg_var = tk.BooleanVar(value=False)
ttk.Checkbutton(
box_bg,
text="Ativar remoção de fundo",
variable=self.remove_bg_var,
command=self.update_preview,
).pack(anchor="w")
self.auto_bg_var = tk.BooleanVar(value=True)
ttk.Checkbutton(
box_bg,
text="Usar canto superior esquerdo como cor",
variable=self.auto_bg_var,
command=self._on_bg_mode_change,
).pack(anchor="w", pady=(4, 0))
ttk.Label(box_bg, text="Tolerância").pack(anchor="w", pady=(8, 0))
self.tolerance_var = tk.IntVar(value=24)
self.tolerance_value = ttk.Label(box_bg, text="24")
self.tolerance_value.pack(anchor="e")
self.tolerance_slider = ttk.Scale(
box_bg, from_=0, to=120, orient="horizontal",
variable=self.tolerance_var, command=self._on_tolerance_change
)
self.tolerance_slider.pack(fill="x")
ttk.Button(box_bg, text="Pegar cor na prévia", command=self.enable_pick_color).pack(fill="x", pady=(8, 0))
self.swatch_frame = tk.Frame(box_bg, bg="#999999", height=24, relief="flat", bd=0)
self.swatch_frame.pack(fill="x", pady=(6, 0))
self.swatch_label = ttk.Label(self.swatch_frame, text="Cor: -")
self.swatch_label.pack(anchor="w", padx=6, pady=3)
# Exportação
box_export = ttk.LabelFrame(left, text="Exportar", padding=10)
box_export.pack(fill="x", pady=(0, 10))
ttk.Button(box_export, text="Salvar imagem final", command=self.save_final).pack(fill="x")
ttk.Button(box_export, text="Resetar posição", command=self.reset_position).pack(fill="x", pady=(6, 0))
self.status = ttk.Label(left, text="Abra uma imagem e uma logo.", wraplength=285, justify="left")
self.status.pack(fill="x", pady=(10, 10))
# Canvas de Prévia
canvas_box = ttk.LabelFrame(right, text="Prévia", padding=6)
canvas_box.pack(fill="both", expand=True)
self.canvas = tk.Canvas(canvas_box, bg="#262626", highlightthickness=0)
self.canvas.pack(fill="both", expand=True)
self.canvas.bind("<Button-1>", self.on_canvas_click)
self.canvas.bind("<B1-Motion>", self.on_canvas_drag)
self.canvas.bind("<ButtonRelease-1>", self.on_canvas_release)
# Trace dos campos X/Y
self.x_var.trace_add("write", lambda *_: self._on_xy_field_entry())
self.y_var.trace_add("write", lambda *_: self._on_xy_field_entry())
self._refresh_fields()
self._update_swatch()
# --------- Load ----------
def open_base(self):
path = filedialog.askopenfilename(
title="Escolher imagem",
filetypes=[
("Imagens", "*.png *.jpg *.jpeg *.webp *.bmp"),
("Todos os arquivos", "*.*"),
],
)
if not path:
return
try:
self.base_original = Image.open(path).convert("RGBA")
self.base_path = path
self.base_label.config(text=f"Imagem: {os.path.basename(path)}")
self.fit_preview_base()
self.reset_position()
self.update_preview()
except Exception as e:
messagebox.showerror("Erro", f"Não consegui abrir a imagem.\n\n{e}")
def open_logo(self):
path = filedialog.askopenfilename(
title="Escolher logo",
filetypes=[
("Imagens", "*.png *.jpg *.jpeg *.webp *.bmp"),
("Todos os arquivos", "*.*"),
],
)
if not path:
return
try:
self.logo_original = Image.open(path).convert("RGBA")
self.logo_path = path
self.logo_label.config(text=f"Logo: {os.path.basename(path)}")
self.update_preview()
except Exception as e:
messagebox.showerror("Erro", f"Não consegui abrir a logo.\n\n{e}")
# --------- Preview / processing ----------
def fit_preview_base(self):
if not self.base_original:
return
bw, bh = self.base_original.size
ratio = min(PREVIEW_MAX_W / bw, PREVIEW_MAX_H / bh, 1.0)
ratio = max(ratio, 0.01)
self.preview_ratio = ratio
pw = max(1, int(bw * ratio))
ph = max(1, int(bh * ratio))
self.preview_base = self.base_original.resize((pw, ph), Image.LANCZOS)
self.canvas.config(width=pw, height=ph)
self.clamp_logo_inside_preview()
def get_bg_color(self):
if self.bg_color is not None:
return self.bg_color
if self.auto_bg_var.get() and self.logo_original:
return self.logo_original.getpixel((0, 0))[:3]
return None
def build_logo_image(self, target_scale, preview=False):
if not self.logo_original:
return None
logo = self.logo_original.copy().convert("RGBA")
if self.remove_bg_var.get():
bg = self.get_bg_color()
if bg is not None:
logo = remove_background_by_color(logo, bg, int(self.tolerance_var.get()))
logo = apply_opacity(logo, float(self.opacity_var.get()))
target_scale = max(0.01, float(target_scale))
w = max(1, int(logo.width * target_scale))
h = max(1, int(logo.height * target_scale))
if (w, h) != logo.size:
logo = logo.resize((w, h), Image.LANCZOS)
return logo
def update_preview(self):
if not self.base_original:
self.canvas.delete("all")
return
if not self.preview_base:
self.fit_preview_base()
if not self.preview_base:
return
preview = self.preview_base.copy().convert("RGBA")
self.preview_logo_cache = None
if self.logo_original:
preview_logo = self.build_logo_image(
target_scale=float(self.scale_var.get()) * self.preview_ratio,
preview=True
)
if preview_logo is not None:
self.preview_logo_cache = preview_logo
x = int(self.logo_x_preview)
y = int(self.logo_y_preview)
preview.alpha_composite(preview_logo, (x, y))
self.preview_tk = ImageTk.PhotoImage(preview)
self.canvas.delete("all")
self.canvas.create_image(0, 0, image=self.preview_tk, anchor="nw")
self._refresh_fields()
self._update_status()
self._update_swatch()
def _update_status(self):
parts = []
if self.base_original:
parts.append(f"Imagem: {self.base_original.width}x{self.base_original.height}")
if self.logo_original:
parts.append(f"Logo: {self.logo_original.width}x{self.logo_original.height}")
parts.append(f"Escala: {self.scale_var.get():.2f}x")
parts.append(f"Opacidade: {int(self.opacity_var.get() * 100)}%")
parts.append(f"Margem: {self.margin_var.get()} px")
parts.append(f"Posição: X={self.logo_x_preview}, Y={self.logo_y_preview}")
if self.remove_bg_var.get():
parts.append("Fundo: ligado")
else:
parts.append("Fundo: desligado")
self.status.config(text=" | ".join(parts))
def _update_swatch(self):
if self.bg_color is None:
if self.auto_bg_var.get() and self.logo_original:
c = self.logo_original.getpixel((0, 0))[:3]
self.swatch_frame.configure(bg=hex_from_rgb(c))
self.swatch_label.configure(text=f"Cor: auto {hex_from_rgb(c)}")
else:
self.swatch_frame.configure(bg="#999999")
self.swatch_label.configure(text="Cor: -")
return
self.swatch_frame.configure(bg=hex_from_rgb(self.bg_color))
self.swatch_label.configure(text=f"Cor: {hex_from_rgb(self.bg_color)}")
# --------- Sync fields ----------
def _refresh_fields(self):
if self._syncing_fields:
return
self._syncing_fields = True
try:
self.x_var.set(int(self.logo_x_preview))
self.y_var.set(int(self.logo_y_preview))
self.scale_value.config(text=f"{self.scale_var.get():.2f}x")
self.opacity_value.config(text=f"{int(float(self.opacity_var.get()) * 100)}%")
self.margin_value.config(text=f"{int(self.margin_var.get())} px")
self.tolerance_value.config(text=str(int(self.tolerance_var.get())))
finally:
self._syncing_fields = False
def _on_xy_field_entry(self):
if self._syncing_fields:
return
if not self.preview_base or not self.logo_original:
return
try:
x = int(self.x_var.get())
y = int(self.y_var.get())
except Exception:
return
self.logo_x_preview = x
self.logo_y_preview = y
self.clamp_logo_inside_preview()
self.update_preview()
def _on_xy_changed(self):
self._on_xy_field_entry()
def _on_scale_change(self, _=None):
self.scale_value.config(text=f"{float(self.scale_var.get()):.2f}x")
self.clamp_logo_inside_preview()
self.update_preview()
def _on_opacity_change(self, _=None):
self.opacity_value.config(text=f"{int(float(self.opacity_var.get()) * 100)}%")
self.update_preview()
def _on_margin_change(self, _=None):
self.margin_value.config(text=f"{int(self.margin_var.get())} px")
self.update_preview()
def _on_tolerance_change(self, _=None):
self.tolerance_value.config(text=str(int(self.tolerance_var.get())))
self.update_preview()
def _on_bg_mode_change(self):
if not self.auto_bg_var.get():
pass
self.update_preview()
# --------- Position helpers ----------
def current_preview_logo_size(self):
logo = self.preview_logo_cache
if logo is None:
logo = self.build_logo_image(float(self.scale_var.get()) * self.preview_ratio, preview=True)
if logo is None:
return None
return logo.width, logo.height
def clamp_logo_inside_preview(self):
if not self.preview_base or not self.logo_original:
return
size = self.current_preview_logo_size()
if not size:
return
lw, lh = size
bw, bh = self.preview_base.size
self.logo_x_preview = clamp(int(self.logo_x_preview), 0, max(0, bw - lw))
self.logo_y_preview = clamp(int(self.logo_y_preview), 0, max(0, bh - lh))
def snap(self, hx, vy):
if not self.preview_base or not self.logo_original:
return
size = self.current_preview_logo_size()
if not size:
return
lw, lh = size
bw, bh = self.preview_base.size
margin = int(self.margin_var.get())
if hx == "left":
x = margin
elif hx == "center":
x = (bw - lw) // 2
else:
x = bw - lw - margin
if vy == "top":
y = margin
elif vy == "center":
y = (bh - lh) // 2
else:
y = bh - lh - margin
self.logo_x_preview = clamp(x, 0, max(0, bw - lw))
self.logo_y_preview = clamp(y, 0, max(0, bh - lh))
self.update_preview()
def reset_position(self):
if not self.base_original:
return
self.logo_x_preview = 20
self.logo_y_preview = 20
self.update_preview()
def nudge(self, dx, dy):
if not self.preview_base or not self.logo_original:
return "break"
self.logo_x_preview += int(dx)
self.logo_y_preview += int(dy)
self.clamp_logo_inside_preview()
self.update_preview()
return "break"
# --------- Canvas interaction ----------
def enable_pick_color(self):
if not self.logo_original:
messagebox.showinfo("Falta a logo", "Abra a logo primeiro.")
return
self.pick_color_mode = True
self.root.config(cursor="crosshair")
self.status.config(text="Clique em cima da logo para pegar a cor a remover.")
def on_canvas_click(self, event):
if not self.base_original or not self.logo_original:
return
size = self.current_preview_logo_size()
if not size:
return
lw, lh = size
if self.pick_color_mode:
self.pick_color_mode = False
self.root.config(cursor="")
inside = (
self.logo_x_preview <= event.x <= self.logo_x_preview + lw and
self.logo_y_preview <= event.y <= self.logo_y_preview + lh
)
if not inside:
messagebox.showinfo("Fora da logo", "Clique em cima da logo para pegar a cor.")
self.update_preview()
return
px = event.x - self.logo_x_preview
py = event.y - self.logo_y_preview
scale = max(0.0001, float(self.scale_var.get()) * self.preview_ratio)
ox = clamp(int(px / scale), 0, self.logo_original.width - 1)
oy = clamp(int(py / scale), 0, self.logo_original.height - 1)
self.bg_color = self.logo_original.getpixel((ox, oy))[:3]
self._update_swatch()
self.update_preview()
return
if self.logo_x_preview <= event.x <= self.logo_x_preview + lw and self.logo_y_preview <= event.y <= self.logo_y_preview + lh:
self.dragging = True
self.drag_offset_x = event.x - self.logo_x_preview
self.drag_offset_y = event.y - self.logo_y_preview
def on_canvas_drag(self, event):
if not self.dragging:
return
if not self.preview_base or not self.logo_original:
return
size = self.current_preview_logo_size()
if not size:
return
lw, lh = size
bw, bh = self.preview_base.size
self.logo_x_preview = clamp(event.x - self.drag_offset_x, 0, max(0, bw - lw))
self.logo_y_preview = clamp(event.y - self.drag_offset_y, 0, max(0, bh - lh))
self.update_preview()
def on_canvas_release(self, _event):
self.dragging = False
# --------- Export ----------
def save_final(self):
if not self.base_original or not self.logo_original:
messagebox.showinfo("Falta coisa", "Abra a imagem e a logo primeiro.")
return
out = filedialog.asksaveasfilename(
title="Salvar imagem final",
defaultextension=".png",
filetypes=[
("PNG", "*.png"),
("WebP", "*.webp"),
("JPG", "*.jpg *.jpeg"),
],
)
if not out:
return
try:
base = self.base_original.copy().convert("RGBA")
logo = self.build_logo_image(float(self.scale_var.get()), preview=False)
if logo is None:
raise RuntimeError("Logo inválida.")
x = int(self.logo_x_preview / max(self.preview_ratio, 0.0001))
y = int(self.logo_y_preview / max(self.preview_ratio, 0.0001))
layer = Image.new("RGBA", base.size, (0, 0, 0, 0))
layer.alpha_composite(logo, (x, y))
result = Image.alpha_composite(base, layer)
ext = os.path.splitext(out)[1].lower()
if ext in (".jpg", ".jpeg"):
result = result.convert("RGB")
result.save(out, quality=95)
elif ext == ".webp":
result.save(out, quality=95)
else:
result.save(out)
messagebox.showinfo("Feito", f"Salvo em:\n{out}")
except Exception as e:
messagebox.showerror("Erro", f"Falhou ao salvar.\n\n{e}")
def main():
root = tk.Tk()
app = WatermarkApp(root)
root.mainloop()
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment