@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.
@@ -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