Skip to content

Latest commit

 

History

19 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

CS2 pattern tiers

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] |

How it works

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.

Why tiers and not a ranking

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.

What is measured, and why it is not obvious

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.

Install

Python 3.10 or newer.

pip install -r requirements.txt

Running

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.

Adding a skin

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_KEY from the config, falling back to the directory name, and SKIN_SLUG has to match the directory name;
  • the analyzer class is named after the key, so SKIN_KEY = "nocts" needs a class NoctsAnalyzer, and SKIN_KEY = "my_skin" needs MySkinAnalyzer. The class lives in the package's analyzer.py and subclasses core.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.

Configuration

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 last

label 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.

What the numbers mean

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.

What the downloader does

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 list

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.

Limits

  • 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.

Seed numbering

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.

Tests

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.

License

MIT. See LICENSE.

About

Sorts every paint seed of a CS2 skin into tiers by measuring its renders

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Contributors

Languages