Vector linework with weight, for the case where there is no tablet.
A stroke in SVG has one width from end to end, which is why vector drawings read as diagrams next to drawings made
by hand. lineweight models the thing a tablet measures — pressure — and expands each stroke into a filled
outline whose width varies along it, so plain SVG can carry line weight without any dependency on a drawing
application.
It exists because a language model asked to draw has no hand. Pressure cannot be felt, so it is computed from what is observable about a line, and the numbers can be calibrated against real artwork rather than guessed.
- Four brushes (
fine,ink,pencil,wash), each the same set of numbers a paint program exposes: width, opacity, dab spacing, and jitter, plus two taper curves. - A pressure model. Pressure follows four effects that a real stroke shows: it is lighter when the stroke is moving fast, lighter through a sharp turn, tapered at both ends of an open stroke, and drifting slowly underneath. Closed contours are walked once and never tapered, because a loop has no ends to taper.
- Stroke to outline. SVG cannot vary a stroke's width, so the width profile is expanded into an outline — offset the path to both sides by half the local width and fill the result. This is what a drawing application does when it expands a variable-width stroke, so the output stays plain SVG — but this implementation is the cheap version of it, and "What it is not" below has the measurements that shows where it differs.
- Whole-document inking.
inked_svg()walks an existing SVG and gives every shape large enough to be part of a silhouette a weighted contour, leaving small details alone. Extent decides, not a list of names, so a shape added later is inked without anybody remembering to. The whole SVG command set is parsed — curves, arcs, relative commands — and ancestortransformattributes are applied, because a contour drawn in local coordinates inside a transformed group lands in the wrong place. - A document, and one writer per application. The drawing is built once as layers of paths and handed to each destination in the form it opens: a script Illustrator runs, a layered PSD for SAI, and XFL for Animate.
- Calibration against real artwork.
--fitmeasures a drawing's linework, andlineweight.refmeasures a whole library of it — the distribution of ink across rows and columns, which is the appearance a brush's curves have to reproduce.check()compares a generated sheet against the library and reports the ratio, so "close enough" is a number rather than a judgement.
It is not a renderer and not a drawing program. It produces path data, and now also the files three drawing applications open; what those applications then do with a path is their business and not this library's.
The outline expansion is an approximation, and its failure mode is measured. A stroke becomes a filled polygon by offsetting the centreline along its normals, which is cheap and produces the correct appearance -- but the offset curves cross each other where the stroke curves tightly, because that is what offset curves do. Counted on six shapes:
| centreline | self-intersections in the outline |
|---|---|
| straight line | 0 |
| right angle | 0 |
| sharp V | 0 |
| hairpin (out and back) | 9 |
| overlapping zigzag | 14 |
| a smooth wave | 354 |
SVG's default fill-rule is nonzero, so an overlapping lobe fills as a union and the crossings are invisible when the
shape is filled -- which is why this has not shown up as a visible defect, and why the numbers above are worth stating
rather than discovering later. They are still wrong geometry: any boolean operation, stroke-to-path conversion, or
geometry comparison downstream sees them. A real stroke expansion engine resamples by arc length, offsets with a variable
radius, solves the joins (miter, round, bevel) and removes the self-intersections. This is not that, and the calibration
numbers in this file are about appearance, which is the thing it does produce.
It is not a general SVG processor. inked_svg() walks a document with regular expressions; the table under "Use"
lists exactly what that reaches and what it does not.
pip install -e . # add [fit] to measure images instead of only drawing
python -m lineweight --out strokes.svg
python -m lineweight --fit reference.png
from lineweight import stroke, inked_svg
# one stroke: a path in, a filled outline plus an opacity out
d, opacity = stroke([(40, 40), (140, 30), (240, 60)], 'ink')
svg = f'<path d="{d}" fill="#1A1620" opacity="{opacity:.2f}"/>'
# or ink a whole document that something else drew
inked = inked_svg(open('figure.svg').read(), min_extent=46, brush='ink', colour='#2A1E26')What inked_svg is, and what it is not. It walks a document with regular expressions rather than parsing XML, which
is a real limitation and not a detail. It works well on the vector artwork it was written against -- generated SVG, hand-
built icons, illustration exports -- and it is not a general SVG processor. What it does is measured:
| document | what happens |
|---|---|
<path d="..."/> and <path d="..."></path> |
inked |
| any path command, absolute or relative, including arcs | inked |
transform on the path, and on any <g> above it |
accumulated correctly |
a <path> inside <!-- --> |
left alone -- a commented-out shape is not part of the drawing |
a <path> inside <defs> or <clipPath> |
inked, though it is a definition rather than visible artwork |
<use href="#id"> |
the definition is inked where it is written, not where it is used |
style="transform: ..." (a CSS transform) |
ignored, so the outline is placed as though it were not there |
<svg:path>, or any namespaced element name |
not matched at all |
<rect>, <circle>, <ellipse>, <polygon>, <polyline> |
not inked: only <path> is, whatever its size |
Everything the patterns do not match is preserved verbatim -- the pass is a substitution, never a rebuild -- so an unsupported element is left as it was rather than deleted. The first version of this function did rebuild, and it took a figure's eyes with it.
SVG is the right output when the destination is a browser or a repository. It is the wrong output when the destination is an application, because a format cannot carry what it does not have: which layer a shape belongs to, whether it is a filled outline or a stroked centre line. So the drawing is built once as a document and each application is handed the form it actually opens.
from lineweight import Document, Path, Appearance, jsx_document, write_xfl
doc = Document(width=800, height=600)
doc.layer('LINE').add(Path(points=outline_points, appearance=Appearance(fill='#19151F')))
doc.layer('DETAIL').add(Path(points=[(40, 380), (360, 380)], closed=False,
appearance=Appearance(filled=False, stroke='#6E1E2E', stroke_width=2.5)))
jsx_document(doc, export_svg='out.svg') # a script Illustrator runs, rebuilding real layers and paths
write_xfl(doc, 'drawing.xfl') # Animate's own uncompressed project formatOn the command line:
python -m lineweight --bridge draw.jsx --run # Illustrator: write the script and run it
python -m lineweight --psd draw.psd # SAI: a layered PSD
python -m lineweight --xfl draw.xfl --run # Animate: write the XFL and open it
What is measured, and what is not. Illustrator 28.5 runs a generated script from a command line in about twenty
seconds and returns the drawing with its layers, fills and opacities intact, verified by reading back the SVG Illustrator
itself exported -- including that ExportType.SVG is the working constant and ExportType.SVGFORMAT does not exist in
that build.
It needs Illustrator's own first run to have been completed once. A fresh installation, or one whose settings directory has been moved aside, will start, draw its menu bar, leave the workspace blank, ignore the script argument and report nothing -- from the outside that is indistinguishable from the script being rejected, and it is what a documented "restart to fix it" does not cure. Launching Illustrator by hand once, until its home screen appears, is what settles it; after that the command-line path works every time. Written down because an afternoon went into diagnosing the script generator for a fault that was in the application's first-run state.
SAI works, and it is the destination that took the longest to reach. A generated PSD opens in SAI 1.2.6 with its
canvas, its layer, its layer name, and its pixels -- verified by looking at the screen, because SAI has no scripting
interface and no other kind of evidence exists for it. Two independent parsers agree with that reading: psd-tools and
this project's own reader both return the exact colours written.
Getting there took several rounds, and PSD-REPORT.md has the full account. The short version is that four separate
faults each produced a file that opened, parsed, and showed nothing, and every one of them survived this project's own
round-trip tests because the reader made the same mistake as the writer:
- a layer record with no
8BIMsignature -- the file-header signature written where the resource signature belongs, so no reader could find any layer at all; - row lengths written as one byte, and interleaved with the data, where the format has a table of two-byte lengths before all the rows;
- rows reversed, on a belief that layer channels are stored bottom-up, "confirmed" by an experiment that read the file back with the same assumption the writer used;
- the alpha channel inverted, which turns every opaque pixel into zero.
And one more that no amount of comparing bytes against a reference file found, because every comparison was of the wrong
question: layer.channel_bytes(-1) was called for every channel id, so channels 0, 1 and 2 each received the alpha
plane. The blobs were present, the PackBits coding was byte-identical to psd-tools', the record parsed, the offsets
lined up -- all of it verifying that the wrong data had been written faithfully. A unit test on channel_bytes() passed
throughout, because that function was correct; its caller had stopped passing the argument.
The last piece was a channel this writer did not need. A -2 channel declares a user layer mask, and SAI reads one
as a real mask and switches the layer into mask-editing mode -- so the canvas shows the mask, which is empty, while the
layer content is perfectly correct. SAI's own saved file has four channels and no -2. It was added here to match
psd-tools' output, which was the wrong writer to imitate.
Animate works, and getting there was mostly about finding out what the format actually is. Nine hand-written skeletons
opened as documents while importing nothing, because an XFL is a directory holding DOMDocument.xml beside a
marker file named after the project and containing PROXY-CS5, and Animate has to be pointed at the marker, not
the folder -- the folder opens the home screen. A document Animate saved itself supplied the rest: xflVersion="23.0",
creatorInfo="Adobe Animate", <scripts/>/<PrinterSettings/>/<publishHistory/> on the root,
layerDepthEnabled on the timeline, and a frame with no duration.
Two more things were settled by measurement rather than by reading. A shape's geometry is written twice, in a
compact edges attribute and a verbose cubics form, and only the second is not a shape. And a drawing unit is not a
scene unit: a contour spanning its canvas renders about a fortieth of the stage, so the coordinates are scaled
before they are written. A generated XFL now opens with its own scene name and canvas and the drawing's variable-width
strokes visible on the stage.
Animate has no scriptable entry point on this machine at all, which is why the bridge is a file: JSFL passed on the
command line opens as a document, FlashFactory refuses out-of-process creation, a JSFL in Configuration/Commands
does not run when a document opens, its UI Automation tree is empty, a background process cannot take its focus, and
Animate.exe drawing.svg opens the home screen. After Effects 2024 ships no XFL exporter and Illustrator has no XFL
in its scripting dictionary.
The model's numbers should not be a matter of taste, so they are fitted to real drawings rather than chosen. A reference library is a folder of artwork, measured by the same code that can measure a generated sheet, which makes the gap between them a number instead of an opinion.
from lineweight import scan, summarise, check
measurements = scan(r'D:\art\linework') # decodes PNG by hand; JPEG needs Pillow
reference = summarise([m for m in measurements if m.ink_ratio < 0.30])
print(check(generated, reference))
# {'verdicts': {'taper_ratio': 'ok', 'ink_ratio': 'off by 0.1x'}, ...}A run of ink is not necessarily a line. A full-colour illustration's dark regions read as strokes tens of pixels wide, so runs are split into lines (2-16 px) and areas, and a width that repeats on many scanlines is treated as a region rather than a stroke. A library of paintings measured naively returns a mean width that describes no line in any of them; the counts are printed so that is visible rather than plausible.
What the measurement says about the current model. The numbers are ratios of one length to another inside a single
drawing, which is the only kind of comparison that survives two pictures being different sizes. The targets come
from 276 line drawings pooled out of 929 measured images across three private collections -- character art, design
sheets and illustration files -- and the pooled summary is checked in as tests/data/corpus_summary.json so the targets
can be re-derived rather than taken on trust:
| reference, inside one drawing | the model | verdict | |
|---|---|---|---|
p90/median -- a few heavy lines among many light ones |
2.75 | 2.67 | close: the model does produce a drawing's spread |
max/median -- how far the heavy end reaches |
5.33 | 5.33 | equal |
taper_ratio |
0.42 | 0.44 | closer after recalibration, but the instrument is confounded -- see below |
Three independent collections agree, and that is the whole argument. The 26-image library used first gave
p90/median 2.750 and taper_ratio 0.410; the 262-image pool gave 2.750 and 0.423; adding seventeen character-art
sheets moved the pool to 276 and returned 2.750 and 0.421. A number that survives being computed from three separate
sets of artwork is worth more than any one of them, and this is the only kind of check available here -- the model
cannot be its own referee. That cost this project five separate faults on the way to a working PSD, four of which
survived its own round-trip tests for the same reason: a reader and a writer that share a mistake validate each other
perfectly, and a check that is only this library reading this library's output is not a check. PSD-REPORT.md has the
list.
Two of these three numbers were wrong before they were right, and both times the fault was in the experiment.
- The first comparison divided a four-stroke demo sheet by a finished illustration, at a render scale that inflated a 6.5-unit brush to 26 pixels -- past the width at which a run stops counting as a line. It reported "ink ratio 7x too sparse" and "strokes too thick", and both numbers were describing the setup.
- The second measured the model over four stroke shapes of one length, while the pressure model's speed effect is defined against each stroke's own length. Over a spread of lengths from 24 to 600 pixels -- what a drawing actually contains -- the spread the library shows appears, and the "model cannot produce a drawing's hierarchy" finding dissolved.
taper_ratio was reported as unresolved, and the investigation of it changed the model rather than the metric. Lowering
the taper floor (how thin the very tip gets) from 0.25 to 0.06 moved the measured ratio by nothing at any render
scale, which first looked like a broken metric. It is not: the floor touches only the first sample, and a run of pixels
cannot resolve that. What moves the profile is the taper length, and the length turned out to be wrong in its unit --
it was a fraction of the stroke's arc length, so one unchanged brush spent 6 px building pressure on a 40 px stroke and
640 px on a 4000 px one. Tapers are distances now, calibrated against a measured 丸ペン setting (see
RESEARCH-LINE-QUALITY.md), and the ratio moved from 0.4595 to 0.4372 over a rendered sheet of 60 strokes.
The paragraph that used to sit here predicted this correctly, and the prediction is worth keeping because the
measurement then confirmed it: a whole-stroke statistic is dominated by the difference between strokes, not by the
shape of an end. Measured on one stroke in isolation the same model reports 0.72-0.76 -- far above the corpus,
readable as "the tapers are far too shallow", and the exact opposite of the truth, because on a single long stroke the
thinnest fifth of the runs is mostly full-width runs. taper_ratio is only meaningful over a drawing. The remaining
0.437-against-0.421 gap is recorded rather than closed: lengthening the taper would close it, but the taper length is a
measured value and tuning a measurement to fit a confounded statistic is fitting the wrong way round.
- Draw a sheet of strokes with one brush: a long line pressed from light through heavy and back, the same line drawn fast and then slow, a right-angle turn, a hairpin, and a tapered flick.
python -m lineweight --fit sheet.pngprints the ink distribution.- Adjust
BRUSHESuntil the model's distribution matches. The four effects inpressures()are the dials, andlineweight.ref.checkis how you know when to stop -- but only for the metrics that actually respond to a change.
A line drawing is not a set of lines of assorted widths. It is a set of lines that mean different things, and the convention that assigns the meanings is explicit:
| role | convention | formal term | width | ink |
|---|---|---|---|---|
silhouette |
輪郭線, the outer contour | silhouette | 2.75 | #2A1C18 |
shadow |
陰影線, the shadow or form line | suggestive contour | 1.66 | #241A18 |
contour |
内部線, an interior line | contour | 1.00 | #1A1A1A |
detail |
補助線, fine hair tips, auxiliary lines | -- | 0.60 | #3A2A26 |
project.add_stroke(stroke_record(jaw, 'ink', role='silhouette')) # thicker, warmer, and it says so
project.add_stroke(stroke_record(eyelid, 'ink', role='contour'))
A role multiplies the width profile; it never replaces it, so naming a line's role does not remove the weight variation — which is the thing this library exists to produce and the thing the convention says artists forget on hair. No role is the default and means exactly the brush width, so nothing written before roles existed changed.
The widths are placed against two measured numbers, and only two of the four are anchored. From 276 line drawings
pooled out of 929 images across three independent collections: within one drawing the 90th-percentile line width is
2.75x the median and the maximum is 5.33x. contour is the anchor at 1.0, because the brush width is
calibrated to the median line width and interior lines are the most numerous; silhouette is the corpus's own
p90/median, because in a finished drawing the p90 is the outer contour; shadow and detail sit one geometric
step either side of the anchor, inside the max/median envelope. Where inside that range each role sits is not
measured — the convention gives the ordering and no magnitudes — and the module says so per role.
Every role states the trade it makes, because it is a trade and not an improvement. KEER2014 drew four characters with pressure deliberately removed and found that impressions of naturalness and potency are moved by the outline's colour and thickness rather than by the character design, while activity is moved by the design and not by the outlines. Thicker raises potency and lowers naturalness; brown raises naturalness and lowers potency. A hierarchy that only ever thickens the contour is spending naturalness to buy potency without saying so.
The colours are the other measured axis, and it is saturation rather than hue. Black and the two browns scored
positive on naturalness; green, blue and red scored negative. Reddish-brown and red are twenty-five degrees apart in
hue and land on opposite sides, so roles.measured_natural tests saturation. Every role's default ink is checked
against that axis by a test.
アンミ (CGイラストテクニック vol.9): get the form right first, and add the width variation at the colouring stage. So a role is a decision made about a stroke that already exists, not a parameter of drawing it:
project.add_stroke(stroke_record(jaw, 'ink')) # pass one: the shape, and nothing about hierarchy
project.add_stroke(stroke_record(eyelid, 'ink'))
...
project.next_turn('the line hierarchy') # pass two: what each line is for
project.assign_roles({'m0001': 'silhouette', 'm0002': 'contour'})
python -m lineweight --roles face.json --assign-role m0001=silhouette,m0002=contour
Nothing is redrawn. assign_role touches geometry['role'] and nothing else -- a test asserts the geometry is
byte-identical afterwards, because that is the property that makes it a second pass rather than a second attempt.
Every width comes from stroke_widths, which reads the role, so the same record produces a different outline; and
every ink is resolved at the point of use, so an absent appearance.colour means "the role decides". That last
part is load-bearing: resolving the role's ink when the mark was created looked right and would have made a later role
assignment silently not change the colour.
A whole second pass is one turn, so one correction undoes all of it — roles and inks, since the inks were never written down.
The model examines the drawing it was applied to, which is the only way to argue with it:
python -m lineweight --roles face.json
role n median p90 max ink
contour 87 2.62 3.50 4.35 #1A1A1A measured positive (black, or a desaturated brown)
detail 58 1.64 2.07 2.26 #3A2A26 measured positive (black, or a desaturated brown)
shadow 29 7.65 10.32 10.49 #241A18 measured positive (black, or a desaturated brown)
silhouette 114 16.62 17.12 17.35 #2A1C18 measured positive (black, or a desaturated brown)
this drawing: p90/median 4.39 max/median 4.53
corpus (276 line drawings, three collections): 2.75 5.33
outer/inner median width: 7.51 (16.21 vs 2.16)
That report is answering the first item of the drawing convention's finished-linework checklist — is there a
difference between outer and inner line width? — with the library's own measurements. It reports rather than
refuses, because putting every stroke in silhouette is a real choice: the convention names it as the chibi and
sticker look, deliberate rather than accidental. What must not happen is that it is silent.
The path this exists for: describe a body, check it, and only then draw. A generator that produces three arms has
no representation in which that is wrong -- it has marks, and no statement of what the thing was supposed to be. A
Figure is that statement, and Project.set_figure refuses one that could not exist.
python -m lineweight --standard-figure figure.json # write the reference figure to vary
python -m lineweight --check-figure figure.json # validate before drawing on it
figure 'standard'
body: 34 joints -- arm 8, axis 4, finger 12, head 2, leg 6, toe 2
layers: 12 in 3 tiers, parts body, eye_mouth, eyebrow, face, hair
no problems: this figure may be drawn on
figure 'three arms'
body: 35 joints -- arm 9, ...
2 problem(s):
Bip001 Spine1 has 4 children, a body has 3 -- an extra limb (...)
branching counts [4, 3, 3, 3], a body has [3, 3, 3, 3]
In code, the refusal is the feature:
project.set_figure(figure.standard()) # raises if the figure could not exist
project.add_stroke(record, layer='body') # every mark names its layerOnce a figure is declared, a mark's layer is required and must be one the figure declares -- a drawing whose marks
do not say which layer they are is a pile of marks. project.layers_in_use() then reports what the drawing actually
used, against what the figure declared.
A project with no figure is unaffected, which matters because every project file written before this existed is
one, and version: 2 still loads.
python tools/gate_probe.py draws a correct figure and then breaks it five ways. It is a check on structure, and it
catches every structural fault:
| probe | result |
|---|---|
| a mark that names no layer | caught -- a figure is declared, so every mark must name its layer |
| a mark on a layer the figure never declared | caught -- no such layer: 'tail' |
| a body with a third arm | caught -- Bip001 Spine1 has 4 children, a body has 3 -- an extra limb |
| marks in an order the constraints forbid | caught -- four problems, naming the pair and the rig count |
And the figure cannot look at geometry at all, which is where the errors in this project have actually been -- it reads the parent relation and the layer stack and holds no positions, so a drawing placed off the edge of its own canvas passes every structural check there is. That gap is now closed in two halves, and which half refuses and which half reports is the design:
project.geometry_errors() # refuses a box that cannot put a pixel on the page
project.extent_report() # the box and the share of the page it claims
The off-page box is refused, because it is a fault with no judgement in it: the drawing is entirely off the page: its box is (-9000.0, -9000.0)-(-8899.6, -8900.0) and the canvas is 800x600. That is the fault demo_document already
records in its own comment -- a shape that exists in every destination's data and is visible in none of them,
because no format complains about geometry off the page. Now one of them does, and --render refuses before it
rasterises.
A drawing that is merely small is reported and not refused, and that distinction is not squeamishness: a
legitimate drawing of a small object is geometrically identical to a figure drawn at a hundredth of the right
scale. What separates them is a ratio, so --render prints it and leaves it to be looked at:
every live mark: 41 of 41 marks, 800 x 600, 4095 px covered (0.9%)
extent: (273.0, 62.9)-(526.9, 539.6), 254 x 477, 25.22% of the page
A standing figure fills about a quarter of its canvas. The first end-to-end drawing, emitted in normalised units, filled 0.0000% -- and that is the number that makes it legible as a fault rather than as a small drawing.
Two facts a drawing can be held to, both read off the rigs Blue Archive ships for the same character rather than guessed at.
The body is a constant. Across fourteen characters with a 3D model, thirteen have the identical body bone set
-- 34 names, including twelve finger bones -- while total node count runs from 102 to 526. So the body is one spec
and the per-character difference is the costume worn on it. lineweight/body.py carries that table and two checks:
python -m lineweight --check-body body.json
# body.json is a JSON object of joint -> parent
- by name: the right parents, complete chains, no invented joints, no cycles, and a limb count that is wrong in either direction at the pelvis, chest and hand;
- without names at all, which is the one that works on something a generator produced:
arity_profilecompares the shape of the tree. The reference branches 3/3/3 at pelvis, chest and hand — a fourth child at the chest is a third arm, whatever the joints are called.
The layers are ordered, and the order is stored. The 2D rig is 174 slots and Spine keeps them in draw order:
BG → SakuraTree → Flower → Handkerchief_Shadow → Handkerchief → Airi_Shadow → TreeShadow → …, i.e. background,
scenery, the painting's own cast shadows, and only then the character. And the 3D asset gives every part its own
_OutlineTint and _Adjustive…Shadow, so outline and shadow are properties of the part.
python -m lineweight --check-layers layers.json
# layers.json is a JSON list of {name, tier, part, casts_for}
It catches a tier out of order, a cast shadow drawn after the thing casting it (measured: the shadow is the lower slot), a part with no outline layer, and a part split across two tiers.
Which half was missing. The 3D rig says what a body is; the 2D rig says what a drawing is made of. A generator with the first and not the second produces a correct body wearing nothing; with the second and not the first, a pile of layers with three arms. This game ships both halves for the same character, and so does this library now.
And a figure also carries its colour specification, because the production hands the three over together: J.C.STAFF's 仕上げ department describes a 色指定表 naming the colour "for each character part and each shadow".
from lineweight import cel
figure.palettes['face'] = cel.Palette(lit=(240, 225, 215), shades=[(190, 180, 205)]) # 通常色 / 1影色
figure.shadow_steps = 1 # 0 is 影無し, an ordinary production choice
figure.errors() # the band count and the shade counts have to agreeThe shadow REGIONS are not on the figure and that is deliberate. A region belongs to one drawing -- a pose, a
light direction, a cut -- while the palette and the band count belong to the character. Ghibli's diary says the
amount of shadow 「作品によって様々」, varies by work, and the 色指定表 is per character. So cel.Shadow travels
with whatever is being drawn and cel.Palette lives on the figure.
grain on a brush says how much a medium shows the tooth. A Paper says what the tooth is — without it,
"pencil on smooth cartridge" and "pencil on rough watercolour paper" are the same drawing, which is the gap the
per-stroke specification names as media simulation.
python -m lineweight --render draw.json --out draw.png --paper rough
| surface | tooth | scale | direction | bite |
|---|---|---|---|---|
default |
1.0 | 1.0 | 0.0 | 0.0 |
smooth |
0.35 | 1.0 | — | 0.0 |
drawing |
1.0 | 1.6 | — | 0.08 |
rough |
1.7 | 3.2 | — | 0.26 |
canvas |
1.5 | 2.6 | 0.9 | 0.20 |
The surface belongs to the Layer, because a buffer is a surface. Every caller that makes one can name the sheet
it paints on, and dab reads it with no signature changing — which matters because that is the hot loop and the only
place a paper has to reach.
Two amplitude fields, because they answer two questions. tooth scales what the medium reveals; bite is what
the surface removes regardless of the medium. That second one exists because of a defect: the paper was applied only
when the brush declared grain > 0, and the ink brush declares exactly 0 — so four different surfaces rendered to
byte-identical files and the feature was inert for the medium this library mostly draws in. It was found by running
the command line four times and getting the same byte count back. A rough sheet makes even a loaded pen stutter.
The default is the identity, and exactly so — tooth 1, scale 1, direction 0, bite 0 reproduces the old
sampling bit for bit, which is why Paper.at special-cases it rather than dividing by a scale of one. The four other
presets are round numbers that produce visibly different surfaces; they are not measurements, and the module says
so.
Pen pressure moves width (the dominant effect), opacity, dab spacing and a little colour jitter. Those correspond to four parameters a vector stroke already exposes, which is what makes a pressure model possible at all — and a record that can express only the first cannot stand in for the hand. A stroke record now carries:
| knob | field | absent means |
|---|---|---|
| width | width_profile (per point) |
brush.width * pressure ** brush.curve |
| opacity | alpha_profile (per point) |
brush.opacity * (0.55 + 0.45 * mean(pressure)) |
| spacing | brush.spacing |
a brush table value; not yet per stroke |
| colour | appearance.colour |
the line role's ink, or DEFAULT_INK |
| role | geometry.role |
the brush width exactly as given |
record = stroke_record(pts, 'ink', width_profile=measured_widths, alpha_profile=[1.0, ..., 0.2])
project.add_stroke(record)
Absent means absent. Every one of these defaults to empty and derives, rather than being filled in with a
computed list at creation — so a project file written before a field existed expands byte-for-byte as it did, and no
file claims a caller supplied something they never mentioned. width_profile is where a measured distribution
lands: ref.py reads width distributions out of real artwork, and the brush table is per-brush, so it cannot hold a
per-stroke measurement. A role multiplies a supplied profile rather than being overridden by it, so the second pass
still reaches the strokes that need it most.
SVG cannot carry a varying alpha, any more than it can carry a varying width. A filled path takes one
fill-opacity; the width becomes an outline because of that, and an alpha profile cannot become anything — so the
vector export uses its mean, and collapses_alpha(record) is the detectable form of the loss. Without it, a
caller who wrote a fade and got a flat export back could not tell that from a fade too subtle to see.
A field the renderer ignores is a comment, so each knob is tested by changing it and measuring the picture. The two profiles have deliberately different signatures: a width change moves both the ink and the touched-pixel count, an alpha change moves the ink and leaves the shape alone.
「私は髪を描くたび線画に強弱を付けることを忘れがち」 I tend to forget to add width variation to the line art every time I draw hair. -- DSマイル, CGイラストテクニック vol.10
Forgetting the width variation is a named, recurring failure, and a uniform line is what forgetting looks like. So it is measured:
python -m lineweight --uniform face.json
4 strokes, 強弱 = thinnest fifth over the mean (low is strong variation)
flattest 0.534 median 0.514 strongest 0.468
uniform is 1.000, the floor is 0.95; the corpus puts a whole drawing at 0.421
no uniform strokes: every line has width variation.
strong is a stroke's own thinnest fifth against its own mean, along its own length. That is the opposite of the
pooled statistic ref.measure_taper documents the failure of: a taper occupies a few percent of a stroke, so a
drawing-level ratio is dominated by differences between strokes and barely moves when the ends change -- which
means an even line among varied ones is invisible to it and obvious to this.
The floor is placed in a gap that was measured, not tuned. A flat profile scores 1.000; across 160 generated
strokes of every brush the model's flattest scores 0.910 and its median 0.555; the pooled corpus puts a whole
drawing at 0.421 with a p90 of 0.507. So 0.95 is above everything this library produces and below uniform, and a test
asserts the first half of that as a promise: stroke_record cannot produce a uniform stroke.
It can still come from the document, which is why the check lives there. A project file is the product, and a caller writing one by hand can put a flat pressure profile in it — so the tool says so rather than assuming the generator was used. The report names the strokes and how flat, and does not refuse: "this line is too even" is a correction an artist makes, not an error a program rejects.
The library marks its own homework; --judge does not. A project can be drawn a second time by cairo, which has
never heard of this library, and the two compared:
python -m lineweight --judge draw.json # both referees, with the numbers and their caveats
A. filler -- the same polygon points, two unrelated rasterisers
B. offsetter -- outline() against cairo's stroker, per stroke, with the sharpest turn on that path
Referee B reports the sharpest direction change alongside every stroke because the number is unreadable without it: agreement is expected while the path is smooth, and a disagreement at a reversal is the known invalid-loop problem rather than a regression. A 90-degree corner in the control points is not a sharp turn -- the curve is smoothed through it and the sampled turn is about 25 degrees.
--audit draws the same project through cairo with each mark in its own colour, after Metzger 2024 (CESCG)
Figure 5:
python -m lineweight --audit draw.json --out audit.png
python -m lineweight --audit draw.json --stage line --out lines.png
python -m lineweight --audit draw.json --out detail.png --zoom 250,120,44 --pixels 760
Why the colours are the point. Visual similarity hides structure. A stroke that has merged into its neighbour, or crossed itself into a pinhole, is two or three slightly-off pixels in an honest render and nobody would ever see it. Painted its own colour it becomes a hole in a solid field, and a hole in a solid field is the one kind of defect that cannot be mistaken for antialiasing. Hue steps by the golden angle so that any two strokes adjacent in draw order -- the only two that can touch -- are always furthest apart.
Output is byte-identical for the same input, including across processes, because "I looked at the render" is only evidence if the render is the same thing next time.
audit.py needs pycairo. The library does not: cairo is imported at the point of use, and import lineweight
pulls in nothing but the standard library.
The measurement code has tests; the bridges cannot, because they need an application to be installed and driven. So the
checks live in tests/tools/ and are run deliberately:
python tests/tools/stress_inked.py # inked_svg over real vector artwork: nothing lost, XML still valid
python tests/tools/where.py # do the weighted outlines land on the shape, or somewhere the transform left them
python tests/tools/verify_all.py # one end-to-end run per destination
LW_CORPUS=... python tests/tools/run_corpus.py # measure a corpus into a resumable library
python tests/tools/add_corpus.py LIB.jsonl TAG DIR # add a collection without re-measuring the rest
Each is a script rather than a test because each needs something this repository cannot supply: an Illustrator
installation, an Animate installation, a directory of artwork. Two of them are worth more than the library's own tests,
because what they check is the thing this project has got wrong most often -- output that is well formed and wrong.
where.py compares the box the outlines occupy against the box of the input geometry with its transforms applied;
stress_inked.py counts the elements that survived, because a pass that rebuilds a document deletes what it does not
understand and did exactly that once, taking a figure's eyes with it.
--fit and --fit-dir measure a drawing or a folder on the same scale as the reference library, so a sheet drawn by
hand can be read against tests/data/corpus_summary.json directly.
The corpora are private and are not here. They are collections of artwork, so what is shared is the distribution --
lengths and ratios -- and the code that produced it. The per-image measurements stay on the machine that made them, the
corpus directories are named by environment variable rather than in the source, and .gitignore refuses images,
archives and .jsonl outright, because a rule that depends on remembering is not a rule.
A UI kit is not linework. Several thousand PNGs from two game UI packs were offered to this library and are not in it. A line-width distribution describes strokes, and a UI kit is filled rectangles, gradients and sprite-sheet slices; measuring those describes the widths of nothing, and pooling them would move the targets in a direction that has nothing to do with drawing. What that material would need is a different measurement -- corner radii, border weights, a palette -- which is a different question from this one.
This file is the how-to; the research documents are the evidence. Every number this library uses came from one of them, and each states its own sample size and what it could not establish.
| document | what it holds |
|---|---|
RESEARCH-MOE.md |
moe construction -- the textbook's method, the head-body ratio, hair and folds, and the rig censuses: 296 character skeletons, 292 material sets, 63 lobby rigs. The largest of these and the origin of body.py, layers.py and figure.py |
RESEARCH-CEL.md |
cel shading -- the shadow boundary as an authored line (Ghibli, NAFCA, J.C.STAFF), the ramp parameters from three shipped shaders, and the measurement of 332 cel illustrations against a 229-work control |
RESEARCH-LINE-QUALITY.md |
line quality for Japanese-style linework: taper, 強弱, the pen models |
RESEARCH-OUTLINE.md |
prior art on stroke-to-outline |
RESEARCH-VECTORIZATION.md |
raster to vector, and why pinholes are a published open problem rather than this project's bug |
RESEARCH-SCENERY.md |
scenery and architecture |
RESEARCH-SVG-TOOLS.md |
SVG optimisers, and why SVGO is not used |
RESEARCH-SOURCES.md |
where the source material is and how to reach it |
CORPUS-CLASSIFICATION.md |
why the corpus cannot be classified, and how the extractor was calibrated. Two classifiers tried, both wrong, and five faults found on the way -- each of which produced a number that looked like a finding |
DESIGN-PROJECT.md |
what the product is: a drawing project file, not an image |
DRAWING-PROCEDURE.md |
the procedure derived from a drawing video -- and refuted by the next five, see below |
PSD-REPORT.md |
how the PSD writer works and every fault found in it |
TODO.md |
what is open, and the corrections that closed the rest |
Two of the findings live outside the repository, because they are about material that is not shared: the video
comparison is at G:\lineweight-video\videos\COMPARISON.md, beside the six analyses it summarises, and the corpus
records stay on the machine that made them. They are summarised below anyway, because a finding that lives only in
a directory nobody opens is not a finding.
Both are worth reading as records of being wrong, which is what the discipline at the end of this file is for.
The corpus. 929 pages of a game's official design materials were to be split into linework and illustrations so the line-width distribution could be measured. Two classifiers were built and both failed, for different reasons:
- a colour measure -- it separates text from pictures, not line drawings from illustrations. The assumption was that a design sheet is monochrome; it is single-hued, and a saturated pale-blue line measures a chroma of 60. Only 20 of 929 pages fell below chroma 3 and 18 of those were text.
- a line-fraction measure -- it is inverted. The top fifteen are all text pages, because it measures fine features against coarse ones, and a sparse line drawing produces long runs that land in the other bucket.
What actually separates them is ink_ratio, which is what the original rule used before it was replaced. And it
cannot separate them completely, because the pages are genuinely mixed. Which is why the recommendation written
before any of this stands: do not classify, extract -- what is needed is not "is this page a drawing" but "how wide
are the lines on it".
The extraction is calibrated against the drawing itself, since an extractor cannot be its own reference: a design sheet is ink on paper, so its widths are measurable directly. The reference is a median of 3 px. And a neural extractor does not clearly beat a trivial threshold -- 0.883 / 1.767 / 0.943 against the classical 0.667 / 1.333 / 1.000.
The videos. Six drawing-process recordings, two of them by one artist, were to reveal a working procedure. They reveal nothing of the kind. Every claim made on the way fell:
- a "four-stage procedure" read from one video, downgraded by the second;
- an event rate that looked like the artist's signature -- and was the frame rate. Events were counted per frame, so a 58 fps video scored double; normalised, the ranges overlap.
- and after six videos, not even a personal habit holds: the four by the same artist fall into two groups by themselves, and the two slowest are by different artists. These measures describe what is being drawn and how fast, not who is drawing it.
Nine, by the time the frames were extracted. The ninth belongs with these: ffmpeg dropped frames silently when
writing the full frame sequences. Two of the six videos carry repeated timestamps -- ffmpeg says so itself, "non
monotonically increasing dts to muxer" -- and the default for an image2 sequence is to discard the colliding frame.
Measured: one video declared 4021 frames and wrote 4020, another declared 10231 and wrote 10155, both with exit code
0 and nothing on stderr at -v error. -fps_mode passthrough fixes it, and the count to check against had to change
too -- from the container's nb_frames to -count_frames's nb_read_frames, because a declared count is not an
existing one.
Eight faults across the two, and every one of them produced a number that could have been written up as a result.
A missing functools.partial branch reported as eighteen unexpected keys; a wrong layer count as six keys that do not
match; an inverted output polarity as a model that draws too coarsely; an un-undone resize as an extractor that thins
lines by 62%; a threshold that kept only the line cores as beating the baseline; scale=trunc(iw/2)*2 as a no-op on a
1530-wide source; an event ring trimmed before its comparison; a path built with str.replace.
What exposed each one was looking at an intermediate artefact -- a histogram whose mean was 2.3, a contact sheet where the number said "19 line drawings" and the picture said "18 are articles", an output image where the whole character was visible while the metric said 0.0002.
The discipline they share, stated once: a number is quoted with its sample, a claim is checked against an
independent artefact, and an error found along the way is written down rather than removed. RESEARCH-MOE.md
opens with a section saying so; TODO.md ends with the list. And the corollary these two added: a quantity being
comparable along one axis does not make it comparable along another, and a boundary that looks clean may only mean
no sample happened to fall between the groups.
MIT — see LICENSE.