File size: 15,376 Bytes
fd5a407
 
 
 
b40cca3
fd5a407
b7c9a7a
b40cca3
18c7155
fd5a407
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b7c9a7a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18c7155
fd5a407
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0815a02
 
 
 
 
fd5a407
b7c9a7a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18c7155
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b7c9a7a
 
 
b40cca3
 
 
 
b7c9a7a
 
 
 
b40cca3
b7c9a7a
b40cca3
 
 
18c7155
b40cca3
 
b7c9a7a
 
 
b40cca3
b7c9a7a
18c7155
fd5a407
b7c9a7a
 
fd5a407
18c7155
fd5a407
 
b7c9a7a
 
 
 
 
fd5a407
18c7155
fd5a407
b40cca3
fd5a407
 
b7c9a7a
 
18c7155
b40cca3
18c7155
 
 
 
b7c9a7a
 
b40cca3
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
import base64,io,os,re,tempfile,time
import gradio as gr
import requests
from PIL import Image,ImageDraw
CHAT='openai/gpt-4.1'; PLAN='gemini-3.1-flash-lite'; GEMINI_IMAGE='gemini-2.5-flash-image'; ATTEMPTS=5
CHAT_SYS='''You are Pixelship, a concise creative chatbot. Chat normally. Only when asked to create an image, end with (TOOL:TXTTOIMGOUT, prompt:DETAILED PROMPT). If an image is attached or an edit is requested, use IMGTOIMGOUT. Always specify a complete background. Never invent brands; user-mentioned brand/model names must be inside double quotes.'''
BLUE_SYS='''Make a strict 16x16 blueprint with a full background, but make the composition creative: angled subjects, diagonals, asymmetry, foreground/background layers, perspective cues, and expressive staging are allowed. If text is needed, use TEXT() commands as semantic exact words, not as a requirement for pixel-art font. For posters/flyers/ads/thumbnails/covers, include every requested text line and make the layout richly designed with tasteful clutter: badges, panels, icons, arrows, callouts, feature boxes, stickers, borders, and layered elements. Output ROWS:16, PIXELS_PER_ROW:16, PALETTE entries 1-16 as six-digit hex, CREATIVITY, and ROW1-ROW16 with 16 comma-separated palette numbers or TEXT() values. Output only the blueprint.'''
QUALITY='''IMAGE 1 is the candidate; later images are references. Score 1-10. PASS requires 7+, correct subject/reference identity, composition, background, requested text, and no gibberish. If uploaded/user references and web references are both supplied, uploaded/user references are primary and web references are supplemental only. The candidate must preserve uploaded/user reference identity first; web refs may add current/factual details but must not override the uploaded refs. Ignore microscopic defects. Reply exactly (img:PASS score:N correction:) or (img:FALSE score:N correction:reason).'''
MERCH=re.compile(r'\b(merch|merchandise|t-?shirt|tee|hoodie|sweatshirt|poster|art print|sticker|decal|mug|phone case|case cover|skin|costume|plush|plushie|figurine|keychain|lanyard|wall art)\b',re.I)
def run(token,model,data):
 h={'Authorization':'Bearer '+token.strip(),'Content-Type':'application/json'}
 if not token.strip(): raise gr.Error('Enter your Replicate API token.')
 for wait in (0,1,2,4,8):
  if wait: time.sleep(wait)
  try:
   r=requests.post('https://api.replicate.com/v1/models/'+model+'/predictions',headers=h,json={'input':data},timeout=60)
   if r.status_code in (408,429,500,502,503,504): continue
   r.raise_for_status(); p=r.json(); break
  except requests.RequestException:
   if wait==8: raise
 while p['status'] not in ('succeeded','failed','canceled','aborted'):
  time.sleep(1); p=requests.get(p['urls']['get'],headers=h,timeout=60).json()
 if p['status']!='succeeded': raise gr.Error(str(p.get('error') or 'Replicate failed'))
 o=p.get('output'); return (o[0] if len(o)==1 else ''.join(map(str,o))) if isinstance(o,list) else (o.get('url') if isinstance(o,dict) else str(o))
def gemini_part(source):
 if str(source).startswith('data:'):
  m=re.match(r'^data:([^;]+);base64,(.+)$',source)
  if not m: raise gr.Error('Gemini image input must be a data URL.')
  return {'inline_data':{'mime_type':m.group(1),'data':m.group(2)}}
 r=requests.get(source,headers={'User-Agent':'Mozilla/5.0'},timeout=60)
 r.raise_for_status()
 return {'inline_data':{'mime_type':r.headers.get('content-type') or 'image/jpeg','data':base64.b64encode(r.content).decode()}}
def gemini_text(key,model,data):
 if not key.strip(): raise gr.Error('Enter your Gemini API key.')
 parts=[{'text':str(data.get('prompt',''))}]
 for image in (data.get('image_input') or [])[:16]: parts.append(gemini_part(image))
 body={'contents':[{'role':'user','parts':parts}]}
 if data.get('system_prompt'): body['systemInstruction']={'parts':[{'text':str(data.get('system_prompt'))}]}
 url='https://generativelanguage.googleapis.com/v1beta/models/'+model+':generateContent?key='+key.strip()
 last=None
 for wait in (0,1,2,4,8,16):
  if wait: time.sleep(wait)
  r=requests.post(url,json=body,timeout=120)
  if r.status_code in (408,429,500,502,503,504): last=r.text; continue
  try: r.raise_for_status()
  except requests.HTTPError as e: raise gr.Error(r.text[:500]) from e
  j=r.json(); break
 else:
  raise gr.Error(last or 'Gemini text request failed')
 text=''.join(part.get('text','') for part in j.get('candidates',[{}])[0].get('content',{}).get('parts',[])).strip()
 if not text: raise gr.Error('Gemini text response was empty.')
 return text
def gemini_image(key,prompt,image_data_url,aspect='match_input_image'):
 if not key.strip(): raise gr.Error('Enter your Gemini API key.')
 parts=[{'text':prompt+'\nRender one polished image. Preferred aspect ratio: '+aspect+'.'}]
 if image_data_url:
  m=re.match(r'^data:([^;]+);base64,(.+)$',image_data_url)
  if not m: raise gr.Error('Gemini image input must be a data URL.')
  parts.append({'inline_data':{'mime_type':m.group(1),'data':m.group(2)}})
 body={'contents':[{'role':'user','parts':parts}],'generationConfig':{'responseModalities':['IMAGE']}}
 url='https://generativelanguage.googleapis.com/v1beta/models/'+GEMINI_IMAGE+':generateContent?key='+key.strip()
 last=None
 for wait in (0,1,2,4,8,16):
  if wait: time.sleep(wait)
  r=requests.post(url,json=body,timeout=120)
  if r.status_code in (408,429,500,502,503,504): last=r.text; continue
  try: r.raise_for_status()
  except requests.HTTPError as e: raise gr.Error(r.text[:500]) from e
  j=r.json(); break
 else:
  raise gr.Error(last or 'Gemini image request failed')
 for part in j.get('candidates',[{}])[0].get('content',{}).get('parts',[]):
  blob=part.get('inlineData') or part.get('inline_data') or {}
  data=blob.get('data'); mime=blob.get('mimeType') or blob.get('mime_type') or 'image/jpeg'
  if data: return 'data:'+mime+';base64,'+data
 raise gr.Error('Gemini did not return an image.')
def data_url(im):
 im=im.convert('RGB'); im.thumbnail((768,768)); b=io.BytesIO(); im.save(b,'JPEG',quality=80,optimize=True); return 'data:image/jpeg;base64,'+base64.b64encode(b.getvalue()).decode()
def splitrow(s):
 out=[]; cur=''; depth=0
 for c in s:
  depth+=c=='('; depth-=c==')'
  if c==',' and depth==0: out.append(cur.strip()); cur=''
  else: cur+=c
 if cur.strip(): out.append(cur.strip())
 return out
def blueprint(text):
 pal={int(n):c for n,c in re.findall(r'^\s*(1[0-6]|[1-9])\s*:\s*#?([0-9a-fA-F]{6})\s*$',text,re.M)}
 if len(pal)!=16: raise gr.Error('Blueprint palette was incomplete.')
 rows=[]
 for i in range(1,17):
  m=re.search(r'^\s*ROW'+str(i)+r'\s*:\s*(?:\((.*?)\)|(.*?))\s*$',text,re.M); cells=splitrow((m.group(1) or m.group(2)).strip()) if m else []
  vals=[int(x) if x.isdigit() and int(x) in pal else 1 for x in cells[:16]]; vals += [vals[-1] if vals else 1]*(16-len(vals)); rows.append(vals)
 im=Image.new('RGB',(256,256)); d=ImageDraw.Draw(im)
 for y,row in enumerate(rows):
  for x,v in enumerate(row): d.rectangle((x*16,y*16,x*16+15,y*16+15),fill='#'+pal[v])
 return im
def collage(ref,bp):
 largest=max(ref.width,ref.height); scale=1024/largest if largest>1024 else (512/largest if largest<512 else 1.0); w=max(1,round(ref.width*scale)); h=max(1,round(ref.height*scale))
 c=Image.new('RGB',(w,h),'#0f1116'); main=ref.copy(); main.thumbnail((w,h)); c.paste(main,((w-main.width)//2,(h-main.height)//2))
 inset=max(24,min(round(w*.32),round(h*.42),320)); panel=bp.copy().resize((inset,inset),Image.Resampling.NEAREST)
 border=max(4,round(inset*.025)); framed=Image.new('RGB',(inset+border*2,inset+border*2),'#b8ff4d'); framed.paste(panel,(border,border))
 c.paste(framed,(w-framed.width-border,h-framed.height-border))
 return c
def search_queries(token,prompt):
 raw=run(token,CHAT,{'prompt':'Extract 1 to 3 web search queries for this image request. Search the subject/design, not the requested output format. Example: ios 26 poster with all new features -> ios 26, ios 26 features. Remove poster/wallpaper/banner/render/style words unless they are the actual subject. Return comma-separated queries only. Request: '+prompt,'system_prompt':'You extract concise search queries.'})
 return [q.strip() for q in re.split(r'[,\n]+',raw) if q.strip()][:3] or [prompt]
def serper_web(key,query):
 r=requests.post('https://google.serper.dev/search',headers={'X-API-KEY':key.strip(),'Content-Type':'application/json'},json={'q':query,'num':5},timeout=20)
 r.raise_for_status(); return '\n'.join((i.get('title','')+': '+i.get('snippet','')).strip() for i in (r.json().get('organic') or [])[:5])
def collage_many(refs,bp):
 if not refs: return bp
 thumbs=[]
 for ref in refs[:6]:
  im=ref.copy(); im.thumbnail((512,512)); thumbs.append(im)
 cols=2 if len(thumbs)>1 else 1; rows=(len(thumbs)+cols-1)//cols
 cell=512; c=Image.new('RGB',(cols*cell,rows*cell),'#0f1116')
 for i,im in enumerate(thumbs):
  x=(i%cols)*cell+(cell-im.width)//2; y=(i//cols)*cell+(cell-im.height)//2; c.paste(im,(x,y))
 inset=220; panel=bp.copy().resize((inset,inset),Image.Resampling.NEAREST); framed=Image.new('RGB',(inset+12,inset+12),'#b8ff4d'); framed.paste(panel,(6,6)); c.paste(framed,(c.width-framed.width-12,c.height-framed.height-12))
 return c
def serper_reference(key,query):
 if not key.strip(): raise gr.Error('Enter your Serper API key or turn web search off.')
 r=requests.post('https://google.serper.dev/images',headers={'X-API-KEY':key.strip(),'Content-Type':'application/json'},json={'q':query,'num':10},timeout=20)
 r.raise_for_status(); results=r.json().get('images') or []
 for item in results:
  title=(item.get('title') or '')+' '+(item.get('source') or '')+' '+(item.get('link') or '')
  src=item.get('imageUrl') or item.get('thumbnailUrl')
  if not src or MERCH.search(title): continue
  try:
   img=requests.get(src,headers={'User-Agent':'Mozilla/5.0'},timeout=20).content
   im=Image.open(io.BytesIO(img)).convert('RGB')
   if im.width<80 or im.height<80: continue
   return im,src
  except Exception:
   continue
 raise gr.Error('Web search found no usable non-merch images.')
def judge(gemini_key,prompt,result,refs):
 v=gemini_text(gemini_key,PLAN,{'prompt':'Request: '+prompt+'\nJudge IMAGE 1 strictly.','system_prompt':QUALITY,'image_input':[result]+refs}); m=re.search(r'score:\s*(10|[1-9])',v,re.I); score=int(m.group(1)) if m else 0; corr=(re.search(r'correction:([^)]*)',v,re.I) or [0,'Improve accuracy'])[1]; return bool(re.search(r'img:PASS',v,re.I)) and score>=7,score,corr
def generate(token,gemini_key,prompt,refpath=None,web_key='',web_search=False):
 ref=None; ref_imgs=[]; user_refs=[]; web_refs=[]; refs=[]; note=''; research=''
 if refpath:
  ref=Image.open(refpath).convert('RGB'); user_refs=[ref]; ref_imgs=[ref]; note='uploaded reference'
 if web_search:
  queries=search_queries(token,prompt); research='\n'.join(serper_web(web_key,q) for q in queries[:3])
  sources=[]
  for q in queries[:3]:
   try:
    im,src=serper_reference(web_key,q); web_refs.append(im); sources.append(src)
   except Exception: pass
  if web_refs and not ref: ref=web_refs[0]
  ref_imgs=user_refs+web_refs
  note=(note+' + supplemental web references: ' if note else 'web references: ')+', '.join(sources[:3])
 pi={'prompt':prompt,'system_prompt':BLUE_SYS}
 if user_refs and web_refs:
  pi['prompt']+='''\nUploaded/user reference images are PRIMARY. Web images are supplemental only for missing/current details. Do not let web images override, replace, dominate, or distract from the uploaded reference.'''
 if ref_imgs: pi['image_input']=[data_url(x) for x in ref_imgs[:6]]
 bp=blueprint(gemini_text(gemini_key,PLAN,pi)); inp=collage_many(ref_imgs,bp) if ref_imgs else bp; refs=[data_url(x) for x in ref_imgs[:6]]
 base='Create a polished creative image from the supplied blueprint/reference. The blueprint is a composition guide, not a pixel-art font/style requirement. Allow dynamic angles, depth, layered foreground/background, expressive staging, and tasteful visual density. For posters/flyers/ads/thumbnails/covers, make the design rich and information-dense with readable panels, badges, icons, callouts, and decorative clutter. '+prompt+'. Preserve reference identity and physical design. Use a complete background and no gibberish. Render any requested text exactly with an appropriate font, not automatically a pixel font.'
 if note: base+=' The uploaded/user reference is the primary visual source. If web references are present, use them only as supplemental context for missing/current design details; never let web results override, replace, dominate, or distract from uploaded/user references. Do not copy a poster/layout from search results.'
 if research: base+=' Web research notes for factual feature/design details, not layout copying:\n'+research
 current=base; candidates=[]
 for _ in range(ATTEMPTS):
  image_data=data_url(inp)
  result=gemini_image(gemini_key,current,image_data,'match_input_image' if ref else '1:1'); passed,score,corr=judge(gemini_key,prompt,result,refs); candidates.append((score,result))
  if passed: return result
  current=base+' Improve the result while keeping the same reference and blueprint. Fix: '+corr
 return max(candidates)[1]
def download(u):
 fd,p=tempfile.mkstemp(suffix='.png'); os.close(fd)
 if str(u).startswith('data:image/'):
  open(p,'wb').write(base64.b64decode(str(u).split(',',1)[1])); return p
 r=requests.get(u,timeout=90); r.raise_for_status(); open(p,'wb').write(r.content); return p
def send(msg,history,token,gemini_key,attachment,web_key,web_search):
 if not msg.strip(): return history,'',attachment
 transcript='\n'.join(x['role']+': '+x['content'] for x in history if isinstance(x['content'],str)); reply=run(token,CHAT,{'prompt':transcript+'\nuser: '+msg+'\nassistant:','system_prompt':CHAT_SYS+('\nAn image is attached.' if attachment else '')+('\nThe user enabled web search. If creating an image, use the searched image as a visual reference.' if web_search else '')}); m=re.search(r'\(TOOL:(TXTTOIMGOUT|IMGTOIMGOUT)\s*,\s*prompt:(.*)\)\s*$',reply,re.I|re.S); history=history+[{'role':'user','content':msg}]
 if not m: return history+[{'role':'assistant','content':reply}],'',attachment
 visible=reply[:m.start()].strip() or 'Here is what I made.'; result=generate(token,gemini_key,m.group(2).strip(),attachment if m.group(1).upper()=='IMGTOIMGOUT' else None,web_key,web_search); history += [{'role':'assistant','content':visible},{'role':'assistant','content':gr.Image(value=download(result))}]; return history,'',None
with gr.Blocks(title='Pixelship Gradio') as demo:
 gr.Markdown('# Pixelship\nStandalone chatbot image pipeline')
 token=gr.Textbox(label='Replicate API token (chat only)',type='password',placeholder='r8_...')
 gemini_key=gr.Textbox(label='Gemini API key (blueprint, judging, image rendering)',type='password',placeholder='AIza...')
 web_key=gr.Textbox(label='Serper API key (optional, for web search references)',type='password',placeholder='serper.dev API key')
 web_search=gr.Checkbox(label='Use web search reference',value=False)
 chat=gr.Chatbot(height=600)
 attach=gr.Image(label='Optional edit reference',type='filepath')
 msg=gr.Textbox(label='Message',lines=2)
 button=gr.Button('Send',variant='primary')
 button.click(send,[msg,chat,token,gemini_key,attach,web_key,web_search],[chat,msg,attach])
 msg.submit(send,[msg,chat,token,gemini_key,attach,web_key,web_search],[chat,msg,attach])
if __name__=='__main__': demo.queue().launch()