文件预览

render_weight_png.py

查看 Baby Tracker 技能包中的文件内容。

文件内容

scripts/render_weight_png.py

#!/usr/bin/env python3
"""Render a WHO weight-for-age chart as a PNG using only Python stdlib."""
from __future__ import annotations

import argparse, csv, json, math, os, statistics, struct, zlib
from pathlib import Path
from datetime import datetime, date

DEFAULT_DATA_DIR = Path(os.environ.get("BABY_TRACKER_DIR", Path.home() / ".openclaw" / "workspace" / "data" / "baby-tracker"))
W,H = 1400,900
ND = statistics.NormalDist()

FONT = {
' ': ['000','000','000','000','000','000','000'],'.': ['000','000','000','000','000','110','110'],
'-': ['000','000','000','111','000','000','000'],':': ['000','110','110','000','110','110','000'],
'0': ['111','101','101','101','101','101','111'],'1': ['010','110','010','010','010','010','111'],
'2': ['111','001','001','111','100','100','111'],'3': ['111','001','001','111','001','001','111'],
'4': ['101','101','101','111','001','001','001'],'5': ['111','100','100','111','001','001','111'],
'6': ['111','100','100','111','101','101','111'],'7': ['111','001','001','010','010','100','100'],
'8': ['111','101','101','111','101','101','111'],'9': ['111','101','101','111','001','001','111'],
'A': ['010','101','101','111','101','101','101'],'B': ['110','101','101','110','101','101','110'],
'C': ['111','100','100','100','100','100','111'],'D': ['110','101','101','101','101','101','110'],
'E': ['111','100','100','110','100','100','111'],'F': ['111','100','100','110','100','100','100'],
'G': ['111','100','100','101','101','101','111'],'H': ['101','101','101','111','101','101','101'],
'I': ['111','010','010','010','010','010','111'],'J': ['111','001','001','001','001','101','111'],
'K': ['101','101','110','100','110','101','101'],'L': ['100','100','100','100','100','100','111'],
'M': ['101','111','111','101','101','101','101'],'N': ['101','111','111','111','101','101','101'],
'O': ['111','101','101','101','101','101','111'],'P': ['111','101','101','111','100','100','100'],
'Q': ['111','101','101','101','111','001','001'],'R': ['110','101','101','110','101','101','101'],
'S': ['111','100','100','111','001','001','111'],'T': ['111','010','010','010','010','010','010'],
'U': ['101','101','101','101','101','101','111'],'V': ['101','101','101','101','101','101','010'],
'W': ['101','101','101','101','111','111','101'],'X': ['101','101','101','010','101','101','101'],
'Y': ['101','101','101','010','010','010','010'],'Z': ['111','001','001','010','100','100','111'],
'/': ['001','001','010','010','010','100','100'],'%': ['101','001','010','010','010','100','101'],
'(': ['001','010','100','100','100','010','001'],')': ['100','010','001','001','001','010','100'],
}
for k,v in list(FONT.items()): FONT[k.lower()] = v

img = bytearray([248,250,252]) * (W*H)
def rgb(h): h=h.lstrip('#'); return tuple(int(h[i:i+2],16) for i in (0,2,4))
def px(x,y,c):
    if 0 <= x < W and 0 <= y < H:
        i=(y*W+x)*3; img[i:i+3]=bytes(c)
def rect(x0,y0,x1,y1,c):
    for y in range(max(0,int(y0)), min(H,int(y1))):
        for x in range(max(0,int(x0)), min(W,int(x1))): px(x,y,c)
def line(x0,y0,x1,y1,c,width=1):
    x0=int(round(x0)); y0=int(round(y0)); x1=int(round(x1)); y1=int(round(y1))
    dx=abs(x1-x0); dy=-abs(y1-y0); sx=1 if x0<x1 else -1; sy=1 if y0<y1 else -1; err=dx+dy
    while True:
        r=width//2
        for yy in range(y0-r,y0+r+1):
            for xx in range(x0-r,x0+r+1): px(xx,yy,c)
        if x0==x1 and y0==y1: break
        e2=2*err
        if e2>=dy: err+=dy; x0+=sx
        if e2<=dx: err+=dx; y0+=sy
def circle(cx,cy,r,c,outline=None):
    cx=int(round(cx)); cy=int(round(cy))
    for y in range(cy-r-2,cy+r+3):
        for x in range(cx-r-2,cx+r+3):
            d=(x-cx)**2+(y-cy)**2
            if d <= r*r: px(x,y,c)
            if outline and r*r < d <= (r+3)*(r+3): px(x,y,outline)
def text(x,y,s,c,scale=3):
    x0=x
    for ch in s:
        pat=FONT.get(ch, FONT.get(ch.upper(), FONT[' ']))
        for row,bits in enumerate(pat):
            for col,b in enumerate(bits):
                if b=='1':
                    for yy in range(scale):
                        for xx in range(scale): px(x+col*scale+xx,y+row*scale+yy,c)
        x += 4*scale
    return x-x0
def poly(points,c,width=2):
    for (x0,y0),(x1,y1) in zip(points, points[1:]): line(x0,y0,x1,y1,c,width)


def load_lms(sex, who_lms):
    by_unit={'week': [], 'month': []}
    with who_lms.open(newline='',encoding='utf-8') as f:
        for r in csv.DictReader(f):
            if r['sex'] == sex:
                by_unit[r['age_unit']].append({k: float(r[k]) if k in {'age_days','L','M','S'} else r[k] for k in r})
    for rows in by_unit.values(): rows.sort(key=lambda r:r['age_days'])
    if not by_unit['week'] or not by_unit['month']:
        raise SystemExit(f'No WHO LMS rows found for sex={sex!r} in {who_lms}')
    return by_unit

def interp(age_days, rows):
    if age_days <= rows[0]['age_days']: return rows[0]
    if age_days >= rows[-1]['age_days']: return rows[-1]
    for a,b in zip(rows, rows[1:]):
        if a['age_days'] <= age_days <= b['age_days']:
            t=(age_days-a['age_days'])/(b['age_days']-a['age_days'])
            return {k: (a[k]+t*(b[k]-a[k]) if k in {'age_days','L','M','S'} else a[k]) for k in a}
    return rows[-1]

def lms_for_age(age_days, lms):
    # WHO offers a more granular 0-13 weeks table; use it while in range, then monthly 0-5y.
    if age_days <= lms['week'][-1]['age_days']:
        return interp(age_days, lms['week'])
    return interp(age_days, lms['month'])

def weight_for_z(age_days, z, lms):
    r=lms_for_age(age_days,lms); L,M,S=r['L'],r['M'],r['S']
    if abs(L) < 1e-9: return M * math.exp(S*z)
    return M * ((1 + L*S*z) ** (1/L))

def z_for_weight(age_days, weight, lms):
    r=lms_for_age(age_days,lms); L,M,S=r['L'],r['M'],r['S']
    if abs(L) < 1e-9: return math.log(weight/M)/S
    return ((weight/M)**L - 1)/(L*S)

def percentile_for_weight(age_days, weight, lms):
    return ND.cdf(z_for_weight(age_days, weight, lms))*100

def main():
    ap = argparse.ArgumentParser(description='Render a WHO weight-for-age PNG chart from baby-tracker CSV data')
    ap.add_argument('--data-dir', type=Path, default=DEFAULT_DATA_DIR)
    ap.add_argument('--output', type=Path)
    ap.add_argument('--who-lms', type=Path, help='Path to who_weight_lms.csv; defaults to <data-dir>/who/who_weight_lms.csv')
    ap.add_argument('--title')
    args = ap.parse_args()

    data = args.data_dir
    who_lms = args.who_lms or data / 'who' / 'who_weight_lms.csv'
    out = args.output or data / 'charts' / 'weight-latest.png'

    meta=json.loads((data/'metadata.json').read_text())
    if not meta.get('date_of_birth'):
        raise SystemExit('metadata.json must contain date_of_birth')
    dob=date.fromisoformat(meta['date_of_birth'])
    sex=meta.get('sex') or 'female'
    baby_name = meta.get('name') or 'Baby'
    lms=load_lms(sex, who_lms)
    weights=[]
    with (data/'events.csv').open(newline='',encoding='utf-8') as f:
        for r in csv.DictReader(f):
            if r.get('metric')=='weight' and r.get('value') and (r.get('unit') in ('kg','')):
                ts=datetime.fromisoformat(r['timestamp_local'])
                age=(ts.date()-dob).days + ts.hour/24 + ts.minute/1440
                weights.append((age,float(r['value']),ts.strftime('%d %b')))
    weights=sorted(weights)
    if not weights:
        raise SystemExit('No weight events found in events.csv')
    latest_age, latest_value, latest_label = weights[-1]
    latest_pct = percentile_for_weight(latest_age, latest_value, lms)
    latest_z = z_for_weight(latest_age, latest_value, lms)

    xmin=max(0, latest_age-45); xmax=latest_age+8
    visible=[w for w in weights if xmin <= w[0] <= xmax]
    curve_ps=[3,15,50,85,97]
    plot_vals=[v for _,v,_ in visible]
    for day in range(int(xmin), int(xmax)+1):
        for p in curve_ps:
            plot_vals.append(weight_for_z(day, ND.inv_cdf(p/100), lms))
    ymin=max(0,min(plot_vals)-0.18); ymax=max(plot_vals)+0.28

    ml,mr,mt,mb=135,125,145,155; pw=W-ml-mr; ph=H-mt-mb
    xmap=lambda x: ml+(x-xmin)/(xmax-xmin)*pw
    ymap=lambda y: mt+ph-(y-ymin)/(ymax-ymin)*ph

    rect(28,28,W-28,H-28,rgb('#ffffff'))
    title = args.title or f'{baby_name} weight recent view'
    text(ml,48,title,rgb('#0f172a'),5)
    text(ml,88,f'Current {latest_value:.2f}kg at {latest_age:.0f}d WHO {latest_pct:.1f}%',rgb('#0f766e'),4)
    text(ml,121,'Official WHO weight-for-age LMS/z-score reference',rgb('#475569'),3)

    for i in range(6):
        yv=ymin+(ymax-ymin)*i/5; yy=ymap(yv)
        line(ml,yy,W-mr,yy,rgb('#e2e8f0'),1)
        text(42,int(yy)-11,f'{yv:.1f}',rgb('#64748b'),3)
    for i in range(6):
        xv=xmin+(xmax-xmin)*i/5; xx=xmap(xv)
        line(xx,mt,xx,H-mb,rgb('#e2e8f0'),1)
        text(int(xx)-30,H-mb+30,f'{int(round(xv))}d',rgb('#64748b'),3)
    line(ml,H-mb,W-mr,H-mb,rgb('#334155'),2); line(ml,mt,ml,H-mb,rgb('#334155'),2)
    text(W//2-110,H-54,'Age in days',rgb('#334155'),4)
    text(44,mt+8,'kg',rgb('#334155'),4)

    colors={3:'#c7d2fe',15:'#93c5fd',50:'#64748b',85:'#f9a8d4',97:'#f0abfc'}
    for p in curve_ps:
        z=ND.inv_cdf(p/100)
        pts=[]
        for step in range(0, 240):
            age=xmin+(xmax-xmin)*step/239
            pts.append((xmap(age), ymap(weight_for_z(age,z,lms))))
        poly(pts,rgb(colors[p]),2)
        text(int(pts[-1][0])+10,int(pts[-1][1])-10,f'P{p}',rgb(colors[p]),3)

    wpts=[(xmap(a),ymap(v)) for a,v,_ in visible]
    poly(wpts,rgb('#0f766e'),5)
    for a,v,label in visible:
        circle(xmap(a),ymap(v),8,rgb('#14b8a6'),rgb('#0f766e'))

    offsets=[-58,-86,-44,-72]
    for idx,(a,v,label) in enumerate(visible):
        xx=int(xmap(a)); yy=int(ymap(v)); dy=offsets[idx % len(offsets)]
        text(xx-34,yy+dy,f'{v:.2f}',rgb('#0f766e'),3)
        line(xx,yy-13,xx,yy+dy+24,rgb('#99f6e4'),1)

    lx,ly=xmap(latest_age),ymap(latest_value)
    line(lx,ly,lx-210,ly-112,rgb('#0f766e'),2)
    text(int(lx)-390,int(ly)-154,f'Latest {latest_value:.2f}kg',rgb('#0f766e'),4)
    text(int(lx)-390,int(ly)-116,f'WHO percentile {latest_pct:.1f}',rgb('#0f766e'),4)
    text(int(lx)-390,int(ly)-82,f'z {latest_z:.2f}',rgb('#0f766e'),3)

    text(ml,H-26,'WHO reference for monitoring growth; not a diagnosis',rgb('#64748b'),3)

    out.parent.mkdir(parents=True,exist_ok=True)
    raw=b''.join(b'\x00'+bytes(img[y*W*3:(y+1)*W*3]) for y in range(H))
    def chunk(t,d): return struct.pack('>I',len(d))+t+d+struct.pack('>I',zlib.crc32(t+d)&0xffffffff)
    png=b'\x89PNG\r\n\x1a\n'+chunk(b'IHDR',struct.pack('>IIBBBBB',W,H,8,2,0,0,0))+chunk(b'IDAT',zlib.compress(raw,9))+chunk(b'IEND',b'')
    out.write_bytes(png)
    print(out)
    print(f'who_percentile={latest_pct:.2f}')
    print(f'z_score={latest_z:.3f}')
    return 0

if __name__ == '__main__':
    raise SystemExit(main())