I’ve been dealing with gallery performance issues for some time - primarily due to my inexperience with Qt. I’ve been using a
QScrollAreato lay out all the image previews. The issue was that when
QGridLayoutdoes not support resizing/rearranging elements when the window resizes. So, in an effort to fix that, I implemented a custom
resizeEventon my gallery window which would clear the gallery and refill it. The issue was that this took as much time as it would on initial load to paint the previews, and because this had to happen in the GUI thread, it would freeze the window while it was being resized. Overall — pretty janky.
So I’ve instead implemented a custom
FlowLayoutwhich you can also check out here.
import math from PySide6 import QtWidgets, QtCore class FlowLayout(QtWidgets.QLayout): item_list: list[QtWidgets.QLayoutItem] horizontal_space: int vertical_space: int margin: int current_size: tuple[int, int] item_width: int item_height: int def __init__(self, parent: QtWidgets.QWidget, item_width: int, item_height: int): super().__init__(parent) if parent is not None: self.setContentsMargins(0, 0, 0, 0) # spaces between each item self.horizontal_space = 5 self.vertical_space = 5 self.item_width = item_width self.item_height = item_height self.item_list =  self.current_size = (0, 0) def __del__(self): item = self.takeAt(0) while item: item = self.takeAt(0) def addItem(self, item): self.item_list.append(item) def count(self) -> int: return len(self.item_list) def itemAt(self, index) -> QtWidgets.QLayoutItem | None: if index >= 0 and index < len(self.item_list): return self.item_list[index] return None def takeAt(self, index: int) -> QtWidgets.QLayoutItem | None: if index >= 0 and index < len(self.item_list): return self.item_list.pop(index) return None def addWidget(self, widget: QtWidgets.QWidget): super().addWidget(widget) def expandingDirections(self) -> QtCore.Qt.Orientations: return QtCore.Qt.Orientations(QtCore.Qt.Orientation(0)) def hasHeightForWidth(self) -> bool: return True def heightForWidth(self, width: int) -> int: if width == 0: return -1 # TODO take into account the right most horizontal_space column_count = max([width // self.item_width, 1]) row_count = math.ceil(len(self.item_list) / column_count) height = row_count * (self.item_height + self.horizontal_space) # if even, remove last spacing if len(self.item_list) % 2 == 0: height -= self.vertical_space return height def setGeometry(self, rect: QtCore.QRect): super().setGeometry(rect) self.place_items(rect) def sizeHint(self) -> int: return self.minimumSize() def minimumSize(self) -> int: size = QtCore.QSize() for item in self.item_list: size = size.expandedTo(item.minimumSize()) return size def place_items(self, rect: QtCore.QRect): if (rect.width(), rect.height()) == self.current_size: return self.current_size = (rect.width(), rect.height()) column_count = max([rect.width() // self.item_width, 1]) centering_offset = (rect.width() - (column_count * self.item_width)) // 2 row = 0 column = 0 for item in self.item_list: x_offset = column * self.item_width + centering_offset y_offset = row * self.item_height column += 1 if column == column_count: column = 0 row += 1 item.setGeometry(QtCore.QRect(x_offset, y_offset, self.item_width, self.item_height))
With this layout, and knowing that all child widgets will have the same size, I can very efficiently recompute placement on resize by changing the geometry of each child widget at a low level. Most of these are custom sub-implementations of a
QLayoutbut the magic happens within
place_items. I don’t really know why, but
QLayout.setGeometryis called an absurd amount of times — I think once for every time a child is added, but with a debounce, so only some time after the last child is added.
Anyway, in order to not iterate over each child each-child-number-of-times this only runs
place_itemsonce the geometry of the layout itself changes — i.e. when the window is resized.
Did some more image processing/color-sciency work today.
I implemented the “sauce” — a term used to describe the custom CMYK changes made to a negative to give it its look and feel by lab techs — in the processing panel. Here’s what it looks like within the Noritsu scanner.
I wanted to give users a bit more control, though, so it’s not only possible to change the CMYK values globally, but also across different luminosity ranges — for now they are highlights, midtones, and shadows. Right now, this works by adjusting each channel’s brightness values by creating a LUT which maps existing channel values to ones dictated by a gamma curve. In order to target luminosity ranges, the gamma curve is modified with a combination of logistic curves, which mix the gamma with a straight y=x line to target only one portion of the image. The resulting luminosity masks look like so when plotted:
This isn’t yet perfect, and I’ll continue tweaking how I apply the logistic curve to target different luminosity regions and whether a gamma function is best used in the first place, but for now it’ll suffice.
Additionally, I switched over to a white-balance methodology over a subtract-base methodology. That is, rather than removing the base to remove the color cast, I instead white balance against it. I find that this better preserves luminosity ranges and handles a much wider variety of film bases and scan methods.
Yeehaw, I’ve done it. I’ve tweaked some more things, uncommented the rest of the code, and was able to package the app to create a macOS executable. Nuitka also has a setting to automatically create a
.appbundle, but it’s lacking in configurability so I’ll just be bundling it myself.
At this point, I was undeterred - I grabbed my girlfriend’s spare Windows laptop and got to work setting up a new user, installing powershell, chocolatey, winget, git, pyenv, poetry, et al. This was my first time developing on Windows since, like, 2014 and it was cool to see that tooling has really improved and a dev environment has gotten way easier to get up and running.
I was amazed — it. just. worked.
So, I set up two GitHub Actions on
windows-latestto build macOS and Windows executables and upload the artefacts. There was a slight caveat with the windows run, as nuitka needed to download some extra
exes. Luckily, I was able to inspect where they were downloaded on my local Windows computer, commit them to my repo, and just copy them to the right locations during the Actions run (nuitka asks for an interactive y/n response response during the bulid so there was no way for me to download the files as part of the build, hence downloading them beforehand).
I’m so stoked. I’ll probably be building a linux app for this at some point, but for the time being I’m back to feature development. Stay tuned for some more frequent UI updates and accompanying screenshots!
I spent a bit of time fixing bugs from yesterday and refining bits of the Process UI/UX. However, the majority of today has been spent trying to figure out packaging the app for distribution (specifically for macOS). I’ll work on Windows/Linux packaging once I’m able to prove out that packaging works on at least one platform.
I’ve chosen to use nuitka over other options such as PyInstaller, py2exe/py2app, and PyOxidizer, primarily because nuitka gathers and links app code to its dependencies, transpiles the Python code to C, and compiles it all down to machine code. All other Python distribution solutions merely embed the Python interpreter (to varying degrees) alongside Python bytecode. Because of nuitka’s ability to actually compile down to machine code we get 1) better performance and 2) a proper machine-code based binary that should be pretty tough to reverse engineer.
I’m testing packaging the app piecewise, basically uncommenting the app code piece by piece and building a macOS app bundle each time, to see where things break. I started with just the empty Gallery/Process windows to test that PySide6 works. Then added some trivial numpy/PIL code to test those libraries. So far, I’m able to create an app bundle that
- opens the Gallery
- loads up all the imported folders in the explorer
- displays images in the Gallery for selected folders
- opens the Process page for a selected image
The app bundle is coming out at around ~150 megabytes. That’s not too bad, given all the modules and libraries that need to be bundled to get all this to work. And with this ending up as a pretty well equipped image editor, I think people won’t be too bothered by the size of the app.
Currently, I’m working on (and failing to) package get the image to show on the Process page. I’m not yet sure which specific part of that codepath is causing issues.
nuitkais able to still build the bundle, but upon opening the
.app, nothing happens.
I need to investigate how to be able to view debug logs and stack traces for crashes, as I’m kinda flying blind here. Figuring out better visibility and getting the selected image to show in the Process page is going to be my continued goal for tomorrow.
Wish me luck 👋
Feels good to get back into it.
I decided to set some soft deadlines for myself
- late October for an MVP I can share about to get beta testing
- mid November for a 1.0 product launch (including infrastructure around distributing app)
These were kinda arbitrary, but I figured a month to get an MVP in a good spot and then a few more weeks to test, get feedback, and polish sounded reasonable. In an effort to keep myself on track, motivated, and well equipped to deal with unknown-unknowns, I set up a GitHub Project.
I’ve filed and groomed a bunch of issues I know I have to get done, but will go through a more serious planning session tomorrow and start establishing a timeline. I’ve found that the best way to conceptualize a project is to 1) define what the end state is 2) break it down into the features necessary to support that end state and 3) break down those features into atomic steps necessary for them to exist. Once you’ve done that, you basically have a step by step recipe on how to get to your end state. And, when you’re following a recipe, you mostly only have to concern yourself with the task at hand - which is a known quantity - it’s much more difficult to get overwhelmed or demoralized.
Anyway, moving on to some progress reports. Today, I built out a few more enhancements.
Disclaimer - obviously the UI is in a rough state. I’m going to go through the design process of coming up with a cohesive design language and creating the design system at a later date. For the time being, though, processing is coming along nicely. You can now
- Rotate images
- Set the sample space
- Set the “sauce” - individual tweaks to the cyan, magenta, and yellows across highlights, midtones, shadows, and globally
For any change, your “recipe” - as I’m calling it - and your thumbnail are recomputed and persisted in your catalog (sqlite db). This means you can go back to the gallery at any time, see your updated changes in the thumbnail of the image you just processed, and then reopen it to have changes right where you left off - even if you completely quit the app in between. One of my main goals with this app was to make sure that every single change was nondestructive. i.e. pixel changes are never baked in, only the reproducible recipe is stored.
This auto-save feature is a little bit buggy right now. It’s done as a new
QThreadright before returning from the function process. In doing so, I’m able to return the pixel data to the GUI thread to update the user’s screen as soon as possible, while updating the catalog in the background. Unfortunately, i’m occasionally running into a SQLite OperationalError
sqlite3.OperationalError: cannot commit transaction - SQL statements in progress. I’ll have to update my ORM library (
p3orm) to better handle transaction commits. Right now, each statement is automatically committed just after execution. I’m going to add the ability to also manually commit transaction instead. Then, it’ll just be a matter of sequencing SQL transactions across multiple threads here so we don’t get statement execution/connection commit overlap.
There are a few other bugs, like some things not persisting right, switching directly between gallery and processing page causing some changes to rollback, etc. There’s a whole host of things I need to tackle as you could tell from my project board - but it’s getting real late here, so I’m going to head to sleep and tackle more tomorrow morning.
Quick updates for today because it’s late.
Started on progress window, and added a window manager to switch between windows with hotkeys.
The library page shows the image nice and big, includes a CMY histogram (that I want to tweak some more), and will have the main 3 process tools (film base, sample crop, and sauce).
Fixed my p3orm library to finally fully support SQLite, added full suite of tests, and getting ready to publish version 0.6.0, pending updating docs. Main issue here was how the
INclause was being added, as well as the order of parameters/arguments getting out of whack with
UPDATEqueries. Now I can update the Library page to more efficiently fetch folders/images.
I played around a bit with Nuitka for packaging the app. There were some errors, specifically around PIL, I think, so I’m going to have to look into that further. Would suck to finish this up and then not be able to package it as a standalone app for distribution.
Been hard at work this past weekend learning more of Qt’s general usage and design patterns. I’ve gotten a very rough skeleton of the gallery page done, though, so that’s exciting!
Now, it’s not pretty, but it’s got some functionality and I’m finally grasping how to use Qt.
So far, the Catalog screen
- Imports (with a sick progress bar) any folders, their children, and contained images
- Persists them in a SQLite database
- Displays the images of any selected folder and its children
Importing images and fetching images for display are both done in a separate thread, to not block the GUI. During import, I connect a signal from the background thread to a slot in the GUI thread in order to update the progress bar. I pre-walk the directory to get a count of all images, and then walk it a second time to do the actual processing. Since the initial walk is practically instant, the user sees an immediate indication that the action took place and the progress bar can be reasonably accurate. Once done, I send a signal to the GUI thread, which kicks off another background thread that fetches all folders, returns a signal with the results, and the GUI thread updates the file explorer
Next up, will be selecting an image and opening it up in the Process page.
As I’ve said, this page is nowhere near done, and is just a skeleton. There are two categories of changes this page will require: cleanups/optimizations and feature/UIX improvements.
In terms of cleanup, optimization, and bug fixes the main things I want to tackle are:
- I want to optimize importing images. With a large import (my import of my entire “Scans” folder of 1500+ images takes a while, with each image taking ~0.25 seconds to complete. The bulk of this time is spent generating the preview image. I will have the process thread kick off further threads for image resizing.
- When showing images for the gallery, I currently need to hackily fetch all the folders and all the images, the retrieval of which takes some time. I believe that it’s due to a bug in p3orm that doesn’t correctly construct parametrized queries for SQLite. I’ll need to fix this so I can more efficiently fetch just the folders I want.
For feature/UIX improvements
- I need to make things look pretty. I’m going to ask for someone with product design skills to help me come up with a theme, palette, and overall design system.
- Toolbar on the right side - image and folder metadata like film type, format, etc; batch processing tools; exporting; and more.
- Navigation between screens and a proper menubar (particularly for macOS)
Just going to be spitballing here today, mostly for myself, to figure out how I want to structure things. I’ve spent the past couple of days pouring through Qt documentation to learn all about the tools that PySide6 provides - and it’s a lot. I thought I’d have to build out my own solutions for settings persistence, passing off work to threads, model/view/data framework, etc. Turns out Qt has really powerful solutions to all of these, and more that I’m still discovering. It’s really cool and comforting to know that I won’t have to build these all out myself, but does introduce the hitch that I’ll likely be (equally) slowed down by having to learn the Qt-way of doing things. Not a problem, as I’m sure the folks behind Qt have figure these things out over the years better than I would for my first time building a desktop app.
Upon launch, first thing the app must do is ensure persisted settings exist and are set to sane values. Next, we’ll have to do the same with the catalog db, creating it and its schema if it doesn’t exist. Only then can we load up the gallery view and populate it with image previews.
I’ll have to set up a generic (probably) QThread based worked class. My thinking is I’ll set it up to take any arbitrary function/coroutine to run and pass back data to the main GUI thread with result, error, and progress signals. I’m leaning toward QThreads over regular Python Threads because it has support for Qt specific workings. While I’m not 100% sure where I’ll use them, I’m willing to take the tradeoff of a potentially slightly higher overhead. Ultimately, QThreads and Python threads are subclasses of the same foundational components, so it’s not like I’m choosing between two vastly different solutions.
For now, I’m working with a really shoddy UI, bunch of hard-codes, little preferences/configurability. Basically, trying to get
Well, that wasn’t too painful. I’ve wrapped up adding support for SQLite (with
aiosqliteas the driver) in
p3orm. I’ve not yet published a new package version as I’ll need to first add thorough testing and update the documentation, but I can continue on building the film editor off the master GitHub branch of
Not a long update today, as I’ve been doing a bit of rethinking on my strategy when I release this project. I’ve come to realize that the web based solution will not be the best way to distribute this image editor, particularly because most who use it may not want to be forced to store their content in the cloud (as would have been necessitated by my original direction) and the likely recurring cost that would come with that. Instead, I plan on building out the full featured editor as a desktop app I’ll ship for macOS, Windows, and Linux.
I’ve looked into solutions for building native desktop apps, and I’ve settled on Qt for Python, specifically PySide6 (as opposed to PyQT due to PySide6 having a more generous LGPL license).
Some of the other options I’ve considered (and ruled out) are
- electron or nw.js - fairly bulky, but also would require figuring out how to simultaneously spin up a Python server to do the image processing and that just seemed messy
- Eel - actually not super against this one, but I wanted to explore more than web technology for UIs (though I see myself coming back to this in the future)
- Tkinter - just looks ugly
- Kivy - still not huge on kvlang (lack of intellisense, mostly) and also because it’s basically all drawn on OpenQL and doesn’t use native UI bindings it won’t look truly native on each platform
- PyQT - has an unfavorable-for-commercial GPL v3 license
I’ve already ported the existing project I’ve had to PySide6 over the past two days (although it doesn’t look as pretty) and going forward I’ll be focusing on building out the basis of the remaining UI flows - specifically DAM and Editing, before I start refining them and Processing.
That, however, brought me to the realization that I’ll need to figure out a solution for embedding image and library metadata for the Digital Asset Management piece. I’m pretty against the Darktable solution of a sidecar .xml file for each image, and much more in favor of a singular library file like Lightroom has.
SQLite is the perfect solution for this. Unfortunately (as I’m sure you’ll eventually come to realize) I don’t like the state of ORMs within Python. That’s why I built p3orm, to have a Postgres ORM for my web projects. I never thought I’d need to have p3orm support non-Postgres drivers, as I’m a web developer through and through, but alas here we are. Before I continue on with building out the image editor, I’ll first be adding sqlite support (with aiosqlite as my driver) to p3orm. I’ve made some small foundational changes to p3orm and should have this wrapped up with a new version release in a couple of days.
P.S. I need to figure out pagination for this devlog - it’s getting kinda big for just one page, especially since it’s all SSR’d right now
I’ve made some progress on the film editor this past week. This includes the first pass at the UI for the process page (which includes uploading an image and some levers to manipulate) as well as the main
/processwebsocket that contains much of the business logic of converting an orange image to something 80% of the way there. There’s certainly a good amount of tweaking i have to do to the pixel maffs in order to improve the color science, but I’ve built up a good base. I’ll go into some more details of my “color science” later.
The UI is pretty minimalist so far, and I’m definitely going to have to do a full pass of making a nice UI once everything’s in place, but it’ll suffice so far.
So what’s this do? Temporarily, there’s the ability to upload a single image on this page - in the future, you’ll have the ability to import a bunch of images (like a whole roll) in the DAM piece, kinda of like Lightroom, before picking from there which negative to process. Once an image is selected, a websocket is established with the server, the image is uploaded as raw bytes, stored in the websocket session, naively inverted, and returned to the UI.
Currently there are 3 sets of settings to process the image
- Film base color selection which the server uses to remove the color cast on the image
- Cropping out the film border for the server to get the right sample to do its color science on
- The colloquially named “sauce”, which controls the cyan, magenta, and yellow presence in the image (I’ve broken these out into global, highlight, midtone, and shadow changes)
I’m aiming to make the first two sections smarter by having the server figure out where the frame border is to be able to 1) pick the film base color and 2) crop it out automatically. Then, these would be optional and only have to be tweaked by the user if the server makes a mistake.
The actual color science right now is very naive. Once the film border color is “subtracted”, the sample crop is used to determine where all the image color is in (i.e. ignoring the frame border). Then, the image channels are normalized to fill the whole 0..255 range of pixel values, which helps correct for the different sensitivities of each of the red-, green-, blue-sensitive layers in the film. This, however, is not ideal as 1) it doesn’t by itself produce the best looking results and 2) really struggles when an image is primarily either a single color or single tonal range (i.e. really bright, or really dim).
I’m pretty proud of how I did the luminosity masking for the “sauce”. The cyan, magenta, and yellow channels are separated into three
np.ndarrays. In order to figure out how much to increase/decrease the intensity of each color at a certain value, I use a simple gamma function from 0.255 with values clamped between 0..1 acting as a multiplier. Then, that is bitwise-multiplied by one of three logistic functions, corresponding to either highlights, midtones, or shadows.
With this bitwise multiplications, all values of the gamma function that apply outside the luminosity mask (like ~85..255) are scaled to 0. I use a logistic function rather than a stepwise function in order to smooth out the transition between tones. I’m considering lowering the growth rate of the functions even more to further smooth out the transition.
I also need to explore a different function for actually scaling the intensity of the colors, as gamma does not play well with luminosity masks. For example, a gamma function that increases the intensity of a color will have little to no affect if targeting only highlights.
One last thing I wanted to write about about for now is the performance. I already mentioned how this runs through a websocket so that data data can be pushed from the server and so we can keep the image in memory for quick access at every change of the recipe. The image that is stored for the duration of the session and the one thats edited and shown to the user is scaled down to 1000px on its long edge and is returned as a 95% quality JPEG. With these compromises (which for getting a sense of color accuracy are totally passable) changes to the image take only ~0.08 seconds to process. On average, it takes only a tenth of the second for changes to be reflected on the users’ screen, which makes this whole process feel very snappy. Because of this average time, edits sent over to the server are throttled to run once every 200ms so as to strike a compromise between not overriding the server and still making the experience feel snappy (it does). The user can, however, choose to rerun the current processing recipe and return the full quality lossless image for closer inspection.
I’m thinking about what to name this once it’s launched. I’ve been playing around with naming derived from the RA-4 process - which is the Kodak’s chemical process that allows for printing a color negative onto a photo sensitive piece of paper in the darkroom.
I’ll be working on doing more research on negative color inversion and the color science behind similar software like Negative Lab Pro, Silverfast, and Noritsu and Frontier scanners. Additionally, I’ll start working on the DAM aspect of the project so I can get a head start on reorganizing my own film scan library.
‘til next time 👋
I’m really into film photography. I have recently switched to scanning my negatives with a (dedicated) digital camera rather than my film scanner (which I sold off, so that’s no longer an option). Before going into some more context, might be useful for those who have no idea what the hell I’m talking about to understand, well, what the hell I’m talking about.
If you remember back in the ol’ days, before digital cameras, you took pictures on film. And when you got them developed, what you’d get back is a roll of images that looked kinda like the below image on the left. It was Sunny-D flavored and everything was inverted (black was white, green was magenta, blue was yellow, and red was cyan).
So, high level, I wanna build an app where I can toss in these scans and edit them to make them pretty for the ‘gram.
I’m using Python to do the image processing, and before you ask, it’s plenty fast enough. I’m using a combination of numpy, ImageMagick, and OpenCV for most (if not all) of the actual image data manipulation, so really this is more of a C/C++ project glued together with some Python.
I was torn on how to tackle the UI. Initially, I wanted to bundle a Python GUI framework into a native (macOS, Linux, Window) desktop app, but I found that there weren’t any suitable solutions. Options like TKinter and PyQT just seem so outdated, Kivy is cool but I really dislike using Kivy Language to model the UI (even with the VSCode plugin), and Toga doesn’t let you display images from memory or even blit textures directly to a canvas.
And then I wondered… paint.net, figma, and a bunch of other image-y apps run in the browser - why couldn’t this?
Well, folks, it can.
I’ll get into the nitty-gritty of the image processing and UI another time, so in the meantime I’ll tell you about the quick test I did the other day. As a proof-of-concept, I set up a janky Python service running FastAPI and a dirty UI which let me slide around a couple sliders and dial a couple of dials to upload and edit an image. The UI and server talked to each other through a websocket - sending binary data back and forth - and lo and behold, it worked like a charm. There was little perceivable delay, totally passable for the unoptimized garbage I put together, and I was actually able to edit an image!
“Why websockets?” you may be asking? A websocket is a persistent stateful TCP connection. So, for any incremental edit you make to the image, the server can keep a bunch of the state in memory (which is way faster than having it stored on disk/cache/db) and you save on the time it’d take to establish a new TCP connection with HTTP. Also, because websockets are bidirectional, I can do cool stuff like quickly show a lower-resolution to the user, and then progressively load in higher and higher quality previews as the server churns them out.
Let’s talk features.
- Digital asset management (DAM). I should be able to upload a bunch of negatives, have them be treated like a cohesive roll of film (tagging things with EXIF/metadata like what camera I used, what film this is, what format, etc.)
- Non-destructive editing. I don’t want the changes I make to be baked into the image. Instead, all changes, no matter after how many weeks later I come back, should be easily undo-able without quality/data loss of the image. This also means support for raw image formats straight from the camera (thank you rawpy).
- Batch processing. If I process a frame from a roll, it stands to reason that I should be able to apply those base settings to all the other images from that roll. This should get me 90% of the way with all the images in a roll with minimal effort.
- Negative conversion feature. i.e. the ability to turn an orange inverted image into something that looks passable.
- Additional editing features. To start, I’m thinking at least:
- blemish (read: dust/scratch) removal
- (AI powered) sharpening
- HSL/color tweaking
- And of course, the basic stuff like cropping, exposure, contrast, etc.
Before I sign off, I want to quickly mention my motivation for this project. You must be wondering, “Surely there’s some tools that already do this, the film photography community is growing like crazy. There’s dozens of us!” Well you’d be right, but I don’t like any of the current tools. I used to use VueScan and Silverfast 8 with my film scanner, which were alright but lacked any DAM and advanced photo editing features. This meant I’d always have move to my photo editor (Affinity Photo), so I was manually managing all the storage, tagging, and collating of my images, and was losing data along the way with all the destructive editing. There’s also a really cool and popular tool called Negative Lab Pro, which is an Adobe Lightroom plugin. It does really well with color reproduction, has a bunch of neat features, and because it’s a Lightroom plugin, I get the DAM and non-destructive editing features out of the box. But, it still doesn’t have all the features I want, the workflow (at least for me) could really be streamlined, and Lightroom has a bunch of features I don’t want. Also, there’s no way I’m shelling out more money on this hobby for Lightroom and NLP.
So instead of paying a few bucks for software, I’ll be spending countless hours over the next few weeks building some myself. Because why buy a wheel when you can make yourself a slightly shittier one for twice the cost.