import os
import cv2
import numpy as np
import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
from sklearn.cluster import KMeans
from PIL import Image
import gradio as gr
def rgb_to_hex(r, g, b):
"""Converts RGB integers to a hex string."""
return f"#{int(r):02x}{int(g):02x}{int(b):02x}"
def analyze_palette(img_numpy, num_colors=5):
"""Clusters pixels using K-Means to extract dominant color palette."""
try:
# Resize image to speed up K-Means significantly
img_small = cv2.resize(img_numpy, (150, 150), interpolation=cv2.INTER_AREA)
pixels = img_small.reshape(-1, 3)
# Run K-Means
kmeans = KMeans(n_clusters=num_colors, random_state=42, n_init=10)
kmeans.fit(pixels)
colors = kmeans.cluster_centers_
labels = kmeans.labels_
# Calculate percentages
counts = np.bincount(labels)
total = len(labels)
percentages = counts / total
# Sort colors by dominance (percentage)
sorted_indices = np.argsort(percentages)[::-1]
colors = colors[sorted_indices]
percentages = percentages[sorted_indices]
# Compile color data
palette_data = []
warm_percentage = 0.0
cool_percentage = 0.0
for i in range(num_colors):
r, g, b = colors[i]
hex_val = rgb_to_hex(r, g, b)
pct = percentages[i]
# Simple heuristic for color temperature
# Warm: R > B. Cool: B >= R
is_warm = r > b
if is_warm:
warm_percentage += pct
else:
cool_percentage += pct
# Basic visual psychology association maps
vibes = "Neutral/Balanced"
if r > 150 and g < 100 and b < 100:
vibes = "Urgency, Excitement, Passion, or Danger"
elif b > 150 and r < 100 and g < 150:
vibes = "Trust, Stability, Calm, or Professionalism"
elif g > 150 and r < 120 and b < 120:
vibes = "Nature, Growth, Health, or Balance"
elif r > 180 and g > 150 and b < 100:
vibes = "Energy, Optimism, Warmth, or Caution"
elif r > 120 and g < 80 and b > 120:
vibes = "Luxury, Creative, Mystery, or Dignity"
elif r > 200 and g > 200 and b > 200:
vibes = "Clarity, Minimalism, Openness, or Light"
elif r < 60 and g < 60 and b < 60:
vibes = "Authority, Sophistication, Drama, or Mystery"
palette_data.append({
"Hex": hex_val,
"RGB": f"({int(r)}, {int(g)}, {int(b)})",
"Dominance": pct,
"Vibes & Framing Role": vibes
})
# Draw Plotly stacked bar chart
fig = go.Figure()
for item in palette_data:
fig.add_trace(go.Bar(
name=item["Hex"],
y=["Palette"],
x=[item["Dominance"]],
orientation='h',
marker=dict(color=item["Hex"]),
hovertemplate=f"Color: {item['Hex']}
Dominance: {item['Dominance']:.1%}
{item['Vibes & Framing Role']}"
))
fig.update_layout(
barmode='stack',
showlegend=False,
height=120,
template="plotly_dark",
plot_bgcolor="#111827",
paper_bgcolor="#0d0f12",
margin=dict(l=10, r=10, t=10, b=10),
xaxis=dict(showticklabels=False, showgrid=False, zeroline=False),
yaxis=dict(showticklabels=False, showgrid=False, zeroline=False)
)
# Temp analysis text
temp_status = f"Visual Temperature: **{'Warm' if warm_percentage > cool_percentage else 'Cool'}** " \
f"({warm_percentage:.1%} Warm vs. {cool_percentage:.1%} Cool tones dominant)."
df_palette = pd.DataFrame(palette_data)
return fig, df_palette, temp_status
except Exception as e:
print(f"Palette analysis error: {e}")
return go.Figure(), pd.DataFrame(), f"Error running palette clustering: {e}"
def analyze_composition(img_numpy):
"""Draws Rule-of-Thirds grid lines and measures edge texture centers of gravity."""
try:
h, w, _ = img_numpy.shape
img_grid = img_numpy.copy()
# Draw Rule-of-Thirds grid lines (high-contrast cyan)
grid_color = (0, 255, 255) # Cyan
line_w = max(2, int(w * 0.003))
# Horizontal lines
h1, h2 = int(h / 3), int(2 * h / 3)
cv2.line(img_grid, (0, h1), (w, h1), grid_color, line_w)
cv2.line(img_grid, (0, h2), (w, h2), grid_color, line_w)
# Vertical lines
w1, w2 = int(w / 3), int(2 * w / 3)
cv2.line(img_grid, (w1, 0), (w1, h), grid_color, line_w)
cv2.line(img_grid, (w2, 0), (w2, h), grid_color, line_w)
# Draw intersection circles
intersections = [(w1, h1), (w2, h1), (w1, h2), (w2, h2)]
circle_r = max(5, int(w * 0.01))
for (ix, iy) in intersections:
cv2.circle(img_grid, (ix, iy), circle_r, (255, 112, 67), -1) # Coral dots
cv2.circle(img_grid, (ix, iy), circle_r + 2, (255, 255, 255), max(1, int(w * 0.001)))
# Calculate visual texture density center (Canny edges)
gray = cv2.cvtColor(img_numpy, cv2.COLOR_RGB2GRAY)
edges = cv2.Canny(gray, 50, 150)
# Get coordinates of all edge pixels
edge_coords = np.argwhere(edges > 0)
if len(edge_coords) > 0:
# edge_coords holds (y, x)
avg_y, avg_x = np.mean(edge_coords, axis=0)
# Determine proximity to nearest intersection
distances = [np.hypot(avg_x - ix, avg_y - iy) for (ix, iy) in intersections]
min_dist = min(distances)
max_possible_dist = np.hypot(w, h)
proximity = 1 - (min_dist / (max_possible_dist * 0.25))
proximity = max(0.0, min(1.0, proximity))
# Label alignment
if proximity > 0.70:
align_text = f"Rule-of-Thirds Alignment: **Strong Alignment** ({proximity:.1%} visual intersection score). " \
"The main subject focus resides directly on one of the four power intersections, pulling viewer attention immediately."
else:
align_text = f"Rule-of-Thirds Alignment: **Centered/Diffuse Composition** ({proximity:.1%} visual intersection score). " \
"Visual weight is either balanced in the center or scattered across the frame, standard for documentarians or landscapes."
else:
align_text = "No strong visual edges found. Flat or uniform composition."
return img_grid, align_text
except Exception as e:
print(f"Composition error: {e}")
return img_numpy, f"Error processing geometry: {e}"
def analyze_lighting(img_numpy):
"""Computes a Plotly brightness distribution histogram and classifies visual lighting key."""
try:
gray = cv2.cvtColor(img_numpy, cv2.COLOR_RGB2GRAY)
h, w = gray.shape
total_pixels = h * w
# Calculate luminance histogram
hist = cv2.calcHist([gray], [0], None, [256], [0, 256]).flatten()
# Metrics
mean_brightness = np.mean(gray)
std_brightness = np.std(gray)
# Classify lighting key
# High Key: Bright backgrounds, high mean, lower variance
# Low Key: Shadow dominated, low mean, high variance (chiascuro)
if mean_brightness >= 165:
key_style = "High-Key Lighting (Bright & Open)"
summary_desc = "Features bright, fully lit environments with soft shadows. Commonly utilized in consumer commercials, corporate flyers, and optimistic political campaign media to convey transparency and positive energy."
elif mean_brightness <= 85:
key_style = "Low-Key Lighting (Dramatic & Shadow-Heavy)"
summary_desc = "Dominated by deep shadows, dark backgrounds, and stark contrast. Popular in film noir, investigative photojournalism, or negative attack advertisements to invoke mystery, tension, or critical framing."
else:
key_style = "Mid-Key / Standard Studio Lighting"
summary_desc = "Features a realistic, balanced, or moderate lighting key. Popular in objective documentary filmmaking, standard portraiture, and everyday press releases to represent authenticity and balanced focus."
# Draw Plotly Line Histogram
df_hist = pd.DataFrame({
"Luminance (0-255)": np.arange(256),
"Pixel Count": hist
})
fig = px.line(
df_hist, x="Luminance (0-255)", y="Pixel Count",
title="Luminance Distribution Histogram (0 = Pure Black, 255 = Pure White)",
template="plotly_dark"
)
fig.update_traces(line=dict(color="#f59e0b", width=3)) # Amber curve
fig.update_layout(
plot_bgcolor="#111827",
paper_bgcolor="#0d0f12",
margin=dict(l=20, r=20, t=50, b=20),
xaxis=dict(showgrid=False),
yaxis=dict(showgrid=False)
)
summary_text = f"Visual Key: **{key_style}**\n\n* **Average Luminance**: {mean_brightness:.1f} / 255\n* **Contrast Spread (Std Dev)**: {std_brightness:.1f}\n\n*Description*: {summary_desc}"
return fig, summary_text
except Exception as e:
print(f"Lighting error: {e}")
return go.Figure(), f"Error analyzing luminance: {e}"
def full_analyzer_pipeline(img):
"""Triggers the full deconstruction analysis when an image is uploaded."""
if img is None:
return go.Figure(), pd.DataFrame(), "No image uploaded.", None, "No image uploaded.", go.Figure(), "No image uploaded."
# 1. Palette
fig_pal, df_pal, pal_status = analyze_palette(img)
# 2. Composition
img_grid, comp_status = analyze_composition(img)
# 3. Lighting
fig_light, light_status = analyze_lighting(img)
return fig_pal, df_pal, pal_status, img_grid, comp_status, fig_light, light_status
# Custom premium gradient CSS (Red/Yellow vibes)
custom_css = """
body { background-color: #0d0f12; color: #e3e6eb; font-family: 'Inter', sans-serif; }
.gradio-container { max-width: 1200px !important; margin: 0 auto !important; }
h1, h2, h3 { color: #ffffff !important; font-weight: 700 !important; }
.btn-primary { background: linear-gradient(135deg, #ef4444 0%, #f59e0b 100%) !important; border: none !important; color: white !important; font-weight: 600 !important; }
.btn-primary:hover { filter: brightness(1.1); }
.dataframe-container { background: #111827 !important; border: 1px solid #1f2937 !important; border-radius: 8px; }
"""
with gr.Blocks(theme=gr.themes.Monochrome(), css=custom_css) as demo:
gr.Markdown(
"""
# 🎨 Visual Rhetoric & Composition Analyzer
### Deconstruct media framing, analyze pixel color palettes (K-means), test Rule-of-Thirds alignment, and map lighting profiles to audit visual bias.
"""
)
with gr.Row():
with gr.Column(scale=4):
with gr.Card():
gr.Markdown("### 1. Upload Visual Artifact")
image_input = gr.Image(label="Upload Image (Campaign Flyer, News Photo, Advertisement)", type="numpy")
analyze_btn = gr.Button("🎨 Run Rhetorical Deconstruction", variant="primary", elem_classes="btn-primary")
with gr.Card():
gr.Markdown("### 🔍 Rule-of-Thirds Grid Overlay")
image_grid_output = gr.Image(label="Compositional Lines & Power Intersections", type="numpy", interactive=False)
with gr.Column(scale=6):
with gr.Tabs():
with gr.TabItem("🎨 Color & Palette Psychology"):
palette_plot = gr.Plot(label="Dominant Pixel Palettes (K-Means)")
palette_status = gr.Markdown("Please upload an image to run analysis.")
palette_table = gr.Dataframe(
headers=["Hex", "RGB", "Dominance", "Vibes & Framing Role"],
datatype=["str", "str", "number", "str"],
label="Dominant Colors Quantitative Distribution",
interactive=False,
elem_classes="dataframe-container"
)
with gr.TabItem("📐 Composition & Geometry"):
comp_status = gr.Markdown("Please upload an image to run analysis.")
gr.Markdown(
"""
**Methodology**:
- **Cyan lines** demarcate the vertical and horizontal 1/3 grid markers.
- **Coral dots** highlight the 4 focal intersections where visual elements naturally pull the highest attention.
- **Texture Center**: The app runs localized Sobel edge detection to find the image's texture 'center of gravity' and evaluates how close it is to these intersections.
"""
)
with gr.TabItem("🔆 Lighting, Contrast & Mood"):
lighting_plot = gr.Plot(label="Luminance Profile (LCC)")
lighting_status = gr.Markdown("Please upload an image to run analysis.")
# Core callback
analyze_btn.click(
fn=full_analyzer_pipeline,
inputs=[image_input],
outputs=[palette_plot, palette_table, palette_status, image_grid_output, comp_status, lighting_plot, lighting_status]
)
if __name__ == "__main__":
demo.launch()