Spaces:
Running on Zero
Running on Zero
| import os | |
| import gc | |
| import json | |
| import ast | |
| import re | |
| import base64 | |
| from io import BytesIO | |
| from threading import Thread | |
| from typing import Tuple | |
| import gradio as gr | |
| from gradio import Server | |
| from fastapi.responses import HTMLResponse | |
| import spaces | |
| import torch | |
| import numpy as np | |
| from PIL import Image, ImageDraw, ImageFont | |
| import supervision as sv | |
| from transformers import ( | |
| Qwen3_5ForConditionalGeneration, | |
| AutoProcessor, | |
| TextIteratorStreamer, | |
| ) | |
| # ------------------------------------------------------------------ | |
| # Config & Constants | |
| # ------------------------------------------------------------------ | |
| MODEL_NAME = "Qwen/Qwen3.8-27B" | |
| DEVICE = "cuda" if torch.cuda.is_available() else "cpu" | |
| DTYPE = torch.bfloat16 if torch.cuda.is_available() and torch.cuda.is_bf16_supported() else torch.float16 | |
| GPU_DURATIONS = [60, 90, 120, 150, 180, 250, 300] | |
| DEFAULT_GPU_DURATION_IDX = 1 | |
| BRIGHT_YELLOW = sv.Color(r=255, g=230, b=0) | |
| DARK_OUTLINE = sv.Color(r=40, g=40, b=40) | |
| BLACK = sv.Color(r=0, g=0, b=0) | |
| WHITE = sv.Color(r=255, g=255, b=255) | |
| SPATIAL_LINE = (255, 69, 0) # OrangeRed | |
| SPATIAL_DOT = (255, 69, 0) | |
| SPATIAL_RING = (255, 255, 255) | |
| SPATIAL_LABEL_BG = (50, 10, 0) | |
| SPATIAL_LABEL_TXT = (255, 255, 255) | |
| SPATIAL_ARROW = (255, 140, 0) | |
| EXAMPLES_CONFIG = [ | |
| {"image": "examples/1.jpg", "prompt": "Detect the yellow car that is parked.", "task": "Detect"}, | |
| {"image": "examples/4.jpg", "prompt": "Map the waypoints from the basketball to the basket to shoot the ball inside the net.", "task": "Spatial"}, | |
| {"image": "examples/2.jpg", "prompt": "Point to all the red cars.", "task": "Point"}, | |
| {"image": "examples/3.jpg", "prompt": "Map a path from the door to the lamp.", "task": "Spatial"}, | |
| ] | |
| # ------------------------------------------------------------------ | |
| # Model Loading | |
| # ------------------------------------------------------------------ | |
| print(f"Loading model: {MODEL_NAME} ...") | |
| qwen_model = Qwen3_5ForConditionalGeneration.from_pretrained( | |
| MODEL_NAME, torch_dtype=DTYPE, device_map=DEVICE, attn_implementation="kernels-community/flash-attn2@v3", | |
| ).eval() | |
| qwen_processor = AutoProcessor.from_pretrained(MODEL_NAME) | |
| print("Model loaded.") | |
| # ------------------------------------------------------------------ | |
| # Helper Functions | |
| # ------------------------------------------------------------------ | |
| def make_thumb_b64(path, max_dim=220): | |
| if not os.path.exists(path): | |
| return "" | |
| try: | |
| img = Image.open(path).convert("RGB") | |
| img.thumbnail((max_dim, max_dim), Image.LANCZOS) | |
| buf = BytesIO() | |
| img.save(buf, format="JPEG", quality=65) | |
| return f"data:image/jpeg;base64,{base64.b64encode(buf.getvalue()).decode()}" | |
| except Exception: | |
| return "" | |
| def encode_full_image(path): | |
| if not os.path.exists(path): | |
| return "" | |
| try: | |
| with open(path, "rb") as f: | |
| data = f.read() | |
| ext = path.rsplit(".", 1)[-1].lower() | |
| mime = {"jpg": "image/jpeg", "jpeg": "image/jpeg", "png": "image/png", "webp": "image/webp"}.get(ext, "image/jpeg") | |
| return f"data:{mime};base64,{base64.b64encode(data).decode()}" | |
| except Exception: | |
| return "" | |
| def build_client_config(): | |
| examples = [] | |
| for i, ex in enumerate(EXAMPLES_CONFIG): | |
| examples.append({ | |
| "idx": i, | |
| "thumb": make_thumb_b64(ex["image"]), | |
| "prompt": ex["prompt"], | |
| "task": ex["task"], | |
| }) | |
| return {"examples": examples, "gpu_durations": GPU_DURATIONS, "default_gpu_idx": DEFAULT_GPU_DURATION_IDX} | |
| CLIENT_CONFIG = build_client_config() | |
| def safe_parse_json(text: str): | |
| text = re.sub(r"```(json)?", "", text).strip() | |
| match = re.search(r'(\[.*\]|\{.*\})', text, re.DOTALL) | |
| if match: | |
| json_str = match.group(1) | |
| json_str_clean = re.sub(r',\s*([}\]])', r'\1', json_str) | |
| try: | |
| return json.loads(json_str_clean) | |
| except json.JSONDecodeError: | |
| try: | |
| return ast.literal_eval(json_str_clean) | |
| except Exception: | |
| pass | |
| text_clean = re.sub(r',\s*([}\]])', r'\1', text) | |
| try: | |
| return json.loads(text_clean) | |
| except json.JSONDecodeError: | |
| pass | |
| try: | |
| return ast.literal_eval(text_clean) | |
| except Exception: | |
| pass | |
| return [] | |
| def _extract_point(item: dict): | |
| if not isinstance(item, dict): | |
| return None | |
| for k in ["point_2d", "point", "points", "coordinate", "coordinates", "xy"]: | |
| if k in item and isinstance(item[k], (list, tuple)) and len(item[k]) == 2: | |
| return item[k] | |
| return None | |
| def _extract_bbox(item: dict): | |
| if not isinstance(item, dict): | |
| return None | |
| for k in ["bbox_2d", "bbox", "box", "bounding_box", "xyxy"]: | |
| if k in item and isinstance(item[k], (list, tuple)) and len(item[k]) == 4: | |
| return item[k] | |
| return None | |
| def _load_font(size: int = 16): | |
| size = max(6, int(size)) | |
| try: | |
| return ImageFont.truetype("arial.ttf", size) | |
| except (IOError, OSError): | |
| try: | |
| return ImageFont.truetype("DejaVuSans.ttf", size) | |
| except (IOError, OSError): | |
| return ImageFont.load_default() | |
| def pil_to_b64_png(image: Image.Image) -> str: | |
| buf = BytesIO() | |
| image.save(buf, format="PNG") | |
| return f"data:image/png;base64,{base64.b64encode(buf.getvalue()).decode()}" | |
| def annotate_image(image: Image.Image, result: dict, point_radius: int = 6, box_thickness: int = 2, text_scale: float = 0.5): | |
| if not isinstance(image, Image.Image) or not isinstance(result, dict): | |
| return image | |
| image = image.convert("RGB") | |
| ow, oh = image.size | |
| point_radius = max(1, int(point_radius)) | |
| box_thickness = max(1, int(box_thickness)) | |
| text_scale = max(0.1, float(text_scale)) | |
| text_thickness = max(1, round(text_scale * 2)) | |
| if "points" in result and result["points"]: | |
| pts = [[int(p["x"] * ow), int(p["y"] * oh)] for p in result["points"]] | |
| if not pts: | |
| return image | |
| kp = sv.KeyPoints(xy=np.array(pts).reshape(1, -1, 2)) | |
| scene = np.array(image.copy()) | |
| scene = sv.VertexAnnotator(radius=point_radius + 3, color=DARK_OUTLINE).annotate(scene=scene, key_points=kp) | |
| scene = sv.VertexAnnotator(radius=point_radius, color=BRIGHT_YELLOW).annotate(scene=scene, key_points=kp) | |
| labels = [p.get("label", "") for p in result["points"]] | |
| if any(labels): | |
| tb, vl = [], [] | |
| for i, p in enumerate(result["points"]): | |
| if labels[i]: | |
| cx, cy = int(p["x"] * ow), int(p["y"] * oh) | |
| tb.append([cx - 2, cy - 2, cx + 2, cy + 2]) | |
| vl.append(labels[i]) | |
| if tb: | |
| scene = sv.LabelAnnotator( | |
| color=BRIGHT_YELLOW, text_color=BLACK, text_scale=text_scale, | |
| text_thickness=text_thickness, text_padding=5, | |
| text_position=sv.Position.TOP_CENTER, | |
| color_lookup=sv.ColorLookup.INDEX, | |
| ).annotate(scene=scene, detections=sv.Detections(xyxy=np.array(tb)), labels=vl) | |
| return Image.fromarray(scene) | |
| if "objects" in result and result["objects"]: | |
| boxes, labels = [], [] | |
| for obj in result["objects"]: | |
| boxes.append([ | |
| obj.get("x_min", 0.0) * ow, obj.get("y_min", 0.0) * oh, | |
| obj.get("x_max", 0.0) * ow, obj.get("y_max", 0.0) * oh, | |
| ]) | |
| labels.append(obj.get("label", "object")) | |
| if not boxes: | |
| return image | |
| scene = np.array(image.copy()) | |
| h, w = scene.shape[:2] | |
| masks = np.zeros((len(boxes), h, w), dtype=bool) | |
| for i, box in enumerate(boxes): | |
| x1, y1 = max(0, int(box[0])), max(0, int(box[1])) | |
| x2, y2 = min(w, int(box[2])), min(h, int(box[3])) | |
| masks[i, y1:y2, x1:x2] = True | |
| dets = sv.Detections(xyxy=np.array(boxes), mask=masks) | |
| if len(dets) == 0: | |
| return image | |
| scene = sv.MaskAnnotator(color=BRIGHT_YELLOW, opacity=0.18, color_lookup=sv.ColorLookup.INDEX).annotate(scene=scene, detections=dets) | |
| scene = sv.BoxAnnotator(color=BRIGHT_YELLOW, thickness=box_thickness, color_lookup=sv.ColorLookup.INDEX).annotate(scene=scene, detections=dets) | |
| scene = sv.LabelAnnotator( | |
| color=BRIGHT_YELLOW, text_color=BLACK, text_scale=text_scale, | |
| text_thickness=text_thickness, text_padding=6, color_lookup=sv.ColorLookup.INDEX, | |
| ).annotate(scene=scene, detections=dets, labels=labels) | |
| return Image.fromarray(scene) | |
| return image | |
| def annotate_spatial_path(image: Image.Image, result: dict, dot_radius: int = 6, line_width: int = 4, text_scale: float = 0.5): | |
| if not isinstance(image, Image.Image) or not isinstance(result, dict): | |
| return image | |
| image = image.convert("RGB") | |
| w, h = image.size | |
| if "points" not in result or not result["points"]: | |
| return image | |
| dot_radius = max(1, int(dot_radius)) | |
| line_width = max(1, int(line_width)) | |
| text_scale = max(0.1, float(text_scale)) | |
| draw = ImageDraw.Draw(image, "RGBA") | |
| font_label = _load_font(16 * text_scale * 2) | |
| font_num = _load_font(14 * text_scale * 2) | |
| points = result["points"] | |
| pts = [(int(p["x"] * w), int(p["y"] * h)) for p in points] | |
| labels = [p.get("label", f"P{i+1}") for i, p in enumerate(points)] | |
| scale_ratio = dot_radius / 8.0 | |
| if len(pts) >= 2: | |
| for i in range(len(pts) - 1): | |
| draw.line([pts[i], pts[i+1]], fill=SPATIAL_LINE + (60,), width=line_width + 6) | |
| for i in range(len(pts) - 1): | |
| draw.line([pts[i], pts[i+1]], fill=SPATIAL_LINE, width=line_width) | |
| for i in range(len(pts) - 1): | |
| x1, y1 = pts[i] | |
| x2, y2 = pts[i+1] | |
| dx, dy = x2 - x1, y2 - y1 | |
| length = (dx * dx + dy * dy) ** 0.5 | |
| if length < 12: | |
| continue | |
| ux, uy = dx / length, dy / length | |
| offset = 18 * scale_ratio | |
| bx, by = x2 - ux * offset, y2 - uy * offset | |
| px, py = -uy, ux | |
| aw, ah = 7 * scale_ratio, 9 * scale_ratio | |
| p1 = (bx + px * aw, by + py * aw) | |
| p2 = (bx - px * aw, by - py * aw) | |
| p3 = (bx + ux * ah, by + uy * ah) | |
| draw.polygon([p1, p2, p3], fill=SPATIAL_ARROW) | |
| for i, (cx, cy) in enumerate(pts): | |
| halo_r = dot_radius + 8 | |
| ring_r = dot_radius + 3 | |
| draw.ellipse((cx - halo_r, cy - halo_r, cx + halo_r, cy + halo_r), fill=SPATIAL_LINE + (50,)) | |
| draw.ellipse((cx - ring_r, cy - ring_r, cx + ring_r, cy + ring_r), | |
| outline=SPATIAL_RING, width=max(1, round(3 * scale_ratio))) | |
| draw.ellipse((cx - dot_radius, cy - dot_radius, cx + dot_radius, cy + dot_radius), | |
| fill=SPATIAL_DOT, outline=SPATIAL_DOT) | |
| num_text = str(i + 1) | |
| nbbox = draw.textbbox((0, 0), num_text, font=font_num) | |
| nw = nbbox[2] - nbbox[0] | |
| nh = nbbox[3] - nbbox[1] | |
| nx, ny = cx - nw // 2, cy - nh // 2 - 1 | |
| draw.text((nx, ny), num_text, fill=SPATIAL_RING, font=font_num) | |
| for i, (cx, cy) in enumerate(pts): | |
| label = f"{i+1}. {labels[i]}" | |
| bbox = draw.textbbox((0, 0), label, font=font_label) | |
| tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1] | |
| lx, ly = cx + dot_radius + 10, cy - th - 8 | |
| pad = 5 | |
| draw.rectangle( | |
| (lx - pad, ly - pad, lx + tw + pad, ly + th + pad), | |
| fill=SPATIAL_LABEL_BG, | |
| outline=SPATIAL_LINE, width=1, | |
| ) | |
| draw.text((lx, ly), label, fill=SPATIAL_LABEL_TXT, font=font_label) | |
| n_pts = len(pts) | |
| legend_text = f"Spatial map · {n_pts} waypoints · path length {len(pts)-1} segments" | |
| legend_font = _load_font(13 * text_scale * 2) | |
| bbox = draw.textbbox((0, 0), legend_text, font=legend_font) | |
| tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1] | |
| fx, fy = 10, h - th - 22 | |
| draw.rectangle((fx - 8, fy - 6, fx + tw + 16, fy + th + 10), | |
| fill=SPATIAL_LABEL_BG + (220,)) | |
| draw.text((fx, fy), legend_text, fill=SPATIAL_LABEL_TXT, font=legend_font) | |
| return image | |
| def get_gpu_duration(image_b64, prompt, task_type, point_radius, box_thickness, text_scale, gpu_duration_seconds): | |
| try: | |
| return int(gpu_duration_seconds) | |
| except (TypeError, ValueError): | |
| return GPU_DURATIONS[DEFAULT_GPU_DURATION_IDX] | |
| # ------------------------------------------------------------------ | |
| # Gradio Server (Server mode) | |
| # ------------------------------------------------------------------ | |
| app = Server(title="Qwen3.8-27B-Object-Detection") | |
| def infer( | |
| image_b64: str, | |
| prompt: str, | |
| task_type: str, | |
| point_radius: int, | |
| box_thickness: int, | |
| text_scale: float, | |
| gpu_duration_seconds: int, | |
| ) -> dict: | |
| """Runs object detection, point localization, or spatial mapping.""" | |
| gc.collect() | |
| torch.cuda.empty_cache() | |
| if not image_b64: | |
| raise gr.Error("Please upload an image.") | |
| if not prompt or prompt.strip() == "": | |
| raise gr.Error("Please provide a prompt.") | |
| try: | |
| header, data = image_b64.split(",", 1) | |
| pil_image = Image.open(BytesIO(base64.b64decode(data))).convert("RGB") | |
| except Exception as e: | |
| raise gr.Error(f"Invalid image data: {e}") | |
| pil_image.thumbnail((512, 512)) | |
| if task_type == "Detect": | |
| full_prompt = ( | |
| f"Provide bounding box coordinates for {prompt}. " | |
| f"Report strictly in JSON format as a list of objects with 'label' and " | |
| f"'bbox_2d' (xmin, ymin, xmax, ymax in 0-1000 scale)." | |
| ) | |
| elif task_type == "Point": | |
| full_prompt = ( | |
| f"Provide 2d point coordinates for {prompt}. " | |
| f"Report strictly in JSON format as a list of objects with 'label' and " | |
| f"'point_2d' (x, y in 0-1000 scale)." | |
| ) | |
| elif task_type == "Spatial": | |
| full_prompt = ( | |
| f"Identify the key spatial waypoints to map a path/route for: {prompt}. " | |
| f"Return the points in the order they should be connected along the path, " | |
| f"from start to end. Report in JSON format as a list of objects with " | |
| f"'label' (name of each waypoint) and 'point_2d' (x, y in 0-1000 scale). " | |
| f"Include 2-8 waypoints that best describe the spatial map." | |
| ) | |
| else: | |
| full_prompt = prompt | |
| messages = [{ | |
| "role": "user", | |
| "content": [ | |
| {"type": "image", "image": pil_image}, | |
| {"type": "text", "text": full_prompt}, | |
| ], | |
| }] | |
| text = qwen_processor.apply_chat_template( | |
| messages, tokenize=False, add_generation_prompt=True | |
| ) | |
| inputs = qwen_processor( | |
| text=[text], images=[pil_image], | |
| return_tensors="pt", padding=True, | |
| ).to(qwen_model.device) | |
| streamer = TextIteratorStreamer( | |
| qwen_processor.tokenizer, | |
| skip_prompt=True, skip_special_tokens=True, timeout=120, | |
| ) | |
| thread = Thread( | |
| target=qwen_model.generate, | |
| kwargs=dict( | |
| **inputs, | |
| streamer=streamer, | |
| max_new_tokens=2048, | |
| use_cache=True, | |
| do_sample=False, | |
| ), | |
| ) | |
| thread.start() | |
| full_text = "" | |
| for tok in streamer: | |
| full_text += tok | |
| thread.join() | |
| if task_type == "Point": | |
| parsed = safe_parse_json(full_text) | |
| if isinstance(parsed, dict): | |
| list_found = False | |
| for k in ["points", "keypoints", "point"]: | |
| if k in parsed and isinstance(parsed[k], list): | |
| parsed = parsed[k] | |
| list_found = True | |
| break | |
| if not list_found: | |
| for v in parsed.values(): | |
| if isinstance(v, list): | |
| parsed = v | |
| break | |
| else: | |
| parsed = [] | |
| result = {"points": []} | |
| if isinstance(parsed, list): | |
| for item in parsed: | |
| pt = _extract_point(item) | |
| if pt: | |
| x, y = pt | |
| result["points"].append({ | |
| "label": item.get("label", ""), | |
| "x": x / 1000.0, "y": y / 1000.0, | |
| }) | |
| if result["points"]: | |
| annotated_img = annotate_image( | |
| pil_image.copy(), result, | |
| point_radius=point_radius, box_thickness=box_thickness, text_scale=text_scale, | |
| ) | |
| return {"image": pil_to_b64_png(annotated_img), "text": json.dumps(result, indent=2)} | |
| else: | |
| return {"image": pil_to_b64_png(pil_image), "text": f"Could not extract any points.\nRaw model output:\n{full_text}"} | |
| elif task_type == "Detect": | |
| parsed = safe_parse_json(full_text) | |
| if isinstance(parsed, dict): | |
| list_found = False | |
| for k in ["objects", "detections", "bboxes", "boxes", "results"]: | |
| if k in parsed and isinstance(parsed[k], list): | |
| parsed = parsed[k] | |
| list_found = True | |
| break | |
| if not list_found: | |
| for v in parsed.values(): | |
| if isinstance(v, list): | |
| parsed = v | |
| break | |
| else: | |
| parsed = [] | |
| result = {"objects": []} | |
| if isinstance(parsed, list): | |
| for item in parsed: | |
| bbox = _extract_bbox(item) | |
| if bbox: | |
| xmin, ymin, xmax, ymax = bbox | |
| result["objects"].append({ | |
| "label": item.get("label", "object"), | |
| "x_min": xmin / 1000.0, "y_min": ymin / 1000.0, | |
| "x_max": xmax / 1000.0, "y_max": ymax / 1000.0, | |
| }) | |
| if result["objects"]: | |
| annotated_img = annotate_image( | |
| pil_image.copy(), result, | |
| point_radius=point_radius, box_thickness=box_thickness, text_scale=text_scale, | |
| ) | |
| return {"image": pil_to_b64_png(annotated_img), "text": json.dumps(result, indent=2)} | |
| else: | |
| return {"image": pil_to_b64_png(pil_image), "text": f"Could not extract any objects.\nRaw model output:\n{full_text}"} | |
| elif task_type == "Spatial": | |
| parsed = safe_parse_json(full_text) | |
| if isinstance(parsed, dict): | |
| list_found = False | |
| for k in ["points", "waypoints", "path", "route", "nodes", "map"]: | |
| if k in parsed and isinstance(parsed[k], list): | |
| parsed = parsed[k] | |
| list_found = True | |
| break | |
| if not list_found: | |
| for v in parsed.values(): | |
| if isinstance(v, list): | |
| parsed = v | |
| break | |
| else: | |
| parsed = [] | |
| result = {"points": []} | |
| if isinstance(parsed, list): | |
| for item in parsed: | |
| pt = _extract_point(item) | |
| if pt: | |
| x, y = pt | |
| result["points"].append({ | |
| "label": item.get("label", "waypoint"), | |
| "x": x / 1000.0, "y": y / 1000.0, | |
| }) | |
| if result["points"]: | |
| wp_lines = "\n".join( | |
| f" {i+1}. {p['label']} → ({p['x']:.3f}, {p['y']:.3f})" | |
| for i, p in enumerate(result["points"]) | |
| ) | |
| summary = ( | |
| f"Spatial map generated.\n" | |
| f"Waypoints ({len(result['points'])}):\n{wp_lines}\n" | |
| f"Path segments: {max(0, len(result['points']) - 1)}" | |
| ) | |
| annotated_img = annotate_spatial_path( | |
| pil_image.copy(), result, | |
| dot_radius=point_radius, line_width=box_thickness * 2, text_scale=text_scale, | |
| ) | |
| return {"image": pil_to_b64_png(annotated_img), "text": summary} | |
| else: | |
| return {"image": pil_to_b64_png(pil_image), "text": f"Could not extract any spatial waypoints.\nRaw model output:\n{full_text}"} | |
| return {"image": pil_to_b64_png(pil_image), "text": "Unknown task type."} | |
| def load_example(idx: float) -> dict: | |
| try: | |
| i = int(idx) | |
| except (ValueError, TypeError): | |
| i = -1 | |
| if i < 0 or i >= len(EXAMPLES_CONFIG): | |
| return {"image": "", "prompt": "", "task": "", "status": "error"} | |
| ex = EXAMPLES_CONFIG[i] | |
| b64 = encode_full_image(ex["image"]) | |
| return { | |
| "image": b64, | |
| "prompt": ex["prompt"], | |
| "task": ex["task"], | |
| "status": "ok" if b64 else "error" | |
| } | |
| def client_config(): | |
| return CLIENT_CONFIG | |
| async def homepage(): | |
| html_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "index.html") | |
| with open(html_path, "r", encoding="utf-8") as f: | |
| return f.read() | |
| if __name__ == "__main__": | |
| app.launch(show_error=True, mcp_server=True) |