File size: 14,159 Bytes
3abe264
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
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()