fullseye

The Studio-Screen / 3-D-Display Wing — exhibit captions (English)

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

Spinning a CT — surface and grain, at the same angle

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

One z slice at a time

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

Registering point clouds — from initial offset to convergence

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

The colour of normals — the first picture to look at when debugging 3-D

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

Moving the viewpoint in a light field — shooting with 49 cameras

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

Lifting a depth map into 3-D

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

Mapping defects back onto CAD faces, and counting the faces never seen

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

A 3-D processing domain — crop it, process it, paste it back

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)

Walking inside 3-D data with the F key (the real Studio screen)

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

Spinning with the orbit camera — opening a volume straight in the 3-D viewer

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

Opening the new families' op help inside Studio

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

Write it, run it with F5, watch the result arrive

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

From 900-plus ops to the one you want

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

Building a pipeline — a type mismatch shows up in Problems

Building a pipeline — a type mismatch shows up in Problemsgaussian → 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


Appendix: the “visual anomalies” found while building these exhibits — and what happened next

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.

Fixed (5)

1. The GIF writer folded runs of identical frames into one

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.

2. A volume opened in the 3-D viewer lay on its side

It was like thisstudio.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.

3. Two pixel-centre conventions met, and cost half a pixel

It was like thisrender3d.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 thisrender3d, 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)

4. cadmap quietly accepted a closed mesh wound inward

It 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

5. Stage numbers mixed 0-based and 1-based inside one Problems line

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 thismessage 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'

Still open (2)

6. 110 of the 155 generated help pages cannot be reached from the screen

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.

7. The normalisation in vol_mip is not documented in ops.py itself

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

Intended behaviour (1)

Reproducibility (everything generated twice, and measured)

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.