Skip to content

focus: the operator can be the motor - #39

Merged
widgetii merged 3 commits into
mainfrom
focus-hand
Sep 25, 2026
Merged

widgetii merged 3 commits into
mainfrom
focus-hand

Conversation

@widgetii

Copy link
Copy Markdown
Member

A camera focused by hand could read its zones and tune its filter, but not measure how sharply that filter peaks — the sweep needed something to drive the lens.

It never did. sweepZones works from the order the readings arrived in and takes a median consensus; it has no idea a motor exists. A hand on the barrel is as good a sweep, only less evenly spaced — which is exactly what a median is robust to.

What it does

Measure by hand → "Turn the lens slowly from one end of its travel to the other, right through focus. Keep going — 23 readings so far." → Done → the same verdict the motorised sweep gives: the falls-to ratio, and the zones focusing at a different distance from the rest of the frame, ringed on the picture.

That second one is the finding that matters in the field. Dirt on the dome drags autofocus onto the glass, and it is exactly as invisible on a hand-focused camera as on a motorised one.

Nothing is fetched twice. focusTick was already reading the grid for the live display and throwing all but the latest away; the sweep just keeps them. So the operator watches the numbers move while they turn.

Offered even where there is a motor

That is not the house rule, but it is what the measurements say. Measure it takes eight fixed steps — a guess at how far this lens must travel to leave focus — and on the 85H50AI those eight steps moved the reading 3%, while the full travel moved it fourfold. The automatic sweep had nothing to measure. A hand covers the whole range.

One verdict for both

sayVerdict() is now shared, so a hand sweep and a motor sweep cannot word the same measurement differently, and the clamped-counter refusal from #34 applies to both.

Three guards, because a hand is less trustworthy than a motor

  • under 8 readings → refused, not averaged: "turn the lens right through focus, slowly, so there is a curve to measure"
  • the reading barely changed → "the lens does not look like it moved through focus", rather than a ratio that would read as "this filter cannot see focus"
  • collection capped at 400, so a sweep someone walked away from does not grow for as long as the page is open

The middle one is the same lesson as the clamped peak: say what you could not measure instead of returning a number that looks like a measurement.

Tests

Eleven new checks. Every guard mutation-tested:

break red
too few readings accepted too few readings is refused, not averaged
a still lens is scored a lens that never moved is said so, not scored
hand sweep does not ring a hand sweep rings what it found, on the picture
readings never collected 3 checks

The ring check was added after the first mutation pass came back vacuous — I had tested that the verdict mentions the odd zones but never that they are drawn on the picture.

node tools/smoke.mjs and node tools/ui-check.mjs both pass.

A camera focused by hand could read its zones and tune its filter, but not
measure how sharply that filter peaks -- the sweep needed something to drive
the lens. It never did. sweepZones works from the ORDER the readings arrived
in and takes a median consensus; it has no idea a motor exists. A hand on
the barrel is as good a sweep, only less evenly spaced, which is exactly
what a median is robust to.

So "Measure by hand" starts collecting, the operator turns the lens right
through focus, and Done gives the same verdict the motorised sweep gives:
the falls-to ratio, and the zones focusing at a different distance from the
rest of the frame, ringed on the picture. That second one is the finding
that matters in the field -- dirt on the dome drags autofocus onto the glass
and is exactly as invisible on a hand-focused camera as a motorised one.

Nothing is fetched twice. focusTick was already reading the grid for the
live display and throwing all but the latest away; the sweep just keeps
them, so the operator watches the numbers move while they turn.

Offered even where there IS a motor, which is not the house rule but is
what the measurements say. Measure it takes eight fixed steps -- a guess at
how far this lens must travel to leave focus -- and on the 85H50AI those
eight steps moved the reading 3% while the full travel moved it fourfold.
The automatic sweep had nothing to measure. A hand covers the whole range.

One verdict for both paths: sayVerdict() is shared, so a hand sweep and a
motor sweep cannot word the same measurement differently, and the pinned
counter refusal applies to both.

Three guards, because a hand is less trustworthy than a motor: under eight
readings is refused rather than averaged; a reading that barely changed is
reported as a lens that did not move rather than as a filter that cannot see
focus; and the collection is capped so a sweep someone walked away from does
not grow for as long as the page is open.
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Add operator-driven focus sweeps

✨ Enhancement 🧪 Tests 🕐 20-40 Minutes

Grey Divider

AI Description

• Adds manual focus sweeps for cameras with or without lens motors.
• Reuses live readings and shared verdicts to score sharpness and flag anomalous zones.
• Rejects short, static, saturated sweeps and caps retained readings.
Diagram

graph TD
  H["Hand sweep"] --> P["Live focus poll"] --> V{"Valid sample?"}
  V -- "Valid" --> Z["Zone analysis"] --> M["Picture marks"] --> R["Shared verdict"]
  V -- "Invalid" --> E["Retry guidance"]
  A["Motor sweep"] --> Z
Loading
High-Level Assessment

The approach is appropriate: retaining readings from the existing live poll avoids duplicate camera requests, while reusing sweepZones and a shared verdict formatter keeps manual and motor measurements consistent. A separate manual polling loop or fully generalized motor/hand sweep runner would add request coordination or lens-ownership complexity without improving the measurement.

Files changed (3) +379 / -70

Enhancement (2) +280 / -70
editor.jsShip manual focus-sweep support in the browser distribution +140/-35

Ship manual focus-sweep support in the browser distribution

• Updates the distributable editor with bounded live-frame collection, hand-sweep validation, operator controls, anomalous-zone marking, and shared motor/manual verdict formatting. This mirrors the source implementation served through the CDN.

dist/editor.js

editor.jsAdd operator-driven focus measurement and shared sweep verdicts +140/-35

Add operator-driven focus measurement and shared sweep verdicts

• Adds Measure by hand and Done controls that retain up to 400 existing live focus readings. It rejects sweeps with fewer than eight readings or less than 20% peak movement, analyzes valid frames for focus spread and anomalous zones, and shares saturation and result messaging with motorized sweeps.

src/editor.js

Tests (1) +99 / -0
ui-check.htmlCover manual sweep controls, validation, verdicts, and overlays +99/-0

Cover manual sweep controls, validation, verdicts, and overlays

• Adds UI checks for manual-only and motorized cameras, hand-sweep instructions and control state, falls-to results, anomalous-zone detection and picture rings. It also verifies that static lenses and undersampled sweeps are refused rather than scored.

tests/ui-check.html

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Hand sweeps ring the wrong zones 🐞 Bug ≡ Correctness
Description
focusTick stores only the order of manual readings, without the lens position represented by each
frame. sweepZones measures separation as a fraction of frame indices, so pauses or speed changes
distort physical focus distance and can either ring normal zones or miss genuinely distant ones.
Code

src/editor.js[R4021-4024]

+			if (handFrames && handFrames.length < HAND_MAX) {
+				handFrames.push({ fv: sum.fv, state: sum.state, sat: sum.satZone,
+					rows: sum.rows, cols: sum.cols, peak: sum.peak,
+					pinned: sum.peakSaturated });
Evidence
The new collector records focus values and grid metadata but no lens position. sweepZones assigns
each zone's peak to a frame index and declares it suspect when its index differs from the median by
more than 30% of the number of frames, which is only proportional to lens distance when sampling is
evenly spaced.

src/editor.js[4021-4024]
src/editor.js[4978-4985]
src/aftune.js[388-427]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Manual sweeps collect frame order but no lens position, while zone-distance detection treats frame-index separation as physical travel. Uneven hand speed therefore invalidates the claim that suspect zones focus at a different distance.
## Fix Focus Areas
- src/editor.js[4021-4024]
- src/editor.js[4423-4431]
- src/aftune.js[411-427]
- dist/editor.js[4021-4024]
## Recommended Fix
Do not run or report distance-based zone detection for hand sweeps unless the frames include a position or another validated travel coordinate. Either add position-aware sampling and make `sweepZones` compare that coordinate, or limit hand sweeps to the peak ratio and clearly omit zone rings.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Two sweeps can control one lens at once ✓ Resolved 🐞 Bug ☼ Reliability
Description
The manual-sweep busy function leaves the automatic measurement controls active, and the automatic
equivalent leaves the manual controls active. Starting one while the other is running stops or
resumes polling underneath the manual collection and can mix or omit readings while both workflows
instruct or drive the same lens.
Code

src/editor.js[R4787-4791]

+			const busy = (on) => {
+				hand.hidden = on;
+				handDone.hidden = !on;
+				send.disabled = on;
+				back.disabled = on;
Evidence
Manual mode disables only filter Send and Read controls, while automatic mode independently disables
the same pair and its own Measure button. Automatic sweeping stops focus polling until its return
walk completes, but manual collection depends exclusively on that polling to append frames.

src/editor.js[4785-4818]
src/editor.js[4821-4859]
src/editor.js[4439-4450]
src/editor.js[4498-4506]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Manual and automatic focus measurements can be started concurrently because each workflow only disables its own controls. Their polling and lens-control behavior then interfere and invalidate both measurements.
## Fix Focus Areas
- src/editor.js[4785-4818]
- src/editor.js[4821-4859]
- src/editor.js[4435-4506]
- dist/editor.js[4785-4818]
- dist/editor.js[4821-4859]
## Recommended Fix
Introduce one shared sweep-busy state covering manual collection, automatic measurement, and lens movement. Refuse or disable every competing sweep control while either workflow is active, and release the shared state on completion, cancellation, mode change, and destruction.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

3. Closed focus panels leak sweep timers ✓ Resolved 🐞 Bug ☼ Reliability
Description
Starting a manual measurement creates a panel-local interval and leaves the global handFrames
collection active until that panel's Done button is clicked. Leaving Focus or destroying the editor
removes access to that button without clearing either resource, so detached panels keep running and
later Focus polling can continue filling an abandoned sweep.
Code

src/editor.js[R4805-4806]

+				tick();
+				ticker = setInterval(tick, 400);
Evidence
The interval is created in the new click handler and cleared only in the new Done handler. Existing
mode-change and destruction cleanup stop camera polling, lens movement, and automatic sweeps, but
contain no cleanup for the manual ticker or handFrames.

src/editor.js[4793-4818]
src/editor.js[5161-5169]
src/editor.js[5441-5479]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A manual sweep's interval and frame collection are cleared only by its Done handler. Mode changes and editor destruction detach that handler without cancelling the active sweep.
## Fix Focus Areas
- src/editor.js[4785-4818]
- src/editor.js[5161-5169]
- src/editor.js[5441-5479]
- dist/editor.js[4785-4818]
## Recommended Fix
Move manual-sweep cancellation into a lifecycle-level helper that clears the interval, nulls `handFrames`, and restores controls. Call it from Done, every transition away from Focus, panel rebuilds, and `destroy()`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


4. Grid changes still produce a verdict ✓ Resolved 🐞 Bug ≡ Correctness
Description
handVerdict catches every sweepZones failure and continues to calculate a successful ratio from
the collected frame peaks. When the camera changes grid dimensions during collection, frames no
longer represent comparable image regions or scales, yet the operator still receives a normal
measurement instead of a refusal.
Code

src/editor.js[R4423-4426]

+		let odd = null;
+		try { odd = sweepZones(frames); } catch (e) { odd = null; }
+		return {
+			hi: hi, lo: lo, ratio: lo > 0 ? hi / lo : null, steps: vals.length,
Evidence
sweepZones explicitly throws if any frame changes rows, columns, or zone count because indices
would map to different parts of the picture. The new manual path converts that exception to `odd =
null and still returns hi, lo, and ratio`.

src/aftune.js[347-357]
src/editor.js[4415-4432]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Manual sweep validation suppresses grid-shape errors and returns a normal verdict using incompatible frames. The shape guard in `sweepZones` should invalidate the entire measurement rather than only omit zone rings.
## Fix Focus Areas
- src/editor.js[4411-4432]
- src/aftune.js[347-357]
- dist/editor.js[4411-4432]
## Recommended Fix
Validate that all manual frames have identical rows, columns, and focus-value lengths before calculating peaks or ratios. Return a clear failed verdict when validation or `sweepZones` fails instead of swallowing the exception and continuing.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Tip of the day
💡 Did you know, you can enable the Remediation agent and Qodo fixes findings in a dedicated fix PR

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread src/editor.js
Comment on lines +4021 to +4024
if (handFrames && handFrames.length < HAND_MAX) {
handFrames.push({ fv: sum.fv, state: sum.state, sat: sum.satZone,
rows: sum.rows, cols: sum.cols, peak: sum.peak,
pinned: sum.peakSaturated });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Hand sweeps ring the wrong zones 🐞 Bug ≡ Correctness

focusTick stores only the order of manual readings, without the lens position represented by each
frame. sweepZones measures separation as a fraction of frame indices, so pauses or speed changes
distort physical focus distance and can either ring normal zones or miss genuinely distant ones.
Agent Prompt
## Issue description
Manual sweeps collect frame order but no lens position, while zone-distance detection treats frame-index separation as physical travel. Uneven hand speed therefore invalidates the claim that suspect zones focus at a different distance.

## Fix Focus Areas
- src/editor.js[4021-4024]
- src/editor.js[4423-4431]
- src/aftune.js[411-427]
- dist/editor.js[4021-4024]

## Recommended Fix
Do not run or report distance-based zone detection for hand sweeps unless the frames include a position or another validated travel coordinate. Either add position-aware sampling and make `sweepZones` compare that coordinate, or limit hand sweeps to the peak ratio and clearly omit zone rings.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment thread src/editor.js
Comment thread src/editor.js
Comment thread src/editor.js
Four from review, all real, and the first corrects something I wrote in the
commit that introduced this. I said a median consensus is robust to uneven
spacing. That is true of the CONSENSUS and false of the THRESHOLD, which was
a fixed fraction of the reading count -- and an operator who slows down
through focus piles most of the readings there, spreading the normal zones
out in index until that fraction is either blind or trigger-happy.

It now measures against how tightly the scene itself agrees: three times the
median absolute deviation of the zones that responded. Every zone is sampled
at the same instants, so their peak ORDER means something however fast the
barrel turned, and the MAD is in the same distorted units as the thing being
judged, so the distortion cancels.

My first attempt kept the old fraction as a floor and the new test caught it
immediately: on a 38-reading sweep that floor is 11 frames, which swallows a
genuine 6-frame outlier. The more carefully someone measured, the blinder it
got. The floor is two readings flat, there only to stop a scene that agrees
to the frame from flagging its own noise.

The rest: one shared sweepping() so the two sweeps cannot run at once -- the
hand one lives off the live poll the motor one stops; handStop() wired into
both leaving Focus and destroy, so an abandoned sweep does not keep a ticker
and a growing array alive behind a Done button that is no longer in the
document; and a grid-shape check that refuses the RATIO too, because peaks
taken over different zone sizes are not comparable numbers and reporting one
while dropping the zone findings hands back the half that is equally wrong.

Two of the five guards came back vacuous on the first mutation pass. The
mutual-exclusion one sits behind the disabled attribute, and a disabled
button never fires its handler, so the test could not reach the code it was
testing; it now re-enables the button first, which is also the real case, a
panel rebuild clearing disabled. The teardown one asserted that readings
stop, which stopFocusPoll already does on its own; it now holds the old Done
button and checks it flipped back, because leaving Focus must END the sweep
rather than merely detach its panel.
@widgetii

Copy link
Copy Markdown
Member Author

All four were real and are fixed in c8db186. The first corrects something I wrote in the commit that introduced this.

1 — Hand sweeps ring the wrong zones. Correct, and my justification was wrong in exactly the place it mattered. I claimed a median consensus is robust to uneven spacing: true of the consensus, false of the threshold, which was a fixed fraction of the reading count. An operator who slows down through focus piles most readings there, spreading the normal zones out in index until that fraction is either blind or trigger-happy.

It now measures against how tightly the scene itself agrees — 3× the median absolute deviation of the zones that responded. Every zone is sampled at the same instants, so their peak order means something however fast the barrel turned, and the MAD is in the same distorted units as the thing being judged, so the distortion cancels.

My first attempt kept the old fraction as a floor, and the new test caught it immediately: on a 38-reading sweep that floor is 11 frames, which swallows a genuine 6-frame outlier. The more carefully someone measured, the blinder it got. The floor is now two readings flat, there only to stop a scene that agrees to the frame from flagging its own noise.

2 — Two sweeps can control one lens at once. Correct. One shared sweepping() now covers both, each greys the other out, and both handlers refuse on their own.

3 — Closed focus panels leak sweep timers. Correct. handStop() is wired into leaving Focus and into destroy(), so an abandoned sweep does not keep a ticker and a growing array alive behind a Done button that is no longer in the document.

4 — Grid changes still produce a verdict. Correct, and it applies to the ratio as much as the rings: peaks taken over different zone sizes are not comparable numbers, so reporting one while quietly dropping the zone findings would hand back the half that is equally wrong. A reshaped grid is now refused outright.

On the tests

Two of the five guards came back vacuous on the first mutation pass:

  • the mutual-exclusion guard sits behind the disabled attribute, and a disabled button never fires its handler — the test could not reach the code it was testing. It now re-enables the button first, which is also the real case: a panel rebuild can clear disabled.
  • the teardown guard's test asserted that readings stop, which stopFocusPoll() already does on its own, so it passed either way. It now holds the old Done button and checks it flipped back — leaving Focus must end the sweep, not merely detach its panel.

All five now discriminate:

break red
threshold as a fraction of count a tight scene makes a modest outlier visible
hand starts inside a motor sweep a hand sweep cannot start inside a motor one
motor button left live a running hand sweep greys out the motor one
hand sweep survives leaving Focus leaving Focus ends the hand sweep, not just its panel
reshaped grid still rated a grid that changed shape is refused, not rated

node tools/smoke.mjs and node tools/ui-check.mjs both pass.

The review kept the ring finding open after the median-absolute-deviation
fix, and it was right to. That fix makes the threshold survive UNEVEN
spacing. It does nothing about spacing that is not monotonic.

Everything sweepZones concludes about distance rests on one assumption:
reading order is lens order. A motor guarantees it -- every step goes the
same way. A hand does not. An operator who turns forward, back, and forward
again visits the same position at three different indices, and two zones
peaking at different indices may be at the same distance after all. Uneven
speed stretches the mapping and a median absorbs that; turning back breaks
the mapping, and no statistic recovers it.

So the sweep is checked for going one way, from the scene's own curve rather
than from a position the readings do not carry: swept once through focus the
overall reading rises and falls once, and crossing the halfway mark upwards
more than once means the lens came back. Half way up is high enough that
noise around the trough does not register as a turn and low enough to catch
a real second excursion.

A wandering sweep keeps its ratio -- highest over lowest is the same
whatever order they arrived in -- and loses only the claim it cannot
support, with the reason said out loud rather than the rings quietly not
appearing.

Motor sweeps are unaffected: they are monotonic by construction.
@widgetii

Copy link
Copy Markdown
Member Author

You kept #1 open after the MAD fix, and you were right to. Fixed properly in e567198.

The median-absolute-deviation change makes the threshold survive uneven spacing. It does nothing about spacing that is not monotonic, and that is the assumption the whole distance claim rests on: reading order is lens order.

A motor guarantees it — every step goes the same way. A hand does not. An operator who turns forward, back, and forward again visits the same position at three different indices, and two zones peaking at different indices may be at the same distance after all. Uneven speed stretches the mapping and a median absorbs it; turning back breaks the mapping, and no statistic recovers it.

So the sweep is now checked for going one way, from the scene's own curve rather than from a position the readings do not carry:

/* swept once through focus, the overall reading rises and falls once.
 * Crossing the halfway mark upwards more than once means the lens came back. */
const mid = lo + (hi - lo) / 2;
let ups = 0;
for (let i = 1; i < v.length; i++)
    if (v[i - 1] < mid && v[i] >= mid) ups++;
return ups <= 1;

Half way up is high enough that noise around the trough does not register as a turn, and low enough to catch a real second excursion.

A wandering sweep keeps its ratio — highest over lowest is the same whatever order they arrived in — and loses only the claim it cannot support:

Falls to 1/8.4 of its peak across the sweep. … Zones at a different distance are not reported: the reading rose and fell more than once, so the lens looks like it was turned back and forth rather than swept one way.

Said out loud, rather than the rings quietly not appearing. Motor sweeps are unaffected — monotonic by construction.

On your alternative "limit hand sweeps to the peak ratio and clearly omit zone rings": that is exactly what this does, but only for the sweeps where the assumption actually fails, rather than for every hand sweep. A steady one-way turn supports the finding, and that is the common case worth keeping — dirt on the dome is the reason this feature exists and is just as invisible on a manual camera.

Tests

Five on the detector (one pass; there-and-back; a wobble that must still count as one sweep; too-short and flat both declining to answer) and three end to end (a wandering sweep keeps its ratio, drops the claim, rings nothing). All three mutations discriminate:

break red
wandering sweep still rings but not a claim about distance it cannot support
every sweep called one-way there and back again is not; + the end-to-end check
a stub sweep called one-way three readings are not a sweep

node tools/smoke.mjs and node tools/ui-check.mjs both pass.

@widgetii
widgetii merged commit 71040d9 into main Sep 25, 2026
1 check passed
@widgetii
widgetii deleted the focus-hand branch September 25, 2026 09:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant