<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: takhir adilzhanov</title>
    <description>The latest articles on DEV Community by takhir adilzhanov (@adilzhanovt).</description>
    <link>https://dev.to/adilzhanovt</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4115608%2F67616c55-6f1a-4b52-b281-4ef2de36390a.jpg</url>
      <title>DEV Community: takhir adilzhanov</title>
      <link>https://dev.to/adilzhanovt</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/adilzhanovt"/>
    <language>en</language>
    <item>
      <title>How a 45,225-pixel painting became a blurry thumbnail</title>
      <dc:creator>takhir adilzhanov</dc:creator>
      <pubDate>Tue, 08 Sep 2026 11:30:10 +0000</pubDate>
      <link>https://dev.to/adilzhanovt/how-a-45225-pixel-painting-became-a-blurry-thumbnail-2dpi</link>
      <guid>https://dev.to/adilzhanovt/how-a-45225-pixel-painting-became-a-blurry-thumbnail-2dpi</guid>
      <description>&lt;p&gt;I am building &lt;a href="https://polotno.app/en" rel="noopener noreferrer"&gt;Polotno&lt;/a&gt;, a free, noncommercial gallery where a visitor can zoom into a painting, read its story, or listen to a prepared audio guide. The collection currently contains 509 works, 295 artists, and 20 tours.&lt;/p&gt;

&lt;p&gt;One painting exposed a mistake in my image pipeline better than any synthetic performance test could.&lt;/p&gt;

&lt;p&gt;Mi Youren’s &lt;em&gt;Cloudy Mountains&lt;/em&gt; is a horizontal handscroll. The source used by the gallery is 45,225 × 4,878 pixels—roughly 221 million pixels. The source was detailed enough for deep zoom, yet the painting looked blurred in the catalog.&lt;/p&gt;

&lt;p&gt;The problem was not JPEG quality. I had resized and cropped in the wrong order.&lt;/p&gt;

&lt;h2&gt;
  
  
  One image could not serve two different jobs
&lt;/h2&gt;

&lt;p&gt;The full painting view and the catalog card look like two versions of the same component, but they ask the image pipeline for different things.&lt;/p&gt;

&lt;p&gt;In the viewer, a person first sees the complete work and then zooms into a small area. Sending the full 45,225-pixel-wide image for the initial view would waste bandwidth and ask the browser to decode far more pixels than it needs.&lt;/p&gt;

&lt;p&gt;The catalog has the opposite constraint. It shows many paintings at once, each inside a card with a relatively compact aspect ratio. It needs a recognisable crop, several responsive sizes, and no deep-zoom machinery.&lt;/p&gt;

&lt;p&gt;Treating both surfaces as one image-delivery problem produced a poor result in the catalog.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deep Zoom sends only the required region and resolution
&lt;/h2&gt;

&lt;p&gt;The painting viewer uses &lt;a href="https://openseadragon.github.io/" rel="noopener noreferrer"&gt;OpenSeadragon&lt;/a&gt; and Deep Zoom pyramids. A pyramid contains the image at several resolution levels. Each level is split into tiles.&lt;/p&gt;

&lt;p&gt;At the overview scale, the browser requests a low-resolution level. After a visitor zooms in, it requests detailed tiles only for the visible area. The interaction resembles a map: the whole world is not downloaded at street-level resolution before the first frame appears.&lt;/p&gt;

&lt;p&gt;In Polotno, the viewer currently limits concurrent image requests to six and caps its image cache at 20 entries on small screens or in TV mode, and 36 on larger screens. Those numbers are operational limits, not universal recommendations; they still need measurement on real devices and networks.&lt;/p&gt;

&lt;p&gt;Deep Zoom adds costs of its own. I have to prepare and store many files, the browser makes more HTTP requests, and the delivery path has to cache immutable tiles correctly. For a gallery whose main interaction is close looking, that trade-off is useful. For a grid of cards, it is unnecessary.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 640-pixel thumbnail had only 69 pixels of height
&lt;/h2&gt;

&lt;p&gt;The original catalog pipeline generated a conventional thumbnail of the whole scroll. At 640 pixels wide, the proportional height was approximately 69 pixels.&lt;/p&gt;

&lt;p&gt;The CSS card was much taller. &lt;code&gt;object-fit: cover&lt;/code&gt; filled the card by enlarging the tiny thumbnail and cutting off both sides. The screen therefore showed a stretched fragment of a 69-pixel-high image.&lt;/p&gt;

&lt;p&gt;Increasing the encoder quality could not restore detail that had already disappeared. A bigger source file was available, but the pipeline had reduced it before deciding which part the card needed.&lt;/p&gt;

&lt;p&gt;The useful order is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Choose the region that represents the work in the card.&lt;/li&gt;
&lt;li&gt;Extract that region from a detailed source.&lt;/li&gt;
&lt;li&gt;Resize the crop to the delivery sizes.&lt;/li&gt;
&lt;li&gt;Encode immutable card assets.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For local paintings, the preparation script reconstructs the crop from source tiles. For some museum-hosted images, it requests the required region through IIIF. It does not generate or extend the painting with an image model.&lt;/p&gt;

&lt;p&gt;The crop logic begins with a default viewing region and constrains its aspect ratio before reading pixels:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;artwork&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;width&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;artwork&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;height&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;bounds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;artwork&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;defaultViewBounds&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;x&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;y&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;width&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;height&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;bounds&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;x&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;
&lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;bounds&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;y&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;
&lt;span class="n"&gt;crop_w&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;bounds&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;width&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;
&lt;span class="n"&gt;crop_h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;bounds&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;height&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;

&lt;span class="n"&gt;ratio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;crop_w&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;crop_h&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The production script then reads the required tiles, assembles the crop, and generates WebP variants up to 480, 960, and 1,440 pixels wide. It never enlarges a source merely to satisfy a target width.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvlkxzb04h8bf04xlmefd.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvlkxzb04h8bf04xlmefd.webp" alt="The old 640-pixel-wide full-scroll thumbnail, only 69 pixels high" width="640" height="69"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The old 640-pixel-wide thumbnail preserved the whole scroll but had only 69 vertical pixels.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb5fjsbqgg0ntk1n4v8m4.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb5fjsbqgg0ntk1n4v8m4.webp" alt="The new 960-pixel-wide card crop" width="799" height="532"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The new asset crops first and resizes second. The complete work remains unchanged in the viewer.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;srcset&lt;/code&gt; selects among prepared card assets
&lt;/h2&gt;

&lt;p&gt;Once the assets exist, the React component exposes them with width descriptors:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;candidates&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;variants&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;variant&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;w`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;img&lt;/span&gt;
    &lt;span class="na"&gt;src&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;variants&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;at&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="na"&gt;srcSet&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;, &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="na"&gt;sizes&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"(max-width: 700px) 100vw, (max-width: 1200px) 50vw, 33vw"&lt;/span&gt;
    &lt;span class="na"&gt;loading&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;eager&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;eager&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;lazy&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="na"&gt;decoding&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"async"&lt;/span&gt;
    &lt;span class="na"&gt;alt&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;alt&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The browser chooses a candidate using the rendered size and device pixel ratio. Images below the first screen load lazily. The original overview remains a fallback if a prepared asset fails.&lt;/p&gt;

&lt;p&gt;This change produced dedicated card variants for all 509 works. Together they occupy about 263 MB in storage. A visitor does not download that set at once, but a detailed card can still increase the traffic for a particular screen. Storage size, transfer size, and decoded memory are different budgets; reporting only one of them hides part of the cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  Text and audio need separate delivery decisions
&lt;/h2&gt;

&lt;p&gt;The same distinction between library size and per-visit cost appears outside the image pipeline.&lt;/p&gt;

&lt;p&gt;The complete artwork database with long-form stories is about 7.6 MB of JSON. The initial catalog uses a separate summary file of about 717 KB, or 155 KB in a local gzip measurement. A full story is fetched only when its painting opens. The client keeps at most eight loaded stories in memory for quick return navigation.&lt;/p&gt;

&lt;p&gt;The audio guide is also prepared before a visitor arrives. The Russian and English tracks are ordinary MP3 files generated from the reviewed text. Playback does not call a model at runtime. The current inventory contains 8,228 recordings—about 55 hours of audio—so the relevant problems are storage, byte-range delivery, invalidation, and caching.&lt;/p&gt;

&lt;p&gt;Each recording identifier depends on the exact text, voice, model, and delivery instruction. When one paragraph changes, the build replaces only the related files.&lt;/p&gt;

&lt;h2&gt;
  
  
  A fast local build does not prove fast delivery
&lt;/h2&gt;

&lt;p&gt;The public domain currently passes through one Nginx proxy before it reaches the application. I added that route after direct access to the hosting provider failed on a tested Russian network. It solved that access problem, but it also made one server and its connection part of every request path.&lt;/p&gt;

&lt;p&gt;Caching can reduce requests to storage. It cannot create bandwidth that the proxy does not have. I therefore do not treat a good desktop run or one Lighthouse score as proof that the gallery is ready for a large simultaneous audience.&lt;/p&gt;

&lt;p&gt;The next useful measurements are complete user flows on constrained mobile connections: opening the catalog, selecting a painting, zooming into several areas, and starting audio. I also need cache-hit data from the proxy and a load test before changing the delivery architecture.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug was a mismatch of intent, not resolution
&lt;/h2&gt;

&lt;p&gt;The 45,225-pixel source looked blurred because the pipeline answered the wrong question. It created a small picture of the entire scroll, while the card needed a detailed crop of one region.&lt;/p&gt;

&lt;p&gt;Separating viewer assets from catalog assets made the responsibilities clearer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;tiled pyramids support exploration;&lt;/li&gt;
&lt;li&gt;prepared crops support recognition in a grid;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;srcset&lt;/code&gt; handles responsive delivery;&lt;/li&gt;
&lt;li&gt;per-item JSON and MP3 files keep the first visit independent of the total library size.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can inspect the result in &lt;a href="https://polotno.app/en" rel="noopener noreferrer"&gt;Polotno&lt;/a&gt;. Registration is not required. If one of the paintings loads slowly, I would especially value the work you opened, your device, and your network conditions—those details are more useful than a generi&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ai</category>
      <category>images</category>
      <category>performance</category>
    </item>
  </channel>
</rss>
