¿Cuál es la mejor parte de ser ingeniero? Puedes simplemente construir cosas. Es como una superpotencia. Una tarde lluviosa tuve esta idea aleatoria de crear una visualización de sentimientos de una entrada de texto con una cara sonriente que cambia su base de expresión en cuán positivo es el texto. Cuanto más positivo sea el texto, más feliz se ve la sonrisa. Hay algunos conceptos interesantes que aprender aquí, ¡así que déjame guiarte a través de cómo funciona este proyecto!
Requisitos previos
Para seguir, necesita los siguientes paquetes:
- CustomTkinter
- Opencvv-pitón
- antorcha
- transformadores
Usando uvapuede agregar las dependencias con el siguiente comando:
uv add customtkinter opencv-Python torch transformers
NOTA: Al usar uva Con la antorcha debe especificar el índice para el paquete. Por ejemplo, si desea usar CUDA, necesita lo siguiente en su
pyproject.toml:[[tool.uv.index]] name = "pytorch-cu118" url = "https://download.pytorch.org/whl/cu118" explicit = true [tool.uv.sources] torch = [{ index = "pytorch-cu118" }] torchvision = [{ index = "pytorch-cu118" }]
Esqueleto de diseño de ui
Para este tipo de proyectos, siempre me gusta comenzar con un diseño rápido de los componentes de la interfaz de usuario. En este caso, el diseño será bastante simple, hay un cuadro de texto con una sola línea en la parte superior que llena el ancho y debajo del lienzo que llena el resto del espacio disponible. Aquí será donde dibujamos la cara sonriente 🙂
Usando customtkinterpodemos escribir el diseño de la siguiente manera:
import customtkinter
class App(customtkinter.CTk):
def __init__(self) -> None:
super().__init__()
self.title("Sentiment Analysis")
self.geometry("800x600")
self.grid_columnconfigure(0, weight=1)
self.grid_rowconfigure(0, weight=0)
self.grid_rowconfigure(1, weight=1)
self.sentiment_text_var = customtkinter.StringVar(master=self, value="Love")
self.textbox = customtkinter.CTkEntry(
master=self,
corner_radius=10,
font=("Consolas", 50),
justify="center",
placeholder_text="Enter text here...",
placeholder_text_color="gray",
textvariable=self.sentiment_text_var,
)
self.textbox.grid(row=0, column=0, padx=20, pady=20, sticky="nsew")
self.textbox.focus()
self.image_display = CTkImageDisplay(self)
self.image_display.grid(row=1, column=0, padx=20, pady=20, sticky="nsew")
Desafortunadamente, no hay una buena solución fuera de la caja para dibujar marcos de OpenCV en un elemento de interfaz de usuario, así que construí la mía CTkImageDisplay. Si desea aprender en detalle cómo funciona, consulte mi publicación anterior. En resumen, uso un CTKLabel componente y desacoplar el hilo que actualiza la imagen del hilo de la GUI utilizando una cola de sincronización.
Smiley de procedimiento
Para nuestra cara sonriente, podríamos usar diferentes imágenes discretas para rangos de sentimientos, por lo que, por ejemplo, tener tres imágenes guardadas para negativo, neutral y positivo. Sin embargo, para obtener un sentimiento más de grano visualizado, necesitaríamos más imágenes y rápidamente se vuelve inviable y no podremos animar las transiciones entre estas imágenes.
Un mejor enfoque es generar la imagen de la cara sonriente procesalmente en tiempo de ejecución. Para mantenerlo simple, solo cambiaremos el color de fondo del Smiley, así como la curva de su boca.
Primero necesitamos generar una imagen de lienzo, en la que podemos dibujar el Smiley.
def create_sentiment_image(positivity: float, image_size: tuple[int, int]) -> np.ndarray:
"""
Generates a sentiment image based on the positivity score.
This draws a smiley with its expression based on the positivity score.
Args:
positivity: A float representing the positivity score in the range [-1, 1].
image_size: A tuple representing the size of the image (width, height).
Returns:
A string representing the path to the generated sentiment image.
"""
width, height = image_size
frame = np.zeros((height, width, 4), dtype=np.uint8)
# TODO: draw smiley
return frame
Nuestra imagen debe ser transparente fuera de la cara sonriente, por lo que necesitamos 4 canales de color, la última será el canal alfa. Dado que las imágenes OpenCV se representan como numpy matrices con enteros de 8 bits sin firmarcreamos la imagen usando el np.uint8 Tipo de datos. Recuerda que las matrices se almacenan Y-Firstentonces el height del image_size se pasa primero a la creación de la matriz
Podemos definir algunas variables para las dimensiones y los colores de nuestra sonrisa que serán útiles mientras dibujan.
color_outline = (80,) * 3 + (255,) # gray
thickness_outline = min(image_size) // 30
center = (width // 2, height // 2)
radius = min(image_size) // 2 - thickness_outline
El color de fondo de la cara sonriente debe ser rojo para sentimientos negativos y verde para sentimientos positivos. Para lograr esto con un brillo uniforme en la transición, podemos usar el espacio de color HSV y simplemente interpolar el tono entre 0% y 30%.
color_bgr = color_hsv_to_bgr(
hue=(positivity + 1) / 6, # positivity [-1,1] -> hue [0,1/3]
saturation=0.5,
value=1,
)
color_bgra = color_bgr + (255,)
Necesitamos asegurarnos de hacer que el color sea completamente opaco agregando un valor Alpha 100% en el cuarto canal. Ahora podemos dibujar nuestro círculo de cara sonriente con un borde.
cv2.circle(frame, center, radius, color_bgra, -1) # Fill
cv2.circle(frame, center, radius, color_outline, thickness_outline) # Border
Hasta ahora todo bien, ahora podemos agregar los ojos. Calculamos un desplazamiento desde el centro a la izquierda y a la derecha para colocar los dos ojos simétricamente.
# calculate the position of the eyes
eye_radius = radius // 5
eye_offset_x = radius // 3
eye_offset_y = radius // 4
eye_left = (center[0] - eye_offset_x, center[1] - eye_offset_y)
eye_right = (center[0] + eye_offset_x, center[1] - eye_offset_y)
cv2.circle(frame, eye_left, eye_radius, color_outline, -1)
cv2.circle(frame, eye_right, eye_radius, color_outline, -1)
Ahora en la parte desafiante, la boca. La forma de la boca será una parábola escalada apropiadamente. Simplemente podemos multiplicar la parábola estándar y=x² con la puntuación de positividad.
Al final, la línea se dibujará usando cv2.polylinesque necesita pares de coordenadas XY. Usando np.linspace Generamos 100 puntos en el eje x y el polyval Función para calcular los valores y los valores del polígono.
# mouth parameters
mouth_wdith = radius // 2
mouth_height = radius // 3
mouth_offset_y = radius // 3
mouth_center_y = center[1] + mouth_offset_y + positivity * mouth_height // 2
mouth_left = (center[0] - mouth_wdith, center[1] + mouth_offset_y)
mouth_right = (center[0] + mouth_wdith, center[1] + mouth_offset_y)
# calculate points of polynomial for the mouth
ply_points_t = np.linspace(-1, 1, 100)
ply_points_y = np.polyval([positivity, 0, 0], ply_points_t) # y=positivity*x²
ply_points = np.array(
[
(
mouth_left[0] + i * (mouth_right[0] - mouth_left[0]) / 100,
mouth_center_y - ply_points_y[i] * mouth_height,
)
for i in range(len(ply_points_y))
],
dtype=np.int32,
)
# draw the mouth
cv2.polylines(
frame,
[ply_points],
isClosed=False,
color=color_outline,
thickness=int(thickness_outline * 1.5),
)
Et voilà, ¡tenemos una cara de procedimiento sonriente!
Para probar la función, escribí un caso de prueba rápido usando pytest Eso salva las caras sonrientes con diferentes puntajes de sentimiento:
from pathlib import Path
import cv2
import numpy as np
import pytest
from sentiment_analysis.utils import create_sentiment_image
IMAGE_SIZE = (512, 512)
@pytest.mark.parametrize(
"positivity",
np.linspace(-1, 1, 5),
)
def test_sentiments(visual_output_path: Path, positivity: float) -> None:
"""
Test the smiley face generation.
"""
image = create_sentiment_image(positivity, IMAGE_SIZE)
assert image.shape == (IMAGE_SIZE[1], IMAGE_SIZE[0], 4)
# assert center pixel is opaque
assert image[IMAGE_SIZE[1] // 2, IMAGE_SIZE[0] // 2, 3] == 255
# save the image for visual inspection
positivity_num_0_100 = int((positivity + 1) * 50)
image_fn = f"smiley_{positivity_num_0_100}.png"
cv2.imwrite(str(visual_output_path / image_fn), image)
Análisis de sentimientos
Para determinar qué tan feliz o triste debería ser nuestro smiley, primero debemos analizar la entrada del texto y calcular un sentimiento. Esta tarea se llama análisis de sentimientos. Usaremos un modelo de transformador previamente capacitado para predecir una puntuación de clasificación para las clases NEGATIVO, NEUTRAL y POSITIVO. Luego podemos fusionar los puntajes de confianza de estas clases para calcular un puntaje de sentimiento final entre -1 y +1.
Usando la tubería de la biblioteca de transformadores, podemos definir la tubería de procesamiento en función de un Modelo previamente capacitado de Huggingface. Usando el top_k Parámetro, podemos especificar cuántos resultados de clasificación se deben devolver. Como queremos las tres clases, lo establecemos en 3.
from transformers import pipeline
model_name = "cardiffnlp/twitter-roberta-base-sentiment"
sentiment_pipeline = pipeline(
task="sentiment-analysis",
model=model_name,
top_k=3,
)
Para ejecutar el análisis de sentimientos, podemos llamar a la tubería con un argumento de cadena. Esto devolverá una lista de resultados con un solo elemento, por lo que necesitamos desempacar el primer elemento.
results = self.sentiment_pipeline(text)
# [
# [
# {"label": "LABEL_2", "score": 0.5925878286361694},
# {"label": "LABEL_1", "score": 0.3553399443626404},
# {"label": "LABEL_0", "score": 0.05207228660583496},
# ]
# ]
for label_score_dict in results[0]:
label: str = label_score_dict["label"]
score: float = label_score_dict["score"]
Podemos definir un mapeo de etiquetas, que nos dice cómo cada puntaje de confianza afecta el sentimiento final. Entonces podemos agregar la positividad sobre todos los puntajes de confianza.
label_mapping = {"LABEL_0": -1, "LABEL_1": 0, "LABEL_2": 1}
positivity = 0.0
for label_score_dict in results[0]:
label: str = label_score_dict["label"]
score: float = label_score_dict["score"]
if label in label_mapping:
positivity += label_mapping[label] * score
Para probar nuestra tubería, podemos envolverlo en una clase y ejecutar algunas pruebas usando pytest. Verificamos que las oraciones con un sentimiento positivo tienen una puntuación mayor que cero y las oraciones viceversa con un sentimiento negativo deberían tener una puntuación por debajo de cero.
import pytest
from sentiment_analysis.sentiment_pipeline import SentimentAnalysisPipeline
@pytest.fixture
def sentiment_pipeline() -> SentimentAnalysisPipeline:
"""
Fixture to create a SentimentAnalysisPipeline instance.
"""
return SentimentAnalysisPipeline(
model_name="cardiffnlp/twitter-roberta-base-sentiment",
label_mapping={"LABEL_0": -1.0, "LABEL_1": 0.0, "LABEL_2": 1.0},
)
@pytest.mark.parametrize(
"text_input",
[
"I love this!",
"This is awesome!",
"I am so happy!",
"This is the best day ever!",
"I am thrilled with the results!",
],
)
def test_sentiment_analysis_pipeline_positive(
sentiment_pipeline: SentimentAnalysisPipeline, text_input: str
) -> None:
"""
Test the sentiment analysis pipeline with a positive input.
"""
assert (
sentiment_pipeline.run(text_input) > 0.0
), "Expected positive sentiment score."
@pytest.mark.parametrize(
"text_input",
[
"I hate this!",
"This is terrible!",
"I am so sad!",
"This is the worst day ever!",
"I am disappointed with the results!",
],
)
def test_sentiment_analysis_pipeline_negative(
sentiment_pipeline: SentimentAnalysisPipeline, text_input: str
) -> None:
"""
Test the sentiment analysis pipeline with a negative input.
"""
assert (
sentiment_pipeline.run(text_input) < 0.0
), "Expected negative sentiment score."
Integración
Ahora, la última parte que falta es simplemente conectar el cuadro de texto a nuestra tubería de sentimientos y actualizar la imagen que se muestra con la cara sonriente correspondiente. Podemos agregar un trace A la variable de texto, que ejecutará la tubería de sentimientos en un nuevo hilo administrado por un grupo de subprocesos, para evitar que la interfaz de usuario se congele mientras se ejecuta la tubería.
class App(customtkinter.CTk):
def __init__(self, sentiment_analysis_pipeline: SentimentAnalysisPipeline) -> None:
super().__init__()
self.sentiment_analysis_pipeline = sentiment_analysis_pipeline
...
self.sentiment_image = None
self.sentiment_text_var = customtkinter.StringVar(master=self, value="Love")
self.sentiment_text_var.trace_add("write", lambda *_: self.on_sentiment_text_changed())
...
self.update_sentiment_pool = ThreadPool(processes=1)
self.on_sentiment_text_changed()
def on_sentiment_text_changed(self) -> None:
"""
Callback function to handle text changes in the textbox.
"""
new_text = self.sentiment_text_var.get()
self.update_sentiment_pool.apply_async(
self._update_sentiment,
(new_text,),
)
def _update_sentiment(self, new_text: str) -> None:
"""
Update the sentiment image based on the new text input.
This function is run in a separate process to avoid blocking the main thread.
Args:
new_text: The new text input from the user.
"""
positivity = self.sentiment_analysis_pipeline.run(new_text)
self.sentiment_image = create_sentiment_image(
positivity,
self.image_display.display_size,
)
self.image_display.update_frame(self.sentiment_image)
def main() -> None:
# Initialize the sentiment analysis pipeline
sentiment_analysis = SentimentAnalysisPipeline(
model_name="cardiffnlp/twitter-roberta-base-sentiment",
label_mapping={"LABEL_0": -1, "LABEL_1": 0, "LABEL_2": 1},
)
app = App(sentiment_analysis)
app.mainloop()
¡Y finalmente el Smiley se visualiza en la aplicación y cambia dinámicamente con el sentimiento de la entrada de texto!
Para obtener la implementación completa y más detalles, consulte el repositorio del proyecto en GitHub:
https://github.com/trflorian/sentiment-analysis-viz
Todas las visualizaciones en esta publicación fueron creadas por el autor.