From f0636b85b75561e2a815c96b33fb6f24f91a99b5 Mon Sep 17 00:00:00 2001 From: alban Date: Tue, 10 Dec 2024 23:14:39 +0100 Subject: [PATCH] feat: add User Interface --- main.py | 199 ++++++++++++++++++++++++----------------------- process.py | 120 ++++++++++++++++++++++++++++ requirements.txt | 3 +- 3 files changed, 225 insertions(+), 97 deletions(-) create mode 100644 process.py diff --git a/main.py b/main.py index e7c5eac..db43183 100644 --- a/main.py +++ b/main.py @@ -1,116 +1,123 @@ -import math -from dataclasses import dataclass +import tkinter as tk +from tkinter import ttk +from process import process_frame +from PIL import Image, ImageTk # Required for displaying images +import cv2 # OpenCV for numpy array to image conversion -import numpy as np -import cv2 -import tkinter -import os -import sys -from pathlib import Path +class OpenCVInterface: + def __init__(self, root): + self.root = root + self.root.title("Yiking") -dir_path = Path(".").absolute() -TYPE_1 = "_________" -TYPE_2 = "___ ___" + # Variables for sliders with min, max, and default values + self.variables_config = { + "minDist": (0, 500, 100), + "param1": (0, 500, 30), + "param2": (0, 400, 25), + "minRadius": (0, 100, 5), + "maxRadius": (0, 1000, 1000), + "color1_R": (0, 64, 5), + "color1_V": (0, 64, 5), + "color1_B": (0, 64, 5), + "color2_R": (0, 64, 5), + "color2_V": (0, 64, 5), + "color2_B": (0, 64, 5), + } + self.variables = { + name: tk.IntVar(value=config[2]) for name, config in self.variables_config.items() + } -@dataclass -class Object: - x: int - y: int - diametre: int + # GUI Layout + self.setup_gui() + def setup_gui(self): + # Root grid layout + self.root.rowconfigure(1, weight=1) + self.root.columnconfigure(0, weight=1) + self.root.columnconfigure(1, weight=1) -# 1. Acquisition de l'image -src = dir_path.joinpath('tests/images/balls-full-small.jpg') -raw_image = cv2.imread(str(src)) + # Left Column: Sliders + left_frame = ttk.Frame(self.root) + left_frame.grid(row=0, column=0, sticky="nswe") -# 2. Boxing des objets via opencv -gray = cv2.cvtColor(raw_image, cv2.COLOR_BGR2GRAY) -blurred = cv2.medianBlur(gray, 25) -minDist = 100 -param1 = 30 # 500 -param2 = 25 # 200 #smaller value-> more false circles -minRadius = 5 -maxRadius = 1000 # 10 -circles = cv2.HoughCircles(blurred, cv2.HOUGH_GRADIENT, 1, minDist, param1=param1, param2=param2, minRadius=minRadius, - maxRadius=maxRadius) + for var_name, var in self.variables.items(): + min_val, max_val, _ = self.variables_config[var_name] + self.create_slider(left_frame, var_name, var, min_val, max_val) -min_diameter = 9999 -cochonnet = None -boules = [] -if circles is not None: - circles = np.uint16(np.around(circles)) - for i in circles[0, :]: - boule = Object(x=int(i[0]), y=int(i[1]), diametre=int(i[2])) - # cv2.circle(img, (boule.x, boule.y), boule.diametre, (0, 255, 0), 2) + # Right Column: Image Placeholder + self.image_canvas = tk.Canvas(self.root, bg="gray", width=1024, height=768) + self.image_canvas.grid(row=0, column=1, sticky="nswe") - # 3. Détection de la box la plus petite : cochonnet - if boule.diametre < min_diameter: - min_diameter = boule.diametre - if cochonnet != None: - boules.append(cochonnet) - cochonnet = boule - else: - boules.append(boule) + # Bottom Row: Run Button and Result + run_button = ttk.Button(self.root, text="Run", command=self.run_process) + run_button.grid(row=1, column=0, sticky="we") -img_check_shapes = raw_image.copy() -for boule in boules: - cv2.circle(img_check_shapes, (boule.x, boule.y), boule.diametre, (0, 255, 0), 2) -cv2.circle(img_check_shapes, (cochonnet.x, cochonnet.y), cochonnet.diametre, (255, 255, 0), -1) -# cv2.imshow('img_check_shapes', img_check_shapes) -# cv2.waitKey(0) -# cv2.destroyAllWindows() + self.result_text = tk.Text(self.root, height=10, width=40) + self.result_text.grid(row=1, column=1, sticky="nswe") -# 4. Regroupement en liste de boules 1 ou 2 selon la couleur principale de chaque box restante + def create_slider(self, parent, name, variable, min_val, max_val): + frame = ttk.Frame(parent) + frame.pack(fill="x", padx=5, pady=2) -hsv = cv2.cvtColor(raw_image, cv2.COLOR_BGR2HSV) -(h, s, v) = cv2.split(hsv) -s = s * 2 -s = np.clip(s, 0, 255) -imghsv = cv2.merge([h, s, v]) -# cv2.imshow('imghsv', imghsv) -# cv2.waitKey(0) -# cv2.destroyAllWindows() + # Label + label = ttk.Label(frame, text=name) + label.pack(side="left") -boules_couleurs = [] -for boule in boules: - half_diametre = int(boule.diametre / 2) - crop = imghsv[ - boule.y - half_diametre:boule.y + half_diametre, - boule.x - half_diametre:boule.x + half_diametre, - ].copy() - pixels = np.float32(crop.reshape(-1, 3)) - n_colors = 2 - criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 200, .1) - _, labels, palette = cv2.kmeans(pixels, n_colors, None, criteria, 10, cv2.KMEANS_RANDOM_CENTERS) - _, counts = np.unique(labels, return_counts=True) - (b, g, r) = palette[np.argmax(counts)] / 16 + def on_slide(value): + # Round value to nearest multiple of 5 + rounded_value = round(float(value) / 5) * 5 + variable.set(int(rounded_value)) # Update the variable with the rounded value - # A modulariser - boules_couleurs.append(TYPE_1 if b > 4 else TYPE_2) + # Slider + slider = ttk.Scale( + frame, from_=min_val, to=max_val, + variable=variable, orient="horizontal", command=on_slide + ) + slider.pack(side="left", fill="x", expand=True, padx=5) - # cv2.imshow('crop', crop) - # cv2.waitKey(0) + # Entry box + entry = ttk.Entry(frame, textvariable=variable, width=5) + entry.pack(side="left") -# 5. Calcul des distances entre chaque boule et le cochonnet selon le centre des boxs -boules_distance = {} -for i, boule in enumerate(boules): - dist = int(math.sqrt(math.pow(cochonnet.x - boule.x, 2) + math.pow(cochonnet.y - boule.y, 2))) - boules_distance[i] = dist -boules_distance = dict(sorted(boules_distance.items(), key=lambda item: item[1])) + def run_process(self): + # Collect slider values + parameters = {key: var.get() for key, var in self.variables.items()} -# 6. Liste ordonnée des 6 distances les plus faibles + try: + # Call process function + image, result_text = process_frame(parameters) -boules_proches = [x for x in list(boules_distance)[0:6]] + # Convert OpenCV image (numpy array) to PIL Image for Tkinter display + if image is not None: + image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) # Convert BGR to RGB + pil_image = Image.fromarray(image) # Convert numpy array to PIL Image -# 7. Sortie des 6 couleurs en --- ou - - -img_final = raw_image.copy() -for i in boules_proches: - boule = boules[i] - print(boules_couleurs[i]) - cv2.circle(img_final, (boule.x, boule.y), boule.diametre, (0, 255, 0), 2) + # Rescale image to fit within 1024x768 while preserving aspect ratio + max_width, max_height = 1024, 768 + original_width, original_height = pil_image.size + aspect_ratio = min(max_width / original_width, max_height / original_height) + new_width = int(original_width * aspect_ratio) + new_height = int(original_height * aspect_ratio) + pil_image = pil_image.resize((new_width, new_height), Image.Resampling.LANCZOS) -# Show result for testing: -cv2.imshow('img_final', img_final) -cv2.waitKey(0) -cv2.destroyAllWindows() + tk_image = ImageTk.PhotoImage(pil_image) # Convert PIL Image to Tkinter Image + + # Clear canvas and display the image + self.image_canvas.delete("all") + self.image_canvas.create_image(512, 384, image=tk_image, anchor="center") + self.image_canvas.image = tk_image # Keep a reference to prevent garbage collection + + # Update result text + self.result_text.delete(1.0, tk.END) + self.result_text.insert(tk.END, result_text) + except Exception as exc: + # Handle and display exceptions + self.result_text.delete(1.0, tk.END) + self.result_text.insert(tk.END, str(exc)) + self.image_canvas.delete("all") + +if __name__ == "__main__": + root = tk.Tk() + app = OpenCVInterface(root) + root.mainloop() diff --git a/process.py b/process.py new file mode 100644 index 0000000..f2ef7e7 --- /dev/null +++ b/process.py @@ -0,0 +1,120 @@ +from operator import itemgetter + +from PIL import Image, ImageTk + +import math +from dataclasses import dataclass + +import numpy as np +import cv2 +from pathlib import Path + +dir_path = Path(".").absolute() +TYPE_1 = "_________" +TYPE_2 = "___ ___" + + +@dataclass +class Object: + x: int + y: int + rayon: int + + +def process_frame(params): + """ + Simulates OpenCV processing using parameters from the GUI. + + Args: + params (dict): A dictionary of variable values passed from the GUI. + + Returns: + ImageTk.PhotoImage: A Tkinter-compatible image. + str: A result text description. + """ + # Simulate processing: for now, return a dummy image and text. + # width, height = 400, 300 + # image = Image.new("RGB", (width, height), + # color=(params["color1_R"] * 4, params["color1_V"] * 4, params["color1_B"] * 4)) + # image_tk = ImageTk.PhotoImage(image) + # + # result_text = f"Processed image with params: {params}" + # return image_tk, result_text + + (minDist, param1, param2, minRadius, maxRadius, + color1_R, color1_V, color1_B, color2_R, color2_V, color2_B) = itemgetter( + 'minDist', 'param1', 'param2', 'minRadius', 'maxRadius', + 'color1_R', 'color1_V', 'color1_B', 'color2_R', 'color2_V', 'color2_B' + )(params) + + # 1. Acquisition de l'image + src = dir_path.joinpath('tests/images/balls-full-small.jpg') + raw_image = cv2.imread(str(src)) + + # 2. Boxing des objets via opencv + gray = cv2.cvtColor(raw_image, cv2.COLOR_BGR2GRAY) + blurred = cv2.medianBlur(gray, 25) + + circles = cv2.HoughCircles(blurred, cv2.HOUGH_GRADIENT, 1, minDist, param1=param1, param2=param2, + minRadius=minRadius, + maxRadius=maxRadius) + + min_rayon = 9999 + cochonnet = None + boules = [] + if circles is not None: + circles = np.uint16(np.around(circles)) + for i in circles[0, :]: + boule = Object(x=int(i[0]), y=int(i[1]), rayon=int(i[2])) + + # 3. Détection de la box la plus petite : cochonnet + if boule.rayon < min_rayon: + min_rayon = boule.rayon + if cochonnet is not None: + boules.append(cochonnet) + cochonnet = boule + else: + boules.append(boule) + + # 4. Regroupement en liste de boules 1 ou 2 selon la couleur principale de chaque box restante + hsv = cv2.cvtColor(raw_image, cv2.COLOR_BGR2HSV) + (h, s, v) = cv2.split(hsv) + s = s * 2 + s = np.clip(s, 0, 255) + imghsv = cv2.merge([h, s, v]) + boules_couleurs = [] + for boule in boules: + half_diametre = int(boule.rayon / 2) + crop = imghsv[ + boule.y - half_diametre:boule.y + half_diametre, + boule.x - half_diametre:boule.x + half_diametre, + ].copy() + pixels = np.float32(crop.reshape(-1, 3)) + n_colors = 2 + criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 200, .1) + _, labels, palette = cv2.kmeans(pixels, n_colors, None, criteria, 10, cv2.KMEANS_RANDOM_CENTERS) + _, counts = np.unique(labels, return_counts=True) + (b, g, r) = palette[np.argmax(counts)] / 16 + + # A modulariser + boules_couleurs.append(TYPE_1 if b > 4 else TYPE_2) + + # 5. Calcul des distances entre chaque boule et le cochonnet selon le centre des boxs + boules_distance = {} + for i, boule in enumerate(boules): + dist = int(math.sqrt(math.pow(cochonnet.x - boule.x, 2) + math.pow(cochonnet.y - boule.y, 2))) + boules_distance[i] = dist + boules_distance = dict(sorted(boules_distance.items(), key=lambda item: item[1])) + + # 6. Liste ordonnée des 6 distances les plus faibles + boules_proches = [x for x in list(boules_distance)[0:6]] + + # 7. Sortie des 6 couleurs en --- ou - - + return_text = "" + img_final = raw_image.copy() + for i in boules_proches: + boule = boules[i] + return_text += f"{boules_couleurs[i]}\n" + cv2.circle(img_final, (boule.x, boule.y), boule.rayon, (0, 255, 0), 2) + + return img_final, return_text \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index cbce648..a58a8d2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ numpy~=2.1.3 -opencv-python \ No newline at end of file +opencv-python +pillow \ No newline at end of file