@deepblend/dsh-blender-provider-local 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/index.js +1610 -0
- package/package.json +25 -0
- package/python/__pycache__/bootstrap.cpython-314.pyc +0 -0
- package/python/__pycache__/deepblend_capabilities.cpython-313.pyc +0 -0
- package/python/__pycache__/deepblend_frames.cpython-313.pyc +0 -0
- package/python/__pycache__/deepblend_frames.cpython-314.pyc +0 -0
- package/python/__pycache__/deepblend_render.cpython-313.pyc +0 -0
- package/python/__pycache__/deepblend_render.cpython-314.pyc +0 -0
- package/python/__pycache__/deepblend_scene.cpython-313.pyc +0 -0
- package/python/__pycache__/deepblend_scene.cpython-314.pyc +0 -0
- package/python/__pycache__/deepblend_util.cpython-313.pyc +0 -0
- package/python/__pycache__/deepblend_util.cpython-314.pyc +0 -0
- package/python/__pycache__/deepblend_validate.cpython-313.pyc +0 -0
- package/python/__pycache__/deepblend_views.cpython-313.pyc +0 -0
- package/python/bootstrap.py +625 -0
- package/python/deepblend_capabilities.py +633 -0
- package/python/deepblend_frames.py +402 -0
- package/python/deepblend_render.py +299 -0
- package/python/deepblend_scene.py +1537 -0
- package/python/deepblend_util.py +264 -0
- package/python/deepblend_validate.py +358 -0
- package/python/deepblend_views.py +745 -0
|
@@ -0,0 +1,745 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""DeepBlend multi-view preview renderer.
|
|
4
|
+
|
|
5
|
+
Renders a **plan** of views — each one a (camera, frame) pair — out of an
|
|
6
|
+
already-compiled ``.blend`` checkpoint, in a single Blender process, and returns
|
|
7
|
+
one measurement record per view.
|
|
8
|
+
|
|
9
|
+
WHY ONE PROCESS FOR N VIEWS
|
|
10
|
+
---------------------------
|
|
11
|
+
Every Blender launch costs roughly 0.4 s of cold start on top of the render
|
|
12
|
+
itself, and the M2 visual loop renders four views per round for up to five
|
|
13
|
+
rounds. Launching once per view would spend about two minutes on startup alone.
|
|
14
|
+
Rendering them all from one opened checkpoint also guarantees the views really do
|
|
15
|
+
describe ONE scene state: four separate launches could in principle open four
|
|
16
|
+
different files if something changed in between.
|
|
17
|
+
|
|
18
|
+
WHAT IS MEASURED, AND WHY IT IS MEASURED HERE
|
|
19
|
+
---------------------------------------------
|
|
20
|
+
Pixel facts are computed in this process rather than in Node because the decoder
|
|
21
|
+
is already here (``bpy.data.images`` + numpy) and because the alternative is a
|
|
22
|
+
second PNG decoder in a second language — the exact kind of duplicate that drifts.
|
|
23
|
+
The measurements are the *evidence* half of a visual review: the model may judge,
|
|
24
|
+
but it may not invent numbers (decision D30).
|
|
25
|
+
|
|
26
|
+
Tracked objects are measured by **isolation**: with every object except the
|
|
27
|
+
target hidden, the number of non-black pixels in the frame is exactly that
|
|
28
|
+
object's full silhouette area, and the count in the normal render is exactly how
|
|
29
|
+
much of it survives occlusion. That difference is the only occlusion evidence in
|
|
30
|
+
this system that is *definitional* rather than heuristic — a luminance threshold
|
|
31
|
+
cannot tell "the subject is dark" from "the subject is behind something".
|
|
32
|
+
|
|
33
|
+
Runs inside Blender's embedded interpreter.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
import os
|
|
37
|
+
import tempfile
|
|
38
|
+
|
|
39
|
+
import bpy
|
|
40
|
+
import numpy as np
|
|
41
|
+
|
|
42
|
+
from deepblend_render import (
|
|
43
|
+
apply_render_overrides,
|
|
44
|
+
checkpoint_profile,
|
|
45
|
+
find_camera,
|
|
46
|
+
open_checkpoint,
|
|
47
|
+
png_dimensions,
|
|
48
|
+
)
|
|
49
|
+
from deepblend_scene import describe_objects
|
|
50
|
+
from deepblend_util import ActionError, error_text, report_progress
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
#: A pixel counts as "belonging to the subject" in a mask render when it is at
|
|
54
|
+
#: least this bright. The mask render composites a solid white silhouette with no
|
|
55
|
+
#: lighting, so its interior is exactly 1.0 and its background exactly 0.0; the
|
|
56
|
+
#: threshold only has to absorb PNG quantisation and a possible view transform.
|
|
57
|
+
MASK_THRESHOLD = 0.5
|
|
58
|
+
|
|
59
|
+
#: Cap on ray-cast samples per tracked object per view; see `_visible_samples`.
|
|
60
|
+
MAX_RAY_CASTS = 4000
|
|
61
|
+
|
|
62
|
+
#: Histogram bucket count for the luminance distribution. 64 buckets is enough to
|
|
63
|
+
#: see a clipped shadow or a blown highlight and small enough to carry in JSON.
|
|
64
|
+
HISTOGRAM_BUCKETS = 64
|
|
65
|
+
|
|
66
|
+
#: Luminance weights (Rec. 709). The renderer writes display-referred sRGB values,
|
|
67
|
+
#: which is what a viewer — and the model — actually sees.
|
|
68
|
+
_LUMA_WEIGHTS = np.array([0.2126, 0.7152, 0.0722], dtype=np.float32)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def render_views(options, guard):
|
|
72
|
+
"""Render a plan of views and measure each one.
|
|
73
|
+
|
|
74
|
+
``options['views']`` is a JSON document::
|
|
75
|
+
|
|
76
|
+
{
|
|
77
|
+
"checkpoint": "<path.blend>",
|
|
78
|
+
"engine": "cycles", # optional override
|
|
79
|
+
"samples": 16, # optional override
|
|
80
|
+
"width": 640, "height": 360, # optional override
|
|
81
|
+
"track": ["watch-body", ...], # object ids to measure by isolation
|
|
82
|
+
"views": [
|
|
83
|
+
{"id": "main", "role": "active-camera", "cameraId": "camera-main",
|
|
84
|
+
"frame": 22, "output": "/abs/path/main.png"},
|
|
85
|
+
...
|
|
86
|
+
]
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
Returns ``{"views": [...], "checkpoint": ..., "renderConfig": {...},
|
|
90
|
+
"objects": [...]}`` where every view entry carries its artifact facts AND its
|
|
91
|
+
measurements.
|
|
92
|
+
"""
|
|
93
|
+
plan = options.get("views")
|
|
94
|
+
if not isinstance(plan, dict):
|
|
95
|
+
raise ActionError("BLENDER_SCRIPT_ERROR", "render_views requires a view plan document")
|
|
96
|
+
|
|
97
|
+
checkpoint = plan.get("checkpoint")
|
|
98
|
+
if not checkpoint:
|
|
99
|
+
raise ActionError("REVISION_CHECKPOINT_MISSING", "the view plan names no checkpoint .blend")
|
|
100
|
+
|
|
101
|
+
entries = plan.get("views")
|
|
102
|
+
if not isinstance(entries, list) or len(entries) == 0:
|
|
103
|
+
raise ActionError("BLENDER_SCRIPT_ERROR", "the view plan contains no views")
|
|
104
|
+
|
|
105
|
+
tracked = plan.get("track")
|
|
106
|
+
tracked = [str(entry) for entry in tracked] if isinstance(tracked, list) else []
|
|
107
|
+
# Entities that are part of the subject's own body rather than things standing in
|
|
108
|
+
# front of it (see `_visibility`). Authored, never inferred.
|
|
109
|
+
parts = plan.get("parts")
|
|
110
|
+
parts = set(str(entry) for entry in parts) if isinstance(parts, list) else set()
|
|
111
|
+
|
|
112
|
+
report_progress("open_checkpoint", 5)
|
|
113
|
+
scene = open_checkpoint(checkpoint)
|
|
114
|
+
|
|
115
|
+
overrides = apply_render_overrides(
|
|
116
|
+
scene,
|
|
117
|
+
{
|
|
118
|
+
"engine": plan.get("engine"),
|
|
119
|
+
"width": plan.get("width"),
|
|
120
|
+
"height": plan.get("height"),
|
|
121
|
+
"samples": plan.get("samples"),
|
|
122
|
+
},
|
|
123
|
+
guard,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
scene.render.image_settings.file_format = "PNG"
|
|
127
|
+
scene.render.resolution_percentage = 100
|
|
128
|
+
|
|
129
|
+
object_index = _object_index(scene)
|
|
130
|
+
measurements = []
|
|
131
|
+
total = len(entries)
|
|
132
|
+
|
|
133
|
+
# Isolation masks are scratch: they are read into numpy and deleted in the
|
|
134
|
+
# same breath, so they live in a private directory that this action removes
|
|
135
|
+
# before returning. Nothing about them is an artifact.
|
|
136
|
+
scratch = tempfile.mkdtemp(prefix="deepblend-masks-")
|
|
137
|
+
try:
|
|
138
|
+
for position, entry in enumerate(entries):
|
|
139
|
+
base = 8.0 + (position / float(total)) * 84.0
|
|
140
|
+
measurements.append(
|
|
141
|
+
_render_one(scene, entry, tracked, parts, object_index, guard, base, scratch)
|
|
142
|
+
)
|
|
143
|
+
finally:
|
|
144
|
+
_remove_tree(scratch)
|
|
145
|
+
|
|
146
|
+
report_progress("render_views_done", 95)
|
|
147
|
+
return {
|
|
148
|
+
"checkpoint": checkpoint,
|
|
149
|
+
"views": measurements,
|
|
150
|
+
"overrides": overrides,
|
|
151
|
+
"renderConfig": _render_config(scene),
|
|
152
|
+
"objects": describe_objects(),
|
|
153
|
+
"tracked": tracked,
|
|
154
|
+
"parts": sorted(parts),
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _render_one(scene, entry, tracked, parts, object_index, guard, base_percent, scratch):
|
|
159
|
+
"""Render one view, then measure it. Returns the view's report entry."""
|
|
160
|
+
if not isinstance(entry, dict):
|
|
161
|
+
raise ActionError("BLENDER_SCRIPT_ERROR", "every view in the plan must be an object")
|
|
162
|
+
|
|
163
|
+
view_id = str(entry.get("id") or entry.get("cameraId") or "view")
|
|
164
|
+
output = entry.get("output")
|
|
165
|
+
if not output:
|
|
166
|
+
raise ActionError("BLENDER_SCRIPT_ERROR", 'view "%s" names no output path' % (view_id,))
|
|
167
|
+
# Resolve against this process's working directory. Blender resolves a RELATIVE
|
|
168
|
+
# `render.filepath` against the .blend file's directory (or its own default),
|
|
169
|
+
# NOT against the process cwd — so a plan that names bare file names, which is
|
|
170
|
+
# what keeps it from escaping the invocation directory, is made absolute here.
|
|
171
|
+
output = os.path.abspath(output)
|
|
172
|
+
|
|
173
|
+
report_progress("select_camera", base_percent, {"view": view_id})
|
|
174
|
+
camera, _ = find_camera(scene, entry.get("cameraId"))
|
|
175
|
+
scene.camera = camera
|
|
176
|
+
|
|
177
|
+
frame = entry.get("frame")
|
|
178
|
+
if frame is None:
|
|
179
|
+
frame = int((scene.frame_start + scene.frame_end) / 2)
|
|
180
|
+
frame = int(frame)
|
|
181
|
+
scene.frame_set(frame)
|
|
182
|
+
|
|
183
|
+
os.makedirs(os.path.dirname(os.path.abspath(output)), exist_ok=True)
|
|
184
|
+
scene.render.filepath = output
|
|
185
|
+
|
|
186
|
+
report_progress("render", base_percent + 2.0, {"view": view_id, "frame": frame})
|
|
187
|
+
try:
|
|
188
|
+
bpy.ops.render.render(write_still=True)
|
|
189
|
+
except Exception as exc:
|
|
190
|
+
raise ActionError(
|
|
191
|
+
"BLENDER_ENGINE_UNAVAILABLE",
|
|
192
|
+
'rendering view "%s" with engine %s failed: %s'
|
|
193
|
+
% (view_id, scene.render.engine, error_text(exc)),
|
|
194
|
+
{"view": view_id, "engine": scene.render.engine, "frame": frame},
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
if not os.path.isfile(output):
|
|
198
|
+
raise ActionError(
|
|
199
|
+
"RENDER_NO_OUTPUT",
|
|
200
|
+
'engine %s reported success but wrote no file for view "%s" at "%s"'
|
|
201
|
+
% (scene.render.engine, view_id, output),
|
|
202
|
+
{"view": view_id, "output": output},
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
report_progress("measure", base_percent + 5.0, {"view": view_id})
|
|
206
|
+
width, height = png_dimensions(output) or (
|
|
207
|
+
int(scene.render.resolution_x),
|
|
208
|
+
int(scene.render.resolution_y),
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
entry_report = {
|
|
212
|
+
"viewId": view_id,
|
|
213
|
+
"role": entry.get("role"),
|
|
214
|
+
"outputPath": output,
|
|
215
|
+
"bytes": os.path.getsize(output),
|
|
216
|
+
"width": width,
|
|
217
|
+
"height": height,
|
|
218
|
+
"frame": frame,
|
|
219
|
+
"cameraId": camera.get("deepblend_id") or camera.name,
|
|
220
|
+
"cameraName": camera.name,
|
|
221
|
+
"lens": round(float(camera.data.lens), 6),
|
|
222
|
+
"engine": scene.render.engine,
|
|
223
|
+
"metrics": measure_view(scene, output, tracked, parts, object_index, guard, view_id, scratch),
|
|
224
|
+
}
|
|
225
|
+
return entry_report
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def measure_view(scene, image_path, tracked, parts, object_index, guard, view_id, scratch):
|
|
229
|
+
"""Compute the deterministic measurements for one rendered frame.
|
|
230
|
+
|
|
231
|
+
Split out from ``_render_one`` because it is the part that must be *testable
|
|
232
|
+
and stable*: the scorer consumes exactly this document, and a change here is a
|
|
233
|
+
change in what the product calls a problem.
|
|
234
|
+
"""
|
|
235
|
+
rgb = _load_rgb(image_path)
|
|
236
|
+
if rgb is None:
|
|
237
|
+
guard.warn(
|
|
238
|
+
"RENDER_NO_OUTPUT",
|
|
239
|
+
'view "%s" was rendered but its pixels could not be read back for measurement' % (view_id,),
|
|
240
|
+
{"output": image_path},
|
|
241
|
+
)
|
|
242
|
+
return None
|
|
243
|
+
|
|
244
|
+
luminance = rgb @ _LUMA_WEIGHTS
|
|
245
|
+
height, width = luminance.shape
|
|
246
|
+
|
|
247
|
+
clipped_dark = float(np.count_nonzero(luminance <= 0.02)) / float(luminance.size)
|
|
248
|
+
clipped_bright = float(np.count_nonzero(luminance >= 0.98)) / float(luminance.size)
|
|
249
|
+
histogram, _ = np.histogram(luminance, bins=HISTOGRAM_BUCKETS, range=(0.0, 1.0))
|
|
250
|
+
|
|
251
|
+
metrics = {
|
|
252
|
+
"width": int(width),
|
|
253
|
+
"height": int(height),
|
|
254
|
+
"luminance": {
|
|
255
|
+
"mean": round(float(luminance.mean()), 6),
|
|
256
|
+
"median": round(float(np.median(luminance)), 6),
|
|
257
|
+
"p05": round(float(np.percentile(luminance, 5)), 6),
|
|
258
|
+
"p95": round(float(np.percentile(luminance, 95)), 6),
|
|
259
|
+
"stdDev": round(float(luminance.std()), 6),
|
|
260
|
+
"clippedDarkFraction": round(clipped_dark, 6),
|
|
261
|
+
"clippedBrightFraction": round(clipped_bright, 6),
|
|
262
|
+
"histogram": [int(value) for value in histogram],
|
|
263
|
+
},
|
|
264
|
+
"objects": [],
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
for object_id in tracked:
|
|
268
|
+
metrics["objects"].append(
|
|
269
|
+
_measure_object(scene, luminance, object_id, tracked, parts, object_index, guard, view_id, scratch)
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
return metrics
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _measure_object(scene, luminance, object_id, tracked, parts, index, guard, view_id, scratch_dir):
|
|
276
|
+
"""Measure one tracked object's visibility in this view.
|
|
277
|
+
|
|
278
|
+
``silhouettePixels`` is how large the object would be with nothing in the way;
|
|
279
|
+
``visiblePixels`` is how much of that it actually owns in the composed frame.
|
|
280
|
+
The ratio between them is the occlusion evidence.
|
|
281
|
+
"""
|
|
282
|
+
entry = {
|
|
283
|
+
"id": object_id,
|
|
284
|
+
"viewId": view_id,
|
|
285
|
+
"visiblePixels": 0,
|
|
286
|
+
"silhouettePixels": 0,
|
|
287
|
+
"visibleFraction": 0.0,
|
|
288
|
+
"occludedFraction": 0.0,
|
|
289
|
+
"frameCoverage": 0.0,
|
|
290
|
+
"silhouetteCoverage": 0.0,
|
|
291
|
+
"bbox": None,
|
|
292
|
+
"centroid": None,
|
|
293
|
+
"inFrame": False,
|
|
294
|
+
"occludedBy": [],
|
|
295
|
+
# Whether the SCENE declared this entity part of the subject's own body. The
|
|
296
|
+
# measurement carries the flag so the scorer stays a pure function of the
|
|
297
|
+
# measurements and never has to re-read the spec to interpret one.
|
|
298
|
+
"part": object_id in parts,
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
target = index.get(object_id)
|
|
302
|
+
if target is None:
|
|
303
|
+
# A tracked object that is not in the scene is a real finding, not an
|
|
304
|
+
# error: the scene may have been legitimately edited away from it, and the
|
|
305
|
+
# caller must be able to see that rather than receive a silent zero.
|
|
306
|
+
guard.note(
|
|
307
|
+
"SCENE_COMPILER_DECISION",
|
|
308
|
+
'view "%s" tracks object "%s", which is not present in this scene' % (view_id, object_id),
|
|
309
|
+
{"object": object_id, "view": view_id},
|
|
310
|
+
)
|
|
311
|
+
return entry
|
|
312
|
+
|
|
313
|
+
# An object the scene HIDES is not "occluded": it is not there. Reporting it as
|
|
314
|
+
# fully occluded would send the reviewer looking for a blocker that does not
|
|
315
|
+
# exist, and the fix for a hidden object is `entity.visibility.set`, which is a
|
|
316
|
+
# different finding with a different code.
|
|
317
|
+
if target.hide_render:
|
|
318
|
+
guard.note(
|
|
319
|
+
"SCENE_COMPILER_DECISION",
|
|
320
|
+
'view "%s" tracks object "%s", which this scene hides; it is measured as absent, not as occluded'
|
|
321
|
+
% (view_id, object_id),
|
|
322
|
+
{"object": object_id, "view": view_id},
|
|
323
|
+
)
|
|
324
|
+
return entry
|
|
325
|
+
|
|
326
|
+
mask = _isolation_mask(scene, target, scratch_dir)
|
|
327
|
+
if mask is None:
|
|
328
|
+
return entry
|
|
329
|
+
|
|
330
|
+
mask = mask[: luminance.shape[0], : luminance.shape[1]]
|
|
331
|
+
silhouette = int(np.count_nonzero(mask))
|
|
332
|
+
entry["silhouettePixels"] = silhouette
|
|
333
|
+
entry["silhouetteCoverage"] = round(float(silhouette) / float(luminance.size), 6)
|
|
334
|
+
|
|
335
|
+
visible_pixels, agrees, behind = _visibility(scene, target, mask, tracked, index, parts)
|
|
336
|
+
entry["visiblePixels"] = visible_pixels
|
|
337
|
+
|
|
338
|
+
if silhouette > 0:
|
|
339
|
+
entry["frameCoverage"] = round(float(visible_pixels) / float(luminance.size), 6)
|
|
340
|
+
rows = np.nonzero(mask.any(axis=1))[0]
|
|
341
|
+
columns = np.nonzero(mask.any(axis=0))[0]
|
|
342
|
+
if rows.size > 0 and columns.size > 0:
|
|
343
|
+
height, width = mask.shape
|
|
344
|
+
entry["bbox"] = [
|
|
345
|
+
round(float(columns[0]) / width, 6),
|
|
346
|
+
round(float(rows[0]) / height, 6),
|
|
347
|
+
round(float(columns[-1] + 1) / width, 6),
|
|
348
|
+
round(float(rows[-1] + 1) / height, 6),
|
|
349
|
+
]
|
|
350
|
+
coordinates = np.argwhere(mask)
|
|
351
|
+
entry["centroid"] = [
|
|
352
|
+
round(float(coordinates[:, 1].mean()) / width, 6),
|
|
353
|
+
round(float(coordinates[:, 0].mean()) / height, 6),
|
|
354
|
+
]
|
|
355
|
+
entry["inFrame"] = bool(
|
|
356
|
+
columns[0] > 0 and rows[0] > 0 and columns[-1] < width - 1 and rows[-1] < height - 1
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
entry["visibleFraction"] = round(min(1.0, agrees), 6)
|
|
360
|
+
entry["occludedFraction"] = round(max(0.0, 1.0 - min(1.0, agrees)), 6)
|
|
361
|
+
entry["occludedBy"] = behind
|
|
362
|
+
|
|
363
|
+
# The exposure of THIS object's own pixels, measured over its silhouette.
|
|
364
|
+
#
|
|
365
|
+
# The frame's mean luminance cannot judge a product shot: a brief that asks for a
|
|
366
|
+
# black background — the ordinary way to photograph a product — drives the frame
|
|
367
|
+
# mean down, and a scorer reading only the frame reports a correctly lit subject as
|
|
368
|
+
# `FRAME_UNDEREXPOSED`. That is not cosmetic: the repair loop accepts only patches
|
|
369
|
+
# that RAISE the score, so it would have brightened the background to "fix" a scene
|
|
370
|
+
# that was already right.
|
|
371
|
+
#
|
|
372
|
+
# The silhouette is used rather than the visible region because the visible region
|
|
373
|
+
# is counted by ray casting and has no mask; for a subject standing in front of its
|
|
374
|
+
# own background the two are nearly the same set of pixels.
|
|
375
|
+
region = luminance[mask > 0]
|
|
376
|
+
if region.size > 0:
|
|
377
|
+
entry["luminance"] = {
|
|
378
|
+
"mean": round(float(region.mean()), 6),
|
|
379
|
+
"median": round(float(np.median(region)), 6),
|
|
380
|
+
"p05": round(float(np.percentile(region, 5)), 6),
|
|
381
|
+
"p95": round(float(np.percentile(region, 95)), 6),
|
|
382
|
+
"clippedDarkFraction": round(float(np.count_nonzero(region <= 0.02)) / float(region.size), 6),
|
|
383
|
+
"clippedBrightFraction": round(float(np.count_nonzero(region >= 0.98)) / float(region.size), 6),
|
|
384
|
+
"pixels": int(region.size),
|
|
385
|
+
}
|
|
386
|
+
return entry
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _visibility(scene, target, mask, tracked, index, parts):
|
|
390
|
+
"""How much of the target's silhouette is actually VISIBLE in the frame, and the ratio.
|
|
391
|
+
|
|
392
|
+
WHY A RAY CAST AND NOT A MASK COMPARISON
|
|
393
|
+
----------------------------------------
|
|
394
|
+
The first two attempts compared the subject's isolation mask against another
|
|
395
|
+
render. Both were wrong, and the second one was wrong in an instructive way:
|
|
396
|
+
|
|
397
|
+
* "everything except the subject" made the floor — a plane covering the entire
|
|
398
|
+
frame and lying under everything — occupy every subject pixel, so every subject
|
|
399
|
+
measured 100% occluded. A backdrop is not an occluder.
|
|
400
|
+
* "the other tracked actors" only fixed that because the floor happened not to be
|
|
401
|
+
tracked. A coffee table tracked alongside a sofa measures as occluded by the
|
|
402
|
+
sofa whenever their screen rectangles overlap, even though the table is in
|
|
403
|
+
front. Screen overlap is not occlusion, and no amount of choosing the right
|
|
404
|
+
object set fixes a comparison that ignores distance.
|
|
405
|
+
|
|
406
|
+
A ray from the camera through one pixel answers the actual question: what is the
|
|
407
|
+
nearest thing along this line of sight? If the subject is nearest, that pixel
|
|
408
|
+
shows the subject; if a tracked actor is nearer, it is occluded. That is the
|
|
409
|
+
definition, it costs no render, and it is exactly reproducible.
|
|
410
|
+
|
|
411
|
+
Rays are cast on a stride grid inside the silhouette and scaled back up. The
|
|
412
|
+
stride is chosen so the total cast count stays bounded regardless of subject size,
|
|
413
|
+
because this runs once per tracked object per view and a full-resolution cast over
|
|
414
|
+
a subject filling a third of a 640x360 frame would be tens of thousands of BVH
|
|
415
|
+
queries in Python.
|
|
416
|
+
|
|
417
|
+
WHOSE BODY IS IT
|
|
418
|
+
---------------
|
|
419
|
+
"The nearest thing is not the target, so the target is occluded" is still too
|
|
420
|
+
simple, and a real watch showed why: a dial standing proud of its case covers 56%
|
|
421
|
+
of the case's silhouette, so every view reported the case as occluded by its own
|
|
422
|
+
dial. Arithmetically right, semantically backwards — and it scored a CORRECT watch
|
|
423
|
+
10 points below a watch whose dial was buried invisibly inside the case.
|
|
424
|
+
|
|
425
|
+
`parts` is the author's answer to "which entities are this same object". A ray that
|
|
426
|
+
lands on the target or on one of its parts has still reached the object; only
|
|
427
|
+
something outside the body can be in its way. Which entities those are is authored
|
|
428
|
+
intent (`subject-part`), for the same reason `environment` is: no measurement can
|
|
429
|
+
tell a product's own face from a wall.
|
|
430
|
+
|
|
431
|
+
@returns {(number, number, list)} visible pixel count, the visible fraction, and
|
|
432
|
+
who the rest of the silhouette is behind — most-covered first, because that is
|
|
433
|
+
the name a fix needs.
|
|
434
|
+
"""
|
|
435
|
+
silhouette = int(np.count_nonzero(mask))
|
|
436
|
+
if silhouette == 0:
|
|
437
|
+
return 0, 1.0, []
|
|
438
|
+
|
|
439
|
+
actors = [
|
|
440
|
+
candidate for candidate in (_tracked_objects(target, tracked, index))
|
|
441
|
+
if candidate is not target and not candidate.hide_render
|
|
442
|
+
]
|
|
443
|
+
if not actors:
|
|
444
|
+
return silhouette, 1.0, []
|
|
445
|
+
|
|
446
|
+
sample = _visible_samples(scene, target, mask, actors, parts)
|
|
447
|
+
if sample is None:
|
|
448
|
+
# No camera or no usable grid: report full visibility. That is the optimistic
|
|
449
|
+
# direction and it is deliberate — an unavailable comparison must not
|
|
450
|
+
# manufacture an occlusion finding that the loop would then "fix".
|
|
451
|
+
return silhouette, 1.0, []
|
|
452
|
+
|
|
453
|
+
sampled, visible, occluders = sample
|
|
454
|
+
if sampled == 0:
|
|
455
|
+
return silhouette, 1.0, []
|
|
456
|
+
fraction = float(visible) / float(sampled)
|
|
457
|
+
behind = [
|
|
458
|
+
{
|
|
459
|
+
"entityId": entity_id,
|
|
460
|
+
"samples": count,
|
|
461
|
+
"fraction": round(count / float(sampled), 6),
|
|
462
|
+
}
|
|
463
|
+
for entity_id, count in sorted(occluders.items(), key=lambda pair: (-pair[1], pair[0]))
|
|
464
|
+
]
|
|
465
|
+
return int(round(fraction * silhouette)), fraction, behind
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _tracked_objects(target, tracked, index):
|
|
469
|
+
"""The tracked objects, by object, never by name."""
|
|
470
|
+
wanted = set(tracked)
|
|
471
|
+
wanted.add(target.get("deepblend_id"))
|
|
472
|
+
found = []
|
|
473
|
+
for object_id, obj in index.items():
|
|
474
|
+
if object_id in wanted:
|
|
475
|
+
found.append(obj)
|
|
476
|
+
return found
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _visible_samples(scene, target, mask, actors, parts):
|
|
480
|
+
"""Cast one ray per sampled silhouette pixel and count how many reach the object.
|
|
481
|
+
|
|
482
|
+
@returns {(number, number, dict)|None} (sampled, visible, occluder counts) or null
|
|
483
|
+
when there is no usable camera or the silhouette is empty.
|
|
484
|
+
"""
|
|
485
|
+
camera = scene.camera
|
|
486
|
+
if camera is None or camera.type != "CAMERA":
|
|
487
|
+
return None
|
|
488
|
+
|
|
489
|
+
rows, columns = np.nonzero(mask)
|
|
490
|
+
total = int(rows.size)
|
|
491
|
+
if total == 0:
|
|
492
|
+
return None
|
|
493
|
+
|
|
494
|
+
# Bound the number of Python-side BVH queries. Small subjects are exact; large
|
|
495
|
+
# ones are sampled on a grid, which is a fine trade for a fraction whose
|
|
496
|
+
# thresholds are 15 percentage points apart.
|
|
497
|
+
stride = 1 if total <= MAX_RAY_CASTS else int(np.ceil(np.sqrt(total / float(MAX_RAY_CASTS))))
|
|
498
|
+
|
|
499
|
+
height, width = mask.shape
|
|
500
|
+
depsgraph = bpy.context.evaluated_depsgraph_get()
|
|
501
|
+
origin = camera.matrix_world.translation.copy()
|
|
502
|
+
frame = camera.data.view_frame(scene=scene)
|
|
503
|
+
# `view_frame` returns the frame corners in camera space, ordered
|
|
504
|
+
# (top-right, bottom-right, bottom-left, top-left). Linear interpolation between
|
|
505
|
+
# opposite corners is exact under perspective projection because the projection is
|
|
506
|
+
# linear in normalized device coordinates.
|
|
507
|
+
top_right, bottom_right, bottom_left, top_left = frame
|
|
508
|
+
|
|
509
|
+
# The target plus the parts it is made of: one body, for occlusion purposes.
|
|
510
|
+
body = {id(target)}
|
|
511
|
+
for candidate in actors:
|
|
512
|
+
if candidate.get("deepblend_id") in parts:
|
|
513
|
+
body.add(id(candidate))
|
|
514
|
+
|
|
515
|
+
sampled = 0
|
|
516
|
+
visible = 0
|
|
517
|
+
occluders = {}
|
|
518
|
+
for index in range(0, total, stride):
|
|
519
|
+
row = int(rows[index])
|
|
520
|
+
column = int(columns[index])
|
|
521
|
+
u = (column + 0.5) / float(width)
|
|
522
|
+
v = (row + 0.5) / float(height)
|
|
523
|
+
# Screen rows run downward; the camera frame's vertical axis runs upward.
|
|
524
|
+
local = _bilinear(top_left, bottom_left, bottom_right, top_right, u, 1.0 - v)
|
|
525
|
+
direction = (camera.matrix_world @ local) - origin
|
|
526
|
+
if direction.length < 1e-9:
|
|
527
|
+
continue
|
|
528
|
+
sampled += 1
|
|
529
|
+
# Blender 5.x returns SIX values here (hit, location, normal, face index,
|
|
530
|
+
# object, matrix); 4.x returned five. Unpacking with a length check keeps both
|
|
531
|
+
# working, and the hit object is the only field this measurement uses.
|
|
532
|
+
cast = scene.ray_cast(depsgraph, origin, direction.normalized(), distance=direction.length * 1.5)
|
|
533
|
+
hit = bool(cast[0])
|
|
534
|
+
hit_object = cast[4] if len(cast) >= 6 else cast[3]
|
|
535
|
+
if hit and id(hit_object) in body:
|
|
536
|
+
visible += 1
|
|
537
|
+
elif not hit:
|
|
538
|
+
# Nothing along the line of sight: the pixel is background, which for a
|
|
539
|
+
# silhouette pixel cannot happen unless the mask and the geometry disagree
|
|
540
|
+
# (a moving object, a modifier). Counting it as visible keeps the reading
|
|
541
|
+
# optimistic rather than inventing an occluder from a mismatch.
|
|
542
|
+
visible += 1
|
|
543
|
+
else:
|
|
544
|
+
# Something outside the body is in the way. Naming it is what turns the
|
|
545
|
+
# finding from "the subject is 44% hidden" into "the subject is 44% hidden
|
|
546
|
+
# by the partition screen" — which is the sentence a fix can act on.
|
|
547
|
+
name = hit_object.get("deepblend_id") or hit_object.name
|
|
548
|
+
occluders[str(name)] = occluders.get(str(name), 0) + 1
|
|
549
|
+
|
|
550
|
+
return sampled, visible, occluders
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def _bilinear(top_left, bottom_left, bottom_right, top_right, u, v):
|
|
554
|
+
"""Interpolate a point inside the camera's view frame, corner order given."""
|
|
555
|
+
top = top_left.lerp(top_right, u)
|
|
556
|
+
bottom = bottom_left.lerp(bottom_right, u)
|
|
557
|
+
return top.lerp(bottom, v)
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _object_index(scene):
|
|
561
|
+
"""Map every object's ``deepblend_id`` to the OBJECT, not just its name."""
|
|
562
|
+
mapping = {}
|
|
563
|
+
for obj in scene.objects:
|
|
564
|
+
object_id = obj.get("deepblend_id")
|
|
565
|
+
if object_id:
|
|
566
|
+
mapping[str(object_id)] = obj
|
|
567
|
+
return mapping
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def _isolation_mask(scene, target, scratch):
|
|
571
|
+
"""Render ``target`` alone and return its silhouette as a boolean array."""
|
|
572
|
+
values = _isolation_render_of(scene, [target], scratch, "solo")
|
|
573
|
+
if values is None:
|
|
574
|
+
return None
|
|
575
|
+
return values > MASK_THRESHOLD
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def _isolation_render_of(scene, targets, scratch_dir, label):
|
|
579
|
+
"""Render exactly ``targets`` as a flat white silhouette, and read it back.
|
|
580
|
+
|
|
581
|
+
Returns a float silhouette array, or ``None`` when the render could not be read
|
|
582
|
+
back. Every object that is ALREADY hidden by the scene stays hidden, and every
|
|
583
|
+
other object is hidden for the duration and restored afterwards — so a hidden
|
|
584
|
+
object can never be reported as an occluder.
|
|
585
|
+
|
|
586
|
+
Workbench renders a flat unlit silhouette, which is exactly the mask needed and
|
|
587
|
+
far cheaper than re-running Cycles for it. Three settings make it exact rather
|
|
588
|
+
than approximate: a transparent film (so the alpha channel carries the silhouette
|
|
589
|
+
even for a white object against a white background), the ``Standard`` view
|
|
590
|
+
transform (so a fully lit surface is not re-graded), and flat single-colour
|
|
591
|
+
shading with anti-aliasing off (so every pixel is decisively in or out).
|
|
592
|
+
|
|
593
|
+
The mask goes through a scratch PNG rather than the in-memory ``Render Result``.
|
|
594
|
+
M1 already learned that Blender's render result is not readable under
|
|
595
|
+
``--background`` (it reports size 0 and ``has_data=False``); a file that was
|
|
596
|
+
actually written is both readable and the thing being described.
|
|
597
|
+
"""
|
|
598
|
+
wanted = set(id(obj) for obj in targets)
|
|
599
|
+
if not wanted:
|
|
600
|
+
return None
|
|
601
|
+
|
|
602
|
+
hidden = []
|
|
603
|
+
previous = {
|
|
604
|
+
"engine": scene.render.engine,
|
|
605
|
+
"view_transform": scene.view_settings.view_transform,
|
|
606
|
+
"film_transparent": scene.render.film_transparent,
|
|
607
|
+
"use_nodes": scene.use_nodes,
|
|
608
|
+
"filepath": scene.render.filepath,
|
|
609
|
+
"color_mode": scene.render.image_settings.color_mode,
|
|
610
|
+
}
|
|
611
|
+
scratch = os.path.join(scratch_dir, "mask-%s.png" % (label,))
|
|
612
|
+
|
|
613
|
+
for obj in scene.objects:
|
|
614
|
+
if obj.type in ("CAMERA", "LIGHT", "EMPTY"):
|
|
615
|
+
continue
|
|
616
|
+
if id(obj) not in wanted and not obj.hide_render:
|
|
617
|
+
obj.hide_render = True
|
|
618
|
+
hidden.append(obj)
|
|
619
|
+
|
|
620
|
+
try:
|
|
621
|
+
scene.render.engine = "BLENDER_WORKBENCH"
|
|
622
|
+
scene.display.shading.light = "FLAT"
|
|
623
|
+
scene.display.shading.color_type = "SINGLE"
|
|
624
|
+
scene.display.shading.single_color = (1.0, 1.0, 1.0)
|
|
625
|
+
scene.display.shading.show_specular_highlight = False
|
|
626
|
+
scene.display.shading.show_shadows = False
|
|
627
|
+
scene.display.shading.show_cavity = False
|
|
628
|
+
scene.display.render_aa = "OFF"
|
|
629
|
+
scene.render.film_transparent = True
|
|
630
|
+
scene.render.image_settings.color_mode = "RGBA"
|
|
631
|
+
scene.view_settings.view_transform = "Standard"
|
|
632
|
+
scene.use_nodes = False
|
|
633
|
+
scene.render.filepath = scratch
|
|
634
|
+
|
|
635
|
+
os.makedirs(os.path.dirname(scratch), exist_ok=True)
|
|
636
|
+
try:
|
|
637
|
+
bpy.ops.render.render(write_still=True)
|
|
638
|
+
except Exception as exc:
|
|
639
|
+
raise ActionError(
|
|
640
|
+
"BLENDER_ENGINE_UNAVAILABLE",
|
|
641
|
+
"the isolation render failed: %s" % (error_text(exc),),
|
|
642
|
+
)
|
|
643
|
+
|
|
644
|
+
return _load_mask(scratch)
|
|
645
|
+
finally:
|
|
646
|
+
for obj in hidden:
|
|
647
|
+
obj.hide_render = False
|
|
648
|
+
scene.render.engine = previous["engine"]
|
|
649
|
+
scene.view_settings.view_transform = previous["view_transform"]
|
|
650
|
+
scene.render.film_transparent = previous["film_transparent"]
|
|
651
|
+
scene.use_nodes = previous["use_nodes"]
|
|
652
|
+
scene.render.filepath = previous["filepath"]
|
|
653
|
+
scene.render.image_settings.color_mode = previous["color_mode"]
|
|
654
|
+
try:
|
|
655
|
+
if os.path.isfile(scratch):
|
|
656
|
+
os.unlink(scratch)
|
|
657
|
+
except Exception:
|
|
658
|
+
pass
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def _load_mask(path):
|
|
663
|
+
"""Read a silhouette mask PNG as a 0.0/1.0 float array in image order."""
|
|
664
|
+
image = None
|
|
665
|
+
try:
|
|
666
|
+
image = bpy.data.images.load(path, check_existing=False)
|
|
667
|
+
width, height = image.size
|
|
668
|
+
if width == 0 or height == 0:
|
|
669
|
+
return None
|
|
670
|
+
buffer = np.empty(width * height * 4, dtype=np.float32)
|
|
671
|
+
image.pixels.foreach_get(buffer)
|
|
672
|
+
rgba = buffer.reshape(height, width, 4)
|
|
673
|
+
# Alpha is the silhouette when the film is transparent. The RGB fallback
|
|
674
|
+
# covers a build that composites the film onto black instead.
|
|
675
|
+
alpha = rgba[:, :, 3]
|
|
676
|
+
if float(alpha.max()) > MASK_THRESHOLD:
|
|
677
|
+
silhouette = alpha > MASK_THRESHOLD
|
|
678
|
+
else:
|
|
679
|
+
silhouette = rgba[:, :, :3].max(axis=2) > MASK_THRESHOLD
|
|
680
|
+
values = np.zeros_like(alpha)
|
|
681
|
+
values[silhouette] = 1.0
|
|
682
|
+
return np.ascontiguousarray(np.flipud(values))
|
|
683
|
+
except Exception:
|
|
684
|
+
return None
|
|
685
|
+
finally:
|
|
686
|
+
if image is not None:
|
|
687
|
+
try:
|
|
688
|
+
bpy.data.images.remove(image)
|
|
689
|
+
except Exception:
|
|
690
|
+
pass
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def _load_rgb(path):
|
|
694
|
+
"""Load a rendered PNG as a float RGB array in row-major image order."""
|
|
695
|
+
image = None
|
|
696
|
+
try:
|
|
697
|
+
image = bpy.data.images.load(path, check_existing=False)
|
|
698
|
+
width, height = image.size
|
|
699
|
+
if width == 0 or height == 0:
|
|
700
|
+
return None
|
|
701
|
+
buffer = np.empty(width * height * 4, dtype=np.float32)
|
|
702
|
+
image.pixels.foreach_get(buffer)
|
|
703
|
+
rgba = buffer.reshape(height, width, 4)
|
|
704
|
+
# Blender stores images bottom-up; the measurements below do not care, but
|
|
705
|
+
# the bbox and centroid do, so flip once here and let every consumer work
|
|
706
|
+
# in ordinary top-down image coordinates.
|
|
707
|
+
return np.ascontiguousarray(np.flipud(rgba[:, :, :3]))
|
|
708
|
+
except Exception:
|
|
709
|
+
return None
|
|
710
|
+
finally:
|
|
711
|
+
if image is not None:
|
|
712
|
+
try:
|
|
713
|
+
bpy.data.images.remove(image)
|
|
714
|
+
except Exception:
|
|
715
|
+
pass
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def _render_config(scene):
|
|
719
|
+
"""The render settings in force for this plan, for the manifest."""
|
|
720
|
+
samples = None
|
|
721
|
+
try:
|
|
722
|
+
if scene.render.engine == "CYCLES":
|
|
723
|
+
samples = int(scene.cycles.samples)
|
|
724
|
+
except Exception:
|
|
725
|
+
samples = None
|
|
726
|
+
return {
|
|
727
|
+
"engine": scene.render.engine,
|
|
728
|
+
"resolution": [int(scene.render.resolution_x), int(scene.render.resolution_y)],
|
|
729
|
+
"samples": samples,
|
|
730
|
+
"viewTransform": scene.view_settings.view_transform,
|
|
731
|
+
"frameStart": int(scene.frame_start),
|
|
732
|
+
"frameEnd": int(scene.frame_end),
|
|
733
|
+
"fps": int(scene.render.fps),
|
|
734
|
+
"checkpointProfile": checkpoint_profile(scene),
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def _remove_tree(path):
|
|
739
|
+
"""Delete a scratch directory tree, ignoring anything that resists."""
|
|
740
|
+
import shutil
|
|
741
|
+
|
|
742
|
+
try:
|
|
743
|
+
shutil.rmtree(path, ignore_errors=True)
|
|
744
|
+
except Exception:
|
|
745
|
+
pass
|