#!/usr/bin/env python3 """Alpha constraints and deterministic seed candidates, never semantic masks.""" import argparse import json import math from pathlib import Path import cv2 import numpy as np from PIL import Image from normalize_reference import prepare_rgba def pixel(point, width, height): # Normalized points map to their containing pixel; 1 maps to the last pixel. return min(width - 1, int(point[0] * width)), min(height - 1, int(point[1] * height)) def ground_parts(image, plan): alpha = np.array(image.convert('RGBA'))[:, :, 3] foreground = alpha > 0 height, width = alpha.shape usable_alpha = bool(foreground.any() and not foreground.all()) silhouette_distance = cv2.distanceTransform(np.pad(foreground.astype(np.uint8), 1), cv2.DIST_L2, cv2.DIST_MASK_PRECISE)[1:-1, 1:-1] diagnostics, prompts = [], [] for part in plan['parts']: box = part['bbox_norm_xyxy'] entry = { 'id': part['id'], 'status': 'hidden' if part['visibility'] == 'hidden' else 'blocked', 'bbox_px_xyxy': None, 'bbox_foreground_ratio': None, 'foreground_bbox_px_xyxy': None, 'foreground_components': 0, 'model_positive_points': [], 'candidate_points': [], 'warnings': [], 'semantic_verified': False, } for point in part.get('positive_points_norm', []): x, y = pixel(point, width, height) in_box = bool(box and box[0] <= point[0] <= box[2] and box[1] <= point[1] <= box[3]) on_foreground = bool(foreground[y, x]) entry['model_positive_points'].append({ 'point_norm': point, 'point_px': [x, y], 'alpha': int(alpha[y, x]), 'inside_bbox': in_box, 'on_foreground': on_foreground, 'status': 'UNTRUSTED_SEED' if usable_alpha and on_foreground and in_box else 'INVALID_SEED', }) if part['visibility'] != 'hidden' and box: x0, y0 = math.floor(box[0] * width), math.floor(box[1] * height) x1, y1 = math.ceil(box[2] * width), math.ceil(box[3] * height) entry['bbox_px_xyxy'] = [x0, y0, x1, y1] roi = foreground[y0:y1, x0:x1] entry['bbox_foreground_ratio'] = float(roi.mean()) if roi.size else 0 if not usable_alpha: entry['warnings'].append('NO_USABLE_ALPHA') elif not roi.any(): entry['warnings'].append('EMPTY_FOREGROUND_ROI') else: ys, xs = np.nonzero(roi) entry['foreground_bbox_px_xyxy'] = [int(xs.min()) + x0, int(ys.min()) + y0, int(xs.max()) + x0 + 1, int(ys.max()) + y0 + 1] count, _ = cv2.connectedComponents(roi.astype(np.uint8), connectivity=8) entry['foreground_components'] = count - 1 # Pad the ROI with background so an opaque ROI has a finite # distance, and candidates stay away from both silhouette/box edges. distance = cv2.distanceTransform(np.pad(roi.astype(np.uint8), 1), cv2.DIST_L2, cv2.DIST_MASK_PRECISE)[1:-1, 1:-1] # Only centers strictly inside the original continuous ROI qualify. eligible_x = ((np.arange(x0, x1) + .5) / width >= box[0]) & ((np.arange(x0, x1) + .5) / width <= box[2]) eligible_y = ((np.arange(y0, y1) + .5) / height >= box[1]) & ((np.arange(y0, y1) + .5) / height <= box[3]) eligible = roi & eligible_y[:, None] & eligible_x[None, :] if eligible.any(): scores = np.where(eligible, distance, -1) cy, cx = np.unravel_index(np.argmax(scores), scores.shape) x, y = int(cx) + x0, int(cy) + y0 entry['candidate_points'] = [{ 'point_px': [x, y], 'point_norm': [(x + .5) / width, (y + .5) / height], 'distance_to_roi_boundary_px': float(distance[cy, cx]), 'distance_to_silhouette_px': float(silhouette_distance[y, x]), 'source': 'alpha_roi_distance_transform', }] entry['status'] = 'candidate' else: entry['warnings'].append('NO_PIXEL_CENTER_IN_ROI') if entry['bbox_foreground_ratio'] < .5: entry['warnings'].append('LOW_FOREGROUND_RATIO') if count > 2: entry['warnings'].append('MULTIPLE_FOREGROUND_COMPONENTS') entry['warnings'].append('SEMANTIC_REVIEW_REQUIRED') diagnostics.append(entry) prompts.append({ 'id': part['id'], 'text_prompt': part['semantic'], 'status': entry['status'], 'bbox_px_xyxy': entry['bbox_px_xyxy'], 'positive_points_px': [p['point_px'] for p in entry['candidate_points']], 'positive_points_norm': [p['point_norm'] for p in entry['candidate_points']], 'negative_points_px': [], 'seed_source': 'alpha_roi_distance_transform', 'semantic_verified': False, 'requires_mask_qa': True, }) points = [p for entry in diagnostics for p in entry['model_positive_points']] return { 'schema_version': 1, 'width': width, 'height': height, 'alpha_threshold': 0, 'coordinate_mapping': 'floor(norm * size), clamped at size - 1; boxes are half-open', 'summary': {'model_points': len(points), 'invalid_model_points': sum(p['status'] == 'INVALID_SEED' for p in points), 'background_model_points': sum(not p['on_foreground'] for p in points), 'candidate_parts': sum(p['status'] == 'candidate' for p in diagnostics), 'blocked_parts': sum(p['status'] == 'blocked' for p in diagnostics)}, 'parts': diagnostics, }, {'schema_version': 1, 'width': width, 'height': height, 'quality_status': 'needs_review', 'parts': prompts} def main(): parser = argparse.ArgumentParser() parser.add_argument('--input', required=True) parser.add_argument('--output', required=True) parser.add_argument('--prepare', action='store_true') parser.add_argument('--parts') parser.add_argument('--prompts') args = parser.parse_args() with Image.open(args.input) as image: if args.prepare: prepared, metadata = prepare_rgba(image) prepared.save(args.output, format='PNG') print(json.dumps(metadata)) else: if not args.parts or not args.prompts: parser.error('--parts and --prompts are required for grounding') plan = json.loads(Path(args.parts).read_text(encoding='utf-8')) diagnostics, prompts = ground_parts(image, plan) Path(args.output).write_text(json.dumps(diagnostics, indent=2), encoding='utf-8') Path(args.prompts).write_text(json.dumps(prompts, indent=2), encoding='utf-8') if __name__ == '__main__': main()