«DiY» WePosture#

Good posture through equilibrium. WIP - Work-In-Progress

Let’s tinker gadgets

  • Time estimate: One afternoon (net)

  • Cost estimate: Far below 50 Euro (Raspberry Pi, Monitor, Keyboard, Mouse not included)

  • Skill level: Intermediate hobby tinkerer

Background#

Recently I met a physician in the field of orthopaedics, sports medicine and performance diagnostics. I learned from him that a good posture is important for our health and well-being. So let’s create some thing!

The Gadget#

With a …

  • home-made Balance Board (like a Wii Balance Board) with hardware used in digital scales, voltage amplifiers (HX711)

  • a (little) oversized microcontroller, Raspberry PI 5 (RPI5), we can directly work on, so a full-blown computer.

  • WebApp (Full-Stack with FARM-Stack: FastAPI, React, MongoDB) to visualize the data and to provide a user interface for the user to interact with the gadget. In the DataBase historical health data can be stored and analyzed.

BOM - Bill Of Material#

Production (and Development):

Development (in addition):

SBOM - Software Bill Of Material#

Production (and Development):

Development (in addition):

  • Visual Studio Code 1.92.2

  • pipx 1.1.0

  • Poetry 1.8.3

Resources#

Sketches#

Here some sketches:

../../_images/bd_weposture.svg
../../_images/board_with_feed_and_loadcells.png

Expert Dialogs#

Here some GPT-4o chats:

Insights in Work-In-Progress#

Hardware (Electronics + Mechanics)#

Prototype Generation One#

../../_images/balance_board.png

Balance board with top side made of glass waiting and 1k Resistors waiting to get to the four HX711 amplifiers of in the circuit.#

UI/UX:

Create image Create a screenshot from a web application created with ant design based on the hand drawing I provided.

You can see the outlines of a left foot and a right foot from top. Each is standing on a plate, illustrated as a rectangle. In the middle there is in black+white the optimal center of gravity of the person who’s feet we see projected onto the floor the person is standing. We the actual projected center of gravity as a circle with blue outline and a pie chart in the middle. The left sector of the pie chart is red, the right one is green. In maritime colors the left (larboard, aka port) is red, the right (starboard) is green. We see, in red, another pie chart with a red outline and the same in green, last one on the right. Those are the projected center of gravity of each of the feet. Both feed-center-of-gravities (more exact the projections of them) are combined to the one with the blue outline.

../../_images/sketch_ui_ux.png

Sketch on UI/UX#

../../_images/rpi_4b_and_circuit.png

RPI 4B connected to 2x4 HX711, Power, Monitor, Keyboard and Mouse#

../../_images/pinout_connections_1_of_2.png

Pinout connections 1 of 2#

../../_images/pinout_connections_2_of_2.png

Pinout connections 2 of 2#

Prototype Generation Two#

The one-board design from Generation One has changed to a two-board design, each foot gets its own board. The boards are not mechanically connected. The advantage is that they can be adjusted to the foot distance and the orientations to the feed.

I’ve also thought about a slight modified design which connects the boards mechanically to establish parallel alignment while leaving the distance to the boards variable (within a defined range).

The posture boards’ frames are made of wood, the top plates are made of 8mm multiplex board to establish the necessary bending stiffness. The bottom plates are made of 4mm wood plates usually found as rear panels of furniture. The load cells. The top place is pulled to the bottom by a center screw tightened from the bottom in order to leave the top plate untouched and clean w/o any bores. The tightened center screw creates a preload onto the cells. This preload is zerod out in the initialization phase of the software. While the bottom plates are glued to the frames the top plate floats a little, held and limited by the frame.

One of the boards is extended by the integrated housing of the RaspberryPi. The RaspberyPi has a shield which enables Power-On-LAN (PoL). This reduced the number of cables to be connected to the board. The connection to the other board is made by standard RJ45 cable and suitable sockets. There is no IP network, though. It is the SPI + GPIO + Power connection just realized over those standard cables. This is to keep the design simple and with standardized components.

../../_images/sketch_ui_ux.svg

Sketch on UI/UX#

../../_images/posture_boards_assembled.png

Posture boards assembled#

../../_images/posture_cable_harness_inkl_pol_injector.png

Posture cable harness inkl. PoL injector#

../../_images/posture_kit_complete.png

Posture kit complete#

../../_images/posture_boards_disassembled.png

Posture boards disassembled#

../../_images/setup_hardware_software_integration.png

Setup hardware and software integration testing/tinkering#

The demo setup is a simple mug scale. The load cell is mounted on the bottom of the mug. The load cell is connected to the HX711 amplifier. The HX711 is connected to a

../../_images/demo_mug_scale_one_load_cell.png

Demo mug scale with one load cell, the HX711 amplifier and a display module#

Software#

Backend#

Software (Repository: basejumpa/WeBalance ) already acquires data. Here the continuous output (obsolete, still the output of just 4 load cells connected):

--snip--

read duration: 0.969 seconds, rate: 10.3 Hz
raw ['1990.750', '-28956.286', '-1740.286', '-17075.444']
wt ['1990.750', '-28956.286', '-1740.286', '-17075.444']

read duration: 0.968 seconds, rate: 10.3 Hz
raw ['1995.625', '-28946.571', '-1719.857', '-17096.286']
wt ['1995.625', '-28946.571', '-1719.857', '-17096.286']

read duration: 0.978 seconds, rate: 10.2 Hz
raw ['1998.714', '-28930.429', '-1654.143', '-17004.875']
wt ['1998.714', '-28930.429', '-1654.143', '-17004.875']

--snap--

Next step: connect each load cell to its own HX711 as shown here:

../../_images/circuit_hx711_load_cell.jpg

Circuit one cell connected to one hx711. Origin: Arduino Scale with HX711 and 50kg Bathroom Scale Load Cells | Step by Step Guide | Connecting one load cell#

Realization of REST-API with OpenAPI specificatin using FastAPI.

Constraints: Use Python, use FastAPI. Realize the persistent storage via json files. Expose OpenAPI description.

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from typing import Dict
import json
import os
from pathlib import Path
import uvicorn

# Import sensor reading logic
from weposture.__main__ import get_sensor_values

CONFIG_FILE = Path("config.json")

def load_config():
    if CONFIG_FILE.exists():
        with open(CONFIG_FILE) as f:
            return json.load(f)
    return {"offsets": {"left": 0.0, "right": 0.0, "center": 0.0}, "k_factors": {"left": 1.0, "right": 1.0, "center": 1.0}}

def save_config(cfg):
    with open(CONFIG_FILE, 'w') as f:
        json.dump(cfg, f, indent=2)

app = FastAPI(title="WePosture API", version="1.0")

config = load_config()

class SensorValues(BaseModel):
    left: float
    right: float
    center: float

class Vector(BaseModel):
    x: float
    y: float
    value: float

class PostureData(BaseModel):
    left: Vector
    right: Vector
    center: Vector

class CalibrateInput(BaseModel):
    mass_kg: float

@app.get("/live", response_model=PostureData)
def get_live_data():
    raw = get_sensor_values()
    try:
        adjusted = {
            k: (raw[k] - config['offsets'][k]) * config['k_factors'][k]
            for k in ['left', 'right', 'center']
        }
        # Placeholder positions based on domain logic
        positions = {
            "left": {"x": -100, "y": -50},
            "right": {"x": 100, "y": -50},
            "center": {"x": 0, "y": 0},
        }
        return PostureData(**{
            k: Vector(x=positions[k]["x"], y=positions[k]["y"], value=adjusted[k])
            for k in adjusted
        })
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

@app.post("/zero")
def zero_system():
    raw = get_sensor_values()
    config['offsets'] = raw
    save_config(config)
    return {"status": "ok", "message": "System zeroed"}

@app.post("/calibrate")
def calibrate_system(data: CalibrateInput):
    raw = get_sensor_values()
    # Compute average value as sum of all for simplicity (can be refined)
    total_raw = sum(raw.values())
    if total_raw == 0:
        raise HTTPException(status_code=400, detail="Sensor values are all zero, cannot calibrate.")
    for k in raw:
        config['k_factors'][k] = (data.mass_kg * 9.81 * (raw[k] - config['offsets'][k]) / raw[k]) if raw[k] != 0 else 1.0
    save_config(config)
    return {"status": "ok", "message": "Calibration done", "k_factors": config['k_factors']}

if __name__ == "__main__":
    uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)

Frontend#

Constraints: JavaScript, React, React-Router, Ant-Design, React-Icons / Ant-Icons, Redux, RTK-Query. if necesarry: React-Three-Fiber, Drei, React-Plotly.

import React, { useEffect } from 'react';
import { BrowserRouter as Router, Route, Routes } from 'react-router-dom';
import { Button } from 'antd';
import { useKeyPress } from 'ahooks';
import { useDispatch } from 'react-redux';
import { useGetPostureDataQuery, useZeroSystemMutation } from './api/postureApi';
import './App.css';

const origin = { x: 0, y: 0 };

const FloatingCircle = ({ x, y, value, color }) => {
const style = {
    position: 'absolute',
    left: `calc(50% + ${x}px)`,
    top: `calc(50% - ${y}px)`,
    transform: 'translate(-50%, -50%)',
    borderRadius: '50%',
    border: `2px solid ${color}`,
    padding: '10px',
    background: 'rgba(255,255,255,0.8)',
    fontWeight: 'bold',
};
return <div style={style}>{value}</div>;
};

const LiveView = () => {
const { data, refetch } = useGetPostureDataQuery();
const [zeroSystem] = useZeroSystemMutation();

useKeyPress('space', () => zeroSystem());

const handleZero = () => {
    zeroSystem();
};

return (
    <div className="canvas-wrapper">
    <div className="feet-overlay">
        <img src="/left_foot.svg" alt="left foot" className="foot left" />
        <img src="/right_foot.svg" alt="right foot" className="foot right" />
    </div>
    <div className="fixed-circle" />

    {data && (
        <>
        <FloatingCircle x={data.left.x} y={data.left.y} value={data.left.value} color="red" />
        <FloatingCircle x={data.center.x} y={data.center.y} value={data.center.value} color="blue" />
        <FloatingCircle x={data.right.x} y={data.right.y} value={data.right.value} color="green" />
        </>
    )}

    <Button onClick={handleZero} className="zero-button">Zero</Button>
    </div>
);
};

const App = () => {
return (
    <Router>
    <Routes>
        <Route path="/" element={<LiveView />} />
    </Routes>
    </Router>
);
};

export default App;

Operating System#

Flash SD-Card with Raspberry Pi OS Lite 64-bit:

../../_images/initial_os_settings.png

Initial OS settings on Raspberry Pi OS Lite 64-bit#

License#

This maker-project is licensed under CC BY-SA 4.0, copyright 2024 Alexander Mann-Wahrenberg (basejumpa).

The derived software “WeBalance”, split apart as own repository at WeBalance, is licensed under MIT.

Donations welcome (see coffee cup icon top right :-)).