Two copies of the same CS2 skin can look nothing alike. Every item carries a paint seed, a number from 0 to 999 that decides how the skin's texture lands on the model, and it never changes for the life of the item. On some skins the seed barely matters. On others it decides whether you are holding the version people queue up for or the one nobody wants, and the price follows.
Which seeds are the good ones is normally worked out by hand: someone opens a few hundred renders, sorts them by eye, and publishes a tier list. That works, and it is also slow, partial, and impossible to check.
This measures it instead. Point it at a skin, and it fetches a render of every seed, measures the thing that actually separates them, and sorts all 1000 into tiers you can look your own seed up in.
The skin it ships with is Sport Gloves | Nocts, where the question is how much of the glove is black leather rather than leopard print. Against the tier lists the community has published, it agrees on 87% of the 174 seeds those lists name, and puts every single one of the 30 seeds they call top tier into its own top tier. That the two are talking about the same seeds is itself checked: shifting the indices by one in either direction drops the agreement from 87% to about 20%.
| Pattern | Left | Right | Leather | Render |
| 141 | 81.17% | 82.68% | light | [view] |
| 136 | 81.14% | 84.68% | light | [view] |
download fetch one render per seed from the host that publishes them
analyze measure every render into skins/<skin>/output/scores.json
tier apply the skin's rules, writing tiers.json and tiers.md beside it
Analyzing reads every render and takes minutes. Tiering reads scores.json and
takes no time, so a threshold can be moved and the tier step run again on its own.
A skin ships as a package under skins/: its config says where the renders live
and where the cuts between tiers fall, its analyzer says what is being measured.
Adding a skin changes nothing outside its own folder.
Because a ranking would claim more than a render can settle. On the shipped skin the median gap between one seed and the next is a few hundredths of a point, so an ordered list of a thousand is mostly noise dressed as precision. Which group a seed falls into is stable, and it is what the thing is actually traded on.
Two properties of this particular set decide what a fair comparison is, and both were measured rather than assumed. They are worth reading before trusting a number, and they are the kind of thing any new skin should be checked for.
The frame holds two gloves, and they can differ. A seed can black out one hand and leave the other covered in print. A single number over the whole frame averages that away, so each glove is scored on its own and the seed is worth its weaker one. The published tiers agree: they separate "right hand black" from "left hand black" as different tiers.
The set holds two colourways. Mean glove brightness lands at 50.9 for 784 of the seeds and 56.5 for the other 216, and the two ranges do not touch. Measured against one average over everything, that flat step swamps the pattern and every seed of the lighter colourway reads as pale however much dark material it carries. Each colourway is its own reference, so a score says how dark a seed is for its own material. Both are found automatically, not hard coded.
Python 3.10 or newer.
pip install -r requirements.txt
python3 main.py --list # skins found under skins/
python3 main.py nocts # all three steps
python3 main.py nocts --steps download # fetch renders only
python3 main.py nocts --steps analyze # measure the renders only
python3 main.py nocts --steps tier # re-apply the tier rules only
python3 main.py nocts --steps analyze,tier # a subset, always in pipeline order
A skin is addressed by its key or by its package directory name, so nocts and
sport_gloves_nocts reach the same skin. Exit status is 0 on success, 1 on
failure, 130 when a download was interrupted.
The download step is the slow one: one request per pattern, spaced out on purpose. It is resumable, so stopping it with Ctrl+C and starting it again costs nothing beyond the file it was in the middle of.
Create a package under skins/. Nothing else in the tree changes; the entry point
finds it on disk.
skins/my_skin/
__init__.py
config.py # settings, including SKIN_KEY
analyzer.py # class MySkinAnalyzer(BaseAnalyzer)
patterns/ # created by the download step
output/ # created by the analyze and tier steps
Two conventions connect the pieces:
- the skin key is
SKIN_KEYfrom the config, falling back to the directory name, andSKIN_SLUGhas to match the directory name; - the analyzer class is named after the key, so
SKIN_KEY = "nocts"needs a classNoctsAnalyzer, andSKIN_KEY = "my_skin"needsMySkinAnalyzer. The class lives in the package'sanalyzer.pyand subclassescore.analyzer.BaseAnalyzer.
A package that breaks either one fails with a message naming the class it looked for and the classes it found instead. It never falls back to guessing.
The analyzer implements two methods:
class MySkinAnalyzer(BaseAnalyzer):
def __init__(self, patterns_dir: Path, config): ...
def parts(self) -> list[str]:
"""The field names that hold a score, in the order they are shown."""
def measure(self, pattern: int) -> dict:
"""One field per part, plus anything else worth recording."""A skin with nothing to split scores one part and is done:
from core.measure import dark_share, luminance
def parts(self):
return ["whole"]
def measure(self, pattern):
frame = self.load_frame(pattern)
return {"whole": dark_share(luminance(frame), self.reference_luminance, self.region)}BaseAnalyzer handles the rest: finding the renders, reporting the ones that are
missing, walking the range with progress, and writing scores.json. Loading a
render at the configured scale is self.load_frame(pattern). Everything under
core/measure.py is available for the measuring itself, including the alpha mask,
the per-pixel variance map, and the two splitters that find a gap between objects
in a frame or a step between colourways in a set.
skins/<skin>/config.py holds three groups of settings.
Identity:
| Setting | Meaning |
|---|---|
SKIN_KEY |
what you type on the command line, and the stem of the analyzer class name |
SKIN_NAME |
display name, used in the report |
SKIN_SLUG |
the package directory name, checked against the directory it was loaded from |
Download:
| Setting | Meaning |
|---|---|
IMAGE_URL_TEMPLATE |
URL with {pattern} and optionally {view}; its extension decides the extension on disk |
IMAGE_VIEWS |
views to fetch, for example ["front"] or ["front", "back"] |
PATTERN_RANGE |
the patterns to fetch and score, normally range(1, 1001) |
DOWNLOAD_DELAY |
seconds between requests |
REQUEST_TIMEOUT |
seconds allowed for one request |
DOWNLOAD_ATTEMPTS |
tries per file before it counts as failed |
Analysis settings belong to the skin's analyzer and are documented there.
VIEW_FOR_ANALYSIS picks the view that gets measured. The Nocts analyzer adds
four settings that decide what a score is compared against:
| Setting | Meaning |
|---|---|
PATTERN_MIN_STD |
how far a pixel must vary within a colourway to count as patterned |
COLOURWAY_MIN_GAP |
how wide a step in mean brightness makes the set two colourways |
COLOURWAY_MIN_FRAMES |
the fewest renders a colourway may hold and still be scored against itself |
SPLIT_GLOVES |
score each glove on its own and tier a pattern by the weaker one |
Tiers:
| Setting | Meaning |
|---|---|
TIER_RULES |
the tiers, strictest first, the last one open |
REPORT_TITLE |
the # line of tiers.md |
REPORT_DESCRIPTION |
the paragraph under it |
REPORT_ROWS_PER_TIER |
how many patterns per tier get a row of their own |
REPORT_EXTRA_FIELD |
{"field", "label"} for one more column read from the measurements |
REPORT_NOTES |
the caveats listed at the end |
A tier rule is tried in order and the first match wins, so they read from strictest to loosest and the last one takes whatever is left. A rule takes one of three shapes:
{"name": "T1", "min_parts": 61.0} # every part reaches the bar
{"name": "T2", "part": "right", "min": 53.0} # that one part reaches it
{"name": "T4"} # anything left, and it must be lastlabel and description are optional and only decide how the tier reads in
tiers.md. The rules are checked before anything is tiered: a name used twice,
an open rule that is not last, a closed rule that is, or a part without a
min all stop the run with a message naming the rule.
scores.json is what the analyzer measured, and names the fields that hold a
score so a reader knows which is which without being told twice:
{
"parts": ["left", "right"],
"patterns": [{"pattern": 1, "left": 57.0945, "right": 31.1736, "colourway": "dark"}]
}tiers.json is the same rows with a tier added, ordered by tier and, inside a
tier, by the weakest part downwards:
[{"pattern": 141, "left": 81.1729, "right": 82.6755, "colourway": "light", "tier": "T1"}]pattern is the paint seed, the number that decides how a skin's texture lands on
the model. It is fixed for the life of an item, so a listing that shows the same
number looks like the render it was measured from.
The part scores are whatever the skin's analyzer measures. Higher is better, by convention: tier rules are minimums, so a skin whose property reads better when low inverts it in its analyzer rather than asking every reader to remember which way round it goes. The numbers only compare patterns of the same skin measured by the same analyzer; two skins never share a scale.
| Situation | What happens |
|---|---|
| the file is already on disk and not empty | it is not requested again |
| an empty or half written file is on disk | it is fetched again |
| 404 or 410 | the pattern is recorded as not published, and is not retried |
| timeout, connection error, 5xx | retried up to DOWNLOAD_ATTEMPTS with a doubling backoff |
| 429 | waits for Retry-After, retries, and doubles the delay for the rest of the run |
| 200 that is not an image, or an empty body | recorded as failed, nothing is written |
| Ctrl+C | stops after the current file and reports how many are left |
Every body is written to a .part neighbour and moved into place, so an
interrupted run never leaves a truncated file that a later run would treat as
finished. Failures are summarised at the end; running the step again retries them
and skips everything that already succeeded.
The tier step reads scores.json and the config, and nothing else. It never
opens a render, so moving a threshold or rewording the report costs milliseconds
and does not need the image set on disk.
tiers.md opens with a table of the tiers and their sizes, then gives each tier
a section: the first REPORT_ROWS_PER_TIER patterns get a row each, with every
part score, any extra field, and a link to the render they were measured from.
The rest of the tier follows as a plain list of numbers, because the order inside
a tier is finer than the renders can settle and a table of a thousand rows would
imply otherwise.
- A part score is one number for a whole part. Two patterns in the same tier can look different, because placement is not part of the number.
- A tier boundary is a cut through a continuous measurement. A pattern sitting a point either side of one could reasonably be called either tier.
- Renders are not the game. Lighting, the viewmodel and the field of view all change what a player sees.
- Wear does not enter any of this. A pattern tiers the same at every float.
- The renders come from a third party service. If it changes its URLs or its camera, the measurements stop being comparable with an older run.
Paint seeds run 0 to 999 in game. The render host used here publishes 1 to 1000: seed 0 has no render, and 1000 is published but is outside the range the game uses. Everything in between lines up one to one, which is what the shift check above measures.
python3 -m unittest discover -s tests -t .
They use the standard library only. Nothing in them touches the network or reads the downloaded image set: the downloader is exercised against a fake HTTP layer and the rest against fabricated skins in a temporary directory.
MIT. See LICENSE.