This commit is contained in:
2026-05-15 13:09:40 +02:00
parent bf3114f09b
commit a18ad914c3
3 changed files with 271 additions and 0 deletions
@@ -0,0 +1,239 @@
"""
Deskew Tool v2 — interaktivní oprava sklonu dokumentu.
Použití:
Klikej na body podél JAKÉKOLI rovné hrany (min. 2 body).
Skript sám rozpozná, jestli je hrana horizontální nebo vertikální.
Výsledný úhel lze doladit sliderem. Náhled před uložením.
"""
import math
import sys
from pathlib import Path
import tkinter as tk
from tkinter import messagebox
import numpy as np
from PIL import Image, ImageTk
IMAGE_PATH = Path(__file__).parent / "2518e068-c7fb-44fc-b130-c75f104ea50e.jpeg"
OUTPUT_PDF = Path(__file__).parent / "opraveny_dokument.pdf"
MAX_DISPLAY = (1300, 820)
def fit_scale(img_w, img_h, max_w, max_h):
scale = min(max_w / img_w, max_h / img_h, 1.0)
return scale, int(img_w * scale), int(img_h * scale)
def angle_of_points(pts):
"""Lineární regrese → úhel přímky procházející body (ve stupních, -90..+90)."""
xs = np.array([p[0] for p in pts], dtype=float)
ys = np.array([p[1] for p in pts], dtype=float)
if len(pts) < 2:
return 0.0
# fit line
if np.ptp(xs) < 1e-6: # téměř vertikální
return 90.0
coeffs = np.polyfit(xs, ys, 1)
slope = coeffs[0] # dy/dx
return math.degrees(math.atan(slope)) # -90..+90
def rotation_from_edge(pts):
"""
Z bodů na hraně určí potřebnou rotaci.
Automaticky rozpozná, jestli je hrana blíž horizontální nebo vertikální.
"""
a = angle_of_points(pts) # úhel vůči horizontální ose (y↓ souřadnice)
# Je-li hrana blíže horizontální (|a| < 45°): přímá korekce
if abs(a) <= 45:
return -a
# Je-li blíže vertikální: hrana by měla být ±90°
if a > 0:
return -(a - 90)
else:
return -(a + 90)
def rotate_image(img, angle):
"""Rotuje PIL Image kolem středu, bílé pozadí, zachová rozměry obsahu."""
return img.rotate(angle, expand=True, fillcolor="white", resample=Image.BICUBIC)
class DeskewApp:
DOT_COLOR = "#e74c3c"
LINE_COLOR = "#e74c3c"
SLIDER_RES = 0.05 # krok slideru ve stupních
def __init__(self, root, image_path):
self.root = root
self.root.title("Deskew Tool v2")
self.orig_img = Image.open(image_path)
iw, ih = self.orig_img.size
self.scale, dw, dh = fit_scale(iw, ih, *MAX_DISPLAY)
self.disp_img = self.orig_img.resize((dw, dh), Image.LANCZOS)
self.tk_img = ImageTk.PhotoImage(self.disp_img)
self.points = [] # (x_orig, y_orig)
self.rot_var = tk.DoubleVar(value=0.0)
self._build_ui(dw, dh)
# ------------------------------------------------------------------ UI --
def _build_ui(self, dw, dh):
# --- horní panel ---
top = tk.Frame(self.root)
top.pack(fill=tk.X, padx=8, pady=(6, 2))
self.lbl = tk.Label(
top,
text="Klikni na body podél rovné hrany (min. 2 body) — Skript sám určí orientaci hrany",
font=("Segoe UI", 10),
fg="#333",
anchor="w",
)
self.lbl.pack(side=tk.LEFT, fill=tk.X, expand=True)
tk.Button(top, text="Reset", command=self.reset, width=8).pack(side=tk.RIGHT, padx=2)
tk.Button(top, text="Spočítej úhel", command=self.compute_angle,
width=14, bg="#2980b9", fg="white").pack(side=tk.RIGHT, padx=2)
# --- canvas ---
self.canvas = tk.Canvas(self.root, width=dw, height=dh, cursor="crosshair")
self.canvas.pack(padx=8, pady=2)
self.canvas.create_image(0, 0, anchor=tk.NW, image=self.tk_img)
self.canvas.bind("<Button-1>", self.on_click)
# --- slider panel ---
bot = tk.Frame(self.root)
bot.pack(fill=tk.X, padx=8, pady=(2, 6))
tk.Label(bot, text="Rotace (°):", font=("Segoe UI", 10)).pack(side=tk.LEFT)
self.lbl_angle = tk.Label(bot, text="0.00°", width=8,
font=("Segoe UI", 10, "bold"), fg="#c0392b")
self.lbl_angle.pack(side=tk.LEFT, padx=4)
self.slider = tk.Scale(
bot,
from_=-20, to=20,
resolution=self.SLIDER_RES,
orient=tk.HORIZONTAL,
variable=self.rot_var,
length=400,
command=self._on_slider,
showvalue=False,
)
self.slider.pack(side=tk.LEFT, padx=6)
tk.Button(bot, text="Náhled", command=self.preview,
width=10, bg="#8e44ad", fg="white").pack(side=tk.LEFT, padx=6)
self.btn_save = tk.Button(
bot, text="Ulož PDF", command=self.save_pdf,
width=12, bg="#27ae60", fg="white",
font=("Segoe UI", 10, "bold"),
)
self.btn_save.pack(side=tk.LEFT, padx=4)
self.root.bind("<Return>", lambda e: self.save_pdf())
# ---------------------------------------------------------- interactions --
def on_click(self, event):
x_orig = event.x / self.scale
y_orig = event.y / self.scale
self.points.append((x_orig, y_orig))
r = 6
self.canvas.create_oval(
event.x - r, event.y - r, event.x + r, event.y + r,
outline=self.DOT_COLOR, fill=self.DOT_COLOR,
)
# průběžná regresní přímka
if len(self.points) >= 2:
self._redraw_line()
n = len(self.points)
suffix = 'y' if 1 < n < 5 else 'ů' if n >= 5 else ''
self.lbl.config(text=f"{n} bod{suffix} — klikej dál nebo stiskni 'Spočítej úhel'")
def _redraw_line(self):
"""Překreslí regresní přímku přes celou canvas."""
self.canvas.delete("regline")
if len(self.points) < 2:
return
xs = [p[0] * self.scale for p in self.points]
ys = [p[1] * self.scale for p in self.points]
x0, x1 = 0, self.canvas.winfo_width()
if np.ptp(xs) < 1:
self.canvas.create_line(xs[0], 0, xs[0], self.canvas.winfo_height(),
fill=self.LINE_COLOR, width=2, dash=(8,4), tags="regline")
return
coeffs = np.polyfit(xs, ys, 1)
y0 = coeffs[0] * x0 + coeffs[1]
y1 = coeffs[0] * x1 + coeffs[1]
self.canvas.create_line(x0, y0, x1, y1,
fill=self.LINE_COLOR, width=2, dash=(8, 4), tags="regline")
def compute_angle(self):
if len(self.points) < 2:
messagebox.showwarning("Málo bodů", "Klikni alespoň na 2 body podél hrany.")
return
rot = rotation_from_edge(self.points)
self.rot_var.set(round(rot, 2))
self.lbl_angle.config(text=f"{rot:+.2f}°")
self.lbl.config(text=f"Vypočítaná rotace: {rot:+.2f}° — dolaď sliderem nebo klikej na Náhled / Ulož PDF")
def _on_slider(self, val):
v = float(val)
self.lbl_angle.config(text=f"{v:+.2f}°")
def reset(self):
self.points.clear()
self.canvas.delete("all")
self.canvas.create_image(0, 0, anchor=tk.NW, image=self.tk_img)
self.rot_var.set(0.0)
self.lbl_angle.config(text="0.00°")
self.lbl.config(text="Klikni na body podél rovné hrany (min. 2 body) — Skript sám určí orientaci hrany")
def _corrected_image(self):
return rotate_image(self.orig_img, self.rot_var.get())
def preview(self):
rot = self.rot_var.get()
corrected = self._corrected_image()
pw, ph = corrected.size
scale2, dw2, dh2 = fit_scale(pw, ph, *MAX_DISPLAY)
thumb = corrected.resize((dw2, dh2), Image.LANCZOS)
win = tk.Toplevel(self.root)
win.title(f"Náhled — rotace {rot:+.2f}°")
tk_thumb = ImageTk.PhotoImage(thumb)
lbl = tk.Label(win, image=tk_thumb)
lbl.image = tk_thumb # drž referenci
lbl.pack()
def save_pdf(self):
corrected = self._corrected_image()
if corrected.mode != "RGB":
corrected = corrected.convert("RGB")
corrected.save(OUTPUT_PDF, "PDF", resolution=200)
rot = self.rot_var.get()
messagebox.showinfo("Uloženo", f"Rotace: {rot:+.2f}°\nPDF:\n{OUTPUT_PDF}")
self.root.destroy()
def main():
if not IMAGE_PATH.exists():
sys.exit(f"Obrázek nenalezen: {IMAGE_PATH}")
root = tk.Tk()
DeskewApp(root, IMAGE_PATH)
root.mainloop()
if __name__ == "__main__":
main()