Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
9864b19
Don't add confusing params to config example, tweak params too
smj-edison Feb 7, 2025
8d21f81
Add bidirectional editor support
smj-edison Feb 11, 2025
89dfad0
Make saving work from webpage
smj-edison Feb 11, 2025
bcaa8cc
Merge pull request #194 from smj-edison/main
osnr Feb 20, 2025
5b0c0d5
Add support for groups
smj-edison Feb 11, 2025
89661cc
Remove unneeded code
smj-edison Feb 21, 2025
5e5657f
Merge pull request #199 from smj-edison/bidirectional-web-editor
cwervo Feb 21, 2025
1d7abf8
Add exposure slider to /calibrate and exposure wish to camera.folk
osnr Feb 24, 2025
a30c545
calibrate: Max out exposure at 2x 60Hz; hold the wish properly
osnr Feb 24, 2025
83cc155
claibrate: Show live exposure value; restore autoexposure on unmatch
osnr Feb 25, 2025
718f31f
calibrate: Update troubleshooting section
osnr Feb 25, 2025
75325d1
Add auto refresh, linked exposure number input
cwervo Feb 25, 2025
204730a
Merge pull request #200 from FolkComputer/osnr/camera-usb-set-exposure
cwervo Feb 25, 2025
4e0d50f
Fix shape rotation relative to parent region
cwervo Mar 3, 2025
f894082
Fix var clashing and clean-up shape functions
cwervo Mar 3, 2025
2b9c697
Add rect shifting
cwervo Mar 3, 2025
cb8245d
Use region utils, refactor shape drawing options
cwervo Mar 3, 2025
3a6aea9
Shape syntax simplified, made shifting easier
cwervo Mar 3, 2025
8732ec0
Fix single object "draw shape" typo in shapes.folk
cwervo Mar 7, 2025
2b8ae03
Add point and polyline drawing to shapes
cwervo Mar 10, 2025
99cd4d0
Add points & polyline; format shapes.folk
cwervo Mar 11, 2025
c385aae
Standardize on angle and thickness for shape args
cwervo Mar 13, 2025
20b54bd
Refactor shapes to use offsets simplify structure
cwervo Mar 13, 2025
add6b0e
Remove conflicting When
cwervo Mar 13, 2025
077517c
Merge pull request #198 from smj-edison/groups-without-other-commits
osnr Mar 14, 2025
431a0e5
Merge pull request #201 from FolkComputer/ac/shapes-tune-up
osnr Mar 14, 2025
911003d
Add wishes page draws text
rooprob Apr 24, 2025
33b4a92
wish highlights region
rooprob Apr 28, 2025
5db94b2
PR
rooprob Apr 28, 2025
d95d9b6
fix compile
rooprob Apr 28, 2025
853b8a4
renable laser.folk
rooprob Apr 28, 2025
be1ac8c
Merge pull request #203 from rooprob/rooprob/shapes-text
cwervo Apr 29, 2025
ef39fc0
Add overridable angle to text wishes
cwervo Apr 29, 2025
bb9f2ff
Merge pull request #205 from rooprob/rooprob/blobdetection
cwervo Apr 30, 2025
e81a2a5
Use options dictionary defaults strategy
cwervo Apr 30, 2025
6f13e0e
Merge pull request #204 from rooprob/rooprob/more-regions
cwervo Apr 30, 2025
65b154e
Update /keyboards layout
cwervo May 1, 2025
937f4f0
laser: Disable laser blob detection by default
osnr May 1, 2025
f7bd9c0
fix esc-pos geom file
ppkn May 3, 2025
39d9c6b
Wrap geom in braces
ppkn May 3, 2025
09f8152
Merge pull request #206 from FolkComputer/dpip/esc-pos-geom-fix
osnr May 3, 2025
bcf097d
Add anchor option to relative text
smj-edison May 12, 2025
61a9e5a
Also add font as a parameter
smj-edison May 13, 2025
5a700e7
Merge pull request #207 from smj-edison/add-anchor-option-to-relative…
cwervo May 15, 2025
3e17eae
Change default size for reciept printer
ppkn May 15, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 63 additions & 24 deletions lib/folk.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ const eatBrace = (str) => {

if (bc === 0) {
return [
str.slice(0, i+1),
str.slice(i+1),
str.slice(0, i + 1),
str.slice(i + 1),
];
}
}
Expand Down Expand Up @@ -118,8 +118,8 @@ const loadDict = (str) => {
if (list.length % 2 !== 0) throw new Error('uneven element count in dict');

const obj = {};
for (let i = 0; i < list.length; i+= 2)
obj[list[i]] = list[i+1];
for (let i = 0; i < list.length; i += 2)
obj[list[i]] = list[i + 1];
return obj;
};

Expand All @@ -141,7 +141,7 @@ const dump = (val) => {
return dumpString(val.toString());
} else if (typeof val == 'object') {
const core = Object.entries(val)
.map(([k, v]) => dump(k) + ' ' + dump(v));
.map(([k, v]) => dump(k) + ' ' + dump(v));
return '{' + core.join(' ') + '}';
}

Expand Down Expand Up @@ -170,18 +170,21 @@ class FolkWSChannel {
this.prefix = prefix;
this.callback = callback;
this.ws.channels[prefix] = this;
this.retractKey = null;
}

stop() {
this.ws.ws.send(`Retract ${this.retractKey}`);
if (this.ws.channels[this.prefix] !== this) return;
delete this.ws.channels[this.prefix];
}
}

class FolkWS {
constructor(statusEl=null, url=null) {
constructor(statusEl = null, url = null) {
this.channels = {};
this.i = 0;
this.assertSeq = 0;

this._connect(statusEl, url);
}
Expand Down Expand Up @@ -247,7 +250,12 @@ class FolkWS {
}
// Evaluates inside a match context dependent on the WS connection:
async send(message) {
await this.evaluate(tcl`Assert when websocket $chan is connected [list {this __seq} ${message}] with environment [list $chan [incr ::__seq]]`);
const seqNumber = this.assertSeq++;
const assertBody = tcl`when websocket $chan is connected [list {this __seq} ${message}] with environment [list $chan ${seqNumber}]`;
await this.evaluate(`Assert ${assertBody}`);

const retractKey = assertBody;
return retractKey;
}
// Evaluates inside a persistent match context that replaces any
// previous hold with same key:
Expand All @@ -270,36 +278,67 @@ class FolkWS {

async watch(statement, callbacks) {
const channel = this.createChannel((message) => {
const [ action, match, matchId ] = loadList(message);
const [action, match, matchId] = loadList(message);
const callback = callbacks[action];
callback && callback(loadDict(match), matchId);
});

await this.send(tcl`
set varNamesWillBeBound [list]
foreach word ${statement} {
if {[set varName [trie scanVariable $word]] != "false"} {
const retractKey = await this.send(tcl`
# for the top level Say
set outerVarNames [list]
# for inner When's
set innerVarNames [list]
set statement ${statement}
set body {
set matches [dict create]
foreach varName $names {
set val [set $varName]
dict append matches $varName $val
}
emit ${channel.prefix} [list add $matches $::matchId]
On unmatch {
emit ${channel.prefix} [list remove $matches $::matchId]
}
}
for {set i 0} {$i < [llength $statement]} {incr i} {
set word [lindex $statement $i]
if {$word eq "&"} {
# Desugar this join into nested Whens.
set remainingStatement [lrange $statement $i+1 end]
set statement [lrange $statement 0 $i-1]
for {set j 0} {$j < [llength $remainingStatement]} {incr j} {
set remainingWord [lindex $remainingStatement $j]
if {[set varName [trie scanVariable $remainingWord]] != "false"} {
if {$varName ni $statement::blanks && $varName ni $statement::negations} {
if {[string range $varName 0 2] eq "..."} {
set varName [string range $varName 3 end]
}
lappend innerVarNames $varName
}
}
if {[regexp {^/([^/ ]+)/$} $remainingWord -> remainingVarName] &&
$remainingVarName in $outerVarNames} {
lset remainingStatement $j \\$$remainingVarName
}
}
set body [list When {*}$remainingStatement $body]
break
} elseif {[set varName [trie scanVariable $word]] != "false"} {
if {$varName ni $statement::blanks && $varName ni $statement::negations} {
if {[string range $varName 0 2] eq "..."} {
set varName [string range $varName 3 end]
}
lappend varNamesWillBeBound $varName
lappend outerVarNames $varName
}
}
}

Say when {*}${statement} {{this names args} {
set matches [dict create]
foreach varName $names val $args {
dict append matches $varName $val
}

emit ${channel.prefix} [list add $matches $::matchId]
On unmatch {
emit ${channel.prefix} [list remove $matches $::matchId]
}
}} with environment [list $this $varNamesWillBeBound]
# join both variable names to be sent along the websocket in the innermost When
set combinedVarNames [concat $outerVarNames $innerVarNames]
Say when {*}$statement [list [list this names {*}$outerVarNames] $body] with environment [list $this $combinedVarNames]
`);
channel.retractKey = retractKey;

return channel;
}
}
4 changes: 2 additions & 2 deletions setup.folk.default
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Copy this file to ~/folk-live/setup.folk and edit it to make
# changes.

Assert $this wishes $::thisNode uses camera "/dev/video0" with width 1280 height 720 bufferCount 4
Assert $this wishes $::thisNode uses camera "/dev/video0" with width 1280 height 720

Assert $this wishes $::thisNode uses display 0 with swapchainPadding 1
Assert $this wishes $::thisNode uses display 0
22 changes: 12 additions & 10 deletions vendor/blobdetect/blobdetect.tcl
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
namespace eval ::BlobDetect {
rename [c create] apc
apc cflags -I$::env(HOME)/apriltag $::env(HOME)/folk/vendor/blobdetect/hk.c
apc include <apriltag.h>
apc include <math.h>
apc include <assert.h>
apc code {
set cc [c create]
$cc cflags -I$::env(HOME)/apriltag $::env(HOME)/folk/vendor/blobdetect/hk.c
$cc include <apriltag.h>
$cc include <math.h>
$cc include <assert.h>
$cc code {
int hoshen_kopelman(int **matrix, int m, int n);

typedef struct {
Expand Down Expand Up @@ -36,6 +36,7 @@ namespace eval ::BlobDetect {
// for(int i = 0; i < rows; i++)
// memset(matrix[i], 0, cols * sizeof(int));

// filter the raster into on or off
for (int y = 0; y < im_orig->height; y++) {
for (int x = 0; x < im_orig->width; x++) {
int i = y * im_orig->stride + x;
Expand All @@ -54,6 +55,7 @@ namespace eval ::BlobDetect {
int clusters = hoshen_kopelman(matrix,m,n);
// printf("clusters: %d\n", clusters);

// initialize a structure
for (int i=0; i<clusters; i++) {
detected_blob_t *det = calloc(1, sizeof(detected_blob_t));
det->id = i;
Expand Down Expand Up @@ -120,9 +122,9 @@ namespace eval ::BlobDetect {
zarray_destroy(detections);
}
}
defineImageType apc
defineImageType $cc

apc proc detect {image_t gray int threshold} Tcl_Obj* {
$cc proc detect {image_t gray int threshold} Tcl_Obj* {
assert(gray.components == 1);
image_u8_t im = (image_u8_t) { .width = gray.width, .height = gray.height, .stride = gray.bytesPerRow, .buf = gray.data };

Expand All @@ -134,7 +136,7 @@ namespace eval ::BlobDetect {
detected_blob_t *det;
zarray_get(detections, i, &det);

// printf("detection %3d: id %-4d\n cx %f cy %f size %d\n", i, det->id, det->c[0], det->c[1], det->size);
printf("detection %3d: id %-4d\n cx %f cy %f size %d\n", i, det->id, det->c[0], det->c[1], det->size);

// int size = sqrt((det->p[0][0] - det->p[1][0])*(det->p[0][0] - det->p[1][0]) + (det->p[0][1] - det->p[1][1])*(det->p[0][1] - det->p[1][1]));
int size = det->size;
Expand All @@ -154,5 +156,5 @@ namespace eval ::BlobDetect {
return result;
}

apc compile
$cc compile
}
87 changes: 75 additions & 12 deletions virtual-programs/calibrate/calibrate.folk
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,9 @@ Wish the web server handles route "/calibrate$" with handler [list apply {{UNIT_
[list /someone/ claims the default program geometry is /defaultGeom/]] 0] defaultGeom]
fn defaultGeomGet {key} { return [string map {mm ""} [dict get $defaultGeom $key]] }

set camera [dict get [lindex [Statements::findMatches \
[list /someone/ claims camera /camera/ has width /cameraWidth/ height /cameraHeight/]] 0] camera]

upvar ^html ^html
html [csubst {
<html>
Expand Down Expand Up @@ -464,15 +467,15 @@ Wish the web server handles route "/calibrate$" with handler [list apply {{UNIT_
});
</script>

<p>Once you start calibration, you'll see some AprilTags get automatically projected on your table. Move your board to the projected tags <strong>so that at least one projected tag sits inside the gap between printed AprilTags</strong>, wait a second for the projected tags to refit into the grid,
<p>Once you start calibration, you'll see some AprilTags get automatically projected on your table. Move your board to the projected tags <em>so that at least one projected tag sits inside the gap between printed AprilTags</em>, wait a second for the projected tags to refit into the grid,
then <strong>hold the board still for a few seconds until
the pose is recorded.</strong></p>

<iframe width="560" height="315" src="https://www.youtube-nocookie.com/embed/l1liP4_yiVM?si=DqgfNKq05EPBT3hT" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>

<p style="font-style: italic; width: 100%; text-align: center;">Example video of Andr&eacute;s calibration the Folk0 system (playing at 2x speed).</p>
<p style="font-style: italic; width: 100%; text-align: center;">Example video of Andr&eacute;s calibrating the folk0 system (2x speed)</p>

<p>Are the projected tags too big to fit in the gaps between printed tags? Adjust this slider to reset & adjust the default projected tag size:
<p><strong>Are the projected tags too big to fit in the gaps between printed tags?</strong> Adjust this slider to reset & adjust the default projected tag size:
<input type="range" min="10" max="100" value="100" class="slider" id="projected-tag-slider">
</p>
<script>
Expand All @@ -482,11 +485,69 @@ Wish the web server handles route "/calibrate$" with handler [list apply {{UNIT_
{*}\$::HoldDefaultModel \${scale}
`);
});

function advanceCamera() {
cameraFrame.src = cameraFrame.src + '0'
}
</script>

<p>Use this camera preview to debug why printed and/or projected tags aren't being recognized (maybe overexposure, maybe your camera isn't in a good position): <button id="refreshButton" onclick="advanceCamera()">Refresh Preview</button> <input type="checkbox" value="true" id="auto-refresh-checkbox" checked>
<label for="auto-refresh-checkbox">Automatically refresh preview (May not work well during calibration)</label> </p><br> <img src="/camera-frame?0" id="cameraFrame" style="max-width: 100%">

<script>
const refreshButton = document.getElementById('refreshButton');
const autoRefreshCheckbox = document.getElementById('auto-refresh-checkbox');
setInterval(() => {
if (autoRefreshCheckbox.checked) {
refreshButton.click()
}
console.log("checked?", autoRefreshCheckbox.checked)
}, 100)
</script>

<p>Use this camera preview to debug why printed and/or projected tags aren't being recognized (maybe overexposure, maybe your camera isn't in a good position): <button onclick="cameraFrame.src = cameraFrame.src + '0'">Refresh Preview</button></p><br> <img src="/camera-frame?0" id="cameraFrame" style="max-width: 100%">

<p>Once you've recorded the first pose, <strong>slowly drag the board around your space</strong>, going slow enough for the projected AprilTags to catch up with the printed AprilTags and fit into the gaps on your board. When you've moved the board at least a full board-length away from the first pose, try to slant it 45 degrees or so off the table and hold it still again to capture another pose.</p>
<p><strong>Is the projection too bright and washing out the camera?</strong>
<br/>
Adjust camera exposure:
<input type="range" min="10" max="3200" value="100" class="slider" id="camera-exposure-slider">
<input type="number" min="1000" max="320000" step="100" value="10000" id="camera-exposure-number">μs
</p>

<script>
const slider = document.getElementById('camera-exposure-slider');
const numberInput = document.getElementById('camera-exposure-number');
let lastRefreshTimeout = null;

function updateExposure(valueInMicroseconds) {
const sliderValue = valueInMicroseconds / 100;

slider.value = sliderValue;
numberInput.value = valueInMicroseconds;

ws.hold('exposure', tcl`
Wish camera $camera uses exposure time \${valueInMicroseconds} us
`, 'virtual-programs/calibrate/calibrate.folk');

if (lastRefreshTimeout != null) {
window.clearTimeout(lastRefreshTimeout);
}
lastRefreshTimeout = setTimeout(() => { advanceCamera() }, 500);
}

slider.addEventListener('input', (e) => {
const exposureValue = Math.round(e.target.value * 100);
updateExposure(exposureValue);
});

numberInput.addEventListener('input', (e) => {
const exposureValue = parseInt(e.target.value);
updateExposure(exposureValue);
});

updateExposure(10000);
</script>

<p>Once you've recorded the first pose, <em>slowly drag the board around your space</em>, going slow enough for the projected AprilTags to catch up with the printed AprilTags and fit into the gaps on your board. When you've moved the board at least a full board-length away from the first pose, try to slant it 45 degrees or so off the table and hold it still again to capture another pose.</p>

<p>Repeat this process of dragging the board around and
capturing a new pose. You'll need to record 10 different
Expand All @@ -498,7 +559,7 @@ Wish the web server handles route "/calibrate$" with handler [list apply {{UNIT_
<details>
<summary>Troubleshooting</summary>
<p>Look at ~/folk-calibration-poses to see images of the captured poses (maybe tags are distorted or washed out?).</p>
<p>You can try manually adjusting webcam settings if your poses are bad. (They should be immediately reflected in the camera preview once you refresh.) Folk tries to turn off autofocus by default, but you might also want to turn off autoexposure and set a manual exposure time. For example:</p>
<p>You can try manually adjusting webcam settings if your poses are bad. (They should be immediately reflected in the camera preview once you refresh.) Folk tries to turn off autofocus by default, and you might also want to check that your camera actually has an exposure setting and focus setting. For example:</p>
<pre>
\$ v4l2-ctl --device=/dev/video0 --list-ctrls

Expand All @@ -524,9 +585,10 @@ Camera Controls
focus_absolute 0x009a090a (int) : min=0 max=250 step=5 default=0 value=30
focus_automatic_continuous 0x009a090c (bool) : default=1 value=0
zoom_absolute 0x009a090d (int) : min=100 max=500 step=1 default=100 value=100
\$ v4l2-ctl --device=/dev/video0 --set-ctrl=auto_exposure=1
\$ v4l2-ctl --device=/dev/video0 --set-ctrl=auto_exposure=1 # to set them manually from terminal
\$ v4l2-ctl --device=/dev/video0 --set-ctrl=exposure_time_absolute=25
</pre>
<p>Camera needs to have auto_exposure and exposure_time_absolute settings listed for Folk to be able to set them.</p>
</details>
</li>

Expand Down Expand Up @@ -671,10 +733,6 @@ When camera /camera/ has width /cameraWidth/ height /cameraHeight/ &\
# TODO: restore old camera resolution later
}

# HACK: hard-coded for now; assumes dark room. Won't work on USB
# webcams yet, either (just Pi).
Wish camera $camera uses exposure time 16000 us

set tagSideLength 1.0
set tagOuterLength [expr {$tagSideLength * 10/6}]
set pad $tagSideLength
Expand Down Expand Up @@ -793,7 +851,12 @@ When camera /camera/ has width /cameraWidth/ height /cameraHeight/ &\
model /anything/ version /anything/ timestamp /anything/ {
HoldDefaultModel 1.0
}
On unmatch { Hold H_modelToDisplay {} }
On unmatch {
Hold H_modelToDisplay {}
Hold exposure {
Wish camera $camera uses exposure time auto us
}
}

When main-detector detects tags /tags/ on $camera at /timestamp/ in time /something/ & \
the calibration model-to-display homography is /H_modelToDisplay/ with \
Expand Down
Loading