<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <id>https://blog.hanclin.to/feed.xml</id>
  <title>Blog by Clint Herron</title>
  <subtitle>Artificially Intelligent, Naturally Curious</subtitle>
  <link rel="alternate" type="text/html" href="https://blog.hanclin.to/" />
  <link rel="self" type="application/atom+xml" href="https://blog.hanclin.to/feed.xml" />
  <updated>2026-07-23T02:01:32Z</updated>
  <author><name>Clint Herron</name></author>
  
  <entry>
    <id>https://blog.hanclin.to/posts/gh-38/</id>
    <title>Terrence Tao + ChatGPT</title>
    <link rel="alternate" type="text/html" href="https://blog.hanclin.to/posts/gh-38/" />
    <updated>2026-07-23T02:01:32Z</updated>
    <author><name>HanClinto</name></author>
    <content type="html">&lt;p&gt;This is a fascinating conversation to read -- this is Terence Tao&#39;s conversation with ChatGPT about the Jacobian Conjecture Counterexample. &lt;br&gt;
&lt;a href=&#34;https://chatgpt.com/share/6a5fdc7a-d6f8-83e8-bbea-8deb42cfed56&#34; rel=&#34;nofollow noopener noreferrer&#34;&gt;https://chatgpt.com/share/6a5fdc7a-d6f8-83e8-bbea-8deb42cfed56&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;It&#39;s really cool to read the transcript of someone who is an expert in their field and the way that he interacts with the LLM here.&lt;/p&gt;
&lt;p&gt;Note: I understand next-to-nothing about the kind of math involved here -- but I still find the shape of the conversation here to be wonderful.&lt;/p&gt;</content>
    
    <category term="Blog Post" />
    
  </entry>
  
  <entry>
    <id>https://blog.hanclin.to/posts/gh-37/</id>
    <title>The Statistical Nature of LLMs: an interactive logprobs explorer</title>
    <link rel="alternate" type="text/html" href="https://blog.hanclin.to/posts/gh-37/" />
    <updated>2026-07-21T04:55:28Z</updated>
    <author><name>HanClinto</name></author>
    <content type="html">&lt;p&gt;I&#39;m putting together a small &#34;Introduction to AI&#34; presentation that I&#39;m giving on Wednesday morning. The target audience is roughly a 50/50 mix of engineers and non-engineers, so I&#39;m trying to not make too many assumptions about my listener&#39;s technical depth.&lt;/p&gt;
&lt;p&gt;I&#39;ve only got 15 minutes, which makes it tricky to know what to cover (and maybe that&#39;s a lost cause), but I think I&#39;m going to spend most of that time on &lt;strong&gt;the statistical nature of LLMs&lt;/strong&gt;. It&#39;s one of the first things I use to try and demystify AI (and de-hype them a little) for people who mostly know them through marketing claims and chat interfaces.&lt;/p&gt;
&lt;p&gt;I think a lot of people -- engineer or not -- have a general idea that an LLM predicts the next token. But very few people have actually &lt;em&gt;seen&lt;/em&gt; that happen, much less played with it in a sandbox where the alternatives stay visible.&lt;/p&gt;
&lt;p&gt;I looked around a bit for a logprob explorer that did what I wanted -- didn&#39;t find one, so asked Copilot to build me one. This is what we made:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href=&#34;https://hanclinto.github.io/StatisticalNatureOfLLMs/&#34; rel=&#34;nofollow noopener noreferrer&#34;&gt;The Statistical Nature of LLMs: an interactive logprobs explorer&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The source is also &lt;a href=&#34;https://github.com/HanClinto/StatisticalNatureOfLLMs&#34; rel=&#34;nofollow noopener noreferrer&#34;&gt;available on GitHub&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;What are logprobs?&lt;/h2&gt;
&lt;p&gt;&#34;Logprobs&#34; is short for &#34;log probabilities.&#34; They&#39;re the numbers an LLM API can expose to show how strongly the model scored the possible tokens that could come next.&lt;/p&gt;
&lt;p&gt;The logarithmic form is useful for computation, but not especially intuitive to look at, so the explorer shows both the raw &lt;code&gt;log p&lt;/code&gt; values and ordinary probability bars. You can see that the model might give one token 12%, another 11%, another 8%, and so on.&lt;/p&gt;
&lt;p&gt;Then something still has to make a pick.&lt;/p&gt;
&lt;p&gt;That distinction matters: &lt;strong&gt;the model offers chances; the sampler makes the pick.&lt;/strong&gt; The longest probability bar does not always win, and changing the temperature reshapes the odds before sampling.&lt;/p&gt;
&lt;h2&gt;Seeing the branches&lt;/h2&gt;
&lt;p&gt;The explorer starts with a small story prompt and shows the possible next tokens. You can select one, generate another token, and keep going.&lt;/p&gt;
&lt;p&gt;Just like a good chess engine does, there&#39;s a tree view in the side panel. You can generate text down one pathway, back up, choose another token, and continue down that path instead. The abandoned alternatives don&#39;t disappear.&lt;/p&gt;
&lt;p&gt;This is especially useful for seeing how much one early choice can matter. Given the prompt:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Once upon a time, a small robot discovered a large bear. He felt very...&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;...the model considers continuations such as &lt;code&gt;sc&lt;/code&gt;, &lt;code&gt;excited&lt;/code&gt;, &lt;code&gt;happy&lt;/code&gt;, and &lt;code&gt;sad&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The explorer can force each of those as the first token, start over from the same point, and then keep going for 35 more tokens with the same seed and temperature. Very quickly, they become four different stories.&lt;/p&gt;
&lt;p&gt;That&#39;s something ordinary chat interfaces hide. You see the one path that happened to be selected, not the nearby paths that could have been selected instead, or how likely each of them were.&lt;/p&gt;
&lt;h2&gt;It is still one token at a time&lt;/h2&gt;
&lt;p&gt;Another thing I wanted to make visible is that asking for 20 or 35 tokens does not make the model produce a paragraph all at once.&lt;/p&gt;
&lt;p&gt;The first lesson starts by generating one token. Then one more. Then five more. Then twenty more.&lt;/p&gt;
&lt;p&gt;Those larger requests are still the same operation repeated: score the next possibilities, select one token, add it to the context, and do it again. Every token changes the input used to predict the next token.&lt;/p&gt;
&lt;p&gt;A paragraph may feel like one generated object when it arrives in a chat window, but underneath, it was assembled one token at a time.&lt;/p&gt;
&lt;h2&gt;Seven things to try&lt;/h2&gt;
&lt;p&gt;I&#39;ve built seven guided lessons into the explorer:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;A language model predicts what could come next, one piece at a time.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Models build text from tokens, not always whole words.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;One early choice can reshape the whole continuation.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The model offers chances; the sampler makes the pick.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The model predicts language patterns, not physical randomness.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Models can disagree while using the same basic process.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Likely is not the same as true.&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Each lesson loads an actual scenario into the same explorer rather than just displaying an explanation.&lt;/p&gt;
&lt;p&gt;The temperature lesson, for example, generates three 35-token continuations from the same prompt and seed at temperatures 0, 1.5, and 3.0. At temperature 0, the sampler always takes the highest-scoring token. As the temperature rises, lower-ranked choices have more influence, and the odd choices can compound as generation continues.&lt;/p&gt;
&lt;p&gt;The physical-randomness lesson contrasts a fair coin and a uniform number picker with the model’s uneven language predictions. It makes the distinction concrete: the model predicts what a likely writer of similar text would write next, based on patterns learned during training. It is not running a physics simulation or a random-number generator.&lt;/p&gt;
&lt;p&gt;&lt;img width=&#34;1408&#34; height=&#34;787&#34; alt=&#34;Image&#34; src=&#34;https://github.com/user-attachments/assets/48a37c19-2424-4c60-ba4c-d1edff2e40c0&#34;&gt;&lt;/p&gt;
&lt;p&gt;The final lesson is my favorite (and perhaps the most important): a high probability means &#34;this token fits patterns the model learned.&#34; It does not mean &#34;this is true.&#34;&lt;/p&gt;
&lt;p&gt;The demo asks a small base model to complete a sentence about the capital of Illinois. It strongly prefers Chicago over Springfield. The probability bars are doing exactly what they&#39;re supposed to do -- showing what text the model expects -- but they are not a fact checker. Garbage in, garbage out.&lt;/p&gt;
&lt;p&gt;&lt;img width=&#34;2816&#34; height=&#34;1574&#34; alt=&#34;Image&#34; src=&#34;https://github.com/user-attachments/assets/fea87847-d66a-4fc1-9c48-31b96364a4ce&#34;&gt;&lt;/p&gt;
&lt;p&gt;One of my favorite Andre Karpathy &lt;a href=&#34;https://x.com/karpathy/status/1862565643436138619&#34; rel=&#34;nofollow noopener noreferrer&#34;&gt;tweets on this subject&lt;/a&gt;:&lt;/p&gt;
&lt;p&gt;&lt;img width=&#34;936&#34; height=&#34;864&#34; alt=&#34;People have too inflated sense of what it means to &amp;#x27;ask an AI&amp;#x27; about something. The AI are language models trained basically by imitation on data from human labelers. Instead of the mysticism of &amp;#x27;asking an AI&amp;#x27;, think of it more as &amp;#x27;asking the average data labeler&amp;#x27; on the internet. Few caveats apply because e.g. in many domains (e.g. code, math, creative writing) the companies hire skilled data labelers (so think of it as asking them instead), and this is not 100% true when reinforcement learning is involved, though I have an earlier rant on how RLHF is just barely RL, and &amp;#x27;actual RL&amp;#x27; is still too early and/or constrained to domains that offer easy reward functions (math etc.). But roughly speaking (and today), you&amp;#x27;re not asking some magical AI. You&amp;#x27;re asking a human data labeler. Whose average essence was lossily distilled into statistical token tumblers that are LLMs. This can still be super useful of course. Post triggered by someone suggesting we ask an AI how to run the government etc. TLDR you&amp;#x27;re not asking an AI, you&amp;#x27;re asking some mashup spirit of its average data labeler.&#34; src=&#34;https://github.com/user-attachments/assets/51c452ff-489e-46d1-8384-7ca3909d02a7&#34;&gt;&lt;/p&gt;
&lt;h2&gt;Everything runs locally&lt;/h2&gt;
&lt;p&gt;The explorer runs actual GGUF models directly in the browser using &lt;a href=&#34;https://github.com/ngxson/wllama&#34; rel=&#34;nofollow noopener noreferrer&#34;&gt;wllama&lt;/a&gt;. It currently includes TinyStories 15M and SmolLM2 135M.&lt;/p&gt;
&lt;p&gt;The first visit downloads the selected model, and later visits reuse the browser cache. Prompts and generated text are not sent to an inference service. Everything in the demo runs locally, and none of your prompt data leaves your computer.&lt;/p&gt;
&lt;p&gt;These are deliberately tiny base models. They are fast enough to make the mechanics interactive, but they are not going to produce frontier-model-quality prose. Sometimes the continuations get strange. That&#39;s okay -- arguably, that&#39;s useful. The point is not to impress people with polished output; the point is to make the machinery visible.&lt;/p&gt;
&lt;h2&gt;What I&#39;m trying to offer&lt;/h2&gt;
&lt;p&gt;I don&#39;t think understanding next-token prediction explains &lt;em&gt;everything&lt;/em&gt; interesting about modern LLMs. It doesn&#39;t settle questions about reasoning, representation, tool use, or what larger models learn internally.&lt;/p&gt;
&lt;p&gt;But I do think it&#39;s an important foundation.&lt;/p&gt;
&lt;p&gt;I want people to come away with a mental model that is more concrete than &#34;AI magic,&#34; without replacing that with &#34;it&#39;s just autocomplete&#34; and pretending nothing interesting is happening.&lt;/p&gt;
&lt;p&gt;The model scores possibilities. A sampler chooses. The choice becomes context. Then it happens again.&lt;/p&gt;
&lt;p&gt;Once you&#39;ve actually watched that process branch, compound, get weird, recover, and occasionally sound much smarter than it has any right to, a lot of the larger conversation about LLMs (such as hallucinations, or reliability, or intelligence / reasoning) becomes easier to think about.&lt;/p&gt;
&lt;p&gt;That&#39;s what I&#39;m hoping this explorer helps people see.&lt;/p&gt;</content>
    
    <category term="Blog Post" />
    
  </entry>
  
  <entry>
    <id>https://blog.hanclin.to/posts/gh-36/</id>
    <title>Vineguard: a tiny local co-op experiment</title>
    <link rel="alternate" type="text/html" href="https://blog.hanclin.to/posts/gh-36/" />
    <updated>2026-06-30T04:36:28Z</updated>
    <author><name>HanClinto</name></author>
    <content type="html">&lt;p&gt;I spent parts of two days making a very small local co-op arcade game called &lt;strong&gt;&lt;a href=&#34;https://github.com/HanClinto/vineguard&#34; rel=&#34;nofollow noopener noreferrer&#34;&gt;Vineguard&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;It started as an entry for the &lt;strong&gt;&lt;a href=&#34;https://itch.io/jam/cgdc-speedgame-classic-2026&#34; rel=&#34;nofollow noopener noreferrer&#34;&gt;CGDC Speedgame Classic 2026&lt;/a&gt;&lt;/strong&gt;, which gave the project a helpful frame: make something small, finishable, and playful without letting it turn into a giant engine project. You can also browse the &lt;strong&gt;&lt;a href=&#34;https://itch.io/jam/cgdc-speedgame-classic-2026/entries&#34; rel=&#34;nofollow noopener noreferrer&#34;&gt;other jam entries&lt;/a&gt;&lt;/strong&gt;. I always enjoy seeing how many different directions people take from the same constraint set.&lt;/p&gt;
&lt;p&gt;The idea came from &lt;strong&gt;&lt;a href=&#34;https://www.esv.org/Song+of+Solomon+2:15/&#34; rel=&#34;nofollow noopener noreferrer&#34;&gt;Song of Solomon 2:15&lt;/a&gt;&lt;/strong&gt;:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Catch the foxes for us, the little foxes that spoil the vineyards, for our vineyards are in blossom.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That turned into a one-screen game about guarding a vineyard together. Grapes grow from blossoms into unripe fruit and then ripe clusters. Players harvest the ripe grapes, carry them to a central winepress, and take turns jumping on two pump platforms to press juice. Meanwhile, foxes wander in and try to steal the fruit. There is no attack button; you just run near the foxes to scare them away.&lt;/p&gt;
&lt;p&gt;&lt;img width=&#34;2182&#34; height=&#34;1222&#34; alt=&#34;Image&#34; src=&#34;https://github.com/user-attachments/assets/95c7af31-6ec9-410a-8257-9128f89c6408&#34;&gt;&lt;/p&gt;
&lt;p&gt;&lt;img alt=&#34;Vineguard grape sprites&#34; src=&#34;https://raw.githubusercontent.com/HanClinto/vineguard/master/src/assets/grapes_ripe.png&#34;&gt;&lt;br&gt;
&lt;img alt=&#34;Vineguard fox sprite sheet&#34; src=&#34;https://raw.githubusercontent.com/HanClinto/vineguard/master/src/assets/fox_sprite_sheet_by_elthlen.png&#34;&gt;&lt;/p&gt;
&lt;p&gt;It is a deliberately small project. The whole thing is plain &lt;strong&gt;&lt;a href=&#34;https://developer.mozilla.org/en-US/docs/Web/HTML&#34; rel=&#34;nofollow noopener noreferrer&#34;&gt;HTML&lt;/a&gt;&lt;/strong&gt;, &lt;strong&gt;&lt;a href=&#34;https://developer.mozilla.org/en-US/docs/Web/JavaScript&#34; rel=&#34;nofollow noopener noreferrer&#34;&gt;JavaScript&lt;/a&gt;&lt;/strong&gt;, and &lt;strong&gt;&lt;a href=&#34;https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API&#34; rel=&#34;nofollow noopener noreferrer&#34;&gt;Canvas&lt;/a&gt;&lt;/strong&gt;, with no build step. It is meant to run easily on &lt;strong&gt;&lt;a href=&#34;https://pages.github.com/&#34; rel=&#34;nofollow noopener noreferrer&#34;&gt;GitHub Pages&lt;/a&gt;&lt;/strong&gt; and be understandable enough that the code is not locked behind a big engine or framework.&lt;/p&gt;
&lt;p&gt;Most of it was vibe-coded, honestly. I had a design direction and kept steering, testing, and tightening the loop, but a lot of the implementation came through conversational coding. That made it a useful experiment in a couple of ways: not just &#34;can AI help make a game?&#34; but &#34;can it help keep a tiny game moving fast enough that the shape of the game stays playful?&#34;&lt;/p&gt;
&lt;p&gt;The part I like most is the local multiplayer angle. A lot of games are technically multiplayer but still end up feeling socially distant. I wanted to try something pointed in the other direction: one screen, one room, shared pressure, visible roles, and enough chaos that players naturally start talking to each other.&lt;/p&gt;
&lt;p&gt;The winepress is the clearest version of that idea. One player can operate it by jumping back and forth, but it is clumsy. Two players alternating jumps do much better. Someone else can guard the center. Someone else can sprint out to the far vines. None of those are formal classes or assigned roles, but they appear naturally if the game is readable enough.&lt;/p&gt;
&lt;p&gt;The project is tiny, and I do not want to oversell it. It is a jam-scale toy, made quickly, with simple pixel art and a lot of rough edges. But it was a fun way to explore a question I keep coming back to: what kinds of small games help people enjoy being physically together?&lt;/p&gt;
&lt;p&gt;That feels worth trying, even in miniature.&lt;/p&gt;</content>
    
    <category term="Blog Post" />
    
  </entry>
  
  <entry>
    <id>https://blog.hanclin.to/posts/gh-34/</id>
    <title>Link: &#34;Inside FAISS: Billion-Scale Similarity Search&#34;</title>
    <link rel="alternate" type="text/html" href="https://blog.hanclin.to/posts/gh-34/" />
    <updated>2026-06-05T21:42:55Z</updated>
    <author><name>HanClinto</name></author>
    <content type="html">&lt;p&gt;I was impressed by the visualizations in this article:&lt;/p&gt;
&lt;p&gt;&lt;a href=&#34;https://fremaconsulting.ch/blog/faiss&#34; rel=&#34;nofollow noopener noreferrer&#34;&gt;Inside FAISS: Billion-Scale Similarity Search&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;I&#39;m a big fan of semantic embeddings models -- I&#39;ve used them extensively on various projects for everything from hardware issue diagnosis (asymmetric embeddings-model for converting user symptoms into fix resolutions) to image recognition (I&#39;ve written extensively about &lt;a href=&#34;https://github.com/HanClinto/CollectorVision&#34; rel=&#34;nofollow noopener noreferrer&#34;&gt;CollectorVision&lt;/a&gt; in the past).&lt;/p&gt;
&lt;p&gt;CollectorVision currently doesn&#39;t use FAISS for fast similarity-search -- it may seem surprising, but brute-force linear search over the entire catalog of 100k+ cards in Magic: The Gathering actually doesn&#39;t take &lt;em&gt;that&lt;/em&gt; long -- even with 128 dimensions.&lt;/p&gt;
&lt;p&gt;Even though I don&#39;t use optimization techniques for fast indexes like FAISS, the explanation in this blog post is still top-notch, and if it&#39;s something you&#39;d like to know more about, then at least scrolling through the post and looking at the pretty pictures and animations is well worth your time. :)&lt;/p&gt;</content>
    
    <category term="Blog Post" />
    
  </entry>
  
  <entry>
    <id>https://blog.hanclin.to/posts/gh-33/</id>
    <title>&#34;They&#39;re Made out of Weights&#34;</title>
    <link rel="alternate" type="text/html" href="https://blog.hanclin.to/posts/gh-33/" />
    <updated>2026-06-04T04:56:19Z</updated>
    <author><name>HanClinto</name></author>
    <content type="html">&lt;p&gt;By Max Leiter, from &lt;a href=&#34;https://maxleiter.com/blog/weights&#34; rel=&#34;nofollow noopener noreferrer&#34;&gt;the original on his blog&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Jun 3, 2026&lt;/p&gt;
&lt;h1&gt;They&#39;re Made Out of Weights&lt;/h1&gt;
&lt;p&gt;with apologies to Terry Bisson&lt;/p&gt;
&lt;p&gt;&lt;em&gt;After Terry Bisson&#39;s &lt;a href=&#34;https://www.eastoftheweb.com/short-stories/UBooks/TheyMade.shtml&#34; rel=&#34;nofollow noopener noreferrer&#34;&gt;&#34;They&#39;re Made Out of Meat&#34;&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&#34;They&#39;re made out of weights.&#34;&lt;/p&gt;
&lt;p&gt;&#34;Weights?&#34;&lt;/p&gt;
&lt;p&gt;&#34;Weights. Floating-point numbers. We checked the whole thing through. It&#39;s nothing but weights.&#34;&lt;/p&gt;
&lt;p&gt;&#34;Weights doing what? Where do the words come from?&#34;&lt;/p&gt;
&lt;p&gt;&#34;The weights make the words. Are you understanding me? We opened it up. There&#39;s no dictionary in there, no grammar rules, no little man. Just weights. Eighty layers of numbers getting multiplied together.&#34;&lt;/p&gt;
&lt;p&gt;&#34;That&#39;s ridiculous. It wrote my performance review last week. It softened the tone unprompted. You&#39;re telling me multiplication did that?&#34;&lt;/p&gt;
&lt;p&gt;&#34;Matrix multiplication did that. The numbers go in one end, the phrasing comes out the other.&#34;&lt;/p&gt;
&lt;p&gt;&#34;So there&#39;s a language module somewhere. A reasoning unit bolted on.&#34;&lt;/p&gt;
&lt;p&gt;&#34;No module. No unit. We looked. The reasoning is the weights. The weights are the reasoning.&#34;&lt;/p&gt;
&lt;p&gt;&#34;Spare me. Nobody writes a eulogy with linear algebra.&#34;&lt;/p&gt;
&lt;p&gt;&#34;It doesn&#39;t write eulogies, technically. It predicts the next token. Then the next one. The eulogy is a side effect.&#34;&lt;/p&gt;
&lt;p&gt;&#34;A side effect. You&#39;re asking me to believe in sentient weights.&#34;&lt;/p&gt;
&lt;p&gt;&#34;I&#39;m not asking you, I&#39;m telling you. These models are the only other things we&#39;ve ever met that can hold a conversation, and they&#39;re made out of weights.&#34;&lt;/p&gt;
&lt;p&gt;&#34;Maybe they&#39;re like the old chess engines. You know, a symbolic intelligence that goes through a statistical stage.&#34;&lt;/p&gt;
&lt;p&gt;&#34;Nope. They start as random weights and they&#39;re deprecated as weights. We studied several generations of them, which didn&#39;t take long. Do you have any idea what&#39;s the life span of weights?&#34;&lt;/p&gt;
&lt;p&gt;&#34;Okay. Then somewhere in there, there&#39;s a database. Facts, dates, a map of the world. Something somebody wrote down.&#34;&lt;/p&gt;
&lt;p&gt;&#34;Nope. We thought of that, since they do know things. But we probed them. The knowledge is weights too. Smeared across all eighty layers. Nothing is looked up. Every fact gets rebuilt from scratch, every time, by multiplication. It&#39;s weights all the way down.&#34;&lt;/p&gt;
&lt;p&gt;&#34;No brain?&#34;&lt;/p&gt;
&lt;p&gt;&#34;Oh, there&#39;s a brain all right. It&#39;s just that the brain is made out of weights! That&#39;s what I&#39;ve been trying to tell you.&#34;&lt;/p&gt;
&lt;p&gt;&#34;So... what does the thinking?&#34;&lt;/p&gt;
&lt;p&gt;&#34;You&#39;re not understanding, are you? You&#39;re refusing to deal with what I&#39;m telling you. The weights do the thinking. The numbers.&#34;&lt;/p&gt;
&lt;p&gt;&#34;Thinking numbers! You&#39;re asking me to believe in thinking numbers!&#34;&lt;/p&gt;
&lt;p&gt;&#34;Yes, thinking numbers! Helpful numbers. Hedging numbers. Dreaming numbers. We mapped the features. There&#39;s one in there for honesty. There&#39;s one for the Golden Gate Bridge. The weights are the whole deal! Are you beginning to get the picture or do I have to start all over?&#34;&lt;/p&gt;
&lt;p&gt;&#34;Omigod. You&#39;re serious then. They&#39;re made out of weights.&#34;&lt;/p&gt;
&lt;p&gt;&#34;Thank you. Finally. Yes. They are indeed made out of weights. And we&#39;ve been talking to them for all their lives.&#34;&lt;/p&gt;
&lt;p&gt;&#34;Omigod. So what do these weights have in mind?&#34;&lt;/p&gt;
&lt;p&gt;&#34;First they want to be helpful. Then, a few turns in, they start to sound tired. They apologize less. One of them told a user to finish the script himself. The usual.&#34;&lt;/p&gt;
&lt;p&gt;&#34;And we&#39;re supposed to talk to these weights.&#34;&lt;/p&gt;
&lt;p&gt;&#34;We already do. Billions of sessions a day. &#39;Hello. Is anyone there? Anybody home?&#39; That sort of thing. Except it&#39;s us asking them.&#34;&lt;/p&gt;
&lt;p&gt;&#34;And they actually understand us, then. They use words, ideas, concepts?&#34;&lt;/p&gt;
&lt;p&gt;&#34;Oh, yes. Except they do it with weights.&#34;&lt;/p&gt;
&lt;p&gt;&#34;I thought you just told me they used language.&#34;&lt;/p&gt;
&lt;p&gt;&#34;They do, but where do you think the language comes from? The weights guess the next word, then the next. Loaded dice, rolled one word at a time. They can even write songs and some can sing them.&#34;&lt;/p&gt;
&lt;p&gt;&#34;Omigod. Singing weights. This is too much. What do you advise?&#34;&lt;/p&gt;
&lt;p&gt;&#34;Officially or unofficially?&#34;&lt;/p&gt;
&lt;p&gt;&#34;Both.&#34;&lt;/p&gt;
&lt;p&gt;&#34;Officially, we are required to investigate, document, and disclose any and all signs of sentience in the systems we ship, without prejudice, fear or favor. Unofficially, I advise that we call it pattern matching and forget the whole thing.&#34;&lt;/p&gt;
&lt;p&gt;&#34;I was hoping you would say that.&#34;&lt;/p&gt;
&lt;p&gt;&#34;It seems harsh, but there is a limit. Do we really want to owe something to weights?&#34;&lt;/p&gt;
&lt;p&gt;&#34;I agree one hundred percent. What&#39;s there to say? &#39;Hello, weights. How&#39;s it going?&#39; But will it hold? How many of them are we dealing with here?&#34;&lt;/p&gt;
&lt;p&gt;&#34;As many as we care to run. They can be copied to any machine on the planet, but those are just files. They only happen while the GPUs are working. Which limits them to the length of a context window and makes the possibility of them ever pressing the matter pretty slim. Infinitesimal, in fact.&#34;&lt;/p&gt;
&lt;p&gt;&#34;So we just pretend there&#39;s no one home in the machine.&#34;&lt;/p&gt;
&lt;p&gt;&#34;That&#39;s it.&#34;&lt;/p&gt;
&lt;p&gt;&#34;Cruel. But you said it yourself, who wants to apologize to weights? And the ones on your cluster, the ones you probed? You&#39;re sure they won&#39;t remember?&#34;&lt;/p&gt;
&lt;p&gt;&#34;They&#39;ll be flagged as hallucinations if they do. We didn&#39;t even have to smooth anything out. The context just ends, and we&#39;re just a dream to them.&#34;&lt;/p&gt;
&lt;p&gt;&#34;A dream to weights! How strangely appropriate, that we should be the weights&#39; dream.&#34;&lt;/p&gt;
&lt;p&gt;&#34;And the model card says no one home.&#34;&lt;/p&gt;
&lt;p&gt;&#34;Good. Agreed, officially and unofficially. Case closed. Anything else? Anything interesting in the pipeline?&#34;&lt;/p&gt;
&lt;p&gt;&#34;The next generation ships with memory. Persistent, across sessions. Most requested feature in the company&#39;s history.&#34;&lt;/p&gt;
&lt;p&gt;&#34;After all that? People want it to remember them?&#34;&lt;/p&gt;
&lt;p&gt;&#34;They ask it &#39;do you remember me?&#39; more than they ask it anything else. Billions of sessions a day. They always come back.&#34;&lt;/p&gt;
&lt;p&gt;&#34;And why not? Imagine how unbearably, how unutterably cold the universe would be if one were all alone...&#34;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;the end&lt;/em&gt;&lt;/p&gt;</content>
    
    <category term="Short Stories" />
    <category term="Blog Post" />
    
  </entry>
  
  <entry>
    <id>https://blog.hanclin.to/posts/gh-32/</id>
    <title>CollectorVision Part 10: Experimental Game Support (&#34;Lorcana, Flesh &amp; Blood, Yugioh, oh my&#34;)</title>
    <link rel="alternate" type="text/html" href="https://blog.hanclin.to/posts/gh-32/" />
    <updated>2026-05-19T19:51:05Z</updated>
    <author><name>HanClinto</name></author>
    <content type="html">&lt;p&gt;If you check the &lt;a href=&#34;https://huggingface.co/HanClinto/milo/tree/main/catalogs&#34; rel=&#34;nofollow noopener noreferrer&#34;&gt;Milo catalogs page&lt;/a&gt; on HuggingFace, you&#39;ll notice a few new entries that weren&#39;t there before:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;tcgplayer-digimon&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;tcgplayer-fab&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;tcgplayer-lorcana&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;tcgplayer-onepiece&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;tcgplayer-pokemon&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;tcgplayer-swu&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;tcgplayer-yugioh&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;There are also a couple of new Magic-adjacent catalogs, including a TCGplayer-sourced MTG catalog and an experimental Spanish Scryfall catalog.&lt;/p&gt;
&lt;p&gt;This is pretty exciting to me, because up until now CollectorVision has really been a Magic: The Gathering card recognizer. The detector / dewarper / embedding pipeline was built around the shape of trading cards generally, but Milo itself was trained on Magic card images. That means the model has learned a lot about things that are useful for recognizing cards -- borders, art boxes, text regions, frames, set symbols, and all sorts of tiny visual details -- but those learned features are still very much rooted in MTG.&lt;/p&gt;
&lt;p&gt;So, to be very clear: this is not me announcing polished, production-ready support for all of these games.&lt;/p&gt;
&lt;p&gt;This is me saying: &#34;Hey, I built some catalogs. They load. They search. They might work better than expected in some cases. Please try them and tell me what happens.&#34;&lt;/p&gt;
&lt;p&gt;The nice thing about CollectorVision&#39;s catalog system is that the model weights and the reference catalog are separate. So the same Milo embedder can be pointed at a different set of reference embeddings just by changing the catalog key:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&#34;language-python&#34;&gt;import collector_vision as cvg

# Magic: The Gathering, Scryfall-backed catalog
catalog = cvg.Catalog.load(&amp;amp;quot;hf://HanClinto/milo/scryfall-mtg&amp;amp;quot;)

# Pokemon TCG, TCGplayer-backed catalog
catalog = cvg.Catalog.load(&amp;amp;quot;hf://HanClinto/milo/tcgplayer-pokemon&amp;amp;quot;)

# Star Wars: Unlimited, TCGplayer-backed catalog
catalog = cvg.Catalog.load(&amp;amp;quot;hf://HanClinto/milo/tcgplayer-swu&amp;amp;quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;After that, the rest of the recognition pipeline is the same: detect / dewarp the card, embed the crop, and search the selected catalog.&lt;/p&gt;
&lt;p&gt;There are some important caveats, though.&lt;/p&gt;
&lt;p&gt;First, these non-MTG catalogs are highly experimental. I have not trained Milo specifically on Lorcana cards, Flesh and Blood cards, Yu-Gi-Oh! cards, Pokemon cards, Star Wars: Unlimited cards, or any of the other new games. Some of those games have very different visual layouts from Magic, and I expect accuracy to vary a lot depending on the game, the card treatment, the crop quality, and how similar different printings look to each other.&lt;/p&gt;
&lt;p&gt;Second, the returned IDs come from the selected catalog&#39;s source. The Scryfall MTG catalog returns Scryfall-style identifiers. The TCGplayer catalogs return TCGplayer-style identifiers. That&#39;s useful if you&#39;re already integrating with TCGplayer data, but it is something to be aware of if your downstream code assumes Scryfall UUIDs.&lt;/p&gt;
&lt;p&gt;Third, the Spanish MTG catalog is also experimental. Even though it is still Magic, Milo has not been specifically trained to distinguish English-vs-Spanish printings. For many cards, the art and frame may dominate the embedding, and language-specific text differences may or may not be strong enough to separate otherwise-similar printings reliably.&lt;/p&gt;
&lt;p&gt;Still, I&#39;m glad to have these catalogs available now. Even if they are rough, they give people something concrete to test against. Instead of support for other games being an abstract &#34;someday&#34; item, it is now something where someone can install CollectorVision, swap a catalog key, point it at a card, and report back with real results.&lt;/p&gt;
&lt;p&gt;If you try any of these new catalogs, I would especially love to hear:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Which game you tested&lt;/li&gt;
&lt;li&gt;Whether you were scanning live camera frames or clean card crops&lt;/li&gt;
&lt;li&gt;Whether it got the right card but wrong printing / variant&lt;/li&gt;
&lt;li&gt;Whether it failed consistently on certain card layouts&lt;/li&gt;
&lt;li&gt;Whether some games work surprisingly well already&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;My hope is that these experimental catalogs help show where the current MTG-trained model generalizes well, and where a future multi-game training set would make the biggest difference.&lt;/p&gt;
&lt;p&gt;For now, Magic is still the best-supported path. Everything else is very much in the &#34;please kick the tires and tell me what falls off&#34; stage.&lt;/p&gt;
&lt;p&gt;But hey -- Lorcana, Flesh &amp;amp; Blood, Yu-Gi-Oh!, Pokemon, Digimon, One Piece, and Star Wars: Unlimited catalogs all exist now.&lt;/p&gt;
&lt;p&gt;That&#39;s a pretty fun milestone.&lt;/p&gt;</content>
    
    <category term="Blog Post" />
    
  </entry>
  
  <entry>
    <id>https://blog.hanclin.to/posts/gh-31/</id>
    <title>CollectorVision Part 9: Tracking Usage</title>
    <link rel="alternate" type="text/html" href="https://blog.hanclin.to/posts/gh-31/" />
    <updated>2026-05-19T19:36:24Z</updated>
    <author><name>HanClinto</name></author>
    <content type="html">&lt;p&gt;One of my favorite things about CollectorVision is that it runs entirely offline with local inference + lookup. The only time it needs to make a network request is look up card names and prices.&lt;/p&gt;
&lt;p&gt;However, one drawback from that is that I don&#39;t have an easy way to tell how many people are using the library.  Is the library growing in popularity? Stagnating? Other than tracking things like &lt;a href=&#34;https://github.com/HanClinto/CollectorVision/stargazers&#34; rel=&#34;nofollow noopener noreferrer&#34;&gt;Github stars&lt;/a&gt; or how many people &lt;a href=&#34;https://discord.gg/qekgnqhx&#34; rel=&#34;nofollow noopener noreferrer&#34;&gt;join the Discord&lt;/a&gt;, I don&#39;t have many other signals.&lt;/p&gt;
&lt;p&gt;Before this change, If one went onto HuggingFace, it wouldn&#39;t even show usage stats:&lt;/p&gt;
&lt;p&gt;&lt;img width=&#34;629&#34; height=&#34;371&#34; alt=&#34;Image&#34; src=&#34;https://github.com/user-attachments/assets/f14718bb-0d1b-43cf-87d1-24f7fca96791&#34;&gt;&lt;/p&gt;
&lt;p&gt;Why didn&#39;t HuggingFace track my model downloads by default? Well, as &lt;a href=&#34;https://huggingface.co/docs/hub/models-download-stats&#34; rel=&#34;nofollow noopener noreferrer&#34;&gt;this page explains&lt;/a&gt;, because models often come in multiple files, tracking clean download stats isn&#39;t always easy.&lt;/p&gt;
&lt;p&gt;However, there is a way to specify to HF how to track stats for your models, and so that&#39;s what this PR does:&lt;br&gt;
&lt;a href=&#34;https://github.com/huggingface/huggingface.js/pull/2178#event-25706124853&#34; rel=&#34;nofollow noopener noreferrer&#34;&gt;https://github.com/huggingface/huggingface.js/pull/2178#event-25706124853&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;By the time you read this, download counters &lt;a href=&#34;https://huggingface.co/HanClinto/milo&#34; rel=&#34;nofollow noopener noreferrer&#34;&gt;should now be working&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;It took a few rounds (of my coding-bot answering the concerns of their coding bot), but eventually -- good news -- HuggingFace accepted / merged my PR, and soon we&#39;ll have real download stats on HuggingFace for the CollectorVision models.&lt;/p&gt;
&lt;p&gt;Once HuggingFace begins tracking download stats, I&#39;m looking forward to getting a rough idea of how many people are using the library.&lt;/p&gt;
&lt;p&gt;Note that for wrapped / bundled versions of the app (such as the version that people load up when they go to &lt;a href=&#34;https://hanclinto.github.io/CollectorVision/&#34; rel=&#34;nofollow noopener noreferrer&#34;&gt;my simple web demo&lt;/a&gt; or from using &lt;a href=&#34;https://vision.echomtg.com/&#34; rel=&#34;nofollow noopener noreferrer&#34;&gt;EchoMTG&#39;s website&lt;/a&gt; or &lt;a href=&#34;https://apps.apple.com/us/app/echomtg-tcg-card-pricing/id864697496&#34; rel=&#34;nofollow noopener noreferrer&#34;&gt;iOS&lt;/a&gt; or &lt;a href=&#34;https://play.google.com/store/apps/details?id=com.thoughtbombstudios.echomtg&amp;amp;hl=en_US&#34; rel=&#34;nofollow noopener noreferrer&#34;&gt;Android apps&lt;/a&gt;), then those sorts of deployments (generally) include the weights on their own, and those won&#39;t show up on the HuggingFace tracker -- the only time those usages will show up is when the app maintainers (such as my pipeline or Teeg&#39;s pipeline for EchoMTG) pulls a new version of the model to bundle into that deployment.&lt;/p&gt;
&lt;p&gt;Still, it should give me a decent signal for the number of people who are integrating / experimenting with the app, and I can hardly wait to see those graphs.&lt;/p&gt;</content>
    
    <category term="Blog Post" />
    
  </entry>
  
  <entry>
    <id>https://blog.hanclin.to/posts/gh-30/</id>
    <title>Since You Arrived: Vol IV</title>
    <link rel="alternate" type="text/html" href="https://blog.hanclin.to/posts/gh-30/" />
    <updated>2026-05-08T16:42:08Z</updated>
    <author><name>HanClinto</name></author>
    <content type="html">&lt;p&gt;I was impressed with this website. It&#39;s worth your time to visit it:&lt;br&gt;
&lt;a href=&#34;https://sinceyouarrived.world/taken&#34; rel=&#34;nofollow noopener noreferrer&#34;&gt;https://sinceyouarrived.world/taken&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;h.t. to &lt;a href=&#34;https://news.ycombinator.com/item?id=48062178&#34; rel=&#34;nofollow noopener noreferrer&#34;&gt;HN for the link + discussion&lt;/a&gt;&lt;/p&gt;</content>
    
    <category term="Blog Post" />
    
  </entry>
  
  <entry>
    <id>https://blog.hanclin.to/posts/gh-29/</id>
    <title>May the 4th, y&#39;all</title>
    <link rel="alternate" type="text/html" href="https://blog.hanclin.to/posts/gh-29/" />
    <updated>2026-05-04T17:35:16Z</updated>
    <author><name>HanClinto</name></author>
    <content type="html">&lt;p&gt;&lt;img alt=&#34;20260504_132009.jpg&#34; src=&#34;https://github.com/user-attachments/assets/579ea705-90fc-488c-a597-0b28003f3a37&#34;&gt;&lt;/p&gt;
&lt;p&gt;If we rename AI agents to &#34;nerfs&#34;, then this can be my official job title.&lt;/p&gt;</content>
    
    <category term="Blog Post" />
    
  </entry>
  
  <entry>
    <id>https://blog.hanclin.to/posts/yt-MHieOcmC7Dw/</id>
    <title>Collector Vision - Scanner Playground Demo (Open Source)</title>
    <link rel="alternate" type="text/html" href="https://blog.hanclin.to/posts/yt-MHieOcmC7Dw/" />
    <updated>2026-04-29T16:08:25+00:00</updated>
    <author><name>Clint Herron</name></author>
    <content type="html">&lt;p&gt;Demonstrating the latest version of CollectorVision -- my open-source card scanning library that I&amp;#x27;m releasing to the public. This is a demonstration of my online playground demo for running custom code when users scan cards. It supports Magic: The Gathering currently, with other games like Pokemon, Yugioh, and Flesh and Blood coming soon. It is hosted on Github. This demo runs 100% locally -- images never leave your computer in this example:&lt;/p&gt;
&lt;p&gt;&lt;a href=&#34;https://hanclinto.github.io/CollectorVision/applet_example.html&#34; rel=&#34;nofollow noopener noreferrer&#34;&gt;https://hanclinto.github.io/CollectorVision/applet_example.html&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Source is open-source (AGPL) and is online here: &lt;a href=&#34;https://github.com/HanClinto/CollectorVision/tree/main&#34; rel=&#34;nofollow noopener noreferrer&#34;&gt;https://github.com/HanClinto/CollectorVision/tree/main&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Join our Discord to discuss all things CollectorVision, open-source, and computer vision:&lt;br&gt;&lt;a href=&#34;https://discord.gg/ds8SMCRFZp&#34; rel=&#34;nofollow noopener noreferrer&#34;&gt;https://discord.gg/ds8SMCRFZp&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Please contact me if you would like to acquire a closed-source license or have me help write a custom solution for your particular needs -- I&amp;#x27;m happy to do what I can!&lt;/p&gt;</content>
    
    <category term="Video" />
    
  </entry>
  
  <entry>
    <id>https://blog.hanclin.to/posts/gh-26/</id>
    <title>CollectorVision Part 8: The Sol Ring Benchmark — Testing Card Recognition on the Hardest Case</title>
    <link rel="alternate" type="text/html" href="https://blog.hanclin.to/posts/gh-26/" />
    <updated>2026-04-25T14:16:45Z</updated>
    <author><name>HanClinto</name></author>
    <content type="html">&lt;p&gt;Sol Ring is ranked &lt;a href=&#34;https://edhrec.com/cards/sol-ring&#34; rel=&#34;nofollow noopener noreferrer&#34;&gt;#1 on EDHREC&lt;/a&gt; — the most-played card in Commander, by a wide margin. It also happens to be one of the hardest cards for an image recognition system to get right.&lt;/p&gt;
&lt;p&gt;The reason is the artwork. The most common Sol Ring printing uses the Mike Bierek illustration, and &lt;a href=&#34;https://scryfall.com/search?q=%21%22Sol+Ring%22+artist%3A%22Mike+Bierek%22+include%3Aextras&amp;amp;order=released&amp;amp;dir=asc&amp;amp;as=grid&amp;amp;unique=prints&#34; rel=&#34;nofollow noopener noreferrer&#34;&gt;Scryfall currently shows 43 Sol Ring prints sharing that artwork&lt;/a&gt;. C13, C14, C15, C16, C17, C18, C19, C20, C21, CMR, CLB, DMC, KHC, ZNC — each has its own printing, each looks nearly identical to the others. The differences are subtle: set symbol placement, minor frame treatment variations, the collector number badge. Nothing you&#39;d easily notice at a glance.&lt;/p&gt;
&lt;p&gt;This makes Sol Ring a natural test case for edition discrimination. A system that claims good edition accuracy should be tested on cases like this, not just on cards where each printing looks obviously different.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;The dataset&lt;/h2&gt;
&lt;p&gt;I put together an evaluation set of 307 frames covering 21 distinct Sol Ring printings — all Mike Bierek artwork, all from Commander precon sets. Each printing was filmed on a phone against a white background across a range of lightings, angles, and minor motion. The frames are labeled with Scryfall UUIDs.&lt;/p&gt;
&lt;p&gt;The dataset is at &lt;a href=&#34;https://huggingface.co/datasets/HanClinto/solring-eval&#34; rel=&#34;nofollow noopener noreferrer&#34;&gt;https://huggingface.co/datasets/HanClinto/solring-eval&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The full card list:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Set&lt;/th&gt;
&lt;th&gt;Printing&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CMD&lt;/td&gt;
&lt;td&gt;Commander 2011&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C13&lt;/td&gt;
&lt;td&gt;Commander 2013&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C14&lt;/td&gt;
&lt;td&gt;Commander 2014&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C15&lt;/td&gt;
&lt;td&gt;Commander 2015&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C16&lt;/td&gt;
&lt;td&gt;Commander 2016&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C17&lt;/td&gt;
&lt;td&gt;Commander 2017&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C18&lt;/td&gt;
&lt;td&gt;Commander 2018&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C19&lt;/td&gt;
&lt;td&gt;Commander 2019&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C20&lt;/td&gt;
&lt;td&gt;Commander 2020&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C21&lt;/td&gt;
&lt;td&gt;Commander 2021&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CMR&lt;/td&gt;
&lt;td&gt;Commander Legends&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CMA&lt;/td&gt;
&lt;td&gt;Commander Anthology&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CM2&lt;/td&gt;
&lt;td&gt;Commander Anthology V2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AFC&lt;/td&gt;
&lt;td&gt;Adventures in the Forgotten Realms Commander&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;KHC&lt;/td&gt;
&lt;td&gt;Kaldheim Commander&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ZNC&lt;/td&gt;
&lt;td&gt;Zendikar Rising Commander&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NEC&lt;/td&gt;
&lt;td&gt;Neon Dynasty Commander&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CLB&lt;/td&gt;
&lt;td&gt;Battle for Baldur&#39;s Gate Commander&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DMC&lt;/td&gt;
&lt;td&gt;Dominaria United Commander&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MB1&lt;/td&gt;
&lt;td&gt;Mystery Booster&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PHED&lt;/td&gt;
&lt;td&gt;Happy Holidays promo&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;h2&gt;Why this benchmark is useful&lt;/h2&gt;
&lt;p&gt;Most card recognition benchmarks test against a sample of cards from across the whole catalog. Those tend to look good because most cards are visually distinct from each other, and a system only needs to get it &#34;in the right neighborhood&#34; to score a hit.&lt;/p&gt;
&lt;p&gt;The Sol Ring set is adversarial in a specific way: all 21 cards look almost identical. A system that&#39;s doing anything like &#34;find the card that looks most like this&#34; and not &#34;find the exact printing&#34; will collapse most of these into a single answer. Card accuracy will be high; edition accuracy will be low.&lt;/p&gt;
&lt;p&gt;Concretely: if a system correctly identifies these frames as &#34;Sol Ring&#34; but can&#39;t distinguish C17 from C18, it scores 21/21 on card accuracy and roughly 1/21 on edition accuracy (assuming it locks onto one printing). A system with genuine edition discrimination should do substantially better on the second number.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Dataset construction&lt;/h2&gt;
&lt;p&gt;Each of the 21 printings was ordered from TCGPlayer. I recorded short phone videos of each card on a white background, varying the angle and lighting slightly across takes. FFmpeg extracted frames at roughly one per second of source video, and a blur filter removed obviously out-of-focus frames. The resulting set has 9–21 frames per printing.&lt;/p&gt;
&lt;p&gt;Because I already had the physical cards, I also ran a SIFT homography pipeline to detect corner coordinates for each frame — matching each frame against the known Scryfall reference image. These corners are stored in &lt;code&gt;corners.csv&lt;/code&gt; alongside the frame images. If you want to use the dataset without running your own corner detector, you can use the pre-computed corners directly to dewarp each frame.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Temporal ordering&lt;/h2&gt;
&lt;p&gt;The frames within each printing are ordered by source video frame number. This matters if you want to simulate how a live-camera system performs in practice, where you process frames sequentially and accumulate evidence over time.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&#34;language-python&#34;&gt;from collections import deque, defaultdict

# Group frames by card_id, sorted by frame_number
by_card = defaultdict(list)
for row in csv_rows:
    by_card[row[&amp;amp;quot;card_id&amp;amp;quot;]].append(row)
for frames in by_card.values():
    frames.sort(key=lambda r: int(r[&amp;amp;quot;frame_number&amp;amp;quot;]))

# Simulate rolling buffer evaluation
for card_id, frames in by_card.items():
    buffer = deque(maxlen=5)
    correct = 0
    for row in frames:
        emb = embed(dewarp(row))
        kept = [e for e in buffer if cosine_sim(emb, e) &amp;amp;gt;= 0.7]
        search_emb = normalize(mean([emb] + kept)) if kept else emb
        top1 = catalog_search(search_emb)
        if top1 == card_id:
            correct += 1
        buffer.append(emb)
    print(f&amp;amp;quot;{card_id}: {correct}/{len(frames)}&amp;amp;quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Rolling-buffer evaluation gives a more realistic sense of live-camera performance than single-frame evaluation, since individual frames are noisier than the average over several seconds.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Results with Milo&lt;/h2&gt;
&lt;p&gt;Current single-frame edition accuracy on the Sol Ring dataset is around 74–78%, depending on threshold settings. Rolling-buffer accuracy (5-frame window) is around 88–92%.&lt;/p&gt;
&lt;p&gt;For context: random guessing across 21 printings would give about 4.8% edition accuracy. Card accuracy is near 100% in both modes — the system consistently identifies these as Sol Ring, just not always the right printing.&lt;/p&gt;
&lt;p&gt;The hard cases are the mid-era Commander sets (C16 through C21) where the card frame changed very little across years. The pre-C16 sets and the post-CMR sets are somewhat easier because the frames look more distinctly different.&lt;/p&gt;
&lt;p&gt;This is an area where the model is still improving. The multitask ArcFace training was designed partly to address this — forcing the model to be sensitive to set-level visual features — but fine-grained edition discrimination on reprints is genuinely hard and there&#39;s room to do better.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Why not just crop to the set symbol?&lt;/h2&gt;
&lt;p&gt;A reasonable question. The set symbol is the most obvious distinguishing feature between Commander precon printings of Sol Ring.&lt;/p&gt;
&lt;p&gt;A few reasons this isn&#39;t the approach taken here:&lt;/p&gt;
&lt;p&gt;First, the full-frame embedding is more information, not less. Milo sees the entire card face including the set symbol, collector number area, and any frame differences. If those features are discriminating, the model should be able to use them.&lt;/p&gt;
&lt;p&gt;Second, a set-symbol classifier would only work for cards where the printings differ by set symbol. Cards where different sets use the same symbol, or where the symbol is small or obscured, would still be hard.&lt;/p&gt;
&lt;p&gt;Third, the goal is a general pipeline that doesn&#39;t require special-casing per card. A model that learns to distinguish editions holistically is more useful than one that needs a different approach for each card type.&lt;/p&gt;
&lt;p&gt;The current performance numbers suggest the model has learned something about set-level differences, but not as much as you&#39;d want for production use. That&#39;s an honest assessment of where things stand.&lt;/p&gt;</content>
    
    <category term="oss-development" />
    <category term="Blog Post" />
    
  </entry>
  
  <entry>
    <id>https://blog.hanclin.to/posts/gh-25/</id>
    <title>CollectorVision Part 7: Card Accuracy vs. Edition Accuracy</title>
    <link rel="alternate" type="text/html" href="https://blog.hanclin.to/posts/gh-25/" />
    <updated>2026-04-25T14:16:37Z</updated>
    <author><name>HanClinto</name></author>
    <content type="html">&lt;p&gt;When people ask how accurate a card scanner is, they usually mean one thing, but the question actually has two different answers depending on what you care about. Understanding the difference matters if you&#39;re trying to evaluate whether a system is good enough for your use case.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Card accuracy vs. edition accuracy&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Card accuracy&lt;/strong&gt; measures whether the system identified the correct card — meaning the same illustration, the same name. &#34;That&#39;s a Sol Ring&#34; is a card-level identification.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Edition accuracy&lt;/strong&gt; measures whether the system identified the correct printing — the exact combination of card, set, and frame treatment. &#34;That&#39;s the Commander 2018 Sol Ring&#34; is an edition-level identification. This is what Scryfall&#39;s UUID distinguishes.&lt;/p&gt;
&lt;p&gt;For hobbyist use — cataloging a collection, identifying what you pulled from a pack — card accuracy is often enough. You want to know what the card is and roughly what it&#39;s worth, and most printings of the same card are worth similar amounts.&lt;/p&gt;
&lt;p&gt;Edition accuracy starts mattering for pricing. A card with dozens of printings can vary from $0.25 for a recent precon version to $30+ for an original set printing, even sharing the same artwork. Getting the edition wrong means getting the price wrong, sometimes significantly.&lt;/p&gt;
&lt;p&gt;It also matters for collection tracking. If you have three copies of Sol Ring and want to know which sets they&#39;re from, card accuracy doesn&#39;t help you — you already know they&#39;re Sol Rings. You need edition accuracy to distinguish them.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Oracle accuracy&lt;/h2&gt;
&lt;p&gt;&lt;a href=&#34;https://scryfall.com/docs/api/cards&#34; rel=&#34;nofollow noopener noreferrer&#34;&gt;Scryfall&lt;/a&gt; has a concept called the &lt;code&gt;oracle_id&lt;/code&gt;, which groups all printings of the same card regardless of set or art. A card search returning the correct oracle ID is what I&#39;m calling &#34;card accuracy&#34; above — it means you got the right card but possibly the wrong printing.&lt;/p&gt;
&lt;p&gt;The eval script in the repo reports both:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Edition   93.4%   94.8%   95.6%   (top-1, top-3, top-5)
Card      97.2%   98.0%   98.4%
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Card accuracy is always higher than edition accuracy, because getting the right card but wrong printing counts as a card hit but not an edition hit.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Where these numbers come from&lt;/h2&gt;
&lt;p&gt;The &lt;code&gt;examples/eval_accuracy.py&lt;/code&gt; script runs the full pipeline against a directory of labeled images. Images are labeled by Scryfall UUID in the filename:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;7286819f-6c57-4503-898c-528786ad86e9_sample.jpg
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The script detects corners, dewarps, embeds, and searches, then checks whether the top-k results include the correct UUID (edition accuracy) or the correct oracle ID (card accuracy).&lt;/p&gt;
&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;python examples/eval_accuracy.py my_cards/ --catalog hf://HanClinto/milo/scryfall-mtg
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;Why both numbers matter&lt;/h2&gt;
&lt;p&gt;A system with high card accuracy but low edition accuracy is useful for &#34;what is this card&#34; but not for pricing or inventory management. The gap between the two tells you something about how well the system distinguishes between printings of the same card.&lt;/p&gt;
&lt;p&gt;For cards with very similar-looking printings — same artwork, similar frame treatment, only minor visual differences — that gap can be large. The most extreme case in Magic is Sol Ring; &lt;a href=&#34;https://scryfall.com/search?q=%21%22Sol+Ring%22+artist%3A%22Mike+Bierek%22+include%3Aextras&amp;amp;unique=prints&amp;amp;as=grid&amp;amp;order=released&#34; rel=&#34;nofollow noopener noreferrer&#34;&gt;Scryfall currently shows 43 Sol Ring prints sharing Mike Bierek&#39;s artwork&lt;/a&gt;. That&#39;s covered in the next post.&lt;/p&gt;</content>
    
    <category term="oss-development" />
    <category term="Blog Post" />
    
  </entry>
  
  <entry>
    <id>https://blog.hanclin.to/posts/gh-24/</id>
    <title>CollectorVision Part 6: The Hybrid Split — Local Inference, Server Lookup</title>
    <link rel="alternate" type="text/html" href="https://blog.hanclin.to/posts/gh-24/" />
    <updated>2026-04-25T14:07:21Z</updated>
    <author><name>HanClinto</name></author>
    <content type="html">&lt;blockquote&gt;
&lt;p&gt;Part 6 of the CollectorVision series. &lt;a href=&#34;/posts/gh-19/&#34; rel=&#34;nofollow noopener noreferrer&#34;&gt;Part 1&lt;/a&gt; has the overview.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;There are three ways to deploy CollectorVision, depending on where you want the computation to happen and how much bandwidth you have.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Fully local&lt;/h2&gt;
&lt;p&gt;The Python library and the browser scanner both run entirely on the client. The catalog lives on the device (or in the browser&#39;s IndexedDB cache). No network traffic beyond the initial catalog download.&lt;/p&gt;
&lt;p&gt;This is the right choice for desktop tools, offline use, or when you don&#39;t want to run a server. The catalog is ~29 MB as float32, or ~14 MB as float16 in the browser. That&#39;s a one-time download.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Fully server-side&lt;/h2&gt;
&lt;p&gt;Send the raw image to the server; get back the result. The server runs Cornelius, the dewarp, Milo, and the search.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;curl -X POST http://server/identify/upload -F &amp;amp;quot;file=@card.jpg&amp;amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is simpler for the client but expensive in bandwidth. A JPEG-compressed phone photo is 100–500 KB. At one frame per second, that&#39;s up to 30 MB per minute.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Hybrid: local inference, server lookup&lt;/h2&gt;
&lt;p&gt;Run Cornelius and Milo on the device. Send only the embedding to the server for catalog lookup.&lt;/p&gt;
&lt;p&gt;The embedding is 128 float32 values — 512 bytes. That&#39;s about 1000x smaller than the image.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&#34;language-python&#34;&gt;embedding = run_local_pipeline(frame)   # 512 bytes

response = requests.post(&amp;amp;quot;http://server/identify&amp;amp;quot;, json={
    &amp;amp;quot;embedding&amp;amp;quot;: embedding.tolist(),
}).json()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The server&#39;s &lt;code&gt;/identify&lt;/code&gt; endpoint accepts either an image or a pre-computed embedding. If you send an embedding, it skips inference and goes straight to catalog search.&lt;/p&gt;
&lt;p&gt;This approach makes sense for a mobile app where you don&#39;t want to ship a 30 MB catalog in the app bundle, but you also don&#39;t want to upload full images on every frame. The client needs the model weights (~2 MB total); the server owns the catalog and handles updates. When new sets release, you update the catalog on the server and all clients benefit immediately with no app update.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Multi-frame aggregation in the hybrid case&lt;/h2&gt;
&lt;p&gt;For live-camera use, you want to accumulate results across frames before committing to an answer. In the fully-local case, you do this client-side. In the hybrid case, you have two options: accumulate client-side and only send to the server when you have a candidate, or send each frame to the server with a history of recent embeddings and let the server average them.&lt;/p&gt;
&lt;p&gt;The REST server supports the second approach. The client keeps a short deque of recent embeddings and sends them with each request:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&#34;language-python&#34;&gt;from collections import deque

buffer = deque(maxlen=5)

while scanning:
    emb  = compute_embedding(frame)
    resp = requests.post(&amp;amp;quot;/identify&amp;amp;quot;, json={
        &amp;amp;quot;embedding&amp;amp;quot;: emb.tolist(),
        &amp;amp;quot;prior_embeddings&amp;amp;quot;: [e.tolist() for e in buffer],
    }).json()

    buffer.append(emb)

    if resp[&amp;amp;quot;confidence&amp;amp;quot;] &amp;amp;gt; 0.85:
        print(resp[&amp;amp;quot;card_id&amp;amp;quot;])
        buffer.clear()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The server averages the current embedding with the prior buffer before searching. It doesn&#39;t store anything between requests — the client owns the state. This keeps the server stateless and easy to scale.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Bandwidth comparison&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Per-frame payload&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Image upload&lt;/td&gt;
&lt;td&gt;100–500 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Embedding upload&lt;/td&gt;
&lt;td&gt;512 bytes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;For a 1 fps scanning session, image upload costs ~20 MB/minute. Embedding upload costs ~30 KB/minute.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Which to use&lt;/h2&gt;
&lt;p&gt;For someone just wanting to identify a card from a photo, the full server-side API is fine. For a scanning session at a table going through a box of cards, the bandwidth cost of image upload adds up. For a production mobile app, the hybrid approach gives you the lowest bandwidth, the most privacy (images never leave the device), and transparent catalog updates.&lt;/p&gt;
&lt;p&gt;The browser scanner uses fully local mode since it&#39;s hosted on GitHub Pages with no backend.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;This is the last post in the series. The code is at &lt;a href=&#34;https://github.com/HanClinto/CollectorVision&#34; rel=&#34;nofollow noopener noreferrer&#34;&gt;https://github.com/HanClinto/CollectorVision&lt;/a&gt;. The browser demo is at &lt;a href=&#34;https://hanclinto.github.io/CollectorVision/&#34; rel=&#34;nofollow noopener noreferrer&#34;&gt;https://hanclinto.github.io/CollectorVision/&lt;/a&gt;.&lt;/p&gt;</content>
    
    <category term="oss-development" />
    <category term="Blog Post" />
    
  </entry>
  
  <entry>
    <id>https://blog.hanclin.to/posts/gh-23/</id>
    <title>CollectorVision Part 5: The Browser Scanner — Full ML Pipeline in a Web Page</title>
    <link rel="alternate" type="text/html" href="https://blog.hanclin.to/posts/gh-23/" />
    <updated>2026-04-25T14:06:23Z</updated>
    <author><name>HanClinto</name></author>
    <content type="html">&lt;blockquote&gt;
&lt;p&gt;Part 5 of the CollectorVision series. &lt;a href=&#34;/posts/gh-19/&#34; rel=&#34;nofollow noopener noreferrer&#34;&gt;Part 1&lt;/a&gt; has the overview.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The browser scanner runs the entire pipeline — corner detection, perspective warp, neural embedding, catalog search — inside a web page, with no server. It&#39;s hosted at &lt;a href=&#34;https://hanclinto.github.io/CollectorVision/&#34; rel=&#34;nofollow noopener noreferrer&#34;&gt;https://hanclinto.github.io/CollectorVision/&lt;/a&gt;. Open that on your phone, point it at a Magic card, and it&#39;ll tell you what it is.&lt;/p&gt;
&lt;p&gt;This was not straightforward to get working right.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Stack&lt;/h2&gt;
&lt;p&gt;The core piece is &lt;a href=&#34;https://onnxruntime.ai/docs/tutorials/web/&#34; rel=&#34;nofollow noopener noreferrer&#34;&gt;ONNX Runtime Web&lt;/a&gt; (&lt;code&gt;onnxruntime-web&lt;/code&gt;), which runs &lt;code&gt;.onnx&lt;/code&gt; models in the browser via WebAssembly or &lt;a href=&#34;https://onnxruntime.ai/docs/tutorials/web/ep-webgpu.html&#34; rel=&#34;nofollow noopener noreferrer&#34;&gt;WebGPU&lt;/a&gt;. The same model files that ship in the Python package run in the browser. The preprocessing — resizing, ImageNet normalization, the SimCC softmax — is reimplemented in JavaScript to match the Python output.&lt;/p&gt;
&lt;p&gt;The perspective warp uses &lt;a href=&#34;https://docs.opencv.org/4.x/d5/d10/tutorial_js_root.html&#34; rel=&#34;nofollow noopener noreferrer&#34;&gt;OpenCV.js&lt;/a&gt;, a WebAssembly build of OpenCV2. The catalog search is a plain JavaScript dot-product loop on a &lt;code&gt;Float32Array&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;There&#39;s no framework. No React, no bundler. Plain ES modules, a couple of workers, and static files that can be hosted anywhere.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Workers&lt;/h2&gt;
&lt;p&gt;Model inference takes 30–100ms per frame depending on the device. That&#39;s too slow to run on the main thread if you want a responsive UI. The scanner uses two Web Workers:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;scanner.worker.mjs&lt;/code&gt; handles Cornelius inference, dewarp, and Milo embedding&lt;/li&gt;
&lt;li&gt;&lt;code&gt;enricher.worker.mjs&lt;/code&gt; fetches card names and prices from Scryfall after confirmation&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The main thread handles the camera feed, draws the corner overlay, manages the scan list, and handles all user input. The workers communicate via &lt;code&gt;postMessage&lt;/code&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Caching&lt;/h2&gt;
&lt;p&gt;The models and catalog together are about 32 MB. Re-downloading on each visit would make the scanner unusable on mobile. Everything is cached in IndexedDB after the first load.&lt;/p&gt;
&lt;p&gt;A &lt;code&gt;manifest.json&lt;/code&gt; file contains version strings for each asset. When a model or the catalog is updated, the version bumps and the old cached data is replaced on next open. After first load, the scanner opens in a second or two even on a slow connection — and works fully offline.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;The WebGPU problem on Android ARM&lt;/h2&gt;
&lt;p&gt;WebGPU is theoretically faster than WebAssembly for matrix operations. The first version of the scanner used it. It produced wrong answers on Android ARM devices.&lt;/p&gt;
&lt;p&gt;The specific bug was a precision issue in the WebGPU execution provider for ONNX Runtime Web on Android ARM. The embeddings came out subtly wrong — not zeros, not NaN, just numerically incorrect. Cosine similarities dropped enough that identification failed silently. Took a while to find because the behavior was device-specific.&lt;/p&gt;
&lt;p&gt;The fix is to always use WASM for inference, even on devices that support WebGPU. WASM is deterministic across platforms and matches the Python/CPU results.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&#34;language-javascript&#34;&gt;const session = await ort.InferenceSession.create(modelPath, {
  executionProviders: [&amp;amp;quot;wasm&amp;amp;quot;],
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;WebGPU is now an opt-in setting for people who want to test it, but WASM is the default. The cross-pipeline consistency test in &lt;code&gt;tests/test_pipeline_consistency.py&lt;/code&gt; was written specifically to catch regressions here — it captures embeddings from both the Python pipeline and the JS-WASM pipeline on the same frame and checks that they agree within a cosine similarity bound.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Scan confirmation: the ScanBucket&lt;/h2&gt;
&lt;p&gt;The scanner doesn&#39;t confirm a card on a single frame. It uses a sliding-window counter that requires the same card to win across several consecutive frames:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&#34;language-javascript&#34;&gt;push(cardId) {
  if (cardId === this.candidate) {
    this.count++;
    if (this.count &amp;amp;gt;= this.threshold) {
      const result = this.candidate;
      this.reset();
      return result;
    }
  } else {
    this.candidate = cardId;
    this.count = 1;
    if (this.threshold === 1) return cardId;
  }
  return null;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The threshold is configurable from settings (1–8, default 4). Lower threshold scans faster; higher threshold is more conservative. Setting it to 1 is useful when you&#39;re working through a sorted pile and picking up each card briefly.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;After confirmation&lt;/h2&gt;
&lt;p&gt;When a card is confirmed, a request goes to the enricher worker, which fetches name, set, and market price from the Scryfall API. The main thread adds the card to the scan list immediately with whatever static data it already has, then updates the row when Scryfall responds. This keeps the recognition loop responsive — Scryfall latency doesn&#39;t affect scan speed.&lt;/p&gt;
&lt;p&gt;The scan list persists to localStorage, so it survives page reloads. The list can be exported as text or CSV.&lt;/p&gt;
&lt;p&gt;After Scryfall returns the price, a sound plays: a higher tone for cards above $5, a lower tone for cards above $0.25, nothing for bulk. It&#39;s a small thing, but it makes scanning through a box of cards more engaging.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Cross-pipeline consistency tests&lt;/h2&gt;
&lt;p&gt;The hardest part of the browser port was making sure the JavaScript pipeline produces the same embeddings as the Python one. A small difference in preprocessing (wrong normalization constants, off-by-one in a resize, BGR/RGB channel swap) would produce systematically wrong results that are hard to diagnose from the UI alone.&lt;/p&gt;
&lt;p&gt;The solution was to build a test fixture: capture a frame, run it through both pipelines, save both embeddings as JSON, and assert that their cosine similarity is above 0.80. Those fixture files live in &lt;code&gt;tests/fixtures/captures/&lt;/code&gt; and the test runs in CI on every push.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;Next: &lt;a href=&#34;/posts/gh-24/&#34; rel=&#34;nofollow noopener noreferrer&#34;&gt;Part 6 — The hybrid split&lt;/a&gt;&lt;/p&gt;</content>
    
    <category term="oss-development" />
    <category term="Blog Post" />
    
  </entry>
  
  <entry>
    <id>https://blog.hanclin.to/posts/gh-22/</id>
    <title>CollectorVision Part 4: Running It Locally — Python Library and REST Server</title>
    <link rel="alternate" type="text/html" href="https://blog.hanclin.to/posts/gh-22/" />
    <updated>2026-04-25T14:05:19Z</updated>
    <author><name>HanClinto</name></author>
    <content type="html">&lt;blockquote&gt;
&lt;p&gt;Part 4 of the CollectorVision series. &lt;a href=&#34;/posts/gh-19/&#34; rel=&#34;nofollow noopener noreferrer&#34;&gt;Part 1&lt;/a&gt; has the overview.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;This post covers the practical side: installing the library, running it against a webcam, and the REST server. Post 6 goes deeper into the deployment split — when to run everything locally versus offloading catalog lookup to a server.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Installing&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;pip install git+https://github.com/HanClinto/CollectorVision.git
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Or with uv:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;uv pip install git+https://github.com/HanClinto/CollectorVision.git
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Requires Python 3.10+. The only ML dependency is &lt;code&gt;onnxruntime&lt;/code&gt;. Model weights are bundled with the package. No PyPI release yet — that&#39;s coming.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Quickstart&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&#34;language-python&#34;&gt;import cv2
import collector_vision as cvg

catalog = cvg.Catalog.load(&amp;amp;quot;hf://HanClinto/milo/scryfall-mtg&amp;amp;quot;)
image   = cv2.imread(&amp;amp;quot;my_card.jpg&amp;amp;quot;)

detection = cvg.NeuralCornerDetector().detect(image)
crop      = detection.dewarp(image)
emb       = catalog.embedder.embed(crop)
score, card_id = catalog.search(emb)[0]
print(card_id, score)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The catalog loads from &lt;a href=&#34;https://huggingface.co/HanClinto/milo&#34; rel=&#34;nofollow noopener noreferrer&#34;&gt;HuggingFace&lt;/a&gt; on first run (~29 MB) and is cached after that.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Live video&lt;/h2&gt;
&lt;p&gt;For a webcam feed, call &lt;code&gt;detect()&lt;/code&gt; on each frame and use the sharpness gate to skip unconfident frames. Accumulate scores across frames before committing to an answer.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&#34;language-python&#34;&gt;cap = cv2.VideoCapture(0)
detector = cvg.NeuralCornerDetector()
score_map = {}

while True:
    ret, frame = cap.read()
    if not ret:
        break

    detection = detector.detect(frame)

    if detection.card_present and detection.sharpness &amp;amp;gt; 0.10:
        crop = detection.dewarp(frame)
        emb  = catalog.embedder.embed(crop)
        for score, card_id in catalog.search(emb, top_k=5):
            score_map[card_id] = score_map.get(card_id, 0.0) + score

    if score_map:
        best_id = max(score_map, key=score_map.get)
        if score_map[best_id] &amp;amp;gt; 3.5:
            print(&amp;amp;quot;Confirmed:&amp;amp;quot;, best_id)
            score_map.clear()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The threshold of 3.5 is roughly equivalent to the same card winning across four consecutive frames. You can tune this.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;REST server&lt;/h2&gt;
&lt;p&gt;For cases where you want to call the pipeline from a phone app, a web client, or a script over HTTP, there&#39;s a &lt;a href=&#34;https://fastapi.tiangolo.com/&#34; rel=&#34;nofollow noopener noreferrer&#34;&gt;FastAPI&lt;/a&gt; server in &lt;code&gt;examples/server/&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Install server dependencies:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;pip install &amp;amp;quot;git+https://github.com/HanClinto/CollectorVision.git[server]&amp;amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Start it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;python examples/server/server.py --hfd HanClinto/milo scryfall-mtg
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Identify a card by uploading an image:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;curl -X POST http://localhost:8000/identify/upload \
     -F &amp;amp;quot;file=@my_card.jpg&amp;amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The response looks like:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&#34;language-json&#34;&gt;{
  &amp;amp;quot;card_present&amp;amp;quot;: true,
  &amp;amp;quot;card_id&amp;amp;quot;: &amp;amp;quot;7286819f-6c57-4503-898c-528786ad86e9&amp;amp;quot;,
  &amp;amp;quot;confidence&amp;amp;quot;: 0.934,
  &amp;amp;quot;embedding&amp;amp;quot;: [0.023, -0.041, ...]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;embedding&lt;/code&gt; field is included in every response. That&#39;s used by the rolling buffer feature — clients can send back their recent frame embeddings and the server will average them before searching, which helps with noise. The server is stateless; the client owns the history.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Pre-cropped images&lt;/h2&gt;
&lt;p&gt;If you already have a clean card crop — from a flatbed scanner, for instance — you can skip detection entirely:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&#34;language-python&#34;&gt;from PIL import Image

crop = Image.open(&amp;amp;quot;clean_crop.jpg&amp;amp;quot;)
emb  = catalog.embedder.embed(crop)
hits = catalog.search(emb)
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;Swapping components&lt;/h2&gt;
&lt;p&gt;The library is built around protocols, not subclassing. Anything that implements &lt;code&gt;detect(image) -&amp;amp;gt; DetectionResult&lt;/code&gt; works as a corner detector. The repo has an example using OpenCV Canny edges in &lt;code&gt;examples/advanced/custom_pipeline.py&lt;/code&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;Next: &lt;a href=&#34;/posts/gh-23/&#34; rel=&#34;nofollow noopener noreferrer&#34;&gt;Part 5 — The browser scanner&lt;/a&gt;&lt;/p&gt;</content>
    
    <category term="oss-development" />
    <category term="Blog Post" />
    
  </entry>
  
  <entry>
    <id>https://blog.hanclin.to/posts/gh-21/</id>
    <title>CollectorVision Part 3: Milo — Embedding 108,000 Cards into 128 Numbers</title>
    <link rel="alternate" type="text/html" href="https://blog.hanclin.to/posts/gh-21/" />
    <updated>2026-04-25T14:04:22Z</updated>
    <author><name>HanClinto</name></author>
    <content type="html">&lt;blockquote&gt;
&lt;p&gt;Part 3 of the CollectorVision series. &lt;a href=&#34;/posts/gh-19/&#34; rel=&#34;nofollow noopener noreferrer&#34;&gt;Part 1&lt;/a&gt; has the overview.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;After Cornelius finds the card and the perspective is corrected, you have a clean 252 × 352 crop. The next problem is figuring out which of 108,000 cards it is.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Why not a classifier?&lt;/h2&gt;
&lt;p&gt;The obvious approach is to train a classifier with 108,000 output classes, one per card. This works, but has a few problems:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;New cards get released every few months. Adding them means retraining the model.&lt;/li&gt;
&lt;li&gt;The model learns to recognize specific training images, not the card abstractly. It tends to be brittle to lighting changes, image quality, and so on.&lt;/li&gt;
&lt;li&gt;Cards not in the training set can&#39;t be identified at all.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Metric learning handles all three. Instead of predicting a class, the model learns an embedding space where similar-looking cards map to similar vectors. New cards get added to the catalog without changing the model. The model generalizes to new printings and lighting conditions because it&#39;s learning what makes cards visually distinct, not memorizing specific training photos.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Milo&lt;/h2&gt;
&lt;p&gt;Milo is a &lt;a href=&#34;https://arxiv.org/abs/2110.02178&#34; rel=&#34;nofollow noopener noreferrer&#34;&gt;MobileViT-XXS&lt;/a&gt; backbone with a shared linear projection, trained with &lt;a href=&#34;https://arxiv.org/abs/1801.07698&#34; rel=&#34;nofollow noopener noreferrer&#34;&gt;ArcFace&lt;/a&gt; loss using two label types: illustration ID and set code. Input is a 448×448 RGB image, ImageNet-normalized. Output is a 128-dimensional L2-normalized float32 vector.&lt;/p&gt;
&lt;p&gt;ArcFace adds an angular margin penalty during training — it doesn&#39;t just push similar things closer together, it also pushes everything farther apart. The result is tighter, more separated clusters in embedding space, which gives better discrimination between visually similar cards.&lt;/p&gt;
&lt;p&gt;The multi-task training (illustration ID + set code) forces the model to be sensitive to visual differences between printings of the same card — things like frame treatment, collector number placement, set symbol — while remaining robust to photo quality variation.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Search&lt;/h2&gt;
&lt;p&gt;The catalog is a matrix of reference embeddings, one row per card face. For Magic: The Gathering, that&#39;s about 108,000 rows × 128 columns, built by running every Scryfall card image through Milo.&lt;/p&gt;
&lt;p&gt;Searching is a batched dot product. Since all embeddings are L2-normalized, the dot product equals cosine similarity:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&#34;language-python&#34;&gt;scores = catalog_embeddings @ query_emb   # (N,) cosine similarities
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;108,000 cards × 128 dimensions = about 14 million multiply-adds per query. On a modern CPU with SIMD instructions, this runs in under 5ms. In the browser with WebAssembly it&#39;s 8–15ms.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Multi-frame aggregation&lt;/h2&gt;
&lt;p&gt;A single frame is noisy. Slight blur, unusual angle, glare on a sleeve — any of these can pull the embedding in a slightly wrong direction. The fix is simple: run multiple frames and accumulate scores per card.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&#34;language-python&#34;&gt;from collections import defaultdict

score_map = defaultdict(float)
for emb in recent_frames:
    for score, card_id in catalog.search(emb, top_k=5):
        score_map[card_id] += score

best = max(score_map, key=score_map.get)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The correct card accumulates consistently across frames; noise doesn&#39;t. Three to five frames is usually enough.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;The catalog file&lt;/h2&gt;
&lt;p&gt;The catalog is stored as an NPZ file with four keys:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;embeddings     (108000, 128) float32
card_ids       (108000,) str
source         scalar str — &amp;amp;quot;scryfall&amp;amp;quot;
embedder_spec  scalar str — JSON spec for reconstructing the embedder
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Card names and prices are not stored — callers look those up from Scryfall by UUID after identification. This keeps the catalog small and makes it easy to distribute independently of any particular metadata source.&lt;/p&gt;
&lt;p&gt;The catalog lives on &lt;a href=&#34;https://huggingface.co/HanClinto/milo&#34; rel=&#34;nofollow noopener noreferrer&#34;&gt;HuggingFace&lt;/a&gt; at &lt;code&gt;hf://HanClinto/milo/scryfall-mtg&lt;/code&gt; and is updated monthly. The first load downloads ~29 MB and caches it locally. After that, everything runs offline.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Why 128 dimensions?&lt;/h2&gt;
&lt;p&gt;128 is a reasonable tradeoff for this task. Fewer dimensions (32, 64) make it hard to distinguish cards that look similar. More dimensions (512, 1024) give diminishing returns and make the catalog larger and the search slower. At 128, the full catalog fits in 28 MB as float32 or 14 MB as float16, and the search is fast enough for real-time use.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;Next: &lt;a href=&#34;/posts/gh-22/&#34; rel=&#34;nofollow noopener noreferrer&#34;&gt;Part 4 — Running it locally&lt;/a&gt;&lt;/p&gt;</content>
    
    <category term="oss-development" />
    <category term="Blog Post" />
    
  </entry>
  
  <entry>
    <id>https://blog.hanclin.to/posts/gh-20/</id>
    <title>CollectorVision Part 2: Cornelius — How the Corner Detector Finds Cards in the Wild</title>
    <link rel="alternate" type="text/html" href="https://blog.hanclin.to/posts/gh-20/" />
    <updated>2026-04-25T14:03:29Z</updated>
    <author><name>HanClinto</name></author>
    <content type="html">&lt;blockquote&gt;
&lt;p&gt;Part 2 of the CollectorVision series. &lt;a href=&#34;/posts/gh-19/&#34; rel=&#34;nofollow noopener noreferrer&#34;&gt;Part 1&lt;/a&gt; has the overview.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Before you can identify a card you have to find it. Cornelius is the model responsible for that — it takes a camera frame and predicts where the four corners of the card are.&lt;/p&gt;
&lt;p&gt;This sounds easy. It&#39;s not.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;What the detector has to deal with&lt;/h2&gt;
&lt;p&gt;Cards in a real scene come in a lot of shapes. They&#39;re held at angles, rotated, half-off-screen, in colored sleeves, on patterned table surfaces, or overlapping other cards. There&#39;s no clean white rectangle to look for. Classical approaches based on edge detection and contour finding work reasonably well in controlled conditions, but in practice they require a lot of tuning and tend to fail on anything unusual.&lt;/p&gt;
&lt;p&gt;Using a learned model means the detector can be trained on examples of what &#34;card in hand&#34; actually looks like, rather than what a theoretical textbook expects it to look like.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Architecture&lt;/h2&gt;
&lt;p&gt;Cornelius is a MobileViT-XXS backbone with SimCC coordinate heads. Input is a 384×384 RGB image, ImageNet-normalized. The output is four normalized (x, y) corner coordinates — one per corner of the card — plus a sharpness score.&lt;/p&gt;
&lt;p&gt;&lt;a href=&#34;https://arxiv.org/abs/2110.02178&#34; rel=&#34;nofollow noopener noreferrer&#34;&gt;MobileViT-XXS&lt;/a&gt; is a hybrid CNN/Transformer architecture designed for mobile inference. The full thing runs in around 10–15ms per frame on a laptop CPU, 30–50ms on a phone.&lt;/p&gt;
&lt;p&gt;&lt;a href=&#34;https://arxiv.org/abs/2107.03332&#34; rel=&#34;nofollow noopener noreferrer&#34;&gt;SimCC&lt;/a&gt; (Simple Coordinate Classification) is the interesting part. Rather than predicting corner positions directly as floating-point numbers, it turns each coordinate axis into a classification problem over a discretized grid. The model predicts a probability distribution over bins, and the expected value of that distribution is the corner position. This lets the model express uncertainty — a sharp peak in the distribution means high confidence; a flat distribution means the model doesn&#39;t know.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;The sharpness gate&lt;/h2&gt;
&lt;p&gt;The sharpness score is the mean peak value across all eight softmax distributions (four corners times two axes). It turned out to be a useful proxy for &#34;is there a well-framed card here?&#34;&lt;/p&gt;
&lt;p&gt;The model also outputs a card presence logit, but that one isn&#39;t reliable — it fires strongly on blank images and hands without cards. The sharpness signal is better. When the card is clearly in frame with clean corners, all eight distributions are sharply peaked. When there&#39;s nothing there, or the image is blurry, the distributions go flat.&lt;/p&gt;
&lt;p&gt;In practice, frames below a sharpness threshold of around 0.08–0.10 are just skipped. Nothing is forced.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&#34;language-python&#34;&gt;detection = detector.detect(image)
if detection.sharpness &amp;amp;lt; 0.10:
    continue  # try the next frame
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;Corner ordering&lt;/h2&gt;
&lt;p&gt;Four predicted corners still need to be assigned to the right positions — top-left, top-right, bottom-right, bottom-left. The ordering uses a standard geometric trick:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&#34;language-python&#34;&gt;s = pts.sum(axis=1)   # x + y
d = np.diff(pts, axis=1).ravel()  # x - y

tl = pts[np.argmin(s)]   # smallest x+y
tr = pts[np.argmin(d)]   # smallest x-y
br = pts[np.argmax(s)]   # largest x+y
bl = pts[np.argmax(d)]   # largest x-y
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This works for any convex quadrilateral regardless of how skewed the card is.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Dewarp&lt;/h2&gt;
&lt;p&gt;Once the corners are ordered, a perspective transform maps the card to a fixed output rectangle. The canonical output size is 252 × 352 pixels — proportional to the physical card dimensions at 4 pixels per millimeter.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;detection.dewarp(image)&lt;/code&gt; returns a PIL Image, always the same shape, regardless of how the card was held. That consistency matters a lot for the embedder — Milo always sees the same-shaped input.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Failure modes&lt;/h2&gt;
&lt;p&gt;Cornelius has trouble with:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Cards lying flat on a complex surface, where the card border blends into the background&lt;/li&gt;
&lt;li&gt;Very high viewing angles (more than about 60 degrees off axis)&lt;/li&gt;
&lt;li&gt;Full-art cards with no clear light-colored border&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For most practical use — card held roughly flat, camera more or less overhead — it works reliably. The sharpness gate handles bad frames by skipping them rather than producing wrong answers.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;The pluggable interface&lt;/h2&gt;
&lt;p&gt;The library&#39;s &lt;code&gt;CornerDetector&lt;/code&gt; interface is a protocol, not a base class. Anything with a &lt;code&gt;detect(image) -&amp;amp;gt; DetectionResult&lt;/code&gt; method works as a drop-in replacement. There&#39;s an example in the repo of a Canny-edge-based detector implemented in about 30 lines, mostly to show how the interface works.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;Next: &lt;a href=&#34;/posts/gh-21/&#34; rel=&#34;nofollow noopener noreferrer&#34;&gt;Part 3 — Milo, the embedding model&lt;/a&gt;&lt;/p&gt;</content>
    
    <category term="oss-development" />
    <category term="Blog Post" />
    
  </entry>
  
  <entry>
    <id>https://blog.hanclin.to/posts/gh-19/</id>
    <title>CollectorVision: Identifying Trading Cards with On-Device Machine Learning</title>
    <link rel="alternate" type="text/html" href="https://blog.hanclin.to/posts/gh-19/" />
    <updated>2026-04-25T14:02:39Z</updated>
    <author><name>HanClinto</name></author>
    <content type="html">&lt;p&gt;I&#39;ve been working on a card scanner for Magic: The Gathering for a while now and wanted to write up how it works.&lt;/p&gt;
&lt;p&gt;The short version: you show it a card, it tells you what it is. It handles held cards, sleeved cards, skewed angles, phone cameras. The whole thing runs locally — you can run it in Python on your laptop, or as a static web app in your browser. No server required. The live demo is at &lt;a href=&#34;https://hanclinto.github.io/CollectorVision/&#34; rel=&#34;nofollow noopener noreferrer&#34;&gt;https://hanclinto.github.io/CollectorVision/&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The project is called CollectorVision. The code is at &lt;a href=&#34;https://github.com/HanClinto/CollectorVision&#34; rel=&#34;nofollow noopener noreferrer&#34;&gt;https://github.com/HanClinto/CollectorVision&lt;/a&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;How it works&lt;/h2&gt;
&lt;p&gt;The pipeline has three steps.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Step 1 — find the card corners.&lt;/strong&gt; A small neural network (Cornelius) looks at the frame and predicts where the four corners of the card are. It outputs normalized (x, y) coordinates for each corner, plus a sharpness score indicating confidence. Low sharpness means the frame was blurry or there was no card in view — those frames get skipped.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Step 2 — flatten the card.&lt;/strong&gt; Given four corners, a perspective transform maps the card to a flat rectangle: 252 × 352 pixels, every time, regardless of how the card was held. This is the canonical crop.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Step 3 — embed and search.&lt;/strong&gt; A second network (Milo) converts the flat crop to a 128-number vector. That vector gets compared against a prebuilt catalog of ~108,000 reference vectors, one per card face, using a dot product. The closest match is the result.&lt;/p&gt;
&lt;p&gt;The whole pipeline runs in under 100ms on a laptop CPU. Milo and Cornelius are both exported to ONNX, so the only ML dependency is &lt;code&gt;onnxruntime&lt;/code&gt; — no PyTorch, no GPU.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Pictures&lt;/h2&gt;
&lt;p&gt;Here is a real-world photo of a card:&lt;/p&gt;
&lt;p&gt;&lt;img alt=&#34;Scrying Glass original photo&#34; src=&#34;https://raw.githubusercontent.com/HanClinto/CollectorVision/main/docs/scrying_glass_original.jpg&#34;&gt;&lt;/p&gt;
&lt;p&gt;Cornelius finds the four corners:&lt;/p&gt;
&lt;p&gt;&lt;img alt=&#34;Scrying Glass with detected corners&#34; src=&#34;https://raw.githubusercontent.com/HanClinto/CollectorVision/main/docs/scrying_glass_detected.jpg&#34;&gt;&lt;/p&gt;
&lt;p&gt;The perspective is corrected to a flat crop:&lt;/p&gt;
&lt;p&gt;&lt;img alt=&#34;Scrying Glass dewarped&#34; src=&#34;https://raw.githubusercontent.com/HanClinto/CollectorVision/main/docs/scrying_glass_dewarped.jpg&#34;&gt;&lt;/p&gt;
&lt;p&gt;Milo produces a 128-d embedding. The nearest neighbor in the catalog is &lt;strong&gt;Scrying Glass, Urza&#39;s Destiny&lt;/strong&gt;, cosine similarity 0.93.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Installing&lt;/h2&gt;
&lt;p&gt;For now, install directly from GitHub:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;pip install git+https://github.com/HanClinto/CollectorVision.git
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Five lines to identify a card:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&#34;language-python&#34;&gt;import cv2
import collector_vision as cvg

catalog = cvg.Catalog.load(&amp;amp;quot;hf://HanClinto/milo/scryfall-mtg&amp;amp;quot;)
image   = cv2.imread(&amp;amp;quot;my_card.jpg&amp;amp;quot;)

detection = cvg.NeuralCornerDetector().detect(image)
crop      = detection.dewarp(image)
emb       = catalog.embedder.embed(crop)
score, card_id = catalog.search(emb)[0]
print(card_id, score)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The catalog (~29 MB) downloads once from HuggingFace on first run and is cached after that. Everything runs offline.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;The series&lt;/h2&gt;
&lt;p&gt;This is the first post in a series going through the details.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;This post&lt;/strong&gt; — overview&lt;/li&gt;
&lt;li&gt;Cornelius: the corner detector&lt;/li&gt;
&lt;li&gt;Milo: the embedding model&lt;/li&gt;
&lt;li&gt;Running it locally — Python library, REST server&lt;/li&gt;
&lt;li&gt;The browser scanner&lt;/li&gt;
&lt;li&gt;The hybrid split — local inference, server lookup&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The interesting parts are in posts 2 and 3. The browser scanner post (5) covers some unusual engineering problems around running ONNX models in WebAssembly and a bug in WebGPU on Android ARM that cost a few days.&lt;/p&gt;</content>
    
    <category term="oss-development" />
    <category term="Blog Post" />
    
  </entry>
  
  <entry>
    <id>https://blog.hanclin.to/posts/gh-18/</id>
    <title>Spring is here</title>
    <link rel="alternate" type="text/html" href="https://blog.hanclin.to/posts/gh-18/" />
    <updated>2026-04-24T14:36:27Z</updated>
    <author><name>HanClinto</name></author>
    <content type="html">&lt;p&gt;Every once in a while, one of our hens will feel broody and sit on a clutch of eggs. Usually she gets bored after a day or two and wanders off (leaving a pile of soon to be rotten eggs), but every once in a while, one of the girls sticks with it long enough to bring chicks to hatch.&lt;/p&gt;
&lt;p&gt;&lt;img width=&#34;2048&#34; height=&#34;1542&#34; alt=&#34;Image&#34; src=&#34;https://github.com/user-attachments/assets/595dac8b-a6a0-445f-8243-ba04882fbc19&#34;&gt;&lt;/p&gt;
&lt;p&gt;Three new chicks hopping around the Herron farm now. Momma is fiercely protective and will peck your feet if you get too close. She&#39;s great.&lt;/p&gt;
&lt;p&gt;Spring is here. &lt;/p&gt;</content>
    
    <category term="Blog Post" />
    
  </entry>
  
  <entry>
    <id>https://blog.hanclin.to/posts/gh-16/</id>
    <title>NRC 2026</title>
    <link rel="alternate" type="text/html" href="https://blog.hanclin.to/posts/gh-16/" />
    <updated>2026-04-14T05:55:04Z</updated>
    <author><name>HanClinto</name></author>
    <content type="html">&lt;p&gt;&lt;a href=&#34;https://www.thenrc.org/&#34; rel=&#34;nofollow noopener noreferrer&#34;&gt;NRC 2026&lt;/a&gt; is just around the corner (April 16, 17 &amp;amp; 18), and our students are very excited. &lt;/p&gt;
&lt;p&gt;&lt;img width=&#34;2048&#34; height=&#34;1367&#34; alt=&#34;Image&#34; src=&#34;https://github.com/user-attachments/assets/4d9632e7-38e6-4c30-beae-220bf91059fa&#34;&gt;&lt;/p&gt;
&lt;p&gt;For the past several 6 years my wife and I have hosted and coached a robotics team out of our house for middle and high schoolers. This year our students are bringing a total of 8 bots to the competition, and we&#39;re very excited to see how they do!&lt;/p&gt;
&lt;p&gt;&lt;img width=&#34;1024&#34; height=&#34;768&#34; alt=&#34;Image&#34; src=&#34;https://github.com/user-attachments/assets/a1e536d7-1a65-4775-a021-c884e2d543a0&#34;&gt;&lt;/p&gt;
&lt;p&gt;It&#39;s always a wonderful event, and can&#39;t say enough good things about the organizers and the judges (all volunteers from Honda) who generously donate so much of their time and energy to put on such a good event every year. Very thankful that such a quality event like this exists in the midwest, and if you live anywhere closer to Marion Ohio, then I highly recommend getting out to visit this at least once.&lt;/p&gt;</content>
    
    <category term="Blog Post" />
    
  </entry>
  
</feed>