A usability + visual-design overhaul of studio.py, done from a product-design
perspective. This documents what changed, why (the design principles behind
each choice), and how to keep improving it. Everything below is verifiable:
the logic is Qt-free and unit-tested (tests/test_studio.py), and the window is
built headlessly in the suite.
The palette is taken from the Fullseye icon (assets/fullseye.ico) so the app and
its mark read as one product — a deliberate move for brand cohesion:
| role | token | value | used for |
|---|---|---|---|
| ground | NAVY_0 |
#14161d |
window background |
| surface | NAVY_1 |
#1b1e28 |
section cards (QGroupBox) |
| input | NAVY_2 |
#232734 |
fields, buttons, list rows |
| hairline | LINE |
#2c313f |
borders, separators |
| text / muted | TEXT/MUTED |
#e2e5ec / #8b91a0 |
primary / secondary text |
| primary accent | TEAL |
#17b8a6 |
interaction, selection, focus, “Run all” |
| secondary accent | AMBER |
#f5a524 |
section titles, knob handles, brand title |
| on-accent ink | INK |
#0c1116 |
text on teal/amber fills |
Principles applied:
:hover, :pressed,
:focus, and :disabled styling (the old theme had only hover/pressed). Focus
rings (teal border) make the app keyboard-navigable and visible.Segoe UI/Yu Gothic UI/Meiryo) for chrome;
monospace only where values are read (inspector, hover read-out, hints).HDevelop users work by keyboard. Every action is now a QAction with a shortcut,
grouped into File / Edit / View / Run / Help menus (and mirrored on the
toolbar and buttons — same handler, three entry points).
| action | shortcut | action | shortcut |
|---|---|---|---|
| Open image | Ctrl+O |
Remove stage | Del |
| Synthetic demo | Ctrl+D |
Move stage up / down | Ctrl+↑ / Ctrl+↓ |
| Save result | Ctrl+S |
Clear pipeline | Ctrl+Shift+⌫ |
| Open / Save pipeline | Ctrl+Shift+O/S |
Zoom in / out | Ctrl+= / Ctrl+- |
| Export | Ctrl+E |
Fit / 1:1 | Ctrl+0 / Ctrl+1 |
| Quit | Ctrl+Q |
3-D surface | Ctrl+3 |
| Reset / Step / Run all | Home / Ctrl+→ / Ctrl+Enter |
Principle — recognition over recall + Fitts’s law: the highest-frequency actions are also large toolbar targets; everything is discoverable in a menu without hunting through panels. Help ▸ Keyboard shortcuts (F1) shows the whole table in-app, so the bindings are self-documenting.
Transient results (frame B loaded, perception errors, “pipeline cleared”) now go
to a status bar via win._flash(msg) instead of overwriting the pixel read-out.
The read-out (x,y,value on hover) has a permanent home there too. Principle —
visibility of system status; one consistent place for ephemeral messages.
The three panels are now grouped into labelled cards — Operators, Pipeline, Selected stage · knobs, Export & I/O, Image, Display & perception (v14), Analysis — with amber uppercase titles. Principle — Gestalt grouping + progressive disclosure: structure is read at a glance instead of a flat stack of unlabelled widgets.
op_tooltip).op_detail): the knobs card names the selected op,
its in → out sort, category and HALCON alias — so you know what you are
tuning, not just “a” and “b”.The window carries the Fullseye icon (title bar / taskbar); the toolbar shows the
mark + wordmark; an About dialog (Help menu) presents the icon, version and a
one-line description. A desktop shortcut (Fullseye Studio.lnk, launched via
pyw.exe so no console window appears) rounds out the install-level polish.
| before | after | |
|---|---|---|
| chrome | a single coral header label | menu bar + branded toolbar + status bar |
| keyboard | none | 20 actions with shortcuts |
| grouping | flat stacks of labels + widgets | 7 titled section cards |
| feedback | a label buried in the right panel | status bar + flash() |
| discoverability | op label name [in→out] only |
tooltips + selected-stage detail |
| palette | one coral accent | brand teal + amber system, full state styling |
| identity | none | window/taskbar icon, toolbar mark, About, desktop shortcut |
The video/perception code went through a 6-agent adversarial review (findings verified against the real code, then fixed). The Studio redesign was then given a second-opinion pass by an external model (Codex, read-only), per the project’s “verify with a second AI” rule — it caught two real behaviour bugs this pass had introduced/left, both now fixed and regression-tested:
remove() now calls on_stage_selected() to resync.view_raw state now makes Reset show result_upto(-1) (and Step
advances out of it).Lesson (again): an independent reviewer catches state-consistency bugs that the author’s own tests, written to the author’s mental model, miss.
Ranked by usability payoff; each is a self-contained, testable increment:
QListWidget.InternalMove; a drop permutes model.stages via each row’s stored
model index. Up/Down buttons and Ctrl+↑/↓ still work.knob_doc to the Op dataclass and showing it under the sliders.PipelineModel.stage_preview(i) hands the list a small
(48×36) uint8 picture made from the stage cache, never from a recompute; a stage
never computed shows “未計算 / not computed”, one computed with older knobs / another
image / another bypass shows “再計算が必要 / recompute needed” (no stale picture).
Region, contour, table, scalar and series results get a picture or a kind symbol; only
the small arrays are kept (well under 1 MB for a 2048×2048, 5-stage pipeline).QSettings), and remember window
geometry + splitter sizes between sessions.ImageView.set_message) instead of a blank view.Ctrl+P)palette_filter is a
Qt-free, unit-tested ranker: prefix > word-start > substring).Onboarding: a first-run overlay pointing at the operator browser → pipeline → knobs → result loop.
PipelineModel
keeps one cached result per stage, keyed by the stage prefix (op, a, b of every stage
up to it) and the base-image version, so a knob drag recomputes only that stage and
the ones after it; step_states / step_times walk the stages once (O(n), not
O(n²)). The stage list shows ⟳ calc / ↺ cache per row (cache_status(),
stage_marks()). Still open: a non-blocking apply for slow ops (item 5), and a
cache that spans several base images.ran (no
exception), valid (True / False / None = not judged) and reasons, kept apart.
A plain array is judged only when clearly bad (non-finite values, empty, or all
pixels equal); a result table maps its explicit fields (converged, valid,
guf_valid, ok, success, status, n_iter against max_iter). Rows show
⚠ (distinct from ✕ errors); the status bar says “Run: done / Result: needs
review (reason)” or “validity not judged”. Studio reports; it does not repair the
operator’s numerics. Still open: a per-op validity declaration in the registry
so ops that return plain arrays can say what “sound” means for them.Project keeps its input reference (done, first part, Studio slice 1) —
the saved JSON gains an optional input (path as given, sha256 of the file, shape,
dtype); opening reloads a matching file silently, warns on a changed file, reports
a missing one in Problems and tries the same file name next to the .json. Still
open: embedding small inputs in the project, and a multi-image project.
_stage_delta_summary: regions n → m, image
mean / σ / min / max, table rows × cols, contour count). The two things this item
left open are done in Studio slice 3 (item 14): A/B compare and synchronised zoom.Temporary bypass of a stage (done, Studio slice 2) — PipelineModel.set_bypass
/ toggle_bypass, Ctrl+B and the stage context menu. A bypassed stage passes its input
through, is greyed and struck through, is part of the cache key, is saved as a 4th
stage element true only when on (older files are byte-identical), appears as
# bypassed: op in the export and the Program text, and its undo entry is labelled
“bypass”. The Program text writes # bypassed: op (a, b) and the parser reads it back
(Apply round-trips). Still open: bypass has no per-run / per-image scope.
knob_candidates, PipelineModel.run_variants): under the
knob rows, “Try nearby values” shows the selected stage’s result at 3 or 5 values around
the current one (spec step or 1/10 of the range, geometric for a log spec, neighbouring
choices for an int / choice knob, never outside the range). Only that stage runs, once per
candidate, from the cached output of the stage before; only small pixmaps are kept; one
display range for all (apply_display(..., vmin, vmax), shared_display_range); an
optional Shift+drag region of interest of the main view limits what is drawn; choosing a
picture changes nothing and “Adopt this value” goes through the normal knob path (one
undo entry). Pin A / compare B (PinnedSnapshot, PipelineModel.pin, the A / B
dock): a frozen copy of the stage list, input reference, final output (read-only array)
and display settings; side by side / swipe / blink / difference (absolute, signed,
ratio); a “what changed” list (diff_pipelines); restore A (one undo entry) / adopt B /
keep both; at most two pins; saved under pinned WITHOUT arrays and recomputed on load
(a pin that cannot be rebuilt is kept and reported in Problems). Synchronised views
(ViewSyncGroup): the Stage in → out panes and the A / B panes (optionally the main
view) zoom, pan and show a crosshair together. Still open: the strip shows one knob at
a time (a two-knob grid is not built); a pin keeps the stages and the picture but not
the input image itself; A / B always compares the FINAL output (no “A at stage 3 versus B
at stage 5”); the swipe boundary is vertical only.table
inputs, offers only actual CSV headers for the gauge R&R roles, and stores JSON mappings
under the op plus a signature of sorted headers and their count. Unequal repeats are refused
before calling the op with the groups named. No input is repaired, modified or renamed.
Still open: declarative column-role/check metadata for table ops other than gauge R&R.Studio の UX 案のうち ①(目的検索)と ⑦(直せるエラー)を、Qt を使わない論理層から画面まで配線した。
studio_search.search_grouped(query, current_value, current_sort, k, lang) — fullseye.opsearch.search_ops
の順位のまま、結果を applicable(今の入力の型で適用できる: 宣言どおり、または opassist.accepted_sorts
の実測が通す)/ needs_preprocessing(2 手以内の前処理 bridge で届く)/ other に分ける。
各行は正式名・表示名(要約の 1 行目)・入力→出力の型・点数・当たった欄(why)を持つ。今の型が分からなければ
全部 other で、並びは search_ops と 1 対 1。実測は上位 k 件だけ・op ごとに覚える。
current_sort_of(value) は値から台帳の型名を推し、studio.inspect_result の kind と矛盾しないことを門で照合している。
0 件のときは search_zero_results_help が近い語・近い op 名・カテゴリ・型を返し、空の一覧で終わらない。studio_errors.explain_failure(exc, op_name=, stage_index=, inputs=) — 失敗を 原因 / 場所 / 入力の事実 / 次の行動 に
分ける。認識する種類は 大きさの不一致・型の取り違え・非有限・空・つまみの範囲外・次元違い・依存の欠落・未知の op・
汎用(これも場所と生の文面は埋める)。行動の種類は reselect_input / show_inputs_side_by_side / open_log /
edit_knob / insert_stage の 5 つだけで、自動の修復(大きさを合わせて再実行など)は種類として存在しない。
format_failure(info, lang) が Problems 欄の複数行(原因 / 場所 / 次にできること + 入力の表)にする。検索欄は250 ms待って現在値を渡し、「適用できる / 前処理が必要 / その他」の折り畳み群を表示する。 各候補は正式名、入出力型、理由、必要な前処理を示し、段を増やさず一度だけ試すこともできる。0件なら近い語を 再検索できる。実行失敗はProblems欄で原因・場所・次の行動に分け、入力の選び直し、前後比較、詳細ログ、つまみ編集、 候補段の検索へ移れる。入力の大きさや型を黙って変更する処理はない。残るバックログは上の15、16と、複数入力の割当、 重い検索・実行のワーカースレッド化である。
Method for each: keep the logic in a Qt-free helper (like PipelineModel /
PerceptionModel / op_detail), unit-test the helper, and let the widget be a
thin wiring layer — the pattern that makes this file testable headlessly.
Run ▸ Verify exported Python executes the generated run(input) in an isolated
Python process (sys.executable -I). Arrays cross the process boundary only as
.npy/.npz; Studio never imports the generated program into the GUI process.
Every stage is compared in order for shape, dtype, infinities, NaN handling and
numeric tolerance. The result reports the first mismatching stage in Problems
and offers direct navigation to that stage and the editable exported program.
The check only reports differences: it never rewrites an operator or its data.