diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2ba8ed5 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,47 @@ +# Ignorar arquivos e diretórios irrelevantes +*.pyc +*.pyo +*.pyd +__pycache__/ +*.log +*.tmp +*.swp + +# Ignorar arquivos de controle de versão +.git/ +*.gitignore + +# Ignorar ambientes virtuais e pacotes locais +.env/ +*.env +*.tar +*.zip +*.rar +recorte-placas-env/ + +# Ignorar imagens e arquivos grandes +*.jpg +*.png +photos/ +processed_photos/ + +# Ignorar outros arquivos desnecessários +*.tar +*.bak +*.old +*.DS_Store +*.idea +*.vscode + +# Incluir arquivos essenciais explicitamente +!Dockerfile +!requirements.txt +!imageProcAPI.py +!workflow.py +!scan.py +!teste.py + +# Incluir pastas essenciais explicitamente +!connection/ +!pyimagesearch/ +!utils/ \ No newline at end of file diff --git a/.gitignore b/.gitignore index 058598c..472de1a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,9 @@ *.pyc -.DS_Store \ No newline at end of file +.DS_Store +images/ +output/ +recorte-placas-env/ +photos/ +processed_photos/ +python-image/ +recorte-placas.tar \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..494b9b2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +# Use a imagem oficial do Python mais leve +FROM python:3.8-slim + +# Defina um ambiente não interativo para evitar prompts do apt +ENV DEBIAN_FRONTEND=noninteractive + +# Instale dependências essenciais do sistema +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + libopencv-dev \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +# Defina o diretório de trabalho dentro do container +WORKDIR /app + +# Copie e instale apenas as dependências antes para aproveitar o cache do Docker +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copie o restante do código da aplicação +COPY . . + +# Exponha a porta que o Flask usará +EXPOSE 5000 + +# Defina variáveis de ambiente para produção +ENV FLASK_APP=imageProcAPI.py +ENV FLASK_RUN_HOST=0.0.0.0 + +# Execute o servidor Flask diretamente para melhor compatibilidade +CMD ["python", "-m", "flask", "run", "--host=0.0.0.0"] \ No newline at end of file diff --git a/README.md b/README.md deleted file mode 100644 index 9ce8d29..0000000 --- a/README.md +++ /dev/null @@ -1,38 +0,0 @@ -# Document Scanner - -### An interactive document scanner built in Python using OpenCV - -The scanner takes a poorly scanned image, finds the corners of the document, applies the perspective transformation to get a top-down view of the document, sharpens the image, and applies an adaptive color threshold to clean up the image. - -On my test dataset of 280 images, the program correctly detected the corners of the document 92.8% of the time. - -This project makes use of the transform and imutils modules from pyimagesearch (which can be accessed [here](http://www.pyimagesearch.com/2014/09/01/build-kick-ass-mobile-document-scanner-just-5-minutes/)). The UI code for the interactive mode is adapted from `poly_editor.py` from [here](https://matplotlib.org/examples/event_handling/poly_editor.html). - -* You can manually click and drag the corners of the document to be perspective transformed: -![Example of interactive GUI](https://github.com/andrewdcampbell/doc_scanner/blob/master/ui.gif) - -* The scanner can also process an entire directory of images automatically and save the output in an output directory: -![Image Directory of images to be processed](https://github.com/andrewdcampbell/doc_scanner/blob/master/before_after.gif) - -#### Here are some examples of images before and after scan: - - - - - - - - - -### Usage -``` -python scan.py (--images | --image ) [-i] -``` -* The `-i` flag enables interactive mode, where you will be prompted to click and drag the corners of the document. For example, to scan a single image with interactive mode enabled: -``` -python scan.py --image sample_images/desk.JPG -i -``` -* Alternatively, to scan all images in a directory without any input: -``` -python scan.py --images sample_images -``` diff --git a/before_after.gif b/before_after.gif deleted file mode 100644 index 6bb31de..0000000 Binary files a/before_after.gif and /dev/null differ diff --git a/connection/patch.py b/connection/patch.py new file mode 100644 index 0000000..1762b14 --- /dev/null +++ b/connection/patch.py @@ -0,0 +1,23 @@ +import requests + +def patch(id, photo_url): + + url = f"https://tfiswpjimraodvnjbybx.supabase.co/rest/v1/Boards?id=eq.{id}" + headers = { "Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InRmaXN3cGppbXJhb2R2bmpieWJ4Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3MzMxNTIwNTUsImV4cCI6MjA0ODcyODA1NX0.I8UX36hAphowNk85VcL6iYh4TIBwsE0r3wgreTEDaII", + "apikey": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InRmaXN3cGppbXJhb2R2bmpieWJ4Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3MzMxNTIwNTUsImV4cCI6MjA0ODcyODA1NX0.I8UX36hAphowNk85VcL6iYh4TIBwsE0r3wgreTEDaII", + "Prefer": "return=representation" + } + + data = { + "trimmed_photo_url": photo_url + } + + + response = requests.patch(url, json=data, headers=headers) + + if response.status_code in [200, 204]: + print("Atualização feita com sucesso!") + print(response.json()) # Caso a API retorne um JSON + else: + print(f"Erro ao atualizar: {response.status_code}") + print(response.text) # Exibir a resposta do servidor diff --git a/connection/supabase_connection.py b/connection/supabase_connection.py new file mode 100644 index 0000000..204b6ae --- /dev/null +++ b/connection/supabase_connection.py @@ -0,0 +1,46 @@ +import requests + +class SupabaseConnection: + """ + Classe para gerenciar a conexão e upload de arquivos para o Supabase Storage. + """ + + def __init__(self): + """ + Inicializa a conexão com os parâmetros fornecidos. + + :param supabase_url: URL do Supabase + :param bucket_name: Nome do bucket no Supabase + :param file_key: Caminho onde será salvo no Supabase + :param file_path: Caminho do arquivo local + :param jwt_token: JWT Token para autenticação + """ + self.supabase_url = "https://tfiswpjimraodvnjbybx.supabase.co" + self.bucket_name = "boards" + self.jwt_token = "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InRmaXN3cGppbXJhb2R2bmpieWJ4Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3MzMxNTIwNTUsImV4cCI6MjA0ODcyODA1NX0.I8UX36hAphowNk85VcL6iYh4TIBwsE0r3wgreTEDaII" + self.headers = { + "Authorization": f"{self.jwt_token}", + "Content-Type": "image/jpeg" + } + + def upload_file_to_supabase(self, image_name, image_path): + """ + Faz o upload do arquivo para o Supabase Storage. + """ + upload_url = f"{self.supabase_url}/storage/v1/object/{self.bucket_name}/images/{image_name}" + + try: + with open(image_path, "rb") as file: + response = requests.put(upload_url, headers=self.headers, data=file) + + # Verificar resposta + if response.status_code == 200: + print(f"✅ Upload bem-sucedido! Arquivo salvo como: images/{image_name}") + response_json = response.json() + response_json.update({"url": f"{upload_url}"}) + return response_json + else: + print(f"❌ Erro no upload: {response.status_code} - {response.text}") + + except Exception as e: + print("❌ Erro ao enviar a imagem:", e) \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8f37bad --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +version: '3.8' # Versão do Docker Compose + +services: + flask_app: # Nome do serviço (pode ser qualquer nome) + build: . # Indica que o Dockerfile está no diretório atual + container_name: flask_container # Nome do contêiner (opcional) + ports: + - "5000:5000" # Mapeia a porta 5000 do contêiner para a porta 5000 do host + environment: + - FLASK_APP=imageProcAPI.py # Define a variável de ambiente FLASK_APP + - FLASK_RUN_HOST=0.0.0.0 # Define a variável de ambiente FLASK_RUN_HOST + volumes: + - .:/app # Monta o diretório atual do host no diretório /app do contêiner (útil para desenvolvimento) + restart: unless-stopped # Reinicia o contêiner automaticamente, a menos que seja parado manualmente \ No newline at end of file diff --git a/imageProcAPI.py b/imageProcAPI.py new file mode 100644 index 0000000..e12aa61 --- /dev/null +++ b/imageProcAPI.py @@ -0,0 +1,59 @@ +from flask import Flask, request, jsonify +from workflow import workImage +import threading +import time +import random +import string + +app = Flask(__name__) + +image_queue = [] # Lista para armazenar as URLs da fila +processing = False # Flag para indicar se há uma imagem sendo processada + +def process_next_image(): + global processing + while True: + if image_queue and not processing: + processing = True + image_data = image_queue.pop(0) + image_name = image_data["image_name"] + print(f"Processando imagem: {image_name}") + + # Processa a imagem e obtém os dados da imagem no bucket + workImage(image_data["photo_url"], image_name, image_data["id"]) + + processing = False + time.sleep(1) # Pequeno delay para evitar loop intenso + +def generate_random_name(length=10): + letters = string.ascii_lowercase + image_name = ''.join(random.choice(letters) for _ in range(length)) + return image_name + ".jpg" + +@app.route("/") +def imageURL(): + return "API de Processamento de Imagens - Online" + +@app.route("/create-procimage", methods=["POST"]) +def create_procimage(): + data = request.get_json() + + if "photo_url" not in data: + return jsonify({"error": "Parâmetro 'photo_url' é obrigatório"}), 400 + + if "id" not in data: + return jsonify({"error": "Parâmetro 'id' é obrigatório"}), 400 + + image_name = generate_random_name() + data["image_name"] = image_name + image_queue.append(data) + print(f"Imagem recebida e adicionada na fila com nome: {data['image_name']}") + + return jsonify({"status": "ok", "image_name": image_name, "processed_image_url": f"https://tfiswpjimraodvnjbybx.supabase.co/storage/v1/object/boards/images/{image_name}"}), 201 + +if __name__ == "__main__": + # Iniciar a thread para processar a fila em background + processing_thread = threading.Thread(target=process_next_image, daemon=True) + processing_thread.start() + + app.run(host="0.0.0.0", debug=True) diff --git a/output/cell_pic.jpg b/output/cell_pic.jpg deleted file mode 100644 index edea3a3..0000000 Binary files a/output/cell_pic.jpg and /dev/null differ diff --git a/output/chart.JPG b/output/chart.JPG deleted file mode 100644 index 11bffdd..0000000 Binary files a/output/chart.JPG and /dev/null differ diff --git a/output/desk.JPG b/output/desk.JPG deleted file mode 100644 index c4f6373..0000000 Binary files a/output/desk.JPG and /dev/null differ diff --git a/output/dollar_bill.JPG b/output/dollar_bill.JPG deleted file mode 100644 index 82daeb1..0000000 Binary files a/output/dollar_bill.JPG and /dev/null differ diff --git a/output/math_cheat_sheet.JPG b/output/math_cheat_sheet.JPG deleted file mode 100644 index e97451e..0000000 Binary files a/output/math_cheat_sheet.JPG and /dev/null differ diff --git a/output/notepad.JPG b/output/notepad.JPG deleted file mode 100644 index 2990c84..0000000 Binary files a/output/notepad.JPG and /dev/null differ diff --git a/output/receipt.jpg b/output/receipt.jpg deleted file mode 100644 index 6fba926..0000000 Binary files a/output/receipt.jpg and /dev/null differ diff --git a/output/tax.jpeg b/output/tax.jpeg deleted file mode 100644 index 3db7423..0000000 Binary files a/output/tax.jpeg and /dev/null differ diff --git a/polygon_interacter.py b/polygon_interacter.py deleted file mode 100644 index 66b3e1e..0000000 --- a/polygon_interacter.py +++ /dev/null @@ -1,106 +0,0 @@ -import numpy as np -from matplotlib.lines import Line2D -from matplotlib.artist import Artist - - -class PolygonInteractor(object): - """ - An polygon editor - """ - - showverts = True - epsilon = 5 # max pixel distance to count as a vertex hit - - def __init__(self, ax, poly): - if poly.figure is None: - raise RuntimeError('You must first add the polygon to a figure or canvas before defining the interactor') - self.ax = ax - canvas = poly.figure.canvas - self.poly = poly - - x, y = zip(*self.poly.xy) - self.line = Line2D(x, y, marker='o', markerfacecolor='r', animated=True) - self.ax.add_line(self.line) - - cid = self.poly.add_callback(self.poly_changed) - self._ind = None # the active vert - - canvas.mpl_connect('draw_event', self.draw_callback) - canvas.mpl_connect('button_press_event', self.button_press_callback) - canvas.mpl_connect('button_release_event', self.button_release_callback) - canvas.mpl_connect('motion_notify_event', self.motion_notify_callback) - self.canvas = canvas - - def get_poly_points(self): - return np.asarray(self.poly.xy) - - def draw_callback(self, event): - self.background = self.canvas.copy_from_bbox(self.ax.bbox) - self.ax.draw_artist(self.poly) - self.ax.draw_artist(self.line) - self.canvas.blit(self.ax.bbox) - - def poly_changed(self, poly): - 'this method is called whenever the polygon object is called' - # only copy the artist props to the line (except visibility) - vis = self.line.get_visible() - Artist.update_from(self.line, poly) - self.line.set_visible(vis) # don't use the poly visibility state - - def get_ind_under_point(self, event): - 'get the index of the vertex under point if within epsilon tolerance' - - # display coords - xy = np.asarray(self.poly.xy) - xyt = self.poly.get_transform().transform(xy) - xt, yt = xyt[:, 0], xyt[:, 1] - d = np.sqrt((xt - event.x)**2 + (yt - event.y)**2) - indseq = np.nonzero(np.equal(d, np.amin(d)))[0] - ind = indseq[0] - - if d[ind] >= self.epsilon: - ind = None - - return ind - - def button_press_callback(self, event): - 'whenever a mouse button is pressed' - if not self.showverts: - return - if event.inaxes is None: - return - if event.button != 1: - return - self._ind = self.get_ind_under_point(event) - - def button_release_callback(self, event): - 'whenever a mouse button is released' - if not self.showverts: - return - if event.button != 1: - return - self._ind = None - - def motion_notify_callback(self, event): - 'on mouse movement' - if not self.showverts: - return - if self._ind is None: - return - if event.inaxes is None: - return - if event.button != 1: - return - x, y = event.xdata, event.ydata - - self.poly.xy[self._ind] = x, y - if self._ind == 0: - self.poly.xy[-1] = x, y - elif self._ind == len(self.poly.xy) - 1: - self.poly.xy[0] = x, y - self.line.set_data(zip(*self.poly.xy)) - - self.canvas.restore_region(self.background) - self.ax.draw_artist(self.poly) - self.ax.draw_artist(self.line) - self.canvas.blit(self.ax.bbox) diff --git a/pyimagesearch/__init__.py b/pyimagesearch/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/requirements.txt b/requirements.txt index 6c24c69..90e7a3d 100644 Binary files a/requirements.txt and b/requirements.txt differ diff --git a/sample_images/cell_pic.jpg b/sample_images/cell_pic.jpg deleted file mode 100644 index 04e7a9c..0000000 Binary files a/sample_images/cell_pic.jpg and /dev/null differ diff --git a/sample_images/chart.JPG b/sample_images/chart.JPG deleted file mode 100755 index ebf13ef..0000000 Binary files a/sample_images/chart.JPG and /dev/null differ diff --git a/sample_images/desk.JPG b/sample_images/desk.JPG deleted file mode 100644 index f8b745c..0000000 Binary files a/sample_images/desk.JPG and /dev/null differ diff --git a/sample_images/dollar_bill.JPG b/sample_images/dollar_bill.JPG deleted file mode 100755 index 6d58e3b..0000000 Binary files a/sample_images/dollar_bill.JPG and /dev/null differ diff --git a/sample_images/math_cheat_sheet.JPG b/sample_images/math_cheat_sheet.JPG deleted file mode 100755 index a1c20e9..0000000 Binary files a/sample_images/math_cheat_sheet.JPG and /dev/null differ diff --git a/sample_images/notepad.JPG b/sample_images/notepad.JPG deleted file mode 100644 index afab3e2..0000000 Binary files a/sample_images/notepad.JPG and /dev/null differ diff --git a/sample_images/receipt.jpg b/sample_images/receipt.jpg deleted file mode 100644 index 35aa50d..0000000 Binary files a/sample_images/receipt.jpg and /dev/null differ diff --git a/sample_images/tax.jpeg b/sample_images/tax.jpeg deleted file mode 100644 index 99a450f..0000000 Binary files a/sample_images/tax.jpeg and /dev/null differ diff --git a/scan.py b/scan.py index 4aa835e..45d2b57 100644 --- a/scan.py +++ b/scan.py @@ -11,7 +11,6 @@ from pyimagesearch import imutils from scipy.spatial import distance as dist from matplotlib.patches import Polygon -import polygon_interacter as poly_i import numpy as np import matplotlib.pyplot as plt import itertools @@ -94,7 +93,6 @@ def get_corners(self, img): to be rescaled and Canny filtered prior to be passed in. """ lines = lsd(img) - # massages the output from LSD # LSD operates on edges. One "line" has 2 edges, and so we need to combine the edges back into lines # 1. separate out the lines into horizontal and vertical lines. @@ -160,6 +158,11 @@ def get_corners(self, img): corners = self.filter_corners(corners) return corners + + + + + def is_valid_contour(self, cnt, IM_WIDTH, IM_HEIGHT): """Returns True if the contour satisfies all requirements set at instantitation""" @@ -249,24 +252,11 @@ def get_contour(self, rescaled_image): return screenCnt.reshape(4, 2) - def interactive_get_contour(self, screenCnt, rescaled_image): - poly = Polygon(screenCnt, animated=True, fill=False, color="yellow", linewidth=5) - fig, ax = plt.subplots() - ax.add_patch(poly) - ax.set_title(('Drag the corners of the box to the corners of the document. \n' - 'Close the window when finished.')) - p = poly_i.PolygonInteractor(ax, poly) - plt.imshow(rescaled_image) - plt.show() - - new_points = p.get_poly_points()[:4] - new_points = np.array([[p] for p in new_points], dtype = "int32") - return new_points.reshape(4, 2) - def scan(self, image_path): RESCALED_HEIGHT = 500.0 - OUTPUT_DIR = 'output' + # OUTPUT_DIR = 'output' + OUTPUT_DIR = 'processed_photos' # load the image and compute the ratio of the old height # to the new height, clone it, and resize it @@ -287,23 +277,13 @@ def scan(self, image_path): # apply the perspective transformation warped = transform.four_point_transform(orig, screenCnt * ratio) - # convert the warped image to grayscale - gray = cv2.cvtColor(warped, cv2.COLOR_BGR2GRAY) - - # sharpen image - sharpen = cv2.GaussianBlur(gray, (0,0), 3) - sharpen = cv2.addWeighted(gray, 1.5, sharpen, -0.5, 0) - - # apply adaptive threshold to get black and white effect - thresh = cv2.adaptiveThreshold(sharpen, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 21, 15) - # save the transformed image basename = os.path.basename(image_path) - cv2.imwrite(OUTPUT_DIR + '/' + basename, thresh) - print("Proccessed " + basename) + cv2.imwrite(OUTPUT_DIR + '/' + basename, warped) + return OUTPUT_DIR + '/' + basename -if __name__ == "__main__": +def main(args): ap = argparse.ArgumentParser() group = ap.add_mutually_exclusive_group(required=True) group.add_argument("--images", help="Directory of images to be scanned") @@ -311,7 +291,7 @@ def scan(self, image_path): ap.add_argument("-i", action='store_true', help = "Flag for manually verifying and/or setting document corners") - args = vars(ap.parse_args()) + args = vars(ap.parse_args(args)) im_dir = args["images"] im_file_path = args["image"] interactive_mode = args["i"] @@ -324,10 +304,14 @@ def scan(self, image_path): # Scan single image specified by command line argument --image if im_file_path: - scanner.scan(im_file_path) + return scanner.scan(im_file_path) # Scan all valid images in directory specified by command line argument --images else: im_files = [f for f in os.listdir(im_dir) if get_ext(f) in valid_formats] for im in im_files: scanner.scan(im_dir + '/' + im) + +if __name__ == "__main__": + import sys + main(sys.argv[1:]) diff --git a/ui.gif b/ui.gif deleted file mode 100644 index 8a183a8..0000000 Binary files a/ui.gif and /dev/null differ diff --git a/utils/utils.py b/utils/utils.py new file mode 100644 index 0000000..a961ce5 --- /dev/null +++ b/utils/utils.py @@ -0,0 +1,45 @@ +import requests +import os +import random +import string +import requests +import tempfile +from io import BytesIO + + +def download_image_temp(url, image_name): + try: + # Gerar um nome aleatório para a imagem + image_name = image_name + + # Fazer a requisição da imagem + response = requests.get(url, stream=True) + response.raise_for_status() + + processed_photos_dir = "processed_photos" + if not os.path.exists(processed_photos_dir): + os.makedirs(processed_photos_dir) + + # Criar um arquivo temporário + temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=".jpg") + temp_path = temp_file.name # Caminho do arquivo temporário + + # Escrever a imagem no arquivo temporário + with open(temp_path, 'wb') as file: + for chunk in response.iter_content(chunk_size=8192): + file.write(chunk) + + print(f"Imagem baixada temporariamente em {temp_path}") + + return temp_path # Retorna o caminho do arquivo temporário + + except requests.exceptions.HTTPError as e: + print(f"Erro ao baixar a imagem: {e}") + return None + +def delete_temp_file(file_path): + try: + os.remove(file_path) + print(f"Arquivo temporário {file_path} removido com sucesso") + except Exception as e: + print(f"Erro ao remover o arquivo temporário {file_path}: {e}") \ No newline at end of file diff --git a/workflow.py b/workflow.py new file mode 100644 index 0000000..c3870e0 --- /dev/null +++ b/workflow.py @@ -0,0 +1,21 @@ +from connection.supabase_connection import SupabaseConnection +import utils.utils as utils +import scan +import connection.patch as patch + +# Baixar a imagem localmente +def workImage(photo_url, image_name, image_id): + + image_path = utils.download_image_temp(photo_url, image_name) + + processed_path = scan.main(["--image", f"{image_path}"]) + + utils.delete_temp_file(image_path) + + bucket = SupabaseConnection() + + data = bucket.upload_file_to_supabase(image_name, processed_path) + + patch.patch(image_id, data["url"]) + + utils.delete_temp_file(processed_path) \ No newline at end of file