From 8ae9068ffa50dbc52c6dabff1aa3d2e439a45604 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Mon, 21 Jul 2025 12:08:01 +0200 Subject: [PATCH] OCR --- Dockerfile | 11 +++ app.py | 144 ++++++++++++++++++++++++++++++++++ config.py | 11 ++- requirements.txt | 5 +- static/js/receipt_analysis.js | 63 +++++++++++++++ templates/list_share.html | 17 +++- 6 files changed, 247 insertions(+), 4 deletions(-) create mode 100644 static/js/receipt_analysis.js diff --git a/Dockerfile b/Dockerfile index 7f44721..6fb9036 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,6 +4,17 @@ FROM python:3.13-slim # Ustawiamy katalog roboczy WORKDIR /app +# Zależności systemowe do OCR, obrazów, tesseract i języka PL +RUN apt-get update && apt-get install -y --no-install-recommends \ + tesseract-ocr \ + tesseract-ocr-pol \ + libglib2.0-0 \ + libsm6 \ + libxrender1 \ + libxext6 \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + # Kopiujemy wymagania COPY requirements.txt requirements.txt diff --git a/app.py b/app.py index 5c731c2..6655c0f 100644 --- a/app.py +++ b/app.py @@ -9,6 +9,9 @@ import psutil import secrets import hashlib +import re +import tempfile + from pillow_heif import register_heif_opener from datetime import datetime, timedelta, UTC, timezone @@ -49,6 +52,11 @@ from sqlalchemy import func, extract from collections import defaultdict, deque from functools import wraps +# OCR +from collections import Counter +import pytesseract + + app = Flask(__name__) app.config.from_object(Config) register_heif_opener() # pillow_heif dla HEIC @@ -335,6 +343,89 @@ def _receipt_error(message): return redirect(request.referrer or url_for("main_page")) +############# OCR ########################### + + +def preprocess_image_for_tesseract(pil_image): + import cv2 + import numpy as np + from PIL import Image + + # Konwersja PIL.Image → NumPy grayscale + image = np.array(pil_image.convert("L")) + + # Zwiększenie skali dla lepszej czytelności OCR + image = cv2.resize(image, None, fx=2.0, fy=2.0, interpolation=cv2.INTER_LINEAR) + + # Adaptacyjne progowanie (lepsze niż THRESH_BINARY przy nierównym tle) + image = cv2.adaptiveThreshold( + image, 255, cv2.ADAPTIVE_THRESH_MEAN_C, cv2.THRESH_BINARY, blockSize=15, C=10 + ) + + # Konwersja z powrotem na PIL.Image (dla pytesseract) + return Image.fromarray(image) + + +def extract_total_tesseract(image): + + text = pytesseract.image_to_string(image, lang="pol", config="--psm 6") + lines = text.splitlines() + candidates = [] + + fuzzy_regex = re.compile(r"[\dOo][.,:;g9zZ][\d]{2}") + + for line in lines: + if not line.strip(): + continue + + matches = re.findall(r"\d{1,4}[.,]\d{2}", line) + for match in matches: + try: + val = float(match.replace(",", ".")) + if 0.1 <= val <= 100000: + candidates.append((val, line)) + except: + continue + + fuzzy_matches = fuzzy_regex.findall(line) + for match in fuzzy_matches: + cleaned = ( + match.replace("O", "0") + .replace("o", "0") + .replace(":", ".") + .replace(";", ".") + .replace(",", ".") + .replace("g", "9") + .replace("z", "9") + .replace("Z", "9") + ) + try: + val = float(cleaned) + if 0.1 <= val <= 100: + candidates.append((val, line)) + except: + continue + + preferred = [ + val + for val, line in candidates + if re.search(r"sum[aąo]?|razem|zapłaty", line.lower()) + ] + + if preferred: + max_val = round(max(preferred), 2) + return max_val, lines + + if candidates: + max_val = round(max([val for val, _ in candidates]), 2) + return max_val, lines + + return 0.0, lines + + +############# END OCR ####################### + + # zabezpieczenie logowani do systemu - błędne hasła def is_ip_blocked(ip): now = time.time() @@ -1037,6 +1128,59 @@ def reorder_items(): return jsonify(success=True) +# OCR +@app.route("/lists//analyze", methods=["POST"]) +@login_required +def analyze_receipts_for_list(list_id): + list_obj = db.session.get(ShoppingList, list_id) + if not list_obj or list_obj.owner_id != current_user.id: + return jsonify({"error": "Brak dostępu"}), 403 + + receipt_objs = Receipt.query.filter_by(list_id=list_id).all() + results = [] + total = 0.0 + + for receipt in receipt_objs: + filepath = os.path.join(app.config["UPLOAD_FOLDER"], receipt.filename) + if not os.path.exists(filepath): + continue + + temp_path = None + + try: + if filepath.lower().endswith(".webp"): + + raw_image = Image.open(filepath).convert("RGB") + image = preprocess_image_for_tesseract(raw_image) + else: + + raw_image = Image.open(filepath).convert("RGB") + image = preprocess_image_for_tesseract(raw_image) + + value, lines = extract_total_tesseract(image) + + except Exception as e: + print(f"OCR error for {receipt.filename}: {e}") + value = 0.0 + lines = [] + + finally: + if temp_path and os.path.exists(temp_path): + os.unlink(temp_path) + + results.append( + { + "id": receipt.id, + "filename": receipt.filename, + "amount": round(value, 2), + "debug_text": lines, + } + ) + total += value + + return jsonify({"results": results, "total": round(total, 2)}) + + @app.route("/admin") @login_required @admin_required diff --git a/config.py b/config.py index 4bbb4bd..feb098e 100644 --- a/config.py +++ b/config.py @@ -10,6 +10,13 @@ class Config: DEFAULT_ADMIN_PASSWORD = os.environ.get("DEFAULT_ADMIN_PASSWORD", "admin123") UPLOAD_FOLDER = os.environ.get("UPLOAD_FOLDER", "uploads") AUTHORIZED_COOKIE_VALUE = os.environ.get("AUTHORIZED_COOKIE_VALUE", "cookievalue") - AUTH_COOKIE_MAX_AGE = int(os.environ.get("AUTH_COOKIE_MAX_AGE", 86400)) + try: + AUTH_COOKIE_MAX_AGE = int(os.environ.get("AUTH_COOKIE_MAX_AGE", "86400") or "86400") + except ValueError: + AUTH_COOKIE_MAX_AGE = 86400 + HEALTHCHECK_TOKEN = os.environ.get("HEALTHCHECK_TOKEN", "alamapsaikota1234") - SESSION_TIMEOUT_MINUTES = int(os.environ.get("SESSION_TIMEOUT_MINUTES", 10080)) + try: + SESSION_TIMEOUT_MINUTES = int(os.environ.get("SESSION_TIMEOUT_MINUTES", "10080") or "10080") + except ValueError: + SESSION_TIMEOUT_MINUTES = 10080 diff --git a/requirements.txt b/requirements.txt index 15f3aa5..5957702 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,4 +7,7 @@ eventlet Werkzeug Pillow psutil -pillow-heif \ No newline at end of file +pillow-heif + +pytesseract +opencv-python-headless \ No newline at end of file diff --git a/static/js/receipt_analysis.js b/static/js/receipt_analysis.js new file mode 100644 index 0000000..6210a5a --- /dev/null +++ b/static/js/receipt_analysis.js @@ -0,0 +1,63 @@ +document.addEventListener("DOMContentLoaded", () => { + const analyzeBtn = document.getElementById("analyzeBtn"); + if (analyzeBtn) { + analyzeBtn.addEventListener("click", () => analyzeReceipts(LIST_ID)); + } +}); + +async function analyzeReceipts(listId) { + const resultsDiv = document.getElementById("analysisResults"); + resultsDiv.innerHTML = `
⏳ Trwa analiza paragonów...
`; + + const start = performance.now(); // ⏱ START + + try { + const res = await fetch(`/lists/${listId}/analyze`, { method: "POST" }); + const data = await res.json(); + + const duration = ((performance.now() - start) / 1000).toFixed(2); // ⏱ STOP + + let html = `

📊 Łącznie wykryto: ${data.total.toFixed(2)} PLN

`; + html += `

⏱ Czas analizy OCR: ${duration} sek.

`; + + data.results.forEach((r, i) => { + html += ` +
+ ${r.filename}: + + +
`; + }); + + if (data.results.length > 1) { + html += ``; + } + + resultsDiv.innerHTML = html; + window._ocr_results = data.results; + + } catch (err) { + resultsDiv.innerHTML = `
❌ Wystąpił błąd podczas analizy.
`; + console.error(err); + } +} + + +function emitExpense(i) { + const r = window._ocr_results[i]; + const val = parseFloat(document.getElementById(`amount-${i}`).value); + if (!isNaN(val) && val > 0) { + socket.emit('add_expense', { + list_id: LIST_ID, + amount: val + + }); + document.getElementById(`amount-${i}`).disabled = true; + } +} + +function emitAllExpenses(n) { + for (let i = 0; i < n; i++) { + emitExpense(i); + } +} diff --git a/templates/list_share.html b/templates/list_share.html index 746b238..6b1e122 100644 --- a/templates/list_share.html +++ b/templates/list_share.html @@ -106,6 +106,21 @@
{% set receipt_pattern = 'list_' ~ list.id %} + {% if receipt_files %} +
+
+
🧠 Analiza paragonów (OCR)
+

System spróbuje automatycznie rozpoznać kwoty z dodanych paragonów.

+ + + +
+
+ {% endif %} + +
📸 Paragony dodane do tej listy
@@ -192,7 +207,7 @@ - +