nemo04 commited on
Commit
30e6fa0
·
verified ·
1 Parent(s): 95885f5

Switch GPT client to GLM-5.3-Flash

Browse files
embodied_gen/utils/gpt_clients.py CHANGED
@@ -29,11 +29,12 @@ from typing import Optional
29
 
30
  import openai
31
  import yaml
 
32
  from openai import AzureOpenAI, OpenAI # pip install openai
33
  from PIL import Image
34
  from tenacity import (
35
  retry,
36
- retry_if_not_exception_type,
37
  stop_after_attempt,
38
  stop_after_delay,
39
  wait_random_exponential,
@@ -50,10 +51,29 @@ __all__ = [
50
 
51
  CONFIG_FILE = str(Path(__file__).with_name("gpt_config.yaml"))
52
  DEFAULT_GPT_TIMEOUT = float(os.environ.get("GPT_TIMEOUT", 90))
 
 
 
 
 
 
 
53
  # GPT-5.x counts reasoning tokens against this cap, so it must be high
54
  # enough to leave room for both reasoning and the visible reply.
55
  GPT5_DEFAULT_MAX_COMPLETION_TOKENS = 8192
 
 
 
56
  _CODEX_DEFAULT_REASONING_EFFORT = "medium"
 
 
 
 
 
 
 
 
 
57
  _CODEX_ENV_KEYS = {
58
  "ALL_PROXY",
59
  "CODEX_HOME",
@@ -77,19 +97,50 @@ def _resolve_agent_settings(
77
  if provider_override is not None:
78
  agent_config = {}
79
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  return {
81
- "endpoint": environ.get("ENDPOINT", agent_config.get("endpoint")),
82
- "api_key": environ.get("API_KEY", agent_config.get("api_key")),
83
- "api_version": environ.get(
84
- "API_VERSION", agent_config.get("api_version")
85
- ),
86
- "model_name": environ.get(
87
- "MODEL_NAME", agent_config.get("model_name")
88
- ),
89
- "provider": provider_override or agent_config.get("provider"),
90
  }
91
 
92
 
 
 
 
 
 
 
 
 
 
 
93
  def _codex_subprocess_environment() -> dict[str, str]:
94
  """Return the minimal host environment required by Codex CLI."""
95
  return {
@@ -144,9 +195,12 @@ class GPTclient:
144
  check_connection (bool, optional): Whether to check API connection.
145
  verbose (bool, optional): Enable verbose logging.
146
  timeout (float, optional): Max seconds for a single GPT request.
147
- provider (str, optional): Backend provider. Use ``codex`` to reuse a
148
- local Codex CLI login; otherwise the existing Azure/OpenAI-
149
- compatible API selection is preserved.
 
 
 
150
 
151
  Example:
152
  ```sh
@@ -176,10 +230,13 @@ class GPTclient:
176
  verbose: bool = False,
177
  timeout: float = DEFAULT_GPT_TIMEOUT,
178
  provider: Optional[str] = None,
 
179
  ):
180
  self.provider = (
181
  provider or ("azure" if api_version else "openai")
182
  ).lower()
 
 
183
  self.codex_executable = None
184
  if self.provider == "codex":
185
  self.codex_executable = shutil.which("codex")
@@ -189,7 +246,10 @@ class GPTclient:
189
  "`codex login` before using the Codex provider."
190
  )
191
  self.client = None
192
- elif self.provider == "azure" or api_version is not None:
 
 
 
193
  self.client = AzureOpenAI(
194
  azure_endpoint=endpoint,
195
  api_key=api_key,
@@ -198,16 +258,23 @@ class GPTclient:
198
  max_retries=0,
199
  )
200
  else:
201
- self.client = OpenAI(
202
- base_url=endpoint,
203
- api_key=api_key,
204
- timeout=timeout,
205
- max_retries=0,
206
- )
 
 
 
 
 
207
 
208
  self.endpoint = endpoint
209
  self.model_name = model_name
 
210
  self.timeout = timeout
 
211
  self.image_formats = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif"}
212
  self.verbose = verbose
213
  if check_connection:
@@ -262,6 +329,116 @@ class GPTclient:
262
  ) from exc
263
  return target
264
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
265
  def _query_codex(
266
  self,
267
  text_prompt: str,
@@ -345,10 +522,31 @@ class GPTclient:
345
  name = (model_name or "").lower()
346
  return "gpt-5" in name or "gpt5" in name
347
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
348
  @retry(
349
- retry=retry_if_not_exception_type(openai.BadRequestError),
350
  wait=wait_random_exponential(min=1, max=10),
351
  stop=stop_after_attempt(5) | stop_after_delay(DEFAULT_GPT_TIMEOUT),
 
352
  )
353
  def completion_with_backoff(self, **kwargs):
354
  """Performs a chat completion request with retry/backoff."""
@@ -409,25 +607,22 @@ class GPTclient:
409
  if "openrouter" in self.endpoint:
410
  image_base64 = combine_images_to_grid(image_base64)
411
  for img in image_base64:
412
- if isinstance(img, Image.Image):
413
- buffer = BytesIO()
414
- img.save(buffer, format=img.format or "PNG")
415
- buffer.seek(0)
416
- image_binary = buffer.read()
417
- img = base64.b64encode(image_binary).decode("utf-8")
418
- elif (
419
- len(os.path.splitext(img)) > 1
420
- and os.path.splitext(img)[-1].lower() in self.image_formats
421
- ):
422
- if not os.path.exists(img):
423
- raise FileNotFoundError(f"Image file not found: {img}")
424
- with open(img, "rb") as f:
425
- img = base64.b64encode(f.read()).decode("utf-8")
426
-
427
  content_user.append(
428
  {
429
  "type": "image_url",
430
- "image_url": {"url": f"data:image/png;base64,{img}"},
 
 
431
  }
432
  )
433
 
@@ -444,19 +639,27 @@ class GPTclient:
444
  "model": self.model_name,
445
  }
446
  else:
 
 
 
 
 
447
  payload = {
448
  "messages": [
449
  {"role": "system", "content": system_role},
450
  {"role": "user", "content": content_user},
451
  ],
452
  "temperature": 0.1,
453
- "max_tokens": 500,
454
- "top_p": 0.1,
455
  "frequency_penalty": 0,
456
  "presence_penalty": 0,
457
  "stop": None,
458
  "model": self.model_name,
459
  }
 
 
 
460
 
461
  if params:
462
  params = dict(params)
@@ -480,11 +683,19 @@ class GPTclient:
480
  payload.update(params)
481
 
482
  response = None
 
483
  try:
484
- response = self.completion_with_backoff(**payload)
485
- response = response.choices[0].message.content
 
 
 
 
 
 
 
486
  except Exception as e:
487
- logger.error(f"Error GPTclint {self.endpoint} API call: {e}")
488
  response = None
489
 
490
  if self.verbose:
@@ -549,6 +760,7 @@ GPT_CLIENT = GPTclient(
549
  check_connection=False,
550
  timeout=DEFAULT_GPT_TIMEOUT,
551
  provider=settings["provider"],
 
552
  )
553
 
554
 
 
29
 
30
  import openai
31
  import yaml
32
+ from huggingface_hub import get_token
33
  from openai import AzureOpenAI, OpenAI # pip install openai
34
  from PIL import Image
35
  from tenacity import (
36
  retry,
37
+ retry_if_exception,
38
  stop_after_attempt,
39
  stop_after_delay,
40
  wait_random_exponential,
 
51
 
52
  CONFIG_FILE = str(Path(__file__).with_name("gpt_config.yaml"))
53
  DEFAULT_GPT_TIMEOUT = float(os.environ.get("GPT_TIMEOUT", 90))
54
+ DEFAULT_HF_ENDPOINT = "https://router.huggingface.co/v1"
55
+ DEFAULT_HF_MODEL = "zai-org/GLM-5.3-Flash:baseten"
56
+ HF_MAX_IMAGE_DIMENSION = int(os.environ.get("HF_MAX_IMAGE_DIMENSION", 1024))
57
+ HF_IMAGE_JPEG_QUALITY = int(os.environ.get("HF_IMAGE_JPEG_QUALITY", 90))
58
+ OPENAI_MAX_IMAGE_DIMENSION = int(
59
+ os.environ.get("OPENAI_MAX_IMAGE_DIMENSION", 1024)
60
+ )
61
  # GPT-5.x counts reasoning tokens against this cap, so it must be high
62
  # enough to leave room for both reasoning and the visible reply.
63
  GPT5_DEFAULT_MAX_COMPLETION_TOKENS = 8192
64
+ REASONING_VLM_DEFAULT_MAX_TOKENS = int(
65
+ os.environ.get("REASONING_VLM_MAX_TOKENS", 2048)
66
+ )
67
  _CODEX_DEFAULT_REASONING_EFFORT = "medium"
68
+ _HF_PROVIDERS = {"hf", "huggingface"}
69
+ _OPENAI_COMPATIBLE_PROVIDERS = {"openai", *_HF_PROVIDERS}
70
+ _NON_RETRYABLE_API_ERRORS = (
71
+ openai.AuthenticationError,
72
+ openai.BadRequestError,
73
+ openai.NotFoundError,
74
+ openai.PermissionDeniedError,
75
+ openai.UnprocessableEntityError,
76
+ )
77
  _CODEX_ENV_KEYS = {
78
  "ALL_PROXY",
79
  "CODEX_HOME",
 
97
  if provider_override is not None:
98
  agent_config = {}
99
 
100
+ endpoint = environ.get("ENDPOINT", agent_config.get("endpoint"))
101
+ provider = provider_override or agent_config.get("provider")
102
+ provider_name = (provider or "").lower()
103
+ is_huggingface = provider_name in _HF_PROVIDERS or (
104
+ endpoint is not None and "router.huggingface.co" in endpoint
105
+ )
106
+ if is_huggingface:
107
+ provider = "huggingface"
108
+ endpoint = endpoint or DEFAULT_HF_ENDPOINT
109
+
110
+ api_key = environ.get("API_KEY")
111
+ if api_key is None and is_huggingface:
112
+ api_key = environ.get("HF_TOKEN") or environ.get(
113
+ "HUGGING_FACE_HUB_TOKEN"
114
+ ) or get_token()
115
+ if api_key is None:
116
+ api_key = agent_config.get("api_key")
117
+
118
+ api_version = environ.get("API_VERSION", agent_config.get("api_version"))
119
+ if is_huggingface:
120
+ api_version = None
121
+
122
  return {
123
+ "endpoint": endpoint,
124
+ "api_key": api_key,
125
+ "api_version": api_version,
126
+ "model_name": environ.get("MODEL_NAME")
127
+ or agent_config.get("model_name")
128
+ or (DEFAULT_HF_MODEL if is_huggingface else None),
129
+ "provider": provider,
130
+ "bill_to": environ.get("HF_BILL_TO") if is_huggingface else None,
 
131
  }
132
 
133
 
134
+ def _is_retryable_api_error(error: BaseException) -> bool:
135
+ """Return whether an API failure may succeed when retried."""
136
+ if isinstance(error, _NON_RETRYABLE_API_ERRORS):
137
+ return False
138
+ status_code = getattr(error, "status_code", None)
139
+ if status_code is not None and 400 <= status_code < 500:
140
+ return status_code in {408, 409, 429}
141
+ return True
142
+
143
+
144
  def _codex_subprocess_environment() -> dict[str, str]:
145
  """Return the minimal host environment required by Codex CLI."""
146
  return {
 
195
  check_connection (bool, optional): Whether to check API connection.
196
  verbose (bool, optional): Enable verbose logging.
197
  timeout (float, optional): Max seconds for a single GPT request.
198
+ provider (str, optional): Backend provider. Use ``huggingface`` for
199
+ Hugging Face Inference Providers or ``codex`` to reuse a local
200
+ Codex CLI login; otherwise the existing Azure/OpenAI-compatible
201
+ API selection is preserved.
202
+ bill_to (str, optional): Hugging Face organization charged for routed
203
+ requests. Ignored by other providers.
204
 
205
  Example:
206
  ```sh
 
230
  verbose: bool = False,
231
  timeout: float = DEFAULT_GPT_TIMEOUT,
232
  provider: Optional[str] = None,
233
+ bill_to: Optional[str] = None,
234
  ):
235
  self.provider = (
236
  provider or ("azure" if api_version else "openai")
237
  ).lower()
238
+ if self.provider == "hf":
239
+ self.provider = "huggingface"
240
  self.codex_executable = None
241
  if self.provider == "codex":
242
  self.codex_executable = shutil.which("codex")
 
246
  "`codex login` before using the Codex provider."
247
  )
248
  self.client = None
249
+ elif self.provider == "azure" or (
250
+ self.provider not in _OPENAI_COMPATIBLE_PROVIDERS
251
+ and api_version is not None
252
+ ):
253
  self.client = AzureOpenAI(
254
  azure_endpoint=endpoint,
255
  api_key=api_key,
 
258
  max_retries=0,
259
  )
260
  else:
261
+ client_kwargs = {
262
+ "base_url": endpoint,
263
+ "api_key": api_key,
264
+ "timeout": timeout,
265
+ "max_retries": 0,
266
+ }
267
+ if self.provider == "huggingface" and bill_to:
268
+ client_kwargs["default_headers"] = {
269
+ "X-HF-Bill-To": bill_to
270
+ }
271
+ self.client = OpenAI(**client_kwargs)
272
 
273
  self.endpoint = endpoint
274
  self.model_name = model_name
275
+ self.bill_to = bill_to if self.provider == "huggingface" else None
276
  self.timeout = timeout
277
+ self.last_usage = None
278
  self.image_formats = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif"}
279
  self.verbose = verbose
280
  if check_connection:
 
329
  ) from exc
330
  return target
331
 
332
+ def _prepare_huggingface_image(self, image: str | Image.Image) -> str:
333
+ """Encode one image as a compact data URL for Hugging Face."""
334
+ if isinstance(image, str) and image.startswith("data:"):
335
+ return image
336
+
337
+ if isinstance(image, Image.Image):
338
+ prepared_image = image.copy()
339
+ elif isinstance(image, str):
340
+ source = Path(image).expanduser()
341
+ try:
342
+ source_is_file = source.is_file()
343
+ except OSError:
344
+ source_is_file = False
345
+ if not source_is_file:
346
+ if source.suffix.lower() in self.image_formats:
347
+ raise FileNotFoundError(f"Image file not found: {image}")
348
+ return f"data:image/png;base64,{image}"
349
+ try:
350
+ with Image.open(source) as source_image:
351
+ prepared_image = source_image.copy()
352
+ except OSError as exc:
353
+ raise ValueError(f"Invalid image file: {image}") from exc
354
+ else:
355
+ raise TypeError(
356
+ "Image input must be a path, base64 string, or PIL Image"
357
+ )
358
+
359
+ prepared_image.thumbnail(
360
+ (HF_MAX_IMAGE_DIMENSION, HF_MAX_IMAGE_DIMENSION),
361
+ Image.Resampling.LANCZOS,
362
+ )
363
+ has_alpha = prepared_image.mode in {"LA", "RGBA"} or (
364
+ prepared_image.mode == "P" and "transparency" in prepared_image.info
365
+ )
366
+ buffer = BytesIO()
367
+ if has_alpha:
368
+ prepared_image.convert("RGBA").save(
369
+ buffer,
370
+ format="PNG",
371
+ optimize=True,
372
+ )
373
+ mime_type = "image/png"
374
+ else:
375
+ prepared_image.convert("RGB").save(
376
+ buffer,
377
+ format="JPEG",
378
+ quality=HF_IMAGE_JPEG_QUALITY,
379
+ optimize=True,
380
+ )
381
+ mime_type = "image/jpeg"
382
+ encoded = base64.b64encode(buffer.getvalue()).decode("utf-8")
383
+ return f"data:{mime_type};base64,{encoded}"
384
+
385
+ def _prepare_openai_image(self, image: str | Image.Image) -> str:
386
+ """Encode visible pixels as PNG for OpenAI-compatible APIs."""
387
+ if isinstance(image, Image.Image):
388
+ prepared_image = image.copy()
389
+ elif isinstance(image, str):
390
+ source = Path(image).expanduser()
391
+ try:
392
+ source_is_file = source.is_file()
393
+ except OSError:
394
+ source_is_file = False
395
+ if source_is_file:
396
+ try:
397
+ with Image.open(source) as source_image:
398
+ prepared_image = source_image.copy()
399
+ except OSError as exc:
400
+ raise ValueError(f"Invalid image file: {image}") from exc
401
+ else:
402
+ if source.suffix.lower() in self.image_formats:
403
+ raise FileNotFoundError(f"Image file not found: {image}")
404
+ encoded = image
405
+ if image.startswith("data:"):
406
+ header, separator, encoded = image.partition(",")
407
+ if not separator or ";base64" not in header.lower():
408
+ raise ValueError(
409
+ "Image data URI must contain base64 data"
410
+ )
411
+ try:
412
+ image_data = base64.b64decode(encoded, validate=True)
413
+ with Image.open(BytesIO(image_data)) as decoded_image:
414
+ prepared_image = decoded_image.copy()
415
+ except (OSError, ValueError) as exc:
416
+ raise ValueError(
417
+ "Image input is neither an existing image nor valid "
418
+ "base64"
419
+ ) from exc
420
+ else:
421
+ raise TypeError(
422
+ "Image input must be a path, base64 string, or PIL Image"
423
+ )
424
+
425
+ prepared_image.thumbnail(
426
+ (OPENAI_MAX_IMAGE_DIMENSION, OPENAI_MAX_IMAGE_DIMENSION),
427
+ Image.Resampling.LANCZOS,
428
+ )
429
+ has_alpha = prepared_image.mode in {"LA", "RGBA"} or (
430
+ prepared_image.mode == "P" and "transparency" in prepared_image.info
431
+ )
432
+ if has_alpha:
433
+ foreground = prepared_image.convert("RGBA")
434
+ background = Image.new("RGBA", foreground.size, (0, 0, 0, 255))
435
+ prepared_image = Image.alpha_composite(background, foreground)
436
+
437
+ buffer = BytesIO()
438
+ prepared_image.convert("RGB").save(buffer, format="PNG", optimize=True)
439
+ encoded = base64.b64encode(buffer.getvalue()).decode("utf-8")
440
+ return f"data:image/png;base64,{encoded}"
441
+
442
  def _query_codex(
443
  self,
444
  text_prompt: str,
 
522
  name = (model_name or "").lower()
523
  return "gpt-5" in name or "gpt5" in name
524
 
525
+ @staticmethod
526
+ def _is_reasoning_vlm(model_name: str) -> bool:
527
+ name = (model_name or "").lower()
528
+ return any(
529
+ model in name
530
+ for model in ("glm-4.5v", "glm-5.3-flash", "kimi-k3")
531
+ )
532
+
533
+ @staticmethod
534
+ def _default_top_p(model_name: str) -> float:
535
+ name = (model_name or "").lower()
536
+ return 0.95 if "kimi-k3" in name else 0.1
537
+
538
+ @staticmethod
539
+ def _default_reasoning_effort(model_name: str) -> str | None:
540
+ name = (model_name or "").lower()
541
+ if "glm-5.3-flash" in name:
542
+ return "max"
543
+ return "low" if "kimi-k3" in name else None
544
+
545
  @retry(
546
+ retry=retry_if_exception(_is_retryable_api_error),
547
  wait=wait_random_exponential(min=1, max=10),
548
  stop=stop_after_attempt(5) | stop_after_delay(DEFAULT_GPT_TIMEOUT),
549
+ reraise=True,
550
  )
551
  def completion_with_backoff(self, **kwargs):
552
  """Performs a chat completion request with retry/backoff."""
 
607
  if "openrouter" in self.endpoint:
608
  image_base64 = combine_images_to_grid(image_base64)
609
  for img in image_base64:
610
+ if self.provider == "huggingface":
611
+ content_user.append(
612
+ {
613
+ "type": "image_url",
614
+ "image_url": {
615
+ "url": self._prepare_huggingface_image(img)
616
+ },
617
+ }
618
+ )
619
+ continue
 
 
 
 
 
620
  content_user.append(
621
  {
622
  "type": "image_url",
623
+ "image_url": {
624
+ "url": self._prepare_openai_image(img)
625
+ },
626
  }
627
  )
628
 
 
639
  "model": self.model_name,
640
  }
641
  else:
642
+ max_tokens = (
643
+ REASONING_VLM_DEFAULT_MAX_TOKENS
644
+ if self._is_reasoning_vlm(self.model_name)
645
+ else 500
646
+ )
647
  payload = {
648
  "messages": [
649
  {"role": "system", "content": system_role},
650
  {"role": "user", "content": content_user},
651
  ],
652
  "temperature": 0.1,
653
+ "max_tokens": max_tokens,
654
+ "top_p": self._default_top_p(self.model_name),
655
  "frequency_penalty": 0,
656
  "presence_penalty": 0,
657
  "stop": None,
658
  "model": self.model_name,
659
  }
660
+ reasoning_effort = self._default_reasoning_effort(self.model_name)
661
+ if reasoning_effort is not None:
662
+ payload["reasoning_effort"] = reasoning_effort
663
 
664
  if params:
665
  params = dict(params)
 
683
  payload.update(params)
684
 
685
  response = None
686
+ self.last_usage = None
687
  try:
688
+ completion = self.completion_with_backoff(**payload)
689
+ usage = getattr(completion, "usage", None)
690
+ if usage is not None:
691
+ self.last_usage = (
692
+ usage.model_dump()
693
+ if hasattr(usage, "model_dump")
694
+ else dict(usage)
695
+ )
696
+ response = completion.choices[0].message.content
697
  except Exception as e:
698
+ logger.error(f"Error GPTclient {self.endpoint} API call: {e}")
699
  response = None
700
 
701
  if self.verbose:
 
760
  check_connection=False,
761
  timeout=DEFAULT_GPT_TIMEOUT,
762
  provider=settings["provider"],
763
+ bill_to=settings["bill_to"],
764
  )
765
 
766
 
embodied_gen/utils/gpt_config.yaml CHANGED
@@ -1,27 +1,9 @@
1
  # config.yaml
2
- agent_type: "gpt-5.4" # gpt-4o, gpt-5.4 or gemma-4-31b or codex
3
 
4
- gpt-4o:
5
- endpoint: https://xxx.openai.azure.com
6
- api_key: xxx
7
- api_version: 2025-xx-xx
8
- model_name: yfb-gpt-4o
9
-
10
- gpt-5.4:
11
- endpoint: https://yfb-openai-sweden.openai.azure.com/
12
- api_key: xxx
13
- api_version: 2024-12-01-preview
14
- model_name: gpt-5.4
15
-
16
- gemma-4-31b:
17
- endpoint: https://openrouter.ai/api/v1
18
- api_key: sk-or-v1-xxx
19
- api_version: null
20
- model_name: google/gemma-4-31b-it:free
21
-
22
- codex:
23
- provider: codex
24
- endpoint: null
25
  api_key: null
26
  api_version: null
27
- model_name: null
 
1
  # config.yaml
2
+ agent_type: "glm-5.3-flash"
3
 
4
+ glm-5.3-flash:
5
+ provider: huggingface
6
+ endpoint: https://router.huggingface.co/v1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  api_key: null
8
  api_version: null
9
+ model_name: zai-org/GLM-5.3-Flash:baseten
embodied_gen/validators/quality_checkers.py CHANGED
@@ -155,6 +155,9 @@ class MeshGeoChecker(BaseChecker):
155
  self.prompt = """
156
  You are an expert in evaluating the geometry quality of generated 3D asset.
157
  You will be given rendered views of a generated 3D asset, type {}, with black background.
 
 
 
158
  Your task is to evaluate the quality of the 3D asset generation,
159
  including geometry, structure, and appearance, based on the rendered views.
160
  Criteria:
 
155
  self.prompt = """
156
  You are an expert in evaluating the geometry quality of generated 3D asset.
157
  You will be given rendered views of a generated 3D asset, type {}, with black background.
158
+ The input may be a contact sheet whose panels are different camera views of the same asset.
159
+ Do not treat separate view panels as duplicate object instances. Only report duplicate geometry
160
+ when duplication or overlap appears within an individual view.
161
  Your task is to evaluate the quality of the 3D asset generation,
162
  including geometry, structure, and appearance, based on the rendered views.
163
  Criteria: