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()
|