File size: 6,475 Bytes
cfd1982
6f5fd07
 
 
84aef20
 
 
 
eddf0e9
84aef20
 
 
 
 
 
 
eddf0e9
84aef20
 
 
 
 
 
 
 
 
eddf0e9
84aef20
cfd1982
 
84aef20
 
5f76a01
eddf0e9
5f76a01
 
cfd1982
 
5f76a01
eddf0e9
84aef20
 
5f76a01
84aef20
 
 
 
5f76a01
 
 
 
8b9ebbc
84aef20
5f76a01
 
eddf0e9
5f76a01
 
eddf0e9
84aef20
5f76a01
 
 
84aef20
 
8b9ebbc
84aef20
 
 
 
 
 
 
cfd1982
5f76a01
84aef20
 
 
6f5fd07
84aef20
cfd1982
6f5fd07
28fc630
5f76a01
 
 
 
 
 
28fc630
 
5f76a01
 
 
 
 
 
28fc630
 
 
 
 
 
 
 
5f76a01
28fc630
 
 
 
 
 
 
 
 
 
84aef20
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5f76a01
 
 
84aef20
 
 
 
 
 
 
 
 
 
 
 
5f76a01
84aef20
eddf0e9
 
84aef20
 
 
 
 
eddf0e9
8b9ebbc
cfd1982
5f76a01
 
8b9ebbc
5f76a01
8b9ebbc
5f76a01
84aef20
 
5f76a01
 
 
 
 
6f5fd07
cfd1982
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
import gradio as gr
import torch
import numpy as np
from PIL import Image
import os
import plyfile
import open_clip

# Load OpenCLIP for prompt conditioning
model, _, preprocess = open_clip.create_model_and_transforms('ViT-B-32', pretrained='laion2b_s34b_b79K')
model.eval()
tokenizer = open_clip.get_tokenizer('ViT-B-32')

class PersistentCortex:
    def __init__(self, num_gaussians=8000):
        self.num = num_gaussians
        # Initialize compact spherical distribution
        angles = torch.rand(num_gaussians, 2) * 2 * np.pi
        radius = torch.rand(num_gaussians).pow(1/3) * 0.6
        self.positions = torch.stack([
            radius * torch.sin(angles[:, 0]) * torch.cos(angles[:, 1]),
            radius * torch.sin(angles[:, 0]) * torch.sin(angles[:, 1]),
            radius * torch.cos(angles[:, 0])
        ], dim=1)
        
        self.scales = torch.exp(torch.randn(num_gaussians, 3) * -2.5 - 2.0)
        self.colors = torch.rand(num_gaussians, 3) * 0.7 + 0.3
        self.opacities = torch.sigmoid(torch.randn(num_gaussians) * 1.5 + 2.0)
        self.rotations = torch.nn.functional.normalize(torch.randn(num_gaussians, 4), dim=-1)

    def evolve_from_image(self, image: Image.Image, steps=800):
        img_array = np.array(image.resize((256, 256))) / 255.0
        target = torch.tensor(img_array, dtype=torch.float32).permute(2, 0, 1).unsqueeze(0)  # (1, 3, 256, 256)

        # Random projection points in [-1,1] for sampling
        proj = torch.rand(self.num, 2) * 2 - 1  # (N, 2)

        for _ in range(steps):
            # Normalize grid to [-1,1]
            grid = proj.unsqueeze(0).unsqueeze(0)  # (1, 1, N, 2)

            sampled = torch.nn.functional.grid_sample(
                target,
                grid,
                mode='bilinear',
                padding_mode='border',
                align_corners=True
            ).squeeze(1).squeeze(0)  # Squeeze H (1) and batch if needed, to (3, N)

            # Transpose to (N, 3)
            sampled = sampled.t()

            brightness = sampled.mean(dim=1)
            attraction = (brightness - brightness.mean()) * 0.015
            proj += attraction.unsqueeze(1) * (proj / (proj.norm(dim=1, keepdim=True) + 1e-6))

            # Color lerp to image average
            avg_color = target.mean(dim=[2,3]).squeeze()
            self.colors = torch.lerp(self.colors, avg_color.repeat(self.num, 1), 0.02)

        # Apply evolved projection to positions (scale to sphere radius)
        radii = self.positions.norm(dim=1, keepdim=True)
        self.positions[:, :2] = proj * radii

        return self

    def condition_on_prompt(self, prompt: str):
        if not prompt.strip():
            return self
        text_tokens = tokenizer([prompt])
        with torch.no_grad():
            text_emb = model.encode_text(text_tokens).float()
            text_emb = text_emb / text_emb.norm(dim=-1, keepdim=True)

        # Projection to color shift
        proj = torch.nn.Linear(512, 3, bias=False)
        torch.nn.init.normal_(proj.weight, std=0.2)
        color_shift = proj(text_emb)

        self.colors = torch.clamp(self.colors + color_shift.repeat(self.num, 1) * 0.3, 0, 1)
        return self

    def export_ply(self, path="output.ply"):
        pos = self.positions.cpu().numpy()
        col = self.colors.cpu().numpy()
        opa = self.opacities.cpu().numpy()
        sca = np.log(self.scales.cpu().numpy())
        rot = self.rotations.cpu().numpy()
        nor = np.zeros_like(pos)  # Normals placeholder

        vertex_data = np.core.records.fromarrays([
            pos[:,0], pos[:,1], pos[:,2],
            nor[:,0], nor[:,1], nor[:,2],
            col[:,0], col[:,1], col[:,2],
            opa,
            sca[:,0], sca[:,1], sca[:,2],
            rot[:,0], rot[:,1], rot[:,2], rot[:,3]
        ], names='x,y,z,nx,ny,nz,f_dc_0,f_dc_1,f_dc_2,opacity,scale_0,scale_1,scale_2,rot_0,rot_1,rot_2,rot_3')

        el = plyfile.PlyElement.describe(vertex_data, 'vertex')
        plyfile.PlyData([el], text=True).write(path)
        return os.path.abspath(path)

def process(image: Image.Image, prompt: str = ""):
    if image is None:
        raise gr.Error("Please upload an image to proceed.")
    
    cortex = PersistentCortex(num_gaussians=8000)
    cortex.evolve_from_image(image, steps=800)
    if prompt:
        cortex.condition_on_prompt(prompt)

    ply_path = cortex.export_ply("/tmp/output.ply")

    viewer_html = f"""
    <div id="viewer" style="width:100%; height:600px; background:#000;"></div>
    <script type="module">
    import * as SPLAT from "https://cdn.jsdelivr.net/npm/gsplat@latest";

    const container = document.getElementById('viewer');
    const canvas = document.createElement('canvas');
    canvas.style.width = '100%';
    canvas.style.height = '100%';
    container.appendChild(canvas);

    const scene = new SPLAT.Scene();
    const camera = new SPLAT.Camera();
    const renderer = new SPLAT.WebGLRenderer({{ canvas }});
    const controls = new SPLAT.OrbitControls(camera, canvas);

    controls.autoRotate = false;
    controls.enableDamping = true;
    controls.dampingFactor = 0.05;
    controls.rotateSpeed = 1.0;
    controls.zoomSpeed = 1.2;
    controls.panSpeed = 0.8;

    await SPLAT.Loader.LoadAsync("/files/output.ply", scene);

    function animate() {{
        controls.update();
        renderer.render(scene, camera);
        requestAnimationFrame(animate);
    }}
    animate();
    </script>
    """

    status = "Persistent 3D representation evolved from image"
    if prompt:
        status += f" and conditioned on prompt: '{prompt}'"
    status += "."

    return viewer_html, status

with gr.Blocks(title="Persistent 3D Cortex Demo") as demo:
    gr.Markdown("# Persistent 3D Cortex – Interactive Demo")
    gr.Markdown("Upload an image and optionally add a text prompt to generate an evolving 3D Gaussian splat representation.")
    
    with gr.Row():
        img_input = gr.Image(type="pil", label="Input Image")
        prompt_input = gr.Textbox(label="Prompt (e.g., 'shiny red apple', 'futuristic city')", placeholder="Optional text prompt")
    
    generate_btn = gr.Button("Generate & Evolve 3D", variant="primary")
    
    viewer_output = gr.HTML(label="Interactive 3D Viewer")
    status_output = gr.Textbox(label="Status")

    generate_btn.click(
        fn=process,
        inputs=[img_input, prompt_input],
        outputs=[viewer_output, status_output]
    )

demo.launch()