ora editor
This commit is contained in:
@@ -12,7 +12,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from ora_editor.config import TEMP_DIR
|
||||
from ora_editor.routes import (
|
||||
files_bp, layers_bp, images_bp, polygon_bp, mask_bp, krita_bp
|
||||
files_bp, layers_bp, images_bp, polygon_bp, mask_bp, krita_bp, sam_bp
|
||||
)
|
||||
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
@@ -34,7 +34,8 @@ app.register_blueprint(images_bp)
|
||||
app.register_blueprint(polygon_bp)
|
||||
app.register_blueprint(mask_bp)
|
||||
app.register_blueprint(krita_bp)
|
||||
app.register_blueprint(sam_bp)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(debug=False, port=5001, host='127.0.0.1')
|
||||
app.run(debug=False, port=5001, host='0.0.0.0')
|
||||
|
||||
71
tools/ora_editor/package-lock.json
generated
Normal file
71
tools/ora_editor/package-lock.json
generated
Normal file
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"name": "ora_editor",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"@playwright/test": "1.58.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
||||
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
5
tools/ora_editor/package.json
Normal file
5
tools/ora_editor/package.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"@playwright/test": "1.58.2"
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ from .images import images_bp
|
||||
from .polygon import polygon_bp
|
||||
from .mask import mask_bp
|
||||
from .krita import krita_bp
|
||||
from .sam import sam_bp
|
||||
|
||||
__all__ = [
|
||||
'files_bp',
|
||||
@@ -13,5 +14,6 @@ __all__ = [
|
||||
'images_bp',
|
||||
'polygon_bp',
|
||||
'mask_bp',
|
||||
'krita_bp'
|
||||
'krita_bp',
|
||||
'sam_bp'
|
||||
]
|
||||
|
||||
@@ -115,14 +115,20 @@ def api_mask_extract():
|
||||
ora_path = data['ora_path']
|
||||
comfy_url = data.get('comfy_url', COMFYUI_BASE_URL)
|
||||
count = min(max(data.get('count', 1), 1), 10)
|
||||
start_mask_path = data.get('start_mask_path', None)
|
||||
|
||||
logger.info(f"[MASK EXTRACT] Subject: {subject}")
|
||||
logger.info(f"[MASK EXTRACT] Use polygon: {use_polygon}")
|
||||
logger.info(f"[MASK EXTRACT] ORA path: {ora_path}")
|
||||
logger.info(f"[MASK EXTRACT] ComfyUI URL: {comfy_url}")
|
||||
logger.info(f"[MASK EXTRACT] Count: {count}")
|
||||
logger.info(f"[MASK EXTRACT] Start mask path: {start_mask_path}")
|
||||
|
||||
if start_mask_path:
|
||||
workflow_path = APP_DIR.parent / "image_mask_extraction_with_start.json"
|
||||
else:
|
||||
workflow_path = APP_DIR.parent / "image_mask_extraction.json"
|
||||
|
||||
workflow_path = APP_DIR.parent / "image_mask_extraction.json"
|
||||
if not workflow_path.exists():
|
||||
logger.error(f"[MASK EXTRACT] Workflow file not found: {workflow_path}")
|
||||
return jsonify({'success': False, 'error': f'Workflow file not found: {workflow_path}'}), 500
|
||||
@@ -142,6 +148,17 @@ def api_mask_extract():
|
||||
logger.error(f"[MASK EXTRACT] Error loading base image: {e}")
|
||||
return jsonify({'success': False, 'error': f'Error loading image: {str(e)}'}), 500
|
||||
|
||||
start_mask_img = None
|
||||
if start_mask_path:
|
||||
try:
|
||||
start_mask_img = __import__('PIL').Image.open(start_mask_path).convert('RGBA')
|
||||
if base_img.size != start_mask_img.size:
|
||||
start_mask_img = start_mask_img.resize(base_img.size, __import__('PIL').Image.LANCZOS)
|
||||
logger.info(f"[MASK EXTRACT] Loaded start mask: {start_mask_img.size}")
|
||||
except Exception as e:
|
||||
logger.error(f"[MASK EXTRACT] Error loading start mask: {e}")
|
||||
return jsonify({'success': False, 'error': f'Error loading start mask: {str(e)}'}), 500
|
||||
|
||||
polygon_points = None
|
||||
polygon_color = '#FF0000'
|
||||
polygon_width = 2
|
||||
@@ -166,18 +183,33 @@ def api_mask_extract():
|
||||
prompt_ids = []
|
||||
|
||||
for i in range(count):
|
||||
workflow = comfy_service.prepare_mask_workflow(
|
||||
base_image=base_img,
|
||||
subject=subject,
|
||||
webhook_url=webhook_url,
|
||||
seed=seeds[i],
|
||||
batch_id=batch_id,
|
||||
mask_index=i,
|
||||
polygon_points=polygon_points,
|
||||
polygon_color=polygon_color,
|
||||
polygon_width=polygon_width,
|
||||
workflow_template=workflow_template
|
||||
)
|
||||
if start_mask_img:
|
||||
workflow = comfy_service.prepare_mask_workflow_with_start(
|
||||
base_image=base_img,
|
||||
start_mask_image=start_mask_img,
|
||||
subject=subject,
|
||||
webhook_url=webhook_url,
|
||||
seed=seeds[i],
|
||||
batch_id=batch_id,
|
||||
mask_index=i,
|
||||
polygon_points=polygon_points,
|
||||
polygon_color=polygon_color,
|
||||
polygon_width=polygon_width,
|
||||
workflow_template=workflow_template
|
||||
)
|
||||
else:
|
||||
workflow = comfy_service.prepare_mask_workflow(
|
||||
base_image=base_img,
|
||||
subject=subject,
|
||||
webhook_url=webhook_url,
|
||||
seed=seeds[i],
|
||||
batch_id=batch_id,
|
||||
mask_index=i,
|
||||
polygon_points=polygon_points,
|
||||
polygon_color=polygon_color,
|
||||
polygon_width=polygon_width,
|
||||
workflow_template=workflow_template
|
||||
)
|
||||
|
||||
logger.info(f"[MASK EXTRACT] Workflow {i} prepared, sending to ComfyUI at http://{comfy_url}")
|
||||
|
||||
|
||||
107
tools/ora_editor/routes/sam.py
Normal file
107
tools/ora_editor/routes/sam.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""SAM3 rough mask generation routes for ORA Editor."""
|
||||
|
||||
import io
|
||||
import json
|
||||
import random
|
||||
import string
|
||||
import time
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from flask import Blueprint, request, jsonify, make_response
|
||||
|
||||
from ora_editor.config import APP_DIR, COMFYUI_BASE_URL, TEMP_DIR
|
||||
from ora_editor.services.comfyui import ComfyUIService, batch_storage
|
||||
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
sam_bp = Blueprint('sam', __name__)
|
||||
comfy_service = ComfyUIService(COMFYUI_BASE_URL)
|
||||
|
||||
|
||||
def generate_batch_id() -> str:
|
||||
"""Generate a unique batch ID."""
|
||||
return ''.join(random.choices(string.ascii_lowercase + string.digits, k=8))
|
||||
|
||||
|
||||
@sam_bp.route('/api/sam/generate', methods=['POST'])
|
||||
def api_sam_generate():
|
||||
"""Generate a rough mask using SAM3 with include/exclude points."""
|
||||
data = request.get_json()
|
||||
|
||||
required = ['ora_path', 'include_points']
|
||||
for field in required:
|
||||
if field not in data:
|
||||
return jsonify({'success': False, 'error': f'Missing {field} parameter'}), 400
|
||||
|
||||
ora_path = data['ora_path']
|
||||
include_points = data['include_points']
|
||||
exclude_points = data.get('exclude_points', [])
|
||||
comfy_url = data.get('comfy_url', COMFYUI_BASE_URL)
|
||||
|
||||
logger.info(f"[SAM] ORA path: {ora_path}")
|
||||
logger.info(f"[SAM] Include points: {include_points}")
|
||||
logger.info(f"[SAM] Exclude points: {exclude_points}")
|
||||
|
||||
workflow_path = APP_DIR.parent / "mask_rough_cut.json"
|
||||
if not workflow_path.exists():
|
||||
logger.error(f"[SAM] Workflow file not found: {workflow_path}")
|
||||
return jsonify({'success': False, 'error': f'Workflow file not found: {workflow_path}'}), 500
|
||||
|
||||
with open(workflow_path) as f:
|
||||
workflow_template = json.load(f)
|
||||
|
||||
base_img = None
|
||||
try:
|
||||
with zipfile.ZipFile(ora_path, 'r') as zf:
|
||||
img_data = zf.read('mergedimage.png')
|
||||
base_img = __import__('PIL').Image.open(io.BytesIO(img_data)).convert('RGBA')
|
||||
logger.info(f"[SAM] Loaded base image: {base_img.size}")
|
||||
except Exception as e:
|
||||
logger.error(f"[SAM] Error loading base image: {e}")
|
||||
return jsonify({'success': False, 'error': f'Error loading image: {str(e)}'}), 500
|
||||
|
||||
batch_id = generate_batch_id()
|
||||
webhook_url = f"http://localhost:5001/api/webhook/comfyui"
|
||||
|
||||
workflow = comfy_service.prepare_sam_workflow(
|
||||
base_image=base_img,
|
||||
include_points=include_points,
|
||||
exclude_points=exclude_points,
|
||||
webhook_url=webhook_url,
|
||||
batch_id=batch_id,
|
||||
workflow_template=workflow_template
|
||||
)
|
||||
|
||||
logger.info(f"[SAM] Workflow prepared, sending to ComfyUI at http://{comfy_url}")
|
||||
|
||||
try:
|
||||
prompt_id = comfy_service.submit_workflow(workflow, comfy_url)
|
||||
logger.info(f"[SAM] Prompt submitted with ID: {prompt_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"[SAM] Error submitting workflow: {e}")
|
||||
return jsonify({'success': False, 'error': f'Failed to connect to ComfyUI: {str(e)}'}), 500
|
||||
|
||||
batch_storage.create_batch(batch_id, 1)
|
||||
|
||||
completed = comfy_service.poll_for_completion(prompt_id, comfy_url, timeout=120)
|
||||
if not completed:
|
||||
logger.error("[SAM] Timeout waiting for workflow completion")
|
||||
return jsonify({'success': False, 'error': 'SAM generation timed out'}), 500
|
||||
|
||||
logger.info("[SAM] Workflow completed, waiting for webhook...")
|
||||
|
||||
mask_paths = comfy_service.poll_for_batch_completion(batch_id, timeout=60.0)
|
||||
|
||||
if not mask_paths:
|
||||
logger.error("[SAM] No mask received via webhook")
|
||||
return jsonify({'success': False, 'error': 'No mask received from SAM'}), 500
|
||||
|
||||
mask_path = mask_paths[0]
|
||||
logger.info(f"[SAM] Mask received: {mask_path}")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'mask_path': str(mask_path),
|
||||
'mask_url': f'/api/file/mask?path={mask_path}'
|
||||
})
|
||||
52
tools/ora_editor/server.log
Normal file
52
tools/ora_editor/server.log
Normal file
@@ -0,0 +1,52 @@
|
||||
INFO:__main__:[CONFIG] COMFYUI_BASE_URL=127.0.0.1:8188
|
||||
INFO:ora_editor.app:[CONFIG] COMFYUI_BASE_URL=127.0.0.1:8188
|
||||
* Serving Flask app 'app'
|
||||
* Debug mode: off
|
||||
INFO:werkzeug:[31m[1mWARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.[0m
|
||||
* Running on http://127.0.0.1:5001
|
||||
INFO:werkzeug:[33mPress CTRL+C to quit[0m
|
||||
INFO:werkzeug:127.0.0.1 - - [27/Mar/2026 17:31:53] "GET / HTTP/1.1" 200 -
|
||||
INFO:werkzeug:127.0.0.1 - - [27/Mar/2026 17:31:58] "GET / HTTP/1.1" 200 -
|
||||
INFO:werkzeug:127.0.0.1 - - [27/Mar/2026 17:32:02] "GET / HTTP/1.1" 200 -
|
||||
INFO:werkzeug:127.0.0.1 - - [27/Mar/2026 17:32:02] "[31m[1mGET /api/image/base?ora_path= HTTP/1.1[0m" 400 -
|
||||
INFO:werkzeug:127.0.0.1 - - [27/Mar/2026 17:32:02] "[31m[1mGET /api/image/masked?ora_path=&mask_path=null HTTP/1.1[0m" 400 -
|
||||
INFO:werkzeug:127.0.0.1 - - [27/Mar/2026 17:32:07] "POST /api/open HTTP/1.1" 200 -
|
||||
INFO:werkzeug:127.0.0.1 - - [27/Mar/2026 17:32:07] "POST /api/polygon/clear HTTP/1.1" 200 -
|
||||
INFO:werkzeug:127.0.0.1 - - [27/Mar/2026 17:32:07] "GET /api/image/base?ora_path=/home/noti/dev/ai-game-2/scenes/kq4_083_castle_dungeon_cell/pic_083_visual.ora HTTP/1.1" 200 -
|
||||
INFO:werkzeug:127.0.0.1 - - [27/Mar/2026 17:32:07] "GET /api/image/layer/base?ora_path=/home/noti/dev/ai-game-2/scenes/kq4_083_castle_dungeon_cell/pic_083_visual.ora HTTP/1.1" 200 -
|
||||
DEBUG:PIL.PngImagePlugin:STREAM b'IHDR' 16 13
|
||||
DEBUG:PIL.PngImagePlugin:STREAM b'IDAT' 41 7212
|
||||
INFO:werkzeug:127.0.0.1 - - [27/Mar/2026 17:32:07] "[35m[1mGET /api/image/masked?ora_path=/home/noti/dev/ai-game-2/scenes/kq4_083_castle_dungeon_cell/pic_083_visual.ora&mask_path=null HTTP/1.1[0m" 500 -
|
||||
DEBUG:PIL.PngImagePlugin:STREAM b'IHDR' 16 13
|
||||
DEBUG:PIL.PngImagePlugin:STREAM b'IDAT' 41 7212
|
||||
INFO:werkzeug:127.0.0.1 - - [27/Mar/2026 17:32:15] "POST /api/save HTTP/1.1" 200 -
|
||||
INFO:werkzeug:127.0.0.1 - - [27/Mar/2026 17:32:22] "POST /api/krita/open HTTP/1.1" 200 -
|
||||
INFO:werkzeug:127.0.0.1 - - [27/Mar/2026 17:32:36] "GET / HTTP/1.1" 200 -
|
||||
INFO:werkzeug:127.0.0.1 - - [27/Mar/2026 17:32:37] "[31m[1mGET /api/image/base?ora_path= HTTP/1.1[0m" 400 -
|
||||
INFO:werkzeug:127.0.0.1 - - [27/Mar/2026 17:32:37] "[31m[1mGET /api/image/masked?ora_path=&mask_path=null HTTP/1.1[0m" 400 -
|
||||
DEBUG:PIL.PngImagePlugin:STREAM b'IHDR' 16 13
|
||||
DEBUG:PIL.PngImagePlugin:STREAM b'tEXt' 41 4534
|
||||
DEBUG:PIL.PngImagePlugin:STREAM b'IDAT' 4587 65536
|
||||
INFO:werkzeug:127.0.0.1 - - [27/Mar/2026 17:32:42] "POST /api/open HTTP/1.1" 200 -
|
||||
INFO:werkzeug:127.0.0.1 - - [27/Mar/2026 17:32:42] "POST /api/polygon/clear HTTP/1.1" 200 -
|
||||
DEBUG:PIL.PngImagePlugin:STREAM b'IHDR' 16 13
|
||||
DEBUG:PIL.PngImagePlugin:STREAM b'IDAT' 41 65536
|
||||
INFO:werkzeug:127.0.0.1 - - [27/Mar/2026 17:32:42] "GET /api/image/layer/base?ora_path=/home/noti/dev/ai-game-2/scenes/kq4_001_beach/bg.ora HTTP/1.1" 200 -
|
||||
INFO:werkzeug:127.0.0.1 - - [27/Mar/2026 17:32:42] "GET /api/image/base?ora_path=/home/noti/dev/ai-game-2/scenes/kq4_001_beach/bg.ora HTTP/1.1" 200 -
|
||||
INFO:werkzeug:127.0.0.1 - - [27/Mar/2026 17:32:42] "[35m[1mGET /api/image/masked?ora_path=/home/noti/dev/ai-game-2/scenes/kq4_001_beach/bg.ora&mask_path=null HTTP/1.1[0m" 500 -
|
||||
INFO:werkzeug:127.0.0.1 - - [27/Mar/2026 17:32:44] "POST /api/krita/open HTTP/1.1" 200 -
|
||||
INFO:werkzeug:127.0.0.1 - - [27/Mar/2026 17:32:47] "POST /api/polygon/clear HTTP/1.1" 200 -
|
||||
INFO:__main__:[POLYGON] Storing polygon: 4 points, color: #FF0000, width: 2
|
||||
INFO:werkzeug:127.0.0.1 - - [27/Mar/2026 17:33:05] "POST /api/polygon HTTP/1.1" 200 -
|
||||
DEBUG:PIL.PngImagePlugin:STREAM b'IHDR' 16 13
|
||||
DEBUG:PIL.PngImagePlugin:STREAM b'IDAT' 41 7212
|
||||
INFO:werkzeug:127.0.0.1 - - [27/Mar/2026 17:33:05] "GET /api/image/polygon?ora_path=/home/noti/dev/ai-game-2/scenes/kq4_083_castle_dungeon_cell/pic_083_visual.ora&ts=1774657985514 HTTP/1.1" 200 -
|
||||
INFO:__main__:[POLYGON] Storing polygon: 4 points, color: #FF0000, width: 2
|
||||
INFO:werkzeug:127.0.0.1 - - [27/Mar/2026 17:33:22] "POST /api/polygon HTTP/1.1" 200 -
|
||||
DEBUG:PIL.PngImagePlugin:STREAM b'IHDR' 16 13
|
||||
DEBUG:PIL.PngImagePlugin:STREAM b'IDAT' 41 7212
|
||||
INFO:werkzeug:127.0.0.1 - - [27/Mar/2026 17:33:22] "GET /api/image/polygon?ora_path=/home/noti/dev/ai-game-2/scenes/kq4_083_castle_dungeon_cell/pic_083_visual.ora&ts=1774658002210 HTTP/1.1" 200 -
|
||||
INFO:werkzeug:127.0.0.1 - - [27/Mar/2026 17:33:28] "POST /api/polygon/clear HTTP/1.1" 200 -
|
||||
DEBUG:PIL.PngImagePlugin:STREAM b'IHDR' 16 13
|
||||
DEBUG:PIL.PngImagePlugin:STREAM b'IDAT' 41 7212
|
||||
INFO:werkzeug:127.0.0.1 - - [27/Mar/2026 17:33:37] "POST /api/krita/open HTTP/1.1" 200 -
|
||||
@@ -192,3 +192,135 @@ class ComfyUIService:
|
||||
workflow["96"]["inputs"]["external_uid"] = metadata
|
||||
|
||||
return workflow
|
||||
|
||||
def prepare_sam_workflow(
|
||||
self,
|
||||
base_image: Image.Image,
|
||||
include_points: list,
|
||||
exclude_points: list,
|
||||
webhook_url: str,
|
||||
batch_id: str = None,
|
||||
workflow_template: dict | None = None
|
||||
) -> dict:
|
||||
"""Prepare the SAM3 rough mask workflow with user-provided points."""
|
||||
workflow = json.loads(json.dumps(workflow_template)) if workflow_template else {}
|
||||
|
||||
img_io = io.BytesIO()
|
||||
base_image.save(img_io, format='PNG')
|
||||
img_io.seek(0)
|
||||
base64_image = base64.b64encode(img_io.read()).decode('utf-8')
|
||||
|
||||
if "9" in workflow:
|
||||
workflow["9"]["inputs"]["image"] = base64_image
|
||||
|
||||
all_points = []
|
||||
for pt in include_points:
|
||||
all_points.append({
|
||||
'x': pt['x'],
|
||||
'y': pt['y'],
|
||||
'is_foreground': True
|
||||
})
|
||||
for pt in exclude_points:
|
||||
all_points.append({
|
||||
'x': pt['x'],
|
||||
'y': pt['y'],
|
||||
'is_foreground': False
|
||||
})
|
||||
|
||||
point_nodes = {}
|
||||
point_node_ids = []
|
||||
|
||||
for i, pt in enumerate(all_points):
|
||||
node_id = str(100 + i)
|
||||
point_nodes[node_id] = {
|
||||
"inputs": {
|
||||
"x": pt['x'],
|
||||
"y": pt['y'],
|
||||
"is_foreground": pt['is_foreground']
|
||||
},
|
||||
"class_type": "SAM3CreatePoint",
|
||||
"_meta": {"title": f"SAM3 Point {i+1}"}
|
||||
}
|
||||
point_node_ids.append(node_id)
|
||||
|
||||
for node_id, node_data in point_nodes.items():
|
||||
workflow[node_id] = node_data
|
||||
|
||||
if point_node_ids:
|
||||
combine_inputs = {}
|
||||
for i, node_id in enumerate(point_node_ids):
|
||||
combine_inputs[f"point_{i+1}"] = [node_id, 0]
|
||||
|
||||
workflow["8"] = {
|
||||
"inputs": combine_inputs,
|
||||
"class_type": "SAM3CombinePoints",
|
||||
"_meta": {"title": "SAM3 Combine Points"}
|
||||
}
|
||||
|
||||
if "1" in workflow:
|
||||
workflow["1"]["inputs"]["positive_points"] = ["8", 0]
|
||||
|
||||
if "11" in workflow:
|
||||
workflow["11"]["inputs"]["webhook_url"] = webhook_url
|
||||
if batch_id:
|
||||
workflow["11"]["inputs"]["external_uid"] = f"{batch_id}:0"
|
||||
|
||||
return workflow
|
||||
|
||||
def prepare_mask_workflow_with_start(
|
||||
self,
|
||||
base_image: Image.Image,
|
||||
start_mask_image: Image.Image,
|
||||
subject: str,
|
||||
webhook_url: str,
|
||||
seed: int,
|
||||
batch_id: str = None,
|
||||
mask_index: int = 0,
|
||||
polygon_points: list | None = None,
|
||||
polygon_color: str = '#FF0000',
|
||||
polygon_width: int = 2,
|
||||
workflow_template: dict | None = None
|
||||
) -> dict:
|
||||
"""Prepare the mask extraction workflow with a starting mask (lower denoise)."""
|
||||
workflow = json.loads(json.dumps(workflow_template)) if workflow_template else {}
|
||||
|
||||
img = base_image.copy()
|
||||
|
||||
if polygon_points and len(polygon_points) >= 3:
|
||||
w, h = img.size
|
||||
pixel_points = [(int(p['x'] * w), int(p['y'] * h)) for p in polygon_points]
|
||||
|
||||
draw = ImageDraw.Draw(img)
|
||||
hex_color = polygon_color if len(polygon_color) == 7 else polygon_color + 'FF'
|
||||
draw.polygon(pixel_points, outline=hex_color, width=polygon_width)
|
||||
|
||||
img_io = io.BytesIO()
|
||||
img.save(img_io, format='PNG')
|
||||
img_io.seek(0)
|
||||
base64_image = base64.b64encode(img_io.read()).decode('utf-8')
|
||||
|
||||
if "87" in workflow:
|
||||
workflow["87"]["inputs"]["image"] = base64_image
|
||||
|
||||
start_mask_io = io.BytesIO()
|
||||
start_mask_image.save(start_mask_io, format='PNG')
|
||||
start_mask_io.seek(0)
|
||||
start_mask_base64 = base64.b64encode(start_mask_io.read()).decode('utf-8')
|
||||
|
||||
if "200" in workflow:
|
||||
workflow["200"]["inputs"]["image"] = start_mask_base64
|
||||
|
||||
if "1:68" in workflow and 'inputs' in workflow["1:68"]:
|
||||
workflow["1:68"]["inputs"]["prompt"] = f"Create a black and white alpha mask of {subject}, leaving everything else black"
|
||||
|
||||
if "96" in workflow and 'inputs' in workflow["96"]:
|
||||
workflow["96"]["inputs"]["webhook_url"] = webhook_url
|
||||
|
||||
if "50" in workflow and 'inputs' in workflow["50"]:
|
||||
workflow["50"]["inputs"]["seed"] = seed
|
||||
|
||||
if "96" in workflow and 'inputs' in workflow["96"]:
|
||||
metadata = f"{batch_id}:{mask_index}" if batch_id else str(mask_index)
|
||||
workflow["96"]["inputs"]["external_uid"] = metadata
|
||||
|
||||
return workflow
|
||||
|
||||
@@ -17,6 +17,40 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- SAM Include points (green) -->
|
||||
<template x-if="mode === 'add' && isSamMode">
|
||||
<template x-for="(point, idx) in samIncludePoints" :key="'sam-include-' + idx">
|
||||
<div
|
||||
class="absolute w-5 h-5 bg-green-500 border-2 border-white rounded-full cursor-move z-20 flex items-center justify-center text-xs font-bold text-white"
|
||||
style="transform: translate(-50%, -50%);"
|
||||
:style="`left: ${point.x * 100}%; top: ${point.y * 100}%`"
|
||||
@contextmenu.prevent="removeSamPoint('include', idx)"
|
||||
x-text="idx + 1"
|
||||
></div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- SAM Exclude points (red) -->
|
||||
<template x-if="mode === 'add' && isSamMode">
|
||||
<template x-for="(point, idx) in samExcludePoints" :key="'sam-exclude-' + idx">
|
||||
<div
|
||||
class="absolute w-5 h-5 bg-red-500 border-2 border-white rounded-full cursor-move z-20 flex items-center justify-center text-xs font-bold text-white"
|
||||
style="transform: translate(-50%, -50%);"
|
||||
:style="`left: ${point.x * 100}%; top: ${point.y * 100}%`"
|
||||
@contextmenu.prevent="removeSamPoint('exclude', idx)"
|
||||
x-text="'X'"
|
||||
></div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- SAM mask preview overlay -->
|
||||
<img
|
||||
x-show="samMaskUrl"
|
||||
:src="samMaskUrl"
|
||||
class="absolute inset-0 w-full h-full object-contain pointer-events-none z-15 opacity-50"
|
||||
alt="SAM mask preview"
|
||||
>
|
||||
|
||||
<!-- Polygon points markers (draggable) - shown in add mode -->
|
||||
<template x-if="mode === 'add' && polygonPoints.length > 0">
|
||||
<template x-for="(point, idx) in polygonPoints" :key="'point-' + idx">
|
||||
@@ -46,6 +80,15 @@
|
||||
:height="imageHeight"
|
||||
class="absolute inset-0 cursor-crosshair pointer-events-auto border-2 border-dashed border-blue-500 opacity-90"
|
||||
></canvas>
|
||||
|
||||
<!-- SAM click canvas -->
|
||||
<div
|
||||
x-show="isSamMode"
|
||||
id="samCanvas"
|
||||
class="absolute inset-0 cursor-crosshair z-10"
|
||||
@click="handleSamClick($event)"
|
||||
@contextmenu.prevent="handleSamRightClick($event)"
|
||||
></div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -60,13 +103,16 @@
|
||||
<div x-show="error" class="text-red-400 text-center mt-8" x-text="error"></div>
|
||||
|
||||
<!-- Mode-specific instructions -->
|
||||
<div x-show="isSamMode" class="mt-2 text-sm text-gray-400">
|
||||
Left-click to add include points (green). Right-click to add exclude points (red). Right-click on point to remove.
|
||||
</div>
|
||||
<div x-show="isDrawing" class="mt-2 text-sm text-gray-400">
|
||||
Click to add points. Drag points to adjust. Double-click or Enter to finish, Escape to cancel.
|
||||
</div>
|
||||
<div x-show="mode === 'add' && !isDrawing && polygonPoints.length >= 3" class="mt-2 text-sm text-gray-400">
|
||||
Drag points to adjust polygon, then extract mask or open in Krita.
|
||||
</div>
|
||||
<div x-show="mode === 'add' && !isDrawing && !polygonPreviewUrl && polygonPoints.length < 3" class="mt-2 text-sm text-gray-400">
|
||||
Draw a polygon (optional) then extract mask, or use Open in Krita to annotation manually.
|
||||
<div x-show="mode === 'add' && !isDrawing && !polygonPreviewUrl && polygonPoints.length < 3 && !isSamMode" class="mt-2 text-sm text-gray-400">
|
||||
Use SAM rough mask or draw a polygon, then extract mask.
|
||||
</div>
|
||||
</main>
|
||||
@@ -64,6 +64,71 @@
|
||||
<p class="text-xs text-gray-400 mt-1">Will create layer: <span x-text="entityName ? entityName + '_0' : 'element_0'"></span></p>
|
||||
</div>
|
||||
|
||||
<!-- SAM Rough Mask -->
|
||||
<div class="bg-gray-800 rounded-lg p-4 border border-gray-700">
|
||||
<h3 class="font-bold mb-3 text-gray-300">Rough Mask (SAM)</h3>
|
||||
|
||||
<div class="space-y-3">
|
||||
<p class="text-xs text-gray-400">Click to mark include points (green). Right-click to mark exclude points (red).</p>
|
||||
|
||||
<div x-show="samIncludePoints.length > 0 || samExcludePoints.length > 0" class="text-xs text-gray-300 space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-3 h-3 bg-green-500 rounded-full"></span>
|
||||
<span>Include: <span x-text="samIncludePoints.length"></span></span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-3 h-3 bg-red-500 rounded-full"></span>
|
||||
<span>Exclude: <span x-text="samExcludePoints.length"></span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
@click="startSamMode()"
|
||||
:disabled="isSamMode"
|
||||
class="flex-1 bg-teal-600 hover:bg-teal-700 disabled:bg-gray-600 px-3 py-1.5 rounded text-sm transition"
|
||||
>
|
||||
Start
|
||||
</button>
|
||||
<button
|
||||
@click="clearSamPoints()"
|
||||
:disabled="samIncludePoints.length === 0 && samExcludePoints.length === 0"
|
||||
class="bg-gray-600 hover:bg-gray-500 disabled:bg-gray-700 px-3 py-1.5 rounded text-sm transition"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="generateSamMask()"
|
||||
:disabled="samIncludePoints.length === 0 || isSamGenerating"
|
||||
class="w-full bg-teal-600 hover:bg-teal-700 disabled:bg-gray-600 px-4 py-2 rounded transition flex items-center justify-center gap-2"
|
||||
>
|
||||
<span x-show="isSamGenerating" class="animate-spin rounded-full h-4 w-4 border-t-2 border-b-2 border-white"></span>
|
||||
<span x-show="!isSamGenerating">Generate Rough Mask</span>
|
||||
<span x-show="isSamGenerating">Generating...</span>
|
||||
</button>
|
||||
|
||||
<div x-show="samMaskUrl" class="space-y-2">
|
||||
<p class="text-xs text-green-400">Rough mask ready!</p>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
@click="useSamMask()"
|
||||
class="flex-1 bg-green-600 hover:bg-green-700 px-3 py-1.5 rounded text-sm"
|
||||
>
|
||||
Use as Mask
|
||||
</button>
|
||||
<button
|
||||
@click="discardSamMask()"
|
||||
class="bg-gray-600 hover:bg-gray-500 px-3 py-1.5 rounded text-sm"
|
||||
>
|
||||
Discard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Polygon tool -->
|
||||
<div class="bg-gray-800 rounded-lg p-4 border border-gray-700">
|
||||
<h3 class="font-bold mb-3 text-gray-300">Polygon (Optional)</h3>
|
||||
@@ -110,6 +175,16 @@
|
||||
<span class="text-sm">Use polygon hint</span>
|
||||
</label>
|
||||
|
||||
<label x-show="samMaskPath" class="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
x-model="useSamAsStart"
|
||||
:disabled="isExtracting || !samMaskPath"
|
||||
class="w-4 h-4 rounded"
|
||||
>
|
||||
<span class="text-sm">Use SAM mask as starting point (0.8 denoise)</span>
|
||||
</label>
|
||||
|
||||
<!-- Count selector -->
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-sm text-gray-400">Generate:</label>
|
||||
|
||||
@@ -132,6 +132,15 @@ function oraEditor() {
|
||||
polygonWidth: 2,
|
||||
polygonPreviewUrl: null,
|
||||
|
||||
// SAM rough mask state
|
||||
isSamMode: false,
|
||||
samIncludePoints: [],
|
||||
samExcludePoints: [],
|
||||
isSamGenerating: false,
|
||||
samMaskPath: null,
|
||||
samMaskUrl: null,
|
||||
useSamAsStart: true,
|
||||
|
||||
// Mask extraction
|
||||
maskSubject: '',
|
||||
usePolygonHint: true,
|
||||
@@ -301,6 +310,7 @@ function oraEditor() {
|
||||
this.entityName = '';
|
||||
this.maskSubject = '';
|
||||
this.clearPolygon();
|
||||
this.clearSamPoints();
|
||||
this.scale = 100;
|
||||
},
|
||||
|
||||
@@ -310,6 +320,11 @@ function oraEditor() {
|
||||
this.isDrawing = false;
|
||||
this.polygonPoints = [];
|
||||
this.polygonPreviewUrl = null;
|
||||
this.isSamMode = false;
|
||||
this.samIncludePoints = [];
|
||||
this.samExcludePoints = [];
|
||||
this.samMaskPath = null;
|
||||
this.samMaskUrl = null;
|
||||
const canvas = document.getElementById('polygonCanvas');
|
||||
if (canvas) {
|
||||
canvas.style.display = 'none';
|
||||
@@ -319,6 +334,8 @@ function oraEditor() {
|
||||
async cancelAddMode() {
|
||||
if (this.isDrawing) {
|
||||
this.clearPolygon();
|
||||
} else if (this.isSamMode) {
|
||||
this.isSamMode = false;
|
||||
} else {
|
||||
this.exitAddMode();
|
||||
}
|
||||
@@ -585,6 +602,118 @@ function oraEditor() {
|
||||
}
|
||||
},
|
||||
|
||||
// === SAM Rough Mask ===
|
||||
startSamMode() {
|
||||
console.log('[ORA EDITOR] Starting SAM mode');
|
||||
this.isSamMode = true;
|
||||
this.samIncludePoints = [];
|
||||
this.samExcludePoints = [];
|
||||
this.samMaskPath = null;
|
||||
this.samMaskUrl = null;
|
||||
},
|
||||
|
||||
clearSamPoints() {
|
||||
this.samIncludePoints = [];
|
||||
this.samExcludePoints = [];
|
||||
this.samMaskPath = null;
|
||||
this.samMaskUrl = null;
|
||||
},
|
||||
|
||||
handleSamClick(e) {
|
||||
if (!this.isSamMode) return;
|
||||
|
||||
const container = document.getElementById('imageContainer');
|
||||
if (!container) return;
|
||||
|
||||
const rect = container.getBoundingClientRect();
|
||||
let x = (e.clientX - rect.left) / rect.width;
|
||||
let y = (e.clientY - rect.top) / rect.height;
|
||||
|
||||
x = Math.max(0, Math.min(1, x));
|
||||
y = Math.max(0, Math.min(1, y));
|
||||
|
||||
this.samIncludePoints.push({ x, y });
|
||||
console.log('[SAM] Added include point:', x, y);
|
||||
},
|
||||
|
||||
handleSamRightClick(e) {
|
||||
if (!this.isSamMode) return;
|
||||
e.preventDefault();
|
||||
|
||||
const container = document.getElementById('imageContainer');
|
||||
if (!container) return;
|
||||
|
||||
const rect = container.getBoundingClientRect();
|
||||
let x = (e.clientX - rect.left) / rect.width;
|
||||
let y = (e.clientY - rect.top) / rect.height;
|
||||
|
||||
x = Math.max(0, Math.min(1, x));
|
||||
y = Math.max(0, Math.min(1, y));
|
||||
|
||||
this.samExcludePoints.push({ x, y });
|
||||
console.log('[SAM] Added exclude point:', x, y);
|
||||
},
|
||||
|
||||
removeSamPoint(type, idx) {
|
||||
if (type === 'include') {
|
||||
this.samIncludePoints.splice(idx, 1);
|
||||
} else {
|
||||
this.samExcludePoints.splice(idx, 1);
|
||||
}
|
||||
},
|
||||
|
||||
async generateSamMask() {
|
||||
if (this.samIncludePoints.length === 0 || !this.oraPath) return;
|
||||
|
||||
console.log('[ORA EDITOR] Generating SAM mask with', this.samIncludePoints.length, 'include points');
|
||||
this.isSamGenerating = true;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/sam/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
ora_path: this.oraPath,
|
||||
include_points: this.samIncludePoints,
|
||||
exclude_points: this.samExcludePoints,
|
||||
comfy_url: this.comfyUrl
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
console.log('[ORA EDITOR] SAM response:', data);
|
||||
|
||||
if (!data.success) throw new Error(data.error || 'Failed');
|
||||
|
||||
this.samMaskPath = data.mask_path;
|
||||
this.samMaskUrl = data.mask_url;
|
||||
this.isSamMode = false;
|
||||
} catch (e) {
|
||||
console.error('[ORA EDITOR] Error generating SAM mask:', e);
|
||||
this.lastError = e.message;
|
||||
} finally {
|
||||
this.isSamGenerating = false;
|
||||
}
|
||||
},
|
||||
|
||||
useSamMask() {
|
||||
if (this.samMaskPath) {
|
||||
this.tempMaskPath = this.samMaskPath;
|
||||
this.tempMaskUrl = this.samMaskUrl;
|
||||
this.tempMaskPaths = [this.samMaskPath];
|
||||
this.currentMaskIndex = 0;
|
||||
this.showMaskModal = true;
|
||||
}
|
||||
},
|
||||
|
||||
discardSamMask() {
|
||||
this.samMaskPath = null;
|
||||
this.samMaskUrl = null;
|
||||
this.samIncludePoints = [];
|
||||
this.samExcludePoints = [];
|
||||
},
|
||||
|
||||
// === Mask Extraction ===
|
||||
async extractMask() {
|
||||
if (!this.maskSubject.trim()) return;
|
||||
@@ -593,17 +722,24 @@ function oraEditor() {
|
||||
this.isExtracting = true;
|
||||
this.lastError = '';
|
||||
|
||||
const requestBody = {
|
||||
subject: this.maskSubject,
|
||||
use_polygon: this.usePolygonHint && this.polygonPoints.length >= 3,
|
||||
ora_path: this.oraPath,
|
||||
comfy_url: this.comfyUrl,
|
||||
count: this.maskCount
|
||||
};
|
||||
|
||||
if (this.useSamAsStart && this.samMaskPath) {
|
||||
requestBody.start_mask_path = this.samMaskPath;
|
||||
console.log('[ORA EDITOR] Using SAM mask as starting point:', this.samMaskPath);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/mask/extract', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
subject: this.maskSubject,
|
||||
use_polygon: this.usePolygonHint && this.polygonPoints.length >= 3,
|
||||
ora_path: this.oraPath,
|
||||
comfy_url: this.comfyUrl,
|
||||
count: this.maskCount
|
||||
})
|
||||
body: JSON.stringify(requestBody)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
Reference in New Issue
Block a user