Jomaric's picture
feat: initial release of vision social research space
3abe264
Raw
History Blame Contribute Delete
14.2 kB
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']}<br>Dominance: {item['Dominance']:.1%}<br>{item['Vibes & Framing Role']}<extra></extra>"
))
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()