Add Qwen camera translator
This commit is contained in:
@@ -10,6 +10,7 @@ The node is registered as:
|
|||||||
- `prompt_builder / SxCP Seed Locker`
|
- `prompt_builder / SxCP Seed Locker`
|
||||||
- `prompt_builder / SxCP Camera Control`
|
- `prompt_builder / SxCP Camera Control`
|
||||||
- `prompt_builder / SxCP Camera Orbit Control`
|
- `prompt_builder / SxCP Camera Orbit Control`
|
||||||
|
- `prompt_builder / SxCP Qwen Camera Translator`
|
||||||
- `prompt_builder / SxCP For Loop Start`
|
- `prompt_builder / SxCP For Loop Start`
|
||||||
- `prompt_builder / SxCP For Loop End`
|
- `prompt_builder / SxCP For Loop End`
|
||||||
- `prompt_builder / SxCP Loop Append`
|
- `prompt_builder / SxCP Loop Append`
|
||||||
@@ -253,6 +254,19 @@ Orbit controls:
|
|||||||
contact points, or the environment.
|
contact points, or the environment.
|
||||||
- `include_degrees`: keeps the numeric angle in the emitted camera phrase.
|
- `include_degrees`: keeps the numeric angle in the emitted camera phrase.
|
||||||
|
|
||||||
|
If you use `ComfyUI-qwenmultiangle`, keep its nicer Three.js camera viewer and
|
||||||
|
add `SxCP Qwen Camera Translator` after it:
|
||||||
|
|
||||||
|
1. Connect Qwen Multiangle Camera `prompt` to translator `qwen_prompt`.
|
||||||
|
2. Optionally connect Qwen Multiangle Camera `camera_info` to translator
|
||||||
|
`camera_info`; this keeps exact numeric angle/zoom from the viewer.
|
||||||
|
3. Connect translator `camera_config` to `Prompt Builder`, `Prompt Builder From
|
||||||
|
Configs`, or `Insta/OF Prompt Pair`.
|
||||||
|
|
||||||
|
The translator accepts the Qwen labels such as `front-right quarter view`,
|
||||||
|
`eye-level shot`, and `medium shot`, then emits the same `camera_config` format
|
||||||
|
as the native camera nodes.
|
||||||
|
|
||||||
`SxCP Caption Naturalizer` rewrites tag-like captions or labeled prompts into
|
`SxCP Caption Naturalizer` rewrites tag-like captions or labeled prompts into
|
||||||
more natural language. Connect the prompt builder's `metadata_json` output to
|
more natural language. Connect the prompt builder's `metadata_json` output to
|
||||||
`source_text` for the cleanest result. You can also connect `caption` or
|
`source_text` for the cleanest result. You can also connect `caption` or
|
||||||
|
|||||||
+64
-2
@@ -4,10 +4,11 @@ import json
|
|||||||
import random
|
import random
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from .loop_nodes import LOOP_NODE_CLASS_MAPPINGS, LOOP_NODE_DISPLAY_NAME_MAPPINGS
|
from .loop_nodes import ANY_TYPE, LOOP_NODE_CLASS_MAPPINGS, LOOP_NODE_DISPLAY_NAME_MAPPINGS
|
||||||
from .prompt_builder import (
|
from .prompt_builder import (
|
||||||
build_camera_config_json,
|
build_camera_config_json,
|
||||||
build_camera_orbit_config_json,
|
build_camera_orbit_config_json,
|
||||||
|
build_qwen_camera_config_json,
|
||||||
build_cast_config_json,
|
build_cast_config_json,
|
||||||
build_category_config_json,
|
build_category_config_json,
|
||||||
build_character_slot_json,
|
build_character_slot_json,
|
||||||
@@ -54,10 +55,11 @@ try:
|
|||||||
from .caption_naturalizer import naturalize_caption
|
from .caption_naturalizer import naturalize_caption
|
||||||
from .krea_formatter import format_krea2_prompt
|
from .krea_formatter import format_krea2_prompt
|
||||||
except ImportError:
|
except ImportError:
|
||||||
from loop_nodes import LOOP_NODE_CLASS_MAPPINGS, LOOP_NODE_DISPLAY_NAME_MAPPINGS
|
from loop_nodes import ANY_TYPE, LOOP_NODE_CLASS_MAPPINGS, LOOP_NODE_DISPLAY_NAME_MAPPINGS
|
||||||
from prompt_builder import (
|
from prompt_builder import (
|
||||||
build_camera_config_json,
|
build_camera_config_json,
|
||||||
build_camera_orbit_config_json,
|
build_camera_orbit_config_json,
|
||||||
|
build_qwen_camera_config_json,
|
||||||
build_cast_config_json,
|
build_cast_config_json,
|
||||||
build_category_config_json,
|
build_category_config_json,
|
||||||
build_character_slot_json,
|
build_character_slot_json,
|
||||||
@@ -443,6 +445,64 @@ class SxCPCameraOrbitControl:
|
|||||||
return config, camera_prompt, json.dumps(parsed, ensure_ascii=True, sort_keys=True)
|
return config, camera_prompt, json.dumps(parsed, ensure_ascii=True, sort_keys=True)
|
||||||
|
|
||||||
|
|
||||||
|
class SxCPQwenCameraTranslator:
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"qwen_prompt": ("STRING", {"default": ""}),
|
||||||
|
"prefer_camera_info": ("BOOLEAN", {"default": True}),
|
||||||
|
"camera_mode": (camera_mode_choices(), {"default": "standard"}),
|
||||||
|
"subject_focus": (camera_orbit_focus_choices(), {"default": "auto"}),
|
||||||
|
"lens": (camera_lens_choices(), {"default": "auto"}),
|
||||||
|
"orientation": (camera_orientation_choices(), {"default": "auto"}),
|
||||||
|
"phone_visibility": (camera_phone_choices(), {"default": "auto"}),
|
||||||
|
"priority": (camera_priority_choices(), {"default": "locked"}),
|
||||||
|
"camera_detail": (camera_detail_choices(), {"default": "compact"}),
|
||||||
|
"include_degrees": ("BOOLEAN", {"default": False}),
|
||||||
|
},
|
||||||
|
"optional": {
|
||||||
|
"camera_info": (ANY_TYPE,),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
RETURN_TYPES = ("STRING", "STRING", "STRING")
|
||||||
|
RETURN_NAMES = ("camera_config", "camera_prompt", "camera_info_json")
|
||||||
|
FUNCTION = "build"
|
||||||
|
CATEGORY = "prompt_builder"
|
||||||
|
|
||||||
|
def build(
|
||||||
|
self,
|
||||||
|
qwen_prompt,
|
||||||
|
prefer_camera_info,
|
||||||
|
camera_mode,
|
||||||
|
subject_focus,
|
||||||
|
lens,
|
||||||
|
orientation,
|
||||||
|
phone_visibility,
|
||||||
|
priority,
|
||||||
|
camera_detail,
|
||||||
|
include_degrees,
|
||||||
|
camera_info=None,
|
||||||
|
):
|
||||||
|
config = build_qwen_camera_config_json(
|
||||||
|
qwen_prompt=qwen_prompt or "",
|
||||||
|
camera_info=camera_info,
|
||||||
|
prefer_camera_info=prefer_camera_info,
|
||||||
|
camera_mode=camera_mode,
|
||||||
|
subject_focus=subject_focus,
|
||||||
|
lens=lens,
|
||||||
|
orientation=orientation,
|
||||||
|
phone_visibility=phone_visibility,
|
||||||
|
priority=priority,
|
||||||
|
camera_detail=camera_detail,
|
||||||
|
include_degrees=include_degrees,
|
||||||
|
)
|
||||||
|
parsed = json.loads(config)
|
||||||
|
camera_prompt = parsed.get("custom_camera_prompt", "")
|
||||||
|
return config, camera_prompt, json.dumps(parsed, ensure_ascii=True, sort_keys=True)
|
||||||
|
|
||||||
|
|
||||||
class SxCPCategoryPreset:
|
class SxCPCategoryPreset:
|
||||||
@classmethod
|
@classmethod
|
||||||
def INPUT_TYPES(cls):
|
def INPUT_TYPES(cls):
|
||||||
@@ -1304,6 +1364,7 @@ NODE_CLASS_MAPPINGS = {
|
|||||||
"SxCPSeedLocker": SxCPSeedLocker,
|
"SxCPSeedLocker": SxCPSeedLocker,
|
||||||
"SxCPCameraControl": SxCPCameraControl,
|
"SxCPCameraControl": SxCPCameraControl,
|
||||||
"SxCPCameraOrbitControl": SxCPCameraOrbitControl,
|
"SxCPCameraOrbitControl": SxCPCameraOrbitControl,
|
||||||
|
"SxCPQwenCameraTranslator": SxCPQwenCameraTranslator,
|
||||||
"SxCPCategoryPreset": SxCPCategoryPreset,
|
"SxCPCategoryPreset": SxCPCategoryPreset,
|
||||||
"SxCPCastControl": SxCPCastControl,
|
"SxCPCastControl": SxCPCastControl,
|
||||||
"SxCPGenerationProfile": SxCPGenerationProfile,
|
"SxCPGenerationProfile": SxCPGenerationProfile,
|
||||||
@@ -1327,6 +1388,7 @@ NODE_DISPLAY_NAME_MAPPINGS = {
|
|||||||
"SxCPSeedLocker": "SxCP Seed Locker",
|
"SxCPSeedLocker": "SxCP Seed Locker",
|
||||||
"SxCPCameraControl": "SxCP Camera Control",
|
"SxCPCameraControl": "SxCP Camera Control",
|
||||||
"SxCPCameraOrbitControl": "SxCP Camera Orbit Control",
|
"SxCPCameraOrbitControl": "SxCP Camera Orbit Control",
|
||||||
|
"SxCPQwenCameraTranslator": "SxCP Qwen Camera Translator",
|
||||||
"SxCPCategoryPreset": "SxCP Category Preset",
|
"SxCPCategoryPreset": "SxCP Category Preset",
|
||||||
"SxCPCastControl": "SxCP Cast Control",
|
"SxCPCastControl": "SxCP Cast Control",
|
||||||
"SxCPGenerationProfile": "SxCP Generation Profile",
|
"SxCPGenerationProfile": "SxCP Generation Profile",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import math
|
||||||
import random
|
import random
|
||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -1666,6 +1667,135 @@ def build_camera_orbit_config_json(
|
|||||||
return json.dumps(config, ensure_ascii=True, sort_keys=True)
|
return json.dumps(config, ensure_ascii=True, sort_keys=True)
|
||||||
|
|
||||||
|
|
||||||
|
QWEN_CAMERA_DIRECTIONS = {
|
||||||
|
"front-right quarter view": 45,
|
||||||
|
"right side view": 90,
|
||||||
|
"back-right quarter view": 135,
|
||||||
|
"back view": 180,
|
||||||
|
"back-left quarter view": 225,
|
||||||
|
"left side view": 270,
|
||||||
|
"front-left quarter view": 315,
|
||||||
|
"front view": 0,
|
||||||
|
}
|
||||||
|
QWEN_CAMERA_ELEVATIONS = {
|
||||||
|
"low-angle shot": -30,
|
||||||
|
"eye-level shot": 0,
|
||||||
|
"elevated shot": 30,
|
||||||
|
"high-angle shot": 60,
|
||||||
|
}
|
||||||
|
QWEN_CAMERA_ZOOMS = {
|
||||||
|
"wide shot": 0.0,
|
||||||
|
"medium shot": 5.0,
|
||||||
|
"close-up": 8.0,
|
||||||
|
}
|
||||||
|
QWEN_CAMERA_SCENE_CENTER_Y = 0.5
|
||||||
|
|
||||||
|
|
||||||
|
def _qwen_prompt_camera_values(qwen_prompt: Any) -> tuple[int, int, float]:
|
||||||
|
text = _clean_prompt_punctuation(str(qwen_prompt or "").lower().replace(",", " "))
|
||||||
|
horizontal_angle = 0
|
||||||
|
vertical_angle = 0
|
||||||
|
zoom = 5.0
|
||||||
|
for label, value in QWEN_CAMERA_DIRECTIONS.items():
|
||||||
|
if label in text:
|
||||||
|
horizontal_angle = value
|
||||||
|
break
|
||||||
|
for label, value in QWEN_CAMERA_ELEVATIONS.items():
|
||||||
|
if label in text:
|
||||||
|
vertical_angle = value
|
||||||
|
break
|
||||||
|
for label, value in QWEN_CAMERA_ZOOMS.items():
|
||||||
|
if label in text:
|
||||||
|
zoom = value
|
||||||
|
break
|
||||||
|
return horizontal_angle, vertical_angle, zoom
|
||||||
|
|
||||||
|
|
||||||
|
def _camera_info_dict(camera_info: Any) -> dict[str, Any] | None:
|
||||||
|
if not camera_info:
|
||||||
|
return None
|
||||||
|
if isinstance(camera_info, dict):
|
||||||
|
return camera_info
|
||||||
|
if isinstance(camera_info, str):
|
||||||
|
try:
|
||||||
|
raw = json.loads(camera_info)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None
|
||||||
|
return raw if isinstance(raw, dict) else None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _qwen_camera_info_values(camera_info: Any) -> tuple[int, int, float] | None:
|
||||||
|
info = _camera_info_dict(camera_info)
|
||||||
|
if not info:
|
||||||
|
return None
|
||||||
|
position = info.get("position") if isinstance(info.get("position"), dict) else {}
|
||||||
|
target = info.get("target") if isinstance(info.get("target"), dict) else {}
|
||||||
|
try:
|
||||||
|
dx = float(position.get("x", 0.0)) - float(target.get("x", 0.0))
|
||||||
|
dy = float(position.get("y", QWEN_CAMERA_SCENE_CENTER_Y)) - float(
|
||||||
|
target.get("y", QWEN_CAMERA_SCENE_CENTER_Y)
|
||||||
|
)
|
||||||
|
dz = float(position.get("z", 0.0)) - float(target.get("z", 0.0))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
distance = math.sqrt(dx * dx + dy * dy + dz * dz)
|
||||||
|
if distance <= 0:
|
||||||
|
return None
|
||||||
|
horizontal_angle = int(round(math.degrees(math.atan2(dx, dz)))) % 360
|
||||||
|
vertical_angle = int(round(math.degrees(math.asin(max(-1.0, min(1.0, dy / distance))))))
|
||||||
|
zoom = max(0.0, min(10.0, ((2.6 - distance) / 2.0) * 10.0))
|
||||||
|
return horizontal_angle, vertical_angle, round(zoom, 2)
|
||||||
|
|
||||||
|
|
||||||
|
def build_qwen_camera_config_json(
|
||||||
|
qwen_prompt: str = "",
|
||||||
|
camera_info: Any = None,
|
||||||
|
prefer_camera_info: bool = True,
|
||||||
|
camera_mode: str = "standard",
|
||||||
|
subject_focus: str = "auto",
|
||||||
|
lens: str = "auto",
|
||||||
|
orientation: str = "auto",
|
||||||
|
phone_visibility: str = "auto",
|
||||||
|
priority: str = "locked",
|
||||||
|
camera_detail: str = "compact",
|
||||||
|
include_degrees: bool = False,
|
||||||
|
) -> str:
|
||||||
|
info_values = _qwen_camera_info_values(camera_info)
|
||||||
|
if prefer_camera_info and info_values is not None:
|
||||||
|
horizontal_angle, vertical_angle, zoom = info_values
|
||||||
|
source = "qwen_multiangle_camera_info"
|
||||||
|
else:
|
||||||
|
horizontal_angle, vertical_angle, zoom = _qwen_prompt_camera_values(qwen_prompt)
|
||||||
|
source = "qwen_multiangle_prompt"
|
||||||
|
config = json.loads(
|
||||||
|
build_camera_orbit_config_json(
|
||||||
|
enabled=True,
|
||||||
|
camera_mode=camera_mode,
|
||||||
|
horizontal_angle=horizontal_angle,
|
||||||
|
vertical_angle=vertical_angle,
|
||||||
|
zoom=zoom,
|
||||||
|
framing="from_zoom",
|
||||||
|
subject_focus=subject_focus,
|
||||||
|
lens=lens,
|
||||||
|
orientation=orientation,
|
||||||
|
phone_visibility=phone_visibility,
|
||||||
|
priority=priority,
|
||||||
|
camera_detail=camera_detail,
|
||||||
|
include_degrees=include_degrees,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
config["camera_source"] = source
|
||||||
|
config["qwen_prompt"] = str(qwen_prompt or "").strip()
|
||||||
|
if info_values is not None:
|
||||||
|
config["qwen_camera_info_values"] = {
|
||||||
|
"horizontal_angle": info_values[0],
|
||||||
|
"vertical_angle": info_values[1],
|
||||||
|
"zoom": info_values[2],
|
||||||
|
}
|
||||||
|
return json.dumps(config, ensure_ascii=True, sort_keys=True)
|
||||||
|
|
||||||
|
|
||||||
def _choice(value: Any, choices: dict[str, str], default: str) -> str:
|
def _choice(value: Any, choices: dict[str, str], default: str) -> str:
|
||||||
value = str(value or default)
|
value = str(value or default)
|
||||||
return value if value in choices else default
|
return value if value in choices else default
|
||||||
|
|||||||
Reference in New Issue
Block a user