240 lines
8.2 KiB
Python
240 lines
8.2 KiB
Python
"""
|
|
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()
|