Generated by tools/gen_wingstudio_gallery.py (re-running rebuilds every exhibit).
Every Studio screenshot is a widget.grab() (offscreen) of the real UI assembled by studio.build_window() — there are no mock-ups.
The 3-D exhibits are drawn with fullseye ops and numpy compositing alone (no matplotlib; Pillow only for text). Every number is measured.
This file is delivery copy: transcribe it into the article markdown by hand (the articles themselves were deliberately left untouched). The Japanese version is wingstudio.ja.md.

↑ Spinning a CT — surface and grain, at the same angle — The bundled skeleton CT (20×97×28 voxels) meshed at the iso-level mean+std = 0.5108 into 9,710 triangles / 4,866 vertices, shown beside the boundary shell of that same threshold — 2,759 shell voxels — spun at the same yaw and the same elevation. Surface on the left, grains on the right. The same shape turning the same way is the best evidence there is that no axis got swapped (36 frames). Ops / features: marching_cubes, phong_shade, vol_boundary, render_points_frame.
wingstudio_volume_turntable.gif — 36 frames / 12 fps / 996×431 px / 1.12 MB / SHA-256 1cb0def25c830444

↑ One z slice at a time — The same CT, one slice at a time from z = 0 to 19 (all 20 frames; the bar shows the current position). On the right is the MIP over every z. The single slice on the left carries a per-frame measurement of the bone fraction and the min / max / mean, so an off-by-one — a missing or a duplicated end slice — cannot hide here. The magnification is nearest-neighbour ×6 (no interpolation — the coarseness of the pixels is itself information). Ops / features: vol_mip, apply_cmap, nearest-neighbour integer upscaling.
wingstudio_zslices.gif — 20 frames / 5 fps / 896×726 px / 1.30 MB / SHA-256 1241579b9480c167

↑ Registering point clouds — from initial offset to convergence — Real data (the Itokawa surface, 3,000 points) displaced by a known rigid transform of 22 degrees plus a translation of 42.451, with isotropic noise σ = 1.2160; trimmed ICP then runs one iteration at a time, 48 times. The raw mean point-to-point distance before any correspondence is 74.763, the first iteration gives 22.770 and the last 1.754 (13.0× better) — it settles almost exactly on the σ that was injected. If the curve bottoms out but the orange has not landed on the blue, you have converged without aligning — a failure the numbers alone will not show you, and the picture will. Ops / features: registration.icp (trimmed), render_points_frame, imagedraw.draw_polyline.
wingstudio_registration.gif — 48 frames / 6 fps / 972×500 px / 0.52 MB / SHA-256 995ef59ea259ded0
↑ The colour of normals — the first picture to look at when debugging 3-D — itokawa_f0049152.stl (JAXA はやぶさ Gaskell 形状モデル) (49,152 triangles / 24,578 vertices, surface area 0.399) shot from the front and from 180 degrees behind, with the shading placed beside the world normals taken straight to RGB. A world normal is “colour = direction”, so a surface that joins smoothly joins smoothly in colour too; a salt-and-pepper mottle is the sign of broken winding. Measured: 48,639 / 49,152 = 98.96 % of the faces point outward (the remaining 1 % is what an “outward from the centroid?” test misses on a non-convex asteroid). Coverage is 38,540 px from the front and 39,686 px from the back. Ops / features: render_mesh, phong_shade, world normals as RGB.
wingstudio_normals.png — 1840×600 px / 339 kB / SHA-256 155b586afb9f5615

↑ Moving the viewpoint in a light field — shooting with 49 cameras — A synthetic light field of 7×7 = 49 views × 128×128 pixels; the clip walks once around the rim of the aperture (all 24 frames). Nearer things move further — the difference against the centre view *is the picture of what lies in front. The measured maximum disparity is 21.33 px, and the slope of the lines in the EPI (row y = 64) corresponds to it. The refocused variance is 0.00682 at slope = 0 and 0.01487 at slope = 3. Ops / features: lf_synthesize, lf_subaperture, lf_epi, lf_refocus, lf_stats.*
wingstudio_lightfield.gif — 24 frames / 8 fps / 864×484 px / 2.33 MB / SHA-256 bcca4f45d63d9d65

↑ Lifting a depth map into 3-D — itokawa_f0049152.stl rendered to a 200×200 px depth image, of which the 9,715 valid pixels (24.3 %) — and only those — are back-projected into a solid. Depth runs 0.7363 to 0.8827. What matters here is the pixel-centre convention: render3d, camera.depth_to_points and cadmap all put pixel centres on integer indices, so projecting the back-projected points again leaves a residual of 1.31e-14 px rms — rounding error. Add 0.5 by mistake and the whole cloud shifts 0.00229 world units, every point to the same side. Ops / features: render_mesh, camera.backproject, render_points_frame.
wingstudio_depth3d.gif — 30 frames / 10 fps / 812×620 px / 0.68 MB / SHA-256 6ed4d91ac7009986

↑ Mapping defects back onto CAD faces, and counting the faces never seen — A stepped part built from an SDF (1,400 triangles, surface area 8856.6) imaged by a 240×240 px inspection camera, in four panels: ① what the camera sees ② pixel → CAD face ID ③ the inverse mapping of the 4 defect labels drawn on the image ④ faces seen (green) versus faces never seen (red). 15,980 pixels hit (27.7 %). 48.3 % of the area faces the camera, but the tower hides its own base, so only 46.8 % was actually seen (608 / 1,400 = 43.4 % by face count). 26,000 surface points agree: 41.3 % visible / 58.7 % occluded. Defects #3 and #4 fall outside the CAD (0 hits) and stay at 0 real area — the point being that they do not quietly disappear. Ops / features: cad_pixel_to_surface, cad_defect_to_cad, cad_visible_faces, cad_surface_to_pixel.
wingstudio_cadmap.gif — 24 frames / 10 fps / 1200×518 px / 0.50 MB / SHA-256 eda5aa159d5dd0c4

↑ A 3-D processing domain — crop it, process it, paste it back — Cropping y ∈ [20, 56) with margin 2 out of the 20×97×28 CT gives 20×40×28 (offset (z,y,x) = (0, 18, 0)). The gradient is computed inside that box alone and pasted back into the original coordinate frame; the four stages are shown in 3-D (on the right the original whole is overlaid in grey). The round trip measures max 0 outside the box (exactly zero) / a maximum difference of 0 against the original inside it (bit-identical). A one-voxel slip on the paste-back is invisible in a 2-D table, and obvious the moment you overlay and spin. Ops / features: vol_crop_domain, vol_gradient_magnitude, vol_uncrop, vol_boundary.
wingstudio_crop3d.gif — 36 frames / 8 fps / 976×491 px / 0.46 MB / SHA-256 3902cbec3f013592

↑ Walking inside 3-D data with the F key (the real Studio screen) — The real Fullseye Studio (1280×800 px, offscreen) with Itokawa’s actual shape model open (vertices 24,578 / triangles 49,152, 73,730 splat points), driven by genuine QKeyEvents: F, then W to walk forward, drag to look around, +/- for the field of view, A to strafe left, R for the entrance, F back to the orbit camera — 24 frames. The projection is perspective, so what is near grows as you approach, and changing the field of view changes the sense of depth itself. One tap = radius/50 = 0.00592 of a step (default FOV 70 degrees, adjustable 40–100). The thin band along the bottom is this GIF’s progress bar, not part of the UI. Ops / features: the first-person mode of Studio’s 3-D viewer (render_points_frame_fp), viewer3d_project_persp.
wingstudio_studio_walk.gif — 24 frames / 4 fps / 1280×800 px / 2.93 MB / SHA-256 bec27bc1ab57984b

↑ Spinning with the orbit camera — opening a volume straight in the 3-D viewer — The bundled skeleton CT (20×97×28) through exactly the path Studio takes when it opens a “volume file”. Otsu’s threshold 0.5389 picks the foreground, and only its boundary shell is dropped into 2,733 physical-coordinate points (decimation 1/1) for display. The rotation is not synthesised: it is 30 real left-drags (one = yaw +12 degrees), ending at yaw 35 degrees. Ops / features: volume_to_shell_points (Otsu → boundary shell), the orbit camera of Studio’s 3-D viewer.
wingstudio_studio_turntable.gif — 30 frames / 10 fps / 1280×800 px / 0.92 MB / SHA-256 02c1ff44094868fe

↑ Opening the new families’ op help inside Studio — Light field → FMCW range-Doppler → quaternion monogenic → photon counting (SPAD) → acoustic beamforming → interferometry (angular-spectrum propagation) → 3-D ICP and principal curvatures: 8 pages actually opened and scrolled from top to bottom, 24 frames. The help text is generated from docs/ops/**/*.md into real files (2D 907 / 3D 310). The per-family directories hold 351 pages in all, of which only the 53 reachable through tb_* typed ops can be opened from Studio; the other 298 still cannot be reached from the screen (interferometry: 0 of 9). Ops / features: Studio’s help dialog (op_help_html / op_help_html_3d), HTML generated by tools/opdocs.py.
wingstudio_studio_help.gif — 24 frames / 3 fps / 1000×720 px / 0.53 MB / SHA-256 49fc9ab4fffe092c

↑ Write it, run it with F5, watch the result arrive — 18 lines typed into the tabbed editor, run with F5, and the output console read down to the end — 24 frames (a 1060×740 px dialog). The run is a real child process, not a mock, and the status reads “PASS ✓ (exit 0)”. The last of the 6 output lines are foreground fraction = 0.2995 / objects = 21 / area min/median/max = 1118 / 1494 / 3084 — the result of segmenting the coins. Ops / features: Studio’s Python editor (tabs + F5), fullseye.apply, fullseye.segment_objects.
wingstudio_studio_editor.gif — 24 frames / 6 fps / 1060×740 px / 0.36 MB / SHA-256 ce1154f194aeb759

↑ From 900-plus ops to the one you want — Typing “watershed” one character at a time into the search box narrows a list of 917 ops down to 4 (measured: (empty):917 → w:80 → wa:11 → wat:4 → wate:4 → water:4 → waters:4 → watersh:4 → watershe:4 → watershed:4). Selecting one puts its in_sort → out_sort signature in the bottom right — the types are visible, so what can be attached next is answered on the spot. A final search for “cad” returns 0. Ops / features: Studio’s operator search (across name / HALCON alias / category / docstring).
wingstudio_studio_opsearch.gif — 17 frames / 4 fps / 1280×800 px / 0.54 MB / SHA-256 7c1af28cc7a99e86

↑ Building a pipeline — a type mismatch shows up in Problems — gaussian → otsu → opening_circle → sk_clear_border is added to the coins sample one stage at a time; at ⑤ circularity_xld (which takes a contour) is added on purpose, and it cannot accept a region. Problems then says “stage 4 (sk_clear_border) outputs ‘region’ but circularity_xld expects ‘contour’” — Fullseye does not fall over after you connect things, it tells you about the type mismatch the moment you connect them. Removing it at ⑥ returns to “no problems” (all 24 frames). Ops / features: Studio’s Program panel (HDevelop-style) + Problems, engine.diagnose_stages.
wingstudio_studio_pipeline.gif — 24 frames / 4 fps / 1280×800 px / 0.54 MB / SHA-256 e395b77263a3e87a
These exhibits were built on the assumption that a visualisation is also a debugging tool. Every number below is measured. Of the eight items reported, five have been fixed in the library, two are still open, and one turned out to be intended behaviour. The fixed ones are kept in a “it was like this → it was fixed like this” form, because deleting them would erase the reason the code looks the way it does now.
It was like this — the GIF path of video.write_video (Pillow) merges a frame
that is pixel-identical to its predecessor, so lining up the same grab to create a
pause meant writing 18 frames and reading back 6. Nothing catches that unless
you read the file back and compare the count.
It was fixed like this — GIFs are now written by video._write_gif_all_frames,
which drives Pillow directly and stores duplicates one by one (the cost is file
size). Same reproduction, measured:
seq = [base] * 6 + [other] * 6 + [base] * 6 # 18 frames, three identical runs
video.write_video(path, seq, fps=6)
# measured: wrote 18 frames -> read back 18
save_gif in this script still reads every GIF back and checks the count — there is
no reason to drop a check that costs nothing.
It was like this — studio.volume_to_shell_points returned points in (z, y, x)
order while every consumer (render_points_frame / viewer3d_project) treats the
third component as world up, so the slice axis lay across the screen. The default
viridis height ramp coloured by the x index for the same reason.
It was fixed like this — the function is now the boundary between voxel order and
viewer world, and returns world (x, y, z). spacing ((sz, sy, sx)) is reversed
together with the indices.
v = np.zeros((40, 8, 8)); v[:, 3:5, 3:5] = 1.0 # a bar extending along z
P, C, info = studio.volume_to_shell_points(v)
P.max(0) - P.min(0) # measured [1.0, 1.0, 39.0] — the third (up) axis is the long one
info["axis_order"] # measured "xyz" — an assertable marker of the convention
studio.volume_to_shell_points(v, spacing=(2.0, 1.0, 1.0)) # measured [1.0, 1.0, 78.0]
The “orbit camera” exhibit goes through exactly this path, so its orientation is fixed too.
It was like this — render3d.render_mesh shot its rays through “index + 0.5”
while camera.depth_to_points back-projected from the integer index, so connecting
them naively moved the whole cloud by half a pixel — and moved every point to the
same side, which reads as a bias rather than as noise.
It was fixed like this — render3d, camera and cadmap now share one
convention, integer indices (the principal point too, at (w - 1) * 0.5), so
back-projection and re-projection close. Measured on this exhibit:
| Quantity | Measured |
|---|---|
| residual of back-projection → re-projection | 1.31e-14 px (rounding error) |
| shift if 0.5 is added by mistake | 0.00229 world units (half a pixel, fx = 241.42) |
cadmap quietly accepted a closed mesh wound inwardIt was like this — with cull_backfaces=True (the default) the faces that should
have occluded were culled instead, rays went straight through, and the visible
fraction came out at 0.857. That is above the 0.517 of “area facing the camera”,
which is physically impossible — occlusion can only ever remove visibility. That
inequality is what gave the bug away.
It was fixed like this — winding is checked in exactly one place, and a mesh that
is closed yet has a negative signed volume is repaired and reported through
winding_fixed. cad_visible_faces refuses it by default, and strict=True makes
all three raise. Measured on the stepped part (1,400 faces, signed volume ±37290.4):
| Call | Inward mesh | Outward mesh |
|---|---|---|
visible fraction from cad_surface_to_pixel |
0.4129 (winding_fixed=True) |
0.4129 (winding_fixed=False) |
cad_surface_to_pixel(strict=True) |
ValueError |
0.4129 |
cad_visible_faces (default) |
ValueError |
608 faces |
The caller still has to be careful, though. V[:, ::-1] for (z,y,x) -> (x,y,z)
is not an axis swap but a mirror (determinant -1), so flipping the coordinates
alone flips the winding of every triangle. voxel_mesh_to_world in this script
flips the face winding at the same time to cancel it out.
Vz, F = render3d.marching_cubes(vol, 0.0) # 35,746 interior voxels
signed_volume(Vz, F) # measured +37294.7
signed_volume(Vz[:, ::-1], F) # measured -37294.7 <- now wound inward
signed_volume(Vz[:, ::-1], F[:, ::-1]) # measured +37294.7 <- cancelled out
It was like this — the message from engine.diagnose_stages was 0-based while
the heading of Studio’s Problems list was 1-based, so a single line carried two
numbering systems and sent the reader to the wrong stage.
! stage 5 (circularity_xld): stage 3 (sk_clear_border) outputs 'region' but ...
(sk_clear_border is the 4th stage, both in the Program panel and in the
Problems heading.)
It was fixed like this — message is prose for a human and is uniformly
1-based; the machine-readable index / prev_index (0-based, usable directly to
select a row) and prev_op are carried separately. Exhibit ⑤ now measures:
! stage 5 (circularity_xld): stage 4 (sk_clear_border) outputs 'region' but circularity_xld expects 'contour'
studio_assets/op_help/<family>/ holds 155 HTML pages generated by
tools/opdocs.py, but Studio’s help search only looks up 2-D and 3-D op names, so
only the 45 registered as tb_* typed ops can be opened (this regeneration measures
the same breakdown).
| Family | Generated | Reachable via tb_* |
Unreachable |
|---|---|---|---|
| acoustics | 19 | 3 | 16 |
| interferometry | 9 | 0 | 9 |
| lightfield | 17 | 8 | 9 |
| math | 26 | 6 | 20 |
| motionmag | 9 | 2 | 7 |
| optics | 18 | 1 | 17 |
| photon | 17 | 6 | 11 |
| quat | 19 | 12 | 7 |
| rangedoppler | 8 | 4 | 4 |
| specular | 13 | 3 | 10 |
| total | 155 | 45 | 110 |
On top of that, the 45 that do open have an empty “runnable examples” section, and their “same category” row lists unrelated ops because the typed ops all share one category — both visible in the exhibit itself.
vol_mip is not documented in ops.py itselfops.RT["vol_mip"] returns the projection rescaled into [0, 1] for display, so
using it as the denominator of a cumulative-MIP reach ratio pushes the result
above 100 %. Measured on the bundled skeleton CT (20×97×28, raw values up to 1.2264):
| Denominator | Reach of the complete cumulative MIP |
|---|---|
ops.RT["vol_mip"](vol, 0.0, 0.0) |
122.64 % |
vol.max(axis=0) (the raw projection) |
100.00 % |
The module docstrings of volops.py and volio.py now carry the note, but neither
the body of _vol_mip nor the registry table in ops.py says anything, so a reader
who only opens ops.py will not see it. The op itself is not wrong — normalising is
what a display projection should do — this is a missing note about which one to
use when.
11 of the 14 exhibits match down to the SHA-256; three do not:
| Exhibit | What moves | Measured |
|---|---|---|
studio_editor |
the temporary file name scratch_<pid>.py Studio runs, printed to the output console |
one 8×27 px region of one frame, up to 191 levels |
studio_pipeline |
the per-stage wall-clock ms shown in the Pipeline panel | 444 px inside the list box |
studio_opsearch |
paint-timing jitter (no localised text difference) | 13,317 of 17.41 M pixels, at most 29 levels |
The remaining differences are GIF palette re-quantisation (median 1–2 levels): the
picture itself is identical. studio_pipeline lost its bit-reproducibility in this
pass because the Problems panel was brought to the front, which also brings the
measured milliseconds into shot — showing the point of the exhibit (that one
type-mismatch line) wins over reproducing bytes.